mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 13:40:39 +00:00
PPTX import Phase 7: per-run rich text spans
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
<b> / <i> / <span style="..."> 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.
- <i> / <span style="font-style:normal"> for italic deltas.
- <b> / <span style="font-weight:normal"> 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 <br>.
- 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 → <br>, 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.
This commit is contained in:
1 parent
24338c4f12
commit
b6cf4f38eb
1 file changed
+115
-39
+115
-39
@@ -606,28 +606,87 @@ window.ArtstrPptxImporter = (function () {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk a:r (run) and a:br (line break) children of a paragraph,
|
// Walk a:r (run), a:fld (field), and a:br (line break) children of a
|
||||||
// returning { text, firstRunStyle }. PPTX paragraphs can also contain
|
// paragraph and return an ordered list of { text, style } entries.
|
||||||
// a:fld (field) runs which we treat like a:r for text extraction.
|
// a:br becomes a run with text '\n' and a null style so the HTML
|
||||||
|
// builder can emit a <br> 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) {
|
function _readParagraphRuns(pNode, theme) {
|
||||||
let text = '';
|
const runs = [];
|
||||||
let firstRunStyle = null;
|
|
||||||
for (let i = 0; i < pNode.children.length; i++) {
|
for (let i = 0; i < pNode.children.length; i++) {
|
||||||
const child = pNode.children[i];
|
const child = pNode.children[i];
|
||||||
const ln = child.localName;
|
const ln = child.localName;
|
||||||
if (ln === 'r' || ln === 'fld') {
|
if (ln === 'r' || ln === 'fld') {
|
||||||
const t = child.getElementsByTagName('a:t')[0]
|
const t = child.getElementsByTagName('a:t')[0]
|
||||||
|| child.getElementsByTagNameNS('*', 't')[0];
|
|| child.getElementsByTagNameNS('*', 't')[0];
|
||||||
if (t) text += t.textContent || '';
|
const text = t ? (t.textContent || '') : '';
|
||||||
if (!firstRunStyle) {
|
if (!text) continue;
|
||||||
const rPr = _directChild(child, 'rPr');
|
const rPr = _directChild(child, 'rPr');
|
||||||
if (rPr) firstRunStyle = _readRunStyle(rPr, theme);
|
const style = rPr ? _readRunStyle(rPr, theme) : null;
|
||||||
}
|
runs.push({ text, style });
|
||||||
} else if (ln === 'br') {
|
} 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 <b>/<i>/<span> 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, '<br>');
|
||||||
|
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: <span style> goes innermost, then <i>, then <b>
|
||||||
|
// outermost. b/i are categorical state; the inline style is the
|
||||||
|
// fine-grained override.
|
||||||
|
if (styleParts.length) {
|
||||||
|
body = `<span style="${styleParts.join(';')}">${body}</span>`;
|
||||||
|
}
|
||||||
|
// Italic delta. The reverse case (dominant italic, run not) gets
|
||||||
|
// an explicit font-style:normal span.
|
||||||
|
if (s.italic && !d.italic) body = `<i>${body}</i>`;
|
||||||
|
else if (!s.italic && d.italic && run.style) {
|
||||||
|
body = `<span style="font-style:normal">${body}</span>`;
|
||||||
|
}
|
||||||
|
// Bold delta — same shape as italic.
|
||||||
|
if (s.bold && !d.bold) body = `<b>${body}</b>`;
|
||||||
|
else if (!s.bold && d.bold && run.style) {
|
||||||
|
body = `<span style="font-weight:normal">${body}</span>`;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the inline style off an a:rPr (run properties) node:
|
// Pull the inline style off an a:rPr (run properties) node:
|
||||||
@@ -661,43 +720,60 @@ window.ArtstrPptxImporter = (function () {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read p:txBody → Artstr text layer's html + dominant style.
|
// Read p:txBody → Artstr text layer's html + dominant style. The
|
||||||
// Phase 2 uses the first run's style for the whole text box; per-run
|
// first run with an explicit a:rPr becomes the layer-level dominant;
|
||||||
// rich text is a Phase 7 enhancement.
|
// subsequent runs that disagree get wrapped in inline <b>/<i>/<span>
|
||||||
|
// so mid-paragraph styling survives the round-trip.
|
||||||
function _readTxBody(txBodyNode, theme) {
|
function _readTxBody(txBodyNode, theme) {
|
||||||
if (!txBodyNode) return null;
|
if (!txBodyNode) return null;
|
||||||
|
// Walk every paragraph up front so we can pick the dominant style
|
||||||
|
// before emitting any HTML.
|
||||||
const paragraphs = [];
|
const paragraphs = [];
|
||||||
let dominantStyle = null;
|
|
||||||
let alignFromFirstP = null;
|
let alignFromFirstP = null;
|
||||||
for (let i = 0; i < txBodyNode.children.length; i++) {
|
for (let i = 0; i < txBodyNode.children.length; i++) {
|
||||||
const p = txBodyNode.children[i];
|
const p = txBodyNode.children[i];
|
||||||
if (p.localName !== 'p') continue;
|
if (p.localName !== 'p') continue;
|
||||||
const { text, firstRunStyle } = _readParagraphRuns(p, theme);
|
const runs = _readParagraphRuns(p, theme);
|
||||||
if (!dominantStyle && firstRunStyle) dominantStyle = firstRunStyle;
|
const pPr = _directChild(p, 'pPr');
|
||||||
// alignment lives on the paragraph (a:pPr@algn); take the first
|
const algn = pPr?.getAttribute('algn');
|
||||||
// paragraph's alignment as the layer's alignment.
|
const align = (algn && PPTX_ALIGN_TO_ARTSTR[algn]) ? PPTX_ALIGN_TO_ARTSTR[algn] : null;
|
||||||
if (alignFromFirstP === null) {
|
if (alignFromFirstP === null && align) alignFromFirstP = align;
|
||||||
const pPr = _directChild(p, 'pPr');
|
paragraphs.push({ runs });
|
||||||
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;
|
// Dominant style = first run anywhere in the body with an explicit
|
||||||
// Escape per-paragraph, then join with <br> (and inline-newline runs
|
// rPr. Falls back to {} if every run inherits defaults.
|
||||||
// contribute their own <br> via the \n we inserted in
|
let dominantStyle = null;
|
||||||
// _readParagraphRuns).
|
for (const p of paragraphs) {
|
||||||
const html = paragraphs
|
for (const r of p.runs) {
|
||||||
.map((p) => _escapeHtml(p).replace(/\n/g, '<br>'))
|
if (r.style) { dominantStyle = r.style; break; }
|
||||||
.join('<br>');
|
}
|
||||||
|
if (dominantStyle) break;
|
||||||
|
}
|
||||||
|
dominantStyle = dominantStyle || {};
|
||||||
|
|
||||||
|
// Emit HTML: each paragraph's runs joined contiguously, paragraphs
|
||||||
|
// joined with <br>. 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('<br>');
|
||||||
return {
|
return {
|
||||||
html,
|
html,
|
||||||
align: alignFromFirstP || 'left',
|
align: alignFromFirstP || 'left',
|
||||||
dominantStyle: dominantStyle || {},
|
dominantStyle,
|
||||||
previewText: allText.slice(0, 60),
|
previewText: plainText.slice(0, 60),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,7 +1166,7 @@ window.ArtstrPptxImporter = (function () {
|
|||||||
for (let j = 0; j < txBody.children.length; j++) {
|
for (let j = 0; j < txBody.children.length; j++) {
|
||||||
const p = txBody.children[j];
|
const p = txBody.children[j];
|
||||||
if (p.localName !== 'p') continue;
|
if (p.localName !== 'p') continue;
|
||||||
const { text } = _readParagraphRuns(p);
|
const text = _runsToPlainText(_readParagraphRuns(p));
|
||||||
if (text) paragraphs.push(text);
|
if (text) paragraphs.push(text);
|
||||||
}
|
}
|
||||||
const joined = paragraphs.join('\n').trim();
|
const joined = paragraphs.join('\n').trim();
|
||||||
|
|||||||
Reference in new issue
Block a user