mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 13:40:39 +00:00
PPTX import Phase 6: theme colors + bg inheritance + line flips
Three related upgrades to fidelity: Theme color resolution: - loadThemeForSlide(files, slidePath) chases the OOXML inheritance chain (slide → slideLayout → slideMaster → theme) and parses the a:clrScheme into a name → hex map for accent1..6, dk1/2, lt1/2, hlink, folHlink. sysClr entries fall back to @lastClr. The map also honours the bg/tx → lt/dk aliases that PPTX uses for light themes. Themes are cached by zip path so a 50-slide deck sharing one theme reparses zero times after the first slide; the cache is cleared at the start of each importPptxFile. - convertColor(node, theme) now accepts a:srgbClr, a:schemeClr, and a:sysClr. schemeClr@val is resolved through the theme map. - After a base hex is chosen, _readColorModifiers + _applyColorModifiers apply lumMod / lumOff / tint / shade in HSL (PPTX values are in 1/1000 of a percent). Verified: accent1 (#ff0000) with lumMod 60000 → #990000 — matches PowerPoint's render. Background inheritance: - readSlideBackground now falls back through the slide → layout → master chain when the slide has no direct p:bg or only a p:bgRef. Theme is passed through so schemeClr-based backgrounds also resolve. Verified: a slide with no own bg correctly inherits the master's #abcdef. Line flip handling: - a:prstGeom prst="line" / "straightConnector1" go corner-to-corner of the bounding box. PowerPoint flips them via xfrm@flipH / @flipV; the Artstr line shape stores its segment endpoints in a 0-100 viewBox, so we set x1/y1/x2/y2 to the right diagonal at conversion time: no flip: (0,0)→(100,100) flipH: (100,0)→(0,100) flipV: (0,100)→(100,0) both: (100,100)→(0,0) Previously every line came in as a flat horizontal at y=50; now diagonals show up correctly. Plumbing: theme + groupXfrm + slideRels all live on ctx and are threaded through walkSpTree, the converters, readFill, readStroke, _readTxBody, _readParagraphRuns, _readRunStyle. Real-deck regression (gallery_template_layout_set.pptx): 16 slides, 156 text, 64 shapes, 1 chart placeholder, 0 groups — all counters identical to Phase 5. Synthetic theme-inheritance deck passes every check (bg inheritance, scheme color, lumMod modifier, line flip).
This commit is contained in:
1 parent
1d0239f022
commit
a448afc077
1 file changed
+288
-64
+288
-64
@@ -196,24 +196,225 @@ window.ArtstrPptxImporter = (function () {
|
||||
return refs;
|
||||
}
|
||||
|
||||
// ---- Theme resolution -------------------------------------------------
|
||||
// The PPTX inheritance chain (per NIP— sorry, per OOXML §19) is:
|
||||
// slide → slideLayout → slideMaster → theme.
|
||||
// Each step is a separate part with its own _rels file; we chase the
|
||||
// chain once per slide and cache the parsed theme by zip path so a
|
||||
// 50-slide deck sharing one theme doesn't reparse the XML 50 times.
|
||||
// Phase 6 resolves the color scheme (accent1..6, dk1/2, lt1/2 + the
|
||||
// bg/tx aliases) and inherited slide backgrounds; font scheme is
|
||||
// Phase 7.
|
||||
const _themeCache = new Map();
|
||||
|
||||
// Color-scheme alias map (bg/tx are aliases for the lt/dk pairs in
|
||||
// light themes — which is the default and what 99% of decks use).
|
||||
const SCHEME_ALIASES = {
|
||||
bg1: 'lt1',
|
||||
bg2: 'lt2',
|
||||
tx1: 'dk1',
|
||||
tx2: 'dk2',
|
||||
};
|
||||
|
||||
function _firstRelTarget(relsDoc, typeSuffix) {
|
||||
if (!relsDoc) return '';
|
||||
const rels = relsDoc.getElementsByTagName('Relationship');
|
||||
for (let i = 0; i < rels.length; i++) {
|
||||
const type = rels[i].getAttribute('Type') || '';
|
||||
if (type.endsWith(typeSuffix)) return rels[i].getAttribute('Target') || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _readRelsAt(files, path) {
|
||||
const text = readZipText(files, path);
|
||||
if (!text) return null;
|
||||
try { return parseXml(text); } catch { return null; }
|
||||
}
|
||||
|
||||
// Returns { colors: { accent1:'#...', dk1:'#...', ... }, themePath }
|
||||
// or null when no theme is reachable from this slide.
|
||||
function loadThemeForSlide(files, slidePath) {
|
||||
if (!slidePath) return null;
|
||||
// slide → slideLayout
|
||||
const sm = slidePath.match(/^(.*\/)([^/]+)$/);
|
||||
if (!sm) return null;
|
||||
const slideRelsDoc = _readRelsAt(files, `${sm[1]}_rels/${sm[2]}.rels`);
|
||||
const layoutTarget = _firstRelTarget(slideRelsDoc, '/slideLayout');
|
||||
if (!layoutTarget) return null;
|
||||
const layoutPath = pptxTargetToZipPath(slidePath, layoutTarget);
|
||||
|
||||
// slideLayout → slideMaster
|
||||
const lm = layoutPath.match(/^(.*\/)([^/]+)$/);
|
||||
if (!lm) return null;
|
||||
const layoutRelsDoc = _readRelsAt(files, `${lm[1]}_rels/${lm[2]}.rels`);
|
||||
const masterTarget = _firstRelTarget(layoutRelsDoc, '/slideMaster');
|
||||
if (!masterTarget) return null;
|
||||
const masterPath = pptxTargetToZipPath(layoutPath, masterTarget);
|
||||
|
||||
// slideMaster → theme
|
||||
const mm = masterPath.match(/^(.*\/)([^/]+)$/);
|
||||
if (!mm) return null;
|
||||
const masterRelsDoc = _readRelsAt(files, `${mm[1]}_rels/${mm[2]}.rels`);
|
||||
const themeTarget = _firstRelTarget(masterRelsDoc, '/theme');
|
||||
if (!themeTarget) return null;
|
||||
const themePath = pptxTargetToZipPath(masterPath, themeTarget);
|
||||
|
||||
if (_themeCache.has(themePath)) {
|
||||
const cached = _themeCache.get(themePath);
|
||||
return cached ? { ...cached, layoutPath, masterPath } : null;
|
||||
}
|
||||
|
||||
const themeDoc = _readRelsAt(files, themePath);
|
||||
if (!themeDoc) { _themeCache.set(themePath, null); return null; }
|
||||
// Walk a:clrScheme/*. Each child element's localName is the slot
|
||||
// name ('accent1', 'dk1', etc.), and its child is either srgbClr
|
||||
// or sysClr (which carries a lastClr fallback hex).
|
||||
const clrScheme = themeDoc.getElementsByTagName('a:clrScheme')[0]
|
||||
|| themeDoc.getElementsByTagNameNS('*', 'clrScheme')[0];
|
||||
const colors = {};
|
||||
if (clrScheme) {
|
||||
for (let i = 0; i < clrScheme.children.length; i++) {
|
||||
const slot = clrScheme.children[i];
|
||||
const slotName = slot.localName;
|
||||
if (!slotName) continue;
|
||||
// Most slots wrap a single srgbClr or sysClr child.
|
||||
const srgb = _directChild(slot, 'srgbClr');
|
||||
const sysClr = _directChild(slot, 'sysClr');
|
||||
let hex = '';
|
||||
if (srgb) {
|
||||
hex = (srgb.getAttribute('val') || '').trim().toLowerCase();
|
||||
} else if (sysClr) {
|
||||
// sysClr@lastClr is the resolved hex of the last time the file
|
||||
// was rendered — a good enough proxy here.
|
||||
hex = (sysClr.getAttribute('lastClr') || '').trim().toLowerCase();
|
||||
}
|
||||
if (/^[0-9a-f]{6}$/.test(hex)) colors[slotName] = '#' + hex;
|
||||
}
|
||||
}
|
||||
const out = { colors, themePath };
|
||||
_themeCache.set(themePath, out);
|
||||
return { ...out, layoutPath, masterPath };
|
||||
}
|
||||
|
||||
// Map an a:schemeClr name to a hex via the theme, honoring the
|
||||
// bg/tx → lt/dk aliases.
|
||||
function _resolveSchemeName(name, theme) {
|
||||
if (!theme || !theme.colors) return null;
|
||||
const resolved = SCHEME_ALIASES[name] || name;
|
||||
return theme.colors[resolved] || theme.colors[name] || null;
|
||||
}
|
||||
|
||||
// ---- Color modifier math (lumMod / lumOff / tint / shade) -----------
|
||||
// Operates in HSL. PPTX modifier values are in 1/1000 of a percent
|
||||
// (so 60000 = 60%, 100000 = 100%).
|
||||
function _hexToRgb(hex) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16) / 255,
|
||||
g: parseInt(hex.slice(3, 5), 16) / 255,
|
||||
b: parseInt(hex.slice(5, 7), 16) / 255,
|
||||
};
|
||||
}
|
||||
function _rgbToHex(r, g, b) {
|
||||
const c = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
|
||||
return '#' + c(r) + c(g) + c(b);
|
||||
}
|
||||
function _rgbToHsl({ r, g, b }) {
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l };
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
return { h, s, l };
|
||||
}
|
||||
function _hslToRgb({ h, s, l }) {
|
||||
if (s === 0) return { r: l, g: l, b: l };
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const h2rgb = (t) => {
|
||||
t = (t + 1) % 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return { r: h2rgb(h + 1 / 3), g: h2rgb(h), b: h2rgb(h - 1 / 3) };
|
||||
}
|
||||
// Read modifier children of a color element (srgbClr / schemeClr /
|
||||
// sysClr) and return their values as fractions (0..1).
|
||||
function _readColorModifiers(colorNode) {
|
||||
const out = {};
|
||||
if (!colorNode) return out;
|
||||
for (let i = 0; i < colorNode.children.length; i++) {
|
||||
const ch = colorNode.children[i];
|
||||
const name = ch.localName;
|
||||
const v = Number(ch.getAttribute('val')) / 100000;
|
||||
if (!Number.isFinite(v)) continue;
|
||||
if (name === 'lumMod') out.lumMod = v;
|
||||
else if (name === 'lumOff') out.lumOff = v;
|
||||
else if (name === 'tint') out.tint = v;
|
||||
else if (name === 'shade') out.shade = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function _applyColorModifiers(hex, mods) {
|
||||
if (!hex || !mods) return hex;
|
||||
if (mods.lumMod == null && mods.lumOff == null && mods.tint == null && mods.shade == null) return hex;
|
||||
const hsl = _rgbToHsl(_hexToRgb(hex));
|
||||
if (mods.lumMod != null) hsl.l *= mods.lumMod;
|
||||
if (mods.lumOff != null) hsl.l += mods.lumOff;
|
||||
if (mods.tint != null) hsl.l = hsl.l * (1 - mods.tint) + mods.tint; // toward white
|
||||
if (mods.shade != null) hsl.l = hsl.l * mods.shade; // toward black
|
||||
hsl.l = Math.max(0, Math.min(1, hsl.l));
|
||||
const rgb = _hslToRgb(hsl);
|
||||
return _rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||
}
|
||||
|
||||
// ---- Color / fill / stroke helpers ------------------------------------
|
||||
// Read a:srgbClr → '#rrggbb'. Returns null for scheme colors, preset
|
||||
// colors, system colors, or anything else we can't resolve in this
|
||||
// phase (theme resolution lands in Phase 6).
|
||||
function convertColor(colorParentNode) {
|
||||
// Read a color element (srgbClr / schemeClr / sysClr) into hex, with
|
||||
// PPTX color modifiers (lumMod / lumOff / tint / shade) applied
|
||||
// approximately in HSL. The optional `theme` argument lets schemeClr
|
||||
// names resolve through the slide's inheritance chain (Phase 6).
|
||||
// Returns null for preset colors and anything else we can't reach.
|
||||
function convertColor(colorParentNode, theme) {
|
||||
if (!colorParentNode) return null;
|
||||
const srgb = colorParentNode.getElementsByTagName('a:srgbClr')[0]
|
||||
|| colorParentNode.getElementsByTagNameNS('*', 'srgbClr')[0];
|
||||
if (!srgb) return null;
|
||||
const hex = (srgb.getAttribute('val') || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{6}$/.test(hex)) return null;
|
||||
return '#' + hex;
|
||||
// The parent can either be the color element itself (when called
|
||||
// directly on a:srgbClr in some paths) or a wrapper containing one.
|
||||
const candidates = [colorParentNode];
|
||||
for (let i = 0; i < colorParentNode.children.length; i++) {
|
||||
candidates.push(colorParentNode.children[i]);
|
||||
}
|
||||
for (const node of candidates) {
|
||||
const ln = node.localName;
|
||||
let hex = null;
|
||||
let modSource = null;
|
||||
if (ln === 'srgbClr') {
|
||||
const v = (node.getAttribute('val') || '').trim().toLowerCase();
|
||||
if (/^[0-9a-f]{6}$/.test(v)) { hex = '#' + v; modSource = node; }
|
||||
} else if (ln === 'schemeClr') {
|
||||
const name = (node.getAttribute('val') || '').trim();
|
||||
const resolved = _resolveSchemeName(name, theme);
|
||||
if (resolved) { hex = resolved; modSource = node; }
|
||||
} else if (ln === 'sysClr') {
|
||||
const last = (node.getAttribute('lastClr') || '').trim().toLowerCase();
|
||||
if (/^[0-9a-f]{6}$/.test(last)) { hex = '#' + last; modSource = node; }
|
||||
}
|
||||
if (hex) {
|
||||
const mods = _readColorModifiers(modSource);
|
||||
return _applyColorModifiers(hex, mods);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read a:solidFill from inside a spPr / ln node. Returns Artstr fill
|
||||
// descriptor or null when no fill is set, or { type: 'none' } when
|
||||
// an explicit a:noFill is present.
|
||||
function readFill(spPrNode) {
|
||||
function readFill(spPrNode, theme) {
|
||||
if (!spPrNode) return null;
|
||||
const noFill = spPrNode.getElementsByTagName('a:noFill')[0]
|
||||
|| spPrNode.getElementsByTagNameNS('*', 'noFill')[0];
|
||||
@@ -221,7 +422,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
if (noFill && noFill.parentNode === spPrNode) return { type: 'none' };
|
||||
const solid = _directChild(spPrNode, 'solidFill');
|
||||
if (!solid) return null;
|
||||
const color = convertColor(solid);
|
||||
const color = convertColor(solid, theme);
|
||||
if (!color) return null;
|
||||
return { type: 'solid', color };
|
||||
}
|
||||
@@ -230,7 +431,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
// convert through the slide scale (inches per EMU) to land in Artstr's
|
||||
// inch-based layer coord system. Minimum visible width is ~1 pixel
|
||||
// equivalent (1/96 inch) so a 0-width hairline still renders.
|
||||
function readStroke(spPrNode, scale) {
|
||||
function readStroke(spPrNode, scale, theme) {
|
||||
if (!spPrNode) return null;
|
||||
const ln = _directChild(spPrNode, 'ln');
|
||||
if (!ln) return null;
|
||||
@@ -243,7 +444,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
if (lnNoFill) return { type: 'none', color: '#000000', width, dash: 'solid' };
|
||||
const solid = _directChild(ln, 'solidFill');
|
||||
if (!solid) return null;
|
||||
const color = convertColor(solid);
|
||||
const color = convertColor(solid, theme);
|
||||
if (!color) return null;
|
||||
// a:prstDash → Artstr dash. Map the three styles Artstr supports.
|
||||
const prstDash = _directChild(ln, 'prstDash');
|
||||
@@ -348,47 +549,41 @@ window.ArtstrPptxImporter = (function () {
|
||||
//
|
||||
// Returns either a CSS hex string like '#fbf8f0' or null if nothing
|
||||
// usable was found (caller leaves slide.background at the default).
|
||||
function readSlideBackground(slideDoc, slideIndex, report) {
|
||||
const bg = slideDoc.getElementsByTagName('p:bg')[0]
|
||||
|| slideDoc.getElementsByTagNameNS('*', 'bg')[0];
|
||||
if (!bg) return null;
|
||||
|
||||
// Direct background properties live in <p:bgPr>. <p:bgRef> means the
|
||||
// slide inherits from its layout/master via a theme scheme — defer
|
||||
// to Phase 6 with a warning.
|
||||
const bgRef = bg.getElementsByTagName('p:bgRef')[0]
|
||||
|| bg.getElementsByTagNameNS('*', 'bgRef')[0];
|
||||
if (bgRef) {
|
||||
_warn(report, slideIndex, 'LAYOUT_INHERITANCE_PARTIAL',
|
||||
`Slide ${slideIndex + 1}: background inherited from layout/master — not resolved in Phase 1.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgPr = bg.getElementsByTagName('p:bgPr')[0]
|
||||
|| bg.getElementsByTagNameNS('*', 'bgPr')[0];
|
||||
// Resolve a p:bg/p:bgPr/a:solidFill into a hex via the theme. Returns
|
||||
// null when there's no solid fill (gradient / picture / etc.) — these
|
||||
// are still Phase 7+ work.
|
||||
function _readBackgroundFromBgNode(bgNode, theme) {
|
||||
if (!bgNode) return null;
|
||||
const bgPr = _directChild(bgNode, 'bgPr');
|
||||
if (!bgPr) return null;
|
||||
const solid = _directChild(bgPr, 'solidFill');
|
||||
if (!solid) return null;
|
||||
return convertColor(solid, theme);
|
||||
}
|
||||
|
||||
// Only solidFill + srgbClr in Phase 1. gradFill, blipFill (picture),
|
||||
// pattFill, and schemeClr-based fills land in Phase 6 with the rest
|
||||
// of theme resolution.
|
||||
const solid = bgPr.getElementsByTagName('a:solidFill')[0]
|
||||
|| bgPr.getElementsByTagNameNS('*', 'solidFill')[0];
|
||||
if (!solid) {
|
||||
_warn(report, slideIndex, 'THEME_COLOR_UNRESOLVED',
|
||||
`Slide ${slideIndex + 1}: non-solid background fill (gradient / picture / scheme color) — left as default for now.`);
|
||||
return null;
|
||||
}
|
||||
function readSlideBackground(slideDoc, slideIndex, report, theme, files, layoutPath, masterPath) {
|
||||
// 1) Direct slide background.
|
||||
const slideBg = slideDoc.getElementsByTagName('p:bg')[0]
|
||||
|| slideDoc.getElementsByTagNameNS('*', 'bg')[0];
|
||||
const direct = _readBackgroundFromBgNode(slideBg, theme);
|
||||
if (direct) return direct;
|
||||
|
||||
const srgb = solid.getElementsByTagName('a:srgbClr')[0]
|
||||
|| solid.getElementsByTagNameNS('*', 'srgbClr')[0];
|
||||
if (!srgb) {
|
||||
_warn(report, slideIndex, 'THEME_COLOR_UNRESOLVED',
|
||||
`Slide ${slideIndex + 1}: background uses a scheme color — theme resolution is Phase 6.`);
|
||||
return null;
|
||||
// 2) Inherit from slide layout's p:bg, then slide master's p:bg.
|
||||
// p:bgRef in the slide explicitly references the layout's bg, but in
|
||||
// practice we walk both regardless — whichever has a concrete fill
|
||||
// wins.
|
||||
for (const path of [layoutPath, masterPath]) {
|
||||
if (!path) continue;
|
||||
const text = readZipText(files, path);
|
||||
if (!text) continue;
|
||||
let doc;
|
||||
try { doc = parseXml(text); } catch { continue; }
|
||||
const bgNode = doc.getElementsByTagName('p:bg')[0]
|
||||
|| doc.getElementsByTagNameNS('*', 'bg')[0];
|
||||
const inherited = _readBackgroundFromBgNode(bgNode, theme);
|
||||
if (inherited) return inherited;
|
||||
}
|
||||
const hex = (srgb.getAttribute('val') || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{6}$/.test(hex)) return null;
|
||||
return '#' + hex;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Text helpers ----------------------------------------------------
|
||||
@@ -414,7 +609,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
// 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) {
|
||||
function _readParagraphRuns(pNode, theme) {
|
||||
let text = '';
|
||||
let firstRunStyle = null;
|
||||
for (let i = 0; i < pNode.children.length; i++) {
|
||||
@@ -426,7 +621,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
if (t) text += t.textContent || '';
|
||||
if (!firstRunStyle) {
|
||||
const rPr = _directChild(child, 'rPr');
|
||||
if (rPr) firstRunStyle = _readRunStyle(rPr);
|
||||
if (rPr) firstRunStyle = _readRunStyle(rPr, theme);
|
||||
}
|
||||
} else if (ln === 'br') {
|
||||
text += '\n';
|
||||
@@ -439,7 +634,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
// 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) {
|
||||
function _readRunStyle(rPr, theme) {
|
||||
if (!rPr) return null;
|
||||
const out = {};
|
||||
const sz = rPr.getAttribute('sz');
|
||||
@@ -453,7 +648,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
if (i === '1' || i === 'true') out.italic = true;
|
||||
const fill = _directChild(rPr, 'solidFill');
|
||||
if (fill) {
|
||||
const c = convertColor(fill);
|
||||
const c = convertColor(fill, theme);
|
||||
if (c) out.color = c;
|
||||
}
|
||||
const latin = _directChild(rPr, 'latin');
|
||||
@@ -469,7 +664,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
// 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) {
|
||||
function _readTxBody(txBodyNode, theme) {
|
||||
if (!txBodyNode) return null;
|
||||
const paragraphs = [];
|
||||
let dominantStyle = null;
|
||||
@@ -477,7 +672,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
for (let i = 0; i < txBodyNode.children.length; i++) {
|
||||
const p = txBodyNode.children[i];
|
||||
if (p.localName !== 'p') continue;
|
||||
const { text, firstRunStyle } = _readParagraphRuns(p);
|
||||
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.
|
||||
@@ -687,7 +882,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = _readTxBody(txBody);
|
||||
const body = _readTxBody(txBody, ctx.theme);
|
||||
if (!body) return null; // empty placeholder text
|
||||
|
||||
const style = body.dominantStyle;
|
||||
@@ -748,6 +943,21 @@ window.ArtstrPptxImporter = (function () {
|
||||
// Shallow-clone so subsequent shapes don't mutate the shared template.
|
||||
shape = { ...shape };
|
||||
|
||||
// PPTX lines and straight connectors go corner-to-corner of their
|
||||
// bounding box; flipH / flipV choose which diagonal. The default
|
||||
// line-shape template ships as a horizontal segment, which looks
|
||||
// right for thin horizontal bounds but flat for diagonals — fix by
|
||||
// honouring the flips here.
|
||||
if (shape.kind === 'line') {
|
||||
const flipH = xfrm.getAttribute('flipH') === '1';
|
||||
const flipV = xfrm.getAttribute('flipV') === '1';
|
||||
// Base diagonal: top-left → bottom-right.
|
||||
let x1 = 0, y1 = 0, x2 = 100, y2 = 100;
|
||||
if (flipH) { x1 = 100; x2 = 0; }
|
||||
if (flipV) { y1 = (y1 === 0 ? 100 : 0); y2 = (y2 === 0 ? 100 : 0); }
|
||||
shape.x1 = x1; shape.y1 = y1; shape.x2 = x2; shape.y2 = y2;
|
||||
}
|
||||
|
||||
// Name the layer with PowerPoint's <p:cNvPr name="..."> if present so
|
||||
// the user can match it back to the source deck in the layer panel.
|
||||
const cNvPr = spNode.getElementsByTagName('p:cNvPr')[0]
|
||||
@@ -755,8 +965,8 @@ window.ArtstrPptxImporter = (function () {
|
||||
const sourceName = cNvPr?.getAttribute('name') || '';
|
||||
const name = sourceName ? `${label || 'Shape'} (${sourceName})` : (label || 'Shape');
|
||||
|
||||
const fill = readFill(spPr) || { type: 'none' };
|
||||
const stroke = readStroke(spPr, ctx.scale) || { type: 'none', color: '#000000', width: 1, dash: 'solid' };
|
||||
const fill = readFill(spPr, ctx.theme) || { type: 'none' };
|
||||
const stroke = readStroke(spPr, ctx.scale, ctx.theme) || { type: 'none', color: '#000000', width: 1, dash: 'solid' };
|
||||
|
||||
return {
|
||||
id: _makePptxLayerId(),
|
||||
@@ -922,6 +1132,11 @@ window.ArtstrPptxImporter = (function () {
|
||||
if (!file) throw new Error('No file provided.');
|
||||
if (!report) report = makePptxImportReport(file.name);
|
||||
|
||||
// The theme cache is keyed by zip path, which is per-package — clear
|
||||
// it between imports so a re-import of a different .pptx doesn't
|
||||
// reuse a stale entry under the same path.
|
||||
_themeCache.clear();
|
||||
|
||||
const files = await readPptxPackage(file);
|
||||
const presentation = readPresentationInfo(files, report);
|
||||
// EMU → Artstr layer inches. Layer x/y/w/h and stroke width all live
|
||||
@@ -948,17 +1163,23 @@ window.ArtstrPptxImporter = (function () {
|
||||
const layers = [];
|
||||
const slidePath = presentation.slideRefs[i].path;
|
||||
const slideText = slidePath ? readZipText(files, slidePath) : null;
|
||||
// Resolve the theme + layout / master paths for this slide once;
|
||||
// every converter and the background reader use them.
|
||||
const theme = loadThemeForSlide(files, slidePath);
|
||||
const layoutPath = theme?.layoutPath || '';
|
||||
const masterPath = theme?.masterPath || '';
|
||||
|
||||
if (slideText) {
|
||||
try {
|
||||
const slideDoc = parseXml(slideText);
|
||||
const bg = readSlideBackground(slideDoc, i, report);
|
||||
const bg = readSlideBackground(slideDoc, i, report, theme, files, layoutPath, masterPath);
|
||||
if (bg) {
|
||||
background = bg;
|
||||
report.imported.backgrounds += 1;
|
||||
}
|
||||
// Walk the shape tree and convert each p:sp with prstGeom into
|
||||
// an Artstr shape layer. Phase 2 will fill in text; Phase 3
|
||||
// fills in images / chart placeholders; Phase 5 flattens groups.
|
||||
// Walk the shape tree and convert each p:sp / p:pic /
|
||||
// p:graphicFrame / p:grpSp. Theme + group transform are
|
||||
// threaded through ctx.
|
||||
const spTree = slideDoc.getElementsByTagName('p:spTree')[0]
|
||||
|| slideDoc.getElementsByTagNameNS('*', 'spTree')[0];
|
||||
const slideRels = readSlideRels(files, slidePath);
|
||||
@@ -969,6 +1190,7 @@ window.ArtstrPptxImporter = (function () {
|
||||
nextZ: 0,
|
||||
slideRels,
|
||||
groupXfrm: IDENTITY_GROUP_XFRM,
|
||||
theme,
|
||||
};
|
||||
walkSpTree(spTree, ctx, layers);
|
||||
} catch (err) {
|
||||
@@ -1044,6 +1266,8 @@ window.ArtstrPptxImporter = (function () {
|
||||
readXfrm,
|
||||
enterGroup,
|
||||
IDENTITY_GROUP_XFRM,
|
||||
loadThemeForSlide,
|
||||
_applyColorModifiers,
|
||||
convertPresetShape,
|
||||
convertTextShape,
|
||||
convertPicture,
|
||||
|
||||
Reference in new issue
Block a user