PPTX import Phase 1: deck-shell parser

Replace the Phase 0 "not implemented" stub with a working deck-shell
import. A .pptx file in becomes a templateMode:'deck' payload out,
one empty Artstr slide per PowerPoint slide, with no layer extraction
yet (Phase 2+ adds text, notes, images, shapes).

What landed in src/pptx-importer.js:

- readPptxPackage(file): file -> ArrayBuffer -> fflate.unzipSync ->
  { [path]: Uint8Array }. Throws on missing fflate or invalid zip.
- parseXml(xmlText): DOMParser wrapper that detects <parsererror>
  and re-throws so silent malformed-doc cases don't slip through.
- pptxTargetToZipPath(basePath, target): resolves OOXML relationship
  Target values against the owning part's path. Handles relative,
  absolute-in-package, and ./.. segments.
- readSlideSize(presDoc): reads p:sldSz cx/cy (EMU). Falls back to
  16:9 widescreen default (12192000 x 6858000) when absent.
- readSlideRefsInOrder(presDoc, presRelsDoc): walks p:sldIdLst for
  canonical order, resolves each r:id through the rels file to the
  actual zip path of the slide XML. Handles both getAttributeNS for
  the relationships namespace and the plain "r:id" attribute form.
- readPresentationInfo(files, report): bundles size + slide refs.
  Adds a LARGE_DECK warning at >100 slides.
- importPptxFile(file, report): reads the package, parses
  presentation info, emits one empty deck slide per ref at 1920x1080
  with #ffffff bg + ignoreDeckTheme:true. Yields to RAF every 10
  slides so a 50+ slide deck doesn't lock the UI. Returns a
  templateMode:'deck' payload ready for loadProjectFromText.

Internals are exposed via window.ArtstrPptxImporter._internals for
future-phase tests and harnesses.

Smoke-tested via CDP against a synthetic .pptx built in-browser with
fflate.zipSync:
- 3-slide deck imports with correct count, dims, bg, ignoreDeckTheme.
- 5-slide deck round-trips through loadProjectFromText: editor
  comes up with templateMode='deck', 5 slides, body has template-deck
  class (Deck Builder live).
- Garbage bytes throw a clean "invalid zip data" error instead of
  silently producing a zero-slide deck.
This commit is contained in:
thePR0M3TH3AN committed 2026-05-29 14:32:56 -04:00
1 parent e8e96e2a3d
commit 2cc2b85883
1 file changed
+205 -11
+205 -11
View File
@@ -3,22 +3,34 @@
*
* Canonical spec: docs/PPTX_IMPORT_FEATURE.md
*
* This module is lazy-loaded on first click of the Import PPTX
* button. It depends on src/vendor/fflate.min.js being loaded
* first (window.fflate).
* Lazy-loaded on the first click of the Import PPTX button. Depends
* on src/vendor/fflate.min.js being loaded first (window.fflate).
*
* Phase 0: module shell + report data model only. importPptxFile
* throws "not implemented yet" so the lazy-load + UI plumbing can
* be verified end-to-end before Phase 1 lands the deck-shell
* parser.
* Phase 1 (current): deck-shell parse — file → unzip → presentation.xml
* → slide order → one empty Artstr deck slide per PowerPoint slide,
* normalized to a 1920x1080 canvas. No layer extraction yet. Slide
* count + slide order are the load-bearing things at this phase.
*/
window.ArtstrPptxImporter = (function () {
'use strict';
// ---- Constants --------------------------------------------------------
const PPTX_IMAGE_PLACEHOLDER_URL =
'https://i.postimg.cc/9fY3n60q/example-image-replace-me.png';
const RELATIONSHIPS_NS =
'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
// Default to 16:9 widescreen (12192000 x 6858000 EMU = 13.33in x 7.5in)
// when presentation.xml omits p:sldSz. Standard PowerPoint widescreen
// template default.
const DEFAULT_PPTX_SLIDE_SIZE = { cx: 12192000, cy: 6858000 };
const TARGET_W = 1920;
const TARGET_H = 1080;
// Anything above this prints a friendly warning rather than crashing —
// matches the spec's PPTX_MAX_SLIDES_WARN.
const SLIDE_COUNT_WARN_AT = 100;
// ---- Report -----------------------------------------------------------
function makePptxImportReport(fileName) {
return {
fileName: fileName || '',
@@ -42,15 +54,197 @@ window.ArtstrPptxImporter = (function () {
};
}
async function importPptxFile(_file, _report) {
throw new Error(
'PPTX import is not implemented yet — Phase 1 lands the deck-shell parser.'
);
function _warn(report, slideIndex, code, message) {
if (!report || !Array.isArray(report.warnings)) return;
report.warnings.push({ slideIndex, code, message });
}
// ---- XML helpers ------------------------------------------------------
function parseXml(xmlText) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'application/xml');
// DOMParser surfaces XML parse errors as a <parsererror> element
// rather than throwing. Detect and re-throw so callers see a real
// error instead of a quietly malformed document.
const err = doc.getElementsByTagName('parsererror')[0];
if (err) throw new Error('Could not parse PPTX XML: ' + (err.textContent || 'unknown').slice(0, 200));
return doc;
}
// ---- Zip helpers ------------------------------------------------------
async function readPptxPackage(file) {
if (!file) throw new Error('No file provided.');
if (!window.fflate?.unzipSync) {
throw new Error('fflate is not loaded — call ensurePptxImporterLoaded first.');
}
const buf = await file.arrayBuffer();
const u8 = new Uint8Array(buf);
// Synchronous unzip blocks the UI briefly for large decks but is
// simpler than fflate.unzip's callback-based async API. Typical
// 1–10 MB pptx files unzip in well under 200 ms on a modern laptop.
return window.fflate.unzipSync(u8);
}
function readZipText(files, path) {
const bytes = files[path];
if (!bytes) return null;
return new TextDecoder('utf-8').decode(bytes);
}
// Resolve an OOXML relationship Target against the part that owned the
// relationship file. Examples:
// pptxTargetToZipPath('ppt/presentation.xml', 'slides/slide1.xml')
// => 'ppt/slides/slide1.xml'
// pptxTargetToZipPath('ppt/slides/slide1.xml', '../media/image1.png')
// => 'ppt/media/image1.png'
// pptxTargetToZipPath(anything, '/ppt/media/image1.png')
// => 'ppt/media/image1.png' (absolute-in-package)
function pptxTargetToZipPath(basePath, target) {
if (!target) return '';
if (target.startsWith('/')) return target.replace(/^\/+/, '');
const baseDir = basePath.replace(/[^/]+$/, '');
const combined = baseDir + target;
const parts = [];
for (const seg of combined.split('/')) {
if (!seg || seg === '.') continue;
if (seg === '..') { parts.pop(); continue; }
parts.push(seg);
}
return parts.join('/');
}
// ---- Presentation parsing --------------------------------------------
function readSlideSize(presDoc) {
const sldSz = presDoc.getElementsByTagName('p:sldSz')[0]
|| presDoc.getElementsByTagNameNS('*', 'sldSz')[0];
if (!sldSz) return { ...DEFAULT_PPTX_SLIDE_SIZE };
const cx = Number(sldSz.getAttribute('cx')) || DEFAULT_PPTX_SLIDE_SIZE.cx;
const cy = Number(sldSz.getAttribute('cy')) || DEFAULT_PPTX_SLIDE_SIZE.cy;
return { cx, cy };
}
// Walk <p:sldIdLst> for the canonical slide order, then resolve each
// <p:sldId r:id="..."/> against ppt/_rels/presentation.xml.rels to get
// the actual zip path of the slide XML.
function readSlideRefsInOrder(presDoc, presRelsDoc) {
const sldIdLst = presDoc.getElementsByTagName('p:sldIdLst')[0]
|| presDoc.getElementsByTagNameNS('*', 'sldIdLst')[0];
if (!sldIdLst) return [];
const relIdToTarget = new Map();
const rels = presRelsDoc.getElementsByTagName('Relationship');
for (let i = 0; i < rels.length; i++) {
const r = rels[i];
const id = r.getAttribute('Id');
const tgt = r.getAttribute('Target');
if (id && tgt) relIdToTarget.set(id, tgt);
}
const refs = [];
for (let i = 0; i < sldIdLst.children.length; i++) {
const sldId = sldIdLst.children[i];
if (sldId.localName !== 'sldId') continue;
const rId = sldId.getAttributeNS(RELATIONSHIPS_NS, 'id')
|| sldId.getAttribute('r:id')
|| '';
if (!rId) continue;
const target = relIdToTarget.get(rId);
if (!target) continue;
refs.push({
rId,
path: pptxTargetToZipPath('ppt/presentation.xml', target),
});
}
return refs;
}
function readPresentationInfo(files, report) {
const presText = readZipText(files, 'ppt/presentation.xml');
if (!presText) {
throw new Error('Missing ppt/presentation.xml — file does not look like a valid .pptx package.');
}
const presDoc = parseXml(presText);
const presRelsText = readZipText(files, 'ppt/_rels/presentation.xml.rels');
if (!presRelsText) {
throw new Error('Missing ppt/_rels/presentation.xml.rels — relationship metadata missing.');
}
const presRelsDoc = parseXml(presRelsText);
const slideSize = readSlideSize(presDoc);
const slideRefs = readSlideRefsInOrder(presDoc, presRelsDoc);
if (slideRefs.length > SLIDE_COUNT_WARN_AT) {
_warn(report, -1, 'LARGE_DECK',
`Large deck: ${slideRefs.length} slides. Import may take a moment.`);
}
return { slideSize, slideRefs };
}
// ---- Phase 1: deck shell ---------------------------------------------
async function importPptxFile(file, report) {
if (!file) throw new Error('No file provided.');
if (!report) report = makePptxImportReport(file.name);
const files = await readPptxPackage(file);
const presentation = readPresentationInfo(files, report);
const slides = [];
for (let i = 0; i < presentation.slideRefs.length; i++) {
// Yield to the browser every 10 slides so a 50+ slide deck doesn't
// freeze the UI thread during import.
if (i && i % 10 === 0) {
await new Promise((r) => requestAnimationFrame(r));
}
slides.push({
name: 'Slide ' + (i + 1),
slide: {
width: TARGET_W,
height: TARGET_H,
background: '#ffffff',
notes: '',
},
layers: [],
ignoreDeckTheme: true,
});
}
report.slideCount = slides.length;
return {
version: 5,
templateMode: 'deck',
templateType: 'deck',
meta: {
title: file.name.replace(/\.pptx$/i, ''),
imdbId: '',
upc: '',
tvdbId: '',
musicbrainzDiscId: '',
customTag: '',
category: 'presentation',
language: 'en',
},
deck: {
theme: { fontFamily: '', background: '' },
slides,
},
};
}
return {
PPTX_IMAGE_PLACEHOLDER_URL,
makePptxImportReport,
importPptxFile,
// Exposed for unit tests / future phases.
_internals: {
parseXml,
readPptxPackage,
readZipText,
pptxTargetToZipPath,
readSlideSize,
readSlideRefsInOrder,
readPresentationInfo,
},
};
})();