导入后会创建一个以网页标题命名的画板,并尽量还原文本、图片、背景、边框和圆角。
+ + 使用说明 +diff --git a/web-to-pixso/README.md b/web-to-pixso/README.md new file mode 100644 index 0000000..6d8e2b2 --- /dev/null +++ b/web-to-pixso/README.md @@ -0,0 +1,30 @@ +# Web to Pixso + +一套对标 `figma-capture-extension` 使用体验的网页采集工具,包含 Chrome 扩展和 Pixso 导入插件。 + +## 目录 + +- `manifest.json`、`popup.*`、`background.js`、`capture.js`、`runner.js`:Chrome 扩展 +- `pixso-plugin/`:Pixso 插件,用于导入扩展导出的 JSON 文件 +- `logo/`:扩展与插件图标 + +## 使用 + +1. 打开 `chrome://extensions/`,开启开发者模式。 +2. 点击“加载已解压的扩展程序”,选择本目录 `web-to-pixso`。 +3. 打开要采集的网页,点击扩展图标,按需开启“跨域图片代理模式”,点击“开始采集”。 +4. 扩展会下载一个 `web-to-pixso/*.json` 文件。 +5. 在 Pixso 中导入 `pixso-plugin/manifest.json`,运行插件并选择上一步下载的 JSON 文件。若旧版导入器要求 `plugin.json`,目录内也保留了同内容兼容文件。 + +## 数据格式 + +扩展导出的文件格式为 `pixso-design-capture`,包含页面来源、画布尺寸、DOM 节点树、图片资源、字体和诊断信息。Pixso 插件会尽量还原: + +- 文本图层 +- 图片和背景图片 +- 背景色、边框、圆角、透明度 +- DOM 层级和基础坐标 + +## 注意 + +网页到设计稿的转换无法做到 100% 语义等价,复杂 CSS、canvas、视频帧、伪元素和部分字体效果可能需要在 Pixso 中二次微调。 diff --git a/web-to-pixso/background.js b/web-to-pixso/background.js new file mode 100644 index 0000000..84c296d --- /dev/null +++ b/web-to-pixso/background.js @@ -0,0 +1,419 @@ +const CAPTURE_FILE = "capture.js"; +const RUNNER_FILE = "runner.js"; +const POPUP_PANEL_FILE = "popup-panel.js"; +const ELEMENT_PICKER_FILE = "element-picker.js"; +const SETTINGS_KEY = "webToPixsoSettings"; +const DEFAULT_SETTINGS = { + useProxy: false, + concurrency: "8", + captureMode: "mixed", + captureWidth: null +}; +const MIN_CAPTURE_WIDTH = 320; +const MAX_CAPTURE_WIDTH = 3840; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function normalizeSettings(value = {}) { + const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency); + const captureWidth = normalizeCaptureWidth(value.captureWidth, null); + return { + useProxy: Boolean(value.useProxy), + concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency) + ? concurrency + : DEFAULT_SETTINGS.concurrency, + captureMode: value.captureMode === "editable" ? "editable" : "mixed", + captureWidth + }; +} + +function normalizeCaptureWidth(value, fallback = null) { + const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number)); +} + +function definedWindowBounds(bounds = {}) { + return Object.fromEntries( + Object.entries(bounds).filter(([, value]) => Number.isFinite(value)) + ); +} + +function assertCaptureableTab(tab) { + if (!tab?.id || !tab.url) { + throw new Error("没有可采集的当前标签页"); + } + + if (/^(chrome|edge|about|devtools|chrome-extension):/i.test(tab.url)) { + throw new Error("浏览器内置页面不支持采集,请切换到普通网页"); + } +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + assertCaptureableTab(tab); + return tab; +} + +async function runCapture(tabId, options) { + await chrome.scripting.executeScript({ + target: { tabId }, + files: [CAPTURE_FILE] + }); + + await chrome.scripting.executeScript({ + target: { tabId }, + files: [RUNNER_FILE] + }); + + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: captureOptions => window.__webToPixsoRunCapture(captureOptions), + args: [options] + }); + + if (!result) { + throw new Error("页面没有返回采集结果"); + } + + return result; +} + +async function getTabViewportWidth(tabId) { + try { + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0) + }); + return normalizeCaptureWidth(result, null); + } catch { + return null; + } +} + +async function prepareCaptureViewport(tab, requestedWidth) { + const targetWidth = normalizeCaptureWidth(requestedWidth, null); + const beforeViewportWidth = await getTabViewportWidth(tab.id); + const noop = async () => {}; + if (!targetWidth || !beforeViewportWidth || Math.abs(beforeViewportWidth - targetWidth) <= 2) { + return { + restore: noop, + requestedWidth: targetWidth || beforeViewportWidth, + beforeViewportWidth, + actualViewportWidth: beforeViewportWidth, + resizedWindow: false + }; + } + + if (!tab.windowId || !chrome.windows?.get || !chrome.windows?.update) { + throw new Error("当前浏览器不支持临时调整采集视口宽度"); + } + + const originalWindow = await chrome.windows.get(tab.windowId); + const originalState = originalWindow.state || "normal"; + const originalBounds = { + left: originalWindow.left, + top: originalWindow.top, + width: originalWindow.width, + height: originalWindow.height + }; + + const restore = async () => { + try { + if (originalState !== "normal") { + await chrome.windows.update(tab.windowId, { state: "normal" }); + await delay(120); + } + const restoreBounds = definedWindowBounds(originalBounds); + if (Object.keys(restoreBounds).length) { + await chrome.windows.update(tab.windowId, restoreBounds); + } + if (originalState !== "normal") { + await delay(120); + await chrome.windows.update(tab.windowId, { state: originalState }); + } + await delay(250); + } catch { + // Restoring the user's window is best effort only. + } + }; + + try { + if (originalState !== "normal") { + await chrome.windows.update(tab.windowId, { state: "normal" }); + await delay(250); + } + + let currentViewportWidth = beforeViewportWidth; + let currentWindow = await chrome.windows.get(tab.windowId); + for (let attempt = 0; attempt < 2; attempt += 1) { + const delta = targetWidth - currentViewportWidth; + const nextWidth = Math.max(360, Math.round((currentWindow.width || targetWidth) + delta)); + await chrome.windows.update(tab.windowId, { width: nextWidth }); + await delay(650); + currentViewportWidth = await getTabViewportWidth(tab.id) || currentViewportWidth; + if (Math.abs(currentViewportWidth - targetWidth) <= 2) break; + currentWindow = await chrome.windows.get(tab.windowId); + } + + if (Math.abs(currentViewportWidth - targetWidth) > 2) { + throw new Error(`采集视口未生效:目标 ${targetWidth}px,实际 ${currentViewportWidth}px。请退出全屏或手动放宽浏览器窗口后重试。`); + } + + return { + restore, + requestedWidth: targetWidth, + beforeViewportWidth, + actualViewportWidth: currentViewportWidth, + resizedWindow: true + }; + } catch (error) { + await restore(); + throw error; + } +} + +async function captureCurrentTab(tab, settings) { + const viewport = await prepareCaptureViewport(tab, settings.captureWidth); + try { + const data = await runCapture(tab.id, { + ...settings, + captureWidth: viewport.requestedWidth + }); + data.capture = { + ...(data.capture || {}), + resizedWindow: viewport.resizedWindow, + usedTemporaryWindow: viewport.resizedWindow, + requestedWidth: viewport.requestedWidth || data.source?.actualViewportWidth || data.canvas?.width, + beforeViewportWidth: viewport.beforeViewportWidth, + actualViewportWidth: data.source?.actualViewportWidth || viewport.actualViewportWidth + }; + return data; + } finally { + await viewport.restore(); + } +} + +async function downloadCapture(data) { + const json = JSON.stringify(data, null, 2); + const encodedJson = arrayBufferToBase64(new TextEncoder().encode(json)); + const url = `data:application/json;charset=utf-8;base64,${encodedJson}`; + const title = data.source?.title || "webpage"; + const safeTitle = title + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .slice(0, 64) || "webpage"; + const filename = `web-to-pixso/${safeTitle}-${Date.now()}.json`; + + await chrome.downloads.download({ + url, + filename, + saveAs: true + }); + + return filename; +} + +async function startCapture(options) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = await getActiveTab(); + const data = await captureCurrentTab(tab, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function startCaptureFromSender(options, sender) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await captureCurrentTab(tab, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function captureClipboardFromSender(options, sender) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await captureCurrentTab(tab, settings); + return { + ok: true, + json: JSON.stringify(data, null, 2), + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function startElementCapture(options, sender) { + const settings = { + ...normalizeSettings(options), + captureWidth: null, + selectionId: options?.selectionId, + selectionWidth: Math.max(1, Math.round(Number(options?.selectionWidth || 0))), + captureMode: "mixed" + }; + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await runCapture(tab.id, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + selectionWidth: data.import?.defaultWidth + }; +} + +async function fetchWithTimeout(url, timeout = 10000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + return await fetch(url, { + signal: controller.signal, + credentials: "include", + cache: "force-cache" + }); + } finally { + clearTimeout(timeoutId); + } +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + + return btoa(binary); +} + +async function proxyFetchAsset(url) { + if (!/^https?:\/\//i.test(url)) { + return { ok: false, error: "仅支持 http/https 图片代理" }; + } + + const response = await fetchWithTimeout(url, 12000); + if (!response.ok) { + return { ok: false, status: response.status, error: `HTTP ${response.status}` }; + } + + const contentType = response.headers.get("content-type") || "application/octet-stream"; + const buffer = await response.arrayBuffer(); + + return { + ok: true, + status: response.status, + contentType, + base64: arrayBufferToBase64(buffer) + }; +} + +async function captureVisibleTab(sender) { + if (!sender?.tab?.windowId || !chrome.tabs?.captureVisibleTab) { + return { ok: false, error: "当前标签页截图不可用" }; + } + + try { + const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, { + format: "png" + }); + return { ok: true, dataUrl }; + } catch (error) { + return { + ok: false, + error: error.message || String(error), + nonFatal: true + }; + } +} + +async function showInPagePanel(tab) { + assertCaptureableTab(tab); + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: [ELEMENT_PICKER_FILE, POPUP_PANEL_FILE] + }); + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => window.__webToPixsoShowPanel?.() + }); +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }).then(result => { + chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(result[SETTINGS_KEY]) }); + }); +}); + +chrome.action.onClicked.addListener(tab => { + showInPagePanel(tab).catch(error => { + console.warn("[Web to Pixso] Failed to open panel", error); + }); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type === "PIXSO_CAPTURE_START") { + startCapture(message.options) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_ELEMENT_START") { + startElementCapture(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_CURRENT_TAB") { + startCaptureFromSender(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_CLIPBOARD") { + captureClipboardFromSender(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_FETCH_ASSET") { + proxyFetchAsset(message.url) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_VISIBLE_TAB") { + captureVisibleTab(sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + return false; +}); diff --git a/web-to-pixso/capture.js b/web-to-pixso/capture.js new file mode 100644 index 0000000..ad68999 --- /dev/null +++ b/web-to-pixso/capture.js @@ -0,0 +1,2018 @@ +(function () { + "use strict"; + + const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "META", "LINK", "TEMPLATE"]); + const STYLE_PROPS = [ + ["display", "display"], + ["visibility", "visibility"], + ["position", "position"], + ["zIndex", "z-index"], + ["opacity", "opacity"], + ["overflow", "overflow"], + ["overflowX", "overflow-x"], + ["overflowY", "overflow-y"], + ["backgroundColor", "background-color"], + ["backgroundImage", "background-image"], + ["backgroundPosition", "background-position"], + ["backgroundSize", "background-size"], + ["backgroundRepeat", "background-repeat"], + ["backgroundClip", "background-clip"], + ["backgroundOrigin", "background-origin"], + ["objectFit", "object-fit"], + ["objectPosition", "object-position"], + ["maskImage", "mask-image"], + ["webkitMaskImage", "-webkit-mask-image"], + ["maskPosition", "mask-position"], + ["webkitMaskPosition", "-webkit-mask-position"], + ["maskSize", "mask-size"], + ["webkitMaskSize", "-webkit-mask-size"], + ["borderTopWidth", "border-top-width"], + ["borderRightWidth", "border-right-width"], + ["borderBottomWidth", "border-bottom-width"], + ["borderLeftWidth", "border-left-width"], + ["borderTopColor", "border-top-color"], + ["borderRightColor", "border-right-color"], + ["borderBottomColor", "border-bottom-color"], + ["borderLeftColor", "border-left-color"], + ["borderTopStyle", "border-top-style"], + ["borderRightStyle", "border-right-style"], + ["borderBottomStyle", "border-bottom-style"], + ["borderLeftStyle", "border-left-style"], + ["borderTopLeftRadius", "border-top-left-radius"], + ["borderTopRightRadius", "border-top-right-radius"], + ["borderBottomRightRadius", "border-bottom-right-radius"], + ["borderBottomLeftRadius", "border-bottom-left-radius"], + ["boxShadow", "box-shadow"], + ["filter", "filter"], + ["backdropFilter", "backdrop-filter"], + ["color", "color"], + ["fontFamily", "font-family"], + ["fontSize", "font-size"], + ["fontWeight", "font-weight"], + ["fontStyle", "font-style"], + ["lineHeight", "line-height"], + ["letterSpacing", "letter-spacing"], + ["textAlign", "text-align"], + ["textTransform", "text-transform"], + ["textDecorationLine", "text-decoration-line"], + ["whiteSpace", "white-space"], + ["wordBreak", "word-break"], + ["transform", "transform"], + ["transformOrigin", "transform-origin"], + ["clipPath", "clip-path"] + ]; + const LAYOUT_PROPS = [ + ["flexDirection", "flex-direction"], + ["flexWrap", "flex-wrap"], + ["justifyContent", "justify-content"], + ["alignItems", "align-items"], + ["alignContent", "align-content"], + ["gap", "gap"], + ["rowGap", "row-gap"], + ["columnGap", "column-gap"], + ["gridTemplateColumns", "grid-template-columns"], + ["gridTemplateRows", "grid-template-rows"], + ["gridAutoFlow", "grid-auto-flow"], + ["paddingTop", "padding-top"], + ["paddingRight", "padding-right"], + ["paddingBottom", "padding-bottom"], + ["paddingLeft", "padding-left"], + ["marginTop", "margin-top"], + ["marginRight", "margin-right"], + ["marginBottom", "margin-bottom"], + ["marginLeft", "margin-left"], + ["width", "width"], + ["height", "height"], + ["minWidth", "min-width"], + ["maxWidth", "max-width"], + ["minHeight", "min-height"], + ["maxHeight", "max-height"] + ]; + + let nodeCounter = 0; + let rasterCounter = 0; + const CAPTURE_SCHEMA = "web-to-pixso-capture"; + const CAPTURE_SCHEMA_VERSION = "1.1.1"; + + function isWebToPixsoElement(element) { + if (!element?.closest) return false; + return Boolean(element.closest([ + "#__web_to_pixso_panel_root__", + "#__web_to_pixso_picker_root__", + "#__web_to_pixso_picker_box__", + "#__web_to_pixso_picker_label__" + ].join(","))); + } + + function absoluteRect(rect, style) { + const isViewportAnchored = style?.position === "fixed"; + return { + x: round(rect.left + (isViewportAnchored ? 0 : window.scrollX)), + y: round(rect.top + (isViewportAnchored ? 0 : window.scrollY)), + width: round(rect.width), + height: round(rect.height) + }; + } + + function round(value) { + return Math.round((Number(value) || 0) * 100) / 100; + } + + function isVisible(element, rect, style) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + if (style.display === "none" || style.visibility === "hidden") return false; + if (Number(style.opacity) === 0) return false; + return true; + } + + function intersectsDocument(rect) { + const documentRect = getDocumentRect(); + return intersectRects(rect, documentRect); + } + + function intersectWithClip(rect, clipRect) { + return clipRect ? intersectRects(rect, clipRect) : intersectsDocument(rect); + } + + function intersectRects(a, b) { + if (!a) return b || null; + if (!b) return a || null; + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + if (x2 <= x1 || y2 <= y1) return null; + return { + x: round(x1), + y: round(y1), + width: round(x2 - x1), + height: round(y2 - y1) + }; + } + + 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 clipsChildren(styles) { + const overflow = `${styles.overflow || ""} ${styles.overflowX || ""} ${styles.overflowY || ""}`; + return /(hidden|clip|scroll|auto)/i.test(overflow); + } + + function isCarouselClone(element) { + const className = String(element.className || ""); + if (/(^|\s)(slick-cloned|swiper-slide-duplicate|swiper-slide-duplicate-active|swiper-slide-duplicate-next|swiper-slide-duplicate-prev)(\s|$)/i.test(className)) { + return true; + } + if (element.getAttribute("data-swiper-slide-index") && /swiper-slide-duplicate/i.test(className)) { + return true; + } + if (element.getAttribute("aria-hidden") === "true" && /(^|\s)(slick-slide|swiper-slide)(\s|$)/i.test(className)) { + return true; + } + return false; + } + + function shouldClampToClip(element, rect, clipRect) { + if (!clipRect || !rect) return false; + const className = String(element.className || ""); + const isTrack = /(slick-track|swiper-wrapper|carousel|marquee|scroll|slider|slide-track)/i.test(className); + const isMedia = /^(IMG|PICTURE|VIDEO|CANVAS)$/i.test(element.tagName); + const isClippedMedia = isMedia && rectsDiffer(rect, clipRect); + const isVeryWide = rect.width > Math.max(window.innerWidth * 1.5, clipRect.width * 1.5); + const isVeryTall = rect.height > Math.max(window.innerHeight * 2, clipRect.height * 2); + return isTrack || isClippedMedia || isVeryWide || isVeryTall; + } + + function getChildNodes(element) { + const nodes = Array.from(element.childNodes || []); + if (element.shadowRoot) { + nodes.push(...Array.from(element.shadowRoot.childNodes || [])); + } + return nodes; + } + + function isHeaderLikeElement(element) { + if (!element) return false; + const name = `${element.tagName || ""} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + return /^(HEADER|NAV)$/i.test(element.tagName || "") || + /header|nav|navigation|topbar|navbar|menu/i.test(String(name)); + } + + function isTopHeaderElement(element, rect, styles) { + if (!isHeaderLikeElement(element)) return false; + const tag = element?.tagName || ""; + const name = `${tag} ${element?.id || ""} ${element?.className || ""} ${element?.getAttribute?.("role") || ""}`; + const fixedOrSticky = styles?.position === "fixed" || styles?.position === "sticky"; + const nearTop = (rect?.y || 0) <= 140 || fixedOrSticky; + const headerSized = (rect?.height || 0) >= 20 && + (rect?.height || 0) <= Math.max(180, window.innerHeight * 0.18) && + (rect?.width || 0) >= window.innerWidth * 0.35; + const strongSemantic = /^(HEADER)$/i.test(tag) || /cloud-header|site-header|page-header|topbar|navbar|navigation/i.test(String(name)); + const navSemantic = /^(NAV)$/i.test(tag) || /nav|menu/i.test(String(name)); + return nearTop && headerSized && (strongSemantic || navSemantic); + } + + function inferSection(element, rect, styles, inheritedSection) { + if (inheritedSection && inheritedSection !== "document") return inheritedSection; + const tag = element?.tagName || ""; + const name = `${tag} ${element?.id || ""} ${element?.className || ""} ${element?.getAttribute?.("role") || ""}`; + if (isTopHeaderElement(element, rect, styles)) return "header"; + if (/^(FOOTER)$/i.test(tag) || /footer/i.test(String(name))) return "footer"; + if (rect?.y < Math.max(window.innerHeight * 1.4, 900) && /hero|banner|kv|carousel|swiper|slick|slider|slide|campaign|masthead/i.test(String(name))) { + return "hero"; + } + if (styles && isLikelyHeroOrCarousel(element, rect, styles)) return "hero"; + return inheritedSection || "content"; + } + + function layerMetaFor(type, options = {}) { + if (options.rasterMode === "comparison") { + return { layerGroup: "comparison", layerPriority: 0, rasterRole: "comparison" }; + } + if (options.rasterMode) { + return { layerGroup: "comparison", layerPriority: 0, rasterRole: options.rasterMode }; + } + if (type === "TEXT") { + return { layerGroup: "text", layerPriority: 200 }; + } + return { layerGroup: "editable", layerPriority: 100 }; + } + + function inferRasterSection(target = {}) { + const name = String(target.name || ""); + const mode = String(target.mode || ""); + if (/header/i.test(name)) return "header"; + if (/footer/i.test(name)) return "footer"; + if (/hero|carousel/i.test(name) || mode === "hero-background") return "hero"; + if (mode === "comparison") return "document"; + return "content"; + } + + function getDocumentRect() { + const doc = document.documentElement; + const body = document.body; + return { + x: 0, + y: 0, + width: Math.max(window.innerWidth, doc.clientWidth), + height: Math.max(doc.scrollHeight, body.scrollHeight, doc.clientHeight) + }; + } + + function getViewportRect() { + return { + x: window.scrollX, + y: window.scrollY, + width: window.innerWidth, + height: window.innerHeight + }; + } + + function extractStyles(computed) { + const styles = {}; + for (const [key, cssProp] of STYLE_PROPS) { + styles[key] = computed.getPropertyValue(cssProp); + } + return styles; + } + + function parsePixels(value, fallback = 0) { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + function countDistinctPositions(rects, key, tolerance = 6) { + const positions = []; + for (const rect of rects || []) { + const value = rect?.[key]; + if (!Number.isFinite(value)) continue; + if (!positions.some(position => Math.abs(position - value) <= tolerance)) { + positions.push(value); + } + } + return positions.length; + } + + function inferGridMetrics(childRects, gap = 0) { + const tolerance = Math.max(6, Math.min(18, Number(gap || 0) * 0.5)); + const columns = countDistinctPositions(childRects, "x", tolerance); + const rows = countDistinctPositions(childRects, "y", tolerance); + return { + columnCount: Math.max(1, columns || 1), + rowCount: Math.max(1, rows || 1) + }; + } + + function isGridLikeLayout({ isGrid, isFlex, isTable, semanticLayout, styles, childRects, inferredGrid }) { + if (isGrid) return true; + if (!childRects?.length || childRects.length < 4) return false; + const hasRowsAndColumns = inferredGrid.columnCount >= 2 && inferredGrid.rowCount >= 2; + if (!hasRowsAndColumns) return false; + const wraps = isFlex && /wrap/i.test(styles.flexWrap || ""); + const semanticGrid = semanticLayout && /grid|cards|card|product|goods|coupon|list|items|row|cols|columns|footer/i.test(String(styles.className || "")); + const visuallyGrid = inferredGrid.columnCount * inferredGrid.rowCount >= Math.max(4, childRects.length * 0.7); + return !isTable && visuallyGrid && (wraps || semanticLayout || semanticGrid); + } + + function extractLayout(computed, element, rect) { + const display = computed.getPropertyValue("display"); + const isFlex = /\b(inline-)?flex\b/i.test(display); + const isGrid = /\b(inline-)?grid\b/i.test(display); + const isTable = /\btable\b/i.test(display); + const children = Array.from(element.children || []).filter(child => { + const childRect = child.getBoundingClientRect(); + const childStyle = window.getComputedStyle(child); + return isVisible(child, childRect, childStyle); + }); + const hasLayoutChildren = children.length >= 2; + const className = String(element.className || ""); + const semanticLayout = /nav|menu|list|grid|row|column|cols|cards|items|tabs|footer|header|toolbar|form|actions|buttons/i.test(className); + + if (!isFlex && !isGrid && !isTable && !semanticLayout) return null; + if (!hasLayoutChildren && !isFlex && !isGrid) return null; + + const styles = {}; + for (const [key, cssProp] of LAYOUT_PROPS) { + styles[key] = computed.getPropertyValue(cssProp); + } + styles.className = className; + + let inferredDirection = styles.flexDirection || "row"; + if (!isFlex && hasLayoutChildren) { + const rects = children.map(child => child.getBoundingClientRect()); + const sameRowCount = rects.filter(childRect => Math.abs(childRect.top - rects[0].top) < 6).length; + inferredDirection = sameRowCount >= Math.max(2, children.length * 0.65) ? "row" : "column"; + } + + const childRects = children.map(child => { + const childStyle = window.getComputedStyle(child); + return absoluteRect(child.getBoundingClientRect(), childStyle); + }); + const inferredGrid = inferGridMetrics(childRects, parsePixels(styles.columnGap || styles.gap)); + const gridLike = isGridLikeLayout({ isGrid, isFlex, isTable, semanticLayout, styles, childRects, inferredGrid }); + const layoutType = gridLike ? "grid" : isFlex ? "flex" : isTable ? "table" : "inferred"; + + return { + type: layoutType, + display, + direction: inferredDirection, + wrap: styles.flexWrap, + justifyContent: styles.justifyContent, + alignItems: styles.alignItems, + alignContent: styles.alignContent, + gap: parsePixels(styles.gap), + rowGap: parsePixels(styles.rowGap), + columnGap: parsePixels(styles.columnGap), + padding: { + top: parsePixels(styles.paddingTop), + right: parsePixels(styles.paddingRight), + bottom: parsePixels(styles.paddingBottom), + left: parsePixels(styles.paddingLeft) + }, + margin: { + top: parsePixels(styles.marginTop), + right: parsePixels(styles.marginRight), + bottom: parsePixels(styles.marginBottom), + left: parsePixels(styles.marginLeft) + }, + grid: gridLike ? { + columns: styles.gridTemplateColumns, + rows: styles.gridTemplateRows, + autoFlow: styles.gridAutoFlow, + columnCount: inferredGrid.columnCount, + rowCount: inferredGrid.rowCount + } : null, + inferredGrid, + sizing: { + width: styles.width, + height: styles.height, + minWidth: styles.minWidth, + maxWidth: styles.maxWidth, + minHeight: styles.minHeight, + maxHeight: styles.maxHeight + }, + childCount: children.length, + childRects, + confidence: isFlex || isGrid ? 1 : gridLike ? 0.86 : semanticLayout ? 0.72 : 0.55, + rect: { + width: round(rect.width), + height: round(rect.height) + } + }; + } + + function collectAttributes(element) { + const attrs = {}; + for (const attr of Array.from(element.attributes || [])) { + if ( + attr.name === "id" || + attr.name === "class" || + attr.name === "alt" || + attr.name === "title" || + attr.name === "href" || + attr.name === "src" || + attr.name === "role" || + attr.name.startsWith("aria-") + ) { + attrs[attr.name] = attr.value; + } + } + return attrs; + } + + function extractBackgroundUrls(value) { + if (!value || value === "none") return []; + const urls = []; + const re = /url\((["']?)(.*?)\1\)/g; + let match; + while ((match = re.exec(value))) { + if (!match[2] || match[2] === "undefined" || match[2] === "null") continue; + try { + const url = new URL(match[2], document.baseURI).href; + if (!/\/undefined(?:$|[?#])/.test(url)) urls.push(url); + } catch { + urls.push(match[2]); + } + } + return urls; + } + + function extractStyleImageUrls(styles) { + return [ + ...extractBackgroundUrls(styles.backgroundImage), + ...extractBackgroundUrls(styles.maskImage), + ...extractBackgroundUrls(styles.webkitMaskImage) + ]; + } + + function parseSrcset(value) { + if (!value) return []; + return String(value) + .split(",") + .map(item => item.trim().split(/\s+/)[0]) + .filter(Boolean); + } + + function splitConcatenatedUrls(value) { + if (!value) return []; + const text = String(value).trim(); + if (!text) return []; + const matches = text.match(/https?:\/\/(?:(?!https?:\/\/).)+/gi); + if (matches && matches.length > 1) return matches; + return [text]; + } + + function canUseDescendantSources(element) { + return /^(PICTURE|VIDEO|AUDIO)$/i.test(element.tagName); + } + + function getElementImageCandidates(element) { + const candidates = []; + const add = value => { + for (const item of splitConcatenatedUrls(value)) { + const normalized = normalizeAssetUrl(item); + if (normalized) candidates.push(normalized); + } + }; + + add(element.currentSrc); + add(element.src); + add(element.poster); + add(element.getAttribute?.("src")); + add(element.getAttribute?.("data-src")); + add(element.getAttribute?.("data-original")); + add(element.getAttribute?.("data-lazy")); + add(element.getAttribute?.("data-lazy-src")); + add(element.getAttribute?.("data-bg")); + add(element.getAttribute?.("data-background")); + + for (const value of parseSrcset(element.srcset || element.getAttribute?.("srcset"))) { + add(value); + } + if (canUseDescendantSources(element)) { + for (const source of Array.from(element.querySelectorAll?.("source") || [])) { + for (const value of parseSrcset(source.srcset || source.getAttribute("srcset"))) { + add(value); + } + add(source.src); + } + } + + return Array.from(new Set(candidates)); + } + + function normalizeText(value) { + return String(value || "").replace(/\s+/g, "").trim(); + } + + 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 hasMaskImage(styles) { + return Boolean( + (styles.maskImage && styles.maskImage !== "none") || + (styles.webkitMaskImage && styles.webkitMaskImage !== "none") + ); + } + + function hasUsableBackgroundImage(styles) { + return Boolean(styles.backgroundImage && styles.backgroundImage !== "none"); + } + + function hasVisiblePaintColor(value) { + return Boolean(value && value !== "transparent" && !/rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value)); + } + + function isLightOpaquePaint(value) { + if (!value || value === "transparent") return false; + const rgba = String(value).match(/rgba?\(([^)]+)\)/i); + if (rgba) { + const parts = rgba[1].split(",").map(part => parseFloat(part.trim())); + const alpha = parts[3] == null ? 1 : parts[3]; + return alpha > 0.92 && parts[0] >= 235 && parts[1] >= 235 && parts[2] >= 235; + } + const hex = String(value).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!hex) return false; + const body = hex[1].length === 3 ? hex[1].split("").map(char => char + char).join("") : hex[1]; + return parseInt(body.slice(0, 2), 16) >= 235 && + parseInt(body.slice(2, 4), 16) >= 235 && + parseInt(body.slice(4, 6), 16) >= 235; + } + + function getMaxBorderWidth(styles) { + return Math.max( + parsePixels(styles.borderTopWidth), + parsePixels(styles.borderRightWidth), + parsePixels(styles.borderBottomWidth), + parsePixels(styles.borderLeftWidth) + ); + } + + function hasCornerRadius(styles) { + return Boolean( + parsePixels(styles.borderTopLeftRadius) || + parsePixels(styles.borderTopRightRadius) || + parsePixels(styles.borderBottomRightRadius) || + parsePixels(styles.borderBottomLeftRadius) + ); + } + + function classifyVisualRole(element, styles, children, currentSrc, backgroundUrls, layout) { + const tag = element?.tagName || ""; + if (/^(IMG|PICTURE|VIDEO|CANVAS|SOURCE)$/i.test(tag) || currentSrc || backgroundUrls?.length) { + return "image-node"; + } + const hasDecor = getMaxBorderWidth(styles) > 0 || + hasCornerRadius(styles) || + (styles.boxShadow && styles.boxShadow !== "none") || + (styles.filter && /drop-shadow/i.test(styles.filter)); + const isContainer = /^(HTML|BODY|DIV|SECTION|MAIN|ARTICLE|HEADER|NAV|FOOTER|ASIDE|UL|OL|LI|FORM)$/i.test(tag); + if ( + (layout || children?.length) && + isContainer && + !hasUsableBackgroundImage(styles) && + !hasDecor && + (!hasVisiblePaintColor(styles.backgroundColor) || isLightOpaquePaint(styles.backgroundColor)) + ) { + return "layout-wrapper"; + } + if ( + hasVisiblePaintColor(styles.backgroundColor) || + hasUsableBackgroundImage(styles) || + hasDecor + ) { + return "paint-node"; + } + if (layout || children?.length) return "layout-wrapper"; + return "paint-node"; + } + + function shouldSkipPseudoBackground(styles, parentRect) { + if (!styles.backgroundImage || styles.backgroundImage === "none") return false; + if (styles.backgroundRepeat && styles.backgroundRepeat !== "no-repeat") return true; + if (/auto\s+\d+px/i.test(styles.backgroundSize || "")) return true; + if (/-\d+(?:\.\d+)?px/.test(styles.backgroundPosition || "")) return true; + const parentArea = Math.max(1, (parentRect?.width || 1) * (parentRect?.height || 1)); + if (parentArea > 12000 && /(?:^|\s)(?:\d+px\s+auto|auto\s+\d+px|auto)(?:\s|$)/i.test(styles.backgroundSize || "")) return true; + return false; + } + + function hasMeaningfulElementChildren(element) { + return Array.from(element.children || []).some(child => { + const rect = child.getBoundingClientRect(); + const style = window.getComputedStyle(child); + return rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity || 1) > 0; + }); + } + + function hasWhiteTextDescendant(element) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + while (textNode) { + if (textNode.textContent.trim()) { + const parent = textNode.parentElement; + if (parent) { + const color = window.getComputedStyle(parent).color; + if (/rgb\(255,\s*255,\s*255\)|rgba\(255,\s*255,\s*255,\s*(?:0\.\d+|1)\)/i.test(color)) { + return true; + } + } + } + textNode = walker.nextNode(); + } + return false; + } + + function visibleTextLength(element) { + return normalizeText(element.innerText).length; + } + + function shouldSkipTextNode(textNode, parentStyle) { + const parent = textNode.parentElement; + if (!parent) return true; + if (parent.closest("[hidden], [aria-hidden='true']")) return true; + if (parentStyle.display === "none" || parentStyle.visibility === "hidden") return true; + if (Number(parentStyle.opacity || 1) < 0.04) return true; + if (parseFloat(parentStyle.fontSize || "0") <= 0) return true; + const color = parentStyle.color || ""; + if (/rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(color)) return true; + return false; + } + + function pushCaptureChild(children, childNode) { + if (!childNode) return; + if (childNode.type === "TEXT") { + const text = normalizeText(childNode.text); + if (!text) return; + const duplicate = children.some(existing => { + if (existing.type !== "TEXT") return false; + if (normalizeText(existing.text) !== text) return false; + return overlapRatio(existing.visibleRect || existing.rect, childNode.visibleRect || childNode.rect) > 0.72; + }); + if (duplicate) return; + } + children.push(childNode); + } + + function isUsefulHeaderElement(element, headerRect) { + if (!element || isWebToPixsoElement(element)) return false; + if (/^(HTML|BODY|SCRIPT|STYLE|META|LINK)$/i.test(element.tagName || "")) return false; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return false; + const absRect = absoluteRect(rect, style); + const intersection = intersectRects(absRect, headerRect); + if (!intersection) return false; + const overlap = (intersection.width * intersection.height) / Math.max(1, absRect.width * absRect.height); + if (overlap < 0.55) return false; + if (absRect.width > headerRect.width * 0.92 && absRect.height > headerRect.height * 0.8) return false; + + const text = normalizeText(element.innerText || element.textContent || element.getAttribute?.("aria-label") || element.getAttribute?.("alt") || element.getAttribute?.("placeholder") || ""); + const name = `${element.tagName || ""} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const hasMedia = /^(IMG|SVG|PICTURE|CANVAS)$/i.test(element.tagName || "") || Boolean(element.querySelector?.("img, svg, picture, canvas")); + const isControl = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|LABEL)$/i.test(element.tagName || "") || + /button|btn|link|logo|icon|search|input|nav|menu|item|login|register/i.test(String(name)); + return Boolean(text || hasMedia || isControl); + } + + function collectHeaderPointElements(headerRect) { + const found = new Set(); + const xSteps = Math.max(8, Math.min(28, Math.ceil(headerRect.width / 88))); + const ySteps = Math.max(2, Math.min(6, Math.ceil(headerRect.height / 24))); + for (let yIndex = 0; yIndex <= ySteps; yIndex += 1) { + const y = Math.min(window.innerHeight - 1, Math.max(0, headerRect.y + (headerRect.height * yIndex / Math.max(1, ySteps)))); + for (let xIndex = 0; xIndex <= xSteps; xIndex += 1) { + const x = Math.min(window.innerWidth - 1, Math.max(0, headerRect.x + (headerRect.width * xIndex / Math.max(1, xSteps)))); + for (const element of document.elementsFromPoint(x, y) || []) { + if (isUsefulHeaderElement(element, headerRect)) { + found.add(element); + } + } + } + } + + return Array.from(found) + .filter(element => { + return !Array.from(found).some(other => other !== element && other.contains(element) && isUsefulHeaderElement(other, headerRect)); + }) + .sort((a, b) => { + const ar = a.getBoundingClientRect(); + const br = b.getBoundingClientRect(); + if (Math.abs(ar.top - br.top) > 4) return ar.top - br.top; + return ar.left - br.left; + }); + } + + function isProbablyComplexCard(element, absRect, styles) { + if (element.tagName === "HTML" || element.tagName === "BODY") return false; + if (absRect.y < window.innerHeight * 2.2) return false; + if (absRect.width < 240 || absRect.width > Math.min(window.innerWidth * 0.75, 980)) return false; + if (absRect.height < 150 || absRect.height > 640) return false; + + const imageUrls = extractStyleImageUrls(styles); + const hasImgChild = Boolean(element.querySelector("img, picture, video")); + const hasWhiteText = hasWhiteTextDescendant(element); + const hasRoundedCard = parseFloat(styles.borderTopLeftRadius || "0") > 0 || + parseFloat(styles.borderTopRightRadius || "0") > 0 || + parseFloat(styles.borderBottomLeftRadius || "0") > 0 || + parseFloat(styles.borderBottomRightRadius || "0") > 0; + const hasCardClass = /card|case|customer|client|solution|scene|cert|safe|security|item|slide|swiper/i.test(String(element.className || "")); + const hasVisualFill = imageUrls.length > 0 || + (styles.backgroundColor && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/i.test(styles.backgroundColor)); + if (visibleTextLength(element) < 8 && !hasImgChild && !imageUrls.length && !hasCardClass) return false; + + return hasWhiteText || hasImgChild || (hasVisualFill && (hasRoundedCard || hasCardClass)); + } + + function addTargetIfNonOverlapping(targets, target, maxOverlapRatio = 0.65) { + const area = Math.max(1, target.rect.width * target.rect.height); + const overlaps = targets.some(item => { + const intersection = intersectRects(item.rect, target.rect); + if (!intersection) return false; + const intersectionArea = intersection.width * intersection.height; + return intersectionArea / area > maxOverlapRatio; + }); + if (!overlaps) targets.push(target); + } + + function clippedViewportRect(absRect, minHeight = 1) { + if (!absRect) return null; + const rect = intersectRects(absRect, { + x: 0, + y: absRect.y, + width: window.innerWidth, + height: Math.min(absRect.height, window.innerHeight) + }); + if (!rect || rect.height < minHeight || rect.width < 1) return null; + return rect; + } + + function getSelectionElement(options = {}) { + if (!options.selectionId) return null; + try { + const raw = String(options.selectionId); + const escaped = window.CSS?.escape + ? CSS.escape(raw) + : raw.replace(/["\\]/g, "\\$&"); + return document.querySelector(`[data-web-to-pixso-selection-id="${escaped}"]`); + } catch { + return null; + } + } + + function getSelectionRect(element) { + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return null; + return absoluteRect(rect, style); + } + + function textNodeToCaptureNode(textNode, clipRect, context = {}) { + const text = textNode.textContent.replace(/\s+/g, " ").trim(); + if (!text) return null; + + const range = document.createRange(); + range.selectNodeContents(textNode); + const rect = range.getBoundingClientRect(); + range.detach(); + + const parentStyle = window.getComputedStyle(textNode.parentElement); + if (shouldSkipTextNode(textNode, parentStyle)) return null; + const absRect = absoluteRect(rect, parentStyle); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect || visibleRect.width <= 0 || visibleRect.height <= 0) return null; + + return { + id: `text-${++nodeCounter}`, + type: "TEXT", + tag: "#text", + name: text.slice(0, 40), + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: "text-node", + section: context.section || "content", + ...layerMetaFor("TEXT"), + styles: extractStyles(parentStyle), + attributes: {}, + children: [] + }; + } + + function elementLabelToTextNode(element, clipRect, context = {}) { + const text = normalizeText( + element.getAttribute?.("aria-label") || + element.getAttribute?.("alt") || + element.getAttribute?.("title") || + element.getAttribute?.("placeholder") || + element.value || + "" + ); + if (!text) return null; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (!isVisible(element, rect, style)) return null; + const absRect = absoluteRect(rect, style); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect) return null; + return { + id: `text-${++nodeCounter}`, + type: "TEXT", + tag: "#text", + name: text.slice(0, 40), + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: "text-node", + section: context.section || "content", + ...layerMetaFor("TEXT"), + styles: extractStyles(style), + attributes: { "data-generated-from": "element-label" }, + children: [] + }; + } + + function pseudoElementToCaptureNode(element, pseudo, parentRect, assetUrls, clipRect, context = {}) { + const computed = window.getComputedStyle(element, pseudo); + if (!computed || computed.display === "none" || computed.visibility === "hidden") return null; + + const styles = extractStyles(computed); + const rawContent = computed.getPropertyValue("content"); + const text = rawContent && rawContent !== "none" && rawContent !== "normal" + ? rawContent.replace(/^["']|["']$/g, "") + : ""; + const backgroundUrls = extractStyleImageUrls(styles); + if (text && normalizeText(element.innerText).includes(normalizeText(text))) { + return null; + } + if (!text && !backgroundUrls.length) { + return null; + } + if (!text && hasMaskImage(styles) && !hasUsableBackgroundImage(styles) && parentRect.width * parentRect.height > 4096) { + return null; + } + if (!text && backgroundUrls.length && shouldSkipPseudoBackground(styles, parentRect)) { + return null; + } + for (const url of backgroundUrls) { + assetUrls.add(url); + } + + const hasVisibleBackground = ( + styles.backgroundColor && + styles.backgroundColor !== "rgba(0, 0, 0, 0)" && + styles.backgroundColor !== "transparent" + ) || backgroundUrls.length; + + if (!text && !hasVisibleBackground) return null; + + const absRect = absoluteRect(parentRect, styles); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect) return null; + + return { + id: `pseudo-${++nodeCounter}`, + type: text ? "TEXT" : "RECTANGLE", + tag: pseudo, + name: pseudo, + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: text ? "text-node" : "paint-node", + section: context.section || "content", + ...layerMetaFor(text ? "TEXT" : "RECTANGLE"), + styles, + attributes: {}, + backgroundImages: backgroundUrls, + children: [] + }; + } + + function elementToCaptureNode(element, assetUrls, clipRect, diagnostics, context = {}) { + if (!element || SKIP_TAGS.has(element.tagName)) return null; + if (isWebToPixsoElement(element)) return null; + if (isCarouselClone(element)) { + if (diagnostics) diagnostics.skippedCarouselClones += 1; + return null; + } + + const computed = window.getComputedStyle(element); + const clientRect = element.getBoundingClientRect(); + if (!isVisible(element, clientRect, computed)) return null; + + const styles = extractStyles(computed); + const absRect = element.tagName === "BODY" || element.tagName === "HTML" + ? getDocumentRect() + : absoluteRect(clientRect, styles); + const section = element.tagName === "HTML" || element.tagName === "BODY" + ? "document" + : inferSection(element, absRect, styles, context.section); + const childContext = { section }; + const layout = extractLayout(computed, element, absRect); + const documentClip = clipRect || getDocumentRect(); + const visibleRect = intersectRects(absRect, documentClip); + if (!visibleRect) { + if (diagnostics) diagnostics.skippedOutsideClip += 1; + return null; + } + + const ownClip = clipsChildren(styles) || element.tagName === "BODY" || element.tagName === "HTML" + ? intersectRects(documentClip, absRect) || visibleRect + : documentClip; + const renderRect = shouldClampToClip(element, absRect, visibleRect) ? visibleRect : absRect; + const attrs = collectAttributes(element); + const children = []; + const imageCandidates = getElementImageCandidates(element); + const currentSrc = imageCandidates[0] || null; + const backgroundUrls = extractStyleImageUrls(styles); + + if (/^(IMG|VIDEO|SOURCE|PICTURE)$/i.test(element.tagName)) { + for (const url of imageCandidates) { + assetUrls.add(url); + } + } + + for (const url of backgroundUrls) { + assetUrls.add(url); + } + + const beforeNode = pseudoElementToCaptureNode(element, "::before", clientRect, assetUrls, ownClip, childContext); + pushCaptureChild(children, beforeNode); + + for (const child of getChildNodes(element)) { + if (child.nodeType === Node.TEXT_NODE) { + const textNode = textNodeToCaptureNode(child, ownClip, childContext); + pushCaptureChild(children, textNode); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const childNode = elementToCaptureNode(child, assetUrls, ownClip, diagnostics, childContext); + pushCaptureChild(children, childNode); + } + } + + if (!children.some(child => child.type === "TEXT")) { + pushCaptureChild(children, elementLabelToTextNode(element, ownClip, childContext)); + } + + if (isHeaderLikeElement(element) && children.length === 0) { + for (const headerElement of collectHeaderPointElements(absRect)) { + if (headerElement === element || element.contains(headerElement)) continue; + const childNode = elementToCaptureNode(headerElement, assetUrls, ownClip, diagnostics, { section: "header" }); + pushCaptureChild(children, childNode); + } + } + + const afterNode = pseudoElementToCaptureNode(element, "::after", clientRect, assetUrls, ownClip, childContext); + pushCaptureChild(children, afterNode); + + const type = element.tagName === "IMG" || backgroundUrls.length ? "RECTANGLE" : "FRAME"; + return { + id: `node-${++nodeCounter}`, + type, + tag: element.tagName, + name: attrs.id || attrs.class || element.tagName.toLowerCase(), + rect: renderRect, + sourceRect: absRect, + visibleRect, + clipRect: ownClip, + clipped: renderRect.width !== absRect.width || renderRect.height !== absRect.height, + visualRole: classifyVisualRole(element, styles, children, currentSrc, backgroundUrls, layout), + section, + ...layerMetaFor(type), + styles, + layout, + attributes: attrs, + src: currentSrc, + backgroundImages: backgroundUrls, + children + }; + } + + function normalizeAssetUrl(url) { + if (!url || url === "undefined" || url === "null") return null; + try { + const absolute = new URL(url, document.baseURI).href; + if (/\/undefined(?:$|[?#])/.test(absolute)) return null; + return absolute; + } catch { + return null; + } + } + + function toDataUrl(contentType, base64) { + return `data:${contentType || "application/octet-stream"};base64,${base64}`; + } + + function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + async function directFetchAsset(url) { + const response = await fetch(url, { credentials: "include", cache: "force-cache" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + return { + ok: true, + mimeType: blob.type || response.headers.get("content-type") || "application/octet-stream", + data: await blobToDataUrl(blob) + }; + } + + function captureVideoPoster(url) { + const video = Array.from(document.querySelectorAll("video")).find(item => { + const source = normalizeAssetUrl(item.currentSrc || item.src || item.querySelector("source")?.src); + return source === url; + }); + if (!video || !video.videoWidth || !video.videoHeight) return null; + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + return canvasToPng(canvas); + } + + async function proxyFetchAsset(url) { + if (!chrome?.runtime?.sendMessage) { + throw new Error("扩展代理不可用"); + } + const response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_FETCH_ASSET", + url + }); + if (!response?.ok) { + throw new Error(response?.error || "代理拉取失败"); + } + return { + ok: true, + mimeType: response.contentType || "application/octet-stream", + data: toDataUrl(response.contentType, response.base64) + }; + } + + async function captureVisibleTabImage() { + if (!chrome?.runtime?.sendMessage) { + return null; + } + let response = null; + try { + response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_VISIBLE_TAB" + }); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback message failed", error); + return null; + } + if (!response?.ok || !response.dataUrl) { + console.warn("[Web to Pixso] Raster fallback skipped", response?.error || "标签页截图失败"); + return null; + } + const image = new Image(); + image.decoding = "async"; + image.src = response.dataUrl; + try { + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + return image; + } catch (error) { + console.warn("[Web to Pixso] Raster fallback image decode failed", error); + return null; + } + } + + async function cropViewportRegion(rect, scrollY) { + const image = await captureVisibleTabImage(); + if (!image) return null; + const ratioX = image.naturalWidth / Math.max(1, window.innerWidth); + const ratioY = image.naturalHeight / Math.max(1, window.innerHeight); + const sourceX = Math.max(0, Math.round(rect.x * ratioX)); + const sourceY = Math.max(0, Math.round((rect.y - scrollY) * ratioY)); + const sourceWidth = Math.min(image.naturalWidth - sourceX, Math.round(rect.width * ratioX)); + const sourceHeight = Math.min(image.naturalHeight - sourceY, Math.round(rect.height * ratioY)); + if (sourceWidth <= 0 || sourceHeight <= 0) return null; + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(rect.width)); + canvas.height = Math.max(1, Math.round(rect.height)); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height); + return canvasToPng(canvas); + } + + function hideFixedTopOverlays() { + const hidden = []; + for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { + if (isWebToPixsoElement(element)) continue; + const style = window.getComputedStyle(element); + if (style.position !== "fixed" && style.position !== "sticky") continue; + const rect = element.getBoundingClientRect(); + if (rect.width < 8 || rect.height < 8) continue; + const topBar = rect.width >= window.innerWidth * 0.25 && + rect.height >= 20 && + rect.height <= 180 && + rect.top <= 140 && + rect.bottom >= 0; + const sideFloat = rect.right >= window.innerWidth - 160 && + rect.width <= 180 && + rect.height >= 40; + const bottomFloat = rect.bottom >= window.innerHeight - 120 && + rect.width <= Math.max(720, window.innerWidth * 0.55) && + rect.height <= 180; + if (!topBar && !sideFloat && !bottomFloat) continue; + const previous = { + element, + value: element.style.getPropertyValue("visibility"), + priority: element.style.getPropertyPriority("visibility") + }; + element.style.setProperty("visibility", "hidden", "important"); + hidden.push(previous); + } + + return () => { + for (const item of hidden) { + if (item.value) { + item.element.style.setProperty("visibility", item.value, item.priority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + } + }; + } + + function hideWebToPixsoOverlays() { + const hidden = []; + for (const selector of [ + "#__web_to_pixso_panel_root__", + "#__web_to_pixso_picker_root__", + "#__web_to_pixso_picker_box__", + "#__web_to_pixso_picker_label__" + ]) { + const element = document.querySelector(selector); + if (!element) continue; + hidden.push({ + element, + visibility: element.style.getPropertyValue("visibility"), + visibilityPriority: element.style.getPropertyPriority("visibility"), + display: element.style.getPropertyValue("display"), + displayPriority: element.style.getPropertyPriority("display"), + opacity: element.style.getPropertyValue("opacity"), + opacityPriority: element.style.getPropertyPriority("opacity") + }); + element.style.setProperty("visibility", "hidden", "important"); + element.style.setProperty("opacity", "0", "important"); + element.style.setProperty("display", "none", "important"); + } + return () => { + for (const item of hidden) { + if (item.visibility) { + item.element.style.setProperty("visibility", item.visibility, item.visibilityPriority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + if (item.display) { + item.element.style.setProperty("display", item.display, item.displayPriority || ""); + } else { + item.element.style.removeProperty("display"); + } + if (item.opacity) { + item.element.style.setProperty("opacity", item.opacity, item.opacityPriority || ""); + } else { + item.element.style.removeProperty("opacity"); + } + } + }; + } + + function hideTextForBackgroundRaster(targetRect) { + const style = document.createElement("style"); + style.setAttribute("data-web-to-pixso-background-raster", "true"); + style.textContent = ` + html body *:not(#__web_to_pixso_panel_root__):not(#__web_to_pixso_picker_root__) { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + text-shadow: none !important; + caret-color: transparent !important; + } + html body *::before, + html body *::after, + html body input::placeholder, + html body textarea::placeholder { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + text-shadow: none !important; + } + `; + document.documentElement.appendChild(style); + return () => style.remove(); + } + + function hideHeroForegroundForRaster(targetRect) { + const hidden = []; + const targetArea = Math.max(1, targetRect.width * targetRect.height); + const isTransparent = value => !value || /transparent|rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value); + + for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { + if (isWebToPixsoElement(element)) continue; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const intersection = intersectRects(absRect, targetRect); + if (!intersection) continue; + + const tag = element.tagName || ""; + const areaRatio = (absRect.width * absRect.height) / targetArea; + const text = normalizeText(element.innerText || element.textContent || ""); + const name = `${tag} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const hasFill = !isTransparent(style.backgroundColor) || + (style.backgroundImage && style.backgroundImage !== "none") || + parseFloat(style.borderTopWidth || "0") > 0 || + parseFloat(style.borderRightWidth || "0") > 0 || + parseFloat(style.borderBottomWidth || "0") > 0 || + parseFloat(style.borderLeftWidth || "0") > 0 || + (style.boxShadow && style.boxShadow !== "none"); + const isMedia = /^(IMG|PICTURE|VIDEO|CANVAS)$/i.test(tag); + const isLargeMedia = isMedia && areaRatio > 0.08 && absRect.width > 260 && absRect.height > 140; + const isControl = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|LABEL|SVG|I|EM)$/i.test(tag) || + /button|btn|link|nav|menu|tab|card|coupon|activity|hot|item|tag|badge|icon|service|float|customer|consult|sidebar/i.test(String(name)); + const isForegroundBlock = areaRatio < 0.34 && ( + isControl || + (text.length > 0 && areaRatio < 0.24) || + (hasFill && areaRatio < 0.24) + ); + + if (!isForegroundBlock || isLargeMedia) continue; + + hidden.push({ + element, + visibility: element.style.getPropertyValue("visibility"), + visibilityPriority: element.style.getPropertyPriority("visibility") + }); + element.style.setProperty("visibility", "hidden", "important"); + } + + return () => { + for (const item of hidden) { + if (item.visibility) { + item.element.style.setProperty("visibility", item.visibility, item.visibilityPriority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + } + }; + } + + function waitForPaint(ms = 80) { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.setTimeout(resolve, ms); + }); + }); + }); + } + + function hidePageScrollbars() { + const style = document.createElement("style"); + style.textContent = ` + html, body { + scrollbar-width: none !important; + -ms-overflow-style: none !important; + } + html::-webkit-scrollbar, + body::-webkit-scrollbar, + *::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; + } + `; + document.documentElement.appendChild(style); + return () => style.remove(); + } + + function isLikelyHeroOrCarousel(element, absRect, styles) { + const name = `${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const isNamed = /hero|banner|kv|carousel|swiper|slick|slider|slide|campaign|masthead/i.test(String(name)); + const isMedia = /^(PICTURE|VIDEO|CANVAS)$/i.test(element.tagName) || Boolean(element.querySelector?.("picture, video, canvas")); + const nearTop = absRect.y < Math.max(window.innerHeight * 1.4, 900); + const wide = absRect.width >= window.innerWidth * 0.65; + const tall = absRect.height >= 180; + const visual = isMedia || extractStyleImageUrls(styles).length > 0 || /gradient|url\(/i.test(styles.backgroundImage || ""); + return nearTop && wide && tall && (isNamed || visual); + } + + function isLikelyFullWidthVisualSection(element, absRect, styles) { + if (element.tagName === "HTML" || element.tagName === "BODY") return false; + const name = `${element.id || ""} ${element.className || ""}`; + const named = /banner|hero|section|panel|visual|picture|media|video|campaign|activity|recommend|promo|feature/i.test(String(name)); + const media = /^(PICTURE|VIDEO|CANVAS)$/i.test(element.tagName) || Boolean(element.querySelector?.("picture, video, canvas")); + const hasImage = extractStyleImageUrls(styles).length > 0 || Boolean(element.querySelector?.("img, picture, video, canvas")); + const wide = absRect.width >= window.innerWidth * 0.78; + const tall = absRect.height >= 220; + const saneHeight = absRect.height <= window.innerHeight * 1.05; + return wide && tall && saneHeight && (media || hasImage || named); + } + + function addRasterTarget(targets, target, overlap = 0.7) { + if (!target?.rect) return; + if (target.rect.width < 2 || target.rect.height < 2) return; + const viewportRect = { + x: 0, + y: target.rect.y, + width: window.innerWidth, + height: Math.min(target.rect.height, window.innerHeight) + }; + if (!intersectRects(target.rect, viewportRect)) return; + addTargetIfNonOverlapping(targets, target, overlap); + } + + function addRasterTargetSlices(targets, target, overlap = 0.7) { + if (!target?.rect) return; + const maxHeight = Math.max(320, Math.floor(window.innerHeight * 0.86)); + const totalHeight = Math.max(1, target.rect.height); + if (totalHeight <= maxHeight) { + addRasterTarget(targets, target, overlap); + return; + } + + let offset = 0; + let index = 1; + while (offset < totalHeight - 1) { + const remaining = totalHeight - offset; + const height = Math.min(maxHeight, remaining); + addRasterTarget(targets, { + ...target, + name: `${target.name} ${index}`, + rect: { + x: target.rect.x, + y: round(target.rect.y + offset), + width: target.rect.width, + height: round(height) + } + }, overlap); + offset += height; + index += 1; + } + } + + function countKeyword(text, pattern) { + return (String(text || "").match(pattern) || []).length; + } + + function isLikelyCommerceSection(element, absRect) { + if (!element || element.tagName === "HTML" || element.tagName === "BODY") return false; + if (absRect.y < 240) return false; + if (absRect.width < window.innerWidth * 0.62) return false; + if (absRect.height < 64 || absRect.height > getDocumentRect().height * 0.85) return false; + + const name = `${element.id || ""} ${element.className || ""}`.trim(); + if (/^app$/i.test(name)) return false; + + const text = normalizeText(element.innerText || ""); + const actionCount = countKeyword(text, /立即购买|立即开通|立即领取|免费体验|了解详情/g); + const cardCount = countKeyword(text, /云服务器|云数据库|对象存储|CDN|LSS|OCR|API|价格|配置/g); + const named = /module|product|coupon|goods|package|card|recommend|activity|campaign|promo|experience|权益|优惠|more|hot/i.test(name); + const contentNamed = /新用户|上云|产品|特惠|优惠|活动|推荐|更多产品|企业实名|价格|立即/g.test(text); + + return named && contentNamed || actionCount >= 3 || cardCount >= 4; + } + + function getRasterFallbackTargets(options = {}) { + const targets = []; + const mixedMode = options.captureMode !== "editable"; + const selectionElement = getSelectionElement(options); + const selectionRect = getSelectionRect(selectionElement); + if (selectionRect) { + addRasterTarget(targets, { + name: "Selected element raster fallback", + rect: clippedViewportRect(selectionRect, 1) || selectionRect, + mode: "visual" + }, 1); + return targets; + } + + for (const element of Array.from(document.querySelectorAll("header, nav, [role='navigation'], [class*='header' i], [class*='nav' i]"))) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const nearTop = absRect.y <= 120 || style.position === "fixed" || style.position === "sticky"; + const likelyHeader = rect.height >= 24 && rect.height <= 160 && rect.width >= window.innerWidth * 0.45; + if (!nearTop || !likelyHeader) continue; + addRasterTarget(targets, { + name: "Header raster fallback", + rect: absRect, + mode: "visual" + }, 0.8); + break; + } + + if (mixedMode) { + const heroCandidates = Array.from(document.querySelectorAll([ + "section", + "main > div", + "[role='banner']", + "[class*='hero' i]", + "[class*='banner' i]", + "[class*='kv' i]", + "[class*='carousel' i]", + "[class*='swiper' i]", + "[class*='slick' i]", + "[class*='slider' i]", + "[class*='slide' i]", + "[class*='campaign' i]", + "picture", + "video", + "canvas" + ].join(","))); + const heroElements = new Set(); + for (const element of heroCandidates) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isLikelyHeroOrCarousel(element, absRect, styles)) continue; + if (heroElements.has(element)) continue; + heroElements.add(element); + const targetRect = clippedViewportRect(absRect, 160); + if (!targetRect) continue; + addRasterTarget(targets, { + name: "Hero carousel raster fallback", + rect: targetRect, + mode: "hero-background" + }, 0.45); + if (heroElements.size >= 3) break; + } + + const commerceCandidates = Array.from(document.querySelectorAll([ + "section", + "main > div", + "[class*='module' i]", + "[class*='product' i]", + "[class*='coupon' i]", + "[class*='goods' i]", + "[class*='package' i]", + "[class*='recommend' i]", + "[class*='activity' i]", + "[class*='campaign' i]", + "[class*='promo' i]", + "[class*='more' i]", + "[class*='experience' i]" + ].join(","))); + const commerceTargets = []; + for (const element of commerceCandidates) { + if (commerceTargets.length >= 12) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + if (!isLikelyCommerceSection(element, absRect)) continue; + addRasterTargetSlices(commerceTargets, { + name: "Commerce section raster fallback", + mode: "background", + rect: { + x: Math.max(0, absRect.x), + y: absRect.y, + width: Math.min(window.innerWidth, absRect.width), + height: absRect.height + } + }, 0.68); + } + targets.push(...commerceTargets); + + const sectionCandidates = Array.from(document.querySelectorAll([ + "section", + "article", + "[class*='banner' i]", + "[class*='panel' i]", + "[class*='visual' i]", + "[class*='media' i]", + "[class*='feature' i]", + "[class*='activity' i]", + "[class*='promo' i]", + "[class*='recommend' i]", + "picture", + "video", + "canvas" + ].join(","))); + for (const element of sectionCandidates) { + if (targets.filter(target => target.name === "Visual section raster fallback").length >= 10) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isLikelyFullWidthVisualSection(element, absRect, styles)) continue; + const targetRect = clippedViewportRect(absRect, 180); + if (!targetRect) continue; + addRasterTarget(targets, { + name: "Visual section raster fallback", + rect: targetRect, + mode: "background" + }, 0.55); + } + } + + const footer = Array.from(document.querySelectorAll("footer, [class*='footer' i]")) + .map(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return null; + const absRect = absoluteRect(rect, style); + if (absRect.y < window.innerHeight * 3) return null; + if (absRect.width < window.innerWidth * 0.45 || absRect.height < 180) return null; + return { + name: "Footer raster fallback", + mode: "background", + rect: { + x: Math.max(0, absRect.x), + y: absRect.y, + width: Math.min(window.innerWidth, absRect.width), + height: Math.min(absRect.height, window.innerHeight * 0.95) + } + }; + }) + .filter(Boolean) + .sort((a, b) => b.rect.height - a.rect.height)[0]; + if (footer) addRasterTarget(targets, footer, 0.75); + + const complexCards = []; + const candidates = Array.from(document.querySelectorAll([ + "a", + "li", + "article", + "section", + "section div", + "[class*='card' i]", + "[class*='case' i]", + "[class*='customer' i]", + "[class*='client' i]", + "[class*='solution' i]", + "[class*='scene' i]", + "[class*='safe' i]", + "[class*='security' i]", + "[class*='cert' i]", + "[class*='swiper-slide' i]" + ].join(","))); + for (const element of candidates) { + if (complexCards.length >= 18) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isProbablyComplexCard(element, absRect, styles)) continue; + addRasterTarget(complexCards, { + name: "Complex card raster fallback", + rect: absRect, + mode: "background" + }, 0.65); + } + targets.push(...complexCards); + return targets; + } + + async function collectRasterFallbacks(options = {}) { + const nodes = []; + const assets = {}; + const targets = getRasterFallbackTargets(options); + const originalX = window.scrollX; + const originalY = window.scrollY; + const restoreScrollbars = hidePageScrollbars(); + + try { + for (const target of targets) { + try { + const scrollY = Math.max(0, Math.min(target.rect.y, document.documentElement.scrollHeight - window.innerHeight)); + window.scrollTo(0, scrollY); + await new Promise(resolve => setTimeout(resolve, 620)); + const restorePluginOverlays = hideWebToPixsoOverlays(); + const restoreText = target.mode === "visual" + ? () => {} + : hideTextForBackgroundRaster(target.rect); + const restoreHeroForeground = target.mode === "hero-background" + ? hideHeroForegroundForRaster(target.rect) + : () => {}; + const restoreOverlays = target.name.includes("Header") + ? () => {} + : hideFixedTopOverlays(); + let asset = null; + try { + await waitForPaint(target.mode === "visual" ? 20 : 90); + asset = await cropViewportRegion(target.rect, window.scrollY); + } finally { + restoreOverlays(); + restoreHeroForeground(); + restoreText(); + restorePluginOverlays(); + } + if (!asset?.data) continue; + const key = `web-to-pixso-raster://${++rasterCounter}`; + assets[key] = { + type: "image", + mimeType: asset.mimeType || "image/png", + data: asset.data + }; + nodes.push({ + id: `raster-${rasterCounter}`, + type: "RECTANGLE", + tag: "RASTER_FALLBACK", + name: target.name, + rect: target.rect, + visibleRect: target.rect, + clipped: false, + visualRole: "raster-fallback", + section: inferRasterSection(target), + ...layerMetaFor("RECTANGLE", { rasterMode: target.mode || "background" }), + styles: { + display: "block", + visibility: "visible", + position: target.name.includes("Header") ? "fixed" : "absolute", + zIndex: target.name.includes("Header") ? "20" : "1", + opacity: "1", + overflow: "hidden", + backgroundColor: "rgba(0, 0, 0, 0)", + backgroundImage: `url("${key}")`, + backgroundSize: "100% 100%", + backgroundRepeat: "no-repeat", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + boxShadow: "none", + filter: "none", + color: "rgba(0, 0, 0, 0.85)", + transform: "none" + }, + attributes: { + "data-raster-fallback": "true", + "data-raster-mode": target.mode || "background", + "data-layer-group": "comparison", + "data-section": inferRasterSection(target) + }, + rasterMode: target.mode || "background", + backgroundImages: [key], + children: [] + }); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback failed", error); + } + } + } finally { + restoreScrollbars(); + window.scrollTo(originalX, originalY); + } + + await new Promise(resolve => setTimeout(resolve, 120)); + return { nodes, assets }; + } + + async function collectComparisonFallbacks(options = {}) { + const nodes = []; + const assets = {}; + const documentRect = getDocumentRect(); + const width = Math.max(1, Math.round(window.innerWidth || documentRect.width || Number(options.captureWidth || 0) || 1440)); + const totalHeight = Math.max(1, Math.round(documentRect.height || document.documentElement.scrollHeight || window.innerHeight)); + const sliceHeight = Math.max(320, Math.floor(window.innerHeight * 0.86)); + const originalX = window.scrollX; + const originalY = window.scrollY; + const restoreScrollbars = hidePageScrollbars(); + + try { + let y = 0; + let index = 1; + while (y < totalHeight - 1) { + const height = Math.min(sliceHeight, totalHeight - y); + const rect = { + x: 0, + y, + width, + height + }; + try { + const scrollY = Math.max(0, Math.min(y, document.documentElement.scrollHeight - window.innerHeight)); + window.scrollTo(0, scrollY); + await new Promise(resolve => setTimeout(resolve, 420)); + const restorePluginOverlays = hideWebToPixsoOverlays(); + const restoreFixedOverlays = options.keepFixedOverlays === true + ? () => {} + : hideFixedTopOverlays(); + let asset = null; + try { + await waitForPaint(40); + asset = await cropViewportRegion(rect, window.scrollY); + } finally { + restoreFixedOverlays(); + restorePluginOverlays(); + } + if (asset?.data) { + const key = `web-to-pixso-raster://${++rasterCounter}`; + assets[key] = { + type: "image", + mimeType: asset.mimeType || "image/png", + data: asset.data + }; + nodes.push({ + id: `raster-${rasterCounter}`, + type: "RECTANGLE", + tag: "RASTER_FALLBACK", + name: `对比底图-${width}px${totalHeight > sliceHeight ? ` ${index}` : ""}`, + rect, + visibleRect: rect, + clipped: false, + visualRole: "raster-fallback", + section: "document", + ...layerMetaFor("RECTANGLE", { rasterMode: "comparison" }), + styles: { + display: "block", + visibility: "visible", + position: "absolute", + zIndex: "0", + opacity: "1", + overflow: "hidden", + backgroundColor: "rgba(0, 0, 0, 0)", + backgroundImage: `url("${key}")`, + backgroundSize: "100% 100%", + backgroundRepeat: "no-repeat", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + boxShadow: "none", + filter: "none", + color: "rgba(0, 0, 0, 0.85)", + transform: "none" + }, + attributes: { + "data-raster-fallback": "true", + "data-raster-mode": "comparison", + "data-layer-group": "comparison", + "data-section": "document" + }, + rasterMode: "comparison", + backgroundImages: [key], + children: [] + }); + } + } catch (error) { + console.warn("[Web to Pixso] Comparison fallback failed", error); + } + y += height; + index += 1; + } + } finally { + restoreScrollbars(); + window.scrollTo(originalX, originalY); + } + + await new Promise(resolve => setTimeout(resolve, 120)); + return { nodes, assets }; + } + + function canvasToPng(canvas) { + return new Promise(resolve => { + canvas.toBlob(blob => { + if (!blob) { + resolve(null); + return; + } + blobToDataUrl(blob).then(data => resolve({ + mimeType: "image/png", + data + })); + }, "image/png"); + }); + } + + async function normalizeAssetForPixso(asset) { + if (!asset?.data) return asset; + if (/^image\/(png|jpe?g|webp)$/i.test(asset.mimeType || "")) { + return asset; + } + + try { + const image = new Image(); + image.decoding = "async"; + image.src = asset.data; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, image.naturalWidth || image.width || 1); + canvas.height = Math.max(1, image.naturalHeight || image.height || 1); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + return await canvasToPng(canvas) || asset; + } catch (error) { + console.warn("[Web to Pixso] Image normalization failed", error); + return asset; + } + } + + async function fetchAsset(url, useProxy) { + const videoPoster = await captureVideoPoster(url); + if (videoPoster) return videoPoster; + + if (useProxy) { + try { + return await proxyFetchAsset(url); + } catch (proxyError) { + console.warn("[Web to Pixso] Proxy image fetch failed, trying direct fetch", proxyError); + return directFetchAsset(url); + } + } + + try { + return await directFetchAsset(url); + } catch (directError) { + console.warn("[Web to Pixso] Direct image fetch failed, trying extension proxy", directError); + return proxyFetchAsset(url); + } + } + + async function collectAssets(urls, options) { + const assets = {}; + const queue = Array.from(new Set(Array.from(urls).map(url => normalizeAssetUrl(url)).filter(Boolean))); + const rawConcurrency = options.concurrency === "infinite" + ? queue.length || 1 + : Number(options.concurrency || 8); + const concurrency = Math.max(1, Math.min(queue.length || 1, rawConcurrency || 8)); + let cursor = 0; + + async function worker() { + while (cursor < queue.length) { + const url = queue[cursor++]; + try { + const result = await normalizeAssetForPixso(await fetchAsset(url, options.useProxy)); + assets[url] = { + type: "image", + mimeType: result.mimeType, + data: result.data + }; + } catch (error) { + assets[url] = { + type: "image", + error: error.message || String(error) + }; + } + } + } + + await Promise.all(Array.from({ length: concurrency }, worker)); + return assets; + } + + function collectFonts(root) { + const fonts = {}; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node = walker.currentNode; + + while (node) { + const style = window.getComputedStyle(node); + const family = style.fontFamily; + if (family) { + fonts[family] = { + family, + weight: style.fontWeight, + style: style.fontStyle + }; + } + node = walker.nextNode(); + } + + return fonts; + } + + function countLayoutNodes(captureNode) { + if (!captureNode) return 0; + let count = captureNode.layout ? 1 : 0; + for (const child of captureNode.children || []) { + count += countLayoutNodes(child); + } + return count; + } + + window.__webToPixsoCapture = async function capture(options = {}) { + nodeCounter = 0; + rasterCounter = 0; + const assetUrls = new Set(); + const selectionElement = getSelectionElement(options); + const selectionRect = getSelectionRect(selectionElement); + const diagnostics = { + skippedCarouselClones: 0, + skippedOutsideClip: 0, + selectedElement: Boolean(selectionElement) + }; + let comparisonFallbacks = { nodes: [], assets: {} }; + if (!selectionRect && options.comparisonFallback !== false) { + try { + comparisonFallbacks = await collectComparisonFallbacks(options); + } catch (error) { + console.warn("[Web to Pixso] Comparison fallback collection skipped", error); + } + } + const documentRect = selectionRect || getDocumentRect(); + const root = elementToCaptureNode(selectionElement || document.documentElement, assetUrls, documentRect, diagnostics); + const viewportRect = selectionRect || getViewportRect(); + let rasterFallbacks = { nodes: [], assets: {} }; + if (options.rasterFallbacks !== false && options.captureMode !== "editable") { + try { + rasterFallbacks = await collectRasterFallbacks(options); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback collection skipped", error); + } + } + if (root?.children) { + if (comparisonFallbacks.nodes.length) root.children.push(...comparisonFallbacks.nodes); + if (rasterFallbacks.nodes.length) root.children.push(...rasterFallbacks.nodes); + } + const assets = { + ...(await collectAssets(assetUrls, { + useProxy: Boolean(options.useProxy), + concurrency: options.concurrency || "8" + })), + ...comparisonFallbacks.assets, + ...rasterFallbacks.assets + }; + + return { + schema: CAPTURE_SCHEMA, + schemaVersion: CAPTURE_SCHEMA_VERSION, + format: "pixso-design-capture", + version: CAPTURE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + source: { + title: document.title || location.hostname || "Web Capture", + url: location.href, + viewport: viewportRect, + document: documentRect, + devicePixelRatio: window.devicePixelRatio || 1, + requestedViewportWidth: Math.max(1, Math.round(Number(options.captureWidth || 0))) || window.innerWidth || viewportRect.width, + actualViewportWidth: window.innerWidth, + selection: selectionRect ? { + x: selectionRect.x, + y: selectionRect.y, + width: selectionRect.width, + height: selectionRect.height, + elementName: selectionElement?.tagName?.toLowerCase() || "element" + } : null + }, + canvas: { + x: selectionRect?.x || 0, + y: selectionRect?.y || 0, + width: viewportRect.width, + height: documentRect.height, + backgroundColor: window.getComputedStyle(document.body).backgroundColor || "#ffffff" + }, + import: { + defaultWidth: selectionRect?.width || Math.max(1, Math.round(Number(options.captureWidth || 0))) || window.innerWidth || viewportRect.width || 1440, + captureMode: options.captureMode === "editable" ? "editable" : "mixed" + }, + nodes: root, + assets, + fonts: collectFonts(document.documentElement), + diagnostics: { + nodeCount: nodeCounter, + assetCount: Object.keys(assets).length, + failedAssetCount: Object.values(assets).filter(asset => asset.error).length, + layoutNodeCount: countLayoutNodes(root), + comparisonFallbackCount: comparisonFallbacks.nodes.length, + rasterFallbackCount: rasterFallbacks.nodes.length, + captureMode: options.captureMode === "editable" ? "editable" : "mixed", + ...diagnostics + } + }; + }; +})(); diff --git a/web-to-pixso/element-picker.js b/web-to-pixso/element-picker.js new file mode 100644 index 0000000..3a47c41 --- /dev/null +++ b/web-to-pixso/element-picker.js @@ -0,0 +1,455 @@ +(function () { + "use strict"; + + const ROOT_ID = "__web_to_pixso_picker_root__"; + const BOX_ID = "__web_to_pixso_picker_box__"; + const LABEL_ID = "__web_to_pixso_picker_label__"; + const ATTR = "data-web-to-pixso-selection-id"; + const STORAGE_KEY = "__web_to_pixso_island_position__"; + + let currentElement = null; + let currentSettings = null; + let mode = "toolbar"; + let dragging = null; + + function removePicker() { + document.getElementById(ROOT_ID)?.remove(); + document.getElementById(BOX_ID)?.remove(); + document.getElementById(LABEL_ID)?.remove(); + document.removeEventListener("mousemove", onMouseMove, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("pointermove", onDragMove, true); + document.removeEventListener("pointerup", onDragEnd, true); + currentElement = null; + mode = "toolbar"; + dragging = null; + } + + function elementName(element) { + if (!element) return ""; + const tag = element.tagName ? element.tagName.toLowerCase() : "element"; + const id = element.id ? `#${element.id}` : ""; + const className = String(element.className || "") + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(item => `.${item}`) + .join(""); + return `${tag}${id}${className}`; + } + + function icon(type) { + if (type === "screen") { + return ''; + } + if (type === "select") { + return ''; + } + if (type === "copy") { + return ''; + } + return ''; + } + + function rootCss() { + return ` + #${ROOT_ID} { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + left: 50%; + position: fixed; + top: 24px; + transform: translateX(-50%); + user-select: none; + z-index: 2147483647; + } + #${ROOT_ID} .w2p-island { + align-items: stretch; + background: rgba(36, 36, 38, 0.97); + border-radius: 22px; + box-shadow: 0 8px 24px rgba(0,0,0,.22); + color: white; + display: flex; + min-height: 56px; + overflow: hidden; + } + #${ROOT_ID} .w2p-action, + #${ROOT_ID} .w2p-close, + #${ROOT_ID} .w2p-cancel { + align-items: center; + background: transparent; + border: 0; + color: white; + cursor: pointer; + display: flex; + font: inherit; + gap: 10px; + justify-content: center; + min-height: 56px; + padding: 0 18px; + white-space: nowrap; + } + #${ROOT_ID} .w2p-action { + border-right: 1px solid rgba(255,255,255,.14); + font-size: 16px; + font-weight: 650; + } + #${ROOT_ID} .w2p-action:hover, + #${ROOT_ID} .w2p-close:hover, + #${ROOT_ID} .w2p-cancel:hover { + background: rgba(255,255,255,.1); + } + #${ROOT_ID} .w2p-action.active { + background: rgba(255,255,255,.12); + } + #${ROOT_ID} svg { + fill: none; + height: 20px; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + width: 20px; + } + #${ROOT_ID} .w2p-close { + min-width: 56px; + padding: 0 16px; + } + #${ROOT_ID} .w2p-status { + align-items: center; + display: flex; + gap: 12px; + min-height: 56px; + padding: 0 20px; + } + #${ROOT_ID} .w2p-text { + font-size: 16px; + font-weight: 650; + white-space: nowrap; + } + #${ROOT_ID} .w2p-spinner { + animation: w2p-spin .9s linear infinite; + border: 2px solid rgba(255,255,255,.32); + border-radius: 50%; + border-top-color: #fff; + height: 20px; + width: 20px; + } + #${ROOT_ID} .w2p-cancel { + border-left: 1px solid rgba(255,255,255,.14); + font-size: 15px; + padding: 0 18px; + } + #${BOX_ID} { + background: rgba(46, 156, 255, .16); + border: 2px dashed #1593ff; + border-radius: 6px; + box-sizing: border-box; + display: none; + left: 0; + pointer-events: none; + position: fixed; + top: 0; + z-index: 2147483646; + } + #${LABEL_ID} { + background: #fff; + border-radius: 6px; + box-shadow: 0 8px 22px rgba(0,0,0,.2); + color: #333; + display: none; + font: 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + max-width: 280px; + overflow: hidden; + padding: 8px 10px; + pointer-events: none; + position: fixed; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 2147483647; + } + @keyframes w2p-spin { to { transform: rotate(360deg); } } + `; + } + + function ensureBase() { + document.getElementById(ROOT_ID)?.remove(); + const root = document.createElement("div"); + root.id = ROOT_ID; + const style = document.createElement("style"); + style.textContent = rootCss(); + root.appendChild(style); + document.documentElement.appendChild(root); + + let box = document.getElementById(BOX_ID); + if (!box) { + box = document.createElement("div"); + box.id = BOX_ID; + document.documentElement.appendChild(box); + } + + let label = document.getElementById(LABEL_ID); + if (!label) { + label = document.createElement("div"); + label.id = LABEL_ID; + document.documentElement.appendChild(label); + } + + restorePosition(root); + root.addEventListener("pointerdown", onDragStart, true); + return root; + } + + function restorePosition(root) { + try { + const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null"); + if (saved && Number.isFinite(saved.left) && Number.isFinite(saved.top)) { + root.style.left = `${saved.left}px`; + root.style.top = `${saved.top}px`; + root.style.transform = "none"; + } + } catch { + // Keep the default centered position when storage is unavailable. + } + } + + function savePosition(root) { + const rect = root.getBoundingClientRect(); + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + left: Math.round(rect.left), + top: Math.round(rect.top) + })); + } catch { + // Position persistence is only a convenience. + } + } + + function clampIsland(root, snap) { + const rect = root.getBoundingClientRect(); + let left = rect.left; + let top = rect.top; + const gap = 10; + left = Math.max(gap, Math.min(window.innerWidth - rect.width - gap, left)); + top = Math.max(gap, Math.min(window.innerHeight - rect.height - gap, top)); + if (snap) { + const distances = [ + { side: "left", value: left }, + { side: "right", value: window.innerWidth - left - rect.width }, + { side: "top", value: top }, + { side: "bottom", value: window.innerHeight - top - rect.height } + ].sort((a, b) => a.value - b.value); + if (distances[0].value < 96) { + if (distances[0].side === "left") left = gap; + if (distances[0].side === "right") left = window.innerWidth - rect.width - gap; + if (distances[0].side === "top") top = gap; + if (distances[0].side === "bottom") top = window.innerHeight - rect.height - gap; + } + } + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + root.style.transform = "none"; + savePosition(root); + } + + function onDragStart(event) { + const target = event.target; + if (target?.closest?.("button")) return; + const root = document.getElementById(ROOT_ID); + if (!root) return; + const rect = root.getBoundingClientRect(); + dragging = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top + }; + root.style.transform = "none"; + document.addEventListener("pointermove", onDragMove, true); + document.addEventListener("pointerup", onDragEnd, true); + } + + function onDragMove(event) { + if (!dragging) return; + event.preventDefault(); + const root = document.getElementById(ROOT_ID); + if (!root) return; + root.style.left = `${event.clientX - dragging.offsetX}px`; + root.style.top = `${event.clientY - dragging.offsetY}px`; + } + + function onDragEnd() { + const root = document.getElementById(ROOT_ID); + dragging = null; + document.removeEventListener("pointermove", onDragMove, true); + document.removeEventListener("pointerup", onDragEnd, true); + if (root) clampIsland(root, true); + } + + function setToolbarStatus(text, busy = true) { + const root = document.getElementById(ROOT_ID) || ensureBase(); + root.innerHTML = ` +
导入后会创建一个以网页标题命名的画板,并尽量还原文本、图片、背景、边框和圆角。
+ + 使用说明 +页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。
+ + +
+ Web to Pixso
+ v1.1.1
+ 页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。
+ + + + +