feat: add DOCX/XLSX/PPTX file preview

- DOCX: mammoth converts to semantic HTML (prose preview)
- XLSX: xlsx library extended to FileNav with sheet tabs at bottom
- PPTX: custom canvas renderer produces PNG images per slide
  with panzoom zoom/pan and slide navigation

Changes:
- New: src/lib/utils/pptxToHtml.ts (canvas-based PPTX renderer)
- FileNav.svelte: office format detection, blob download, conversion
- FilePreview.svelte: office rendering branches, sheet tabs, slide viewer
- FileItemModal.svelte: DOCX/PPTX preview tabs
- package.json: added mammoth dependency
This commit is contained in:
Timothy Jaeryang Baek
2026-03-04 15:50:37 -06:00
parent 6e43861c0c
commit 890949abe6
6 changed files with 683 additions and 105 deletions
+79 -2
View File
@@ -37,6 +37,7 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import PortList from './FileNav/PortList.svelte';
import XTerminal from './XTerminal.svelte';
const i18n = getContext('i18n');
@@ -93,6 +94,14 @@
let fileLoading = false;
let filePreviewRef: FilePreview;
// ── Office preview state ────────────────────────────────────────────
let fileOfficeHtml: string | null = null;
let fileOfficeSlides: string[] | null = null;
let currentSlide = 0;
let excelSheetNames: string[] = [];
let selectedExcelSheet = '';
let excelWorkbook: import('xlsx').WorkBook | null = null;
// ── File preview toolbar state (bound from FilePreview) ─────────────
let editing = false;
let showRaw = false;
@@ -101,12 +110,15 @@
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
const CSV_EXTS = new Set(['csv', 'tsv']);
const HTML_EXTS = new Set(['html', 'htm']);
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'pptx']);
const getFileExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
$: isCsv = CSV_EXTS.has(getFileExt(selectedFile));
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
$: isTextFile =
fileContent !== null && fileImageUrl === null && filePdfData === null && !isOfficeFile;
// ── Upload / folder creation ─────────────────────────────────────────
let isDragOver = false;
@@ -169,6 +181,7 @@
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const buildBreadcrumbs = (path: string) =>
path
@@ -192,6 +205,12 @@
fileImageUrl = null;
}
filePdfData = null;
fileOfficeHtml = null;
fileOfficeSlides = null;
currentSlide = 0;
excelSheetNames = [];
selectedExcelSheet = '';
excelWorkbook = null;
};
// ── Directory operations ─────────────────────────────────────────────
@@ -244,6 +263,41 @@
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
try {
if (ext === 'docx') {
const mammoth = await import('mammoth');
const res = await mammoth.convertToHtml({ arrayBuffer });
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(res.value);
} else if (ext === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
excelSheetNames = wb.SheetNames;
if (excelSheetNames.length > 0) {
selectedExcelSheet = excelSheetNames[0];
const ws = wb.Sheets[selectedExcelSheet];
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(
XLSX.utils.sheet_to_html(ws, { id: 'excel-table', editable: false, header: '' })
);
}
} else if (ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
currentSlide = 0;
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath);
}
@@ -536,7 +590,7 @@
onUploadFiles={handleUploadFiles}
onMove={handleMove}
>
{#if fileImageUrl !== null}
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -664,6 +718,7 @@
</Tooltip>
{/if}
{/if}
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -696,11 +751,26 @@
bind:editing
bind:showRaw
bind:saving
bind:currentSlide
{selectedFile}
{fileLoading}
{fileImageUrl}
{filePdfData}
{fileContent}
{fileOfficeHtml}
{fileOfficeSlides}
{excelSheetNames}
{selectedExcelSheet}
onSheetChange={async (sheet) => {
if (!excelWorkbook) return;
selectedExcelSheet = sheet;
const XLSX = await import('xlsx');
const ws = excelWorkbook.Sheets[sheet];
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(
XLSX.utils.sheet_to_html(ws, { id: 'excel-table', editable: false, header: '' })
);
}}
{overlay}
onSave={async (content) => {
const terminal = selectedTerminal;
@@ -797,6 +867,13 @@
{/if}
</div>
<!-- Port detection -->
{#if selectedTerminal && !selectedFile}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
<PortList baseUrl={selectedTerminal.url} apiKey={selectedTerminal.key} />
</div>
{/if}
<!-- Terminal bottom panel -->
{#if terminalEnabled}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
@@ -17,6 +17,14 @@
export let filePdfData: ArrayBuffer | null = null;
export let fileContent: string | null = null;
// Office preview props
export let fileOfficeHtml: string | null = null;
export let fileOfficeSlides: string[] | null = null;
export let currentSlide = 0;
export let excelSheetNames: string[] = [];
export let selectedExcelSheet = '';
export let onSheetChange: ((sheet: string) => void) | null = null;
export let overlay = false;
export let onSave: ((content: string) => Promise<void>) | null = null;
@@ -124,7 +132,8 @@
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
zoomSpeed: 0.065,
zoomDoubleClickSpeed: 1
});
};
@@ -152,7 +161,7 @@
</script>
<div
class="flex-1 {fileImageUrl !== null
class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 relative h-full"
>
@@ -170,6 +179,61 @@
</div>
{:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
{:else if fileOfficeHtml !== null}
<div class="flex flex-col h-full">
<div class="office-preview overflow-auto flex-1 min-h-0 p-3">
{@html fileOfficeHtml}
</div>
{#if excelSheetNames.length > 1}
<div class="flex items-center gap-1 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 overflow-x-auto">
{#each excelSheetNames as sheet}
<button
class="shrink-0 px-3 py-1 text-xs rounded-md transition-colors
{selectedExcelSheet === sheet
? 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => onSheetChange?.(sheet)}
>
{sheet}
</button>
{/each}
</div>
{/if}
</div>
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
<div class="flex flex-col h-full">
<div class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden" use:initImagePanzoom>
<img
src={fileOfficeSlides[currentSlide]}
alt="Slide {currentSlide + 1}"
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
{#if fileOfficeSlides.length > 1}
<div class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500">
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={currentSlide === 0}
on:click={() => { resetImageView(); currentSlide = Math.max(0, currentSlide - 1); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
</button>
<span>{currentSlide + 1} / {fileOfficeSlides.length}</span>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={currentSlide === fileOfficeSlides.length - 1}
on:click={() => { resetImageView(); currentSlide = Math.min(fileOfficeSlides.length - 1, currentSlide + 1); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
{/if}
</div>
{:else if fileContent !== null}
{#if isHtml && !showRaw}
{#if overlay}
@@ -226,7 +290,7 @@
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed p-3">{fileContent}</pre>
{/if}
{:else}
<div class="text-sm text-gray-400 text-center pt-8">
<div class="text-xs text-gray-400 text-center pt-8">
{$i18n.t('Could not read file.')}
</div>
{/if}
@@ -284,4 +348,62 @@
:global(.dark) .csv-row-num {
color: #6b7280;
}
/* ── Office preview styles ──────────────────────────────────────── */
:global(.office-preview) {
font-size: 0.875rem;
line-height: 1.6;
color: #1f2937;
background: #fff;
border-radius: 4px;
}
:global(.dark .office-preview) {
color: #e5e7eb;
background: #1a1a2e;
}
:global(.office-preview table) {
border-collapse: collapse;
font-size: 0.8rem;
line-height: 1.25rem;
}
:global(.office-preview table td),
:global(.office-preview table th) {
border: 1px solid rgba(128, 128, 128, 0.25);
padding: 0.5rem 0.75rem;
text-align: left;
white-space: nowrap;
}
:global(.dark .office-preview table td),
:global(.dark .office-preview table th) {
border-color: rgba(128, 128, 128, 0.35);
}
:global(.office-preview table th) {
background: rgba(243, 244, 246, 0.95);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
:global(.dark .office-preview table th) {
background: rgba(31, 41, 55, 0.95);
}
:global(.office-preview table tr:nth-child(even)) {
background: rgba(128, 128, 128, 0.04);
}
:global(.office-preview table tr:hover) {
background: rgba(59, 130, 246, 0.06);
}
:global(.dark .office-preview table tr:hover) {
background: rgba(59, 130, 246, 0.1);
}
:global(.office-preview img) {
max-width: 100%;
height: auto;
}
:global(.office-preview h1) { font-size: 1.5rem; font-weight: 700; margin: 0.75em 0 0.5em; }
:global(.office-preview h2) { font-size: 1.25rem; font-weight: 600; margin: 0.75em 0 0.5em; }
:global(.office-preview h3) { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
:global(.office-preview p) { margin: 0.25em 0; }
:global(.office-preview ul),
:global(.office-preview ol) { padding-left: 1.5em; margin: 0.5em 0; }
</style>
+109 -1
View File
@@ -40,6 +40,8 @@
let isAudio = false;
let isImage = false;
let isExcel = false;
let isDocx = false;
let isPptx = false;
let selectedTab = '';
let excelWorkbook: WorkBook | null = null;
@@ -49,6 +51,15 @@
let excelError = '';
let rowCount = 0;
// DOCX state
let docxHtml = '';
let docxError = '';
// PPTX state
let pptxSlides: string[] = [];
let pptxCurrentSlide = 0;
let pptxError = '';
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
@@ -128,6 +139,16 @@
item.name.toLowerCase().endsWith('.xlsx') ||
item.name.toLowerCase().endsWith('.csv')));
$: isDocx =
item?.meta?.content_type ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
(item?.name && item.name.toLowerCase().endsWith('.docx'));
$: isPptx =
item?.meta?.content_type ===
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
(item?.name && item.name.toLowerCase().endsWith('.pptx'));
const loadExcelContent = async () => {
try {
excelError = '';
@@ -170,6 +191,37 @@
renderExcelSheet();
}
const loadDocxContent = async () => {
try {
docxError = '';
const [arrayBuffer, mammoth] = await Promise.all([
getFileContentById(item.id),
import('mammoth')
]);
const result = await mammoth.convertToHtml({ arrayBuffer });
docxHtml = DOMPurify.sanitize(result.value);
} catch (error) {
console.error('Error loading DOCX file:', error);
docxError = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
}
};
const loadPptxContent = async () => {
try {
pptxError = '';
const [arrayBuffer, { pptxToImages }] = await Promise.all([
getFileContentById(item.id),
import('$lib/utils/pptxToHtml')
]);
const result = await pptxToImages(arrayBuffer);
pptxSlides = result.images;
pptxCurrentSlide = 0;
} catch (error) {
console.error('Error loading PPTX file:', error);
pptxError = $i18n.t('Failed to load PPTX file. Please try downloading it instead.');
}
};
const loadContent = async () => {
selectedTab = '';
expandedContent = false;
@@ -201,6 +253,12 @@
if (isExcel) {
await loadExcelContent();
}
if (isDocx) {
await loadDocxContent();
}
if (isPptx) {
await loadPptxContent();
}
loading = false;
}
@@ -358,7 +416,7 @@
</div>
{/if}
{#if isAudio || isPDF || isExcel || isCode || isMarkdown}
{#if isAudio || isPDF || isExcel || isCode || isMarkdown || isDocx || isPptx}
<div
class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200"
>
@@ -534,6 +592,56 @@
>
<Markdown content={item.file.data.content} id="markdown-viewer" />
</div>
{:else if isDocx}
{#if docxError}
<div class="text-red-500 text-sm p-4">{docxError}</div>
{:else if docxHtml}
<div class="office-preview max-h-[60vh] overflow-auto p-4 prose dark:prose-invert max-w-full text-sm">
{@html docxHtml}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">No content available</div>
{/if}
{:else if isPptx}
{#if pptxError}
<div class="text-red-500 text-sm p-4">{pptxError}</div>
{:else if pptxSlides.length > 0}
<div class="max-h-[60vh] overflow-auto">
<div class="flex justify-center p-4">
<img
src={pptxSlides[pptxCurrentSlide]}
alt="Slide {pptxCurrentSlide + 1}"
class="max-w-full max-h-[50vh] object-contain rounded-md shadow-lg"
draggable="false"
/>
</div>
{#if pptxSlides.length > 1}
<div class="flex items-center justify-center gap-3 pb-3 text-sm text-gray-500">
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide === 0}
on:click={() => pptxCurrentSlide = Math.max(0, pptxCurrentSlide - 1)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
</button>
<span>{pptxCurrentSlide + 1} / {pptxSlides.length}</span>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide === pptxSlides.length - 1}
on:click={() => pptxCurrentSlide = Math.min(pptxSlides.length - 1, pptxCurrentSlide + 1)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
{/if}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">No content available</div>
{/if}
{:else}
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
{(item?.file?.data?.content ?? '').trim() || 'No content'}
+259
View File
@@ -0,0 +1,259 @@
/**
* Lightweight PPTX → Image renderer.
*
* Extracts text and images from each slide and renders them
* 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.
*/
const EMU_PER_PX = 9525;
const emuToPx = (emu: number) => Math.round(emu / EMU_PER_PX);
const parseEmu = (val: string | null | undefined): number =>
val ? parseInt(val, 10) || 0 : 0;
/** Load a data URI into an Image element and wait for it. */
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
img.src = src;
});
/**
* Convert PPTX ArrayBuffer → array of PNG data URL strings, one per slide.
*/
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);
// ── Read slide dimensions from presentation.xml ──────────────────
let slideW = 960;
let slideH = 540;
const presXml = zip.file('ppt/presentation.xml');
if (presXml) {
const presText = await presXml.async('text');
const presDoc = new DOMParser().parseFromString(presText, 'application/xml');
const sldSz = presDoc.getElementsByTagName('p:sldSz')[0];
if (sldSz) {
slideW = emuToPx(parseEmu(sldSz.getAttribute('cx')));
slideH = emuToPx(parseEmu(sldSz.getAttribute('cy')));
}
}
// ── Collect media files (images) as base64 data URIs ─────────────
const media: Record<string, string> = {};
const mediaFiles = Object.keys(zip.files).filter((f) => f.startsWith('ppt/media/'));
await Promise.all(
mediaFiles.map(async (path) => {
const file = zip.file(path);
if (!file) return;
const base64 = await file.async('base64');
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const mime =
ext === 'png'
? 'image/png'
: ext === 'gif'
? 'image/gif'
: ext === 'svg'
? 'image/svg+xml'
: ext === 'emf' || ext === 'wmf'
? 'image/x-emf'
: 'image/jpeg';
media[path] = `data:${mime};base64,${base64}`;
})
);
// ── Discover slide files ─────────────────────────────────────────
const slideFiles = Object.keys(zip.files)
.filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))
.sort((a, b) => {
const na = parseInt(a.match(/slide(\d+)/)?.[1] ?? '0');
const nb = parseInt(b.match(/slide(\d+)/)?.[1] ?? '0');
return na - nb;
});
const images: string[] = [];
for (const slidePath of slideFiles) {
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<string, string> = {};
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;
}
}
}
// ── Create canvas and render slide ───────────────────────────
const canvas = document.createElement('canvas');
canvas.width = slideW;
canvas.height = slideH;
const ctx = canvas.getContext('2d')!;
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, slideW, slideH);
const spTree = slideDoc.getElementsByTagName('p:spTree')[0];
if (!spTree) {
images.push(canvas.toDataURL('image/png'));
continue;
}
const shapes = [
...Array.from(spTree.getElementsByTagName('p:sp')),
...Array.from(spTree.getElementsByTagName('p:pic'))
];
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')));
if (w === 0 && h === 0) continue;
// ── 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 dataUri = mediaPath ? media[mediaPath] : '';
if (dataUri && !dataUri.includes('image/x-emf')) {
try {
const img = await loadImage(dataUri);
ctx.drawImage(img, x, y, w, h);
} catch {
// Skip images that fail to load
}
}
}
continue;
}
// ── Text shape ───────────────────────────────────────────
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');
let cursorY = y;
const defaultFontSize = 12;
for (let pi = 0; pi < paragraphs.length; pi++) {
const para = paragraphs[pi];
const runs = para.getElementsByTagName('a:r');
if (runs.length === 0) {
cursorY += defaultFontSize * 1.5;
continue;
}
// Calculate max font size in this paragraph for line height
let maxFontPt = defaultFontSize;
for (let ri = 0; ri < runs.length; ri++) {
const rPr = runs[ri].getElementsByTagName('a:rPr')[0];
if (rPr) {
const sz = rPr.getAttribute('sz');
if (sz) {
const pt = parseInt(sz, 10) / 100;
if (pt > maxFontPt) maxFontPt = pt;
}
}
}
const lineHeight = maxFontPt * 1.4;
cursorY += maxFontPt; // baseline offset
let cursorX = x + 4; // small left padding
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';
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+)/);
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 (cursorY > y + h) break;
ctx.fillText(word, cursorX, cursorY);
cursorX += metrics.width;
}
}
cursorY += lineHeight * 0.4; // paragraph spacing
}
ctx.restore();
}
images.push(canvas.toDataURL('image/png'));
}
return { images, width: slideW, height: slideH };
}