mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
PPTX import Phase 2: text shapes + speaker notes
Two paths land together because they share paragraph/run parsing. Text shapes (p:sp with p:txBody): - _readTxBody walks a:p paragraphs and a:r / a:fld runs, joining paragraph text with \n internally then escaping + converting to <br>-joined HTML on the way out. - _readRunStyle pulls the first run's a:rPr → fontSize (sz is hundredths-of-pt → pt), bold (b), italic (i), color (a:solidFill/a:srgbClr), and explicit font family (a:latin@typeface, but skip theme placeholders like '+mj-lt'). - a:pPr@algn → Artstr alignment via PPTX_ALIGN_TO_ARTSTR map (l/ctr/r/just). First paragraph's alignment wins for the whole box. - convertTextShape emits an Artstr text layer with the dominant style applied to the whole box; xfrm provides bounds. - Shapes without explicit xfrm (text inheriting placeholder geometry from a layout / master) warn TEXT_STYLE_APPROXIMATED and skip — proper placeholder resolution is Phase 6. - walkSpTree dispatches p:sp to convertTextShape if a p:txBody child is present, otherwise to convertPresetShape. Bumps report.imported.text or .shapes accordingly. Speaker notes: - readSlideNotes(files, slidePath, slideIndex, report) reads the slide's _rels/<name>.xml.rels, finds the .../notesSlide relationship, resolves the path, and walks the notesSlide's spTree concatenating every p:sp/p:txBody's plain text. - Filters out the slide-number stamp (PowerPoint embeds the current slide number as a "1" / "2" text on the notes-slide image placeholder; matching ^\d+$ drops it). - Result goes into slide.notes, which Presenter Mode already renders. report.imported.notes counts non-empty slides. Smoke-tested against gallery_template_layout_set.pptx (16 slides): - 156 text layers imported, zero warnings. - Slide 1 title 'Gallery\\nDeck System' lands as 'Gallery<br>Deck System' at 49pt Aptos Display bold #17202a. - Subtitle EDITORIAL TEMPLATE KIT lands at 8.5pt Aptos bold #b05a44 — exact source color. - 0 notes (this template-only deck doesn't ship with notes). Synthetic deck still produces an empty deck shell with no regressions.
This commit is contained in:
1 parent
feace5e78c
commit
49d6697660
1 file changed
+264
-10
+264
-10
@@ -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, '"')
|
||||
.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 <br> (and inline-newline runs
|
||||
// contribute their own <br> via the \n we inserted in
|
||||
// _readParagraphRuns).
|
||||
const html = paragraphs
|
||||
.map((p) => _escapeHtml(p).replace(/\n/g, '<br>'))
|
||||
.join('<br>');
|
||||
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,
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user