@fulate/import 1.0.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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # `@fulate/import`
2
+
3
+ > 文档角色:`GUIDE`。权威来源:import adapter 源码与测试;当前文件 schema 和元素身份规则见 Core/UI 领域规范。
4
+
5
+ `@fulate/import` 负责把当前支持的外部格式映射为 Fulate canonical creation input。它不是旧 Fulate
6
+ 版本兼容层,不提供旧 schema 协商、自动迁移或整树 validation;解析后的声明格式直接交给当前
7
+ owner。
8
+
9
+ ## Fulate 文件
10
+
11
+ ```ts
12
+ import {
13
+ serializeSceneToJSON,
14
+ parseFileData,
15
+ restoreScene
16
+ } from "@fulate/import";
17
+
18
+ const json = serializeSceneToJSON(root);
19
+ const data = parseFileData(json);
20
+ restoreScene(root, data);
21
+ ```
22
+
23
+ `serializeScene()`/`serializeSceneToJSON()` 保存当前 Root 配置和元素树。当前 file marker 为
24
+ `__fulate_file__`,`version` 为 `2`。`deserializeElement()`
25
+ 按 `type` 选择当前内建或调用者提供的 factory;文件中的 `key` 由 producer 保留,Core 不生成第二
26
+ 份身份。`exportToFile()` 与 `importFromFile()` 是浏览器文件 API 的薄适配。
27
+ `parseFileData()` 只执行 JSON parse;声明格式错误和 restore 错误直接传播。History 属于应用组合层,
28
+ 成功恢复场景后由拥有 Select 的应用决定是否清空。
29
+
30
+ ## 自定义 factory
31
+
32
+ 通过 `ReadonlyMap<string, DeserializeFactory>` 或普通对象传入 custom factory。factory 直接返回
33
+ 当前 Element;不需要包装 DTO、预检整棵图或保留旧 reader。
34
+
35
+ ## Sketch
36
+
37
+ `importSketch(file)` 解析 Sketch 输入并返回 `{ fileData, images, warnings }`;
38
+ `importSketchFile(root, deserialize, filter?)` 负责文件选择、恢复和必要的导入警告。Sketch 的
39
+ 外部数据只在 adapter 边界映射,恢复后的场景继续使用当前 Fulate schema 与 owner。
@@ -0,0 +1,30 @@
1
+ import { Element, type ElementJSON, type Root, type SkinOptions } from "@fulate/core";
2
+ import type { ElementFilter } from "../util";
3
+ export type { ElementFilter };
4
+ export type DeserializeFactory = (data: Record<string, any>) => Element;
5
+ export type DeserializeFactories = ReadonlyMap<string, DeserializeFactory> | Readonly<Record<string, DeserializeFactory>>;
6
+ export interface FileData {
7
+ [key: string]: any;
8
+ __fulate_file__: true;
9
+ version: 2;
10
+ root?: {
11
+ viewport?: {
12
+ x?: number;
13
+ y?: number;
14
+ scale?: number;
15
+ minScale?: number;
16
+ maxScale?: number;
17
+ };
18
+ skin?: SkinOptions;
19
+ width?: number;
20
+ height?: number;
21
+ };
22
+ children: ElementJSON[];
23
+ }
24
+ export declare function serializeScene(root: Root, filter?: ElementFilter): FileData;
25
+ export declare function serializeSceneToJSON(root: Root, filter?: ElementFilter): string;
26
+ export declare function deserializeElement(data: Record<string, any>, factories?: DeserializeFactories): Element;
27
+ export declare function restoreScene(root: Root, fileData: FileData, filter?: ElementFilter, factories?: DeserializeFactories): void;
28
+ export declare function parseFileData(json: string): FileData;
29
+ export declare function exportToFile(root: Root, filename?: string, filter?: ElementFilter): void;
30
+ export declare function importFromFile(root: Root, filter?: ElementFilter, factories?: DeserializeFactories): Promise<boolean>;
@@ -0,0 +1,6 @@
1
+ export type { ImportResult, Importer } from "./types";
2
+ export { importSketch, importSketchFile, SketchImporter } from "./sketch";
3
+ export { serializeScene, serializeSceneToJSON, deserializeElement, restoreScene, parseFileData, exportToFile, importFromFile } from "./fulate";
4
+ export type { FileData, DeserializeFactories, DeserializeFactory } from "./fulate";
5
+ export { restoreScene as restoreSceneBase } from "./util";
6
+ export type { ElementFilter, DeserializeFn, RestoreOptions } from "./util";
package/dist/index.js ADDED
@@ -0,0 +1,745 @@
1
+ import { Element, Layer, Shape } from "@fulate/core";
2
+ import { Circle, Group, Image, Line, LineTree, Polygon, Rectangle, RippleOverlay, ScrollView, Text, Triangle, VectorPath, Workspace } from "@fulate/ui";
3
+ import { loadAsync } from "jszip";
4
+ import { isNil, isUndefined } from "lodash-es";
5
+ //#region packages/import/src/sketch/parser.ts
6
+ async function parseSketchFile(data) {
7
+ const zip = await loadAsync(data instanceof File ? await data.arrayBuffer() : data);
8
+ const document = await readJSON(zip, "document.json");
9
+ const meta = await readJSON(zip, "meta.json");
10
+ const pages = [];
11
+ for (const ref of document.pages) {
12
+ const pagePath = `${ref._ref}.json`;
13
+ pages.push(await readJSON(zip, pagePath));
14
+ }
15
+ const images = /* @__PURE__ */ new Map();
16
+ const imagesFolder = zip.folder("images");
17
+ if (imagesFolder) {
18
+ const entries = [];
19
+ imagesFolder.forEach((relativePath, file) => {
20
+ if (!file.dir) entries.push({
21
+ name: relativePath,
22
+ file
23
+ });
24
+ });
25
+ for (const { name, file } of entries) images.set(`images/${name}`, await file.async("arraybuffer"));
26
+ }
27
+ return {
28
+ document,
29
+ meta,
30
+ pages,
31
+ images
32
+ };
33
+ }
34
+ async function readJSON(zip, path) {
35
+ const entry = zip.file(path);
36
+ if (!entry) throw new Error(`Missing ${path} in sketch file`);
37
+ const text = await entry.async("text");
38
+ return JSON.parse(text);
39
+ }
40
+ //#endregion
41
+ //#region packages/import/src/sketch/style.ts
42
+ function sketchColorToCSS(c) {
43
+ const r = Math.round(c.red * 255);
44
+ const g = Math.round(c.green * 255);
45
+ const b = Math.round(c.blue * 255);
46
+ if (c.alpha >= 1) return `rgb(${r},${g},${b})`;
47
+ return `rgba(${r},${g},${b},${parseFloat(c.alpha.toFixed(3))})`;
48
+ }
49
+ function convertStyle(style) {
50
+ const props = {};
51
+ const warnings = [];
52
+ if (!style) return {
53
+ props,
54
+ warnings
55
+ };
56
+ if (!isUndefined(style.windingRule)) props.fillRule = style.windingRule === 1 ? "evenodd" : "nonzero";
57
+ const opacity = style.contextSettings?.opacity ?? style.opacity;
58
+ if (!isUndefined(opacity) && opacity < 1) props.opacity = opacity;
59
+ const gradientFill = findFirstEnabled(style.fills, (f) => f.fillType === 1);
60
+ if (gradientFill?.gradient) props.backgroundColor = convertGradient(gradientFill.gradient);
61
+ else {
62
+ const fill = findFirstEnabled(style.fills, (f) => f.fillType === 0);
63
+ if (fill) props.backgroundColor = sketchColorToCSS(fill.color);
64
+ }
65
+ const gradientBorder = findFirstEnabled(style.borders, (b) => b.fillType === 1);
66
+ if (gradientBorder?.gradient) {
67
+ props.borderColor = convertGradient(gradientBorder.gradient);
68
+ props.borderWidth = gradientBorder.thickness;
69
+ props.borderPosition = convertBorderPosition(gradientBorder.position);
70
+ } else {
71
+ const border = findFirstEnabled(style.borders, (b) => b.fillType === 0);
72
+ if (border) {
73
+ props.borderColor = sketchColorToCSS(border.color);
74
+ props.borderWidth = border.thickness;
75
+ props.borderPosition = convertBorderPosition(border.position);
76
+ }
77
+ }
78
+ const shadow = findFirstEnabled(style.shadows);
79
+ if (shadow) props.shadow = convertShadow(shadow);
80
+ if (style.innerShadows?.some((s) => s.isEnabled)) warnings.push("Inner shadows not supported, skipped");
81
+ if (style.blur?.isEnabled) warnings.push("Blur effect not supported, skipped");
82
+ return {
83
+ props,
84
+ warnings
85
+ };
86
+ }
87
+ function convertBorderPosition(pos) {
88
+ if (pos === 2) return "outside";
89
+ return "inside";
90
+ }
91
+ function convertShadow(s) {
92
+ return {
93
+ color: sketchColorToCSS(s.color),
94
+ blur: s.blurRadius,
95
+ offsetX: s.offsetX,
96
+ offsetY: s.offsetY
97
+ };
98
+ }
99
+ function convertGradient(sg) {
100
+ const from = parseSketchNormalizedPoint(sg.from);
101
+ const to = parseSketchNormalizedPoint(sg.to);
102
+ return {
103
+ type: sg.gradientType === 0 ? "linear" : "radial",
104
+ from,
105
+ to,
106
+ ...sg.gradientType === 1 ? {
107
+ center: from,
108
+ radius: .5
109
+ } : {},
110
+ stops: sg.stops.map((s) => ({
111
+ color: sketchColorToCSS(s.color),
112
+ position: s.position
113
+ }))
114
+ };
115
+ }
116
+ function parseSketchNormalizedPoint(str) {
117
+ const match = str.match(/\{([^,]+),\s*([^}]+)\}/);
118
+ if (!match) return {
119
+ x: .5,
120
+ y: .5
121
+ };
122
+ return {
123
+ x: parseFloat(match[1]),
124
+ y: parseFloat(match[2])
125
+ };
126
+ }
127
+ function findFirstEnabled(items, filter) {
128
+ if (!items) return void 0;
129
+ return items.find((item) => item.isEnabled && (!filter || filter(item)));
130
+ }
131
+ //#endregion
132
+ //#region packages/import/src/sketch/text.ts
133
+ function convertTextProps(layer) {
134
+ const result = {};
135
+ const attrStr = layer.attributedString;
136
+ if (!attrStr) return result;
137
+ result.text = attrStr.string;
138
+ result.overflow = "visible";
139
+ result.verticalAlign = "top";
140
+ result.wordWrap = false;
141
+ const dominant = getDominantAttribute(attrStr.attributes);
142
+ if (!dominant) return result;
143
+ const font = dominant.attributes.MSAttributedStringFontAttribute;
144
+ if (font) {
145
+ result.fontSize = font.attributes.size;
146
+ result.fontFamily = normalizeFontFamily(font.attributes.name);
147
+ if (isBoldFont(font.attributes.name)) result.fontWeight = "bold";
148
+ if (isItalicFont(font.attributes.name)) result.fontStyle = "italic";
149
+ }
150
+ const color = dominant.attributes.MSAttributedStringColorAttribute;
151
+ if (color) result.color = sketchColorToCSS(color);
152
+ const paragraph = dominant.attributes.paragraphStyle;
153
+ if (paragraph) {
154
+ result.textAlign = convertAlignment(paragraph.alignment);
155
+ if (paragraph.maximumLineHeight && result.fontSize) result.lineHeight = parseFloat((paragraph.maximumLineHeight / result.fontSize).toFixed(2));
156
+ }
157
+ if (dominant.attributes.underlineStyle) result.underline = true;
158
+ if (dominant.attributes.strikethroughStyle) result.strikethrough = true;
159
+ if (dominant.attributes.kerning) result.letterSpacing = dominant.attributes.kerning;
160
+ if (layer.style) {
161
+ const { props } = convertStyle(layer.style);
162
+ if (props.backgroundColor) result.color = props.backgroundColor;
163
+ if (props.borderColor && props.borderWidth) {
164
+ result.textStrokeColor = props.borderColor;
165
+ result.textStrokeWidth = props.borderWidth;
166
+ }
167
+ if (props.shadow) result.textShadow = props.shadow;
168
+ }
169
+ return result;
170
+ }
171
+ function getDominantAttribute(attrs) {
172
+ if (!attrs.length) return void 0;
173
+ if (attrs.length === 1) return attrs[0];
174
+ return attrs.reduce((a, b) => a.length >= b.length ? a : b);
175
+ }
176
+ function convertAlignment(alignment) {
177
+ switch (alignment) {
178
+ case 1: return "right";
179
+ case 2: return "center";
180
+ default: return "left";
181
+ }
182
+ }
183
+ function normalizeFontFamily(name) {
184
+ return name.replace(/-(Bold|Italic|Light|Medium|Regular|Thin|Black|Heavy|Semibold|UltraLight|ExtraBold|BoldItalic|MediumItalic|LightItalic)$/i, "").replace(/([a-z])([A-Z])/g, "$1 $2");
185
+ }
186
+ function isBoldFont(name) {
187
+ return /bold|black|heavy|extrabold|semibold/i.test(name);
188
+ }
189
+ function isItalicFont(name) {
190
+ return /italic|oblique/i.test(name);
191
+ }
192
+ //#endregion
193
+ //#region packages/import/src/util/index.ts
194
+ function defaultAppend(els, root) {
195
+ root.append(...els);
196
+ }
197
+ /**
198
+ * 通用场景恢复模板:
199
+ * 1. 应用 root 配置(resize、viewport、Skin)
200
+ * 2. reset viewport
201
+ * 3. 移除旧元素(由 filter 决定)
202
+ * 4. 反序列化并添加 children(由 append 决定)
203
+ * 5. requestRender
204
+ */
205
+ function restoreScene$1(options) {
206
+ const { root, fileData, deserialize, append, filter } = options;
207
+ const toRemove = filter ? root.children.filter((child) => filter(child)) : [];
208
+ if (fileData.root) {
209
+ const { viewport, skin, width, height } = fileData.root;
210
+ if (skin) {
211
+ const { text, ...paint } = skin;
212
+ Object.assign(root.skin, paint);
213
+ if (text) Object.assign(root.skin.text, text);
214
+ }
215
+ if (!isNil(width) && !isNil(height)) root.resize(width, height);
216
+ if (viewport) root.viewport.restore({
217
+ x: viewport.x ?? 0,
218
+ y: viewport.y ?? 0,
219
+ scale: viewport.scale ?? 1
220
+ });
221
+ else root.viewport.reset();
222
+ } else root.viewport.reset();
223
+ if (toRemove.length) root.removeChild(...toRemove);
224
+ const els = fileData.children.map((data) => deserialize(data));
225
+ if (els.length) (append ?? defaultAppend)(els, root);
226
+ root.invalidateAll();
227
+ }
228
+ function pickFile(accept) {
229
+ return new Promise((resolve) => {
230
+ const input = document.createElement("input");
231
+ input.type = "file";
232
+ input.accept = accept;
233
+ input.onchange = () => resolve(input.files?.[0] ?? null);
234
+ input.click();
235
+ });
236
+ }
237
+ function arrayBufferToDataURL(buf, mime) {
238
+ const bytes = new Uint8Array(buf);
239
+ let binary = "";
240
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
241
+ return `data:${mime};base64,${btoa(binary)}`;
242
+ }
243
+ function guessMimeType(path) {
244
+ switch (path.split(".").pop()?.toLowerCase()) {
245
+ case "jpg":
246
+ case "jpeg": return "image/jpeg";
247
+ case "png": return "image/png";
248
+ case "gif": return "image/gif";
249
+ case "webp": return "image/webp";
250
+ case "svg": return "image/svg+xml";
251
+ default: return "image/png";
252
+ }
253
+ }
254
+ //#endregion
255
+ //#region packages/import/src/sketch/image.ts
256
+ /**
257
+ * Extract the image ref path from a bitmap layer and resolve it
258
+ * to a data URL using the images extracted from the zip.
259
+ */
260
+ function resolveImageSrc(layer, images) {
261
+ const ref = layer.image;
262
+ if (!ref) return void 0;
263
+ const refPath = ref._ref;
264
+ const buf = images.get(refPath);
265
+ if (!buf) return void 0;
266
+ return arrayBufferToDataURL(buf, guessMimeType(refPath));
267
+ }
268
+ //#endregion
269
+ //#region packages/import/src/sketch/converter.ts
270
+ function convertSketchToFileData(sketch) {
271
+ const warnings = [];
272
+ const imageDataURLs = /* @__PURE__ */ new Map();
273
+ const children = [];
274
+ const ctx = {
275
+ offsetX: 0,
276
+ offsetY: 0,
277
+ flipH: false,
278
+ flipV: false,
279
+ groupW: 0,
280
+ groupH: 0,
281
+ zipImages: sketch.images,
282
+ imageDataURLs,
283
+ warnings,
284
+ out: children
285
+ };
286
+ for (const page of sketch.pages) flattenLayers(page.layers ?? [], ctx);
287
+ return {
288
+ fileData: {
289
+ __fulate_file__: true,
290
+ version: 2,
291
+ children
292
+ },
293
+ images: imageDataURLs,
294
+ warnings
295
+ };
296
+ }
297
+ var MASK_SHAPE_TYPES = new Set([
298
+ "rectangle",
299
+ "circle",
300
+ "triangle",
301
+ "polygon",
302
+ "vectorPath"
303
+ ]);
304
+ function flattenLayers(layers, ctx) {
305
+ for (let index = 0; index < layers.length;) {
306
+ const layer = layers[index];
307
+ if (!layer.isVisible || !layer.hasClippingMask) {
308
+ flattenLayer(layer, ctx);
309
+ index++;
310
+ continue;
311
+ }
312
+ if ((layer.clippingMaskMode ?? 0) !== 0) {
313
+ ctx.warnings.push(`[${layer.name}] alpha clipping mask not supported`);
314
+ flattenLayer(layer, ctx);
315
+ index++;
316
+ continue;
317
+ }
318
+ if (layer.rotation || layer.isFlippedHorizontal || layer.isFlippedVertical || ctx.flipH || ctx.flipV) {
319
+ ctx.warnings.push(`[${layer.name}] transformed clipping mask not supported`);
320
+ flattenLayer(layer, ctx);
321
+ index++;
322
+ continue;
323
+ }
324
+ let chainEnd = index + 1;
325
+ while (chainEnd < layers.length && !layers[chainEnd].shouldBreakMaskChain) chainEnd++;
326
+ const maskOutput = [];
327
+ flattenLayer(layer, {
328
+ ...ctx,
329
+ out: maskOutput
330
+ });
331
+ const mask = maskOutput.length === 1 ? maskOutput[0] : void 0;
332
+ if (!mask || !MASK_SHAPE_TYPES.has(mask.type)) {
333
+ ctx.warnings.push(`[${layer.name}] clipping mask shape not supported`);
334
+ ctx.out.push(...maskOutput);
335
+ index++;
336
+ continue;
337
+ }
338
+ mask.overflow = "hidden";
339
+ const children = [];
340
+ flattenLayers(layers.slice(index + 1, chainEnd), {
341
+ ...ctx,
342
+ out: children
343
+ });
344
+ for (const child of children) {
345
+ child.left -= mask.left;
346
+ child.top -= mask.top;
347
+ }
348
+ if (children.length) mask.children = children;
349
+ ctx.out.push(mask);
350
+ index = chainEnd;
351
+ }
352
+ }
353
+ function flattenLayer(layer, ctx) {
354
+ if (!layer.isVisible) return;
355
+ const cls = layer._class;
356
+ if (cls === "artboard") {
357
+ const childCtx = {
358
+ ...ctx,
359
+ offsetX: ctx.offsetX,
360
+ offsetY: ctx.offsetY,
361
+ flipH: false,
362
+ flipV: false,
363
+ groupW: 0,
364
+ groupH: 0
365
+ };
366
+ flattenLayers(layer.layers ?? [], childCtx);
367
+ return;
368
+ }
369
+ if (cls === "group") {
370
+ let cx = ctx.offsetX + layer.frame.x;
371
+ let cy = ctx.offsetY + layer.frame.y;
372
+ if (ctx.flipH) cx = ctx.offsetX + (ctx.groupW - layer.frame.x - layer.frame.width);
373
+ if (ctx.flipV) cy = ctx.offsetY + (ctx.groupH - layer.frame.y - layer.frame.height);
374
+ const childCtx = {
375
+ ...ctx,
376
+ offsetX: cx,
377
+ offsetY: cy,
378
+ flipH: ctx.flipH !== !!layer.isFlippedHorizontal,
379
+ flipV: ctx.flipV !== !!layer.isFlippedVertical,
380
+ groupW: layer.frame.width,
381
+ groupH: layer.frame.height
382
+ };
383
+ flattenLayers(layer.layers ?? [], childCtx);
384
+ return;
385
+ }
386
+ if (cls === "shapeGroup") {
387
+ const vectorPath = convertShapeGroup(layer, ctx);
388
+ if (vectorPath) ctx.out.push(vectorPath);
389
+ return;
390
+ }
391
+ const type = mapSketchClass(cls, layer, ctx.warnings);
392
+ if (!type) return;
393
+ const base = buildBase(layer, type, ctx);
394
+ switch (type) {
395
+ case "text":
396
+ delete base.backgroundColor;
397
+ delete base.borderColor;
398
+ delete base.borderWidth;
399
+ delete base.borderPosition;
400
+ delete base.shadow;
401
+ Object.assign(base, convertTextProps(layer));
402
+ break;
403
+ case "image":
404
+ assignImageSrc(base, layer, ctx);
405
+ break;
406
+ case "line":
407
+ assignLinePath(base, layer);
408
+ break;
409
+ case "polygon":
410
+ assignPolygonContour(base, layer);
411
+ break;
412
+ case "vectorPath":
413
+ assignVectorPath(base, layer);
414
+ break;
415
+ }
416
+ applyInheritedFlips(base, ctx);
417
+ ctx.out.push(base);
418
+ }
419
+ function applyInheritedFlips(base, ctx) {
420
+ if (ctx.flipH) base.scaleX = (base.scaleX ?? 1) * -1;
421
+ if (ctx.flipV) base.scaleY = (base.scaleY ?? 1) * -1;
422
+ }
423
+ function buildBase(layer, type, ctx) {
424
+ const { props, warnings: styleWarnings } = convertStyle(layer.style);
425
+ for (const w of styleWarnings) ctx.warnings.push(`[${layer.name}] ${w}`);
426
+ let left = ctx.offsetX + layer.frame.x;
427
+ let top = ctx.offsetY + layer.frame.y;
428
+ if (ctx.flipH) left = ctx.offsetX + (ctx.groupW - layer.frame.x - layer.frame.width);
429
+ if (ctx.flipV) top = ctx.offsetY + (ctx.groupH - layer.frame.y - layer.frame.height);
430
+ const base = {
431
+ type,
432
+ left,
433
+ top,
434
+ width: layer.frame.width,
435
+ height: layer.frame.height,
436
+ ...props
437
+ };
438
+ if (layer.rotation) base.angle = -layer.rotation;
439
+ if (type === "rectangle" || type === "text" || type === "image") {
440
+ if (layer.fixedRadius) base.radius = layer.fixedRadius;
441
+ else if (layer.points?.length) {
442
+ const cr = layer.points[0].cornerRadius;
443
+ if (cr > 0) base.radius = cr;
444
+ }
445
+ }
446
+ if (layer.isFlippedHorizontal) base.scaleX = -1;
447
+ if (layer.isFlippedVertical) base.scaleY = -1;
448
+ return base;
449
+ }
450
+ var CLASS_MAP = {
451
+ rectangle: "rectangle",
452
+ oval: "circle",
453
+ triangle: "triangle",
454
+ text: "text",
455
+ bitmap: "image",
456
+ star: "polygon",
457
+ polygon: "polygon",
458
+ slice: "",
459
+ hotspot: "",
460
+ MSImmutableHotspotLayer: ""
461
+ };
462
+ function mapSketchClass(cls, layer, warnings) {
463
+ if (cls in CLASS_MAP) {
464
+ const mapped = CLASS_MAP[cls];
465
+ if (!mapped) return null;
466
+ if (cls === "star") warnings.push(`[${layer.name}] "${cls}" mapped to polygon`);
467
+ return mapped;
468
+ }
469
+ if (cls === "shapePath") return resolveShapePath(layer, warnings);
470
+ if (cls === "shapeGroup") return null;
471
+ warnings.push(`[${layer.name}] unsupported layer class "${cls}", skipped`);
472
+ return null;
473
+ }
474
+ function resolveShapePath(layer, warnings) {
475
+ const points = layer.points;
476
+ if (!points || points.length < 2) {
477
+ warnings.push(`[${layer.name}] shapePath with <2 points, skipped`);
478
+ return null;
479
+ }
480
+ if (points.some(hasCurveHandle)) return "vectorPath";
481
+ if (layer.isClosed) return "polygon";
482
+ return "line";
483
+ }
484
+ function hasCurveHandle(point) {
485
+ return hasOutgoingHandle(point) || hasIncomingHandle(point);
486
+ }
487
+ function hasOutgoingHandle(point) {
488
+ return point.hasCurveFrom ?? !sketchPointsNearEqual(point.point, point.curveFrom);
489
+ }
490
+ function hasIncomingHandle(point) {
491
+ return point.hasCurveTo ?? !sketchPointsNearEqual(point.point, point.curveTo);
492
+ }
493
+ function sketchPointsNearEqual(a, b, eps = 1e-6) {
494
+ if (a === b) return true;
495
+ const pa = parseSketchPointRaw(a);
496
+ const pb = parseSketchPointRaw(b);
497
+ if (!pa || !pb) return false;
498
+ return Math.abs(pa.x - pb.x) < eps && Math.abs(pa.y - pb.y) < eps;
499
+ }
500
+ function parseSketchPointRaw(str) {
501
+ const match = str.match(/\{([^,]+),\s*([^}]+)\}/);
502
+ if (!match) return null;
503
+ return {
504
+ x: parseFloat(match[1]),
505
+ y: parseFloat(match[2])
506
+ };
507
+ }
508
+ function convertShapeGroup(layer, ctx) {
509
+ const paths = (layer.layers ?? []).filter((child) => child.isVisible);
510
+ if (!(paths.length > 0 && paths.every((path) => path._class === "shapePath" && (path.booleanOperation === void 0 || path.booleanOperation === -1) && (path.points?.length ?? 0) >= 2))) {
511
+ ctx.warnings.push(`[${layer.name}] boolean or heterogeneous shapeGroup not supported, skipped`);
512
+ return null;
513
+ }
514
+ const base = buildBase(layer, "vectorPath", ctx);
515
+ base.path = paths.flatMap((path) => createVectorPathCommands(path, (point) => parseShapeGroupPoint(point, path)));
516
+ applyInheritedFlips(base, ctx);
517
+ return base;
518
+ }
519
+ function assignVectorPath(base, layer) {
520
+ const { width, height } = layer.frame;
521
+ base.path = createVectorPathCommands(layer, (point) => parseSketchPoint(point, width, height));
522
+ }
523
+ function createVectorPathCommands(layer, resolvePoint) {
524
+ const points = layer.points;
525
+ const commands = [{
526
+ type: "M",
527
+ point: resolvePoint(points[0].point)
528
+ }];
529
+ for (let index = 1; index < points.length; index++) appendSketchSegment(commands, points[index - 1], points[index], resolvePoint, false);
530
+ if (layer.isClosed) {
531
+ appendSketchSegment(commands, points[points.length - 1], points[0], resolvePoint, true);
532
+ commands.push({ type: "Z" });
533
+ }
534
+ return commands;
535
+ }
536
+ function appendSketchSegment(commands, from, to, resolvePoint, closing) {
537
+ const outgoing = hasOutgoingHandle(from);
538
+ const incoming = hasIncomingHandle(to);
539
+ if (outgoing || incoming) commands.push({
540
+ type: "C",
541
+ control1: resolvePoint(outgoing ? from.curveFrom : from.point),
542
+ control2: resolvePoint(incoming ? to.curveTo : to.point),
543
+ point: resolvePoint(to.point)
544
+ });
545
+ else if (!closing) commands.push({
546
+ type: "L",
547
+ point: resolvePoint(to.point)
548
+ });
549
+ }
550
+ function parseShapeGroupPoint(point, path) {
551
+ const { width, height, x: left, y: top } = path.frame;
552
+ let { x, y } = parseSketchPoint(point, width, height);
553
+ if (path.isFlippedHorizontal) x = width - x;
554
+ if (path.isFlippedVertical) y = height - y;
555
+ if (path.rotation) {
556
+ const centerX = width / 2;
557
+ const centerY = height / 2;
558
+ const radians = -path.rotation * Math.PI / 180;
559
+ const cos = Math.cos(radians);
560
+ const sin = Math.sin(radians);
561
+ const offsetX = x - centerX;
562
+ const offsetY = y - centerY;
563
+ x = centerX + offsetX * cos - offsetY * sin;
564
+ y = centerY + offsetX * sin + offsetY * cos;
565
+ }
566
+ return {
567
+ x: left + x,
568
+ y: top + y
569
+ };
570
+ }
571
+ function assignPolygonContour(base, layer) {
572
+ const points = layer.points;
573
+ if (!points?.length) return;
574
+ const { width, height } = layer.frame;
575
+ base.contour = points.map((p) => parseSketchPoint(p.point, width, height));
576
+ }
577
+ function assignLinePath(base, layer) {
578
+ const points = layer.points;
579
+ if (!points?.length) return;
580
+ const { width, height } = layer.frame;
581
+ let parsed = points.map((p) => parseSketchPoint(p.point, width, height));
582
+ if (base.angle) {
583
+ const cx = width / 2;
584
+ const cy = height / 2;
585
+ const rad = base.angle * Math.PI / 180;
586
+ const cos = Math.cos(rad);
587
+ const sin = Math.sin(rad);
588
+ parsed = parsed.map((p) => ({
589
+ x: cx + (p.x - cx) * cos - (p.y - cy) * sin,
590
+ y: cy + (p.x - cx) * sin + (p.y - cy) * cos
591
+ }));
592
+ delete base.angle;
593
+ }
594
+ const origin = parsed[0];
595
+ base.left += origin.x;
596
+ base.top += origin.y;
597
+ base.path = parsed.map((p) => ({
598
+ x: p.x - origin.x,
599
+ y: p.y - origin.y
600
+ }));
601
+ delete base.width;
602
+ delete base.height;
603
+ if (base.borderColor) {
604
+ base.strokeColor = base.borderColor;
605
+ delete base.borderColor;
606
+ }
607
+ if (base.borderWidth) {
608
+ base.strokeWidth = base.borderWidth;
609
+ delete base.borderWidth;
610
+ }
611
+ delete base.borderPosition;
612
+ }
613
+ function parseSketchPoint(str, frameW, frameH) {
614
+ const match = str.match(/\{([^,]+),\s*([^}]+)\}/);
615
+ if (!match) return {
616
+ x: 0,
617
+ y: 0
618
+ };
619
+ return {
620
+ x: parseFloat(match[1]) * frameW,
621
+ y: parseFloat(match[2]) * frameH
622
+ };
623
+ }
624
+ function assignImageSrc(base, layer, ctx) {
625
+ const src = resolveImageSrc(layer, ctx.zipImages);
626
+ if (src) {
627
+ base.src = src;
628
+ const ref = layer.image?._ref;
629
+ if (ref) ctx.imageDataURLs.set(ref, src);
630
+ } else ctx.warnings.push(`[${layer.name}] image resource not found`);
631
+ }
632
+ //#endregion
633
+ //#region packages/import/src/sketch/index.ts
634
+ var SketchImporter = class {
635
+ async import(file) {
636
+ return importSketch(file);
637
+ }
638
+ };
639
+ async function importSketch(file) {
640
+ return convertSketchToFileData(await parseSketchFile(file));
641
+ }
642
+ async function importSketchFile(root, deserialize, filter) {
643
+ const file = await pickFile(".sketch");
644
+ if (!file) return false;
645
+ const result = await importSketch(file);
646
+ if (result.warnings.length) console.warn("[Sketch Import]", result.warnings);
647
+ restoreScene$1({
648
+ root,
649
+ fileData: result.fileData,
650
+ deserialize,
651
+ filter,
652
+ append: (els, root) => {
653
+ const layer = new Layer();
654
+ const workspace = new Workspace({
655
+ key: "workspace",
656
+ width: 1920,
657
+ height: 1080
658
+ });
659
+ layer.append(workspace, ...els);
660
+ root.append(layer);
661
+ }
662
+ });
663
+ return true;
664
+ }
665
+ //#endregion
666
+ //#region packages/import/src/fulate/index.ts
667
+ var FILE_MARKER = "__fulate_file__";
668
+ var FILE_VERSION = 2;
669
+ var builtInConstructors = {
670
+ element: Element,
671
+ shape: Shape,
672
+ layer: Layer,
673
+ rectangle: Rectangle,
674
+ circle: Circle,
675
+ triangle: Triangle,
676
+ polygon: Polygon,
677
+ vectorPath: VectorPath,
678
+ text: Text,
679
+ image: Image,
680
+ workspace: Workspace,
681
+ group: Group,
682
+ scrollview: ScrollView,
683
+ line: Line,
684
+ lineTree: LineTree,
685
+ ripple: RippleOverlay
686
+ };
687
+ function normalizeType(type) {
688
+ return type.startsWith("f-") ? type.slice(2) : type;
689
+ }
690
+ var builtInFactories = Object.fromEntries(Object.entries(builtInConstructors).map(([type, Constructor]) => [type, (data) => {
691
+ const { type, children, ...options } = data;
692
+ return new Constructor(options);
693
+ }]));
694
+ function resolveFactory(type, factories) {
695
+ const normalized = normalizeType(type);
696
+ let custom;
697
+ if (factories instanceof Map) custom = factories.get(normalized);
698
+ else if (factories && Object.prototype.hasOwnProperty.call(factories, normalized)) custom = factories[normalized];
699
+ return custom ?? builtInFactories[normalized];
700
+ }
701
+ function serializeScene(root, filter) {
702
+ return {
703
+ [FILE_MARKER]: true,
704
+ version: FILE_VERSION,
705
+ root: root.toJSON(),
706
+ children: root.children.filter((child) => !filter || filter(child)).map((child) => child.toJSON())
707
+ };
708
+ }
709
+ function serializeSceneToJSON(root, filter) {
710
+ return JSON.stringify(serializeScene(root, filter));
711
+ }
712
+ function deserializeElement(data, factories) {
713
+ const element = resolveFactory(data.type, factories)(data);
714
+ if (data.children) element.replaceChildren(...data.children.map((child) => deserializeElement(child, factories)));
715
+ return element;
716
+ }
717
+ function restoreScene(root, fileData, filter, factories) {
718
+ restoreScene$1({
719
+ root,
720
+ fileData,
721
+ deserialize: (data) => deserializeElement(data, factories),
722
+ filter
723
+ });
724
+ }
725
+ function parseFileData(json) {
726
+ return JSON.parse(json);
727
+ }
728
+ function exportToFile(root, filename = "fulate-design.json", filter) {
729
+ const json = serializeSceneToJSON(root, filter);
730
+ const blob = new Blob([json], { type: "application/json" });
731
+ const url = URL.createObjectURL(blob);
732
+ const anchor = document.createElement("a");
733
+ anchor.href = url;
734
+ anchor.download = filename;
735
+ anchor.click();
736
+ URL.revokeObjectURL(url);
737
+ }
738
+ async function importFromFile(root, filter, factories) {
739
+ const file = await pickFile(".json");
740
+ if (!file) return false;
741
+ restoreScene(root, parseFileData(await file.text()), filter, factories);
742
+ return true;
743
+ }
744
+ //#endregion
745
+ export { SketchImporter, deserializeElement, exportToFile, importFromFile, importSketch, importSketchFile, parseFileData, restoreScene, restoreScene$1 as restoreSceneBase, serializeScene, serializeSceneToJSON };
@@ -0,0 +1,3 @@
1
+ import type { SketchFile } from "./types";
2
+ import type { ImportResult } from "../types";
3
+ export declare function convertSketchToFileData(sketch: SketchFile): ImportResult;
@@ -0,0 +1,6 @@
1
+ import type { SketchLayer } from "./types";
2
+ /**
3
+ * Extract the image ref path from a bitmap layer and resolve it
4
+ * to a data URL using the images extracted from the zip.
5
+ */
6
+ export declare function resolveImageSrc(layer: SketchLayer, images: Map<string, ArrayBuffer>): string | undefined;
@@ -0,0 +1,8 @@
1
+ import type { Root } from "@fulate/core";
2
+ import type { Importer, ImportResult } from "../types";
3
+ import type { ElementFilter, DeserializeFn } from "../util";
4
+ export declare class SketchImporter implements Importer {
5
+ import(file: File | ArrayBuffer): Promise<ImportResult>;
6
+ }
7
+ export declare function importSketch(file: File | ArrayBuffer): Promise<ImportResult>;
8
+ export declare function importSketchFile(root: Root, deserialize: DeserializeFn, filter?: ElementFilter): Promise<boolean>;
@@ -0,0 +1,2 @@
1
+ import type { SketchFile } from "./types";
2
+ export declare function parseSketchFile(data: File | ArrayBuffer): Promise<SketchFile>;
@@ -0,0 +1,8 @@
1
+ import type { SketchColor, SketchStyle } from "./types";
2
+ import type { ShapeOption } from "@fulate/core";
3
+ export declare function sketchColorToCSS(c: SketchColor): string;
4
+ export interface StyleConvertResult {
5
+ props: Partial<ShapeOption>;
6
+ warnings: string[];
7
+ }
8
+ export declare function convertStyle(style: SketchStyle | undefined): StyleConvertResult;
@@ -0,0 +1,3 @@
1
+ import type { SketchLayer } from "./types";
2
+ import type { TextOption } from "@fulate/ui";
3
+ export declare function convertTextProps(layer: SketchLayer): Partial<TextOption>;
@@ -0,0 +1,184 @@
1
+ export interface SketchFile {
2
+ document: SketchDocument;
3
+ meta: SketchMeta;
4
+ pages: SketchPage[];
5
+ images: Map<string, ArrayBuffer>;
6
+ }
7
+ export interface SketchDocument {
8
+ _class: "document";
9
+ do_objectID: string;
10
+ assets: SketchAssets;
11
+ colorSpace: number;
12
+ pages: SketchPageRef[];
13
+ }
14
+ export interface SketchPageRef {
15
+ _class: "MSJSONFileReference";
16
+ _ref_class: "MSImmutablePage";
17
+ _ref: string;
18
+ }
19
+ export interface SketchAssets {
20
+ _class: "assetCollection";
21
+ colors: SketchColor[];
22
+ gradients: any[];
23
+ images: any[];
24
+ }
25
+ export interface SketchMeta {
26
+ commit: string;
27
+ appVersion: string;
28
+ build: number;
29
+ app: string;
30
+ pagesAndArtboards: Record<string, {
31
+ name: string;
32
+ artboards: Record<string, {
33
+ name: string;
34
+ }>;
35
+ }>;
36
+ version: number;
37
+ }
38
+ export interface SketchPage {
39
+ _class: "page";
40
+ do_objectID: string;
41
+ name: string;
42
+ layers: SketchLayer[];
43
+ frame: SketchRect;
44
+ }
45
+ export interface SketchLayer {
46
+ _class: string;
47
+ do_objectID: string;
48
+ name: string;
49
+ frame: SketchRect;
50
+ isVisible: boolean;
51
+ rotation: number;
52
+ isFlippedHorizontal: boolean;
53
+ isFlippedVertical: boolean;
54
+ isLocked: boolean;
55
+ style?: SketchStyle;
56
+ layers?: SketchLayer[];
57
+ attributedString?: SketchAttributedString;
58
+ image?: SketchImageRef;
59
+ fixedRadius?: number;
60
+ points?: SketchCurvePoint[];
61
+ isClosed?: boolean;
62
+ booleanOperation?: number;
63
+ hasClippingMask?: boolean;
64
+ shouldBreakMaskChain?: boolean;
65
+ clippingMaskMode?: number;
66
+ }
67
+ export interface SketchRect {
68
+ _class: "rect";
69
+ x: number;
70
+ y: number;
71
+ width: number;
72
+ height: number;
73
+ }
74
+ export interface SketchStyle {
75
+ _class: "style";
76
+ windingRule?: number;
77
+ fills?: SketchFill[];
78
+ borders?: SketchBorder[];
79
+ shadows?: SketchShadow[];
80
+ innerShadows?: SketchShadow[];
81
+ blur?: SketchBlur;
82
+ opacity?: number;
83
+ contextSettings?: SketchGraphicsContextSettings;
84
+ }
85
+ export interface SketchGraphicsContextSettings {
86
+ _class: "graphicsContextSettings";
87
+ blendMode: number;
88
+ opacity: number;
89
+ }
90
+ export interface SketchFill {
91
+ _class: "fill";
92
+ isEnabled: boolean;
93
+ fillType: number;
94
+ color: SketchColor;
95
+ gradient?: SketchGradient;
96
+ }
97
+ export interface SketchBorder {
98
+ _class: "border";
99
+ isEnabled: boolean;
100
+ color: SketchColor;
101
+ thickness: number;
102
+ position: number;
103
+ fillType: number;
104
+ gradient?: SketchGradient;
105
+ }
106
+ export interface SketchShadow {
107
+ _class: "shadow" | "innerShadow";
108
+ isEnabled: boolean;
109
+ color: SketchColor;
110
+ blurRadius: number;
111
+ offsetX: number;
112
+ offsetY: number;
113
+ spread: number;
114
+ }
115
+ export interface SketchBlur {
116
+ _class: "blur";
117
+ isEnabled: boolean;
118
+ type: number;
119
+ radius: number;
120
+ }
121
+ export interface SketchColor {
122
+ _class: "color";
123
+ red: number;
124
+ green: number;
125
+ blue: number;
126
+ alpha: number;
127
+ }
128
+ export interface SketchGradient {
129
+ _class: "gradient";
130
+ gradientType: number;
131
+ from: string;
132
+ to: string;
133
+ stops: SketchGradientStop[];
134
+ }
135
+ export interface SketchGradientStop {
136
+ _class: "gradientStop";
137
+ color: SketchColor;
138
+ position: number;
139
+ }
140
+ export interface SketchAttributedString {
141
+ _class: "attributedString";
142
+ string: string;
143
+ attributes: SketchStringAttribute[];
144
+ }
145
+ export interface SketchStringAttribute {
146
+ _class: "stringAttribute";
147
+ location: number;
148
+ length: number;
149
+ attributes: {
150
+ MSAttributedStringFontAttribute: {
151
+ _class: "fontDescriptor";
152
+ attributes: {
153
+ name: string;
154
+ size: number;
155
+ };
156
+ };
157
+ MSAttributedStringColorAttribute?: SketchColor;
158
+ paragraphStyle?: {
159
+ _class: "paragraphStyle";
160
+ alignment?: number;
161
+ maximumLineHeight?: number;
162
+ minimumLineHeight?: number;
163
+ };
164
+ kerning?: number;
165
+ textStyleVerticalAlignmentKey?: number;
166
+ underlineStyle?: number;
167
+ strikethroughStyle?: number;
168
+ };
169
+ }
170
+ export interface SketchImageRef {
171
+ _class: "MSJSONFileReference" | "MSJSONOriginalDataReference";
172
+ _ref_class: "MSImageData";
173
+ _ref: string;
174
+ }
175
+ export interface SketchCurvePoint {
176
+ _class: "curvePoint";
177
+ point: string;
178
+ curveFrom: string;
179
+ curveTo: string;
180
+ hasCurveFrom?: boolean;
181
+ hasCurveTo?: boolean;
182
+ cornerRadius: number;
183
+ curveMode: number;
184
+ }
@@ -0,0 +1,10 @@
1
+ import type { FileData } from "./fulate";
2
+ export interface ImportResult {
3
+ fileData: FileData;
4
+ /** imageRef → data URL */
5
+ images: Map<string, string>;
6
+ warnings: string[];
7
+ }
8
+ export interface Importer {
9
+ import(file: File | ArrayBuffer): Promise<ImportResult>;
10
+ }
@@ -0,0 +1,25 @@
1
+ import type { Root, Element } from "@fulate/core";
2
+ import type { FileData } from "../fulate";
3
+ export type ElementFilter = (element: Element) => boolean;
4
+ export type DeserializeFn = (data: any) => Element;
5
+ export interface RestoreOptions {
6
+ root: Root;
7
+ fileData: FileData;
8
+ deserialize: DeserializeFn;
9
+ /** 决定把反序列化后的元素添加到哪里、怎么添加 */
10
+ append?: (els: Element[], root: Root) => void;
11
+ /** 决定清除哪些旧元素,不传则不清除 */
12
+ filter?: ElementFilter;
13
+ }
14
+ /**
15
+ * 通用场景恢复模板:
16
+ * 1. 应用 root 配置(resize、viewport、Skin)
17
+ * 2. reset viewport
18
+ * 3. 移除旧元素(由 filter 决定)
19
+ * 4. 反序列化并添加 children(由 append 决定)
20
+ * 5. requestRender
21
+ */
22
+ export declare function restoreScene(options: RestoreOptions): void;
23
+ export declare function pickFile(accept: string): Promise<File | null>;
24
+ export declare function arrayBufferToDataURL(buf: ArrayBuffer, mime: string): string;
25
+ export declare function guessMimeType(path: string): string;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fulate/import",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "license": "MIT",
17
+ "scripts": {
18
+ "test": "vitest run --config vitest.config.ts",
19
+ "test:watch": "vitest --config vitest.config.ts"
20
+ },
21
+ "dependencies": {
22
+ "@fulate/share": "*",
23
+ "@fulate/core": "*",
24
+ "@fulate/ui": "*",
25
+ "jszip": "^3.10.1",
26
+ "lodash-es": "^4.17.22"
27
+ }
28
+ }