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
966 lines
36 KiB
TypeScript
966 lines
36 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
}
|