/** * Image renderer — converts PicNodeData into positioned HTML image/video/audio elements. */ import type { PicNodeData } from '../model/nodes/pic-node' import { hexToRgb } from '../utils/color' import { parseEmfContent } from '../utils/emf-parser' import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media' import { renderPdfToImage } from '../utils/pdf-renderer' import type { RenderContext } from './render-context' import { resolveColor } from './style-resolver' /** * 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' } // --------------------------------------------------------------------------- // Image Rendering // --------------------------------------------------------------------------- /** * Render a picture node into an absolutely-positioned HTML element. * * Handles: * - Standard images (png, jpg, gif, svg, bmp) * - Unsupported formats (emf, wmf) with placeholder * - Video elements with controls * - Audio elements with controls * - Crop via CSS clip-path * - Rotation and flip transforms */ export function renderImage(node: PicNodeData, ctx: RenderContext): HTMLElement { const wrapper = document.createElement('div') wrapper.style.position = 'absolute' wrapper.style.left = `${node.position.x}px` wrapper.style.top = `${node.position.y}px` wrapper.style.width = `${node.size.w}px` wrapper.style.height = `${node.size.h}px` wrapper.style.overflow = 'hidden' // Apply transforms const transforms: string[] = [] if (node.rotation !== 0) { transforms.push(`rotate(${node.rotation}deg)`) } if (node.flipH) { transforms.push('scaleX(-1)') } if (node.flipV) { transforms.push('scaleY(-1)') } if (transforms.length > 0) { wrapper.style.transform = transforms.join(' ') } // ---- Handle video ---- if (node.isVideo) { renderVideo(node, ctx, wrapper) return wrapper } // ---- Handle audio ---- if (node.isAudio) { renderAudio(node, ctx, wrapper) return wrapper } // ---- Resolve image data ---- const embedId = node.blipEmbed if (!embedId) { renderPlaceholder(wrapper, 'No image data') return wrapper } const rel = ctx.slide.rels.get(embedId) if (!rel) { renderPlaceholder(wrapper, 'Missing image reference') return wrapper } const mediaPath = resolveMediaPath(rel.target) // Check for unsupported formats (WMF) if (isUnsupportedFormat(mediaPath)) { renderUnsupportedPlaceholder(wrapper, mediaPath) return wrapper } const data = ctx.presentation.media.get(mediaPath) if (!data) { renderPlaceholder(wrapper, 'Image not found') return wrapper } // Handle EMF images — extract embedded PDF/bitmap content if (isEmfFormat(mediaPath)) { const emfData = data instanceof Uint8Array ? data : new Uint8Array(data) renderEmf(emfData, node, ctx, wrapper, mediaPath) return wrapper } // Create blob URL (with caching) const url = getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache) // Create image element const img = document.createElement('img') img.src = url img.style.width = '100%' img.style.height = '100%' img.style.objectFit = 'fill' img.style.display = 'block' img.draggable = false // Apply crop if present. // OOXML srcRect defines what portion of the source image is cropped away. // The REMAINING visible region must stretch to fill the entire shape bounding box. // We achieve this by scaling the larger than the wrapper and offsetting it, // relying on the wrapper's overflow:hidden to clip. if (node.crop) { const { top, right, bottom, left } = node.crop // Visible fraction of original image in each dimension const visibleW = 1 - left - right const visibleH = 1 - top - bottom // Guard against degenerate crops (<=0 visible) if (visibleW > 0.001 && visibleH > 0.001) { // Scale image so the visible portion fills the wrapper exactly const scaleX = 1 / visibleW // e.g. if 95.4% visible → scale to ~104.8% const scaleY = 1 / visibleH // Use pixel values for offset — CSS margin-top/margin-left percentages are // both relative to the containing block's WIDTH (not height), which causes // incorrect offsets for non-square wrappers with significant crops. const wrapperW = node.size.w const wrapperH = node.size.h img.style.width = `${(scaleX * wrapperW).toFixed(4)}px` img.style.height = `${(scaleY * wrapperH).toFixed(4)}px` img.style.marginLeft = `${(-left * scaleX * wrapperW).toFixed(4)}px` img.style.marginTop = `${(-top * scaleY * wrapperH).toFixed(4)}px` } } // --- Blip effects --- const blip = node.source.child('blipFill').child('blip') const blipOpacity = resolveBlipOpacity(blip) if (blipOpacity < 1) { wrapper.style.opacity = `${Number(blipOpacity.toFixed(4))}` } // Duotone: recolor image (dark→color1, light→color2) const duotone = blip.child('duotone') if (duotone.exists()) { applyDuotoneFilter(duotone, ctx, img, wrapper) } // Luminance: brightness/contrast adjustment const lum = blip.child('lum') if (lum.exists()) { applyLumEffect(lum, img) } // BiLevel: threshold to black/white const biLevel = blip.child('biLevel') if (biLevel.exists()) { applyBiLevelEffect(biLevel, img) } wrapper.appendChild(img) return wrapper } /** * Resolve overall image opacity from OOXML blip alpha modifiers. * * Supported today: * - 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)) } /** * Render a video element inside the wrapper. */ function renderVideo(node: PicNodeData, ctx: RenderContext, wrapper: HTMLElement): void { // Try to get video URL from mediaRId const videoUrl = resolveMediaUrl(node.mediaRId, ctx) // Also try to show poster image from blipEmbed let posterUrl: string | undefined if (node.blipEmbed) { const rel = ctx.slide.rels.get(node.blipEmbed) if (rel) { const mediaPath = resolveMediaPath(rel.target) const data = ctx.presentation.media.get(mediaPath) if (data && !isUnsupportedFormat(mediaPath)) { posterUrl = getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache) } } } if (videoUrl) { const video = document.createElement('video') video.src = videoUrl video.controls = true video.style.width = '100%' video.style.height = '100%' video.style.objectFit = 'contain' video.style.backgroundColor = '#000' if (posterUrl) { video.poster = posterUrl } wrapper.appendChild(video) } else if (posterUrl) { // No video data available — show poster with play overlay const img = document.createElement('img') img.src = posterUrl img.style.width = '100%' img.style.height = '100%' img.style.objectFit = 'fill' wrapper.appendChild(img) const overlay = document.createElement('div') overlay.style.position = 'absolute' overlay.style.inset = '0' overlay.style.display = 'flex' overlay.style.alignItems = 'center' overlay.style.justifyContent = 'center' overlay.style.backgroundColor = 'rgba(0,0,0,0.3)' overlay.style.color = '#fff' overlay.style.fontSize = '24px' overlay.textContent = '\u25B6' // play symbol wrapper.appendChild(overlay) } else { renderPlaceholder(wrapper, 'Video') } } /** * Render an audio element inside the wrapper. */ function renderAudio(node: PicNodeData, ctx: RenderContext, wrapper: HTMLElement): void { const audioUrl = resolveMediaUrl(node.mediaRId, ctx) if (audioUrl) { // Show poster image if available if (node.blipEmbed) { const rel = ctx.slide.rels.get(node.blipEmbed) if (rel) { const mediaPath = resolveMediaPath(rel.target) const data = ctx.presentation.media.get(mediaPath) if (data && !isUnsupportedFormat(mediaPath)) { const cached = getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache) const img = document.createElement('img') img.src = cached img.style.width = '100%' img.style.height = 'calc(100% - 32px)' img.style.objectFit = 'contain' wrapper.appendChild(img) } } } const audio = document.createElement('audio') audio.src = audioUrl audio.controls = true audio.style.width = '100%' audio.style.position = 'absolute' audio.style.bottom = '0' audio.style.left = '0' wrapper.appendChild(audio) } else { renderPlaceholder(wrapper, 'Audio') } } /** * Resolve a media URL from a relationship ID. */ function resolveMediaUrl(rId: string | undefined, ctx: RenderContext): string | undefined { 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 getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache) } /** * Render a placeholder div for missing or error content. */ function renderPlaceholder(wrapper: HTMLElement, message: string): void { const placeholder = document.createElement('div') placeholder.style.width = '100%' placeholder.style.height = '100%' placeholder.style.display = 'flex' placeholder.style.alignItems = 'center' placeholder.style.justifyContent = 'center' placeholder.style.backgroundColor = '#f0f0f0' placeholder.style.color = '#888' placeholder.style.fontSize = '12px' placeholder.style.border = '1px dashed #ccc' placeholder.textContent = message wrapper.appendChild(placeholder) } /** * Render a placeholder for unsupported image formats (WMF). */ function renderUnsupportedPlaceholder(wrapper: HTMLElement, path: string): void { const ext = path.split('.').pop()?.toUpperCase() || 'Unknown' const placeholder = document.createElement('div') placeholder.style.width = '100%' placeholder.style.height = '100%' placeholder.style.display = 'flex' placeholder.style.flexDirection = 'column' placeholder.style.alignItems = 'center' placeholder.style.justifyContent = 'center' placeholder.style.backgroundColor = '#f5f5f5' placeholder.style.color = '#999' placeholder.style.fontSize = '11px' placeholder.style.border = '1px dashed #ddd' const icon = document.createElement('div') icon.style.fontSize = '24px' icon.style.marginBottom = '4px' icon.textContent = '\uD83D\uDDBC' // framed picture emoji const label = document.createElement('div') label.textContent = `Unsupported format: ${ext}` placeholder.appendChild(icon) placeholder.appendChild(label) wrapper.appendChild(placeholder) } // --------------------------------------------------------------------------- // EMF Rendering // --------------------------------------------------------------------------- /** * Render EMF content by extracting embedded PDF or bitmap data. */ function renderEmf( data: Uint8Array, node: PicNodeData, ctx: RenderContext, wrapper: HTMLElement, mediaPath: string ): void { const content = parseEmfContent(data) switch (content.type) { case 'pdf': renderEmfPdf(content.data, wrapper, node, ctx, mediaPath) break case 'bitmap': renderEmfBitmap(content.imageData, wrapper, ctx, mediaPath) break case 'empty': // Render nothing — transparent placeholder break case 'unsupported': renderUnsupportedPlaceholder(wrapper, mediaPath) break } } /** * Render an embedded PDF from EMF using pdfjs-dist. * Populates the wrapper asynchronously — the wrapper is returned immediately. */ function renderEmfPdf( pdfData: Uint8Array, wrapper: HTMLElement, node: PicNodeData, ctx: RenderContext, mediaPath: string ): void { const cacheKey = `${mediaPath}:emf-pdf` const cached = ctx.mediaUrlCache.get(cacheKey) if (cached) { wrapper.appendChild(createFillImage(cached)) return } renderPdfToImage(pdfData, node.size.w, node.size.h) .then((url) => { if (url) { ctx.mediaUrlCache.set(cacheKey, url) wrapper.appendChild(createFillImage(url)) } }) .catch(() => { // PDF rendering failed — leave wrapper empty (transparent) }) } /** * Render an embedded DIB bitmap from EMF. */ function renderEmfBitmap( imageData: ImageData, wrapper: HTMLElement, ctx: RenderContext, mediaPath: string ): void { const cacheKey = `${mediaPath}:emf-bitmap` const cached = ctx.mediaUrlCache.get(cacheKey) if (cached) { wrapper.appendChild(createFillImage(cached)) return } const canvas = document.createElement('canvas') canvas.width = imageData.width canvas.height = imageData.height const canvasCtx = canvas.getContext('2d') if (!canvasCtx) return canvasCtx.putImageData(imageData, 0, 0) canvas.toBlob((blob) => { if (!blob) return const url = URL.createObjectURL(blob) ctx.mediaUrlCache.set(cacheKey, url) wrapper.appendChild(createFillImage(url)) }, 'image/png') } /** * Create an element that fills its container. */ function createFillImage(url: string): HTMLImageElement { const img = document.createElement('img') img.src = url img.style.width = '100%' img.style.height = '100%' img.style.objectFit = 'fill' img.style.display = 'block' img.draggable = false return img } // --------------------------------------------------------------------------- // Duotone Effect // --------------------------------------------------------------------------- import type { SafeXmlNode } from '../parser/xml-parser' /** * Apply a duotone effect to an image via canvas pixel manipulation. * * OOXML `` contains two color children (dark and light). * The image is converted to grayscale, then black→color1, white→color2. */ function applyDuotoneFilter( duotone: SafeXmlNode, ctx: RenderContext, img: HTMLImageElement, _wrapper: HTMLElement ): void { // Extract the two colors (first = dark, second = light) const colorChildren = duotone.allChildren() if (colorChildren.length < 2) return const { color: c1 } = resolveColor(colorChildren[0], ctx) const { color: c2 } = resolveColor(colorChildren[1], ctx) if (!c1 || !c2) return const hex1 = c1.startsWith('#') ? c1 : `#${c1}` const hex2 = c2.startsWith('#') ? c2 : `#${c2}` const rgb1 = hexToRgb(hex1) const rgb2 = hexToRgb(hex2) // After the image loads, redraw it through a canvas with duotone applied const apply = () => { const w = img.naturalWidth const h = img.naturalHeight if (!w || !h) return const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const c = canvas.getContext('2d') if (!c) return c.drawImage(img, 0, 0) const imageData = c.getImageData(0, 0, w, h) const data = imageData.data for (let i = 0; i < data.length; i += 4) { // Convert to grayscale using luminance weights const gray = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255 // Linearly interpolate between color1 (dark) and color2 (light) data[i] = Math.round(rgb1.r + (rgb2.r - rgb1.r) * gray) data[i + 1] = Math.round(rgb1.g + (rgb2.g - rgb1.g) * gray) data[i + 2] = Math.round(rgb1.b + (rgb2.b - rgb1.b) * gray) // Alpha channel (data[i+3]) is preserved } c.putImageData(imageData, 0, 0) img.src = canvas.toDataURL() } if (img.complete && img.naturalWidth) { apply() } else { img.addEventListener('load', apply, { once: true }) } } // --------------------------------------------------------------------------- // Luminance Effect // --------------------------------------------------------------------------- /** * Apply a luminance (brightness/contrast) effect to an image. * * OOXML `` supports `bright` (additive brightness offset, 0–100000 = 0–100%) * and `contrast` (multiplicative contrast, -100000 to 100000). * e.g. bright="100000" makes the entire image white (preserving alpha). */ function applyLumEffect(lum: SafeXmlNode, img: HTMLImageElement): void { const bright = (lum.numAttr('bright') ?? 0) / 100000 // 0–1 const contrast = (lum.numAttr('contrast') ?? 0) / 100000 // -1 to 1 if (bright === 0 && contrast === 0) return const apply = () => { const w = img.naturalWidth const h = img.naturalHeight if (!w || !h) return const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const c = canvas.getContext('2d') if (!c) return c.drawImage(img, 0, 0) const imageData = c.getImageData(0, 0, w, h) const data = imageData.data for (let i = 0; i < data.length; i += 4) { for (let ch = 0; ch < 3; ch++) { // Normalize to 0–1 let v = data[i + ch] / 255 // Apply contrast (expand/compress around 0.5) if (contrast !== 0) { v = 0.5 + (v - 0.5) * (1 + contrast) } // Apply additive brightness offset v += bright data[i + ch] = Math.round(Math.max(0, Math.min(255, v * 255))) } // Alpha preserved } c.putImageData(imageData, 0, 0) img.src = canvas.toDataURL() } if (img.complete && img.naturalWidth) { apply() } else { img.addEventListener('load', apply, { once: true }) } } // --------------------------------------------------------------------------- // BiLevel Effect // --------------------------------------------------------------------------- /** * Apply a bi-level (threshold) effect to an image. * * OOXML `` converts the image to black and white. * Each pixel's luminance is compared to the threshold (0–100000 = 0–100%). * Pixels above become white, pixels below become black. Alpha is preserved. */ function applyBiLevelEffect(biLevel: SafeXmlNode, img: HTMLImageElement): void { const thresh = (biLevel.numAttr('thresh') ?? 50000) / 100000 // 0–1 const apply = () => { const w = img.naturalWidth const h = img.naturalHeight if (!w || !h) return const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const c = canvas.getContext('2d') if (!c) return c.drawImage(img, 0, 0) const imageData = c.getImageData(0, 0, w, h) const data = imageData.data for (let i = 0; i < data.length; i += 4) { const gray = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255 const val = gray >= thresh ? 255 : 0 data[i] = val data[i + 1] = val data[i + 2] = val // Alpha preserved } c.putImageData(imageData, 0, 0) img.src = canvas.toDataURL() } if (img.complete && img.naturalWidth) { apply() } else { img.addEventListener('load', apply, { once: true }) } }