Files
simstudioai--sim/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

966 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Text renderer — converts OOXML text body into HTML DOM elements
* with full 7-level style inheritance.
*/
import { hexToRgb, toCssColor } from '@/lib/colors'
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
import type { PlaceholderInfo } from '../model/nodes/base-node'
import type { TextBody } from '../model/nodes/shape-node'
import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units'
import { SafeXmlNode } from '../parser/xml-parser'
import type { RenderContext } from './render-context'
import { resolveColor, resolveColorToCss } from './style-resolver'
// ---------------------------------------------------------------------------
// Style Inheritance Helpers
// ---------------------------------------------------------------------------
/**
* Find paragraph properties at a specific indent level from a list style node.
* Tries lvl{n}pPr (where n = level + 1), then falls back to defPPr.
*/
function findStyleAtLevel(styleNode: SafeXmlNode | undefined, level: number): SafeXmlNode {
if (!styleNode || !styleNode.exists()) {
return new SafeXmlNode(null)
}
// Try level-specific style (lvl1pPr, lvl2pPr, etc.)
const lvlNode = styleNode.child(`lvl${level + 1}pPr`)
if (lvlNode.exists()) return lvlNode
// Fall back to default
return styleNode.child('defPPr')
}
/**
* Determine the placeholder category for style inheritance.
* Returns 'title', 'body', or 'other'.
*/
function getPlaceholderCategory(
placeholder: PlaceholderInfo | undefined
): 'title' | 'body' | 'other' {
if (!placeholder || !placeholder.type) return 'other'
const t = placeholder.type
if (t === 'title' || t === 'ctrTitle') return 'title'
if (
t === 'body' ||
t === 'subTitle' ||
t === 'obj' ||
t === 'dt' ||
t === 'ftr' ||
t === 'sldNum'
) {
return 'body'
}
return 'other'
}
/**
* Find a placeholder node in a list by matching type and/or idx.
*/
function findPlaceholderNode(
placeholders: SafeXmlNode[],
info: PlaceholderInfo
): SafeXmlNode | undefined {
for (const ph of placeholders) {
// Navigate to the ph element to read its attributes
let phEl: SafeXmlNode | undefined
const nvSpPr = ph.child('nvSpPr')
if (nvSpPr.exists()) {
phEl = nvSpPr.child('nvPr').child('ph')
}
if (!phEl || !phEl.exists()) {
const nvPicPr = ph.child('nvPicPr')
if (nvPicPr.exists()) {
phEl = nvPicPr.child('nvPr').child('ph')
}
}
if (!phEl || !phEl.exists()) continue
const phType = phEl.attr('type')
const phIdx = phEl.numAttr('idx')
// Match by idx first (most specific), then by type
if (info.idx !== undefined && phIdx === info.idx) return ph
if (info.type && phType === info.type) return ph
}
return undefined
}
/**
* Extract lstStyle from a placeholder shape node.
*/
function getPlaceholderLstStyle(phNode: SafeXmlNode): SafeXmlNode | undefined {
const txBody = phNode.child('txBody')
if (!txBody.exists()) return undefined
const lstStyle = txBody.child('lstStyle')
return lstStyle.exists() ? lstStyle : undefined
}
/**
* Merge a source paragraph property node onto a target style object.
* Later calls override earlier values (higher priority wins).
*/
interface MergedParagraphStyle {
align?: string
marginLeft?: number
textIndent?: number
lineHeight?: string
/** True when lineHeight comes from spcPts (absolute pt value). For CJK fonts, CSS line-height
* with absolute values may not produce exact spacing because the font's content area can exceed
* the line-height. When true, we use block-level line wrappers instead of <br> for line breaks. */
lineHeightAbsolute?: boolean
spaceBefore?: number
spaceBeforePct?: number // percentage of font size (0-1 range)
spaceAfter?: number
spaceAfterPct?: number // percentage of font size (0-1 range)
bulletChar?: string
bulletFont?: string
bulletAutoNum?: string
bulletNone?: boolean
/** When set, bullet color is taken from this OOXML buClr node (a:buClr with srgbClr/schemeClr child). */
bulletColorNode?: SafeXmlNode
defRPr?: SafeXmlNode
}
function mergeParagraphProps(target: MergedParagraphStyle, pPr: SafeXmlNode): void {
if (!pPr.exists()) return
const algn = pPr.attr('algn')
if (algn) target.align = algn
const marL = pPr.numAttr('marL')
if (marL !== undefined) target.marginLeft = emuToPx(marL)
const indent = pPr.numAttr('indent')
if (indent !== undefined) target.textIndent = emuToPx(indent)
// Line spacing
// OOXML spcPct: 100000 = "single spacing" = 1.0× the font's line height.
// IMPORTANT: We must use UNITLESS CSS line-height values (e.g., 1.0, 1.2)
// instead of percentages (e.g., 100%, 120%). CSS percentage line-height is
// computed once against the element's own font-size and inherited as a FIXED
// pixel value — so a parent div with line-height:120% and font-size:16px
// inherits 19.2px to ALL children, even those with font-size:80pt.
// Unitless values are inherited as-is and each child recomputes against its
// own font-size.
const lnSpc = pPr.child('lnSpc')
if (lnSpc.exists()) {
const spcPct = lnSpc.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) {
// OOXML 100000 → CSS unitless 1.0; OOXML 120000 → CSS 1.2
target.lineHeight = `${(val / 100000).toFixed(3)}`
}
}
const spcPts = lnSpc.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) {
target.lineHeight = `${val / 100}pt`
target.lineHeightAbsolute = true
}
}
}
// Space before
const spcBef = pPr.child('spcBef')
if (spcBef.exists()) {
const spcPts = spcBef.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) target.spaceBefore = val / 100
}
const spcPct = spcBef.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) target.spaceBeforePct = val / 100000 // store as ratio
}
}
// Space after
const spcAft = pPr.child('spcAft')
if (spcAft.exists()) {
const spcPts = spcAft.child('spcPts')
if (spcPts.exists()) {
const val = spcPts.numAttr('val')
if (val !== undefined) target.spaceAfter = val / 100
}
const spcPct = spcAft.child('spcPct')
if (spcPct.exists()) {
const val = spcPct.numAttr('val')
if (val !== undefined) target.spaceAfterPct = val / 100000 // store as ratio
}
}
// Bullets
const buChar = pPr.child('buChar')
if (buChar.exists()) {
target.bulletChar = buChar.attr('char') || ''
target.bulletNone = false
}
const buAutoNum = pPr.child('buAutoNum')
if (buAutoNum.exists()) {
target.bulletAutoNum = buAutoNum.attr('type') || 'arabicPeriod'
target.bulletNone = false
}
const buNone = pPr.child('buNone')
if (buNone.exists()) {
target.bulletNone = true
target.bulletChar = undefined
target.bulletAutoNum = undefined
}
const buFont = pPr.child('buFont')
if (buFont.exists()) {
target.bulletFont = buFont.attr('typeface')
}
// Explicit bullet color (a:buClr); when present overrides defRPr for bullet color
const buClr = pPr.child('buClr')
if (buClr.exists()) {
target.bulletColorNode = buClr
}
// Default run properties (used as fallback for runs without rPr)
const defRPr = pPr.child('defRPr')
if (defRPr.exists()) {
target.defRPr = defRPr
}
}
// ---------------------------------------------------------------------------
// Run Style Resolution
// ---------------------------------------------------------------------------
interface MergedRunStyle {
fontSize?: number
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
color?: string
fontFamily?: string
hlinkClick?: string
/** Character spacing (tracking) in points — from a:spc @val (hundredths of pt). */
letterSpacingPt?: number
/** Kerning: minimum font size (pt) for kerning; 0 = always kern. */
kern?: number
/** Text capitalization: "all" = ALL CAPS, "small" = SMALL CAPS, "none" = normal. */
cap?: string
/** Baseline shift in percentage (positive = superscript, negative = subscript). */
baseline?: number
/** CSS gradient string for text fill (from rPr > gradFill). */
textGradientCss?: string
/** When true, text fill is transparent (a:noFill on rPr). */
textNoFill?: boolean
/** Text outline width in px (from a:ln on rPr). */
textOutlineWidth?: number
/** Text outline CSS color (solid fill on ln). */
textOutlineColor?: string
/** Text outline CSS gradient (gradient fill on ln) — used as mask-image for fade effect. */
textOutlineGradientCss?: string
}
function mergeRunProps(target: MergedRunStyle, rPr: SafeXmlNode, ctx: RenderContext): void {
if (!rPr.exists()) return
const sz = rPr.numAttr('sz')
if (sz !== undefined) target.fontSize = sz / 100 // hundredths of point -> pt
const b = rPr.attr('b')
if (b !== undefined) target.bold = b === '1' || b === 'true'
const i = rPr.attr('i')
if (i !== undefined) target.italic = i === '1' || i === 'true'
const u = rPr.attr('u')
if (u !== undefined && u !== 'none') target.underline = true
if (u === 'none') target.underline = false
const strike = rPr.attr('strike')
if (strike !== undefined && strike !== 'noStrike') target.strikethrough = true
if (strike === 'noStrike') target.strikethrough = false
// Color from solidFill or gradFill child
const solidFill = rPr.child('solidFill')
if (solidFill.exists()) {
const { color, alpha } = resolveColor(solidFill, ctx)
const hex = color.startsWith('#') ? color : `#${color}`
if (alpha < 1) {
const { r, g, b: bl } = hexToRgb(hex)
target.color = `rgba(${r},${g},${bl},${alpha.toFixed(3)})`
} else {
target.color = hex
}
}
const gradFill = rPr.child('gradFill')
if (gradFill.exists()) {
const css = resolveGradientForText(gradFill, ctx)
if (css) target.textGradientCss = css
}
// Font family
const latin = rPr.child('latin')
if (latin.exists()) {
const typeface = latin.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
if (!target.fontFamily) {
const ea = rPr.child('ea')
if (ea.exists()) {
const typeface = ea.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
}
if (!target.fontFamily) {
const cs = rPr.child('cs')
if (cs.exists()) {
const typeface = cs.attr('typeface')
if (typeface) {
target.fontFamily = resolveThemeFont(typeface, ctx)
}
}
}
// Hyperlink
const hlinkClick = rPr.child('hlinkClick')
if (hlinkClick.exists()) {
// The actual URL is in the slide rels, referenced by r:id
const rId = hlinkClick.attr('id') ?? hlinkClick.attr('r:id')
if (rId) {
const rel = ctx.slide.rels.get(rId)
if (rel && rel.targetMode === 'External' && isAllowedExternalUrl(rel.target)) {
target.hlinkClick = rel.target
}
}
}
// Character spacing (compact/tracking): rPr@spc in hundredths of a point
const spc = rPr.numAttr('spc')
if (spc !== undefined) target.letterSpacingPt = spc / 100
// Kerning: rPr@kern = minimum font size (hundredths of pt) to apply kerning; 0 = always
const kern = rPr.numAttr('kern')
if (kern !== undefined) target.kern = kern / 100
// Text capitalization: cap="all" (ALL CAPS) or cap="small" (SMALL CAPS)
const cap = rPr.attr('cap')
if (cap !== undefined) target.cap = cap
// Baseline shift: positive = superscript, negative = subscript (in 1000ths of percent)
const baseline = rPr.numAttr('baseline')
if (baseline !== undefined) target.baseline = baseline
// Text noFill: a:noFill on rPr makes text interior transparent
if (rPr.child('noFill').exists()) {
target.textNoFill = true
}
// Text outline: a:ln on rPr defines text stroke/outline
const ln = rPr.child('ln')
if (ln.exists() && !ln.child('noFill').exists()) {
const lnW = ln.numAttr('w')
target.textOutlineWidth = lnW ? emuToPx(lnW) : 0.75 // default ~0.75px
// Solid fill on outline
const lnSolid = ln.child('solidFill')
if (lnSolid.exists()) {
const { color: c, alpha: a } = resolveColor(lnSolid, ctx)
target.textOutlineColor = toCssColor(c, a)
}
// Gradient fill on outline — build CSS gradient for mask effect
const lnGrad = ln.child('gradFill')
if (lnGrad.exists()) {
target.textOutlineGradientCss = resolveGradientForText(lnGrad, ctx)
}
}
}
/**
* Resolve theme font placeholder references like "+mj-lt" or "+mn-lt".
*/
function resolveThemeFont(typeface: string, ctx: RenderContext): string {
if (typeface === '+mj-lt' || typeface === '+mj-ea' || typeface === '+mj-cs') {
const key = typeface.slice(3) as 'lt' | 'ea' | 'cs'
const mapping: Record<string, 'latin' | 'ea' | 'cs'> = { lt: 'latin', ea: 'ea', cs: 'cs' }
return ctx.theme.majorFont[mapping[key] || 'latin'] || typeface
}
if (typeface === '+mn-lt' || typeface === '+mn-ea' || typeface === '+mn-cs') {
const key = typeface.slice(3) as 'lt' | 'ea' | 'cs'
const mapping: Record<string, 'latin' | 'ea' | 'cs'> = { lt: 'latin', ea: 'ea', cs: 'cs' }
return ctx.theme.minorFont[mapping[key] || 'latin'] || typeface
}
return typeface
}
/**
* Resolve a gradient fill node into a CSS linear-gradient string.
* Used for text outline gradient effects.
*/
function resolveGradientForText(gradFill: SafeXmlNode, ctx: RenderContext): string {
const gsLst = gradFill.child('gsLst')
const stops: { position: number; color: string }[] = []
for (const gs of gsLst.children('gs')) {
const pos = gs.numAttr('pos') ?? 0
const posPercent = pctToDecimal(pos) * 100
const { color, alpha } = resolveColor(gs, ctx)
stops.push({ position: posPercent, color: toCssColor(color, alpha) })
}
if (stops.length === 0) return ''
stops.sort((a, b) => a.position - b.position)
const stopsStr = stops.map((s) => `${s.color} ${s.position.toFixed(1)}%`).join(', ')
const lin = gradFill.child('lin')
if (lin.exists()) {
const angle = angleToDeg(lin.numAttr('ang') ?? 0)
const cssAngle = (angle + 90) % 360
return `linear-gradient(${cssAngle.toFixed(1)}deg, ${stopsStr})`
}
return `linear-gradient(180deg, ${stopsStr})`
}
// ---------------------------------------------------------------------------
// Bullet Generation
// ---------------------------------------------------------------------------
function generateAutoNumber(type: string, index: number): string {
const num = index + 1
switch (type) {
case 'arabicPeriod':
return `${num}.`
case 'arabicParenR':
return `${num})`
case 'arabicParenBoth':
return `(${num})`
case 'arabicPlain':
return `${num}`
case 'romanUcPeriod':
return `${toRoman(num)}.`
case 'romanLcPeriod':
return `${toRoman(num).toLowerCase()}.`
case 'alphaUcPeriod':
return `${String.fromCharCode(64 + (((num - 1) % 26) + 1))}.`
case 'alphaLcPeriod':
return `${String.fromCharCode(96 + (((num - 1) % 26) + 1))}.`
case 'alphaUcParenR':
return `${String.fromCharCode(64 + (((num - 1) % 26) + 1))})`
case 'alphaLcParenR':
return `${String.fromCharCode(96 + (((num - 1) % 26) + 1))})`
default:
return `${num}.`
}
}
function toRoman(num: number): string {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
const syms = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']
let result = ''
let remaining = num
for (let i = 0; i < vals.length; i++) {
while (remaining >= vals[i]) {
result += syms[i]
remaining -= vals[i]
}
}
return result
}
// ---------------------------------------------------------------------------
// Main Render Function
// ---------------------------------------------------------------------------
/**
* Render a text body into the provided container element.
*
* Implements 7-level style inheritance:
* 1. master.defaultTextStyle
* 2. master.textStyles[category] (titleStyle / bodyStyle / otherStyle)
* 3. master placeholder lstStyle
* 4. layout placeholder lstStyle
* 5. shape lstStyle
* 6. paragraph pPr
* 7. run rPr
*/
/** Optional overrides when rendering text (e.g. table cell style text properties from tcTxStyle). */
interface RenderTextBodyOptions {
/** When set, used as text color when the run has no explicit color (e.g. table style tcTxStyle). */
cellTextColor?: string
/** When set, applies bold from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextBold?: boolean
/** When set, applies italic from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextItalic?: boolean
/** When set, applies font family from table style tcTxStyle (overrides inherited, yields to explicit run rPr). */
cellTextFontFamily?: string
/** fontRef color from shape style (e.g. SmartArt). Overrides inherited styles but yields to explicit run rPr color. */
fontRefColor?: string
}
export function renderTextBody(
textBody: TextBody,
placeholder: PlaceholderInfo | undefined,
ctx: RenderContext,
container: HTMLElement,
options?: RenderTextBodyOptions
): void {
const category = getPlaceholderCategory(placeholder)
let bulletCounter = 0
// Parse normAutofit from bodyPr (font scaling + line spacing reduction)
let fontScale = 1
let lnSpcReduction = 0
if (textBody.bodyProperties) {
const normAutofit = textBody.bodyProperties.child('normAutofit')
if (normAutofit.exists()) {
const fs = normAutofit.numAttr('fontScale')
if (fs !== undefined) fontScale = fs / 100000 // 100000 = 100%
const lsr = normAutofit.numAttr('lnSpcReduction')
if (lsr !== undefined) lnSpcReduction = lsr / 100000 // e.g., 20000 = 20%
}
}
for (const paragraph of textBody.paragraphs) {
const paraDiv = document.createElement('div')
const level = paragraph.level
// ---- Build merged paragraph style (7-level inheritance) ----
const merged: MergedParagraphStyle = {}
// Level 1: master defaultTextStyle
mergeParagraphProps(merged, findStyleAtLevel(ctx.master.defaultTextStyle, level))
// Level 2: master text styles by category
const masterTextStyle =
category === 'title'
? ctx.master.textStyles.titleStyle
: category === 'body'
? ctx.master.textStyles.bodyStyle
: ctx.master.textStyles.otherStyle
mergeParagraphProps(merged, findStyleAtLevel(masterTextStyle, level))
// Level 3: master placeholder lstStyle
if (placeholder) {
const masterPh = findPlaceholderNode(ctx.master.placeholders, placeholder)
if (masterPh) {
const lstStyle = getPlaceholderLstStyle(masterPh)
mergeParagraphProps(merged, findStyleAtLevel(lstStyle, level))
}
}
// Level 4: layout placeholder lstStyle
if (placeholder) {
const layoutPh = findPlaceholderNode(
ctx.layout.placeholders.map((e) => e.node),
placeholder
)
if (layoutPh) {
const lstStyle = getPlaceholderLstStyle(layoutPh)
mergeParagraphProps(merged, findStyleAtLevel(lstStyle, level))
}
}
// Level 5: shape lstStyle
mergeParagraphProps(merged, findStyleAtLevel(textBody.listStyle, level))
// Level 6: paragraph pPr
if (paragraph.properties) {
mergeParagraphProps(merged, paragraph.properties)
}
// ---- Apply paragraph styles ----
if (merged.align) {
const alignMap: Record<string, string> = {
l: 'left',
ctr: 'center',
r: 'right',
just: 'justify',
dist: 'justify',
}
paraDiv.style.textAlign = alignMap[merged.align] || 'left'
}
if (merged.marginLeft !== undefined) {
paraDiv.style.marginLeft = `${merged.marginLeft}px`
}
if (merged.textIndent !== undefined) {
paraDiv.style.textIndent = `${merged.textIndent}px`
}
// Compute effective line-height (with optional lnSpcReduction from normAutofit)
let effectiveLineHeight = merged.lineHeight
if (merged.lineHeight) {
if (lnSpcReduction > 0) {
const parsed = Number.parseFloat(merged.lineHeight)
if (!Number.isNaN(parsed)) {
if (merged.lineHeight.includes('pt')) {
effectiveLineHeight = `${(parsed * (1 - lnSpcReduction)).toFixed(2)}pt`
} else {
effectiveLineHeight = `${(parsed * (1 - lnSpcReduction)).toFixed(3)}`
}
}
}
paraDiv.style.lineHeight = effectiveLineHeight!
}
// Determine effective font size for percentage-based spacing
// Use defRPr or first run's font size, fallback to 12pt
let effectiveFontSize = 12 // default 12pt
if (merged.defRPr) {
const sz = merged.defRPr.numAttr('sz')
if (sz !== undefined) effectiveFontSize = sz / 100
}
if (paragraph.runs.length > 0 && paragraph.runs[0].properties) {
const sz = paragraph.runs[0].properties.numAttr('sz')
if (sz !== undefined) effectiveFontSize = sz / 100
}
if (merged.spaceBefore !== undefined) {
paraDiv.style.marginTop = `${merged.spaceBefore}pt`
} else if (merged.spaceBeforePct !== undefined) {
paraDiv.style.marginTop = `${merged.spaceBeforePct * effectiveFontSize}pt`
}
if (merged.spaceAfter !== undefined) {
paraDiv.style.marginBottom = `${merged.spaceAfter}pt`
} else if (merged.spaceAfterPct !== undefined) {
paraDiv.style.marginBottom = `${merged.spaceAfterPct * effectiveFontSize}pt`
}
// ---- Bullets ----
// Suppress bullets for metadata placeholders (slide number, date, footer)
// Also suppress for empty paragraphs (no visible runs) — PowerPoint never shows bullets for them
const hasVisibleRuns = paragraph.runs.some((r) => r.text != null && r.text.length > 0)
const suppressBullet =
!hasVisibleRuns ||
placeholder?.type === 'sldNum' ||
placeholder?.type === 'dt' ||
placeholder?.type === 'ftr' ||
placeholder?.type === 'title' ||
placeholder?.type === 'ctrTitle' ||
placeholder?.type === 'subTitle'
let bulletPrefix = ''
if (!suppressBullet && merged.bulletNone !== true) {
if (merged.bulletChar) {
bulletPrefix = merged.bulletChar
} else if (merged.bulletAutoNum) {
bulletPrefix = generateAutoNumber(merged.bulletAutoNum, bulletCounter)
bulletCounter++
}
}
if (bulletPrefix) {
const bulletSpan = document.createElement('span')
bulletSpan.textContent = `${bulletPrefix} `
if (merged.bulletFont) {
bulletSpan.style.fontFamily = merged.bulletFont
}
// Bullet color: 1) explicit buClr from list style, 2) paragraph defRPr, 3) first run's color (so bullet matches text), 4) cell/fallback
let bulletColor: string | undefined
if (merged.bulletColorNode?.exists()) {
bulletColor = resolveColorToCss(merged.bulletColorNode, ctx)
}
if (bulletColor === undefined && merged.defRPr && merged.defRPr.exists()) {
const bulletRunStyle: MergedRunStyle = {}
mergeRunProps(bulletRunStyle, merged.defRPr, ctx)
bulletColor = bulletRunStyle.color
}
if (bulletColor === undefined && paragraph.runs.length > 0) {
const runStyle: MergedRunStyle = {}
if (merged.defRPr) mergeRunProps(runStyle, merged.defRPr, ctx)
if (paragraph.runs[0].properties) mergeRunProps(runStyle, paragraph.runs[0].properties, ctx)
bulletColor = runStyle.color
}
// Fallback: check shape's lstStyle defRPr for color (same as run fallback)
if (bulletColor === undefined && textBody.listStyle) {
const lstStyleLevel = findStyleAtLevel(textBody.listStyle, level)
if (lstStyleLevel.exists()) {
const lstDefRPr = lstStyleLevel.child('defRPr')
if (lstDefRPr.exists()) {
const fallbackStyle: MergedRunStyle = {}
mergeRunProps(fallbackStyle, lstDefRPr, ctx)
if (fallbackStyle.color !== undefined) {
bulletColor = fallbackStyle.color
}
}
}
}
bulletSpan.style.color =
bulletColor ?? options?.fontRefColor ?? options?.cellTextColor ?? '#000000'
paraDiv.appendChild(bulletSpan)
}
// ---- Render runs ----
if (paragraph.runs.length === 0) {
// Empty paragraph — still need to maintain spacing
paraDiv.appendChild(document.createElement('br'))
}
// When line spacing is absolute (spcPts) and paragraph has line breaks,
// wrap each line in a block-level div with explicit height. This ensures
// exact spacing regardless of font metrics (CJK fonts e.g. Microsoft YaHei have
// content areas taller than font-size, causing CSS line-height to be
// overridden by the font's natural spacing).
const hasLineBreaks = paragraph.runs.some((r) => r.text === '\n')
// Set tab-size when paragraph contains tab characters (default OOXML tab spacing = 914400 EMU = 96px)
if (paragraph.runs.some((r) => r.text?.includes('\t'))) {
const defaultTabPx = 96 // 914400 EMU at 96 dpi
paraDiv.style.tabSize = `${defaultTabPx}px`
}
const useLineWrappers = merged.lineHeightAbsolute && hasLineBreaks && effectiveLineHeight
let currentLineDiv: HTMLElement | null = null
if (useLineWrappers) {
currentLineDiv = document.createElement('div')
currentLineDiv.style.height = effectiveLineHeight!
currentLineDiv.style.overflow = 'visible'
paraDiv.appendChild(currentLineDiv)
}
for (const run of paragraph.runs) {
if (run.text === '\n') {
if (useLineWrappers) {
// Close current line div and start a new one
currentLineDiv = document.createElement('div')
currentLineDiv.style.height = effectiveLineHeight!
currentLineDiv.style.overflow = 'visible'
paraDiv.appendChild(currentLineDiv)
} else {
paraDiv.appendChild(document.createElement('br'))
}
continue
}
// Build merged run style
const runStyle: MergedRunStyle = {}
// Apply default run properties from merged paragraph defRPr
if (merged.defRPr) {
mergeRunProps(runStyle, merged.defRPr, ctx)
}
// Level 7: run rPr
if (run.properties) {
mergeRunProps(runStyle, run.properties, ctx)
}
// Fallback: if no color resolved yet, check the shape's lstStyle defRPr.
// This handles the case where paragraph pPr has an empty <a:defRPr/> that
// overwrites the lstStyle's defRPr (which may carry solidFill color).
if (runStyle.color === undefined && textBody.listStyle) {
const lstStyleLevel = findStyleAtLevel(textBody.listStyle, level)
if (lstStyleLevel.exists()) {
const lstDefRPr = lstStyleLevel.child('defRPr')
if (lstDefRPr.exists()) {
const fallbackStyle: MergedRunStyle = {}
mergeRunProps(fallbackStyle, lstDefRPr, ctx)
if (fallbackStyle.color !== undefined) {
runStyle.color = fallbackStyle.color
}
}
}
}
// Determine if this should be a link
let element: HTMLElement
if (runStyle.hlinkClick) {
const a = document.createElement('a')
a.href = runStyle.hlinkClick
a.target = '_blank'
a.rel = 'noopener noreferrer'
element = a
} else {
element = document.createElement('span')
}
// Preserve consecutive spaces by alternating with &nbsp; so they survive
// HTML whitespace collapse without being stretched by text-align:justify.
// Tabs still need white-space:pre for tab-stop rendering.
if (run.text?.includes('\t')) {
element.textContent = run.text
element.style.whiteSpace = 'pre'
} else if (run.text && / {2}/.test(run.text)) {
// Replace pairs of spaces with " &nbsp;" so browsers cannot collapse them,
// while normal spaces between words remain stretchable for justify.
const escaped = run.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/ {2}/g, ' \u00a0')
element.innerHTML = escaped
} else {
element.textContent = run.text
}
// Apply run styles (with normAutofit fontScale)
// Default to 12pt if no font size specified at any inheritance level
const fontSize = runStyle.fontSize || 12
element.style.fontSize = `${fontSize * fontScale}pt`
// Bold: explicit run rPr > cellTextBold (table style tcTxStyle) > inherited styles
const hasExplicitRunBold = run.properties?.attr('b') !== undefined
if (hasExplicitRunBold ? runStyle.bold : (options?.cellTextBold ?? runStyle.bold)) {
element.style.fontWeight = 'bold'
}
// Italic: explicit run rPr > cellTextItalic (table style tcTxStyle) > inherited styles
const hasExplicitRunItalic = run.properties?.attr('i') !== undefined
if (hasExplicitRunItalic ? runStyle.italic : (options?.cellTextItalic ?? runStyle.italic)) {
element.style.fontStyle = 'italic'
}
const decorations: string[] = []
if (runStyle.underline) decorations.push('underline')
if (runStyle.strikethrough) decorations.push('line-through')
if (decorations.length > 0) {
element.style.textDecoration = decorations.join(' ')
}
// Color priority: explicit run rPr > hlink theme color > cellTextColor (table style tcTxStyle) > fontRef (shape style) > inherited styles > black default
// cellTextColor from table style overrides inherited cascade colors but yields to explicit run/paragraph solidFill/gradFill.
// fontRefColor overrides inherited styles but yields to explicit run solidFill/gradFill.
const hasExplicitRunColor =
run.properties?.child('solidFill').exists() || run.properties?.child('gradFill').exists()
let effectiveColor: string | undefined
if (options?.fontRefColor) {
effectiveColor = hasExplicitRunColor ? runStyle.color : options.fontRefColor
} else if (options?.cellTextColor && !hasExplicitRunColor) {
effectiveColor = options.cellTextColor
} else {
effectiveColor = runStyle.color
}
// Hyperlink default color: when the run is a hyperlink and has no explicit
// solidFill on its own rPr, use the theme's hlink color. This matches
// PowerPoint behaviour where hyperlink text defaults to the hlink scheme color.
if (runStyle.hlinkClick && !hasExplicitRunColor) {
const hlinkHex = ctx.theme.colorScheme.get('hlink')
if (hlinkHex) {
effectiveColor = hlinkHex.startsWith('#') ? hlinkHex : `#${hlinkHex}`
}
}
if (effectiveColor) {
element.style.color = effectiveColor
} else {
// No explicit color from run/paragraph/style: use black so text does not inherit page CSS (e.g. body { color: #e0e0e0 })
element.style.color = '#000000'
}
// Gradient text fill: use background-clip to paint text with gradient
if (runStyle.textGradientCss) {
element.style.background = runStyle.textGradientCss
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitBackgroundClip = 'text'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).backgroundClip = 'text'
element.style.color = 'transparent'
}
// Text outline (a:ln on rPr) and noFill handling
if (runStyle.textNoFill || runStyle.textOutlineWidth) {
const strokeW = runStyle.textOutlineWidth ?? 0.75
if (runStyle.textNoFill && runStyle.textOutlineGradientCss) {
// Ghost text: no fill + gradient outline → show outline fading via mask
const outlineColor = '#ffffff' // base stroke color (gradient applied via mask)
element.style.color = 'transparent'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = outlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
// Use mask-image to apply the gradient fade to the entire text element
const maskGrad = runStyle.textOutlineGradientCss
element.style.maskImage = maskGrad
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitMaskImage = maskGrad
} else if (runStyle.textNoFill && runStyle.textOutlineColor) {
// Ghost text with solid outline
element.style.color = 'transparent'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = runStyle.textOutlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
} else if (runStyle.textNoFill) {
// noFill with no outline — invisible text (but keep space)
element.style.color = 'transparent'
} else if (runStyle.textOutlineColor) {
// Outline with normal fill
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeWidth = `${strokeW}px`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).webkitTextStrokeColor = runStyle.textOutlineColor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(element.style as any).paintOrder = 'stroke fill'
}
}
// Font family: explicit run rPr > cellTextFontFamily (table style) > inherited > theme fallback
const hasExplicitRunFont =
run.properties?.child('latin').exists() ||
run.properties?.child('ea').exists() ||
run.properties?.child('cs').exists()
const effectiveFont = hasExplicitRunFont
? runStyle.fontFamily
: (options?.cellTextFontFamily ?? runStyle.fontFamily)
if (effectiveFont) {
element.style.fontFamily = `"${effectiveFont}"`
} else {
// Fallback to theme minor font
const fallback = ctx.theme.minorFont.latin || ctx.theme.minorFont.ea
if (fallback) {
element.style.fontFamily = `"${fallback}"`
}
}
// Character spacing (a:spc) — compact/tracking in points
if (runStyle.letterSpacingPt !== undefined) {
element.style.letterSpacing = `${runStyle.letterSpacingPt}pt`
}
// Kerning (a:kern): val = min font size (pt) to kern; 0 = always kern
if (runStyle.kern !== undefined) {
const effectivePt = (runStyle.fontSize || 12) * fontScale
element.style.fontKerning = effectivePt >= runStyle.kern ? 'normal' : 'none'
}
// Text capitalization (a:rPr@cap)
if (runStyle.cap === 'all') {
element.style.textTransform = 'uppercase'
} else if (runStyle.cap === 'small') {
element.style.fontVariant = 'small-caps'
}
// Baseline shift (superscript/subscript)
if (runStyle.baseline !== undefined && runStyle.baseline !== 0) {
// OOXML baseline is in 1000ths of percent; positive = superscript, negative = subscript
const shiftPct = runStyle.baseline / 1000
element.style.verticalAlign = `${shiftPct}%`
// Reduce font size for super/subscript
if (Math.abs(shiftPct) >= 20) {
element.style.fontSize = `${fontSize * fontScale * 0.65}pt`
}
}
// Append to the current line wrapper (when using absolute line spacing)
// or directly to the paragraph div
const appendTarget = currentLineDiv ?? paraDiv
appendTarget.appendChild(element)
}
// endParaRPr: when the paragraph ends with a line break (trailing \n),
// the end-of-paragraph mark (endParaRPr) defines the font size for the
// trailing blank line. Without this, bottom-anchored text boxes render
// content too low because the trailing space is too small.
if (paragraph.endParaRPr) {
const lastRun = paragraph.runs[paragraph.runs.length - 1]
if (lastRun?.text === '\n') {
const epSz = paragraph.endParaRPr.numAttr('sz')
if (epSz !== undefined) {
const spacer = document.createElement('span')
spacer.textContent = '\u200B' // zero-width space to maintain line height
spacer.style.fontSize = `${(epSz / 100) * fontScale}pt`
const target = currentLineDiv ?? paraDiv
target.appendChild(spacer)
}
}
}
container.appendChild(paraDiv)
}
}