Files
李进 330f4e96ce feat: 加入 web-to-pixso 插件源码
Chrome 扩展,用于从网页提取 DOM 树为 JSON 格式。
核心文件:capture.js(76KB,提取逻辑)
2026-07-27 12:22:27 +08:00

1128 lines
38 KiB
JavaScript

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");
}
};