d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
657 lines
19 KiB
TypeScript
657 lines
19 KiB
TypeScript
/**
|
||
* 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 <img> 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 <img> 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 `<a:duotone>` 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 `<a:lum>` 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 `<a:biLevel thresh="25000">` 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 })
|
||
}
|
||
}
|