chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user