移除导入时的缩略图生成
This commit is contained in:
1 parent
bb73cc9227
commit
fe71d2d6ce
32 files changed
+35
-4192
No files matched your search
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 lynweklm@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。
|
||||
|
||||
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。
|
||||
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再由查看器检查完整文件名。普通 JSON/TXT/BYTES 不会进入 Spine 预览。插件不注册导入缩略图处理器,导入阶段由 Eagle 自身处理。
|
||||
|
||||
支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
|
||||
|
||||
@@ -24,14 +24,12 @@ SpineViewer/
|
||||
├─ logo.png # 插件图标
|
||||
├─ assets/
|
||||
│ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge
|
||||
├─ thumbnail/
|
||||
│ └─ spine.js # 缩略图生成(渲染骨骼首帧并交由 Eagle 缓存)
|
||||
├─ viewer/
|
||||
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
|
||||
├─ js/
|
||||
│ ├─ spine-utils.js # 资源匹配、版本检测工具
|
||||
│ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层
|
||||
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
|
||||
│ └─ spine-render.js # Node 渲染辅助模块
|
||||
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
|
||||
│ ├─ 3.4/ spine-canvas.js
|
||||
│ ├─ 3.5/ spine-canvas.js
|
||||
@@ -42,8 +40,7 @@ SpineViewer/
|
||||
│ ├─ 4.1/ spine-canvas.js
|
||||
│ ├─ 4.2/ spine-canvas.js
|
||||
│ └─ 4.3/ spine-canvas.js
|
||||
├─ LICENSES/ # 第三方运行时及依赖的再分发许可证
|
||||
└─ node_modules/ # 可选:@napi-rs/canvas(用于高质量缩略图渲染)
|
||||
└─ LICENSES/ # 第三方运行时的再分发许可证
|
||||
```
|
||||
|
||||
---
|
||||
@@ -66,10 +63,7 @@ SpineViewer/
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
|
||||
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中的手动设置都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。自动流程只写入 Eagle 为当前素材提供的缩略图目标,不会改写同组其他素材的自定义缩略图。
|
||||
- JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,也不进入资源扫描,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。
|
||||
- 暂时缺少骨骼、atlas 或贴图时会显示回退图。资源补齐后,请使用 Eagle 的“刷新缩略图”重新生成当前素材的缩略图。
|
||||
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
|
||||
4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,可主动将当前帧设为**当前素材**的自定义缩略图;若已有自定义缩略图,替换前会明确确认。手动缩略图会添加 Spine badge 和导出版本号。
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +77,7 @@ SpineViewer/
|
||||
|
||||
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
|
||||
|
||||
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。自动缩略图只写 Eagle 为当前素材提供的目标,不会隐式改写同组其他素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
|
||||
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动操作只能设置当前素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
|
||||
|
||||
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
|
||||
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
|
||||
@@ -106,7 +100,7 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4
|
||||
3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。
|
||||
|
||||
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
|
||||
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览和生成缩略图。
|
||||
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览。
|
||||
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
|
||||
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
|
||||
|
||||
@@ -133,51 +127,24 @@ node tests/legacy-binary.test.js
|
||||
|
||||
---
|
||||
|
||||
## 可选依赖说明
|
||||
|
||||
`@napi-rs/canvas` 仅用于缩略图的高质量渲染。它并非必需:
|
||||
- 若保留 `node_modules/`(当前平台原生二进制),缩略图会渲染骨骼形象。
|
||||
- 若删除 `node_modules/`,插件仍可正常预览动画,缩略图会显示一条纯 JS 占位图。
|
||||
|
||||
如需在多个平台分发,请安装对应平台的 `@napi-rs/canvas` 原生二进制。
|
||||
|
||||
---
|
||||
|
||||
## 分享与打包
|
||||
|
||||
### 分享源码
|
||||
|
||||
仓库中的 `node_modules/` 被 `.gitignore` 忽略是正常的。接收者克隆源码后,需要在插件目录执行:
|
||||
|
||||
```bash
|
||||
npm ci --omit=dev
|
||||
```
|
||||
|
||||
`@napi-rs/canvas` 会按当前操作系统和 CPU 下载预编译的 N-API 二进制,正常情况下不需要编译,也不需要安装 Rust/C++ 工具链。安装完成后,再从 Eagle 插件面板加载或打包插件。
|
||||
源码不依赖原生 Node 扩展;加载插件后即可使用预览和手动缩略图功能。
|
||||
|
||||
### 分享可直接安装的插件
|
||||
|
||||
1. 在准备出包的目标系统上执行 `npm ci --omit=dev`。
|
||||
2. 确认插件目录中保留了 `node_modules/`。否则骨骼动画仍可预览,但自动缩略图只能使用占位图。
|
||||
3. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
|
||||
4. 把生成的 `.eagleplugin` 文件发给对方。对方直接安装,不需要再执行 npm,也不需要编译。
|
||||
|
||||
当前工作目录安装的是 **macOS Apple 芯片(darwin-arm64)** 的 Canvas 原生二进制。它不能直接作为 Windows 或 Intel Mac 的完整缩略图版本分发。建议分别在以下目标环境执行安装、测试和打包:
|
||||
|
||||
- macOS Apple 芯片
|
||||
- macOS Intel
|
||||
- Windows x64
|
||||
|
||||
如果每个安装包只携带一个平台的原生二进制,出包时还应把 `manifest.json` 的 `platform` / `arch` 改为对应的 `mac` + `arm`、`mac` + `x64` 或 `win` + `x64`。只有安装包确实包含并测试过所有目标平台的原生二进制时,才保留当前的 `all` + `all`。
|
||||
1. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
|
||||
2. 把生成的 `.eagleplugin` 文件发给对方。安装包不含平台原生依赖,Windows 与 macOS 共用。
|
||||
|
||||
### 出包文件检查
|
||||
|
||||
完整功能包至少应包含:
|
||||
|
||||
- `manifest.json`、`logo.png`
|
||||
- `viewer/`、`thumbnail/`、`js/`、`lib/`、`assets/`
|
||||
- `viewer/`、`js/`、`lib/`、`assets/`
|
||||
- `LICENSES/`
|
||||
- `package.json`、`package-lock.json`
|
||||
- 与目标平台匹配的 `node_modules/`
|
||||
|
||||
无需放入发布包的开发文件包括 `.git/`、日志、`.DS_Store`、`debug*.js`、`test-*.js`、`SpineViewer.code-workspace`,以及未在 `manifest.json` 中使用的 ICNS 测试文件。
|
||||
@@ -26,11 +26,6 @@
|
||||
"devTools": false,
|
||||
"preview": {
|
||||
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
|
||||
"thumbnail": {
|
||||
"path": "thumbnail/spine.js",
|
||||
"size": 400,
|
||||
"allowZoom": false
|
||||
},
|
||||
"viewer": {
|
||||
"path": "viewer/spine.html"
|
||||
}
|
||||
|
||||
Generated
+1
-201
@@ -6,207 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.66"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
|
||||
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.66.tgz",
|
||||
"integrity": "sha512-77Yq9yaUYN90zCovYOpw7LhidJiswU9wLIWWBGF6iiEJyQdt6tkiXpGRZpOMJVO70afkcdc4T7532cxMIBhk0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.66.tgz",
|
||||
"integrity": "sha512-cz3aJ06b8BZGtwRxKTiE0OVUlB17MH8j+BnE4A5+wD9aD1guCCqECsz+k7tpXdAdTAYKRIz2pq6ZuiJ76NyUbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.66.tgz",
|
||||
"integrity": "sha512-szIWqJgFm2OTyGzM+hSiJOaOtjI73VYRC2KN30zZTt7i1+0sgpm5exK5ltDBPOmCdnLt7SbUfpInLj8VvxYlKA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.66.tgz",
|
||||
"integrity": "sha512-h/TZJFc6JLvp8FwbA5mu+yXiblN0iKqshU7xzd6L+ks5uNYgjS7XWLkNiyPQkMaXQgVczOJfZy7r4NSPK3V8Hg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-RGFUdBdi0Xmf+TfwZcB89Ap6hDYh4nzyJhXhNJIgve6ELrIPFhf7sDHvUHxjgW0YzczGoo+ophyCm03cJggu+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.66.tgz",
|
||||
"integrity": "sha512-2cFViDIZ0xQlAHyJmyym+rj3p04V16vgAiz64sCAfwOOiW6e19agv1HQWHUsro3G2lF3PaHGAnp0WRPXGqLOfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-Vm5ZWS2RDPeBpnfx83eJpZfJT07xl0jqp8d83PklKqiDNa3BmDZZ/uuI40/ICgejGLymXXYo5N21b7oAxhRTSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-/ptGBhErNBCgWff3khtuEjhiiYWf70oWvBPRj8y5EMB0nLYpve7RxxFnavVvxN49kJ0MQHRIwgfyd47RSOOKPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.66.tgz",
|
||||
"integrity": "sha512-XunvXisTkIG+bpq6BcXmsUstoLX3RLS6N9Uz9Pg9RpWIMeM6ObR5shr3NgpGRJq93769I1hS4mJW0DX2Au3WBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
|
||||
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
"version": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,9 @@
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
|
||||
"main": "thumbnail/spine.js",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"check:release": "node tests/release-package.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.66"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 lynweklm@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,39 +1,22 @@
|
||||
# SpineViewer 1.1.1
|
||||
|
||||
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、3.7、3.8、4.0、4.1、4.2、4.3。
|
||||
|
||||
本包为**windows和macos通用包**,同时适用于 **Windows x64** 与 **macOS Apple 芯片**。
|
||||
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4–4.3,适用于 Windows 和 macOS。
|
||||
|
||||
## 功能与使用
|
||||
|
||||
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。
|
||||
2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。
|
||||
3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。
|
||||
4. 自动生成带 Spine 标记和版本号的当前素材缩略图,也可在预览器中主动将当前帧设为当前素材的自定义缩略图。已有自定义缩略图时会先确认;插件不会自动改写同组其他素材。
|
||||
5. 若导入时资源尚未配齐,会先显示回退图;补齐后请使用 Eagle 的“刷新缩略图”重新生成。
|
||||
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内。
|
||||
2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。
|
||||
3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。
|
||||
4. 打开预览后,可点击“设为当前素材缩略图”,把当前帧手动设为当前素材的自定义缩略图。已有自定义缩略图时会先询问是否替换。
|
||||
|
||||
## 安全与隐私
|
||||
|
||||
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。跨素材配对只使用当前 Eagle 素材库中已枚举并验证的素材。
|
||||
- 自动缩略图只写 Eagle 为当前素材提供的目标,不会改写同组其他素材。手动设置也只作用于当前素材;已有自定义缩略图时会先确认。
|
||||
- 诊断日志默认关闭。显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时,日志只写系统临时目录,不写插件目录,也不会进入安装包。
|
||||
|
||||
## 平台与依赖
|
||||
|
||||
`node_modules/@napi-rs/` 下并排携带三个包,运行时由 `@napi-rs/canvas` 依据 `process.platform` 与 `process.arch` 自动选择,未匹配的平台包不会被加载:
|
||||
|
||||
| 平台 | 原生包目录 |
|
||||
| --- | --- |
|
||||
| Windows x64 | `canvas-win32-x64-msvc` |
|
||||
| macOS Apple 芯片 | `canvas-darwin-arm64` |
|
||||
| 公共代码(两平台共用) | `canvas` |
|
||||
|
||||
原生包版本均为 **0.1.66**。
|
||||
|
||||
清单使用 `platform: all` / `arch: all`。本包**未包含 macOS Intel 与 Windows ARM64 的原生二进制**:这两种环境下动画预览仍可正常使用。
|
||||
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。
|
||||
- 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。
|
||||
- 手动设置缩略图只作用于当前素材,不会自动改写其他素材。
|
||||
|
||||
## 多语言
|
||||
|
||||
界面支持简体中文、繁体中文、English、日本語、한국어、Русский、Español、Deutsch,随 Eagle 的界面语言切换,无法识别时回退到简体中文。文案位于 `_locales/`。
|
||||
支持简体中文、繁体中文、English、日本語、한국어、Русский、Español 和 Deutsch。
|
||||
|
||||
第三方许可证见 `LICENSES/`,分发已保留。
|
||||
第三方许可见 `LICENSES/`。
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
|
||||
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
|
||||
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path');
|
||||
const utils = require('./spine-utils.js');
|
||||
const spineBadge = require('./spine-badge.js');
|
||||
|
||||
let canvasLib = null;
|
||||
try {
|
||||
canvasLib = require('@napi-rs/canvas');
|
||||
} catch (e) {
|
||||
canvasLib = null;
|
||||
}
|
||||
|
||||
let spineBadgePromise = null;
|
||||
|
||||
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
|
||||
async function drawSpineBadge(ctx, width, height) {
|
||||
if (!canvasLib || !ctx) return;
|
||||
try {
|
||||
if (!spineBadgePromise) {
|
||||
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
|
||||
}
|
||||
const badge = await spineBadgePromise;
|
||||
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
|
||||
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
|
||||
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
|
||||
*/
|
||||
function loadRuntime(dir) {
|
||||
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
performance: { now: () => Date.now() },
|
||||
};
|
||||
// 部分运行时通过 window/self/globalThis 访问全局
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
|
||||
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
|
||||
}
|
||||
|
||||
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
|
||||
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
|
||||
*/
|
||||
function buildTextureAtlas(spine, atlasText, texturePaths) {
|
||||
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
||||
return new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
// 解析所有页面贴图名,并预先异步加载
|
||||
let pageNames = [];
|
||||
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
|
||||
|
||||
const textureMap = {};
|
||||
await Promise.all(pageNames.map(async (name) => {
|
||||
// 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
|
||||
// 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
|
||||
const p = texturePaths && texturePaths[name];
|
||||
if (!p || !fs.existsSync(p)) return;
|
||||
try {
|
||||
const img = await canvasLib.loadImage(p);
|
||||
textureMap[name] = img;
|
||||
} catch (e) { /* ignore */ }
|
||||
}));
|
||||
|
||||
const makeTexture = (name) => {
|
||||
let img = textureMap[name];
|
||||
if (!img) {
|
||||
// 找不到图片时用 2x2 灰色占位纹理
|
||||
const c = canvasLib.createCanvas(2, 2);
|
||||
const cx = c.getContext('2d');
|
||||
cx.fillStyle = '#888888';
|
||||
cx.fillRect(0, 0, 2, 2);
|
||||
img = c;
|
||||
}
|
||||
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
|
||||
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
|
||||
return new spine.Texture(img);
|
||||
};
|
||||
|
||||
let atlas;
|
||||
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
|
||||
if (is4x) {
|
||||
atlas = new spine.TextureAtlas(atlasText);
|
||||
for (const page of atlas.pages) {
|
||||
const tex = makeTexture(page.name);
|
||||
if (page.setTexture) page.setTexture(tex);
|
||||
else page.texture = tex;
|
||||
}
|
||||
} else {
|
||||
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
|
||||
}
|
||||
resolve(atlas);
|
||||
})().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取骨骼数据(json 或二进制) */
|
||||
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
|
||||
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||
if (isBinary) {
|
||||
const loader = new spine.SkeletonBinary(atlasLoader);
|
||||
loader.scale = 1;
|
||||
const buf = fs.readFileSync(skeletonPath);
|
||||
return loader.readSkeletonData(new Uint8Array(buf));
|
||||
} else {
|
||||
const loader = new spine.SkeletonJson(atlasLoader);
|
||||
loader.scale = 1;
|
||||
const text = fs.readFileSync(skeletonPath, 'utf8');
|
||||
return loader.readSkeletonData(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算包围盒(跨版本兼容) */
|
||||
function getBounds(spine, skeleton) {
|
||||
try {
|
||||
if (skeleton.getBoundsRect) {
|
||||
const r = skeleton.getBoundsRect();
|
||||
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}
|
||||
if (skeleton.getBounds) {
|
||||
const offset = new spine.Vector2();
|
||||
const size = new spine.Vector2();
|
||||
skeleton.getBounds(offset, size, []);
|
||||
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染首帧动画到 PNG buffer。
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
|
||||
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
||||
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
|
||||
const atlasText = fs.readFileSync(atlasPath, 'utf8');
|
||||
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
|
||||
|
||||
const spine = loadRuntime(runtimeDir);
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, texturePaths);
|
||||
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
|
||||
|
||||
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
|
||||
const name = path.basename(skeletonPath);
|
||||
throw new Error('骨骼数据为空:' + name);
|
||||
}
|
||||
const skeleton = new spine.Skeleton(skeletonData);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(skeletonData));
|
||||
|
||||
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
|
||||
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
|
||||
let bounds = null;
|
||||
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
|
||||
if (skeleton.updateWorldTransform) {
|
||||
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
|
||||
else skeleton.updateWorldTransform();
|
||||
}
|
||||
bounds = getBounds(spine, skeleton);
|
||||
|
||||
const cw = size, ch = size;
|
||||
const canvas = canvasLib.createCanvas(cw, ch);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
|
||||
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
ctx.save();
|
||||
ctx.translate(cw / 2, ch / 2);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
let renderer = null;
|
||||
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
|
||||
catch (e) {
|
||||
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
|
||||
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
|
||||
}
|
||||
if (renderer) {
|
||||
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
|
||||
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
|
||||
renderer.premultipliedAlpha = premultipliedAlpha;
|
||||
renderer.draw(skeleton);
|
||||
if (renderer.dispose) renderer.dispose();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
await drawSpineBadge(ctx, cw, ch);
|
||||
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
|
||||
const buf = canvas.toBuffer('image/png');
|
||||
return buf;
|
||||
}
|
||||
|
||||
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
|
||||
@@ -26,11 +26,6 @@
|
||||
"devTools": false,
|
||||
"preview": {
|
||||
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
|
||||
"thumbnail": {
|
||||
"path": "thumbnail/spine.js",
|
||||
"size": 400,
|
||||
"allowZoom": false
|
||||
},
|
||||
"viewer": {
|
||||
"path": "viewer/spine.html"
|
||||
}
|
||||
|
||||
Generated
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
# `@napi-rs/canvas-darwin-arm64`
|
||||
|
||||
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
|
||||
Generated
Vendored
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas-darwin-arm64",
|
||||
"version": "0.1.66",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "skia.darwin-arm64.node",
|
||||
"files": [
|
||||
"skia.darwin-arm64.node"
|
||||
],
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
}
|
||||
}
|
||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
# `@napi-rs/canvas-win32-x64-msvc`
|
||||
|
||||
This is the **x86_64-pc-windows-msvc** binary for `@napi-rs/canvas`
|
||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas-win32-x64-msvc",
|
||||
"version": "0.1.66",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"main": "skia.win32-x64-msvc.node",
|
||||
"files": [
|
||||
"skia.win32-x64-msvc.node",
|
||||
"icudtl.dat"
|
||||
],
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
}
|
||||
}
|
||||
Generated
Vendored
BIN
Binary file not shown.
-21
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 lynweklm@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-361
@@ -1,361 +0,0 @@
|
||||
# `skr canvas`
|
||||
|
||||
[](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
|
||||

|
||||
[](https://packagephobia.com/result?p=@napi-rs/canvas)
|
||||
[](https://npmcharts.com/compare/@napi-rs/canvas?minimal=true)
|
||||
|
||||
> 🚀 Help me to become a full-time open-source developer by [sponsoring me on Github](https://github.com/sponsors/Brooooooklyn)
|
||||
|
||||
Google Skia binding to Node.js via [Node-API](https://napi.rs), **0 System dependencies!**
|
||||
|
||||
⚠️ This project is in pre-release stage. And there may be some bugs.<br/>
|
||||
For details on planned features and future direction please refer to the [Roadmap](https://github.com/Brooooooklyn/canvas/issues/113).
|
||||
|
||||
[中文文档](./README-zh.md)
|
||||
|
||||
# Install
|
||||
|
||||
```bash
|
||||
yarn add @napi-rs/canvas
|
||||
npm install @napi-rs/canvas
|
||||
```
|
||||
|
||||
# Support matrix
|
||||
|
||||
## System requirement
|
||||
|
||||
### `arm64`
|
||||
|
||||
[**_cortex-a57_**](https://en.wikipedia.org/wiki/ARM_Cortex-A57) or newer CPU architecture on **Linux**.
|
||||
|
||||
All Apple M chips on **macOS**.
|
||||
|
||||
### `armv7`
|
||||
|
||||
[**_cortex-a7_**](https://en.wikipedia.org/wiki/ARM_Cortex-A7) or newer CPU architecture.
|
||||
|
||||
### glibc
|
||||
|
||||
Since Skia relies on the [glibc](https://www.gnu.org/software/libc/) 2.18 API, you need to have at least glibc version >= 2.18 on your system.
|
||||
|
||||
## AWS Lambda usage
|
||||
|
||||
To use this library on Lambda you will need to use a Lambda layer.
|
||||
|
||||
You can simply attach a lambda layer by getting an ARN from [Canvas-Lambda-Layer](https://github.com/ShivamJoker/Canvas-Lambda-Layer)
|
||||
|
||||
> Make sure to exclude `@napi-rs/canvas` while bundling your Lambda.
|
||||
|
||||
# Usage
|
||||
|
||||
```js
|
||||
const { promises } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { createCanvas, loadImage } = require('@napi-rs/canvas')
|
||||
|
||||
const canvas = createCanvas(300, 320)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.lineWidth = 10
|
||||
ctx.strokeStyle = '#03a9f4'
|
||||
ctx.fillStyle = '#03a9f4'
|
||||
|
||||
// Wall
|
||||
ctx.strokeRect(75, 140, 150, 110)
|
||||
|
||||
// Door
|
||||
ctx.fillRect(130, 190, 40, 60)
|
||||
|
||||
// Roof
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(50, 140)
|
||||
ctx.lineTo(150, 60)
|
||||
ctx.lineTo(250, 140)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
|
||||
async function main() {
|
||||
// load images from disk or from a URL
|
||||
const catImage = await loadImage('path/to/cat.png')
|
||||
const dogImage = await loadImage('https://example.com/path/to/dog.jpg')
|
||||
|
||||
ctx.drawImage(catImage, 0, 0, catImage.width, catImage.height)
|
||||
|
||||
ctx.drawImage(dogImage, canvas.width / 2, canvas.height / 2, dogImage.width, dogImage.height)
|
||||
|
||||
// export canvas as image
|
||||
const pngData = await canvas.encode('png') // JPEG, AVIF and WebP are also supported
|
||||
// encoding in libuv thread pool, non-blocking
|
||||
await promises.writeFile(join(__dirname, 'simple.png'), pngData)
|
||||
}
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Emoji text
|
||||
|
||||
```js
|
||||
const { writeFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas')
|
||||
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', 'AppleColorEmoji@2x.ttf'), 'Apple Emoji')
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')
|
||||
|
||||
console.info(GlobalFonts.families)
|
||||
|
||||
const canvas = createCanvas(760, 360)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.font = '50px Apple Emoji'
|
||||
ctx.strokeText('😀😃😄😁😆😅😂🤣☺️😊😊😇', 50, 150)
|
||||
|
||||
ctx.font = '100px COLRv1'
|
||||
ctx.fillText('abc', 50, 300)
|
||||
|
||||
const b = canvas.toBuffer('image/png')
|
||||
|
||||
writeFileSync(join(__dirname, 'draw-emoji.png'), b)
|
||||
```
|
||||
|
||||

|
||||
|
||||
# Performance
|
||||
|
||||
See [benchmark](./benchmark) for benchmark code.
|
||||
|
||||
Hardware info:
|
||||
|
||||
```
|
||||
,MMMM. Host - xxxxxxxxxxxxxxxxxxxxxxx
|
||||
.MMMMMM Machine - Mac15,9
|
||||
MMMMM, Kernel - 24.0.0
|
||||
.;MMMMM:' MMMMMMMMMM;. OS - macOS 15.0.1 Sequoia
|
||||
MMMMMMMMMMMMNWMMMMMMMMMMM: DE - Aqua
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMWM. WM - Quartz Compositor
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMM. Packages - 194 (Homebrew), 32 (cargo)
|
||||
;MMMMMMMMMMMMMMMMMMMMMMMM: Shell - zsh
|
||||
:MMMMMMMMMMMMMMMMMMMMMMMM: Terminal - warpterminal (Version v0.2024.10.23.14.49.stable_00)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMM. Resolution - 5120x2880@160fps (as 2560x1440)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMMMM. 2992x1934@120fps (as 1496x967)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMMM. 2232x1512@60fps (as 1116x756)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMM Uptime - 1d 2h 32m
|
||||
;MMMMMMMMMMMMMMMMMMMM. CPU - Apple M3 Max (16)
|
||||
.MMMM,. .MMMM,. CPU Load - 16%
|
||||
Memory - 50.1 GB / 134.2 GB
|
||||
Battery - 78% & Discharging
|
||||
Disk Space - 624.0 GB / 994.7 GB
|
||||
```
|
||||
|
||||
```
|
||||
❯ yarn bench
|
||||
Draw a House and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00' │ '68 ± 0.59%' │ '68' │ 69 │
|
||||
│ 1 │ 'skia-canvas' │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%' │ '48' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50' │ '60 ± 0.62%' │ '61' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
Draw Gradient and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬─────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼─────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '15228495.58 ± 0.53%' │ '15146312.50 ± 1187.50' │ '66 ± 0.48%' │ '66' │ 66 │
|
||||
│ 1 │ 'skia-canvas' │ '21725564.41 ± 2.20%' │ '21412520.50 ± 2104.50' │ '46 ± 1.39%' │ '47' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '17976022.14 ± 1.53%' │ '17563479.50 ± 5104.50' │ '56 ± 1.38%' │ '57' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴─────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
```
|
||||
|
||||
# Features
|
||||
|
||||
## Path2D
|
||||
|
||||
```typescript
|
||||
new Path2D()
|
||||
new Path2D(path: Path2D)
|
||||
// new Path2D('M108.956,403.826c0,0,0.178,3.344-1.276,3.311 c-1.455-0.033-30.507-84.917-66.752-80.957C40.928,326.18,72.326,313.197,108.956,403.826z')
|
||||
new Path2D(path: string)
|
||||
```
|
||||
|
||||
```typescript
|
||||
export interface DOMMatrix2DInit {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
}
|
||||
|
||||
export class Path2D {
|
||||
constructor(path?: Path2D | string)
|
||||
|
||||
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
|
||||
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
|
||||
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
|
||||
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
|
||||
closePath(): void
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
rotation: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
anticlockwise?: boolean,
|
||||
): void
|
||||
lineTo(x: number, y: number): void
|
||||
moveTo(x: number, y: number): void
|
||||
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
|
||||
rect(x: number, y: number, w: number, h: number): void
|
||||
|
||||
// PathKit methods
|
||||
op(path: Path2D, operation: PathOp): Path2D
|
||||
toSVGString(): string
|
||||
getFillType(): FillType
|
||||
getFillTypeString(): string
|
||||
setFillType(type: FillType): void
|
||||
simplify(): Path2D
|
||||
asWinding(): Path2D
|
||||
stroke(stroke?: StrokeOptions): Path2D
|
||||
transform(transform: DOMMatrix2DInit): Path2D
|
||||
getBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
trim(start: number, end: number, isComplement?: boolean): Path2D
|
||||
equals(path: Path2D): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PathKit
|
||||
|
||||
`PathKit` is a toolset for manipulating Path in `Skia`, supporting **_quadratic beziers_**, **_cubic beziers_** and **_conics_**.
|
||||
The main features are.
|
||||
|
||||
### Path Operation
|
||||
|
||||
`.op(path, PathOp)`
|
||||
|
||||
```js
|
||||
const pathOne = new Path2D(
|
||||
'M8 50H92C96.4183 50 100 53.5817 100 58V142C100 146.418 96.4183 150 92 150H8C3.58172 150 0 146.418 0 142V58C0 53.5817 3.58172 50 8 50Z',
|
||||
)
|
||||
const pathTwo = new Path2D(
|
||||
'"M58 0H142C146.418 0 150 3.58172 150 8V92C150 96.4183 146.418 100 142 100H58C53.5817 100 50 96.4183 50 92V8C50 3.58172 53.5817 0 58 0Z',
|
||||
)
|
||||
|
||||
pathOne.op(pathTwo, PathOp.Intersect).toSVGString()
|
||||
// => "M100 100L58 100C53.5817 100 50 96.4183 50 92L50 50L92 50C96.4183 50 100 53.5817 100 58L100 100Z"
|
||||
```
|
||||
|
||||
- **Union**, subtract the op path from the first path
|
||||
- **Difference**, intersect the two paths
|
||||
- **ReverseDifference**, union (inclusive-or) the two paths
|
||||
- **Intersect**, exclusive-or the two paths
|
||||
- **XOR**, subtract the first path from the op path
|
||||
|
||||

|
||||
|
||||
### Covert `FillType` in **_Path_**
|
||||
|
||||
`.asWinding()`
|
||||
|
||||
You can convert `fill-rule="evenodd"` to `fill-rule="nonzero"` in SVG.
|
||||
This is useful for **OpenType** font-related tools, as `fill-rule="nonzero"` is only supported in **OpenType** fonts.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
const pathCircle = new Path2D(
|
||||
'M24.2979 13.6364H129.394V40.9091H24.2979L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455H70.4673V68.1818H16.073C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727V113.636C7.00728 118.657 11.0661 122.727 16.073 122.727H70.4673V150H84.0658V122.727H128.041C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818H84.0658V54.5455H133.927C138.934 54.5455 142.993 50.4755 142.993 45.4545V9.09091C142.993 4.07014 138.934 0 133.927 0H21.9592ZM125.702 109.091H20.6058V81.8182H125.702L135.372 95.4545L125.702 109.091Z',
|
||||
)
|
||||
pathCircle.setFillType(FillType.EvenOdd)
|
||||
pathCircle.asWinding().toSVGString()
|
||||
// => "M24.2979 13.6364L129.394 13.6364L129.394 40.9091L24.2979 40.9091L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455L70.4673 54.5455L70.4673 68.1818L16.073 68.1818C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727L7.00728 113.636C7.00728 118.657 11.0661 122.727 16.073 122.727L70.4673 122.727L70.4673 150L84.0658 150L84.0658 122.727L128.041 122.727C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818L84.0658 68.1818L84.0658 54.5455L133.927 54.5455C138.934 54.5455 142.993 50.4755 142.993 45.4545L142.993 9.09091C142.993 4.07014 138.934 0 133.927 0L21.9592 0ZM125.702 109.091L20.6058 109.091L20.6058 81.8182L125.702 81.8182L135.372 95.4545L125.702 109.091Z"
|
||||
```
|
||||
|
||||
### Simplify **_Path_**
|
||||
|
||||
`.simplify()`
|
||||
|
||||
Set the path to the same non-overlapping contour as the original path area, which means that it can also remove overlapping paths.
|
||||
|
||||
<img width="800" src="./docs/imgs/simplify.png" >
|
||||
|
||||
[SVG with overlapping paths](./docs/imgs/overlapping-path.svg) (Left)
|
||||
|
||||
```js
|
||||
const path =
|
||||
'M2.933,89.89 L89.005,3.818 Q90.412,2.411 92.249,1.65 Q94.087,0.889 96.076,0.889 Q98.065,0.889 99.903,1.65 Q101.741,2.411 103.147,3.818 L189.22,89.89 Q190.626,91.296 191.387,93.134 Q192.148,94.972 192.148,96.961 Q192.148,98.95 191.387,100.788 Q190.626,102.625 189.219,104.032 Q187.813,105.439 185.975,106.2 Q184.138,106.961 182.148,106.961 Q180.159,106.961 178.322,106.2 Q176.484,105.439 175.077,104.032 L89.005,17.96 L96.076,10.889 L103.147,17.96 L17.075,104.032 Q15.668,105.439 13.831,106.2 Q11.993,106.961 10.004,106.961 Q8.015,106.961 6.177,106.2 Q4.339,105.439 2.933,104.032 Q1.526,102.625 0.765,100.788 Q0.004,98.95 0.004,96.961 Q0.004,94.972 0.765,93.134 Q1.526,91.296 2.933,89.89 Z'
|
||||
|
||||
path.simplify().toSVGString()
|
||||
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
|
||||
```
|
||||
|
||||
# [Example](./example/tiger.js)
|
||||
|
||||
> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
|
||||
|
||||
<img width="500" src="example/tiger.png">
|
||||
|
||||
```shell
|
||||
node example/anime-girl.js
|
||||
```
|
||||
|
||||
| SVG | PNG |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <img width="500" src="example/anime-girl.svg"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) | <img width="500" src="example/anime-girl.png"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) |
|
||||
|
||||
# Building
|
||||
|
||||
## Build skia from source
|
||||
|
||||
You can build this project from source, the system requirements are here: https://skia.org/docs/user/build
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Build Skia:
|
||||
$ node scripts/build-skia.js
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build # Here are modules that are used for benchmarking and are hard to install, you can skip it by specifying `--mode=skip-build`
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
|
||||
## Pull pre-build skia binary from GitHub
|
||||
|
||||
You can pull skia pre-build binaries if you just care the `Rust` part:
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Download Skia binaries:
|
||||
# It will pull the binaries match the git hash in `./skia` submodule
|
||||
$ node scripts/release-skia-binary.mjs --download
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
-866
@@ -1,866 +0,0 @@
|
||||
const { inspect } = require('util')
|
||||
/*
|
||||
* vendored in order to fix its dependence on the window global [cds 2020/08/04]
|
||||
* otherwise unchanged from https://github.com/jarek-foksa/geometry-polyfill/tree/f36bbc8f4bc43539d980687904ce46c8e915543d
|
||||
*/
|
||||
|
||||
// @info
|
||||
// DOMPoint polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMPoint
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_point_read_only.cc
|
||||
class DOMPoint {
|
||||
constructor(x = 0, y = 0, z = 0, w = 1) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.z = z
|
||||
this.w = w
|
||||
}
|
||||
|
||||
static fromPoint(otherPoint) {
|
||||
return new DOMPoint(
|
||||
otherPoint.x,
|
||||
otherPoint.y,
|
||||
otherPoint.z !== undefined ? otherPoint.z : 0,
|
||||
otherPoint.w !== undefined ? otherPoint.w : 1,
|
||||
)
|
||||
}
|
||||
|
||||
matrixTransform(matrix) {
|
||||
if ((matrix.is2D || matrix instanceof SVGMatrix) && this.z === 0 && this.w === 1) {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.a + this.y * matrix.c + matrix.e,
|
||||
this.x * matrix.b + this.y * matrix.d + matrix.f,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.m11 + this.y * matrix.m21 + this.z * matrix.m31 + this.w * matrix.m41,
|
||||
this.x * matrix.m12 + this.y * matrix.m22 + this.z * matrix.m32 + this.w * matrix.m42,
|
||||
this.x * matrix.m13 + this.y * matrix.m23 + this.z * matrix.m33 + this.w * matrix.m43,
|
||||
this.x * matrix.m14 + this.y * matrix.m24 + this.z * matrix.m34 + this.w * matrix.m44,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
z: this.z,
|
||||
w: this.w,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMRect polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMRect
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_rect_read_only.cc
|
||||
|
||||
class DOMRect {
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
static fromRect(otherRect) {
|
||||
return new DOMRect(otherRect.x, otherRect.y, otherRect.width, otherRect.height)
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.y
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.x
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.x + this.width
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.y + this.height
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
right: this.right,
|
||||
bottom: this.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of ['top', 'right', 'bottom', 'left']) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMRect.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMRect.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMMatrix polyfill (SVG 2)
|
||||
// @src
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_matrix_read_only.cc
|
||||
// https://github.com/tocharomera/generativecanvas/blob/master/node-canvas/lib/DOMMatrix.js
|
||||
|
||||
const M11 = 0,
|
||||
M12 = 1,
|
||||
M13 = 2,
|
||||
M14 = 3
|
||||
const M21 = 4,
|
||||
M22 = 5,
|
||||
M23 = 6,
|
||||
M24 = 7
|
||||
const M31 = 8,
|
||||
M32 = 9,
|
||||
M33 = 10,
|
||||
M34 = 11
|
||||
const M41 = 12,
|
||||
M42 = 13,
|
||||
M43 = 14,
|
||||
M44 = 15
|
||||
|
||||
const A = M11,
|
||||
B = M12
|
||||
const C = M21,
|
||||
D = M22
|
||||
const E = M41,
|
||||
F = M42
|
||||
|
||||
const DEGREE_PER_RAD = 180 / Math.PI
|
||||
const RAD_PER_DEGREE = Math.PI / 180
|
||||
|
||||
const VALUES = Symbol('values')
|
||||
const IS_2D = Symbol('is2D')
|
||||
|
||||
function parseMatrix(init) {
|
||||
let parsed = init.replace(/matrix\(/, '').split(/,/, 7)
|
||||
|
||||
if (parsed.length !== 6) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
parsed = parsed.map(parseFloat)
|
||||
|
||||
return [parsed[0], parsed[1], 0, 0, parsed[2], parsed[3], 0, 0, 0, 0, 1, 0, parsed[4], parsed[5], 0, 1]
|
||||
}
|
||||
|
||||
function parseMatrix3d(init) {
|
||||
const parsed = init.replace(/matrix3d\(/, '').split(/,/, 17)
|
||||
|
||||
if (parsed.length !== 16) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
return parsed.map(parseFloat)
|
||||
}
|
||||
|
||||
function parseTransform(tform) {
|
||||
const type = tform.split(/\(/, 1)[0]
|
||||
|
||||
if (type === 'matrix') {
|
||||
return parseMatrix(tform)
|
||||
} else if (type === 'matrix3d') {
|
||||
return parseMatrix3d(tform)
|
||||
} else {
|
||||
throw new Error(`${type} parsing not implemented`)
|
||||
}
|
||||
}
|
||||
|
||||
const setNumber2D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const setNumber3D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
if (index === M33 || index === M44) {
|
||||
if (value !== 1) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
} else if (value !== 0) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const newInstance = (values) => {
|
||||
const instance = Object.create(DOMMatrix.prototype)
|
||||
instance.constructor = DOMMatrix
|
||||
instance[IS_2D] = true
|
||||
instance[VALUES] = values
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
const multiply = (first, second) => {
|
||||
const dest = new Float64Array(16)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
let sum = 0
|
||||
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += first[i * 4 + k] * second[k * 4 + j]
|
||||
}
|
||||
|
||||
dest[i * 4 + j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
return dest
|
||||
}
|
||||
|
||||
class DOMMatrix {
|
||||
get m11() {
|
||||
return this[VALUES][M11]
|
||||
}
|
||||
set m11(value) {
|
||||
setNumber2D(this, M11, value)
|
||||
}
|
||||
get m12() {
|
||||
return this[VALUES][M12]
|
||||
}
|
||||
set m12(value) {
|
||||
setNumber2D(this, M12, value)
|
||||
}
|
||||
get m13() {
|
||||
return this[VALUES][M13]
|
||||
}
|
||||
set m13(value) {
|
||||
setNumber3D(this, M13, value)
|
||||
}
|
||||
get m14() {
|
||||
return this[VALUES][M14]
|
||||
}
|
||||
set m14(value) {
|
||||
setNumber3D(this, M14, value)
|
||||
}
|
||||
get m21() {
|
||||
return this[VALUES][M21]
|
||||
}
|
||||
set m21(value) {
|
||||
setNumber2D(this, M21, value)
|
||||
}
|
||||
get m22() {
|
||||
return this[VALUES][M22]
|
||||
}
|
||||
set m22(value) {
|
||||
setNumber2D(this, M22, value)
|
||||
}
|
||||
get m23() {
|
||||
return this[VALUES][M23]
|
||||
}
|
||||
set m23(value) {
|
||||
setNumber3D(this, M23, value)
|
||||
}
|
||||
get m24() {
|
||||
return this[VALUES][M24]
|
||||
}
|
||||
set m24(value) {
|
||||
setNumber3D(this, M24, value)
|
||||
}
|
||||
get m31() {
|
||||
return this[VALUES][M31]
|
||||
}
|
||||
set m31(value) {
|
||||
setNumber3D(this, M31, value)
|
||||
}
|
||||
get m32() {
|
||||
return this[VALUES][M32]
|
||||
}
|
||||
set m32(value) {
|
||||
setNumber3D(this, M32, value)
|
||||
}
|
||||
get m33() {
|
||||
return this[VALUES][M33]
|
||||
}
|
||||
set m33(value) {
|
||||
setNumber3D(this, M33, value)
|
||||
}
|
||||
get m34() {
|
||||
return this[VALUES][M34]
|
||||
}
|
||||
set m34(value) {
|
||||
setNumber3D(this, M34, value)
|
||||
}
|
||||
get m41() {
|
||||
return this[VALUES][M41]
|
||||
}
|
||||
set m41(value) {
|
||||
setNumber2D(this, M41, value)
|
||||
}
|
||||
get m42() {
|
||||
return this[VALUES][M42]
|
||||
}
|
||||
set m42(value) {
|
||||
setNumber2D(this, M42, value)
|
||||
}
|
||||
get m43() {
|
||||
return this[VALUES][M43]
|
||||
}
|
||||
set m43(value) {
|
||||
setNumber3D(this, M43, value)
|
||||
}
|
||||
get m44() {
|
||||
return this[VALUES][M44]
|
||||
}
|
||||
set m44(value) {
|
||||
setNumber3D(this, M44, value)
|
||||
}
|
||||
|
||||
get a() {
|
||||
return this[VALUES][A]
|
||||
}
|
||||
set a(value) {
|
||||
setNumber2D(this, A, value)
|
||||
}
|
||||
get b() {
|
||||
return this[VALUES][B]
|
||||
}
|
||||
set b(value) {
|
||||
setNumber2D(this, B, value)
|
||||
}
|
||||
get c() {
|
||||
return this[VALUES][C]
|
||||
}
|
||||
set c(value) {
|
||||
setNumber2D(this, C, value)
|
||||
}
|
||||
get d() {
|
||||
return this[VALUES][D]
|
||||
}
|
||||
set d(value) {
|
||||
setNumber2D(this, D, value)
|
||||
}
|
||||
get e() {
|
||||
return this[VALUES][E]
|
||||
}
|
||||
set e(value) {
|
||||
setNumber2D(this, E, value)
|
||||
}
|
||||
get f() {
|
||||
return this[VALUES][F]
|
||||
}
|
||||
set f(value) {
|
||||
setNumber2D(this, F, value)
|
||||
}
|
||||
|
||||
get is2D() {
|
||||
return this[IS_2D]
|
||||
}
|
||||
|
||||
get isIdentity() {
|
||||
const values = this[VALUES]
|
||||
|
||||
return (
|
||||
values[M11] === 1 &&
|
||||
values[M12] === 0 &&
|
||||
values[M13] === 0 &&
|
||||
values[M14] === 0 &&
|
||||
values[M21] === 0 &&
|
||||
values[M22] === 1 &&
|
||||
values[M23] === 0 &&
|
||||
values[M24] === 0 &&
|
||||
values[M31] === 0 &&
|
||||
values[M32] === 0 &&
|
||||
values[M33] === 1 &&
|
||||
values[M34] === 0 &&
|
||||
values[M41] === 0 &&
|
||||
values[M42] === 0 &&
|
||||
values[M43] === 0 &&
|
||||
values[M44] === 1
|
||||
)
|
||||
}
|
||||
|
||||
static fromMatrix(init) {
|
||||
if (init instanceof DOMMatrix) {
|
||||
return new DOMMatrix(init[VALUES])
|
||||
} else if (init instanceof SVGMatrix) {
|
||||
return new DOMMatrix([init.a, init.b, init.c, init.d, init.e, init.f])
|
||||
} else {
|
||||
throw new TypeError('Expected DOMMatrix')
|
||||
}
|
||||
}
|
||||
|
||||
static fromFloat32Array(init) {
|
||||
if (!(init instanceof Float32Array)) throw new TypeError('Expected Float32Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
static fromFloat64Array(init) {
|
||||
if (!(init instanceof Float64Array)) throw new TypeError('Expected Float64Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
// @type
|
||||
// (Float64Array) => void
|
||||
constructor(init) {
|
||||
this[IS_2D] = true
|
||||
|
||||
this[VALUES] = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
|
||||
|
||||
// Parse CSS transformList
|
||||
if (typeof init === 'string') {
|
||||
if (init === '') {
|
||||
return
|
||||
} else {
|
||||
const tforms = init.split(/\)\s+/, 20).map(parseTransform)
|
||||
|
||||
if (tforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
init = tforms[0]
|
||||
|
||||
for (let i = 1; i < tforms.length; i++) {
|
||||
init = multiply(tforms[i], init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0
|
||||
|
||||
if (init && init.length === 6) {
|
||||
setNumber2D(this, A, init[i++])
|
||||
setNumber2D(this, B, init[i++])
|
||||
setNumber2D(this, C, init[i++])
|
||||
setNumber2D(this, D, init[i++])
|
||||
setNumber2D(this, E, init[i++])
|
||||
setNumber2D(this, F, init[i++])
|
||||
} else if (init && init.length === 16) {
|
||||
setNumber2D(this, M11, init[i++])
|
||||
setNumber2D(this, M12, init[i++])
|
||||
setNumber3D(this, M13, init[i++])
|
||||
setNumber3D(this, M14, init[i++])
|
||||
setNumber2D(this, M21, init[i++])
|
||||
setNumber2D(this, M22, init[i++])
|
||||
setNumber3D(this, M23, init[i++])
|
||||
setNumber3D(this, M24, init[i++])
|
||||
setNumber3D(this, M31, init[i++])
|
||||
setNumber3D(this, M32, init[i++])
|
||||
setNumber3D(this, M33, init[i++])
|
||||
setNumber3D(this, M34, init[i++])
|
||||
setNumber2D(this, M41, init[i++])
|
||||
setNumber2D(this, M42, init[i++])
|
||||
setNumber3D(this, M43, init[i++])
|
||||
setNumber3D(this, M44, init[i])
|
||||
} else if (init !== undefined) {
|
||||
throw new TypeError('Expected string or array.')
|
||||
}
|
||||
}
|
||||
|
||||
dump() {
|
||||
const mat = this[VALUES]
|
||||
console.info([mat.slice(0, 4), mat.slice(4, 8), mat.slice(8, 12), mat.slice(12, 16)])
|
||||
}
|
||||
|
||||
[inspect.custom](depth, options) {
|
||||
if (depth < 0) return '[DOMMatrix]'
|
||||
|
||||
const { a, b, c, d, e, f, is2D, isIdentity } = this
|
||||
if (this.is2D) {
|
||||
return `DOMMatrix ${inspect({ a, b, c, d, e, f, is2D, isIdentity }, { colors: true })}`
|
||||
} else {
|
||||
const { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44, is2D, isIdentity } = this
|
||||
return `DOMMatrix ${inspect(
|
||||
{
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
e,
|
||||
f,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
is2D,
|
||||
isIdentity,
|
||||
},
|
||||
{ colors: true },
|
||||
)}`
|
||||
}
|
||||
}
|
||||
|
||||
multiply(other) {
|
||||
return newInstance(this[VALUES]).multiplySelf(other)
|
||||
}
|
||||
|
||||
multiplySelf(other) {
|
||||
this[VALUES] = multiply(other[VALUES], this[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
preMultiplySelf(other) {
|
||||
this[VALUES] = multiply(this[VALUES], other[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx, ty, tz) {
|
||||
return newInstance(this[VALUES]).translateSelf(tx, ty, tz)
|
||||
}
|
||||
|
||||
translateSelf(tx = 0, ty = 0, tz = 0) {
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1], this[VALUES])
|
||||
|
||||
if (tz !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
scale(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3d(scale, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scale3dSelf(scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3dSelf(scale, originX, originY, originZ) {
|
||||
return this.scaleSelf(scale, scale, scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
// Not redundant with translate's checks because we need to negate the values later.
|
||||
if (typeof originX !== 'number') originX = 0
|
||||
if (typeof originY !== 'number') originY = 0
|
||||
if (typeof originZ !== 'number') originZ = 0
|
||||
|
||||
this.translateSelf(originX, originY, originZ)
|
||||
|
||||
if (typeof scaleX !== 'number') scaleX = 1
|
||||
if (typeof scaleY !== 'number') scaleY = scaleX
|
||||
if (typeof scaleZ !== 'number') scaleZ = 1
|
||||
|
||||
this[VALUES] = multiply([scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
this.translateSelf(-originX, -originY, -originZ)
|
||||
|
||||
if (scaleZ !== 1 || originZ !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateFromVector(x, y) {
|
||||
return newInstance(this[VALUES]).rotateFromVectorSelf(x, y)
|
||||
}
|
||||
|
||||
rotateFromVectorSelf(x = 0, y = 0) {
|
||||
const theta = x === 0 && y === 0 ? 0 : Math.atan2(y, x) * DEGREE_PER_RAD
|
||||
return this.rotateSelf(theta)
|
||||
}
|
||||
|
||||
rotate(rotX, rotY, rotZ) {
|
||||
return newInstance(this[VALUES]).rotateSelf(rotX, rotY, rotZ)
|
||||
}
|
||||
|
||||
rotateSelf(rotX, rotY, rotZ) {
|
||||
if (rotY === undefined && rotZ === undefined) {
|
||||
rotZ = rotX
|
||||
rotX = rotY = 0
|
||||
}
|
||||
|
||||
if (typeof rotY !== 'number') rotY = 0
|
||||
if (typeof rotZ !== 'number') rotZ = 0
|
||||
|
||||
if (rotX !== 0 || rotY !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
rotX *= RAD_PER_DEGREE
|
||||
rotY *= RAD_PER_DEGREE
|
||||
rotZ *= RAD_PER_DEGREE
|
||||
|
||||
let c = Math.cos(rotZ)
|
||||
let s = Math.sin(rotZ)
|
||||
|
||||
this[VALUES] = multiply([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotY)
|
||||
s = Math.sin(rotY)
|
||||
|
||||
this[VALUES] = multiply([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotX)
|
||||
s = Math.sin(rotX)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateAxisAngle(x, y, z, angle) {
|
||||
return newInstance(this[VALUES]).rotateAxisAngleSelf(x, y, z, angle)
|
||||
}
|
||||
|
||||
rotateAxisAngleSelf(x = 0, y = 0, z = 0, angle = 0) {
|
||||
const length = Math.sqrt(x * x + y * y + z * z)
|
||||
|
||||
if (length === 0) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (length !== 1) {
|
||||
x /= length
|
||||
y /= length
|
||||
z /= length
|
||||
}
|
||||
|
||||
angle *= RAD_PER_DEGREE
|
||||
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
const t = 1 - c
|
||||
const tx = t * x
|
||||
const ty = t * y
|
||||
|
||||
this[VALUES] = multiply(
|
||||
[
|
||||
tx * x + c,
|
||||
tx * y + s * z,
|
||||
tx * z - s * y,
|
||||
0,
|
||||
tx * y - s * z,
|
||||
ty * y + c,
|
||||
ty * z + s * x,
|
||||
0,
|
||||
tx * z + s * y,
|
||||
ty * z - s * x,
|
||||
t * z * z + c,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
],
|
||||
this[VALUES],
|
||||
)
|
||||
|
||||
if (x !== 0 || y !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewX(sx) {
|
||||
return newInstance(this[VALUES]).skewXSelf(sx)
|
||||
}
|
||||
|
||||
skewXSelf(sx) {
|
||||
if (typeof sx !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sx * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, t, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewY(sy) {
|
||||
return newInstance(this[VALUES]).skewYSelf(sy)
|
||||
}
|
||||
|
||||
skewYSelf(sy) {
|
||||
if (typeof sy !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sy * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, t, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
flipX() {
|
||||
return newInstance(multiply([-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
flipY() {
|
||||
return newInstance(multiply([1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
inverse() {
|
||||
return newInstance(this[VALUES]).invertSelf()
|
||||
}
|
||||
|
||||
invertSelf() {
|
||||
if (this[IS_2D]) {
|
||||
const det = this[VALUES][A] * this[VALUES][D] - this[VALUES][B] * this[VALUES][C]
|
||||
|
||||
// Invertable
|
||||
if (det !== 0) {
|
||||
const result = new DOMMatrix()
|
||||
|
||||
result.a = this[VALUES][D] / det
|
||||
result.b = -this[VALUES][B] / det
|
||||
result.c = -this[VALUES][C] / det
|
||||
result.d = this[VALUES][A] / det
|
||||
result.e = (this[VALUES][C] * this[VALUES][F] - this[VALUES][D] * this[VALUES][E]) / det
|
||||
result.f = (this[VALUES][B] * this[VALUES][E] - this[VALUES][A] * this[VALUES][F]) / det
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Not invertable
|
||||
else {
|
||||
this[IS_2D] = false
|
||||
|
||||
this[VALUES] = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
|
||||
}
|
||||
} else {
|
||||
throw new Error('3D matrix inversion is not implemented.')
|
||||
}
|
||||
}
|
||||
|
||||
setMatrixValue(transformList) {
|
||||
const temp = new DOMMatrix(transformList)
|
||||
|
||||
this[VALUES] = temp[VALUES]
|
||||
this[IS_2D] = temp[IS_2D]
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
transformPoint(point) {
|
||||
const x = point.x || 0
|
||||
const y = point.y || 0
|
||||
const z = point.z || 0
|
||||
const w = point.w || 1
|
||||
|
||||
const values = this[VALUES]
|
||||
|
||||
const nx = values[M11] * x + values[M21] * y + values[M31] * z + values[M41] * w
|
||||
const ny = values[M12] * x + values[M22] * y + values[M32] * z + values[M42] * w
|
||||
const nz = values[M13] * x + values[M23] * y + values[M33] * z + values[M43] * w
|
||||
const nw = values[M14] * x + values[M24] * y + values[M34] * z + values[M44] * w
|
||||
|
||||
return new DOMPoint(nx, ny, nz, nw)
|
||||
}
|
||||
|
||||
toFloat32Array() {
|
||||
return Float32Array.from(this[VALUES])
|
||||
}
|
||||
|
||||
toFloat64Array() {
|
||||
return this[VALUES].slice(0)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
a: this.a,
|
||||
b: this.b,
|
||||
c: this.c,
|
||||
d: this.d,
|
||||
e: this.e,
|
||||
f: this.f,
|
||||
m11: this.m11,
|
||||
m12: this.m12,
|
||||
m13: this.m13,
|
||||
m14: this.m14,
|
||||
m21: this.m21,
|
||||
m22: this.m22,
|
||||
m23: this.m23,
|
||||
m24: this.m24,
|
||||
m31: this.m31,
|
||||
m32: this.m32,
|
||||
m33: this.m33,
|
||||
m34: this.m34,
|
||||
m41: this.m41,
|
||||
m42: this.m42,
|
||||
m43: this.m43,
|
||||
m44: this.m44,
|
||||
is2D: this.is2D,
|
||||
isIdentity: this.isIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.is2D) {
|
||||
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`
|
||||
} else {
|
||||
return `matrix3d(${this[VALUES].join(', ')})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'm11',
|
||||
'm12',
|
||||
'm13',
|
||||
'm14',
|
||||
'm21',
|
||||
'm22',
|
||||
'm23',
|
||||
'm24',
|
||||
'm31',
|
||||
'm32',
|
||||
'm33',
|
||||
'm34',
|
||||
'm41',
|
||||
'm42',
|
||||
'm43',
|
||||
'm44',
|
||||
'is2D',
|
||||
'isIdentity',
|
||||
]) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMMatrix.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMMatrix.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
module.exports = { DOMPoint, DOMMatrix, DOMRect }
|
||||
-458
@@ -1,458 +0,0 @@
|
||||
import { ReadableStream } from 'node:stream/web'
|
||||
|
||||
// Clear all type of caches in Skia
|
||||
export function clearAllCache(): void
|
||||
|
||||
export interface DOMMatrix2DInit {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
}
|
||||
|
||||
interface DOMMatrixReadOnly {
|
||||
readonly a: number
|
||||
readonly b: number
|
||||
readonly c: number
|
||||
readonly d: number
|
||||
readonly e: number
|
||||
readonly f: number
|
||||
readonly is2D: boolean
|
||||
readonly isIdentity: boolean
|
||||
readonly m11: number
|
||||
readonly m12: number
|
||||
readonly m13: number
|
||||
readonly m14: number
|
||||
readonly m21: number
|
||||
readonly m22: number
|
||||
readonly m23: number
|
||||
readonly m24: number
|
||||
readonly m31: number
|
||||
readonly m32: number
|
||||
readonly m33: number
|
||||
readonly m34: number
|
||||
readonly m41: number
|
||||
readonly m42: number
|
||||
readonly m43: number
|
||||
readonly m44: number
|
||||
flipX(): DOMMatrix
|
||||
flipY(): DOMMatrix
|
||||
inverse(): DOMMatrix
|
||||
multiply(other?: DOMMatrixInit): DOMMatrix
|
||||
rotate(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
|
||||
rotateAxisAngle(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
|
||||
rotateFromVector(x?: number, y?: number): DOMMatrix
|
||||
scale(
|
||||
scaleX?: number,
|
||||
scaleY?: number,
|
||||
scaleZ?: number,
|
||||
originX?: number,
|
||||
originY?: number,
|
||||
originZ?: number,
|
||||
): DOMMatrix
|
||||
scale3d(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
|
||||
skewX(sx?: number): DOMMatrix
|
||||
skewY(sy?: number): DOMMatrix
|
||||
toFloat32Array(): Float32Array
|
||||
toFloat64Array(): Float64Array
|
||||
transformPoint(point?: DOMPointInit): DOMPoint
|
||||
translate(tx?: number, ty?: number, tz?: number): DOMMatrix
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export interface DOMMatrix extends DOMMatrixReadOnly {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
m11: number
|
||||
m12: number
|
||||
m13: number
|
||||
m14: number
|
||||
m21: number
|
||||
m22: number
|
||||
m23: number
|
||||
m24: number
|
||||
m31: number
|
||||
m32: number
|
||||
m33: number
|
||||
m34: number
|
||||
m41: number
|
||||
m42: number
|
||||
m43: number
|
||||
m44: number
|
||||
invertSelf(): DOMMatrix
|
||||
multiplySelf(other?: DOMMatrixInit): DOMMatrix
|
||||
preMultiplySelf(other?: DOMMatrixInit): DOMMatrix
|
||||
rotateAxisAngleSelf(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
|
||||
rotateFromVectorSelf(x?: number, y?: number): DOMMatrix
|
||||
rotateSelf(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
|
||||
scale3dSelf(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
|
||||
scaleSelf(
|
||||
scaleX?: number,
|
||||
scaleY?: number,
|
||||
scaleZ?: number,
|
||||
originX?: number,
|
||||
originY?: number,
|
||||
originZ?: number,
|
||||
): DOMMatrix
|
||||
setMatrixValue(transformList: string): DOMMatrix
|
||||
skewXSelf(sx?: number): DOMMatrix
|
||||
skewYSelf(sy?: number): DOMMatrix
|
||||
translateSelf(tx?: number, ty?: number, tz?: number): DOMMatrix
|
||||
toJSON(): { [K in OmitNeverOfMatrix]: DOMMatrix[K] }
|
||||
}
|
||||
|
||||
type OmitMatrixMethod = {
|
||||
[K in keyof DOMMatrix]: DOMMatrix[K] extends (...args: any[]) => any ? never : K
|
||||
}
|
||||
|
||||
type OmitNeverOfMatrix = OmitMatrixMethod[keyof OmitMatrixMethod]
|
||||
|
||||
export const DOMMatrix: {
|
||||
prototype: DOMMatrix
|
||||
new (init?: string | number[]): DOMMatrix
|
||||
fromFloat32Array(array32: Float32Array): DOMMatrix
|
||||
fromFloat64Array(array64: Float64Array): DOMMatrix
|
||||
fromMatrix(other?: DOMMatrixInit): DOMMatrix
|
||||
}
|
||||
|
||||
interface DOMRectReadOnly {
|
||||
readonly bottom: number
|
||||
readonly height: number
|
||||
readonly left: number
|
||||
readonly right: number
|
||||
readonly top: number
|
||||
readonly width: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export interface DOMRect extends DOMRectReadOnly {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
toJSON(): Omit<this, 'toJSON' | 'fromRect'>
|
||||
}
|
||||
|
||||
export const DOMRect: {
|
||||
prototype: DOMRect
|
||||
new (x?: number, y?: number, width?: number, height?: number): DOMRect
|
||||
fromRect(other?: DOMRectInit): DOMRect
|
||||
}
|
||||
|
||||
interface DOMPointReadOnly {
|
||||
readonly w: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly z: number
|
||||
matrixTransform(matrix?: DOMMatrixInit): DOMPoint
|
||||
}
|
||||
|
||||
export interface DOMPoint extends DOMPointReadOnly {
|
||||
w: number
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
toJSON(): Omit<DOMPoint, 'matrixTransform' | 'toJSON'>
|
||||
}
|
||||
|
||||
export const DOMPoint: {
|
||||
prototype: DOMPoint
|
||||
new (x?: number, y?: number, z?: number, w?: number): DOMPoint
|
||||
fromPoint(other?: DOMPointInit): DOMPoint
|
||||
}
|
||||
|
||||
export class ImageData {
|
||||
/**
|
||||
* Returns the one-dimensional array containing the data in RGBA order, as integers in the range 0 to 255.
|
||||
*/
|
||||
readonly data: Uint8ClampedArray
|
||||
/**
|
||||
* Returns the actual dimensions of the data in the ImageData object, in pixels.
|
||||
*/
|
||||
readonly height: number
|
||||
/**
|
||||
* Returns the actual dimensions of the data in the ImageData object, in pixels.
|
||||
*/
|
||||
readonly width: number
|
||||
|
||||
constructor(sw: number, sh: number, attr?: { colorSpace?: ColorSpace })
|
||||
constructor(imageData: ImageData, attr?: { colorSpace?: ColorSpace })
|
||||
constructor(data: Uint8ClampedArray, sw: number, sh?: number)
|
||||
}
|
||||
|
||||
export class Image {
|
||||
constructor()
|
||||
// attrs only affects SVG
|
||||
constructor(width: number, height: number, attrs?: { colorSpace?: ColorSpace })
|
||||
width: number
|
||||
height: number
|
||||
readonly naturalWidth: number
|
||||
readonly naturalHeight: number
|
||||
readonly complete: boolean
|
||||
alt: string
|
||||
// the src can be a Uint8Array or a string
|
||||
// if it's a string, it can be a file path, a data URL, a remote URL, or a SVG string
|
||||
src: Uint8Array | string
|
||||
onload?(): void
|
||||
onerror?(err: Error): void
|
||||
}
|
||||
|
||||
export class Path2D {
|
||||
constructor(path?: Path2D | string)
|
||||
|
||||
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
|
||||
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
|
||||
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
|
||||
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
|
||||
closePath(): void
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
rotation: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
anticlockwise?: boolean,
|
||||
): void
|
||||
lineTo(x: number, y: number): void
|
||||
moveTo(x: number, y: number): void
|
||||
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
|
||||
rect(x: number, y: number, w: number, h: number): void
|
||||
roundRect(x: number, y: number, w: number, h: number, radii?: number | number[]): void
|
||||
|
||||
// PathKit methods
|
||||
op(path: Path2D, operation: PathOp): Path2D
|
||||
toSVGString(): string
|
||||
getFillType(): FillType
|
||||
getFillTypeString(): string
|
||||
setFillType(type: FillType): void
|
||||
simplify(): Path2D
|
||||
asWinding(): Path2D
|
||||
stroke(stroke?: StrokeOptions): Path2D
|
||||
transform(transform: DOMMatrix2DInit): Path2D
|
||||
getBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
trim(start: number, end: number, isComplement?: boolean): Path2D
|
||||
dash(on: number, off: number, phase: number): Path2D
|
||||
equals(path: Path2D): boolean
|
||||
}
|
||||
|
||||
export interface StrokeOptions {
|
||||
width?: number
|
||||
miterLimit?: number
|
||||
cap?: StrokeCap
|
||||
join?: StrokeJoin
|
||||
}
|
||||
|
||||
export interface SKRSContext2D
|
||||
extends Omit<
|
||||
CanvasRenderingContext2D,
|
||||
'drawImage' | 'createPattern' | 'getTransform' | 'drawFocusIfNeeded' | 'scrollPathIntoView' | 'canvas'
|
||||
> {
|
||||
canvas: Canvas
|
||||
/**
|
||||
* @param startAngle The angle at which to begin the gradient, in radians. Angle measurements start vertically above the centre and move around clockwise.
|
||||
* @param x The x-axis coordinate of the centre of the gradient.
|
||||
* @param y The y-axis coordinate of the centre of the gradient.
|
||||
*/
|
||||
createConicGradient(startAngle: number, x: number, y: number): CanvasGradient
|
||||
drawImage(image: Image | Canvas, dx: number, dy: number): void
|
||||
drawImage(image: Image | Canvas, dx: number, dy: number, dw: number, dh: number): void
|
||||
drawImage(
|
||||
image: Image | Canvas,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dw: number,
|
||||
dh: number,
|
||||
): void
|
||||
createPattern(
|
||||
image: Image | ImageData,
|
||||
repeat: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | null,
|
||||
): CanvasPattern
|
||||
getContextAttributes(): { alpha: boolean; desynchronized: boolean }
|
||||
getTransform(): DOMMatrix
|
||||
|
||||
letterSpacing: string
|
||||
wordSpacing: string
|
||||
}
|
||||
|
||||
export type ColorSpace = 'srgb' | 'display-p3'
|
||||
|
||||
export interface ContextAttributes {
|
||||
alpha?: boolean
|
||||
colorSpace?: ColorSpace
|
||||
}
|
||||
|
||||
export interface SvgCanvas {
|
||||
width: number
|
||||
height: number
|
||||
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
|
||||
|
||||
getContent(): Buffer
|
||||
}
|
||||
|
||||
export interface AvifConfig {
|
||||
/** 0-100 scale, 100 is lossless */
|
||||
quality?: number
|
||||
/** 0-100 scale */
|
||||
alphaQuality?: number
|
||||
/** rav1e preset 1 (slow) 10 (fast but crappy), default is 4 */
|
||||
speed?: number
|
||||
/** How many threads should be used (0 = match core count) */
|
||||
threads?: number
|
||||
/** set to '4:2:0' to use chroma subsampling, default '4:4:4' */
|
||||
chromaSubsampling?: ChromaSubsampling
|
||||
}
|
||||
/**
|
||||
* https://en.wikipedia.org/wiki/Chroma_subsampling#Types_of_sampling_and_subsampling
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts
|
||||
*/
|
||||
export enum ChromaSubsampling {
|
||||
/**
|
||||
* Each of the three Y'CbCr components has the same sample rate, thus there is no chroma subsampling. This scheme is sometimes used in high-end film scanners and cinematic post-production.
|
||||
* Note that "4:4:4" may instead be wrongly referring to R'G'B' color space, which implicitly also does not have any chroma subsampling (except in JPEG R'G'B' can be subsampled).
|
||||
* Formats such as HDCAM SR can record 4:4:4 R'G'B' over dual-link HD-SDI.
|
||||
*/
|
||||
Yuv444 = 0,
|
||||
/**
|
||||
* The two chroma components are sampled at half the horizontal sample rate of luma: the horizontal chroma resolution is halved. This reduces the bandwidth of an uncompressed video signal by one-third.
|
||||
* Many high-end digital video formats and interfaces use this scheme:
|
||||
* - [AVC-Intra 100](https://en.wikipedia.org/wiki/AVC-Intra)
|
||||
* - [Digital Betacam](https://en.wikipedia.org/wiki/Betacam#Digital_Betacam)
|
||||
* - [Betacam SX](https://en.wikipedia.org/wiki/Betacam#Betacam_SX)
|
||||
* - [DVCPRO50](https://en.wikipedia.org/wiki/DV#DVCPRO) and [DVCPRO HD](https://en.wikipedia.org/wiki/DV#DVCPRO_HD)
|
||||
* - [Digital-S](https://en.wikipedia.org/wiki/Digital-S)
|
||||
* - [CCIR 601](https://en.wikipedia.org/wiki/Rec._601) / [Serial Digital Interface](https://en.wikipedia.org/wiki/Serial_digital_interface) / [D1](https://en.wikipedia.org/wiki/D-1_(Sony))
|
||||
* - [ProRes (HQ, 422, LT, and Proxy)](https://en.wikipedia.org/wiki/Apple_ProRes)
|
||||
* - [XDCAM HD422](https://en.wikipedia.org/wiki/XDCAM)
|
||||
* - [Canon MXF HD422](https://en.wikipedia.org/wiki/Canon_XF-300)
|
||||
*/
|
||||
Yuv422 = 1,
|
||||
/**
|
||||
* n 4:2:0, the horizontal sampling is doubled compared to 4:1:1,
|
||||
* but as the **Cb** and **Cr** channels are only sampled on each alternate line in this scheme, the vertical resolution is halved.
|
||||
* The data rate is thus the same.
|
||||
* This fits reasonably well with the PAL color encoding system, since this has only half the vertical chrominance resolution of [NTSC](https://en.wikipedia.org/wiki/NTSC).
|
||||
* It would also fit extremely well with the [SECAM](https://en.wikipedia.org/wiki/SECAM) color encoding system,
|
||||
* since like that format, 4:2:0 only stores and transmits one color channel per line (the other channel being recovered from the previous line).
|
||||
* However, little equipment has actually been produced that outputs a SECAM analogue video signal.
|
||||
* In general, SECAM territories either have to use a PAL-capable display or a [transcoder](https://en.wikipedia.org/wiki/Transcoding) to convert the PAL signal to SECAM for display.
|
||||
*/
|
||||
Yuv420 = 2,
|
||||
/**
|
||||
* What if the chroma subsampling model is 4:0:0?
|
||||
* That says to use every pixel of luma data, but that each row has 0 chroma samples applied to it. The resulting image, then, is comprised solely of the luminance data—a greyscale image.
|
||||
*/
|
||||
Yuv400 = 3,
|
||||
}
|
||||
|
||||
export class Canvas {
|
||||
constructor(width: number, height: number, flag?: SvgExportFlag)
|
||||
|
||||
width: number
|
||||
height: number
|
||||
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
|
||||
encodeSync(format: 'webp' | 'jpeg', quality?: number): Buffer
|
||||
encodeSync(format: 'png'): Buffer
|
||||
encodeSync(format: 'avif', cfg?: AvifConfig): Buffer
|
||||
encode(format: 'webp' | 'jpeg', quality?: number): Promise<Buffer>
|
||||
encode(format: 'png'): Promise<Buffer>
|
||||
encode(format: 'avif', cfg?: AvifConfig): Promise<Buffer>
|
||||
encodeStream(format: 'webp' | 'jpeg', quality?: number): ReadableStream<Buffer>
|
||||
encodeStream(format: 'png'): ReadableStream<Buffer>
|
||||
toBuffer(mime: 'image/png'): Buffer
|
||||
toBuffer(mime: 'image/jpeg' | 'image/webp', quality?: number): Buffer
|
||||
toBuffer(mime: 'image/avif', cfg?: AvifConfig): Buffer
|
||||
// raw pixels
|
||||
data(): Buffer
|
||||
toDataURL(mime?: 'image/png'): string
|
||||
toDataURL(mime: 'image/jpeg' | 'image/webp', quality?: number): string
|
||||
toDataURL(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): string
|
||||
toDataURL(mime?: 'image/avif', cfg?: AvifConfig): string
|
||||
|
||||
toDataURLAsync(mime?: 'image/png'): Promise<string>
|
||||
toDataURLAsync(mime: 'image/jpeg' | 'image/webp', quality?: number): Promise<string>
|
||||
toDataURLAsync(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): Promise<string>
|
||||
toDataURLAsync(mime?: 'image/avif', cfg?: AvifConfig): Promise<string>
|
||||
}
|
||||
|
||||
export function createCanvas(width: number, height: number): Canvas
|
||||
|
||||
export function createCanvas(width: number, height: number, svgExportFlag: SvgExportFlag): SvgCanvas
|
||||
|
||||
interface IGlobalFonts {
|
||||
readonly families: {
|
||||
family: string
|
||||
styles: {
|
||||
weight: number
|
||||
width: string
|
||||
style: string
|
||||
}[]
|
||||
}[]
|
||||
// return true if succeeded
|
||||
register(font: Buffer, nameAlias?: string): boolean
|
||||
// absolute path
|
||||
registerFromPath(path: string, nameAlias?: string): boolean
|
||||
has(name: string): boolean
|
||||
loadFontsFromDir(path: string): number
|
||||
}
|
||||
|
||||
export const GlobalFonts: IGlobalFonts
|
||||
|
||||
export enum PathOp {
|
||||
Difference = 0, // subtract the op path from the first path
|
||||
Intersect = 1, // intersect the two paths
|
||||
Union = 2, // union (inclusive-or) the two paths
|
||||
Xor = 3, // exclusive-or the two paths
|
||||
ReverseDifference = 4, // subtract the first path from the op path
|
||||
}
|
||||
|
||||
export enum FillType {
|
||||
Winding = 0,
|
||||
EvenOdd = 1,
|
||||
InverseWinding = 2,
|
||||
InverseEvenOdd = 3,
|
||||
}
|
||||
|
||||
export enum StrokeJoin {
|
||||
Miter = 0,
|
||||
Round = 1,
|
||||
Bevel = 2,
|
||||
}
|
||||
|
||||
export enum StrokeCap {
|
||||
Butt = 0,
|
||||
Round = 1,
|
||||
Square = 2,
|
||||
}
|
||||
|
||||
export enum SvgExportFlag {
|
||||
ConvertTextToPaths = 0x01,
|
||||
NoPrettyXML = 0x02,
|
||||
RelativePathEncoding = 0x04,
|
||||
}
|
||||
|
||||
export function convertSVGTextToPath(svg: Buffer | string): Buffer
|
||||
|
||||
export interface LoadImageOptions {
|
||||
alt?: string
|
||||
maxRedirects?: number
|
||||
requestOptions?: import('http').RequestOptions
|
||||
}
|
||||
|
||||
export function loadImage(
|
||||
source: string | URL | Buffer | ArrayBufferLike | Uint8Array | Image | import('stream').Readable,
|
||||
options?: LoadImageOptions,
|
||||
): Promise<Image>
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
const { platform, homedir } = require('os')
|
||||
const { join } = require('path')
|
||||
|
||||
const {
|
||||
clearAllCache,
|
||||
CanvasRenderingContext2D,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
Path: Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
CanvasPattern,
|
||||
GlobalFonts,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeJoin,
|
||||
StrokeCap,
|
||||
convertSVGTextToPath,
|
||||
} = require('./js-binding')
|
||||
|
||||
const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
|
||||
|
||||
const loadImage = require('./load-image')
|
||||
|
||||
const SvgExportFlag = {
|
||||
ConvertTextToPaths: 0x01,
|
||||
NoPrettyXML: 0x02,
|
||||
RelativePathEncoding: 0x04,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-unused-collection
|
||||
const Fonts = []
|
||||
|
||||
if (!('families' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'families', {
|
||||
get: function () {
|
||||
return JSON.parse(GlobalFonts.getFamilies().toString())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!('has' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'has', {
|
||||
value: function has(name) {
|
||||
return !!JSON.parse(GlobalFonts.getFamilies().toString()).find(({ family }) => family === name)
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const _getTransform = CanvasRenderingContext2D.prototype.getTransform
|
||||
|
||||
CanvasRenderingContext2D.prototype.getTransform = function getTransform() {
|
||||
const transform = _getTransform.apply(this, arguments)
|
||||
// monkey patched, skip
|
||||
if (transform instanceof DOMMatrix) {
|
||||
return transform
|
||||
}
|
||||
const { a, b, c, d, e, f } = transform
|
||||
return new DOMMatrix([a, b, c, d, e, f])
|
||||
}
|
||||
|
||||
function createCanvas(width, height, flag) {
|
||||
const isSvgBackend = typeof flag !== 'undefined'
|
||||
return isSvgBackend ? new SVGCanvas(width, height, flag) : new CanvasElement(width, height)
|
||||
}
|
||||
|
||||
class Canvas {
|
||||
constructor(width, height, flag) {
|
||||
return createCanvas(width, height, flag)
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance instanceof CanvasElement || instance instanceof SVGCanvas
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.DISABLE_SYSTEM_FONTS_LOAD) {
|
||||
GlobalFonts.loadSystemFonts()
|
||||
const platformName = platform()
|
||||
const homedirPath = homedir()
|
||||
switch (platformName) {
|
||||
case 'win32':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts'))
|
||||
break
|
||||
case 'darwin':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'Library', 'Fonts'))
|
||||
break
|
||||
case 'linux':
|
||||
GlobalFonts.loadFontsFromDir(join('usr', 'local', 'share', 'fonts'))
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, '.fonts'))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearAllCache,
|
||||
Canvas,
|
||||
createCanvas,
|
||||
Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
SvgExportFlag,
|
||||
GlobalFonts: GlobalFonts,
|
||||
convertSVGTextToPath,
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
loadImage,
|
||||
}
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
const { existsSync, readFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
const { platform, arch } = process
|
||||
|
||||
let nativeBinding = null
|
||||
let localFileExisted = false
|
||||
let loadError = null
|
||||
|
||||
function isMusl() {
|
||||
// For Node 10
|
||||
if (!process.report || typeof process.report.getReport !== 'function') {
|
||||
try {
|
||||
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl')
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const { glibcVersionRuntime } = process.report.getReport().header
|
||||
return !glibcVersionRuntime
|
||||
}
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
switch (arch) {
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.android-arm64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.android-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-android-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.android-arm-eabi.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.android-arm-eabi.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-android-arm-eabi')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Android ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'win32':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.win32-x64-msvc.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.win32-x64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-win32-x64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'ia32':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.win32-ia32-msvc.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.win32-ia32-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-win32-ia32-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.win32-arm64-msvc.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.win32-arm64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-win32-arm64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Windows: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'darwin':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.darwin-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.darwin-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-darwin-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.darwin-arm64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.darwin-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-darwin-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on macOS: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'freebsd':
|
||||
if (arch !== 'x64') {
|
||||
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
|
||||
}
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.freebsd-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.freebsd-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-freebsd-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'linux':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-musl.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-x64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-x64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-gnu.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-x64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-x64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-musl.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-arm64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-arm64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-gnu.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-arm64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-arm64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm-gnueabihf.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-arm-gnueabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-arm-gnueabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'riscv64':
|
||||
localFileExisted = existsSync(join(__dirname, 'skia.linux-riscv64-gnu.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./skia.linux-riscv64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('@napi-rs/canvas-linux-riscv64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Linux: ${arch}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadError) {
|
||||
throw loadError
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const {
|
||||
ChromaSubsampling,
|
||||
SvgExportFlag,
|
||||
CanvasRenderingContext2D,
|
||||
CanvasGradient,
|
||||
ImageData,
|
||||
Image,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
Path,
|
||||
CanvasPattern,
|
||||
convertSVGTextToPath,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
clearAllCache,
|
||||
GlobalFonts,
|
||||
} = nativeBinding
|
||||
|
||||
module.exports.ChromaSubsampling = ChromaSubsampling
|
||||
module.exports.SvgExportFlag = SvgExportFlag
|
||||
module.exports.CanvasRenderingContext2D = CanvasRenderingContext2D
|
||||
module.exports.CanvasGradient = CanvasGradient
|
||||
module.exports.ImageData = ImageData
|
||||
module.exports.Image = Image
|
||||
module.exports.PathOp = PathOp
|
||||
module.exports.FillType = FillType
|
||||
module.exports.StrokeCap = StrokeCap
|
||||
module.exports.StrokeJoin = StrokeJoin
|
||||
module.exports.Path = Path
|
||||
module.exports.CanvasPattern = CanvasPattern
|
||||
module.exports.convertSVGTextToPath = convertSVGTextToPath
|
||||
module.exports.CanvasElement = CanvasElement
|
||||
module.exports.SVGCanvas = SVGCanvas
|
||||
module.exports.clearAllCache = clearAllCache
|
||||
module.exports.GlobalFonts = GlobalFonts
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const { Readable } = require('stream')
|
||||
const { URL } = require('url')
|
||||
|
||||
const { Image } = require('./js-binding')
|
||||
|
||||
let http, https
|
||||
|
||||
const MAX_REDIRECTS = 20
|
||||
const REDIRECT_STATUSES = new Set([301, 302])
|
||||
|
||||
/**
|
||||
* Loads the given source into canvas Image
|
||||
* @param {string|URL|Image|Buffer} source The image source to be loaded
|
||||
* @param {object} options Options passed to the loader
|
||||
*/
|
||||
module.exports = async function loadImage(source, options = {}) {
|
||||
// use the same buffer without copying if the source is a buffer
|
||||
if (Buffer.isBuffer(source) || source instanceof Uint8Array) return createImage(source, options.alt)
|
||||
// load readable stream as image
|
||||
if (source instanceof Readable) return createImage(await consumeStream(source), options.alt)
|
||||
// construct a Uint8Array if the source is ArrayBuffer or SharedArrayBuffer
|
||||
if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer)
|
||||
return createImage(new Uint8Array(source), options.alt)
|
||||
// construct a buffer if the source is buffer-like
|
||||
if (isBufferLike(source)) return createImage(Buffer.from(source), options.alt)
|
||||
// if the source is Image instance, copy the image src to new image
|
||||
if (source instanceof Image) return createImage(source.src, options.alt)
|
||||
// if source is string and in data uri format, construct image using data uri
|
||||
if (typeof source === 'string' && source.trimStart().startsWith('data:')) {
|
||||
const commaIdx = source.indexOf(',')
|
||||
const encoding = source.lastIndexOf('base64', commaIdx) < 0 ? 'utf-8' : 'base64'
|
||||
const data = Buffer.from(source.slice(commaIdx + 1), encoding)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
// if source is a string or URL instance
|
||||
if (typeof source === 'string') {
|
||||
// if the source exists as a file, construct image from that file
|
||||
if ((!source.startsWith('http') && !source.startsWith('https')) && await exists(source)) {
|
||||
return createImage(source, options.alt)
|
||||
} else {
|
||||
// the source is a remote url here
|
||||
source = new URL(source)
|
||||
// attempt to download the remote source and construct image
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
if (source instanceof URL) {
|
||||
if (source.protocol === 'file:') {
|
||||
// remove the leading slash on windows
|
||||
return createImage(process.platform === 'win32' ? source.pathname.substring(1) : source.pathname, options.alt)
|
||||
} else {
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
// throw error as don't support that source
|
||||
throw new TypeError('unsupported image source')
|
||||
}
|
||||
|
||||
function makeRequest(url, resolve, reject, redirectCount, requestOptions) {
|
||||
const isHttps = url.protocol === 'https:'
|
||||
// lazy load the lib
|
||||
const lib = isHttps ? (!https ? (https = require('https')) : https) : !http ? (http = require('http')) : http
|
||||
|
||||
lib
|
||||
.get(url.toString(), requestOptions || {}, (res) => {
|
||||
try {
|
||||
const shouldRedirect = REDIRECT_STATUSES.has(res.statusCode) && typeof res.headers.location === 'string'
|
||||
if (shouldRedirect && redirectCount > 0)
|
||||
return makeRequest(
|
||||
new URL(res.headers.location, url.origin),
|
||||
resolve,
|
||||
reject,
|
||||
redirectCount - 1,
|
||||
requestOptions,
|
||||
)
|
||||
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
return reject(new Error(`remote source rejected with status code ${res.statusCode}`))
|
||||
}
|
||||
|
||||
consumeStream(res).then(resolve, reject)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
.on('error', reject)
|
||||
}
|
||||
|
||||
// use stream/consumers in the future?
|
||||
function consumeStream(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => chunks.push(chunk))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function createImage(src, alt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
if (typeof alt === 'string') image.alt = alt
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = (e) => reject(e)
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function isBufferLike(src) {
|
||||
return (src && src.type === 'Buffer') || Array.isArray(src)
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await fs.promises.access(path, fs.constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas",
|
||||
"version": "0.1.66",
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"files": [
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"geometry.js",
|
||||
"js-binding.js",
|
||||
"load-image.js"
|
||||
],
|
||||
"napi": {
|
||||
"binaryName": "skia",
|
||||
"targets": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"armv7-unknown-linux-gnueabihf",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-linux-android",
|
||||
"riscv64-unknown-linux-gnu"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"artifacts": "napi artifacts",
|
||||
"bench": "node -r @swc-node/register benchmark/bench.ts",
|
||||
"build": "napi build --platform --release --js js-binding.js",
|
||||
"build:debug": "napi build --platform --js js-binding.js",
|
||||
"format": "run-p format:source format:rs format:toml",
|
||||
"format:rs": "cargo fmt",
|
||||
"format:source": "prettier . -w",
|
||||
"format:toml": "taplo format",
|
||||
"lint": "oxlint",
|
||||
"prepublishOnly": "pinst --disable && napi prepublish -t npm",
|
||||
"postpublish": "pinst --enable",
|
||||
"test:ci": "ava -c 1",
|
||||
"test": "ava",
|
||||
"version": "napi version && conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jimp/core": "^1.0.0",
|
||||
"@jimp/custom": "^0.22.10",
|
||||
"@jimp/jpeg": "^0.22.10",
|
||||
"@jimp/png": "^0.22.10",
|
||||
"@napi-rs/cli": "^3.0.0-alpha.70",
|
||||
"@octokit/rest": "^21.0.0",
|
||||
"@swc-node/register": "^1.8.0",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/semver": "^7",
|
||||
"ava": "^6.1.1",
|
||||
"canvas": "^3.0.0",
|
||||
"canvaskit-wasm": "^0.39.1",
|
||||
"colorette": "^2.0.20",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"core-js": "^3.38.0",
|
||||
"echarts": "^5.4.3",
|
||||
"husky": "^9.0.10",
|
||||
"lint-staged": "^15.2.1",
|
||||
"lodash": "^4.17.21",
|
||||
"npm-run-all2": "^7.0.0",
|
||||
"oxlint": "^0.15.0",
|
||||
"pinst": "^3.0.0",
|
||||
"png.js": "^0.2.1",
|
||||
"prettier": "^3.2.5",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"semver": "^7.5.4",
|
||||
"skia-canvas": "^2.0.0",
|
||||
"table": "^6.8.1",
|
||||
"tinybench": "^3.0.6",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.@(js|ts|tsx|yml|yaml|md|json|html)": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.@(js|ts|tsx)": [
|
||||
"oxlint --fix"
|
||||
]
|
||||
},
|
||||
"ava": {
|
||||
"require": [
|
||||
"@swc-node/register",
|
||||
"core-js/proposals/promise-with-resolvers.js"
|
||||
],
|
||||
"extensions": [
|
||||
"ts"
|
||||
],
|
||||
"files": [
|
||||
"__test__/**/*.spec.ts",
|
||||
"scripts/__test__/**/*.spec.ts"
|
||||
],
|
||||
"workerThreads": false,
|
||||
"cache": false,
|
||||
"timeout": "3m",
|
||||
"environmentVariables": {
|
||||
"SWC_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "ava"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.66",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-android-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66"
|
||||
}
|
||||
}
|
||||
+1
-201
@@ -6,207 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.66"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
|
||||
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.66",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.66.tgz",
|
||||
"integrity": "sha512-77Yq9yaUYN90zCovYOpw7LhidJiswU9wLIWWBGF6iiEJyQdt6tkiXpGRZpOMJVO70afkcdc4T7532cxMIBhk0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.66.tgz",
|
||||
"integrity": "sha512-cz3aJ06b8BZGtwRxKTiE0OVUlB17MH8j+BnE4A5+wD9aD1guCCqECsz+k7tpXdAdTAYKRIz2pq6ZuiJ76NyUbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.66.tgz",
|
||||
"integrity": "sha512-szIWqJgFm2OTyGzM+hSiJOaOtjI73VYRC2KN30zZTt7i1+0sgpm5exK5ltDBPOmCdnLt7SbUfpInLj8VvxYlKA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.66.tgz",
|
||||
"integrity": "sha512-h/TZJFc6JLvp8FwbA5mu+yXiblN0iKqshU7xzd6L+ks5uNYgjS7XWLkNiyPQkMaXQgVczOJfZy7r4NSPK3V8Hg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-RGFUdBdi0Xmf+TfwZcB89Ap6hDYh4nzyJhXhNJIgve6ELrIPFhf7sDHvUHxjgW0YzczGoo+ophyCm03cJggu+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.66.tgz",
|
||||
"integrity": "sha512-2cFViDIZ0xQlAHyJmyym+rj3p04V16vgAiz64sCAfwOOiW6e19agv1HQWHUsro3G2lF3PaHGAnp0WRPXGqLOfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-Vm5ZWS2RDPeBpnfx83eJpZfJT07xl0jqp8d83PklKqiDNa3BmDZZ/uuI40/ICgejGLymXXYo5N21b7oAxhRTSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.66.tgz",
|
||||
"integrity": "sha512-/ptGBhErNBCgWff3khtuEjhiiYWf70oWvBPRj8y5EMB0nLYpve7RxxFnavVvxN49kJ0MQHRIwgfyd47RSOOKPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.66.tgz",
|
||||
"integrity": "sha512-XunvXisTkIG+bpq6BcXmsUstoLX3RLS6N9Uz9Pg9RpWIMeM6ObR5shr3NgpGRJq93769I1hS4mJW0DX2Au3WBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.66",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
|
||||
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
"version": "1.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
|
||||
"main": "thumbnail/spine.js",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.66"
|
||||
"scripts": {
|
||||
"check:release": "node tests/release-package.test.js"
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const zlib = require('zlib');
|
||||
const utils = require('./../js/spine-utils.js');
|
||||
const render = require('./../js/spine-render.js');
|
||||
|
||||
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
|
||||
function log(msg) {
|
||||
try {
|
||||
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
|
||||
const logDir = path.join(os.tmpdir(), 'SpineViewer');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, 'thumbnails.log');
|
||||
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const discoveryJobs = new Map();
|
||||
const renderJobs = new Map();
|
||||
|
||||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||
const VERSION = 'v15';
|
||||
|
||||
function coordinationKey(src) {
|
||||
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
|
||||
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
|
||||
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
|
||||
}
|
||||
|
||||
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
|
||||
function assetSignature(assets) {
|
||||
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
|
||||
let atlasText = '';
|
||||
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
|
||||
const pages = utils.parseAtlasPages(atlasText);
|
||||
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
|
||||
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
|
||||
const stats = [];
|
||||
for (const file of paths) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile() || stat.size === 0) return null;
|
||||
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return stats.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
|
||||
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
|
||||
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
|
||||
*/
|
||||
async function runDiscovery(src, retries, delay) {
|
||||
let assets = null;
|
||||
let previousSignature = null;
|
||||
let stableCount = 0;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
|
||||
// 分批拖入的跨批次补齐统一交给后台任务处理。
|
||||
assets = utils.discoverAssets(src, {
|
||||
strictStem: true,
|
||||
allowCrossBatch: false,
|
||||
refreshIndex: i > 0,
|
||||
});
|
||||
const signature = assetSignature(assets);
|
||||
if (signature && signature === previousSignature) stableCount += 1;
|
||||
else stableCount = signature ? 1 : 0;
|
||||
previousSignature = signature;
|
||||
if (stableCount >= 3) return assets;
|
||||
if (i < retries - 1) await sleep(delay);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
|
||||
async function discoverWithRetry(src, retries = 5, delay = 120) {
|
||||
const key = coordinationKey(src);
|
||||
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
|
||||
const job = runDiscovery(src, retries, delay);
|
||||
discoveryJobs.set(key, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
|
||||
async function classifyJsonBriefly(src) {
|
||||
let kind = 'pending';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
kind = utils.classifySkeletonJson(src);
|
||||
if (kind !== 'pending') return kind;
|
||||
if (i < 2) await sleep(80);
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
|
||||
async function renderThumbnailShared(assets, options) {
|
||||
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
|
||||
if (renderJobs.has(key)) {
|
||||
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
|
||||
return renderJobs.get(key);
|
||||
}
|
||||
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
|
||||
const job = render.renderThumbnail(options);
|
||||
renderJobs.set(key, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 格式扩展缩略图处理函数。
|
||||
* 参数:{ src, dest, item }
|
||||
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
|
||||
* - dest : 需要生成的缩略图 PNG 路径
|
||||
* - item : 当前 item 对象(可更新宽高)
|
||||
*/
|
||||
module.exports = async (options) => {
|
||||
const { src, dest, item } = options;
|
||||
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
|
||||
// Eagle originally chooses the cache name before invoking the plugin.
|
||||
// Supply that requested output as well as the corrected compound-name cache.
|
||||
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
|
||||
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
|
||||
const result = await generate(options);
|
||||
if (canonical !== dest && fs.existsSync(dest)) {
|
||||
if (render.canvasLib) {
|
||||
const image = await render.canvasLib.loadImage(dest);
|
||||
const canvas = render.canvasLib.createCanvas(image.width, image.height);
|
||||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||||
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
|
||||
} else fs.copyFileSync(dest, canonical);
|
||||
}
|
||||
if (changed && item.id) {
|
||||
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
async function generate({ src, dest, item }) {
|
||||
try {
|
||||
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
|
||||
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
|
||||
try {
|
||||
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
|
||||
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
|
||||
if (item && item.isDeleted) {
|
||||
log(' skip: item.isDeleted=true');
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
|
||||
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
|
||||
const srcExt = utils.extOf(path.basename(src));
|
||||
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
|
||||
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
|
||||
if (!srcExt) {
|
||||
if (/\.txt$/i.test(src)) {
|
||||
// Eagle's native text card requires item.text, even for an empty file.
|
||||
item.text = '';
|
||||
const fd = fs.openSync(src, 'r');
|
||||
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
|
||||
finally { fs.closeSync(fd); }
|
||||
}
|
||||
await writeNeutralJsonPlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
|
||||
await copyImageAsThumb(src, dest);
|
||||
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
|
||||
// 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。
|
||||
// 这样可以保留 Eagle 或其他 JSON 插件生成的缩略图,避免本插件的回退图覆盖它。
|
||||
if (srcExt === '.json') {
|
||||
const jsonKind = await classifyJsonBriefly(src);
|
||||
if (jsonKind === 'other') {
|
||||
log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src);
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
let assets = await discoverWithRetry(src);
|
||||
|
||||
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
|
||||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
|
||||
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
|
||||
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
let version = utils.detectVersion(assets.skeletonPath);
|
||||
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
|
||||
if (!runtimeDir) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
|
||||
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
|
||||
let png = null;
|
||||
let lastRenderError = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
png = await renderThumbnailShared(assets, {
|
||||
skeletonPath: assets.skeletonPath,
|
||||
atlasPath: assets.atlasPath,
|
||||
texturePaths: assets.texturePaths,
|
||||
version,
|
||||
runtimeDir,
|
||||
size: 400,
|
||||
});
|
||||
break;
|
||||
} catch (e) {
|
||||
lastRenderError = e;
|
||||
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
|
||||
if (attempt === 2) break;
|
||||
await sleep(500);
|
||||
assets = await discoverWithRetry(src, 4, 150);
|
||||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
|
||||
version = utils.detectVersion(assets.skeletonPath);
|
||||
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
}
|
||||
}
|
||||
if (!png) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
throw lastRenderError || new Error('Spine 资源尚未写入完成');
|
||||
}
|
||||
|
||||
fs.writeFileSync(dest, png);
|
||||
|
||||
try {
|
||||
const size = imageSizeOf(dest);
|
||||
item.width = size.width || 400;
|
||||
item.height = size.height || 400;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return Promise.resolve(item);
|
||||
} catch (err) {
|
||||
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
|
||||
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
|
||||
try {
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400;
|
||||
item.height = 400;
|
||||
return resolve(item);
|
||||
} catch (e2) {
|
||||
// 最后兜底:写入一张最小 1x1 PNG
|
||||
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function resolve(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
|
||||
async function writeNeutralJsonPlaceholder(dest) {
|
||||
const size = 400;
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const canvas = render.canvasLib.createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#202126';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = '#555963';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(112, 72);
|
||||
ctx.lineTo(244, 72);
|
||||
ctx.lineTo(288, 116);
|
||||
ctx.lineTo(288, 328);
|
||||
ctx.lineTo(112, 328);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#777c87';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(244, 72);
|
||||
ctx.lineTo(244, 116);
|
||||
ctx.lineTo(288, 116);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
|
||||
}
|
||||
fs.writeFileSync(dest, encodePng(size, size, pixels));
|
||||
}
|
||||
|
||||
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
|
||||
async function copyImageAsThumb(src, dest) {
|
||||
const ext = path.extname(src).toLowerCase();
|
||||
if (ext === '.png') {
|
||||
fs.copyFileSync(src, dest);
|
||||
return;
|
||||
}
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const img = await render.canvasLib.loadImage(src);
|
||||
const w = img.width, h = img.height;
|
||||
const canvas = render.canvasLib.createCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
|
||||
async function writeAtlasFallback(assets, dest) {
|
||||
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
|
||||
const texturePath = Object.values(assets.texturePaths).find((file) => {
|
||||
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
|
||||
});
|
||||
if (!texturePath) return false;
|
||||
try {
|
||||
const img = await render.canvasLib.loadImage(texturePath);
|
||||
const size = 400;
|
||||
const canvas = render.canvasLib.createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
|
||||
await render.drawSpineBadge(ctx, size, size);
|
||||
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('atlas 贴图回退失败: ' + (e && e.message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 PNG 宽高(仅支持 PNG 头部) */
|
||||
function imageSizeOf(file) {
|
||||
const buf = fs.readFileSync(file);
|
||||
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
|
||||
async function writePlaceholder(dest) {
|
||||
const w = 400, h = 400;
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const canvas = render.canvasLib.createCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
await render.drawSpineBadge(ctx, w, h);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through to pure JS encoder */ }
|
||||
}
|
||||
// 深色底
|
||||
const pixels = Buffer.alloc(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
// 简单的斜线纹理,让占位图不单调
|
||||
for (let i = 0; i < w + h; i += 8) {
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const x = Math.min(w - 1, i - k);
|
||||
const y = Math.min(h - 1, k);
|
||||
const idx = (y * w + x) * 4;
|
||||
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(dest, encodePng(w, h, pixels));
|
||||
}
|
||||
|
||||
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
|
||||
function encodePng(width, height, rgba) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const ihdrChunk = chunk('IHDR', ihdr);
|
||||
|
||||
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0; // filter type 0
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
const idat = zlib.deflateSync(raw);
|
||||
const idatChunk = chunk('IDAT', idat);
|
||||
const iend = chunk('IEND', Buffer.alloc(0));
|
||||
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let c, crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = (crc ^ buf[i]) & 0xFF;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crc = (crc >>> 8) ^ c;
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
@@ -38,6 +38,11 @@ for (const [label, files] of [
|
||||
]) {
|
||||
assert.equal(files.some((file) => /\.log$/i.test(file)), false, label + ' must not contain runtime or historical logs');
|
||||
assert.equal(files.some((file) => /(^|\/)\.DS_Store$/i.test(file)), false, label + ' must not contain macOS metadata');
|
||||
assert.equal(files.includes('thumbnail/spine.js'), false, label + ' must not contain an import-time Spine thumbnail handler');
|
||||
assert.equal(files.some((file) => /^node_modules\//.test(file)), false, label + ' must not contain unused native thumbnail dependencies');
|
||||
}
|
||||
|
||||
console.log('PASS: release directory and .eagleplugin contain no logs or .DS_Store files');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
|
||||
assert.equal(Object.values(manifest.preview || {}).some((entry) => entry && entry.thumbnail), false);
|
||||
|
||||
console.log('PASS: release has no logs, metadata, import thumbnail handler, or unused native thumbnail dependencies');
|
||||
@@ -64,14 +64,16 @@ try {
|
||||
fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n');
|
||||
assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
|
||||
|
||||
const thumbnailSource = fs.readFileSync(path.join(__dirname, '../thumbnail/spine.js'), 'utf8');
|
||||
assert.doesNotMatch(thumbnailSource, /setCustomThumbnail|refreshRelatedThumbnails|spine-reconcile-worker|relatedDataPaths/);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../manifest.json'), 'utf8'));
|
||||
const preview = Object.values(manifest.preview || {});
|
||||
assert.equal(preview.some((entry) => entry && entry.thumbnail), false, 'import-time thumbnail handler must stay disabled');
|
||||
assert.equal(fs.existsSync(path.join(__dirname, '../thumbnail/spine.js')), false);
|
||||
assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false);
|
||||
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
|
||||
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/);
|
||||
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
|
||||
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
|
||||
console.log('PASS: atlas traversal, absolute paths, symlink escape, validated Eagle pairing, and no automatic cross-item thumbnail writes');
|
||||
console.log('PASS: atlas path boundaries, validated Eagle pairing, no import thumbnail handler, and manual current-item confirmation');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use strict';
|
||||
const assert = require('assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { normalizeItem } = require('../js/eagle-compound');
|
||||
const generate = require('../thumbnail/spine');
|
||||
const { canvasLib } = require('../js/spine-render');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
|
||||
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '34-43/3.4/spineboy'));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for (const [name, ext, full] of [['lili.atlas', 'txt', 'atlas.txt'], ['lili.skel', 'bytes', 'skel.bytes']]) {
|
||||
const item = { name, ext, text: 'old text' };
|
||||
const source = path.join(root, name + '.' + ext);
|
||||
assert(normalizeItem(source, item));
|
||||
assert.equal(item.name, 'lili'); assert.equal(item.ext, full);
|
||||
assert.equal(path.basename(source), item.name + '.' + item.ext);
|
||||
assert.equal(item.text, undefined); assert.equal(normalizeItem(source, item), false);
|
||||
}
|
||||
for (const text of ['', 'ordinary text\nsecond line']) {
|
||||
const src = path.join(root, 'notes.txt'); fs.writeFileSync(src, text);
|
||||
const result = await generate({ src, dest: path.join(root, 'notes_thumbnail.png'), item: { name: 'notes', ext: 'txt' } });
|
||||
assert.equal(result.text, text); assert.equal(result.ext, 'txt'); assert.equal(result.name, 'notes');
|
||||
}
|
||||
const ordinaryJson = path.join(root, 'config.json');
|
||||
const ordinaryJsonDest = path.join(root, 'config_thumbnail.png');
|
||||
fs.writeFileSync(ordinaryJson, JSON.stringify({ skeleton: { spine: null }, enabled: true }));
|
||||
const ordinaryJsonItem = { name: 'config', ext: 'json' };
|
||||
assert.equal(await generate({ src: ordinaryJson, dest: ordinaryJsonDest, item: ordinaryJsonItem }), ordinaryJsonItem);
|
||||
assert.equal(fs.existsSync(ordinaryJsonDest), false, 'non-Spine JSON must leave thumbnail destination untouched');
|
||||
assert.equal(ordinaryJsonItem.width, undefined); assert.equal(ordinaryJsonItem.height, undefined);
|
||||
for (const [source, target] of [['spineboy-mesh.skel', 'spineboy.skel.bytes'], ['spineboy.atlas', 'spineboy.atlas.txt'], ['spineboy.png', 'spineboy.png']])
|
||||
fs.copyFileSync(path.join(fixtures, source), path.join(root, target));
|
||||
for (const [name, ext] of [['spineboy.atlas', 'txt'], ['spineboy.skel', 'bytes']]) {
|
||||
const src = path.join(root, name + '.' + ext), before = fs.readFileSync(src);
|
||||
const dest = path.join(root, name + '_thumbnail.png');
|
||||
const result = await generate({ src, dest, item: { name, ext } });
|
||||
assert.equal(result.name, 'spineboy'); assert.equal(result.ext, name.split('.')[1] + '.' + ext);
|
||||
assert(before.equals(fs.readFileSync(path.join(root, result.name + '.' + result.ext))));
|
||||
const canonical = path.join(root, 'spineboy_thumbnail.png');
|
||||
assert(fs.existsSync(dest)); assert(fs.existsSync(canonical));
|
||||
assert.equal(fs.readFileSync(canonical).subarray(8,12).toString(), 'WEBP');
|
||||
const image = await canvasLib.loadImage(canonical), canvas = canvasLib.createCanvas(400,400);
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(image,0,0);
|
||||
const pixels = ctx.getImageData(12,356,110,32).data;
|
||||
let white = 0; for (let i = 0; i < pixels.length; i += 4) if (pixels[i]>190 && pixels[i+1]>190 && pixels[i+2]>190) white++;
|
||||
assert(white > 50, 'bottom-left version watermark must be visible');
|
||||
}
|
||||
console.log('PASS: ordinary TXT remains visible, compound metadata round-trip, canonical WebP cache and version watermark');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -1,458 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const zlib = require('zlib');
|
||||
const utils = require('./../js/spine-utils.js');
|
||||
const render = require('./../js/spine-render.js');
|
||||
|
||||
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
|
||||
function log(msg) {
|
||||
try {
|
||||
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
|
||||
const logDir = path.join(os.tmpdir(), 'SpineViewer');
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, 'thumbnails.log');
|
||||
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const discoveryJobs = new Map();
|
||||
const renderJobs = new Map();
|
||||
|
||||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||
const VERSION = 'v15';
|
||||
|
||||
function coordinationKey(src) {
|
||||
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
|
||||
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
|
||||
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
|
||||
}
|
||||
|
||||
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
|
||||
function assetSignature(assets) {
|
||||
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
|
||||
let atlasText = '';
|
||||
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
|
||||
const pages = utils.parseAtlasPages(atlasText);
|
||||
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
|
||||
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
|
||||
const stats = [];
|
||||
for (const file of paths) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile() || stat.size === 0) return null;
|
||||
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return stats.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
|
||||
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
|
||||
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
|
||||
*/
|
||||
async function runDiscovery(src, retries, delay) {
|
||||
let assets = null;
|
||||
let previousSignature = null;
|
||||
let stableCount = 0;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
|
||||
// 分批拖入的跨批次补齐统一交给后台任务处理。
|
||||
assets = utils.discoverAssets(src, {
|
||||
strictStem: true,
|
||||
allowCrossBatch: false,
|
||||
refreshIndex: i > 0,
|
||||
});
|
||||
const signature = assetSignature(assets);
|
||||
if (signature && signature === previousSignature) stableCount += 1;
|
||||
else stableCount = signature ? 1 : 0;
|
||||
previousSignature = signature;
|
||||
if (stableCount >= 3) return assets;
|
||||
if (i < retries - 1) await sleep(delay);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
|
||||
async function discoverWithRetry(src, retries = 5, delay = 120) {
|
||||
const key = coordinationKey(src);
|
||||
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
|
||||
const job = runDiscovery(src, retries, delay);
|
||||
discoveryJobs.set(key, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
|
||||
async function classifyJsonBriefly(src) {
|
||||
let kind = 'pending';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
kind = utils.classifySkeletonJson(src);
|
||||
if (kind !== 'pending') return kind;
|
||||
if (i < 2) await sleep(80);
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
|
||||
async function renderThumbnailShared(assets, options) {
|
||||
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
|
||||
if (renderJobs.has(key)) {
|
||||
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
|
||||
return renderJobs.get(key);
|
||||
}
|
||||
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
|
||||
const job = render.renderThumbnail(options);
|
||||
renderJobs.set(key, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle 格式扩展缩略图处理函数。
|
||||
* 参数:{ src, dest, item }
|
||||
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
|
||||
* - dest : 需要生成的缩略图 PNG 路径
|
||||
* - item : 当前 item 对象(可更新宽高)
|
||||
*/
|
||||
module.exports = async (options) => {
|
||||
const { src, dest, item } = options;
|
||||
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
|
||||
// Eagle originally chooses the cache name before invoking the plugin.
|
||||
// Supply that requested output as well as the corrected compound-name cache.
|
||||
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
|
||||
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
|
||||
const result = await generate(options);
|
||||
if (canonical !== dest && fs.existsSync(dest)) {
|
||||
if (render.canvasLib) {
|
||||
const image = await render.canvasLib.loadImage(dest);
|
||||
const canvas = render.canvasLib.createCanvas(image.width, image.height);
|
||||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||||
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
|
||||
} else fs.copyFileSync(dest, canonical);
|
||||
}
|
||||
if (changed && item.id) {
|
||||
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
async function generate({ src, dest, item }) {
|
||||
try {
|
||||
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
|
||||
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
|
||||
try {
|
||||
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
|
||||
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
|
||||
if (item && item.isDeleted) {
|
||||
log(' skip: item.isDeleted=true');
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
|
||||
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
|
||||
const srcExt = utils.extOf(path.basename(src));
|
||||
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
|
||||
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
|
||||
if (!srcExt) {
|
||||
if (/\.txt$/i.test(src)) {
|
||||
// Eagle's native text card requires item.text, even for an empty file.
|
||||
item.text = '';
|
||||
const fd = fs.openSync(src, 'r');
|
||||
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
|
||||
finally { fs.closeSync(fd); }
|
||||
}
|
||||
await writeNeutralJsonPlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
|
||||
await copyImageAsThumb(src, dest);
|
||||
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
|
||||
// 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。
|
||||
// 这样可以保留 Eagle 或其他 JSON 插件生成的缩略图,避免本插件的回退图覆盖它。
|
||||
if (srcExt === '.json') {
|
||||
const jsonKind = await classifyJsonBriefly(src);
|
||||
if (jsonKind === 'other') {
|
||||
log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src);
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
let assets = await discoverWithRetry(src);
|
||||
|
||||
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
|
||||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
|
||||
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
|
||||
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
let version = utils.detectVersion(assets.skeletonPath);
|
||||
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
|
||||
if (!runtimeDir) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
|
||||
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
|
||||
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
|
||||
let png = null;
|
||||
let lastRenderError = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
png = await renderThumbnailShared(assets, {
|
||||
skeletonPath: assets.skeletonPath,
|
||||
atlasPath: assets.atlasPath,
|
||||
texturePaths: assets.texturePaths,
|
||||
version,
|
||||
runtimeDir,
|
||||
size: 400,
|
||||
});
|
||||
break;
|
||||
} catch (e) {
|
||||
lastRenderError = e;
|
||||
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
|
||||
if (attempt === 2) break;
|
||||
await sleep(500);
|
||||
assets = await discoverWithRetry(src, 4, 150);
|
||||
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
|
||||
version = utils.detectVersion(assets.skeletonPath);
|
||||
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
}
|
||||
}
|
||||
if (!png) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
throw lastRenderError || new Error('Spine 资源尚未写入完成');
|
||||
}
|
||||
|
||||
fs.writeFileSync(dest, png);
|
||||
|
||||
try {
|
||||
const size = imageSizeOf(dest);
|
||||
item.width = size.width || 400;
|
||||
item.height = size.height || 400;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return Promise.resolve(item);
|
||||
} catch (err) {
|
||||
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
|
||||
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
|
||||
try {
|
||||
await writePlaceholder(dest);
|
||||
item.width = 400;
|
||||
item.height = 400;
|
||||
return resolve(item);
|
||||
} catch (e2) {
|
||||
// 最后兜底:写入一张最小 1x1 PNG
|
||||
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function resolve(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
|
||||
async function writeNeutralJsonPlaceholder(dest) {
|
||||
const size = 400;
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const canvas = render.canvasLib.createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#202126';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = '#555963';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(112, 72);
|
||||
ctx.lineTo(244, 72);
|
||||
ctx.lineTo(288, 116);
|
||||
ctx.lineTo(288, 328);
|
||||
ctx.lineTo(112, 328);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#777c87';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(244, 72);
|
||||
ctx.lineTo(244, 116);
|
||||
ctx.lineTo(288, 116);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
|
||||
}
|
||||
fs.writeFileSync(dest, encodePng(size, size, pixels));
|
||||
}
|
||||
|
||||
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
|
||||
async function copyImageAsThumb(src, dest) {
|
||||
const ext = path.extname(src).toLowerCase();
|
||||
if (ext === '.png') {
|
||||
fs.copyFileSync(src, dest);
|
||||
return;
|
||||
}
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const img = await render.canvasLib.loadImage(src);
|
||||
const w = img.width, h = img.height;
|
||||
const canvas = render.canvasLib.createCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
|
||||
async function writeAtlasFallback(assets, dest) {
|
||||
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
|
||||
const texturePath = Object.values(assets.texturePaths).find((file) => {
|
||||
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
|
||||
});
|
||||
if (!texturePath) return false;
|
||||
try {
|
||||
const img = await render.canvasLib.loadImage(texturePath);
|
||||
const size = 400;
|
||||
const canvas = render.canvasLib.createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
|
||||
await render.drawSpineBadge(ctx, size, size);
|
||||
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('atlas 贴图回退失败: ' + (e && e.message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 PNG 宽高(仅支持 PNG 头部) */
|
||||
function imageSizeOf(file) {
|
||||
const buf = fs.readFileSync(file);
|
||||
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
|
||||
async function writePlaceholder(dest) {
|
||||
const w = 400, h = 400;
|
||||
if (render.canvasLib) {
|
||||
try {
|
||||
const canvas = render.canvasLib.createCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e22';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
await render.drawSpineBadge(ctx, w, h);
|
||||
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
|
||||
return;
|
||||
} catch (e) { /* fall through to pure JS encoder */ }
|
||||
}
|
||||
// 深色底
|
||||
const pixels = Buffer.alloc(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
// 简单的斜线纹理,让占位图不单调
|
||||
for (let i = 0; i < w + h; i += 8) {
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const x = Math.min(w - 1, i - k);
|
||||
const y = Math.min(h - 1, k);
|
||||
const idx = (y * w + x) * 4;
|
||||
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(dest, encodePng(w, h, pixels));
|
||||
}
|
||||
|
||||
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
|
||||
function encodePng(width, height, rgba) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const ihdrChunk = chunk('IHDR', ihdr);
|
||||
|
||||
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0; // filter type 0
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
const idat = zlib.deflateSync(raw);
|
||||
const idatChunk = chunk('IDAT', idat);
|
||||
const iend = chunk('IEND', Buffer.alloc(0));
|
||||
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let c, crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = (crc ^ buf[i]) & 0xFF;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crc = (crc >>> 8) ^ c;
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
Reference in new issue
Block a user