返回 oh-my-ppt
browser-scripts.ts
根目录 / src / main / io / html-pptx / browser-scripts.ts
1 export const FREEZE_PAGE_FOR_EXPORT_SCRIPT = `
2 (async () => {
3 const waitForMasterStylesheet = async () => {
4 const master = document.querySelector('link[data-ppt-master="1"]');
5 const expectsMaster = new URLSearchParams(window.location.search).get('_pptMasterExpected') === '1';
6 if (!master || !expectsMaster) return;
7 if (master.dataset.pptMasterExportReady === '1' && master.sheet) return;
8 const masterUrl = new URL(master.href, window.location.href);
9 masterUrl.searchParams.set('_pptMasterExport', String(Date.now()));
10 master.href = masterUrl.toString();
11 await new Promise((resolve, reject) => {
12 let settled = false;
13 const finish = (callback) => {
14 if (settled) return;
15 settled = true;
16 clearTimeout(timeout);
17 master.removeEventListener('load', onLoad);
18 master.removeEventListener('error', onError);
19 callback();
20 };
21 const onLoad = () => finish(() => {
22 master.dataset.pptMasterExportReady = '1';
23 resolve(true);
24 });
25 const onError = () => finish(() => reject(new Error('母版样式表加载失败')));
26 const timeout = setTimeout(
27 () => finish(() => reject(new Error('母版样式表加载超时'))),
28 5000
29 );
30 master.addEventListener('load', onLoad, { once: true });
31 master.addEventListener('error', onError, { once: true });
32 });
33 };
34
35 await waitForMasterStylesheet();
36 if (window.PPT?.whenReadyForPrint) {
37 await window.PPT.whenReadyForPrint(5000);
38 }
39 const expectsMasterElements =
40 new URLSearchParams(window.location.search).get('_pptMasterElementsExpected') === '1';
41 if (expectsMasterElements && !window.PPT?.assertMasterElementsReady) {
42 throw new Error('母版全局元素运行时不可用');
43 }
44 if (window.PPT?.assertMasterElementsReady) {
45 await window.PPT.assertMasterElementsReady(5000);
46 }
47 const root =
48 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
49 document.querySelector('.ppt-page-root') ||
50 document.body;
51 const existing = document.getElementById('ohmyppt-export-freeze-page');
52 if (existing) existing.remove();
53 const style = document.createElement('style');
54 style.id = 'ohmyppt-export-freeze-page';
55 style.textContent = [
56 'html { scroll-behavior: auto !important; }',
57 '*, *::before, *::after { animation: none !important; transition: none !important; animation-delay: 0s !important; animation-duration: 0s !important; animation-play-state: paused !important; transition-delay: 0s !important; transition-duration: 0s !important; }',
58 '.opacity-0, [data-anime], [data-animate], [data-anim] { opacity: 1 !important; transform: none !important; }'
59 ].join('\\n');
60 document.head.appendChild(style);
61
62 try {
63 document.getAnimations?.().forEach((animation) => {
64 try {
65 animation.finish();
66 } catch (_err) {
67 try {
68 animation.cancel();
69 } catch (_cancelErr) {}
70 }
71 });
72 } catch (_err) {}
73
74 const waitFrames = (frames) =>
75 new Promise((resolve) => {
76 let remaining = Math.max(1, Number(frames) || 1);
77 const next = () => {
78 remaining -= 1;
79 if (remaining <= 0) {
80 resolve(true);
81 return;
82 }
83 requestAnimationFrame(next);
84 };
85 requestAnimationFrame(next);
86 });
87
88 const collectChartInstances = () => {
89 const charts = new Set();
90 const ChartCtor = window.Chart;
91 try {
92 if (window.__PPT_CHART_REGISTRY__ instanceof Map) {
93 window.__PPT_CHART_REGISTRY__.forEach((chart) => {
94 if (chart) charts.add(chart);
95 });
96 }
97 } catch (_err) {}
98 try {
99 if (ChartCtor?.instances) {
100 const instances = Array.isArray(ChartCtor.instances)
101 ? ChartCtor.instances
102 : Object.values(ChartCtor.instances);
103 instances.forEach((chart) => {
104 if (chart) charts.add(chart);
105 });
106 }
107 } catch (_err) {}
108 try {
109 root.querySelectorAll('canvas').forEach((canvas) => {
110 let chart = null;
111 try {
112 chart = ChartCtor?.getChart?.(canvas) || null;
113 } catch (_err) {}
114 if (chart) charts.add(chart);
115 });
116 } catch (_err) {}
117 return Array.from(charts);
118 };
119
120 const disableChartAnimations = () => {
121 const ChartCtor = window.Chart;
122 try {
123 if (ChartCtor?.defaults) {
124 ChartCtor.defaults.animation = false;
125 ChartCtor.defaults.animations = false;
126 if (ChartCtor.defaults.transitions) {
127 Object.values(ChartCtor.defaults.transitions).forEach((transition) => {
128 if (transition?.animation) transition.animation.duration = 0;
129 if (transition?.animations) {
130 Object.values(transition.animations).forEach((animation) => {
131 if (animation && typeof animation === 'object') animation.duration = 0;
132 });
133 }
134 });
135 }
136 }
137 } catch (_err) {}
138 };
139
140 const fingerprintCanvases = () => {
141 const canvases = Array.from(root.querySelectorAll('canvas'));
142 if (canvases.length === 0) return '';
143 return canvases
144 .map((canvas) => {
145 const width = canvas.width || 0;
146 const height = canvas.height || 0;
147 if (!width || !height) return 'empty';
148 let ctx = null;
149 try {
150 ctx = canvas.getContext('2d', { willReadFrequently: true }) || canvas.getContext('2d');
151 } catch (_err) {
152 return 'unreadable';
153 }
154 if (!ctx) return 'noctx';
155 const columns = Math.min(8, Math.max(2, Math.floor(width / 80)));
156 const rows = Math.min(6, Math.max(2, Math.floor(height / 60)));
157 let hash = 2166136261;
158 try {
159 for (let yIndex = 0; yIndex < rows; yIndex += 1) {
160 const y = Math.min(height - 1, Math.floor(((yIndex + 0.5) * height) / rows));
161 for (let xIndex = 0; xIndex < columns; xIndex += 1) {
162 const x = Math.min(width - 1, Math.floor(((xIndex + 0.5) * width) / columns));
163 const data = ctx.getImageData(x, y, 1, 1).data;
164 for (let i = 0; i < 4; i += 1) {
165 hash ^= data[i] || 0;
166 hash = Math.imul(hash, 16777619);
167 }
168 }
169 }
170 return String(width) + 'x' + String(height) + ':' + String(hash >>> 0);
171 } catch (_err) {
172 return 'tainted';
173 }
174 })
175 .join('|');
176 };
177
178 const waitForCanvasStability = async () => {
179 if (!root.querySelector('canvas')) return;
180 let previous = '';
181 let stableFrames = 0;
182 const deadline = Date.now() + 1200;
183 while (Date.now() < deadline) {
184 await waitFrames(2);
185 const next = fingerprintCanvases();
186 if (next && next === previous) {
187 stableFrames += 1;
188 if (stableFrames >= 2) return;
189 } else {
190 stableFrames = 0;
191 previous = next;
192 }
193 }
194 };
195
196 const stabilizeCharts = async () => {
197 disableChartAnimations();
198 const applyFinalChartState = () => {
199 collectChartInstances().forEach((chart) => {
200 try {
201 if (chart?.options) {
202 chart.options.animation = false;
203 chart.options.animations = false;
204 chart.options.responsive = false;
205 chart.options.maintainAspectRatio = false;
206 }
207 } catch (_err) {}
208 try {
209 if (typeof chart?.stop === 'function') chart.stop();
210 } catch (_err) {}
211 try {
212 if (typeof chart?.resize === 'function') chart.resize();
213 } catch (_err) {}
214 try {
215 if (typeof chart?.update === 'function') chart.update('none');
216 } catch (_err) {}
217 try {
218 if (typeof chart?.render === 'function') chart.render();
219 else if (typeof chart?.draw === 'function') chart.draw();
220 } catch (_err) {}
221 });
222 };
223
224 applyFinalChartState();
225 await waitFrames(2);
226 applyFinalChartState();
227 await waitForCanvasStability();
228 };
229
230 await stabilizeCharts();
231
232 const shouldForceVisibleForMotion = (node) => {
233 if (!node?.matches?.('.opacity-0, [data-anime], [data-animate], [data-anim]')) return false;
234 return Number(getComputedStyle(node).opacity || '1') <= 0.04;
235 };
236
237 const motionTargets = root.querySelectorAll(
238 '.opacity-0, [data-anime], [data-animate], h1, h2, h3, p, li, .card, .panel, .text-section, .diagram-section, .timeline-node, section, section > *'
239 );
240 motionTargets.forEach((element) => {
241 const node = element;
242 node.style.transition = 'none';
243 node.style.animation = 'none';
244 if (shouldForceVisibleForMotion(node)) {
245 node.setAttribute('data-pptx-animated', '1');
246 node.style.opacity = '1';
247 }
248 if (/translateY\\([^)]*\\)/.test(node.style.transform || '')) {
249 node.setAttribute('data-pptx-animated', '1');
250 node.style.transform = 'none';
251 }
252 });
253
254 root.querySelectorAll('*').forEach((element) => {
255 const node = element;
256 const computed = getComputedStyle(node);
257 if (computed.display === 'none' || computed.visibility === 'hidden') return;
258 if (shouldForceVisibleForMotion(node)) {
259 node.setAttribute('data-pptx-animated', '1');
260 node.style.opacity = '1';
261 }
262 if (/translate(?:3d|X|Y)?\\(/.test(node.style.transform || '')) {
263 node.setAttribute('data-pptx-animated', '1');
264 node.style.transform = 'none';
265 }
266 });
267
268 if (document.fonts?.ready) {
269 try {
270 await document.fonts.ready;
271 } catch (_err) {}
272 }
273 return true;
274 })()
275 `
276
277 export const FREEZE_PAGE_FOR_PPTX_SCRIPT = FREEZE_PAGE_FOR_EXPORT_SCRIPT
278
279 /**
280 * Reset ppt-page-fit-scope transform to scale(1) for full-resolution capture.
281 * Must be executed AFTER text/shape extraction (which needs the scaled coordinates)
282 * but BEFORE screen capture.
283 */
284 export const RESET_SCALE_FOR_PPTX_CAPTURE_SCRIPT = `
285 (async () => {
286 const root =
287 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
288 document.querySelector('.ppt-page-root') ||
289 document.body;
290 const scope = root.querySelector(':scope > .ppt-page-fit-scope');
291 if (scope) scope.style.transform = 'scale(1)';
292 void document.body.offsetHeight;
293 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
294 return true;
295 })()
296 `
297
298 export const HIDE_TEXT_FOR_PPTX_BACKGROUND_SCRIPT = `
299 (async () => {
300 const existing = document.getElementById('ohmyppt-pptx-hide-text');
301 if (existing) existing.remove();
302 const isVisibleColor = (value) => {
303 const color = String(value || '').trim().toLowerCase();
304 return Boolean(color && color !== 'transparent' && !/^rgba?\\([^)]*,\\s*0\\s*\\)$/.test(color));
305 };
306 const resolveVisibleTextColor = (element) => {
307 let current = element;
308 while (current && current.nodeType === 1) {
309 const color = getComputedStyle(current).color;
310 if (isVisibleColor(color)) return color;
311 current = current.parentElement;
312 }
313 return '#111827';
314 };
315 const style = document.createElement('style');
316 style.id = 'ohmyppt-pptx-hide-text';
317 style.textContent = [
318 'body :not(.katex):not(.katex *):not(canvas) { -webkit-text-fill-color: transparent !important; -webkit-text-stroke-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; caret-color: transparent !important; }',
319 'body :not(.katex):not(.katex *)::before, body :not(.katex):not(.katex *)::after { -webkit-text-fill-color: transparent !important; -webkit-text-stroke-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; }',
320 '.katex, .katex * { -webkit-text-fill-color: currentColor !important; text-shadow: none !important; }',
321 'svg text, svg tspan { fill: transparent !important; stroke: transparent !important; }',
322 'input, textarea { color: transparent !important; -webkit-text-fill-color: transparent !important; }'
323 ].join('\\n');
324 document.head.appendChild(style);
325 document.querySelectorAll('.katex').forEach((element) => {
326 const node = element;
327 const color = resolveVisibleTextColor(node);
328 node.style.color = color;
329 node.style.webkitTextFillColor = color;
330 node.style.fontFamily = 'KaTeX_Main, "Times New Roman", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif';
331 });
332 const hideTextPaint = (node) => {
333 node.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
334 node.style.setProperty('-webkit-text-stroke-color', 'transparent', 'important');
335 node.style.setProperty('text-shadow', 'none', 'important');
336 node.style.setProperty('text-decoration-color', 'transparent', 'important');
337 node.style.setProperty('caret-color', 'transparent', 'important');
338 };
339 const hasOwnTextNode = (element) =>
340 Array.from(element.childNodes || []).some((node) => node.nodeType === Node.TEXT_NODE && String(node.textContent || '').trim());
341 document.querySelectorAll('body *').forEach((element) => {
342 if (element.closest('.katex, .katex-mathml, script, style, noscript, canvas')) return;
343 if (hasOwnTextNode(element)) hideTextPaint(element);
344 });
345 document.querySelectorAll('svg text, svg tspan').forEach((element) => {
346 element.style.setProperty('fill', 'transparent', 'important');
347 element.style.setProperty('stroke', 'transparent', 'important');
348 });
349 void document.body.offsetHeight;
350 if (document.fonts?.ready) {
351 try {
352 await document.fonts.ready;
353 } catch (_err) {}
354 }
355 void document.body.offsetHeight;
356 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
357 return true;
358 })()
359 `
360
361 export const buildMarkPptxExtractedTextForBackgroundScript = (
362 texts: Array<{ x: number; y: number; w: number; h: number }>,
363 slideSize: { widthIn: number; heightIn: number } = { widthIn: 13.333, heightIn: 7.5 }
364 ): string => {
365 const boxes = texts
366 .filter(
367 (text) =>
368 Number.isFinite(text.x) &&
369 Number.isFinite(text.y) &&
370 Number.isFinite(text.w) &&
371 Number.isFinite(text.h) &&
372 text.w > 0.02 &&
373 text.h > 0.02
374 )
375 .map((text) => ({ x: text.x, y: text.y, w: text.w, h: text.h }))
376
377 return `
378 (() => {
379 const root =
380 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
381 document.querySelector('.ppt-page-root') ||
382 document.body;
383 // This script is rerun for background-capture retries. Remove stale matches before
384 // evaluating the latest extraction result so a newly unsupported text node stays rasterized.
385 root.querySelectorAll('[data-pptx-background-text-match]').forEach((element) => {
386 element.removeAttribute('data-pptx-background-text-match');
387 });
388 const textBoxes = ${JSON.stringify(boxes)};
389 if (!textBoxes.length) return 0;
390 const rootRect = root.getBoundingClientRect();
391 if (!rootRect.width || !rootRect.height) return 0;
392 const slideWidth = ${JSON.stringify(slideSize.widthIn)};
393 const slideHeight = ${JSON.stringify(slideSize.heightIn)};
394 const toClientBox = (box) => ({
395 left: rootRect.left + (box.x / slideWidth) * rootRect.width,
396 top: rootRect.top + (box.y / slideHeight) * rootRect.height,
397 width: (box.w / slideWidth) * rootRect.width,
398 height: (box.h / slideHeight) * rootRect.height
399 });
400 const boxesInClientSpace = textBoxes.map(toClientBox);
401 const overlapsExtractedText = (rect) => {
402 if (rect.width < 1 || rect.height < 1) return false;
403 const rectArea = rect.width * rect.height;
404 return boxesInClientSpace.some((box) => {
405 const overlapWidth = Math.max(0, Math.min(rect.right, box.left + box.width) - Math.max(rect.left, box.left));
406 const overlapHeight = Math.max(0, Math.min(rect.bottom, box.top + box.height) - Math.max(rect.top, box.top));
407 const overlap = overlapWidth * overlapHeight;
408 // A native PPT box can be intentionally wider than its painted glyphs, but it must
409 // still cover almost all of this DOM text fragment. Partial or center-only overlap
410 // leaves the fragment in the raster background as a conservative fallback.
411 return overlap / rectArea >= 0.85;
412 });
413 };
414 let marked = 0;
415 root.querySelectorAll('*').forEach((element) => {
416 if (element.closest('script, style, noscript, svg, canvas, video, iframe, .katex, .katex-mathml')) return;
417 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
418 acceptNode: (node) =>
419 String(node.textContent || '').trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
420 });
421 const textNodes = [];
422 while (walker.nextNode()) textNodes.push(walker.currentNode);
423 if (!textNodes.length) return;
424 const textRects = textNodes.flatMap((node) => {
425 const range = document.createRange();
426 range.selectNodeContents(node);
427 return Array.from(range.getClientRects());
428 });
429 if (textRects.length && textRects.every(overlapsExtractedText)) {
430 // Mark the container only when every descendant text fragment maps to a
431 // native PPT text box. This handles mixed content such as "$528<span>亿</span>"
432 // without hiding a neighboring fallback-only child through inheritance.
433 element.setAttribute('data-pptx-background-text-match', '1');
434 marked += 1;
435 }
436 });
437 return marked;
438 })()
439 `
440 }
441
442 // Background capture for PPTX keeps every visual that cannot be represented as
443 // a native PPTX object. Only successfully extracted objects are removed. This
444 // avoids both text ghosts on animated nodes and missing complex CSS/DOM visuals.
445 export const HIDE_FOR_PPTX_BACKGROUND_SCRIPT = `
446 (async () => {
447 // Helper: same rgbToHex as main extraction script
448 const rgbToHex = (value) => {
449 const source = String(value || '').trim();
450 if (!source || source === 'transparent') return '';
451 if (source.startsWith('#')) {
452 const raw = source.slice(1).toUpperCase();
453 return raw.length === 3 ? raw.split('').map((part) => part + part).join('') : raw;
454 }
455 const match = source.match(/rgba?\\(\\s*(\\d+(?:\\.\\d+)?)(?:\\s*,\\s*|\\s+)(\\d+(?:\\.\\d+)?)(?:\\s*,\\s*|\\s+)(\\d+(?:\\.\\d+)?)(?:\\s*(?:,|\\/)\\s*(\\d+(?:\\.\\d+)?%?))?/i);
456 if (!match) return '';
457 const alpha = match[4] === undefined
458 ? 1
459 : String(match[4]).endsWith('%')
460 ? Number.parseFloat(match[4]) / 100
461 : Number(match[4]);
462 if (alpha <= 0.02) return '';
463 return [match[1], match[2], match[3]]
464 .map((part) => Math.max(0, Math.min(255, Math.round(Number(part) || 0))).toString(16).padStart(2, '0'))
465 .join('')
466 .toUpperCase();
467 };
468
469 // 1. Mark additional decorative elements (blur blobs, glass-morphism, very low Tailwind opacity)
470 const root = document.querySelector('.ppt-page-root') || document.body;
471 root.querySelectorAll('*').forEach((el) => {
472 if (el.hasAttribute('data-pptx-animated')) return;
473 const style = getComputedStyle(el);
474 const hasBlur = /blur/i.test(style.filter || '') || /blur/i.test(style.backdropFilter || '');
475 const cls = el.className && typeof el.className === 'string' ? el.className : '';
476 const hasDecoClass = /\\b(opacity-[012]0|opacity-[12]5)\\b/.test(cls) || /\\bblur-(sm|md|lg|xl|2xl|3xl)\\b/.test(cls);
477 if (hasBlur || hasDecoClass) {
478 el.setAttribute('data-pptx-animated', '1');
479 }
480 });
481
482 // 1b. Mark full-page background elements as decorative (preserve their background during capture)
483 const pageArea = root.getBoundingClientRect().width * root.getBoundingClientRect().height;
484 root.querySelectorAll(':scope > div, :scope > section, :scope > main').forEach((el) => {
485 if (el.hasAttribute('data-pptx-animated')) return;
486 const style = getComputedStyle(el);
487 const fill = rgbToHex(style.backgroundColor);
488 if (!fill) return;
489 const rect = el.getBoundingClientRect();
490 if (rect.width * rect.height >= pageArea * 0.5) {
491 el.setAttribute('data-pptx-animated', '1');
492 }
493 });
494
495 // Large edge-anchored fills are structural backgrounds. Keep them in the
496 // screenshot base instead of relying on a native shape whose z-order can
497 // cover the slide or be accidentally targeted by a text animation.
498 root.querySelectorAll('[data-pptx-extracted-shape]').forEach((el) => {
499 const rect = el.getBoundingClientRect();
500 const rootRect = root.getBoundingClientRect();
501 const pageArea = rootRect.width * rootRect.height;
502 if (!pageArea || rect.width * rect.height < pageArea * 0.2) return;
503 const horizontalTolerance = 2;
504 const verticalTolerance = 2;
505 const spansWidth = rect.width >= rootRect.width - horizontalTolerance;
506 const spansHeight = rect.height >= rootRect.height - verticalTolerance;
507 if (!spansWidth && !spansHeight) return;
508 const touchesHorizontalEdge = rect.left <= rootRect.left + horizontalTolerance || rect.right >= rootRect.right - horizontalTolerance;
509 const touchesVerticalEdge = rect.top <= rootRect.top + verticalTolerance || rect.bottom >= rootRect.bottom - verticalTolerance;
510 if (touchesHorizontalEdge && touchesVerticalEdge) {
511 el.setAttribute('data-pptx-static-background', '1');
512 }
513 });
514
515 // 2. Remove previous style
516 const existing = document.getElementById('ohmyppt-pptx-hide-elements');
517 if (existing) existing.remove();
518
519 // Pseudo-elements are not part of the DOM text extraction. Keep their own
520 // text paint while only confirmed extracted DOM text is made transparent below.
521 root.querySelectorAll('*').forEach((el) => {
522 ['before', 'after'].forEach((kind) => {
523 const pseudo = getComputedStyle(el, '::' + kind);
524 const content = String(pseudo.content || '').trim();
525 if (!content || content === 'none' || content === 'normal') return;
526 el.setAttribute('data-pptx-has-' + kind, '1');
527 el.style.setProperty('--pptx-' + kind + '-color', pseudo.color || 'transparent');
528 el.style.setProperty('--pptx-' + kind + '-text-fill', pseudo.webkitTextFillColor || pseudo.color || 'transparent');
529 el.style.setProperty('--pptx-' + kind + '-text-stroke', pseudo.webkitTextStrokeColor || 'transparent');
530 el.style.setProperty('--pptx-' + kind + '-text-shadow', pseudo.textShadow || 'none');
531 el.style.setProperty('--pptx-' + kind + '-text-decoration', pseudo.textDecorationColor || 'transparent');
532 });
533 });
534
535 // 3. CSS: keep any visual that was not confirmed extracted in the raster
536 // background. This is the fallback for complex or capped-out source content.
537 const style = document.createElement('style');
538 style.id = 'ohmyppt-pptx-hide-elements';
539 style.textContent = [
540 // Precisely hide extracted shapes (background/border) and images (visibility)
541 '[data-pptx-extracted-shape]:not([data-pptx-static-background]) { background-color: transparent !important; border-color: transparent !important; }',
542 '[data-pptx-extracted-image] { opacity: 0 !important; visibility: hidden !important; }',
543 // Keep unmapped images, shadows and complex containers in the raster background.
544 // Their extraction can fail because of data limits, filters or cross-origin assets.
545 // Only hide text whose source element was confirmed extracted. Hiding all DOM
546 // text leaves no fallback when a text-box limit or complex layout skips it.
547 '[data-pptx-extracted-text], [data-pptx-extracted-text] *, [data-pptx-background-text-match] { color: transparent !important; -webkit-text-fill-color: transparent !important; -webkit-text-stroke-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; caret-color: transparent !important; }',
548 '[data-pptx-has-before]::before { color: var(--pptx-before-color) !important; -webkit-text-fill-color: var(--pptx-before-text-fill) !important; -webkit-text-stroke-color: var(--pptx-before-text-stroke) !important; text-shadow: var(--pptx-before-text-shadow) !important; text-decoration-color: var(--pptx-before-text-decoration) !important; }',
549 '[data-pptx-has-after]::after { color: var(--pptx-after-color) !important; -webkit-text-fill-color: var(--pptx-after-text-fill) !important; -webkit-text-stroke-color: var(--pptx-after-text-stroke) !important; text-shadow: var(--pptx-after-text-shadow) !important; text-decoration-color: var(--pptx-after-text-decoration) !important; }',
550 // Hide katex elements (captured as separate images before background capture)
551 '.katex { opacity: 0 !important; visibility: hidden !important; }',
552 // Hide formula blocks (captured as block-level overlay images)
553 '[data-pptx-formula-block] { opacity: 0 !important; visibility: hidden !important; }',
554 // An SVG that could not be rasterized stays visible in the background.
555 'svg[data-pptx-extracted-image] text, svg[data-pptx-extracted-image] tspan { fill: transparent !important; stroke: transparent !important; }',
556 // Hide input/textarea text
557 'input, textarea { color: transparent !important; -webkit-text-fill-color: transparent !important; }'
558 ].join('\\n');
559 document.head.appendChild(style);
560 void document.body.offsetHeight;
561 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
562 return true;
563 })()
564 `
565
566 export const RESTORE_PPTX_PAGE_AFTER_BACKGROUND_CAPTURE_SCRIPT = `
567 (async () => {
568 document.getElementById('ohmyppt-pptx-hide-elements')?.remove();
569 void document.body.offsetHeight;
570 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
571 return true;
572 })()
573 `
574
575 export const HIDE_ELEMENTS_FOR_PPTX_BACKGROUND_SCRIPT = `
576 (async () => {
577 let existing = document.getElementById('ohmyppt-pptx-hide-elements');
578 if (existing) existing.remove();
579 const style = document.createElement('style');
580 style.id = 'ohmyppt-pptx-hide-elements';
581 style.textContent = [
582 'img, canvas { opacity: 0 !important; visibility: hidden !important; }',
583 'svg { opacity: 0 !important; visibility: hidden !important; }',
584 'section, main, article, header, footer, aside, div, figure, figcaption, table, td, th { background-color: transparent !important; border-color: transparent !important; }',
585 'body :not(canvas) { -webkit-text-fill-color: transparent !important; -webkit-text-stroke-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; caret-color: transparent !important; }',
586 'body::before, body::after { -webkit-text-fill-color: transparent !important; -webkit-text-stroke-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; }',
587 '.katex { opacity: 0 !important; visibility: hidden !important; }',
588 'svg text, svg tspan { fill: transparent !important; stroke: transparent !important; }',
589 'input, textarea { color: transparent !important; -webkit-text-fill-color: transparent !important; }'
590 ].join('\\n');
591 document.head.appendChild(style);
592 void document.body.offsetHeight;
593 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
594 return true;
595 })()
596 `
597
598 export const MARK_KATEX_BLOCKS_SCRIPT = `
599 (() => {
600 const root = document.querySelector('.ppt-page-root') || document.body;
601 root.querySelectorAll('[data-pptx-formula-block]').forEach((block) => {
602 block.removeAttribute('data-pptx-formula-block');
603 });
604 const blockSelector = [
605 'p',
606 'div',
607 'section',
608 'article',
609 'main',
610 'aside',
611 'header',
612 'footer',
613 'figure',
614 'figcaption',
615 'li',
616 'ul',
617 'ol',
618 'dl',
619 'dt',
620 'dd',
621 'blockquote',
622 'pre',
623 'h1',
624 'h2',
625 'h3',
626 'h4',
627 'h5',
628 'h6',
629 'td',
630 'th'
631 ].join(',');
632 const BLOCK_TAGS = new Set(blockSelector.split(',').map((tag) => tag.toUpperCase()));
633 const allBlocks = root.querySelectorAll(blockSelector);
634 let count = 0;
635 for (const block of allBlocks) {
636 // Must contain katex
637 if (!block.querySelector('.katex')) continue;
638 // Check if any direct child block also contains katex — if so, this is a
639 // parent container and the children are the actual leaf targets.
640 let childBlockHasKatex = false;
641 for (const child of block.children) {
642 if (!BLOCK_TAGS.has(child.tagName)) continue;
643 if (child.querySelector('.katex')) { childBlockHasKatex = true; break; }
644 }
645 if (childBlockHasKatex) continue;
646 block.setAttribute('data-pptx-formula-block', '1');
647 count++;
648 }
649 return count;
650 })()
651 `
652
653 export const COLLECT_PPTX_ANIMATION_TRACES_SCRIPT = `
654 (() => {
655 const root = document.querySelector('.ppt-page-root') || document.body;
656 const pageRect = root.getBoundingClientRect();
657 // These are native PowerPoint effects with a stable final state. Path motion is
658 // intentionally excluded: an HTML path's final CSS transform cannot be
659 // faithfully reconstructed from the static exported geometry.
660 const safeNativeTypes = new Set([
661 'fade',
662 'fade-up',
663 'fade-down',
664 'fade-left',
665 'fade-right',
666 'scale-in',
667 'slide-up',
668 'slide-down',
669 'slide-left',
670 'slide-right',
671 'fly-in',
672 'wipe',
673 'zoom-in',
674 'spin-in',
675 'grow-shrink-soft',
676 'grow-shrink',
677 'grow-shrink-strong',
678 'pulse-soft',
679 'pulse',
680 'pulse-strong',
681 'exit-fade',
682 'exit-scale',
683 'exit-zoom',
684 'exit-wipe',
685 'exit-fly'
686 ]);
687 const supportedTriggers = new Set(['load', 'click', 'with', 'after']);
688 const supportedSequences = new Set(['with', 'after']);
689 const staggerCounters = {};
690 let lastSequenceStart = 0;
691 let lastSequenceEnd = 0;
692 const traces = [];
693 const normalizeType = (value) => {
694 const type = String(value || 'fade-up').trim().toLowerCase();
695 if (type === 'none') return 'none';
696 if (type === 'fly' || type === 'flyin') return 'fly-in';
697 if (type === 'zoom' || type === 'zoomin') return 'zoom-in';
698 if (type === 'spin' || type === 'spinin') return 'spin-in';
699 if (type === 'growsoft' || type === 'growshrinksoft') return 'grow-shrink-soft';
700 if (type === 'grow' || type === 'growshrink') return 'grow-shrink';
701 if (type === 'growstrong' || type === 'growshrinkstrong') return 'grow-shrink-strong';
702 if (type === 'emphasis') return 'pulse';
703 if (type === 'pulsesoft') return 'pulse-soft';
704 if (type === 'pulsestrong') return 'pulse-strong';
705 if (type === 'exitscale') return 'exit-scale';
706 if (type === 'exitzoom') return 'exit-zoom';
707 return safeNativeTypes.has(type) || type === 'path' ? type : 'fade-up';
708 };
709 const normalizeTrigger = (value) => {
710 const trigger = String(value || 'load').trim().toLowerCase();
711 if (trigger === 'on-click') return 'click';
712 if (trigger === 'after-previous') return 'after';
713 if (trigger === 'with-previous') return 'with';
714 return supportedTriggers.has(trigger) ? trigger : 'load';
715 };
716 const normalizeSequence = (value) => {
717 const sequence = String(value || '').trim().toLowerCase();
718 if (sequence === 'after-previous') return 'after';
719 if (sequence === 'with-previous') return 'with';
720 return supportedSequences.has(sequence) ? sequence : '';
721 };
722 const normalizeClickGroup = (value) => {
723 const group = String(value || '').trim();
724 return group || '';
725 };
726 const isValidClickGroup = (value) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(value || ''));
727 const defaultFrom = (type) => {
728 if (type === 'fade-down') return 'top';
729 if (type === 'slide-down') return 'top';
730 if (type === 'fade-left' || type === 'slide-left') return 'right';
731 if (type === 'slide-right') return 'left';
732 if (type === 'fade-right') return 'left';
733 if (type === 'wipe' || type === 'exit-wipe') return 'left';
734 return 'bottom';
735 };
736 const normalizeFrom = (value, fallback) => {
737 const from = String(value || fallback || 'bottom').trim().toLowerCase();
738 if (from === 'up' || from === 'top') return 'top';
739 if (from === 'down' || from === 'bottom') return 'bottom';
740 if (from === 'start') return 'left';
741 if (from === 'end') return 'right';
742 if (from === 'left' || from === 'right' || from === 'center') return from;
743 return fallback || 'bottom';
744 };
745 const extractedSelector = '[data-pptx-extracted-text], [data-pptx-extracted-shape], [data-pptx-extracted-image]';
746 const collectTrace = (el, type, trigger, from, duration, delay, order, clickGroup) => {
747 const rect = el.getBoundingClientRect();
748 if (rect.width < 2 || rect.height < 2) return;
749 const trace = {
750 type,
751 trigger,
752 from,
753 duration: Math.max(100, Math.min(5000, Number(duration) || 500)),
754 delay: Math.max(0, Math.min(30000, Number(delay) || 0)),
755 order,
756 x: Math.round(rect.left - pageRect.left),
757 y: Math.round(rect.top - pageRect.top),
758 w: Math.round(rect.width),
759 h: Math.round(rect.height)
760 };
761 if (clickGroup) trace.clickGroup = clickGroup;
762 traces.push(trace);
763 };
764
765 const collectExtractedTargets = (animationRoot) => {
766 const candidates = [];
767 if (animationRoot.matches(extractedSelector)) candidates.push(animationRoot);
768 animationRoot.querySelectorAll(extractedSelector).forEach((candidate) => {
769 // Nested data-anim blocks own their extracted objects and receive a
770 // separate native effect, avoiding duplicate timing entries.
771 if (candidate.closest('[data-anim]') === animationRoot) candidates.push(candidate);
772 });
773 return Array.from(new Set(candidates));
774 };
775
776 const elements = Array.from(root.querySelectorAll('[data-anim]'));
777
778 elements.forEach((el, order) => {
779 const type = normalizeType(el.getAttribute('data-anim'));
780 if (type === 'none' || !safeNativeTypes.has(type)) return;
781
782 const trigger = normalizeTrigger(el.getAttribute('data-anim-trigger'));
783 const effectiveTrigger = trigger === 'click' ? 'click' : 'load';
784 const sequence = normalizeSequence(el.getAttribute('data-anim-sequence'));
785 const clickGroupRaw = normalizeClickGroup(el.getAttribute('data-anim-click-group'));
786 const clickGroup =
787 effectiveTrigger === 'click' && isValidClickGroup(clickGroupRaw) ? clickGroupRaw : '';
788 const from = normalizeFrom(el.getAttribute('data-anim-from'), defaultFrom(type));
789 const duration = Math.max(100, Math.min(5000, Number(el.getAttribute('data-anim-duration')) || 500));
790 const delayRaw = (el.getAttribute('data-anim-delay') || '0').trim();
791 const staggerRaw = (el.getAttribute('data-anim-stagger') || '').trim();
792 let delay = 0;
793 if (staggerRaw) {
794 const gap = Number(staggerRaw);
795 const normalizedGap = Number.isFinite(gap) ? Math.max(0, gap) : 0;
796 const key = effectiveTrigger;
797 if (staggerCounters[key] === undefined) staggerCounters[key] = 0;
798 delay = staggerCounters[key] * normalizedGap;
799 staggerCounters[key] += 1;
800 } else if (delayRaw.indexOf('stagger') === 0) {
801 const match = delayRaw.match(/stagger\\s*\\(\\s*(\\d+)\\s*\\)/);
802 const gap = match ? Number(match[1]) : 50;
803 const key = effectiveTrigger;
804 if (staggerCounters[key] === undefined) staggerCounters[key] = 0;
805 delay = staggerCounters[key] * gap;
806 staggerCounters[key] += 1;
807 } else {
808 delay = Number(delayRaw) || 0;
809 }
810
811 if (effectiveTrigger === 'load') {
812 const sequencingMode = sequence || trigger;
813 if (sequencingMode === 'after') {
814 delay += lastSequenceEnd;
815 lastSequenceStart = delay;
816 lastSequenceEnd = Math.max(lastSequenceEnd, delay + duration);
817 } else if (sequencingMode === 'with') {
818 delay += lastSequenceStart;
819 lastSequenceEnd = Math.max(lastSequenceEnd, delay + duration);
820 } else {
821 lastSequenceStart = delay;
822 lastSequenceEnd = Math.max(lastSequenceEnd, delay + duration);
823 }
824 }
825
826 // The extractor adds these attributes only after it has created a native
827 // PPT text/shape/image. Use those exact boxes instead of guessing from a
828 // parent container; unmapped source content remains visible in the raster
829 // fallback and can never disappear because of a timing entry.
830 collectExtractedTargets(el).forEach((target) => {
831 collectTrace(target, type, effectiveTrigger, from, duration, delay, order, clickGroup);
832 });
833 });
834
835 return traces;
836 })()
837 `
838
839 // PPTX element timing is not reliable enough for normal editable exports. This
840 // only detects that the source page contains animation so the slide can use a
841 // safe, page-level transition without hiding individual editable objects.
842 export const HAS_DECLARED_PPTX_ANIMATION_SCRIPT = `
843 (() => {
844 const root =
845 document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') ||
846 document.querySelector('.ppt-page-root') ||
847 document.body;
848 const selector = '[data-anim]:not([data-anim="none"]), [data-anime], [data-animate]';
849 return Boolean(
850 root.matches(selector) || root.querySelector(selector)
851 );
852 })()
853 `
854
855 export const COLLECT_KATEX_BLOCK_RECTS_SCRIPT = `
856 (async () => {
857 const root = document.querySelector('.ppt-page-root') || document.body;
858 const pageRect = root.getBoundingClientRect();
859 const blocks = root.querySelectorAll('[data-pptx-formula-block="1"]');
860 const results = [];
861 for (const block of blocks) {
862 const rect = block.getBoundingClientRect();
863 if (rect.width < 2 || rect.height < 2) continue;
864 results.push({
865 x: Math.round(rect.left - pageRect.left),
866 y: Math.round(rect.top - pageRect.top),
867 w: Math.round(rect.width),
868 h: Math.round(rect.height)
869 });
870 }
871 return results;
872 })()
873 `
874
875 export const WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT = `
876 (async () => {
877 void document.body.offsetHeight;
878 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
879 void document.body.offsetHeight;
880 return true;
881 })()
882 `
883
883 lines TYPESCRIPT