/**
* 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
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 = { 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 = { 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 = {
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 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(/ {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)
}
}