From 8dd23f74c9a59fdfbdaefb4a403ef79bdd3dfed9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 10 Aug 2026 00:52:14 -0600 Subject: [PATCH] refac --- src/lib/utils/pptxToHtml.ts | 424 ++++++++++++++++++++++++++++++------ 1 file changed, 353 insertions(+), 71 deletions(-) diff --git a/src/lib/utils/pptxToHtml.ts b/src/lib/utils/pptxToHtml.ts index 045ac70001..d3eec432fe 100644 --- a/src/lib/utils/pptxToHtml.ts +++ b/src/lib/utils/pptxToHtml.ts @@ -13,6 +13,298 @@ const emuToPx = (emu: number) => Math.round(emu / EMU_PER_PX); const parseEmu = (val: string | null | undefined): number => (val ? parseInt(val, 10) || 0 : 0); +type ZipLike = { + file: (path: string) => { async: (type: 'text' | 'base64') => Promise } | null; + files: Record; +}; + +type Relationship = { + type: string; + target: string; +}; + +type Rect = { + x: number; + y: number; + w: number; + h: number; +}; + +type Placeholder = { + type: string; + idx: string; + rect: Rect; +}; + +type TextToken = { + text: string; + fontPt: number; + bold: boolean; + italic: boolean; + color: string; + width: number; +}; + +const getTag = (el: Element) => el.tagName.split(':').pop(); + +const normalizePath = (path: string) => { + const parts: string[] = []; + for (const part of path.split('/')) { + if (!part || part === '.') continue; + if (part === '..') parts.pop(); + else parts.push(part); + } + return parts.join('/'); +}; + +const dirname = (path: string) => path.slice(0, path.lastIndexOf('/')); +const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1); + +const resolveTarget = (sourcePath: string, target: string) => { + const cleanTarget = target.split('#')[0]; + if (cleanTarget.startsWith('/')) return normalizePath(cleanTarget.slice(1)); + return normalizePath(`${dirname(sourcePath)}/${cleanTarget}`); +}; + +const readXml = async (zip: ZipLike, path: string): Promise => { + const file = zip.file(path); + if (!file) return null; + const text = await file.async('text'); + return new DOMParser().parseFromString(text, 'application/xml'); +}; + +const readRels = async ( + zip: ZipLike, + sourcePath: string +): Promise> => { + const relsPath = `${dirname(sourcePath)}/_rels/${basename(sourcePath)}.rels`; + const relsDoc = await readXml(zip, relsPath); + const rels: Record = {}; + if (!relsDoc) return rels; + + for (const rel of Array.from(relsDoc.getElementsByTagName('Relationship'))) { + const id = rel.getAttribute('Id') ?? ''; + const target = rel.getAttribute('Target') ?? ''; + if (!id || !target || rel.getAttribute('TargetMode') === 'External') continue; + + rels[id] = { + type: rel.getAttribute('Type') ?? '', + target: resolveTarget(sourcePath, target) + }; + } + + return rels; +}; + +const getRelationshipTarget = ( + rels: Record, + typeSuffix: string +): string | null => { + const rel = Object.values(rels).find((entry) => entry.type.endsWith(typeSuffix)); + return rel?.target ?? null; +}; + +const parseXfrm = (shape: Element): Rect | null => { + const xfrm = shape.getElementsByTagName('a:xfrm')[0] ?? shape.getElementsByTagName('p:xfrm')[0]; + if (!xfrm) return null; + + const off = xfrm.getElementsByTagName('a:off')[0]; + const ext = xfrm.getElementsByTagName('a:ext')[0]; + if (!off || !ext) return null; + + const rect = { + x: emuToPx(parseEmu(off.getAttribute('x'))), + y: emuToPx(parseEmu(off.getAttribute('y'))), + w: emuToPx(parseEmu(ext.getAttribute('cx'))), + h: emuToPx(parseEmu(ext.getAttribute('cy'))) + }; + + return rect.w === 0 && rect.h === 0 ? null : rect; +}; + +const getPlaceholder = (shape: Element): { type: string; idx: string } | null => { + const ph = shape.getElementsByTagName('p:ph')[0]; + if (!ph) return null; + return { + type: ph.getAttribute('type') ?? 'body', + idx: ph.getAttribute('idx') ?? '' + }; +}; + +const normalizePlaceholderType = (type: string) => (type === 'ctrTitle' ? 'title' : type); + +const placeholderKeys = ({ type, idx }: { type: string; idx: string }) => { + const normalizedType = normalizePlaceholderType(type); + return [ + idx ? `${type}:${idx}` : '', + idx ? `${normalizedType}:${idx}` : '', + type, + normalizedType, + idx ? `idx:${idx}` : '' + ].filter(Boolean); +}; + +const collectPlaceholders = (doc: Document | null): Record => { + const placeholders: Record = {}; + if (!doc) return placeholders; + + for (const shape of Array.from(doc.getElementsByTagName('p:sp'))) { + const ph = getPlaceholder(shape); + const rect = parseXfrm(shape); + if (!ph || !rect) continue; + + for (const key of placeholderKeys(ph)) { + placeholders[key] ??= { ...ph, rect }; + } + } + + return placeholders; +}; + +const findPlaceholder = ( + ph: { type: string; idx: string } | null, + placeholderSources: Record[] +): Placeholder | null => { + if (!ph) return null; + for (const key of placeholderKeys(ph)) { + for (const placeholders of placeholderSources) { + if (placeholders[key]) return placeholders[key]; + } + } + return null; +}; + +const fallbackPlaceholderRect = ( + ph: { type: string; idx: string } | null, + slideW: number, + slideH: number +): Rect | null => { + if (!ph) return null; + const type = normalizePlaceholderType(ph.type); + if (type === 'title') { + return { x: slideW * 0.06, y: slideH * 0.08, w: slideW * 0.88, h: slideH * 0.18 }; + } + if (type === 'subTitle') { + return { x: slideW * 0.15, y: slideH * 0.58, w: slideW * 0.7, h: slideH * 0.2 }; + } + if (['body', 'obj', 'content'].includes(type)) { + return { x: slideW * 0.07, y: slideH * 0.26, w: slideW * 0.86, h: slideH * 0.6 }; + } + return null; +}; + +const placeholderFontSize = (ph: { type: string; idx: string } | null) => { + const type = normalizePlaceholderType(ph?.type ?? ''); + if (type === 'title') return 34; + if (type === 'subTitle') return 22; + return 16; +}; + +const paragraphAlign = ( + para: Element, + ph: { type: string; idx: string } | null +): CanvasTextAlign => { + const align = para.getElementsByTagName('a:pPr')[0]?.getAttribute('algn'); + if (align === 'ctr') return 'center'; + if (align === 'r') return 'right'; + + const type = normalizePlaceholderType(ph?.type ?? ''); + if (type === 'title' || type === 'subTitle') return 'center'; + return 'left'; +}; + +const schemeColor = (val: string | null) => { + switch (val) { + case 'bg1': + case 'lt1': + return '#ffffff'; + case 'tx1': + case 'dk1': + return '#000000'; + case 'accent1': + return '#4472c4'; + case 'accent2': + return '#ed7d31'; + case 'accent3': + return '#a5a5a5'; + case 'accent4': + return '#ffc000'; + case 'accent5': + return '#5b9bd5'; + case 'accent6': + return '#70ad47'; + default: + return null; + } +}; + +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}`; + + const scheme = fill.getElementsByTagName('a:schemeClr')[0]; + return schemeColor(scheme?.getAttribute('val') ?? null); +}; + +const renderBackground = ( + ctx: CanvasRenderingContext2D, + doc: Document, + slideW: number, + slideH: number +) => { + const bgColor = solidFillColor(doc.getElementsByTagName('p:bg')[0]); + if (!bgColor) return; + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, slideW, slideH); +}; + +const fontString = ({ italic, bold, fontPt }: Pick) => + `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${fontPt}pt Calibri, Arial, sans-serif`; + +const readRunStyle = (run: Element, defaultFontSize: number) => { + const rPr = run.getElementsByTagName('a:rPr')[0]; + let fontPt = defaultFontSize; + let bold = false; + let italic = false; + let color = '#000000'; + + if (rPr) { + if (rPr.getAttribute('b') === '1') bold = true; + if (rPr.getAttribute('i') === '1') italic = true; + const sz = rPr.getAttribute('sz'); + if (sz) fontPt = parseInt(sz, 10) / 100; + color = solidFillColor(rPr) ?? color; + } + + return { fontPt, bold, italic, color }; +}; + +const drawTextLine = ( + ctx: CanvasRenderingContext2D, + line: TextToken[], + align: CanvasTextAlign, + x: number, + y: number, + w: number +) => { + const lineWidth = line.reduce((sum, token) => sum + token.width, 0); + let cursorX = x + 4; + if (align === 'center') cursorX = x + Math.max(0, (w - lineWidth) / 2); + if (align === 'right') cursorX = x + w - lineWidth - 4; + + for (const token of line) { + ctx.font = fontString(token); + ctx.fillStyle = token.color; + ctx.fillText(token.text, cursorX, y); + cursorX += token.width; + } +}; + /** Load a data URI into an Image element and wait for it. */ const loadImage = (src: string): Promise => new Promise((resolve, reject) => { @@ -29,7 +321,7 @@ export async function pptxToImages( buffer: ArrayBuffer ): Promise<{ images: string[]; width: number; height: number }> { const JSZip = (await import('jszip')).default; - const zip = await JSZip.loadAsync(buffer); + const zip = (await JSZip.loadAsync(buffer)) as ZipLike; // ── Read slide dimensions from presentation.xml ────────────────── let slideW = 960; @@ -83,26 +375,13 @@ export async function pptxToImages( const slideText = await zip.file(slidePath)!.async('text'); const slideDoc = new DOMParser().parseFromString(slideText, 'application/xml'); - // Load relationship file for this slide to resolve image references - const slideNum = slidePath.match(/slide(\d+)/)?.[1]; - const relsPath = `ppt/slides/_rels/slide${slideNum}.xml.rels`; - const rels: Record = {}; - const relsFile = zip.file(relsPath); - if (relsFile) { - const relsText = await relsFile.async('text'); - const relsDoc = new DOMParser().parseFromString(relsText, 'application/xml'); - const relEls = relsDoc.getElementsByTagName('Relationship'); - for (let i = 0; i < relEls.length; i++) { - const rel = relEls[i]; - const id = rel.getAttribute('Id') ?? ''; - const target = rel.getAttribute('Target') ?? ''; - if (target.startsWith('../')) { - rels[id] = 'ppt/' + target.replace('../', ''); - } else { - rels[id] = target; - } - } - } + const rels = await readRels(zip, slidePath); + const layoutPath = getRelationshipTarget(rels, '/slideLayout'); + const layoutDoc = layoutPath ? await readXml(zip, layoutPath) : null; + const layoutRels = layoutPath ? await readRels(zip, layoutPath) : {}; + const masterPath = getRelationshipTarget(layoutRels, '/slideMaster'); + const masterDoc = masterPath ? await readXml(zip, masterPath) : null; + const placeholderSources = [collectPlaceholders(layoutDoc), collectPlaceholders(masterDoc)]; // ── Create canvas and render slide ─────────────────────────── const canvas = document.createElement('canvas'); @@ -110,9 +389,11 @@ export async function pptxToImages( canvas.height = slideH; const ctx = canvas.getContext('2d')!; - // White background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, slideW, slideH); + if (masterDoc) renderBackground(ctx, masterDoc, slideW, slideH); + if (layoutDoc) renderBackground(ctx, layoutDoc, slideW, slideH); + renderBackground(ctx, slideDoc, slideW, slideH); const spTree = slideDoc.getElementsByTagName('p:spTree')[0]; if (!spTree) { @@ -120,34 +401,35 @@ export async function pptxToImages( continue; } - const shapes = [ - ...Array.from(spTree.getElementsByTagName('p:sp')), - ...Array.from(spTree.getElementsByTagName('p:pic')) - ]; + const shapes = Array.from(spTree.children).filter((el) => + ['sp', 'pic'].includes(getTag(el) ?? '') + ); for (const shape of shapes) { - const xfrm = - shape.getElementsByTagName('a:xfrm')[0] ?? shape.getElementsByTagName('p:xfrm')[0]; - if (!xfrm) continue; - - const off = xfrm.getElementsByTagName('a:off')[0]; - const ext = xfrm.getElementsByTagName('a:ext')[0]; - if (!off || !ext) continue; - - const x = emuToPx(parseEmu(off.getAttribute('x'))); - const y = emuToPx(parseEmu(off.getAttribute('y'))); - const w = emuToPx(parseEmu(ext.getAttribute('cx'))); - const h = emuToPx(parseEmu(ext.getAttribute('cy'))); + const ph = getPlaceholder(shape); + const placeholder = findPlaceholder(ph, placeholderSources); + const rect = + parseXfrm(shape) ?? placeholder?.rect ?? fallbackPlaceholderRect(ph, slideW, slideH); + if (!rect) continue; + const { x, y, w, h } = rect; if (w === 0 && h === 0) continue; + if (getTag(shape) === 'sp') { + const shapeFill = solidFillColor(shape.getElementsByTagName('p:spPr')[0]); + if (shapeFill) { + ctx.fillStyle = shapeFill; + ctx.fillRect(x, y, w, h); + } + } + // ── Picture ────────────────────────────────────────────── const blipFill = shape.getElementsByTagName('p:blipFill')[0]; if (blipFill) { const blip = blipFill.getElementsByTagName('a:blip')[0]; if (blip) { const rEmbed = blip.getAttribute('r:embed') ?? ''; - const mediaPath = rels[rEmbed]; + const mediaPath = rels[rEmbed]?.target; const dataUri = mediaPath ? media[mediaPath] : ''; if (dataUri && !dataUri.includes('image/x-emf')) { try { @@ -171,11 +453,12 @@ export async function pptxToImages( const paragraphs = txBody.getElementsByTagName('a:p'); let cursorY = y; - const defaultFontSize = 12; + const defaultFontSize = placeholderFontSize(ph ?? placeholder ?? null); for (let pi = 0; pi < paragraphs.length; pi++) { const para = paragraphs[pi]; const runs = para.getElementsByTagName('a:r'); + const align = paragraphAlign(para, ph ?? placeholder ?? null); if (runs.length === 0) { cursorY += defaultFontSize * 1.5; @@ -198,53 +481,52 @@ export async function pptxToImages( const lineHeight = maxFontPt * 1.4; cursorY += maxFontPt; // baseline offset - let cursorX = x + 4; // small left padding + let line: TextToken[] = []; + let lineWidth = 0; + const flushLine = () => { + if (line.length === 0) return; + if (cursorY <= y + h) { + drawTextLine(ctx, line, align, x, cursorY, w); + } + line = []; + lineWidth = 0; + cursorY += lineHeight; + }; for (let ri = 0; ri < runs.length; ri++) { const run = runs[ri]; - const rPr = run.getElementsByTagName('a:rPr')[0]; const text = run.getElementsByTagName('a:t')[0]?.textContent ?? ''; if (!text) continue; - let fontPt = defaultFontSize; - let bold = false; - let italic = false; - let color = '#000000'; + const style = readRunStyle(run, defaultFontSize); + ctx.font = fontString(style); - if (rPr) { - if (rPr.getAttribute('b') === '1') bold = true; - if (rPr.getAttribute('i') === '1') italic = true; - const sz = rPr.getAttribute('sz'); - if (sz) fontPt = parseInt(sz, 10) / 100; - const solidFill = rPr.getElementsByTagName('a:solidFill')[0]; - if (solidFill) { - const srgb = solidFill.getElementsByTagName('a:srgbClr')[0]; - if (srgb) { - const val = srgb.getAttribute('val'); - if (val) color = `#${val}`; - } - } - } - - ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${fontPt}pt Calibri, Arial, sans-serif`; - ctx.fillStyle = color; ctx.textBaseline = 'alphabetic'; // Simple word-wrap within the shape bounds - const words = text.split(/(\s+)/); + const words = text.split(/(\n|[^\S\n]+)/); for (const word of words) { - const metrics = ctx.measureText(word); - if (cursorX + metrics.width > x + w && cursorX > x + 4) { - cursorX = x + 4; - cursorY += lineHeight; + if (word === '') continue; + if (word === '\n') { + flushLine(); + continue; } if (cursorY > y + h) break; - ctx.fillText(word, cursorX, cursorY); - cursorX += metrics.width; + + const width = ctx.measureText(word).width; + if (lineWidth + width > w - 8 && line.length > 0) { + flushLine(); + if (word.trim() === '') continue; + } + if (line.length === 0 && word.trim() === '') continue; + + line.push({ ...style, text: word, width }); + lineWidth += width; } } - cursorY += lineHeight * 0.4; // paragraph spacing + flushLine(); + cursorY -= lineHeight * 0.6; // paragraph spacing } ctx.restore();