From b7292890ccea794750651abbcde01cda62551e1b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 16:13:47 -0600 Subject: [PATCH] refac --- src/lib/utils/pptxToHtml.ts | 711 ++++++++++++++++++++++++++++++------ 1 file changed, 592 insertions(+), 119 deletions(-) diff --git a/src/lib/utils/pptxToHtml.ts b/src/lib/utils/pptxToHtml.ts index cd490cbf24..cac78b5f95 100644 --- a/src/lib/utils/pptxToHtml.ts +++ b/src/lib/utils/pptxToHtml.ts @@ -5,7 +5,7 @@ * directly to canvas, returning PNG data URLs. * * Uses jszip (dynamically imported) and the browser Canvas 2D API. - * No theme resolution, charts, SmartArt, or animations — preview only. + * No full theme resolution, SmartArt, or animations — preview only. */ const EMU_PER_PX = 9525; @@ -48,6 +48,24 @@ type TextToken = { type TextStyle = Omit; +type TextInsets = { + left: number; + right: number; + top: number; + bottom: number; +}; + +type ChartSeries = { + name: string; + categories: string[]; + values: number[]; + color: string; +}; + +type ThemeColors = Record; + +let activeThemeColors: ThemeColors = {}; + const getTag = (el: Element) => el.tagName.split(':').pop(); const normalizePath = (path: string) => { @@ -99,6 +117,34 @@ const readRels = async ( return rels; }; +const readThemeColors = async (zip: ZipLike): Promise => { + const themePath = + Object.keys(zip.files) + .filter((path) => /^ppt\/theme\/theme\d+\.xml$/.test(path)) + .sort()[0] ?? ''; + const themeDoc = themePath ? await readXml(zip, themePath) : null; + const clrScheme = themeDoc?.getElementsByTagName('a:clrScheme')[0]; + const colors: ThemeColors = {}; + if (!clrScheme) return colors; + + for (const child of Array.from(clrScheme.children)) { + const key = getTag(child); + if (!key) continue; + + const srgb = child.getElementsByTagName('a:srgbClr')[0]?.getAttribute('val'); + if (srgb) { + colors[key] = `#${srgb}`; + continue; + } + + const sys = child.getElementsByTagName('a:sysClr')[0]; + const lastClr = sys?.getAttribute('lastClr'); + if (lastClr) colors[key] = `#${lastClr}`; + } + + return colors; +}; + const getRelationshipTarget = ( rels: Record, typeSuffix: string @@ -217,6 +263,18 @@ const paragraphAlign = ( }; const schemeColor = (val: string | null) => { + const themeKey = + val === 'bg1' + ? 'lt1' + : val === 'tx1' + ? 'dk1' + : val === 'bg2' + ? 'lt2' + : val === 'tx2' + ? 'dk2' + : val; + if (themeKey && activeThemeColors[themeKey]) return activeThemeColors[themeKey]; + switch (val) { case 'bg1': case 'lt1': @@ -241,17 +299,119 @@ const schemeColor = (val: string | null) => { } }; +const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value))); + +const hexToRgb = (color: string) => { + const hex = color.replace('#', ''); + if (!/^[\da-f]{6}$/i.test(hex)) return null; + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16) + }; +}; + +const rgbToHex = ({ r, g, b }: { r: number; g: number; b: number }) => + `#${[r, g, b].map((value) => clampByte(value).toString(16).padStart(2, '0')).join('')}`; + +const colorWithTransforms = (color: string | null, colorEl: Element | undefined) => { + if (!color || !colorEl) return color; + const rgb = hexToRgb(color); + if (!rgb) return color; + + const lumMod = colorEl.getElementsByTagName('a:lumMod')[0]?.getAttribute('val'); + const lumOff = colorEl.getElementsByTagName('a:lumOff')[0]?.getAttribute('val'); + const alpha = colorEl.getElementsByTagName('a:alpha')[0]?.getAttribute('val'); + const mod = lumMod ? parseInt(lumMod, 10) / 100000 : 1; + const off = lumOff ? (parseInt(lumOff, 10) / 100000) * 255 : 0; + const transformed = { + r: rgb.r * mod + off, + g: rgb.g * mod + off, + b: rgb.b * mod + off + }; + + if (alpha) { + const opacity = Math.max(0, Math.min(1, parseInt(alpha, 10) / 100000)); + return `rgba(${clampByte(transformed.r)}, ${clampByte(transformed.g)}, ${clampByte( + transformed.b + )}, ${opacity})`; + } + + return rgbToHex(transformed); +}; + +const prstColor = (val: string | null) => { + switch (val) { + case 'black': + return '#000000'; + case 'white': + return '#ffffff'; + case 'gray': + case 'grey': + return '#808080'; + default: + return null; + } +}; + +const colorFromElement = (el: Element | undefined): string | null => { + const srgb = el?.getElementsByTagName('a:srgbClr')[0]; + const srgbVal = srgb?.getAttribute('val'); + if (srgbVal) return colorWithTransforms(`#${srgbVal}`, srgb); + + const prst = el?.getElementsByTagName('a:prstClr')[0]; + const prstVal = prst?.getAttribute('val'); + if (prstVal) return colorWithTransforms(prstColor(prstVal), prst); + + const scheme = el?.getElementsByTagName('a:schemeClr')[0]; + return colorWithTransforms(schemeColor(scheme?.getAttribute('val') ?? null), scheme); +}; + const solidFillColor = (el: Element | Document | null): string | null => { if (!el) return null; const fill = el.getElementsByTagName('a:solidFill')[0]; if (!fill) return null; - const srgb = fill.getElementsByTagName('a:srgbClr')[0]; - const srgbVal = srgb?.getAttribute('val'); - if (srgbVal) return `#${srgbVal}`; + return colorFromElement(fill); +}; - const scheme = fill.getElementsByTagName('a:schemeClr')[0]; - return schemeColor(scheme?.getAttribute('val') ?? null); +const renderFill = (ctx: CanvasRenderingContext2D, el: Element | Document | null, rect: Rect) => { + if (!el) return false; + + const gradFill = el.getElementsByTagName('a:gradFill')[0]; + if (gradFill) { + const stops = Array.from(gradFill.getElementsByTagName('a:gs')) + .map((stop) => ({ + pos: parseInt(stop.getAttribute('pos') ?? '0', 10) / 100000, + color: colorFromElement(stop) + })) + .filter((stop): stop is { pos: number; color: string } => Boolean(stop.color)); + + if (stops.length > 0) { + const lin = gradFill.getElementsByTagName('a:lin')[0]; + const angle = + (((parseInt(lin?.getAttribute('ang') ?? '0', 10) / 60000) % 360) * Math.PI) / 180; + const dx = Math.cos(angle) * rect.w; + const dy = Math.sin(angle) * rect.h; + const gradient = ctx.createLinearGradient( + rect.x + rect.w / 2 - dx / 2, + rect.y + rect.h / 2 - dy / 2, + rect.x + rect.w / 2 + dx / 2, + rect.y + rect.h / 2 + dy / 2 + ); + for (const stop of stops) + gradient.addColorStop(Math.max(0, Math.min(1, stop.pos)), stop.color); + ctx.fillStyle = gradient; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + return true; + } + } + + const fill = solidFillColor(el); + if (!fill) return false; + ctx.fillStyle = fill; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + return true; }; const renderBackground = ( @@ -260,10 +420,7 @@ const renderBackground = ( slideW: number, slideH: number ) => { - const bgColor = solidFillColor(doc.getElementsByTagName('p:bg')[0]); - if (!bgColor) return; - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, slideW, slideH); + renderFill(ctx, doc.getElementsByTagName('p:bg')[0], { x: 0, y: 0, w: slideW, h: slideH }); }; const directChild = (el: Element, tag: string) => @@ -311,13 +468,24 @@ const readRunStyle = (run: Element, base: TextStyle) => { return readTextStyle(rPr, base); }; -const textBodyInsets = (txBody: Element) => { +const textBodyInsets = ( + txBody: Element, + fallback: TextInsets = { left: 10, right: 10, top: 5, bottom: 5 } +) => { const bodyPr = txBody.getElementsByTagName('a:bodyPr')[0]; return { - left: emuToPx(parseEmu(bodyPr?.getAttribute('lIns') ?? '91440')), - right: emuToPx(parseEmu(bodyPr?.getAttribute('rIns') ?? '91440')), - top: emuToPx(parseEmu(bodyPr?.getAttribute('tIns') ?? '45720')), - bottom: emuToPx(parseEmu(bodyPr?.getAttribute('bIns') ?? '45720')) + left: bodyPr?.hasAttribute('lIns') + ? emuToPx(parseEmu(bodyPr.getAttribute('lIns'))) + : fallback.left, + right: bodyPr?.hasAttribute('rIns') + ? emuToPx(parseEmu(bodyPr.getAttribute('rIns'))) + : fallback.right, + top: bodyPr?.hasAttribute('tIns') + ? emuToPx(parseEmu(bodyPr.getAttribute('tIns'))) + : fallback.top, + bottom: bodyPr?.hasAttribute('bIns') + ? emuToPx(parseEmu(bodyPr.getAttribute('bIns'))) + : fallback.bottom }; }; @@ -353,6 +521,395 @@ const drawTextLine = ( } }; +const paragraphTextParts = (para: Element, defaultFontSize: number) => { + const paragraphStyle = readParagraphStyle(para, defaultFontSize); + const textParts: Array<{ text: string; style: TextStyle } | { newline: true }> = []; + const bullet = paragraphBullet(para); + + for (const child of Array.from(para.children)) { + const tag = getTag(child); + if (tag === 'br') { + textParts.push({ newline: true }); + continue; + } + if (tag !== 'r' && tag !== 'fld') continue; + + const style = tag === 'r' ? readRunStyle(child, paragraphStyle) : paragraphStyle; + const text = child.getElementsByTagName('a:t')[0]?.textContent ?? ''; + if (text) textParts.push({ text, style }); + } + + if (bullet && textParts.some((part) => 'text' in part && part.text.trim())) { + textParts.unshift({ text: bullet, style: paragraphStyle }); + } + + return { paragraphStyle, textParts }; +}; + +const estimateTextHeight = ( + ctx: CanvasRenderingContext2D, + paragraphs: HTMLCollectionOf, + defaultFontSize: number, + textW: number +) => { + let height = 0; + for (let pi = 0; pi < paragraphs.length; pi++) { + const { paragraphStyle, textParts } = paragraphTextParts(paragraphs[pi], defaultFontSize); + const maxFontPt = textParts.reduce( + (max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max), + paragraphStyle.fontPt + ); + const lineHeight = maxFontPt * 1.4; + let lines = 1; + let lineWidth = 0; + + for (const part of textParts) { + if ('newline' in part) { + lines++; + lineWidth = 0; + continue; + } + ctx.font = fontString(part.style); + for (const word of part.text.split(/(\n|[^\S\n]+)/)) { + if (word === '') continue; + if (word === '\n') { + lines++; + lineWidth = 0; + continue; + } + const width = ctx.measureText(word).width; + if (lineWidth + width > textW && lineWidth > 0) { + lines++; + lineWidth = word.trim() ? width : 0; + } else if (lineWidth > 0 || word.trim()) { + lineWidth += width; + } + } + } + + height += lines * lineHeight; + if (pi < paragraphs.length - 1) height += lineHeight * 0.4; + } + return height; +}; + +const renderTextBody = ( + ctx: CanvasRenderingContext2D, + txBody: Element, + rect: Rect, + defaultFontSize: number, + ph: { type: string; idx: string } | Placeholder | null = null, + fallbackInsets?: TextInsets +) => { + const { x, y, w, h } = rect; + ctx.save(); + ctx.rect(x, y, w, h); + ctx.clip(); + + const paragraphs = txBody.getElementsByTagName('a:p'); + const bodyPr = txBody.getElementsByTagName('a:bodyPr')[0]; + const insets = textBodyInsets(txBody, fallbackInsets); + const textX = x + insets.left; + const textW = Math.max(1, w - insets.left - insets.right); + const textBottom = y + h - insets.bottom; + const textH = Math.max(1, textBottom - (y + insets.top)); + const estimatedHeight = estimateTextHeight(ctx, paragraphs, defaultFontSize, textW); + const anchor = bodyPr?.getAttribute('anchor'); + const anchorOffset = + anchor === 'b' + ? Math.max(0, textH - estimatedHeight) + : anchor === 'ctr' + ? Math.max(0, (textH - estimatedHeight) / 2) + : 0; + const textY = y + insets.top + anchorOffset; + let cursorY = textY; + + for (let pi = 0; pi < paragraphs.length; pi++) { + const para = paragraphs[pi]; + const align = paragraphAlign(para, ph); + const { paragraphStyle, textParts } = paragraphTextParts(para, defaultFontSize); + + const maxFontPt = textParts.reduce( + (max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max), + paragraphStyle.fontPt + ); + const lineHeight = maxFontPt * 1.4; + cursorY += maxFontPt; + + let line: TextToken[] = []; + let lineWidth = 0; + const flushLine = () => { + if (line.length === 0) return; + if (cursorY <= textBottom) { + drawTextLine(ctx, line, align, textX, cursorY, textW); + } + line = []; + lineWidth = 0; + cursorY += lineHeight; + }; + + if (textParts.length === 0) { + cursorY += lineHeight; + continue; + } + + for (const part of textParts) { + if ('newline' in part) { + if (line.length > 0) flushLine(); + else cursorY += lineHeight; + continue; + } + const { text, style } = part; + ctx.font = fontString(style); + ctx.textBaseline = 'alphabetic'; + + const words = text.split(/(\n|[^\S\n]+)/); + for (const word of words) { + if (word === '') continue; + if (word === '\n') { + flushLine(); + continue; + } + if (cursorY > textBottom) break; + + const width = ctx.measureText(word).width; + if (lineWidth + width > textW && line.length > 0) { + flushLine(); + if (word.trim() === '') continue; + } + if (line.length === 0 && word.trim() === '') continue; + + line.push({ ...style, text: word, width }); + lineWidth += width; + } + } + + flushLine(); + cursorY -= lineHeight * 0.6; + } + + ctx.restore(); +}; + +const renderTable = (ctx: CanvasRenderingContext2D, frame: Element, rect: Rect) => { + const table = frame.getElementsByTagName('a:tbl')[0]; + if (!table) return false; + + const grid = table.getElementsByTagName('a:tblGrid')[0]; + const colEmus = Array.from(grid?.getElementsByTagName('a:gridCol') ?? []).map((col) => + Math.max(1, parseEmu(col.getAttribute('w'))) + ); + const rows = Array.from(table.getElementsByTagName('a:tr')); + if (colEmus.length === 0 || rows.length === 0) return true; + + const rowEmus = rows.map((row) => Math.max(1, parseEmu(row.getAttribute('h')))); + const colTotal = colEmus.reduce((sum, val) => sum + val, 0); + const rowTotal = rowEmus.reduce((sum, val) => sum + val, 0); + const colWidths = colEmus.map((val) => (val / colTotal) * rect.w); + const rowHeights = rowEmus.map((val) => (val / rowTotal) * rect.h); + + let cy = rect.y; + for (let ri = 0; ri < rows.length; ri++) { + const row = rows[ri]; + const cells = Array.from(row.children).filter((child) => getTag(child) === 'tc'); + let cx = rect.x; + for (let ci = 0; ci < colWidths.length; ci++) { + const cw = colWidths[ci]; + const ch = rowHeights[ri]; + const cell = cells[ci]; + if (cell) { + const tcPr = cell.getElementsByTagName('a:tcPr')[0]; + const fill = solidFillColor(tcPr); + if (fill) { + ctx.fillStyle = fill; + ctx.fillRect(cx, cy, cw, ch); + } + + ctx.strokeStyle = 'rgba(17, 24, 39, 0.12)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx, cy + ch); + ctx.lineTo(cx + cw, cy + ch); + ctx.stroke(); + + const txBody = cell.getElementsByTagName('a:txBody')[0]; + if (txBody) { + const marL = tcPr?.hasAttribute('marL') + ? emuToPx(parseEmu(tcPr.getAttribute('marL'))) + : 4; + const marR = tcPr?.hasAttribute('marR') + ? emuToPx(parseEmu(tcPr.getAttribute('marR'))) + : 4; + const marT = tcPr?.hasAttribute('marT') + ? emuToPx(parseEmu(tcPr.getAttribute('marT'))) + : 2; + const marB = tcPr?.hasAttribute('marB') + ? emuToPx(parseEmu(tcPr.getAttribute('marB'))) + : 2; + renderTextBody(ctx, txBody, { x: cx, y: cy, w: cw, h: ch }, 10, null, { + left: marL, + right: marR, + top: marT, + bottom: marB + }); + } + } + cx += cw; + } + cy += rowHeights[ri]; + } + + return true; +}; + +const chartPointValues = (parent: Element, tag: string) => { + const container = parent.getElementsByTagName(tag)[0]; + const pts = Array.from(container?.getElementsByTagName('c:pt') ?? []); + return pts + .sort((a, b) => parseInt(a.getAttribute('idx') ?? '0') - parseInt(b.getAttribute('idx') ?? '0')) + .map((pt) => pt.getElementsByTagName('c:v')[0]?.textContent ?? ''); +}; + +const chartSeries = (chartDoc: Document, chartType: 'bar' | 'line'): ChartSeries[] => { + const chartEl = chartDoc.getElementsByTagName( + chartType === 'bar' ? 'c:barChart' : 'c:lineChart' + )[0]; + if (!chartEl) return []; + const colors = ['#4472c4', '#70ad47', '#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000']; + + return Array.from(chartEl.getElementsByTagName('c:ser')).map((ser, index) => { + const name = chartPointValues(ser, 'c:tx')[0] || `Series ${index + 1}`; + const categories = chartPointValues(ser, 'c:cat'); + const values = chartPointValues(ser, 'c:val').map((value) => Number(value) || 0); + return { + name, + categories, + values, + color: solidFillColor(ser.getElementsByTagName('c:spPr')[0]) ?? colors[index % colors.length] + }; + }); +}; + +const renderChart = (ctx: CanvasRenderingContext2D, chartDoc: Document, rect: Rect) => { + const type = chartDoc.getElementsByTagName('c:barChart')[0] + ? 'bar' + : chartDoc.getElementsByTagName('c:lineChart')[0] + ? 'line' + : null; + if (!type) return false; + + const series = chartSeries(chartDoc, type); + const categories = series[0]?.categories ?? []; + if (series.length === 0 || categories.length === 0) return true; + + const values = series.flatMap((item) => item.values); + const maxValue = Math.max(1, ...values) * 1.15; + const left = rect.x + 42; + const top = rect.y + 28; + const right = rect.x + rect.w - 14; + const bottom = rect.y + rect.h - 34; + const plotW = Math.max(1, right - left); + const plotH = Math.max(1, bottom - top); + + ctx.save(); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + ctx.strokeStyle = 'rgba(17, 24, 39, 0.16)'; + ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); + + ctx.font = '9px Arial, sans-serif'; + ctx.fillStyle = '#6b7280'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let i = 0; i <= 4; i++) { + const value = (maxValue / 4) * i; + const y = bottom - (value / maxValue) * plotH; + ctx.strokeStyle = i === 0 ? '#9ca3af' : 'rgba(17, 24, 39, 0.08)'; + ctx.beginPath(); + ctx.moveTo(left, y); + ctx.lineTo(right, y); + ctx.stroke(); + ctx.fillText(Math.round(value).toString(), left - 6, y); + } + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + categories.forEach((category, index) => { + const x = left + ((index + 0.5) / categories.length) * plotW; + ctx.fillStyle = '#6b7280'; + ctx.fillText(category, x, bottom + 7); + }); + + if (type === 'bar') { + const groupW = plotW / categories.length; + const barW = (groupW * 0.7) / Math.max(1, series.length); + series.forEach((item, si) => { + ctx.fillStyle = item.color; + item.values.forEach((value, vi) => { + const x = left + vi * groupW + groupW * 0.15 + si * barW; + const h = (value / maxValue) * plotH; + const y = bottom - h; + ctx.fillRect(x, y, Math.max(1, barW - 2), h); + ctx.fillStyle = '#111827'; + ctx.fillText(String(value), x + barW / 2, y - 12); + ctx.fillStyle = item.color; + }); + }); + } else { + series.forEach((item) => { + ctx.strokeStyle = item.color; + ctx.fillStyle = item.color; + ctx.lineWidth = 2; + ctx.beginPath(); + item.values.forEach((value, vi) => { + const x = left + ((vi + 0.5) / categories.length) * plotW; + const y = bottom - (value / maxValue) * plotH; + if (vi === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + item.values.forEach((value, vi) => { + const x = left + ((vi + 0.5) / categories.length) * plotW; + const y = bottom - (value / maxValue) * plotH; + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#111827'; + ctx.fillText(String(value), x, y - 14); + ctx.fillStyle = item.color; + }); + }); + } + + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + let legendX = rect.x + rect.w * 0.48; + const legendY = rect.y + 13; + series.forEach((item) => { + ctx.fillStyle = item.color; + ctx.fillRect(legendX, legendY - 3, 6, 6); + ctx.fillStyle = '#6b7280'; + ctx.fillText(item.name, legendX + 9, legendY); + legendX += ctx.measureText(item.name).width + 24; + }); + ctx.restore(); + + return true; +}; + +const renderConnector = (ctx: CanvasRenderingContext2D, shape: Element, rect: Rect) => { + const spPr = shape.getElementsByTagName('p:spPr')[0]; + const line = spPr?.getElementsByTagName('a:ln')[0]; + ctx.save(); + ctx.strokeStyle = solidFillColor(line) ?? '#9ca3af'; + ctx.lineWidth = Math.max(1, emuToPx(parseEmu(line?.getAttribute('w') ?? '9525'))); + ctx.beginPath(); + ctx.moveTo(rect.x, rect.y); + ctx.lineTo(rect.x + rect.w, rect.y + rect.h); + ctx.stroke(); + ctx.restore(); +}; + /** Load a data URI into an Image element and wait for it. */ const loadImage = (src: string): Promise => new Promise((resolve, reject) => { @@ -370,6 +927,7 @@ export async function pptxToImages( ): Promise<{ images: string[]; width: number; height: number }> { const JSZip = (await import('jszip')).default; const zip = (await JSZip.loadAsync(buffer)) as ZipLike; + activeThemeColors = await readThemeColors(zip); // ── Read slide dimensions from presentation.xml ────────────────── let slideW = 960; @@ -450,7 +1008,7 @@ export async function pptxToImages( } const shapes = Array.from(spTree.children).filter((el) => - ['sp', 'pic'].includes(getTag(el) ?? '') + ['sp', 'pic', 'graphicFrame', 'cxnSp'].includes(getTag(el) ?? '') ); for (const shape of shapes) { @@ -462,13 +1020,24 @@ export async function pptxToImages( const { x, y, w, h } = rect; if (w === 0 && h === 0) continue; + const tag = getTag(shape); - if (getTag(shape) === 'sp') { - const shapeFill = solidFillColor(shape.getElementsByTagName('p:spPr')[0]); - if (shapeFill) { - ctx.fillStyle = shapeFill; - ctx.fillRect(x, y, w, h); - } + if (tag === 'cxnSp') { + renderConnector(ctx, shape, rect); + continue; + } + + if (tag === 'graphicFrame') { + if (renderTable(ctx, shape, rect)) continue; + + const chartRelId = shape.getElementsByTagName('c:chart')[0]?.getAttribute('r:id') ?? ''; + const chartPath = rels[chartRelId]?.target; + const chartDoc = chartPath ? await readXml(zip, chartPath) : null; + if (chartDoc && renderChart(ctx, chartDoc, rect)) continue; + } + + if (tag === 'sp') { + renderFill(ctx, shape.getElementsByTagName('p:spPr')[0], rect); } // ── Picture ────────────────────────────────────────────── @@ -495,104 +1064,8 @@ export async function pptxToImages( const txBody = shape.getElementsByTagName('p:txBody')[0]; if (!txBody) continue; - ctx.save(); - ctx.rect(x, y, w, h); - ctx.clip(); - - const paragraphs = txBody.getElementsByTagName('a:p'); - const insets = textBodyInsets(txBody); - const textX = x + insets.left; - const textY = y + insets.top; - const textW = Math.max(1, w - insets.left - insets.right); - const textBottom = y + h - insets.bottom; - let cursorY = textY; const defaultFontSize = placeholderFontSize(ph ?? placeholder ?? null); - - for (let pi = 0; pi < paragraphs.length; pi++) { - const para = paragraphs[pi]; - const align = paragraphAlign(para, ph ?? placeholder ?? null); - const paragraphStyle = readParagraphStyle(para, defaultFontSize); - const textParts: Array<{ text: string; style: TextStyle } | { newline: true }> = []; - const bullet = paragraphBullet(para); - - for (const child of Array.from(para.children)) { - const tag = getTag(child); - if (tag === 'br') { - textParts.push({ newline: true }); - continue; - } - if (tag !== 'r' && tag !== 'fld') continue; - - const style = tag === 'r' ? readRunStyle(child, paragraphStyle) : paragraphStyle; - const text = child.getElementsByTagName('a:t')[0]?.textContent ?? ''; - if (text) textParts.push({ text, style }); - } - - if (bullet && textParts.some((part) => 'text' in part && part.text.trim())) { - textParts.unshift({ text: bullet, style: paragraphStyle }); - } - - const maxFontPt = textParts.reduce( - (max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max), - paragraphStyle.fontPt - ); - const lineHeight = maxFontPt * 1.4; - cursorY += maxFontPt; // baseline offset - - let line: TextToken[] = []; - let lineWidth = 0; - const flushLine = () => { - if (line.length === 0) return; - if (cursorY <= textBottom) { - drawTextLine(ctx, line, align, textX, cursorY, textW); - } - line = []; - lineWidth = 0; - cursorY += lineHeight; - }; - - if (textParts.length === 0) { - cursorY += lineHeight; - continue; - } - - for (const part of textParts) { - if ('newline' in part) { - if (line.length > 0) flushLine(); - else cursorY += lineHeight; - continue; - } - const { text, style } = part; - ctx.font = fontString(style); - ctx.textBaseline = 'alphabetic'; - - // Simple word-wrap within the shape bounds - const words = text.split(/(\n|[^\S\n]+)/); - for (const word of words) { - if (word === '') continue; - if (word === '\n') { - flushLine(); - continue; - } - if (cursorY > textBottom) break; - - const width = ctx.measureText(word).width; - if (lineWidth + width > textW && line.length > 0) { - flushLine(); - if (word.trim() === '') continue; - } - if (line.length === 0 && word.trim() === '') continue; - - line.push({ ...style, text: word, width }); - lineWidth += width; - } - } - - flushLine(); - cursorY -= lineHeight * 0.6; // paragraph spacing - } - - ctx.restore(); + renderTextBody(ctx, txBody, rect, defaultFontSize, ph ?? placeholder ?? null); } images.push(canvas.toDataURL('image/png'));