chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
|
||||
import { buildPresentation, type PresentationData } from '../model/presentation'
|
||||
import type { ZipParseLimits } from '../parser/zip-parser'
|
||||
import { parseZip } from '../parser/zip-parser'
|
||||
import type { SlideHandle } from '../renderer/slide-renderer'
|
||||
import { renderSlide as renderSlideInternal } from '../renderer/slide-renderer'
|
||||
|
||||
export type { SlideHandle } from '../renderer/slide-renderer'
|
||||
|
||||
export type FitMode = 'contain' | 'none'
|
||||
|
||||
export type PreviewInput = ArrayBuffer | Uint8Array | Blob
|
||||
|
||||
export interface ViewerOptions {
|
||||
width?: number
|
||||
/** Scaling mode. contain = fit container width, none = use intrinsic slide size. */
|
||||
fitMode?: FitMode
|
||||
/** Initial zoom percentage. Effective scale = fitScale * zoomPercent/100. */
|
||||
zoomPercent?: number
|
||||
/**
|
||||
* Scroll container element used as IntersectionObserver root in list mode
|
||||
* (both windowed mounting and scroll-based slide tracking).
|
||||
* When omitted, the viewport (null root) is used.
|
||||
*/
|
||||
scrollContainer?: HTMLElement
|
||||
/** Optional ZIP parsing limits for controlling resource usage and DoS surface. */
|
||||
zipLimits?: ZipParseLimits
|
||||
onSlideChange?: (index: number) => void
|
||||
onSlideRendered?: (index: number, element: HTMLElement) => void
|
||||
onSlideError?: (index: number, error: unknown) => void
|
||||
onSlideUnmounted?: (index: number) => void
|
||||
onNodeError?: (nodeId: string, error: unknown) => void
|
||||
onRenderStart?: () => void
|
||||
onRenderComplete?: () => void
|
||||
}
|
||||
|
||||
export interface ListRenderOptions {
|
||||
windowed?: boolean
|
||||
batchSize?: number
|
||||
initialSlides?: number
|
||||
overscanViewport?: number
|
||||
/** Show "Slide N" labels below each slide. Default `false`. */
|
||||
showSlideLabels?: boolean
|
||||
}
|
||||
|
||||
export interface PptxViewerEventMap {
|
||||
renderstart: Event
|
||||
rendercomplete: Event
|
||||
slidechange: CustomEvent<{ index: number }>
|
||||
sliderendered: CustomEvent<{ index: number; element: HTMLElement }>
|
||||
slideerror: CustomEvent<{ index: number; error: unknown }>
|
||||
slideunmounted: CustomEvent<{ index: number }>
|
||||
nodeerror: CustomEvent<{ nodeId: string; error: unknown }>
|
||||
}
|
||||
|
||||
export class PptxViewer extends EventTarget {
|
||||
protected container: HTMLElement
|
||||
private viewerOptions: ViewerOptions
|
||||
private presentation: PresentationData | null = null
|
||||
private mediaUrlCache = new Map<string, string>()
|
||||
private chartInstances = new Set<ECharts>()
|
||||
private currentSlide = 0
|
||||
private _fitMode: FitMode
|
||||
private _isRendering = false
|
||||
private zoomFactor = 1
|
||||
private renderChain: Promise<void> = Promise.resolve()
|
||||
private cleanupListMount?: () => void
|
||||
private cleanupScrollObserver?: () => void
|
||||
private suppressScrollChange = false
|
||||
private ensureListSlideMountedFn?: (index: number) => void
|
||||
private resizeObserver?: ResizeObserver
|
||||
private windowResizeHandler?: () => void
|
||||
private resizeRafId: number | null = null
|
||||
private lastMeasuredContainerWidth = 0
|
||||
private mountedSlides = new Set<number>()
|
||||
private slideHandles = new Map<number, SlideHandle>()
|
||||
private activeRenderMode: 'list' | 'slide' | null = null
|
||||
private listOptions: Required<ListRenderOptions> = {
|
||||
windowed: false,
|
||||
batchSize: 12,
|
||||
initialSlides: 4,
|
||||
overscanViewport: 1.5,
|
||||
showSlideLabels: false,
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, options?: ViewerOptions) {
|
||||
super()
|
||||
this.container = container
|
||||
this.viewerOptions = options ?? {}
|
||||
const zoomPercent = this.normalizeZoomPercent(options?.zoomPercent ?? 100)
|
||||
this._fitMode = options?.fitMode ?? 'contain'
|
||||
this.zoomFactor = zoomPercent / 100
|
||||
|
||||
// Register shorthand callbacks as event listeners
|
||||
if (options?.onSlideChange) {
|
||||
const cb = options.onSlideChange
|
||||
this.addEventListener('slidechange', ((e: CustomEvent) =>
|
||||
cb(e.detail.index)) as EventListener)
|
||||
}
|
||||
if (options?.onSlideRendered) {
|
||||
const cb = options.onSlideRendered
|
||||
this.addEventListener('sliderendered', ((e: CustomEvent) =>
|
||||
cb(e.detail.index, e.detail.element)) as EventListener)
|
||||
}
|
||||
if (options?.onSlideError) {
|
||||
const cb = options.onSlideError
|
||||
this.addEventListener('slideerror', ((e: CustomEvent) =>
|
||||
cb(e.detail.index, e.detail.error)) as EventListener)
|
||||
}
|
||||
if (options?.onSlideUnmounted) {
|
||||
const cb = options.onSlideUnmounted
|
||||
this.addEventListener('slideunmounted', ((e: CustomEvent) =>
|
||||
cb(e.detail.index)) as EventListener)
|
||||
}
|
||||
if (options?.onNodeError) {
|
||||
const cb = options.onNodeError
|
||||
this.addEventListener('nodeerror', ((e: CustomEvent) =>
|
||||
cb(e.detail.nodeId, e.detail.error)) as EventListener)
|
||||
}
|
||||
if (options?.onRenderStart) {
|
||||
const cb = options.onRenderStart
|
||||
this.addEventListener('renderstart', () => cb())
|
||||
}
|
||||
if (options?.onRenderComplete) {
|
||||
const cb = options.onRenderComplete
|
||||
this.addEventListener('rendercomplete', () => cb())
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event dispatch helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private emitRenderStart(): void {
|
||||
this._isRendering = true
|
||||
this.dispatchEvent(new Event('renderstart'))
|
||||
}
|
||||
|
||||
private emitRenderComplete(): void {
|
||||
this._isRendering = false
|
||||
this.dispatchEvent(new Event('rendercomplete'))
|
||||
}
|
||||
|
||||
private emitSlideChange(index: number): void {
|
||||
this.dispatchEvent(new CustomEvent('slidechange', { detail: { index } }))
|
||||
}
|
||||
|
||||
private emitSlideRendered(index: number, element: HTMLElement): void {
|
||||
this.dispatchEvent(new CustomEvent('sliderendered', { detail: { index, element } }))
|
||||
}
|
||||
|
||||
private emitSlideError(index: number, error: unknown): void {
|
||||
this.dispatchEvent(new CustomEvent('slideerror', { detail: { index, error } }))
|
||||
}
|
||||
|
||||
private emitSlideUnmounted(index: number): void {
|
||||
this.dispatchEvent(new CustomEvent('slideunmounted', { detail: { index } }))
|
||||
}
|
||||
|
||||
private emitNodeError(nodeId: string, error: unknown): void {
|
||||
this.dispatchEvent(new CustomEvent('nodeerror', { detail: { nodeId, error } }))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public: load / render modes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load a parsed presentation model. Does NOT render — call `renderList()` or
|
||||
* `renderSlide()` afterwards.
|
||||
*/
|
||||
load(presentation: PresentationData): void {
|
||||
this.presentation = presentation
|
||||
this.setupAdaptiveResize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all slides in a scrollable list.
|
||||
*/
|
||||
async renderList(options?: ListRenderOptions): Promise<void> {
|
||||
this.activeRenderMode = 'list'
|
||||
this.listOptions = {
|
||||
windowed: options?.windowed ?? false,
|
||||
batchSize: this.normalizeBatchSize(options?.batchSize ?? 12),
|
||||
initialSlides: this.normalizePositiveInt(options?.initialSlides ?? 4, 4),
|
||||
overscanViewport: this.normalizePositiveFloat(options?.overscanViewport ?? 1.5, 1.5),
|
||||
showSlideLabels: options?.showSlideLabels ?? false,
|
||||
}
|
||||
await this.queueRender()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single slide (no built-in nav UI).
|
||||
*/
|
||||
async renderSlide(index?: number): Promise<void> {
|
||||
this.activeRenderMode = 'slide'
|
||||
if (index !== undefined && this.presentation) {
|
||||
this.currentSlide = Math.max(0, Math.min(index, this.presentation.slides.length - 1))
|
||||
}
|
||||
await this.queueRender()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Instance open
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async open(
|
||||
input: PreviewInput,
|
||||
options?: {
|
||||
renderMode?: 'list' | 'slide'
|
||||
listOptions?: ListRenderOptions
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<void> {
|
||||
const signal = options?.signal
|
||||
const checkAborted = () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Preview aborted', 'AbortError')
|
||||
}
|
||||
}
|
||||
|
||||
checkAborted()
|
||||
|
||||
// Clean up previous state
|
||||
this.destroy()
|
||||
|
||||
const buffer = await normalizePreviewInput(input)
|
||||
checkAborted()
|
||||
|
||||
const files = await parseZip(buffer, this.viewerOptions.zipLimits)
|
||||
checkAborted()
|
||||
|
||||
const presentation = buildPresentation(files)
|
||||
checkAborted()
|
||||
|
||||
this.load(presentation)
|
||||
|
||||
const renderMode = options?.renderMode ?? 'list'
|
||||
if (renderMode === 'slide') {
|
||||
await this.renderSlide(0)
|
||||
} else {
|
||||
await this.renderList(options?.listOptions)
|
||||
}
|
||||
|
||||
checkAborted()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Static factory
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static async open(
|
||||
input: PreviewInput,
|
||||
container: HTMLElement,
|
||||
options?: ViewerOptions & {
|
||||
renderMode?: 'list' | 'slide'
|
||||
listOptions?: ListRenderOptions
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PptxViewer> {
|
||||
const viewer = new PptxViewer(container, options)
|
||||
await viewer.open(input, {
|
||||
renderMode: options?.renderMode,
|
||||
listOptions: options?.listOptions,
|
||||
signal: options?.signal,
|
||||
})
|
||||
return viewer
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async goToSlide(index: number, scrollOptions?: ScrollIntoViewOptions): Promise<void> {
|
||||
if (!this.presentation) return
|
||||
const prev = this.currentSlide
|
||||
this.currentSlide = Math.max(0, Math.min(index, this.presentation.slides.length - 1))
|
||||
if (this.currentSlide !== prev) {
|
||||
this.emitSlideChange(this.currentSlide)
|
||||
}
|
||||
if (this.activeRenderMode === 'slide') {
|
||||
const { scale, displayWidth, displayHeight } = this.getDisplayMetrics()
|
||||
this.renderSingleSlide(scale, displayWidth, displayHeight)
|
||||
} else {
|
||||
this.suppressScrollChange = true
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => {
|
||||
this.suppressScrollChange = false
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
this.ensureListSlideMountedFn?.(this.currentSlide)
|
||||
const targetChild = this.container.querySelector<HTMLElement>(
|
||||
`[data-slide-index="${this.currentSlide}"]`
|
||||
)
|
||||
if (targetChild) {
|
||||
targetChild.scrollIntoView(scrollOptions ?? { behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setZoom(percent: number): Promise<void> {
|
||||
const normalized = this.normalizeZoomPercent(percent)
|
||||
const nextFactor = normalized / 100
|
||||
if (nextFactor === this.zoomFactor) return
|
||||
this.zoomFactor = nextFactor
|
||||
await this.queueRender()
|
||||
}
|
||||
|
||||
async setFitMode(mode: FitMode): Promise<void> {
|
||||
if (this._fitMode === mode) return
|
||||
this._fitMode = mode
|
||||
if (mode === 'none') {
|
||||
this.lastMeasuredContainerWidth = 0
|
||||
}
|
||||
await this.queueRender()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Getters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
get presentationData(): PresentationData | null {
|
||||
return this.presentation
|
||||
}
|
||||
|
||||
get slideCount(): number {
|
||||
return this.presentation?.slides.length ?? 0
|
||||
}
|
||||
|
||||
get slideWidth(): number {
|
||||
return this.presentation?.width ?? 0
|
||||
}
|
||||
|
||||
get slideHeight(): number {
|
||||
return this.presentation?.height ?? 0
|
||||
}
|
||||
|
||||
get currentSlideIndex(): number {
|
||||
return this.currentSlide
|
||||
}
|
||||
|
||||
get isRendering(): boolean {
|
||||
return this._isRendering
|
||||
}
|
||||
|
||||
get zoomPercent(): number {
|
||||
return this.zoomFactor * 100
|
||||
}
|
||||
|
||||
get fitMode(): FitMode {
|
||||
return this._fitMode
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Typed event helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
on<K extends keyof PptxViewerEventMap>(
|
||||
type: K,
|
||||
listener: (event: PptxViewerEventMap[K]) => void
|
||||
): this {
|
||||
this.addEventListener(type, listener as EventListener)
|
||||
return this
|
||||
}
|
||||
|
||||
off<K extends keyof PptxViewerEventMap>(
|
||||
type: K,
|
||||
listener: (event: PptxViewerEventMap[K]) => void
|
||||
): this {
|
||||
this.removeEventListener(type, listener as EventListener)
|
||||
return this
|
||||
}
|
||||
|
||||
isSlideMounted(index: number): boolean {
|
||||
return this.mountedSlides.has(index)
|
||||
}
|
||||
|
||||
getMountedSlides(): number[] {
|
||||
return [...this.mountedSlides].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// External slide rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a single slide into an external container element.
|
||||
* Useful for React/Vue integration, thumbnail generation, etc.
|
||||
*
|
||||
* **Ownership:** The caller owns the returned {@link SlideHandle} and is
|
||||
* responsible for calling `handle.dispose()` when the slide is no longer
|
||||
* needed. `destroy()` does NOT automatically dispose externally-rendered
|
||||
* handles.
|
||||
*/
|
||||
renderSlideToContainer(
|
||||
index: number,
|
||||
container: HTMLElement,
|
||||
scale?: number
|
||||
): SlideHandle | null {
|
||||
if (!this.presentation) return null
|
||||
const slide = this.presentation.slides[index]
|
||||
if (!slide) return null
|
||||
|
||||
const handle = renderSlideInternal(this.presentation, slide, {
|
||||
onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error),
|
||||
onNavigate: (target) => this.handleNavigate(target),
|
||||
mediaUrlCache: this.mediaUrlCache,
|
||||
chartInstances: this.chartInstances,
|
||||
})
|
||||
|
||||
if (scale !== undefined && scale !== 1) {
|
||||
handle.element.style.transform = `scale(${scale})`
|
||||
handle.element.style.transformOrigin = 'top left'
|
||||
}
|
||||
|
||||
container.appendChild(handle.element)
|
||||
this.emitSlideRendered(index, handle.element)
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called after rendering a single slide. Override in subclasses to
|
||||
* append additional UI (e.g. navigation buttons).
|
||||
*/
|
||||
protected afterSingleSlideRender(): void {
|
||||
// No-op in base class
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
destroy(): void {
|
||||
this.teardownAdaptiveResize()
|
||||
this.cleanupScrollObserver?.()
|
||||
this.cleanupScrollObserver = undefined
|
||||
this.cleanupListMount?.()
|
||||
this.cleanupListMount = undefined
|
||||
this.ensureListSlideMountedFn = undefined
|
||||
this.mountedSlides.clear()
|
||||
for (const handle of this.slideHandles.values()) {
|
||||
handle.dispose()
|
||||
}
|
||||
this.slideHandles.clear()
|
||||
this.disposeAllCharts()
|
||||
for (const url of this.mediaUrlCache.values()) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
this.mediaUrlCache.clear()
|
||||
this.container.innerHTML = ''
|
||||
this.presentation = null
|
||||
this.activeRenderMode = null
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: rendering pipeline
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private normalizeZoomPercent(percent: number): number {
|
||||
if (!Number.isFinite(percent)) return 100
|
||||
return Math.max(10, Math.min(400, percent))
|
||||
}
|
||||
|
||||
private normalizeBatchSize(val: number): number {
|
||||
return Number.isInteger(val) && val > 0 ? val : 12
|
||||
}
|
||||
|
||||
private normalizePositiveInt(val: number, fallback: number): number {
|
||||
return Number.isInteger(val) && val > 0 ? val : fallback
|
||||
}
|
||||
|
||||
private normalizePositiveFloat(val: number, fallback: number): number {
|
||||
return Number.isFinite(val) && val > 0 ? val : fallback
|
||||
}
|
||||
|
||||
private getDisplayMetrics(): { scale: number; displayWidth: number; displayHeight: number } {
|
||||
if (!this.presentation) {
|
||||
return { scale: 1, displayWidth: 0, displayHeight: 0 }
|
||||
}
|
||||
const fitWidth = this.viewerOptions.width ?? (this.container.clientWidth || 960)
|
||||
if (this._fitMode === 'contain' && this.viewerOptions.width === undefined) {
|
||||
this.lastMeasuredContainerWidth = fitWidth
|
||||
}
|
||||
const fitScale = this._fitMode === 'contain' ? fitWidth / this.presentation.width : 1
|
||||
const scale = fitScale * this.zoomFactor
|
||||
return {
|
||||
scale,
|
||||
displayWidth: this.presentation.width * scale,
|
||||
displayHeight: this.presentation.height * scale,
|
||||
}
|
||||
}
|
||||
|
||||
private async queueRender(): Promise<void> {
|
||||
this.renderChain = this.renderChain.then(async () => {
|
||||
if (!this.presentation) return
|
||||
this.emitRenderStart()
|
||||
try {
|
||||
const { scale, displayWidth, displayHeight } = this.getDisplayMetrics()
|
||||
|
||||
this.cleanupScrollObserver?.()
|
||||
this.cleanupScrollObserver = undefined
|
||||
this.cleanupListMount?.()
|
||||
this.cleanupListMount = undefined
|
||||
this.ensureListSlideMountedFn = undefined
|
||||
this.mountedSlides.clear()
|
||||
for (const handle of this.slideHandles.values()) {
|
||||
handle.dispose()
|
||||
}
|
||||
this.slideHandles.clear()
|
||||
this.disposeAllCharts()
|
||||
this.container.innerHTML = ''
|
||||
this.container.style.position = 'relative'
|
||||
|
||||
if (this.activeRenderMode === 'slide') {
|
||||
this.renderSingleSlide(scale, displayWidth, displayHeight)
|
||||
} else if (this.listOptions.windowed) {
|
||||
await this.renderAllSlidesWindowed(scale, displayWidth, displayHeight)
|
||||
} else {
|
||||
await this.renderAllSlidesFull(scale, displayWidth, displayHeight)
|
||||
}
|
||||
|
||||
// Post-render width correction: appending slides may cause a scrollbar
|
||||
// to appear on the page, narrowing the container. If the measured width
|
||||
// changed, patch wrapper sizes and scale transforms in-place so content
|
||||
// is not clipped by the (now narrower) container.
|
||||
if (this.activeRenderMode !== 'slide') {
|
||||
this.correctListMetricsIfNeeded()
|
||||
}
|
||||
|
||||
this.emitSlideChange(this.currentSlide)
|
||||
} finally {
|
||||
this.emitRenderComplete()
|
||||
}
|
||||
})
|
||||
return this.renderChain
|
||||
}
|
||||
|
||||
private handleContainerResize(): void {
|
||||
if (!this.presentation) return
|
||||
if (this._fitMode !== 'contain') return
|
||||
if (this.viewerOptions.width !== undefined) return
|
||||
|
||||
const nextWidth = this.container.clientWidth || 0
|
||||
if (!nextWidth || nextWidth === this.lastMeasuredContainerWidth) return
|
||||
this.lastMeasuredContainerWidth = nextWidth
|
||||
|
||||
if (this.resizeRafId !== null) {
|
||||
cancelAnimationFrame(this.resizeRafId)
|
||||
}
|
||||
this.resizeRafId = requestAnimationFrame(() => {
|
||||
this.resizeRafId = null
|
||||
void this.queueRender()
|
||||
})
|
||||
}
|
||||
|
||||
private setupAdaptiveResize(): void {
|
||||
this.teardownAdaptiveResize()
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(() => this.handleContainerResize())
|
||||
observer.observe(this.container)
|
||||
this.resizeObserver = observer
|
||||
return
|
||||
}
|
||||
|
||||
this.windowResizeHandler = () => this.handleContainerResize()
|
||||
window.addEventListener('resize', this.windowResizeHandler)
|
||||
}
|
||||
|
||||
private teardownAdaptiveResize(): void {
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = undefined
|
||||
if (this.windowResizeHandler) {
|
||||
window.removeEventListener('resize', this.windowResizeHandler)
|
||||
this.windowResizeHandler = undefined
|
||||
}
|
||||
if (this.resizeRafId !== null) {
|
||||
cancelAnimationFrame(this.resizeRafId)
|
||||
this.resizeRafId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield to the event loop between render batches so the browser can paint.
|
||||
*
|
||||
* We intentionally do NOT rely solely on `requestAnimationFrame`: rAF callbacks
|
||||
* are paused while the document is hidden (backgrounded tab). A render kicked
|
||||
* off in that state would otherwise stall forever on the batch yield and the
|
||||
* `open()` promise would never settle (perma-loading preview). The `setTimeout`
|
||||
* fallback fires regardless of visibility, so the render always completes —
|
||||
* matching the server-side renderer, which has no visibility dependency.
|
||||
*/
|
||||
private yieldToNextFrame(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(finish)
|
||||
}
|
||||
setTimeout(finish, 32)
|
||||
})
|
||||
}
|
||||
|
||||
private disposeAllCharts(): void {
|
||||
for (const chart of this.chartInstances) {
|
||||
if (!chart.isDisposed()) {
|
||||
chart.dispose()
|
||||
}
|
||||
}
|
||||
this.chartInstances.clear()
|
||||
}
|
||||
|
||||
private createListSlideItem(
|
||||
index: number,
|
||||
displayWidth: number,
|
||||
displayHeight: number
|
||||
): { item: HTMLDivElement; wrapper: HTMLDivElement } {
|
||||
const item = document.createElement('div')
|
||||
item.dataset.slideIndex = String(index)
|
||||
item.style.cssText = 'width: fit-content; margin: 0 auto 20px;'
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.cssText = `
|
||||
width: ${displayWidth}px;
|
||||
height: ${displayHeight}px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
`
|
||||
|
||||
item.appendChild(wrapper)
|
||||
|
||||
if (this.listOptions.showSlideLabels) {
|
||||
const label = document.createElement('div')
|
||||
label.style.cssText = 'text-align: center; padding: 4px; font-size: 12px; color: #666;'
|
||||
label.textContent = `Slide ${index + 1}`
|
||||
item.appendChild(label)
|
||||
}
|
||||
return { item, wrapper }
|
||||
}
|
||||
|
||||
private mountListSlide(
|
||||
index: number,
|
||||
wrapper: HTMLDivElement,
|
||||
scale: number,
|
||||
_displayWidth: number,
|
||||
_displayHeight: number
|
||||
): void {
|
||||
if (!this.presentation) return
|
||||
if (wrapper.dataset.mounted === '1') return
|
||||
wrapper.dataset.mounted = '1'
|
||||
wrapper.innerHTML = ''
|
||||
this.mountedSlides.add(index)
|
||||
|
||||
const slide = this.presentation.slides[index]
|
||||
try {
|
||||
const handle = renderSlideInternal(this.presentation, slide, {
|
||||
onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error),
|
||||
onNavigate: (target) => this.handleNavigate(target),
|
||||
mediaUrlCache: this.mediaUrlCache,
|
||||
chartInstances: this.chartInstances,
|
||||
})
|
||||
|
||||
this.slideHandles.set(index, handle)
|
||||
handle.element.style.transform = `scale(${scale})`
|
||||
handle.element.style.transformOrigin = 'top left'
|
||||
wrapper.appendChild(handle.element)
|
||||
this.emitSlideRendered(index, handle.element)
|
||||
} catch (e) {
|
||||
this.emitSlideError(index, e)
|
||||
wrapper.style.background = '#fff3f3'
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.justifyContent = 'center'
|
||||
wrapper.style.border = '2px dashed #ff6b6b'
|
||||
wrapper.style.color = '#cc0000'
|
||||
wrapper.style.fontSize = '14px'
|
||||
wrapper.textContent = `Slide ${index + 1}: Render Error - ${getErrorMessage(e)}`
|
||||
}
|
||||
}
|
||||
|
||||
private unmountListSlide(index: number, wrapper: HTMLDivElement, displayHeight: number): void {
|
||||
if (wrapper.dataset.mounted !== '1') return
|
||||
wrapper.dataset.mounted = '0'
|
||||
this.mountedSlides.delete(index)
|
||||
const handle = this.slideHandles.get(index)
|
||||
if (handle) {
|
||||
handle.dispose()
|
||||
this.slideHandles.delete(index)
|
||||
}
|
||||
wrapper.innerHTML = ''
|
||||
wrapper.style.background = '#fff'
|
||||
wrapper.style.display = ''
|
||||
wrapper.style.alignItems = ''
|
||||
wrapper.style.justifyContent = ''
|
||||
wrapper.style.border = ''
|
||||
wrapper.style.color = ''
|
||||
wrapper.style.fontSize = ''
|
||||
wrapper.style.height = `${displayHeight}px`
|
||||
this.emitSlideUnmounted(index)
|
||||
}
|
||||
|
||||
private async renderAllSlidesFull(
|
||||
scale: number,
|
||||
displayWidth: number,
|
||||
displayHeight: number
|
||||
): Promise<void> {
|
||||
if (!this.presentation) return
|
||||
const batchSize = this.listOptions.batchSize
|
||||
let batchFragment = document.createDocumentFragment()
|
||||
|
||||
for (let i = 0; i < this.presentation.slides.length; i++) {
|
||||
const { item, wrapper } = this.createListSlideItem(i, displayWidth, displayHeight)
|
||||
this.mountListSlide(i, wrapper, scale, displayWidth, displayHeight)
|
||||
batchFragment.appendChild(item)
|
||||
|
||||
if ((i + 1) % batchSize === 0) {
|
||||
this.container.appendChild(batchFragment)
|
||||
batchFragment = document.createDocumentFragment()
|
||||
await this.yieldToNextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
if (batchFragment.childNodes.length > 0) {
|
||||
this.container.appendChild(batchFragment)
|
||||
}
|
||||
|
||||
this.setupScrollSlideTracking()
|
||||
}
|
||||
|
||||
private async renderAllSlidesWindowed(
|
||||
scale: number,
|
||||
displayWidth: number,
|
||||
displayHeight: number
|
||||
): Promise<void> {
|
||||
if (!this.presentation) return
|
||||
const batchSize = this.listOptions.batchSize
|
||||
let batchFragment = document.createDocumentFragment()
|
||||
const wrappers: HTMLDivElement[] = []
|
||||
|
||||
for (let i = 0; i < this.presentation.slides.length; i++) {
|
||||
const { item, wrapper } = this.createListSlideItem(i, displayWidth, displayHeight)
|
||||
wrappers.push(wrapper)
|
||||
batchFragment.appendChild(item)
|
||||
|
||||
if ((i + 1) % batchSize === 0) {
|
||||
this.container.appendChild(batchFragment)
|
||||
batchFragment = document.createDocumentFragment()
|
||||
await this.yieldToNextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
if (batchFragment.childNodes.length > 0) {
|
||||
this.container.appendChild(batchFragment)
|
||||
}
|
||||
|
||||
const mount = (idx: number): void => {
|
||||
if (idx < 0 || idx >= wrappers.length) return
|
||||
this.mountListSlide(idx, wrappers[idx], scale, displayWidth, displayHeight)
|
||||
}
|
||||
const unmount = (idx: number): void => {
|
||||
if (idx < 0 || idx >= wrappers.length) return
|
||||
this.unmountListSlide(idx, wrappers[idx], displayHeight)
|
||||
}
|
||||
|
||||
const initial = this.listOptions.initialSlides
|
||||
for (let i = 0; i < Math.min(initial, wrappers.length); i++) mount(i)
|
||||
this.ensureListSlideMountedFn = mount
|
||||
|
||||
const IO = window.IntersectionObserver
|
||||
if (!IO) {
|
||||
for (let i = initial; i < wrappers.length; i++) mount(i)
|
||||
this.setupScrollSlideTracking()
|
||||
return
|
||||
}
|
||||
|
||||
const ioRoot = this.viewerOptions.scrollContainer ?? null
|
||||
const overscanViewport = this.listOptions.overscanViewport
|
||||
const rootHeight = ioRoot ? ioRoot.clientHeight : window.innerHeight
|
||||
const rootMargin = `${Math.round(rootHeight * overscanViewport)}px 0px`
|
||||
const observer = new IO(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const item = (entry.target as HTMLElement).parentElement
|
||||
const index = Number(item?.dataset.slideIndex ?? '-1')
|
||||
if (Number.isNaN(index) || index < 0) continue
|
||||
if (entry.isIntersecting) {
|
||||
mount(index)
|
||||
} else {
|
||||
unmount(index)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: ioRoot, rootMargin, threshold: 0 }
|
||||
)
|
||||
|
||||
wrappers.forEach((wrapper) => {
|
||||
observer.observe(wrapper)
|
||||
})
|
||||
|
||||
this.cleanupListMount = () => {
|
||||
observer.disconnect()
|
||||
this.ensureListSlideMountedFn = undefined
|
||||
}
|
||||
|
||||
this.setupScrollSlideTracking()
|
||||
}
|
||||
|
||||
private setupScrollSlideTracking(): void {
|
||||
if (this.activeRenderMode === 'slide') return
|
||||
|
||||
const IO = window.IntersectionObserver
|
||||
if (!IO) return
|
||||
|
||||
const items = this.container.querySelectorAll<HTMLElement>('[data-slide-index]')
|
||||
if (!items.length) return
|
||||
|
||||
const ratios = new Map<number, number>()
|
||||
const ioRoot = this.viewerOptions.scrollContainer ?? null
|
||||
|
||||
const observer = new IO(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const idx = Number((entry.target as HTMLElement).dataset.slideIndex ?? '-1')
|
||||
if (Number.isNaN(idx) || idx < 0) continue
|
||||
ratios.set(idx, entry.intersectionRatio)
|
||||
}
|
||||
|
||||
if (this.suppressScrollChange) return
|
||||
|
||||
let bestIdx = -1
|
||||
let bestRatio = -1
|
||||
for (const [idx, ratio] of ratios) {
|
||||
if (ratio > bestRatio) {
|
||||
bestRatio = ratio
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0 && bestIdx !== this.currentSlide) {
|
||||
this.currentSlide = bestIdx
|
||||
this.emitSlideChange(bestIdx)
|
||||
}
|
||||
},
|
||||
{ root: ioRoot, threshold: [0, 0.25, 0.5, 0.75, 1.0] }
|
||||
)
|
||||
|
||||
items.forEach((item) => observer.observe(item))
|
||||
|
||||
this.cleanupScrollObserver = () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private renderSingleSlide(scale: number, displayWidth: number, displayHeight: number): void {
|
||||
if (!this.presentation) return
|
||||
|
||||
const slide = this.presentation.slides[this.currentSlide]
|
||||
if (!slide) return
|
||||
|
||||
for (const handle of this.slideHandles.values()) {
|
||||
handle.dispose()
|
||||
}
|
||||
this.slideHandles.clear()
|
||||
this.disposeAllCharts()
|
||||
this.container.innerHTML = ''
|
||||
this.mountedSlides.clear()
|
||||
this.mountedSlides.add(this.currentSlide)
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.cssText = `
|
||||
width: ${displayWidth}px; height: ${displayHeight}px;
|
||||
margin: 0 auto; overflow: hidden; position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
`
|
||||
|
||||
try {
|
||||
const handle = renderSlideInternal(this.presentation, slide, {
|
||||
onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error),
|
||||
onNavigate: (target) => this.handleNavigate(target),
|
||||
mediaUrlCache: this.mediaUrlCache,
|
||||
chartInstances: this.chartInstances,
|
||||
})
|
||||
this.slideHandles.set(this.currentSlide, handle)
|
||||
handle.element.style.transform = `scale(${scale})`
|
||||
handle.element.style.transformOrigin = 'top left'
|
||||
wrapper.appendChild(handle.element)
|
||||
this.emitSlideRendered(this.currentSlide, handle.element)
|
||||
} catch (e) {
|
||||
this.emitSlideError(this.currentSlide, e)
|
||||
wrapper.style.background = '#fff3f3'
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.justifyContent = 'center'
|
||||
wrapper.style.border = '2px dashed #ff6b6b'
|
||||
wrapper.style.color = '#cc0000'
|
||||
wrapper.style.fontSize = '14px'
|
||||
wrapper.textContent = `Slide ${this.currentSlide + 1}: Render Error - ${getErrorMessage(e)}`
|
||||
}
|
||||
|
||||
this.container.appendChild(wrapper)
|
||||
this.afterSingleSlideRender()
|
||||
}
|
||||
|
||||
/**
|
||||
* After list-mode rendering, a scrollbar may appear on the page body
|
||||
* (or a scroll ancestor), narrowing the container. If the container's
|
||||
* clientWidth now differs from the width used to compute the initial
|
||||
* scale, patch every wrapper's dimensions and each slide element's
|
||||
* transform in-place — no DOM rebuild required.
|
||||
*/
|
||||
private correctListMetricsIfNeeded(): void {
|
||||
if (!this.presentation) return
|
||||
if (this._fitMode !== 'contain') return
|
||||
if (this.viewerOptions.width !== undefined) return
|
||||
|
||||
const currentWidth = this.container.clientWidth || 0
|
||||
if (!currentWidth || currentWidth === this.lastMeasuredContainerWidth) return
|
||||
|
||||
// Width changed — recompute metrics
|
||||
this.lastMeasuredContainerWidth = currentWidth
|
||||
const fitScale = currentWidth / this.presentation.width
|
||||
const newScale = fitScale * this.zoomFactor
|
||||
const newDisplayW = this.presentation.width * newScale
|
||||
const newDisplayH = this.presentation.height * newScale
|
||||
|
||||
// Patch every slide wrapper in the list
|
||||
const items = this.container.querySelectorAll<HTMLElement>('[data-slide-index]')
|
||||
for (const item of items) {
|
||||
const wrapper = item.firstElementChild as HTMLElement | null
|
||||
if (!wrapper) continue
|
||||
wrapper.style.width = `${newDisplayW}px`
|
||||
wrapper.style.height = `${newDisplayH}px`
|
||||
// The slide element is the first child of the wrapper
|
||||
const slideEl = wrapper.firstElementChild as HTMLElement | null
|
||||
if (slideEl) {
|
||||
slideEl.style.transform = `scale(${newScale})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleNavigate(target: { slideIndex?: number; url?: string }): void {
|
||||
if (target.slideIndex !== undefined) {
|
||||
this.goToSlide(target.slideIndex)
|
||||
} else if (target.url && isAllowedExternalUrl(target.url)) {
|
||||
window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Standalone helper (shared with Renderer.ts)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function normalizePreviewInput(input: PreviewInput): Promise<ArrayBuffer> {
|
||||
if (input instanceof ArrayBuffer) return input
|
||||
if (input instanceof Uint8Array) {
|
||||
const bytes = new Uint8Array(input.byteLength)
|
||||
bytes.set(input)
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
const blobLike = input as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
|
||||
if (typeof blobLike.arrayBuffer === 'function') {
|
||||
return blobLike.arrayBuffer()
|
||||
}
|
||||
|
||||
if (typeof FileReader !== 'undefined') {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read Blob input'))
|
||||
reader.readAsArrayBuffer(blobLike)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof Response !== 'undefined') {
|
||||
return new Response(blobLike).arrayBuffer()
|
||||
}
|
||||
|
||||
throw new Error('Blob preview input is not supported in this runtime')
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Slide layout parser — extracts color map override, background,
|
||||
* and placeholder shapes from a p:sldLayout XML.
|
||||
*/
|
||||
|
||||
import { emuToPx } from '../parser/units'
|
||||
import type { SafeXmlNode } from '../parser/xml-parser'
|
||||
import { isPlaceholder, parseAllAttributes } from './xml-helpers'
|
||||
|
||||
interface PlaceholderXfrm {
|
||||
position: { x: number; y: number }
|
||||
size: { w: number; h: number }
|
||||
}
|
||||
|
||||
export interface PlaceholderEntry {
|
||||
node: SafeXmlNode
|
||||
/** When placeholder is inside a group, position/size in slide space (px). */
|
||||
absoluteXfrm?: PlaceholderXfrm
|
||||
}
|
||||
|
||||
export interface LayoutData {
|
||||
colorMapOverride?: Map<string, string>
|
||||
background?: SafeXmlNode
|
||||
placeholders: PlaceholderEntry[]
|
||||
spTree: SafeXmlNode
|
||||
rels: Map<string, import('../parser/rel-parser').RelEntry>
|
||||
/** When false, shapes from the slide master should NOT be rendered on this layout. */
|
||||
showMasterSp: boolean
|
||||
}
|
||||
|
||||
function getShapeXfrmInEmu(
|
||||
node: SafeXmlNode
|
||||
): { offX: number; offY: number; cx: number; cy: number } | null {
|
||||
const spPr = node.child('spPr')
|
||||
if (!spPr.exists()) return null
|
||||
const xfrm = spPr.child('xfrm')
|
||||
if (!xfrm.exists()) return null
|
||||
const off = xfrm.child('off')
|
||||
const ext = xfrm.child('ext')
|
||||
const offX = off.numAttr('x') ?? 0
|
||||
const offY = off.numAttr('y') ?? 0
|
||||
const cx = ext.numAttr('cx') ?? 0
|
||||
const cy = ext.numAttr('cy') ?? 0
|
||||
return { offX, offY, cx, cy }
|
||||
}
|
||||
|
||||
function getGroupXfrmInEmu(grpSp: SafeXmlNode): {
|
||||
offX: number
|
||||
offY: number
|
||||
cx: number
|
||||
cy: number
|
||||
chOffX: number
|
||||
chOffY: number
|
||||
chExtCx: number
|
||||
chExtCy: number
|
||||
} | null {
|
||||
const grpSpPr = grpSp.child('grpSpPr')
|
||||
if (!grpSpPr.exists()) return null
|
||||
const xfrm = grpSpPr.child('xfrm')
|
||||
if (!xfrm.exists()) return null
|
||||
const off = xfrm.child('off')
|
||||
const ext = xfrm.child('ext')
|
||||
const chOff = xfrm.child('chOff')
|
||||
const chExt = xfrm.child('chExt')
|
||||
const offX = off.numAttr('x') ?? 0
|
||||
const offY = off.numAttr('y') ?? 0
|
||||
const cx = ext.numAttr('cx') ?? 0
|
||||
const cy = ext.numAttr('cy') ?? 0
|
||||
// OOXML: when chOff/chExt omitted, child box equals group box (chOff=0,0 and chExt=ext)
|
||||
const chOffX = chOff.exists() ? (chOff.numAttr('x') ?? 0) : 0
|
||||
const chOffY = chOff.exists() ? (chOff.numAttr('y') ?? 0) : 0
|
||||
const chExtCx = chExt.exists() ? (chExt.numAttr('cx') ?? cx) : cx
|
||||
const chExtCy = chExt.exists() ? (chExt.numAttr('cy') ?? cy) : cy
|
||||
return {
|
||||
offX,
|
||||
offY,
|
||||
cx,
|
||||
cy,
|
||||
chOffX,
|
||||
chOffY,
|
||||
chExtCx: chExtCx > 0 ? chExtCx : 1,
|
||||
chExtCy: chExtCy > 0 ? chExtCy : 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect placeholders; when inside a group, compute position/size in slide space.
|
||||
*/
|
||||
function extractPlaceholdersRecursive(
|
||||
spTree: SafeXmlNode,
|
||||
groupTransform: { offX: number; offY: number; scaleX: number; scaleY: number } | null
|
||||
): PlaceholderEntry[] {
|
||||
const out: PlaceholderEntry[] = []
|
||||
for (const child of spTree.allChildren()) {
|
||||
if (child.localName === 'grpSp') {
|
||||
const gx = getGroupXfrmInEmu(child)
|
||||
if (gx && gx.chExtCx > 0 && gx.chExtCy > 0) {
|
||||
const scaleX = gx.cx / gx.chExtCx
|
||||
const scaleY = gx.cy / gx.chExtCy
|
||||
const baseOffX = gx.offX - gx.chOffX * scaleX
|
||||
const baseOffY = gx.offY - gx.chOffY * scaleY
|
||||
const nextTransform = groupTransform
|
||||
? {
|
||||
offX: groupTransform.offX + baseOffX * groupTransform.scaleX,
|
||||
offY: groupTransform.offY + baseOffY * groupTransform.scaleY,
|
||||
scaleX: groupTransform.scaleX * scaleX,
|
||||
scaleY: groupTransform.scaleY * scaleY,
|
||||
}
|
||||
: { offX: baseOffX, offY: baseOffY, scaleX, scaleY }
|
||||
const nested = extractPlaceholdersRecursive(child, nextTransform)
|
||||
out.push(...nested)
|
||||
} else {
|
||||
out.push(...extractPlaceholdersRecursive(child, groupTransform))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isPlaceholder(child)) continue
|
||||
const sx = getShapeXfrmInEmu(child)
|
||||
if (!sx) {
|
||||
out.push({ node: child })
|
||||
continue
|
||||
}
|
||||
if (groupTransform) {
|
||||
const absOffX = groupTransform.offX + sx.offX * groupTransform.scaleX
|
||||
const absOffY = groupTransform.offY + sx.offY * groupTransform.scaleY
|
||||
const absCx = sx.cx * groupTransform.scaleX
|
||||
const absCy = sx.cy * groupTransform.scaleY
|
||||
out.push({
|
||||
node: child,
|
||||
absoluteXfrm: {
|
||||
position: { x: emuToPx(absOffX), y: emuToPx(absOffY) },
|
||||
size: { w: emuToPx(absCx), h: emuToPx(absCy) },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
out.push({
|
||||
node: child,
|
||||
absoluteXfrm: {
|
||||
position: { x: emuToPx(sx.offX), y: emuToPx(sx.offY) },
|
||||
size: { w: emuToPx(sx.cx), h: emuToPx(sx.cy) },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a slide layout XML root (`p:sldLayout`) into LayoutData.
|
||||
*/
|
||||
export function parseLayout(root: SafeXmlNode): LayoutData {
|
||||
const cSld = root.child('cSld')
|
||||
|
||||
// --- Background ---
|
||||
const bg = cSld.child('bg')
|
||||
const background = bg.exists() ? bg : undefined
|
||||
|
||||
// --- Shape tree ---
|
||||
const spTree = cSld.child('spTree')
|
||||
|
||||
// --- Color map override ---
|
||||
let colorMapOverride: Map<string, string> | undefined
|
||||
const clrMapOvr = root.child('clrMapOvr')
|
||||
if (clrMapOvr.exists()) {
|
||||
const overrideMapping = clrMapOvr.child('overrideClrMapping')
|
||||
if (overrideMapping.exists()) {
|
||||
colorMapOverride = parseAllAttributes(overrideMapping)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Placeholders (recursive so we find title/body inside grpSp; resolve position in slide space) ---
|
||||
const placeholders = extractPlaceholdersRecursive(spTree, null)
|
||||
|
||||
// --- showMasterSp: if "0", master shapes should not be rendered for this layout ---
|
||||
const showMasterSpAttr = root.attr('showMasterSp')
|
||||
const showMasterSp = showMasterSpAttr !== '0'
|
||||
|
||||
return {
|
||||
colorMapOverride,
|
||||
background,
|
||||
placeholders,
|
||||
spTree,
|
||||
rels: new Map(), // populated later by buildPresentation
|
||||
showMasterSp,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Slide master parser — extracts color map, background, text styles,
|
||||
* and placeholder shapes from a p:sldMaster XML.
|
||||
*/
|
||||
|
||||
import type { SafeXmlNode } from '../parser/xml-parser'
|
||||
import { isPlaceholder, parseAllAttributes } from './xml-helpers'
|
||||
|
||||
export interface MasterData {
|
||||
colorMap: Map<string, string>
|
||||
background?: SafeXmlNode
|
||||
textStyles: {
|
||||
titleStyle?: SafeXmlNode
|
||||
bodyStyle?: SafeXmlNode
|
||||
otherStyle?: SafeXmlNode
|
||||
}
|
||||
defaultTextStyle?: SafeXmlNode
|
||||
placeholders: SafeXmlNode[]
|
||||
spTree: SafeXmlNode
|
||||
rels: Map<string, import('../parser/rel-parser').RelEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract placeholder shape nodes from an spTree node.
|
||||
* A shape is considered a placeholder if it has a `p:ph` element in its nvPr.
|
||||
*/
|
||||
function extractPlaceholders(spTree: SafeXmlNode): SafeXmlNode[] {
|
||||
const placeholders: SafeXmlNode[] = []
|
||||
const allChildren = spTree.allChildren()
|
||||
for (const child of allChildren) {
|
||||
if (isPlaceholder(child)) {
|
||||
placeholders.push(child)
|
||||
}
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a slide master XML root (`p:sldMaster`) into MasterData.
|
||||
*/
|
||||
export function parseMaster(root: SafeXmlNode): MasterData {
|
||||
const cSld = root.child('cSld')
|
||||
|
||||
// --- Background ---
|
||||
const bg = cSld.child('bg')
|
||||
const background = bg.exists() ? bg : undefined
|
||||
|
||||
// --- Shape tree ---
|
||||
const spTree = cSld.child('spTree')
|
||||
|
||||
// --- Color map ---
|
||||
const clrMap = root.child('clrMap')
|
||||
const colorMap = parseAllAttributes(clrMap)
|
||||
|
||||
// --- Text styles ---
|
||||
const txStyles = root.child('txStyles')
|
||||
const titleStyle = txStyles.child('titleStyle')
|
||||
const bodyStyle = txStyles.child('bodyStyle')
|
||||
const otherStyle = txStyles.child('otherStyle')
|
||||
|
||||
// --- Default text style ---
|
||||
const defaultTextStyle = root.child('defaultTextStyle')
|
||||
|
||||
// --- Placeholders ---
|
||||
const placeholders = extractPlaceholders(spTree)
|
||||
|
||||
return {
|
||||
colorMap,
|
||||
background,
|
||||
textStyles: {
|
||||
titleStyle: titleStyle.exists() ? titleStyle : undefined,
|
||||
bodyStyle: bodyStyle.exists() ? bodyStyle : undefined,
|
||||
otherStyle: otherStyle.exists() ? otherStyle : undefined,
|
||||
},
|
||||
defaultTextStyle: defaultTextStyle.exists() ? defaultTextStyle : undefined,
|
||||
placeholders,
|
||||
spTree,
|
||||
rels: new Map(), // populated later by buildPresentation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Base node types and property parser shared by all slide node kinds.
|
||||
*/
|
||||
|
||||
import { angleToDeg, emuToPx } from '../../parser/units'
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
|
||||
export type NodeType = 'shape' | 'picture' | 'table' | 'group' | 'chart' | 'unknown'
|
||||
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface PlaceholderInfo {
|
||||
type?: string
|
||||
idx?: number
|
||||
}
|
||||
|
||||
/** Shape-level hyperlink click action (from cNvPr > a:hlinkClick). */
|
||||
interface HlinkAction {
|
||||
/** Action URI, e.g. "ppaction://hlinksldjump", "ppaction://hlinkpres", or empty for URL links. */
|
||||
action?: string
|
||||
/** Relationship ID for the target (slide, URL, etc.). */
|
||||
rId?: string
|
||||
/** Optional tooltip text. */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface BaseNodeData {
|
||||
id: string
|
||||
name: string
|
||||
nodeType: NodeType
|
||||
position: Position
|
||||
size: Size
|
||||
rotation: number
|
||||
flipH: boolean
|
||||
flipV: boolean
|
||||
placeholder?: PlaceholderInfo
|
||||
/** Shape-level hyperlink/click action (action buttons, clickable shapes). */
|
||||
hlinkClick?: HlinkAction
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
source: SafeXmlNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the non-visual properties container in the given node.
|
||||
* PPTX uses different wrapper names depending on the shape kind:
|
||||
* p:nvSpPr (shapes/connectors), p:nvPicPr (pictures),
|
||||
* p:nvGrpSpPr (groups), p:nvGraphicFramePr (tables/charts).
|
||||
*/
|
||||
function findNvProps(node: SafeXmlNode): { cNvPr: SafeXmlNode; nvPr: SafeXmlNode } {
|
||||
const wrappers = ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']
|
||||
for (const name of wrappers) {
|
||||
const wrapper = node.child(name)
|
||||
if (wrapper.exists()) {
|
||||
return {
|
||||
cNvPr: wrapper.child('cNvPr'),
|
||||
nvPr: wrapper.child('nvPr'),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
cNvPr: node.child('cNvPr'),
|
||||
nvPr: node.child('nvPr'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the transform (xfrm) node. Shapes use `p:spPr > a:xfrm`,
|
||||
* groups use `p:grpSpPr > a:xfrm`, graphic frames use `p:xfrm`.
|
||||
*/
|
||||
function findXfrm(node: SafeXmlNode): SafeXmlNode {
|
||||
// Try spPr first (most shapes)
|
||||
const spPr = node.child('spPr')
|
||||
if (spPr.exists()) {
|
||||
const xfrm = spPr.child('xfrm')
|
||||
if (xfrm.exists()) return xfrm
|
||||
}
|
||||
|
||||
// Try grpSpPr (groups)
|
||||
const grpSpPr = node.child('grpSpPr')
|
||||
if (grpSpPr.exists()) {
|
||||
const xfrm = grpSpPr.child('xfrm')
|
||||
if (xfrm.exists()) return xfrm
|
||||
}
|
||||
|
||||
// Try direct xfrm (graphic frames)
|
||||
const directXfrm = node.child('xfrm')
|
||||
if (directXfrm.exists()) return directXfrm
|
||||
|
||||
// Return empty node — all reads will return defaults
|
||||
return node.child('__nonexistent__')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse placeholder info from nvPr > p:ph.
|
||||
*/
|
||||
function parsePlaceholder(nvPr: SafeXmlNode): PlaceholderInfo | undefined {
|
||||
const ph = nvPr.child('ph')
|
||||
if (!ph.exists()) return undefined
|
||||
|
||||
const type = ph.attr('type')
|
||||
const idx = ph.numAttr('idx')
|
||||
|
||||
return { type, idx }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the base properties common to all node types from a shape-like XML node.
|
||||
* Returns everything except `nodeType`, which the caller must set.
|
||||
*/
|
||||
export function parseBaseProps(spNode: SafeXmlNode): Omit<BaseNodeData, 'nodeType'> {
|
||||
const { cNvPr, nvPr } = findNvProps(spNode)
|
||||
|
||||
const id = cNvPr.attr('id') ?? ''
|
||||
const name = cNvPr.attr('name') ?? ''
|
||||
|
||||
// --- Transform ---
|
||||
const xfrm = findXfrm(spNode)
|
||||
const off = xfrm.child('off')
|
||||
const ext = xfrm.child('ext')
|
||||
|
||||
const position: Position = {
|
||||
x: emuToPx(off.numAttr('x') ?? 0),
|
||||
y: emuToPx(off.numAttr('y') ?? 0),
|
||||
}
|
||||
|
||||
const size: Size = {
|
||||
w: emuToPx(ext.numAttr('cx') ?? 0),
|
||||
h: emuToPx(ext.numAttr('cy') ?? 0),
|
||||
}
|
||||
|
||||
const rotation = angleToDeg(xfrm.numAttr('rot') ?? 0)
|
||||
const flipH = xfrm.attr('flipH') === '1' || xfrm.attr('flipH') === 'true'
|
||||
const flipV = xfrm.attr('flipV') === '1' || xfrm.attr('flipV') === 'true'
|
||||
|
||||
// --- Placeholder ---
|
||||
const placeholder = parsePlaceholder(nvPr)
|
||||
|
||||
// --- Shape-level hyperlink action (cNvPr > a:hlinkClick) ---
|
||||
let hlinkClick: HlinkAction | undefined
|
||||
const hlinkNode = cNvPr.child('hlinkClick')
|
||||
if (hlinkNode.exists()) {
|
||||
hlinkClick = {
|
||||
action: hlinkNode.attr('action') ?? undefined,
|
||||
rId: hlinkNode.attr('id') ?? hlinkNode.attr('r:id') ?? undefined,
|
||||
tooltip: hlinkNode.attr('tooltip') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
size,
|
||||
rotation,
|
||||
flipH,
|
||||
flipV,
|
||||
placeholder,
|
||||
hlinkClick,
|
||||
source: spNode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Chart node — represents a chart embedded in a graphicFrame element.
|
||||
*/
|
||||
|
||||
import { type RelEntry, resolveRelTarget } from '../../parser/rel-parser'
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
import { type BaseNodeData, parseBaseProps } from './base-node'
|
||||
|
||||
export interface ChartNodeData extends BaseNodeData {
|
||||
nodeType: 'chart'
|
||||
chartPath: string // e.g. "ppt/charts/chart1.xml"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a graphicFrame containing a chart reference into a ChartNodeData.
|
||||
*
|
||||
* @param graphicFrame The graphicFrame XML node
|
||||
* @param slideRels Relationship entries for the containing slide
|
||||
* @param slidePath Full path of the slide (e.g. "ppt/slides/slide1.xml")
|
||||
*/
|
||||
export function parseChartNode(
|
||||
graphicFrame: SafeXmlNode,
|
||||
slideRels: Map<string, RelEntry>,
|
||||
slidePath: string
|
||||
): ChartNodeData | undefined {
|
||||
const base = parseBaseProps(graphicFrame)
|
||||
|
||||
// Find chart relationship
|
||||
const graphic = graphicFrame.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
|
||||
// Find the chart reference - look for c:chart element with r:id
|
||||
let chartRId: string | undefined
|
||||
for (const child of graphicData.allChildren()) {
|
||||
if (child.localName === 'chart') {
|
||||
chartRId = child.attr('r:id') || child.attr('id')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!chartRId) return undefined
|
||||
|
||||
const rel = slideRels.get(chartRId)
|
||||
if (!rel) return undefined
|
||||
|
||||
// Resolve chart path relative to slide
|
||||
const slideDir = slidePath.substring(0, slidePath.lastIndexOf('/'))
|
||||
const chartPath = resolveRelTarget(slideDir, rel.target)
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'chart' as const,
|
||||
chartPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Group node parser — handles grouped shapes (p:grpSp).
|
||||
*/
|
||||
|
||||
import { emuToPx } from '../../parser/units'
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
import { type BaseNodeData, type Position, parseBaseProps, type Size } from './base-node'
|
||||
|
||||
export interface GroupNodeData extends BaseNodeData {
|
||||
nodeType: 'group'
|
||||
childOffset: Position
|
||||
childExtent: Size
|
||||
/** @internal Raw XML nodes — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
children: SafeXmlNode[]
|
||||
}
|
||||
|
||||
/** Tag names of elements that can be children in a group's spTree. */
|
||||
const GROUP_CHILD_TAGS = new Set(['sp', 'pic', 'grpSp', 'graphicFrame', 'cxnSp'])
|
||||
|
||||
/**
|
||||
* Parse a group shape XML node (`p:grpSp`) into GroupNodeData.
|
||||
*/
|
||||
export function parseGroupNode(grpNode: SafeXmlNode): GroupNodeData {
|
||||
const base = parseBaseProps(grpNode)
|
||||
|
||||
// --- Child coordinate space from grpSpPr > a:xfrm ---
|
||||
// OOXML: when chOff/chExt omitted, child box equals group box (chOff=0,0, chExt=ext).
|
||||
const grpSpPr = grpNode.child('grpSpPr')
|
||||
const xfrm = grpSpPr.child('xfrm')
|
||||
const chOff = xfrm.child('chOff')
|
||||
const chExt = xfrm.child('chExt')
|
||||
|
||||
const childOffset: Position = chOff.exists()
|
||||
? { x: emuToPx(chOff.numAttr('x') ?? 0), y: emuToPx(chOff.numAttr('y') ?? 0) }
|
||||
: { x: 0, y: 0 }
|
||||
|
||||
const childExtent: Size = (() => {
|
||||
if (!chExt.exists()) return { w: base.size.w, h: base.size.h }
|
||||
const cx = chExt.numAttr('cx')
|
||||
const cy = chExt.numAttr('cy')
|
||||
return {
|
||||
w: cx !== undefined && cx > 0 ? emuToPx(cx) : base.size.w,
|
||||
h: cy !== undefined && cy > 0 ? emuToPx(cy) : base.size.h,
|
||||
}
|
||||
})()
|
||||
|
||||
// --- Collect direct child shape nodes ---
|
||||
const children: SafeXmlNode[] = []
|
||||
for (const child of grpNode.allChildren()) {
|
||||
if (GROUP_CHILD_TAGS.has(child.localName)) {
|
||||
children.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'group',
|
||||
childOffset,
|
||||
childExtent,
|
||||
children,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Picture node parser — handles images, video placeholders, and audio placeholders.
|
||||
*/
|
||||
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
import { type BaseNodeData, parseBaseProps } from './base-node'
|
||||
|
||||
interface CropRect {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
export interface PicNodeData extends BaseNodeData {
|
||||
nodeType: 'picture'
|
||||
blipEmbed?: string
|
||||
blipLink?: string
|
||||
crop?: CropRect
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
fill?: SafeXmlNode
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
line?: SafeXmlNode
|
||||
isVideo?: boolean
|
||||
isAudio?: boolean
|
||||
mediaRId?: string
|
||||
}
|
||||
|
||||
/** OOXML encodes srcRect percentages as 1/100000 of full extent. */
|
||||
const CROP_DIVISOR = 100000
|
||||
|
||||
/**
|
||||
* Parse a picture XML node (`p:pic`) into PicNodeData.
|
||||
*/
|
||||
export function parsePicNode(picNode: SafeXmlNode): PicNodeData {
|
||||
const base = parseBaseProps(picNode)
|
||||
|
||||
// --- Blip fill ---
|
||||
const blipFill = picNode.child('blipFill')
|
||||
const blip = blipFill.child('blip')
|
||||
|
||||
// Try both namespaced and non-namespaced embed attribute
|
||||
const blipEmbed = blip.attr('embed') ?? blip.attr('r:embed')
|
||||
const blipLink = blip.attr('link') ?? blip.attr('r:link')
|
||||
|
||||
// --- Crop (srcRect) ---
|
||||
const srcRect = blipFill.child('srcRect')
|
||||
let crop: CropRect | undefined
|
||||
if (srcRect.exists()) {
|
||||
const t = srcRect.numAttr('t')
|
||||
const b = srcRect.numAttr('b')
|
||||
const l = srcRect.numAttr('l')
|
||||
const r = srcRect.numAttr('r')
|
||||
if (t !== undefined || b !== undefined || l !== undefined || r !== undefined) {
|
||||
crop = {
|
||||
top: (t ?? 0) / CROP_DIVISOR,
|
||||
bottom: (b ?? 0) / CROP_DIVISOR,
|
||||
left: (l ?? 0) / CROP_DIVISOR,
|
||||
right: (r ?? 0) / CROP_DIVISOR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shape properties (fill + line) ---
|
||||
const spPr = picNode.child('spPr')
|
||||
const solidFill = spPr.child('solidFill')
|
||||
const gradFill = spPr.child('gradFill')
|
||||
const fill = solidFill.exists() ? solidFill : gradFill.exists() ? gradFill : undefined
|
||||
|
||||
const ln = spPr.child('ln')
|
||||
const line = ln.exists() ? ln : undefined
|
||||
|
||||
// --- Video / Audio detection ---
|
||||
const nvPicPr = picNode.child('nvPicPr')
|
||||
const nvPr = nvPicPr.child('nvPr')
|
||||
|
||||
const videoFile = nvPr.child('videoFile')
|
||||
const audioFile = nvPr.child('audioFile')
|
||||
|
||||
const isVideo = videoFile.exists()
|
||||
const isAudio = audioFile.exists()
|
||||
|
||||
let mediaRId: string | undefined
|
||||
if (isVideo) {
|
||||
mediaRId = videoFile.attr('link') ?? videoFile.attr('r:link')
|
||||
} else if (isAudio) {
|
||||
mediaRId = audioFile.attr('link') ?? audioFile.attr('r:link')
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'picture',
|
||||
blipEmbed,
|
||||
blipLink,
|
||||
crop,
|
||||
fill,
|
||||
line,
|
||||
isVideo: isVideo || undefined,
|
||||
isAudio: isAudio || undefined,
|
||||
mediaRId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Shape node parser — handles auto-shapes, text boxes, and connectors.
|
||||
*/
|
||||
|
||||
import { angleToDeg, emuToPx } from '../../parser/units'
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
import { type BaseNodeData, parseBaseProps } from './base-node'
|
||||
|
||||
interface TextRun {
|
||||
text: string
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
properties?: SafeXmlNode
|
||||
}
|
||||
|
||||
interface TextParagraph {
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
properties?: SafeXmlNode
|
||||
runs: TextRun[]
|
||||
level: number
|
||||
/** @internal End-of-paragraph run properties (a:endParaRPr). Defines font size for trailing paragraph mark. */
|
||||
endParaRPr?: SafeXmlNode
|
||||
}
|
||||
|
||||
export interface TextBody {
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
bodyProperties?: SafeXmlNode
|
||||
/** @internal Fallback bodyPr from layout/master placeholder (used when shape's own bodyPr is missing attrs). */
|
||||
layoutBodyProperties?: SafeXmlNode
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
listStyle?: SafeXmlNode
|
||||
paragraphs: TextParagraph[]
|
||||
}
|
||||
|
||||
export interface LineEndInfo {
|
||||
type: string // 'triangle', 'arrow', 'stealth', 'diamond', 'oval', 'none'
|
||||
w?: string // 'sm', 'med', 'lg'
|
||||
len?: string // 'sm', 'med', 'lg'
|
||||
}
|
||||
|
||||
/** Text box bounds in shape-local coordinates (used by diagram shapes with txXfrm). */
|
||||
interface TextBoxBounds {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
rotation?: number
|
||||
}
|
||||
|
||||
export interface ShapeNodeData extends BaseNodeData {
|
||||
nodeType: 'shape'
|
||||
presetGeometry?: string
|
||||
adjustments: Map<string, number>
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
customGeometry?: SafeXmlNode
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
fill?: SafeXmlNode
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
line?: SafeXmlNode
|
||||
headEnd?: LineEndInfo
|
||||
tailEnd?: LineEndInfo
|
||||
textBody?: TextBody
|
||||
/** When set (e.g. diagram txXfrm), text is laid out in this rect instead of full shape. */
|
||||
textBoxBounds?: TextBoxBounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single text paragraph (`a:p`).
|
||||
*/
|
||||
function parseParagraph(pNode: SafeXmlNode): TextParagraph {
|
||||
const pPr = pNode.child('pPr')
|
||||
const level = pPr.numAttr('lvl') ?? 0
|
||||
|
||||
// Re-scan in document order to get correct interleaving of r, br, fld
|
||||
const orderedRuns: TextRun[] = []
|
||||
for (const child of pNode.allChildren()) {
|
||||
const ln = child.localName
|
||||
if (ln === 'r') {
|
||||
const rPr = child.child('rPr')
|
||||
const tNode = child.child('t')
|
||||
orderedRuns.push({
|
||||
text: tNode.text(),
|
||||
properties: rPr.exists() ? rPr : undefined,
|
||||
})
|
||||
} else if (ln === 'br') {
|
||||
const rPr = child.child('rPr')
|
||||
orderedRuns.push({
|
||||
text: '\n',
|
||||
properties: rPr.exists() ? rPr : undefined,
|
||||
})
|
||||
} else if (ln === 'fld') {
|
||||
const rPr = child.child('rPr')
|
||||
const tNode = child.child('t')
|
||||
orderedRuns.push({
|
||||
text: tNode.text(),
|
||||
properties: rPr.exists() ? rPr : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const endParaRPrNode = pNode.child('endParaRPr')
|
||||
return {
|
||||
properties: pPr.exists() ? pPr : undefined,
|
||||
runs: orderedRuns,
|
||||
level,
|
||||
endParaRPr: endParaRPrNode.exists() ? endParaRPrNode : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a text body (`p:txBody` or `a:txBody`).
|
||||
*/
|
||||
export function parseTextBody(txBody: SafeXmlNode): TextBody | undefined {
|
||||
if (!txBody.exists()) return undefined
|
||||
|
||||
const bodyPr = txBody.child('bodyPr')
|
||||
const lstStyle = txBody.child('lstStyle')
|
||||
|
||||
const paragraphs: TextParagraph[] = []
|
||||
for (const pNode of txBody.children('p')) {
|
||||
paragraphs.push(parseParagraph(pNode))
|
||||
}
|
||||
|
||||
return {
|
||||
bodyProperties: bodyPr.exists() ? bodyPr : undefined,
|
||||
listStyle: lstStyle.exists() ? lstStyle : undefined,
|
||||
paragraphs,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill type local names in priority order. */
|
||||
const FILL_TYPES = ['solidFill', 'gradFill', 'blipFill', 'pattFill', 'grpFill', 'noFill'] as const
|
||||
|
||||
/**
|
||||
* Find the first fill element in a shape properties node.
|
||||
*/
|
||||
function findFill(spPr: SafeXmlNode): SafeXmlNode | undefined {
|
||||
for (const fillType of FILL_TYPES) {
|
||||
const fill = spPr.child(fillType)
|
||||
if (fill.exists()) return fill
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse adjustment values from `a:avLst > a:gd` elements.
|
||||
* Each guide has a `name` attribute and a `fmla` attribute like "val 50000".
|
||||
*/
|
||||
function parseAdjustments(avLst: SafeXmlNode): Map<string, number> {
|
||||
const adjustments = new Map<string, number>()
|
||||
for (const gd of avLst.children('gd')) {
|
||||
const name = gd.attr('name')
|
||||
const fmla = gd.attr('fmla') ?? ''
|
||||
if (!name) continue
|
||||
|
||||
// fmla is typically "val NNNNN" — extract the numeric part
|
||||
const match = fmla.match(/val\s+(-?\d+)/)
|
||||
if (match) {
|
||||
adjustments.set(name, Number(match[1]))
|
||||
} else {
|
||||
// Try direct numeric value
|
||||
const num = Number(fmla)
|
||||
if (!Number.isNaN(num)) {
|
||||
adjustments.set(name, num)
|
||||
}
|
||||
}
|
||||
}
|
||||
return adjustments
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a shape XML node (`p:sp` or `p:cxnSp`) into ShapeNodeData.
|
||||
*/
|
||||
export function parseShapeNode(spNode: SafeXmlNode): ShapeNodeData {
|
||||
const base = parseBaseProps(spNode)
|
||||
const spPr = spNode.child('spPr')
|
||||
|
||||
// --- Preset geometry ---
|
||||
const prstGeom = spPr.child('prstGeom')
|
||||
const presetGeometry = prstGeom.attr('prst')
|
||||
const avLst = prstGeom.child('avLst')
|
||||
const adjustments = parseAdjustments(avLst)
|
||||
|
||||
// --- Custom geometry ---
|
||||
const custGeom = spPr.child('custGeom')
|
||||
const customGeometry = custGeom.exists() ? custGeom : undefined
|
||||
|
||||
// --- Fill ---
|
||||
const fill = findFill(spPr)
|
||||
|
||||
// --- Line ---
|
||||
const ln = spPr.child('ln')
|
||||
const line = ln.exists() ? ln : undefined
|
||||
|
||||
// --- Line end markers (arrowheads) ---
|
||||
let headEnd: LineEndInfo | undefined
|
||||
let tailEnd: LineEndInfo | undefined
|
||||
if (ln.exists()) {
|
||||
const headEndNode = ln.child('headEnd')
|
||||
if (headEndNode.exists()) {
|
||||
const t = headEndNode.attr('type')
|
||||
if (t && t !== 'none') {
|
||||
headEnd = { type: t, w: headEndNode.attr('w'), len: headEndNode.attr('len') }
|
||||
}
|
||||
}
|
||||
const tailEndNode = ln.child('tailEnd')
|
||||
if (tailEndNode.exists()) {
|
||||
const t = tailEndNode.attr('type')
|
||||
if (t && t !== 'none') {
|
||||
tailEnd = { type: t, w: tailEndNode.attr('w'), len: tailEndNode.attr('len') }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Text body ---
|
||||
const txBody = spNode.child('txBody')
|
||||
const textBody = parseTextBody(txBody)
|
||||
|
||||
// --- Text transform (diagram shapes: dsp:txXfrm gives text box position/size in same space as xfrm)
|
||||
let textBoxBounds: TextBoxBounds | undefined
|
||||
const txXfrm = spNode.child('txXfrm')
|
||||
if (txXfrm.exists()) {
|
||||
const txOff = txXfrm.child('off')
|
||||
const txExt = txXfrm.child('ext')
|
||||
const xfrm = spPr.child('xfrm')
|
||||
const off = xfrm.child('off')
|
||||
const ext = xfrm.child('ext')
|
||||
const shapeX = off.numAttr('x') ?? 0
|
||||
const shapeY = off.numAttr('y') ?? 0
|
||||
const shapeW = ext.numAttr('cx') ?? 0
|
||||
const shapeH = ext.numAttr('cy') ?? 0
|
||||
const txX = txOff.numAttr('x') ?? 0
|
||||
const txY = txOff.numAttr('y') ?? 0
|
||||
const txW = txExt.numAttr('cx') ?? 0
|
||||
const txH = txExt.numAttr('cy') ?? 0
|
||||
if (shapeW > 0 && shapeH > 0) {
|
||||
const txRotDeg = angleToDeg(txXfrm.numAttr('rot') ?? 0)
|
||||
const localX = txX - shapeX
|
||||
const localY = txY - shapeY
|
||||
// For 180deg txXfrm, mirror text box placement inside shape-local coordinates.
|
||||
// (Common in SmartArt where shape xfrm also rotates by 180deg but text should remain upright.)
|
||||
const isHalfTurn = Math.abs(Math.round(txRotDeg)) % 360 === 180
|
||||
const boxX = isHalfTurn ? shapeW - (localX + txW) : localX
|
||||
const boxY = isHalfTurn ? shapeH - (localY + txH) : localY
|
||||
textBoxBounds = {
|
||||
x: emuToPx(boxX),
|
||||
y: emuToPx(boxY),
|
||||
w: emuToPx(txW),
|
||||
h: emuToPx(txH),
|
||||
rotation: txRotDeg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'shape',
|
||||
presetGeometry,
|
||||
adjustments,
|
||||
customGeometry,
|
||||
fill,
|
||||
line,
|
||||
headEnd,
|
||||
tailEnd,
|
||||
textBody,
|
||||
textBoxBounds,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Table node parser — handles graphicFrame elements containing a:tbl.
|
||||
*/
|
||||
|
||||
import { emuToPx } from '../../parser/units'
|
||||
import type { SafeXmlNode } from '../../parser/xml-parser'
|
||||
import { type BaseNodeData, parseBaseProps } from './base-node'
|
||||
import { parseTextBody, type TextBody } from './shape-node'
|
||||
|
||||
export interface TableCell {
|
||||
gridSpan: number
|
||||
rowSpan: number
|
||||
hMerge: boolean
|
||||
vMerge: boolean
|
||||
textBody?: TextBody
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
properties?: SafeXmlNode
|
||||
}
|
||||
|
||||
interface TableRow {
|
||||
height: number
|
||||
cells: TableCell[]
|
||||
}
|
||||
|
||||
export interface TableNodeData extends BaseNodeData {
|
||||
nodeType: 'table'
|
||||
columns: number[]
|
||||
rows: TableRow[]
|
||||
/** @internal Raw XML node — opaque to consumers. Use serializePresentation() for JSON-safe data. */
|
||||
properties?: SafeXmlNode
|
||||
tableStyleId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single table cell (`a:tc`).
|
||||
*/
|
||||
function parseCell(tcNode: SafeXmlNode): TableCell {
|
||||
const gridSpan = tcNode.numAttr('gridSpan') ?? 1
|
||||
const rowSpan = tcNode.numAttr('rowSpan') ?? 1
|
||||
const hMerge = tcNode.attr('hMerge') === '1' || tcNode.attr('hMerge') === 'true'
|
||||
const vMerge = tcNode.attr('vMerge') === '1' || tcNode.attr('vMerge') === 'true'
|
||||
|
||||
// Cell text body
|
||||
const txBody = tcNode.child('txBody')
|
||||
const textBody = parseTextBody(txBody)
|
||||
|
||||
// Cell properties
|
||||
const tcPr = tcNode.child('tcPr')
|
||||
|
||||
return {
|
||||
gridSpan,
|
||||
rowSpan,
|
||||
hMerge,
|
||||
vMerge,
|
||||
textBody,
|
||||
properties: tcPr.exists() ? tcPr : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a table row (`a:tr`).
|
||||
*/
|
||||
function parseRow(trNode: SafeXmlNode): TableRow {
|
||||
const height = emuToPx(trNode.numAttr('h') ?? 0)
|
||||
const cells: TableCell[] = []
|
||||
|
||||
for (const tcNode of trNode.children('tc')) {
|
||||
cells.push(parseCell(tcNode))
|
||||
}
|
||||
|
||||
return { height, cells }
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the `a:tbl` element inside a graphicFrame.
|
||||
* Path: `a:graphic > a:graphicData > a:tbl`
|
||||
*/
|
||||
function findTable(frameNode: SafeXmlNode): SafeXmlNode {
|
||||
const graphic = frameNode.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
return graphicData.child('tbl')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the table style ID from tblPr.
|
||||
* It can be in `a:tblStyle@val` or as a direct `tblStyle` attribute.
|
||||
*/
|
||||
function extractTableStyleId(tblPr: SafeXmlNode): string | undefined {
|
||||
// Try <a:tableStyleId>{UUID}</a:tableStyleId> (most common in OOXML)
|
||||
const tableStyleIdNode = tblPr.child('tableStyleId')
|
||||
if (tableStyleIdNode.exists()) {
|
||||
return tableStyleIdNode.text() || tableStyleIdNode.attr('val') || undefined
|
||||
}
|
||||
// Try <a:tblStyle val="{UUID}"/>
|
||||
const tblStyleNode = tblPr.child('tblStyle')
|
||||
if (tblStyleNode.exists()) {
|
||||
return tblStyleNode.attr('val') ?? (tblStyleNode.text() || undefined)
|
||||
}
|
||||
// Try direct attribute
|
||||
return tblPr.attr('tblStyle') ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a graphicFrame XML node containing a table into TableNodeData.
|
||||
*/
|
||||
export function parseTableNode(frameNode: SafeXmlNode): TableNodeData {
|
||||
const base = parseBaseProps(frameNode)
|
||||
const tbl = findTable(frameNode)
|
||||
|
||||
// --- Column widths ---
|
||||
const tblGrid = tbl.child('tblGrid')
|
||||
const columns: number[] = []
|
||||
for (const gridCol of tblGrid.children('gridCol')) {
|
||||
columns.push(emuToPx(gridCol.numAttr('w') ?? 0))
|
||||
}
|
||||
|
||||
// --- Rows ---
|
||||
const rows: TableRow[] = []
|
||||
for (const trNode of tbl.children('tr')) {
|
||||
rows.push(parseRow(trNode))
|
||||
}
|
||||
|
||||
// --- Table properties ---
|
||||
const tblPr = tbl.child('tblPr')
|
||||
const tableStyleId = extractTableStyleId(tblPr)
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'table',
|
||||
columns,
|
||||
rows,
|
||||
properties: tblPr.exists() ? tblPr : undefined,
|
||||
tableStyleId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPresentation } from '@/lib/pptx-renderer/model/presentation'
|
||||
import type { PptxFiles } from '@/lib/pptx-renderer/parser/zip-parser'
|
||||
|
||||
function createFiles(presentation: string): PptxFiles {
|
||||
return {
|
||||
contentTypes: '<Types />',
|
||||
presentation,
|
||||
presentationRels: `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml" />
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml" />
|
||||
</Relationships>`,
|
||||
slides: new Map([
|
||||
['ppt/slides/slide1.xml', createSlideXml()],
|
||||
['ppt/slides/slide2.xml', createSlideXml()],
|
||||
]),
|
||||
slideRels: new Map([
|
||||
['ppt/slides/_rels/slide1.xml.rels', '<Relationships />'],
|
||||
['ppt/slides/_rels/slide2.xml.rels', '<Relationships />'],
|
||||
]),
|
||||
slideLayouts: new Map(),
|
||||
slideLayoutRels: new Map(),
|
||||
slideMasters: new Map(),
|
||||
slideMasterRels: new Map(),
|
||||
themes: new Map(),
|
||||
media: new Map(),
|
||||
charts: new Map(),
|
||||
chartStyles: new Map(),
|
||||
chartColors: new Map(),
|
||||
diagramDrawings: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function createPresentationXml(markers = ''): string {
|
||||
return `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ${markers}>
|
||||
<p:sldSz cx="9144000" cy="5143500" />
|
||||
<p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rId2" />
|
||||
<p:sldId id="257" r:id="rId1" />
|
||||
</p:sldIdLst>
|
||||
</p:presentation>`
|
||||
}
|
||||
|
||||
function createSlideXml(): string {
|
||||
return `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr><p:cNvPr id="1" name="" /><p:cNvGrpSpPr /><p:nvPr /></p:nvGrpSpPr>
|
||||
<p:grpSpPr />
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>`
|
||||
}
|
||||
|
||||
describe('buildPresentation', () => {
|
||||
it('does not treat the standard wps namespace prefix as WPS Office', () => {
|
||||
const presentation = buildPresentation(
|
||||
createFiles(
|
||||
createPresentationXml(
|
||||
'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expect(presentation.isWps).toBe(false)
|
||||
})
|
||||
|
||||
it('orders slides by relationship id instead of numeric slide id', () => {
|
||||
const presentation = buildPresentation(createFiles(createPresentationXml()))
|
||||
|
||||
expect(presentation.slides.map((slide) => slide.slidePath)).toEqual([
|
||||
'ppt/slides/slide2.xml',
|
||||
'ppt/slides/slide1.xml',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Top-level presentation builder — assembles all parsed components
|
||||
* (themes, masters, layouts, slides) into a single PresentationData structure.
|
||||
*/
|
||||
|
||||
import { parseRels, type RelEntry, resolveRelTarget } from '../parser/rel-parser'
|
||||
import { emuToPx } from '../parser/units'
|
||||
import { parseXml, type SafeXmlNode } from '../parser/xml-parser'
|
||||
import type { PptxFiles } from '../parser/zip-parser'
|
||||
import { type LayoutData, type PlaceholderEntry, parseLayout } from './layout'
|
||||
import { type MasterData, parseMaster } from './master'
|
||||
import type { Position, Size } from './nodes/base-node'
|
||||
import { parseSlide, type SlideData, type SlideNode } from './slide'
|
||||
import { parseTheme, type ThemeData } from './theme'
|
||||
|
||||
export interface PresentationData {
|
||||
width: number
|
||||
height: number
|
||||
slides: SlideData[]
|
||||
layouts: Map<string, LayoutData>
|
||||
masters: Map<string, MasterData>
|
||||
themes: Map<string, ThemeData>
|
||||
slideToLayout: Map<number, string>
|
||||
layoutToMaster: Map<string, string>
|
||||
masterToTheme: Map<string, string>
|
||||
media: Map<string, Uint8Array>
|
||||
tableStyles?: SafeXmlNode
|
||||
charts: Map<string, SafeXmlNode>
|
||||
isWps: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the base directory from a file path.
|
||||
* E.g., "ppt/slides/slide1.xml" → "ppt/slides"
|
||||
*/
|
||||
function basePath(filePath: string): string {
|
||||
const idx = filePath.lastIndexOf('/')
|
||||
return idx >= 0 ? filePath.substring(0, idx) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given XML file path, find its corresponding .rels file path.
|
||||
* E.g., "ppt/slides/slide1.xml" → "ppt/slides/_rels/slide1.xml.rels"
|
||||
*/
|
||||
function relsPathFor(filePath: string): string {
|
||||
const dir = basePath(filePath)
|
||||
const fileName = filePath.substring(filePath.lastIndexOf('/') + 1)
|
||||
return `${dir}/_rels/${fileName}.rels`
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect WPS (Kingsoft Office / WPS Office) by checking for known markers
|
||||
* in the presentation XML string.
|
||||
*/
|
||||
function detectWps(presentationXml: string): boolean {
|
||||
return (
|
||||
/\bKingsoft\b/i.test(presentationXml) ||
|
||||
/\bWPS Office\b/i.test(presentationXml) ||
|
||||
/xmlns:[\w.-]*kso[\w.-]*=/i.test(presentationXml)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a rels entry by type substring match.
|
||||
*/
|
||||
function findRelByType(rels: Map<string, RelEntry>, typeSubstring: string): RelEntry | undefined {
|
||||
for (const [, entry] of rels) {
|
||||
if (entry.type.includes(typeSubstring)) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL rels entries matching a type substring, returning [rId, entry] pairs.
|
||||
*/
|
||||
function findRelsByType(rels: Map<string, RelEntry>, typeSubstring: string): [string, RelEntry][] {
|
||||
const results: [string, RelEntry][] = []
|
||||
for (const [rId, entry] of rels) {
|
||||
if (entry.type.includes(typeSubstring)) {
|
||||
results.push([rId, entry])
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete PresentationData from extracted PPTX files.
|
||||
*
|
||||
* This is the main factory function that wires together all parsed components:
|
||||
* 1. Parses presentation.xml for slide ordering and size
|
||||
* 2. Resolves the full relationship chain: slide → layout → master → theme
|
||||
* 3. Parses each component and assembles the final structure
|
||||
*/
|
||||
export function buildPresentation(files: PptxFiles): PresentationData {
|
||||
// --- Parse presentation root ---
|
||||
const presRoot = parseXml(files.presentation)
|
||||
const presRels = parseRels(files.presentationRels)
|
||||
|
||||
// --- Slide size ---
|
||||
const sldSz = presRoot.child('sldSz')
|
||||
const width = emuToPx(sldSz.numAttr('cx') ?? 9144000) // default 10 inches
|
||||
const height = emuToPx(sldSz.numAttr('cy') ?? 6858000) // default 7.5 inches
|
||||
|
||||
// --- WPS detection ---
|
||||
const isWps = detectWps(files.presentation)
|
||||
|
||||
// --- Parse themes ---
|
||||
const themes = new Map<string, ThemeData>()
|
||||
for (const [themePath, themeXml] of files.themes) {
|
||||
const themeRoot = parseXml(themeXml)
|
||||
themes.set(themePath, parseTheme(themeRoot))
|
||||
}
|
||||
|
||||
// --- Parse slide masters and build master→theme mapping ---
|
||||
const masters = new Map<string, MasterData>()
|
||||
const masterToTheme = new Map<string, string>()
|
||||
|
||||
for (const [masterPath, masterXml] of files.slideMasters) {
|
||||
const masterRoot = parseXml(masterXml)
|
||||
const masterData = parseMaster(masterRoot)
|
||||
|
||||
// Find theme relationship for this master
|
||||
const masterRelsPath = relsPathFor(masterPath)
|
||||
const masterRelsXml = files.slideMasterRels.get(masterRelsPath)
|
||||
if (masterRelsXml) {
|
||||
const masterRels = parseRels(masterRelsXml)
|
||||
masterData.rels = masterRels
|
||||
const themeRel = findRelByType(masterRels, 'theme')
|
||||
if (themeRel) {
|
||||
const themePath = resolveRelTarget(basePath(masterPath), themeRel.target)
|
||||
masterToTheme.set(masterPath, themePath)
|
||||
}
|
||||
}
|
||||
masters.set(masterPath, masterData)
|
||||
}
|
||||
|
||||
// --- Parse slide layouts and build layout→master mapping ---
|
||||
const layouts = new Map<string, LayoutData>()
|
||||
const layoutToMaster = new Map<string, string>()
|
||||
|
||||
for (const [layoutPath, layoutXml] of files.slideLayouts) {
|
||||
const layoutRoot = parseXml(layoutXml)
|
||||
const layoutData = parseLayout(layoutRoot)
|
||||
|
||||
// Find master relationship for this layout
|
||||
const layoutRelsPath = relsPathFor(layoutPath)
|
||||
const layoutRelsXml = files.slideLayoutRels.get(layoutRelsPath)
|
||||
if (layoutRelsXml) {
|
||||
const layoutRels = parseRels(layoutRelsXml)
|
||||
layoutData.rels = layoutRels
|
||||
const masterRel = findRelByType(layoutRels, 'slideMaster')
|
||||
if (masterRel) {
|
||||
const masterPath = resolveRelTarget(basePath(layoutPath), masterRel.target)
|
||||
layoutToMaster.set(layoutPath, masterPath)
|
||||
}
|
||||
}
|
||||
layouts.set(layoutPath, layoutData)
|
||||
}
|
||||
|
||||
// --- Parse charts ---
|
||||
const charts = new Map<string, SafeXmlNode>()
|
||||
for (const [chartPath, chartXml] of files.charts) {
|
||||
const chartRoot = parseXml(chartXml)
|
||||
if (chartRoot.exists()) {
|
||||
charts.set(chartPath, chartRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Determine slide ordering ---
|
||||
// The sldIdLst contains sldId elements with r:id attributes that reference
|
||||
// presentation.xml.rels. We need to handle the fact that the attr might be
|
||||
// stored as 'r:id' in the original XML but SafeXmlNode.attr() uses localName.
|
||||
const sldIdLst = presRoot.child('sldIdLst')
|
||||
const orderedSlideTargets: string[] = []
|
||||
|
||||
for (const sldId of sldIdLst.children('sldId')) {
|
||||
// Try multiple attribute name patterns
|
||||
const rId = sldId.attr('r:id') ?? sldId.attr('id')
|
||||
if (rId) {
|
||||
const relEntry = presRels.get(rId)
|
||||
if (relEntry) {
|
||||
const slidePath = resolveRelTarget('ppt', relEntry.target)
|
||||
orderedSlideTargets.push(slidePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if sldIdLst parsing didn't yield results, use presRels directly
|
||||
if (orderedSlideTargets.length === 0) {
|
||||
const slideRels = findRelsByType(presRels, 'slide')
|
||||
// Sort by rId number to maintain order
|
||||
slideRels.sort((a, b) => {
|
||||
const numA = Number.parseInt(a[0].replace(/\D/g, ''), 10) || 0
|
||||
const numB = Number.parseInt(b[0].replace(/\D/g, ''), 10) || 0
|
||||
return numA - numB
|
||||
})
|
||||
for (const [, entry] of slideRels) {
|
||||
// Only include direct slide relationships, not slideLayout or slideMaster
|
||||
if (
|
||||
entry.type.includes('/slide') &&
|
||||
!entry.type.includes('slideLayout') &&
|
||||
!entry.type.includes('slideMaster')
|
||||
) {
|
||||
const slidePath = resolveRelTarget('ppt', entry.target)
|
||||
orderedSlideTargets.push(slidePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Parse slides ---
|
||||
const slides: SlideData[] = []
|
||||
const slideToLayout = new Map<number, string>()
|
||||
|
||||
for (let i = 0; i < orderedSlideTargets.length; i++) {
|
||||
const slidePath = orderedSlideTargets[i]
|
||||
const slideXml = files.slides.get(slidePath)
|
||||
if (!slideXml) continue
|
||||
|
||||
// Parse slide rels
|
||||
const slideRelsPath = relsPathFor(slidePath)
|
||||
const slideRelsXml = files.slideRels.get(slideRelsPath)
|
||||
const slideRels = slideRelsXml ? parseRels(slideRelsXml) : new Map<string, RelEntry>()
|
||||
|
||||
// Parse slide
|
||||
const slideRoot = parseXml(slideXml)
|
||||
const slideData = parseSlide(slideRoot, i, slideRels, slidePath, files.diagramDrawings)
|
||||
|
||||
// Resolve layout path from the slide's layout relationship target
|
||||
if (slideData.layoutIndex) {
|
||||
const layoutPath = resolveRelTarget(basePath(slidePath), slideData.layoutIndex)
|
||||
slideData.layoutIndex = layoutPath
|
||||
slideToLayout.set(i, layoutPath)
|
||||
}
|
||||
|
||||
slides.push(slideData)
|
||||
}
|
||||
|
||||
// --- Table styles ---
|
||||
let tableStyles: SafeXmlNode | undefined
|
||||
if (files.tableStyles) {
|
||||
const tsRoot = parseXml(files.tableStyles)
|
||||
if (tsRoot.exists()) {
|
||||
tableStyles = tsRoot
|
||||
}
|
||||
}
|
||||
|
||||
const result: PresentationData = {
|
||||
width,
|
||||
height,
|
||||
slides,
|
||||
layouts,
|
||||
masters,
|
||||
themes,
|
||||
slideToLayout,
|
||||
layoutToMaster,
|
||||
masterToTheme,
|
||||
media: files.media,
|
||||
tableStyles,
|
||||
charts,
|
||||
isWps,
|
||||
}
|
||||
|
||||
// --- Resolve placeholder position inheritance ---
|
||||
resolvePlaceholderInheritance(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder Position Inheritance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract placeholder info (type, idx) from a raw placeholder XML node
|
||||
* stored in layout/master.
|
||||
*/
|
||||
function getPhInfo(phNode: SafeXmlNode): { type?: string; idx?: number } {
|
||||
// Try nvSpPr > nvPr > ph, or nvPicPr > nvPr > ph
|
||||
for (const wrapper of ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']) {
|
||||
const nvWrapper = phNode.child(wrapper)
|
||||
if (nvWrapper.exists()) {
|
||||
const nvPr = nvWrapper.child('nvPr')
|
||||
const ph = nvPr.child('ph')
|
||||
if (ph.exists()) {
|
||||
const type = ph.attr('type')
|
||||
const idxStr = ph.attr('idx')
|
||||
const idx = idxStr !== undefined ? Number(idxStr) : undefined
|
||||
return { type, idx: idx !== undefined && !Number.isNaN(idx) ? idx : undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract xfrm position/size from a raw placeholder XML node.
|
||||
*/
|
||||
function getPhXfrm(phNode: SafeXmlNode): { position: Position; size: Size } | undefined {
|
||||
// Try spPr > xfrm first (most shapes)
|
||||
const spPr = phNode.child('spPr')
|
||||
if (spPr.exists()) {
|
||||
const xfrm = spPr.child('xfrm')
|
||||
if (xfrm.exists()) {
|
||||
const off = xfrm.child('off')
|
||||
const ext = xfrm.child('ext')
|
||||
const x = off.numAttr('x')
|
||||
const cx = ext.numAttr('cx')
|
||||
if (x !== undefined && cx !== undefined) {
|
||||
return {
|
||||
position: { x: emuToPx(off.numAttr('x') ?? 0), y: emuToPx(off.numAttr('y') ?? 0) },
|
||||
size: { w: emuToPx(ext.numAttr('cx') ?? 0), h: emuToPx(ext.numAttr('cy') ?? 0) },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching placeholder node by type and idx.
|
||||
* Matching rules (based on OOXML spec):
|
||||
* 1. Exact match on type AND idx
|
||||
* 2. Match on type only (if idx is undefined or not found)
|
||||
* 3. For "body" type, also check idx match only
|
||||
*/
|
||||
function findMatchingPlaceholder(
|
||||
placeholders: SafeXmlNode[],
|
||||
type?: string,
|
||||
idx?: number
|
||||
): SafeXmlNode | undefined {
|
||||
let typeMatch: SafeXmlNode | undefined
|
||||
|
||||
for (const ph of placeholders) {
|
||||
const info = getPhInfo(ph)
|
||||
|
||||
// Exact match (type + idx)
|
||||
if (type !== undefined && info.type === type && idx !== undefined && info.idx === idx) {
|
||||
return ph
|
||||
}
|
||||
|
||||
// Type-only match
|
||||
if (type !== undefined && info.type === type) {
|
||||
if (!typeMatch) typeMatch = ph
|
||||
}
|
||||
|
||||
// idx-only match (for body/content placeholders that may omit type)
|
||||
if (idx !== undefined && info.idx === idx && type === undefined && info.type === undefined) {
|
||||
return ph
|
||||
}
|
||||
}
|
||||
|
||||
// For placeholders without type (defaults to "body"), match by idx
|
||||
if (type === undefined && idx !== undefined) {
|
||||
for (const ph of placeholders) {
|
||||
const info = getPhInfo(ph)
|
||||
if (info.idx === idx) return ph
|
||||
}
|
||||
}
|
||||
|
||||
return typeMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching layout placeholder (PlaceholderEntry); use entry.absoluteXfrm when present.
|
||||
*/
|
||||
function findMatchingLayoutPlaceholder(
|
||||
placeholders: PlaceholderEntry[],
|
||||
type?: string,
|
||||
idx?: number
|
||||
): PlaceholderEntry | undefined {
|
||||
let typeMatch: PlaceholderEntry | undefined
|
||||
|
||||
for (const entry of placeholders) {
|
||||
const info = getPhInfo(entry.node)
|
||||
|
||||
if (type !== undefined && info.type === type && idx !== undefined && info.idx === idx) {
|
||||
return entry
|
||||
}
|
||||
if (type !== undefined && info.type === type && !typeMatch) {
|
||||
typeMatch = entry
|
||||
}
|
||||
if (idx !== undefined && info.idx === idx && type === undefined && info.type === undefined) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
if (type === undefined && idx !== undefined) {
|
||||
for (const entry of placeholders) {
|
||||
if (getPhInfo(entry.node).idx === idx) return entry
|
||||
}
|
||||
}
|
||||
return typeMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk through all slide nodes (including group children recursively)
|
||||
* and fill in missing position/size from layout/master placeholders.
|
||||
*/
|
||||
function resolvePlaceholderInheritance(pres: PresentationData): void {
|
||||
for (let i = 0; i < pres.slides.length; i++) {
|
||||
const slide = pres.slides[i]
|
||||
const layoutPath = pres.slideToLayout.get(i)
|
||||
const layout = layoutPath ? pres.layouts.get(layoutPath) : undefined
|
||||
const masterPath = layoutPath ? pres.layoutToMaster.get(layoutPath) : undefined
|
||||
const master = masterPath ? pres.masters.get(masterPath) : undefined
|
||||
|
||||
resolveNodesPlaceholders(slide.nodes, layout, master)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract bodyPr from a placeholder shape node (layout or master). */
|
||||
function getPhBodyPr(phNode: SafeXmlNode): SafeXmlNode | undefined {
|
||||
const txBody = phNode.child('txBody')
|
||||
if (!txBody.exists()) return undefined
|
||||
const bodyPr = txBody.child('bodyPr')
|
||||
return bodyPr.exists() ? bodyPr : undefined
|
||||
}
|
||||
|
||||
function resolveNodesPlaceholders(
|
||||
nodes: SlideNode[],
|
||||
layout: LayoutData | undefined,
|
||||
master: MasterData | undefined
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
// Recursively handle group children
|
||||
if (node.nodeType === 'group' && 'children' in node) {
|
||||
// Group children are raw SafeXmlNode, not parsed yet — skip
|
||||
// (they get parsed during rendering in GroupRenderer)
|
||||
}
|
||||
|
||||
if (!node.placeholder) continue
|
||||
|
||||
const { type, idx } = node.placeholder
|
||||
const sizeIsEmpty = node.size.w === 0 && node.size.h === 0
|
||||
const positionLooksDefault = node.position.y < 5 // y=0 or near top → use layout position
|
||||
|
||||
if (layout) {
|
||||
const layoutMatch = findMatchingLayoutPlaceholder(layout.placeholders, type, idx)
|
||||
if (layoutMatch) {
|
||||
const xfrm = layoutMatch.absoluteXfrm ?? getPhXfrm(layoutMatch.node)
|
||||
if (xfrm) {
|
||||
if (sizeIsEmpty) {
|
||||
node.position = xfrm.position
|
||||
node.size = xfrm.size
|
||||
} else if (positionLooksDefault) {
|
||||
node.position = xfrm.position
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit bodyPr from layout placeholder for text rendering (anchor, insets, etc.)
|
||||
if ('textBody' in node && node.textBody) {
|
||||
const layoutBodyPr = getPhBodyPr(layoutMatch.node)
|
||||
if (layoutBodyPr) {
|
||||
node.textBody.layoutBodyProperties = layoutBodyPr
|
||||
}
|
||||
}
|
||||
|
||||
if (xfrm) continue
|
||||
}
|
||||
}
|
||||
|
||||
if (master) {
|
||||
const match = findMatchingPlaceholder(master.placeholders, type, idx)
|
||||
if (match) {
|
||||
const xfrm = getPhXfrm(match)
|
||||
if (xfrm) {
|
||||
if (sizeIsEmpty) {
|
||||
node.position = xfrm.position
|
||||
node.size = xfrm.size
|
||||
} else if (positionLooksDefault) {
|
||||
node.position = xfrm.position
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit bodyPr from master placeholder as fallback
|
||||
if ('textBody' in node && node.textBody && !node.textBody.layoutBodyProperties) {
|
||||
const masterBodyPr = getPhBodyPr(match)
|
||||
if (masterBodyPr) {
|
||||
node.textBody.layoutBodyProperties = masterBodyPr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Slide parser — converts a slide XML into a structured SlideData
|
||||
* with typed node objects for each shape on the slide.
|
||||
*/
|
||||
|
||||
import { type RelEntry, resolveRelTarget } from '../parser/rel-parser'
|
||||
import { parseXml, type SafeXmlNode } from '../parser/xml-parser'
|
||||
import { parseBaseProps } from './nodes/base-node'
|
||||
import { type ChartNodeData, parseChartNode } from './nodes/chart-node'
|
||||
import { type GroupNodeData, parseGroupNode } from './nodes/group-node'
|
||||
import { type PicNodeData, parsePicNode } from './nodes/pic-node'
|
||||
import { parseShapeNode, type ShapeNodeData } from './nodes/shape-node'
|
||||
import { parseTableNode, type TableNodeData } from './nodes/table-node'
|
||||
|
||||
export type SlideNode = ShapeNodeData | PicNodeData | TableNodeData | GroupNodeData | ChartNodeData
|
||||
|
||||
export interface SlideData {
|
||||
index: number
|
||||
nodes: SlideNode[]
|
||||
background?: SafeXmlNode
|
||||
layoutIndex: string
|
||||
rels: Map<string, RelEntry>
|
||||
/** Full path to the slide file (e.g. "ppt/slides/slide3.xml"). */
|
||||
slidePath: string
|
||||
/** When false, shapes from the layout and master should NOT be rendered on this slide. */
|
||||
showMasterSp: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a graphicFrame contains a table (`a:tbl`).
|
||||
*/
|
||||
function isTableFrame(node: SafeXmlNode): boolean {
|
||||
const graphic = node.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
return graphicData.child('tbl').exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a graphicFrame contains a chart.
|
||||
*/
|
||||
function isChartFrame(node: SafeXmlNode): boolean {
|
||||
const graphic = node.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
const uri = graphicData.attr('uri') || ''
|
||||
return uri.includes('chart')
|
||||
}
|
||||
|
||||
/**
|
||||
* Find p:pic inside OLE graphicData (mc:AlternateContent > mc:Fallback or mc:Choice > p:oleObj > p:pic).
|
||||
* Returns the pic node if it has blipFill with embed (so we can render the preview image).
|
||||
*/
|
||||
function findOleFallbackPic(graphicFrame: SafeXmlNode): SafeXmlNode | null {
|
||||
const graphic = graphicFrame.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
const uri = graphicData.attr('uri') || ''
|
||||
if (!uri.includes('ole')) return null
|
||||
|
||||
const altContent = graphicData.child('AlternateContent')
|
||||
if (!altContent.exists()) return null
|
||||
|
||||
for (const branch of ['Fallback', 'Choice'] as const) {
|
||||
const oleObj = altContent.child(branch).child('oleObj')
|
||||
if (!oleObj.exists()) continue
|
||||
const pic = oleObj.child('pic')
|
||||
if (!pic.exists()) continue
|
||||
const blipFill = pic.child('blipFill')
|
||||
const blip = blipFill.child('blip')
|
||||
const embed = blip.attr('embed') ?? blip.attr('r:embed')
|
||||
if (embed) return pic
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a graphicFrame that contains an OLE object with a fallback picture (preview image).
|
||||
* Uses the frame's position/size and the inner pic's blip embed.
|
||||
* Exported for use in GroupRenderer when parsing group children.
|
||||
*/
|
||||
export function parseOleFrameAsPicture(graphicFrame: SafeXmlNode): PicNodeData | undefined {
|
||||
const pic = findOleFallbackPic(graphicFrame)
|
||||
if (!pic) return undefined
|
||||
|
||||
const base = parseBaseProps(graphicFrame)
|
||||
const blipFill = pic.child('blipFill')
|
||||
const blip = blipFill.child('blip')
|
||||
const blipEmbed = blip.attr('embed') ?? blip.attr('r:embed')
|
||||
const blipLink = blip.attr('link') ?? blip.attr('r:link')
|
||||
if (!blipEmbed) return undefined
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'picture',
|
||||
blipEmbed,
|
||||
blipLink,
|
||||
source: graphicFrame,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a graphicFrame contains a SmartArt diagram.
|
||||
*/
|
||||
function isDiagramFrame(node: SafeXmlNode): boolean {
|
||||
const graphic = node.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
const uri = graphicData.attr('uri') || ''
|
||||
return uri.includes('diagram')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SmartArt diagram graphicFrame by resolving the diagram drawing fallback XML.
|
||||
* The drawing XML contains pre-rendered shapes in a spTree that we can display as a group.
|
||||
*/
|
||||
function parseDiagramFrame(
|
||||
graphicFrame: SafeXmlNode,
|
||||
rels: Map<string, RelEntry>,
|
||||
slidePath: string,
|
||||
diagramDrawings: Map<string, string>
|
||||
): GroupNodeData | undefined {
|
||||
const base = parseBaseProps(graphicFrame)
|
||||
const slideDir = slidePath.substring(0, slidePath.lastIndexOf('/'))
|
||||
const drawingCandidates = Array.from(rels.values())
|
||||
.filter(
|
||||
(entry) => entry.type.includes('diagramDrawing') || entry.target.includes('diagrams/drawing')
|
||||
)
|
||||
.map((entry) => {
|
||||
const target = entry.target
|
||||
const match = target.match(/drawing(\d+)/)
|
||||
return {
|
||||
target,
|
||||
num: match ? Number.parseInt(match[1], 10) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the diagram data rId from the relIds element to identify which diagram this is
|
||||
const graphic = graphicFrame.child('graphic')
|
||||
const graphicData = graphic.child('graphicData')
|
||||
const relIds = graphicData.child('relIds')
|
||||
|
||||
// Strategy 1: Match data file number to drawing file number
|
||||
// e.g. data3.xml → drawing3.xml
|
||||
if (relIds.exists()) {
|
||||
const dmRId = relIds.attr('r:dm') ?? relIds.attr('dm')
|
||||
if (dmRId) {
|
||||
const dmRel = rels.get(dmRId)
|
||||
if (dmRel) {
|
||||
// Extract the number from the data target (e.g. "data3" → "3")
|
||||
const numMatch = dmRel.target.match(/data(\d+)/)
|
||||
if (numMatch) {
|
||||
const drawingNum = Number.parseInt(numMatch[1], 10)
|
||||
// Prefer exact drawingN; if absent, use the nearest numbered drawing relation.
|
||||
const ordered = drawingCandidates.slice().sort((a, b) => {
|
||||
const da = a.num === undefined ? Number.POSITIVE_INFINITY : Math.abs(a.num - drawingNum)
|
||||
const db = b.num === undefined ? Number.POSITIVE_INFINITY : Math.abs(b.num - drawingNum)
|
||||
return da - db
|
||||
})
|
||||
for (const candidate of ordered) {
|
||||
const drawingPath = resolveRelTarget(slideDir, candidate.target)
|
||||
const drawingXml = diagramDrawings.get(drawingPath)
|
||||
if (drawingXml) {
|
||||
return buildDiagramGroup(base, drawingXml)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Fallback - find any diagramDrawing relationship
|
||||
for (const candidate of drawingCandidates) {
|
||||
const drawingPath = resolveRelTarget(slideDir, candidate.target)
|
||||
const drawingXml = diagramDrawings.get(drawingPath)
|
||||
if (drawingXml) {
|
||||
return buildDiagramGroup(base, drawingXml)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GroupNodeData from a diagram drawing XML string.
|
||||
* Diagram drawings use dsp: namespace (drawingml 2008); structure is dsp:drawing > dsp:spTree > dsp:sp.
|
||||
* Diagram shapes are positioned in the graphicFrame's own coordinate space.
|
||||
*/
|
||||
function buildDiagramGroup(
|
||||
base: ReturnType<typeof parseBaseProps>,
|
||||
drawingXml: string
|
||||
): GroupNodeData {
|
||||
const drawingRoot = parseXml(drawingXml)
|
||||
const spTree = drawingRoot.child('spTree')
|
||||
if (!spTree.exists()) {
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'group',
|
||||
childOffset: { x: 0, y: 0 },
|
||||
childExtent: { w: base.size.w, h: base.size.h },
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
const CHILD_TAGS = new Set(['sp', 'pic', 'grpSp', 'graphicFrame', 'cxnSp'])
|
||||
const children: SafeXmlNode[] = []
|
||||
|
||||
for (const child of spTree.allChildren()) {
|
||||
if (CHILD_TAGS.has(child.localName)) {
|
||||
children.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the graphicFrame's own dimensions as the child coordinate space.
|
||||
// Diagram shapes are positioned in the frame's coordinate space (EMU converted to px).
|
||||
// Using frame dimensions gives a 1:1 scale, preserving original positions and sizes.
|
||||
// This avoids enlarging shapes when the bounding box is smaller than the frame.
|
||||
const extentW = Math.max(1, base.size.w)
|
||||
const extentH = Math.max(1, base.size.h)
|
||||
|
||||
return {
|
||||
...base,
|
||||
nodeType: 'group',
|
||||
childOffset: { x: 0, y: 0 },
|
||||
childExtent: { w: extentW, h: extentH },
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single child node from spTree, dispatching to the appropriate parser.
|
||||
*/
|
||||
function parseChildNode(
|
||||
child: SafeXmlNode,
|
||||
rels: Map<string, RelEntry>,
|
||||
slidePath: string,
|
||||
diagramDrawings?: Map<string, string>
|
||||
): SlideNode | undefined {
|
||||
const tag = child.localName
|
||||
|
||||
switch (tag) {
|
||||
case 'sp':
|
||||
case 'cxnSp':
|
||||
return parseShapeNode(child)
|
||||
case 'pic':
|
||||
return parsePicNode(child)
|
||||
case 'grpSp':
|
||||
return parseGroupNode(child)
|
||||
case 'graphicFrame':
|
||||
if (isTableFrame(child)) {
|
||||
return parseTableNode(child)
|
||||
}
|
||||
if (isChartFrame(child)) {
|
||||
return parseChartNode(child, rels, slidePath)
|
||||
}
|
||||
// SmartArt diagram with drawing fallback
|
||||
if (isDiagramFrame(child) && diagramDrawings) {
|
||||
return parseDiagramFrame(child, rels, slidePath, diagramDrawings)
|
||||
}
|
||||
// OLE object with fallback picture (e.g. embedded PDF preview on slide 34)
|
||||
{
|
||||
const olePic = parseOleFrameAsPicture(child)
|
||||
if (olePic) return olePic
|
||||
}
|
||||
// Non-table/chart/ole graphic frames — skip
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the layout relationship target from a slide's rels map.
|
||||
* The relationship type URI for slide layouts ends with "slideLayout".
|
||||
*/
|
||||
function findLayoutRel(rels: Map<string, RelEntry>): string {
|
||||
for (const [, entry] of rels) {
|
||||
if (entry.type.includes('slideLayout')) {
|
||||
return entry.target
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a slide XML root (`p:sld`) into SlideData.
|
||||
*
|
||||
* @param root Parsed XML root of the slide
|
||||
* @param index Zero-based slide index
|
||||
* @param rels Relationship entries for this slide
|
||||
* @param slidePath Full path to the slide file (e.g. "ppt/slides/slide1.xml")
|
||||
*/
|
||||
export function parseSlide(
|
||||
root: SafeXmlNode,
|
||||
index: number,
|
||||
rels: Map<string, RelEntry>,
|
||||
slidePath = '',
|
||||
diagramDrawings?: Map<string, string>
|
||||
): SlideData {
|
||||
const cSld = root.child('cSld')
|
||||
|
||||
// --- Background ---
|
||||
const bg = cSld.child('bg')
|
||||
const background = bg.exists() ? bg : undefined
|
||||
|
||||
// --- Parse shape tree children ---
|
||||
const spTree = cSld.child('spTree')
|
||||
const nodes: SlideNode[] = []
|
||||
|
||||
for (const child of spTree.allChildren()) {
|
||||
const node = parseChildNode(child, rels, slidePath, diagramDrawings)
|
||||
if (node) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout relationship ---
|
||||
const layoutIndex = findLayoutRel(rels)
|
||||
|
||||
// --- showMasterSp: if "0", layout/master shapes should not be rendered on this slide ---
|
||||
const showMasterSpAttr = root.attr('showMasterSp')
|
||||
const showMasterSp = showMasterSpAttr !== '0'
|
||||
|
||||
return {
|
||||
index,
|
||||
nodes,
|
||||
background,
|
||||
layoutIndex,
|
||||
rels,
|
||||
slidePath,
|
||||
showMasterSp,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Theme parser — extracts color scheme and font definitions from a:theme XML.
|
||||
*/
|
||||
|
||||
import type { SafeXmlNode } from '../parser/xml-parser'
|
||||
|
||||
export interface ThemeData {
|
||||
colorScheme: Map<string, string>
|
||||
majorFont: { latin: string; ea: string; cs: string }
|
||||
minorFont: { latin: string; ea: string; cs: string }
|
||||
fillStyles: SafeXmlNode[] // from a:fillStyleLst children (indexed 1-based)
|
||||
lineStyles: SafeXmlNode[] // from a:lnStyleLst children (indexed 1-based)
|
||||
effectStyles: SafeXmlNode[] // from a:effectStyleLst children (indexed 1-based)
|
||||
}
|
||||
|
||||
/** Known color scheme slot names in a:clrScheme. */
|
||||
const COLOR_SLOTS = [
|
||||
'dk1',
|
||||
'dk2',
|
||||
'lt1',
|
||||
'lt2',
|
||||
'accent1',
|
||||
'accent2',
|
||||
'accent3',
|
||||
'accent4',
|
||||
'accent5',
|
||||
'accent6',
|
||||
'hlink',
|
||||
'folHlink',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Extract a hex color value from a color definition node.
|
||||
* Handles both `a:srgbClr@val` and `a:sysClr@lastClr`.
|
||||
*/
|
||||
function extractColor(node: SafeXmlNode): string | undefined {
|
||||
const srgb = node.child('srgbClr')
|
||||
if (srgb.exists()) {
|
||||
return srgb.attr('val')
|
||||
}
|
||||
const sys = node.child('sysClr')
|
||||
if (sys.exists()) {
|
||||
return sys.attr('lastClr') ?? sys.attr('val')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse font info from a majorFont or minorFont node.
|
||||
* Extracts typeface attributes from latin, ea, and cs child elements.
|
||||
*/
|
||||
function parseFontInfo(fontNode: SafeXmlNode): { latin: string; ea: string; cs: string } {
|
||||
return {
|
||||
latin: fontNode.child('latin').attr('typeface') ?? '',
|
||||
ea: fontNode.child('ea').attr('typeface') ?? '',
|
||||
cs: fontNode.child('cs').attr('typeface') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a theme XML root (`a:theme`) into ThemeData.
|
||||
*/
|
||||
export function parseTheme(root: SafeXmlNode): ThemeData {
|
||||
const themeElements = root.child('themeElements')
|
||||
|
||||
// --- Color scheme ---
|
||||
const clrScheme = themeElements.child('clrScheme')
|
||||
const colorScheme = new Map<string, string>()
|
||||
|
||||
for (const slot of COLOR_SLOTS) {
|
||||
const slotNode = clrScheme.child(slot)
|
||||
if (slotNode.exists()) {
|
||||
const hex = extractColor(slotNode)
|
||||
if (hex !== undefined) {
|
||||
colorScheme.set(slot, hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Font scheme ---
|
||||
const fontScheme = themeElements.child('fontScheme')
|
||||
const majorFont = parseFontInfo(fontScheme.child('majorFont'))
|
||||
const minorFont = parseFontInfo(fontScheme.child('minorFont'))
|
||||
|
||||
// --- Format scheme ---
|
||||
const fmtScheme = themeElements.child('fmtScheme')
|
||||
const fillStyleLst = fmtScheme.child('fillStyleLst')
|
||||
const fillStyles: SafeXmlNode[] = fillStyleLst.allChildren()
|
||||
const lnStyleLst = fmtScheme.child('lnStyleLst')
|
||||
const lineStyles: SafeXmlNode[] = lnStyleLst.allChildren()
|
||||
const effectStyleLst = fmtScheme.child('effectStyleLst')
|
||||
const effectStyles: SafeXmlNode[] = effectStyleLst.allChildren()
|
||||
|
||||
return { colorScheme, majorFont, minorFont, fillStyles, lineStyles, effectStyles }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SafeXmlNode } from '../parser/xml-parser'
|
||||
|
||||
/**
|
||||
* Check whether a shape-like node contains a placeholder definition.
|
||||
*/
|
||||
export function isPlaceholder(node: SafeXmlNode): boolean {
|
||||
const nvSpPr = node.child('nvSpPr')
|
||||
if (nvSpPr.exists()) {
|
||||
const nvPr = nvSpPr.child('nvPr')
|
||||
if (nvPr.child('ph').exists()) return true
|
||||
}
|
||||
const nvPicPr = node.child('nvPicPr')
|
||||
if (nvPicPr.exists()) {
|
||||
const nvPr = nvPicPr.child('nvPr')
|
||||
if (nvPr.child('ph').exists()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all attributes of a node into a local-name keyed map.
|
||||
*/
|
||||
export function parseAllAttributes(node: SafeXmlNode): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
const el = node.element
|
||||
if (!el) return result
|
||||
const attrs = el.attributes
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
const attr = attrs[i]
|
||||
result.set(attr.localName, attr.value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Parser for .rels (Relationship) XML files in OOXML packages.
|
||||
* These files map relationship IDs (rId1, rId2, ...) to targets.
|
||||
*/
|
||||
|
||||
import { parseXml } from './xml-parser'
|
||||
|
||||
export interface RelEntry {
|
||||
type: string
|
||||
target: string
|
||||
targetMode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .rels XML string into a Map of relationship ID -> RelEntry.
|
||||
*
|
||||
* Example input:
|
||||
* ```xml
|
||||
* <Relationships xmlns="...">
|
||||
* <Relationship Id="rId1" Type="http://...slide" Target="slides/slide1.xml"/>
|
||||
* </Relationships>
|
||||
* ```
|
||||
*/
|
||||
export function parseRels(xmlString: string): Map<string, RelEntry> {
|
||||
const result = new Map<string, RelEntry>()
|
||||
|
||||
if (!xmlString) return result
|
||||
|
||||
const root = parseXml(xmlString)
|
||||
if (!root.exists()) return result
|
||||
|
||||
const relationships = root.children('Relationship')
|
||||
for (const rel of relationships) {
|
||||
const id = rel.attr('Id')
|
||||
const type = rel.attr('Type')
|
||||
const target = rel.attr('Target')
|
||||
const targetMode = rel.attr('TargetMode')
|
||||
|
||||
if (id && type !== undefined && target !== undefined) {
|
||||
result.set(id, { type, target, targetMode })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative target path against a base path.
|
||||
*
|
||||
* Examples:
|
||||
* resolveRelTarget('ppt/slides', '../slideLayouts/slideLayout1.xml')
|
||||
* → 'ppt/slideLayouts/slideLayout1.xml'
|
||||
*
|
||||
* resolveRelTarget('ppt/slides', 'media/image1.png')
|
||||
* → 'ppt/slides/media/image1.png'
|
||||
*
|
||||
* resolveRelTarget('ppt', 'slides/slide1.xml')
|
||||
* → 'ppt/slides/slide1.xml'
|
||||
*/
|
||||
export function resolveRelTarget(basePath: string, target: string): string {
|
||||
// Absolute targets (start with /) are returned as-is (strip leading /)
|
||||
if (target.startsWith('/')) {
|
||||
return target.slice(1)
|
||||
}
|
||||
|
||||
// Split the base path into segments
|
||||
const baseParts = basePath.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const targetParts = target.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
|
||||
// Walk through target parts, resolving '..' by popping base parts
|
||||
const resolved = [...baseParts]
|
||||
for (const part of targetParts) {
|
||||
if (part === '..') {
|
||||
resolved.pop()
|
||||
} else if (part !== '.') {
|
||||
resolved.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return resolved.join('/')
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Unit conversion utilities for OOXML / PPTX.
|
||||
*
|
||||
* PPTX uses several unit systems:
|
||||
* - EMU (English Metric Units): 1 inch = 914400 EMU
|
||||
* - Points: 1 inch = 72 pt
|
||||
* - Hundredths of a point: used for font sizes
|
||||
* - 60000ths of a degree: used for angles
|
||||
* - 100000ths (percentage): used for scale factors
|
||||
*/
|
||||
|
||||
/** EMU to pixels (at 96 DPI). */
|
||||
export function emuToPx(emu: number): number {
|
||||
return (emu / 914400) * 96
|
||||
}
|
||||
|
||||
/** EMU to points. */
|
||||
export function emuToPt(emu: number): number {
|
||||
return emu / 12700
|
||||
}
|
||||
|
||||
/** OOXML angle (60000ths of a degree) to degrees. */
|
||||
export function angleToDeg(angle: number): number {
|
||||
return angle / 60000
|
||||
}
|
||||
|
||||
/** OOXML percentage (100000ths) to a decimal fraction (0..1 range for 0%..100%). */
|
||||
export function pctToDecimal(pct: number): number {
|
||||
return pct / 100000
|
||||
}
|
||||
|
||||
/** Hundredths of a point to points (used for font sizes in OOXML). */
|
||||
export function hundredthPtToPt(val: number): number {
|
||||
return val / 100
|
||||
}
|
||||
|
||||
/** Points to pixels (at 96 DPI). */
|
||||
export function ptToPx(pt: number): number {
|
||||
return (pt * 96) / 72
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: detect whether a value is in EMU or points.
|
||||
* Values with abs > 20000 are almost certainly EMU (a single point = 12700 EMU).
|
||||
*/
|
||||
export function detectUnit(value: number): 'emu' | 'point' {
|
||||
return Math.abs(value) > 20000 ? 'emu' : 'point'
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart conversion to pixels: auto-detects whether the value is EMU or points
|
||||
* and converts accordingly.
|
||||
*/
|
||||
export function smartToPx(value: number): number {
|
||||
if (detectUnit(value) === 'emu') {
|
||||
return emuToPx(value)
|
||||
}
|
||||
return ptToPx(value)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('PptxXmlParser')
|
||||
|
||||
/**
|
||||
* Safe XML parser using browser DOMParser.
|
||||
* All operations are null-safe — accessing missing elements never crashes.
|
||||
*/
|
||||
|
||||
export class SafeXmlNode {
|
||||
private readonly el: Element | null
|
||||
|
||||
constructor(el: Element | null) {
|
||||
this.el = el
|
||||
}
|
||||
|
||||
/** Get a string attribute value, or undefined if missing. */
|
||||
attr(name: string): string | undefined {
|
||||
if (!this.el) return undefined
|
||||
return this.el.hasAttribute(name) ? this.el.getAttribute(name)! : undefined
|
||||
}
|
||||
|
||||
/** Get a numeric attribute value, or undefined if missing or not a number. */
|
||||
numAttr(name: string): number | undefined {
|
||||
const raw = this.attr(name)
|
||||
if (raw === undefined) return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first child element matching the given localName (namespace-agnostic).
|
||||
* Returns an empty SafeXmlNode if not found, so chaining never crashes.
|
||||
*/
|
||||
child(localName: string): SafeXmlNode {
|
||||
if (!this.el) return new SafeXmlNode(null)
|
||||
const children = this.el.children
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].localName === localName) {
|
||||
return new SafeXmlNode(children[i])
|
||||
}
|
||||
}
|
||||
return new SafeXmlNode(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get child elements, optionally filtered by localName (namespace-agnostic).
|
||||
* If no localName is given, returns all direct child elements.
|
||||
*/
|
||||
children(localName?: string): SafeXmlNode[] {
|
||||
if (!this.el) return []
|
||||
const result: SafeXmlNode[] = []
|
||||
const children = this.el.children
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (localName === undefined || children[i].localName === localName) {
|
||||
result.push(new SafeXmlNode(children[i]))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the text content, or empty string if the element is missing. */
|
||||
text(): string {
|
||||
if (!this.el) return ''
|
||||
return this.el.textContent ?? ''
|
||||
}
|
||||
|
||||
/** Whether the underlying element actually exists. */
|
||||
exists(): boolean {
|
||||
return this.el !== null
|
||||
}
|
||||
|
||||
/** All direct child elements as SafeXmlNode[]. */
|
||||
allChildren(): SafeXmlNode[] {
|
||||
return this.children()
|
||||
}
|
||||
|
||||
/** The localName of the underlying element, or empty string. */
|
||||
get localName(): string {
|
||||
return this.el?.localName ?? ''
|
||||
}
|
||||
|
||||
/** Raw access to the underlying Element (may be null). */
|
||||
get element(): Element | null {
|
||||
return this.el
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an XML string into a SafeXmlNode wrapping the document element.
|
||||
* Uses the browser's built-in DOMParser.
|
||||
*/
|
||||
export function parseXml(xmlString: string): SafeXmlNode {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlString, 'application/xml')
|
||||
|
||||
// Check for parser errors — DOMParser returns a parsererror document on failure
|
||||
const errorNode = doc.querySelector('parsererror')
|
||||
if (errorNode) {
|
||||
logger.warn('XML parse error', { error: errorNode.textContent ?? '' })
|
||||
return new SafeXmlNode(null)
|
||||
}
|
||||
|
||||
return new SafeXmlNode(doc.documentElement)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseZip } from '@/lib/pptx-renderer/parser/zip-parser'
|
||||
|
||||
async function createZip(entries: Record<string, string | Uint8Array>): Promise<ArrayBuffer> {
|
||||
const zip = new JSZip()
|
||||
for (const [path, content] of Object.entries(entries)) {
|
||||
zip.file(path, content)
|
||||
}
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
}
|
||||
|
||||
describe('parseZip', () => {
|
||||
it('extracts PPTX package parts into categorized maps', async () => {
|
||||
const buffer = await createZip({
|
||||
'[Content_Types].xml': '<Types />',
|
||||
'ppt/presentation.xml': '<p:presentation />',
|
||||
'ppt/_rels/presentation.xml.rels': '<Relationships />',
|
||||
'ppt/slides/slide1.xml': '<p:sld />',
|
||||
'ppt/slides/_rels/slide1.xml.rels': '<Relationships />',
|
||||
'ppt/media/image1.png': new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
const files = await parseZip(buffer)
|
||||
|
||||
expect(files.contentTypes).toBe('<Types />')
|
||||
expect(files.presentation).toBe('<p:presentation />')
|
||||
expect(files.slides.get('ppt/slides/slide1.xml')).toBe('<p:sld />')
|
||||
expect(files.slideRels.get('ppt/slides/_rels/slide1.xml.rels')).toBe('<Relationships />')
|
||||
expect(files.media.get('ppt/media/image1.png')).toEqual(new Uint8Array([1, 2, 3]))
|
||||
})
|
||||
|
||||
it('rejects archives that exceed entry limits', async () => {
|
||||
const buffer = await createZip({
|
||||
'[Content_Types].xml': '<Types />',
|
||||
'ppt/presentation.xml': '<p:presentation />',
|
||||
})
|
||||
|
||||
await expect(parseZip(buffer, { maxEntries: 1 })).rejects.toThrow(
|
||||
'PPTX zip limit exceeded: entries 2 > maxEntries 1'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects archives that exceed media byte limits', async () => {
|
||||
const buffer = await createZip({
|
||||
'ppt/media/image1.png': new Uint8Array([1, 2, 3, 4]),
|
||||
})
|
||||
|
||||
await expect(parseZip(buffer, { maxMediaBytes: 3 })).rejects.toThrow('PPTX zip limit exceeded')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* PPTX zip archive parser.
|
||||
* Extracts and categorizes all files from a .pptx (which is a zip archive).
|
||||
*/
|
||||
|
||||
import type { JSZipObject } from 'jszip'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface PptxFiles {
|
||||
contentTypes: string
|
||||
presentation: string
|
||||
presentationRels: string
|
||||
slides: Map<string, string>
|
||||
slideRels: Map<string, string>
|
||||
slideLayouts: Map<string, string>
|
||||
slideLayoutRels: Map<string, string>
|
||||
slideMasters: Map<string, string>
|
||||
slideMasterRels: Map<string, string>
|
||||
themes: Map<string, string>
|
||||
media: Map<string, Uint8Array>
|
||||
tableStyles?: string
|
||||
charts: Map<string, string> // ppt/charts/chart*.xml
|
||||
chartStyles: Map<string, string> // ppt/charts/style*.xml
|
||||
chartColors: Map<string, string> // ppt/charts/colors*.xml
|
||||
diagramDrawings: Map<string, string> // ppt/diagrams/drawing*.xml (SmartArt fallback)
|
||||
}
|
||||
|
||||
export interface ZipParseLimits {
|
||||
/** Maximum number of non-directory entries in the zip archive. */
|
||||
maxEntries?: number
|
||||
/** Maximum uncompressed size for any single entry (bytes). */
|
||||
maxEntryUncompressedBytes?: number
|
||||
/** Maximum total uncompressed size across all entries (bytes). */
|
||||
maxTotalUncompressedBytes?: number
|
||||
/** Maximum uncompressed size across media entries under `ppt/media/` (bytes). */
|
||||
maxMediaBytes?: number
|
||||
/** Maximum concurrent zip entry reads during parsing. */
|
||||
maxConcurrency?: number
|
||||
}
|
||||
|
||||
function throwZipLimitExceeded(reason: string): never {
|
||||
throw new Error(`PPTX zip limit exceeded: ${reason}`)
|
||||
}
|
||||
|
||||
function readUncompressedSize(file: JSZipObject): number | undefined {
|
||||
const data = (file as JSZipObject & { _data?: { uncompressedSize?: number } })._data
|
||||
const size = data?.uncompressedSize
|
||||
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return
|
||||
const workerCount = Math.min(concurrency, items.length)
|
||||
let cursor = 0
|
||||
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor++
|
||||
if (index >= items.length) return
|
||||
await mapper(items[index])
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .pptx file buffer and extract all relevant files, categorized by type.
|
||||
*/
|
||||
export async function parseZip(
|
||||
buffer: ArrayBuffer,
|
||||
limits: ZipParseLimits = {}
|
||||
): Promise<PptxFiles> {
|
||||
const maxConcurrency = limits.maxConcurrency ?? 8
|
||||
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
||||
throwZipLimitExceeded(`maxConcurrency ${limits.maxConcurrency} must be an integer >= 1`)
|
||||
}
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const entries = Object.entries(zip.files).filter(([, file]) => !file.dir)
|
||||
|
||||
if (limits.maxEntries !== undefined && entries.length > limits.maxEntries) {
|
||||
throwZipLimitExceeded(`entries ${entries.length} > maxEntries ${limits.maxEntries}`)
|
||||
}
|
||||
|
||||
const knownSizeByPath = new Map<string, number>()
|
||||
let knownTotalBytes = 0
|
||||
let knownMediaBytes = 0
|
||||
|
||||
for (const [rawPath, file] of entries) {
|
||||
const normalizedPath = rawPath.replace(/\\/g, '/')
|
||||
const size = readUncompressedSize(file)
|
||||
if (size === undefined) continue
|
||||
|
||||
knownSizeByPath.set(normalizedPath, size)
|
||||
|
||||
if (limits.maxEntryUncompressedBytes !== undefined && size > limits.maxEntryUncompressedBytes) {
|
||||
throwZipLimitExceeded(
|
||||
`${normalizedPath} is ${size} bytes > maxEntryUncompressedBytes ${limits.maxEntryUncompressedBytes}`
|
||||
)
|
||||
}
|
||||
|
||||
knownTotalBytes += size
|
||||
if (
|
||||
limits.maxTotalUncompressedBytes !== undefined &&
|
||||
knownTotalBytes > limits.maxTotalUncompressedBytes
|
||||
) {
|
||||
throwZipLimitExceeded(
|
||||
`total uncompressed bytes ${knownTotalBytes} > maxTotalUncompressedBytes ${limits.maxTotalUncompressedBytes}`
|
||||
)
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('ppt/media/')) {
|
||||
knownMediaBytes += size
|
||||
if (limits.maxMediaBytes !== undefined && knownMediaBytes > limits.maxMediaBytes) {
|
||||
throwZipLimitExceeded(
|
||||
`media bytes ${knownMediaBytes} > maxMediaBytes ${limits.maxMediaBytes}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: PptxFiles = {
|
||||
contentTypes: '',
|
||||
presentation: '',
|
||||
presentationRels: '',
|
||||
slides: new Map(),
|
||||
slideRels: new Map(),
|
||||
slideLayouts: new Map(),
|
||||
slideLayoutRels: new Map(),
|
||||
slideMasters: new Map(),
|
||||
slideMasterRels: new Map(),
|
||||
themes: new Map(),
|
||||
media: new Map(),
|
||||
charts: new Map(),
|
||||
chartStyles: new Map(),
|
||||
chartColors: new Map(),
|
||||
diagramDrawings: new Map(),
|
||||
}
|
||||
|
||||
let unknownMediaBytes = 0
|
||||
|
||||
await mapWithConcurrency(entries, maxConcurrency, async ([path, file]) => {
|
||||
const normalizedPath = path.replace(/\\/g, '/')
|
||||
|
||||
// --- Content Types ---
|
||||
if (normalizedPath === '[Content_Types].xml') {
|
||||
result.contentTypes = await file.async('string')
|
||||
return
|
||||
}
|
||||
|
||||
// --- Presentation ---
|
||||
if (normalizedPath === 'ppt/presentation.xml') {
|
||||
result.presentation = await file.async('string')
|
||||
return
|
||||
}
|
||||
|
||||
// --- Presentation Rels ---
|
||||
if (normalizedPath === 'ppt/_rels/presentation.xml.rels') {
|
||||
result.presentationRels = await file.async('string')
|
||||
return
|
||||
}
|
||||
|
||||
// --- Table Styles ---
|
||||
if (normalizedPath === 'ppt/tableStyles.xml') {
|
||||
result.tableStyles = await file.async('string')
|
||||
return
|
||||
}
|
||||
|
||||
// --- Media (binary) ---
|
||||
if (normalizedPath.startsWith('ppt/media/')) {
|
||||
const bytes = await file.async('uint8array')
|
||||
if (!knownSizeByPath.has(normalizedPath)) {
|
||||
const size = bytes.byteLength
|
||||
if (
|
||||
limits.maxEntryUncompressedBytes !== undefined &&
|
||||
size > limits.maxEntryUncompressedBytes
|
||||
) {
|
||||
throwZipLimitExceeded(
|
||||
`${normalizedPath} is ${size} bytes > maxEntryUncompressedBytes ${limits.maxEntryUncompressedBytes}`
|
||||
)
|
||||
}
|
||||
unknownMediaBytes += size
|
||||
if (
|
||||
limits.maxMediaBytes !== undefined &&
|
||||
knownMediaBytes + unknownMediaBytes > limits.maxMediaBytes
|
||||
) {
|
||||
throwZipLimitExceeded(
|
||||
`media bytes ${knownMediaBytes + unknownMediaBytes} > maxMediaBytes ${limits.maxMediaBytes}`
|
||||
)
|
||||
}
|
||||
}
|
||||
result.media.set(normalizedPath, bytes)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slide Rels (must check before slides to avoid false match) ---
|
||||
if (/^ppt\/slides\/_rels\/slide\d+\.xml\.rels$/.test(normalizedPath)) {
|
||||
result.slideRels.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slides ---
|
||||
if (/^ppt\/slides\/slide\d+\.xml$/.test(normalizedPath)) {
|
||||
result.slides.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slide Layout Rels ---
|
||||
if (/^ppt\/slideLayouts\/_rels\/slideLayout\d+\.xml\.rels$/.test(normalizedPath)) {
|
||||
result.slideLayoutRels.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slide Layouts ---
|
||||
if (/^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(normalizedPath)) {
|
||||
result.slideLayouts.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slide Master Rels ---
|
||||
if (/^ppt\/slideMasters\/_rels\/slideMaster\d+\.xml\.rels$/.test(normalizedPath)) {
|
||||
result.slideMasterRels.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Slide Masters ---
|
||||
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(normalizedPath)) {
|
||||
result.slideMasters.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Themes ---
|
||||
if (/^ppt\/theme\/theme\d+\.xml$/.test(normalizedPath)) {
|
||||
result.themes.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Charts ---
|
||||
if (/^ppt\/charts\/chart\d+\.xml$/.test(normalizedPath)) {
|
||||
result.charts.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Chart Styles ---
|
||||
if (/^ppt\/charts\/style\d+\.xml$/.test(normalizedPath)) {
|
||||
result.chartStyles.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Chart Colors ---
|
||||
if (/^ppt\/charts\/colors\d+\.xml$/.test(normalizedPath)) {
|
||||
result.chartColors.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
|
||||
// --- Diagram Drawings (SmartArt fallback) ---
|
||||
if (/^ppt\/diagrams\/drawing\d+\.xml$/.test(normalizedPath)) {
|
||||
result.diagramDrawings.set(normalizedPath, await file.async('string'))
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -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, 0–100000 = 0–100%)
|
||||
* and `contrast` (multiplicative contrast, -100000 to 100000).
|
||||
* e.g. bright="100000" makes the entire image white (preserving alpha).
|
||||
*/
|
||||
function applyLumEffect(lum: SafeXmlNode, img: HTMLImageElement): void {
|
||||
const bright = (lum.numAttr('bright') ?? 0) / 100000 // 0–1
|
||||
const contrast = (lum.numAttr('contrast') ?? 0) / 100000 // -1 to 1
|
||||
|
||||
if (bright === 0 && contrast === 0) return
|
||||
|
||||
const apply = () => {
|
||||
const w = img.naturalWidth
|
||||
const h = img.naturalHeight
|
||||
if (!w || !h) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const c = canvas.getContext('2d')
|
||||
if (!c) return
|
||||
|
||||
c.drawImage(img, 0, 0)
|
||||
const imageData = c.getImageData(0, 0, w, h)
|
||||
const data = imageData.data
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
// Normalize to 0–1
|
||||
let v = data[i + ch] / 255
|
||||
// Apply contrast (expand/compress around 0.5)
|
||||
if (contrast !== 0) {
|
||||
v = 0.5 + (v - 0.5) * (1 + contrast)
|
||||
}
|
||||
// Apply additive brightness offset
|
||||
v += bright
|
||||
data[i + ch] = Math.round(Math.max(0, Math.min(255, v * 255)))
|
||||
}
|
||||
// Alpha preserved
|
||||
}
|
||||
|
||||
c.putImageData(imageData, 0, 0)
|
||||
img.src = canvas.toDataURL()
|
||||
}
|
||||
|
||||
if (img.complete && img.naturalWidth) {
|
||||
apply()
|
||||
} else {
|
||||
img.addEventListener('load', apply, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BiLevel Effect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a bi-level (threshold) effect to an image.
|
||||
*
|
||||
* OOXML `<a:biLevel thresh="25000">` converts the image to black and white.
|
||||
* Each pixel's luminance is compared to the threshold (0–100000 = 0–100%).
|
||||
* Pixels above become white, pixels below become black. Alpha is preserved.
|
||||
*/
|
||||
function applyBiLevelEffect(biLevel: SafeXmlNode, img: HTMLImageElement): void {
|
||||
const thresh = (biLevel.numAttr('thresh') ?? 50000) / 100000 // 0–1
|
||||
|
||||
const apply = () => {
|
||||
const w = img.naturalWidth
|
||||
const h = img.naturalHeight
|
||||
if (!w || !h) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const c = canvas.getContext('2d')
|
||||
if (!c) return
|
||||
|
||||
c.drawImage(img, 0, 0)
|
||||
const imageData = c.getImageData(0, 0, w, h)
|
||||
const data = imageData.data
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const gray = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255
|
||||
const val = gray >= thresh ? 255 : 0
|
||||
data[i] = val
|
||||
data[i + 1] = val
|
||||
data[i + 2] = val
|
||||
// Alpha preserved
|
||||
}
|
||||
|
||||
c.putImageData(imageData, 0, 0)
|
||||
img.src = canvas.toDataURL()
|
||||
}
|
||||
|
||||
if (img.complete && img.naturalWidth) {
|
||||
apply()
|
||||
} else {
|
||||
img.addEventListener('load', apply, { once: true })
|
||||
}
|
||||
}
|
||||
@@ -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 0–1. Default 0.5. */
|
||||
cx?: number
|
||||
/** Radial gradient center Y as fraction 0–1. 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 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 " " so browsers cannot collapse them,
|
||||
// while normal spaces between words remain stretchable for justify.
|
||||
const escaped = run.text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Parse OOXML custom geometry (a:custGeom) into SVG path strings.
|
||||
*/
|
||||
|
||||
import type { SafeXmlNode } from '../parser/xml-parser'
|
||||
|
||||
function inferPathExtent(pathNode: SafeXmlNode): { w: number; h: number } {
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
|
||||
for (const cmd of pathNode.allChildren()) {
|
||||
if (cmd.localName === 'moveTo' || cmd.localName === 'lnTo') {
|
||||
const pt = cmd.child('pt')
|
||||
maxX = Math.max(maxX, pt.numAttr('x') ?? 0)
|
||||
maxY = Math.max(maxY, pt.numAttr('y') ?? 0)
|
||||
continue
|
||||
}
|
||||
if (cmd.localName === 'cubicBezTo' || cmd.localName === 'quadBezTo') {
|
||||
for (const pt of cmd.children('pt')) {
|
||||
maxX = Math.max(maxX, pt.numAttr('x') ?? 0)
|
||||
maxY = Math.max(maxY, pt.numAttr('y') ?? 0)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (cmd.localName === 'arcTo') {
|
||||
maxX = Math.max(maxX, cmd.numAttr('wR') ?? 0)
|
||||
maxY = Math.max(maxY, cmd.numAttr('hR') ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
w: Math.max(1, maxX),
|
||||
h: Math.max(1, maxY),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a custom geometry element to an SVG path d-attribute string.
|
||||
*
|
||||
* @param custGeom - SafeXmlNode wrapping the `a:custGeom` element
|
||||
* @param width - Target width in pixels
|
||||
* @param height - Target height in pixels
|
||||
* @returns SVG path d-attribute string
|
||||
*/
|
||||
export function renderCustomGeometry(
|
||||
custGeom: SafeXmlNode,
|
||||
width: number,
|
||||
height: number,
|
||||
sourceExtent?: { w: number; h: number }
|
||||
): string {
|
||||
const pathLst = custGeom.child('pathLst')
|
||||
if (!pathLst.exists()) return ''
|
||||
|
||||
const paths = pathLst.children('path')
|
||||
const segments: string[] = []
|
||||
|
||||
for (const pathNode of paths) {
|
||||
const fallbackExtent = inferPathExtent(pathNode)
|
||||
const pathW = pathNode.numAttr('w') ?? sourceExtent?.w ?? fallbackExtent.w
|
||||
const pathH = pathNode.numAttr('h') ?? sourceExtent?.h ?? fallbackExtent.h
|
||||
|
||||
const scaleX = pathW > 0 ? width / pathW : 1
|
||||
const scaleY = pathH > 0 ? height / pathH : 1
|
||||
|
||||
// Track current position for arcTo calculations
|
||||
let curX = 0
|
||||
let curY = 0
|
||||
|
||||
const commands = pathNode.allChildren()
|
||||
for (const cmd of commands) {
|
||||
switch (cmd.localName) {
|
||||
case 'moveTo': {
|
||||
const pt = cmd.child('pt')
|
||||
const x = (pt.numAttr('x') ?? 0) * scaleX
|
||||
const y = (pt.numAttr('y') ?? 0) * scaleY
|
||||
segments.push(`M${x},${y}`)
|
||||
curX = x
|
||||
curY = y
|
||||
break
|
||||
}
|
||||
|
||||
case 'lnTo': {
|
||||
const pt = cmd.child('pt')
|
||||
const x = (pt.numAttr('x') ?? 0) * scaleX
|
||||
const y = (pt.numAttr('y') ?? 0) * scaleY
|
||||
segments.push(`L${x},${y}`)
|
||||
curX = x
|
||||
curY = y
|
||||
break
|
||||
}
|
||||
|
||||
case 'cubicBezTo': {
|
||||
const pts = cmd.children('pt')
|
||||
if (pts.length >= 3) {
|
||||
const x1 = (pts[0].numAttr('x') ?? 0) * scaleX
|
||||
const y1 = (pts[0].numAttr('y') ?? 0) * scaleY
|
||||
const x2 = (pts[1].numAttr('x') ?? 0) * scaleX
|
||||
const y2 = (pts[1].numAttr('y') ?? 0) * scaleY
|
||||
const x3 = (pts[2].numAttr('x') ?? 0) * scaleX
|
||||
const y3 = (pts[2].numAttr('y') ?? 0) * scaleY
|
||||
segments.push(`C${x1},${y1} ${x2},${y2} ${x3},${y3}`)
|
||||
curX = x3
|
||||
curY = y3
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'quadBezTo': {
|
||||
const pts = cmd.children('pt')
|
||||
if (pts.length >= 2) {
|
||||
const x1 = (pts[0].numAttr('x') ?? 0) * scaleX
|
||||
const y1 = (pts[0].numAttr('y') ?? 0) * scaleY
|
||||
const x2 = (pts[1].numAttr('x') ?? 0) * scaleX
|
||||
const y2 = (pts[1].numAttr('y') ?? 0) * scaleY
|
||||
segments.push(`Q${x1},${y1} ${x2},${y2}`)
|
||||
curX = x2
|
||||
curY = y2
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'arcTo': {
|
||||
const wRRaw = cmd.numAttr('wR') ?? 0
|
||||
const hRRaw = cmd.numAttr('hR') ?? 0
|
||||
const wR = wRRaw * scaleX
|
||||
const hR = hRRaw * scaleY
|
||||
const stAngRaw = cmd.numAttr('stAng') ?? 0
|
||||
const swAngRaw = cmd.numAttr('swAng') ?? 0
|
||||
|
||||
// OOXML angles are in 60000ths of a degree
|
||||
const stAng = stAngRaw / 60000
|
||||
const swAng = swAngRaw / 60000
|
||||
|
||||
if (wR === 0 || hR === 0 || swAng === 0) {
|
||||
// Degenerate arc, skip
|
||||
break
|
||||
}
|
||||
|
||||
// OOXML arcTo angles are visual (geometric ray) angles in path coordinate space.
|
||||
// Convert to parametric using UNSCALED radii before computing positions.
|
||||
const stVisRad = (stAng * Math.PI) / 180
|
||||
const stAngRad = Math.atan2(wRRaw * Math.sin(stVisRad), hRRaw * Math.cos(stVisRad))
|
||||
|
||||
const endVisRad = ((stAng + swAng) * Math.PI) / 180
|
||||
const endAngRad = Math.atan2(wRRaw * Math.sin(endVisRad), hRRaw * Math.cos(endVisRad))
|
||||
|
||||
// Compute center and endpoint in unscaled path space, then scale
|
||||
const curXU = curX / scaleX
|
||||
const curYU = curY / scaleY
|
||||
const cx = curXU - wRRaw * Math.cos(stAngRad)
|
||||
const cy = curYU - hRRaw * Math.sin(stAngRad)
|
||||
const endX = (cx + wRRaw * Math.cos(endAngRad)) * scaleX
|
||||
const endY = (cy + hRRaw * Math.sin(endAngRad)) * scaleY
|
||||
|
||||
// SVG arc flags
|
||||
const largeArc = Math.abs(swAng) > 180 ? 1 : 0
|
||||
const sweep = swAng > 0 ? 1 : 0
|
||||
|
||||
segments.push(`A${wR},${hR} 0 ${largeArc},${sweep} ${endX},${endY}`)
|
||||
curX = endX
|
||||
curY = endY
|
||||
break
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
segments.push('Z')
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown command, skip
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments.join(' ')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Convert OOXML arc specification to SVG path arc command.
|
||||
* Based on PPTXjs shapeArc() implementation.
|
||||
*
|
||||
* @param cx - Center X coordinate
|
||||
* @param cy - Center Y coordinate
|
||||
* @param rx - Horizontal radius
|
||||
* @param ry - Vertical radius
|
||||
* @param startAngle - Start angle in degrees
|
||||
* @param endAngle - End angle in degrees
|
||||
* @param isClose - Whether to close the path with Z
|
||||
* @returns SVG path string for the arc
|
||||
*/
|
||||
export function shapeArc(
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
isClose: boolean
|
||||
): string {
|
||||
const startRad = (startAngle * Math.PI) / 180
|
||||
const endRad = (endAngle * Math.PI) / 180
|
||||
|
||||
const x1 = cx + rx * Math.cos(startRad)
|
||||
const y1 = cy + ry * Math.sin(startRad)
|
||||
const x2 = cx + rx * Math.cos(endRad)
|
||||
const y2 = cy + ry * Math.sin(endRad)
|
||||
|
||||
// OOXML convention: always sweep clockwise from startAngle to endAngle.
|
||||
// Compute the clockwise sweep in degrees, handling angle wrapping.
|
||||
let sweepDeg = (((endAngle - startAngle) % 360) + 360) % 360
|
||||
if (sweepDeg === 0 && startAngle !== endAngle) sweepDeg = 360
|
||||
|
||||
const largeArc = sweepDeg > 180 ? 1 : 0
|
||||
const sweep = 1 // always clockwise
|
||||
|
||||
let d = `M${x1},${y1} A${rx},${ry} 0 ${largeArc},${sweep} ${x2},${y2}`
|
||||
if (isClose) {
|
||||
d += ' Z'
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openSimPptxViewer, SIM_PPTX_LIST_OPTIONS } from '@/lib/pptx-renderer/sim-pptx-viewer'
|
||||
|
||||
async function createMinimalPptx(): Promise<ArrayBuffer> {
|
||||
const zip = new JSZip()
|
||||
zip.file('[Content_Types].xml', '<Types />')
|
||||
zip.file(
|
||||
'ppt/presentation.xml',
|
||||
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<p:sldSz cx="9144000" cy="5143500" />
|
||||
<p:sldIdLst><p:sldId id="256" r:id="rId1" /></p:sldIdLst>
|
||||
</p:presentation>`
|
||||
)
|
||||
zip.file(
|
||||
'ppt/_rels/presentation.xml.rels',
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml" />
|
||||
</Relationships>`
|
||||
)
|
||||
zip.file(
|
||||
'ppt/slides/slide1.xml',
|
||||
`<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr><p:cNvPr id="1" name="" /><p:cNvGrpSpPr /><p:nvPr /></p:nvGrpSpPr>
|
||||
<p:grpSpPr />
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>`
|
||||
)
|
||||
zip.file('ppt/slides/_rels/slide1.xml.rels', '<Relationships />')
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
}
|
||||
|
||||
describe('openSimPptxViewer', () => {
|
||||
it('renders a minimal PPTX and cleans up the container on destroy', async () => {
|
||||
const container = document.createElement('div')
|
||||
Object.defineProperty(container, 'clientWidth', { configurable: true, value: 960 })
|
||||
const onRenderComplete = vi.fn()
|
||||
|
||||
const handle = await openSimPptxViewer({
|
||||
buffer: await createMinimalPptx(),
|
||||
container,
|
||||
onRenderComplete,
|
||||
})
|
||||
|
||||
expect(onRenderComplete).toHaveBeenCalled()
|
||||
expect(container.querySelector('[data-slide-index="0"]')).not.toBeNull()
|
||||
|
||||
handle.destroy()
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('uses windowed list rendering defaults for large decks', () => {
|
||||
expect(SIM_PPTX_LIST_OPTIONS).toMatchObject({
|
||||
windowed: true,
|
||||
batchSize: 8,
|
||||
initialSlides: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { ListRenderOptions } from '@/lib/pptx-renderer/core/viewer'
|
||||
import { PptxViewer } from '@/lib/pptx-renderer/core/viewer'
|
||||
import type { ZipParseLimits } from '@/lib/pptx-renderer/parser/zip-parser'
|
||||
|
||||
const logger = createLogger('SimPptxViewer')
|
||||
|
||||
export const SIM_PPTX_ZIP_LIMITS = {
|
||||
maxEntries: 2500,
|
||||
maxEntryUncompressedBytes: 50 * 1024 * 1024,
|
||||
maxTotalUncompressedBytes: 200 * 1024 * 1024,
|
||||
maxMediaBytes: 150 * 1024 * 1024,
|
||||
maxConcurrency: 8,
|
||||
} as const satisfies ZipParseLimits
|
||||
|
||||
export const SIM_PPTX_LIST_OPTIONS = {
|
||||
windowed: true,
|
||||
batchSize: 8,
|
||||
initialSlides: 4,
|
||||
overscanViewport: 1.5,
|
||||
} as const satisfies ListRenderOptions
|
||||
|
||||
export interface OpenSimPptxViewerOptions {
|
||||
buffer: ArrayBuffer | Uint8Array
|
||||
container: HTMLElement
|
||||
scrollContainer?: HTMLElement
|
||||
signal?: AbortSignal
|
||||
zipLimits?: ZipParseLimits
|
||||
listOptions?: ListRenderOptions
|
||||
onRenderStart?: () => void
|
||||
onRenderComplete?: () => void
|
||||
onSlideChange?: (index: number) => void
|
||||
onSlideError?: (index: number, error: unknown) => void
|
||||
onNodeError?: (nodeId: string, error: unknown) => void
|
||||
}
|
||||
|
||||
export interface SimPptxViewerHandle {
|
||||
readonly viewer: PptxViewer
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
export async function openSimPptxViewer({
|
||||
buffer,
|
||||
container,
|
||||
scrollContainer,
|
||||
signal,
|
||||
zipLimits = SIM_PPTX_ZIP_LIMITS,
|
||||
listOptions = SIM_PPTX_LIST_OPTIONS,
|
||||
onRenderStart,
|
||||
onRenderComplete,
|
||||
onSlideChange,
|
||||
onSlideError,
|
||||
onNodeError,
|
||||
}: OpenSimPptxViewerOptions): Promise<SimPptxViewerHandle> {
|
||||
const viewer = new PptxViewer(container, {
|
||||
fitMode: 'contain',
|
||||
scrollContainer,
|
||||
zipLimits,
|
||||
onRenderStart,
|
||||
onRenderComplete,
|
||||
onSlideChange,
|
||||
onSlideError,
|
||||
onNodeError,
|
||||
})
|
||||
|
||||
let destroyed = false
|
||||
const destroy = () => {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
viewer.destroy()
|
||||
}
|
||||
|
||||
const abortDestroy = () => destroy()
|
||||
signal?.addEventListener('abort', abortDestroy, { once: true })
|
||||
|
||||
try {
|
||||
await viewer.open(buffer, {
|
||||
renderMode: 'list',
|
||||
listOptions,
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
destroy()
|
||||
const normalized = toError(error)
|
||||
if (normalized.name !== 'AbortError') {
|
||||
logger.warn('Failed to render PPTX preview', { error: normalized.message })
|
||||
}
|
||||
throw normalized
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortDestroy)
|
||||
}
|
||||
|
||||
return { viewer, destroy }
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// ============================================================================
|
||||
// OOXML Color Utilities
|
||||
// Full color manipulation for PowerPoint XML color processing
|
||||
// ============================================================================
|
||||
|
||||
export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from '@/lib/colors'
|
||||
|
||||
import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl } from '@/lib/colors'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sRGB ↔ Linear RGB conversion (IEC 61966-2-1)
|
||||
// PowerPoint applies tint/shade in linear (scene-referred) space.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function srgbToLinear(c: number): number {
|
||||
const s = c / 255
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function linearToSrgb(c: number): number {
|
||||
const s = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055
|
||||
return Math.max(0, Math.min(255, Math.round(s * 255)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OOXML Color Modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply tint modifier (mix toward white in linear RGB space).
|
||||
* OOXML spec: tint val is 0-100000 where 100000 = original color, 0 = fully white.
|
||||
* PowerPoint performs the blend in linear RGB space for perceptual correctness.
|
||||
*/
|
||||
export function applyTint(hex: string, tint: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const t = tint / 100000
|
||||
const rl = srgbToLinear(r)
|
||||
const gl = srgbToLinear(g)
|
||||
const bl = srgbToLinear(b)
|
||||
return rgbToHex(
|
||||
linearToSrgb(rl * t + 1.0 * (1 - t)),
|
||||
linearToSrgb(gl * t + 1.0 * (1 - t)),
|
||||
linearToSrgb(bl * t + 1.0 * (1 - t))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply shade modifier (mix toward black in linear RGB space).
|
||||
* shade: 0-100000 where 100000 = original color, 0 = fully black.
|
||||
*/
|
||||
export function applyShade(hex: string, shade: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const s = shade / 100000
|
||||
return rgbToHex(
|
||||
linearToSrgb(srgbToLinear(r) * s),
|
||||
linearToSrgb(srgbToLinear(g) * s),
|
||||
linearToSrgb(srgbToLinear(b) * s)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply luminance modulation.
|
||||
* lumMod: percentage in OOXML units (e.g., 75000 = 75%).
|
||||
* Multiplies the L channel of HSL.
|
||||
*/
|
||||
export function applyLumMod(hex: string, lumMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newL = Math.max(0, Math.min(1, l * (lumMod / 100000)))
|
||||
const rgb = hslToRgb(h, s, newL)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply luminance offset.
|
||||
* lumOff: percentage offset in OOXML units (e.g., 25000 = +25%).
|
||||
* Adds to the L channel of HSL.
|
||||
*/
|
||||
export function applyLumOff(hex: string, lumOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newL = Math.max(0, Math.min(1, l + lumOff / 100000))
|
||||
const rgb = hslToRgb(h, s, newL)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saturation modulation.
|
||||
* satMod: percentage in OOXML units (e.g., 120000 = 120%).
|
||||
* Multiplies the S channel of HSL.
|
||||
*/
|
||||
export function applySatMod(hex: string, satMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newS = Math.max(0, Math.min(1, s * (satMod / 100000)))
|
||||
const rgb = hslToRgb(h, newS, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hue modulation.
|
||||
* hueMod: percentage in OOXML units (e.g., 60000 = shift hue by ratio).
|
||||
* In OOXML, hueMod multiplies the hue value. Hue wraps around at 360.
|
||||
*/
|
||||
export function applyHueMod(hex: string, hueMod: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newH = (h * (hueMod / 100000)) % 360
|
||||
const rgb = hslToRgb(newH, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply hue offset (additive).
|
||||
* hueOff: in 60000ths of a degree (OOXML ST_FixedAngle).
|
||||
* Adds to the hue channel of HSL, wrapping at 360.
|
||||
*/
|
||||
export function applyHueOff(hex: string, hueOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const offsetDeg = hueOff / 60000
|
||||
const newH = (((h + offsetDeg) % 360) + 360) % 360
|
||||
const rgb = hslToRgb(newH, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saturation offset (additive).
|
||||
* satOff: in OOXML percentage units (100000 = 100%).
|
||||
* Adds to the S channel of HSL.
|
||||
*/
|
||||
export function applySatOff(hex: string, satOff: number): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
const newS = Math.max(0, Math.min(1, s + satOff / 100000))
|
||||
const rgb = hslToRgb(h, newS, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OOXML alpha value (0-100000) to CSS opacity (0-1).
|
||||
* 100000 = fully opaque, 0 = fully transparent.
|
||||
*/
|
||||
export function applyAlpha(alpha: number): number {
|
||||
return Math.max(0, Math.min(1, alpha / 100000))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite Modifier Application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ColorModifier {
|
||||
name: string
|
||||
val: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all OOXML color modifiers from an array of {name, val} objects.
|
||||
* Modifiers are applied in the order they appear (matching XML document order).
|
||||
* Returns the final hex color and alpha value.
|
||||
*/
|
||||
export function applyColorModifiers(
|
||||
hex: string,
|
||||
modifiers: ColorModifier[]
|
||||
): { color: string; alpha: number } {
|
||||
let color = hex
|
||||
let alpha = 1
|
||||
|
||||
for (const mod of modifiers) {
|
||||
switch (mod.name) {
|
||||
case 'tint':
|
||||
case 'a:tint':
|
||||
color = applyTint(color, mod.val)
|
||||
break
|
||||
case 'shade':
|
||||
case 'a:shade':
|
||||
color = applyShade(color, mod.val)
|
||||
break
|
||||
case 'lumMod':
|
||||
case 'a:lumMod':
|
||||
color = applyLumMod(color, mod.val)
|
||||
break
|
||||
case 'lumOff':
|
||||
case 'a:lumOff':
|
||||
color = applyLumOff(color, mod.val)
|
||||
break
|
||||
case 'satMod':
|
||||
case 'a:satMod':
|
||||
color = applySatMod(color, mod.val)
|
||||
break
|
||||
case 'hueMod':
|
||||
case 'a:hueMod':
|
||||
color = applyHueMod(color, mod.val)
|
||||
break
|
||||
case 'hueOff':
|
||||
case 'a:hueOff':
|
||||
color = applyHueOff(color, mod.val)
|
||||
break
|
||||
case 'satOff':
|
||||
case 'a:satOff':
|
||||
color = applySatOff(color, mod.val)
|
||||
break
|
||||
case 'alpha':
|
||||
case 'a:alpha':
|
||||
alpha = applyAlpha(mod.val)
|
||||
break
|
||||
case 'alphaOff':
|
||||
case 'a:alphaOff':
|
||||
alpha = Math.max(0, Math.min(1, alpha + mod.val / 100000))
|
||||
break
|
||||
default:
|
||||
// Unknown modifier - skip silently
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { color, alpha }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OOXML Preset Color Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESET_COLORS: Record<string, string> = {
|
||||
// Basic colors
|
||||
black: '#000000',
|
||||
white: '#FFFFFF',
|
||||
red: '#FF0000',
|
||||
green: '#008000',
|
||||
blue: '#0000FF',
|
||||
yellow: '#FFFF00',
|
||||
cyan: '#00FFFF',
|
||||
magenta: '#FF00FF',
|
||||
|
||||
// Extended standard colors
|
||||
orange: '#FFA500',
|
||||
purple: '#800080',
|
||||
brown: '#A52A2A',
|
||||
pink: '#FFC0CB',
|
||||
gray: '#808080',
|
||||
grey: '#808080',
|
||||
lime: '#00FF00',
|
||||
navy: '#000080',
|
||||
teal: '#008080',
|
||||
maroon: '#800000',
|
||||
olive: '#808000',
|
||||
silver: '#C0C0C0',
|
||||
aqua: '#00FFFF',
|
||||
fuchsia: '#FF00FF',
|
||||
|
||||
// OOXML-specific preset colors
|
||||
aliceBlue: '#F0F8FF',
|
||||
antiqueWhite: '#FAEBD7',
|
||||
aquamarine: '#7FFFD4',
|
||||
azure: '#F0FFFF',
|
||||
beige: '#F5F5DC',
|
||||
bisque: '#FFE4C4',
|
||||
blanchedAlmond: '#FFEBCD',
|
||||
blueViolet: '#8A2BE2',
|
||||
burlyWood: '#DEB887',
|
||||
cadetBlue: '#5F9EA0',
|
||||
chartreuse: '#7FFF00',
|
||||
chocolate: '#D2691E',
|
||||
coral: '#FF7F50',
|
||||
cornflowerBlue: '#6495ED',
|
||||
cornsilk: '#FFF8DC',
|
||||
crimson: '#DC143C',
|
||||
darkBlue: '#00008B',
|
||||
darkCyan: '#008B8B',
|
||||
darkGoldenrod: '#B8860B',
|
||||
darkGray: '#A9A9A9',
|
||||
darkGrey: '#A9A9A9',
|
||||
darkGreen: '#006400',
|
||||
darkKhaki: '#BDB76B',
|
||||
darkMagenta: '#8B008B',
|
||||
darkOliveGreen: '#556B2F',
|
||||
darkOrange: '#FF8C00',
|
||||
darkOrchid: '#9932CC',
|
||||
darkRed: '#8B0000',
|
||||
darkSalmon: '#E9967A',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
darkSlateBlue: '#483D8B',
|
||||
darkSlateGray: '#2F4F4F',
|
||||
darkSlateGrey: '#2F4F4F',
|
||||
darkTurquoise: '#00CED1',
|
||||
darkViolet: '#9400D3',
|
||||
deepPink: '#FF1493',
|
||||
deepSkyBlue: '#00BFFF',
|
||||
dimGray: '#696969',
|
||||
dimGrey: '#696969',
|
||||
dodgerBlue: '#1E90FF',
|
||||
firebrick: '#B22222',
|
||||
floralWhite: '#FFFAF0',
|
||||
forestGreen: '#228B22',
|
||||
gainsboro: '#DCDCDC',
|
||||
ghostWhite: '#F8F8FF',
|
||||
gold: '#FFD700',
|
||||
goldenrod: '#DAA520',
|
||||
greenYellow: '#ADFF2F',
|
||||
honeydew: '#F0FFF0',
|
||||
hotPink: '#FF69B4',
|
||||
indianRed: '#CD5C5C',
|
||||
indigo: '#4B0082',
|
||||
ivory: '#FFFFF0',
|
||||
khaki: '#F0E68C',
|
||||
lavender: '#E6E6FA',
|
||||
lavenderBlush: '#FFF0F5',
|
||||
lawnGreen: '#7CFC00',
|
||||
lemonChiffon: '#FFFACD',
|
||||
lightBlue: '#ADD8E6',
|
||||
lightCoral: '#F08080',
|
||||
lightCyan: '#E0FFFF',
|
||||
lightGoldenrodYellow: '#FAFAD2',
|
||||
lightGray: '#D3D3D3',
|
||||
lightGrey: '#D3D3D3',
|
||||
lightGreen: '#90EE90',
|
||||
lightPink: '#FFB6C1',
|
||||
lightSalmon: '#FFA07A',
|
||||
lightSeaGreen: '#20B2AA',
|
||||
lightSkyBlue: '#87CEFA',
|
||||
lightSlateGray: '#778899',
|
||||
lightSlateGrey: '#778899',
|
||||
lightSteelBlue: '#B0C4DE',
|
||||
lightYellow: '#FFFFE0',
|
||||
limeGreen: '#32CD32',
|
||||
linen: '#FAF0E6',
|
||||
mediumAquamarine: '#66CDAA',
|
||||
mediumBlue: '#0000CD',
|
||||
mediumOrchid: '#BA55D3',
|
||||
mediumPurple: '#9370DB',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
mediumSlateBlue: '#7B68EE',
|
||||
mediumSpringGreen: '#00FA9A',
|
||||
mediumTurquoise: '#48D1CC',
|
||||
mediumVioletRed: '#C71585',
|
||||
midnightBlue: '#191970',
|
||||
mintCream: '#F5FFFA',
|
||||
mistyRose: '#FFE4E1',
|
||||
moccasin: '#FFE4B5',
|
||||
navajoWhite: '#FFDEAD',
|
||||
oldLace: '#FDF5E6',
|
||||
oliveDrab: '#6B8E23',
|
||||
orangeRed: '#FF4500',
|
||||
orchid: '#DA70D6',
|
||||
paleGoldenrod: '#EEE8AA',
|
||||
paleGreen: '#98FB98',
|
||||
paleTurquoise: '#AFEEEE',
|
||||
paleVioletRed: '#DB7093',
|
||||
papayaWhip: '#FFEFD5',
|
||||
peachPuff: '#FFDAB9',
|
||||
peru: '#CD853F',
|
||||
plum: '#DDA0DD',
|
||||
powderBlue: '#B0E0E6',
|
||||
rosyBrown: '#BC8F8F',
|
||||
royalBlue: '#4169E1',
|
||||
saddleBrown: '#8B4513',
|
||||
salmon: '#FA8072',
|
||||
sandyBrown: '#F4A460',
|
||||
seaGreen: '#2E8B57',
|
||||
seaShell: '#FFF5EE',
|
||||
sienna: '#A0522D',
|
||||
skyBlue: '#87CEEB',
|
||||
slateBlue: '#6A5ACD',
|
||||
slateGray: '#708090',
|
||||
slateGrey: '#708090',
|
||||
snow: '#FFFAFA',
|
||||
springGreen: '#00FF7F',
|
||||
steelBlue: '#4682B4',
|
||||
tan: '#D2B48C',
|
||||
thistle: '#D8BFD8',
|
||||
tomato: '#FF6347',
|
||||
turquoise: '#40E0D0',
|
||||
violet: '#EE82EE',
|
||||
wheat: '#F5DEB3',
|
||||
whiteSmoke: '#F5F5F5',
|
||||
yellowGreen: '#9ACD32',
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a preset OOXML color name and return its hex value.
|
||||
* Returns undefined if the name is not recognized.
|
||||
*/
|
||||
export function presetColorToHex(name: string): string | undefined {
|
||||
// Try exact match first, then case-insensitive
|
||||
if (PRESET_COLORS[name] !== undefined) {
|
||||
return PRESET_COLORS[name]
|
||||
}
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, value] of Object.entries(PRESET_COLORS)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* EMF (Enhanced Metafile) binary parser — extracts embedded content from EMF files.
|
||||
*
|
||||
* PPTX files frequently embed EMF images as OLE object previews.
|
||||
* Most contain embedded PDF data inside GDI comment records, or DIB bitmaps
|
||||
* via STRETCHDIBITS records. This parser extracts those embedded resources
|
||||
* without implementing full EMF record interpretation.
|
||||
*
|
||||
* EMF record format: each record is { type: u32, size: u32, ...data }
|
||||
* Records are walked sequentially until EOF record (type 14).
|
||||
*/
|
||||
|
||||
export type EmfContent =
|
||||
| { type: 'pdf'; data: Uint8Array }
|
||||
| { type: 'bitmap'; imageData: ImageData }
|
||||
| { type: 'empty' }
|
||||
| { type: 'unsupported' }
|
||||
|
||||
// EMF record types
|
||||
const EMR_EOF = 14
|
||||
const EMR_COMMENT = 70
|
||||
const EMR_STRETCHDIBITS = 81
|
||||
|
||||
// GDI comment identifiers (MS-EMF spec)
|
||||
const GDIC_COMMENT_ID = 0x43494447 // "GDIC"
|
||||
const GDIC_BEGINGROUP = 0x00000002
|
||||
const GDIC_MULTIFORMATS = 0x40000004
|
||||
|
||||
// EMF header signature at offset 40
|
||||
const EMF_SIGNATURE = 0x464d4520 // " EMF"
|
||||
|
||||
// PDF markers
|
||||
const PDF_HEADER = [0x25, 0x50, 0x44, 0x46] // "%PDF"
|
||||
const PDF_EOF = [0x25, 0x25, 0x45, 0x4f, 0x46] // "%%EOF"
|
||||
|
||||
// DIB compression
|
||||
const BI_RGB = 0
|
||||
|
||||
/**
|
||||
* Parse an EMF file and extract its embedded content.
|
||||
*/
|
||||
export function parseEmfContent(data: Uint8Array): EmfContent {
|
||||
if (data.length < 44) return { type: 'unsupported' }
|
||||
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
|
||||
// Validate EMF signature at offset 40
|
||||
if (view.getUint32(40, true) !== EMF_SIGNATURE) {
|
||||
return { type: 'unsupported' }
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
let recordCount = 0
|
||||
|
||||
while (offset + 8 <= data.length) {
|
||||
const recordType = view.getUint32(offset, true)
|
||||
const recordSize = view.getUint32(offset + 4, true)
|
||||
|
||||
// Sanity check record size
|
||||
if (recordSize < 8 || offset + recordSize > data.length) break
|
||||
|
||||
recordCount++
|
||||
|
||||
if (recordType === EMR_EOF) break
|
||||
|
||||
// Check GDI Comment records for embedded PDF
|
||||
if (recordType === EMR_COMMENT && recordSize > 16) {
|
||||
const result = parseGdiComment(data, view, offset, recordSize)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
// Check STRETCHDIBITS for embedded bitmaps
|
||||
if (recordType === EMR_STRETCHDIBITS && recordSize > 80) {
|
||||
const result = parseStretchDibits(data, view, offset, recordSize)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
offset += recordSize
|
||||
}
|
||||
|
||||
// Only HEADER + EOF → empty
|
||||
if (recordCount <= 2) {
|
||||
return { type: 'empty' }
|
||||
}
|
||||
|
||||
return { type: 'unsupported' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GDI Comment record looking for embedded PDF data.
|
||||
*/
|
||||
function parseGdiComment(
|
||||
data: Uint8Array,
|
||||
view: DataView,
|
||||
offset: number,
|
||||
recordSize: number
|
||||
): EmfContent | null {
|
||||
// Record layout: type(4) + size(4) + cbData(4) + commentId(4) + ...
|
||||
if (offset + 16 > data.length) return null
|
||||
|
||||
const commentId = view.getUint32(offset + 12, true)
|
||||
|
||||
if (commentId === GDIC_COMMENT_ID && offset + 20 <= data.length) {
|
||||
const publicType = view.getUint32(offset + 16, true)
|
||||
|
||||
if (publicType === GDIC_BEGINGROUP) {
|
||||
// Search for %PDF signature in the record data
|
||||
const recordData = data.subarray(offset + 8, offset + recordSize)
|
||||
const pdf = extractPdfFromBuffer(recordData)
|
||||
if (pdf) return { type: 'pdf', data: pdf }
|
||||
}
|
||||
|
||||
if (publicType === GDIC_MULTIFORMATS && offset + 24 <= data.length) {
|
||||
// MULTIFORMATS: parse format descriptors and extract first usable one
|
||||
const result = parseMultiformats(data, view, offset, recordSize)
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
|
||||
// Also search non-GDIC comments for raw PDF data
|
||||
if (recordSize > 100) {
|
||||
const recordData = data.subarray(offset + 8, offset + recordSize)
|
||||
const pdf = extractPdfFromBuffer(recordData)
|
||||
if (pdf) return { type: 'pdf', data: pdf }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MULTIFORMATS GDI comment — contains format descriptors pointing to embedded data.
|
||||
*/
|
||||
function parseMultiformats(
|
||||
data: Uint8Array,
|
||||
view: DataView,
|
||||
offset: number,
|
||||
_recordSize: number
|
||||
): EmfContent | null {
|
||||
// Layout from record start:
|
||||
// +12: commentIdentifier(4), +16: publicCommentIdentifier(4)
|
||||
// +20: outputRect(16 = RECTL)
|
||||
// +36: countFormats(4)
|
||||
// +40: format descriptors array, each: { signature(4), version(4), cbData(4), offData(4) }
|
||||
if (offset + 40 > data.length) return null
|
||||
|
||||
const countFormats = view.getUint32(offset + 36, true)
|
||||
const descriptorStart = offset + 40
|
||||
|
||||
for (let i = 0; i < countFormats && i < 10; i++) {
|
||||
const descOff = descriptorStart + i * 16
|
||||
if (descOff + 16 > data.length) break
|
||||
|
||||
const cbData = view.getUint32(descOff + 8, true)
|
||||
const offData = view.getUint32(descOff + 12, true)
|
||||
|
||||
// offData is relative to the start of the record
|
||||
const dataStart = offset + offData
|
||||
if (dataStart + cbData > data.length || cbData === 0) continue
|
||||
|
||||
const formatData = data.subarray(dataStart, dataStart + cbData)
|
||||
const pdf = extractPdfFromBuffer(formatData)
|
||||
if (pdf) return { type: 'pdf', data: pdf }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for %PDF...%%EOF in a buffer and extract the PDF bytes.
|
||||
*/
|
||||
function extractPdfFromBuffer(buf: Uint8Array): Uint8Array | null {
|
||||
const pdfStart = findSequence(buf, PDF_HEADER)
|
||||
if (pdfStart === -1) return null
|
||||
|
||||
// Search for %%EOF from the end (PDF may have multiple %%EOF; take the last one)
|
||||
let pdfEnd = -1
|
||||
for (let i = buf.length - PDF_EOF.length; i >= pdfStart; i--) {
|
||||
if (matchesAt(buf, i, PDF_EOF)) {
|
||||
pdfEnd = i + PDF_EOF.length
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (pdfEnd === -1) {
|
||||
// No %%EOF found — take everything from %PDF to end of buffer
|
||||
pdfEnd = buf.length
|
||||
}
|
||||
|
||||
return buf.slice(pdfStart, pdfEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a STRETCHDIBITS record and extract the bitmap as ImageData.
|
||||
*/
|
||||
function parseStretchDibits(
|
||||
data: Uint8Array,
|
||||
view: DataView,
|
||||
offset: number,
|
||||
_recordSize: number
|
||||
): EmfContent | null {
|
||||
// STRETCHDIBITS record layout (offsets from record start):
|
||||
// 0: type(4), 4: size(4)
|
||||
// 8: rclBounds (16 bytes)
|
||||
// 24: xDest(4), 28: yDest(4)
|
||||
// 32: xSrc(4), 36: ySrc(4)
|
||||
// 40: cxSrc(4), 44: cySrc(4)
|
||||
// 48: offBmiSrc(4), 52: cbBmiSrc(4)
|
||||
// 56: offBitsSrc(4), 60: cbBitsSrc(4)
|
||||
// 64: iUsageSrc(4), 68: dwRop(4)
|
||||
// 72: cxDest(4), 76: cyDest(4)
|
||||
if (offset + 80 > data.length) return null
|
||||
|
||||
const offBmiSrc = view.getUint32(offset + 48, true)
|
||||
const cbBmiSrc = view.getUint32(offset + 52, true)
|
||||
const offBitsSrc = view.getUint32(offset + 56, true)
|
||||
const cbBitsSrc = view.getUint32(offset + 60, true)
|
||||
|
||||
if (cbBmiSrc === 0 || cbBitsSrc === 0) return null
|
||||
|
||||
const bmiStart = offset + offBmiSrc
|
||||
if (bmiStart + 40 > data.length) return null
|
||||
|
||||
// Parse BITMAPINFOHEADER
|
||||
const biWidth = view.getInt32(bmiStart + 4, true)
|
||||
const biHeight = view.getInt32(bmiStart + 8, true)
|
||||
const biBitCount = view.getUint16(bmiStart + 14, true)
|
||||
const biCompression = view.getUint32(bmiStart + 16, true)
|
||||
|
||||
// Only support uncompressed RGB bitmaps
|
||||
if (biCompression !== BI_RGB) return null
|
||||
if (biBitCount !== 24 && biBitCount !== 32) return null
|
||||
|
||||
const width = Math.abs(biWidth)
|
||||
const height = Math.abs(biHeight)
|
||||
if (width === 0 || height === 0 || width > 8192 || height > 8192) return null
|
||||
|
||||
const bitsStart = offset + offBitsSrc
|
||||
if (bitsStart + cbBitsSrc > data.length) return null
|
||||
|
||||
const bitsData = data.subarray(bitsStart, bitsStart + cbBitsSrc)
|
||||
|
||||
// Negative height means top-down row order; positive means bottom-up
|
||||
const topDown = biHeight < 0
|
||||
|
||||
const imageData = new ImageData(width, height)
|
||||
const bytesPerPixel = biBitCount / 8
|
||||
// DIB rows are padded to 4-byte boundaries
|
||||
const rowStride = Math.ceil((width * bytesPerPixel) / 4) * 4
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const srcRow = topDown ? y : height - 1 - y
|
||||
const srcOffset = srcRow * rowStride
|
||||
const dstOffset = y * width * 4
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = srcOffset + x * bytesPerPixel
|
||||
if (srcIdx + bytesPerPixel > bitsData.length) break
|
||||
|
||||
// DIB stores BGR(A)
|
||||
imageData.data[dstOffset + x * 4 + 0] = bitsData[srcIdx + 2] // R
|
||||
imageData.data[dstOffset + x * 4 + 1] = bitsData[srcIdx + 1] // G
|
||||
imageData.data[dstOffset + x * 4 + 2] = bitsData[srcIdx + 0] // B
|
||||
imageData.data[dstOffset + x * 4 + 3] = biBitCount === 32 ? bitsData[srcIdx + 3] : 255
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'bitmap', imageData }
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first occurrence of a byte sequence in a buffer.
|
||||
*/
|
||||
function findSequence(buf: Uint8Array, seq: number[]): number {
|
||||
const end = buf.length - seq.length
|
||||
for (let i = 0; i <= end; i++) {
|
||||
if (matchesAt(buf, i, seq)) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer matches a byte sequence at a given offset.
|
||||
*/
|
||||
function matchesAt(buf: Uint8Array, offset: number, seq: number[]): boolean {
|
||||
for (let j = 0; j < seq.length; j++) {
|
||||
if (buf[offset + j] !== seq[j]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Media utilities — MIME type detection, path resolution, and blob URL management.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine MIME type from file extension.
|
||||
* Covers images, video, and audio formats used in PPTX files.
|
||||
*/
|
||||
export function getMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||
const mimeMap: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
bmp: 'image/bmp',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
emf: 'image/x-emf',
|
||||
wmf: 'image/x-wmf',
|
||||
webp: 'image/webp',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
}
|
||||
return mimeMap[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative media path (from rels) to its canonical path in PptxFiles.media.
|
||||
* Rels targets are relative like "../media/image1.png".
|
||||
* Media paths in PptxFiles are like "ppt/media/image1.png".
|
||||
*/
|
||||
export function resolveMediaPath(target: string): string {
|
||||
const fileName = target.split('/').pop() || ''
|
||||
return `ppt/media/${fileName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a blob URL for a media file, using a cache to avoid duplicates.
|
||||
*
|
||||
* @param mediaPath - Canonical path (e.g. "ppt/media/image1.png")
|
||||
* @param data - Raw media data (Uint8Array or ArrayBuffer)
|
||||
* @param cache - Map to store/retrieve cached blob URLs
|
||||
* @returns The blob URL string
|
||||
*/
|
||||
export function getOrCreateBlobUrl(
|
||||
mediaPath: string,
|
||||
data: Uint8Array | ArrayBuffer,
|
||||
cache: Map<string, string>
|
||||
): string {
|
||||
let url = cache.get(mediaPath)
|
||||
if (!url) {
|
||||
const mime = getMimeType(mediaPath)
|
||||
const blobPart = data instanceof ArrayBuffer ? data : copyToArrayBuffer(data)
|
||||
const blob = new Blob([blobPart], { type: mime })
|
||||
url = URL.createObjectURL(blob)
|
||||
cache.set(mediaPath, url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function copyToArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(data.byteLength)
|
||||
copy.set(data)
|
||||
return copy.buffer
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* PDF-to-image renderer for embedded EMF PDFs.
|
||||
*
|
||||
* pdfjs-dist v5 has process-level shared state (PagesMapper.#pagesNumber,
|
||||
* GlobalWorkerOptions.workerSrc, PDFWorker.#isWorkerDisabled) that a library
|
||||
* must never touch on the main thread — doing so clobbers the host app's pdfjs
|
||||
* configuration.
|
||||
*
|
||||
* Solution: render EMF PDFs exclusively inside a dedicated Web Worker. The
|
||||
* worker loads its OWN pdfjs instance via dynamic import, so all static state
|
||||
* is fully isolated from the main thread.
|
||||
*
|
||||
* If Worker + OffscreenCanvas are unavailable (extremely rare in 2025+
|
||||
* browsers), rendering is skipped and the caller gets null — no main-thread
|
||||
* fallback, no global state pollution.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved pdfjs URL — computed once from main thread's module resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _pdfjsUrl: string | null = null
|
||||
|
||||
function getPdfjsUrl(): string | null {
|
||||
if (_pdfjsUrl !== null) return _pdfjsUrl
|
||||
try {
|
||||
// Resolve via the bundler/dev server so the URL is usable from a Worker
|
||||
_pdfjsUrl = new URL('pdfjs-dist/build/pdf.min.mjs', import.meta.url).toString()
|
||||
} catch {
|
||||
_pdfjsUrl = ''
|
||||
}
|
||||
return _pdfjsUrl || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker-based renderer (fully isolated from main thread pdfjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inline source for the PDF render worker.
|
||||
* Receives: { id, pdfData, width, height, pdfjsUrl }
|
||||
* Posts back: { id, blob } or { id, error }
|
||||
*
|
||||
* The worker loads its OWN pdfjs instance via dynamic import, so its static
|
||||
* PagesMapper state is completely independent of the main thread.
|
||||
* pdfjs's own internal worker is disabled (workerPort = null, workerSrc = '')
|
||||
* so pdfjs runs single-threaded inside this worker — acceptable for tiny
|
||||
* 1-page EMF PDFs.
|
||||
*/
|
||||
const WORKER_SRC = /* js */ `
|
||||
let pdfjsLib = null;
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { id, pdfData, width, height, pdfjsUrl } = e.data;
|
||||
try {
|
||||
if (!pdfjsLib) {
|
||||
pdfjsLib = await import(pdfjsUrl);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
|
||||
}
|
||||
|
||||
const doc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
try {
|
||||
if (doc.numPages < 1) {
|
||||
self.postMessage({ id, error: 'no pages' });
|
||||
return;
|
||||
}
|
||||
const page = await doc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1 });
|
||||
const scale = Math.max(width / vp.width, height / vp.height);
|
||||
const svp = page.getViewport({ scale });
|
||||
|
||||
const canvas = new OffscreenCanvas(Math.ceil(svp.width), Math.ceil(svp.height));
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
await page.render({ canvasContext: ctx, viewport: svp, background: 'rgba(0,0,0,0)' }).promise;
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
self.postMessage({ id, blob });
|
||||
} finally {
|
||||
doc.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
self.postMessage({ id, error: String(err) });
|
||||
}
|
||||
};
|
||||
`
|
||||
|
||||
let _worker: Worker | null = null
|
||||
let _workerFailed = false
|
||||
let _msgId = 0
|
||||
const _pending = new Map<
|
||||
number,
|
||||
{ resolve: (b: Blob | null) => void; reject: (e: Error) => void }
|
||||
>()
|
||||
|
||||
function getWorker(_pdfjsUrl: string): Worker | null {
|
||||
if (_workerFailed) return null
|
||||
if (_worker) return _worker
|
||||
|
||||
try {
|
||||
const blob = new Blob([WORKER_SRC], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
_worker = new Worker(url, { type: 'module' })
|
||||
|
||||
_worker.onmessage = (e: MessageEvent) => {
|
||||
const { id, blob, error } = e.data
|
||||
const entry = _pending.get(id)
|
||||
if (!entry) return
|
||||
_pending.delete(id)
|
||||
if (error) {
|
||||
entry.resolve(null) // Treat worker-side errors as "no result"
|
||||
} else {
|
||||
entry.resolve(blob ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
_worker.onerror = () => {
|
||||
// Worker failed to initialize (e.g. module import blocked by CSP)
|
||||
_workerFailed = true
|
||||
_worker = null
|
||||
for (const [, entry] of _pending) {
|
||||
entry.resolve(null)
|
||||
}
|
||||
_pending.clear()
|
||||
}
|
||||
|
||||
return _worker
|
||||
} catch {
|
||||
_workerFailed = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function renderInWorker(
|
||||
pdfData: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pdfjsUrl: string
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = getWorker(pdfjsUrl)
|
||||
if (!worker) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const id = ++_msgId
|
||||
_pending.set(id, {
|
||||
resolve,
|
||||
reject: () => resolve(null),
|
||||
})
|
||||
|
||||
// Transfer the buffer to avoid copying
|
||||
const copy = pdfData.slice() // copy so caller retains original
|
||||
worker.postMessage({ id, pdfData: copy, width, height, pdfjsUrl }, [copy.buffer])
|
||||
|
||||
// Timeout: if worker doesn't respond in 15s, give up
|
||||
setTimeout(() => {
|
||||
if (_pending.has(id)) {
|
||||
_pending.delete(id)
|
||||
resolve(null)
|
||||
}
|
||||
}, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render page 1 of a PDF to a blob URL image.
|
||||
*
|
||||
* Uses a dedicated Web Worker with its own pdfjs instance, fully isolated
|
||||
* from the main thread. Never touches GlobalWorkerOptions or any other
|
||||
* pdfjs global state on the main thread.
|
||||
*
|
||||
* @returns blob URL string, or null if rendering fails or Worker is unavailable
|
||||
*/
|
||||
export async function renderPdfToImage(
|
||||
pdfData: Uint8Array,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<string | null> {
|
||||
const pdfjsUrl = getPdfjsUrl()
|
||||
|
||||
if (!pdfjsUrl || typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await renderInWorker(pdfData, width, height, pdfjsUrl)
|
||||
if (blob) return URL.createObjectURL(blob)
|
||||
} catch {
|
||||
// Worker failed — no fallback, return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user