/** * Text serializer — maps TextBody to HTML for pptxtojson `content` (Shape.content / Text.content). * Migration of pptx-renderer `TextRenderer.renderTextBody`: same inheritance and merge logic; * output is an HTML string instead of a DOM container. See textSerializer.md (this folder). */ import katex from 'katex'; import type { RenderContext } from './RenderContext'; import type { TextBody, TextParagraph, TextRun } from '../model/nodes/ShapeNode'; import type { PlaceholderInfo } from '../model/nodes/BaseNode'; import { SafeXmlNode } from '../parser/XmlParser'; import { resolveColor, resolveColorToCss } from './StyleResolver'; import { ommlToLatex } from './mathSerializer'; import { emuToPx, pctToDecimal, angleToDeg } from '../parser/units'; import { isAllowedExternalUrl } from '../utils/urlSafety'; // --------------------------------------------------------------------------- // Wingdings / Symbol Font → Unicode Mapping // --------------------------------------------------------------------------- const SYMBOL_FONTS = new Set(['wingdings', 'wingdings 2', 'wingdings 3', 'symbol', 'webdings']); function isSymbolFont(fontName: string | undefined): boolean { return !!fontName && SYMBOL_FONTS.has(fontName.toLowerCase()); } const WINGDINGS: Record = { 0x66: '●', 0x67: '●', 0x6c: '●', 0x6d: '○', 0x6e: '■', 0x6f: '□', 0x71: '✕', 0x72: '✓', 0x73: '☐', 0x74: '⬥', 0x75: '◆', 0x76: '❖', 0x77: '⬜', 0x9c: '●', 0x9d: '○', 0x9e: '■', 0x9f: '□', 0xa1: '✡', 0xa7: '✺', 0xab: '⇨', 0xfc: '●', 0xa8: '✶', 0xaa: '⇦', 0xac: '⇧', 0xad: '⇩', // Arrows 0xe0: '→', 0xe1: '←', 0xe2: '↑', 0xe3: '↓', 0xe4: '↔', 0xe5: '↕', 0xe6: '⇒', 0xe7: '⇐', 0xe8: '⇑', 0xe9: '⇓', 0xea: '⇔', 0xeb: '⇕', 0xef: '➔', // Miscellaneous 0xd5: '✉', 0xd6: '☛', 0xd7: '☞', 0xd8: '✌', 0xfb: '⚫', }; const WINGDINGS2: Record = { 0x9e: '◉', 0x9f: '⊙', 0x62: '①', 0x63: '②', 0x64: '③', 0x65: '④', 0x66: '⑤', 0x67: '⑥', 0x68: '⑦', 0x69: '⑧', 0x6a: '⑨', 0x6b: '⑩', 0x98: '⬥', 0x99: '◇', 0xa3: '✦', 0xf0: '●', 0xf1: '○', 0xf2: '◉', 0xf3: '◎', }; const WINGDINGS3: Record = { 0x7d: '▶', 0x7e: '◀', 0x7b: '▲', 0x7c: '▼', 0x75: '►', 0x76: '◄', 0x77: '▸', 0x78: '◂', }; const SYMBOL: Record = { 0xb7: '•', 0xd8: '≠', 0xb3: '≥', 0xa3: '≤', 0xae: '®', 0xa9: '©', 0xc6: '…', }; function symbolFontCharToUnicode(char: string, fontName: string): string { if (!char || char.length === 0) return char; const font = fontName.toLowerCase(); let code = char.codePointAt(0) ?? 0; if (code >= 0xf000 && code <= 0xf0ff) code -= 0xf000; let table: Record | undefined; if (font === 'wingdings') table = WINGDINGS; else if (font === 'wingdings 2') table = WINGDINGS2; else if (font === 'wingdings 3') table = WINGDINGS3; else if (font === 'symbol') table = SYMBOL; if (table && table[code]) return table[code]; return '•'; } // --------------------------------------------------------------------------- // 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) 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' || !t ) { return 'body'; } return 'other'; } /** * Find a placeholder node in a list by matching type and/or idx. */ export 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; /** buSzPct: bullet size as percentage of font size (e.g. 73000 → 0.73). */ bulletSizePct?: number; /** When set, bullet color is taken from this OOXML buClr node (a:buClr with srgbClr/schemeClr child). */ bulletColorNode?: SafeXmlNode; defRPrs?: SafeXmlNode[]; /** Custom tab stop positions (a:tabLst/a:tab@pos) in px, measured from the text frame's * left inset, sorted ascending. PowerPoint uses these to align tabbed text (e.g. list * items / titles positioned to the right of an icon). Without them we fall back to the * OOXML default 96px tab grid, which pushes tabbed text far past its intended column. */ tabStopsPx?: number[]; } function buildMergedParagraphStyle( textBody: TextBody, paragraph: TextParagraph, category: 'title' | 'body' | 'other', placeholder: PlaceholderInfo | undefined, ctx: RenderContext, ): MergedParagraphStyle { const merged: MergedParagraphStyle = {}; const level = paragraph.level; // 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); } return merged; } 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); // Custom tab stops (a:tabLst). A more specific level fully replaces the list — // OOXML tabLst is not additive across inheritance levels. const tabLst = pPr.child('tabLst'); if (tabLst.exists()) { const stops = tabLst .children('tab') .map((t) => t.numAttr('pos')) .filter((p): p is number => p !== undefined) .map((p) => emuToPx(p)) .sort((a, b) => a - b); if (stops.length > 0) target.tabStopsPx = stops; } // 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 = `${parseFloat((val / 100000).toFixed(4))}`; // Reset the absolute flag: a spcPct override at a more specific level // must not leave a stale `lineHeightAbsolute=true` from an inherited // spcPts, otherwise the line wrapper would emit `height: 1.0` (no unit). target.lineHeightAbsolute = false; } } 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'); } const buSzPct = pPr.child('buSzPct'); if (buSzPct.exists()) { const val = buSzPct.numAttr('val'); if (val !== undefined) target.bulletSizePct = val / 100000; } // 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 — accumulate across all inheritance levels so that // an empty at a lower level doesn't discard the sz/color set by // a higher level (e.g. master titleStyle sz="4400"). const defRPr = pPr.child('defRPr'); if (defRPr.exists()) { if (!target.defRPrs) target.defRPrs = []; target.defRPrs.push(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; /** CSS text-shadow string from a:rPr > a:effectLst > a:outerShdw. */ textShadowCss?: string; } /** * Compute CSS text-shadow from inside a rPr. PPT * cover titles routinely use this for the soft drop shadow under big chapter * text. Mirrors shapeSerializer.resolveShapeShadow but emits in px for * text-shadow rather than the shape Shadow record. */ function resolveTextShadowCss(rPr: SafeXmlNode, ctx: RenderContext): string | undefined { const effectLst = rPr.child('effectLst'); if (!effectLst.exists()) return undefined; const shd = effectLst.child('outerShdw'); if (!shd.exists()) return undefined; const dir = shd.numAttr('dir') ?? 0; const dist = shd.numAttr('dist') ?? 0; const blurRad = shd.numAttr('blurRad') ?? 0; const dirDeg = dir / 60000; const distPx = emuToPx(dist); const blurPx = emuToPx(blurRad); const x = distPx * Math.cos((dirDeg * Math.PI) / 180); const y = distPx * Math.sin((dirDeg * Math.PI) / 180); let color = 'rgba(0,0,0,0.4)'; const { color: shdColor, alpha: shdAlpha } = resolveColor(shd, ctx); if (shdColor) { const hex = shdColor.startsWith('#') ? shdColor : `#${shdColor}`; const { r, g, b } = hexToRgbInternal(hex); color = `rgba(${r},${g},${b},${shdAlpha.toFixed(3)})`; } return `${x.toFixed(2)}px ${y.toFixed(2)}px ${blurPx.toFixed(2)}px ${color}`; } 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 / gradFill / noFill child. // 这三种是 OOXML 里互斥的 fill 类型——后处理的 rPr 层级一旦显式声明 // 其中之一,必须把其它两种从上层继承下来的状态清掉。否则会出现 // master 给了 noFill、slide 给了 solidFill 这种"既要白色又要透明"的 // CSS 输出 `color:#FFF;color:transparent;`,浏览器取后者,文字直接看不见 // (slide #3 的"01"-"05" 编号即此类)。 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 } = hexToRgbInternal(hex); target.color = `rgba(${r},${g},${bl},${alpha.toFixed(3)})`; } else { target.color = hex; } target.textGradientCss = undefined; target.textNoFill = undefined; } const gradFill = rPr.child('gradFill'); if (gradFill.exists()) { const css = resolveGradientForText(gradFill, ctx); if (css) { target.textGradientCss = css; target.color = undefined; target.textNoFill = undefined; } } // 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. // 与 solidFill/gradFill 互斥——显式 noFill 必须把上层继承下来的颜色清掉, // 否则会输出 `color:#xxx;color:transparent;` 这种"既继承又透明"的样式。 if (rPr.child('noFill').exists()) { target.textNoFill = true; target.color = undefined; target.textGradientCss = undefined; } // 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 = colorToCssLocal(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); } } // Run-level drop shadow (a:effectLst > a:outerShdw on rPr). Cover-title // decks rely on this for the soft halo under big chapter text — without it // the title looks flat versus the original PPT render. const textShadow = resolveTextShadowCss(rPr, ctx); if (textShadow) target.textShadowCss = textShadow; } /** * Resolve theme font placeholder references like "+mj-lt" or "+mn-lt". * For the EA (East Asian) slot we fall back to the theme's Hans script row * when the explicit `ea` typeface is empty — Office decks routinely leave * `` blank and rely on `` to * pick a Chinese face. Without that fallback the literal "+mj-ea" passes * through unchanged and the browser substitutes a default sans, which then * faux-bolds and visibly mismatches the deck's intended Songti rendering. */ function resolveThemeFont(typeface: string, ctx: RenderContext): string { if (typeface === '+mj-lt' || typeface === '+mj-ea' || typeface === '+mj-cs') { const key = typeface.slice(4) as 'lt' | 'ea' | 'cs'; const mapping: Record = { lt: 'latin', ea: 'ea', cs: 'cs' }; const slot = mapping[key] || 'latin'; const direct = ctx.theme.majorFont[slot]; if (direct) return direct; if (key === 'ea' && ctx.theme.majorFont.hans) return ctx.theme.majorFont.hans; return typeface; } if (typeface === '+mn-lt' || typeface === '+mn-ea' || typeface === '+mn-cs') { const key = typeface.slice(4) as 'lt' | 'ea' | 'cs'; const mapping: Record = { lt: 'latin', ea: 'ea', cs: 'cs' }; const slot = mapping[key] || 'latin'; const direct = ctx.theme.minorFont[slot]; if (direct) return direct; if (key === 'ea' && ctx.theme.minorFont.hans) return ctx.theme.minorFont.hans; return typeface; } return typeface; } /** * Minimal hex-to-rgb parser for inline use. */ function hexToRgbInternal(hex: string): { r: number; g: number; b: number } { const cleaned = hex.replace(/^#/, ''); const num = parseInt( cleaned.length === 3 ? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2] : cleaned, 16, ); return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff }; } /** * Convert resolved color + alpha to CSS color string. */ function colorToCssLocal(color: string, alpha: number): string { const hex = color.startsWith('#') ? color : `#${color}`; if (alpha >= 1) return hex; const { r, g, b } = hexToRgbInternal(hex); return `rgba(${r},${g},${b},${alpha.toFixed(3)})`; } /** * 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: colorToCssLocal(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; } // --------------------------------------------------------------------------- // HTML string output (replaces DOM container in TextRenderer) // --------------------------------------------------------------------------- function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function escapeHtmlAttr(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"'); } /** * Preserve consecutive spaces (same intent as TextRenderer innerHTML / textContent handling). */ function formatRunTextForHtml(raw: string): string { if (!raw) return ''; if (raw.includes('\t')) { return escapeHtml(raw); } // PowerPoint \u91cc\u4f5c\u8005\u5e38\u7528 N \u4e2a ASCII \u7a7a\u683c\u505a\u89c6\u89c9\u7f29\u8fdb\u2014\u2014\u5728 \u7b49\u7ebf/CJK \u5b57\u4f53\u91cc // ASCII space \u662f\u56fa\u5b9a\u7684 half-width\uff080.5em\uff09\uff0c\u4f46\u6d4f\u89c8\u5668\u66ff\u6362\u6210 SourceHanSans // \u540e\u7a7a\u683c\u5bbd\u5ea6\u504f\u5bbd\uff0c\u5bfc\u81f4 "\u6388\u8bfe\u56e2\u961f\uff1a" \u4e0b\u9762\u7528\u7a7a\u683c\u5bf9\u9f50\u7684\u51e0\u884c\u88ab\u63a8\u5230\u6bd4\u6e90 PPT // \u6392\u7248\u610f\u56fe\u66f4\u9760\u53f3\u7684\u4f4d\u7f6e\u3002\u628a\u9996\u6bb5 >=2 \u4e2a\u8fde\u7eed\u7a7a\u683c\u6298\u7b97\u6210\u7b49\u5bbd inline-block\uff0c // \u7f29\u8fdb\u5c31\u548c\u5b57\u4f53\u5ea6\u91cf\u89e3\u8026\uff0c\u5339\u914d CJK half-width \u4e60\u60ef\u3002 let leadingPrefix = ''; let remainder = raw; const leadingMatch = remainder.match(/^( {2,})/); if (leadingMatch) { const count = leadingMatch[1].length; remainder = remainder.slice(count); // 0.25em/空格:自托管思源宋体/黑体实测 ASCII space advance ≈0.256em。早期用 // 0.5em 是 fonts.css 未 import 时空格落到偏宽系统 fallback 的补偿;字体注册修复 // 后 0.5em 反而把「图标+\t+空格+标题」窄框标题多撑约 1em 触发误换行(slide 6)。 leadingPrefix = ``; } let t = escapeHtml(remainder); if (/ {2}/.test(remainder)) { t = t.replace(/ {2}/g, ' \u00a0'); } return leadingPrefix + t; } type SupportedTextWarp = 'textArchUp' | 'textArchDown'; function getSupportedTextWarp(textBody: TextBody): SupportedTextWarp | undefined { const warp = textBody.bodyProperties?.child('prstTxWarp'); const preset = warp?.exists() ? warp.attr('prst') : undefined; return preset === 'textArchUp' || preset === 'textArchDown' ? preset : undefined; } function textRunToGlyphs(text: string): string[] { return Array.from(text.replace(/\s+/g, ' ')); } function bodyTextHeightPx(textBody: TextBody, options: RenderTextBodyOptions | undefined): number { if (!options?.frameHeightPx || options.frameHeightPx <= 0) return 72; const bp = textBody.bodyProperties; const DEFAULT_V_INSET = 45720; const tIns = bp?.numAttr('tIns') ?? DEFAULT_V_INSET; const bIns = bp?.numAttr('bIns') ?? DEFAULT_V_INSET; return Math.max(24, options.frameHeightPx - emuToPx(tIns + bIns)); } function renderTextWarp( textBody: TextBody, category: 'title' | 'body' | 'other', placeholder: PlaceholderInfo | undefined, ctx: RenderContext, options: RenderTextBodyOptions | undefined, warp: SupportedTextWarp, ): string { const glyphs: { text: string; style: string }[] = []; for (const paragraph of textBody.paragraphs) { const level = paragraph.level; const merged = buildMergedParagraphStyle(textBody, paragraph, category, placeholder, ctx); for (const run of paragraph.runs) { if (run.text === '\n' || run.ommlXml) continue; const runStyle: MergedRunStyle = {}; if (merged.defRPrs) { for (const drp of merged.defRPrs) mergeRunProps(runStyle, drp, ctx); } if (run.properties) { mergeRunProps(runStyle, run.properties, ctx); } if (runStyle.color === undefined && textBody.listStyle) { const lstStyleLevel = findStyleAtLevel(textBody.listStyle, level); const lstDefRPr = lstStyleLevel.exists() ? lstStyleLevel.child('defRPr') : undefined; if (lstDefRPr?.exists()) { const fallbackStyle: MergedRunStyle = {}; mergeRunProps(fallbackStyle, lstDefRPr, ctx); if (fallbackStyle.color !== undefined) runStyle.color = fallbackStyle.color; } } let runText = run.text ?? ''; if (run.properties) { const symNode = run.properties.child('sym'); const symTypeface = symNode.exists() ? symNode.attr('typeface') : undefined; if (isSymbolFont(symTypeface)) { runText = Array.from(runText) .map((ch) => symbolFontCharToUnicode(ch, symTypeface!)) .join(''); } } const style = runStylesToCssString(runStyle, run, options, ctx); for (const ch of textRunToGlyphs(runText)) { glyphs.push({ text: ch, style }); } } } if (glyphs.length === 0) return ''; const count = glyphs.length; const totalAngle = Math.min(76, Math.max(40, count * 6.5)); const maxAngle = totalAngle / 2; const maxRad = (maxAngle * Math.PI) / 180; const xRadiusPct = Math.min(44, Math.max(30, count * 3.9)); const yAmplitudePct = 12; const centerYPct = warp === 'textArchUp' ? 18 : 82; const heightPx = bodyTextHeightPx(textBody, options); let html = `
`; for (let i = 0; i < glyphs.length; i++) { const ratio = count === 1 ? 0 : (i / (count - 1)) * 2 - 1; const angle = ratio * maxAngle; const rad = (angle * Math.PI) / 180; const curve = maxRad > 0 ? (1 - Math.cos(Math.abs(rad))) / (1 - Math.cos(maxRad)) : 0; const x = 50 + (maxRad > 0 ? (Math.sin(rad) / Math.sin(maxRad)) * xRadiusPct : 0); const y = warp === 'textArchUp' ? centerYPct + yAmplitudePct * curve : centerYPct - yAmplitudePct * curve; const rotation = warp === 'textArchUp' ? angle : -angle; const text = glyphs[i].text === ' ' ? ' ' : formatRunTextForHtml(glyphs[i].text); const style = `${glyphs[i].style};position: absolute;left: ${x.toFixed(2)}%;top: ${y.toFixed(2)}%;` + `line-height: 1;transform: translate(-50%, -50%) rotate(${rotation.toFixed(2)}deg);` + 'transform-origin: center center;white-space: nowrap;'; html += `${text}`; } html += '
'; return html; } // --------------------------------------------------------------------------- // 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). */ export 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; /** * When set, used as text insets (EMU) for table cells. tcPr cell margins * (marL/marR/marT/marB) override the bodyPr defaults — in PowerPoint table * cells, the tcPr margins are the source of truth for cell padding, not * the OOXML shape bodyPr defaults (91440/45720 EMU). */ cellMargins?: { lIns: number; rIns: number; tIns: number; bIns: number }; /** * Text frame width in px (shape width). Used to clamp the leading tab-fold * indent: a custom tab stop (a:tabLst) is an absolute column that can exceed * a narrow box. Without a clamp the folded `margin-left` swallows the whole * frame and CJK text wraps one char per line (slide 14 的三张窄卡片). When the * stop overshoots the box we cap the indent so a usable text column remains. */ frameWidthPx?: number; /** Text frame height in output CSS coordinates. Used by preset text-warp layouts. */ frameHeightPx?: number; /** Force paragraph text to stay on one line when PPT will grow/rotate the box instead of wrapping. */ forceNoWrap?: boolean; } /** * Same contract as `TextRenderer.renderTextBody`, but returns an HTML string for `Shape.content` / `Text.content` * (types.ts / README) instead of mutating a DOM `container`. */ export function renderTextBody( textBody: TextBody | undefined, placeholder: PlaceholderInfo | undefined, ctx: RenderContext, options?: RenderTextBodyOptions, ): string { if (!textBody?.paragraphs?.length) return ''; const category = getPlaceholderCategory(placeholder); let bulletCounter = 0; const noWrap = options?.forceNoWrap || textBody.bodyProperties?.attr('wrap') === 'none'; // ECMA-376: 默认状态下首段的 spcBef 与末段的 spcAft 都要丢掉,只有 bodyPr@spcFirstLastPara="1" // 时才把它们当真。我们之前对所有段一律渲染成 margin-top,结果首段被多挤了 spcBef pt, // 例如 slide 4 "第一讲 / 初识清华" cell 在 cy=63pt + 17.2pt 上下内边距下只剩 46pt 排两行 // 18pt 文字,被 6pt 的首段 spcBef 顶到溢出,最后一行被裁。 const spcFirstLastPara = textBody.bodyProperties?.attr('spcFirstLastPara') === '1' || textBody.bodyProperties?.attr('spcFirstLastPara') === 'true'; const lastParaIdx = textBody.paragraphs.length - 1; let html = ''; const textWarp = getSupportedTextWarp(textBody); if (textWarp) { html = renderTextWarp(textBody, category, placeholder, ctx, options, textWarp); } else { let paraIdx = 0; for (const paragraph of textBody.paragraphs) { const isFirstPara = paraIdx === 0; const isLastPara = paraIdx === lastParaIdx; paraIdx++; const level = paragraph.level; // ---- Build merged paragraph style (7-level inheritance) ---- const merged = buildMergedParagraphStyle(textBody, paragraph, category, placeholder, ctx); // ---- Apply paragraph styles (equivalent to paraDiv.style.* in TextRenderer) ---- const paraCssParts: string[] = []; if (merged.align) { const alignMap: Record = { l: 'left', ctr: 'center', r: 'right', just: 'justify', dist: 'justify', }; paraCssParts.push(`text-align: ${alignMap[merged.align] || 'left'}`); } // PowerPoint 的 hanging-indent 默认:`` 用于把首行 // 拉回,但作者通常省略 marL,因为 PPT 对带 bullet 的段默认套用 marL=N // 让 bullet 落在元素左边沿、body 落在 +N 缩进。我们 marL 只在显式存在 // 时设置,缺省时 first-line 跑到 element 外,bullet 会和左侧编号圆 // (slide 3) 之类的相邻元素重叠。指标段(有 bullet 且 indent<0 且无 // 显式 marL)按 PPT 默认补 marL = -indent。 let effectiveMarginLeft = merged.marginLeft; // 标记 marL 是否为「合成」(无真实 marL、按 -indent 补)。合成意味着 bullet 紧贴 // element 左沿、可能压住相邻形状(slide 3 编号圆);真实 marL 则 bullet 落在 // marL+indent 的悬挂位、不贴边。下方 bullet 槽位的 symbol padding 据此区分。 let marginLeftSynthesized = false; if ( (effectiveMarginLeft === undefined || effectiveMarginLeft === 0) && merged.textIndent !== undefined && merged.textIndent < 0 && (merged.bulletChar || merged.bulletAutoNum) ) { effectiveMarginLeft = -merged.textIndent; marginLeftSynthesized = true; } // Tab stop width from a:tabLst (first stop): (firstStop − marginLeft), default // OOXML 96px grid. Shared by the leading-indent fold and the tab-size fallback. const resolveTabPx = (): number => { let tabPx = 96; const firstStop = merged.tabStopsPx?.[0]; if (firstStop !== undefined) { const ml = effectiveMarginLeft ?? 0; const derived = firstStop - ml; if (derived >= 1) tabPx = derived; } return tabPx; }; // ── 行首 tab/空格缩进折叠 ────────────────────────────────────────── // PowerPoint 用「前导 \t (+a:tabLst) 和/或前导空格」把标题/列表行推到图标右侧。 // 裸 \t(white-space:pre + tab-size)和空格折出的空 inline-block 占位在原样吐 // HTML 的渲染器里没问题,但 a2m 编辑画布走 ProseMirror 重新解析:绝对行高那条路 // 产出的「外层 div + 行 wrapper div」嵌套里 text-indent 首行缩进继承不可靠(实测 // margin-left 生效、text-indent 丢失)。因此把行首空白折进 margin-left(整块右移) // ——每条图标行都是单行,整块右移与首行缩进视觉等价,raw-HTML 也一致;取值复用既有 // tabPx / 0.25em。仅在「行首含 tab」时折叠,并把已折叠的行首空白从 run 剥掉(见下 run 循环)。 let leadingStripChars = 0; let leadingFoldedTabs = 0; let leadingFoldPx = 0; let leadingFoldEm = 0; let leadingSawTab = false; let leadingFirstStop: number | undefined; { let spaceCount = 0; let consumed = 0; let sawTab = false; let stop = false; for (const run of paragraph.runs) { if (stop) break; const txt = run.text ?? ''; if (txt === '') continue; let i = 0; while (i < txt.length && (txt[i] === '\t' || txt[i] === ' ')) { if (txt[i] === '\t') { leadingFoldedTabs++; sawTab = true; } else spaceCount++; i++; } consumed += i; if (i < txt.length) stop = true; } if (sawTab) { // A custom a:tabLst stop is an ABSOLUTE column. A leading tab advances // the text to that stop; if the stop sits at/behind the paragraph marL // it can't move text backwards, so the tab adds nothing to marginLeft. // (slide 14 的窄卡片:tab pos≈35.5px < marL≈49px,旧逻辑 derived<1 退回 // 96px 默认网格,把首段错误推到 marL+96=145px。)只有「无自定义 tabLst」用 96。 const firstStop = merged.tabStopsPx?.[0]; const ml = effectiveMarginLeft ?? 0; const tabAdvance = firstStop !== undefined ? Math.max(0, firstStop - ml) : 96; leadingFoldPx = leadingFoldedTabs * tabAdvance; leadingFoldEm = spaceCount * 0.25; leadingStripChars = consumed; leadingSawTab = true; leadingFirstStop = firstStop; } } let finalMarginLeftPx: number | undefined; if (effectiveMarginLeft !== undefined || leadingFoldPx > 0 || leadingFoldEm > 0) { if (leadingFoldPx > 0 || leadingFoldEm > 0) { let mlPx = (effectiveMarginLeft ?? 0) + leadingFoldPx; // Clamp against the frame width: a tabLst stop is an absolute column // that may sit past a narrow box (slide 14 的窄卡片 firstStop≈145px vs // 宽 123px)。不裁剪的话 margin-left 吃光整框,CJK 文本逐字竖排。保留至少 // 半个框宽给文本列;宽框(如 388px)下不会触发,行为不变。 const frameW = options?.frameWidthPx; if (frameW && frameW > 0) { const maxMlPx = Math.max(0, frameW - frameW * 0.5); if (mlPx > maxMlPx) mlPx = maxMlPx; } finalMarginLeftPx = mlPx; paraCssParts.push( `margin-left: ${leadingFoldEm > 0 ? `calc(${mlPx.toFixed(2)}px + ${leadingFoldEm.toFixed(2)}em)` : `${mlPx}px`}`, ); } else { finalMarginLeftPx = effectiveMarginLeft; paraCssParts.push(`margin-left: ${effectiveMarginLeft}px`); } } // text-indent: when a leading tab folded but its stop is at/behind marL // (leadingFoldPx === 0), the tab still nudges the FIRST line forward to the // stop — wrapped lines stay at marL. Position the first line at the stop so // it clears a left-side icon (slide 14 图标与首字重叠). a2m drops text-indent // → first line falls back to marL(≥stop here), still clearing the icon. const tabStopBehindMargin = leadingSawTab && leadingFirstStop !== undefined && leadingFoldPx === 0; if (tabStopBehindMargin && finalMarginLeftPx !== undefined) { const firstLineIndentPx = leadingFirstStop! - finalMarginLeftPx; paraCssParts.push(`text-indent: ${firstLineIndentPx.toFixed(2)}px`); } else if (merged.textIndent !== undefined) { paraCssParts.push(`text-indent: ${merged.textIndent}px`); } // OOXML: when is absent at every level of the cascade, the // implicit default is "single spacing" = 1.0. We fall back to that so // the browser doesn't take over with `line-height: normal` (~1.2 for // most fonts, sometimes much larger for CJK with tall typo metrics), // which causes multi-paragraph body text to overflow its container // and visibly stack/overlap. const effectiveLineHeight = merged.lineHeight ?? '1'; paraCssParts.push(`line-height: ${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.defRPrs) { for (const drp of merged.defRPrs) { const sz = drp.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; } // Empty paragraph (no visible runs → renders as a bare
): PowerPoint // sizes the blank line from its end-of-paragraph mark (endParaRPr). Without // a font-size the
collapses to the container default and the blank // line reserves too little height — e.g. slide 27 用两行空段为下方"浮"在 // 文本框上的独立公式预留高度,空段过矮会让后续正文上移、与公式重叠。 // Whitespace-only paragraphs (just tabs/spaces) are pure spacers: their // glyphs get stripped by the leading-tab fold, so they must be sized like // empty paragraphs (font-size at the

, from the run/endParaRPr) or the // line-height collapses to the container default. slide 4 的绿块用一段 // sz=3600 + 仅含 \t 的空段把编号顶到图标下方;按 .length>0 判它"非空"会丢掉 // 36pt 行高,编号塌回去压在图标上。用 trim() 把纯空白段也当空段处理。 const paraHasVisibleRuns = paragraph.runs.some( (r) => r.text != null && r.text.trim().length > 0, ); if (!paraHasVisibleRuns && paragraph.endParaRPr) { const epSz = paragraph.endParaRPr.numAttr('sz'); if (epSz !== undefined) effectiveFontSize = epSz / 100; } if (!paraHasVisibleRuns) { paraCssParts.push(`font-size: ${effectiveFontSize}pt`); } // CSS line-height places the extra leading (lineHeight − 1) × fontSize // half above and half below the glyph, while PowerPoint pushes ALL of it // above the first line — most visible on cover titles that use a large // lnSpc value like 220% to create a deliberate gap between chapter number // and the chapter title. Without the compensation, that "deliberate gap" // ends up half its intended size in the browser. We only top up the // missing half on unitless line-heights > 1; absolute spcPts already // encode the exact line height and shouldn't be double-counted. // Only top up the leading for paragraphs that actually have glyphs. An EMPTY // (blank) paragraph is a pure spacer — PowerPoint reserves exactly lnSpc × // fontSize for it, with no extra half-leading above. Adding padding-top to // blank lines over-reserves height; on decks that stack several blank // paragraphs to push a bottom block down (slide 10 绿色提示条) the drift // accumulates and the bottom text slips below its box. if ( paraHasVisibleRuns && !merged.lineHeightAbsolute && /^[\d.]+$/.test(effectiveLineHeight) ) { const lh = parseFloat(effectiveLineHeight); if (lh > 1) { const extraHalf = ((lh - 1) / 2) * effectiveFontSize; if (extraHalf > 0.01) { paraCssParts.push(`padding-top: ${extraHalf.toFixed(2)}pt`); } } } // 首段 spcBef / 末段 spcAft 仅在 bodyPr@spcFirstLastPara=1 时计入 const applySpaceBefore = !isFirstPara || spcFirstLastPara; const applySpaceAfter = !isLastPara || spcFirstLastPara; if (applySpaceBefore && merged.spaceBefore !== undefined && merged.spaceBefore !== 0) { paraCssParts.push(`margin-top: ${merged.spaceBefore}pt`); } else if ( applySpaceBefore && merged.spaceBeforePct !== undefined && merged.spaceBeforePct !== 0 ) { paraCssParts.push(`margin-top: ${merged.spaceBeforePct * effectiveFontSize}pt`); } if (applySpaceAfter && merged.spaceAfter !== undefined && merged.spaceAfter !== 0) { paraCssParts.push(`margin-bottom: ${merged.spaceAfter}pt`); } else if ( applySpaceAfter && merged.spaceAfterPct !== undefined && merged.spaceAfterPct !== 0 ) { paraCssParts.push(`margin-bottom: ${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++; } } // When line spacing is absolute (spcPts), 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) and bold faces often have // ascent+descent larger than the spcPts value, which causes the browser // to grow the line-box past the CSS line-height and silently overflow the // text frame. Using a fixed-height div with overflow: visible keeps each // line's layout footprint at exactly spcPts, matching PowerPoint behavior // even for single-line paragraphs. // tab-size 仅服务于折叠之后仍剩余的 tab(非行首 tab,少见);行首 tab 已折进 margin-left。 const totalTabs = paragraph.runs.reduce( (n, r) => n + (r.text ? (r.text.match(/\t/g)?.length ?? 0) : 0), 0, ); if (totalTabs - leadingFoldedTabs > 0) { paraCssParts.push(`tab-size: ${resolveTabPx().toFixed(2)}px`); } if (noWrap) { paraCssParts.push('white-space: nowrap'); } const useLineWrappers = !!(merged.lineHeightAbsolute && merged.lineHeight); const paraCss = paraCssParts.join(';'); const openTag = useLineWrappers ? `` : ``; const closeTag = useLineWrappers ? '' : '

'; html += openTag; // Build bullet HTML up front. We emit it AFTER opening the line wrapper // (when wrappers are on) so the bullet sits inline with the first line of // content; emitting an inline before a block-level wrapper
// would push the content onto the next line and shift the whole paragraph. let bulletHtml = ''; if (bulletPrefix) { // Compute the first-run effective style once so the bullet can inherit // color, font-size, AND font-family from it. Without this, auto-number // bullets like (1)/(2)/(3) on a dark slide rendered as tiny black text // because the had only color set and the inherited font-size // from the BaseTextElement wrapper fell back to the browser default. const firstRunStyle: MergedRunStyle = {}; if (merged.defRPrs) { for (const drp of merged.defRPrs) mergeRunProps(firstRunStyle, drp, ctx); } if (paragraph.runs.length > 0 && paragraph.runs[0].properties) { mergeRunProps(firstRunStyle, paragraph.runs[0].properties, ctx); } // Bullet color: 1) explicit buClr from list style, 2) first run effective color, 3) listStyle defRPr, 4) options fallback let bulletColor: string | undefined; if (merged.bulletColorNode && merged.bulletColorNode.exists()) { bulletColor = resolveColorToCss(merged.bulletColorNode, ctx); } if (bulletColor === undefined) { bulletColor = firstRunStyle.color; } 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; } } } } const bColor = bulletColor ?? options?.fontRefColor ?? options?.cellTextColor ?? '#000000'; let displayChar = bulletPrefix; let bFontCss = ''; const isSymbolBullet = !!(merged.bulletFont && isSymbolFont(merged.bulletFont)); if (isSymbolBullet) { displayChar = symbolFontCharToUnicode(bulletPrefix, merged.bulletFont!); } else if (merged.bulletFont) { bFontCss = `font-family: ${merged.bulletFont};`; } else if (firstRunStyle.fontFamily) { // Match the first run's resolved font so the bullet doesn't fall back // to the browser's default serif on top of a slide that uses a sans // body font (or vice versa). bFontCss = `font-family: ${firstRunStyle.fontFamily};`; } // Bullet font-size: always emit so the bullet sizes with the paragraph // rather than the wrapper's inherited size. buSzPct (when present) // scales relative to the run font-size — same semantics as PowerPoint. const baseSize = firstRunStyle.fontSize ?? effectiveFontSize; const bulletPt = merged.bulletSizePct !== undefined && merged.bulletSizePct !== 1 ? baseSize * merged.bulletSizePct : baseSize; const bSizeCss = `font-size: ${bulletPt.toFixed(1)}pt;`; // hanging-indent 段的 bullet 用 inline-block 占满「悬挂缩进区域」的槽位: // bullet 落在 marL+indent(首行起点),槽位把首行正文推到 element_x + marL, // 与 body 续行的左缩进对齐——这就是 PowerPoint 通过 bullet+tab 实现的视觉效果。 // 不用 inline-block 时正文紧贴 bullet 字形右侧(例如 slide 3 row 05 // "■" 紧挨 "传承"),与 body 续行形成台阶状错位。 const useBulletSlot = effectiveMarginLeft !== undefined && effectiveMarginLeft > 0 && merged.textIndent !== undefined && merged.textIndent < 0; if (useBulletSlot) { // 槽宽 = 悬挂缩进量 |indent|(不是 marL)。

上 margin-left:marL + text-indent:indent // 已把首行起点定在 marL+indent;槽位只需补满到 marL,所以宽度是 |indent|。 // 若错用 marL:当真实 marL ≫ |indent|(如 slide 2「在集体中成长」右框 marL=78/indent=-30) // 时,过宽的槽把正文推到 marL+|indent| 之外、bullet 离正文很远。slide 3 那种「无显式 marL、 // 由 -indent 合成 marL」的情形 marL==|indent|,取值不变、不受影响。 const slotWidthPx = -(merged.textIndent ?? 0); // symbol bullet 的字形对齐: // - 合成 marL(无真实 marL、bullet 紧贴 element 左沿、可能压住相邻形状,如 slide 3 编号圆): // 在槽内补 padding-left:16px 把 ■ 往右推、避开相邻形状光晕;body 位置不变。 // - 真实 marL:bullet 落在 marL+indent 的悬挂位(左对齐,无 padding),与 PPT 一致 // (slide 2 右框 ■ 实测落在 marL+indent,加 padding 反而把 ■ 顶到正文上)。 const symbolSlotPad = marginLeftSynthesized ? 'padding-left:16px;box-sizing:border-box;' : 'box-sizing:border-box;'; // 普通 list bullet(•/◦ 等非 symbol-font 字形)在槽位内靠右、贴近正文, // 留一个 0.4em 的小间距,续行左缩进不变、仍与首行正文对齐。 const slotPad = isSymbolBullet ? symbolSlotPad : 'text-align:right;padding-right:0.4em;box-sizing:border-box;'; // text-indent:0 — hanging-indent 段在

上带 `text-indent:-N`,而 CSS text-indent // 会被 display:inline-block 的槽位 span 当作块级容器继承,把槽内 bullet 字形再左移 N, // 落到 element 左外(slide 3 的 ■ 飞到左侧编号圆上)。在槽位上显式归零切断继承,bullet // 字形稳定落在 padding 处,body 与续行仍由

的 margin-left/text-indent 控制不受影响。 bulletHtml = `${escapeHtml(displayChar)}`; } else { bulletHtml = `${escapeHtml(displayChar)} `; } } let currentLineDivOpen = false; const openLineWrapper = () => { if (!useLineWrappers || !merged.lineHeight) return; // Fixed `height` (not min-height) pins each line to exactly spcPts — the // PowerPoint line pitch — regardless of the font's content area. CJK faces // (self-hosted SourceHanSerif/Sans) report a content area noticeably taller // than spcPts (e.g. 15pt glyphs ≈ 30px vs a 19.15pt≈25.5px pitch); with // `min-height` Chrome grows the line box to that content area, so every // absolute-spaced line is a few px too tall. Stacked over a column the drift // pushes bottom blocks down — e.g. 后勤 slide 10/11 绿色提示框文字下溢/未居中。 // Trade-off: a single logical paragraph whose text SOFT-wraps to >1 visual // line will overflow this fixed-height box (overlapping the next paragraph), // since one wrapper == one logical line. Explicit line breaks are safe (each // `\n` opens its own wrapper). Acceptable here: absolute-spaced paragraphs in // these decks are single-line; matching the line pitch matters more. // min-height(而非 height):固定 height 能精确还原 PPT 行距,但被就地编辑(增删行)后 // 无法 reflow——多出的行 overflow 到盒外,且"实时编辑态"与"保存→重解析态"对不齐。 // a2m 是编辑器,编辑一致性优先:改用 min-height,编辑增删行时盒子能撑高、两态一致。 // 代价:CJK 字体内容区略高于 spcPts 时单行可能高几 px(行距漂移),由回归把关。 html += `

`; currentLineDivOpen = true; }; const closeLineWrapper = () => { if (currentLineDivOpen) { html += '
'; currentLineDivOpen = false; } }; if (useLineWrappers) { // wrapper mode: open wrapper then emit bullet inside, so the inline // bullet shares the first line baseline with the run content. Empty // paragraphs need no
— the wrapper
already has a fixed line // height, and an extra
would double the empty paragraph's footprint. openLineWrapper(); html += bulletHtml; } else { // legacy

path: bullet first, then a blank-line filler for an otherwise // empty paragraph. Use   (NOT
): a trailing
renders as 1 line // in raw-HTML renderers, but a2m 的 ProseMirror 会在末尾 hard_break 后再补一个 // trailingBreak(

)→ 空段变成 2 行,累积把下方正文/图标整体压下去 // (图标与行错位、绿框文字被挤出)。  在两端都恰好是 1 行 // (高度 = font-size × line-height),不会被 ProseMirror 加倍。 html += bulletHtml; // Blank-line filler: an empty

reserves NO height (height 0) regardless // of font-size/line-height — it needs an inline box. Emit   whenever // the paragraph has no VISIBLE runs, which now also covers whitespace-only // spacer paragraphs whose tab/space runs get stripped by the leading fold // (slide 4 绿块的 sz=3600 空段:少了   就塌成 0 高,编号顶回图标上)。 if (!paraHasVisibleRuns) { html += ' '; } } // Merge consecutive runs with identical style strings into a single , // matching pptxtojson's compact HTML output for better pptist compatibility. let prevStyleStr: string | null = null; let prevIsLink = false; let accumulatedText = ''; const flushAccumulatedRun = () => { if (!accumulatedText || prevStyleStr === null) return; html += `${accumulatedText}`; accumulatedText = ''; }; for (const run of paragraph.runs) { // Inline formula run (公式与正文混排): render as inline KaTeX so the // surrounding 中文 isn't lost and the formula isn't pulled out into its // own stacked block. Falls back to the formula's plain text on failure. if (run.ommlXml) { flushAccumulatedRun(); prevStyleStr = null; const mStyle: MergedRunStyle = {}; if (merged.defRPrs) { for (const drp of merged.defRPrs) mergeRunProps(mStyle, drp, ctx); } if (run.properties) mergeRunProps(mStyle, run.properties, ctx); const mSizePt = mStyle.fontSize ?? effectiveFontSize; // OMML run color (a:rPr>solidFill) is dropped by the latex conversion, so // when the inline formula is one color, resolve it from the captured node. const mathColorNode = (run as { mathColorNode?: SafeXmlNode }).mathColorNode; const mColor = mathColorNode && mathColorNode.exists() ? resolveColorToCss(mathColorNode, ctx) : (mStyle.color ?? options?.fontRefColor ?? '#000000'); const latex = ommlToLatex(run.ommlXml); let mathHtml = ''; if (latex) { try { // throwOnError:true so invalid LaTeX throws → we fall back to the // formula's plain text below, instead of KaTeX printing the raw // source in red (the "红色 LaTeX 乱码" reviewers flagged). mathHtml = katex.renderToString(latex, { displayMode: false, throwOnError: true, }); } catch { mathHtml = ''; } } const inlineBody = mathHtml || formatRunTextForHtml(run.text ?? ''); if (inlineBody) { html += `${inlineBody}`; } prevIsLink = false; continue; } if (run.text === '\n') { flushAccumulatedRun(); prevStyleStr = null; if (useLineWrappers) { closeLineWrapper(); openLineWrapper(); } else { html += '
'; } continue; } // Build merged run style const runStyle: MergedRunStyle = {}; // Apply default run properties from all inherited defRPr nodes if (merged.defRPrs) { for (const drp of merged.defRPrs) mergeRunProps(runStyle, drp, 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. 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; } } } } let runText = run.text ?? ''; // 剥掉已折叠进段落 text-indent 的行首空白(见上「行首缩进折叠」); // 完全由空白构成的行首 run 折叠后为空,直接跳过不再输出。 if (leadingStripChars > 0 && runText) { let k = 0; while ( k < runText.length && leadingStripChars > 0 && (runText[k] === '\t' || runText[k] === ' ') ) { k++; leadingStripChars--; } if (k > 0) runText = runText.slice(k); if (runText === '') continue; } // 动态字段——OOXML 里 fld 节点常没有 子节点 // (PPT 在显示时算出来塞进去),导致 parser 拿到空字符串。常见的几种: // - slidenum: 当前页码(1-based) // - datetime / datetime1..n: 当前日期,先用占位 mm/dd/yyyy 跑通; // 真要精确格式化交给上层 // 其它没识别的 fld 类型保留 parser 的原文本(多半也是空的)。 if ((run as { fldType?: string }).fldType) { const type = (run as { fldType?: string }).fldType; if (type === 'slidenum') { if (!runText) runText = String(ctx.slide.index + 1); } } if (run.properties) { const symNode = run.properties.child('sym'); if (symNode.exists()) { const symTypeface = symNode.attr('typeface'); if (isSymbolFont(symTypeface)) { runText = Array.from(runText) .map((ch) => symbolFontCharToUnicode(ch, symTypeface!)) .join(''); } } } const inner = formatRunTextForHtml(runText); const tabStyleSuffix = runText.includes('\t') ? ';white-space: pre' : ''; const styleStr = runStylesToCssString(runStyle, run, options, ctx) + tabStyleSuffix; const isLink = !!runStyle.hlinkClick; if (isLink) { flushAccumulatedRun(); prevStyleStr = null; const href = escapeHtmlAttr(runStyle.hlinkClick!); html += `${inner}`; } else if (prevStyleStr === styleStr && !prevIsLink) { accumulatedText += inner; } else { flushAccumulatedRun(); prevStyleStr = styleStr; accumulatedText = inner; } prevIsLink = isLink; } flushAccumulatedRun(); if (useLineWrappers) { closeLineWrapper(); } // 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) { html += `​`; } } } html += closeTag; } } // Apply bodyPr text insets (lIns/rIns/tIns/bIns) as a wrapping div with padding. // OOXML defaults: lIns=91440, tIns=45720, rIns=91440, bIns=45720 (EMU). // Always emit the padding wrapper so the consumer doesn't need to know defaults. // For table cells, `cellMargins` (from tcPr marL/marR/marT/marB) takes // precedence — PowerPoint uses cell margins, not bodyPr defaults, for the // padding inside table cells. Explicit bodyPr insets on the cell's txBody // still win (rare but spec-allowed override). const bp = textBody.bodyProperties; if (bp?.exists()) { const cm = options?.cellMargins; const DEFAULT_H_INSET = 91440; const DEFAULT_V_INSET = 45720; const lIns = bp.numAttr('lIns') ?? cm?.lIns ?? DEFAULT_H_INSET; const rIns = bp.numAttr('rIns') ?? cm?.rIns ?? DEFAULT_H_INSET; const tIns = bp.numAttr('tIns') ?? cm?.tIns ?? DEFAULT_V_INSET; const bIns = bp.numAttr('bIns') ?? cm?.bIns ?? DEFAULT_V_INSET; const pt = (emu: number) => parseFloat((emu / 12700).toFixed(2)); html = `

${html}
`; } return html; } /** Maps TextRenderer run loop (element.style) to a single `style=""` string. */ function runStylesToCssString( runStyle: MergedRunStyle, run: TextRun, options: RenderTextBodyOptions | undefined, ctx: RenderContext, ): string { const fontSize = runStyle.fontSize || 12; const parts: string[] = []; parts.push(`font-size: ${fontSize}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)) { parts.push('font-weight: 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)) { parts.push('font-style: italic'); } const decorations: string[] = []; if (runStyle.underline) decorations.push('underline'); if (runStyle.strikethrough) decorations.push('line-through'); if (decorations.length > 0) { parts.push(`text-decoration: ${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) { parts.push(`color: ${effectiveColor}`); } else { // No explicit color from run/paragraph/style: use black so text does not inherit page CSS (e.g. body { color: #e0e0e0 }) parts.push('color: #000000'); } // Gradient text fill: use background-clip to paint text with gradient if (runStyle.textGradientCss) { parts.push(`background: ${runStyle.textGradientCss}`); parts.push('-webkit-background-clip: text'); parts.push('background-clip: text'); parts.push('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) parts.push('color: transparent'); parts.push(`-webkit-text-stroke-width: ${strokeW}px`); parts.push(`-webkit-text-stroke-color: ${outlineColor}`); parts.push('paint-order: stroke fill'); const maskGrad = runStyle.textOutlineGradientCss; parts.push(`mask-image: ${maskGrad}`); parts.push(`-webkit-mask-image: ${maskGrad}`); } else if (runStyle.textNoFill && runStyle.textOutlineColor) { // Ghost text with solid outline parts.push('color: transparent'); parts.push(`-webkit-text-stroke-width: ${strokeW}px`); parts.push(`-webkit-text-stroke-color: ${runStyle.textOutlineColor}`); parts.push('paint-order: stroke fill'); } else if (runStyle.textNoFill) { // noFill with no outline — invisible text (but keep space) parts.push('color: transparent'); } else if (runStyle.textOutlineColor) { // Outline with normal fill parts.push(`-webkit-text-stroke-width: ${strokeW}px`); parts.push(`-webkit-text-stroke-color: ${runStyle.textOutlineColor}`); parts.push('paint-order: 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) { parts.push(`font-family: ${effectiveFont}`); } else { // Fallback to theme minor font const fallback = ctx.theme.minorFont.latin || ctx.theme.minorFont.ea; if (fallback) { parts.push(`font-family: ${fallback}`); } } // Character spacing (a:spc) — compact/tracking in points if (runStyle.letterSpacingPt !== undefined) { parts.push(`letter-spacing: ${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; parts.push(`font-kerning: ${effectivePt >= runStyle.kern ? 'normal' : 'none'}`); } // Text capitalization (a:rPr@cap) if (runStyle.cap === 'all') { parts.push('text-transform: uppercase'); } else if (runStyle.cap === 'small') { parts.push('font-variant: 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; parts.push(`vertical-align: ${shiftPct}%`); // PowerPoint renders ANY baseline-shifted (super/subscript) run at a reduced // size, regardless of how small the shift is. Some decks abuse a tiny shift // (e.g. baseline=5000 → 5%) together with an inflated sz to fake normal text // (slide 14 的标题首段 sz=2000+baseline=5000,PPT 实际按 ~0.65 缩到 13pt = // 正文字号)。之前只在 |shift|≥20% 时缩,导致这类首段被放大成 20pt。 parts.push(`font-size: ${fontSize * 0.65}pt`); } if (runStyle.textShadowCss) { parts.push(`text-shadow: ${runStyle.textShadowCss}`); } return parts.join(';') + (parts.length ? ';' : ''); }