mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
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:
1 parent
e8e96e2a3d
commit
2cc2b85883
1 file changed
+205
-11
+205
-11
@@ -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,
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user