@hankchen/canvas-browser-renderer 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HankChen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @hankchen/canvas-browser-renderer
2
+
3
+ [@hankchen/canvas](https://www.npmjs.com/package/@hankchen/canvas) 的浏览器渲染后端:Canvas2D 五原语(矩形 / 图片 cover / 文本基线消化 / 字体与 QR 物化)与 `exportPng` 导出。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ npm install @hankchen/canvas @hankchen/canvas-browser-renderer
9
+ ```
10
+
11
+ ## 使用
12
+
13
+ ```ts
14
+ import { decodeGraph } from '@hankchen/canvas'
15
+ import { Canvas2DBackend, exportPreviewPng } from '@hankchen/canvas-browser-renderer'
16
+ ```
17
+
18
+ 五原语渲染契约与自定义渲染端见核心包;编辑器与 Vue 绑定见 [canvas-web 仓](https://github.com/HankChenCH/canvas-web)。
19
+
20
+ ## 环境要求
21
+
22
+ 浏览器环境(Canvas2D + DOM 图片/字体加载)。文本渲染建议提供真实 TTF/TTC 字体资源。
@@ -0,0 +1,277 @@
1
+ import { RenderBackend, Border, TextDrawOptions, Canvas, TextLayoutPolicies } from '@hankchen/canvas';
2
+
3
+ /**
4
+ * Canvas2D 五原语后端。按包红线整体不开 DOM lib,仅本文件局部引入
5
+ * CanvasRenderingContext2D 类型(AGENTS.md 红线 2)。
6
+ *
7
+ * 内容绘制语义(工单 03):
8
+ * - drawImage:cover 等比缩放居中裁切(cover.ts),宽或高 ≤0 跳过(PHP 同门);
9
+ * 图片源经 setImage 预载回写(远程物化在工单 04),未注册的 src 只画盒不绘制;
10
+ * - drawText:布局层只给纯对齐锚点,基线差由本端以 TextMetrics 消化(ADR-0003 同款——
11
+ * PHP 的 GD 基线魔数 round(fontSize*0.1) 不移植);字体经 fonts.ts 的判定与注册缝。
12
+ */
13
+
14
+ /** 可绘制图片源:浏览器图片元素/位图,或结构等价的测试替身(须给得出宽高) */
15
+ interface DrawableImage {
16
+ readonly width: number;
17
+ readonly height: number;
18
+ }
19
+ /**
20
+ * 预览视口变换(工单 05):begin 清屏后施加到 ctx 的 dpr×zoom×相机变换。
21
+ * x/y 由编辑器会话给出(已对齐物理像素),本端只做与 dpr 的乘法合成。
22
+ */
23
+ interface PreviewViewportTransform {
24
+ dpr: number;
25
+ zoom: number;
26
+ x: number;
27
+ y: number;
28
+ }
29
+ /** 视口感知后端能力:编辑器会话在内容重绘前经此同步预览视口(可选能力口) */
30
+ interface ViewportAwareBackend {
31
+ setViewportTransform(transform: PreviewViewportTransform | null): void;
32
+ }
33
+ /**
34
+ * 把预览视口变换施加到 2D 上下文(内容层 begin 与宿主覆盖层共用同一几何,
35
+ * 保证两层在缩放/平移下不错位)。调用方需先自行复位变换并清屏。
36
+ */
37
+ declare function applyViewportTransform(ctx: CanvasRenderingContext2D, transform: PreviewViewportTransform): void;
38
+ declare class Canvas2DBackend implements RenderBackend, ViewportAwareBackend {
39
+ private readonly ctx;
40
+ /** 物化回写面:src → 已加载的图片源(工单 04 的物化器落点,渲染前由宿主/物化器填充) */
41
+ private readonly images;
42
+ /** 字体引用 → 已注册族名(loadCanvasFont 的回写面;未注册 URL 回落内置默认) */
43
+ private readonly fontFamilies;
44
+ /** 预览视口(工单 05):null = 恒等变换(如导出走 begin(w,h) 全幅语义) */
45
+ private viewportTransform;
46
+ constructor(ctx: CanvasRenderingContext2D);
47
+ /** 预载回写:注册 src 对应的图片源后 drawImage 才会绘制 */
48
+ setImage(src: string, image: DrawableImage): void;
49
+ /** 字体物化回写:注册字体引用对应的 ctx.font 族名 */
50
+ setFontFamily(font: string, family: string): void;
51
+ /**
52
+ * 派生同资源的导出后端(工单 13):把本后端已物化的图片/字体注册复制到新
53
+ * 渲染面——导出画布不经预览视口(恒等变换全幅渲染),但资源沿用编辑面的
54
+ * 物化结果,不重新装载。
55
+ */
56
+ forkWith(ctx: CanvasRenderingContext2D): Canvas2DBackend;
57
+ setViewportTransform(transform: PreviewViewportTransform | null): void;
58
+ begin(_width: number, _height: number): void;
59
+ end(): void;
60
+ /**
61
+ * 镜像 PHP ImageRenderer::drawRect:背景填充盒(null/空串跳过)+
62
+ * 四边边框沿盒边描线(线宽居中于路径,与 intervention drawLine 同语义)。
63
+ */
64
+ drawRect(x: number, y: number, width: number, height: number, bgColor: string | null, border: Border): void;
65
+ /** cover 裁切至 width×height 后放置于 (x, y);宽或高 ≤0 跳过(PHP drawImage 同门) */
66
+ drawImage(src: string, x: number, y: number, width: number, height: number): void;
67
+ /**
68
+ * 单行文本,(x, y) 为对齐语义锚点:水平偏移 = 真实文本宽度(measureText,与
69
+ * 布局启发式度量无关)× 对齐语义;垂直基线 = 锚点 + 字体 metrics 偏移
70
+ * (top: +ascent / center: +(ascent-descent)/2 / bottom: -descent,未知取值归 top)。
71
+ * 空行零副作用(PHP 同款空串守卫,模板仍对空行分派)。角度为度,正值视觉逆时针
72
+ * (GD angle 惯例;canvas y 轴向下,rotate 取负)。
73
+ */
74
+ drawText(line: string, x: number, y: number, options: TextDrawOptions): void;
75
+ /** ctx.font 简写:内置引用(空串/纯数字)或未注册 URL → 系统无衬线默认 */
76
+ private fontShorthand;
77
+ private strokeLine;
78
+ }
79
+
80
+ /**
81
+ * cover 缩放裁切几何:等比缩放至铺满目标盒,溢出居中裁掉(PHP intervention cover /
82
+ * go-canvas image-renderer cover 同款)。明确放弃逐像素复刻 GD/浏览器 rasterizer
83
+ * (spec Out of Scope:布局级一致、像素不求等)——本模块只算"源图上裁哪个窗",
84
+ * 缩放插值交给 drawImage 的原生光栅化。纯几何,无 DOM 类型(红线 2)。
85
+ */
86
+ interface CropWindow {
87
+ sx: number;
88
+ sy: number;
89
+ sw: number;
90
+ sh: number;
91
+ }
92
+ /**
93
+ * 计算源图 srcW×srcH 铺满 dstW×dstH 的居中裁切窗。调用方保证 dstW/dstH 为正;
94
+ * 裁切窗与居中偏移整型化向零截断;极端宽高比下截断可得 0 宽窗:钳到 ≥1,
95
+ * 宁可纵横比略让也不输出空图
96
+ */
97
+ declare function coverCrop(srcW: number, srcH: number, dstW: number, dstH: number): CropWindow;
98
+
99
+ /**
100
+ * 字体契约的渲染端消化:内置默认字体判定/简写 + URL 字体的 FontFace 物化。
101
+ * 语义镜像 PHP ImageRenderer::drawText(`$fontFile !== '' && !is_numeric($fontFile)`
102
+ * 才走字体文件,否则 v4 内置默认字体)与 go-canvas typography.IsBuiltinFont——
103
+ * 空串/纯数字字体 id 是旧库 GD 内置字体的血统,浏览器端对应内置默认字体 =
104
+ * 系统无衬线族。本文件是渲染包内唯一触 DOM 运行时 API 的地方(FontFace /
105
+ * document.fonts;Canvas2D 类型在 canvas2d-backend.ts 局部引入),包 tsconfig
106
+ * 仍无 DOM lib(红线 2)。
107
+ */
108
+ /** 内置默认字体判定:空串/纯数字字体引用(PHP is_numeric 同门:旧库 GD 内置字体 id) */
109
+ declare function isBuiltinFontRef(font: string): boolean;
110
+ /** 内置默认字体的 ctx.font 简写:系统无衬线族按字号取用 */
111
+ declare function builtinFontShorthand(fontSize: number): string;
112
+ /**
113
+ * font-family 的 CSS 值(工单 11 textarea overlay 用):内置默认字体 = 系统无衬线
114
+ * 族;URL = 注册族名(与 loadCanvasFont 同一 fnv-1a 派生,注册后即命中)尾部附
115
+ * sans-serif 兜底——未注册/未加载时回落系统无衬线,与 canvas 侧未注册 URL 落内置
116
+ * 默认简写的行为同门。纯字符串函数,无 DOM。
117
+ */
118
+ declare function canvasFontCssFamily(font: string): string;
119
+ /**
120
+ * URL 字体物化:经 FontFace 注册进 document.fonts,resolve 为 ctx.font 可用的族名。
121
+ * 加载失败(离线/404/解析失败)时 reject——调用方决定降级(预览哲学:占位 +
122
+ * 内置默认字体兜底,工单 04 补失败态标识)
123
+ */
124
+ declare function loadCanvasFont(font: string): Promise<string>;
125
+
126
+ /** 固定选项(契约):纠错 High、无静区、黑白;scale 只影响产物清晰度(终图随盒缩放) */
127
+ declare const QR_FIXED_OPTIONS: {
128
+ readonly errorCorrectionLevel: "H";
129
+ readonly margin: 0;
130
+ readonly scale: 4;
131
+ readonly color: {
132
+ readonly dark: "#000000ff";
133
+ readonly light: "#ffffffff";
134
+ };
135
+ };
136
+ /** QR 符号的模块矩阵视图(测试取样缝:点位断言不经过 PNG 解码) */
137
+ interface QrModuleMatrix {
138
+ readonly size: number;
139
+ isDark(row: number, col: number): boolean;
140
+ }
141
+ /** 同步生成模块矩阵(固定选项);UTF-8 串按字节模式编码(endroid Encoding('UTF-8') 同语义) */
142
+ declare function qrModuleMatrix(value: string): QrModuleMatrix;
143
+ /** 生成 PNG dataURL(固定选项);交给 Image 装载后由渲染原语缩放到盒宽 */
144
+ declare function qrDataUrl(value: string): Promise<string>;
145
+
146
+ type ResourceStatus = 'pending' | 'done' | 'failed';
147
+ /** 单条资源状态;error 仅 failed 时携带(加载器的错误消息) */
148
+ interface ResourceEntry {
149
+ readonly status: ResourceStatus;
150
+ readonly error?: string;
151
+ }
152
+ /** 资源状态切片:key = 资源缓存键。整体替换语义,直接对接未来 store 的 ui 分支 */
153
+ type ResourceState = Readonly<Record<string, ResourceEntry>>;
154
+ /** 缓存键:sha256(完整引用)——不继承 PHP basename 旧债(同名不同 URL 不碰撞);派生经键 memo(同引用不重复散列) */
155
+ declare const imageResourceKey: (src: string) => string;
156
+ declare const fontResourceKey: (font: string) => string;
157
+ /** QR 内容加命名空间后散列:内容恰为 URL 的 QR 与图片键域隔离 */
158
+ declare const qrResourceKey: (value: string) => string;
159
+ /**
160
+ * 跨域代理注入点(对应 PHP DownloaderInterface):把 http(s) 资源 URL 改写为代理
161
+ * URL。data:/相对路径不经代理(本就同源/内联)。未配置时原样通过。
162
+ */
163
+ declare function withImageProxy(url: string, proxy: ((url: string) => string) | undefined): string;
164
+ /** 三类资源的装载器(注入缝):测试注桩,宿主可替换缓存/代理策略 */
165
+ interface ResourceLoaders {
166
+ /** 图片 URL → 可绘制源(默认:Image + crossOrigin=anonymous + 可选代理) */
167
+ readonly loadImage: (url: string) => Promise<DrawableImage>;
168
+ /** 字体 URL → ctx.font 可用的族名(默认:loadCanvasFont 的 FontFace 注册) */
169
+ readonly loadFont: (font: string) => Promise<string>;
170
+ /** QR 内容 → 可绘制源(默认:固定选项生成 PNG dataURL 后经 Image 装载) */
171
+ readonly loadQr: (value: string) => Promise<DrawableImage>;
172
+ }
173
+ interface MaterializerOptions {
174
+ /** 覆盖默认浏览器装载器(Node 测试注桩 / 宿主自定义) */
175
+ readonly loaders?: ResourceLoaders;
176
+ /** 跨域代理注入点:默认图片装载器把 http(s) 引用改写为代理 URL */
177
+ readonly imageProxy?: (url: string) => string;
178
+ /** 同时在途的装载上限(默认 6) */
179
+ readonly concurrency?: number;
180
+ }
181
+ declare class Materializer {
182
+ private readonly backend;
183
+ private readonly loaders;
184
+ private readonly maxConcurrent;
185
+ private readonly entries;
186
+ private readonly listeners;
187
+ private readonly settleResolvers;
188
+ private readonly queue;
189
+ private active;
190
+ constructor(backend: Canvas2DBackend, options?: MaterializerOptions);
191
+ /** 资源状态快照(ui.resources 切片;每次变更整体替换,不可变) */
192
+ get state(): ResourceState;
193
+ /** 在途装载条数(playground 提示/将来导出闸消费) */
194
+ get pendingCount(): number;
195
+ /**
196
+ * 扫描文档收集资源引用(含表格下钻,复用渲染模板的同一遍历),调度未决资源。
197
+ * 幂等:pending/done 的键跳过(同 URL 跨图层只物化一次),failed 重新调度。
198
+ * 快照语义(工单 15):文档不再引用的键当场剪除——打开新文档/删层后旧资源
199
+ * 条目不残留(覆盖层标识与宿主"失败 N 项"读数只对当前文档有意义)。
200
+ */
201
+ materialize(doc: Canvas): void;
202
+ /** 订阅状态变更(整体快照推送);返回退订函数 */
203
+ subscribe(listener: (state: ResourceState) => void): () => void;
204
+ /**
205
+ * 全量物化排空闸(工单 13,PNG 导出前置):在途装载全部落定后 resolve,
206
+ * 携带终态快照(failed 不算在途——预览语义按占位出图,宿主可据快照提示)。
207
+ * 闸在 pending 首次归零时收口:其后新调度的装载归下一次 await 覆盖。
208
+ */
209
+ whenSettled(): Promise<ResourceState>;
210
+ private notify;
211
+ /** 并发上限内的队列泵:完成一个补位一个 */
212
+ private pump;
213
+ private load;
214
+ }
215
+ /** 默认浏览器装载器:图片经 Image + crossOrigin(代理注入点),字体经 FontFace */
216
+ declare function createBrowserLoaders(options?: {
217
+ imageProxy?: (url: string) => string;
218
+ }): ResourceLoaders;
219
+
220
+ /** 在覆盖层上按资源状态画标识:failed 红叉+红框、pending 灰叉,done/无状态零笔画 */
221
+ declare function drawResourceMarkers(ctx: CanvasRenderingContext2D, doc: Canvas, state: ResourceState): void;
222
+
223
+ /**
224
+ * 同步 SHA-256(FIPS 180-4):资源缓存键的散列原语。
225
+ *
226
+ * 为什么手写:物化状态机需要同步派生缓存键(渲染/键比较路径不能 await),
227
+ * Web Crypto 的 subtle.digest 只有异步口;而编辑器物化散列不值得为此引依赖。
228
+ * 测试以 node:crypto 为独立 oracle 逐值锁定。
229
+ *
230
+ * TextEncoder 是宿主全局(浏览器 evergreen 与 Node 皆有),本包 tsconfig 无任何
231
+ * lib 注入,这里给最小结构声明以自持类型(不依赖别的文件碰巧拖进的全局)。
232
+ */
233
+ /** UTF-8 串的 SHA-256 摘要,64 位小写十六进制 */
234
+ declare function sha256Hex(input: string): string;
235
+
236
+ /**
237
+ * 预览 PNG 导出(工单 13,ADR 0004):浏览器 Canvas2D 的导出只作**预览/快速
238
+ * 分享**,终图由服务端渲染端依据 graph JSON 权威产出——出图带预览语义标注
239
+ * (PNG tEXt 元数据;界面提示与文件名后缀归宿主),不得当作生产输出。
240
+ *
241
+ * 全幅语义:离屏画布 = 文档像素(可选 scale 倍率),恒等变换渲染(不经预览
242
+ * 视口);资源沿用编辑面后端的物化结果(forkWith 复制注册,不重新装载)。
243
+ * 调用方须先等待物化排空(Materializer.whenSettled),慢资源才有完整呈现。
244
+ */
245
+
246
+ interface PreviewPngOptions {
247
+ /** 输出倍率(相对文档像素;缺省 1 = 文档像素 1:1) */
248
+ readonly scale?: number;
249
+ /** 文本布局策略:与编辑会话同一注入值,导出与预览的断行/盒高才一致 */
250
+ readonly textPolicies?: TextLayoutPolicies;
251
+ }
252
+ interface PreviewPngResult {
253
+ /** PNG blob(已注入预览语义 tEXt) */
254
+ readonly blob: Blob;
255
+ /** 输出像素尺寸 */
256
+ readonly width: number;
257
+ readonly height: number;
258
+ }
259
+ /** 预览语义标注(tEXt 域只收 Latin-1,用 ASCII;中文标注由界面/文件名承担) */
260
+ declare const PREVIEW_TEXT_KEYWORD = "CanvasNext";
261
+ declare const PREVIEW_TEXT_VALUE = "preview render, not the final image (final = server-side render of the graph JSON)";
262
+ /**
263
+ * 渲染当前文档为预览 PNG:离屏 canvas 全幅渲染 → toBlob → 注入 tEXt 标注。
264
+ * 抛错时文档与编辑面不受影响(导出面独立,fork 不回写编辑面后端)。
265
+ */
266
+ declare function exportPreviewPng(doc: Canvas, sourceBackend: Canvas2DBackend, options?: PreviewPngOptions): Promise<PreviewPngResult>;
267
+
268
+ /**
269
+ * @hankchen/canvas-browser-renderer 公共出口。
270
+ *
271
+ * Canvas2D 五原语后端(begin → 按 priority 序分派 → end)+ 物化状态机(工单 04)。
272
+ * 本包整体无 DOM lib 编译,Canvas2D/FontFace/Image 类型只在用到它的文件里局部
273
+ * `/// <reference lib="dom" />`。
274
+ */
275
+ declare const PACKAGE_NAME: "@hankchen/canvas-browser-renderer";
276
+
277
+ export { Canvas2DBackend, type CropWindow, type DrawableImage, Materializer, type MaterializerOptions, PACKAGE_NAME, PREVIEW_TEXT_KEYWORD, PREVIEW_TEXT_VALUE, type PreviewPngOptions, type PreviewPngResult, type PreviewViewportTransform, QR_FIXED_OPTIONS, type QrModuleMatrix, type ResourceEntry, type ResourceLoaders, type ResourceState, type ResourceStatus, type ViewportAwareBackend, applyViewportTransform, builtinFontShorthand, canvasFontCssFamily, coverCrop, createBrowserLoaders, drawResourceMarkers, exportPreviewPng, fontResourceKey, imageResourceKey, isBuiltinFontRef, loadCanvasFont, qrDataUrl, qrModuleMatrix, qrResourceKey, sha256Hex, withImageProxy };
package/dist/index.js ADDED
@@ -0,0 +1,686 @@
1
+ // src/cover.ts
2
+ function coverCrop(srcW, srcH, dstW, dstH) {
3
+ const scale = Math.max(dstW / srcW, dstH / srcH);
4
+ let cropW = Math.trunc(dstW / scale);
5
+ let cropH = Math.trunc(dstH / scale);
6
+ if (cropW < 1) cropW = 1;
7
+ if (cropH < 1) cropH = 1;
8
+ const sx = Math.trunc((srcW - cropW) / 2);
9
+ const sy = Math.trunc((srcH - cropH) / 2);
10
+ return { sx, sy, sw: cropW, sh: cropH };
11
+ }
12
+
13
+ // src/fonts.ts
14
+ function isBuiltinFontRef(font) {
15
+ if (font === "") return true;
16
+ return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(font.trim());
17
+ }
18
+ function builtinFontShorthand(fontSize) {
19
+ return `${fontSize}px sans-serif`;
20
+ }
21
+ function canvasFontCssFamily(font) {
22
+ if (isBuiltinFontRef(font)) return "sans-serif";
23
+ return `${fontFamilyFor(font)}, sans-serif`;
24
+ }
25
+ function fontFamilyFor(font) {
26
+ let hash = 2166136261;
27
+ for (let i = 0; i < font.length; i++) {
28
+ hash ^= font.charCodeAt(i);
29
+ hash = Math.imul(hash, 16777619);
30
+ }
31
+ return `canvas-font-${(hash >>> 0).toString(36)}`;
32
+ }
33
+ var fontRegistry = /* @__PURE__ */ new Map();
34
+ function loadCanvasFont(font) {
35
+ const cached = fontRegistry.get(font);
36
+ if (cached) return cached;
37
+ const family = fontFamilyFor(font);
38
+ const loading = (async () => {
39
+ const source = `url("${font.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
40
+ const face = new FontFace(family, source);
41
+ await face.load();
42
+ document.fonts.add(face);
43
+ return family;
44
+ })();
45
+ loading.catch(() => fontRegistry.delete(font));
46
+ fontRegistry.set(font, loading);
47
+ return loading;
48
+ }
49
+
50
+ // src/canvas2d-backend.ts
51
+ function applyViewportTransform(ctx, transform) {
52
+ const scale = transform.dpr * transform.zoom;
53
+ ctx.setTransform(scale, 0, 0, scale, -transform.x * scale, -transform.y * scale);
54
+ }
55
+ var Canvas2DBackend = class _Canvas2DBackend {
56
+ ctx;
57
+ /** 物化回写面:src → 已加载的图片源(工单 04 的物化器落点,渲染前由宿主/物化器填充) */
58
+ images = /* @__PURE__ */ new Map();
59
+ /** 字体引用 → 已注册族名(loadCanvasFont 的回写面;未注册 URL 回落内置默认) */
60
+ fontFamilies = /* @__PURE__ */ new Map();
61
+ /** 预览视口(工单 05):null = 恒等变换(如导出走 begin(w,h) 全幅语义) */
62
+ viewportTransform = null;
63
+ constructor(ctx) {
64
+ this.ctx = ctx;
65
+ }
66
+ /** 预载回写:注册 src 对应的图片源后 drawImage 才会绘制 */
67
+ setImage(src, image) {
68
+ this.images.set(src, image);
69
+ }
70
+ /** 字体物化回写:注册字体引用对应的 ctx.font 族名 */
71
+ setFontFamily(font, family) {
72
+ this.fontFamilies.set(font, family);
73
+ }
74
+ /**
75
+ * 派生同资源的导出后端(工单 13):把本后端已物化的图片/字体注册复制到新
76
+ * 渲染面——导出画布不经预览视口(恒等变换全幅渲染),但资源沿用编辑面的
77
+ * 物化结果,不重新装载。
78
+ */
79
+ forkWith(ctx) {
80
+ const fork = new _Canvas2DBackend(ctx);
81
+ for (const [src, image] of this.images) fork.images.set(src, image);
82
+ for (const [font, family] of this.fontFamilies) fork.fontFamilies.set(font, family);
83
+ return fork;
84
+ }
85
+ setViewportTransform(transform) {
86
+ this.viewportTransform = transform;
87
+ }
88
+ begin(_width, _height) {
89
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
90
+ this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
91
+ if (this.viewportTransform) applyViewportTransform(this.ctx, this.viewportTransform);
92
+ }
93
+ end() {
94
+ }
95
+ /**
96
+ * 镜像 PHP ImageRenderer::drawRect:背景填充盒(null/空串跳过)+
97
+ * 四边边框沿盒边描线(线宽居中于路径,与 intervention drawLine 同语义)。
98
+ */
99
+ drawRect(x, y, width, height, bgColor, border) {
100
+ const ctx = this.ctx;
101
+ if (bgColor !== null && bgColor !== "") {
102
+ ctx.fillStyle = bgColor;
103
+ ctx.fillRect(x, y, width, height);
104
+ }
105
+ if (border.top) this.strokeLine(x, y, x + width, y, border.top);
106
+ if (border.bottom) this.strokeLine(x, y + height, x + width, y + height, border.bottom);
107
+ if (border.left) this.strokeLine(x, y, x, y + height, border.left);
108
+ if (border.right) this.strokeLine(x + width, y, x + width, y + height, border.right);
109
+ }
110
+ /** cover 裁切至 width×height 后放置于 (x, y);宽或高 ≤0 跳过(PHP drawImage 同门) */
111
+ drawImage(src, x, y, width, height) {
112
+ if (width <= 0 || height <= 0) return;
113
+ const image = this.images.get(src);
114
+ if (!image) return;
115
+ const srcW = naturalWidth(image);
116
+ const srcH = naturalHeight(image);
117
+ if (srcW <= 0 || srcH <= 0) return;
118
+ const crop = coverCrop(srcW, srcH, width, height);
119
+ this.ctx.drawImage(
120
+ image,
121
+ crop.sx,
122
+ crop.sy,
123
+ crop.sw,
124
+ crop.sh,
125
+ x,
126
+ y,
127
+ width,
128
+ height
129
+ );
130
+ }
131
+ /**
132
+ * 单行文本,(x, y) 为对齐语义锚点:水平偏移 = 真实文本宽度(measureText,与
133
+ * 布局启发式度量无关)× 对齐语义;垂直基线 = 锚点 + 字体 metrics 偏移
134
+ * (top: +ascent / center: +(ascent-descent)/2 / bottom: -descent,未知取值归 top)。
135
+ * 空行零副作用(PHP 同款空串守卫,模板仍对空行分派)。角度为度,正值视觉逆时针
136
+ * (GD angle 惯例;canvas y 轴向下,rotate 取负)。
137
+ */
138
+ drawText(line, x, y, options) {
139
+ if (line === "") return;
140
+ const ctx = this.ctx;
141
+ ctx.font = this.fontShorthand(options.font, options.fontSize);
142
+ ctx.fillStyle = options.fontColor;
143
+ const metrics = ctx.measureText(line);
144
+ const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
145
+ const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
146
+ let penX = 0;
147
+ if (options.horizontalAlign === "center") penX = -metrics.width / 2;
148
+ else if (options.horizontalAlign === "right") penX = -metrics.width;
149
+ let baselineY = ascent;
150
+ if (options.verticalAlign === "center") baselineY = (ascent - descent) / 2;
151
+ else if (options.verticalAlign === "bottom") baselineY = -descent;
152
+ if (options.angle === 0) {
153
+ ctx.fillText(line, x + penX, y + baselineY);
154
+ return;
155
+ }
156
+ ctx.save();
157
+ ctx.translate(x, y);
158
+ ctx.rotate(-options.angle * Math.PI / 180);
159
+ ctx.fillText(line, penX, baselineY);
160
+ ctx.restore();
161
+ }
162
+ /** ctx.font 简写:内置引用(空串/纯数字)或未注册 URL → 系统无衬线默认 */
163
+ fontShorthand(font, fontSize) {
164
+ if (isBuiltinFontRef(font)) return builtinFontShorthand(fontSize);
165
+ const family = this.fontFamilies.get(font);
166
+ return family ? `${fontSize}px "${family}"` : builtinFontShorthand(fontSize);
167
+ }
168
+ strokeLine(x1, y1, x2, y2, side) {
169
+ const ctx = this.ctx;
170
+ ctx.beginPath();
171
+ ctx.moveTo(x1, y1);
172
+ ctx.lineTo(x2, y2);
173
+ ctx.strokeStyle = side.color;
174
+ ctx.lineWidth = side.width;
175
+ ctx.stroke();
176
+ }
177
+ };
178
+ function naturalWidth(image) {
179
+ const natural = image.naturalWidth;
180
+ return typeof natural === "number" && natural > 0 ? natural : image.width;
181
+ }
182
+ function naturalHeight(image) {
183
+ const natural = image.naturalHeight;
184
+ return typeof natural === "number" && natural > 0 ? natural : image.height;
185
+ }
186
+
187
+ // src/qr.ts
188
+ import { create as createQrCode, toDataURL as qrToDataURL } from "qrcode";
189
+ var QR_FIXED_OPTIONS = {
190
+ errorCorrectionLevel: "H",
191
+ margin: 0,
192
+ scale: 4,
193
+ color: { dark: "#000000ff", light: "#ffffffff" }
194
+ };
195
+ function qrModuleMatrix(value) {
196
+ const qr = createQrCode(value, { errorCorrectionLevel: QR_FIXED_OPTIONS.errorCorrectionLevel });
197
+ const { size, data } = qr.modules;
198
+ return {
199
+ size,
200
+ isDark: (row, col) => data[row * size + col] === 1
201
+ };
202
+ }
203
+ function qrDataUrl(value) {
204
+ return qrToDataURL(value, QR_FIXED_OPTIONS);
205
+ }
206
+
207
+ // src/materializer.ts
208
+ import { forEachLayerBox, qrImageSrc } from "@hankchen/canvas";
209
+
210
+ // src/sha256.ts
211
+ var K = new Uint32Array([
212
+ 1116352408,
213
+ 1899447441,
214
+ 3049323471,
215
+ 3921009573,
216
+ 961987163,
217
+ 1508970993,
218
+ 2453635748,
219
+ 2870763221,
220
+ 3624381080,
221
+ 310598401,
222
+ 607225278,
223
+ 1426881987,
224
+ 1925078388,
225
+ 2162078206,
226
+ 2614888103,
227
+ 3248222580,
228
+ 3835390401,
229
+ 4022224774,
230
+ 264347078,
231
+ 604807628,
232
+ 770255983,
233
+ 1249150122,
234
+ 1555081692,
235
+ 1996064986,
236
+ 2554220882,
237
+ 2821834349,
238
+ 2952996808,
239
+ 3210313671,
240
+ 3336571891,
241
+ 3584528711,
242
+ 113926993,
243
+ 338241895,
244
+ 666307205,
245
+ 773529912,
246
+ 1294757372,
247
+ 1396182291,
248
+ 1695183700,
249
+ 1986661051,
250
+ 2177026350,
251
+ 2456956037,
252
+ 2730485921,
253
+ 2820302411,
254
+ 3259730800,
255
+ 3345764771,
256
+ 3516065817,
257
+ 3600352804,
258
+ 4094571909,
259
+ 275423344,
260
+ 430227734,
261
+ 506948616,
262
+ 659060556,
263
+ 883997877,
264
+ 958139571,
265
+ 1322822218,
266
+ 1537002063,
267
+ 1747873779,
268
+ 1955562222,
269
+ 2024104815,
270
+ 2227730452,
271
+ 2361852424,
272
+ 2428436474,
273
+ 2756734187,
274
+ 3204031479,
275
+ 3329325298
276
+ ]);
277
+ function rotr(x, n) {
278
+ return x >>> n | x << 32 - n;
279
+ }
280
+ function sha256Hex(input) {
281
+ const data = new TextEncoder().encode(input);
282
+ const bitLength = data.length * 8;
283
+ const paddedLength = (data.length + 8 >> 6) + 1 << 6;
284
+ const words = new Uint32Array(paddedLength / 4);
285
+ for (let i = 0; i < data.length; i++) {
286
+ words[i >>> 2] = words[i >>> 2] | data[i] << (3 - (i & 3)) * 8;
287
+ }
288
+ words[data.length >>> 2] = words[data.length >>> 2] | 128 << (3 - (data.length & 3)) * 8;
289
+ words[words.length - 1] = bitLength >>> 0;
290
+ words[words.length - 2] = Math.floor(bitLength / 4294967296);
291
+ let h0 = 1779033703;
292
+ let h1 = 3144134277;
293
+ let h2 = 1013904242;
294
+ let h3 = 2773480762;
295
+ let h4 = 1359893119;
296
+ let h5 = 2600822924;
297
+ let h6 = 528734635;
298
+ let h7 = 1541459225;
299
+ const w = new Uint32Array(64);
300
+ for (let block = 0; block < words.length; block += 16) {
301
+ for (let t = 0; t < 16; t++) w[t] = words[block + t];
302
+ for (let t = 16; t < 64; t++) {
303
+ const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ w[t - 15] >>> 3;
304
+ const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ w[t - 2] >>> 10;
305
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1 | 0;
306
+ }
307
+ let a = h0;
308
+ let b = h1;
309
+ let c = h2;
310
+ let d = h3;
311
+ let e = h4;
312
+ let f = h5;
313
+ let g = h6;
314
+ let h = h7;
315
+ for (let t = 0; t < 64; t++) {
316
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
317
+ const ch = e & f ^ ~e & g;
318
+ const temp1 = h + S1 + ch + K[t] + w[t] | 0;
319
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
320
+ const maj = a & b ^ a & c ^ b & c;
321
+ const temp2 = S0 + maj | 0;
322
+ h = g;
323
+ g = f;
324
+ f = e;
325
+ e = d + temp1 | 0;
326
+ d = c;
327
+ c = b;
328
+ b = a;
329
+ a = temp1 + temp2 | 0;
330
+ }
331
+ h0 = h0 + a | 0;
332
+ h1 = h1 + b | 0;
333
+ h2 = h2 + c | 0;
334
+ h3 = h3 + d | 0;
335
+ h4 = h4 + e | 0;
336
+ h5 = h5 + f | 0;
337
+ h6 = h6 + g | 0;
338
+ h7 = h7 + h | 0;
339
+ }
340
+ return [h0, h1, h2, h3, h4, h5, h6, h7].map((word) => (word >>> 0).toString(16).padStart(8, "0")).join("");
341
+ }
342
+
343
+ // src/materializer.ts
344
+ var imageResourceKey = (src) => memoSha256Hex(src);
345
+ var fontResourceKey = (font) => memoSha256Hex(font);
346
+ var qrResourceKey = (value) => memoSha256Hex(`qr:${value}`);
347
+ function withImageProxy(url, proxy) {
348
+ if (!proxy || !/^https?:\/\//i.test(url)) return url;
349
+ return proxy(url);
350
+ }
351
+ function resourceRefOf(layer) {
352
+ if (layer.type === "ImageLayer" && layer.src !== null) {
353
+ return { key: imageResourceKey(layer.src), kind: "image", ref: layer.src };
354
+ }
355
+ if (layer.type === "TextLayer" && !isBuiltinFontRef(layer.font)) {
356
+ return { key: fontResourceKey(layer.font), kind: "font", ref: layer.font };
357
+ }
358
+ if (layer.type === "QrCodeLayer" && layer.value !== "") {
359
+ return { key: qrResourceKey(layer.value), kind: "qr", ref: layer.value };
360
+ }
361
+ return null;
362
+ }
363
+ var DEFAULT_CONCURRENCY = 6;
364
+ function createMemoizedKeyHash(load, capacity = 512) {
365
+ const cache = /* @__PURE__ */ new Map();
366
+ return (input) => {
367
+ const hit = cache.get(input);
368
+ if (hit !== void 0) return hit;
369
+ const key = load(input);
370
+ if (cache.size >= capacity) {
371
+ const oldest = cache.keys().next().value;
372
+ if (oldest !== void 0) cache.delete(oldest);
373
+ }
374
+ cache.set(input, key);
375
+ return key;
376
+ };
377
+ }
378
+ var memoSha256Hex = createMemoizedKeyHash(sha256Hex);
379
+ var Materializer = class {
380
+ backend;
381
+ loaders;
382
+ maxConcurrent;
383
+ entries = /* @__PURE__ */ new Map();
384
+ listeners = /* @__PURE__ */ new Set();
385
+ settleResolvers = [];
386
+ queue = [];
387
+ active = 0;
388
+ constructor(backend, options = {}) {
389
+ this.backend = backend;
390
+ this.loaders = options.loaders ?? createBrowserLoaders({ imageProxy: options.imageProxy });
391
+ this.maxConcurrent = Math.max(options.concurrency ?? DEFAULT_CONCURRENCY, 1);
392
+ }
393
+ /** 资源状态快照(ui.resources 切片;每次变更整体替换,不可变) */
394
+ get state() {
395
+ return Object.fromEntries(this.entries);
396
+ }
397
+ /** 在途装载条数(playground 提示/将来导出闸消费) */
398
+ get pendingCount() {
399
+ let count = 0;
400
+ for (const entry of this.entries.values()) {
401
+ if (entry.status === "pending") count++;
402
+ }
403
+ return count;
404
+ }
405
+ /**
406
+ * 扫描文档收集资源引用(含表格下钻,复用渲染模板的同一遍历),调度未决资源。
407
+ * 幂等:pending/done 的键跳过(同 URL 跨图层只物化一次),failed 重新调度。
408
+ * 快照语义(工单 15):文档不再引用的键当场剪除——打开新文档/删层后旧资源
409
+ * 条目不残留(覆盖层标识与宿主"失败 N 项"读数只对当前文档有意义)。
410
+ */
411
+ materialize(doc) {
412
+ const referenced = /* @__PURE__ */ new Map();
413
+ forEachLayerBox(doc, (layer) => {
414
+ const ref = resourceRefOf(layer);
415
+ if (ref === null || referenced.has(ref.key)) return;
416
+ referenced.set(ref.key, ref);
417
+ });
418
+ let pruned = false;
419
+ for (const key of [...this.entries.keys()]) {
420
+ if (!referenced.has(key)) {
421
+ this.entries.delete(key);
422
+ pruned = true;
423
+ }
424
+ }
425
+ const collected = /* @__PURE__ */ new Map();
426
+ for (const ref of referenced.values()) {
427
+ const existing = this.entries.get(ref.key);
428
+ if (!existing || existing.status === "failed") collected.set(ref.key, ref);
429
+ }
430
+ if (collected.size === 0) {
431
+ if (pruned) this.notify();
432
+ return;
433
+ }
434
+ for (const ref of collected.values()) {
435
+ this.entries.set(ref.key, { status: "pending" });
436
+ this.queue.push(ref);
437
+ }
438
+ this.notify();
439
+ this.pump();
440
+ }
441
+ /** 订阅状态变更(整体快照推送);返回退订函数 */
442
+ subscribe(listener) {
443
+ this.listeners.add(listener);
444
+ return () => this.listeners.delete(listener);
445
+ }
446
+ /**
447
+ * 全量物化排空闸(工单 13,PNG 导出前置):在途装载全部落定后 resolve,
448
+ * 携带终态快照(failed 不算在途——预览语义按占位出图,宿主可据快照提示)。
449
+ * 闸在 pending 首次归零时收口:其后新调度的装载归下一次 await 覆盖。
450
+ */
451
+ whenSettled() {
452
+ if (this.pendingCount === 0) return Promise.resolve(this.state);
453
+ return new Promise((resolve) => {
454
+ this.settleResolvers.push(resolve);
455
+ });
456
+ }
457
+ notify() {
458
+ const state = this.state;
459
+ for (const listener of this.listeners) listener(state);
460
+ if (this.pendingCount === 0 && this.settleResolvers.length > 0) {
461
+ const resolvers = this.settleResolvers.splice(0);
462
+ for (const resolve of resolvers) resolve(state);
463
+ }
464
+ }
465
+ /** 并发上限内的队列泵:完成一个补位一个 */
466
+ pump() {
467
+ while (this.active < this.maxConcurrent && this.queue.length > 0) {
468
+ const ref = this.queue.shift();
469
+ this.active++;
470
+ void this.load(ref).finally(() => {
471
+ this.active--;
472
+ this.pump();
473
+ });
474
+ }
475
+ }
476
+ async load(ref) {
477
+ let entry;
478
+ try {
479
+ if (ref.kind === "font") {
480
+ this.backend.setFontFamily(ref.ref, await this.loaders.loadFont(ref.ref));
481
+ } else if (ref.kind === "qr") {
482
+ this.backend.setImage(qrImageSrc(ref.ref), await this.loaders.loadQr(ref.ref));
483
+ } else {
484
+ this.backend.setImage(ref.ref, await this.loaders.loadImage(ref.ref));
485
+ }
486
+ entry = { status: "done" };
487
+ } catch (error) {
488
+ entry = {
489
+ status: "failed",
490
+ error: error instanceof Error ? error.message : String(error)
491
+ };
492
+ }
493
+ if (this.entries.has(ref.key)) {
494
+ this.entries.set(ref.key, entry);
495
+ this.notify();
496
+ }
497
+ }
498
+ };
499
+ function createBrowserLoaders(options = {}) {
500
+ return {
501
+ // 错误消息用原始引用(三端对照 PHP 消息格式),代理 URL 只用于装载
502
+ loadImage: (url) => loadImageElement(withImageProxy(url, options.imageProxy), url),
503
+ loadFont: (font) => loadCanvasFont(font),
504
+ loadQr: async (value) => loadImageElement(await qrDataUrl(value), `qr:${value}`)
505
+ };
506
+ }
507
+ function loadImageElement(src, ref) {
508
+ return new Promise((resolve, reject) => {
509
+ const image = new Image();
510
+ image.crossOrigin = "anonymous";
511
+ image.onerror = () => reject(new Error(`could not get remote file(${ref})`));
512
+ image.onload = () => resolve(image);
513
+ image.src = src;
514
+ });
515
+ }
516
+
517
+ // src/markers.ts
518
+ import { forEachLayerBox as forEachLayerBox2 } from "@hankchen/canvas";
519
+ var PENDING_STROKE = "rgba(100, 116, 139, 0.6)";
520
+ var FAILED_STROKE = "#ef4444";
521
+ var FAILED_LINE_WIDTH = 2;
522
+ function drawResourceMarkers(ctx, doc, state) {
523
+ forEachLayerBox2(doc, (layer, box) => {
524
+ const ref = resourceRefOf(layer);
525
+ if (ref === null) return;
526
+ const entry = state[ref.key];
527
+ if (!entry || entry.status === "done") return;
528
+ if (entry.status === "failed") {
529
+ ctx.strokeStyle = FAILED_STROKE;
530
+ ctx.lineWidth = FAILED_LINE_WIDTH;
531
+ crossAt(ctx, box.x, box.y, box.width, box.height);
532
+ ctx.strokeRect(box.x + 1, box.y + 1, box.width - 2, box.height - 2);
533
+ } else {
534
+ ctx.strokeStyle = PENDING_STROKE;
535
+ ctx.lineWidth = 1;
536
+ crossAt(ctx, box.x, box.y, box.width, box.height);
537
+ }
538
+ });
539
+ }
540
+ function crossAt(ctx, x, y, width, height) {
541
+ ctx.beginPath();
542
+ ctx.moveTo(x, y);
543
+ ctx.lineTo(x + width, y + height);
544
+ ctx.moveTo(x + width, y);
545
+ ctx.lineTo(x, y + height);
546
+ ctx.stroke();
547
+ }
548
+
549
+ // src/exportPng.ts
550
+ import { renderCanvas } from "@hankchen/canvas";
551
+
552
+ // src/png.ts
553
+ var PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
554
+ var crcTable = null;
555
+ function getCrcTable() {
556
+ if (crcTable) return crcTable;
557
+ const table = new Uint32Array(256);
558
+ for (let n = 0; n < 256; n += 1) {
559
+ let c = n;
560
+ for (let k = 0; k < 8; k += 1) {
561
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
562
+ }
563
+ table[n] = c >>> 0;
564
+ }
565
+ crcTable = table;
566
+ return table;
567
+ }
568
+ function crc32(bytes) {
569
+ const table = getCrcTable();
570
+ let crc = 4294967295;
571
+ for (let i = 0; i < bytes.length; i += 1) {
572
+ crc = table[(crc ^ bytes[i]) & 255] ^ crc >>> 8;
573
+ }
574
+ return (crc ^ 4294967295) >>> 0;
575
+ }
576
+ function parsePngChunks(png) {
577
+ for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
578
+ if (png[i] !== PNG_SIGNATURE[i]) throw new Error("\u4E0D\u662F PNG \u5B57\u8282\u6D41\uFF08\u7B7E\u540D\u4E0D\u7B26\uFF09");
579
+ }
580
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
581
+ const chunks = [];
582
+ let offset = PNG_SIGNATURE.length;
583
+ while (offset + 12 <= png.length) {
584
+ const length = view.getUint32(offset);
585
+ const type = String.fromCharCode(png[offset + 4], png[offset + 5], png[offset + 6], png[offset + 7]);
586
+ const dataStart = offset + 8;
587
+ const dataEnd = dataStart + length;
588
+ if (dataEnd + 4 > png.length) throw new Error(`PNG \u5757 ${type} \u957F\u5EA6\u8D8A\u754C`);
589
+ const data = png.subarray(dataStart, dataEnd);
590
+ const expected = view.getUint32(dataEnd);
591
+ const actual = crc32(png.subarray(offset + 4, dataEnd));
592
+ if (expected !== actual) throw new Error(`PNG \u5757 ${type} CRC \u6821\u9A8C\u5931\u8D25`);
593
+ chunks.push({ type, data });
594
+ offset = dataEnd + 4;
595
+ if (type === "IEND") break;
596
+ }
597
+ return chunks;
598
+ }
599
+ function latin1Bytes(text) {
600
+ const out = new Uint8Array(text.length);
601
+ for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 255;
602
+ return out;
603
+ }
604
+ function encodeChunk(type, data) {
605
+ const out = new Uint8Array(12 + data.length);
606
+ const view = new DataView(out.buffer);
607
+ view.setUint32(0, data.length);
608
+ for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i);
609
+ out.set(data, 8);
610
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
611
+ return out;
612
+ }
613
+ function insertPngTextChunk(png, keyword, text) {
614
+ const keywordLength = keyword.length;
615
+ if (keywordLength < 1 || keywordLength > 79) throw new Error(`tEXt keyword \u957F\u5EA6\u8D8A\u754C\uFF081\u201379\uFF09\uFF1A${keywordLength}`);
616
+ if (!parsePngChunks(png).some((chunk) => chunk.type === "IHDR")) throw new Error("PNG \u7F3A\u5C11 IHDR \u5757");
617
+ const data = new Uint8Array([...latin1Bytes(keyword), 0, ...latin1Bytes(text)]);
618
+ const textChunk = encodeChunk("tEXt", data);
619
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
620
+ const ihdrLength = view.getUint32(PNG_SIGNATURE.length);
621
+ const insertAt = PNG_SIGNATURE.length + 12 + ihdrLength;
622
+ const out = new Uint8Array(png.length + textChunk.length);
623
+ out.set(png.subarray(0, insertAt), 0);
624
+ out.set(textChunk, insertAt);
625
+ out.set(png.subarray(insertAt), insertAt + textChunk.length);
626
+ return out;
627
+ }
628
+
629
+ // src/exportPng.ts
630
+ var PREVIEW_TEXT_KEYWORD = "CanvasNext";
631
+ var PREVIEW_TEXT_VALUE = "preview render, not the final image (final = server-side render of the graph JSON)";
632
+ async function exportPreviewPng(doc, sourceBackend, options = {}) {
633
+ const scale = options.scale ?? 1;
634
+ const width = Math.max(1, Math.round(doc.width * scale));
635
+ const height = Math.max(1, Math.round(doc.height * scale));
636
+ const canvas = document.createElement("canvas");
637
+ canvas.width = width;
638
+ canvas.height = height;
639
+ const ctx = canvas.getContext("2d");
640
+ if (!ctx) throw new Error("\u65E0\u6CD5\u521B\u5EFA\u5BFC\u51FA\u753B\u5E03\uFF082D \u4E0A\u4E0B\u6587\u4E0D\u53EF\u7528\uFF09");
641
+ renderCanvas(doc, sourceBackend.forkWith(ctx), options.textPolicies);
642
+ const blob = await toPngBlob(canvas);
643
+ const bytes = await blob.arrayBuffer();
644
+ const annotated = insertPngTextChunk(
645
+ new Uint8Array(bytes),
646
+ PREVIEW_TEXT_KEYWORD,
647
+ PREVIEW_TEXT_VALUE
648
+ );
649
+ return { blob: new Blob([annotated], { type: "image/png" }), width, height };
650
+ }
651
+ function toPngBlob(canvas) {
652
+ return new Promise((resolve, reject) => {
653
+ canvas.toBlob((blob) => {
654
+ if (blob) resolve(blob);
655
+ else reject(new Error("PNG \u5BFC\u51FA\u5931\u8D25\uFF08toBlob \u8FD4\u56DE\u7A7A\uFF09"));
656
+ }, "image/png");
657
+ });
658
+ }
659
+
660
+ // src/index.ts
661
+ var PACKAGE_NAME = "@hankchen/canvas-browser-renderer";
662
+ export {
663
+ Canvas2DBackend,
664
+ Materializer,
665
+ PACKAGE_NAME,
666
+ PREVIEW_TEXT_KEYWORD,
667
+ PREVIEW_TEXT_VALUE,
668
+ QR_FIXED_OPTIONS,
669
+ applyViewportTransform,
670
+ builtinFontShorthand,
671
+ canvasFontCssFamily,
672
+ coverCrop,
673
+ createBrowserLoaders,
674
+ drawResourceMarkers,
675
+ exportPreviewPng,
676
+ fontResourceKey,
677
+ imageResourceKey,
678
+ isBuiltinFontRef,
679
+ loadCanvasFont,
680
+ qrDataUrl,
681
+ qrModuleMatrix,
682
+ qrResourceKey,
683
+ sha256Hex,
684
+ withImageProxy
685
+ };
686
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cover.ts","../src/fonts.ts","../src/canvas2d-backend.ts","../src/qr.ts","../src/materializer.ts","../src/sha256.ts","../src/markers.ts","../src/exportPng.ts","../src/png.ts","../src/index.ts"],"sourcesContent":["/**\n * cover 缩放裁切几何:等比缩放至铺满目标盒,溢出居中裁掉(PHP intervention cover /\n * go-canvas image-renderer cover 同款)。明确放弃逐像素复刻 GD/浏览器 rasterizer\n * (spec Out of Scope:布局级一致、像素不求等)——本模块只算\"源图上裁哪个窗\",\n * 缩放插值交给 drawImage 的原生光栅化。纯几何,无 DOM 类型(红线 2)。\n */\n\nexport interface CropWindow {\n sx: number\n sy: number\n sw: number\n sh: number\n}\n\n/**\n * 计算源图 srcW×srcH 铺满 dstW×dstH 的居中裁切窗。调用方保证 dstW/dstH 为正;\n * 裁切窗与居中偏移整型化向零截断;极端宽高比下截断可得 0 宽窗:钳到 ≥1,\n * 宁可纵横比略让也不输出空图\n */\nexport function coverCrop(srcW: number, srcH: number, dstW: number, dstH: number): CropWindow {\n const scale = Math.max(dstW / srcW, dstH / srcH)\n let cropW = Math.trunc(dstW / scale)\n let cropH = Math.trunc(dstH / scale)\n if (cropW < 1) cropW = 1\n if (cropH < 1) cropH = 1\n const sx = Math.trunc((srcW - cropW) / 2)\n const sy = Math.trunc((srcH - cropH) / 2)\n return { sx, sy, sw: cropW, sh: cropH }\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * 字体契约的渲染端消化:内置默认字体判定/简写 + URL 字体的 FontFace 物化。\n * 语义镜像 PHP ImageRenderer::drawText(`$fontFile !== '' && !is_numeric($fontFile)`\n * 才走字体文件,否则 v4 内置默认字体)与 go-canvas typography.IsBuiltinFont——\n * 空串/纯数字字体 id 是旧库 GD 内置字体的血统,浏览器端对应内置默认字体 =\n * 系统无衬线族。本文件是渲染包内唯一触 DOM 运行时 API 的地方(FontFace /\n * document.fonts;Canvas2D 类型在 canvas2d-backend.ts 局部引入),包 tsconfig\n * 仍无 DOM lib(红线 2)。\n */\n\n/** 内置默认字体判定:空串/纯数字字体引用(PHP is_numeric 同门:旧库 GD 内置字体 id) */\nexport function isBuiltinFontRef(font: string): boolean {\n if (font === '') return true\n // 纯数字:可选符号/小数/指数(is_numeric 的实用子集——旧库只用 '1'-'5' 字体 id)\n return /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/.test(font.trim())\n}\n\n/** 内置默认字体的 ctx.font 简写:系统无衬线族按字号取用 */\nexport function builtinFontShorthand(fontSize: number): string {\n return `${fontSize}px sans-serif`\n}\n\n/**\n * font-family 的 CSS 值(工单 11 textarea overlay 用):内置默认字体 = 系统无衬线\n * 族;URL = 注册族名(与 loadCanvasFont 同一 fnv-1a 派生,注册后即命中)尾部附\n * sans-serif 兜底——未注册/未加载时回落系统无衬线,与 canvas 侧未注册 URL 落内置\n * 默认简写的行为同门。纯字符串函数,无 DOM。\n */\nexport function canvasFontCssFamily(font: string): string {\n if (isBuiltinFontRef(font)) return 'sans-serif'\n return `${fontFamilyFor(font)}, sans-serif`\n}\n\n/** ctx.font 可用的注册族名:由字体 URL 确定性派生(fnv-1a 32 位),CSS 安全 */\nfunction fontFamilyFor(font: string): string {\n let hash = 0x811c9dc5\n for (let i = 0; i < font.length; i++) {\n hash ^= font.charCodeAt(i)\n hash = Math.imul(hash, 0x01000193)\n }\n return `canvas-font-${(hash >>> 0).toString(36)}`\n}\n\n// URL → 注册完成态的缓存:同一字体只注册一次(PHP/Go 侧解析缓存的对位物)\nconst fontRegistry = new Map<string, Promise<string>>()\n\n/**\n * URL 字体物化:经 FontFace 注册进 document.fonts,resolve 为 ctx.font 可用的族名。\n * 加载失败(离线/404/解析失败)时 reject——调用方决定降级(预览哲学:占位 +\n * 内置默认字体兜底,工单 04 补失败态标识)\n */\nexport function loadCanvasFont(font: string): Promise<string> {\n const cached = fontRegistry.get(font)\n if (cached) return cached\n\n const family = fontFamilyFor(font)\n const loading = (async () => {\n // CSS 字符串内的引号转义(graph 引用来自宿主数据,不假设 URL 干净)\n const source = `url(\"${font.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\")`\n const face = new FontFace(family, source)\n await face.load()\n // lib.dom 的 FontFaceSet 声明缺 add(运行时存在),按 Set 结构收窄\n ;(document.fonts as unknown as Set<FontFace>).add(face)\n return family\n })()\n // 失败不缓存:下一次渲染可重试(临时网络故障自愈)\n loading.catch(() => fontRegistry.delete(font))\n fontRegistry.set(font, loading)\n return loading\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * Canvas2D 五原语后端。按包红线整体不开 DOM lib,仅本文件局部引入\n * CanvasRenderingContext2D 类型(AGENTS.md 红线 2)。\n *\n * 内容绘制语义(工单 03):\n * - drawImage:cover 等比缩放居中裁切(cover.ts),宽或高 ≤0 跳过(PHP 同门);\n * 图片源经 setImage 预载回写(远程物化在工单 04),未注册的 src 只画盒不绘制;\n * - drawText:布局层只给纯对齐锚点,基线差由本端以 TextMetrics 消化(ADR-0003 同款——\n * PHP 的 GD 基线魔数 round(fontSize*0.1) 不移植);字体经 fonts.ts 的判定与注册缝。\n */\nimport type { Border, BorderSide, RenderBackend, TextDrawOptions } from '@hankchen/canvas'\n\nimport { coverCrop } from './cover'\nimport { builtinFontShorthand, isBuiltinFontRef } from './fonts'\n\n/** 可绘制图片源:浏览器图片元素/位图,或结构等价的测试替身(须给得出宽高) */\nexport interface DrawableImage {\n readonly width: number\n readonly height: number\n}\n\n/**\n * 预览视口变换(工单 05):begin 清屏后施加到 ctx 的 dpr×zoom×相机变换。\n * x/y 由编辑器会话给出(已对齐物理像素),本端只做与 dpr 的乘法合成。\n */\nexport interface PreviewViewportTransform {\n dpr: number\n zoom: number\n x: number\n y: number\n}\n\n/** 视口感知后端能力:编辑器会话在内容重绘前经此同步预览视口(可选能力口) */\nexport interface ViewportAwareBackend {\n setViewportTransform(transform: PreviewViewportTransform | null): void\n}\n\n/**\n * 把预览视口变换施加到 2D 上下文(内容层 begin 与宿主覆盖层共用同一几何,\n * 保证两层在缩放/平移下不错位)。调用方需先自行复位变换并清屏。\n */\nexport function applyViewportTransform(\n ctx: CanvasRenderingContext2D,\n transform: PreviewViewportTransform,\n): void {\n const scale = transform.dpr * transform.zoom\n // 场景整数坐标经 transform 承担缩放与相机(绘制代码零改动);平移已由\n // 会话侧对齐物理像素,此处直接乘 dpr×zoom 落到设备像素网格。\n ctx.setTransform(scale, 0, 0, scale, -transform.x * scale, -transform.y * scale)\n}\n\nexport class Canvas2DBackend implements RenderBackend, ViewportAwareBackend {\n private readonly ctx: CanvasRenderingContext2D\n\n /** 物化回写面:src → 已加载的图片源(工单 04 的物化器落点,渲染前由宿主/物化器填充) */\n private readonly images = new Map<string, DrawableImage>()\n\n /** 字体引用 → 已注册族名(loadCanvasFont 的回写面;未注册 URL 回落内置默认) */\n private readonly fontFamilies = new Map<string, string>()\n\n /** 预览视口(工单 05):null = 恒等变换(如导出走 begin(w,h) 全幅语义) */\n private viewportTransform: PreviewViewportTransform | null = null\n\n constructor(ctx: CanvasRenderingContext2D) {\n this.ctx = ctx\n }\n\n /** 预载回写:注册 src 对应的图片源后 drawImage 才会绘制 */\n setImage(src: string, image: DrawableImage): void {\n this.images.set(src, image)\n }\n\n /** 字体物化回写:注册字体引用对应的 ctx.font 族名 */\n setFontFamily(font: string, family: string): void {\n this.fontFamilies.set(font, family)\n }\n\n /**\n * 派生同资源的导出后端(工单 13):把本后端已物化的图片/字体注册复制到新\n * 渲染面——导出画布不经预览视口(恒等变换全幅渲染),但资源沿用编辑面的\n * 物化结果,不重新装载。\n */\n forkWith(ctx: CanvasRenderingContext2D): Canvas2DBackend {\n const fork = new Canvas2DBackend(ctx)\n for (const [src, image] of this.images) fork.images.set(src, image)\n for (const [font, family] of this.fontFamilies) fork.fontFamilies.set(font, family)\n return fork\n }\n\n setViewportTransform(transform: PreviewViewportTransform | null): void {\n this.viewportTransform = transform\n }\n\n begin(_width: number, _height: number): void {\n // 新建渲染面语义:复用同一面时清残留变换与上帧像素。物理缓冲 =\n // css × dpr(工单 05 起与画布整数像素语义分离),按缓冲实尺寸清。\n this.ctx.setTransform(1, 0, 0, 1, 0, 0)\n this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)\n\n if (this.viewportTransform) applyViewportTransform(this.ctx, this.viewportTransform)\n }\n\n end(): void {\n // 预览就地绘制,无产物返回;导出(工单 13)经画布 toBlob 走\n }\n\n /**\n * 镜像 PHP ImageRenderer::drawRect:背景填充盒(null/空串跳过)+\n * 四边边框沿盒边描线(线宽居中于路径,与 intervention drawLine 同语义)。\n */\n drawRect(\n x: number,\n y: number,\n width: number,\n height: number,\n bgColor: string | null,\n border: Border,\n ): void {\n const ctx = this.ctx\n if (bgColor !== null && bgColor !== '') {\n ctx.fillStyle = bgColor\n ctx.fillRect(x, y, width, height)\n }\n\n if (border.top) this.strokeLine(x, y, x + width, y, border.top)\n if (border.bottom) this.strokeLine(x, y + height, x + width, y + height, border.bottom)\n if (border.left) this.strokeLine(x, y, x, y + height, border.left)\n if (border.right) this.strokeLine(x + width, y, x + width, y + height, border.right)\n }\n\n /** cover 裁切至 width×height 后放置于 (x, y);宽或高 ≤0 跳过(PHP drawImage 同门) */\n drawImage(src: string, x: number, y: number, width: number, height: number): void {\n if (width <= 0 || height <= 0) return\n\n const image = this.images.get(src)\n if (!image) return // 未物化/未加载:只画盒的占位语义(工单 04 补失败态)\n\n const srcW = naturalWidth(image)\n const srcH = naturalHeight(image)\n if (srcW <= 0 || srcH <= 0) return\n\n const crop = coverCrop(srcW, srcH, width, height)\n this.ctx.drawImage(\n image as CanvasImageSource,\n crop.sx,\n crop.sy,\n crop.sw,\n crop.sh,\n x,\n y,\n width,\n height,\n )\n }\n\n /**\n * 单行文本,(x, y) 为对齐语义锚点:水平偏移 = 真实文本宽度(measureText,与\n * 布局启发式度量无关)× 对齐语义;垂直基线 = 锚点 + 字体 metrics 偏移\n * (top: +ascent / center: +(ascent-descent)/2 / bottom: -descent,未知取值归 top)。\n * 空行零副作用(PHP 同款空串守卫,模板仍对空行分派)。角度为度,正值视觉逆时针\n * (GD angle 惯例;canvas y 轴向下,rotate 取负)。\n */\n drawText(line: string, x: number, y: number, options: TextDrawOptions): void {\n if (line === '') return\n\n const ctx = this.ctx\n ctx.font = this.fontShorthand(options.font, options.fontSize)\n ctx.fillStyle = options.fontColor\n\n const metrics = ctx.measureText(line)\n const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent\n const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent\n\n // 水平:笔起点相对锚点的偏移(left: 0 / center: -宽一半 / right: -宽,未知归 left)\n let penX = 0\n if (options.horizontalAlign === 'center') penX = -metrics.width / 2\n else if (options.horizontalAlign === 'right') penX = -metrics.width\n\n // 垂直:基线相对锚点的偏移,由字体 metrics 消化基线差\n let baselineY = ascent\n if (options.verticalAlign === 'center') baselineY = (ascent - descent) / 2\n else if (options.verticalAlign === 'bottom') baselineY = -descent\n\n if (options.angle === 0) {\n ctx.fillText(line, x + penX, y + baselineY)\n return\n }\n\n ctx.save()\n ctx.translate(x, y)\n ctx.rotate((-options.angle * Math.PI) / 180)\n ctx.fillText(line, penX, baselineY)\n ctx.restore()\n }\n\n /** ctx.font 简写:内置引用(空串/纯数字)或未注册 URL → 系统无衬线默认 */\n private fontShorthand(font: string, fontSize: number): string {\n if (isBuiltinFontRef(font)) return builtinFontShorthand(fontSize)\n const family = this.fontFamilies.get(font)\n return family ? `${fontSize}px \"${family}\"` : builtinFontShorthand(fontSize)\n }\n\n private strokeLine(x1: number, y1: number, x2: number, y2: number, side: BorderSide): void {\n const ctx = this.ctx\n ctx.beginPath()\n ctx.moveTo(x1, y1)\n ctx.lineTo(x2, y2)\n ctx.strokeStyle = side.color\n ctx.lineWidth = side.width\n ctx.stroke()\n }\n}\n\n/** 图片源尺寸:优先 HTMLImageElement 的固有尺寸(SVG 等内容尺寸可能不同于布局尺寸) */\nfunction naturalWidth(image: DrawableImage): number {\n const natural = (image as Partial<HTMLImageElement>).naturalWidth\n return typeof natural === 'number' && natural > 0 ? natural : image.width\n}\n\nfunction naturalHeight(image: DrawableImage): number {\n const natural = (image as Partial<HTMLImageElement>).naturalHeight\n return typeof natural === 'number' && natural > 0 ? natural : image.height\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * QR 物化的生成端:固定选项与 php-canvas-next ResourceResolver::materializeQrCode\n * 逐项对齐(endroid/qr-code:Encoding UTF-8、ErrorCorrectionLevel::High、margin 0、\n * 黑白双色、RoundBlockSizeMode::None)。与 PHP 的差异点:PHP 按图层宽度就地生成\n * 位图(缓存键含宽度),浏览器端按固定 scale 生成一次、由渲染原语 drawImage 缩放\n * 到盒宽——同内容跨图层(不同宽)只生成一次。\n *\n * qrcode 包运行时是 CJS 同构包(Node 走 pngjs、浏览器走 canvas),类型为具名导出。\n * 本文件只产 dataURL 与模块矩阵,不触 DOM 运行时 API(Image 装载归 materializer)。\n */\nimport { create as createQrCode, toDataURL as qrToDataURL } from 'qrcode'\n\n/** 固定选项(契约):纠错 High、无静区、黑白;scale 只影响产物清晰度(终图随盒缩放) */\nexport const QR_FIXED_OPTIONS = {\n errorCorrectionLevel: 'H',\n margin: 0,\n scale: 4,\n color: { dark: '#000000ff', light: '#ffffffff' },\n} as const\n\n/** QR 符号的模块矩阵视图(测试取样缝:点位断言不经过 PNG 解码) */\nexport interface QrModuleMatrix {\n readonly size: number\n isDark(row: number, col: number): boolean\n}\n\n/** 同步生成模块矩阵(固定选项);UTF-8 串按字节模式编码(endroid Encoding('UTF-8') 同语义) */\nexport function qrModuleMatrix(value: string): QrModuleMatrix {\n const qr = createQrCode(value, { errorCorrectionLevel: QR_FIXED_OPTIONS.errorCorrectionLevel })\n const { size, data } = qr.modules\n return {\n size,\n isDark: (row, col) => data[row * size + col] === 1,\n }\n}\n\n/** 生成 PNG dataURL(固定选项);交给 Image 装载后由渲染原语缩放到盒宽 */\nexport function qrDataUrl(value: string): Promise<string> {\n // QR_FIXED_OPTIONS 的形状即 toDataURL 的渲染选项(errorCorrectionLevel/margin/scale/color)\n return qrToDataURL(value, QR_FIXED_OPTIONS)\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * 物化状态机(工单 04):渲染前把图层的资源引用(图片 / 字体 URL)与 QR 内容\n * 准备成可绘资源,状态 pending → done/failed。\n *\n * 红线(图层 setter 禁 I/O 的编辑器延伸):物化状态只落本状态机的资源切片,永不写\n * graph——切片形状即未来内核 store ui 分支的 `ui.resources`(工单 05+ 建 store 时\n * 由订阅桥接整体替换),物化完成只触发重绘、不产生文档 patch。\n *\n * 缓存键 = sha256(完整引用)(图片/字体取 URL 原串,QR 内容加 `qr:` 命名空间),\n * 修正 PHP ResourceResolver 以 URL basename 作键的同名碰撞旧债。同引用跨图层\n * 去重:状态已有 pending/done 的键不再调度,failed 留待下次 materialize 重试\n * (临时网络故障自愈,与 fonts.ts 的失败不缓存同哲学)。\n *\n * 触发集合:非空引用全部装载。与 PHP 的差异点:PHP 对非 URL 引用\"本地路径直接\n * 使用\"(ResourceResolver 跳过下载),浏览器无文件系统对位——相对路径就是同源\n * URL,仍需经 Image 装载(工单 03 预载先例);跨域语义(crossOrigin/代理)只作用\n * 于绝对 http(s) 引用。\n *\n * 跨域:默认图片装载强制 crossOrigin='anonymous'——无 CORS 头的源在物化阶段即\n * 失败(进 failed 态、画失败标识),不留到导出 toBlob 才抛 SecurityError;宿主经\n * `imageProxy` 注入改写(对应 PHP DownloaderInterface 注入语义),错误消息保持\n * 原始引用以便三端对照 PHP `could not get remote file(<url>)`。\n *\n * 触发:宿主在文档变更后调 materialize(doc)(store 建立后由 doc patch 订阅驱动)。\n * 导出前的 pending 排空闸归工单 13。\n */\nimport { forEachLayerBox, qrImageSrc } from '@hankchen/canvas'\nimport type { Canvas as CanvasDoc, Layer } from '@hankchen/canvas'\n\nimport { type Canvas2DBackend, type DrawableImage } from './canvas2d-backend'\nimport { isBuiltinFontRef, loadCanvasFont } from './fonts'\nimport { qrDataUrl } from './qr'\nimport { sha256Hex } from './sha256'\n\nexport type ResourceStatus = 'pending' | 'done' | 'failed'\n\n/** 单条资源状态;error 仅 failed 时携带(加载器的错误消息) */\nexport interface ResourceEntry {\n readonly status: ResourceStatus\n readonly error?: string\n}\n\n/** 资源状态切片:key = 资源缓存键。整体替换语义,直接对接未来 store 的 ui 分支 */\nexport type ResourceState = Readonly<Record<string, ResourceEntry>>\n\n/** 缓存键:sha256(完整引用)——不继承 PHP basename 旧债(同名不同 URL 不碰撞);派生经键 memo(同引用不重复散列) */\nexport const imageResourceKey = (src: string): string => memoSha256Hex(src)\nexport const fontResourceKey = (font: string): string => memoSha256Hex(font)\n/** QR 内容加命名空间后散列:内容恰为 URL 的 QR 与图片键域隔离 */\nexport const qrResourceKey = (value: string): string => memoSha256Hex(`qr:${value}`)\n\n/**\n * 跨域代理注入点(对应 PHP DownloaderInterface):把 http(s) 资源 URL 改写为代理\n * URL。data:/相对路径不经代理(本就同源/内联)。未配置时原样通过。\n */\nexport function withImageProxy(\n url: string,\n proxy: ((url: string) => string) | undefined,\n): string {\n if (!proxy || !/^https?:\\/\\//i.test(url)) return url\n return proxy(url)\n}\n\n/** 三类资源的装载器(注入缝):测试注桩,宿主可替换缓存/代理策略 */\nexport interface ResourceLoaders {\n /** 图片 URL → 可绘制源(默认:Image + crossOrigin=anonymous + 可选代理) */\n readonly loadImage: (url: string) => Promise<DrawableImage>\n /** 字体 URL → ctx.font 可用的族名(默认:loadCanvasFont 的 FontFace 注册) */\n readonly loadFont: (font: string) => Promise<string>\n /** QR 内容 → 可绘制源(默认:固定选项生成 PNG dataURL 后经 Image 装载) */\n readonly loadQr: (value: string) => Promise<DrawableImage>\n}\n\nexport interface MaterializerOptions {\n /** 覆盖默认浏览器装载器(Node 测试注桩 / 宿主自定义) */\n readonly loaders?: ResourceLoaders\n /** 跨域代理注入点:默认图片装载器把 http(s) 引用改写为代理 URL */\n readonly imageProxy?: (url: string) => string\n /** 同时在途的装载上限(默认 6) */\n readonly concurrency?: number\n}\n\ninterface ResourceRef {\n readonly key: string\n readonly kind: 'image' | 'font' | 'qr'\n readonly ref: string\n}\n\n/**\n * 图层 → 资源引用的唯一分派(物化收集与覆盖层标识共用):\n * 图片取非空 src、字体取非内置引用(空串/纯数字 = 渲染端内置默认)、QR 取非空内容。\n * 无资源引用的图层返回 null。\n */\nexport function resourceRefOf(layer: Layer): ResourceRef | null {\n if (layer.type === 'ImageLayer' && layer.src !== null) {\n return { key: imageResourceKey(layer.src), kind: 'image', ref: layer.src }\n }\n if (layer.type === 'TextLayer' && !isBuiltinFontRef(layer.font)) {\n return { key: fontResourceKey(layer.font), kind: 'font', ref: layer.font }\n }\n if (layer.type === 'QrCodeLayer' && layer.value !== '') {\n return { key: qrResourceKey(layer.value), kind: 'qr', ref: layer.value }\n }\n return null\n}\n\nconst DEFAULT_CONCURRENCY = 6\n\n/**\n * 键 memo 原语(canvas-web-render-perf 工单 02):同输入串不重复散列,容量有界\n * (FIFO 逐最旧)。逐键 doc 变更驱动 materialize 全树重扫,data URL 大图的 sha256\n * 是逐键开销链的大头——文本编辑不碰资源,键派生必须 O(1) 走缓存。产物与底层函数\n * 逐字节一致(resource-keys 测试 oracle 复核)。\n */\nexport function createMemoizedKeyHash(\n load: (input: string) => string,\n capacity = 512,\n): (input: string) => string {\n const cache = new Map<string, string>()\n return (input: string): string => {\n const hit = cache.get(input)\n if (hit !== undefined) return hit\n const key = load(input)\n if (cache.size >= capacity) {\n const oldest = cache.keys().next().value\n if (oldest !== undefined) cache.delete(oldest)\n }\n cache.set(input, key)\n return key\n }\n}\n\nconst memoSha256Hex = createMemoizedKeyHash(sha256Hex)\n\nexport class Materializer {\n private readonly backend: Canvas2DBackend\n private readonly loaders: ResourceLoaders\n private readonly maxConcurrent: number\n\n private readonly entries = new Map<string, ResourceEntry>()\n private readonly listeners = new Set<(state: ResourceState) => void>()\n private readonly settleResolvers: Array<(state: ResourceState) => void> = []\n private readonly queue: ResourceRef[] = []\n private active = 0\n\n constructor(backend: Canvas2DBackend, options: MaterializerOptions = {}) {\n this.backend = backend\n this.loaders = options.loaders ?? createBrowserLoaders({ imageProxy: options.imageProxy })\n this.maxConcurrent = Math.max(options.concurrency ?? DEFAULT_CONCURRENCY, 1)\n }\n\n /** 资源状态快照(ui.resources 切片;每次变更整体替换,不可变) */\n get state(): ResourceState {\n return Object.fromEntries(this.entries)\n }\n\n /** 在途装载条数(playground 提示/将来导出闸消费) */\n get pendingCount(): number {\n let count = 0\n for (const entry of this.entries.values()) {\n if (entry.status === 'pending') count++\n }\n return count\n }\n\n /**\n * 扫描文档收集资源引用(含表格下钻,复用渲染模板的同一遍历),调度未决资源。\n * 幂等:pending/done 的键跳过(同 URL 跨图层只物化一次),failed 重新调度。\n * 快照语义(工单 15):文档不再引用的键当场剪除——打开新文档/删层后旧资源\n * 条目不残留(覆盖层标识与宿主\"失败 N 项\"读数只对当前文档有意义)。\n */\n materialize(doc: CanvasDoc): void {\n const referenced = new Map<string, ResourceRef>()\n forEachLayerBox(doc, (layer) => {\n const ref = resourceRefOf(layer)\n if (ref === null || referenced.has(ref.key)) return\n referenced.set(ref.key, ref)\n })\n\n let pruned = false\n for (const key of [...this.entries.keys()]) {\n if (!referenced.has(key)) {\n this.entries.delete(key)\n pruned = true\n }\n }\n\n // 已决跳过(failed 可重试)\n const collected = new Map<string, ResourceRef>()\n for (const ref of referenced.values()) {\n const existing = this.entries.get(ref.key)\n if (!existing || existing.status === 'failed') collected.set(ref.key, ref)\n }\n\n if (collected.size === 0) {\n if (pruned) this.notify()\n return\n }\n\n for (const ref of collected.values()) {\n this.entries.set(ref.key, { status: 'pending' })\n this.queue.push(ref)\n }\n this.notify()\n this.pump()\n }\n\n /** 订阅状态变更(整体快照推送);返回退订函数 */\n subscribe(listener: (state: ResourceState) => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /**\n * 全量物化排空闸(工单 13,PNG 导出前置):在途装载全部落定后 resolve,\n * 携带终态快照(failed 不算在途——预览语义按占位出图,宿主可据快照提示)。\n * 闸在 pending 首次归零时收口:其后新调度的装载归下一次 await 覆盖。\n */\n whenSettled(): Promise<ResourceState> {\n if (this.pendingCount === 0) return Promise.resolve(this.state)\n return new Promise((resolve) => {\n this.settleResolvers.push(resolve)\n })\n }\n\n private notify(): void {\n const state = this.state\n for (const listener of this.listeners) listener(state)\n if (this.pendingCount === 0 && this.settleResolvers.length > 0) {\n const resolvers = this.settleResolvers.splice(0)\n for (const resolve of resolvers) resolve(state)\n }\n }\n\n /** 并发上限内的队列泵:完成一个补位一个 */\n private pump(): void {\n while (this.active < this.maxConcurrent && this.queue.length > 0) {\n const ref = this.queue.shift()!\n this.active++\n void this.load(ref).finally(() => {\n this.active--\n this.pump()\n })\n }\n }\n\n private async load(ref: ResourceRef): Promise<void> {\n let entry: ResourceEntry\n try {\n if (ref.kind === 'font') {\n this.backend.setFontFamily(ref.ref, await this.loaders.loadFont(ref.ref))\n } else if (ref.kind === 'qr') {\n this.backend.setImage(qrImageSrc(ref.ref), await this.loaders.loadQr(ref.ref))\n } else {\n this.backend.setImage(ref.ref, await this.loaders.loadImage(ref.ref))\n }\n entry = { status: 'done' }\n } catch (error) {\n entry = {\n status: 'failed',\n error: error instanceof Error ? error.message : String(error),\n }\n }\n // 装载在途时文档可能已切走(键被剪除):死键不回写(不复活僵尸条目、\n // 不推空通知);回写面已就绪,重开同一文档时重装载依赖浏览器 HTTP 缓存\n if (this.entries.has(ref.key)) {\n this.entries.set(ref.key, entry)\n this.notify()\n }\n }\n}\n\n/** 默认浏览器装载器:图片经 Image + crossOrigin(代理注入点),字体经 FontFace */\nexport function createBrowserLoaders(\n options: { imageProxy?: (url: string) => string } = {},\n): ResourceLoaders {\n return {\n // 错误消息用原始引用(三端对照 PHP 消息格式),代理 URL 只用于装载\n loadImage: (url) => loadImageElement(withImageProxy(url, options.imageProxy), url),\n loadFont: (font) => loadCanvasFont(font),\n loadQr: async (value) => loadImageElement(await qrDataUrl(value), `qr:${value}`),\n }\n}\n\n/**\n * 图片装载:crossOrigin='anonymous' 物化期即暴露跨域失败(污染画布的 toBlob\n * SecurityError 不留到导出)。失败消息用调用方的原始引用(PHP `could not get\n * remote file(<url>)` 同格式,方便三端对照),不掺代理改写后的装载地址。\n */\nfunction loadImageElement(src: string, ref: string): Promise<DrawableImage> {\n return new Promise((resolve, reject) => {\n const image = new Image()\n image.crossOrigin = 'anonymous'\n image.onerror = () => reject(new Error(`could not get remote file(${ref})`))\n image.onload = () => resolve(image)\n image.src = src\n })\n}\n","/**\n * 同步 SHA-256(FIPS 180-4):资源缓存键的散列原语。\n *\n * 为什么手写:物化状态机需要同步派生缓存键(渲染/键比较路径不能 await),\n * Web Crypto 的 subtle.digest 只有异步口;而编辑器物化散列不值得为此引依赖。\n * 测试以 node:crypto 为独立 oracle 逐值锁定。\n *\n * TextEncoder 是宿主全局(浏览器 evergreen 与 Node 皆有),本包 tsconfig 无任何\n * lib 注入,这里给最小结构声明以自持类型(不依赖别的文件碰巧拖进的全局)。\n */\n\ninterface TextEncoderLike {\n encode(input: string): Uint8Array\n}\n\ndeclare const TextEncoder: new () => TextEncoderLike\n\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n])\n\nfunction rotr(x: number, n: number): number {\n return ((x >>> n) | (x << (32 - n)))\n}\n\n/** UTF-8 串的 SHA-256 摘要,64 位小写十六进制 */\nexport function sha256Hex(input: string): string {\n const data = new TextEncoder().encode(input)\n const bitLength = data.length * 8\n\n // 填充:消息 + 0x80 + 零 + 64 位大端长度,总长 512 位倍数\n const paddedLength = (((data.length + 8) >> 6) + 1) << 6\n const words = new Uint32Array(paddedLength / 4)\n for (let i = 0; i < data.length; i++) {\n words[i >>> 2] = words[i >>> 2]! | (data[i]! << ((3 - (i & 3)) * 8))\n }\n words[data.length >>> 2] =\n words[data.length >>> 2]! | (0x80 << ((3 - (data.length & 3)) * 8))\n words[words.length - 1] = bitLength >>> 0\n words[words.length - 2] = Math.floor(bitLength / 0x100000000)\n\n let h0 = 0x6a09e667\n let h1 = 0xbb67ae85\n let h2 = 0x3c6ef372\n let h3 = 0xa54ff53a\n let h4 = 0x510e527f\n let h5 = 0x9b05688c\n let h6 = 0x1f83d9ab\n let h7 = 0x5be0cd19\n\n const w = new Uint32Array(64)\n for (let block = 0; block < words.length; block += 16) {\n for (let t = 0; t < 16; t++) w[t] = words[block + t]!\n for (let t = 16; t < 64; t++) {\n const s0 = rotr(w[t - 15]!, 7) ^ rotr(w[t - 15]!, 18) ^ (w[t - 15]! >>> 3)\n const s1 = rotr(w[t - 2]!, 17) ^ rotr(w[t - 2]!, 19) ^ (w[t - 2]! >>> 10)\n w[t] = (w[t - 16]! + s0 + w[t - 7]! + s1) | 0\n }\n\n let a = h0\n let b = h1\n let c = h2\n let d = h3\n let e = h4\n let f = h5\n let g = h6\n let h = h7\n for (let t = 0; t < 64; t++) {\n const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)\n const ch = (e & f) ^ (~e & g)\n const temp1 = (h + S1 + ch + K[t]! + w[t]!) | 0\n const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)\n const maj = (a & b) ^ (a & c) ^ (b & c)\n const temp2 = (S0 + maj) | 0\n h = g\n g = f\n f = e\n e = (d + temp1) | 0\n d = c\n c = b\n b = a\n a = (temp1 + temp2) | 0\n }\n\n h0 = (h0 + a) | 0\n h1 = (h1 + b) | 0\n h2 = (h2 + c) | 0\n h3 = (h3 + d) | 0\n h4 = (h4 + e) | 0\n h5 = (h5 + f) | 0\n h6 = (h6 + g) | 0\n h7 = (h7 + h) | 0\n }\n\n return [h0, h1, h2, h3, h4, h5, h6, h7]\n .map((word) => (word >>> 0).toString(16).padStart(8, '0'))\n .join('')\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * 物化状态标识(覆盖层):pending → 灰叉(对角交叉占位)、failed → 红叉 + 红框\n * (失败态标识)。研究文档 §2.9 的落点是把标识画在覆盖层而非内容层——内容层保持\n * PHP 镜像语义(物化完成前只有背景/边框盒)。工单 05 的 gizmo 覆盖层建立后由\n * 编辑器管线接管调用,本函数只依赖文档与资源状态切片,编辑器/playground 通用。\n */\nimport { forEachLayerBox } from '@hankchen/canvas'\nimport type { Canvas as CanvasDoc } from '@hankchen/canvas'\n\nimport { resourceRefOf } from './materializer'\nimport type { ResourceState } from './materializer'\n\nconst PENDING_STROKE = 'rgba(100, 116, 139, 0.6)'\nconst FAILED_STROKE = '#ef4444'\nconst FAILED_LINE_WIDTH = 2\n\n/** 在覆盖层上按资源状态画标识:failed 红叉+红框、pending 灰叉,done/无状态零笔画 */\nexport function drawResourceMarkers(\n ctx: CanvasRenderingContext2D,\n doc: CanvasDoc,\n state: ResourceState,\n): void {\n forEachLayerBox(doc, (layer, box) => {\n const ref = resourceRefOf(layer)\n if (ref === null) return\n\n const entry = state[ref.key]\n if (!entry || entry.status === 'done') return\n\n if (entry.status === 'failed') {\n ctx.strokeStyle = FAILED_STROKE\n ctx.lineWidth = FAILED_LINE_WIDTH\n crossAt(ctx, box.x, box.y, box.width, box.height)\n // 红框内缩 1px,避免与图层自身边框叠线\n ctx.strokeRect(box.x + 1, box.y + 1, box.width - 2, box.height - 2)\n } else {\n ctx.strokeStyle = PENDING_STROKE\n ctx.lineWidth = 1\n crossAt(ctx, box.x, box.y, box.width, box.height)\n }\n })\n}\n\n/** 对角交叉:beginPath + 两条对角线 + stroke(笔画按调用序可录断言) */\nfunction crossAt(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n width: number,\n height: number,\n): void {\n ctx.beginPath()\n ctx.moveTo(x, y)\n ctx.lineTo(x + width, y + height)\n ctx.moveTo(x + width, y)\n ctx.lineTo(x, y + height)\n ctx.stroke()\n}\n","/// <reference lib=\"dom\" />\n\n/**\n * 预览 PNG 导出(工单 13,ADR 0004):浏览器 Canvas2D 的导出只作**预览/快速\n * 分享**,终图由服务端渲染端依据 graph JSON 权威产出——出图带预览语义标注\n * (PNG tEXt 元数据;界面提示与文件名后缀归宿主),不得当作生产输出。\n *\n * 全幅语义:离屏画布 = 文档像素(可选 scale 倍率),恒等变换渲染(不经预览\n * 视口);资源沿用编辑面后端的物化结果(forkWith 复制注册,不重新装载)。\n * 调用方须先等待物化排空(Materializer.whenSettled),慢资源才有完整呈现。\n */\nimport { renderCanvas, type Canvas, type TextLayoutPolicies } from '@hankchen/canvas'\n\nimport { type Canvas2DBackend } from './canvas2d-backend'\nimport { insertPngTextChunk } from './png'\n\nexport interface PreviewPngOptions {\n /** 输出倍率(相对文档像素;缺省 1 = 文档像素 1:1) */\n readonly scale?: number\n /** 文本布局策略:与编辑会话同一注入值,导出与预览的断行/盒高才一致 */\n readonly textPolicies?: TextLayoutPolicies\n}\n\nexport interface PreviewPngResult {\n /** PNG blob(已注入预览语义 tEXt) */\n readonly blob: Blob\n /** 输出像素尺寸 */\n readonly width: number\n readonly height: number\n}\n\n/** 预览语义标注(tEXt 域只收 Latin-1,用 ASCII;中文标注由界面/文件名承担) */\nexport const PREVIEW_TEXT_KEYWORD = 'CanvasNext'\nexport const PREVIEW_TEXT_VALUE = 'preview render, not the final image (final = server-side render of the graph JSON)'\n\n/**\n * 渲染当前文档为预览 PNG:离屏 canvas 全幅渲染 → toBlob → 注入 tEXt 标注。\n * 抛错时文档与编辑面不受影响(导出面独立,fork 不回写编辑面后端)。\n */\nexport async function exportPreviewPng(\n doc: Canvas,\n sourceBackend: Canvas2DBackend,\n options: PreviewPngOptions = {},\n): Promise<PreviewPngResult> {\n const scale = options.scale ?? 1\n const width = Math.max(1, Math.round(doc.width * scale))\n const height = Math.max(1, Math.round(doc.height * scale))\n const canvas = document.createElement('canvas')\n canvas.width = width\n canvas.height = height\n const ctx = canvas.getContext('2d')\n if (!ctx) throw new Error('无法创建导出画布(2D 上下文不可用)')\n\n renderCanvas(doc, sourceBackend.forkWith(ctx), options.textPolicies)\n const blob = await toPngBlob(canvas)\n const bytes = await blob.arrayBuffer()\n const annotated = insertPngTextChunk(\n new Uint8Array(bytes),\n PREVIEW_TEXT_KEYWORD,\n PREVIEW_TEXT_VALUE,\n )\n return { blob: new Blob([annotated], { type: 'image/png' }), width, height }\n}\n\nfunction toPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {\n return new Promise((resolve, reject) => {\n canvas.toBlob((blob) => {\n if (blob) resolve(blob)\n else reject(new Error('PNG 导出失败(toBlob 返回空)'))\n }, 'image/png')\n })\n}\n","/**\n * PNG 字节级工具(工单 13):tEXt 文本块注入——预览导出的文件语义标注\n * (「预览图,非终图」,ADR 0004)写进 PNG 元数据,随文件走、机器可读;\n * 界面与文件名标注归宿主。纯字节函数、无 DOM(Node 可测)。\n *\n * 块结构:[长度 u32 BE][类型 4B][数据][CRC32 u32 BE];CRC 覆盖类型+数据\n * (IEEE 802.3 多项式,初始/终值取反)。tEXt 数据 = keyword \\0 text,\n * 两段均为 Latin-1(本包只用 ASCII 常量,规范约束不走 UTF-8 通道)。\n */\n\n/** PNG 文件签名(8 字节魔数) */\nexport const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])\n\nlet crcTable: Uint32Array | null = null\n\nfunction getCrcTable(): Uint32Array {\n if (crcTable) return crcTable\n const table = new Uint32Array(256)\n for (let n = 0; n < 256; n += 1) {\n let c = n\n for (let k = 0; k < 8; k += 1) {\n c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n }\n table[n] = c >>> 0\n }\n crcTable = table\n return table\n}\n\n/** PNG 规范的 CRC32(type+data 域校验用) */\nexport function crc32(bytes: Uint8Array): number {\n const table = getCrcTable()\n let crc = 0xffffffff\n for (let i = 0; i < bytes.length; i += 1) {\n crc = table[(crc ^ bytes[i]!) & 0xff]! ^ (crc >>> 8)\n }\n return (crc ^ 0xffffffff) >>> 0\n}\n\n/** 解析出的 PNG 块(类型 + 数据;解析时逐块校验长度域与 CRC 域) */\nexport interface PngChunk {\n readonly type: string\n readonly data: Uint8Array\n}\n\n/** 解析 PNG 块序列:签名/长度/CRC 任一不符即抛错(注入前的完整性门) */\nexport function parsePngChunks(png: Uint8Array): PngChunk[] {\n for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {\n if (png[i] !== PNG_SIGNATURE[i]) throw new Error('不是 PNG 字节流(签名不符)')\n }\n const view = new DataView(png.buffer, png.byteOffset, png.byteLength)\n const chunks: PngChunk[] = []\n let offset = PNG_SIGNATURE.length\n while (offset + 12 <= png.length) {\n const length = view.getUint32(offset)\n const type = String.fromCharCode(png[offset + 4]!, png[offset + 5]!, png[offset + 6]!, png[offset + 7]!)\n const dataStart = offset + 8\n const dataEnd = dataStart + length\n if (dataEnd + 4 > png.length) throw new Error(`PNG 块 ${type} 长度越界`)\n const data = png.subarray(dataStart, dataEnd)\n const expected = view.getUint32(dataEnd)\n const actual = crc32(png.subarray(offset + 4, dataEnd))\n if (expected !== actual) throw new Error(`PNG 块 ${type} CRC 校验失败`)\n chunks.push({ type, data })\n offset = dataEnd + 4\n if (type === 'IEND') break\n }\n return chunks\n}\n\n/** 单个 Latin-1 字符串 → 字节(charCode & 0xff;本包输入恒为 ASCII 常量) */\nfunction latin1Bytes(text: string): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(text.length)\n for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff\n return out\n}\n\nfunction encodeChunk(type: string, data: Uint8Array): Uint8Array<ArrayBuffer> {\n const out = new Uint8Array(12 + data.length)\n const view = new DataView(out.buffer)\n view.setUint32(0, data.length)\n for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i)\n out.set(data, 8)\n view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))\n return out\n}\n\n/**\n * 在 IHDR 之后注入 tEXt 块(keyword\\0text),返回新字节流(不改写入参)。\n * 块序 tEXt 紧随 IHDR:任何合法 PNG 解析器都能在图像数据前读到标注。\n * keyword 违反 tEXt 规范(1–79 字节)或缺 IHDR 时抛错。\n */\nexport function insertPngTextChunk(png: Uint8Array, keyword: string, text: string): Uint8Array<ArrayBuffer> {\n const keywordLength = keyword.length\n if (keywordLength < 1 || keywordLength > 79) throw new Error(`tEXt keyword 长度越界(1–79):${keywordLength}`)\n if (!parsePngChunks(png).some((chunk) => chunk.type === 'IHDR')) throw new Error('PNG 缺少 IHDR 块')\n\n const data = new Uint8Array([...latin1Bytes(keyword), 0, ...latin1Bytes(text)])\n const textChunk = encodeChunk('tEXt', data)\n\n // IHDR 块 = 签名后第一个块,注入点在其后(IHDR 定长 25 字节由 parse 定位,不硬编码)\n const view = new DataView(png.buffer, png.byteOffset, png.byteLength)\n const ihdrLength = view.getUint32(PNG_SIGNATURE.length)\n const insertAt = PNG_SIGNATURE.length + 12 + ihdrLength\n\n const out = new Uint8Array(png.length + textChunk.length)\n out.set(png.subarray(0, insertAt), 0)\n out.set(textChunk, insertAt)\n out.set(png.subarray(insertAt), insertAt + textChunk.length)\n return out\n}\n","/**\n * @hankchen/canvas-browser-renderer 公共出口。\n *\n * Canvas2D 五原语后端(begin → 按 priority 序分派 → end)+ 物化状态机(工单 04)。\n * 本包整体无 DOM lib 编译,Canvas2D/FontFace/Image 类型只在用到它的文件里局部\n * `/// <reference lib=\"dom\" />`。\n */\nexport const PACKAGE_NAME = '@hankchen/canvas-browser-renderer' as const\n\nexport {\n Canvas2DBackend,\n applyViewportTransform,\n type DrawableImage,\n type PreviewViewportTransform,\n type ViewportAwareBackend,\n} from './canvas2d-backend'\nexport { coverCrop, type CropWindow } from './cover'\nexport {\n builtinFontShorthand,\n canvasFontCssFamily,\n isBuiltinFontRef,\n loadCanvasFont,\n} from './fonts'\nexport { QR_FIXED_OPTIONS, qrDataUrl, qrModuleMatrix, type QrModuleMatrix } from './qr'\nexport {\n Materializer,\n createBrowserLoaders,\n fontResourceKey,\n imageResourceKey,\n qrResourceKey,\n withImageProxy,\n type MaterializerOptions,\n type ResourceEntry,\n type ResourceLoaders,\n type ResourceState,\n type ResourceStatus,\n} from './materializer'\nexport { drawResourceMarkers } from './markers'\nexport { sha256Hex } from './sha256'\nexport {\n PREVIEW_TEXT_KEYWORD,\n PREVIEW_TEXT_VALUE,\n exportPreviewPng,\n type PreviewPngOptions,\n type PreviewPngResult,\n} from './exportPng'\n// png.ts 的字节级工具(crc32/parsePngChunks 等)不进包公共出口:仅 exportPng 与\n// Node 测试(tests/png.test.ts 直连模块)消费,导出面保持最小。\n"],"mappings":"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","names":["forEachLayerBox","forEachLayerBox"]}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@hankchen/canvas-browser-renderer",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Canvas2D 五原语渲染后端(矩形/图片 cover/文本基线消化、字体与 QR 物化)",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "sideEffects": false,
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/HankChenCH/canvas-web.git",
28
+ "directory": "packages/canvas-browser-renderer"
29
+ },
30
+ "dependencies": {
31
+ "@hankchen/canvas": "^0.1.0",
32
+ "qrcode": "^1.5.4"
33
+ },
34
+ "devDependencies": {
35
+ "@types/qrcode": "^1.5.6",
36
+ "tsup": "^8.5.0",
37
+ "vitest": "^5.0.1"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json"
43
+ }
44
+ }