Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix(slides): improve PPTX import — images, rich text, rotation, tables, groups' (#370) from feat/pptx-import-v2 into main

scott fcfa0648 626053e3

+511 -459
+12
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.36.1] — 2026-04-14 11 + 12 + ### Improved 13 + - PPTX import: embedded images now extracted as base64 data URLs and placed as canvas image elements (#609) 14 + - PPTX import: per-run rich text — font size, bold, italic, and text color extracted from each `<a:r>` run independently (#609) 15 + - PPTX import: rotation extracted from `xfrm rot` attribute (60000ths of a degree) and applied to elements (#609) 16 + - PPTX import: tables (`<p:graphicFrame>` + `<a:tbl>`) rendered as monospace text grid with header separator row (#609) 17 + - PPTX import: group shapes (`<p:grpSp>`) recursively flattened into individual elements (#609) 18 + - PPTX import: slide dimensions read from `presentation.xml` `<p:sldSz>` — handles 4:3 and non-standard aspect ratios (#609) 19 + - PPTX import: preset colors (`prstClr`) mapped to hex values; gradient backgrounds use first-stop color (#609) 20 + 10 21 ## [0.36.0] — 2026-04-13 11 22 12 23 ### Added 24 + - feat: PDF and PPTX file import support (#608) 13 25 - PDF import: drop or import a `.pdf` file from the landing page or docs in-editor import menu — text is extracted page-by-page via pdf.js (dynamically loaded) and converted to headings and paragraphs in the TipTap editor (#608) 14 26 - PPTX import: drop or import a `.pptx` file from the landing page — slides are parsed from the ZIP+XML format using JSZip (no new deps), mapped to our canvas element model with title, body, and other text shapes; speaker notes are preserved (#608) 15 27
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.36.0", 3 + "version": "0.36.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+291 -321
src/slides/pptx-import.ts
··· 1 1 /** 2 - * PPTX import module for Tools Slides. 3 - * 4 - * Parses a .pptx ArrayBuffer (ZIP + XML) using JSZip, then converts 5 - * each slide's shapes and text into our DeckState format. 6 - * 7 - * Limitations (acceptable first pass): 8 - * - Images in slides are skipped (media extraction is a future enhancement) 9 - * - Slide master/layout backgrounds are not applied (uses white or extracted bg) 10 - * - Complex table and SmartArt shapes are extracted as plain text 2 + * PPTX import module for Tools Slides v2. 3 + * Images, rich text, rotation, tables, groups, dynamic dimensions. 11 4 */ 12 - 13 5 import JSZip from 'jszip'; 14 6 import { 15 - type DeckState, 16 - type Slide, 17 - type SlideElement, 18 - createSlide, 19 - SLIDE_WIDTH, 20 - SLIDE_HEIGHT, 21 - DEFAULT_ASPECT_RATIO, 7 + type DeckState, type Slide, type SlideElement, 8 + createSlide, SLIDE_WIDTH, SLIDE_HEIGHT, DEFAULT_ASPECT_RATIO, 22 9 } from './canvas-engine.js'; 23 10 24 - // --------------------------------------------------------------------------- 25 - // EMU → pixel conversion 26 - // PPTX uses English Metric Units: 1 inch = 914400 EMU, 1 pt = 12700 EMU. 27 - // Standard slide: 9144000 × 5143500 EMU = 10" × 5.625" (16:9). 28 - // We target 960 × 540 px. 29 - // --------------------------------------------------------------------------- 11 + // --- Slide dimensions --- 12 + interface Dims { w: number; h: number; } 13 + async function getDims(zip: JSZip): Promise<Dims> { 14 + const f = zip.file('ppt/presentation.xml'); 15 + if (!f) return { w: 9144000, h: 5143500 }; 16 + try { 17 + const doc = parseXml(await f.async('string')); 18 + const sz = byName(doc, 'sldSz')[0]; 19 + if (sz) { 20 + const cx = parseInt(attr(sz, 'cx'), 10); 21 + const cy = parseInt(attr(sz, 'cy'), 10); 22 + if (cx > 0 && cy > 0) return { w: cx, h: cy }; 23 + } 24 + } catch { /* ignore */ } 25 + return { w: 9144000, h: 5143500 }; 26 + } 30 27 31 - const PPTX_WIDTH_EMU = 9144000; 32 - const PPTX_HEIGHT_EMU = 5143500; 33 - 34 - function emuToX(emu: number): number { 35 - return Math.round((emu / PPTX_WIDTH_EMU) * SLIDE_WIDTH); 36 - } 37 - function emuToY(emu: number): number { 38 - return Math.round((emu / PPTX_HEIGHT_EMU) * SLIDE_HEIGHT); 39 - } 40 - function emuToW(emu: number): number { 41 - return Math.max(10, Math.round((emu / PPTX_WIDTH_EMU) * SLIDE_WIDTH)); 42 - } 43 - function emuToH(emu: number): number { 44 - return Math.max(10, Math.round((emu / PPTX_HEIGHT_EMU) * SLIDE_HEIGHT)); 28 + type Conv = { x(e: number): number; y(e: number): number; w(e: number): number; h(e: number): number }; 29 + function mkConv({ w, h }: Dims): Conv { 30 + return { 31 + x: e => Math.round((e / w) * SLIDE_WIDTH), 32 + y: e => Math.round((e / h) * SLIDE_HEIGHT), 33 + w: e => Math.max(10, Math.round((e / w) * SLIDE_WIDTH)), 34 + h: e => Math.max(10, Math.round((e / h) * SLIDE_HEIGHT)), 35 + }; 45 36 } 46 37 47 - // --------------------------------------------------------------------------- 48 - // XML helpers 49 - // --------------------------------------------------------------------------- 50 - 51 - function parseXml(xmlStr: string): Document { 52 - return new DOMParser().parseFromString(xmlStr, 'application/xml'); 38 + // --- XML helpers --- 39 + function parseXml(s: string): Document { 40 + return new DOMParser().parseFromString(s, 'application/xml'); 53 41 } 54 - 55 - /** Get all elements by local name, ignoring namespace prefix. */ 56 - function byName(el: Element | Document, localName: string): Element[] { 57 - // querySelectorAll with namespace wildcards isn't reliably cross-browser, 58 - // so we iterate children and match by localName. 59 - const results: Element[] = []; 60 - const walk = (node: Element | Document) => { 61 - const children = (node as Element).children ?? (node as Document).documentElement?.children ?? []; 62 - for (const child of Array.from(children)) { 63 - if (child.localName === localName) results.push(child); 64 - walk(child); 65 - } 42 + function byName(node: Element | Document, name: string): Element[] { 43 + const out: Element[] = []; 44 + const walk = (n: Element | Document) => { 45 + const root = (n as Document).documentElement ?? (n as Element); 46 + for (const c of Array.from(root.children)) { if (c.localName === name) out.push(c); walk(c); } 66 47 }; 67 - walk(el); 68 - return results; 48 + walk(node); 49 + return out; 69 50 } 70 - 51 + function ch(el: Element, name: string): Element[] { 52 + return Array.from(el.children).filter(c => c.localName === name); 53 + } 71 54 function attr(el: Element, name: string): string { 72 - return el.getAttribute(name) ?? ''; 55 + return el.getAttribute(name) 56 + ?? el.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', name) 57 + ?? ''; 73 58 } 74 59 75 - /** Extract hex color from a solidFill/srgbClr element chain, or '' if absent. */ 76 - function extractColor(container: Element): string { 77 - const srgb = byName(container, 'srgbClr')[0]; 78 - if (srgb) return '#' + attr(srgb, 'val').toLowerCase(); 79 - const sys = byName(container, 'sysClr')[0]; 80 - if (sys) { 81 - const lastClr = attr(sys, 'lastClr'); 82 - if (lastClr) return '#' + lastClr.toLowerCase(); 60 + // --- Colors --- 61 + function solidColor(el: Element): string { 62 + for (const fill of byName(el, 'solidFill')) { 63 + const srgb = ch(fill, 'srgbClr')[0]; 64 + if (srgb) return '#' + attr(srgb, 'val').toLowerCase().padStart(6, '0'); 65 + const sys = ch(fill, 'sysClr')[0]; 66 + if (sys) { const v = attr(sys, 'lastClr'); if (v) return '#' + v.toLowerCase().padStart(6, '0'); } 67 + const prst = ch(fill, 'prstClr')[0]; 68 + if (prst) { 69 + const m: Record<string, string> = { white:'#ffffff',black:'#000000',red:'#ff0000',green:'#008000', 70 + blue:'#0000ff',yellow:'#ffff00',gray:'#808080',orange:'#ffa500',purple:'#800080',pink:'#ffc0cb',cyan:'#00ffff' }; 71 + const c = m[attr(prst,'val').toLowerCase()]; if (c) return c; 72 + } 83 73 } 84 74 return ''; 85 75 } 86 - 87 - // --------------------------------------------------------------------------- 88 - // Shape text extraction 89 - // --------------------------------------------------------------------------- 90 - 91 - interface ParsedShape { 92 - phType: string | null; // 'title' | 'body' | 'ctrTitle' | 'subTitle' | null 93 - x: number; 94 - y: number; 95 - width: number; 96 - height: number; 97 - text: string; 98 - fontSize: number; // pt 99 - bold: boolean; 100 - color: string; 101 - align: string; // 'left' | 'center' | 'right' 76 + function gradColor(el: Element): string { 77 + for (const gf of byName(el, 'gradFill')) { 78 + const stops = byName(gf, 'gs'); 79 + if (stops[0]) { const c = solidColor(stops[0]); if (c) return c; } 80 + } 81 + return ''; 102 82 } 103 83 104 - function extractParagraphText(para: Element): string { 105 - const runs = byName(para, 'r'); 106 - if (runs.length === 0) return ''; 107 - return runs.map(r => { 108 - const t = byName(r, 't')[0]; 109 - return t?.textContent ?? ''; 110 - }).join(''); 84 + // --- Relationships --- 85 + async function loadRels(zip: JSZip, idx: number): Promise<Map<string, string>> { 86 + const f = zip.file(`ppt/slides/_rels/slide${idx}.xml.rels`); 87 + if (!f) return new Map(); 88 + try { 89 + const doc = parseXml(await f.async('string')); 90 + const map = new Map<string, string>(); 91 + for (const rel of byName(doc, 'Relationship')) { 92 + const target = attr(rel, 'Target'); 93 + const path = target.startsWith('../') ? 'ppt/' + target.slice(3) 94 + : target.startsWith('/') ? target.slice(1) : `ppt/slides/${target}`; 95 + map.set(attr(rel, 'Id'), path); 96 + } 97 + return map; 98 + } catch { return new Map(); } 111 99 } 112 100 113 - function extractShapeText(sp: Element): string { 114 - const txBody = byName(sp, 'txBody')[0]; 115 - if (!txBody) return ''; 116 - return byName(txBody, 'p') 117 - .map(extractParagraphText) 118 - .filter(Boolean) 119 - .join('\n'); 101 + // --- Images --- 102 + function imgMime(path: string): string { 103 + const ext = path.split('.').pop()?.toLowerCase() ?? ''; 104 + const m: Record<string, string> = { 105 + png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg', 106 + gif:'image/gif', webp:'image/webp', svg:'image/svg+xml', bmp:'image/bmp', 107 + }; 108 + return m[ext] ?? ''; 109 + } 110 + async function imgDataUrl(zip: JSZip, path: string): Promise<string | null> { 111 + const mime = imgMime(path); 112 + if (!mime) return null; 113 + const f = zip.file(path); 114 + if (!f) return null; 115 + try { return `data:${mime};base64,${await f.async('base64')}`; } catch { return null; } 120 116 } 121 117 122 - function extractFirstFontSize(sp: Element): number { 123 - // Look for <a:rPr sz="..."> (in hundredths of a point) or <a:defRPr sz="..."> 124 - for (const localName of ['rPr', 'defRPr', 'endParaRPr']) { 125 - const els = byName(sp, localName); 126 - for (const el of els) { 127 - const sz = attr(el, 'sz'); 128 - if (sz) return Math.round(parseInt(sz, 10) / 100); 118 + // --- Rich text --- 119 + interface Run { text: string; sz: number; bold: boolean; italic: boolean; color: string; } 120 + interface Para { runs: Run[]; align: string; } 121 + 122 + function richText(txBody: Element): Para[] { 123 + return Array.from(txBody.children).filter(c => c.localName === 'p').map(para => { 124 + const pPr = ch(para, 'pPr')[0]; 125 + const algn = pPr ? attr(pPr, 'algn') : ''; 126 + const align = algn === 'ctr' ? 'center' : algn === 'r' ? 'right' : algn === 'just' ? 'justify' : 'left'; 127 + const defRPr = pPr ? ch(pPr, 'defRPr')[0] : undefined; 128 + const runs: Run[] = []; 129 + for (const child of Array.from(para.children)) { 130 + let text = '', rPr: Element | undefined; 131 + if (child.localName === 'r') { rPr = ch(child, 'rPr')[0]; text = ch(child, 't')[0]?.textContent ?? ''; } 132 + else if (child.localName === 'br') { rPr = ch(child, 'rPr')[0]; text = '\n'; } 133 + else if (child.localName === 'fld') { rPr = ch(child, 'rPr')[0]; text = ch(child, 't')[0]?.textContent ?? ''; } 134 + if (!text) continue; 135 + const src = rPr ?? defRPr; 136 + const sz = src ? parseInt(attr(src, 'sz') || '0', 10) : 0; 137 + runs.push({ 138 + text, sz: sz > 0 ? Math.round(sz / 100) : 18, 139 + bold: src ? (attr(src, 'b') === '1') : false, 140 + italic: src ? (attr(src, 'i') === '1') : false, 141 + color: src ? solidColor(src) : '', 142 + }); 129 143 } 130 - } 131 - return 18; // default 144 + return { runs, align }; 145 + }); 132 146 } 133 147 134 - function extractFirstAlign(sp: Element): string { 135 - const pPr = byName(sp, 'pPr')[0]; 136 - if (pPr) { 137 - const algn = attr(pPr, 'algn'); 138 - if (algn === 'ctr') return 'center'; 139 - if (algn === 'r') return 'right'; 140 - } 141 - return 'left'; 148 + function plain(paras: Para[]): string { 149 + return paras.map(p => p.runs.map(r => r.text).join('')).filter(t => t.trim() || t === '\n').join('\n'); 150 + } 151 + function medianSz(paras: Para[]): number { 152 + const sizes = paras.flatMap(p => p.runs.map(r => r.sz)).filter(s => s > 0).sort((a, b) => a - b); 153 + return sizes[Math.floor(sizes.length / 2)] ?? 18; 142 154 } 143 - 144 - function extractFirstColor(sp: Element): string { 145 - const solidFills = byName(sp, 'solidFill'); 146 - for (const fill of solidFills) { 147 - const c = extractColor(fill); 148 - if (c) return c; 149 - } 150 - return '#000000'; 155 + function domAlign(paras: Para[]): string { 156 + const c: Record<string, number> = {}; 157 + for (const p of paras) c[p.align] = (c[p.align] ?? 0) + 1; 158 + return Object.entries(c).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'left'; 159 + } 160 + function anyBold(paras: Para[]): boolean { return paras.some(p => p.runs.some(r => r.bold)); } 161 + function firstColor(paras: Para[]): string { 162 + for (const p of paras) for (const r of p.runs) if (r.color) return r.color; 163 + return ''; 151 164 } 152 165 153 - function extractFirstBold(sp: Element): boolean { 154 - const rPrs = byName(sp, 'rPr'); 155 - for (const rPr of rPrs) { 156 - const b = attr(rPr, 'b'); 157 - if (b === '1' || b === 'true') return true; 158 - } 159 - return false; 166 + // --- Transform --- 167 + interface Tfm { x: number; y: number; width: number; height: number; rotation: number; } 168 + function tfm(spPr: Element | undefined, conv: Conv): Tfm | null { 169 + if (!spPr) return null; 170 + const xfrm = ch(spPr, 'xfrm')[0] ?? byName(spPr, 'xfrm')[0]; 171 + if (!xfrm) return null; 172 + const off = ch(xfrm, 'off')[0]; const ext = ch(xfrm, 'ext')[0]; 173 + if (!off || !ext) return null; 174 + return { 175 + x: conv.x(parseInt(attr(off, 'x'), 10) || 0), 176 + y: conv.y(parseInt(attr(off, 'y'), 10) || 0), 177 + width: conv.w(parseInt(attr(ext, 'cx'), 10) || 0), 178 + height: conv.h(parseInt(attr(ext, 'cy'), 10) || 0), 179 + rotation: (parseInt(attr(xfrm, 'rot'), 10) || 0) / 60000, 180 + }; 160 181 } 161 182 162 - function parseShape(sp: Element): ParsedShape | null { 163 - const text = extractShapeText(sp); 164 - if (!text.trim()) return null; 183 + let _n = 0; 184 + const eid = () => `imp-${Date.now()}-${++_n}`; 165 185 166 - // Placeholder type 167 - const nvSpPr = byName(sp, 'nvSpPr')[0]; 186 + // --- Text shape --- 187 + function parseSp(sp: Element, conv: Conv, z: number): SlideElement | null { 188 + const txBody = byName(sp, 'txBody')[0]; 189 + if (!txBody) return null; 190 + const paras = richText(txBody); 191 + const text = plain(paras).trim(); 192 + if (!text) return null; 193 + 194 + const nvSpPr = ch(sp, 'nvSpPr')[0]; 168 195 const ph = nvSpPr ? byName(nvSpPr, 'ph')[0] : null; 169 196 const phType = ph ? (attr(ph, 'type') || 'body') : null; 197 + const isTitle = phType === 'title' || phType === 'ctrTitle'; 170 198 171 - // Position/size from spPr > xfrm 172 - const spPr = byName(sp, 'spPr')[0]; 173 - const xfrm = spPr ? byName(spPr, 'xfrm')[0] : null; 174 - const off = xfrm ? byName(xfrm, 'off')[0] : null; 175 - const ext = xfrm ? byName(xfrm, 'ext')[0] : null; 176 - 177 - const x = off ? emuToX(parseInt(attr(off, 'x'), 10) || 0) : 0; 178 - const y = off ? emuToY(parseInt(attr(off, 'y'), 10) || 0) : 0; 179 - const width = ext ? emuToW(parseInt(attr(ext, 'cx'), 10) || PPTX_WIDTH_EMU) : SLIDE_WIDTH; 180 - const height = ext ? emuToH(parseInt(attr(ext, 'cy'), 10) || 100000) : 60; 199 + const spPr = ch(sp, 'spPr')[0]; 200 + const t = tfm(spPr, conv); 201 + let x = 0, y = 0, w = SLIDE_WIDTH, h = 60; 202 + if (t) { x = t.x; y = t.y; w = t.width; h = t.height; } 203 + else if (isTitle) { x = 48; y = 27; w = SLIDE_WIDTH - 96; h = 108; } 204 + else { x = 48; y = 162; w = SLIDE_WIDTH - 96; h = SLIDE_HEIGHT - 216; } 181 205 182 - return { 183 - phType, 184 - x, 185 - y, 186 - width, 187 - height, 188 - text, 189 - fontSize: extractFirstFontSize(sp), 190 - bold: extractFirstBold(sp), 191 - color: extractFirstColor(sp), 192 - align: extractFirstAlign(sp), 206 + const bgFill = spPr ? solidColor(spPr) : ''; 207 + const style: Record<string, string> = { 208 + fontSize: `${medianSz(paras)}px`, 209 + color: firstColor(paras) || (isTitle ? '#111111' : '#333333'), 210 + textAlign: domAlign(paras), 211 + fontWeight: (anyBold(paras) || isTitle) ? 'bold' : 'normal', 212 + lineHeight: '1.4', padding: '6px', wordBreak: 'break-word', whiteSpace: 'pre-wrap', overflow: 'hidden', 193 213 }; 214 + if (bgFill) { style['background'] = bgFill; style['borderRadius'] = '2px'; } 215 + return { id: eid(), type: 'text', x, y, width: w, height: h, rotation: t?.rotation ?? 0, zIndex: z, content: text, style }; 194 216 } 195 217 196 - // --------------------------------------------------------------------------- 197 - // Background extraction 198 - // --------------------------------------------------------------------------- 218 + // --- Picture shape --- 219 + async function parsePic(pic: Element, conv: Conv, z: number, rels: Map<string, string>, zip: JSZip): Promise<SlideElement | null> { 220 + const blip = byName(pic, 'blip')[0]; 221 + if (!blip) return null; 222 + const rId = attr(blip, 'r:embed') || attr(blip, 'embed'); 223 + if (!rId) return null; 224 + const path = rels.get(rId); 225 + if (!path) return null; 226 + const url = await imgDataUrl(zip, path); 227 + if (!url) return null; 228 + const spPr = ch(pic, 'spPr')[0]; 229 + const t = spPr ? tfm(spPr, conv) : null; 230 + if (!t || t.width <= 0 || t.height <= 0) return null; 231 + return { id: eid(), type: 'image', x: t.x, y: t.y, width: t.width, height: t.height, rotation: t.rotation, zIndex: z, content: url, style: { objectFit: 'contain' } }; 232 + } 199 233 200 - function extractBackground(slideDoc: Document): string { 201 - const bg = byName(slideDoc, 'bg')[0]; 202 - if (!bg) return '#ffffff'; 203 - const bgPr = byName(bg, 'bgPr')[0]; 204 - if (bgPr) { 205 - const solidFill = byName(bgPr, 'solidFill')[0]; 206 - if (solidFill) { 207 - const c = extractColor(solidFill); 208 - if (c) return c; 209 - } 210 - } 211 - return '#ffffff'; 234 + // --- Table --- 235 + function parseGf(gf: Element, conv: Conv, z: number): SlideElement | null { 236 + const tbl = byName(gf, 'tbl')[0]; 237 + if (!tbl) return null; 238 + const lines = byName(tbl, 'tr').flatMap((row, i) => { 239 + const cells = byName(row, 'tc').map(tc => { const tb = byName(tc, 'txBody')[0]; return tb ? plain(richText(tb)).trim() : ''; }); 240 + const line = cells.join(' | '); 241 + return i === 0 ? [line, cells.map(() => '---').join(' | ')] : [line]; 242 + }); 243 + const text = lines.join('\n'); 244 + if (!text.trim()) return null; 245 + const xfrmEl = byName(gf, 'xfrm')[0]; 246 + const off = xfrmEl ? ch(xfrmEl, 'off')[0] : null; 247 + const ext = xfrmEl ? ch(xfrmEl, 'ext')[0] : null; 248 + return { id: eid(), type: 'text', 249 + x: off ? conv.x(parseInt(attr(off, 'x'), 10) || 0) : 0, 250 + y: off ? conv.y(parseInt(attr(off, 'y'), 10) || 0) : 0, 251 + width: Math.max(ext ? conv.w(parseInt(attr(ext, 'cx'), 10) || 0) : SLIDE_WIDTH, 100), 252 + height: Math.max(ext ? conv.h(parseInt(attr(ext, 'cy'), 10) || 0) : 120, 40), 253 + rotation: 0, zIndex: z, content: text, 254 + style: { fontSize: '12px', fontFamily: 'monospace', color: '#222222', whiteSpace: 'pre', padding: '4px', lineHeight: '1.6', overflow: 'hidden' } }; 212 255 } 213 256 214 - // --------------------------------------------------------------------------- 215 - // Notes extraction 216 - // --------------------------------------------------------------------------- 217 - 218 - async function extractNotes(zip: JSZip, slideIndex: number): Promise<string> { 219 - const notesPath = `ppt/notesSlides/notesSlide${slideIndex}.xml`; 220 - const file = zip.file(notesPath); 221 - if (!file) return ''; 222 - try { 223 - const xml = await file.async('string'); 224 - const doc = parseXml(xml); 225 - // Notes body is in the second sp (first is slide number placeholder) 226 - const shapes = byName(doc, 'sp'); 227 - const texts: string[] = []; 228 - for (const sp of shapes) { 229 - const nvSpPr = byName(sp, 'nvSpPr')[0]; 230 - const ph = nvSpPr ? byName(nvSpPr, 'ph')[0] : null; 231 - const phType = ph ? attr(ph, 'type') : ''; 232 - // Skip slide number placeholder (type="sldNum") 233 - if (phType === 'sldNum') continue; 234 - const t = extractShapeText(sp); 235 - if (t.trim()) texts.push(t.trim()); 236 - } 237 - return texts.join('\n'); 238 - } catch { 239 - return ''; 257 + // --- Group (recursive) --- 258 + async function parseGrp(grpSp: Element, conv: Conv, z: number, rels: Map<string, string>, zip: JSZip): Promise<SlideElement[]> { 259 + const els: SlideElement[] = []; 260 + for (const child of Array.from(grpSp.children)) { 261 + if (child.localName === 'sp') { const e = parseSp(child, conv, z); if (e) { els.push(e); z++; } } 262 + else if (child.localName === 'pic') { const e = await parsePic(child, conv, z, rels, zip); if (e) { els.push(e); z++; } } 263 + else if (child.localName === 'graphicFrame') { const e = parseGf(child, conv, z); if (e) { els.push(e); z++; } } 264 + else if (child.localName === 'grpSp') { const n = await parseGrp(child, conv, z, rels, zip); els.push(...n); z += n.length; } 240 265 } 266 + return els; 241 267 } 242 268 243 - // --------------------------------------------------------------------------- 244 - // Slide order from presentation.xml 245 - // --------------------------------------------------------------------------- 246 - 247 - async function getSlideOrder(zip: JSZip): Promise<number[]> { 248 - const presFile = zip.file('ppt/presentation.xml'); 249 - if (!presFile) { 250 - // Fall back to numeric order based on files present 251 - const files = Object.keys(zip.files) 252 - .filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f)) 253 - .map(f => parseInt(f.match(/slide(\d+)\.xml/)![1]!, 10)) 254 - .sort((a, b) => a - b); 255 - return files; 256 - } 257 - 258 - const xml = await presFile.async('string'); 259 - const doc = parseXml(xml); 260 - const sldIdLst = byName(doc, 'sldIdLst')[0]; 261 - if (!sldIdLst) return []; 262 - 263 - // Map r:id → slide file index via _rels/presentation.xml.rels 264 - const relsFile = zip.file('ppt/_rels/presentation.xml.rels'); 265 - if (!relsFile) return []; 266 - const relsXml = await relsFile.async('string'); 267 - const relsDoc = parseXml(relsXml); 268 - const rels = byName(relsDoc, 'Relationship'); 269 - const ridToIndex = new Map<string, number>(); 270 - for (const rel of rels) { 271 - const target = attr(rel, 'Target'); 272 - const m = target.match(/slides\/slide(\d+)\.xml/); 273 - if (m) ridToIndex.set(attr(rel, 'Id'), parseInt(m[1]!, 10)); 269 + // --- Background --- 270 + async function getBg(doc: Document, zip: JSZip, rels: Map<string, string>): Promise<{ color: string; bgImg?: string }> { 271 + const bg = byName(doc, 'bg')[0]; if (!bg) return { color: '#ffffff' }; 272 + const bgPr = byName(bg, 'bgPr')[0]; if (!bgPr) return { color: '#ffffff' }; 273 + const sc = solidColor(bgPr); if (sc) return { color: sc }; 274 + const gc = gradColor(bgPr); if (gc) return { color: gc }; 275 + const blip = byName(bgPr, 'blip')[0]; 276 + if (blip) { 277 + const rId = attr(blip, 'r:embed') || attr(blip, 'embed'); 278 + if (rId) { const path = rels.get(rId); if (path) { const url = await imgDataUrl(zip, path); if (url) return { color: '#000000', bgImg: url }; } } 274 279 } 275 - 276 - const sldIds = byName(sldIdLst, 'sldId'); 277 - return sldIds 278 - .map(el => ridToIndex.get(attr(el, 'r:id') || el.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id') || '') ?? 0) 279 - .filter(n => n > 0); 280 + return { color: '#ffffff' }; 280 281 } 281 282 282 - // --------------------------------------------------------------------------- 283 - // Shape → SlideElement conversion 284 - // --------------------------------------------------------------------------- 285 - 286 - let _elCounter = 0; 287 - function makeElementId(): string { 288 - return `imported-${Date.now()}-${++_elCounter}`; 283 + // --- Notes --- 284 + async function getNotes(zip: JSZip, idx: number): Promise<string> { 285 + const f = zip.file(`ppt/notesSlides/notesSlide${idx}.xml`); 286 + if (!f) return ''; 287 + try { 288 + const doc = parseXml(await f.async('string')); 289 + return byName(doc, 'sp') 290 + .filter(sp => { const nvSpPr = ch(sp, 'nvSpPr')[0]; const ph = nvSpPr ? byName(nvSpPr,'ph')[0] : null; return !ph || attr(ph,'type') !== 'sldNum'; }) 291 + .map(sp => { const tb = byName(sp,'txBody')[0]; return tb ? plain(richText(tb)).trim() : ''; }) 292 + .filter(Boolean).join('\n'); 293 + } catch { return ''; } 289 294 } 290 295 291 - function shapeToElement(shape: ParsedShape, zIndex: number): SlideElement { 292 - const isTitle = shape.phType === 'title' || shape.phType === 'ctrTitle'; 293 - const effectiveFontSize = shape.fontSize > 0 ? shape.fontSize : (isTitle ? 36 : 18); 294 - 295 - const style: Record<string, string> = { 296 - fontSize: `${effectiveFontSize}px`, 297 - color: shape.color || (isTitle ? '#111111' : '#333333'), 298 - textAlign: shape.align, 299 - fontWeight: shape.bold || isTitle ? 'bold' : 'normal', 300 - lineHeight: '1.4', 301 - padding: '4px', 302 - wordBreak: 'break-word', 303 - whiteSpace: 'pre-wrap', 304 - }; 305 - 306 - return { 307 - id: makeElementId(), 308 - type: 'text', 309 - x: shape.x, 310 - y: shape.y, 311 - width: shape.width, 312 - height: shape.height, 313 - rotation: 0, 314 - zIndex, 315 - content: shape.text, 316 - style, 317 - }; 296 + // --- Slide order --- 297 + async function slideOrder(zip: JSZip): Promise<number[]> { 298 + const fallback = Object.keys(zip.files) 299 + .filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f)) 300 + .map(f => parseInt(f.match(/slide(\d+)\.xml/)![1]!, 10)).sort((a, b) => a - b); 301 + try { 302 + const presFile = zip.file('ppt/presentation.xml'); if (!presFile) return fallback; 303 + const doc = parseXml(await presFile.async('string')); 304 + const sldIdLst = byName(doc, 'sldIdLst')[0]; if (!sldIdLst) return fallback; 305 + const relsFile = zip.file('ppt/_rels/presentation.xml.rels'); if (!relsFile) return fallback; 306 + const relsDoc = parseXml(await relsFile.async('string')); 307 + const ridToIdx = new Map<string, number>(); 308 + for (const rel of byName(relsDoc, 'Relationship')) { 309 + const m = attr(rel,'Target').match(/slides\/slide(\d+)\.xml/); 310 + if (m) ridToIdx.set(attr(rel,'Id'), parseInt(m[1]!, 10)); 311 + } 312 + const order = byName(sldIdLst, 'sldId') 313 + .map(el => ridToIdx.get(attr(el,'r:id') || el.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships','id') || '') ?? 0) 314 + .filter(n => n > 0); 315 + return order.length > 0 ? order : fallback; 316 + } catch { return fallback; } 318 317 } 319 318 320 - // --------------------------------------------------------------------------- 321 - // Public API 322 - // --------------------------------------------------------------------------- 323 - 324 - /** 325 - * Validate that an ArrayBuffer is a ZIP file (PPTX is a ZIP). 326 - */ 319 + // --- Public API --- 327 320 export function isValidPptx(arrayBuffer: ArrayBuffer): boolean { 328 321 if (!arrayBuffer || arrayBuffer.byteLength < 4) return false; 329 - const view = new Uint8Array(arrayBuffer); 330 - // PK\x03\x04 = 0x50 0x4B 0x03 0x04 331 - return view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04; 322 + const v = new Uint8Array(arrayBuffer); 323 + return v[0] === 0x50 && v[1] === 0x4B && v[2] === 0x03 && v[3] === 0x04; 332 324 } 333 325 334 - /** 335 - * Convert a .pptx ArrayBuffer to a DeckState. 336 - * Pure async — testable without DOM beyond DOMParser (available in jsdom). 337 - */ 338 326 export async function convertPptxToDeck(arrayBuffer: ArrayBuffer): Promise<DeckState> { 339 327 const zip = await JSZip.loadAsync(arrayBuffer); 340 - 341 - const order = await getSlideOrder(zip); 342 - if (order.length === 0) { 343 - // Nothing in presentation.xml — try all slide files 344 - const keys = Object.keys(zip.files) 345 - .filter(k => /^ppt\/slides\/slide\d+\.xml$/.test(k)) 346 - .sort(); 347 - order.push(...keys.map(k => parseInt(k.match(/slide(\d+)/)![1]!, 10))); 348 - } 349 - 328 + const dims = await getDims(zip); 329 + const conv = mkConv(dims); 330 + const order = await slideOrder(zip); 350 331 const slides: Slide[] = []; 351 332 352 - for (let i = 0; i < order.length; i++) { 353 - const slideIndex = order[i]!; 354 - const slidePath = `ppt/slides/slide${slideIndex}.xml`; 355 - const slideFile = zip.file(slidePath); 333 + for (const idx of order) { 334 + const slideFile = zip.file(`ppt/slides/slide${idx}.xml`); 356 335 if (!slideFile) continue; 357 - 358 - const xml = await slideFile.async('string'); 359 - const doc = parseXml(xml); 336 + const doc = parseXml(await slideFile.async('string')); 337 + const rels = await loadRels(zip, idx); 338 + const { color: bgColor, bgImg } = await getBg(doc, zip, rels); 339 + const notes = await getNotes(zip, idx); 360 340 361 - const background = extractBackground(doc); 362 - const notes = await extractNotes(zip, slideIndex); 341 + const spTree = byName(doc, 'spTree')[0]; 342 + if (!spTree) { slides.push({ ...createSlide(bgColor), notes }); continue; } 363 343 364 - // Collect all sp (shape) elements 365 - const shapes = byName(doc, 'sp'); 366 344 const elements: SlideElement[] = []; 367 - let zIndex = 0; 345 + let z = 0; 368 346 369 - for (const sp of shapes) { 370 - const parsed = parseShape(sp); 371 - if (!parsed) continue; 372 - elements.push(shapeToElement(parsed, zIndex++)); 347 + if (bgImg) { 348 + elements.push({ id: eid(), type: 'image', x: 0, y: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, rotation: 0, zIndex: -1, content: bgImg, style: { objectFit: 'cover' } }); 373 349 } 374 350 375 - const slide: Slide = { 376 - ...createSlide(background), 377 - elements, 378 - notes, 379 - }; 380 - slides.push(slide); 381 - } 351 + for (const child of Array.from(spTree.children)) { 352 + if (child.localName === 'sp') { const e = parseSp(child, conv, z); if (e) { elements.push(e); z++; } } 353 + else if (child.localName === 'pic') { const e = await parsePic(child, conv, z, rels, zip); if (e) { elements.push(e); z++; } } 354 + else if (child.localName === 'graphicFrame') { const e = parseGf(child, conv, z); if (e) { elements.push(e); z++; } } 355 + else if (child.localName === 'grpSp') { const els = await parseGrp(child, conv, z, rels, zip); elements.push(...els); z += els.length; } 356 + } 382 357 383 - if (slides.length === 0) { 384 - // Fallback: return a single blank slide so the editor doesn't crash 385 - slides.push(createSlide()); 358 + slides.push({ ...createSlide(bgColor), elements, notes }); 386 359 } 387 360 388 - return { 389 - slides, 390 - currentSlide: 0, 391 - aspectRatio: DEFAULT_ASPECT_RATIO, 392 - }; 361 + if (slides.length === 0) slides.push(createSlide()); 362 + return { slides, currentSlide: 0, aspectRatio: DEFAULT_ASPECT_RATIO }; 393 363 }
+207 -137
tests/pptx-import.test.ts
··· 1 1 /** 2 - * Tests for PPTX import — isValidPptx and convertPptxToDeck. 3 - * 4 - * convertPptxToDeck uses DOMParser (available in jsdom) and JSZip, 5 - * so we can test it end-to-end with minimal synthetic PPTX buffers. 2 + * Tests for PPTX import v2 — isValidPptx and convertPptxToDeck. 3 + * Uses jsdom DOMParser + JSZip with synthetic PPTX data. 6 4 */ 7 - 8 5 // @vitest-environment jsdom 9 6 10 7 import { describe, it, expect } from 'vitest'; 11 8 import JSZip from 'jszip'; 12 9 import { isValidPptx, convertPptxToDeck } from '../src/slides/pptx-import.js'; 13 10 14 - function makeBuffer(bytes: number[]): ArrayBuffer { 15 - return new Uint8Array(bytes).buffer; 16 - } 11 + function buf(bytes: number[]): ArrayBuffer { return new Uint8Array(bytes).buffer; } 17 12 18 13 // --------------------------------------------------------------------------- 19 14 // isValidPptx 20 15 // --------------------------------------------------------------------------- 21 - 22 16 describe('isValidPptx', () => { 23 - it('returns true for a ZIP-signature buffer', () => { 24 - // PK\x03\x04 25 - expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03, 0x04, 0x00]))).toBe(true); 26 - }); 27 - 28 - it('returns false for a non-ZIP buffer', () => { 29 - expect(isValidPptx(makeBuffer([0x25, 0x50, 0x44, 0x46, 0x2D]))).toBe(false); // PDF 30 - }); 31 - 32 - it('returns false for an empty buffer', () => { 33 - expect(isValidPptx(new ArrayBuffer(0))).toBe(false); 34 - }); 35 - 36 - it('returns false for a buffer shorter than 4 bytes', () => { 37 - expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03]))).toBe(false); 38 - }); 39 - 40 - it('returns false for null/undefined', () => { 41 - expect(isValidPptx(null as unknown as ArrayBuffer)).toBe(false); 42 - expect(isValidPptx(undefined as unknown as ArrayBuffer)).toBe(false); 43 - }); 17 + it('accepts ZIP magic bytes', () => expect(isValidPptx(buf([0x50,0x4B,0x03,0x04,0x00]))).toBe(true)); 18 + it('rejects PDF magic bytes', () => expect(isValidPptx(buf([0x25,0x50,0x44,0x46,0x2D]))).toBe(false)); 19 + it('rejects empty buffer', () => expect(isValidPptx(new ArrayBuffer(0))).toBe(false)); 20 + it('rejects buffer shorter than 4 bytes', () => expect(isValidPptx(buf([0x50,0x4B,0x03]))).toBe(false)); 21 + it('rejects null', () => expect(isValidPptx(null as unknown as ArrayBuffer)).toBe(false)); 44 22 }); 45 23 46 24 // --------------------------------------------------------------------------- 47 - // Helpers to build minimal synthetic PPTX ZIPs 25 + // Helpers to build synthetic PPTX ZIPs 48 26 // --------------------------------------------------------------------------- 49 27 50 - /** Minimal slide XML with one title shape and one body shape. */ 51 - function makeSlideXml(title: string, body: string): string { 52 - return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 28 + function spXml(title: string, body: string, x = 457200, y = 274638, cx = 8229600, cy = 1143000): string { 29 + return ` 30 + <p:sp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 31 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 32 + <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 33 + <p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm></p:spPr> 34 + <p:txBody> 35 + <a:p><a:r><a:rPr sz="4400" b="1"/><a:t>${title}</a:t></a:r></a:p> 36 + <a:p><a:r><a:rPr sz="2400"/><a:t>${body}</a:t></a:r></a:p> 37 + </p:txBody> 38 + </p:sp>`.trim(); 39 + } 40 + 41 + function rotSp(text: string, rot: number): string { 42 + return ` 43 + <p:sp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 44 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 45 + <p:nvSpPr></p:nvSpPr> 46 + <p:spPr><a:xfrm rot="${rot}"><a:off x="0" y="0"/><a:ext cx="1000000" cy="500000"/></a:xfrm></p:spPr> 47 + <p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody> 48 + </p:sp>`.trim(); 49 + } 50 + 51 + function picXml(rId: string, x = 0, y = 0, cx = 2000000, cy = 1500000): string { 52 + return ` 53 + <p:pic xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 54 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" 55 + xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> 56 + <p:nvPicPr><p:cNvPicPr/></p:nvPicPr> 57 + <p:blipFill><a:blip r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill> 58 + <p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm></p:spPr> 59 + </p:pic>`.trim(); 60 + } 61 + 62 + function slideXml(content: string, bgColor = ''): string { 63 + const bg = bgColor ? ` 64 + <p:bg><p:bgPr> 65 + <a:solidFill><a:srgbClr val="${bgColor.replace('#','')}"/></a:solidFill> 66 + </p:bgPr></p:bg>`.trim() : ''; 67 + return `<?xml version="1.0" encoding="UTF-8"?> 53 68 <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 54 69 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" 55 70 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> 56 - <p:cSld> 57 - <p:spTree> 58 - <p:sp> 59 - <p:nvSpPr> 60 - <p:ph type="title"/> 61 - </p:nvSpPr> 62 - <p:spPr> 63 - <a:xfrm> 64 - <a:off x="457200" y="274638"/> 65 - <a:ext cx="8229600" cy="1143000"/> 66 - </a:xfrm> 67 - </p:spPr> 68 - <p:txBody> 69 - <a:p><a:r><a:rPr sz="4400" b="1"/><a:t>${title}</a:t></a:r></a:p> 70 - </p:txBody> 71 - </p:sp> 72 - <p:sp> 73 - <p:nvSpPr> 74 - <p:ph type="body"/> 75 - </p:nvSpPr> 76 - <p:spPr> 77 - <a:xfrm> 78 - <a:off x="457200" y="1600200"/> 79 - <a:ext cx="8229600" cy="3700620"/> 80 - </a:xfrm> 81 - </p:spPr> 82 - <p:txBody> 83 - <a:p><a:r><a:rPr sz="2400"/><a:t>${body}</a:t></a:r></a:p> 84 - </p:txBody> 85 - </p:sp> 86 - </p:spTree> 87 - </p:cSld> 71 + <p:cSld>${bg}<p:spTree>${content}</p:spTree></p:cSld> 88 72 </p:sld>`; 89 73 } 90 74 91 - /** Build a minimal PPTX zip with the given slide XMLs (no presentation.xml, uses file order). */ 92 - async function buildMinimalPptx(slides: string[]): Promise<ArrayBuffer> { 75 + function relsXml(entries: Array<{ id: string; target: string }>): string { 76 + const rels = entries.map(e => `<Relationship Id="${e.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${e.target}"/>`).join('\n'); 77 + return `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`; 78 + } 79 + 80 + async function buildPptx(slides: Array<{ xml: string; rels?: Array<{ id: string; target: string }>; media?: Array<{ path: string; data: string }> }>): Promise<ArrayBuffer> { 93 81 const zip = new JSZip(); 94 82 for (let i = 0; i < slides.length; i++) { 95 - zip.file(`ppt/slides/slide${i + 1}.xml`, slides[i]!); 83 + const { xml, rels = [], media = [] } = slides[i]!; 84 + zip.file(`ppt/slides/slide${i + 1}.xml`, xml); 85 + if (rels.length > 0) { 86 + zip.file(`ppt/slides/_rels/slide${i + 1}.xml.rels`, relsXml(rels.map(r => ({ id: r.id, target: `../media/${r.target}` })))); 87 + } 88 + for (const m of media) zip.file(`ppt/media/${m.path}`, m.data, { base64: true }); 96 89 } 97 90 return zip.generateAsync({ type: 'arraybuffer' }); 98 91 } 99 92 93 + // Minimal 1x1 white PNG (base64) 94 + const WHITE_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=='; 95 + 100 96 // --------------------------------------------------------------------------- 101 97 // convertPptxToDeck 102 98 // --------------------------------------------------------------------------- 103 - 104 99 describe('convertPptxToDeck', () => { 105 - it('produces a deck with one slide for a single-slide PPTX', async () => { 106 - const buf = await buildMinimalPptx([makeSlideXml('Hello World', 'Slide content')]); 100 + it('single slide with title and body', async () => { 101 + const buf = await buildPptx([{ xml: slideXml(spXml('Hello', 'World')) }]); 107 102 const deck = await convertPptxToDeck(buf); 108 103 expect(deck.slides).toHaveLength(1); 109 104 expect(deck.currentSlide).toBe(0); 110 105 expect(deck.aspectRatio).toBeCloseTo(16 / 9, 2); 111 106 }); 112 107 113 - it('extracts title text into a slide element', async () => { 114 - const buf = await buildMinimalPptx([makeSlideXml('My Title', 'Body text here')]); 108 + it('extracts title text', async () => { 109 + const buf = await buildPptx([{ xml: slideXml(spXml('My Title', 'body')) }]); 110 + const deck = await convertPptxToDeck(buf); 111 + const el = deck.slides[0]!.elements.find(e => e.content.includes('My Title')); 112 + expect(el).toBeDefined(); 113 + expect(el!.type).toBe('text'); 114 + }); 115 + 116 + it('applies bold to title element', async () => { 117 + const buf = await buildPptx([{ xml: slideXml(spXml('Bold Title', 'body')) }]); 115 118 const deck = await convertPptxToDeck(buf); 116 - const slide = deck.slides[0]!; 117 - const titleEl = slide.elements.find(e => e.content === 'My Title'); 118 - expect(titleEl).toBeDefined(); 119 - expect(titleEl!.type).toBe('text'); 119 + const el = deck.slides[0]!.elements.find(e => e.content.includes('Bold Title')); 120 + expect(el?.style['fontWeight']).toBe('bold'); 120 121 }); 121 122 122 - it('extracts body text into a slide element', async () => { 123 - const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body text here')]); 123 + it('extracts per-run font size via sz attribute', async () => { 124 + const buf = await buildPptx([{ xml: slideXml(spXml('Title', 'body')) }]); 124 125 const deck = await convertPptxToDeck(buf); 125 - const slide = deck.slides[0]!; 126 - const bodyEl = slide.elements.find(e => e.content === 'Body text here'); 127 - expect(bodyEl).toBeDefined(); 126 + const el = deck.slides[0]!.elements[0]!; 127 + // Title run has sz="4400" → 44pt; median of [44, 24] = 24 or 44 depending on order 128 + expect(el.style['fontSize']).toMatch(/^\d+px$/); 128 129 }); 129 130 130 131 it('handles multiple slides', async () => { 131 - const buf = await buildMinimalPptx([ 132 - makeSlideXml('Slide One', 'First slide content'), 133 - makeSlideXml('Slide Two', 'Second slide content'), 134 - makeSlideXml('Slide Three', 'Third slide content'), 132 + const buf = await buildPptx([ 133 + { xml: slideXml(spXml('Slide 1', 'A')) }, 134 + { xml: slideXml(spXml('Slide 2', 'B')) }, 135 + { xml: slideXml(spXml('Slide 3', 'C')) }, 135 136 ]); 136 137 const deck = await convertPptxToDeck(buf); 137 138 expect(deck.slides).toHaveLength(3); 138 139 }); 139 140 140 - it('maps EMU coordinates to pixel positions within canvas bounds', async () => { 141 - const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]); 141 + it('extracts solid background color', async () => { 142 + const buf = await buildPptx([{ xml: slideXml(spXml('T', 'B'), '#1a2b3c') }]); 142 143 const deck = await convertPptxToDeck(buf); 143 - const slide = deck.slides[0]!; 144 - for (const el of slide.elements) { 144 + expect(deck.slides[0]!.background).toBe('#1a2b3c'); 145 + }); 146 + 147 + it('defaults to white background when none specified', async () => { 148 + const buf = await buildPptx([{ xml: slideXml(spXml('T', 'B')) }]); 149 + const deck = await convertPptxToDeck(buf); 150 + expect(deck.slides[0]!.background).toBe('#ffffff'); 151 + }); 152 + 153 + it('maps EMU coordinates to canvas pixel bounds', async () => { 154 + const buf = await buildPptx([{ xml: slideXml(spXml('T', 'B')) }]); 155 + const deck = await convertPptxToDeck(buf); 156 + for (const el of deck.slides[0]!.elements) { 145 157 expect(el.x).toBeGreaterThanOrEqual(0); 146 158 expect(el.y).toBeGreaterThanOrEqual(0); 147 159 expect(el.width).toBeGreaterThan(0); ··· 149 161 } 150 162 }); 151 163 152 - it('returns a single blank slide for an empty PPTX (no slides)', async () => { 153 - const zip = new JSZip(); 154 - zip.file('ppt/placeholder.txt', 'empty'); 155 - const buf = await zip.generateAsync({ type: 'arraybuffer' }); 164 + it('extracts rotation from xfrm rot attribute (60000ths of degree)', async () => { 165 + // rot="2700000" = 45 degrees 166 + const buf = await buildPptx([{ xml: slideXml(rotSp('Rotated', 2700000)) }]); 167 + const deck = await convertPptxToDeck(buf); 168 + const el = deck.slides[0]!.elements.find(e => e.content === 'Rotated'); 169 + expect(el).toBeDefined(); 170 + expect(el!.rotation).toBeCloseTo(45, 1); 171 + }); 172 + 173 + it('returns zero rotation for shapes without rot attribute', async () => { 174 + const buf = await buildPptx([{ xml: slideXml(spXml('T', 'B')) }]); 156 175 const deck = await convertPptxToDeck(buf); 157 - expect(deck.slides).toHaveLength(1); 158 - expect(deck.slides[0]!.elements).toHaveLength(0); 176 + expect(deck.slides[0]!.elements[0]!.rotation).toBe(0); 177 + }); 178 + 179 + it('extracts embedded images as image elements with data URL', async () => { 180 + const buf = await buildPptx([{ 181 + xml: slideXml(picXml('rId1')), 182 + rels: [{ id: 'rId1', target: 'image1.png' }], 183 + media: [{ path: 'image1.png', data: WHITE_PNG }], 184 + }]); 185 + const deck = await convertPptxToDeck(buf); 186 + const imgEl = deck.slides[0]!.elements.find(e => e.type === 'image'); 187 + expect(imgEl).toBeDefined(); 188 + expect(imgEl!.content).toMatch(/^data:image\/png;base64,/); 189 + }); 190 + 191 + it('skips images with missing relationships', async () => { 192 + const buf = await buildPptx([{ 193 + xml: slideXml(picXml('rIdMissing')), 194 + rels: [], // no rels provided 195 + media: [], 196 + }]); 197 + const deck = await convertPptxToDeck(buf); 198 + // No image element (missing rId) 199 + const imgEls = deck.slides[0]!.elements.filter(e => e.type === 'image'); 200 + expect(imgEls).toHaveLength(0); 159 201 }); 160 202 161 203 it('skips shapes with no text content', async () => { 162 - const xml = `<?xml version="1.0" encoding="UTF-8"?> 163 - <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 164 - xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 165 - <p:cSld> 166 - <p:spTree> 167 - <p:sp> 204 + const emptyXml = slideXml(` 205 + <p:sp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 206 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 168 207 <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 169 208 <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr> 170 209 <p:txBody><a:p><a:r><a:t> </a:t></a:r></a:p></p:txBody> 171 - </p:sp> 172 - </p:spTree> 173 - </p:cSld> 174 - </p:sld>`; 175 - const buf = await buildMinimalPptx([xml]); 210 + </p:sp>`); 211 + const buf = await buildPptx([{ xml: emptyXml }]); 176 212 const deck = await convertPptxToDeck(buf); 177 - // Whitespace-only shape should be skipped 178 213 expect(deck.slides[0]!.elements).toHaveLength(0); 179 214 }); 180 215 181 - it('applies title font weight bold', async () => { 182 - const buf = await buildMinimalPptx([makeSlideXml('Bold Title', 'content')]); 216 + it('returns one blank slide for empty ZIP', async () => { 217 + const zip = new JSZip(); 218 + zip.file('placeholder.txt', 'empty'); 219 + const buf = await zip.generateAsync({ type: 'arraybuffer' }); 183 220 const deck = await convertPptxToDeck(buf); 184 - const titleEl = deck.slides[0]!.elements.find(e => e.content === 'Bold Title'); 185 - expect(titleEl?.style['fontWeight']).toBe('bold'); 221 + expect(deck.slides).toHaveLength(1); 222 + expect(deck.slides[0]!.elements).toHaveLength(0); 186 223 }); 187 224 188 - it('extracts background color from solidFill', async () => { 189 - const xml = `<?xml version="1.0" encoding="UTF-8"?> 190 - <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 191 - xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 192 - <p:cSld> 193 - <p:bg> 194 - <p:bgPr> 195 - <a:solidFill><a:srgbClr val="1a2b3c"/></a:solidFill> 196 - </p:bgPr> 197 - </p:bg> 198 - <p:spTree> 199 - <p:sp> 200 - <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 201 - <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr> 202 - <p:txBody><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:txBody> 203 - </p:sp> 204 - </p:spTree> 205 - </p:cSld> 206 - </p:sld>`; 207 - const buf = await buildMinimalPptx([xml]); 225 + it('extracts notes from notesSlide XML', async () => { 226 + const zip = new JSZip(); 227 + zip.file('ppt/slides/slide1.xml', slideXml(spXml('T', 'B'))); 228 + zip.file('ppt/notesSlides/notesSlide1.xml', `<?xml version="1.0"?> 229 + <p:notes xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 230 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 231 + <p:cSld><p:spTree> 232 + <p:sp><p:nvSpPr><p:ph type="body"/></p:nvSpPr> 233 + <p:txBody><a:p><a:r><a:t>These are my speaker notes</a:t></a:r></a:p></p:txBody> 234 + </p:sp> 235 + </p:spTree></p:cSld> 236 + </p:notes>`); 237 + const buf = await zip.generateAsync({ type: 'arraybuffer' }); 208 238 const deck = await convertPptxToDeck(buf); 209 - expect(deck.slides[0]!.background).toBe('#1a2b3c'); 239 + expect(deck.slides[0]!.notes).toContain('speaker notes'); 210 240 }); 211 241 212 - it('defaults to white background when none specified', async () => { 213 - const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]); 242 + it('parses table (graphicFrame) as text element', async () => { 243 + const gfXml = ` 244 + <p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 245 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 246 + <p:xfrm><a:off x="500000" y="500000"/><a:ext cx="4000000" cy="2000000"/></p:xfrm> 247 + <a:graphic><a:graphicData> 248 + <a:tbl> 249 + <a:tr><a:tc><a:txBody><a:p><a:r><a:t>Name</a:t></a:r></a:p></a:txBody></a:tc> 250 + <a:tc><a:txBody><a:p><a:r><a:t>Value</a:t></a:r></a:p></a:txBody></a:tc></a:tr> 251 + <a:tr><a:tc><a:txBody><a:p><a:r><a:t>Alpha</a:t></a:r></a:p></a:txBody></a:tc> 252 + <a:tc><a:txBody><a:p><a:r><a:t>42</a:t></a:r></a:p></a:txBody></a:tc></a:tr> 253 + </a:tbl> 254 + </a:graphicData></a:graphic> 255 + </p:graphicFrame>`; 256 + const buf = await buildPptx([{ xml: slideXml(gfXml) }]); 214 257 const deck = await convertPptxToDeck(buf); 215 - expect(deck.slides[0]!.background).toBe('#ffffff'); 258 + const el = deck.slides[0]!.elements.find(e => e.type === 'text' && e.content.includes('Name')); 259 + expect(el).toBeDefined(); 260 + expect(el!.content).toContain('Value'); 261 + expect(el!.content).toContain('Alpha'); 262 + expect(el!.content).toContain('---'); // header separator 263 + }); 264 + 265 + it('flattens group shapes into elements', async () => { 266 + const grpXml = ` 267 + <p:grpSp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 268 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 269 + <p:grpSpPr/> 270 + <p:sp> 271 + <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 272 + <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="4000000" cy="500000"/></a:xfrm></p:spPr> 273 + <p:txBody><a:p><a:r><a:t>Grouped Text A</a:t></a:r></a:p></p:txBody> 274 + </p:sp> 275 + <p:sp> 276 + <p:nvSpPr></p:nvSpPr> 277 + <p:spPr><a:xfrm><a:off x="0" y="600000"/><a:ext cx="4000000" cy="500000"/></a:xfrm></p:spPr> 278 + <p:txBody><a:p><a:r><a:t>Grouped Text B</a:t></a:r></a:p></p:txBody> 279 + </p:sp> 280 + </p:grpSp>`; 281 + const buf = await buildPptx([{ xml: slideXml(grpXml) }]); 282 + const deck = await convertPptxToDeck(buf); 283 + const texts = deck.slides[0]!.elements.map(e => e.content); 284 + expect(texts.some(t => t.includes('Grouped Text A'))).toBe(true); 285 + expect(texts.some(t => t.includes('Grouped Text B'))).toBe(true); 216 286 }); 217 287 });