Files
simstudioai--sim/apps/sim/lib/pptx-renderer/renderer/background-renderer.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

209 lines
5.9 KiB
TypeScript

/**
* Background renderer — resolves and applies slide/layout/master backgrounds.
*/
import type { RelEntry } from '../parser/rel-parser'
import type { SafeXmlNode } from '../parser/xml-parser'
import { hexToRgb } from '../utils/color'
import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media'
import type { RenderContext } from './render-context'
import { resolveColor, resolveFill } from './style-resolver'
const COLOR_NODE_NAMES = new Set([
'srgbClr',
'schemeClr',
'sysClr',
'prstClr',
'hslClr',
'scrgbClr',
])
/**
* Check whether a node contains a supported OOXML color node.
*/
function hasColorNode(node: SafeXmlNode): boolean {
if (COLOR_NODE_NAMES.has(node.localName)) {
return true
}
return node.allChildren().some((child) => COLOR_NODE_NAMES.has(child.localName))
}
/**
* Composite a semi-transparent color on white so the result is always opaque.
* This prevents the slide background from becoming see-through when embedded
* in containers with dark backgrounds (e.g. e2e-compare panels).
*/
function compositeOnWhite(r: number, g: number, b: number, a: number): string {
const cr = Math.round(r * a + 255 * (1 - a))
const cg = Math.round(g * a + 255 * (1 - a))
const cb = Math.round(b * a + 255 * (1 - a))
return `rgb(${cr},${cg},${cb})`
}
/**
* Render the background for a slide onto the container element.
*
* Background priority: slide.background -> layout.background -> master.background.
* The first found background is used.
*/
export function renderBackground(ctx: RenderContext, container: HTMLElement): void {
// Find the first available background in the inheritance chain,
// and track which rels map to use for resolving image references
let bgNode: SafeXmlNode | undefined
let bgRels: Map<string, RelEntry> = ctx.slide.rels
if (ctx.slide.background) {
bgNode = ctx.slide.background
bgRels = ctx.slide.rels
} else if (ctx.layout.background) {
bgNode = ctx.layout.background
bgRels = ctx.layout.rels
} else if (ctx.master.background) {
bgNode = ctx.master.background
bgRels = ctx.master.rels
}
if (!bgNode) {
container.style.backgroundColor = '#FFFFFF'
return
}
// Parse p:bg > p:bgPr
const bgPr = bgNode.child('bgPr')
if (bgPr.exists()) {
renderBgPr(bgPr, ctx, container, bgRels)
return
}
// Parse p:bg > p:bgRef (theme reference)
const bgRef = bgNode.child('bgRef')
if (bgRef.exists()) {
renderBgRef(bgRef, ctx, container)
return
}
// Fallback
container.style.backgroundColor = '#FFFFFF'
}
/**
* Render background from bgPr (background properties).
* Contains direct fill definitions: solidFill, gradFill, blipFill, etc.
*/
function renderBgPr(
bgPr: SafeXmlNode,
ctx: RenderContext,
container: HTMLElement,
rels?: Map<string, RelEntry>
): void {
// solidFill
const solidFill = bgPr.child('solidFill')
if (solidFill.exists()) {
const { color, alpha } = resolveColor(solidFill, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b } = hexToRgb(hex)
container.style.backgroundColor = compositeOnWhite(r, g, b, alpha)
} else {
container.style.backgroundColor = hex
}
return
}
// gradFill
const gradFill = bgPr.child('gradFill')
if (gradFill.exists()) {
const css = resolveFill(bgPr, ctx)
if (css) {
container.style.background = css
}
return
}
// blipFill (image background)
const blipFill = bgPr.child('blipFill')
if (blipFill.exists()) {
renderBlipBackground(blipFill, ctx, container, rels)
return
}
// noFill — still render as white; the slide is a self-contained element
// and transparent backgrounds break when embedded in dark containers
const noFill = bgPr.child('noFill')
if (noFill.exists()) {
container.style.backgroundColor = '#FFFFFF'
return
}
}
/**
* Render background from bgRef (theme format scheme reference).
* Simplified: just resolve the color from the reference.
*/
function renderBgRef(bgRef: SafeXmlNode, ctx: RenderContext, container: HTMLElement): void {
// bgRef may contain a color child (schemeClr, srgbClr, etc.)
if (!hasColorNode(bgRef)) {
container.style.backgroundColor = '#FFFFFF'
return
}
const { color, alpha } = resolveColor(bgRef, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b } = hexToRgb(hex)
container.style.backgroundColor = compositeOnWhite(r, g, b, alpha)
} else {
container.style.backgroundColor = hex
}
}
/**
* Render a blip (image) fill as a CSS background.
*/
function renderBlipBackground(
blipFill: SafeXmlNode,
ctx: RenderContext,
container: HTMLElement,
rels?: Map<string, RelEntry>
): void {
const blip = blipFill.child('blip')
const embedId = blip.attr('embed') ?? blip.attr('r:embed')
if (!embedId) return
// Resolve image from rels + media (use provided rels or fall back to slide rels)
const relsMap = rels ?? ctx.slide.rels
const rel = relsMap.get(embedId)
if (!rel) return
const mediaPath = resolveMediaPath(rel.target)
const data = ctx.presentation.media.get(mediaPath)
if (!data) return
const url = getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache)
container.style.backgroundImage = `url("${url}")`
// Check for stretch or tile mode
const stretch = blipFill.child('stretch')
if (stretch.exists()) {
container.style.backgroundSize = 'cover'
container.style.backgroundPosition = 'center'
container.style.backgroundRepeat = 'no-repeat'
// Parse fillRect for non-uniform stretch
const fillRect = stretch.child('fillRect')
if (fillRect.exists()) {
// fillRect specifies insets — if all zero, it's a full stretch
container.style.backgroundSize = '100% 100%'
}
}
const tile = blipFill.child('tile')
if (tile.exists()) {
container.style.backgroundRepeat = 'repeat'
container.style.backgroundSize = 'auto'
}
}