Files
html2pptx/web-to-ppt/capture.js
T
李进 5ef970337d feat: SVG 图标序列化为图片
SVG 元素(如 Octicons)在 capture 阶段序列化为 SVG data URL,
存储到 backgroundImages,转化时作为图片渲染到 PPT。
2026-07-27 15:34:35 +08:00

2031 lines
75 KiB
JavaScript

(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-ppt-capture";
const CAPTURE_SCHEMA_VERSION = "1.1.1";
function isWebToPPTElement(element) {
if (!element?.closest) return false;
return Boolean(element.closest([
"#__web_to_ppt_panel_root__",
"#__web_to_ppt_picker_root__",
"#__web_to_ppt_picker_box__",
"#__web_to_ppt_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 || isWebToPPTElement(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-ppt-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 (isWebToPPTElement(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);
// SVG → 序列化为 data URL 图片
if (element.tagName === "SVG" && !backgroundUrls.length) {
try {
const svgClone = element.cloneNode(true);
// 确保有 xmlns
if (!svgClone.getAttribute('xmlns')) svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const svgStr = new XMLSerializer().serializeToString(svgClone);
const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
backgroundUrls = [svgDataUrl];
} catch (e) { /* 静默失败 */ }
}
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: "WEB_TO_PPT_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: "WEB_TO_PPT_VISIBLE_TAB"
});
} catch (error) {
console.warn("[Web to PPT] Raster fallback message failed", error);
return null;
}
if (!response?.ok || !response.dataUrl) {
console.warn("[Web to PPT] 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 PPT] 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 (isWebToPPTElement(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 hideWebToPPTOverlays() {
const hidden = [];
for (const selector of [
"#__web_to_ppt_panel_root__",
"#__web_to_ppt_picker_root__",
"#__web_to_ppt_picker_box__",
"#__web_to_ppt_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-ppt-background-raster", "true");
style.textContent = `
html body *:not(#__web_to_ppt_panel_root__):not(#__web_to_ppt_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 (isWebToPPTElement(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 = hideWebToPPTOverlays();
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-ppt-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 PPT] 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 = hideWebToPPTOverlays();
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-ppt-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 PPT] 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 normalizeAssetForPPT(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 PPT] 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 PPT] Proxy image fetch failed, trying direct fetch", proxyError);
return directFetchAsset(url);
}
}
try {
return await directFetchAsset(url);
} catch (directError) {
console.warn("[Web to PPT] 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 normalizeAssetForPPT(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.__webToPPTCapture = 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 PPT] 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 PPT] 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: "web-to-ppt-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
}
};
};
})();