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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,98 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import { parseXml } from '@/lib/pptx-renderer/parser/xml-parser'
import { renderBackground } from '@/lib/pptx-renderer/renderer/background-renderer'
import type { RenderContext } from '@/lib/pptx-renderer/renderer/render-context'
const EMPTY_NODE = parseXml(
'<p:spTree xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" />'
)
function createContext(backgroundXml: string): RenderContext {
const background = parseXml(backgroundXml)
const slide = {
index: 0,
nodes: [],
background,
layoutIndex: '',
rels: new Map(),
slidePath: 'ppt/slides/slide1.xml',
showMasterSp: true,
}
return {
presentation: {
width: 960,
height: 540,
slides: [slide],
layouts: new Map(),
masters: new Map(),
themes: new Map(),
slideToLayout: new Map(),
layoutToMaster: new Map(),
masterToTheme: new Map(),
media: new Map(),
charts: new Map(),
isWps: false,
},
slide,
theme: {
colorScheme: new Map([['bg1', '000000']]),
majorFont: { latin: 'Calibri', ea: '', cs: '' },
minorFont: { latin: 'Calibri', ea: '', cs: '' },
fillStyles: [],
lineStyles: [],
effectStyles: [],
},
master: {
colorMap: new Map(),
textStyles: {},
placeholders: [],
spTree: EMPTY_NODE,
rels: new Map(),
},
layout: {
placeholders: [],
spTree: EMPTY_NODE,
rels: new Map(),
showMasterSp: true,
},
mediaUrlCache: new Map(),
colorCache: new Map(),
}
}
describe('renderBackground', () => {
it('renders bgRef colors that resolve to black after modifiers', () => {
const ctx = createContext(`
<p:bg xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:bgRef idx="1001">
<a:schemeClr val="bg1">
<a:shade val="50000" />
</a:schemeClr>
</p:bgRef>
</p:bg>
`)
const container = document.createElement('div')
renderBackground(ctx, container)
expect(container.style.backgroundColor).toBe('rgb(0, 0, 0)')
})
it('keeps bgRef without a color node on the white fallback', () => {
const ctx = createContext(`
<p:bg xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:bgRef idx="1001" />
</p:bg>
`)
const container = document.createElement('div')
renderBackground(ctx, container)
expect(container.style.backgroundColor).toBe('rgb(255, 255, 255)')
})
})
@@ -0,0 +1,208 @@
/**
* 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'
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
/**
* Group renderer — renders grouped shapes with coordinate space remapping.
*/
import type { BaseNodeData } from '../model/nodes/base-node'
import type { GroupNodeData } from '../model/nodes/group-node'
import type { ShapeNodeData } from '../model/nodes/shape-node'
import type { RenderContext } from './render-context'
// ---------------------------------------------------------------------------
// Group Rendering
// ---------------------------------------------------------------------------
/**
* Render a group node into an absolutely-positioned HTML element.
*
* Groups define a child coordinate space (childOffset + childExtent) that must
* be remapped to the group's actual position and size. Each child's position
* and size are transformed accordingly before rendering.
*
* @param node The parsed group node data
* @param ctx The render context
* @param renderNode A callback to render individual child nodes (avoids circular deps)
*/
export function renderGroup(
node: GroupNodeData,
ctx: RenderContext,
renderNode: (childNode: BaseNodeData, ctx: RenderContext) => HTMLElement
): 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`
// Apply rotation transform
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(' ')
wrapper.style.transformOrigin = 'center center'
}
const chOff = node.childOffset
const chExt = node.childExtent
const groupW = node.size.w
const groupH = node.size.h
// Resolve group fill from grpSpPr for children that use a:grpFill
const grpSpPr = node.source.child('grpSpPr')
const childCtx: RenderContext = { ...ctx }
if (grpSpPr.exists()) {
// Check if the group itself has a fill (solidFill, gradFill, etc.)
// that children can inherit via grpFill
const FILL_TAGS = ['solidFill', 'gradFill', 'blipFill', 'pattFill']
for (const tag of FILL_TAGS) {
if (grpSpPr.child(tag).exists()) {
childCtx.groupFillNode = grpSpPr
break
}
}
// If the group itself uses grpFill, propagate the parent's group fill
if (!childCtx.groupFillNode && grpSpPr.child('grpFill').exists() && ctx.groupFillNode) {
childCtx.groupFillNode = ctx.groupFillNode
}
}
// Cycle diagram: 3 pie sectors + 3 circular arrows → one circle (3 equal 120° sectors) centered in the diagram.
const parsedChildren = new Map<number, BaseNodeData | undefined>()
const parseByIndex = (index: number): BaseNodeData | undefined => {
if (!parsedChildren.has(index)) {
parsedChildren.set(index, parseGroupChild(node.children[index], ctx))
}
return parsedChildren.get(index)
}
let pieCommon: { x: number; y: number; w: number; h: number } | null = null
if (node.children.length === 6 && chExt.w > 0 && chExt.h > 0) {
const prst = (c: (typeof node.children)[0]) => c.child('spPr').child('prstGeom').attr('prst')
const firstPie = node.children.slice(0, 3).every((c) => prst(c) === 'pie')
const nextArrow = node.children.slice(3, 6).every((c) => prst(c) === 'circularArrow')
if (firstPie && nextArrow) {
// Use diagram extent center and a single circle size so the circle is centered and fits.
const pieNodes = [0, 1, 2].map((i) => parseByIndex(i)).filter(Boolean)
if (pieNodes.length === 3) {
const pieW = Math.max(...pieNodes.map((n) => n!.size.w))
const pieH = Math.max(...pieNodes.map((n) => n!.size.h))
const circleSize = Math.min(pieW, pieH, chExt.w, chExt.h)
const centerX = chOff.x + chExt.w / 2
const centerY = chOff.y + chExt.h / 2
const left = centerX - circleSize / 2
const top = centerY - circleSize / 2
pieCommon = {
x: ((left - chOff.x) / chExt.w) * groupW,
y: ((top - chOff.y) / chExt.h) * groupH,
w: (circleSize / chExt.w) * groupW,
h: (circleSize / chExt.h) * groupH,
}
}
}
}
// Cycle diagram: render arrows first (3,4,5) then pies (0,1,2) so blue sectors draw on top.
const order = pieCommon ? [3, 4, 5, 0, 1, 2] : undefined
const indices = order ?? node.children.map((_, i) => i)
for (const index of indices) {
try {
const childNode = parseByIndex(index)
if (!childNode) continue
// Remap child coordinates from child space to group space
if (chExt.w > 0 && chExt.h > 0) {
childNode.position = {
x: ((childNode.position.x - chOff.x) / chExt.w) * groupW,
y: ((childNode.position.y - chOff.y) / chExt.h) * groupH,
}
childNode.size = {
w: (childNode.size.w / chExt.w) * groupW,
h: (childNode.size.h / chExt.h) * groupH,
}
}
// Overlap the 3 pie sectors at the same center so they form one circle
if (pieCommon && index < 3 && childNode.nodeType === 'shape') {
const origW = childNode.size.w
const origH = childNode.size.h
childNode.position = { x: pieCommon.x, y: pieCommon.y }
childNode.size = { w: pieCommon.w, h: pieCommon.h }
// Scale text box so labels stay in the right sector (txXfrm was in original shape space)
const shapeNode = childNode as ShapeNodeData
if (origW > 0 && origH > 0 && shapeNode.textBoxBounds) {
const tb = shapeNode.textBoxBounds
shapeNode.textBoxBounds = {
x: (tb.x / origW) * pieCommon.w,
y: (tb.y / origH) * pieCommon.h,
w: (tb.w / origW) * pieCommon.w,
h: (tb.h / origH) * pieCommon.h,
}
}
}
const el = renderNode(childNode, childCtx)
wrapper.appendChild(el)
} catch {
// Per-child error handling — create error placeholder
const errDiv = document.createElement('div')
errDiv.style.position = 'absolute'
errDiv.style.border = '1px dashed #ff6b6b'
errDiv.style.backgroundColor = 'rgba(255,107,107,0.1)'
errDiv.style.fontSize = '10px'
errDiv.style.color = '#cc0000'
errDiv.style.display = 'flex'
errDiv.style.alignItems = 'center'
errDiv.style.justifyContent = 'center'
errDiv.style.padding = '2px'
errDiv.textContent = 'Group child error'
wrapper.appendChild(errDiv)
}
}
return wrapper
}
// ---------------------------------------------------------------------------
// Child Node Parsing
// ---------------------------------------------------------------------------
import { parseChartNode } from '../model/nodes/chart-node'
import { parseGroupNode } from '../model/nodes/group-node'
import { parsePicNode } from '../model/nodes/pic-node'
// Import parsers for child dispatch
import { parseShapeNode } from '../model/nodes/shape-node'
import { parseTableNode } from '../model/nodes/table-node'
import { parseOleFrameAsPicture } from '../model/slide'
import type { SafeXmlNode } from '../parser/xml-parser'
/**
* Parse a raw XML child node from a group's spTree into a typed node object.
* Returns undefined for unrecognized or unsupported elements.
*/
function parseGroupChild(childXml: SafeXmlNode, ctx: RenderContext): BaseNodeData | undefined {
const tag = childXml.localName
switch (tag) {
case 'sp':
case 'cxnSp':
return parseShapeNode(childXml)
case 'pic':
return parsePicNode(childXml)
case 'grpSp':
return parseGroupNode(childXml)
case 'graphicFrame': {
const graphic = childXml.child('graphic')
const graphicData = graphic.child('graphicData')
if (graphicData.child('tbl').exists()) {
return parseTableNode(childXml)
}
if ((graphicData.attr('uri') || '').includes('chart')) {
return parseChartNode(childXml, ctx.slide.rels, ctx.slide.slidePath)
}
const olePic = parseOleFrameAsPicture(childXml)
if (olePic) return olePic
return undefined
}
default:
return undefined
}
}
@@ -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, 0100000 = 0100%)
* 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 // 01
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 01
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 (0100000 = 0100%).
* 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 // 01
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 })
}
}
@@ -0,0 +1,805 @@
/**
* Predefined (built-in) Office table styles.
*
* PowerPoint has 74 predefined table styles that exist natively but are NOT
* embedded in the PPTX's ppt/tableStyles.xml. Any PPTX can reference them by
* UUID. This module generates synthetic XML matching the <a:tblStyle> schema
* so they flow through the existing rendering pipeline unchanged.
*
* Derived from LibreOffice's predefined-table-styles.cxx (MPL-2.0) and
* cross-verified against the Microsoft OOXML predefined style map.
*/
import { parseXml, type SafeXmlNode } from '../parser/xml-parser'
// ---------------------------------------------------------------------------
// UUID → (styleName, accent) map — 74 entries across 11 style groups
// ---------------------------------------------------------------------------
const styleIdMap = new Map<string, [string, string]>([
// Themed-Style-1
['{2D5ABB26-0587-4C30-8999-92F81FD0307C}', ['Themed-Style-1', '']],
['{3C2FFA5D-87B4-456A-9821-1D502468CF0F}', ['Themed-Style-1', 'accent1']],
['{284E427A-3D55-4303-BF80-6455036E1DE7}', ['Themed-Style-1', 'accent2']],
['{69C7853C-536D-4A76-A0AE-DD22124D55A5}', ['Themed-Style-1', 'accent3']],
['{775DCB02-9BB8-47FD-8907-85C794F793BA}', ['Themed-Style-1', 'accent4']],
['{35758FB7-9AC5-4552-8A53-C91805E547FA}', ['Themed-Style-1', 'accent5']],
['{08FB837D-C827-4EFA-A057-4D05807E0F7C}', ['Themed-Style-1', 'accent6']],
// Themed-Style-2
['{5940675A-B579-460E-94D1-54222C63F5DA}', ['Themed-Style-2', '']],
['{D113A9D2-9D6B-4929-AA2D-F23B5EE8CBE7}', ['Themed-Style-2', 'accent1']],
['{18603FDC-E32A-4AB5-989C-0864C3EAD2B8}', ['Themed-Style-2', 'accent2']],
['{306799F8-075E-4A3A-A7F6-7FBC6576F1A4}', ['Themed-Style-2', 'accent3']],
['{E269D01E-BC32-4049-B463-5C60D7B0CCD2}', ['Themed-Style-2', 'accent4']],
['{327F97BB-C833-4FB7-BDE5-3F7075034690}', ['Themed-Style-2', 'accent5']],
['{638B1855-1B75-4FBE-930C-398BA8C253C6}', ['Themed-Style-2', 'accent6']],
// Light-Style-1
['{9D7B26C5-4107-4FEC-AEDC-1716B250A1EF}', ['Light-Style-1', '']],
['{3B4B98B0-60AC-42C2-AFA5-B58CD77FA1E5}', ['Light-Style-1', 'accent1']],
['{0E3FDE45-AF77-4B5C-9715-49D594BDF05E}', ['Light-Style-1', 'accent2']],
['{C083E6E3-FA7D-4D7B-A595-EF9225AFEA82}', ['Light-Style-1', 'accent3']],
['{D27102A9-8310-4765-A935-A1911B00CA55}', ['Light-Style-1', 'accent4']],
['{5FD0F851-EC5A-4D38-B0AD-8093EC10F338}', ['Light-Style-1', 'accent5']],
['{68D230F3-CF80-4859-8CE7-A43EE81993B5}', ['Light-Style-1', 'accent6']],
// Light-Style-2
['{7E9639D4-E3E2-4D34-9284-5A2195B3D0D7}', ['Light-Style-2', '']],
['{69012ECD-51FC-41F1-AA8D-1B2483CD663E}', ['Light-Style-2', 'accent1']],
['{72833802-FEF1-4C79-8D5D-14CF1EAF98D9}', ['Light-Style-2', 'accent2']],
['{F2DE63D5-997A-4646-A377-4702673A728D}', ['Light-Style-2', 'accent3']],
['{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}', ['Light-Style-2', 'accent4']],
['{5A111915-BE36-4E01-A7E5-04B1672EAD32}', ['Light-Style-2', 'accent5']],
['{912C8C85-51F0-491E-9774-3900AFEF0FD7}', ['Light-Style-2', 'accent6']],
// Light-Style-3
['{616DA210-FB5B-4158-B5E0-FEB733F419BA}', ['Light-Style-3', '']],
['{BC89EF96-8CEA-46FF-86C4-4CE0E7609802}', ['Light-Style-3', 'accent1']],
['{5DA37D80-6434-44D0-A028-1B22A696006F}', ['Light-Style-3', 'accent2']],
['{8799B23B-EC83-4686-B30A-512413B5E67A}', ['Light-Style-3', 'accent3']],
['{ED083AE6-46FA-4A59-8FB0-9F97EB10719F}', ['Light-Style-3', 'accent4']],
['{BDBED569-4797-4DF1-A0F4-6AAB3CD982D8}', ['Light-Style-3', 'accent5']],
['{E8B1032C-EA38-4F05-BA0D-38AFFFC7BED3}', ['Light-Style-3', 'accent6']],
// Medium-Style-1
['{793D81CF-94F2-401A-BA57-92F5A7B2D0C5}', ['Medium-Style-1', '']],
['{B301B821-A1FF-4177-AEE7-76D212191A09}', ['Medium-Style-1', 'accent1']],
['{9DCAF9ED-07DC-4A11-8D7F-57B35C25682E}', ['Medium-Style-1', 'accent2']],
['{1FECB4D8-DB02-4DC6-A0A2-4F2EBAE1DC90}', ['Medium-Style-1', 'accent3']],
['{1E171933-4619-4E11-9A3F-F7608DF75F80}', ['Medium-Style-1', 'accent4']],
['{FABFCF23-3B69-468F-B69F-88F6DE6A72F2}', ['Medium-Style-1', 'accent5']],
['{10A1B5D5-9B99-4C35-A422-299274C87663}', ['Medium-Style-1', 'accent6']],
// Medium-Style-2
['{073A0DAA-6AF3-43AB-8588-CEC1D06C72B9}', ['Medium-Style-2', '']],
['{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}', ['Medium-Style-2', 'accent1']],
['{21E4AEA4-8DFA-4A89-87EB-49C32662AFE0}', ['Medium-Style-2', 'accent2']],
['{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}', ['Medium-Style-2', 'accent3']],
['{00A15C55-8517-42AA-B614-E9B94910E393}', ['Medium-Style-2', 'accent4']],
['{7DF18680-E054-41AD-8BC1-D1AEF772440D}', ['Medium-Style-2', 'accent5']],
['{93296810-A885-4BE3-A3E7-6D5BEEA58F35}', ['Medium-Style-2', 'accent6']],
// Medium-Style-3
['{8EC20E35-A176-4012-BC5E-935CFFF8708E}', ['Medium-Style-3', '']],
['{6E25E649-3F16-4E02-A733-19D2CDBF48F0}', ['Medium-Style-3', 'accent1']],
['{85BE263C-DBD7-4A20-BB59-AAB30ACAA65A}', ['Medium-Style-3', 'accent2']],
['{EB344D84-9AFB-497E-A393-DC336BA19D2E}', ['Medium-Style-3', 'accent3']],
['{EB9631B5-78F2-41C9-869B-9F39066F8104}', ['Medium-Style-3', 'accent4']],
['{74C1A8A3-306A-4EB7-A6B1-4F7E0EB9C5D6}', ['Medium-Style-3', 'accent5']],
['{2A488322-F2BA-4B5B-9748-0D474271808F}', ['Medium-Style-3', 'accent6']],
// Medium-Style-4
['{D7AC3CCA-C797-4891-BE02-D94E43425B78}', ['Medium-Style-4', '']],
['{69CF1AB2-1976-4502-BF36-3FF5EA218861}', ['Medium-Style-4', 'accent1']],
['{8A107856-5554-42FB-B03E-39F5DBC370BA}', ['Medium-Style-4', 'accent2']],
['{0505E3EF-67EA-436B-97B2-0124C06EBD24}', ['Medium-Style-4', 'accent3']],
['{C4B1156A-380E-4F78-BDF5-A606A8083BF9}', ['Medium-Style-4', 'accent4']],
['{22838BEF-8BB2-4498-84A7-C5851F593DF1}', ['Medium-Style-4', 'accent5']],
['{16D9F66E-5EB9-4882-86FB-DCBF35E3C3E4}', ['Medium-Style-4', 'accent6']],
// Dark-Style-1
['{E8034E78-7F5D-4C2E-B375-FC64B27BC917}', ['Dark-Style-1', '']],
['{125E5076-3810-47DD-B79F-674D7AD40C01}', ['Dark-Style-1', 'accent1']],
['{37CE84F3-28C3-443E-9E96-99CF82512B78}', ['Dark-Style-1', 'accent2']],
['{D03447BB-5D67-496B-8E87-E561075AD55C}', ['Dark-Style-1', 'accent3']],
['{E929F9F4-4A8F-4326-A1B4-22849713DDAB}', ['Dark-Style-1', 'accent4']],
['{8FD4443E-F989-4FC4-A0C8-D5A2AF1F390B}', ['Dark-Style-1', 'accent5']],
['{AF606853-7671-496A-8E4F-DF71F8EC918B}', ['Dark-Style-1', 'accent6']],
// Dark-Style-2 (only 4 variants)
['{5202B0CA-FC54-4496-8BCA-5EF66A818D29}', ['Dark-Style-2', '']],
['{0660B408-B3CF-4A94-85FC-2B1E0A45F4A2}', ['Dark-Style-2', 'accent1']],
['{91EBBBCC-DAD2-459C-BE2E-F6DE35CF9A28}', ['Dark-Style-2', 'accent3']],
['{46F890A9-2807-4EBB-B81D-B2AA78EC7F39}', ['Dark-Style-2', 'accent5']],
])
// ---------------------------------------------------------------------------
// XML helpers — reduce boilerplate in style generators
// ---------------------------------------------------------------------------
const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"'
/** Solid fill with a scheme color and optional transform */
function fillSolid(scheme: string, transform?: string): string {
const mod = transform ? `<a:${transform}/>` : ''
return `<a:fill><a:solidFill><a:schemeClr val="${scheme}">${mod}</a:schemeClr></a:solidFill></a:fill>`
}
/** A border <a:ln> element with scheme color */
function borderLn(scheme: string, transform?: string): string {
const mod = transform ? `<a:${transform}/>` : ''
return `<a:ln w="12700"><a:solidFill><a:schemeClr val="${scheme}">${mod}</a:schemeClr></a:solidFill></a:ln>`
}
/** Text color element within tcTxStyle */
function tcTxStyle(scheme: string, bold?: boolean): string {
const bAttr = bold ? ' b="on"' : ''
const colorEl = scheme ? `<a:schemeClr val="${scheme}"/>` : ''
return `<a:tcTxStyle${bAttr}>${colorEl}</a:tcTxStyle>`
}
/** Style part with optional fill, borders, and text style */
function stylePart(
tag: string,
opts: {
textColor?: string
bold?: boolean
fill?: string
borders?: Record<string, string>
}
): string {
if (!opts.textColor && !opts.bold && !opts.fill && !opts.borders) return ''
const parts: string[] = [`<a:${tag}>`]
if (opts.textColor || opts.bold) parts.push(tcTxStyle(opts.textColor ?? '', opts.bold))
parts.push('<a:tcStyle>')
if (opts.fill) parts.push(opts.fill)
if (opts.borders) {
parts.push('<a:tcBdr>')
for (const [side, ln] of Object.entries(opts.borders)) {
parts.push(`<a:${side}>${ln}</a:${side}>`)
}
parts.push('</a:tcBdr>')
}
parts.push('</a:tcStyle>')
parts.push(`</a:${tag}>`)
return parts.join('')
}
// ---------------------------------------------------------------------------
// Style group XML generators
// ---------------------------------------------------------------------------
function themedStyle1(accent: string, styleId: string): string {
const hasAccent = accent !== ''
const accentVal = hasAccent ? accent : 'tx1'
const parts: string[] = []
if (hasAccent) {
// wholeTbl: text=dk1, borders=accent on all sides
const allBorders: Record<string, string> = {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
insideH: borderLn(accentVal),
insideV: borderLn(accentVal),
}
parts.push(stylePart('wholeTbl', { textColor: 'dk1', borders: allBorders }))
// band1H/V: accent + alpha(40000)
const bandFill = fillSolid(accentVal, `alpha val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=accent, borders=accent (+ bottom=lt1)
parts.push(
stylePart('firstRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid(accentVal),
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn('lt1'),
},
})
)
// lastRow: bold, borders=accent
parts.push(
stylePart('lastRow', {
bold: true,
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
},
})
)
// firstCol/lastCol: bold, borders=accent (+ insideH)
const colBorders: Record<string, string> = {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
insideH: borderLn(accentVal),
}
parts.push(stylePart('firstCol', { bold: true, borders: colBorders }))
parts.push(stylePart('lastCol', { bold: true, borders: colBorders }))
} else {
// No accent: text=tx1, band with alpha
parts.push(stylePart('wholeTbl', { textColor: 'tx1' }))
const bandFill = fillSolid('tx1', `alpha val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
}
return wrapTblStyle(styleId, 'Themed-Style-1', parts.join(''))
}
function themedStyle2(accent: string, styleId: string): string {
const hasAccent = accent !== ''
const parts: string[] = []
if (hasAccent) {
const accentVal = accent
// tblBg: accent fill
const tblBg = `<a:tblBg><a:fillRef idx="1"><a:schemeClr val="${accentVal}"/></a:fillRef></a:tblBg>`
// wholeTbl: text=lt1, outer borders=accent+tint(50000)
const outerBorders: Record<string, string> = {
left: borderLn(accentVal, `tint val="50000"`),
right: borderLn(accentVal, `tint val="50000"`),
top: borderLn(accentVal, `tint val="50000"`),
bottom: borderLn(accentVal, `tint val="50000"`),
}
parts.push(stylePart('wholeTbl', { textColor: 'lt1', borders: outerBorders }))
// band1H/V: lt1 + alpha(20000)
const bandFill = fillSolid('lt1', `alpha val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, bottom border=lt1
parts.push(
stylePart('firstRow', { textColor: 'lt1', bold: true, borders: { bottom: borderLn('lt1') } })
)
// lastRow: bold, top border=lt1
parts.push(stylePart('lastRow', { bold: true, borders: { top: borderLn('lt1') } }))
// firstCol: bold, right border=lt1
parts.push(stylePart('firstCol', { bold: true, borders: { right: borderLn('lt1') } }))
// lastCol: bold, left border=lt1
parts.push(stylePart('lastCol', { bold: true, borders: { left: borderLn('lt1') } }))
return wrapTblStyle(styleId, 'Themed-Style-2', tblBg + parts.join(''))
}
// No accent: text=tx1 (implicit), outer borders=tx1+tint(50000), inside borders=tx1
const outerBorders: Record<string, string> = {
left: borderLn('tx1', `tint val="50000"`),
right: borderLn('tx1', `tint val="50000"`),
top: borderLn('tx1', `tint val="50000"`),
bottom: borderLn('tx1', `tint val="50000"`),
insideH: borderLn('tx1'),
insideV: borderLn('tx1'),
}
parts.push(stylePart('wholeTbl', { borders: outerBorders }))
const bandFill = fillSolid('tx1', `alpha val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
return wrapTblStyle(styleId, 'Themed-Style-2', parts.join(''))
}
function lightStyle1(accent: string, styleId: string): string {
const accentVal = accent || 'tx1'
const parts: string[] = []
// wholeTbl: text=tx1, top/bottom borders
parts.push(
stylePart('wholeTbl', {
textColor: 'tx1',
borders: {
top: borderLn(accentVal),
bottom: borderLn(accentVal),
},
})
)
// band1H/V: accent + alpha(20000)
const bandFill = fillSolid(accentVal, `alpha val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=tx1, bold, bottom border
parts.push(
stylePart('firstRow', {
textColor: 'tx1',
bold: true,
borders: { bottom: borderLn(accentVal) },
})
)
// lastRow: bold, top border
parts.push(stylePart('lastRow', { bold: true, borders: { top: borderLn(accentVal) } }))
// firstCol: bold text
parts.push(stylePart('firstCol', { textColor: 'tx1', bold: true }))
// lastCol: bold text
parts.push(stylePart('lastCol', { textColor: 'tx1', bold: true }))
return wrapTblStyle(styleId, 'Light-Style-1', parts.join(''))
}
function lightStyle2(accent: string, styleId: string): string {
const accentVal = accent || 'tx1'
const parts: string[] = []
// wholeTbl: text=tx1, all 4 outer borders
parts.push(
stylePart('wholeTbl', {
textColor: 'tx1',
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
},
})
)
// band1H: top+bottom borders
parts.push(
stylePart('band1H', {
borders: {
top: borderLn(accentVal),
bottom: borderLn(accentVal),
},
})
)
// band1V/band2V: left+right borders
parts.push(
stylePart('band1V', {
borders: { left: borderLn(accentVal), right: borderLn(accentVal) },
})
)
parts.push(
stylePart('band2V', {
borders: { left: borderLn(accentVal), right: borderLn(accentVal) },
})
)
// firstRow: text=bg1, bold, fill=accent
parts.push(stylePart('firstRow', { textColor: 'bg1', bold: true, fill: fillSolid(accentVal) }))
// lastRow: bold, top border
parts.push(stylePart('lastRow', { bold: true, borders: { top: borderLn(accentVal) } }))
// firstCol: bold
parts.push(stylePart('firstCol', { bold: true }))
// lastCol: bold
parts.push(stylePart('lastCol', { bold: true }))
return wrapTblStyle(styleId, 'Light-Style-2', parts.join(''))
}
function lightStyle3(accent: string, styleId: string): string {
const accentVal = accent || 'tx1'
const parts: string[] = []
// wholeTbl: text=tx1, all 6 borders
parts.push(
stylePart('wholeTbl', {
textColor: 'tx1',
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
insideH: borderLn(accentVal),
insideV: borderLn(accentVal),
},
})
)
// band1H/V: accent + alpha(20000)
const bandFill = fillSolid(accentVal, `alpha val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=accent, bold, bottom border
parts.push(
stylePart('firstRow', {
textColor: accentVal,
bold: true,
borders: { bottom: borderLn(accentVal) },
})
)
// lastRow: bold, top border
parts.push(stylePart('lastRow', { bold: true, borders: { top: borderLn(accentVal) } }))
// firstCol: bold
parts.push(stylePart('firstCol', { bold: true }))
// lastCol: bold
parts.push(stylePart('lastCol', { bold: true }))
return wrapTblStyle(styleId, 'Light-Style-3', parts.join(''))
}
function mediumStyle1(accent: string, styleId: string): string {
const accentVal = accent || 'dk1'
const parts: string[] = []
// wholeTbl: text=dk1, fill=lt1, borders (left/right/top/bottom/insideH)
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid('lt1'),
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
insideH: borderLn(accentVal),
},
})
)
// band1H/V: accent + tint(20000)
const bandFill = fillSolid(accentVal, `tint val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=accent
parts.push(stylePart('firstRow', { textColor: 'lt1', bold: true, fill: fillSolid(accentVal) }))
// lastRow: bold, fill=lt1, top border
parts.push(
stylePart('lastRow', {
bold: true,
fill: fillSolid('lt1'),
borders: { top: borderLn(accentVal) },
})
)
// firstCol: bold
parts.push(stylePart('firstCol', { bold: true }))
// lastCol: bold
parts.push(stylePart('lastCol', { bold: true }))
return wrapTblStyle(styleId, 'Medium-Style-1', parts.join(''))
}
function mediumStyle2(accent: string, styleId: string): string {
const accentVal = accent || 'dk1'
const parts: string[] = []
// wholeTbl: text=dk1, fill=accent+tint(20000), all borders=lt1
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid(accentVal, `tint val="20000"`),
borders: {
left: borderLn('lt1'),
right: borderLn('lt1'),
top: borderLn('lt1'),
bottom: borderLn('lt1'),
insideH: borderLn('lt1'),
insideV: borderLn('lt1'),
},
})
)
// band1H/V: accent + tint(40000)
const bandFill = fillSolid(accentVal, `tint val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=accent, bottom border=lt1
parts.push(
stylePart('firstRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid(accentVal),
borders: { bottom: borderLn('lt1') },
})
)
// lastRow: text=lt1, bold, fill=accent, top border=lt1
parts.push(
stylePart('lastRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid(accentVal),
borders: { top: borderLn('lt1') },
})
)
// firstCol: text=lt1, bold, fill=accent
parts.push(stylePart('firstCol', { textColor: 'lt1', bold: true, fill: fillSolid(accentVal) }))
// lastCol: text=lt1, bold, fill=accent
parts.push(stylePart('lastCol', { textColor: 'lt1', bold: true, fill: fillSolid(accentVal) }))
return wrapTblStyle(styleId, 'Medium-Style-2', parts.join(''))
}
function mediumStyle3(accent: string, styleId: string): string {
const accentVal = accent || 'dk1'
const parts: string[] = []
// wholeTbl: text=dk1, fill=lt1, top/bottom borders=dk1
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid('lt1'),
borders: {
top: borderLn('dk1'),
bottom: borderLn('dk1'),
},
})
)
// band1H/V: dk1 + tint(20000)
const bandFill = fillSolid('dk1', `tint val="20000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=accent, bottom border=dk1
parts.push(
stylePart('firstRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid(accentVal),
borders: { bottom: borderLn('dk1') },
})
)
// lastRow: bold, fill=lt1, top border=dk1
parts.push(
stylePart('lastRow', {
bold: true,
fill: fillSolid('lt1'),
borders: { top: borderLn('dk1') },
})
)
// firstCol: text=lt1, bold, fill=accent
parts.push(stylePart('firstCol', { textColor: 'lt1', bold: true, fill: fillSolid(accentVal) }))
// lastCol: text=lt1, bold, fill=accent
parts.push(stylePart('lastCol', { textColor: 'lt1', bold: true, fill: fillSolid(accentVal) }))
return wrapTblStyle(styleId, 'Medium-Style-3', parts.join(''))
}
function mediumStyle4(accent: string, styleId: string): string {
const accentVal = accent || 'dk1'
const parts: string[] = []
// wholeTbl: text=dk1, fill=accent+tint(20000), all 6 borders=accent
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid(accentVal, `tint val="20000"`),
borders: {
left: borderLn(accentVal),
right: borderLn(accentVal),
top: borderLn(accentVal),
bottom: borderLn(accentVal),
insideH: borderLn(accentVal),
insideV: borderLn(accentVal),
},
})
)
// band1H/V: accent + tint(40000)
const bandFill = fillSolid(accentVal, `tint val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=accent, bold, fill=accent+tint(20000)
parts.push(
stylePart('firstRow', {
textColor: accentVal,
bold: true,
fill: fillSolid(accentVal, `tint val="20000"`),
})
)
// lastRow: bold, fill=dk1+tint(20000), top border=dk1
parts.push(
stylePart('lastRow', {
bold: true,
fill: fillSolid('dk1', `tint val="20000"`),
borders: { top: borderLn('dk1') },
})
)
// firstCol: bold
parts.push(stylePart('firstCol', { bold: true }))
// lastCol: bold
parts.push(stylePart('lastCol', { bold: true }))
return wrapTblStyle(styleId, 'Medium-Style-4', parts.join(''))
}
function darkStyle1(accent: string, styleId: string): string {
const hasAccent = accent !== ''
const accentVal = hasAccent ? accent : 'dk1'
const transformType = hasAccent ? 'shade' : 'tint'
const parts: string[] = []
// wholeTbl: text=dk1, fill=accent+shade/tint(20000)
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid(accentVal, `${transformType} val="20000"`),
})
)
// band1H/V: accent + shade/tint(40000)
const bandFill = fillSolid(accentVal, `${transformType} val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=dk1, bottom border=lt1
parts.push(
stylePart('firstRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid('dk1'),
borders: { bottom: borderLn('lt1') },
})
)
// lastRow: bold, fill=accent+shade/tint(20000), top border=lt1
parts.push(
stylePart('lastRow', {
bold: true,
fill: fillSolid(accentVal),
borders: { top: borderLn('lt1') },
})
)
// firstCol: bold, fill=accent+shade/tint(60000), right border=lt1
parts.push(
stylePart('firstCol', {
bold: true,
fill: fillSolid(accentVal, `${transformType} val="60000"`),
borders: { right: borderLn('lt1') },
})
)
// lastCol: bold, fill=accent+shade/tint(60000), left border=lt1
parts.push(
stylePart('lastCol', {
bold: true,
fill: fillSolid(accentVal, `${transformType} val="60000"`),
borders: { left: borderLn('lt1') },
})
)
return wrapTblStyle(styleId, 'Dark-Style-1', parts.join(''))
}
function darkStyle2(accent: string, styleId: string): string {
const accentVal = accent || 'dk1'
const parts: string[] = []
// Determine firstRow fill: accent-shift logic
let firstRowFillColor: string
if (accent === '') firstRowFillColor = 'dk1'
else if (accent === 'accent1') firstRowFillColor = 'accent2'
else if (accent === 'accent3') firstRowFillColor = 'accent4'
else if (accent === 'accent5') firstRowFillColor = 'accent6'
else firstRowFillColor = accentVal
// wholeTbl: text=dk1, fill=accent+tint(20000)
parts.push(
stylePart('wholeTbl', {
textColor: 'dk1',
fill: fillSolid(accentVal, `tint val="20000"`),
})
)
// band1H/V: accent + tint(40000)
const bandFill = fillSolid(accentVal, `tint val="40000"`)
parts.push(stylePart('band1H', { fill: bandFill }))
parts.push(stylePart('band1V', { fill: bandFill }))
// firstRow: text=lt1, bold, fill=firstRowFillColor
parts.push(
stylePart('firstRow', {
textColor: 'lt1',
bold: true,
fill: fillSolid(firstRowFillColor),
})
)
// lastRow: bold, fill=accent+tint(20000), top border=dk1
parts.push(
stylePart('lastRow', {
bold: true,
fill: fillSolid(accentVal, `tint val="20000"`),
borders: { top: borderLn('dk1') },
})
)
// firstCol: bold
parts.push(stylePart('firstCol', { bold: true }))
// lastCol: bold
parts.push(stylePart('lastCol', { bold: true }))
return wrapTblStyle(styleId, 'Dark-Style-2', parts.join(''))
}
// ---------------------------------------------------------------------------
// XML wrapper
// ---------------------------------------------------------------------------
function wrapTblStyle(styleId: string, styleName: string, innerXml: string): string {
return `<a:tblStyle ${NS} styleId="${styleId}" styleName="${styleName}">${innerXml}</a:tblStyle>`
}
// ---------------------------------------------------------------------------
// Style generator dispatch
// ---------------------------------------------------------------------------
const styleGenerators: Record<string, (accent: string, styleId: string) => string> = {
'Themed-Style-1': themedStyle1,
'Themed-Style-2': themedStyle2,
'Light-Style-1': lightStyle1,
'Light-Style-2': lightStyle2,
'Light-Style-3': lightStyle3,
'Medium-Style-1': mediumStyle1,
'Medium-Style-2': mediumStyle2,
'Medium-Style-3': mediumStyle3,
'Medium-Style-4': mediumStyle4,
'Dark-Style-1': darkStyle1,
'Dark-Style-2': darkStyle2,
}
// ---------------------------------------------------------------------------
// Module-level cache & public API
// ---------------------------------------------------------------------------
const cache = new Map<string, SafeXmlNode>()
/**
* Get a predefined table style by its UUID.
* Returns the parsed SafeXmlNode (a:tblStyle element) or undefined if not a known predefined style.
* Results are cached — same UUID always returns the same instance.
*/
export function getPredefinedTableStyle(styleId: string): SafeXmlNode | undefined {
const cached = cache.get(styleId)
if (cached) return cached
const entry = styleIdMap.get(styleId)
if (!entry) return undefined
const [styleName, accent] = entry
const generator = styleGenerators[styleName]
if (!generator) return undefined
const xml = generator(accent, styleId)
const node = parseXml(xml)
if (!node.exists()) return undefined
cache.set(styleId, node)
return node
}
/** Exported for testing: number of known predefined style UUIDs. */
export const PREDEFINED_STYLE_COUNT = styleIdMap.size
/** Exported for testing: all known style IDs. */
export function getAllPredefinedStyleIds(): string[] {
return Array.from(styleIdMap.keys())
}
@@ -0,0 +1,80 @@
/**
* Render context — provides resolved theme/master/layout chain for a given slide.
*/
import type { ECharts } from 'echarts'
import type { LayoutData } from '../model/layout'
import type { MasterData } from '../model/master'
import type { PresentationData } from '../model/presentation'
import type { SlideData } from '../model/slide'
import type { ThemeData } from '../model/theme'
import type { SafeXmlNode } from '../parser/xml-parser'
export interface RenderContext {
presentation: PresentationData
slide: SlideData
theme: ThemeData
master: MasterData
layout: LayoutData
mediaUrlCache: Map<string, string> // path -> blob URL
colorCache: Map<string, { color: string; alpha: number }>
/** Shared set of live ECharts instances for explicit disposal. */
chartInstances?: Set<ECharts>
/** Fill node from parent group's grpSpPr, used to resolve `a:grpFill` in children. */
groupFillNode?: SafeXmlNode
/**
* Navigation callback for shape-level hyperlink actions (action buttons, clickable shapes).
* Called with target slide index (0-based) for `ppaction://hlinksldjump`,
* or with a URL string for external links.
*/
onNavigate?: (target: { slideIndex?: number; url?: string }) => void
}
export function createRenderContext(
presentation: PresentationData,
slide: SlideData,
mediaUrlCache?: Map<string, string>,
chartInstances?: Set<ECharts>
): RenderContext {
// Resolve the chain: slide -> layout -> master -> theme
const layoutPath = presentation.slideToLayout.get(slide.index) || ''
const masterPath = presentation.layoutToMaster.get(layoutPath) || ''
const themePath = presentation.masterToTheme.get(masterPath) || ''
const layout: LayoutData = presentation.layouts.get(layoutPath) || {
placeholders: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spTree: {} as any,
rels: new Map(),
showMasterSp: true,
}
const master: MasterData = presentation.masters.get(masterPath) || {
colorMap: new Map(),
textStyles: {},
placeholders: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spTree: {} as any,
rels: new Map(),
}
const theme: ThemeData = presentation.themes.get(themePath) || {
colorScheme: new Map(),
majorFont: { latin: 'Calibri', ea: '', cs: '' },
minorFont: { latin: 'Calibri', ea: '', cs: '' },
fillStyles: [],
lineStyles: [],
effectStyles: [],
}
return {
presentation,
slide,
theme,
master,
layout,
mediaUrlCache: mediaUrlCache ?? new Map(),
colorCache: new Map(),
chartInstances,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
/**
* Slide renderer — orchestrates rendering of a complete slide with all its nodes.
*/
import type { ECharts } from 'echarts'
import type { BaseNodeData } from '../model/nodes/base-node'
import type { ChartNodeData } from '../model/nodes/chart-node'
import { type GroupNodeData, parseGroupNode } from '../model/nodes/group-node'
import { type PicNodeData, parsePicNode } from '../model/nodes/pic-node'
import { parseShapeNode, type ShapeNodeData } from '../model/nodes/shape-node'
import { parseTableNode, type TableNodeData } from '../model/nodes/table-node'
import type { PresentationData } from '../model/presentation'
import type { SlideData } from '../model/slide'
import type { SafeXmlNode } from '../parser/xml-parser'
import { renderBackground } from './background-renderer'
import { renderChart } from './chart-renderer'
import { renderGroup } from './group-renderer'
import { renderImage } from './image-renderer'
import { createRenderContext, type RenderContext } from './render-context'
import { renderShape } from './shape-renderer'
import { renderTable } from './table-renderer'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SlideRendererOptions {
/** Called when a single node fails to render. */
onNodeError?: (nodeId: string, error: unknown) => void
/**
* Navigation callback for shape-level hyperlink actions (action buttons, etc.).
* Called with target slide index (0-based) for slide jumps,
* or with a URL string for external links.
*/
onNavigate?: (target: { slideIndex?: number; url?: string }) => void
/** Shared media URL cache for blob URL reuse across slides. */
mediaUrlCache?: Map<string, string>
/** Shared set of live ECharts instances for explicit disposal. */
chartInstances?: Set<ECharts>
}
/**
* Per-slide resource handle returned by `renderSlide()`.
* Allows the caller to dispose of slide-specific resources (chart instances,
* blob URLs in standalone mode) without tearing down the whole viewer.
*/
export interface SlideHandle {
/** The rendered slide DOM element. */
readonly element: HTMLElement
/** Dispose slide-specific resources (charts inside this slide, blob URLs if standalone). */
dispose(): void
/** Support `using` declarations (TC39 Explicit Resource Management). */
[Symbol.dispose](): void
}
// ---------------------------------------------------------------------------
// Node Dispatch
// ---------------------------------------------------------------------------
/**
* Dispatch a typed node to its appropriate renderer.
* This function is also passed into GroupRenderer for recursive child rendering.
*/
function renderNode(node: BaseNodeData, ctx: RenderContext): HTMLElement {
switch (node.nodeType) {
case 'shape':
return renderShape(node as ShapeNodeData, ctx)
case 'picture':
return renderImage(node as PicNodeData, ctx)
case 'table':
return renderTable(node as TableNodeData, ctx)
case 'group':
return renderGroup(node as GroupNodeData, ctx, renderNode)
case 'chart':
return renderChart(node as ChartNodeData, ctx)
default: {
// Unknown node type — render as empty positioned div
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${node.position.x}px`
el.style.top = `${node.position.y}px`
el.style.width = `${node.size.w}px`
el.style.height = `${node.size.h}px`
return el
}
}
}
// ---------------------------------------------------------------------------
// Error Placeholder
// ---------------------------------------------------------------------------
/**
* Create a visual error placeholder at the node's position.
*/
function createErrorPlaceholder(node: BaseNodeData): HTMLElement {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${node.position.x}px`
el.style.top = `${node.position.y}px`
el.style.width = `${node.size.w}px`
el.style.height = `${node.size.h}px`
el.style.border = '2px dashed #ff4444'
el.style.backgroundColor = 'rgba(255,68,68,0.08)'
el.style.display = 'flex'
el.style.alignItems = 'center'
el.style.justifyContent = 'center'
el.style.color = '#cc0000'
el.style.fontSize = '11px'
el.style.fontFamily = 'monospace'
el.style.overflow = 'hidden'
el.style.boxSizing = 'border-box'
el.style.padding = '4px'
el.textContent = `Render Error`
el.title = `Failed to render node: ${node.id} (${node.name})`
return el
}
// ---------------------------------------------------------------------------
// Master/Layout Shape Parsing
// ---------------------------------------------------------------------------
/**
* Check whether a shape node is a placeholder (has p:ph in nvPr).
*/
function isPlaceholderNode(node: SafeXmlNode): boolean {
for (const wrapper of ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']) {
const nv = node.child(wrapper)
if (nv.exists()) {
const nvPr = nv.child('nvPr')
if (nvPr.child('ph').exists()) return true
}
}
return false
}
/**
* Parse and collect renderable shapes from a master or layout spTree.
* Only includes NON-placeholder shapes (decorative elements, logos, footers).
* Placeholder shapes are never rendered from master/layout — they only serve
* as position/size inheritance templates.
*/
function parseTemplateShapes(spTree: SafeXmlNode, _slideNodes: BaseNodeData[]): BaseNodeData[] {
const nodes: BaseNodeData[] = []
if (!spTree || !spTree.exists || !spTree.exists()) return nodes
for (const child of spTree.allChildren()) {
const tag = child.localName
// Skip ALL placeholder shapes — they're templates, not renderable content
if (isPlaceholderNode(child)) continue
try {
let node: BaseNodeData | undefined
switch (tag) {
case 'sp':
case 'cxnSp':
node = parseShapeNode(child)
break
case 'pic':
node = parsePicNode(child)
break
case 'grpSp':
node = parseGroupNode(child)
break
case 'graphicFrame': {
const graphic = child.child('graphic')
const graphicData = graphic.child('graphicData')
if (graphicData.child('tbl').exists()) {
node = parseTableNode(child)
}
break
}
}
// Skip empty/invisible nodes (0x0 size and no text)
if (node && (node.size.w > 0 || node.size.h > 0)) {
nodes.push(node)
}
} catch {
// Skip unparseable template shapes silently
}
}
return nodes
}
// ---------------------------------------------------------------------------
// Main Slide Render Function
// ---------------------------------------------------------------------------
/**
* Render a complete slide into an HTML element.
*
* Rendering order:
* 1. Background (slide → layout → master inheritance)
* 2. Master non-placeholder shapes (behind everything)
* 3. Layout non-placeholder shapes
* 4. Slide shapes (on top)
*/
export function renderSlide(
presentation: PresentationData,
slide: SlideData,
options?: SlideRendererOptions
): SlideHandle {
const isSharedCache = !!options?.mediaUrlCache
// Create render context (resolves slide -> layout -> master -> theme chain)
const ctx = createRenderContext(
presentation,
slide,
options?.mediaUrlCache,
options?.chartInstances
)
if (options?.onNavigate) {
ctx.onNavigate = options.onNavigate
}
// Create slide container
const container = document.createElement('div')
container.style.position = 'relative'
container.style.width = `${presentation.width}px`
container.style.height = `${presentation.height}px`
container.style.overflow = 'hidden'
container.style.backgroundColor = '#FFFFFF'
// Render background
try {
renderBackground(ctx, container)
} catch (e) {
options?.onNodeError?.('__background__', e)
}
// --- Render master template shapes (behind layout and slide) ---
// Respect showMasterSp flags:
// - layout.showMasterSp === false → skip master shapes
// - slide.showMasterSp === false → skip both master AND layout shapes
if (slide.showMasterSp && ctx.layout.showMasterSp) {
const masterCtx: RenderContext = {
...ctx,
slide: { ...ctx.slide, rels: ctx.master.rels },
}
const masterShapes = parseTemplateShapes(ctx.master.spTree, slide.nodes)
for (const node of masterShapes) {
try {
const el = renderNode(node, masterCtx)
container.appendChild(el)
} catch {
// Master shape errors are non-fatal
}
}
}
// --- Render layout template shapes ---
if (slide.showMasterSp) {
const layoutCtx: RenderContext = {
...ctx,
slide: { ...ctx.slide, rels: ctx.layout.rels },
}
const layoutShapes = parseTemplateShapes(ctx.layout.spTree, slide.nodes)
for (const node of layoutShapes) {
try {
const el = renderNode(node, layoutCtx)
container.appendChild(el)
} catch {
// Layout shape errors are non-fatal
}
}
}
// --- Render slide shapes (on top) ---
for (const node of slide.nodes) {
try {
const el = renderNode(node, ctx)
container.appendChild(el)
} catch (e) {
options?.onNodeError?.(node.id, e)
container.appendChild(createErrorPlaceholder(node))
}
}
// Build SlideHandle
let disposed = false
const chartInstances = options?.chartInstances
const mediaUrlCache = ctx.mediaUrlCache
const dispose = (): void => {
if (disposed) return
disposed = true
// Dispose chart instances whose DOM is inside this slide container
if (chartInstances) {
for (const chart of chartInstances) {
if (!chart.isDisposed() && container.contains(chart.getDom())) {
chart.dispose()
chartInstances.delete(chart)
}
}
}
// Revoke blob URLs only in standalone mode (caller doesn't own a shared cache)
if (!isSharedCache) {
for (const url of mediaUrlCache.values()) {
URL.revokeObjectURL(url)
}
mediaUrlCache.clear()
}
}
return {
element: container,
dispose,
[Symbol.dispose](): void {
dispose()
},
}
}
@@ -0,0 +1,804 @@
/**
* Style resolver — converts OOXML color and fill nodes to CSS values.
*/
import { toCssColor } from '@/lib/colors'
import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units'
import type { SafeXmlNode } from '../parser/xml-parser'
import type { ColorModifier } from '../utils/color'
import { applyColorModifiers, hslToRgb, presetColorToHex, rgbToHex } from '../utils/color'
import type { RenderContext } from './render-context'
// ---------------------------------------------------------------------------
// Color Resolution
// ---------------------------------------------------------------------------
/**
* Build a cache key for a color node based on its tag, value, and modifiers.
*/
function buildColorCacheKey(colorNode: SafeXmlNode): string {
const parts: string[] = [colorNode.localName, colorNode.attr('val') ?? '']
for (const child of colorNode.allChildren()) {
const tag = child.localName
const val = child.attr('val')
if (tag) parts.push(`${tag}:${val ?? ''}`)
// Include nested color children for wrapper nodes
for (const grandchild of child.allChildren()) {
const gtag = grandchild.localName
const gval = grandchild.attr('val')
if (gtag) parts.push(`${gtag}:${gval ?? ''}`)
}
}
return parts.join('|')
}
/**
* Collect OOXML color modifier children from a color node.
* Modifiers are child elements like alpha, lumMod, lumOff, tint, shade, satMod, hueMod.
*/
function collectModifiers(colorNode: SafeXmlNode): ColorModifier[] {
const modifiers: ColorModifier[] = []
for (const child of colorNode.allChildren()) {
const name = child.localName
const val = child.numAttr('val')
if (val !== undefined && name) {
modifiers.push({ name, val })
}
}
return modifiers
}
/**
* Resolve a scheme color name through the master colorMap then theme colorScheme.
*
* OOXML scheme colors use logical names (e.g., "tx1", "bg1", "accent1").
* The master's colorMap remaps some of these (e.g., "tx1" -> "dk1").
* The theme's colorScheme holds the actual hex values keyed by the mapped name.
*/
function resolveSchemeColor(schemeName: string, ctx: RenderContext): string {
// Apply colorMap remapping (layout override takes priority)
let mappedName = schemeName
if (ctx.layout.colorMapOverride) {
const override = ctx.layout.colorMapOverride.get(schemeName)
if (override) mappedName = override
}
if (mappedName === schemeName) {
const mapped = ctx.master.colorMap.get(schemeName)
if (mapped) mappedName = mapped
}
// Look up in theme color scheme
const hex = ctx.theme.colorScheme.get(mappedName)
if (hex) return hex
// Fallback: try the original name directly in theme
const fallback = ctx.theme.colorScheme.get(schemeName)
return fallback || '000000'
}
/**
* Resolve an OOXML color node (srgbClr, schemeClr, sysClr, prstClr, hslClr, scrgbClr)
* into a CSS-ready hex color and alpha value.
*/
export function resolveColor(
colorNode: SafeXmlNode,
ctx: RenderContext
): { color: string; alpha: number } {
// Check cache
const cacheKey = buildColorCacheKey(colorNode)
const cached = ctx.colorCache.get(cacheKey)
if (cached) return cached
const result = resolveColorUncached(colorNode, ctx)
ctx.colorCache.set(cacheKey, result)
return result
}
function resolveColorUncached(
colorNode: SafeXmlNode,
ctx: RenderContext,
placeholderColorNode?: SafeXmlNode
): { color: string; alpha: number } {
// Iterate child elements to find the actual color type node
for (const child of colorNode.allChildren()) {
const tag = child.localName
const modifiers = collectModifiers(child)
switch (tag) {
case 'srgbClr': {
const hex = child.attr('val') || '000000'
return applyColorModifiers(hex, modifiers)
}
case 'schemeClr': {
const scheme = child.attr('val') || 'tx1'
if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) {
const base = resolveColor(placeholderColorNode, ctx)
const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color
const adjusted = applyColorModifiers(baseHex, modifiers)
return { color: adjusted.color, alpha: adjusted.alpha * base.alpha }
}
const hex = resolveSchemeColor(scheme, ctx)
return applyColorModifiers(hex, modifiers)
}
case 'sysClr': {
const hex = child.attr('lastClr') || child.attr('val') || '000000'
return applyColorModifiers(hex, modifiers)
}
case 'prstClr': {
const name = child.attr('val') || 'black'
const hex = presetColorToHex(name) || '#000000'
return applyColorModifiers(hex.replace('#', ''), modifiers)
}
case 'hslClr': {
const hue = (child.numAttr('hue') ?? 0) / 60000 // 60000ths of degree -> degrees
const sat = (child.numAttr('sat') ?? 0) / 100000 // percentage
const lum = (child.numAttr('lum') ?? 0) / 100000
const rgb = hslToRgb(hue, sat, lum)
const hex = rgbToHex(rgb.r, rgb.g, rgb.b).replace('#', '')
return applyColorModifiers(hex, modifiers)
}
case 'scrgbClr': {
// r, g, b are percentages (0-100000)
const r = Math.round(((child.numAttr('r') ?? 0) / 100000) * 255)
const g = Math.round(((child.numAttr('g') ?? 0) / 100000) * 255)
const b = Math.round(((child.numAttr('b') ?? 0) / 100000) * 255)
const hex = rgbToHex(r, g, b).replace('#', '')
return applyColorModifiers(hex, modifiers)
}
default:
// Not a recognized color child — continue looking
break
}
}
// If the node itself is a color type (no wrapper)
const selfTag = colorNode.localName
if (selfTag === 'srgbClr') {
const hex = colorNode.attr('val') || '000000'
return applyColorModifiers(hex, collectModifiers(colorNode))
}
if (selfTag === 'schemeClr') {
const scheme = colorNode.attr('val') || 'tx1'
if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) {
const base = resolveColor(placeholderColorNode, ctx)
const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color
const adjusted = applyColorModifiers(baseHex, collectModifiers(colorNode))
return { color: adjusted.color, alpha: adjusted.alpha * base.alpha }
}
const hex = resolveSchemeColor(scheme, ctx)
return applyColorModifiers(hex, collectModifiers(colorNode))
}
if (selfTag === 'sysClr') {
const hex = colorNode.attr('lastClr') || colorNode.attr('val') || '000000'
return applyColorModifiers(hex, collectModifiers(colorNode))
}
if (selfTag === 'prstClr') {
const name = colorNode.attr('val') || 'black'
const hex = presetColorToHex(name) || '#000000'
return applyColorModifiers(hex.replace('#', ''), collectModifiers(colorNode))
}
return { color: '#000000', alpha: 1 }
}
/**
* Resolve a color node and return a CSS color string.
* Convenience wrapper combining resolveColor + toCssColor.
*/
export function resolveColorToCss(node: SafeXmlNode, ctx: RenderContext): string {
const { color, alpha } = resolveColor(node, ctx)
return toCssColor(color, alpha)
}
function resolveColorWithPlaceholder(
colorNode: SafeXmlNode,
ctx: RenderContext,
placeholderColorNode?: SafeXmlNode
): { color: string; alpha: number } {
if (!placeholderColorNode?.exists()) return resolveColor(colorNode, ctx)
return resolveColorUncached(colorNode, ctx, placeholderColorNode)
}
// ---------------------------------------------------------------------------
// Fill Resolution
// ---------------------------------------------------------------------------
/**
* Resolve a fill from shape properties (spPr) into a CSS background value.
*
* Returns:
* - CSS color/gradient string for solidFill/gradFill
* - 'transparent' for noFill
* - '' for blipFill (handled by ImageRenderer) or no fill found (inherit)
*/
export function resolveFill(spPr: SafeXmlNode, ctx: RenderContext): string {
// solidFill
const solidFill = spPr.child('solidFill')
if (solidFill.exists()) {
const { color, alpha } = resolveColor(solidFill, ctx)
return toCssColor(color, alpha)
}
// gradFill
const gradFill = spPr.child('gradFill')
if (gradFill.exists()) {
return resolveGradient(gradFill, ctx)
}
// blipFill — handled externally by ImageRenderer
const blipFill = spPr.child('blipFill')
if (blipFill.exists()) {
return ''
}
// pattFill — pattern fill rendered as CSS repeating gradient
const pattFill = spPr.child('pattFill')
if (pattFill.exists()) {
return resolvePatternFill(pattFill, ctx)
}
// grpFill — inherit fill from parent group
const grpFill = spPr.child('grpFill')
if (grpFill.exists()) {
if (ctx.groupFillNode) {
return resolveFill(ctx.groupFillNode, ctx)
}
// No group fill context available — fall through to no fill
return ''
}
// noFill
const noFill = spPr.child('noFill')
if (noFill.exists()) {
return 'transparent'
}
// No fill found — inherit
return ''
}
// ---------------------------------------------------------------------------
// Pattern Fill Resolution
// ---------------------------------------------------------------------------
/**
* Resolve `<a:pattFill>` into a CSS background value using repeating gradients.
*
* OOXML defines 40+ pattern presets. We support the most common ones and
* fall back to a simple foreground/background 50% mix for unknown patterns.
*/
function resolvePatternFill(pattFill: SafeXmlNode, ctx: RenderContext): string {
const preset = pattFill.attr('prst') ?? 'solid'
// Foreground and background colors
let fg = '#000000'
let bg = '#ffffff'
const fgClr = pattFill.child('fgClr')
if (fgClr.exists()) {
const { color, alpha } = resolveColor(fgClr, ctx)
fg = toCssColor(color, alpha)
}
const bgClr = pattFill.child('bgClr')
if (bgClr.exists()) {
const { color, alpha } = resolveColor(bgClr, ctx)
bg = toCssColor(color, alpha)
}
// Size of pattern tile in px
const s = 8
// Helper: returns CSS `background` shorthand with repeating pattern layer(s) over bg color.
// Format: "<gradient-layer> 0 0/<size>, <bg-color-layer>"
// This is a valid multi-layer CSS background shorthand.
const pat = (gradient: string): string => `${gradient} 0 0/${s}px ${s}px, ${bg}`
const pat2 = (g1: string, g2: string): string =>
`${g1} 0 0/${s}px ${s}px, ${g2} 0 0/${s}px ${s}px, ${bg}`
switch (preset) {
// Solid fills
case 'solid':
case 'solidDmnd':
return fg
// Percentage fills (dots on background)
case 'pct5':
case 'pct10':
case 'pct20':
case 'pct25':
return pat(`radial-gradient(${fg} 1px, transparent 1px)`)
case 'pct30':
case 'pct40':
case 'pct50':
return pat(`radial-gradient(${fg} 1.5px, transparent 1.5px)`)
case 'pct60':
case 'pct70':
case 'pct75':
case 'pct80':
case 'pct90':
return pat(`radial-gradient(${fg} 2.5px, transparent 2.5px)`)
// Horizontal lines
case 'horz':
case 'ltHorz':
case 'narHorz':
case 'dkHorz':
return pat(
`repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Vertical lines
case 'vert':
case 'ltVert':
case 'narVert':
case 'dkVert':
return pat(
`repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Diagonal lines (down-right)
case 'dnDiag':
case 'ltDnDiag':
case 'narDnDiag':
case 'dkDnDiag':
case 'wdDnDiag':
return pat(
`repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Diagonal lines (up-right)
case 'upDiag':
case 'ltUpDiag':
case 'narUpDiag':
case 'dkUpDiag':
case 'wdUpDiag':
return pat(
`repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Grid (horizontal + vertical)
case 'smGrid':
case 'lgGrid':
case 'cross':
return pat2(
`repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
`repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Diagonal cross
case 'smCheck':
case 'lgCheck':
case 'diagCross':
case 'openDmnd':
return pat2(
`repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
`repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`
)
// Dot patterns
case 'dotGrid':
case 'dotDmnd':
return pat(`radial-gradient(${fg} 1px, transparent 1px)`)
// Trellis / weave
case 'trellis':
case 'weave':
return pat2(
`repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)`,
`repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)`
)
// Dash variants
case 'dashDnDiag':
case 'dashUpDiag':
case 'dashHorz':
case 'dashVert': {
const angle = preset.includes('Dn')
? '45deg'
: preset.includes('Up')
? '-45deg'
: preset.includes('Horz')
? '0deg'
: '90deg'
return pat(
`repeating-linear-gradient(${angle}, ${fg} 0px, ${fg} 3px, transparent 3px, transparent ${s}px)`
)
}
// Sphere / shingle — radial gradient approximation
case 'sphere':
case 'shingle':
case 'plaid':
case 'divot':
case 'zigZag':
return pat(`radial-gradient(${fg} 2px, transparent 2px)`)
default:
return bg
}
}
/**
* Parse a gradient fill into a CSS gradient string.
*/
function resolveGradient(
gradFill: SafeXmlNode,
ctx: RenderContext,
placeholderColorNode?: SafeXmlNode
): string {
// Parse gradient stops
const gsLst = gradFill.child('gsLst')
const stops: { position: number; color: string }[] = []
for (const gs of gsLst.children('gs')) {
const pos = gs.numAttr('pos') ?? 0
const posPercent = pctToDecimal(pos) * 100
const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode)
stops.push({ position: posPercent, color: toCssColor(color, alpha) })
}
if (stops.length === 0) {
return ''
}
// Sort stops by position
stops.sort((a, b) => a.position - b.position)
const stopsStr = stops.map((s) => `${s.color} ${s.position.toFixed(1)}%`).join(', ')
// Determine gradient type
const lin = gradFill.child('lin')
if (lin.exists()) {
const angle = angleToDeg(lin.numAttr('ang') ?? 0)
// OOXML angle 0 = top-to-bottom in the gradient coordinate system
// CSS angle 0 = bottom-to-top, so we need to adjust
const cssAngle = (angle + 90) % 360
return `linear-gradient(${cssAngle.toFixed(1)}deg, ${stopsStr})`
}
const path = gradFill.child('path')
if (path.exists()) {
const pathType = path.attr('path')
if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') {
// OOXML path gradients: stop pos=0 = fillToRect center, pos=100000 = shape edge.
// CSS radial-gradient: 0% = center, 100% = edge.
// Conventions match — no reversal needed.
// Resolve fillToRect center point
const ftr = path.child('fillToRect')
let cx = 50
let cy = 50
if (ftr.exists()) {
const l = (ftr.numAttr('l') ?? 0) / 100000
const t = (ftr.numAttr('t') ?? 0) / 100000
const r = (ftr.numAttr('r') ?? 0) / 100000
const b = (ftr.numAttr('b') ?? 0) / 100000
cx = ((l + (1 - r)) / 2) * 100
cy = ((t + (1 - b)) / 2) * 100
}
if (pathType === 'rect') {
// Rectangular gradient (L∞ norm / Chebyshev distance): creates cross/X contour
// pattern. CSS can't do this natively; approximate by overlaying horizontal and
// vertical linear gradients with a radial gradient as fallback.
// The SVG path in ShapeRenderer uses the proper blend approach.
return `radial-gradient(closest-side at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})`
}
return `radial-gradient(ellipse at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})`
}
}
// Default to linear top-to-bottom
return `linear-gradient(180deg, ${stopsStr})`
}
// ---------------------------------------------------------------------------
// Line Style Resolution
// ---------------------------------------------------------------------------
/**
* Resolve a line (outline) node into CSS-compatible properties.
*
* @param ln The `<a:ln>` node from spPr
* @param ctx Render context
* @param lnRef Optional `<a:lnRef>` from `<p:style>` — provides fallback color
* when `<a:ln>` has no explicit solidFill (common for connectors)
*/
export function resolveLineStyle(
ln: SafeXmlNode,
ctx: RenderContext,
lnRef?: SafeXmlNode
): { width: number; color: string; dash: string; dashKind: string } {
// Width: a:ln@w is in EMU, convert to px
const widthEmu = ln.numAttr('w') ?? 0
let width = emuToPx(widthEmu)
// Color from solidFill child
let color = 'transparent'
const solidFill = ln.child('solidFill')
if (solidFill.exists()) {
const phClr = solidFill.child('schemeClr')
const usesPlaceholder = phClr.exists() && (phClr.attr('val') ?? '').toLowerCase() === 'phclr'
if (usesPlaceholder && lnRef && lnRef.exists()) {
// Theme line styles often use schemeClr=phClr and expect the concrete color from lnRef.
const base = resolveColor(lnRef, ctx)
const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color
const adjusted = applyColorModifiers(baseHex, collectModifiers(phClr))
color = toCssColor(adjusted.color, adjusted.alpha * base.alpha)
} else {
const resolved = resolveColor(solidFill, ctx)
color = toCssColor(resolved.color, resolved.alpha)
}
} else if (lnRef?.exists() && (lnRef.numAttr('idx') ?? 0) > 0) {
const idx = lnRef.numAttr('idx') ?? 0
// Look up theme line style for width, color, and dash
if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
const themeLn = ctx.theme.lineStyles[idx - 1]
// Get width from theme line if not set on the explicit ln node
if (width === 0) {
const themeW = themeLn.numAttr('w') ?? 0
width = emuToPx(themeW)
}
// Get color: prefer lnRef's own color child, fall back to theme line's solidFill
const resolved = resolveColor(lnRef, ctx)
color = toCssColor(resolved.color, resolved.alpha)
} else {
// Fallback: use lnRef color directly, approximate width from idx
const resolved = resolveColor(lnRef, ctx)
color = toCssColor(resolved.color, resolved.alpha)
if (width === 0 && idx > 0) {
width = idx * 0.75 // approximate: idx 1 = ~0.75px, idx 2 = ~1.5px
}
}
}
// Width fallback should still use lnRef/theme even when explicit solidFill is present on <a:ln>.
if (width === 0 && lnRef && lnRef.exists()) {
const idx = lnRef.numAttr('idx') ?? 0
if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
const themeLn = ctx.theme.lineStyles[idx - 1]
const themeW = themeLn.numAttr('w') ?? 0
width = emuToPx(themeW)
} else if (idx > 0) {
width = idx * 0.75
}
}
// Dash pattern
let dash = 'solid'
let dashKind = 'solid'
const prstDash = ln.child('prstDash')
if (prstDash.exists()) {
const val = prstDash.attr('val') || 'solid'
dashKind = val
dash = ooxmlDashToCss(val)
}
// If no dash from explicit ln, check theme line style
if (dash === 'solid' && lnRef && lnRef.exists()) {
const idx = lnRef.numAttr('idx') ?? 0
if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
const themeLn = ctx.theme.lineStyles[idx - 1]
const themeDash = themeLn.child('prstDash')
if (themeDash.exists()) {
dashKind = themeDash.attr('val') || 'solid'
dash = ooxmlDashToCss(dashKind)
}
}
}
return { width, color, dash, dashKind }
}
/**
* Map OOXML preset dash values to CSS border-style.
*/
function ooxmlDashToCss(val: string): string {
switch (val) {
case 'solid':
return 'solid'
case 'dot':
case 'sysDot':
return 'dotted'
case 'dash':
case 'sysDash':
case 'lgDash':
return 'dashed'
case 'dashDot':
case 'lgDashDot':
case 'lgDashDotDot':
case 'sysDashDot':
case 'sysDashDotDot':
return 'dashed'
default:
return 'solid'
}
}
// ---------------------------------------------------------------------------
// Gradient Fill Resolution (structured data for SVG use)
// ---------------------------------------------------------------------------
export interface GradientFillData {
type: 'linear' | 'radial'
stops: Array<{ position: number; color: string }>
/** SVG gradient interpolation space; OOXML gradients visually match linearRGB more closely. */
colorInterpolation?: 'linearRGB' | 'sRGB'
/** OOXML angle in degrees (0 = top-to-bottom). Only relevant for linear gradients. */
angle: number
/** Radial gradient center X as fraction 01. Default 0.5. */
cx?: number
/** Radial gradient center Y as fraction 01. Default 0.5. */
cy?: number
/** OOXML path type for radial gradients: 'rect', 'circle', or 'shape'. */
pathType?: string
}
function resolveGradientFillNode(
gradFill: SafeXmlNode,
ctx: RenderContext,
placeholderColorNode?: SafeXmlNode
): GradientFillData | null {
const gsLst = gradFill.child('gsLst')
const stops: Array<{ position: number; color: string }> = []
for (const gs of gsLst.children('gs')) {
const pos = gs.numAttr('pos') ?? 0
const posPercent = pctToDecimal(pos) * 100
const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode)
stops.push({ position: posPercent, color: toCssColor(color, alpha) })
}
if (stops.length === 0) return null
stops.sort((a, b) => a.position - b.position)
const lin = gradFill.child('lin')
if (lin.exists()) {
const angle = angleToDeg(lin.numAttr('ang') ?? 0)
return { type: 'linear', stops, angle, colorInterpolation: 'linearRGB' }
}
const path = gradFill.child('path')
if (path.exists()) {
const pathType = path.attr('path')
if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') {
const ftr = path.child('fillToRect')
let cx = 0.5
let cy = 0.5
if (ftr.exists()) {
const l = (ftr.numAttr('l') ?? 0) / 100000
const t = (ftr.numAttr('t') ?? 0) / 100000
const r = (ftr.numAttr('r') ?? 0) / 100000
const b = (ftr.numAttr('b') ?? 0) / 100000
cx = (l + (1 - r)) / 2
cy = (t + (1 - b)) / 2
}
return {
type: 'radial',
stops,
angle: 0,
cx,
cy,
pathType: pathType,
colorInterpolation: 'linearRGB',
}
}
}
return { type: 'linear', stops, angle: 0, colorInterpolation: 'linearRGB' }
}
/**
* Resolve a gradient fill from `spPr` into structured data suitable for
* creating SVG gradient elements. Returns null if no gradient fill is present.
*/
export function resolveGradientFill(
spPr: SafeXmlNode,
ctx: RenderContext
): GradientFillData | null {
let gradFill = spPr.child('gradFill')
// grpFill: inherit gradient from parent group's grpSpPr
if (!gradFill.exists() && spPr.child('grpFill').exists() && ctx.groupFillNode) {
gradFill = ctx.groupFillNode.child('gradFill')
}
if (!gradFill.exists()) return null
return resolveGradientFillNode(gradFill, ctx)
}
export function resolveThemeFillReference(
fillRef: SafeXmlNode,
ctx: RenderContext
): { fillCss: string; gradientFillData: GradientFillData | null } {
const idx = fillRef.numAttr('idx') ?? 0
if (idx <= 0 || (ctx.theme.fillStyles?.length ?? 0) < idx) {
return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null }
}
const themeFill = ctx.theme.fillStyles[idx - 1]
if (!themeFill?.exists()) {
return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null }
}
if (themeFill.localName === 'solidFill') {
const resolved = resolveColorWithPlaceholder(themeFill, ctx, fillRef)
return { fillCss: toCssColor(resolved.color, resolved.alpha), gradientFillData: null }
}
if (themeFill.localName === 'gradFill') {
return {
fillCss: resolveGradient(themeFill, ctx, fillRef),
gradientFillData: resolveGradientFillNode(themeFill, ctx, fillRef),
}
}
if (themeFill.localName === 'pattFill') {
return { fillCss: resolvePatternFill(themeFill, ctx), gradientFillData: null }
}
if (themeFill.localName === 'noFill') {
return { fillCss: 'transparent', gradientFillData: null }
}
return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null }
}
// ---------------------------------------------------------------------------
// Gradient Stroke Resolution
// ---------------------------------------------------------------------------
export interface GradientStrokeData {
stops: Array<{ position: number; color: string }>
angle: number
width: number
colorInterpolation?: 'linearRGB' | 'sRGB'
}
/**
* Resolve a gradient stroke from an `<a:ln>` node that contains `<a:gradFill>`.
* Returns gradient stop data, angle, and line width — or null if no gradient fill is present.
*/
export function resolveGradientStroke(
ln: SafeXmlNode,
ctx: RenderContext
): GradientStrokeData | null {
const gradFill = ln.child('gradFill')
if (!gradFill.exists()) return null
const gsLst = gradFill.child('gsLst')
const stops: Array<{ position: number; color: string }> = []
for (const gs of gsLst.children('gs')) {
const pos = gs.numAttr('pos') ?? 0
const posPercent = pctToDecimal(pos) * 100
const { color, alpha } = resolveColor(gs, ctx)
const cssColor = toCssColor(color, alpha)
stops.push({ position: posPercent, color: cssColor })
}
if (stops.length === 0) return null
stops.sort((a, b) => a.position - b.position)
const lin = gradFill.child('lin')
let angle = 0
if (lin.exists()) {
angle = angleToDeg(lin.numAttr('ang') ?? 0)
}
const widthEmu = ln.numAttr('w') ?? 0
let width = emuToPx(widthEmu)
// OOXML default when w is omitted is typically 1 pt; avoid invisible gradient stroke
if (width <= 0) width = 1
return { stops, angle, width, colorInterpolation: 'linearRGB' }
}
@@ -0,0 +1,608 @@
/**
* Table renderer — converts TableNodeData into positioned HTML table elements.
*
* Table style behavior follows:
* - OOXML ECMA-376 §21.1.3.15 tblPr: firstRow, firstCol, bandRow, bandCol, lastRow, lastCol
* are attributes; when not specified they default to off (no styling).
* - references/pptxjs (gen-table.ts, get-table-row-style.ts, get-table-cell-params.ts):
* reads tblPr attrs only (e.g. firstCol === "1"), applies style parts when attr is "1",
* and uses tcTxStyle from each part for cell text color/font (a:tcTxStyle under firstRow, firstCol, etc.).
*/
import type { TableCell, TableNodeData } from '../model/nodes/table-node'
import { emuToPx } from '../parser/units'
import type { SafeXmlNode } from '../parser/xml-parser'
import { hexToRgb } from '../utils/color'
import { getPredefinedTableStyle } from './predefined-table-styles'
import type { RenderContext } from './render-context'
import { resolveColor, resolveLineStyle } from './style-resolver'
import { renderTextBody } from './text-renderer'
// ---------------------------------------------------------------------------
// Table Style Lookup
// ---------------------------------------------------------------------------
/**
* Find a table style node by its ID from presentation.tableStyles.
* tableStyles XML structure: <a:tblStyleLst> <a:tblStyle styleId="{UUID}" ...>
*/
function findTableStyle(
tableStyleId: string | undefined,
ctx: RenderContext
): SafeXmlNode | undefined {
if (!tableStyleId || !ctx.presentation.tableStyles) return undefined
const tblStyleLst = ctx.presentation.tableStyles
for (const style of tblStyleLst.children('tblStyle')) {
if (style.attr('styleId') === tableStyleId) {
return style
}
}
// Also check from root if tableStyles IS the tblStyleLst
for (const style of tblStyleLst.children()) {
if (style.localName === 'tblStyle' && style.attr('styleId') === tableStyleId) {
return style
}
}
// Fallback: check predefined (built-in) Office table styles not embedded in the PPTX
return getPredefinedTableStyle(tableStyleId)
}
/**
* Get the appropriate style section from a table style for a given cell position.
* Priority: specific section > wholeTbl (fallback).
*/
function getStyleSections(
tblStyle: SafeXmlNode,
rowIdx: number,
colIdx: number,
totalRows: number,
totalCols: number,
tblPr: SafeXmlNode | undefined
): SafeXmlNode[] {
const sections: SafeXmlNode[] = []
// Style parts enabled only when tblPr has attribute "1" (or true); per spec default is off.
// pptxjs uses attrs only (firstCol === "1"); we also accept child elements for compatibility.
const flag = (attrName: string, childName: string): boolean => {
if (!tblPr) return false
const attr = tblPr.attr(attrName)
if (attr !== undefined) return attr === '1' || attr === 'true'
const ch = tblPr.child(childName)
if (ch.exists()) {
const val = ch.attr('val')
return val !== '0' && val !== 'false'
}
return false
}
const bandRow =
tblPr?.attr('bandRow') === '1' ||
tblPr?.attr('bandRow') === 'true' ||
tblPr?.child('bandRow').exists()
const bandCol =
tblPr?.attr('bandCol') === '1' ||
tblPr?.attr('bandCol') === 'true' ||
tblPr?.child('bandCol').exists()
const isFirstRow = flag('firstRow', 'firstRow')
const isLastRow = flag('lastRow', 'lastRow')
const isFirstCol = flag('firstCol', 'firstCol')
const isLastCol = flag('lastCol', 'lastCol')
// wholeTbl is the base (lowest priority)
const wholeTbl = tblStyle.child('wholeTbl')
if (wholeTbl.exists()) sections.push(wholeTbl)
// Banding (applied on top of wholeTbl)
if (bandRow) {
const effectiveRow = isFirstRow ? rowIdx - 1 : rowIdx
if (effectiveRow >= 0 && effectiveRow % 2 === 1) {
const band = tblStyle.child('band2H')
if (band.exists()) sections.push(band)
} else if (effectiveRow >= 0 && effectiveRow % 2 === 0) {
const band = tblStyle.child('band1H')
if (band.exists()) sections.push(band)
}
}
if (bandCol) {
if (colIdx % 2 === 1) {
const band = tblStyle.child('band2V')
if (band.exists()) sections.push(band)
} else {
const band = tblStyle.child('band1V')
if (band.exists()) sections.push(band)
}
}
// Special rows/cols (highest priority, override banding)
if (isFirstRow && rowIdx === 0) {
const s = tblStyle.child('firstRow')
if (s.exists()) sections.push(s)
}
if (isLastRow && rowIdx === totalRows - 1) {
const s = tblStyle.child('lastRow')
if (s.exists()) sections.push(s)
}
if (isFirstCol && colIdx === 0) {
const s = tblStyle.child('firstCol')
if (s.exists()) sections.push(s)
}
if (isLastCol && colIdx === totalCols - 1) {
const s = tblStyle.child('lastCol')
if (s.exists()) sections.push(s)
}
return sections
}
/** Resolved text properties from table style tcTxStyle. */
interface TableStyleTextProps {
color?: string
bold?: boolean
italic?: boolean
fontFamily?: string
}
/**
* Get the effective text properties from table style sections (last section with tcTxStyle wins).
* tcTxStyle supports: b (bold), i (italic), and color children (schemeClr, solidFill, etc.).
* When a style part (e.g. firstCol, firstRow) is applied, we use that part's tcTxStyle for cell
* text styling so text stays readable on styled fill.
*/
function getEffectiveTableStyleTextProps(
sections: SafeXmlNode[],
ctx: RenderContext
): TableStyleTextProps | undefined {
for (let i = sections.length - 1; i >= 0; i--) {
const tcTxStyle = sections[i].child('tcTxStyle')
if (!tcTxStyle.exists()) continue
const props: TableStyleTextProps = {}
// Bold: b="on" or b="off" (OOXML CT_TableStyleTextStyle)
const b = tcTxStyle.attr('b')
if (b === 'on') props.bold = true
else if (b === 'off') props.bold = false
// Italic: i="on" or i="off"
const italic = tcTxStyle.attr('i')
if (italic === 'on') props.italic = true
else if (italic === 'off') props.italic = false
// Color: child elements (schemeClr, solidFill, srgbClr, etc.)
for (const child of tcTxStyle.allChildren()) {
const tag = child.localName
if (
tag === 'schemeClr' ||
tag === 'solidFill' ||
tag === 'srgbClr' ||
tag === 'scrgbClr' ||
tag === 'prstClr' ||
tag === 'sysClr'
) {
const { color, alpha } = resolveColor(child, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b: bl } = hexToRgb(hex)
props.color = `rgba(${r},${g},${bl},${alpha.toFixed(3)})`
} else {
props.color = hex
}
break
}
}
// Font family: <font><latin>/<ea>/<cs> typeface or <fontRef idx="major|minor">
const font = tcTxStyle.child('font')
if (font.exists()) {
const latin = font.child('latin').attr('typeface')
const ea = font.child('ea').attr('typeface')
const cs = font.child('cs').attr('typeface')
props.fontFamily = latin || ea || cs
}
if (!props.fontFamily) {
const fontRef = tcTxStyle.child('fontRef')
if (fontRef.exists()) {
const idx = fontRef.attr('idx')
if (idx === 'major') {
props.fontFamily = ctx.theme.majorFont.latin || ctx.theme.majorFont.ea
} else if (idx === 'minor') {
props.fontFamily = ctx.theme.minorFont.latin || ctx.theme.minorFont.ea
}
}
}
return props
}
return undefined
}
/**
* Apply fill from a table style tcStyle node.
* Structure: <a:tcStyle> <a:fill> <a:solidFill>... or <a:fillRef>...
*/
function applyStyleFill(td: HTMLElement, tcStyle: SafeXmlNode, ctx: RenderContext): boolean {
const fill = tcStyle.child('fill')
if (!fill.exists()) return false
// solidFill
const solidFill = fill.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)
td.style.backgroundColor = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
} else {
td.style.backgroundColor = hex
}
return true
}
// fillRef (theme fill reference)
const fillRef = fill.child('fillRef')
if (fillRef.exists()) {
// fillRef contains a color child + idx attribute
const { color, alpha } = resolveColor(fillRef, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b } = hexToRgb(hex)
td.style.backgroundColor = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
} else {
td.style.backgroundColor = hex
}
return true
}
// noFill
const noFill = fill.child('noFill')
if (noFill.exists()) return true // explicitly no fill
return false
}
/**
* Apply borders from a table style tcStyle node.
* Structure: <a:tcStyle> <a:tcBdr> <a:top>/<a:bottom>/<a:left>/<a:right> <a:ln>...
*/
function applyStyleBorders(
td: HTMLElement,
tcStyle: SafeXmlNode,
ctx: RenderContext,
rowIdx?: number,
colIdx?: number,
totalRows?: number,
totalCols?: number
): void {
const tcBdr = tcStyle.child('tcBdr')
if (!tcBdr.exists()) return
const borderMap: Array<[string, 'borderTop' | 'borderBottom' | 'borderLeft' | 'borderRight']> = [
['top', 'borderTop'],
['bottom', 'borderBottom'],
['left', 'borderLeft'],
['right', 'borderRight'],
]
// Map insideH/insideV to individual cell borders:
// insideH → borderBottom for non-last rows, borderTop for non-first rows
// insideV → borderRight for non-last cols, borderLeft for non-first cols
const insideH = tcBdr.child('insideH')
if (insideH.exists() && rowIdx !== undefined && totalRows !== undefined) {
if (rowIdx < totalRows - 1) {
borderMap.push(['insideH', 'borderBottom'])
}
if (rowIdx > 0) {
borderMap.push(['insideH', 'borderTop'])
}
}
const insideV = tcBdr.child('insideV')
if (insideV.exists() && colIdx !== undefined && totalCols !== undefined) {
if (colIdx < totalCols - 1) {
borderMap.push(['insideV', 'borderRight'])
}
if (colIdx > 0) {
borderMap.push(['insideV', 'borderLeft'])
}
}
for (const [xmlName, cssProp] of borderMap) {
const side = tcBdr.child(xmlName)
if (!side.exists()) continue
// Direct <a:ln> element
const ln = side.child('ln')
if (ln.exists()) {
const noFill = ln.child('noFill')
if (noFill.exists()) continue
const style = resolveLineStyle(ln, ctx)
if (style.width > 0 && style.color !== 'transparent') {
td.style[cssProp] = `${Math.max(style.width, 0.5)}px ${style.dash} ${style.color}`
}
continue
}
// <a:lnRef> — reference to theme line style (common in table styles)
const lnRef = side.child('lnRef')
if (lnRef.exists()) {
const idx = lnRef.numAttr('idx') ?? 0
if (idx === 0) continue // idx 0 = no line
// Resolve color from the lnRef's child color element
const { color, alpha } = resolveColor(lnRef, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
// Get width from theme line style
let width = 1 // default 1px
if (ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
const themeLn = ctx.theme.lineStyles[idx - 1]
const themeW = themeLn.numAttr('w') ?? 12700 // default 1pt
width = emuToPx(themeW)
}
const cssColor =
alpha < 1
? `rgba(${hexToRgb(hex).r},${hexToRgb(hex).g},${hexToRgb(hex).b},${alpha.toFixed(3)})`
: hex
if (width > 0) {
td.style[cssProp] = `${Math.max(width, 0.5)}px solid ${cssColor}`
}
}
}
}
/**
* Apply table-level background from tblStyle > tblBg.
* tblBg can contain fillRef (theme fill reference) or solidFill.
*/
function applyTableBackground(table: HTMLElement, tblStyle: SafeXmlNode, ctx: RenderContext): void {
const tblBg = tblStyle.child('tblBg')
if (!tblBg.exists()) return
// fillRef: references a theme fill style with a color override
const fillRef = tblBg.child('fillRef')
if (fillRef.exists()) {
const { color, alpha } = resolveColor(fillRef, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b } = hexToRgb(hex)
table.style.backgroundColor = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
} else {
table.style.backgroundColor = hex
}
return
}
// solidFill
const solidFill = tblBg.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)
table.style.backgroundColor = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
} else {
table.style.backgroundColor = hex
}
}
}
// ---------------------------------------------------------------------------
// Table Rendering
// ---------------------------------------------------------------------------
/**
* Render a table node into an absolutely-positioned HTML element.
*/
export function renderTable(node: TableNodeData, 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(' ')
}
// Resolve table style
const tblStyle = findTableStyle(node.tableStyleId, ctx)
const tblPr = node.properties
const totalRows = node.rows.length
const totalCols = node.columns.length
// Create table element
const table = document.createElement('table')
table.style.borderCollapse = 'collapse'
table.style.width = '100%'
table.style.height = '100%'
table.style.tableLayout = 'fixed'
// Apply table background from table style (tblBg)
if (tblStyle) {
applyTableBackground(table, tblStyle, ctx)
}
// Column widths
const totalWidth = node.columns.reduce((sum, w) => sum + w, 0)
if (totalWidth > 0 && node.columns.length > 0) {
const colgroup = document.createElement('colgroup')
for (const colW of node.columns) {
const col = document.createElement('col')
col.style.width = `${(colW / totalWidth) * 100}%`
colgroup.appendChild(col)
}
table.appendChild(colgroup)
}
// Compute total row height so we can express each row as a proportion
const totalRowHeight = node.rows.reduce((sum, r) => sum + r.height, 0)
// Render rows
const tbody = document.createElement('tbody')
let colIdx = 0
for (let rowIdx = 0; rowIdx < node.rows.length; rowIdx++) {
const row = node.rows[rowIdx]
const tr = document.createElement('tr')
if (row.height > 0 && totalRowHeight > 0) {
// Use percentage heights so rows stay proportional within the
// table's constrained height instead of expanding beyond it.
tr.style.height = `${(row.height / totalRowHeight) * 100}%`
}
colIdx = 0
for (const cell of row.cells) {
// Skip merged cells
if (cell.hMerge || cell.vMerge) {
colIdx++
continue
}
const td = document.createElement('td')
td.style.overflow = 'hidden'
// Spanning
if (cell.gridSpan > 1) {
td.colSpan = cell.gridSpan
}
if (cell.rowSpan > 1) {
td.rowSpan = cell.rowSpan
}
// Apply table style first (as base), then direct tcPr overrides
let sections: SafeXmlNode[] = []
if (tblStyle) {
sections = getStyleSections(tblStyle, rowIdx, colIdx, totalRows, totalCols, tblPr)
// Apply sections in order (later sections override earlier ones)
for (const section of sections) {
const tcStyle = section.child('tcStyle')
if (tcStyle.exists()) {
applyStyleFill(td, tcStyle, ctx)
applyStyleBorders(td, tcStyle, ctx, rowIdx, colIdx, totalRows, totalCols)
}
}
}
// Apply direct cell properties (override table style)
applyCellProperties(td, cell, ctx)
// Resolve table style text properties (color, bold, italic from tcTxStyle)
const textProps =
sections.length > 0 ? getEffectiveTableStyleTextProps(sections, ctx) : undefined
// Render text inside cell
if (cell.textBody) {
const opts = textProps
? {
cellTextColor: textProps.color,
cellTextBold: textProps.bold,
cellTextItalic: textProps.italic,
cellTextFontFamily: textProps.fontFamily,
}
: undefined
renderTextBody(cell.textBody, undefined, ctx, td, opts)
}
tr.appendChild(td)
colIdx += cell.gridSpan
}
tbody.appendChild(tr)
}
table.appendChild(tbody)
wrapper.appendChild(table)
return wrapper
}
// ---------------------------------------------------------------------------
// Cell Property Application
// ---------------------------------------------------------------------------
/**
* Apply table cell properties (tcPr) to a <td> element.
*/
function applyCellProperties(td: HTMLElement, cell: TableCell, ctx: RenderContext): void {
const tcPr = cell.properties
if (!tcPr) return
// Fill (overrides table style fill)
const solidFill = tcPr.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)
td.style.backgroundColor = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
} else {
td.style.backgroundColor = hex
}
}
// Borders (override table style borders)
applyBorder(td, tcPr, 'lnT', 'borderTop', ctx)
applyBorder(td, tcPr, 'lnB', 'borderBottom', ctx)
applyBorder(td, tcPr, 'lnL', 'borderLeft', ctx)
applyBorder(td, tcPr, 'lnR', 'borderRight', ctx)
// Margins / Padding
const marL = tcPr.numAttr('marL')
const marR = tcPr.numAttr('marR')
const marT = tcPr.numAttr('marT')
const marB = tcPr.numAttr('marB')
// Default margin is 91440 EMU (0.1 inch) = ~9.6px
const defaultMargin = 91440
td.style.paddingLeft = `${emuToPx(marL ?? defaultMargin)}px`
td.style.paddingRight = `${emuToPx(marR ?? defaultMargin)}px`
td.style.paddingTop = `${emuToPx(marT ?? 45720)}px`
td.style.paddingBottom = `${emuToPx(marB ?? 45720)}px`
// Vertical alignment
const anchor = tcPr.attr('anchor')
const alignMap: Record<string, string> = {
t: 'top',
ctr: 'middle',
b: 'bottom',
}
td.style.verticalAlign = alignMap[anchor || 't'] || 'top'
}
/**
* Apply a single border to a <td> element from a line node.
*/
function applyBorder(
td: HTMLElement,
tcPr: SafeXmlNode,
lineName: string,
cssProp: 'borderTop' | 'borderBottom' | 'borderLeft' | 'borderRight',
ctx: RenderContext
): void {
const ln = tcPr.child(lineName)
if (!ln.exists()) return
// Check for noFill — explicitly clear any border set by table style
const noFill = ln.child('noFill')
if (noFill.exists()) {
td.style[cssProp] = 'none'
return
}
const style = resolveLineStyle(ln, ctx)
if (style.width > 0 && style.color !== 'transparent') {
td.style[cssProp] = `${Math.max(style.width, 0.5)}px ${style.dash} ${style.color}`
}
}
@@ -0,0 +1,965 @@
/**
* Text renderer — converts OOXML text body into HTML DOM elements
* with full 7-level style inheritance.
*/
import { hexToRgb, toCssColor } from '@/lib/colors'
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
import type { PlaceholderInfo } from '../model/nodes/base-node'
import type { TextBody } from '../model/nodes/shape-node'
import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units'
import { SafeXmlNode } from '../parser/xml-parser'
import type { RenderContext } from './render-context'
import { resolveColor, resolveColorToCss } from './style-resolver'
// ---------------------------------------------------------------------------
// Style Inheritance Helpers
// ---------------------------------------------------------------------------
/**
* Find paragraph properties at a specific indent level from a list style node.
* Tries lvl{n}pPr (where n = level + 1), then falls back to defPPr.
*/
function findStyleAtLevel(styleNode: SafeXmlNode | undefined, level: number): SafeXmlNode {
if (!styleNode || !styleNode.exists()) {
return new SafeXmlNode(null)
}
// Try level-specific style (lvl1pPr, lvl2pPr, etc.)
const lvlNode = styleNode.child(`lvl${level + 1}pPr`)
if (lvlNode.exists()) return lvlNode
// Fall back to default
return styleNode.child('defPPr')
}
/**
* Determine the placeholder category for style inheritance.
* Returns 'title', 'body', or 'other'.
*/
function getPlaceholderCategory(
placeholder: PlaceholderInfo | undefined
): 'title' | 'body' | 'other' {
if (!placeholder || !placeholder.type) return 'other'
const t = placeholder.type
if (t === 'title' || t === 'ctrTitle') return 'title'
if (
t === 'body' ||
t === 'subTitle' ||
t === 'obj' ||
t === 'dt' ||
t === 'ftr' ||
t === 'sldNum'
) {
return 'body'
}
return 'other'
}
/**
* Find a placeholder node in a list by matching type and/or idx.
*/
function findPlaceholderNode(
placeholders: SafeXmlNode[],
info: PlaceholderInfo
): SafeXmlNode | undefined {
for (const ph of placeholders) {
// Navigate to the ph element to read its attributes
let phEl: SafeXmlNode | undefined
const nvSpPr = ph.child('nvSpPr')
if (nvSpPr.exists()) {
phEl = nvSpPr.child('nvPr').child('ph')
}
if (!phEl || !phEl.exists()) {
const nvPicPr = ph.child('nvPicPr')
if (nvPicPr.exists()) {
phEl = nvPicPr.child('nvPr').child('ph')
}
}
if (!phEl || !phEl.exists()) continue
const phType = phEl.attr('type')
const phIdx = phEl.numAttr('idx')
// Match by idx first (most specific), then by type
if (info.idx !== undefined && phIdx === info.idx) return ph
if (info.type && phType === info.type) return ph
}
return undefined
}
/**
* Extract lstStyle from a placeholder shape node.
*/
function getPlaceholderLstStyle(phNode: SafeXmlNode): SafeXmlNode | undefined {
const txBody = phNode.child('txBody')
if (!txBody.exists()) return undefined
const lstStyle = txBody.child('lstStyle')
return lstStyle.exists() ? lstStyle : undefined
}
/**
* Merge a source paragraph property node onto a target style object.
* Later calls override earlier values (higher priority wins).
*/
interface MergedParagraphStyle {
align?: string
marginLeft?: number
textIndent?: number
lineHeight?: string
/** True when lineHeight comes from spcPts (absolute pt value). For CJK fonts, CSS line-height
* with absolute values may not produce exact spacing because the font's content area can exceed
* the line-height. When true, we use block-level line wrappers instead of <br> for line breaks. */
lineHeightAbsolute?: boolean
spaceBefore?: number
spaceBeforePct?: number // percentage of font size (0-1 range)
spaceAfter?: number
spaceAfterPct?: number // percentage of font size (0-1 range)
bulletChar?: string
bulletFont?: string
bulletAutoNum?: string
bulletNone?: boolean
/** When set, bullet color is taken from this OOXML buClr node (a:buClr with srgbClr/schemeClr child). */
bulletColorNode?: SafeXmlNode
defRPr?: SafeXmlNode
}
function mergeParagraphProps(target: MergedParagraphStyle, pPr: SafeXmlNode): void {
if (!pPr.exists()) return
const algn = pPr.attr('algn')
if (algn) target.align = algn
const marL = pPr.numAttr('marL')
if (marL !== undefined) target.marginLeft = emuToPx(marL)
const indent = pPr.numAttr('indent')
if (indent !== undefined) target.textIndent = emuToPx(indent)
// Line spacing
// OOXML spcPct: 100000 = "single spacing" = 1.0× the font's line height.
// IMPORTANT: We must use UNITLESS CSS line-height values (e.g., 1.0, 1.2)
// instead of percentages (e.g., 100%, 120%). CSS percentage line-height is
// computed once against the element's own font-size and inherited as a FIXED
// pixel value — so a parent div with line-height:120% and font-size:16px
// inherits 19.2px to ALL children, even those with font-size:80pt.
// Unitless values are inherited as-is and each child recomputes against its
// own font-size.
const lnSpc = pPr.child('lnSpc')
if (lnSpc.exists()) {
const spcPct = lnSpc.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) {
// OOXML 100000 → CSS unitless 1.0; OOXML 120000 → CSS 1.2
target.lineHeight = `${(val / 100000).toFixed(3)}`
}
}
const spcPts = lnSpc.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) {
target.lineHeight = `${val / 100}pt`
target.lineHeightAbsolute = true
}
}
}
// Space before
const spcBef = pPr.child('spcBef')
if (spcBef.exists()) {
const spcPts = spcBef.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) target.spaceBefore = val / 100
}
const spcPct = spcBef.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) target.spaceBeforePct = val / 100000 // store as ratio
}
}
// Space after
const spcAft = pPr.child('spcAft')
if (spcAft.exists()) {
const spcPts = spcAft.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) target.spaceAfter = val / 100
}
const spcPct = spcAft.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) target.spaceAfterPct = val / 100000 // store as ratio
}
}
// Bullets
const buChar = pPr.child('buChar')
if (buChar.exists()) {
target.bulletChar = buChar.attr('char') || ''
target.bulletNone = false
}
const buAutoNum = pPr.child('buAutoNum')
if (buAutoNum.exists()) {
target.bulletAutoNum = buAutoNum.attr('type') || 'arabicPeriod'
target.bulletNone = false
}
const buNone = pPr.child('buNone')
if (buNone.exists()) {
target.bulletNone = true
target.bulletChar = undefined
target.bulletAutoNum = undefined
}
const buFont = pPr.child('buFont')
if (buFont.exists()) {
target.bulletFont = buFont.attr('typeface')
}
// Explicit bullet color (a:buClr); when present overrides defRPr for bullet color
const buClr = pPr.child('buClr')
if (buClr.exists()) {
target.bulletColorNode = buClr
}
// Default run properties (used as fallback for runs without rPr)
const defRPr = pPr.child('defRPr')
if (defRPr.exists()) {
target.defRPr = defRPr
}
}
// ---------------------------------------------------------------------------
// Run Style Resolution
// ---------------------------------------------------------------------------
interface MergedRunStyle {
fontSize?: number
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
color?: string
fontFamily?: string
hlinkClick?: string
/** Character spacing (tracking) in points — from a:spc @val (hundredths of pt). */
letterSpacingPt?: number
/** Kerning: minimum font size (pt) for kerning; 0 = always kern. */
kern?: number
/** Text capitalization: "all" = ALL CAPS, "small" = SMALL CAPS, "none" = normal. */
cap?: string
/** Baseline shift in percentage (positive = superscript, negative = subscript). */
baseline?: number
/** CSS gradient string for text fill (from rPr > gradFill). */
textGradientCss?: string
/** When true, text fill is transparent (a:noFill on rPr). */
textNoFill?: boolean
/** Text outline width in px (from a:ln on rPr). */
textOutlineWidth?: number
/** Text outline CSS color (solid fill on ln). */
textOutlineColor?: string
/** Text outline CSS gradient (gradient fill on ln) — used as mask-image for fade effect. */
textOutlineGradientCss?: string
}
function mergeRunProps(target: MergedRunStyle, rPr: SafeXmlNode, ctx: RenderContext): void {
if (!rPr.exists()) return
const sz = rPr.numAttr('sz')
if (sz !== undefined) target.fontSize = sz / 100 // hundredths of point -> pt
const b = rPr.attr('b')
if (b !== undefined) target.bold = b === '1' || b === 'true'
const i = rPr.attr('i')
if (i !== undefined) target.italic = i === '1' || i === 'true'
const u = rPr.attr('u')
if (u !== undefined && u !== 'none') target.underline = true
if (u === 'none') target.underline = false
const strike = rPr.attr('strike')
if (strike !== undefined && strike !== 'noStrike') target.strikethrough = true
if (strike === 'noStrike') target.strikethrough = false
// Color from solidFill or gradFill child
const solidFill = rPr.child('solidFill')
if (solidFill.exists()) {
const { color, alpha } = resolveColor(solidFill, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b: bl } = hexToRgb(hex)
target.color = `rgba(${r},${g},${bl},${alpha.toFixed(3)})`
} else {
target.color = hex
}
}
const gradFill = rPr.child('gradFill')
if (gradFill.exists()) {
const css = resolveGradientForText(gradFill, ctx)
if (css) target.textGradientCss = css
}
// Font family
const latin = rPr.child('latin')
if (latin.exists()) {
const typeface = latin.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
if (!target.fontFamily) {
const ea = rPr.child('ea')
if (ea.exists()) {
const typeface = ea.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
}
if (!target.fontFamily) {
const cs = rPr.child('cs')
if (cs.exists()) {
const typeface = cs.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
}
// Hyperlink
const hlinkClick = rPr.child('hlinkClick')
if (hlinkClick.exists()) {
// The actual URL is in the slide rels, referenced by r:id
const rId = hlinkClick.attr('id') ?? hlinkClick.attr('r:id')
if (rId) {
const rel = ctx.slide.rels.get(rId)
if (rel && rel.targetMode === 'External' && isAllowedExternalUrl(rel.target)) {
target.hlinkClick = rel.target
}
}
}
// Character spacing (compact/tracking): rPr@spc in hundredths of a point
const spc = rPr.numAttr('spc')
if (spc !== undefined) target.letterSpacingPt = spc / 100
// Kerning: rPr@kern = minimum font size (hundredths of pt) to apply kerning; 0 = always
const kern = rPr.numAttr('kern')
if (kern !== undefined) target.kern = kern / 100
// Text capitalization: cap="all" (ALL CAPS) or cap="small" (SMALL CAPS)
const cap = rPr.attr('cap')
if (cap !== undefined) target.cap = cap
// Baseline shift: positive = superscript, negative = subscript (in 1000ths of percent)
const baseline = rPr.numAttr('baseline')
if (baseline !== undefined) target.baseline = baseline
// Text noFill: a:noFill on rPr makes text interior transparent
if (rPr.child('noFill').exists()) {
target.textNoFill = true
}
// Text outline: a:ln on rPr defines text stroke/outline
const ln = rPr.child('ln')
if (ln.exists() && !ln.child('noFill').exists()) {
const lnW = ln.numAttr('w')
target.textOutlineWidth = lnW ? emuToPx(lnW) : 0.75 // default ~0.75px
// Solid fill on outline
const lnSolid = ln.child('solidFill')
if (lnSolid.exists()) {
const { color: c, alpha: a } = resolveColor(lnSolid, ctx)
target.textOutlineColor = toCssColor(c, a)
}
// Gradient fill on outline — build CSS gradient for mask effect
const lnGrad = ln.child('gradFill')
if (lnGrad.exists()) {
target.textOutlineGradientCss = resolveGradientForText(lnGrad, ctx)
}
}
}
/**
* Resolve theme font placeholder references like "+mj-lt" or "+mn-lt".
*/
function resolveThemeFont(typeface: string, ctx: RenderContext): string {
if (typeface === '+mj-lt' || typeface === '+mj-ea' || typeface === '+mj-cs') {
const key = typeface.slice(3) as 'lt' | 'ea' | 'cs'
const mapping: Record<string, 'latin' | 'ea' | 'cs'> = { lt: 'latin', ea: 'ea', cs: 'cs' }
return ctx.theme.majorFont[mapping[key] || 'latin'] || typeface
}
if (typeface === '+mn-lt' || typeface === '+mn-ea' || typeface === '+mn-cs') {
const key = typeface.slice(3) as 'lt' | 'ea' | 'cs'
const mapping: Record<string, 'latin' | 'ea' | 'cs'> = { lt: 'latin', ea: 'ea', cs: 'cs' }
return ctx.theme.minorFont[mapping[key] || 'latin'] || typeface
}
return typeface
}
/**
* Resolve a gradient fill node into a CSS linear-gradient string.
* Used for text outline gradient effects.
*/
function resolveGradientForText(gradFill: SafeXmlNode, ctx: RenderContext): string {
const gsLst = gradFill.child('gsLst')
const stops: { position: number; color: string }[] = []
for (const gs of gsLst.children('gs')) {
const pos = gs.numAttr('pos') ?? 0
const posPercent = pctToDecimal(pos) * 100
const { color, alpha } = resolveColor(gs, ctx)
stops.push({ position: posPercent, color: toCssColor(color, alpha) })
}
if (stops.length === 0) return ''
stops.sort((a, b) => a.position - b.position)
const stopsStr = stops.map((s) => `${s.color} ${s.position.toFixed(1)}%`).join(', ')
const lin = gradFill.child('lin')
if (lin.exists()) {
const angle = angleToDeg(lin.numAttr('ang') ?? 0)
const cssAngle = (angle + 90) % 360
return `linear-gradient(${cssAngle.toFixed(1)}deg, ${stopsStr})`
}
return `linear-gradient(180deg, ${stopsStr})`
}
// ---------------------------------------------------------------------------
// Bullet Generation
// ---------------------------------------------------------------------------
function generateAutoNumber(type: string, index: number): string {
const num = index + 1
switch (type) {
case 'arabicPeriod':
return `${num}.`
case 'arabicParenR':
return `${num})`
case 'arabicParenBoth':
return `(${num})`
case 'arabicPlain':
return `${num}`
case 'romanUcPeriod':
return `${toRoman(num)}.`
case 'romanLcPeriod':
return `${toRoman(num).toLowerCase()}.`
case 'alphaUcPeriod':
return `${String.fromCharCode(64 + (((num - 1) % 26) + 1))}.`
case 'alphaLcPeriod':
return `${String.fromCharCode(96 + (((num - 1) % 26) + 1))}.`
case 'alphaUcParenR':
return `${String.fromCharCode(64 + (((num - 1) % 26) + 1))})`
case 'alphaLcParenR':
return `${String.fromCharCode(96 + (((num - 1) % 26) + 1))})`
default:
return `${num}.`
}
}
function toRoman(num: number): string {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
const syms = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']
let result = ''
let remaining = num
for (let i = 0; i < vals.length; i++) {
while (remaining >= vals[i]) {
result += syms[i]
remaining -= vals[i]
}
}
return result
}
// ---------------------------------------------------------------------------
// Main Render Function
// ---------------------------------------------------------------------------
/**
* Render a text body into the provided container element.
*
* Implements 7-level style inheritance:
* 1. master.defaultTextStyle
* 2. master.textStyles[category] (titleStyle / bodyStyle / otherStyle)
* 3. master placeholder lstStyle
* 4. layout placeholder lstStyle
* 5. shape lstStyle
* 6. paragraph pPr
* 7. run rPr
*/
/** Optional overrides when rendering text (e.g. table cell style text properties from tcTxStyle). */
interface RenderTextBodyOptions {
/** When set, used as text color when the run has no explicit color (e.g. table style tcTxStyle). */
cellTextColor?: string
/** When set, applies bold from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextBold?: boolean
/** When set, applies italic from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextItalic?: boolean
/** When set, applies font family from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextFontFamily?: string
/** fontRef color from shape style (e.g. SmartArt). Overrides inherited styles but yields to explicit run rPr color. */
fontRefColor?: string
}
export function renderTextBody(
textBody: TextBody,
placeholder: PlaceholderInfo | undefined,
ctx: RenderContext,
container: HTMLElement,
options?: RenderTextBodyOptions
): void {
const category = getPlaceholderCategory(placeholder)
let bulletCounter = 0
// Parse normAutofit from bodyPr (font scaling + line spacing reduction)
let fontScale = 1
let lnSpcReduction = 0
if (textBody.bodyProperties) {
const normAutofit = textBody.bodyProperties.child('normAutofit')
if (normAutofit.exists()) {
const fs = normAutofit.numAttr('fontScale')
if (fs !== undefined) fontScale = fs / 100000 // 100000 = 100%
const lsr = normAutofit.numAttr('lnSpcReduction')
if (lsr !== undefined) lnSpcReduction = lsr / 100000 // e.g., 20000 = 20%
}
}
for (const paragraph of textBody.paragraphs) {
const paraDiv = document.createElement('div')
const level = paragraph.level
// ---- Build merged paragraph style (7-level inheritance) ----
const merged: MergedParagraphStyle = {}
// Level 1: master defaultTextStyle
mergeParagraphProps(merged, findStyleAtLevel(ctx.master.defaultTextStyle, level))
// Level 2: master text styles by category
const masterTextStyle =
category === 'title'
? ctx.master.textStyles.titleStyle
: category === 'body'
? ctx.master.textStyles.bodyStyle
: ctx.master.textStyles.otherStyle
mergeParagraphProps(merged, findStyleAtLevel(masterTextStyle, level))
// Level 3: master placeholder lstStyle
if (placeholder) {
const masterPh = findPlaceholderNode(ctx.master.placeholders, placeholder)
if (masterPh) {
const lstStyle = getPlaceholderLstStyle(masterPh)
mergeParagraphProps(merged, findStyleAtLevel(lstStyle, level))
}
}
// Level 4: layout placeholder lstStyle
if (placeholder) {
const layoutPh = findPlaceholderNode(
ctx.layout.placeholders.map((e) => e.node),
placeholder
)
if (layoutPh) {
const lstStyle = getPlaceholderLstStyle(layoutPh)
mergeParagraphProps(merged, findStyleAtLevel(lstStyle, level))
}
}
// Level 5: shape lstStyle
mergeParagraphProps(merged, findStyleAtLevel(textBody.listStyle, level))
// Level 6: paragraph pPr
if (paragraph.properties) {
mergeParagraphProps(merged, paragraph.properties)
}
// ---- Apply paragraph styles ----
if (merged.align) {
const alignMap: Record<string, string> = {
l: 'left',
ctr: 'center',
r: 'right',
just: 'justify',
dist: 'justify',
}
paraDiv.style.textAlign = alignMap[merged.align] || 'left'
}
if (merged.marginLeft !== undefined) {
paraDiv.style.marginLeft = `${merged.marginLeft}px`
}
if (merged.textIndent !== undefined) {
paraDiv.style.textIndent = `${merged.textIndent}px`
}
// Compute effective line-height (with optional lnSpcReduction from normAutofit)
let effectiveLineHeight = merged.lineHeight
if (merged.lineHeight) {
if (lnSpcReduction > 0) {
const parsed = Number.parseFloat(merged.lineHeight)
if (!Number.isNaN(parsed)) {
if (merged.lineHeight.includes('pt')) {
effectiveLineHeight = `${(parsed * (1 - lnSpcReduction)).toFixed(2)}pt`
} else {
effectiveLineHeight = `${(parsed * (1 - lnSpcReduction)).toFixed(3)}`
}
}
}
paraDiv.style.lineHeight = effectiveLineHeight!
}
// Determine effective font size for percentage-based spacing
// Use defRPr or first run's font size, fallback to 12pt
let effectiveFontSize = 12 // default 12pt
if (merged.defRPr) {
const sz = merged.defRPr.numAttr('sz')
if (sz !== undefined) effectiveFontSize = sz / 100
}
if (paragraph.runs.length > 0 && paragraph.runs[0].properties) {
const sz = paragraph.runs[0].properties.numAttr('sz')
if (sz !== undefined) effectiveFontSize = sz / 100
}
if (merged.spaceBefore !== undefined) {
paraDiv.style.marginTop = `${merged.spaceBefore}pt`
} else if (merged.spaceBeforePct !== undefined) {
paraDiv.style.marginTop = `${merged.spaceBeforePct * effectiveFontSize}pt`
}
if (merged.spaceAfter !== undefined) {
paraDiv.style.marginBottom = `${merged.spaceAfter}pt`
} else if (merged.spaceAfterPct !== undefined) {
paraDiv.style.marginBottom = `${merged.spaceAfterPct * effectiveFontSize}pt`
}
// ---- Bullets ----
// Suppress bullets for metadata placeholders (slide number, date, footer)
// Also suppress for empty paragraphs (no visible runs) — PowerPoint never shows bullets for them
const hasVisibleRuns = paragraph.runs.some((r) => r.text != null && r.text.length > 0)
const suppressBullet =
!hasVisibleRuns ||
placeholder?.type === 'sldNum' ||
placeholder?.type === 'dt' ||
placeholder?.type === 'ftr' ||
placeholder?.type === 'title' ||
placeholder?.type === 'ctrTitle' ||
placeholder?.type === 'subTitle'
let bulletPrefix = ''
if (!suppressBullet && merged.bulletNone !== true) {
if (merged.bulletChar) {
bulletPrefix = merged.bulletChar
} else if (merged.bulletAutoNum) {
bulletPrefix = generateAutoNumber(merged.bulletAutoNum, bulletCounter)
bulletCounter++
}
}
if (bulletPrefix) {
const bulletSpan = document.createElement('span')
bulletSpan.textContent = `${bulletPrefix} `
if (merged.bulletFont) {
bulletSpan.style.fontFamily = merged.bulletFont
}
// Bullet color: 1) explicit buClr from list style, 2) paragraph defRPr, 3) first run's color (so bullet matches text), 4) cell/fallback
let bulletColor: string | undefined
if (merged.bulletColorNode?.exists()) {
bulletColor = resolveColorToCss(merged.bulletColorNode, ctx)
}
if (bulletColor === undefined && merged.defRPr && merged.defRPr.exists()) {
const bulletRunStyle: MergedRunStyle = {}
mergeRunProps(bulletRunStyle, merged.defRPr, ctx)
bulletColor = bulletRunStyle.color
}
if (bulletColor === undefined && paragraph.runs.length > 0) {
const runStyle: MergedRunStyle = {}
if (merged.defRPr) mergeRunProps(runStyle, merged.defRPr, ctx)
if (paragraph.runs[0].properties) mergeRunProps(runStyle, paragraph.runs[0].properties, ctx)
bulletColor = runStyle.color
}
// Fallback: check shape's lstStyle defRPr for color (same as run fallback)
if (bulletColor === undefined && textBody.listStyle) {
const lstStyleLevel = findStyleAtLevel(textBody.listStyle, level)
if (lstStyleLevel.exists()) {
const lstDefRPr = lstStyleLevel.child('defRPr')
if (lstDefRPr.exists()) {
const fallbackStyle: MergedRunStyle = {}
mergeRunProps(fallbackStyle, lstDefRPr, ctx)
if (fallbackStyle.color !== undefined) {
bulletColor = fallbackStyle.color
}
}
}
}
bulletSpan.style.color =
bulletColor ?? options?.fontRefColor ?? options?.cellTextColor ?? '#000000'
paraDiv.appendChild(bulletSpan)
}
// ---- Render runs ----
if (paragraph.runs.length === 0) {
// Empty paragraph — still need to maintain spacing
paraDiv.appendChild(document.createElement('br'))
}
// When line spacing is absolute (spcPts) and paragraph has line breaks,
// wrap each line in a block-level div with explicit height. This ensures
// exact spacing regardless of font metrics (CJK fonts e.g. Microsoft YaHei have
// content areas taller than font-size, causing CSS line-height to be
// overridden by the font's natural spacing).
const hasLineBreaks = paragraph.runs.some((r) => r.text === '\n')
// Set tab-size when paragraph contains tab characters (default OOXML tab spacing = 914400 EMU = 96px)
if (paragraph.runs.some((r) => r.text?.includes('\t'))) {
const defaultTabPx = 96 // 914400 EMU at 96 dpi
paraDiv.style.tabSize = `${defaultTabPx}px`
}
const useLineWrappers = merged.lineHeightAbsolute && hasLineBreaks && effectiveLineHeight
let currentLineDiv: HTMLElement | null = null
if (useLineWrappers) {
currentLineDiv = document.createElement('div')
currentLineDiv.style.height = effectiveLineHeight!
currentLineDiv.style.overflow = 'visible'
paraDiv.appendChild(currentLineDiv)
}
for (const run of paragraph.runs) {
if (run.text === '\n') {
if (useLineWrappers) {
// Close current line div and start a new one
currentLineDiv = document.createElement('div')
currentLineDiv.style.height = effectiveLineHeight!
currentLineDiv.style.overflow = 'visible'
paraDiv.appendChild(currentLineDiv)
} else {
paraDiv.appendChild(document.createElement('br'))
}
continue
}
// Build merged run style
const runStyle: MergedRunStyle = {}
// Apply default run properties from merged paragraph defRPr
if (merged.defRPr) {
mergeRunProps(runStyle, merged.defRPr, ctx)
}
// Level 7: run rPr
if (run.properties) {
mergeRunProps(runStyle, run.properties, ctx)
}
// Fallback: if no color resolved yet, check the shape's lstStyle defRPr.
// This handles the case where paragraph pPr has an empty <a:defRPr/> that
// overwrites the lstStyle's defRPr (which may carry solidFill color).
if (runStyle.color === undefined && textBody.listStyle) {
const lstStyleLevel = findStyleAtLevel(textBody.listStyle, level)
if (lstStyleLevel.exists()) {
const lstDefRPr = lstStyleLevel.child('defRPr')
if (lstDefRPr.exists()) {
const fallbackStyle: MergedRunStyle = {}
mergeRunProps(fallbackStyle, lstDefRPr, ctx)
if (fallbackStyle.color !== undefined) {
runStyle.color = fallbackStyle.color
}
}
}
}
// Determine if this should be a link
let element: HTMLElement
if (runStyle.hlinkClick) {
const a = document.createElement('a')
a.href = runStyle.hlinkClick
a.target = '_blank'
a.rel = 'noopener noreferrer'
element = a
} else {
element = document.createElement('span')
}
// Preserve consecutive spaces by alternating with &nbsp; so they survive
// HTML whitespace collapse without being stretched by text-align:justify.
// Tabs still need white-space:pre for tab-stop rendering.
if (run.text?.includes('\t')) {
element.textContent = run.text
element.style.whiteSpace = 'pre'
} else if (run.text && / {2}/.test(run.text)) {
// Replace pairs of spaces with " &nbsp;" so browsers cannot collapse them,
// while normal spaces between words remain stretchable for justify.
const escaped = run.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/ {2}/g, ' \u00a0')
element.innerHTML = escaped
} else {
element.textContent = run.text
}
// Apply run styles (with normAutofit fontScale)
// Default to 12pt if no font size specified at any inheritance level
const fontSize = runStyle.fontSize || 12
element.style.fontSize = `${fontSize * fontScale}pt`
// Bold: explicit run rPr > cellTextBold (table style tcTxStyle) > inherited styles
const hasExplicitRunBold = run.properties?.attr('b') !== undefined
if (hasExplicitRunBold ? runStyle.bold : (options?.cellTextBold ?? runStyle.bold)) {
element.style.fontWeight = 'bold'
}
// Italic: explicit run rPr > cellTextItalic (table style tcTxStyle) > inherited styles
const hasExplicitRunItalic = run.properties?.attr('i') !== undefined
if (hasExplicitRunItalic ? runStyle.italic : (options?.cellTextItalic ?? runStyle.italic)) {
element.style.fontStyle = 'italic'
}
const decorations: string[] = []
if (runStyle.underline) decorations.push('underline')
if (runStyle.strikethrough) decorations.push('line-through')
if (decorations.length > 0) {
element.style.textDecoration = decorations.join(' ')
}
// Color priority: explicit run rPr > hlink theme color > cellTextColor (table style tcTxStyle) > fontRef (shape style) > inherited styles > black default
// cellTextColor from table style overrides inherited cascade colors but yields to explicit run/paragraph solidFill/gradFill.
// fontRefColor overrides inherited styles but yields to explicit run solidFill/gradFill.
const hasExplicitRunColor =
run.properties?.child('solidFill').exists() || run.properties?.child('gradFill').exists()
let effectiveColor: string | undefined
if (options?.fontRefColor) {
effectiveColor = hasExplicitRunColor ? runStyle.color : options.fontRefColor
} else if (options?.cellTextColor && !hasExplicitRunColor) {
effectiveColor = options.cellTextColor
} else {
effectiveColor = runStyle.color
}
// Hyperlink default color: when the run is a hyperlink and has no explicit
// solidFill on its own rPr, use the theme's hlink color. This matches
// PowerPoint behaviour where hyperlink text defaults to the hlink scheme color.
if (runStyle.hlinkClick && !hasExplicitRunColor) {
const hlinkHex = ctx.theme.colorScheme.get('hlink')
if (hlinkHex) {
effectiveColor = hlinkHex.startsWith('#') ? hlinkHex : `#${hlinkHex}`
}
}
if (effectiveColor) {
element.style.color = effectiveColor
} else {
// No explicit color from run/paragraph/style: use black so text does not inherit page CSS (e.g. body { color: #e0e0e0 })
element.style.color = '#000000'
}
// Gradient text fill: use background-clip to paint text with gradient
if (runStyle.textGradientCss) {
element.style.background = runStyle.textGradientCss
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitBackgroundClip = 'text'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).backgroundClip = 'text'
element.style.color = 'transparent'
}
// Text outline (a:ln on rPr) and noFill handling
if (runStyle.textNoFill || runStyle.textOutlineWidth) {
const strokeW = runStyle.textOutlineWidth ?? 0.75
if (runStyle.textNoFill && runStyle.textOutlineGradientCss) {
// Ghost text: no fill + gradient outline → show outline fading via mask
const outlineColor = '#ffffff' // base stroke color (gradient applied via mask)
element.style.color = 'transparent'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = outlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
// Use mask-image to apply the gradient fade to the entire text element
const maskGrad = runStyle.textOutlineGradientCss
element.style.maskImage = maskGrad
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitMaskImage = maskGrad
} else if (runStyle.textNoFill && runStyle.textOutlineColor) {
// Ghost text with solid outline
element.style.color = 'transparent'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = runStyle.textOutlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
} else if (runStyle.textNoFill) {
// noFill with no outline — invisible text (but keep space)
element.style.color = 'transparent'
} else if (runStyle.textOutlineColor) {
// Outline with normal fill
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = runStyle.textOutlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
}
}
// Font family: explicit run rPr > cellTextFontFamily (table style) > inherited > theme fallback
const hasExplicitRunFont =
run.properties?.child('latin').exists() ||
run.properties?.child('ea').exists() ||
run.properties?.child('cs').exists()
const effectiveFont = hasExplicitRunFont
? runStyle.fontFamily
: (options?.cellTextFontFamily ?? runStyle.fontFamily)
if (effectiveFont) {
element.style.fontFamily = `"${effectiveFont}"`
} else {
// Fallback to theme minor font
const fallback = ctx.theme.minorFont.latin || ctx.theme.minorFont.ea
if (fallback) {
element.style.fontFamily = `"${fallback}"`
}
}
// Character spacing (a:spc) — compact/tracking in points
if (runStyle.letterSpacingPt !== undefined) {
element.style.letterSpacing = `${runStyle.letterSpacingPt}pt`
}
// Kerning (a:kern): val = min font size (pt) to kern; 0 = always kern
if (runStyle.kern !== undefined) {
const effectivePt = (runStyle.fontSize || 12) * fontScale
element.style.fontKerning = effectivePt >= runStyle.kern ? 'normal' : 'none'
}
// Text capitalization (a:rPr@cap)
if (runStyle.cap === 'all') {
element.style.textTransform = 'uppercase'
} else if (runStyle.cap === 'small') {
element.style.fontVariant = 'small-caps'
}
// Baseline shift (superscript/subscript)
if (runStyle.baseline !== undefined && runStyle.baseline !== 0) {
// OOXML baseline is in 1000ths of percent; positive = superscript, negative = subscript
const shiftPct = runStyle.baseline / 1000
element.style.verticalAlign = `${shiftPct}%`
// Reduce font size for super/subscript
if (Math.abs(shiftPct) >= 20) {
element.style.fontSize = `${fontSize * fontScale * 0.65}pt`
}
}
// Append to the current line wrapper (when using absolute line spacing)
// or directly to the paragraph div
const appendTarget = currentLineDiv ?? paraDiv
appendTarget.appendChild(element)
}
// endParaRPr: when the paragraph ends with a line break (trailing \n),
// the end-of-paragraph mark (endParaRPr) defines the font size for the
// trailing blank line. Without this, bottom-anchored text boxes render
// content too low because the trailing space is too small.
if (paragraph.endParaRPr) {
const lastRun = paragraph.runs[paragraph.runs.length - 1]
if (lastRun?.text === '\n') {
const epSz = paragraph.endParaRPr.numAttr('sz')
if (epSz !== undefined) {
const spacer = document.createElement('span')
spacer.textContent = '\u200B' // zero-width space to maintain line height
spacer.style.fontSize = `${(epSz / 100) * fontScale}pt`
const target = currentLineDiv ?? paraDiv
target.appendChild(spacer)
}
}
}
container.appendChild(paraDiv)
}
}