codesign-mcp-server 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -2
- package/dist/client.d.ts +85 -0
- package/dist/client.js +678 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +108 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +327 -0
- package/dist/index.js.map +1 -0
- package/dist/spec.d.ts +18 -0
- package/dist/spec.js +274 -0
- package/dist/spec.js.map +1 -0
- package/dist/tools.d.ts +59 -0
- package/dist/tools.js +156 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/concurrency.d.ts +2 -0
- package/dist/utils/concurrency.js +15 -0
- package/dist/utils/concurrency.js.map +1 -0
- package/dist/utils/filename.d.ts +10 -0
- package/dist/utils/filename.js +32 -0
- package/dist/utils/filename.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +52 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { CoDesignError, explainError, explainNetworkError, isCoDesignError, } from "./errors.js";
|
|
2
|
+
const BASE = "https://codesign.qq.com";
|
|
3
|
+
const CDN_ORIGIN = "https://codesign.qq.com";
|
|
4
|
+
const DEFAULT_CDN_HOST = "https://cdn4.codesign.qq.com";
|
|
5
|
+
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36";
|
|
6
|
+
/** 从分享链接里解析分享 ID,兼容带不带 hash 前缀的写法 */
|
|
7
|
+
export function parseShareId(url) {
|
|
8
|
+
const patterns = [
|
|
9
|
+
/#?\/app\/s\/(\d+)/,
|
|
10
|
+
/#?\/s\/(\d+)/,
|
|
11
|
+
/sharings?\/(\d+)/,
|
|
12
|
+
/^\s*(\d{10,})\s*$/,
|
|
13
|
+
];
|
|
14
|
+
for (const re of patterns) {
|
|
15
|
+
const m = url.match(re);
|
|
16
|
+
if (m)
|
|
17
|
+
return Number(m[1]);
|
|
18
|
+
}
|
|
19
|
+
throw new CoDesignError(`无法从链接中解析分享 ID:${url}`, undefined, "支持的格式:\n https://codesign.qq.com/app/s/<shareId>\n https://codesign.qq.com/app/s/<shareId>/<screenId>\n 或直接传纯数字 ID");
|
|
20
|
+
}
|
|
21
|
+
/** 从分享链接里解析当前打开的画板 ID(可选) */
|
|
22
|
+
export function parseScreenId(url) {
|
|
23
|
+
const m = url.match(/#?\/app\/s\/\d+\/(\d+)/);
|
|
24
|
+
return m ? Number(m[1]) : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** 把 Cookie 字符串拆成 map,便于按需取 access_token */
|
|
27
|
+
function parseCookies(cookie) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const part of cookie.split(";")) {
|
|
30
|
+
const i = part.indexOf("=");
|
|
31
|
+
if (i < 0)
|
|
32
|
+
continue;
|
|
33
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export class CoDesignClient {
|
|
38
|
+
cookie;
|
|
39
|
+
accessToken;
|
|
40
|
+
cookieTeamId;
|
|
41
|
+
timeoutMs;
|
|
42
|
+
retries;
|
|
43
|
+
defaultPassword;
|
|
44
|
+
/** 分享密码 → state-key 缓存,密码有效期由服务端决定(响应里带 expires) */
|
|
45
|
+
stateKeys = new Map();
|
|
46
|
+
constructor(cookie, options = {}) {
|
|
47
|
+
this.cookie = (cookie ?? "").trim();
|
|
48
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
49
|
+
this.retries = Math.max(0, options.retries ?? 2);
|
|
50
|
+
this.defaultPassword = options.defaultPassword?.trim() || undefined;
|
|
51
|
+
if (this.cookie) {
|
|
52
|
+
const jar = parseCookies(this.cookie);
|
|
53
|
+
this.accessToken = jar.access_token;
|
|
54
|
+
this.cookieTeamId = jar["X-Team-Id"];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
get hasCookie() {
|
|
58
|
+
return this.cookie.length > 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 用访问密码换 state-key。
|
|
62
|
+
* 带密码的分享,`GET /api/sharings/:id` 会返回 403「请输入密码!」,
|
|
63
|
+
* 必须先 `POST /api/sharings/:id/state-keys` 拿 key,再以 `state-key` 头访问。
|
|
64
|
+
* 注意画板详情接口不需要这个头。
|
|
65
|
+
*/
|
|
66
|
+
async exchangePassword(shareId, password) {
|
|
67
|
+
const cacheKey = `${shareId}:${password}`;
|
|
68
|
+
const cached = this.stateKeys.get(cacheKey);
|
|
69
|
+
if (cached && cached.expires * 1000 > Date.now())
|
|
70
|
+
return cached.key;
|
|
71
|
+
const res = await this.send(`${BASE}/api/sharings/${shareId}/state-keys`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { ...this.authHeaders(), referer: `${BASE}/s/${shareId}`, "content-type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ password }),
|
|
75
|
+
});
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
// 错误密码返回 422,错误信息形如「password 必须是 4 个字符」
|
|
79
|
+
throw explainError(res.status, text, `${BASE}/api/sharings/${shareId}/state-keys`);
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new CoDesignError("密码校验接口返回了非 JSON 内容", res.status, "接口可能已变更,请检查 CoDesign 是否改了分享密码机制。");
|
|
87
|
+
}
|
|
88
|
+
const key = parsed?.key;
|
|
89
|
+
if (typeof key !== "string" || !key) {
|
|
90
|
+
throw new CoDesignError("密码校验未返回 state-key", res.status);
|
|
91
|
+
}
|
|
92
|
+
const expires = typeof parsed.expires === "number" ? parsed.expires : Math.floor(Date.now() / 1000) + 3600;
|
|
93
|
+
this.stateKeys.set(cacheKey, { key, expires });
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
/** 鉴权接口:Cookie + Bearer(两者缺一不可,只带 Cookie 会返回「非项目成员」) */
|
|
97
|
+
authHeaders() {
|
|
98
|
+
const h = {
|
|
99
|
+
accept: "application/json",
|
|
100
|
+
"x-requested-with": "XMLHttpRequest",
|
|
101
|
+
"x-source": "web",
|
|
102
|
+
"user-agent": UA,
|
|
103
|
+
referer: `${BASE}/`,
|
|
104
|
+
};
|
|
105
|
+
if (this.cookie)
|
|
106
|
+
h.cookie = this.cookie;
|
|
107
|
+
if (this.accessToken)
|
|
108
|
+
h.authorization = `Bearer ${this.accessToken}`;
|
|
109
|
+
if (this.cookieTeamId)
|
|
110
|
+
h["x-team-id"] = this.cookieTeamId;
|
|
111
|
+
return h;
|
|
112
|
+
}
|
|
113
|
+
/** CDN 资源:必须带 Origin,否则 403 */
|
|
114
|
+
cdnHeaders() {
|
|
115
|
+
return {
|
|
116
|
+
origin: CDN_ORIGIN,
|
|
117
|
+
referer: `${BASE}/`,
|
|
118
|
+
"user-agent": UA,
|
|
119
|
+
accept: "*/*",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 带超时与重试的请求。只对幂等方法重试:CDN 素材全是 GET,
|
|
124
|
+
* 本项目的鉴权接口也都是查询,所以默认全部可重试。
|
|
125
|
+
*/
|
|
126
|
+
async send(url, init = {}) {
|
|
127
|
+
let lastErr;
|
|
128
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
129
|
+
if (attempt > 0)
|
|
130
|
+
await sleep(300 * 2 ** (attempt - 1));
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
133
|
+
// 5xx / 429 才值得重试;4xx 是确定性的,重试只会白等
|
|
134
|
+
if (res.ok || !isRetryable(res.status) || attempt === this.retries)
|
|
135
|
+
return res;
|
|
136
|
+
// 读掉 body 释放连接,再等下一轮
|
|
137
|
+
await res.text().catch(() => "");
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
lastErr = e;
|
|
141
|
+
if (attempt === this.retries)
|
|
142
|
+
throw explainNetworkError(e, url);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 循环只在最后一次抛错时退出
|
|
146
|
+
throw explainNetworkError(lastErr, url);
|
|
147
|
+
}
|
|
148
|
+
async request(url, init) {
|
|
149
|
+
const res = await this.send(url, init);
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const body = await res.text().catch(() => "");
|
|
152
|
+
throw explainError(res.status, body, url);
|
|
153
|
+
}
|
|
154
|
+
return (await res.json());
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 设计稿元数据。公开分享无需登录即可拿到标题、团队、画板列表;
|
|
158
|
+
* 带密码的分享需要传 password,内部会先换 state-key 再请求。
|
|
159
|
+
*/
|
|
160
|
+
async getShare(shareId, password) {
|
|
161
|
+
// 未显式传密码时退回环境变量里配置的默认密码
|
|
162
|
+
const pwd = password?.trim() || this.defaultPassword;
|
|
163
|
+
// 有密码就先换 key;没密码则直接请求
|
|
164
|
+
let stateKey = pwd ? await this.exchangePassword(shareId, pwd) : undefined;
|
|
165
|
+
const fetchShare = (key) => this.request(`${BASE}/api/sharings/${shareId}`, {
|
|
166
|
+
headers: {
|
|
167
|
+
accept: "application/json",
|
|
168
|
+
referer: `${BASE}/s/${shareId}`,
|
|
169
|
+
"user-agent": UA,
|
|
170
|
+
...(key ? { "state-key": key } : {}),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = await fetchShare(stateKey);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
// 带 Cookie 请求密码分享时,服务端返回的是「您当前暂无权限」而不是「请输入密码」,
|
|
179
|
+
// 无法从这一条判断是否缺密码。用不含 Cookie / Bearer 的探测请求确认一下。
|
|
180
|
+
if (!stateKey && isCoDesignError(e) && e.code === "NO_PERMISSION" && (await this.needsPassword(shareId))) {
|
|
181
|
+
if (this.defaultPassword) {
|
|
182
|
+
raw = await fetchShare(await this.exchangePassword(shareId, this.defaultPassword));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
throw new CoDesignError(`设计稿(分享 ${shareId})需要访问密码`, 403, [
|
|
186
|
+
"该分享开启了密码保护,需要提供访问密码。",
|
|
187
|
+
"请把密码作为 password 参数传入,或设置环境变量 CODESIGN_PASSWORD。",
|
|
188
|
+
'例如:design_get(url, password: "ABCD")',
|
|
189
|
+
"提示:若密码正确仍报「无权限」,说明当前 Cookie 账号不在项目内,",
|
|
190
|
+
" 密码只能解锁分享,解不开团队权限。",
|
|
191
|
+
].join("\n"), "NEED_PASSWORD");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const designs = (raw.designs ?? []).map((d) => {
|
|
199
|
+
const screens = [];
|
|
200
|
+
for (const section of d.sections ?? []) {
|
|
201
|
+
for (const s of section.screens ?? []) {
|
|
202
|
+
screens.push({ id: s.id, name: s.name });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
id: d.id,
|
|
207
|
+
name: d.name,
|
|
208
|
+
viewport: d.viewport ?? "",
|
|
209
|
+
screens,
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
id: raw.id,
|
|
214
|
+
title: raw.title,
|
|
215
|
+
teamName: raw.team?.name ?? "",
|
|
216
|
+
permissions: {
|
|
217
|
+
commentable: raw.permissions?.commentable ?? false,
|
|
218
|
+
inspectable: raw.permissions?.inspectable ?? false,
|
|
219
|
+
assetsDownloadable: raw.permissions?.assetsDownloadable ?? false,
|
|
220
|
+
},
|
|
221
|
+
designs,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** 画板详情:拿 meta_url / slices_url / 预览图地址 */
|
|
225
|
+
async getScreen(designId, screenId) {
|
|
226
|
+
this.requireCookie("读取图层标注");
|
|
227
|
+
const raw = await this.request(`${BASE}/api/designs/${designId}/screens/${screenId}`, { headers: this.authHeaders() });
|
|
228
|
+
const cdnHost = (raw.cdn_host ?? DEFAULT_CDN_HOST).replace(/\/$/, "");
|
|
229
|
+
const rewrite = (u) => (u ? new URL(u, cdnHost).href : undefined);
|
|
230
|
+
return {
|
|
231
|
+
id: raw.id,
|
|
232
|
+
name: raw.name,
|
|
233
|
+
width: raw.width,
|
|
234
|
+
height: raw.height,
|
|
235
|
+
designId: raw.design_id ?? designId,
|
|
236
|
+
source: raw.source ?? "",
|
|
237
|
+
metaUrl: rewrite(raw.meta_url),
|
|
238
|
+
slicesUrl: rewrite(raw.slices_url),
|
|
239
|
+
previewUrl: rewrite(raw.preview_path),
|
|
240
|
+
slicesCount: raw.slices_count ?? 0,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 下载并解析图层树 JSON。
|
|
245
|
+
* 注意:CoDesign 的 meta 里 `layers` 与 `groups` 是两个**扁平**数组,
|
|
246
|
+
* 真实层级只体现在 `parent_id` 上,必须自己重建,否则拿到的是一堆没有父子关系的散图层。
|
|
247
|
+
*/
|
|
248
|
+
async getMeta(metaUrl) {
|
|
249
|
+
const raw = await this.request(metaUrl, { headers: this.cdnHeaders() });
|
|
250
|
+
const nodes = [...(raw.layers ?? []), ...(raw.groups ?? [])];
|
|
251
|
+
return {
|
|
252
|
+
name: raw.name ?? "",
|
|
253
|
+
width: raw.width ?? 0,
|
|
254
|
+
height: raw.height ?? 0,
|
|
255
|
+
layers: buildLayerTree(nodes, raw.object_id),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** 下载并解析切图清单 JSON */
|
|
259
|
+
async getSlices(slicesUrl) {
|
|
260
|
+
const raw = await this.request(slicesUrl, { headers: this.cdnHeaders() });
|
|
261
|
+
return (raw ?? []).map((item) => ({
|
|
262
|
+
id: String(item.id ?? item.object_id ?? ""),
|
|
263
|
+
name: item.name ?? "",
|
|
264
|
+
rect: normalizeRect(item.rect),
|
|
265
|
+
exports: (item.exportables ?? []).map((ex) => ({
|
|
266
|
+
name: item.name ?? "",
|
|
267
|
+
scale: ex.scale ?? 1,
|
|
268
|
+
format: ex.format ?? "png",
|
|
269
|
+
width: ex.screenshot?.width ?? ex.size ?? 0,
|
|
270
|
+
height: ex.screenshot?.height ?? ex.size ?? 0,
|
|
271
|
+
// 清单里给的是 OSS 签名地址,直接下会 AccessDenied,
|
|
272
|
+
// 换到 CDN 域名并加 imageMogr2 参数才可下载(svg 会跳过该参数)
|
|
273
|
+
downloadUrl: toCdnUrl(ex.screenshot?.url ?? "", DEFAULT_CDN_HOST, ex.format),
|
|
274
|
+
objectId: String(ex.object_id ?? item.object_id ?? ""),
|
|
275
|
+
})),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
/** 下载任意 CDN 图片到 Buffer */
|
|
279
|
+
async downloadImage(url) {
|
|
280
|
+
const res = await this.send(url, { headers: this.cdnHeaders() });
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
const body = await res.text().catch(() => "");
|
|
283
|
+
throw explainError(res.status, body, url);
|
|
284
|
+
}
|
|
285
|
+
return Buffer.from(await res.arrayBuffer());
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* 探测某个分享是否设置了访问密码。
|
|
289
|
+
* 不带 Cookie/Bearer 请求分享详情:密码分享会返回 403「请输入密码!」,
|
|
290
|
+
* 普通分享则正常返回 200。仅用于诊断,失败时按「不需要密码」处理。
|
|
291
|
+
*/
|
|
292
|
+
async needsPassword(shareId) {
|
|
293
|
+
try {
|
|
294
|
+
const res = await this.send(`${BASE}/api/sharings/${shareId}`, {
|
|
295
|
+
headers: {
|
|
296
|
+
accept: "application/json",
|
|
297
|
+
referer: `${BASE}/s/${shareId}`,
|
|
298
|
+
"user-agent": UA,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
if (res.ok)
|
|
302
|
+
return false;
|
|
303
|
+
const body = await res.text().catch(() => "");
|
|
304
|
+
const message = (() => {
|
|
305
|
+
try {
|
|
306
|
+
return String(JSON.parse(body)?.message ?? "");
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return body;
|
|
310
|
+
}
|
|
311
|
+
})();
|
|
312
|
+
return res.status === 403 && message.includes("请输入密码");
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
requireCookie(action) {
|
|
319
|
+
if (!this.hasCookie) {
|
|
320
|
+
throw new CoDesignError(`需要登录态才能${action}`, undefined, [
|
|
321
|
+
"未检测到 COOKIE 环境变量。",
|
|
322
|
+
"获取方式:",
|
|
323
|
+
" 1. 浏览器登录 https://codesign.qq.com",
|
|
324
|
+
" 2. 打开目标设计稿分享页,F12 → Network",
|
|
325
|
+
" 3. 任选一条 /api/ 请求 → 右键 → Copy as cURL",
|
|
326
|
+
" 4. 把 -b '...' 引号内的内容设为环境变量 CODESIGN_COOKIE",
|
|
327
|
+
].join("\n"));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** 5xx 与 429 属于瞬时失败,可重试 */
|
|
332
|
+
function isRetryable(status) {
|
|
333
|
+
return status === 429 || status >= 500;
|
|
334
|
+
}
|
|
335
|
+
function sleep(ms) {
|
|
336
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 把 OSS 直链换成可下载的 CDN 直链。
|
|
340
|
+
* 注意:`imageMogr2` 是腾讯云图片处理参数,只能处理位图;
|
|
341
|
+
* 对 svg 这类矢量格式会直接报 `InvalidImageFormat` 400,必须跳过。
|
|
342
|
+
*/
|
|
343
|
+
export function toCdnUrl(url, cdnHost = DEFAULT_CDN_HOST, format) {
|
|
344
|
+
if (!url)
|
|
345
|
+
return "";
|
|
346
|
+
try {
|
|
347
|
+
const u = new URL(url);
|
|
348
|
+
const isVector = isVectorFormat(format) || /\.svgz?$/i.test(u.pathname);
|
|
349
|
+
// cdn4.codesign.qq.com 走腾讯云图片服务,位图必须带 imageMogr2 才放行
|
|
350
|
+
if (/^cdn\d*\.codesign\.qq\.com$/.test(u.hostname)) {
|
|
351
|
+
if (isVector)
|
|
352
|
+
return url;
|
|
353
|
+
return /imageMogr2/.test(url) ? url : appendImageMogr2(url, u);
|
|
354
|
+
}
|
|
355
|
+
// OSS 签名直链:换域名 + 补参数
|
|
356
|
+
const host = cdnHost.replace(/\/$/, "");
|
|
357
|
+
const rebuilt = `${host}${u.pathname}${u.search}`;
|
|
358
|
+
if (isVector)
|
|
359
|
+
return rebuilt;
|
|
360
|
+
return appendImageMogr2(rebuilt, u);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return url;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function isVectorFormat(format) {
|
|
367
|
+
return typeof format === "string" && /^(svg|svgz|pdf|eps|ai)$/i.test(format.trim());
|
|
368
|
+
}
|
|
369
|
+
/** 追加 imageMogr2 时按 URL 解析结果判断分隔符,避免签名链接里已带 ? 时拼错 */
|
|
370
|
+
function appendImageMogr2(url, parsed) {
|
|
371
|
+
const sep = parsed.search ? "&" : "?";
|
|
372
|
+
return `${url}${sep}imageMogr2/format/png`;
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// 颜色
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
/**
|
|
378
|
+
* CoDesign 的颜色对象字段是 r/g/b/a,同时带 color-hex / css-rgba 等现成写法。
|
|
379
|
+
*
|
|
380
|
+
* 实测数据里 alpha 的单位**不固定**:普通填充是 0–1(a:1),
|
|
381
|
+
* 但半透明遮罩会返回 a:100 并把 color-hex 写成 `#000000 10000%`
|
|
382
|
+
* —— 它的 css-rgba 也是 `rgba(0,0,0,100)`,是错的。所以 alpha 必须自己归一化。
|
|
383
|
+
*/
|
|
384
|
+
export function rgba(c) {
|
|
385
|
+
if (!c)
|
|
386
|
+
return undefined;
|
|
387
|
+
const alpha = resolveAlpha(c);
|
|
388
|
+
const hex = typeof c["color-hex"] === "string" ? c["color-hex"].trim() : undefined;
|
|
389
|
+
if (hex) {
|
|
390
|
+
const m = hex.match(/^(#[0-9a-f]{3,8})(?:\s+(\d+(?:\.\d+)?)%)?$/i);
|
|
391
|
+
if (m) {
|
|
392
|
+
const pct = m[2] !== undefined ? Number(m[2]) / 100 : undefined;
|
|
393
|
+
// color-hex 的百分比与 a 字段矛盾时以 a 为准(见上面的 10000% 案例)
|
|
394
|
+
const a = alpha ?? pct;
|
|
395
|
+
return a !== undefined && a < 1 ? withAlpha(m[1], a) : m[1];
|
|
396
|
+
}
|
|
397
|
+
const colorOnly = hex.split(/\s+/)[0];
|
|
398
|
+
if (/^#([0-9a-f]{3,8})$/i.test(colorOnly)) {
|
|
399
|
+
return alpha !== undefined && alpha < 1 ? withAlpha(colorOnly, alpha) : colorOnly;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (typeof c["css-rgba"] === "string")
|
|
403
|
+
return normalizeCssRgba(c["css-rgba"]);
|
|
404
|
+
const r = clamp255(c.r ?? c.red ?? 0);
|
|
405
|
+
const g = clamp255(c.g ?? c.green ?? 0);
|
|
406
|
+
const b = clamp255(c.b ?? c.blue ?? 0);
|
|
407
|
+
if (alpha !== undefined && alpha < 1) {
|
|
408
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
409
|
+
}
|
|
410
|
+
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* a/alpha 字段可能是 0–1 也可能是 0–100,实测两种都有。
|
|
414
|
+
* 大于 1 的一律按百分比处理。
|
|
415
|
+
*/
|
|
416
|
+
function resolveAlpha(c) {
|
|
417
|
+
const raw = c.a ?? c.alpha;
|
|
418
|
+
if (typeof raw !== "number" || Number.isNaN(raw))
|
|
419
|
+
return undefined;
|
|
420
|
+
const a = raw > 1 ? raw / 100 : raw;
|
|
421
|
+
return Number(Math.max(0, Math.min(1, a)).toFixed(3));
|
|
422
|
+
}
|
|
423
|
+
function clamp255(v) {
|
|
424
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
425
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(255, Math.round(n))) : 0;
|
|
426
|
+
}
|
|
427
|
+
/** 把 `rgba(0,0,0,100)` 这类百分比 alpha 纠正成合法 CSS */
|
|
428
|
+
function normalizeCssRgba(css) {
|
|
429
|
+
return css.replace(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)/i, (_m, r, g, b, a) => {
|
|
430
|
+
const rr = clamp255(r);
|
|
431
|
+
const gg = clamp255(g);
|
|
432
|
+
const bb = clamp255(b);
|
|
433
|
+
if (a === undefined)
|
|
434
|
+
return `rgb(${rr},${gg},${bb})`;
|
|
435
|
+
const aa = Number(a) > 1 ? Number((Number(a) / 100).toFixed(3)) : Number(a);
|
|
436
|
+
return aa >= 1 ? `rgb(${rr},${gg},${bb})` : `rgba(${rr},${gg},${bb},${aa})`;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/** 把 alpha 合进 hex 颜色,输出 8 位 hex(浏览器普遍支持) */
|
|
440
|
+
function withAlpha(hex, alpha) {
|
|
441
|
+
const body = hex.slice(1);
|
|
442
|
+
if (body.length === 8)
|
|
443
|
+
return hex; // 已带 alpha
|
|
444
|
+
const full = body.length === 3 || body.length === 4
|
|
445
|
+
? body
|
|
446
|
+
.slice(0, 3)
|
|
447
|
+
.split("")
|
|
448
|
+
.map((ch) => ch + ch)
|
|
449
|
+
.join("")
|
|
450
|
+
: body;
|
|
451
|
+
return `#${full}${Math.round(Math.max(0, Math.min(1, alpha)) * 255)
|
|
452
|
+
.toString(16)
|
|
453
|
+
.padStart(2, "0")}`;
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// 图层树重建
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
/**
|
|
459
|
+
* 用 parent_id 把扁平的 layers + groups 重建成真正的树。
|
|
460
|
+
* 画板自身的 object_id 是根;找不到父节点的节点挂到根上,避免丢数据。
|
|
461
|
+
*/
|
|
462
|
+
export function buildLayerTree(nodes, rootId) {
|
|
463
|
+
if (!nodes.length)
|
|
464
|
+
return [];
|
|
465
|
+
const byId = new Map();
|
|
466
|
+
for (const n of nodes) {
|
|
467
|
+
if (n?.object_id !== undefined)
|
|
468
|
+
byId.set(String(n.object_id), n);
|
|
469
|
+
}
|
|
470
|
+
const childrenOf = new Map();
|
|
471
|
+
for (const n of nodes) {
|
|
472
|
+
const pid = String(n?.parent_id ?? "");
|
|
473
|
+
if (!childrenOf.has(pid))
|
|
474
|
+
childrenOf.set(pid, []);
|
|
475
|
+
childrenOf.get(pid).push(n);
|
|
476
|
+
}
|
|
477
|
+
const build = (n, seen) => {
|
|
478
|
+
const layer = normalizeLayer(n);
|
|
479
|
+
const id = String(n?.object_id ?? "");
|
|
480
|
+
seen.add(id);
|
|
481
|
+
const kids = (childrenOf.get(id) ?? []).filter((k) => !seen.has(String(k.object_id)));
|
|
482
|
+
if (kids.length)
|
|
483
|
+
layer.children = kids.map((k) => build(k, seen));
|
|
484
|
+
return layer;
|
|
485
|
+
};
|
|
486
|
+
const rootChildren = childrenOf.get(String(rootId ?? "")) ?? [];
|
|
487
|
+
if (rootChildren.length) {
|
|
488
|
+
return rootChildren.map((n) => build(n, new Set()));
|
|
489
|
+
}
|
|
490
|
+
// 没有可用的根(例如画板 object_id 缺失):从“父节点不在集合里”的节点开始
|
|
491
|
+
const orphans = nodes.filter((n) => !byId.has(String(n?.parent_id ?? "")));
|
|
492
|
+
return (orphans.length ? orphans : nodes).map((n) => build(n, new Set()));
|
|
493
|
+
}
|
|
494
|
+
function normalizeRect(rect) {
|
|
495
|
+
return {
|
|
496
|
+
x: rect?.x ?? 0,
|
|
497
|
+
y: rect?.y ?? 0,
|
|
498
|
+
width: rect?.width ?? 0,
|
|
499
|
+
height: rect?.height ?? 0,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function normalizeLayer(l) {
|
|
503
|
+
const layer = {
|
|
504
|
+
id: String(l.id ?? l.object_id ?? ""),
|
|
505
|
+
objectId: String(l.object_id ?? ""),
|
|
506
|
+
name: l.name ?? "",
|
|
507
|
+
type: l.type ?? "shape",
|
|
508
|
+
rect: normalizeRect(l.rect),
|
|
509
|
+
visible: l.visible !== false,
|
|
510
|
+
opacity: normalizeOpacity(l.opacity),
|
|
511
|
+
radius: normalizeRadius(l.radius),
|
|
512
|
+
fills: normalizePaints(l.fills),
|
|
513
|
+
borders: normalizeBorders(l.borders),
|
|
514
|
+
images: (l.images ?? []).map((img) => toCdnUrl(typeof img === "string" ? img : img?.url ?? "")),
|
|
515
|
+
// 设计稿自带的 CSS 片段是最可靠的样式来源,原样保留
|
|
516
|
+
css: Array.isArray(l.css) ? l.css.map(String) : typeof l.css === "string" ? [l.css] : undefined,
|
|
517
|
+
};
|
|
518
|
+
if (l.rotation)
|
|
519
|
+
layer.rotation = l.rotation;
|
|
520
|
+
if (l.fillOpacity ?? l.fillopacity)
|
|
521
|
+
layer.fillOpacity = normalizeOpacity(l.fillOpacity ?? l.fillopacity);
|
|
522
|
+
if (l.clipped)
|
|
523
|
+
layer.clipped = true;
|
|
524
|
+
if (l.blendOptions?.mode)
|
|
525
|
+
layer.blendMode = l.blendOptions.mode;
|
|
526
|
+
if (l.smartObject || l.path)
|
|
527
|
+
layer.isVector = Boolean(l.path);
|
|
528
|
+
const effects = normalizeEffects(l.layerEffects);
|
|
529
|
+
if (effects.length)
|
|
530
|
+
layer.effects = effects;
|
|
531
|
+
if (l.type === "text") {
|
|
532
|
+
layer.text = normalizeText(l);
|
|
533
|
+
}
|
|
534
|
+
return layer;
|
|
535
|
+
}
|
|
536
|
+
function normalizeOpacity(v) {
|
|
537
|
+
if (typeof v !== "number" || Number.isNaN(v))
|
|
538
|
+
return undefined;
|
|
539
|
+
const n = v > 1 ? v / 100 : v;
|
|
540
|
+
return Number(Math.max(0, Math.min(1, n)).toFixed(3));
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* 文本样式。
|
|
544
|
+
*
|
|
545
|
+
* 实测要点:
|
|
546
|
+
* - `textStyle.size` 是排版原始字号,会发生缩放;`impliedFontSize` 才是渲染后的实际字号
|
|
547
|
+
* - `fontCaps: "allCaps"` 在当前数据里普遍出现在**越南语/普通文本**上,不是真的大写;
|
|
548
|
+
* CoDesign 的 `css` 字段也不会输出 text-transform,所以不能据此加 `text-transform:uppercase`
|
|
549
|
+
* - `textKey` 才是正文内容
|
|
550
|
+
*/
|
|
551
|
+
function normalizeText(l) {
|
|
552
|
+
const tr = l.text?.textStyleRange?.[0]?.textStyle ?? {};
|
|
553
|
+
const size = numberOr(tr.impliedFontSize, numberOr(l.fontSize, tr.size ?? 0));
|
|
554
|
+
const leading = numberOr(tr.impliedLeading, numberOr(l.lineHeight, tr.leading));
|
|
555
|
+
return {
|
|
556
|
+
content: resolveTextContent(l),
|
|
557
|
+
fontName: String(tr.fontName ?? l.fontFace ?? ""),
|
|
558
|
+
// 字重字段在不同文件里既可能是 "Bold" 也可能是 700,统一成字重值
|
|
559
|
+
fontStyleName: normalizeFontWeight(tr.fontStyleName ?? l.fontWeight),
|
|
560
|
+
size,
|
|
561
|
+
tracking: tr.tracking,
|
|
562
|
+
leading,
|
|
563
|
+
lineHeight: leading,
|
|
564
|
+
letterSpacing: numberOr(l.letterSpacing, undefined),
|
|
565
|
+
color: rgba(tr.color) ?? rgba(l.color),
|
|
566
|
+
textAlign: l.textAlign,
|
|
567
|
+
allCaps: false,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/** 设计稿的字重可能是数字 700、字符串 "700" 或 "Bold" */
|
|
571
|
+
function normalizeFontWeight(v) {
|
|
572
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
573
|
+
return String(v);
|
|
574
|
+
if (typeof v === "string") {
|
|
575
|
+
// "Bold" / "Heavy" 这类保留原名,交给后面映射
|
|
576
|
+
return /^\d+$/.test(v.trim()) ? v.trim() : v;
|
|
577
|
+
}
|
|
578
|
+
return "";
|
|
579
|
+
}
|
|
580
|
+
/** textKey 才是正文;图层名只是设计工具里的标签 */
|
|
581
|
+
function resolveTextContent(l) {
|
|
582
|
+
const raw = l.text ?? {};
|
|
583
|
+
const candidates = [
|
|
584
|
+
raw.textKey,
|
|
585
|
+
raw.content,
|
|
586
|
+
raw.characters,
|
|
587
|
+
raw.value,
|
|
588
|
+
l.content,
|
|
589
|
+
];
|
|
590
|
+
for (const c of candidates) {
|
|
591
|
+
if (typeof c === "string" && c.trim())
|
|
592
|
+
return c;
|
|
593
|
+
}
|
|
594
|
+
return l.name ?? "";
|
|
595
|
+
}
|
|
596
|
+
function numberOr(...vals) {
|
|
597
|
+
for (const v of vals) {
|
|
598
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
599
|
+
return v;
|
|
600
|
+
}
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
function normalizeRadius(r) {
|
|
604
|
+
if (r === undefined || r === null)
|
|
605
|
+
return undefined;
|
|
606
|
+
if (Array.isArray(r))
|
|
607
|
+
return r.map((v) => Number(v) || 0);
|
|
608
|
+
if (typeof r === "number")
|
|
609
|
+
return [r, r, r, r];
|
|
610
|
+
if (typeof r === "object") {
|
|
611
|
+
const vals = [r.topLeft ?? r.tl, r.topRight ?? r.tr, r.bottomRight ?? r.br, r.bottomLeft ?? r.bl];
|
|
612
|
+
if (vals.some((v) => typeof v === "number"))
|
|
613
|
+
return vals.map((v) => Number(v) || 0);
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* fills/borders 都是 { fillType, color, ... }。
|
|
619
|
+
* `fillType: "gradient"` 时颜色在 gradient 里,直接读 color 会拿到 undefined,
|
|
620
|
+
* 所以保留整个对象交给上层处理。
|
|
621
|
+
*/
|
|
622
|
+
function normalizePaints(paints) {
|
|
623
|
+
if (!Array.isArray(paints) || !paints.length)
|
|
624
|
+
return undefined;
|
|
625
|
+
return paints.map((p) => ({
|
|
626
|
+
type: p.fillType ?? "",
|
|
627
|
+
color: rgba(p.color),
|
|
628
|
+
css: paintToCss(p),
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
/** 边框额外带 thickness / position,之前只取了颜色 */
|
|
632
|
+
function normalizeBorders(borders) {
|
|
633
|
+
if (!Array.isArray(borders) || !borders.length)
|
|
634
|
+
return undefined;
|
|
635
|
+
return borders.map((b) => ({
|
|
636
|
+
type: b.fillType ?? "",
|
|
637
|
+
color: rgba(b.color),
|
|
638
|
+
css: paintToCss(b),
|
|
639
|
+
width: numberOr(b.thickness, b.size),
|
|
640
|
+
style: b.position === "inside" ? "inset" : undefined,
|
|
641
|
+
position: b.position,
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
/** 渐变等非纯色填充,尽量还原成 CSS background 值 */
|
|
645
|
+
function paintToCss(p) {
|
|
646
|
+
if (typeof p.css === "string")
|
|
647
|
+
return String(p.css).replace(/^background:\s*/, "");
|
|
648
|
+
const g = p.gradient;
|
|
649
|
+
if (!g)
|
|
650
|
+
return undefined;
|
|
651
|
+
if (typeof g === "string")
|
|
652
|
+
return g;
|
|
653
|
+
if (Array.isArray(g.stops)) {
|
|
654
|
+
const angle = Math.round(g.angle ?? 180);
|
|
655
|
+
const stops = g.stops
|
|
656
|
+
.map((s) => `${rgba(s.color) ?? "transparent"} ${Math.round((s.location ?? 0) * 100)}%`)
|
|
657
|
+
.join(", ");
|
|
658
|
+
return `linear-gradient(${angle}deg, ${stops})`;
|
|
659
|
+
}
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* layerEffects 是 Photoshop 风格的对象:
|
|
664
|
+
* `{ innerGlow: {...}, frameFX: {...} }`,key 就是效果类型。
|
|
665
|
+
* 目前只把能可靠映射成 CSS 的效果转出来,其余原样保留。
|
|
666
|
+
*/
|
|
667
|
+
function normalizeEffects(effects) {
|
|
668
|
+
if (!effects || typeof effects !== "object")
|
|
669
|
+
return [];
|
|
670
|
+
const out = [];
|
|
671
|
+
for (const [type, cfg] of Object.entries(effects)) {
|
|
672
|
+
if (!cfg || typeof cfg !== "object")
|
|
673
|
+
continue;
|
|
674
|
+
out.push({ type, ...cfg });
|
|
675
|
+
}
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=client.js.map
|