diff --git a/src/pptx-importer.js b/src/pptx-importer.js index 97270e8..e7fd8b6 100644 --- a/src/pptx-importer.js +++ b/src/pptx-importer.js @@ -335,6 +335,121 @@ window.ArtstrPptxImporter = (function () { return '#' + hex; } + // ---- Text helpers ---------------------------------------------------- + // PPTX alignment vocabulary -> Artstr alignment. Anything else maps to + // 'left'. + const PPTX_ALIGN_TO_ARTSTR = { + l: 'left', + ctr: 'center', + r: 'right', + just: 'justify', + dist: 'justify', + }; + + function _escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Walk a:r (run) and a:br (line break) children of a paragraph, + // returning { text, firstRunStyle }. PPTX paragraphs can also contain + // a:fld (field) runs which we treat like a:r for text extraction. + function _readParagraphRuns(pNode) { + let text = ''; + let firstRunStyle = null; + for (let i = 0; i < pNode.children.length; i++) { + const child = pNode.children[i]; + const ln = child.localName; + if (ln === 'r' || ln === 'fld') { + const t = child.getElementsByTagName('a:t')[0] + || child.getElementsByTagNameNS('*', 't')[0]; + if (t) text += t.textContent || ''; + if (!firstRunStyle) { + const rPr = _directChild(child, 'rPr'); + if (rPr) firstRunStyle = _readRunStyle(rPr); + } + } else if (ln === 'br') { + text += '\n'; + } + } + return { text, firstRunStyle }; + } + + // Pull the inline style off an a:rPr (run properties) node: + // size (sz, hundredths of pt), bold (b), italic (i), color, and + // font family (latin@typeface). Theme-relative colours / fonts are + // resolved later in Phase 6 — for now we just take what's explicit. + function _readRunStyle(rPr) { + if (!rPr) return null; + const out = {}; + const sz = rPr.getAttribute('sz'); + if (sz) { + const pt = Number(sz) / 100; + if (Number.isFinite(pt) && pt > 0) out.fontSize = pt; + } + const b = rPr.getAttribute('b'); + if (b === '1' || b === 'true') out.bold = true; + const i = rPr.getAttribute('i'); + if (i === '1' || i === 'true') out.italic = true; + const fill = _directChild(rPr, 'solidFill'); + if (fill) { + const c = convertColor(fill); + if (c) out.color = c; + } + const latin = _directChild(rPr, 'latin'); + if (latin) { + const tf = latin.getAttribute('typeface') || ''; + // Theme placeholders like '+mj-lt' / '+mn-lt' are theme references + // — leave fontFamily empty, the editor's default will apply. + if (tf && tf[0] !== '+') out.fontFamily = tf; + } + return out; + } + + // Read p:txBody → Artstr text layer's html + dominant style. + // Phase 2 uses the first run's style for the whole text box; per-run + // rich text is a Phase 7 enhancement. + function _readTxBody(txBodyNode) { + if (!txBodyNode) return null; + const paragraphs = []; + let dominantStyle = null; + let alignFromFirstP = null; + for (let i = 0; i < txBodyNode.children.length; i++) { + const p = txBodyNode.children[i]; + if (p.localName !== 'p') continue; + const { text, firstRunStyle } = _readParagraphRuns(p); + if (!dominantStyle && firstRunStyle) dominantStyle = firstRunStyle; + // alignment lives on the paragraph (a:pPr@algn); take the first + // paragraph's alignment as the layer's alignment. + if (alignFromFirstP === null) { + const pPr = _directChild(p, 'pPr'); + const algn = pPr?.getAttribute('algn'); + if (algn && PPTX_ALIGN_TO_ARTSTR[algn]) { + alignFromFirstP = PPTX_ALIGN_TO_ARTSTR[algn]; + } + } + paragraphs.push(text); + } + const allText = paragraphs.join('\n').trim(); + if (!allText) return null; + // Escape per-paragraph, then join with
(and inline-newline runs + // contribute their own
via the \n we inserted in + // _readParagraphRuns). + const html = paragraphs + .map((p) => _escapeHtml(p).replace(/\n/g, '
')) + .join('
'); + return { + html, + align: alignFromFirstP || 'left', + dominantStyle: dominantStyle || {}, + previewText: allText.slice(0, 60), + }; + } + // ---- Shape conversion ----------------------------------------------- // Per-import counter so two consecutive imports don't generate // colliding layer ids. Reset at the start of importPptxFile. @@ -344,13 +459,64 @@ window.ArtstrPptxImporter = (function () { return 'pptx-' + Date.now().toString(36) + '-' + _layerIdCounter; } + // Returns an Artstr text layer for a p:sp that carries a p:txBody. + // Phase 2 emits one text layer per shape; the dominant run's style + // (font size, color, bold, italic, family) is applied to the whole + // box. Per-run rich text spans are a Phase 7 enhancement. + function convertTextShape(spNode, ctx) { + const txBody = _directChild(spNode, 'txBody'); + if (!txBody) return null; + + const spPr = _directChild(spNode, 'spPr'); + const xfrm = spPr ? _directChild(spPr, 'xfrm') : null; + const bounds = xfrm ? readXfrm(xfrm, ctx.scale) : null; + // A text shape without explicit xfrm usually inherits from a + // placeholder on the layout/master. We don't resolve placeholders + // until Phase 6 — flag and skip so we don't drop a 0x0 layer at + // (0, 0). + if (!bounds || bounds.w <= 0 || bounds.h <= 0) { + _warn(ctx.report, ctx.slideIndex, 'TEXT_STYLE_APPROXIMATED', + `Slide ${ctx.slideIndex + 1}: text shape inherits geometry from its layout — skipped in Phase 2.`); + return null; + } + + const body = _readTxBody(txBody); + if (!body) return null; // empty placeholder text + + const style = body.dominantStyle; + const cNvPr = spNode.getElementsByTagName('p:cNvPr')[0] + || spNode.getElementsByTagNameNS('*', 'cNvPr')[0]; + const sourceName = cNvPr?.getAttribute('name') || ''; + const label = body.previewText || 'Text'; + const name = sourceName ? `Text (${sourceName}): ${label}` : `Text: ${label}`; + + return { + id: _makePptxLayerId(), + type: 'text', + name, + target: 'canvas', + html: body.html, + x: bounds.x, + y: bounds.y, + w: bounds.w, + h: bounds.h, + rotate: bounds.rotate || 0, + opacity: 1, + z: ctx.nextZ++, + fontFamily: style.fontFamily || 'inherit', + fontSize: style.fontSize || 18, + color: style.color || '#111827', + align: body.align, + bold: !!style.bold, + italic: !!style.italic, + }; + } + // Returns an Artstr shape layer for a p:sp with a:prstGeom, or null - // if the shape should be skipped (text-bodied — Phase 2 handles it). - // Unknown preset names fall back to a rectangle with a warning. + // if the shape should be skipped (text-bodied — handled by + // convertTextShape). Unknown preset names fall back to a rectangle + // with a warning. function convertPresetShape(spNode, ctx) { - // Phase 4 doesn't try to convert text shapes — let Phase 2 turn - // those into text layers. A shape with both prstGeom and txBody is - // a text shape (e.g. a title placeholder), so skip here. const txBody = _directChild(spNode, 'txBody'); if (txBody) return null; @@ -411,10 +577,22 @@ window.ArtstrPptxImporter = (function () { for (let i = 0; i < spTreeNode.children.length; i++) { const child = spTreeNode.children[i]; if (child.localName === 'sp') { - const layer = convertPresetShape(child, ctx); - if (layer) { - layersOut.push(layer); - ctx.report.imported.shapes += 1; + // A p:sp with p:txBody is a text shape; without it, it's a + // pure shape. Dispatch to the right converter and bump the + // matching counter. + const hasText = !!_directChild(child, 'txBody'); + if (hasText) { + const layer = convertTextShape(child, ctx); + if (layer) { + layersOut.push(layer); + ctx.report.imported.text += 1; + } + } else { + const layer = convertPresetShape(child, ctx); + if (layer) { + layersOut.push(layer); + ctx.report.imported.shapes += 1; + } } } // Future phases: @@ -424,6 +602,68 @@ window.ArtstrPptxImporter = (function () { } } + // Read the speaker notes for a given slide. The notes-slide + // relationship is in ppt/slides/_rels/slideN.xml.rels; we follow it, + // walk every text shape on the notes slide, and concatenate the + // plain text into a single string for slide.notes. + function readSlideNotes(files, slidePath, slideIndex, report) { + if (!slidePath) return ''; + // Construct the rels path: ppt/slides/slide1.xml → ppt/slides/_rels/slide1.xml.rels + const m = slidePath.match(/^(.*\/)([^/]+)$/); + if (!m) return ''; + const relsPath = `${m[1]}_rels/${m[2]}.rels`; + const relsText = readZipText(files, relsPath); + if (!relsText) return ''; + let relsDoc; + try { relsDoc = parseXml(relsText); } + catch { return ''; } + const rels = relsDoc.getElementsByTagName('Relationship'); + let notesTarget = ''; + for (let i = 0; i < rels.length; i++) { + const type = rels[i].getAttribute('Type') || ''; + if (type.endsWith('/notesSlide')) { + notesTarget = rels[i].getAttribute('Target') || ''; + break; + } + } + if (!notesTarget) return ''; + const notesPath = pptxTargetToZipPath(slidePath, notesTarget); + const notesText = readZipText(files, notesPath); + if (!notesText) return ''; + let notesDoc; + try { notesDoc = parseXml(notesText); } + catch (err) { + _warn(report, slideIndex, 'NOTES_PARSE_FAILED', + `Slide ${slideIndex + 1}: notes XML could not be parsed.`); + return ''; + } + const spTree = notesDoc.getElementsByTagName('p:spTree')[0] + || notesDoc.getElementsByTagNameNS('*', 'spTree')[0]; + if (!spTree) return ''; + const chunks = []; + for (let i = 0; i < spTree.children.length; i++) { + const sp = spTree.children[i]; + if (sp.localName !== 'sp') continue; + const txBody = _directChild(sp, 'txBody'); + if (!txBody) continue; + // Walk paragraphs and join with line breaks. + const paragraphs = []; + for (let j = 0; j < txBody.children.length; j++) { + const p = txBody.children[j]; + if (p.localName !== 'p') continue; + const { text } = _readParagraphRuns(p); + if (text) paragraphs.push(text); + } + const joined = paragraphs.join('\n').trim(); + // The notes slide also has a "slide image" placeholder shape that + // PowerPoint stamps with the slide number ("1", "2", …). Skip + // single-token numeric chunks so the imported notes don't get a + // stray slide number prepended. + if (joined && !/^\d+$/.test(joined)) chunks.push(joined); + } + return chunks.join('\n\n'); + } + function readPresentationInfo(files, report) { const presText = readZipText(files, 'ppt/presentation.xml'); if (!presText) { @@ -500,13 +740,25 @@ window.ArtstrPptxImporter = (function () { } } + // Speaker notes: follow the per-slide notesSlide relationship and + // extract the plain text. Empty string when the deck doesn't have + // notes for this slide (most decks won't). + let notes = ''; + try { + notes = readSlideNotes(files, slidePath, i, report); + if (notes) report.imported.notes += 1; + } catch (err) { + _warn(report, i, 'NOTES_PARSE_FAILED', + `Slide ${i + 1}: speaker notes could not be read.`); + } + slides.push({ name: 'Slide ' + (i + 1), slide: { width: TARGET_W, height: TARGET_H, background, - notes: '', + notes, }, layers, ignoreDeckTheme: true, @@ -554,7 +806,9 @@ window.ArtstrPptxImporter = (function () { readStroke, readXfrm, convertPresetShape, + convertTextShape, walkSpTree, + readSlideNotes, }, }; })();