const PLUGIN_WIDTH = 380; const PLUGIN_HEIGHT = 560; const DEFAULT_FONT = { family: "Inter", style: "Regular" }; function getPixso() { return typeof pixso !== "undefined" ? pixso : figma; } const app = getPixso(); function notify(message, type = "info") { if (app.notification?.show) { app.notification.show(message, type); } else if (app.notify) { app.notify(message); } } function rgbToPaint(color, fallback = { r: 1, g: 1, b: 1, a: 1 }) { const parsed = parseCssColor(color) || fallback; if (!parsed) return null; return { type: "SOLID", color: { r: clamp01(parsed.r), g: clamp01(parsed.g), b: clamp01(parsed.b) }, opacity: clamp01(parsed.a == null ? 1 : parsed.a) }; } function parseCssColor(value) { if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return null; const rgba = String(value).match(/rgba?\(([^)]+)\)/i); if (rgba) { const parts = rgba[1].split(",").map(part => part.trim()); return { r: Number(parts[0]) / 255, g: Number(parts[1]) / 255, b: Number(parts[2]) / 255, a: parts[3] == null ? 1 : Number(parts[3]) }; } const hex = String(value).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); if (!hex) return null; let body = hex[1]; if (body.length === 3) { body = body.split("").map(char => char + char).join(""); } return { r: parseInt(body.slice(0, 2), 16) / 255, g: parseInt(body.slice(2, 4), 16) / 255, b: parseInt(body.slice(4, 6), 16) / 255, a: body.length === 8 ? parseInt(body.slice(6, 8), 16) / 255 : 1 }; } function clamp01(value) { return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1)); } function parsePixels(value, fallback = 0) { const parsed = parseFloat(value); return Number.isFinite(parsed) ? parsed : fallback; } function clampNumber(value, min, max, fallback = 0) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(min, Math.min(max, number)); } function parseZIndex(value) { const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? parsed : 0; } function intersectRects(a, b) { if (!a || !b) return null; const x1 = Math.max(a.x || 0, b.x || 0); const y1 = Math.max(a.y || 0, b.y || 0); const x2 = Math.min((a.x || 0) + (a.width || 0), (b.x || 0) + (b.width || 0)); const y2 = Math.min((a.y || 0) + (a.height || 0), (b.y || 0) + (b.height || 0)); if (x2 <= x1 || y2 <= y1) return null; return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; } function overlapRatio(a, b) { const intersection = intersectRects(a, b); if (!intersection) return 0; const area = Math.max(1, Math.min( (a.width || 1) * (a.height || 1), (b.width || 1) * (b.height || 1) )); return (intersection.width * intersection.height) / area; } function clipsChildren(styles = {}) { const overflow = `${styles.overflow || ""} ${styles.overflowX || ""} ${styles.overflowY || ""}`; return /(hidden|clip|scroll|auto)/i.test(overflow); } function getRenderRect(captureNode) { const rect = captureNode?.rect; const visible = captureNode?.visibleRect; if (!rect) return null; if (!visible) return rect; const rectArea = Math.max(1, (rect.width || 1) * (rect.height || 1)); const visibleArea = Math.max(1, (visible.width || 1) * (visible.height || 1)); const isHugeOverflow = rect.width > visible.width * 1.5 || rect.height > visible.height * 1.5 || rectArea > visibleArea * 2; return captureNode.clipped && isHugeOverflow ? visible : rect; } function rectsDiffer(a, b, tolerance = 0.5) { if (!a || !b) return false; return Math.abs((a.x || 0) - (b.x || 0)) > tolerance || Math.abs((a.y || 0) - (b.y || 0)) > tolerance || Math.abs((a.width || 0) - (b.width || 0)) > tolerance || Math.abs((a.height || 0) - (b.height || 0)) > tolerance; } function shouldUseClipWrapper(captureNode) { if (!captureNode || captureNode.type === "TEXT") return false; if (!captureNode.rect || !captureNode.visibleRect) return false; if (!rectsDiffer(captureNode.rect, captureNode.visibleRect)) return false; return Boolean(captureNode.clipped || hasImageFill(captureNode) || captureNode.backgroundImages?.length || captureNode.type === "RECTANGLE"); } function paintFromBackgroundImage(value) { if (!value || !String(value).includes("gradient")) return null; const colors = String(value).match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/gi); if (!colors?.length) return null; const stops = colors.map((color, index) => { const paint = rgbToPaint(color, null); if (!paint) return null; return { position: colors.length === 1 ? 0 : index / (colors.length - 1), color: { ...paint.color, a: paint.opacity == null ? 1 : paint.opacity } }; }).filter(Boolean); if (stops.length < 2) { return rgbToPaint(colors[0], null); } return { type: "GRADIENT_LINEAR", gradientStops: stops, gradientTransform: [ [1, 0, 0], [0, 1, 0] ] }; } function parseBoxShadow(value, scale) { if (!value || value === "none") return null; const colorMatch = String(value).match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/i); const color = rgbToPaint(colorMatch?.[0] || "rgba(0,0,0,.18)", { r: 0, g: 0, b: 0, a: 0.18 }); const numericPart = String(value).replace(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/ig, ""); const numbers = numericPart.match(/-?\d*\.?\d+px/g)?.map(parseFloat) || []; if (!numbers.length) return null; return { type: "DROP_SHADOW", color: { ...color.color, a: color.opacity == null ? 1 : color.opacity }, offset: { x: (numbers[0] || 0) * scale, y: (numbers[1] || 0) * scale }, radius: Math.max(0, (numbers[2] || 0) * scale), spread: (numbers[3] || 0) * scale, visible: true, blendMode: "NORMAL" }; } function parseDropShadowFilter(value, scale) { if (!value || value === "none") return null; const match = String(value).match(/drop-shadow\(([^)]+)\)/i); if (!match) return null; return parseBoxShadow(match[1], scale); } function setSize(node, width, height) { const safeWidth = Math.max(1, Math.round(width || 1)); const safeHeight = Math.max(1, Math.round(height || 1)); if (typeof node.resize === "function") { node.resize(safeWidth, safeHeight); } else { node.width = safeWidth; node.height = safeHeight; } } function setPosition(node, rect, parentRect, scale) { node.x = Math.round(((rect?.x || 0) - (parentRect?.x || 0)) * scale); node.y = Math.round(((rect?.y || 0) - (parentRect?.y || 0)) * scale); setSize(node, (rect?.width || 1) * scale, (rect?.height || 1) * scale); } function applyTransform(node, captureNode) { const transform = String(captureNode.styles?.transform || ""); if (!transform || transform === "none" || !("rotation" in node)) return; const rotate = transform.match(/rotate\((-?\d*\.?\d+)deg\)/i); if (rotate) { node.rotation = Number(rotate[1]) || 0; return; } const matrix = transform.match(/matrix\(([^)]+)\)/i); if (!matrix) return; const parts = matrix[1].split(",").map(part => Number(part.trim())); if (parts.length < 4 || parts.some(value => !Number.isFinite(value))) return; const angle = Math.atan2(parts[1], parts[0]) * 180 / Math.PI; const hasRotation = Math.abs(angle) > 0.1 && Math.abs(angle) < 89.9; const hasSkew = Math.abs(parts[1]) > 0.001 || Math.abs(parts[2]) > 0.001; if (hasRotation && hasSkew) { node.rotation = angle; } } function mapPrimaryAxisAlign(value) { const normalized = String(value || "").toLowerCase(); if (normalized.includes("space-between")) return "SPACE_BETWEEN"; if (normalized.includes("center")) return "CENTER"; if (normalized.includes("end") || normalized.includes("right") || normalized.includes("bottom")) return "MAX"; return "MIN"; } function mapCounterAxisAlign(value) { const normalized = String(value || "").toLowerCase(); if (normalized.includes("center")) return "CENTER"; if (normalized.includes("end") || normalized.includes("right") || normalized.includes("bottom")) return "MAX"; if (normalized.includes("stretch")) return "MIN"; return "MIN"; } function trySetLayoutProperty(node, key, value) { try { if (key in node) { node[key] = value; return true; } } catch { // Pixso/Figma API compatibility differs between editor versions. } return false; } function applyAutoLayout(node, captureNode, scale) { const layout = captureNode?.layout; if (!layout || captureNode.type === "TEXT") return false; if (layout.type !== "flex" && layout.type !== "inferred" && layout.type !== "grid") return false; const direction = String(layout.direction || "").toLowerCase(); const isGrid = layout.type === "grid"; const isColumn = !isGrid && direction.includes("column"); let applied = false; applied = trySetLayoutProperty(node, "layoutMode", isColumn ? "VERTICAL" : "HORIZONTAL") || applied; applied = trySetLayoutProperty(node, "primaryAxisAlignItems", mapPrimaryAxisAlign(layout.justifyContent)) || applied; applied = trySetLayoutProperty(node, "counterAxisAlignItems", mapCounterAxisAlign(layout.alignItems)) || applied; applied = trySetLayoutProperty(node, "primaryAxisSizingMode", "FIXED") || applied; applied = trySetLayoutProperty(node, "counterAxisSizingMode", "FIXED") || applied; const itemSpacing = isColumn ? layout.rowGap || layout.gap : layout.columnGap || layout.gap; applied = trySetLayoutProperty(node, "itemSpacing", Math.round(clampNumber(itemSpacing, 0, 400, 0) * scale)) || applied; const padding = layout.padding || {}; applied = trySetLayoutProperty(node, "paddingTop", Math.round(clampNumber(padding.top, 0, 400, 0) * scale)) || applied; applied = trySetLayoutProperty(node, "paddingRight", Math.round(clampNumber(padding.right, 0, 400, 0) * scale)) || applied; applied = trySetLayoutProperty(node, "paddingBottom", Math.round(clampNumber(padding.bottom, 0, 400, 0) * scale)) || applied; applied = trySetLayoutProperty(node, "paddingLeft", Math.round(clampNumber(padding.left, 0, 400, 0) * scale)) || applied; if (isGrid || String(layout.wrap || "").toLowerCase().includes("wrap")) { applied = trySetLayoutProperty(node, "layoutWrap", "WRAP") || applied; applied = trySetLayoutProperty(node, "counterAxisSpacing", Math.round(clampNumber(layout.rowGap || layout.gap, 0, 400, 0) * scale)) || applied; } if (typeof node.setPluginData === "function") { try { node.setPluginData("webToPixsoLayout", JSON.stringify(layout)); if (captureNode.section) { node.setPluginData("webToPixsoSection", String(captureNode.section)); } } catch { // Layout metadata is a bonus; failing to store it should not block import. } } return applied; } function getPage() { return app.currentPage || app.state?.currentPage; } async function loadDefaultFont() { if (typeof app.loadFontAsync !== "function") return; try { await app.loadFontAsync(DEFAULT_FONT); } catch { try { await app.loadFontAsync({ family: "Arial", style: "Regular" }); } catch { // Pixso may already have a default font loaded. } } } function hasVisualStyle(captureNode) { if (!captureNode || captureNode.type === "TEXT") return true; const styles = captureNode.styles || {}; const hasImage = Boolean(hasImageFill(captureNode) || captureNode.backgroundImages?.length); const hasFill = Boolean( shouldRenderBackgroundFill(captureNode) && (hasVisibleColor(styles.backgroundColor) || hasCssBackgroundImage(styles)) ); const borderWidth = getBorderWidth(styles); const meaningfulRadius = hasRadius(styles) && !isLargePlainLayoutWrapper(captureNode); return hasImage || hasFill || borderWidth > 0 || hasShadow(styles) || meaningfulRadius; } function shouldSkipImportNode(captureNode) { const styles = captureNode?.styles || {}; if ((captureNode?.tag === "::before" || captureNode?.tag === "::after") && !captureNode.text && !captureNode.src && !captureNode.backgroundImages?.length) { return true; } if ((captureNode?.tag === "::before" || captureNode?.tag === "::after") && captureNode?.backgroundImages?.length && styles.backgroundRepeat && styles.backgroundRepeat !== "no-repeat") { return true; } if (captureNode?.tag === "PICTURE" && captureNode.src && captureNode.children?.some(child => child.tag === "IMG" && child.src)) { return true; } return false; } function isRasterFallbackNode(captureNode) { return captureNode?.tag === "RASTER_FALLBACK" || captureNode?.attributes?.["data-raster-fallback"] === "true"; } function getRasterMode(captureNode) { return captureNode?.rasterMode || captureNode?.attributes?.["data-raster-mode"] || ""; } function getLayerGroup(captureNode) { const explicit = captureNode?.layerGroup || captureNode?.attributes?.["data-layer-group"]; const normalized = String(explicit || "").toLowerCase(); if (normalized === "comparison" || normalized === "fallback") return "comparison"; if (normalized === "text") return "text"; if (normalized === "editable" || normalized === "element") return "editable"; if (isRasterFallbackNode(captureNode)) return "comparison"; if (captureNode?.type === "TEXT") return "text"; return "editable"; } function getLayerPriority(captureNode) { const explicit = Number(captureNode?.layerPriority); if (Number.isFinite(explicit)) return explicit; const group = getLayerGroup(captureNode); if (group === "comparison") return 0; if (group === "text") return 200; return 100; } function hasImageFill(captureNode) { if (!captureNode?.src) return false; if (isRasterFallbackNode(captureNode)) return true; return /^(IMG|PICTURE|VIDEO|CANVAS|SOURCE)$/i.test(captureNode.tag || ""); } function hasVisibleColor(value) { return Boolean(parseCssColor(value)); } function isLightOpaqueColor(value) { const color = parseCssColor(value); return Boolean(color && color.a > 0.92 && color.r > 0.92 && color.g > 0.92 && color.b > 0.92); } function getBorderWidth(styles = {}) { return Math.max( parsePixels(styles.borderTopWidth), parsePixels(styles.borderRightWidth), parsePixels(styles.borderBottomWidth), parsePixels(styles.borderLeftWidth) ); } function hasRadius(styles = {}) { return Boolean( parsePixels(styles.borderTopLeftRadius) || parsePixels(styles.borderTopRightRadius) || parsePixels(styles.borderBottomRightRadius) || parsePixels(styles.borderBottomLeftRadius) ); } function hasShadow(styles = {}) { return Boolean(styles.boxShadow && styles.boxShadow !== "none") || Boolean(styles.filter && /drop-shadow/i.test(styles.filter)); } function hasCssBackgroundImage(styles = {}) { return Boolean(styles.backgroundImage && styles.backgroundImage !== "none"); } function isContainerTag(captureNode) { return /^(HTML|BODY|DIV|SECTION|MAIN|ARTICLE|HEADER|NAV|FOOTER|ASIDE|UL|OL|LI|FORM)$/i.test(captureNode?.tag || ""); } function getNodeArea(captureNode) { const rect = getRenderRect(captureNode) || captureNode?.rect || {}; return Math.max(0, rect.width || 0) * Math.max(0, rect.height || 0); } function isLargePlainLayoutWrapper(captureNode) { if (!captureNode || captureNode.type === "TEXT" || isRasterFallbackNode(captureNode)) return false; if (!captureNode.children?.length || !isContainerTag(captureNode)) return false; const styles = captureNode.styles || {}; if (hasImageFill(captureNode) || captureNode.backgroundImages?.length || hasCssBackgroundImage(styles)) return false; if (!isLightOpaqueColor(styles.backgroundColor)) return false; const rect = getRenderRect(captureNode) || captureNode.rect || {}; const clipWidth = captureNode.clipRect?.width || captureNode.visibleRect?.width || rect.width || 0; const area = getNodeArea(captureNode); const isWideSection = clipWidth > 0 && (rect.width || 0) >= clipWidth * 0.55 && (rect.height || 0) >= 96; const isHugeContainer = area >= 180000; if (!isWideSection && !isHugeContainer) return false; const hasDecor = getBorderWidth(styles) > 0 || hasShadow(styles) || hasRadius(styles); return !hasDecor || area >= 300000; } function shouldRenderBackgroundFill(captureNode) { if (!captureNode || isRasterFallbackNode(captureNode)) return true; if (captureNode.visualRole === "layout-wrapper" && isLargePlainLayoutWrapper(captureNode)) return false; return !isLargePlainLayoutWrapper(captureNode); } function createNodeForCapture(captureNode) { if (captureNode.type === "TEXT") { return app.createText(); } if (captureNode.type === "RECTANGLE" || hasImageFill(captureNode) || captureNode.backgroundImages?.length) { return app.createRectangle(); } return app.createFrame(); } function createNodeForGroupedCapture(captureNode) { if (captureNode.type === "TEXT") { return app.createText(); } const hasChildren = Boolean(captureNode.children?.length); const isLeafMedia = hasImageFill(captureNode) && !hasChildren; const isLeafImageBackground = captureNode.backgroundImages?.length && !hasChildren; if (captureNode.type === "RECTANGLE" && (isLeafMedia || isLeafImageBackground)) { return app.createRectangle(); } return app.createFrame(); } function dataUrlToBytes(dataUrl) { const value = String(dataUrl || ""); const base64 = value.includes(",") ? value.split(",")[1] : value; if (!base64) return null; if (typeof app.base64Decode === "function") { return app.base64Decode(base64); } if (typeof atob !== "function") { return decodeBase64(base64); } const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } function decodeBase64(base64) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const clean = String(base64).replace(/[\r\n\s=]/g, ""); const bytes = []; let buffer = 0; let bits = 0; for (let index = 0; index < clean.length; index += 1) { const value = chars.indexOf(clean[index]); if (value < 0) continue; buffer = (buffer << 6) | value; bits += 6; if (bits >= 8) { bits -= 8; bytes.push((buffer >> bits) & 0xff); } } return new Uint8Array(bytes); } function normalizeUrl(url) { if (!url) return ""; try { const parsed = new URL(url); parsed.hash = ""; return parsed.href; } catch { return String(url).split("#")[0]; } } function resolveAsset(assets, url) { if (!assets || !url) return null; if (assets[url]) return assets[url]; const normalized = normalizeUrl(url); for (const [assetUrl, asset] of Object.entries(assets)) { if (normalizeUrl(assetUrl) === normalized) { return asset; } } return null; } function createImagePaint(asset, stats, scaleMode = "FILL") { if (!asset?.data || typeof app.createImage !== "function") return null; try { const bytes = dataUrlToBytes(asset.data); if (!bytes) return null; const image = app.createImage(bytes); const imageHash = image.hash || image.imageHash || image; if (!imageHash || typeof imageHash !== "string") { throw new Error("Pixso 没有返回有效的 imageHash"); } if (stats) stats.imagesImported += 1; return { type: "IMAGE", scaleMode, imageHash, opacity: 1 }; } catch (error) { console.warn("[Web to Pixso] Image fill failed", error); if (stats) stats.imageFailures += 1; return null; } } function getImageCandidates(captureNode) { const styles = captureNode.styles || {}; return [ hasImageFill(captureNode) ? captureNode.src : null, ...(captureNode.backgroundImages || []), ...[ styles.maskImage, styles.webkitMaskImage ].flatMap(value => { if (!value || value === "none") return []; const urls = []; const re = /url\((["']?)(.*?)\1\)/g; let match; while ((match = re.exec(value))) { if (match[2]) urls.push(match[2]); } return urls; }) ].filter(Boolean); } function applyVisualStyles(node, captureNode, assets, stats, scale) { const styles = captureNode.styles || {}; const fills = []; const background = rgbToPaint(styles.backgroundColor, null) || paintFromBackgroundImage(styles.backgroundImage); if (background && shouldRenderBackgroundFill(captureNode)) { fills.push(background); } else if (background && stats) { stats.transparentWrappers += 1; } const imageUrls = getImageCandidates(captureNode); if (imageUrls.length && stats) stats.imageCandidates += 1; const imageScaleMode = /contain/i.test(styles.backgroundSize || styles.objectFit || "") ? "FIT" : "FILL"; const imagePaint = imageUrls .map(url => createImagePaint(resolveAsset(assets, url), stats, imageScaleMode)) .find(Boolean); if (imagePaint) { fills.push(imagePaint); } if (fills.length) { node.fills = fills; } else if ("fills" in node && captureNode.type !== "TEXT") { node.fills = []; } const borderWidth = Math.max( parsePixels(styles.borderTopWidth), parsePixels(styles.borderRightWidth), parsePixels(styles.borderBottomWidth), parsePixels(styles.borderLeftWidth) ); if (borderWidth > 0 && styles.borderTopStyle !== "none") { node.strokes = [rgbToPaint(styles.borderTopColor, { r: 0, g: 0, b: 0, a: 1 })]; node.strokeWeight = Math.max(1, borderWidth * scale); } const radius = Math.max( parsePixels(styles.borderTopLeftRadius), parsePixels(styles.borderTopRightRadius), parsePixels(styles.borderBottomRightRadius), parsePixels(styles.borderBottomLeftRadius) ); if ("cornerRadius" in node) { node.cornerRadius = radius * scale; } if ("opacity" in node) { node.opacity = clamp01(Number(styles.opacity || 1)); } const shadow = parseBoxShadow(styles.boxShadow, scale) || parseDropShadowFilter(styles.filter, scale); if (shadow && "effects" in node) { node.effects = [shadow]; } if ("clipsContent" in node) { node.clipsContent = clipsChildren(styles); } } function applyTextStyles(node, captureNode, scale) { const styles = captureNode.styles || {}; const color = rgbToPaint(styles.color, { r: 0, g: 0, b: 0, a: 1 }); if ("textAutoResize" in node) { node.textAutoResize = "NONE"; } node.characters = captureNode.text || ""; node.fontSize = Math.max(1, parsePixels(styles.fontSize, 14) * scale); node.fills = [color]; if ("fontWeight" in node) { node.fontWeight = styles.fontWeight || "normal"; } if ("textAlignHorizontal" in node) { const align = String(styles.textAlign || "left").toUpperCase(); node.textAlignHorizontal = align === "CENTER" || align === "RIGHT" || align === "JUSTIFY" ? (align === "JUSTIFY" ? "JUSTIFIED" : align) : "LEFT"; } if ("lineHeight" in node) { const lineHeight = parsePixels(styles.lineHeight); if (lineHeight > 0) { node.lineHeight = { value: lineHeight * scale, unit: "PIXELS" }; } } if ("letterSpacing" in node) { const letterSpacing = parsePixels(styles.letterSpacing); if (letterSpacing) { node.letterSpacing = { value: letterSpacing * scale, unit: "PIXELS" }; } } const decoration = String(styles.textDecorationLine || styles.textDecoration || ""); if ("textDecoration" in node) { if (/line-through/i.test(decoration)) { node.textDecoration = "STRIKETHROUGH"; } else if (/underline/i.test(decoration)) { node.textDecoration = "UNDERLINE"; } } } function expandTextBounds(node, captureNode, rect, scale) { if (!rect || typeof node.resize !== "function") return; const fontSize = parsePixels(captureNode.styles?.fontSize, 14) * scale; const widthPadding = Math.max(4, Math.min(14, fontSize * 0.45)); const heightPadding = Math.max(2, Math.min(8, fontSize * 0.25)); setSize(node, rect.width * scale + widthPadding, rect.height * scale + heightPadding); } function getNodeIdentityText(captureNode) { return [ captureNode?.tag, captureNode?.name, captureNode?.attributes?.id, captureNode?.attributes?.class, captureNode?.attributes?.role ].filter(Boolean).join(" "); } function hasLayoutSignal(captureNode) { const layout = captureNode?.layout; if (!layout) return false; if (layout.type === "flex" || layout.type === "grid") return true; if (layout.type === "inferred" && layout.confidence >= 0.7) return true; return false; } function isHighValueLayoutContainer(captureNode) { if (!captureNode || captureNode.type === "TEXT") return false; if (isRasterFallbackNode(captureNode)) return false; if (!captureNode.rect || !captureNode.children?.length) return false; if (!hasLayoutSignal(captureNode)) return false; if (/^(HTML|BODY)$/i.test(captureNode.tag || "")) return false; const rect = getRenderRect(captureNode) || captureNode.rect; if (!rect || rect.width < 24 || rect.height < 16) return false; const identity = getNodeIdentityText(captureNode); const semanticMatch = /header|nav|navigation|navbar|menu|toolbar|tabs|actions|buttons|button-group|btns|grid|cards|card-list|product|goods|footer|links|columns|list|row|form|search/i.test(identity); const tagMatch = /^(HEADER|NAV|FOOTER|UL|OL|FORM)$/i.test(captureNode.tag || ""); const layout = captureNode.layout || {}; const childCount = layout.childCount || captureNode.children.length; const isReasonableFlex = layout.type === "flex" && childCount >= 2 && childCount <= 18 && rect.height <= 480; const isReasonableGrid = layout.type === "grid" && childCount >= 2 && childCount <= 60; const isFooterColumns = /footer/i.test(identity) && childCount >= 2; return tagMatch || semanticMatch || isReasonableFlex || isReasonableGrid || isFooterColumns; } function markSubtreeIds(captureNode, ids, options = {}) { if (!captureNode) return ids; const includeText = options.includeText === true; if (captureNode.id && (includeText || getLayerGroup(captureNode) !== "text")) { ids.add(captureNode.id); } for (const child of captureNode.children || []) { markSubtreeIds(child, ids, options); } return ids; } function collectLayoutRoots(captureNode, output = [], state = { order: 0 }, insideLayout = false) { if (!captureNode) return output; captureNode.__layoutOrder = state.order; state.order += 1; const isCandidate = isHighValueLayoutContainer(captureNode); if (isCandidate && !insideLayout) { output.push(captureNode); return output; } for (const child of captureNode.children || []) { collectLayoutRoots(child, output, state, insideLayout || isCandidate); } return output; } function sortLayoutRoots(nodes) { return nodes.sort((a, b) => { const ar = getRenderRect(a) || a.rect || {}; const br = getRenderRect(b) || b.rect || {}; const ya = ar.y || 0; const yb = br.y || 0; if (Math.abs(ya - yb) > 8) return ya - yb; const xa = ar.x || 0; const xb = br.x || 0; if (Math.abs(xa - xb) > 8) return xa - xb; return (a.__layoutOrder || 0) - (b.__layoutOrder || 0); }); } function shouldRenderGroupedNode(captureNode) { if (!captureNode) return false; if (shouldSkipImportNode(captureNode)) return false; if (captureNode.type === "TEXT") return true; if (isRasterFallbackNode(captureNode)) return false; if (hasVisualStyle(captureNode)) return true; if (captureNode.children?.length) return true; return false; } function getLayoutGroupName(captureNode, depth) { const base = captureNode.name || captureNode.tag || "Group"; if (depth !== 0) return base; const section = String(captureNode.section || "").toLowerCase(); const sectionLabel = section ? section.charAt(0).toUpperCase() + section.slice(1) : "Module"; const layoutType = captureNode.layout?.type ? ` ${captureNode.layout.type}` : ""; return `Layout - ${sectionLabel}${layoutType} - ${base}`; } function compareVisualRowMajor(a, b) { const ar = getRenderRect(a) || a.rect || {}; const br = getRenderRect(b) || b.rect || {}; const ay = ar.y || 0; const by = br.y || 0; if (Math.abs(ay - by) > 8) return ay - by; const ax = ar.x || 0; const bx = br.x || 0; if (Math.abs(ax - bx) > 8) return ax - bx; return 0; } function compareVisualColumnMajor(a, b) { const ar = getRenderRect(a) || a.rect || {}; const br = getRenderRect(b) || b.rect || {}; const ax = ar.x || 0; const bx = br.x || 0; if (Math.abs(ax - bx) > 8) return ax - bx; const ay = ar.y || 0; const by = br.y || 0; if (Math.abs(ay - by) > 8) return ay - by; return 0; } function getOrderedLayoutChildren(captureNode) { const children = [...(captureNode.children || [])]; const layout = captureNode.layout || {}; if (!children.length || !layout.type) return children; if (layout.type === "grid") return children.sort(compareVisualRowMajor); const direction = String(layout.direction || "").toLowerCase(); if (direction.includes("column")) { return children.sort((a, b) => compareVisualColumnMajor(a, b) || compareVisualRowMajor(a, b)); } return children.sort((a, b) => compareVisualRowMajor(a, b) || compareVisualColumnMajor(a, b)); } async function renderGroupedSubtree(captureNode, parent, parentRect, assets, stats, scale, depth = 0) { if (!shouldRenderGroupedNode(captureNode)) return null; if (getLayerGroup(captureNode) === "text") return null; const renderRect = getRenderRect(captureNode); if (!renderRect || renderRect.width <= 0 || renderRect.height <= 0) return null; const node = createNodeForGroupedCapture(captureNode); node.name = getLayoutGroupName(captureNode, depth); setPosition(node, renderRect, parentRect, scale); if (captureNode.type === "TEXT") { applyTextStyles(node, captureNode, scale); expandTextBounds(node, captureNode, renderRect, scale); } else { applyVisualStyles(node, captureNode, assets, stats, scale); if (applyAutoLayout(node, captureNode, scale) && stats) { stats.layoutFrames += 1; } } applyTransform(node, captureNode); parent.appendChild(node); stats.nodesCreated += 1; if (depth === 0) { stats.layoutGroups += 1; } const canContainChildren = typeof node.appendChild === "function" && captureNode.children?.length; if (!canContainChildren) return node; for (const child of getOrderedLayoutChildren(captureNode)) { try { await renderGroupedSubtree(child, node, renderRect, assets, stats, scale, depth + 1); } catch (error) { console.warn("[Web to Pixso] Grouped layer import skipped", error); stats.nodesSkipped += 1; } } return node; } function collectRenderableNodes(captureNode, output = [], state = { order: 0 }) { if (!captureNode) return output; if ( captureNode.tag !== "BODY" && captureNode.tag !== "HTML" && !shouldSkipImportNode(captureNode) && hasVisualStyle(captureNode) ) { captureNode.__importOrder = state.order; output.push(captureNode); } state.order += 1; for (const child of captureNode.children || []) { collectRenderableNodes(child, output, state); } return output; } function sortRenderableNodes(nodes) { return nodes.sort((a, b) => { const pa = getLayerPriority(a); const pb = getLayerPriority(b); if (pa !== pb) return pa - pb; const za = parseZIndex(a.styles?.zIndex); const zb = parseZIndex(b.styles?.zIndex); if (za !== zb) return za - zb; const ya = a.rect?.y || 0; const yb = b.rect?.y || 0; if (Math.abs(ya - yb) > 2000) return ya - yb; return (a.__importOrder || 0) - (b.__importOrder || 0); }); } function createLayerFrame(name, width, height) { const layer = app.createFrame(); layer.name = name; layer.x = 0; layer.y = 0; setSize(layer, width, height); layer.fills = []; if ("clipsContent" in layer) { layer.clipsContent = true; } return layer; } async function renderCaptureNode(captureNode, parent, rootRect, assets, stats, scale) { const renderRect = getRenderRect(captureNode); if (!renderRect) return null; if (renderRect.width <= 0 || renderRect.height <= 0) return null; if (shouldUseClipWrapper(captureNode)) { const wrapper = app.createFrame(); wrapper.name = `${captureNode.name || captureNode.tag || "Layer"} clip`; setPosition(wrapper, captureNode.visibleRect, rootRect, scale); wrapper.fills = []; if ("clipsContent" in wrapper) { wrapper.clipsContent = true; } const node = createNodeForCapture(captureNode); node.name = captureNode.name || captureNode.tag || "Layer"; setPosition(node, captureNode.rect, captureNode.visibleRect, scale); applyVisualStyles(node, captureNode, assets, stats, scale); if (applyAutoLayout(node, captureNode, scale) && stats) { stats.layoutFrames += 1; } applyTransform(node, captureNode); wrapper.appendChild(node); parent.appendChild(wrapper); if (stats) { stats.nodesCreated += 2; stats.nodesClipped += 1; } return wrapper; } const node = createNodeForCapture(captureNode); node.name = captureNode.name || captureNode.tag || "Layer"; setPosition(node, renderRect, rootRect, scale); if (captureNode.clipped && stats) stats.nodesClipped += 1; if (captureNode.type === "TEXT") { applyTextStyles(node, captureNode, scale); expandTextBounds(node, captureNode, renderRect, scale); } else { applyVisualStyles(node, captureNode, assets, stats, scale); if (applyAutoLayout(node, captureNode, scale) && stats) { stats.layoutFrames += 1; } } applyTransform(node, captureNode); parent.appendChild(node); stats.nodesCreated += 1; return node; } async function importCapture(data) { if (!data || (data.format !== "pixso-design-capture" && data.schema !== "web-to-pixso-capture")) { throw new Error("不是有效的 Web to Pixso 采集文件"); } await loadDefaultFont(); const page = getPage(); if (!page) { throw new Error("没有找到当前 Pixso 页面"); } const frame = app.createFrame(); const sourceWidth = Math.max(data.source?.selection?.width || data.canvas?.width || data.source?.actualViewportWidth || data.source?.requestedViewportWidth || data.source?.viewport?.width || 1440, 1); const scale = 1; const width = Math.min(Math.max(sourceWidth, 1), 20000); const height = Math.min(Math.max(data.canvas?.height || data.source?.selection?.height || data.source?.document?.height || 900, 1), 30000); frame.name = data.source?.title || "Web Capture"; frame.x = 0; frame.y = 0; setSize(frame, width, height); frame.fills = [rgbToPaint(data.canvas?.backgroundColor, { r: 1, g: 1, b: 1, a: 1 })]; if ("clipsContent" in frame) { frame.clipsContent = true; } page.appendChild(frame); const rootRect = { x: data.canvas?.x || data.source?.selection?.x || 0, y: data.canvas?.y || data.source?.selection?.y || 0, width: sourceWidth, height: data.canvas?.height || data.source?.selection?.height || data.source?.document?.height || 900 }; const stats = { nodesCreated: 0, imageCandidates: 0, imagesImported: 0, imageFailures: 0, nodesSkipped: 0, nodesClipped: 0, layoutGroups: 0, layoutFrames: 0, transparentWrappers: 0 }; const fallbackLayerName = `01 兜底截图层 / 对比底图-${Math.round(sourceWidth)}px`; const fallbackLayer = createLayerFrame(fallbackLayerName, width, height); const editableLayer = createLayerFrame("02 可编辑元素层", width, height); const textLayer = createLayerFrame("03 文字编辑层", width, height); frame.appendChild(fallbackLayer); frame.appendChild(editableLayer); frame.appendChild(textLayer); const consumedLayoutIds = new Set(); if (data.nodes) { const layoutRoots = sortLayoutRoots(collectLayoutRoots(data.nodes)); for (const layoutRoot of layoutRoots) { try { await renderGroupedSubtree(layoutRoot, editableLayer, rootRect, data.assets || {}, stats, scale); markSubtreeIds(layoutRoot, consumedLayoutIds); } catch (error) { console.warn("[Web to Pixso] Layout group import skipped", error); stats.nodesSkipped += 1; } } } if (data.nodes) { const renderableNodes = sortRenderableNodes(collectRenderableNodes(data.nodes)); for (const nodeData of renderableNodes) { try { if (!isRasterFallbackNode(nodeData) && consumedLayoutIds.has(nodeData.id)) { continue; } const layerGroup = getLayerGroup(nodeData); const parentLayer = layerGroup === "comparison" ? fallbackLayer : layerGroup === "text" ? textLayer : editableLayer; await renderCaptureNode(nodeData, parentLayer, rootRect, data.assets || {}, stats, scale); } catch (error) { console.warn("[Web to Pixso] Layer import skipped", error); stats.nodesSkipped += 1; } } } if ("selection" in app) { app.selection = [frame]; } else if (app.state) { app.state.currentSelection = [frame]; } if (app.viewport?.scrollAndZoomIntoView) { app.viewport.scrollAndZoomIntoView([frame]); } else if (app.viewport?.zoomToFit) { app.viewport.zoomToFit(); } return { nodeCount: data.diagnostics?.nodeCount || 0, assetCount: data.diagnostics?.assetCount || 0, importWidth: width, ...stats }; } function postStatus(text, status = "info") { app.ui?.postMessage?.({ text, status }); } app.showUI(__html__, { width: PLUGIN_WIDTH, height: PLUGIN_HEIGHT }); app.ui.onmessage = async message => { if (message?.type === "CLOSE") { app.closePlugin?.(); return; } if (message?.type !== "IMPORT_CAPTURE") return; try { postStatus("正在创建画板和图层...", "loading"); const stats = await importCapture(message.data, message.options || {}); const text = `导入完成:创建 ${stats.nodesCreated}/${stats.nodeCount} 层,布局组 ${stats.layoutGroups} 个,自动布局 ${stats.layoutFrames} 组,裁剪 ${stats.nodesClipped} 层,跳过 ${stats.nodesSkipped} 层,宽度 ${stats.importWidth}px,图片 ${stats.imagesImported}/${stats.imageCandidates}`; postStatus(text, "success"); notify(text, "success"); } catch (error) { const text = error.message || String(error); postStatus(text, "error"); notify(text, "error"); } };