mirror of
https://github.com/sligter/LandPPT.git
synced 2026-08-28 23:31:06 +08:00
feat(export): add hybrid raster mode to dom-to-pptx
Subtrees using CSS features OOXML cannot express (conic gradients, multi-layer shadows, clipped badges) are now rasterized as PNG decoration layers while their text stays native editable runs. Bumps dom-to-pptx bundle version to 20260707-hybrid-raster-v1 across editor/template helpers, the slides editor template, and smoke tests. Adds hybrid raster smoke test and fixture.
This commit is contained in:
@@ -65528,7 +65528,7 @@
|
||||
let domOrderCounter = 0;
|
||||
|
||||
// Sync Traversal Function
|
||||
function collect(node, parentZIndex, parentOpacity, zIndexScale = 1) {
|
||||
function collect(node, parentZIndex, parentOpacity, zIndexScale = 1, textOnlyMode = false) {
|
||||
const order = domOrderCounter++;
|
||||
|
||||
let currentZ = parentZIndex;
|
||||
@@ -65562,6 +65562,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// HYBRID MODE: subtrees using CSS features that OOXML cannot express are
|
||||
// rasterized as a decoration layer, while their text is re-collected
|
||||
// natively (editable) on top via textOnlyMode recursion.
|
||||
if (!textOnlyMode && nodeType === 1 && nodeStyle && globalOptions.hybridRaster !== false) {
|
||||
const hybridConfig = { ...layoutConfig, root };
|
||||
const hybridReason = getHybridRasterReason(node, nodeStyle, hybridConfig);
|
||||
if (hybridReason) {
|
||||
// html2canvas already applies the node's own opacity; only the
|
||||
// inherited (parent) opacity must be multiplied onto the raster.
|
||||
const inheritedOpacity = Number.isFinite(parentOpacity)
|
||||
? Math.max(0, Math.min(1, parentOpacity))
|
||||
: 1;
|
||||
const hybrid = prepareHybridRasterItem(
|
||||
node,
|
||||
hybridConfig,
|
||||
order,
|
||||
currentZ,
|
||||
nodeStyle,
|
||||
globalOptions,
|
||||
inheritedOpacity
|
||||
);
|
||||
if (hybrid) {
|
||||
if (hybrid.items) renderQueue.push(...hybrid.items);
|
||||
if (hybrid.job) asyncTasks.push(hybrid.job);
|
||||
if (hybrid.recurseTextOnly) {
|
||||
// Re-enter on the node itself in text-only mode so text held
|
||||
// directly by the hybrid node (not just descendants) is emitted.
|
||||
collect(node, parentZIndex, parentOpacity, zIndexScale, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the item. If it needs async work, it returns a 'job'
|
||||
const result = prepareRenderItem(
|
||||
node,
|
||||
@@ -65571,7 +65605,8 @@
|
||||
currentZ,
|
||||
nodeStyle,
|
||||
globalOptions,
|
||||
currentOpacity
|
||||
currentOpacity,
|
||||
textOnlyMode
|
||||
);
|
||||
|
||||
if (result) {
|
||||
@@ -65589,7 +65624,7 @@
|
||||
// Recurse children synchronously
|
||||
const childNodes = node.childNodes;
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
collect(childNodes[i], currentZ, currentOpacity, childZIndexScale);
|
||||
collect(childNodes[i], currentZ, currentOpacity, childZIndexScale, textOnlyMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66859,6 +66894,425 @@
|
||||
return items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HYBRID RASTER MODE
|
||||
// Detects CSS features that OOXML/PptxGenJS cannot express faithfully. Such a
|
||||
// subtree is exported as one raster "decoration layer" (with its text made
|
||||
// transparent so it isn't baked in twice) plus native editable text boxes
|
||||
// re-collected on top of it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HYBRID_MEDIA_TAGS = ['SVG', 'CANVAS', 'IMG', 'VIDEO', 'IFRAME', 'OBJECT', 'EMBED'];
|
||||
|
||||
function getHybridRasterReason(node, style, config) {
|
||||
if (!node || node.nodeType !== 1 || !style) return null;
|
||||
const tagUpper = String(node.tagName || '').toUpperCase();
|
||||
// Media/formula/icon leaves already have dedicated raster handlers.
|
||||
if (HYBRID_MEDIA_TAGS.includes(tagUpper)) return null;
|
||||
if (tagUpper === 'TABLE') return null;
|
||||
if (isFormulaElement(node) || isIconElement(node)) return null;
|
||||
// Never hybrid-rasterize the slide root or slide-sized background layers:
|
||||
// those keep children native via the background-only raster path, while a
|
||||
// hybrid capture here would flatten the entire slide.
|
||||
if (node === config.root) return null;
|
||||
if (isSlideSizedBackgroundLayerNode(node, config)) return null;
|
||||
|
||||
const clipPath = style.clipPath || style.webkitClipPath;
|
||||
if (clipPath && clipPath !== 'none') return 'clip-path';
|
||||
|
||||
const blend = style.mixBlendMode;
|
||||
if (blend && blend !== 'normal') return 'mix-blend-mode';
|
||||
|
||||
const filter = style.filter;
|
||||
if (filter && filter !== 'none' && !getSoftEdges(filter, 1)) return 'filter';
|
||||
|
||||
const backdrop = style.backdropFilter || style.webkitBackdropFilter;
|
||||
if (backdrop && backdrop !== 'none') return 'backdrop-filter';
|
||||
|
||||
const maskImage = String(style.webkitMaskImage || style.maskImage || '').trim();
|
||||
if (maskImage && maskImage !== 'none') return 'mask-image';
|
||||
|
||||
const borderImage = String(style.borderImageSource || '').trim();
|
||||
if (borderImage && borderImage !== 'none') return 'border-image';
|
||||
|
||||
// Non-rotation transforms (scale/skew/matrix with shear) — getRotation()
|
||||
// only extracts pure rotation, everything else silently distorts.
|
||||
const transform = style.transform;
|
||||
if (transform && transform !== 'none') {
|
||||
const m = transform.match(/matrix\(([^)]+)\)/);
|
||||
if (transform.includes('matrix3d')) return 'transform-3d';
|
||||
if (m) {
|
||||
const v = m[1].split(',').map(parseFloat);
|
||||
if (v.length >= 4) {
|
||||
const scaleX = Math.hypot(v[0], v[1]);
|
||||
const scaleY = Math.hypot(v[2], v[3]);
|
||||
const det = v[0] * v[3] - v[1] * v[2];
|
||||
const notUnitScale = Math.abs(scaleX - 1) > 0.01 || Math.abs(scaleY - 1) > 0.01;
|
||||
const hasShear = Math.abs(v[0] * v[2] + v[1] * v[3]) > 0.01 * scaleX * scaleY;
|
||||
if (notUnitScale || hasShear || det < 0) return 'transform';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient backgrounds on containers WITH content: the sync path only
|
||||
// supports single linear gradients on leaves; conic/radial/multi-layer
|
||||
// gradients behind text/children need rasterization.
|
||||
const bgImage = String(style.backgroundImage || '');
|
||||
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
|
||||
if (bgClip !== 'text' && /\b(?:conic|repeating-linear|repeating-radial)-gradient\s*\(/i.test(bgImage)) {
|
||||
return 'complex-gradient';
|
||||
}
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const hasText = !!(node.textContent && node.textContent.trim());
|
||||
if (
|
||||
bgClip !== 'text' &&
|
||||
(hasChildren || hasText) &&
|
||||
/\bradial-gradient\s*\(/i.test(bgImage)
|
||||
) {
|
||||
return 'radial-gradient-container';
|
||||
}
|
||||
const gradientLayers = splitTopLevelCommaParts(bgImage).filter((part) =>
|
||||
/\b(?:linear|radial|conic|repeating-linear|repeating-radial)-gradient\s*\(/i.test(part)
|
||||
).length;
|
||||
if (bgClip !== 'text' && gradientLayers > 1 && (hasChildren || hasText)) {
|
||||
return 'layered-gradient-container';
|
||||
}
|
||||
|
||||
// Multiple or inset box-shadows — PPTX supports a single outer shadow only.
|
||||
const shadow = style.boxShadow;
|
||||
if (shadow && shadow !== 'none') {
|
||||
const shadowParts = shadow.split(/,(?![^()]*\))/);
|
||||
if (shadowParts.length > 1 || /\binset\b/.test(shadow)) return 'box-shadow';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hybridSubtreeHasText(node) {
|
||||
return !!(node && node.textContent && node.textContent.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the raster decoration layer for a hybrid subtree.
|
||||
* Returns { items, job, recurseTextOnly } or null to fall back to the
|
||||
* standard per-element pipeline.
|
||||
*/
|
||||
function prepareHybridRasterItem(
|
||||
node,
|
||||
config,
|
||||
domOrder,
|
||||
effectiveZIndex,
|
||||
style,
|
||||
globalOptions,
|
||||
effectiveOpacity
|
||||
) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 0.5 || rect.height < 0.5) return null;
|
||||
|
||||
// Overflow guard: filters/shadows can paint outside the border box.
|
||||
const overflowPx = estimateHybridOverflowPx(style);
|
||||
const widthPx = rect.width + overflowPx * 2;
|
||||
const heightPx = rect.height + overflowPx * 2;
|
||||
|
||||
const zIndex = normalizeRenderableZIndex(effectiveZIndex);
|
||||
const x =
|
||||
config.offX + (rect.left - overflowPx - config.rootX) * PX_TO_INCH * config.scale;
|
||||
const y =
|
||||
config.offY + (rect.top - overflowPx - config.rootY) * PX_TO_INCH * config.scale;
|
||||
const w = widthPx * PX_TO_INCH * config.scale;
|
||||
const h = heightPx * PX_TO_INCH * config.scale;
|
||||
|
||||
const hasText = hybridSubtreeHasText(node);
|
||||
|
||||
const item = {
|
||||
type: 'image',
|
||||
zIndex,
|
||||
domOrder,
|
||||
options: { x, y, w, h, data: null },
|
||||
};
|
||||
|
||||
const job = async () => {
|
||||
const rasterData = await rasterizeHybridDecorLayer(node, rect, overflowPx, {
|
||||
hideText: hasText,
|
||||
opacity: effectiveOpacity,
|
||||
});
|
||||
if (rasterData) item.options.data = rasterData;
|
||||
else item.skip = true;
|
||||
};
|
||||
|
||||
return { items: [item], job, recurseTextOnly: hasText };
|
||||
}
|
||||
|
||||
function estimateHybridOverflowPx(style) {
|
||||
let overflow = 0;
|
||||
const shadow = style.boxShadow;
|
||||
if (shadow && shadow !== 'none') {
|
||||
const re = /(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px(?:\s+(-?[\d.]+)px)?/g;
|
||||
let m;
|
||||
while ((m = re.exec(shadow))) {
|
||||
const reach =
|
||||
Math.max(Math.abs(parseFloat(m[1])), Math.abs(parseFloat(m[2]))) +
|
||||
(parseFloat(m[3]) || 0) +
|
||||
(parseFloat(m[4]) || 0);
|
||||
if (reach > overflow) overflow = reach;
|
||||
}
|
||||
}
|
||||
const filter = style.filter;
|
||||
if (filter && filter !== 'none') {
|
||||
const blurMatch = filter.match(/blur\(([\d.]+)px\)/);
|
||||
if (blurMatch) overflow = Math.max(overflow, parseFloat(blurMatch[1]) * 3);
|
||||
const dsMatch = filter.match(/drop-shadow\([^)]*?(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/);
|
||||
if (dsMatch) {
|
||||
overflow = Math.max(
|
||||
overflow,
|
||||
Math.max(Math.abs(parseFloat(dsMatch[1])), Math.abs(parseFloat(dsMatch[2]))) +
|
||||
parseFloat(dsMatch[3])
|
||||
);
|
||||
}
|
||||
}
|
||||
return Math.min(80, Math.ceil(overflow));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a hybrid subtree can be rendered through SVG <foreignObject>.
|
||||
* foreignObject uses the browser's native CSS renderer (conic gradients,
|
||||
* clip-path, blend modes all paint correctly) but cannot fetch external
|
||||
* resources (images, icon fonts) once serialized into an <img> source.
|
||||
*/
|
||||
function canUseForeignObjectForHybrid(node) {
|
||||
if (node.querySelector && node.querySelector('img, canvas, video, iframe, object, embed')) {
|
||||
return false;
|
||||
}
|
||||
if (isIconElement(node)) return false;
|
||||
if (node.querySelectorAll) {
|
||||
const candidates = node.querySelectorAll('i, span, [class]');
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (isIconElement(candidates[i])) return false;
|
||||
}
|
||||
}
|
||||
// url(...) backgrounds anywhere in the subtree also need external fetches.
|
||||
const win = getNodeWindow(node);
|
||||
const all = [node, ...(node.querySelectorAll ? Array.from(node.querySelectorAll('*')) : [])];
|
||||
for (const el of all) {
|
||||
const bg = win.getComputedStyle(el).backgroundImage || '';
|
||||
if (/\burl\s*\(/i.test(bg)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function inlineComputedStylesForForeignObject(sourceEl, targetEl, options = {}) {
|
||||
const win = getNodeWindow(sourceEl);
|
||||
const computed = win.getComputedStyle(sourceEl);
|
||||
let cssText = '';
|
||||
for (let i = 0; i < computed.length; i++) {
|
||||
const prop = computed[i];
|
||||
cssText += `${prop}:${computed.getPropertyValue(prop)};`;
|
||||
}
|
||||
targetEl.setAttribute('style', cssText);
|
||||
if (options.hideText && !isFormulaElement(sourceEl)) {
|
||||
let hasDirectText = false;
|
||||
for (let i = 0; i < sourceEl.childNodes.length; i++) {
|
||||
const child = sourceEl.childNodes[i];
|
||||
if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) {
|
||||
hasDirectText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasDirectText) {
|
||||
targetEl.style.setProperty('color', 'transparent', 'important');
|
||||
targetEl.style.setProperty('text-shadow', 'none', 'important');
|
||||
targetEl.style.setProperty('text-decoration-color', 'transparent', 'important');
|
||||
}
|
||||
}
|
||||
const sourceChildren = sourceEl.children;
|
||||
const targetChildren = targetEl.children;
|
||||
for (let i = 0; i < sourceChildren.length; i++) {
|
||||
if (targetChildren[i]) {
|
||||
inlineComputedStylesForForeignObject(sourceChildren[i], targetChildren[i], options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizeHybridViaForeignObject(node, rect, overflowPx, options = {}) {
|
||||
const width = Math.max(1, Math.ceil(rect.width + overflowPx * 2));
|
||||
const height = Math.max(1, Math.ceil(rect.height + overflowPx * 2));
|
||||
|
||||
const clone = node.cloneNode(true);
|
||||
inlineComputedStylesForForeignObject(node, clone, options);
|
||||
// Neutralize layout-context styles so the clone renders standalone at (overflow, overflow).
|
||||
clone.style.setProperty('position', 'static', 'important');
|
||||
clone.style.setProperty('margin', `${overflowPx}px 0 0 ${overflowPx}px`, 'important');
|
||||
clone.style.setProperty('left', 'auto', 'important');
|
||||
clone.style.setProperty('top', 'auto', 'important');
|
||||
clone.style.setProperty('right', 'auto', 'important');
|
||||
clone.style.setProperty('bottom', 'auto', 'important');
|
||||
clone.style.setProperty('width', `${Math.ceil(rect.width)}px`, 'important');
|
||||
clone.style.setProperty('height', `${Math.ceil(rect.height)}px`, 'important');
|
||||
clone.style.setProperty('box-sizing', 'border-box', 'important');
|
||||
// mix-blend-mode against an empty SVG canvas is a no-op that can blank the
|
||||
// layer entirely; paint it normally and let PPTX stacking approximate it.
|
||||
clone.style.setProperty('mix-blend-mode', 'normal', 'important');
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const wrapper = document.createElementNS('http://www.w3.org/1999/xhtml', 'div');
|
||||
wrapper.appendChild(clone);
|
||||
const svgMarkup =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
|
||||
`<foreignObject x="0" y="0" width="100%" height="100%">` +
|
||||
serializer.serializeToString(wrapper) +
|
||||
`</foreignObject></svg>`;
|
||||
|
||||
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
|
||||
|
||||
const img = await new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(null);
|
||||
image.src = svgUrl;
|
||||
});
|
||||
if (!img) return null;
|
||||
|
||||
const maxPixels = 8_000_000;
|
||||
let captureScale = 2;
|
||||
if (width * height * captureScale * captureScale > maxPixels) {
|
||||
captureScale = Math.max(1, Math.sqrt(maxPixels / Math.max(1, width * height)));
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(width * captureScale);
|
||||
canvas.height = Math.ceil(height * captureScale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const rawOpacity = Number(options.opacity);
|
||||
ctx.globalAlpha = Number.isFinite(rawOpacity) ? Math.max(0, Math.min(1, rawOpacity)) : 1;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
try {
|
||||
const data = canvas.toDataURL('image/png');
|
||||
if (!data || data.length < 64) return null;
|
||||
// A fully transparent capture means foreignObject silently failed
|
||||
// (e.g. unsupported markup) — return null so html2canvas takes over.
|
||||
const probe = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
let hasInk = false;
|
||||
for (let i = 3; i < probe.data.length; i += 4) {
|
||||
if (probe.data[i] > 0) {
|
||||
hasInk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return hasInk ? data : null;
|
||||
} catch (_) {
|
||||
// Tainted canvas — caller falls back to html2canvas.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterizes a hybrid subtree via html2canvas. When hideText is true, text
|
||||
* is made transparent inside the clone so the editable text layer above is
|
||||
* the only visible copy; icon/formula/media subtrees keep their fill.
|
||||
*/
|
||||
async function rasterizeHybridDecorLayer(node, rect, overflowPx, options = {}) {
|
||||
if (canUseForeignObjectForHybrid(node)) {
|
||||
try {
|
||||
const foData = await rasterizeHybridViaForeignObject(node, rect, overflowPx, options);
|
||||
if (foData) return foData;
|
||||
} catch (e) {
|
||||
console.warn('foreignObject hybrid raster failed, trying html2canvas:', e);
|
||||
}
|
||||
}
|
||||
return rasterizeHybridViaHtml2Canvas(node, rect, overflowPx, options);
|
||||
}
|
||||
|
||||
async function rasterizeHybridViaHtml2Canvas(node, rect, overflowPx, options = {}) {
|
||||
const sourceDoc = node.ownerDocument || document;
|
||||
const sourceWin = sourceDoc.defaultView || window;
|
||||
const width = Math.max(1, Math.ceil(rect.width + overflowPx * 2));
|
||||
const height = Math.max(1, Math.ceil(rect.height + overflowPx * 2));
|
||||
|
||||
const originalId = node.id;
|
||||
const tempId = 'pptx-hybrid-' + Math.random().toString(36).substr(2, 9);
|
||||
node.id = tempId;
|
||||
|
||||
const maxPixels = 8_000_000;
|
||||
let captureScale = 2;
|
||||
if (width * height * captureScale * captureScale > maxPixels) {
|
||||
captureScale = Math.max(1, Math.sqrt(maxPixels / Math.max(1, width * height)));
|
||||
}
|
||||
|
||||
const shouldKeepTextVisible = (el) => {
|
||||
if (isFormulaElement(el) || isIconElement(el)) return true;
|
||||
let p = el.parentElement;
|
||||
while (p) {
|
||||
if (isFormulaElement(p) || isIconElement(p)) return true;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(node, {
|
||||
backgroundColor: null,
|
||||
logging: false,
|
||||
scale: captureScale,
|
||||
useCORS: true,
|
||||
width,
|
||||
height,
|
||||
x: -overflowPx,
|
||||
y: -overflowPx,
|
||||
removeContainer: true,
|
||||
imageTimeout: 3500,
|
||||
onclone: (clonedDoc) => {
|
||||
const clonedNode = clonedDoc.getElementById(tempId);
|
||||
if (!clonedNode) return;
|
||||
clonedNode.style.overflow = 'visible';
|
||||
if (options.hideText) {
|
||||
const walker = clonedDoc.createTreeWalker(clonedNode, NodeFilter.SHOW_ELEMENT);
|
||||
const toHide = [];
|
||||
let cur = clonedNode;
|
||||
while (cur) {
|
||||
let hasDirectText = false;
|
||||
for (let i = 0; i < cur.childNodes.length; i++) {
|
||||
const child = cur.childNodes[i];
|
||||
if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) {
|
||||
hasDirectText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasDirectText && !shouldKeepTextVisible(cur)) toHide.push(cur);
|
||||
cur = walker.nextNode();
|
||||
}
|
||||
toHide.forEach((el) => {
|
||||
el.style.setProperty('color', 'transparent', 'important');
|
||||
el.style.setProperty('text-shadow', 'none', 'important');
|
||||
el.style.setProperty('text-decoration-color', 'transparent', 'important');
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const rawOpacity = Number(options.opacity);
|
||||
const opacity = Number.isFinite(rawOpacity) ? Math.max(0, Math.min(1, rawOpacity)) : 1;
|
||||
let outCanvas = canvas;
|
||||
if (opacity < 0.999) {
|
||||
outCanvas = document.createElement('canvas');
|
||||
outCanvas.width = canvas.width;
|
||||
outCanvas.height = canvas.height;
|
||||
const ctx = outCanvas.getContext('2d');
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
}
|
||||
const data = outCanvas.toDataURL('image/png');
|
||||
return data && data.length > 64 ? data : null;
|
||||
} catch (e) {
|
||||
console.warn('Hybrid raster capture failed, falling back to element pipeline:', e);
|
||||
return null;
|
||||
} finally {
|
||||
if (originalId) node.id = originalId;
|
||||
else node.removeAttribute('id');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces createRenderItem.
|
||||
* Returns { items: [], job: () => Promise, stopRecursion: boolean }
|
||||
@@ -66871,7 +67325,8 @@
|
||||
effectiveZIndex,
|
||||
computedStyle,
|
||||
globalOptions = {},
|
||||
effectiveOpacity = 1
|
||||
effectiveOpacity = 1,
|
||||
textOnlyMode = false
|
||||
) {
|
||||
// 1. Text Node Handling
|
||||
if (node.nodeType === 3) {
|
||||
@@ -66958,6 +67413,74 @@
|
||||
|
||||
const items = [];
|
||||
|
||||
// TEXT-ONLY MODE (hybrid raster): visuals are already baked into the
|
||||
// decoration raster; only re-emit editable text on top. Media, icons and
|
||||
// formulas stay visible inside the raster, so skip them here.
|
||||
if (textOnlyMode) {
|
||||
const tagUpper = String(node.tagName || '').toUpperCase();
|
||||
if (['SVG', 'CANVAS', 'IMG', 'VIDEO', 'IFRAME', 'OBJECT', 'EMBED'].includes(tagUpper)) {
|
||||
return { items: [], stopRecursion: true };
|
||||
}
|
||||
if (isFormulaElement(node) || isIconElement(node)) {
|
||||
return { items: [], stopRecursion: true };
|
||||
}
|
||||
const isListRoot = (tagUpper === 'UL' || tagUpper === 'OL') && !isComplexHierarchy(node);
|
||||
if (!isListRoot) {
|
||||
if (node.tagName === 'TABLE') {
|
||||
// Keep table text editable; visuals come from the raster layer.
|
||||
const tableData = extractTableData(node, config.scale);
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
type: 'table',
|
||||
zIndex: nextRenderableZIndex(zIndex),
|
||||
domOrder,
|
||||
tableData,
|
||||
options: { x, y, w: unrotatedW, h: unrotatedH },
|
||||
},
|
||||
],
|
||||
stopRecursion: true,
|
||||
};
|
||||
}
|
||||
if (!isTextContainer(node)) return null;
|
||||
const textParts = finalizeInlineTextParts(
|
||||
collectInlineTextParts(node, config.scale, safeOpacity, node)
|
||||
);
|
||||
if (textParts.length === 0) return { items: [], stopRecursion: true };
|
||||
|
||||
let align = style.textAlign || 'left';
|
||||
if (align === 'start') align = 'left';
|
||||
if (align === 'end') align = 'right';
|
||||
let valign = 'top';
|
||||
if (style.alignItems === 'center') valign = 'middle';
|
||||
if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
|
||||
textParts[0].options.fontSize = Math.floor(textParts[0]?.options?.fontSize) || 12;
|
||||
|
||||
items.push({
|
||||
type: 'text',
|
||||
zIndex: nextRenderableZIndex(zIndex),
|
||||
domOrder,
|
||||
textParts,
|
||||
options: {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
align,
|
||||
valign,
|
||||
inset: getPadding(style, config.scale),
|
||||
rotate: rotation,
|
||||
margin: 0,
|
||||
wrap: true,
|
||||
autoFit: false,
|
||||
},
|
||||
});
|
||||
return { items, stopRecursion: true };
|
||||
}
|
||||
// List roots fall through to the standard UL/OL text extraction below,
|
||||
// where textOnlyMode suppresses their background/formula visuals.
|
||||
}
|
||||
|
||||
if (node.tagName === 'TABLE') {
|
||||
const tableData = extractTableData(node, config.scale);
|
||||
|
||||
@@ -67124,9 +67647,9 @@
|
||||
});
|
||||
|
||||
if (listItems.length > 0) {
|
||||
// Add background if exists
|
||||
// Add background if exists (skip in hybrid text-only mode: raster has it)
|
||||
const bgColorObj = parseColor(style.backgroundColor);
|
||||
if (bgColorObj.hex && bgColorObj.opacity > 0) {
|
||||
if (!textOnlyMode && bgColorObj.hex && bgColorObj.opacity > 0) {
|
||||
items.push({
|
||||
type: 'shape',
|
||||
zIndex,
|
||||
@@ -67159,12 +67682,12 @@
|
||||
},
|
||||
});
|
||||
|
||||
if (formulaArtifacts.items.length > 0) {
|
||||
if (!textOnlyMode && formulaArtifacts.items.length > 0) {
|
||||
items.push(...formulaArtifacts.items);
|
||||
}
|
||||
|
||||
const formulaJob =
|
||||
formulaArtifacts.jobs.length > 0
|
||||
!textOnlyMode && formulaArtifacts.jobs.length > 0
|
||||
? async () => {
|
||||
for (const job of formulaArtifacts.jobs) {
|
||||
await job();
|
||||
@@ -68002,7 +68525,7 @@
|
||||
return items;
|
||||
}
|
||||
|
||||
var LANDPPT_DOM_TO_PPTX_PATCH_VERSION = '2026-06-14-bg-layer-v4';
|
||||
var LANDPPT_DOM_TO_PPTX_PATCH_VERSION = '2026-07-07-hybrid-raster-v1';
|
||||
exports.exportToPptx = exportToPptx;
|
||||
exports.setIconRules = setIconRules;
|
||||
exports.getIconRules = getIconRules;
|
||||
|
||||
+2
-2
@@ -415,8 +415,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
const DOM_TO_PPTX_BUNDLE_VERSION = '20260614-bg-layer-v4';
|
||||
const DOM_TO_PPTX_EXPECTED_PATCH_VERSION = '2026-06-14-bg-layer-v4';
|
||||
const DOM_TO_PPTX_BUNDLE_VERSION = '20260707-hybrid-raster-v1';
|
||||
const DOM_TO_PPTX_EXPECTED_PATCH_VERSION = '2026-07-07-hybrid-raster-v1';
|
||||
let domToPptxReloadPromise = null;
|
||||
|
||||
function isDomToPptxPatchedInstance(instance) {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
let domToPptxLoadPromise = null;
|
||||
const DOM_TO_PPTX_BUNDLE_PATH = '/static/js/dom-to-pptx.bundle.js';
|
||||
const DOM_TO_PPTX_BUNDLE_VERSION = '20260614-bg-layer-v4';
|
||||
const DOM_TO_PPTX_EXPECTED_PATCH_VERSION = '2026-06-14-bg-layer-v4';
|
||||
const DOM_TO_PPTX_BUNDLE_VERSION = '20260707-hybrid-raster-v1';
|
||||
const DOM_TO_PPTX_EXPECTED_PATCH_VERSION = '2026-07-07-hybrid-raster-v1';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -731,7 +731,7 @@
|
||||
'exportTitle': (project.title if project and project.title else 'presentation'),
|
||||
} | tojson | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dom-to-pptx.bundle.js?v=20260614-bg-layer-v4"></script>
|
||||
<script src="/static/js/dom-to-pptx.bundle.js?v=20260707-hybrid-raster-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectEditorShareExport.js?v=20260328-phase3"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.core.js?v=20260613-thumbnail-fill-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.quickEdit.js?v=20260408-quick-reset-v2"></script>
|
||||
@@ -775,8 +775,8 @@
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js?v=20260614-modalui3"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportBase.js?v=20260425-layer-clip-v21"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportIcons.js?v=20260402-split"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportRender.js?v=20260614-bg-layer-v4"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportPptx.js?v=20260614-bg-layer-v4"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportRender.js?v=20260707-hybrid-raster-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportPptx.js?v=20260707-hybrid-raster-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectEditorNarration.js?v=20260509-custom-tts-api-v1"></script>
|
||||
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.init.js?v=20260402-split"></script>
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1280, initial-scale=1">
|
||||
<title>dom-to-pptx hybrid raster smoke</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #101214;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.pptx-slide {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
color: #20242a;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
left: 80px;
|
||||
top: 72px;
|
||||
margin: 0;
|
||||
font-size: 54px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Hybrid trigger 1: clip-path decoration (no text) */
|
||||
.clipped-badge {
|
||||
position: absolute;
|
||||
right: 96px;
|
||||
top: 84px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: #2b66d9;
|
||||
clip-path: polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%);
|
||||
}
|
||||
|
||||
/* Hybrid trigger 2: conic gradient + text content -> raster decor + editable text */
|
||||
.conic-card {
|
||||
position: absolute;
|
||||
left: 80px;
|
||||
top: 240px;
|
||||
width: 560px;
|
||||
height: 300px;
|
||||
padding: 36px;
|
||||
box-sizing: border-box;
|
||||
background: conic-gradient(from 45deg, #ffd9a0, #ff9db0, #a0c8ff, #ffd9a0);
|
||||
border-radius: 24px;
|
||||
font-size: 26px;
|
||||
line-height: 1.5;
|
||||
color: #1c2430;
|
||||
}
|
||||
|
||||
/* Hybrid trigger 3: multi box-shadow container with text */
|
||||
.multi-shadow-card {
|
||||
position: absolute;
|
||||
left: 700px;
|
||||
top: 300px;
|
||||
width: 460px;
|
||||
height: 220px;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(20, 28, 36, 0.18),
|
||||
0 16px 40px rgba(43, 102, 217, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="pptx-slide">
|
||||
<h1 class="title">Hybrid raster export</h1>
|
||||
<div class="clipped-badge"></div>
|
||||
<div class="conic-card">
|
||||
Conic gradient backgrounds rasterize while this sentence stays editable text.
|
||||
</div>
|
||||
<div class="multi-shadow-card">
|
||||
Multi layer shadows rasterize while this sentence stays editable text.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="../../src/landppt/web/static/js/dom-to-pptx.bundle.js"></script>
|
||||
<script>
|
||||
async function blobToBase64(blob) {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
window.runHybridRasterPptxSmokeTest = async function () {
|
||||
const slides = [document.body];
|
||||
const blob = await window.domToPptx.exportToPptx(slides, {
|
||||
skipDownload: true,
|
||||
autoEmbedFonts: false,
|
||||
svgAsVector: false
|
||||
});
|
||||
return {
|
||||
slideCount: slides.length,
|
||||
patchVersion: window.domToPptx.__landpptPatchVersion,
|
||||
blobSize: blob.size,
|
||||
pptxBase64: await blobToBase64(blob)
|
||||
};
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "dom_to_pptx_formula_smoke.html"
|
||||
EXPECTED_PATCH_VERSION = "2026-06-14-bg-layer-v4"
|
||||
EXPECTED_PATCH_VERSION = "2026-07-07-hybrid-raster-v1"
|
||||
|
||||
|
||||
def test_dom_to_pptx_formula_smoke():
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "dom_to_pptx_hybrid_raster_smoke.html"
|
||||
EXPECTED_PATCH_VERSION = "2026-07-07-hybrid-raster-v1"
|
||||
|
||||
|
||||
def _read_pptx(result):
|
||||
pptx_bytes = base64.b64decode(result["pptxBase64"])
|
||||
return zipfile.ZipFile(io.BytesIO(pptx_bytes))
|
||||
|
||||
|
||||
def test_dom_to_pptx_hybrid_raster_keeps_text_editable():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception as exc: # pragma: no cover - depends on local dependency install
|
||||
pytest.skip(f"Playwright is not installed: {exc}")
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch()
|
||||
except Exception as exc: # pragma: no cover - depends on local browser install
|
||||
pytest.skip(f"Chromium is not available for Playwright: {exc}")
|
||||
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page_errors = []
|
||||
page.on("pageerror", lambda exc: page_errors.append(str(exc)))
|
||||
|
||||
try:
|
||||
page.goto(FIXTURE_PATH.resolve().as_uri(), wait_until="load")
|
||||
page.wait_for_function("window.domToPptx && window.runHybridRasterPptxSmokeTest")
|
||||
result = page.evaluate("() => window.runHybridRasterPptxSmokeTest()")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
assert not page_errors
|
||||
assert result["slideCount"] == 1
|
||||
assert result["patchVersion"] == EXPECTED_PATCH_VERSION
|
||||
assert result["blobSize"] > 10_000
|
||||
|
||||
with _read_pptx(result) as archive:
|
||||
slide_xml = archive.read("ppt/slides/slide1.xml").decode("utf-8")
|
||||
media_entries = [name for name in archive.namelist() if name.startswith("ppt/media/")]
|
||||
media_payloads = [archive.read(name) for name in media_entries]
|
||||
|
||||
# Hybrid decoration layers exported as PNG images.
|
||||
assert "<p:pic>" in slide_xml
|
||||
assert any(payload.startswith(b"\x89PNG\r\n\x1a\n") for payload in media_payloads)
|
||||
# At least 3 raster layers: clipped badge, conic card, multi-shadow card.
|
||||
assert len(media_entries) >= 3
|
||||
|
||||
# Text inside hybrid subtrees must remain native editable text runs.
|
||||
assert "Conic gradient backgrounds rasterize" in slide_xml
|
||||
assert "Multi layer shadows rasterize" in slide_xml
|
||||
assert "Hybrid raster export" in slide_xml
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "dom_to_pptx_inline_complex_smoke.html"
|
||||
EXPECTED_PATCH_VERSION = "2026-06-14-bg-layer-v4"
|
||||
EXPECTED_PATCH_VERSION = "2026-07-07-hybrid-raster-v1"
|
||||
|
||||
|
||||
def _read_pptx_entry(result, entry_name):
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "dom_to_pptx_layered_background_smoke.html"
|
||||
EXPECTED_PATCH_VERSION = "2026-06-14-bg-layer-v4"
|
||||
EXPECTED_PATCH_VERSION = "2026-07-07-hybrid-raster-v1"
|
||||
|
||||
|
||||
def _read_pptx(result):
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "dom_to_pptx_svg_css_vars_smoke.html"
|
||||
EXPECTED_PATCH_VERSION = "2026-06-14-bg-layer-v4"
|
||||
EXPECTED_PATCH_VERSION = "2026-07-07-hybrid-raster-v1"
|
||||
|
||||
|
||||
def _read_pptx(result):
|
||||
|
||||
Reference in New Issue
Block a user