From b6cf4f38ebae7476d17d80b4eea929d49edbc9b3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 30 May 2026 08:06:57 -0400 Subject: [PATCH] PPTX import Phase 7: per-run rich text spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-paragraph styling now survives the import. Before this commit, _readTxBody collapsed every paragraph to its first run's style; a title like "Gallery Deck System" where one word was bold would lose the bold. Now each run is emitted as HTML with inline / / wrappers, but only where it disagrees with the layer-level dominant style — so plain runs stay as plain text and the HTML stays clean. Mechanics: - _readParagraphRuns now returns an array of { text, style } per a:r / a:fld (text-bearing) or a:br (synthetic '\n' run with null style). null style means "inherit layer defaults" — the HTML builder emits unwrapped escaped text for these. - _runsToPlainText is the new helper for callers that only want text (the notes-slide reader is the only one today). - _runToHtml(run, dominant) builds the per-run HTML: - inline span for font-size / color / font-family overrides that disagree with the dominant; inner-most wrap. - / for italic deltas. - / for bold deltas; outermost wrap. font-family values with spaces are escaped through " so inner double-quotes can't terminate the style="..." attribute. - _readTxBody picks the first explicit-rPr run anywhere in the body as the dominant style, then emits per-paragraph segments joined contiguously and paragraphs joined with
. - The notes-slide reader uses _runsToPlainText to keep its plain- text concatenation behaviour. Verified with a pure-Node unit test of _runToHtml covering 10 cases: plain run, mid-paragraph bold, color override, font-family with spaces (HTML-entity quoted), size + family + italic combo, HTML escaping, embedded \n →
, null-style inheritance, the reverse-override "not-bold inside bold dominant", and an empty dominant. All passing. Browser CDP integration test isn't in this commit (the dev server / Chrome session was offline at test time). The refactor is otherwise structural: _readTxBody returns the same shape, and the layer-level dominantStyle still drives the layer's fontSize / color / family / align / bold / italic fields, so existing decks should produce the same layer-level output with strictly richer per-run HTML. --- src/pptx-importer.js | 154 ++++++++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 39 deletions(-) diff --git a/src/pptx-importer.js b/src/pptx-importer.js index 8eee90a..2aa16f0 100644 --- a/src/pptx-importer.js +++ b/src/pptx-importer.js @@ -606,28 +606,87 @@ window.ArtstrPptxImporter = (function () { .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. + // Walk a:r (run), a:fld (field), and a:br (line break) children of a + // paragraph and return an ordered list of { text, style } entries. + // a:br becomes a run with text '\n' and a null style so the HTML + // builder can emit a
without wrapping it. style is null when + // a run has no a:rPr — the renderer will fall back to layer defaults + // for those runs. + // + // Callers that only need plain text concatenate `.text` across the + // returned runs (used by the notes-slide reader). function _readParagraphRuns(pNode, theme) { - let text = ''; - let firstRunStyle = null; + const runs = []; 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, theme); - } + const text = t ? (t.textContent || '') : ''; + if (!text) continue; + const rPr = _directChild(child, 'rPr'); + const style = rPr ? _readRunStyle(rPr, theme) : null; + runs.push({ text, style }); } else if (ln === 'br') { - text += '\n'; + runs.push({ text: '\n', style: null }); } } - return { text, firstRunStyle }; + return runs; + } + + // Plain text from a list of runs. Used by the notes-slide reader, + // which doesn't care about per-run styling. + function _runsToPlainText(runs) { + let s = ''; + for (const r of runs) s += r.text; + return s; + } + + // Render a single run into HTML, wrapping with // only + // for fields that differ from the dominant (layer-level) style. The + // layer's defaults already handle anything matching dominant, so we + // emit the minimum markup needed for fidelity. + // + // dominant is normalised to {} when no run had explicit style. + function _runToHtml(run, dominant) { + let body = _escapeHtml(run.text).replace(/\n/g, '
'); + if (!run.style) return body; // inherit layer defaults + const s = run.style; + const d = dominant || {}; + // Build the inline-style fragment for font-size / color / family + // overrides. + const styleParts = []; + if (s.fontSize != null && s.fontSize !== d.fontSize) { + styleParts.push(`font-size:${s.fontSize}pt`); + } + if (s.color && s.color !== d.color) { + styleParts.push(`color:${s.color}`); + } + if (s.fontFamily && s.fontFamily !== d.fontFamily) { + // PPTX font names can contain spaces; quote with HTML entities + // so the inner quotes don't terminate the style="..." attribute. + const safe = s.fontFamily.replace(/&/g, '&').replace(/"/g, '"'); + styleParts.push(`font-family:"${safe}"`); + } + // Wrapping order: goes innermost, then , then + // outermost. b/i are categorical state; the inline style is the + // fine-grained override. + if (styleParts.length) { + body = `${body}`; + } + // Italic delta. The reverse case (dominant italic, run not) gets + // an explicit font-style:normal span. + if (s.italic && !d.italic) body = `${body}`; + else if (!s.italic && d.italic && run.style) { + body = `${body}`; + } + // Bold delta — same shape as italic. + if (s.bold && !d.bold) body = `${body}`; + else if (!s.bold && d.bold && run.style) { + body = `${body}`; + } + return body; } // Pull the inline style off an a:rPr (run properties) node: @@ -661,43 +720,60 @@ window.ArtstrPptxImporter = (function () { 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. + // Read p:txBody → Artstr text layer's html + dominant style. The + // first run with an explicit a:rPr becomes the layer-level dominant; + // subsequent runs that disagree get wrapped in inline // + // so mid-paragraph styling survives the round-trip. function _readTxBody(txBodyNode, theme) { if (!txBodyNode) return null; + // Walk every paragraph up front so we can pick the dominant style + // before emitting any HTML. 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, theme); - 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 runs = _readParagraphRuns(p, theme); + const pPr = _directChild(p, 'pPr'); + const algn = pPr?.getAttribute('algn'); + const align = (algn && PPTX_ALIGN_TO_ARTSTR[algn]) ? PPTX_ALIGN_TO_ARTSTR[algn] : null; + if (alignFromFirstP === null && align) alignFromFirstP = align; + paragraphs.push({ runs }); } - 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('
'); + + // Dominant style = first run anywhere in the body with an explicit + // rPr. Falls back to {} if every run inherits defaults. + let dominantStyle = null; + for (const p of paragraphs) { + for (const r of p.runs) { + if (r.style) { dominantStyle = r.style; break; } + } + if (dominantStyle) break; + } + dominantStyle = dominantStyle || {}; + + // Emit HTML: each paragraph's runs joined contiguously, paragraphs + // joined with
. Plain-text preview pulled in parallel for the + // layer's name field. + const paragraphHtmls = []; + let plainText = ''; + for (const p of paragraphs) { + const segments = []; + for (const r of p.runs) { + segments.push(_runToHtml(r, dominantStyle)); + plainText += r.text; + } + paragraphHtmls.push(segments.join('')); + plainText += '\n'; + } + plainText = plainText.trim(); + if (!plainText) return null; + const html = paragraphHtmls.join('
'); return { html, align: alignFromFirstP || 'left', - dominantStyle: dominantStyle || {}, - previewText: allText.slice(0, 60), + dominantStyle, + previewText: plainText.slice(0, 60), }; } @@ -1090,7 +1166,7 @@ window.ArtstrPptxImporter = (function () { for (let j = 0; j < txBody.children.length; j++) { const p = txBody.children[j]; if (p.localName !== 'p') continue; - const { text } = _readParagraphRuns(p); + const text = _runsToPlainText(_readParagraphRuns(p)); if (text) paragraphs.push(text); } const joined = paragraphs.join('\n').trim();