leafer-x-psd 0.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +794 -0
- package/dist/psd.mjs +11872 -0
- package/dist/psd.mjs.map +1 -0
- package/dist/psd.umd.cjs +13 -0
- package/dist/psd.umd.cjs.map +1 -0
- package/package.json +60 -0
- package/types/adapter/common.d.ts +33 -0
- package/types/adapter/group.d.ts +8 -0
- package/types/adapter/image.d.ts +11 -0
- package/types/adapter/index.d.ts +36 -0
- package/types/adapter/text.d.ts +12 -0
- package/types/adapter/types.d.ts +17 -0
- package/types/context.d.ts +37 -0
- package/types/decorator/bake.d.ts +14 -0
- package/types/decorator/clipping.d.ts +34 -0
- package/types/decorator/effects.d.ts +25 -0
- package/types/decorator/mask.d.ts +17 -0
- package/types/exporter.d.ts +37 -0
- package/types/index.d.ts +24 -0
- package/types/parser.d.ts +67 -0
- package/types/platform.d.ts +89 -0
- package/types/reader.d.ts +13 -0
- package/types/resources.d.ts +62 -0
- package/types/tree.d.ts +43 -0
- package/types/types.d.ts +338 -0
- package/types/utils/blendMode.d.ts +9 -0
- package/types/utils/box.d.ts +29 -0
- package/types/utils/color.d.ts +9 -0
- package/types/utils/effectParams.d.ts +50 -0
- package/types/utils/font.d.ts +20 -0
- package/types/utils/geometry.d.ts +12 -0
- package/types/utils/limits.d.ts +78 -0
- package/types/utils/pixels.d.ts +11 -0
- package/types/utils/slice.d.ts +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
# leafer-x-psd
|
|
2
|
+
|
|
3
|
+
把 **PSD 文件解析成 leaferjs 的 `Frame`** —— leafer-x 生态插件。
|
|
4
|
+
|
|
5
|
+
只依赖 `@leafer-ui/core` 与 `@leafer-ui/interface`(作为 peer dependency,不打包),
|
|
6
|
+
PSD 解码交给 [ag-psd](https://github.com/Agamnentzar/ag-psd)。
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { Leafer } from 'leafer-ui'
|
|
10
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
11
|
+
|
|
12
|
+
const frame = await psdToFrame(fileInput.files[0])
|
|
13
|
+
new Leafer({ view: window }).add(frame)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm i leafer-x-psd
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
运行时依赖为零 —— `ag-psd` 已经打包进产物,不需要宿主再装(这也让 CDN 全局用法可用)。
|
|
25
|
+
|
|
26
|
+
宿主环境需要先有 Leafer 平台:
|
|
27
|
+
|
|
28
|
+
::: code-group
|
|
29
|
+
|
|
30
|
+
```ts [web]
|
|
31
|
+
import { Leafer } from 'leafer-ui' // 引入即完成平台初始化
|
|
32
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts [node]
|
|
36
|
+
import { useCanvas } from '@leafer-ui/node'
|
|
37
|
+
import { Canvas, loadImage } from '@napi-rs/canvas'
|
|
38
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
39
|
+
|
|
40
|
+
useCanvas('napi', { Canvas, loadImage }) // 初始化 Leafer 的 Node 平台适配
|
|
41
|
+
const frame = await psdToFrame(fs.readFileSync('design.psd'))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
:::
|
|
45
|
+
|
|
46
|
+
> **Node 端为什么必须初始化 Leafer 平台?** ag-psd 是在模块求值时检测 `document`
|
|
47
|
+
> 来决定画布工厂的,浏览器里开箱即用,其他环境则需要有画布来源。而 ag-psd 被打包进
|
|
48
|
+
> 产物后宿主对独立安装的 `ag-psd` 调 `initializeCanvas` 是**无效的**(产物里是另一份
|
|
49
|
+
> 副本)。所以本插件直接从 Leafer 的平台适配 `Platform.origin.createCanvas` 取工厂自动
|
|
50
|
+
> 接上 —— 宿主既然已经能跑 Leafer,这一步就不该再让你操心。
|
|
51
|
+
> 只有自动接管不适用时(例如小程序自定义离屏画布)才需要手动调用插件的
|
|
52
|
+
> [`useCanvas()`](#usecanvas)。
|
|
53
|
+
|
|
54
|
+
CDN 用法(UMD 全局变量遵循 leafer 插件规范 `LeaferX.插件名`):
|
|
55
|
+
|
|
56
|
+
```html
|
|
57
|
+
<script src="https://unpkg.com/leafer-ui"></script>
|
|
58
|
+
<script src="https://unpkg.com/leafer-x-psd"></script>
|
|
59
|
+
<script>
|
|
60
|
+
const { Leafer } = LeaferUI
|
|
61
|
+
const { psdToFrame } = LeaferX.psd
|
|
62
|
+
</script>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `psdToFrame(source, options?) => Promise<Frame>`
|
|
70
|
+
|
|
71
|
+
一步到位:解码 + 构建,返回一个尺寸等于文档、`overflow: 'hide'` 的 `Frame`。
|
|
72
|
+
|
|
73
|
+
`source` 可以是 `File` / `Blob` / `ArrayBuffer` / `Uint8Array` / `Buffer`。
|
|
74
|
+
|
|
75
|
+
**不接受 URL 字符串** —— 插件不发任何网络请求,先自己加载成二进制再传进来:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const frame = await psdToFrame(await (await fetch('/design.psd')).arrayBuffer())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
#### 为什么没有 URL 来源
|
|
82
|
+
|
|
83
|
+
早期版本接受 URL 字符串并在内部 `fetch`,把它去掉是因为那件事**不属于解析**:
|
|
84
|
+
|
|
85
|
+
- **取消语义会变浑**。`options.signal` 本该只管「构建到一半停下来」,一旦内部发请求,
|
|
86
|
+
它就得同时管一个可能已经跑了一半、无法回滚的下载;不接上去又会让「点了取消但还在下载」
|
|
87
|
+
成为默认行为。
|
|
88
|
+
- **超时与重试是宿主的策略**。1 秒超时还是 30 秒、失败重试几次、要不要带鉴权头、
|
|
89
|
+
走不走 Service Worker 缓存 —— 插件给不出通用答案,猜一个只会和宿主的 HTTP 层打架。
|
|
90
|
+
- **少一个 SSRF 面**。宿主常把用户输入直接当参数传下去;插件内部持有 `fetch` 时,
|
|
91
|
+
一句 `psdToFrame(userInput)` 就等于对外开放了一个任意 URL 请求原语。
|
|
92
|
+
- **收益极小**。上面那行 `fetch(...).arrayBuffer()` 已经把这件事说完了。
|
|
93
|
+
|
|
94
|
+
`Blob` / `File` 仍然直接支持,所以浏览器里从 `<input type="file">`、
|
|
95
|
+
拖放、`URL.createObjectURL` 拿到的对象都不用先读成 `ArrayBuffer`。
|
|
96
|
+
|
|
97
|
+
### `new PsdParser(source, options?)`
|
|
98
|
+
|
|
99
|
+
把「解码」与「构建」拆开,适合需要复用或中途取消的场景。
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const parser = new PsdParser(file)
|
|
103
|
+
|
|
104
|
+
await parser.ready // 只解码;可拿到 psd / width / height / layers
|
|
105
|
+
await parser.toFrame() // 需要时再构建,可传入已有 Frame 复用
|
|
106
|
+
parser.abort() // 取消
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `psdToContainer(psd, container, options?) => Promise<IUI>`
|
|
110
|
+
|
|
111
|
+
如果你已经在别处(比如 Worker 里)解码好了 `Psd`,直接用这个把元素树挂到任意容器上。
|
|
112
|
+
|
|
113
|
+
### 自定义图层适配器
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { registerAdapter } from 'leafer-x-psd'
|
|
117
|
+
|
|
118
|
+
registerAdapter({
|
|
119
|
+
name: 'my-smart-object',
|
|
120
|
+
priority: 500, // 越大越先匹配
|
|
121
|
+
match: (layer) => !!layer.placedLayer,
|
|
122
|
+
create: (layer, ctx) => /* 返回 IUI 或 undefined 表示放弃 */,
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
适配器是**按优先级逐个尝试**的:某一层认领了图层却没能创建出元素(返回
|
|
127
|
+
`undefined` 或抛异常),就会继续往下试,最终由兜底的位图适配器接手,同时报出
|
|
128
|
+
`semantic-fallback` 诊断。单个图层的数据异常不会让整份 PSD 解析崩掉。
|
|
129
|
+
|
|
130
|
+
### 可编辑性
|
|
131
|
+
|
|
132
|
+
默认解析结果**只读**。两个选项分别对应两种能力,**互相独立** —— 可以只开一个,也可以都开:
|
|
133
|
+
|
|
134
|
+
| 选项 | 依赖 | 设置的属性 | 能力 |
|
|
135
|
+
| --- | --- | --- | --- |
|
|
136
|
+
| `editable: true` | `@leafer-in/editor` + `@leafer-in/resize` | `editable` | 选中、缩放、旋转、编组;双击进组 |
|
|
137
|
+
| `draggable: true` | 无(`leafer-ui` 自带交互) | `draggable` | 直接拖动元素 |
|
|
138
|
+
|
|
139
|
+
> ⚠️ **`editable` 不会连带设置 `draggable`** —— 实测装了编辑器插件也一样:
|
|
140
|
+
>
|
|
141
|
+
> ```
|
|
142
|
+
> editable:true → draggable = false
|
|
143
|
+
> editable:true → draggable = false (加载 @leafer-in/editor 之后)
|
|
144
|
+
> ```
|
|
145
|
+
>
|
|
146
|
+
> 文档里「editable 包含了 draggable」指的是编辑器自己有拖动实现,而不是这个属性被连带设置。
|
|
147
|
+
> 所以要把解析结果**同时**用在编辑器和纯 `leafer-ui` 里,就得两个选项都开:
|
|
148
|
+
>
|
|
149
|
+
> ```ts
|
|
150
|
+
> psdToFrame(file, { editable: true, draggable: true })
|
|
151
|
+
> ```
|
|
152
|
+
|
|
153
|
+
#### 只要可拖动
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { Leafer } from 'leafer-ui'
|
|
157
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
158
|
+
|
|
159
|
+
const leafer = new Leafer({ view: window })
|
|
160
|
+
leafer.add(await psdToFrame(file, { draggable: true }))
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### 完整编辑器
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npm i @leafer-in/editor @leafer-in/resize
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { App } from 'leafer-ui'
|
|
171
|
+
import '@leafer-in/editor'
|
|
172
|
+
import '@leafer-in/resize'
|
|
173
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
174
|
+
|
|
175
|
+
const app = new App({ view: window, editor: {} })
|
|
176
|
+
app.tree.add(await psdToFrame(file, { editable: true }))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
于是:**单击选中图层 · 拖动移动 · 拖控制点缩放旋转 · 双击进组**。
|
|
180
|
+
再装上 [`@leafer-in/text-editor`](https://www.npmjs.com/package/@leafer-in/text-editor)
|
|
181
|
+
就能双击文字直接改字(文字层要用默认的 `text: 'editable'`)。
|
|
182
|
+
|
|
183
|
+
#### 交互单元怎么划分
|
|
184
|
+
|
|
185
|
+
两个选项共用同一条规则:**可交互性只挂在每个图层最外层的那个元素上**,
|
|
186
|
+
所以一层就是一个单元。容器还会关掉 `hitChildren`。
|
|
187
|
+
|
|
188
|
+
| 图层 | 谁成为交互单元 |
|
|
189
|
+
| --- | --- |
|
|
190
|
+
| 普通像素层 / 文字层 | 该元素本身 |
|
|
191
|
+
| 带蒙版的图层 | 外层包装容器(遮罩与内容不可单独选中) |
|
|
192
|
+
| 剪贴蒙版组 | 整个剪贴组(基底与被裁剪层不可单独选中) |
|
|
193
|
+
| 图层组 | 整个组;拖/选都是整组 |
|
|
194
|
+
|
|
195
|
+
容器关掉 `hitChildren` 是与 leafer 编辑器自己编组时的约定一致的 ——
|
|
196
|
+
`editor.group()` 产出的组同样是 `editable = true` + `hitChildren = false`。
|
|
197
|
+
|
|
198
|
+
> ⚠️ 随之而来的一个差别:`editable` 模式下有编辑器的**双击进组**,所以组内子层仍能被选到;
|
|
199
|
+
> `draggable` 模式没有这个机制,`hitChildren: false` 会把组内子层彻底挡住 ——
|
|
200
|
+
> **拖组就是整组一起动,够不到里面**。需要逐层操作就用 `editable`。
|
|
201
|
+
|
|
202
|
+
配合 `options.meta`(默认开启),每个元素上都带着 `ui.data.psd` 元数据,
|
|
203
|
+
选中后可以直接反查它是哪个 PS 图层。调试台就是这么做的。
|
|
204
|
+
|
|
205
|
+
### 资源与持久化
|
|
206
|
+
|
|
207
|
+
解析出来的每张画布都注册进 Leafer 的全局 `Resource` 表,`Image.url` 是**会话内的资源符**
|
|
208
|
+
(形如 `leafer://psd-plugin-resource-1.png`)。这意味着 **`frame.toJSON()` 出来的结果是不可持久化的** ——
|
|
209
|
+
存库、换个客户端、分享链接,图片全都加载不出来。
|
|
210
|
+
|
|
211
|
+
这是一个真实存在的限制,不是 bug。要在别的会话里用,必须先把资源符换成真实 URL:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { psdToFrame, exportResources, canvasToBlob } from 'leafer-x-psd'
|
|
215
|
+
|
|
216
|
+
const frame = await psdToFrame(file)
|
|
217
|
+
|
|
218
|
+
await exportResources(frame, {
|
|
219
|
+
resolve: async (canvas, info) => {
|
|
220
|
+
// info: { key, kind, layer: { id, name, box, isGroup }, width, height }
|
|
221
|
+
const blob = await canvasToBlob(canvas, 'webp', 0.9)
|
|
222
|
+
const name = `${info.layer?.name ?? 'unnamed'}-${info.kind}.webp`
|
|
223
|
+
return (await myOss.put(`psd/${name}`, blob)).url
|
|
224
|
+
},
|
|
225
|
+
concurrency: 4,
|
|
226
|
+
onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const json = frame.toJSON() // 到这一步才可持久化
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`resolve` 是**唯一必需的**回调 —— 上传到对象存储、转 dataURL、配合 zip 写相对路径,
|
|
233
|
+
都由它决定,插件不预设「上传」这件事:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// 转 dataURL(小文件、想完全自包含)
|
|
237
|
+
resolve: (canvas) => canvas.toDataURL?.('image/png') ?? ''
|
|
238
|
+
|
|
239
|
+
// 写进 zip 包,url 用包内相对路径
|
|
240
|
+
resolve: async (canvas, info) => {
|
|
241
|
+
const name = `${info.layer?.name ?? 'layer'}.png`
|
|
242
|
+
zip.file(name, Buffer.from(await canvasToBuffer(canvas, 'png')))
|
|
243
|
+
return `./assets/${name}`
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**为什么这是独立的一步、而不是 `psdToFrame` 的选项:** 解析目前全程**零编码**
|
|
248
|
+
(画布直通),这是它最大的性能优势;一旦要求上传就必须先编码,优势就没了。
|
|
249
|
+
而且上传会慢、会失败、需要并发限流与重试 —— 那是存储与网络问题,不是解析问题。
|
|
250
|
+
|
|
251
|
+
成功替换后会把旧的 `leafer://` 从 `Resource` 表里**摘掉**,立刻省下一份内存。
|
|
252
|
+
|
|
253
|
+
#### 附带:画布编码工具
|
|
254
|
+
|
|
255
|
+
插件自身不做编码,这两个小工具是给 `resolve` 用的,抹平浏览器与 Node 的差异
|
|
256
|
+
(`toBlob` / `toBuffer` / `convertToBlob` / `toDataURL` 里挑一个能用的):
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
canvasToBlob(canvas, 'webp', 0.9) // → Blob(浏览器上传首选,比 dataURL 少 33% 开销)
|
|
260
|
+
canvasToBuffer(canvas, 'png') // → Uint8Array(Node 写文件 / 传 SDK)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`format` 支持 `'png' | 'webp' | 'jpeg'`,`quality` 是 0~1、只对后两者有效。
|
|
264
|
+
照片类图层用 `webp` 比 `png` 小很多;蒙版是灰度图,`png` 就够了。
|
|
265
|
+
|
|
266
|
+
### 资源回收
|
|
267
|
+
|
|
268
|
+
`Resource` 是一张全局表,本插件注册进去的画布**不会自动释放**。做「能反复打开不同
|
|
269
|
+
PSD」的界面时,旧画布会一直留在表里,内存持续增长。处理完一份 PSD 就释放:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const frame = await psdToFrame(file)
|
|
273
|
+
// ... 用户切换到另一份文件时
|
|
274
|
+
releaseResources(frame) // 返回实际释放的数量
|
|
275
|
+
|
|
276
|
+
releaseAllResources() // 不知道具体清单时的兜底(例如热重载)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`getResources(frame)` 可以读出当前还挂着的资源记录(`exportResources` 用的就是它):
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
for (const { key, kind, layer, width, height } of getResources(frame)) {
|
|
283
|
+
console.log(kind, layer?.name, `${width}x${height}`, key)
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
> ⚠️ 资源记录是挂在 `WeakMap` 上的,**不写进 `ui.data`** —— 记录里带着画布引用,
|
|
288
|
+
> 而 `ui.data` 会被 `toJSON()` 序列化,塞进去会把整张位图吐出来。
|
|
289
|
+
|
|
290
|
+
### `useCanvas(createCanvas, createImageData?)`
|
|
291
|
+
|
|
292
|
+
手动指定 ag-psd 的画布工厂。只在自动接管不适用时才需要(小程序自定义离屏画布等)。
|
|
293
|
+
|
|
294
|
+
### 解析选项
|
|
295
|
+
|
|
296
|
+
| 选项 | 类型 | 默认 | 说明 |
|
|
297
|
+
| --- | --- | --- | --- |
|
|
298
|
+
| `content` | `'raster' \| 'editable'` | `'raster'` | 像素层/形状层内容模式(`'editable'` 目前等价于 `'raster'`,见下) |
|
|
299
|
+
| `text` | `'raster' \| 'editable'` | `'editable'` | 文字层是否还原成可编辑 `Text` |
|
|
300
|
+
| `fontFamily` | `(psdFontName, builtin) => string \| undefined` | 内置别名表 | 覆盖/补充 PS 字体全名到族名的映射 |
|
|
301
|
+
| `shadowBlurScale` | `number` | `0.7` | PS 投影 `size` 与画布 `shadowBlur` 的换算系数(已按真实样例校准) |
|
|
302
|
+
| `mask` | `boolean` | `true` | 图层蒙版(位图 + 矢量) |
|
|
303
|
+
| `vectorMask` | `boolean` | `true` | 矢量蒙版还原成 `Path` 遮罩;关掉则退回 ag-psd 光栅化结果 |
|
|
304
|
+
| `clipping` | `boolean` | `true` | 剪贴蒙版 |
|
|
305
|
+
| `effects` | `boolean` | `true` | 图层效果 |
|
|
306
|
+
| `blendMode` | `boolean` | `true` | 混合模式 |
|
|
307
|
+
| `meta` | `boolean` | `true` | 把 PSD 元数据挂到 `ui.data.psd` |
|
|
308
|
+
| `editable` | `boolean` | `false` | 把元素设为可编辑,配合编辑器插件可直接改(见下) |
|
|
309
|
+
| `draggable` | `boolean` | `false` | 让元素可拖动,**不需要**编辑器插件(见下) |
|
|
310
|
+
| `skipHidden` | `boolean` | `false` | 跳过隐藏图层(否则保留但置为不可见) |
|
|
311
|
+
| `filter` | `(layer) => boolean` | — | 返回 `false` 跳过该图层及其子树 |
|
|
312
|
+
| `fitRoot` | `boolean` | `true` | 根 `Frame` 是否设为文档尺寸并裁剪溢出 |
|
|
313
|
+
| `sliceBudget` | `number` | `12` | 分批构建的时间片预算(ms),`0` 表示不切分 |
|
|
314
|
+
| `onProgress` | `(p) => void` | — | 进度回调 |
|
|
315
|
+
| `onWarning` | `(w) => void` | — | 诊断回调 |
|
|
316
|
+
| `signal` | `AbortSignal` | — | 取消信号 |
|
|
317
|
+
| `readOptions` | `ReadOptions` | — | 透传给 ag-psd `readPsd` |
|
|
318
|
+
|
|
319
|
+
### 诊断回调
|
|
320
|
+
|
|
321
|
+
不支持的 PS 能力不会静默失败,而是通过 `onWarning` 报出来:
|
|
322
|
+
|
|
323
|
+
| code | 含义 |
|
|
324
|
+
| --- | --- |
|
|
325
|
+
| `blend-mode-unsupported` | 该 PS 混合模式在 Leafer 中没有对应实现,已降级 |
|
|
326
|
+
| `effect-unsupported` | 该图层效果无法映射,已丢弃 |
|
|
327
|
+
| `semantic-fallback` | 语义化适配器失败,已降级到下一个适配器(通常是位图) |
|
|
328
|
+
| `text-unsupported` | 文字图层的某项属性尚无对应能力(字符级样式、文字变形、水平缩放、段间距) |
|
|
329
|
+
| `text-leading-suspect` | 文字图层的 `leading` 数值不可信,已退回自动行高 |
|
|
330
|
+
| `mask-skipped` | 蒙版的部分参数(如羽化、反相)未映射 |
|
|
331
|
+
| `empty-layer` | 图层没有可渲染内容,已跳过 |
|
|
332
|
+
| `filtered` | 被你自己的 `filter` 跳过 |
|
|
333
|
+
| `limit-exceeded` | 文件声明的尺寸/效果参数超出配额,该图层或该效果被跳过(见[安全与配额](#安全与配额)) |
|
|
334
|
+
| `read-error` | 读取 PSD 失败 |
|
|
335
|
+
|
|
336
|
+
### 安全与配额
|
|
337
|
+
|
|
338
|
+
**PSD 是不可信输入。** 图层尺寸、蒙版框、图层效果半径全都来自文件内容,而解析发生在
|
|
339
|
+
宿主进程的主线程上、以宿主的权限运行。不加限制的话,一个构造过的文件就能让插件去申请一张
|
|
340
|
+
几万像素见方的画布 —— 那是几百 MB 到几 GB 的一次性分配,进程会直接被打爆。
|
|
341
|
+
|
|
342
|
+
所以**所有画布分配都过一道配额**,收口点在 `platform.createCanvas`(插件自己算出来的画布)
|
|
343
|
+
与解码期的 ag-psd 画布工厂(那些尺寸同样来自文件):
|
|
344
|
+
|
|
345
|
+
| 配额 | 默认 | 说明 |
|
|
346
|
+
| --- | --- | --- |
|
|
347
|
+
| `maxSide` | `16384` | 单边最大像素 —— 浏览器 canvas 的安全上限就是 16384×16384 |
|
|
348
|
+
| `maxPixels` | `32 * 1024 * 1024` | 单张画布最大总像素(约 128MB RGBA) |
|
|
349
|
+
|
|
350
|
+
效果参数另有量级钳制:`size` 上限 1000px(PS 自己的 UI 上限是 250px)、
|
|
351
|
+
`choke` / spread 上限 100%。
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { getCanvasLimits, setCanvasLimits, PsdCanvasLimitError } from 'leafer-x-psd'
|
|
355
|
+
|
|
356
|
+
getCanvasLimits() // { maxSide: 16384, maxPixels: 33554432 }
|
|
357
|
+
setCanvasLimits({ maxSide: 30000 }) // 确实要处理 30000px 的巨幅海报时放宽
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
超限时的行为分两种,**都不会让整份 PSD 解析失败**:
|
|
361
|
+
|
|
362
|
+
- **单个图层/蒙版/效果超限** → 跳过它并报 `limit-exceeded`,其余内容照常渲染
|
|
363
|
+
(例如蒙版画布超限就只丢蒙版、内容还在);
|
|
364
|
+
- **解码期就超限**(文件声明的文档/图层尺寸本身越界)→ 抛 `PsdCanvasLimitError`,
|
|
365
|
+
`readPsdFromSource` 会把它包装成带说明的错误信息,而不是去赌分配能成功。
|
|
366
|
+
|
|
367
|
+
配额是**进程级**设置(`setCanvasLimits` 影响之后的所有解析),因为解码期的画布工厂拿不到
|
|
368
|
+
单次解析的选项。非法值(`NaN`、负数、0)会被忽略,不会把配额设成"什么都不挡"。
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 还原能力矩阵
|
|
373
|
+
|
|
374
|
+
这是本插件最重要的一张表 —— 哪些能真的还原,哪些做不到。
|
|
375
|
+
|
|
376
|
+
| PS 概念 | 还原方式 | 状态 |
|
|
377
|
+
| --- | --- | --- |
|
|
378
|
+
| 图层顺序 | 直接对应 Leafer 子元素顺序 | ✅ |
|
|
379
|
+
| 像素图层 | 烘焙成 `Image` | ✅ 像素级一致 |
|
|
380
|
+
| 图层组 | `Group`,原点取子层并集 | ✅ |
|
|
381
|
+
| 图层蒙版(位图) | 灰度图 + `mask: 'grayscale'` | ✅ |
|
|
382
|
+
| 矢量蒙版 | 贝塞尔路径 → `Path` + `mask: 'path'` | ✅ 可编辑 |
|
|
383
|
+
| 剪贴蒙版 | Leafer 原生 `mask: 'clipping'` | ✅ |
|
|
384
|
+
| 混合模式 | 映射表转 Leafer 命名 | ⚠️ 见下 |
|
|
385
|
+
| 图层不透明度 / 可见性 | `opacity` / `visible` | ✅ |
|
|
386
|
+
| 图层效果:投影/内阴影 | 位图层**烘焙进位图**;文字/形状层 → `shadow`/`innerShadow` | ✅ |
|
|
387
|
+
| 图层效果:描边/颜色叠加/渐变叠加 | 位图层烘焙;文字/形状层 → `stroke`/`fill` | ✅ |
|
|
388
|
+
| 图层效果:外发光/内发光 | 位图层烘焙 | ✅ |
|
|
389
|
+
| 图层效果:斜面浮雕/光泽/图案叠加 | — | ❌ 报 `effect-unsupported` |
|
|
390
|
+
| 文字图层 | `Text`(默认 `text: 'editable'`) | ✅ 亚像素级贴合(见下) |
|
|
391
|
+
| 文字图层(竖排/旋转) | 竖排模拟;旋转来自 `transform` | ✅ |
|
|
392
|
+
| 字体名 | 内置 PS 全名 → 字体族名映射 | ✅ 见下 |
|
|
393
|
+
| 形状图层 | 烘焙成位图 | ❌ 无法矢量化,见下 |
|
|
394
|
+
| 智能对象 | 烘焙成位图 | ❌ 不展开原始内容 |
|
|
395
|
+
| 调整图层 | — | ❌ 跳过并报 `effect-unsupported`(见下) |
|
|
396
|
+
|
|
397
|
+
> **当前进度**:M0 ~ M6 均已完成,三个真实样例的还原度都进入个位数误差并锁定为回归基线。
|
|
398
|
+
> 剩余的已知缺口只有 ag-psd 本身不提供数据的部分(形状路径、智能对象内容、CMYK),
|
|
399
|
+
> 以及斜面浮雕/光泽/图案叠加这三类 Leafer 没有等价能力的效果。
|
|
400
|
+
|
|
401
|
+
### 为什么形状图层不能矢量化
|
|
402
|
+
|
|
403
|
+
ag-psd 的 `LayerAdditionalInfo.pathList` 至今仍是 `// TODO: ...` —— **形状图层的几何路径没有暴露**,
|
|
404
|
+
只有 `vectorFill` / `vectorStroke`(填充与描边样式)而没有路径本身。
|
|
405
|
+
|
|
406
|
+
所以「形状层 → `Path`」这条路的输入数据根本不存在,只能烘焙。唯一例外是**矢量蒙版**,
|
|
407
|
+
它的路径数据(`layer.vectorMask.paths`)是完整可用的,本插件已经用它实现了可编辑的路径遮罩。
|
|
408
|
+
|
|
409
|
+
出于同样的原因,`content: 'editable'` 目前做不到任何事,本插件不会假装它生效,
|
|
410
|
+
而是给出 `semantic-fallback` 诊断说清楚原因。
|
|
411
|
+
|
|
412
|
+
### 为什么不烘焙调整图层
|
|
413
|
+
|
|
414
|
+
调整图层(色阶 / 曲线 / 色相饱和度…)自身没有像素,作用是对**下方内容**做处理。
|
|
415
|
+
Leafer 没有等价的图层混合调整能力,而 ag-psd 给它带的 `canvas` 也**不是**「本层该显示
|
|
416
|
+
什么」。照本位图烘焙会画出错误画面,所以宁可跳过并报诊断,也不产出错的像素。
|
|
417
|
+
|
|
418
|
+
### Leafer 没有的混合模式
|
|
419
|
+
|
|
420
|
+
这些 PS 模式会被降级为 `normal` 并报 `blend-mode-unsupported`:
|
|
421
|
+
|
|
422
|
+
`dissolve` `linear burn` `linear dodge` `darker color` `lighter color`
|
|
423
|
+
`vivid light` `linear light` `pin light` `hard mix` `subtract` `divide`
|
|
424
|
+
|
|
425
|
+
其余模式都能映射 —— 注意 ag-psd 用**空格**命名(`'color burn'`),Leafer 用**连字符**
|
|
426
|
+
(`'color-burn'`),直接强转一定失败,本插件走映射表。
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## 实现要点
|
|
431
|
+
|
|
432
|
+
解析 PSD 的坑大多集中在坐标、顺序和蒙版上。以下是经过实测确认的事实,
|
|
433
|
+
也是本插件与「看起来能跑」的实现的区别所在:
|
|
434
|
+
|
|
435
|
+
### 1. 图层数组顺序不需要反转
|
|
436
|
+
|
|
437
|
+
`psd.children` 是**自下而上**的,正好等于 Leafer 的绘制顺序(先 `add` 的在下面)。
|
|
438
|
+
不需要 `reverse()`,也不需要为了排序去摆弄 `zIndex`。
|
|
439
|
+
|
|
440
|
+
### 2. 所有图层的坐标都是文档绝对坐标 —— 包括组内子层
|
|
441
|
+
|
|
442
|
+
把带绝对坐标的子层直接塞进一个带 `x/y` 的 `Group`,会产生 `group.x + child.x`
|
|
443
|
+
的**双重偏移,层级越深偏得越多**。
|
|
444
|
+
|
|
445
|
+
本插件让每个元素的原点等于「自己那个盒的左上角」,进一层容器就重设一次原点,
|
|
446
|
+
保证绝对坐标只被换算一次。
|
|
447
|
+
|
|
448
|
+
### 3. 图层组的 `right/bottom` 不可信
|
|
449
|
+
|
|
450
|
+
实测:写入 `(40,55,155,165)` 的组读回来会塌缩成 `(40,55,40,55)`(宽高为 0)。
|
|
451
|
+
真实样例里也见到 `组 1` 的盒是 `[0,0,0,0]` 而子层在 `(138,98)` 和 `(280,49)`。
|
|
452
|
+
|
|
453
|
+
所以组边界一律由**子层递归求并集**得到,绝不使用组自身的 `right/bottom`。
|
|
454
|
+
|
|
455
|
+
### 4. 矢量蒙版与位图蒙版可以同时存在
|
|
456
|
+
|
|
457
|
+
PS 允许一个图层同时有矢量蒙版和像素图层蒙版,两者**取交集**。
|
|
458
|
+
按「矢量优先,否则位图」写会静默丢掉一个。本插件把可用的蒙版逐个嵌套套上去
|
|
459
|
+
(Leafer 每个容器只认一个遮罩元素,嵌套容器天然求交)。
|
|
460
|
+
|
|
461
|
+
### 5. `canvas.toDataURL()` 是性能陷阱
|
|
462
|
+
|
|
463
|
+
Leafer 的 `Resource.setImage(key, canvas)` 会把画布存成 `{ url, view }`,
|
|
464
|
+
渲染时**直接使用这张画布**,没有编码、没有 base64、没有二次解码。
|
|
465
|
+
而 `toDataURL()` 每层都要做一次完整 PNG 编码 + base64 展开,再在渲染时解码回来。
|
|
466
|
+
|
|
467
|
+
### 6. 图片 `mask` 用 `grayscale`,不需要逐像素转换
|
|
468
|
+
|
|
469
|
+
Leafer 的 `grayscale` 遮罩语义与 PS 图层蒙版完全一致(白=不透明,黑=透明),
|
|
470
|
+
可以直接把灰度图喂进去,省掉一次全画布的 `getImageData`/`putImageData` 往返。
|
|
471
|
+
|
|
472
|
+
### 7. 分批不能用 `setTimeout(..., 0)`
|
|
473
|
+
|
|
474
|
+
定时器最小间隔约 4ms,1000 层就是 4 秒以上的纯等待,比同步还慢。
|
|
475
|
+
本插件按**时间片预算**分批(默认 12ms 处理满才让出一帧)。
|
|
476
|
+
|
|
477
|
+
### 8. Leafer 的 `writingMode` 只有声明,没有实现
|
|
478
|
+
|
|
479
|
+
`IWritingMode` 是公开类型(`'x' | 'y' | 'x-reverse' | 'y-reverse'`),但 Leafer 2.2 里
|
|
480
|
+
它**只被装饰器注册了属性,没有任何消费它的布局代码**。实测:
|
|
481
|
+
|
|
482
|
+
```
|
|
483
|
+
默认 w=68.91 h=30
|
|
484
|
+
writingMode:'y' w=68.91 h=30 ← 完全一样
|
|
485
|
+
writingMode:'y-reverse' w=68.91 h=30 ← 完全一样
|
|
486
|
+
writingMode:'y' + width w=30 h=90 ← 只是普通折行
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
所以竖排文字得自己模拟:
|
|
490
|
+
|
|
491
|
+
- **拉丁文**:PS 的直排就是整段顺时针旋转 90°(`L` 在顶、`M` 在底),
|
|
492
|
+
所以渲染成横向文本再 `rotation: 90` 即可。
|
|
493
|
+
- **中日韩**:字形保持正向、逐字堆叠,用 `Array.from(text).join('\n')`。
|
|
494
|
+
|
|
495
|
+
还有一个细节:PS 的竖排基线是**列的中线**,而 Leafer 旋转后的基线不在列中间
|
|
496
|
+
(Leafer 的行盒对基线不对称),所以还要沿交叉轴补一个
|
|
497
|
+
`lineHeight / 2 − (lineHeight / 2 + 0.35 × fontSize)` 的偏移,把列心挪回基线。
|
|
498
|
+
|
|
499
|
+
### 9. 文字定位锚在基线上,而不是包围盒上
|
|
500
|
+
|
|
501
|
+
Leafer 的首行基线距盒子顶部的偏移是固定的:
|
|
502
|
+
|
|
503
|
+
```js
|
|
504
|
+
// @leafer-ui/draw 的文本样式计算
|
|
505
|
+
data.__baseLine = lineHeight - (lineHeight - fontSize * 0.7) / 2
|
|
506
|
+
// = lineHeight / 2 + 0.35 * fontSize
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
把 `around` 设为 `{ x: 0, y: 该偏移 }`,就能让「元素内部的基线起点」正好落在元素的
|
|
510
|
+
`(x, y)` 上(Leafer 的 `around` 语义就是「把元素内部的 around 点移动到元素的 x/y」),
|
|
511
|
+
旋转也自然绕基线进行。PS 那边 `text.transform` 的平移量正是基线起点,
|
|
512
|
+
`bounds.left` 是文本起点,两边一对就能精确对齐。
|
|
513
|
+
|
|
514
|
+
### 10. 打包 ag-psd 会让宿主配不上画布
|
|
515
|
+
|
|
516
|
+
ag-psd 是在**模块求值时**检测 `typeof document !== 'undefined'` 来决定画布工厂的:
|
|
517
|
+
|
|
518
|
+
```js
|
|
519
|
+
// ag-psd/dist/helpers.js
|
|
520
|
+
if (typeof document !== 'undefined') {
|
|
521
|
+
exports.createCanvas = (width, height) => { ... document.createElement('canvas') ... }
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
浏览器里这就够了,Node / 小程序里则必须显式 `initializeCanvas`。但 **ag-psd 被本插件
|
|
526
|
+
打包进产物后,产物里有自己的一份副本**,宿主对独立安装的 `ag-psd` 调用
|
|
527
|
+
`initializeCanvas` 影响不到它 —— 表现为「源码里跑得好好的,装成包就报 Canvas not
|
|
528
|
+
initialized」。
|
|
529
|
+
|
|
530
|
+
所以本插件在读取前从 Leafer 的平台适配 `Platform.origin.createCanvas` 自动接管;
|
|
531
|
+
浏览器下直接跳过,交给 ag-psd 自己的 `document` 检测。
|
|
532
|
+
|
|
533
|
+
这个问题只有真正去消费构建产物才会暴露,所以仓库里有一组专门跑 `dist/` 的测试。
|
|
534
|
+
|
|
535
|
+
### 11. 蒙版画布能裁就裁
|
|
536
|
+
|
|
537
|
+
Leafer 的遮罩只在**遮罩元素自身的世界边界内**参与合成(见 `__renderMask` 里的
|
|
538
|
+
`useMask(canvas, maskWorld)`),边界之外的内容是看不见的。
|
|
539
|
+
|
|
540
|
+
而 PS 图层蒙版的 `defaultColor` 表示蒙版框**之外**的默认值:`0` = 黑 = 完全遮住。
|
|
541
|
+
既然框外本来就该看不见,就没必要铺满整张图层 —— 直接把遮罩画布裁到
|
|
542
|
+
「蒙版框 ∩ 图层框」即可。实测 1000×1000 的图层配 200×200 的蒙版:
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
蒙版像素: defaultColor=0 → 40000 (裁剪后)
|
|
546
|
+
defaultColor=255 → 1000000 (框外是白色,必须铺满)
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`defaultColor` 为 `255` 时框外是可见的,遮罩必须覆盖整个图层,此时不做裁剪。
|
|
550
|
+
|
|
551
|
+
### 12. PS 的 `choke` / spread 是百分比,不是像素
|
|
552
|
+
|
|
553
|
+
ag-psd 把 `LayerEffectShadow.choke` 的 `units` 标成了 `Pixels`,**但那是错的** ——
|
|
554
|
+
真实 PSD 里存的是 0~100 的百分比。
|
|
555
|
+
|
|
556
|
+
实测代价很大:把 24 当像素用,投影的扩散范围大 4 倍以上,`test-2.psd` 里
|
|
557
|
+
「img 拷贝」区域的像素差从 **8.2 劣化到 37.0**,整图从 **3.02 劣化到 17.43**。
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
// 正确
|
|
561
|
+
spread = size * choke / 100
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### 13. 位图图层的效果只能自己烘焙
|
|
565
|
+
|
|
566
|
+
PS 的描边、颜色叠加、内阴影、内发光都是沿图层 **alpha 轮廓** 生效的。
|
|
567
|
+
Leafer 这边只有 `shadow` 对 `Image` 会跟随轮廓(实测确认),而:
|
|
568
|
+
|
|
569
|
+
- `stroke` 对 `Image` 描的是**包围盒**,不是轮廓
|
|
570
|
+
- `fill` 对 `Image` **根本不能用** —— `Image` 内部就用 `fill` 承载图片画笔
|
|
571
|
+
|
|
572
|
+
所以对像素图层,效果必须按 alpha 自己合成(见 `decorator/bake.ts`)。
|
|
573
|
+
canvas 提供的三件工具刚好够用:`shadowBlur`/`shadowOffset`、`source-in` /
|
|
574
|
+
`destination-in` / `destination-out` 合成、以及 `ctx.filter = 'blur(Npx)'`。
|
|
575
|
+
|
|
576
|
+
两处容易写错的地方(都踩过):
|
|
577
|
+
|
|
578
|
+
- **腐蚀 ≠ 膨胀**。向内描边要的是 `内容 − erode(内容)`,而腐蚀是「所有平移副本的
|
|
579
|
+
**交集**」;用并集(膨胀)去挖空会把整层填成描边色。
|
|
580
|
+
- **补白与内容是两套坐标系**。叠加层若返回补白尺寸的画布,内容会被推移一个 padding。
|
|
581
|
+
|
|
582
|
+
### 14. 模糊半径系数是对着参考图量出来的
|
|
583
|
+
|
|
584
|
+
PS 的 `size` 是高斯模糊半径,画布 `shadowBlur` 则满足 σ = `shadowBlur` / 2,
|
|
585
|
+
两者物理含义不等价,各处的经验换算说法相差很大(约 0.67× ~ 2×)。
|
|
586
|
+
|
|
587
|
+
所以直接做参数扫描:把烘焙结果叠到白底上,与参考合成图逐像素比较,扫遍
|
|
588
|
+
`0.35 ~ 1.0`。结果是 **0.55 ~ 0.75 一个平坦的谷底,取 0.70**。
|
|
589
|
+
|
|
590
|
+
手算也吻合:参考图的投影衰减曲线拟合出 σ ≈ 6.33px,`2σ / 17(PS size) ≈ 0.745`。
|
|
591
|
+
两条独立路径落在同一区间,不是过拟合。扫描有回归护栏(`__tests__/calibrate.test.ts`)。
|
|
592
|
+
|
|
593
|
+
### 15. 字体必须先归一化,否则等于没设字体
|
|
594
|
+
|
|
595
|
+
PS 存的是字体**全名**(`AdobeHeitiStd-Regular`、`MicrosoftYaHei`),系统认的是
|
|
596
|
+
**字体族名**(`Adobe Heiti Std`、`Microsoft YaHei`)。全名匹配不上会静默回退到默认字体。
|
|
597
|
+
|
|
598
|
+
实测这台机器上**装了 `Adobe Heiti Std`**,但用全名 `AdobeHeitiStd-Regular` 仍然回退,
|
|
599
|
+
文字宽度差 **16.42px**;换成族名后降到 **0.42px**。旋转 45° 的那层从
|
|
600
|
+
`dw=+10.98` 降到 **`dw=-0.02`**。
|
|
601
|
+
|
|
602
|
+
所以内置了一张别名表(`utils/font.ts`),并导出 `toFontFamily()` 供宿主复用。
|
|
603
|
+
这里刻意**不做通用的驼峰拆词**:`MicrosoftYaHei` 拆成 `Microsoft Ya Hei` 是错的
|
|
604
|
+
(`YaHei` 是一个词),猜错会命中一个完全无关的字体,比回退到系统默认更糟。
|
|
605
|
+
查不到就原样返回,不猜。
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## 验证
|
|
610
|
+
|
|
611
|
+
还原度用真实 PSD 对着 **ag-psd 合成的参考图**做像素级比对,而不是靠肉眼。
|
|
612
|
+
`__tests__/fidelity.test.ts` 会自动遍历仓库根 `samples/` 下的全部样例。
|
|
613
|
+
|
|
614
|
+
```sh
|
|
615
|
+
npm test
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
三个样例的实测结果(平均通道差 / 255,越小越好):
|
|
619
|
+
|
|
620
|
+
| 样例 | 内容 | `text: 'raster'` | `text: 'editable'` |
|
|
621
|
+
| --- | --- | --- | --- |
|
|
622
|
+
| `test-1.psd` | 像素层、位图+矢量双蒙版、横排/竖排文字、图层组、形状层、负坐标超界 | 1.039 | **0.990** |
|
|
623
|
+
| `test-2.psd` | 剪贴蒙版、正片叠底/变亮、投影、双描边、内阴影、内发光、双蒙版 | 3.022 | 3.022 |
|
|
624
|
+
| `test-text.psd` | 横排、旋转 45°、旋转 -31.79°、竖排、微软雅黑、楷体、三行段落 | **1.200** | 1.773 |
|
|
625
|
+
|
|
626
|
+
**全部样例最优位移都是 (0,0)** —— 任何 1px 平移都会让差异明显劣化,说明不存在
|
|
627
|
+
系统性偏移。位移诊断很关键:整体偏移也会让差异只出现在边缘上,从统计数字里看不出来。
|
|
628
|
+
|
|
629
|
+
注意 `test-1` 上**语义化文字模式比烘焙模式更准**(0.990 vs 1.039):用真字体清晰渲染,
|
|
630
|
+
比复用 PS 烘焙的位图更贴合参考图。`test-text` 反过来(1.773 vs 1.200),因为它是纯文字
|
|
631
|
+
样例,烘焙模式直接复用了 PS 自己的文字位图,而语义化模式要重排一遍。
|
|
632
|
+
|
|
633
|
+
### 文字逐层精度
|
|
634
|
+
|
|
635
|
+
```
|
|
636
|
+
样例 test-text.psd(全部 7 个文字层):
|
|
637
|
+
图层「Lorem Ipsum」 rotation=0° dx=-0.06 dy=0.09 dw=+0.42 dh=-0.19
|
|
638
|
+
图层「…旋转45.00」 rotation=45° dx=-0.47 dy=0.34 dw=-0.02 dh=-0.02
|
|
639
|
+
图层「…竖排文字」 vertical dx=+0.61 dy=-0.06 dw=-0.19 dh=+0.42
|
|
640
|
+
图层「…微软雅黑」 rotation=0° dx=-1.00 dy=-1.00 dw=+1.22 dh=+1.00
|
|
641
|
+
图层「…微软雅黑; 旋转-31.79」 rotation=-31.79° dx=-0.82 dy=-1.04 dw=+1.56 dh=+1.12
|
|
642
|
+
图层「…楷体」 rotation=0° dx=-0.27 dy=-0.22 dw=+0.53 dh=-0.47
|
|
643
|
+
图层「Lorem Ipsu...段落」 3 行段落 dx=-0.27 dy=-0.22 dw=+0.53 dh=-0.47
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
定位普遍在 **1px 以内**;旋转 45° 的那层尺寸误差只有 **0.02px**,负角度(-31.79°)也在
|
|
647
|
+
1px 出头。竖排是靠「整段旋转 90°」模拟的(Leafer 的 `writingMode` 没有实现)。
|
|
648
|
+
|
|
649
|
+
最后一行是**三行段落**(`Lorem Ipsum\nLorem Ipsum\nLorem Ipsum`,楷体):尺寸误差
|
|
650
|
+
`dh=-0.47`,说明行高这条路径在真实多行样例上也是贴合的,哪怕它的 `leading` 数值同样
|
|
651
|
+
被判为不可信、走的是自动行高分支(见「已知限制」)。
|
|
652
|
+
|
|
653
|
+
### 效果逐区域精度(test-2.psd)
|
|
654
|
+
|
|
655
|
+
```
|
|
656
|
+
整图 3.02
|
|
657
|
+
「img 拷贝」 投影+双描边 4.77 ← 修复前 45.26
|
|
658
|
+
「正片叠底」 正片叠底 1.74
|
|
659
|
+
「变亮」 变亮 2.37
|
|
660
|
+
「img 拷贝 2」 内阴影+内发光 2.89
|
|
661
|
+
「img 拷贝 3」 位图+矢量双蒙版 2.61
|
|
662
|
+
「img」 剪贴蒙版 2.59
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
测试输出(渲染图 / 参考图 / 差异可视化 / 分区域并排图)写在 `__tests__/__output__/`。
|
|
666
|
+
|
|
667
|
+
### 性能
|
|
668
|
+
|
|
669
|
+
瓶颈在 ag-psd 解码,不在本插件。真实样例(500×500,7 个顶层图层)的拆解:
|
|
670
|
+
|
|
671
|
+
```
|
|
672
|
+
ag-psd 解码 : 34 ms (91%)
|
|
673
|
+
元素树构建 : 3 ms ( 9%)
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
元素树构建本身极便宜(合成基准里 500 个图层约 5 ms,合 **0.010 ms/层**),
|
|
677
|
+
所以本插件没有引入额外的规模瓶颈。
|
|
678
|
+
|
|
679
|
+
> ⚠️ `readPsd` 是同步阻塞的,那 91% 会实打实卡住主线程。本插件不做 Worker 隔离
|
|
680
|
+
> (按既定方案),但你可以自己在 Worker 里调用 `readPsdFromSource` /
|
|
681
|
+
> `psdToContainer` 来规避。
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## 开发
|
|
686
|
+
|
|
687
|
+
这个仓库是 npm workspace:插件包在 `packages/psd`,调试台在 `playground`,
|
|
688
|
+
真实 PSD 样例在仓库根的 `samples/`(测试与调试台共用一份)。
|
|
689
|
+
|
|
690
|
+
命令建议在**仓库根**执行:
|
|
691
|
+
|
|
692
|
+
```sh
|
|
693
|
+
npm run dev # 启动调试台(http://localhost:5173)
|
|
694
|
+
npm test # 186 个测试(含跑构建产物的冒烟测试)
|
|
695
|
+
npm run typecheck
|
|
696
|
+
npm run build # 打包 ESM + UMD,并生成 types/
|
|
697
|
+
npm run verify:artifact # 构建 + 校验产物(npm publish 前会自动跑)
|
|
698
|
+
npm run pack # 构建 + 校验 + npm pack,检查会发布哪些文件
|
|
699
|
+
|
|
700
|
+
# 命令行查看一个 PSD 的图层结构(排查偏差的第一手段)
|
|
701
|
+
node packages/psd/scripts/inspect-psd.mjs samples/test-1.psd
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
只在插件包内工作的话,也可以 `cd packages/psd` 后直接跑上述命令(去掉 `-w` 相关的部分)。
|
|
705
|
+
|
|
706
|
+
> **`dist/` 与 `types/` 不提交进 git**,它们是 `npm run build` 的产物。
|
|
707
|
+
> 因此发布前必须先构建 —— 这件事由包里的 `prepublishOnly` 钩子自动完成,
|
|
708
|
+
> 并且会顺带跑一遍 `test:artifact`(校验 `main`/`module`/`types`/`unpkg`/`exports`
|
|
709
|
+
> 指向的文件真实存在、ESM 与 UMD 两条消费路径可用)。
|
|
710
|
+
> 详见仓库根 README 的「发布」一节。
|
|
711
|
+
|
|
712
|
+
### 调试台怎么引用这个包
|
|
713
|
+
|
|
714
|
+
调试台里写的是**真实包名**:
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
import { psdToFrame } from 'leafer-x-psd'
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
解析由 `playground/vite.config.ts` 的 alias 决定:
|
|
721
|
+
|
|
722
|
+
| 模式 | 命令 | 解析到 | 需要 build |
|
|
723
|
+
| --- | --- | --- | --- |
|
|
724
|
+
| 默认(源码) | `npm run dev` | `packages/psd/src/index.ts` | 不需要,改一行立刻生效 |
|
|
725
|
+
| 产物 | `PSD_USE_DIST=1 npm run dev` | `packages/psd/dist/psd.mjs` | 需要先 `npm run build` |
|
|
726
|
+
|
|
727
|
+
两条路径的 specifier 完全一样,所以调试台代码一个字都不用改。
|
|
728
|
+
产物模式用来验证「真实发布出去的形态」(走 `exports`、走 dist)。
|
|
729
|
+
|
|
730
|
+
### 测试分层
|
|
731
|
+
|
|
732
|
+
| 文件 | 覆盖 |
|
|
733
|
+
| --- | --- |
|
|
734
|
+
| `__tests__/unit.test.ts` | 纯逻辑:包围盒、混合模式映射、颜色转换、贝塞尔路径、剪贴组探测 |
|
|
735
|
+
| `__tests__/tree.test.ts` | 元素树结构:顺序、坐标归一化、蒙版、剪贴、文字、适配器降级 |
|
|
736
|
+
| `__tests__/effects.test.ts` | 效果两条路径的分工(位图烘焙 vs 属性映射)与能力边界 |
|
|
737
|
+
| `__tests__/bake.test.ts` | 烘焙本身:补白、`choke` 百分比、腐蚀/膨胀、坐标系不串位 |
|
|
738
|
+
| `__tests__/capability.test.ts` | 底层能力探针:Leafer shadow 跟随轮廓、canvas 合成与 filter |
|
|
739
|
+
| `__tests__/text.test.ts` | 文字定位:渲染着墨范围 vs PS 包围盒,含竖排/旋转朝向断言 |
|
|
740
|
+
| `__tests__/fidelity.test.ts` | 整图还原度:遍历全部样例与 ag-psd 合成图做像素对比 + 位移诊断 |
|
|
741
|
+
| `__tests__/calibrate.test.ts` | 参数扫描校准 `shadowBlurScale`,并护栏住默认值 |
|
|
742
|
+
| `__tests__/regions.test.ts` | 分图层区域比对,输出「参考 \| 渲染 \| 差异」并排图 |
|
|
743
|
+
| `__tests__/runtime.test.ts` | 分批切片、规模基准、耗时拆解、取消、资源回收、蒙版裁剪收益 |
|
|
744
|
+
| `__tests__/limits.test.ts` | 配额与不可信输入:画布上限、效果参数钳制、超限降级、URL 来源已移除 |
|
|
745
|
+
| `__tests__/package.test.ts` | **消费 `dist/` 构建产物**:ESM 导入、UMD 的 `LeaferX.psd` 全局、包元信息 |
|
|
746
|
+
|
|
747
|
+
`package.test.ts` 刻意与源码测试分开:只有真正去用发布出去的东西,才会暴露
|
|
748
|
+
「打包后 ag-psd 副本配不上画布」这类问题。
|
|
749
|
+
|
|
750
|
+
调试台支持拖放或一键载入 `samples/` 里的样例、逐项开关解析选项(含文字烘焙/语义化切换)、
|
|
751
|
+
查看进度与诊断;点击元素可查看它的 `ui.data.psd` 元数据。
|
|
752
|
+
|
|
753
|
+
---
|
|
754
|
+
|
|
755
|
+
## 已知限制
|
|
756
|
+
|
|
757
|
+
- **解析结果是会话内的,默认不可持久化**。`Image.url` 是形如
|
|
758
|
+
`leafer://psd-plugin-resource-N.png` 的资源符(前缀见导出的 `RESOURCE_PREFIX`),
|
|
759
|
+
`frame.toJSON()` 会把它原样序列化 —— 存库后再加载、换个客户端渲染、
|
|
760
|
+
分享链接,图片全都出不来。要持久化必须先跑
|
|
761
|
+
[`exportResources()`](#资源与持久化) 把资源符换成真实 URL。
|
|
762
|
+
插件**不预设上传**,URL 从哪来由你的回调决定。
|
|
763
|
+
- **画布资源需要宿主主动回收**。`Resource` 是全局表,插件注册进去的画布不会自动释放,
|
|
764
|
+
反复加载 PSD 会持续占用内存。用完调用 `releaseResources(frame)`。
|
|
765
|
+
(`exportResources()` 替换成功时也会顺手摘掉对应资源。)
|
|
766
|
+
- **`readPsd` 是同步阻塞的**(占端到端耗时的约 90%)。很大的 PSD 会卡住主线程。
|
|
767
|
+
本插件不做 Worker 隔离,但你可以自己在 Worker 里调用 `readPsdFromSource` /
|
|
768
|
+
`psdToContainer` 来规避。
|
|
769
|
+
- **CMYK 色彩模式不支持**(ag-psd 的限制),会给出明确错误提示,请先转为 RGB。
|
|
770
|
+
- **不接受 URL 字符串**。插件不发网络请求,请先自行加载成二进制
|
|
771
|
+
(见[为什么没有 URL 来源](#为什么没有-url-来源))。
|
|
772
|
+
- **有画布配额**。单边 `16384`、单张 `32M` 像素,效果 `size` 钳在 `1000px`。超限的
|
|
773
|
+
图层/蒙版/效果会被跳过并报 `limit-exceeded`,解码期超限则直接抛
|
|
774
|
+
`PsdCanvasLimitError`。确实需要更大的画布时用 `setCanvasLimits()` 放宽
|
|
775
|
+
(见[安全与配额](#安全与配额))。
|
|
776
|
+
- **字体别名表是有限的**。内置表覆盖常见的中英文字体;表外的全名会原样透传,
|
|
777
|
+
匹配不上就回退到系统默认。用 `fontFamily` 回调补充你自己的字体,
|
|
778
|
+
并确保宿主真的加载了它。
|
|
779
|
+
- **文字多行行距**:PS 的 `leading` 单位在 ag-psd 里是原样透传的引擎值(实测样例里
|
|
780
|
+
`leading / fontSize` 恰好是 6.0,作为多行行高明显不合理),所以只在比值落在 `[0.5, 4]`
|
|
781
|
+
时才采用,否则退回 PS 的自动行高倍数并报 `text-leading-suspect`。
|
|
782
|
+
样例里的三行段落层(`Lorem Ipsu...段落`)走的就是这条降级分支,实测高度误差
|
|
783
|
+
`dh=-0.47px` —— 说明降级在真实多行数据上是贴合参考图的,但**`leading` 字段本身的语义
|
|
784
|
+
仍未查清**,遇到行距明显偏离的 PSD 请优先怀疑这里。
|
|
785
|
+
- **文字的水平缩放与文字变形**(`horizontalScale` / `warp`)尚未映射,会报 `text-unsupported`。
|
|
786
|
+
- **斜面浮雕 / 光泽 / 图案叠加**在 Leafer 中没有对应能力,会报 `effect-unsupported` 并丢弃。
|
|
787
|
+
- **效果烘焙的近似之处**:膨胀/腐蚀是用「沿圆周多次绘制」逼近的(最多 24 次),
|
|
788
|
+
大半径下有轻微多边形锯齿;不过效果本身通常带模糊,视觉上不明显。
|
|
789
|
+
- **形状图层无法矢量化**:ag-psd 没有暴露形状路径(见上)。
|
|
790
|
+
- **智能对象不展开**:只烘焙外观,不解析内嵌的原始文档。
|
|
791
|
+
|
|
792
|
+
## License
|
|
793
|
+
|
|
794
|
+
MIT
|