/** * Image serializer — converts PicNodeData into positioned HTML image/video/audio elements. */ import type { PicNodeData } from '../model/nodes/PicNode'; import type { RenderContext } from './RenderContext'; import { SafeXmlNode } from '../parser/XmlParser'; import { resolveMediaToUrl } from '../utils/mediaWebConvert'; import { lineStyleToBorder } from './borderMapper'; import type { Image, Video, Audio } from '../adapter/types'; import { getMimeType, resolveMediaPath, toDataUrl } from '../utils/media'; import { isAllowedExternalUrl } from '../utils/urlSafety'; import { resolveColor } from './StyleResolver'; import { hexToRgb } from '../utils/color'; const PX_TO_PT = 0.75; function pxToPt(px: number): number { return Number((px * PX_TO_PT).toFixed(4)); } /** * Check if a file extension is an unsupported legacy format (WMF only now; EMF is handled). */ function isUnsupportedFormat(path: string): boolean { const ext = path.split('.').pop()?.toLowerCase() || ''; return ext === 'wmf'; } /** * Check if a file path is an EMF image. */ function isEmfFormat(path: string): boolean { const ext = path.split('.').pop()?.toLowerCase() || ''; return ext === 'emf'; } /** * Pull the endpoint of every path-op child of (moveTo/lnTo/cubicBezTo/ * quadBezTo) into a flat [x, y] list. The endpoint is always the LAST * child of the op. arcTo / close contribute no useful endpoint so are skipped. */ function pathEndpoints(path: SafeXmlNode): Array<[number, number]> { const out: Array<[number, number]> = []; for (const op of path.allChildren()) { if (op.localName === 'close' || op.localName === 'arcTo') continue; const pts = op.children('pt'); if (pts.length === 0) continue; const last = pts[pts.length - 1]; const x = last.numAttr('x'); const y = last.numAttr('y'); if (x === undefined || y === undefined) continue; out.push([x, y]); } return out; } /** * Is the path a chamfered (cut-corner) rectangle with 8 distinct vertices — * two on each of the four bounding-box edges? PowerPoint exports the "snipped * corners" / chamfered-square photo frame this way, and there is no prst that * names it, so without this check it falls through to 'rect' and the photo * renders with sharp corners. * * Heuristic: snap every endpoint onto the bounding edge it sits on (x=0, * x=cx, y=0, y=cy) within 2% tolerance, then require exactly 2 distinct * endpoints per edge. Coordinates inside the box are ignored — they're * bezier handles, not corners. */ function looksLikeChamferedRect(path: SafeXmlNode, cx: number, cy: number): boolean { if (cx <= 0 || cy <= 0) return false; const tolX = cx * 0.02; const tolY = cy * 0.02; const onEdge = { top: [] as number[], bottom: [] as number[], left: [] as number[], right: [] as number[], }; for (const [x, y] of pathEndpoints(path)) { if (Math.abs(y) <= tolY) onEdge.top.push(x); else if (Math.abs(y - cy) <= tolY) onEdge.bottom.push(x); if (Math.abs(x) <= tolX) onEdge.left.push(y); else if (Math.abs(x - cx) <= tolX) onEdge.right.push(y); } const distinct = (vals: number[], tol: number): number => { const sorted = [...vals].sort((a, b) => a - b); let count = 0; let prev = -Infinity; for (const v of sorted) { if (v - prev > tol) { count += 1; prev = v; } } return count; }; return ( distinct(onEdge.top, tolX) === 2 && distinct(onEdge.bottom, tolX) === 2 && distinct(onEdge.left, tolY) === 2 && distinct(onEdge.right, tolY) === 2 ); } /** * Resolve clip geometry from spPr (prstGeom or custGeom). * Many decks use custGeom circles on p:pic instead of prst="ellipse". */ function resolvePresetGeom(node: PicNodeData): string { const spPr = node.source.child('spPr'); if (!spPr.exists()) return 'rect'; const prstGeom = spPr.child('prstGeom'); if (prstGeom.exists()) return prstGeom.attr('prst') ?? 'rect'; const custGeom = spPr.child('custGeom'); if (custGeom.exists()) { const ext = spPr.child('xfrm').child('ext'); const cx = ext.numAttr('cx') ?? 0; const cy = ext.numAttr('cy') ?? 0; const path = custGeom.child('pathLst').child('path'); // A real ellipse path is encoded as 1 moveTo + exactly 4 cubicBezTo + close // (one bezier per quadrant). Chamfered / rounded-corner squares hit the // same near-1:1 aspect ratio but use 8+ beziers around the corners, so // bound the bezier count before claiming it's an ellipse — otherwise // every fancy framed photo gets clipped to a circle. if (cx > 0 && cy > 0 && Math.abs(cx - cy) / Math.max(cx, cy) < 0.05) { const cubics = path.children('cubicBezTo').length; const lines = path.children('lnTo').length; const arcs = path.children('arcTo').length; if (lines === 0 && arcs === 0 && cubics > 0 && cubics <= 4) { return 'ellipse'; } } // Photo frames with 8 corner vertices (top/bottom/left/right edges each // hosting 2 distinct points) are the chamfered-square pattern. Renderer // already has an `octagon` clip-path at 30%/70%, which is close enough // to typical 33%/67% PPT chamfers visually. if (looksLikeChamferedRect(path, cx, cy)) { return 'octagon'; } } return 'rect'; } /** EMU per CSS pixel at 96 DPI. */ const EMU_PER_PX = 9525; /** * Resolve the picture soft-edge feather radius (px) from * `spPr > effectLst > softEdge@rad`. PowerPoint feathers the image's alpha to * transparent over this radius at every edge; without it we draw a hard rect. * Returned in the same raw-px scale as `node.size` (no ratio applied), so the * transform passes it straight through. */ function resolveSoftEdgePx(node: PicNodeData): number | undefined { const spPr = node.source.child('spPr'); if (!spPr.exists()) return undefined; const softEdge = spPr.child('effectLst').child('softEdge'); if (!softEdge.exists()) return undefined; const rad = softEdge.numAttr('rad'); if (rad === undefined || rad <= 0) return undefined; return Number((rad / EMU_PER_PX).toFixed(2)); } /** * Resolve image-level hyperlink from hlinkClick on cNvPr. * Mirrors link resolution logic in ShapeRenderer / ImageRenderer. */ function resolvePicLink(node: PicNodeData, ctx: RenderContext): string | undefined { const h = node.hlinkClick; if (!h) return undefined; const { action, rId } = h; if (action === 'ppaction://hlinksldjump' && rId) { const rel = ctx.slide.rels.get(rId); if (rel) { const match = rel.target.match(/slide(\d+)\.xml/i); if (match) return `#slide-${match[1]}`; } } else if (rId) { const rel = ctx.slide.rels.get(rId); if (rel && rel.targetMode === 'External' && isAllowedExternalUrl(rel.target)) { return rel.target; } } return undefined; } /** * Resolve overall image opacity from OOXML blip alpha modifiers. * Same logic as ImageRenderer.resolveBlipOpacity: * - alphaModFix amt="N" * - alphaMod val="N" * - alphaOff val="N" */ function resolveBlipOpacity(blip: SafeXmlNode): number { let alpha = 1; const alphaModFix = blip.child('alphaModFix'); if (alphaModFix.exists()) { alpha *= (alphaModFix.numAttr('amt') ?? 100000) / 100000; } const alphaMod = blip.child('alphaMod'); if (alphaMod.exists()) { alpha *= (alphaMod.numAttr('val') ?? 100000) / 100000; } const alphaOff = blip.child('alphaOff'); if (alphaOff.exists()) { alpha += (alphaOff.numAttr('val') ?? 0) / 100000; } return Math.max(0, Math.min(1, alpha)); } /** OOXML fixed-point scale (100000 = 100%). */ const OOXML_100K = 100000; /** * Build `filters` for PPTist: `sharpen`, `colorTemperature`, `saturation`, `brightness`, `contrast`. * * - **ISO / DrawingML**: `` on ``. * - **Office 2010+** (same as legacy `src1/fill.js` `getPicFilters`): `a:extLst` → `ext` → * `a14:imgProps` / `a14:imgLayer` / `a14:imgEffect` → `a14:saturation`, `a14:brightnessContrast`, * `a14:sharpenSoften`, `a14:colorTemperature`. * * Extension effects are applied after `lum` and may override brightness/contrast when both exist. */ function buildImageFilters(node: PicNodeData): Image['filters'] | undefined { const blipFill = node.source.child('blipFill'); if (!blipFill.exists()) return undefined; const blip = blipFill.child('blip'); if (!blip.exists()) return undefined; const out: NonNullable = {}; applyLumToFilters(blip, out); applyExtLstImageEffectsToFilters(blip, out); return Object.keys(out).length > 0 ? out : undefined; } /** `` — brightness / contrast (values typically −100000…100000, scale 100000 = 100%). */ function applyLumToFilters(blip: SafeXmlNode, out: NonNullable): void { const lum = blip.child('lum'); if (!lum.exists()) return; const bright = lum.numAttr('bright'); const contrast = lum.numAttr('contrast'); if (bright !== undefined && bright !== 0) { out.brightness = bright / OOXML_100K; } if (contrast !== undefined && contrast !== 0) { out.contrast = contrast / OOXML_100K; } } /** * `a:extLst` / `a14:img*` image adjustments (namespace-agnostic `localName` from DOM). */ function applyExtLstImageEffectsToFilters( blip: SafeXmlNode, out: NonNullable, ): void { const extLst = blip.child('extLst'); if (!extLst.exists()) return; for (const ext of extLst.children()) { if (ext.localName !== 'ext') continue; const imgProps = ext.child('imgProps'); if (!imgProps.exists()) continue; const imgLayer = imgProps.child('imgLayer'); if (!imgLayer.exists()) continue; for (const imgEffect of imgLayer.children()) { if (imgEffect.localName !== 'imgEffect') continue; for (const el of imgEffect.allChildren()) { switch (el.localName) { case 'saturation': { const sat = el.numAttr('sat'); if (sat !== undefined) { out.saturation = sat / OOXML_100K; } break; } case 'brightnessContrast': { const bright = el.numAttr('bright'); const contrast = el.numAttr('contrast'); if (bright !== undefined && bright !== 0) { out.brightness = bright / OOXML_100K; } if (contrast !== undefined && contrast !== 0) { out.contrast = contrast / OOXML_100K; } break; } case 'sharpenSoften': { const amount = el.numAttr('amount'); if (amount !== undefined && amount !== 0) { // Positive = sharpen, negative = soften (PPTist only has `sharpen`; use signed value). out.sharpen = amount / OOXML_100K; } break; } case 'colorTemperature': { const ct = el.numAttr('colorTemp'); if (ct !== undefined) { out.colorTemperature = ct; } break; } default: break; } } } } } /** * Resolve a media blob URL from a relationship ID. */ async function resolveMediaUrl( rId: string | undefined, ctx: RenderContext, ): Promise { if (!rId) return undefined; const rel = ctx.slide.rels.get(rId); if (!rel) return undefined; // Check if target is an external URL if (rel.target.startsWith('http://') || rel.target.startsWith('https://')) { return rel.target; } // Resolve from embedded media const mediaPath = resolveMediaPath(rel.target); const data = ctx.presentation.media.get(mediaPath); if (!data) return undefined; return resolveMediaToUrl(mediaPath, data, 'blob', ctx.mediaUrlCache); } /** * Render a video element. */ async function renderVideo( node: PicNodeData, ctx: RenderContext, order: number, box: { left: number; top: number; width: number; height: number }, ): Promise