pptx-automizer
Version:
A template based pptx generator
453 lines • 20.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const xml_helper_1 = require("./xml-helper");
const xml_slide_helper_1 = require("./xml-slide-helper");
class XmlPlaceholderHelper {
constructor(slide, slideElements, sourceLayoutInfo, targetPlaceholders) {
this.mappingResult = {
usedPlaceholders: [],
unmatchedSourcePlaceholderElements: [],
matchedSourceElements: [],
};
this.mapCandidates = {
body: ['textBox', 'rectangle'],
title: ['textBox', 'rectangle'],
subTitle: ['textBox', 'rectangle'],
ctrTitle: ['textBox', 'rectangle'],
pic: ['picture'],
};
this.slide = slide;
this.slideElements = slideElements;
this.sourceLayoutInfo = sourceLayoutInfo;
this.targetPlaceholders = targetPlaceholders;
}
run() {
this.performInitialPlaceholderMatching();
this.handleForceAssignmentPlaceholders([
'title',
'ctrTitle',
'subTitle',
'body',
'pic',
]);
this.cleanupUnmatchedPlaceholders();
}
/**
* Performs the initial placeholder matching between source elements and target placeholders.
* Elements with exact placeholder type matches are processed first.
*/
performInitialPlaceholderMatching() {
this.slideElements.forEach((element) => {
const placeholder = element.getPlaceholderInfo();
if (placeholder === null || placeholder === void 0 ? void 0 : placeholder.type) {
const matchesPlaceholder = this.applyPlaceholderToElement(this.targetPlaceholders, element, placeholder);
if (!matchesPlaceholder) {
this.mappingResult.unmatchedSourcePlaceholderElements.push(element);
}
else {
this.mappingResult.matchedSourceElements.push(element);
}
}
});
}
/**
* Removes an element from the unmatched elements array.
*
* @param element - Element to remove
* @param unmatchedElements - Array to remove from
* @private
*/
removeElementFromUnmatched(element, unmatchedElements) {
const index = unmatchedElements.indexOf(element);
if (index > -1) {
unmatchedElements.splice(index, 1);
}
return unmatchedElements;
}
/**
* Handles force assignment of placeholder types by finding the best matching
* unmatched slide elements for unassigned target placeholders.
*
* @param forceAssignPhTypes - Array of placeholder types that should be force assigned
*/
handleForceAssignmentPlaceholders(forceAssignPhTypes) {
const mappingResult = this.mappingResult;
const usedElements = [];
forceAssignPhTypes.forEach((phType) => {
// Find unassigned target placeholders of this type
const unassignedTargetPlaceholders = this.targetPlaceholders.filter((ph) => ph.type === phType && !mappingResult.usedPlaceholders.includes(ph));
if (unassignedTargetPlaceholders.length === 0) {
return; // No unassigned placeholders of this type
}
const unmatchedElements = this.slideElements.filter((ele) => {
return !mappingResult.matchedSourceElements.includes(ele);
});
// Recalculate candidate elements for each placeholder to reflect current state
const elementsGroups = XmlPlaceholderHelper.groupElements(unmatchedElements);
unassignedTargetPlaceholders.forEach((ph) => {
const targetTypes = this.mapCandidates[ph.type];
const candidateElements = [];
targetTypes.forEach((targetType) => {
candidateElements.push(...(elementsGroups[targetType] || []));
});
const filteredCandidates = candidateElements.filter((candidate) => {
return !usedElements.includes(candidate);
});
const bestCandidate = this.findBestCandidateElementForPlaceholder(filteredCandidates, ph);
if (bestCandidate) {
this.applyForceAssignedPlaceholderToElement(bestCandidate, ph, unmatchedElements);
usedElements.push(bestCandidate);
}
});
});
}
findBestCandidateElementForPlaceholder(candidateElements, ph) {
if (ph.type === 'title') {
if (ph.position) {
return this.findClosestCandidate(ph, candidateElements);
}
return this.findFromTopCandidate(candidateElements, 1, true);
}
if (ph.type === 'subTitle' || ph.type === 'ctrTitle') {
return this.findFromTopCandidate(candidateElements, 2, false);
}
if (ph.type === 'body') {
if (ph.position) {
return this.findClosestCandidate(ph, candidateElements);
}
return this.findLargestCandidate(candidateElements);
}
if (ph.type === 'pic') {
return this.findLargestCandidate(candidateElements);
}
return null;
}
findClosestCandidate(ph, candidateElements) {
let highestScore = 0;
let bestCandidate = null;
candidateElements.forEach((ele) => {
const closestShapeScore = XmlPlaceholderHelper.calculateDistanceScore(ele.position, ph.position);
if (closestShapeScore > highestScore) {
highestScore = closestShapeScore;
bestCandidate = ele;
}
});
return bestCandidate;
}
findLargestCandidate(candidateElements) {
let minSizeRank = Number.POSITIVE_INFINITY;
candidateElements.forEach((ele) => {
minSizeRank = ele.sizeRank < minSizeRank ? ele.sizeRank : minSizeRank;
});
return candidateElements.find((ele) => {
return ele.sizeRank === minSizeRank;
});
}
findFromTopCandidate(candidateElements, fromTopRank, equals) {
const bestCandidate = candidateElements.find((ele) => {
if (equals) {
return ele.fromTopRank === fromTopRank;
}
return ele.fromTopRank >= fromTopRank;
});
if (bestCandidate) {
return bestCandidate;
}
}
/**
* Cleans up elements that still don't have placeholder matches by removing
* their placeholder properties and applying fallback positioning.
*/
cleanupUnmatchedPlaceholders() {
const sourcePlaceholders = this.sourceLayoutInfo.placeholders;
const unmatchedElements = this.mappingResult.unmatchedSourcePlaceholderElements;
unmatchedElements.forEach((element) => {
this.clearUnmatchedPlaceholder(element, sourcePlaceholders);
});
}
clearUnmatchedPlaceholder(element, sourcePlaceholders) {
const placeholder = element.getPlaceholderInfo();
const fallbackPh = sourcePlaceholders.find((ph) => ph.idx === placeholder.idx);
const fallbackPosition = (fallbackPh === null || fallbackPh === void 0 ? void 0 : fallbackPh.position) || {
x: 1000,
y: 1000,
cx: 5000000,
cy: 1000000,
};
const callback = (element) => {
XmlPlaceholderHelper.removePlaceholder(element, fallbackPosition);
};
this.postApplyModification(element, callback);
}
applyPlaceholderToElement(layoutPlaceholders, element, placeholder) {
const unusedPlaceholders = layoutPlaceholders.filter((ph) => !this.mappingResult.usedPlaceholders.includes(ph));
const matchPlaceholders = unusedPlaceholders.filter((ph) => {
return ph.type === (placeholder === null || placeholder === void 0 ? void 0 : placeholder.type);
});
if (matchPlaceholders.length) {
const bestMatch = XmlPlaceholderHelper.findBestMatchingPlaceholder(element, matchPlaceholders);
this.applyPlaceholder(element, bestMatch);
return bestMatch;
}
return null;
}
applyPlaceholder(element, bestMatch) {
const applyPlaceholderCallback = (element) => {
XmlPlaceholderHelper.setPlaceholderDefaults(element, bestMatch);
};
this.postApplyModification(element, applyPlaceholderCallback);
this.mappingResult.usedPlaceholders.push(bestMatch);
}
applyForceAssignedPlaceholderToElement(bestCandidate, bestMatch, unmatchedElements) {
this.applyPlaceholder(bestCandidate, bestMatch);
this.removeElementFromUnmatched(bestCandidate, unmatchedElements);
this.mappingResult.matchedSourceElements.push(bestCandidate);
this.mappingResult.unmatchedSourcePlaceholderElements =
this.mappingResult.unmatchedSourcePlaceholderElements.filter((ele) => ele !== bestCandidate);
}
postApplyModification(element, callback) {
const selector = {
creationId: element.creationId,
nameIdx: element.nameIdx,
name: element.name,
};
this.slide.modifyElement(selector, callback);
}
/**
* Extracts placeholder information from an XML element.
*
* This method parses a PowerPoint shape element to extract its placeholder properties,
* including placeholder type, size, index, position, and element type. It also attempts to merge
* position information from layout placeholders when available.
*
* @param element - The XML element representing a PowerPoint shape
* @param layoutPlaceholders - Optional array of placeholder info from the slide layout
* @returns PlaceholderInfo object containing all extracted placeholder data, or undefined if no placeholder found
*/
static getPlaceholderInfo(element, layoutPlaceholders) {
var _a;
// Find the placeholder element within the shape
const placeholderElement = element.getElementsByTagName('p:ph').item(0);
// Early return if this element doesn't contain a placeholder
if (!placeholderElement) {
return undefined;
}
// Extract the shape properties element for determining element type
const slideElementParent = (_a = element.getElementsByTagName('p:spPr').item(0)) === null || _a === void 0 ? void 0 : _a.parentNode;
// Parse the placeholder index attribute
const indexAttribute = placeholderElement.getAttribute('idx');
const placeholderIndex = (indexAttribute === null || indexAttribute === void 0 ? void 0 : indexAttribute.length)
? parseInt(indexAttribute, 10)
: null;
// Try to find corresponding layout placeholder by matching index
const matchingLayoutPlaceholder = layoutPlaceholders === null || layoutPlaceholders === void 0 ? void 0 : layoutPlaceholders.find((layoutPh) => placeholderIndex !== null && layoutPh.idx === placeholderIndex);
// Determine position - prefer shape's own position, fallback to layout position
const shapePosition = xml_slide_helper_1.XmlSlideHelper.parseShapeCoordinates(element, false);
const finalPosition = shapePosition || (matchingLayoutPlaceholder === null || matchingLayoutPlaceholder === void 0 ? void 0 : matchingLayoutPlaceholder.position);
const elementType = xml_slide_helper_1.XmlSlideHelper.getElementType(slideElementParent);
const phType = placeholderElement.getAttribute('type');
const type = !phType && elementType === 'sp' ? 'body' : phType;
// Build the placeholder info object
const placeholderInfo = {
type: type || 'unknown',
sz: placeholderElement.getAttribute('sz'),
idx: placeholderIndex,
elementType,
position: finalPosition,
};
return placeholderInfo;
}
static setPlaceholderDefaults(element, layoutPlaceholder) {
// Get the placeholder element
let ph = element.getElementsByTagName('p:ph').item(0);
if (!ph) {
// If element has no placeholder, create one
const nvPr = element.getElementsByTagName('p:nvPr').item(0);
if (nvPr) {
ph = element.ownerDocument.createElement('p:ph');
nvPr.appendChild(ph);
}
}
['type', 'sz', 'idx'].forEach((tag) => XmlPlaceholderHelper.updatePlaceholderParams(tag, ph, layoutPlaceholder));
[
// Force fallback to layout properties
'a:xfrm',
'p:style',
].forEach((tag) => {
const ele = element.getElementsByTagName(tag).item(0);
if (ele) {
xml_helper_1.XmlHelper.remove(ele);
}
});
['p:spPr', 'a:bodyPr', 'p:cNvSpPr'].forEach((tag) => {
const ele = element.getElementsByTagName(tag).item(0);
if (ele) {
xml_helper_1.XmlHelper.removeAllChildren(ele);
}
});
// XmlHelper.dump(element);
}
static updatePlaceholderParams(tag, ph, layoutPlaceholder) {
if (layoutPlaceholder[tag]) {
ph.setAttribute(tag, String(layoutPlaceholder[tag]));
}
else {
ph.removeAttribute(tag);
}
}
static removePlaceholder(element, fallbackPosition) {
const ph = element.getElementsByTagName('p:ph').item(0);
xml_helper_1.XmlHelper.remove(ph);
XmlPlaceholderHelper.assertShapeCoordinates(element, fallbackPosition);
}
/**
* Finds the best matching target placeholder for a source placeholder based on multiple criteria.
*
* @returns The best matching target placeholder or null if no suitable match found
* @param sourceElement
* @param typeMatches
*/
static findBestMatchingPlaceholder(sourceElement, typeMatches) {
// Score each potential match based on multiple criteria
const scoredMatches = typeMatches.map((target) => {
const score = XmlPlaceholderHelper.calculatePlaceholderSimilarityScore(0, target, sourceElement);
return { target, score };
});
// Sort by score, highest first
scoredMatches.sort((a, b) => b.score - a.score);
// Return the highest scoring match
return scoredMatches.length > 0 ? scoredMatches[0].target : null;
}
static calculatePlaceholderSimilarityScore(score, availablePlaceholder, element) {
const placeholder = element.getPlaceholderInfo();
// Bonus points for matching size if available
if ((placeholder === null || placeholder === void 0 ? void 0 : placeholder.sz) &&
availablePlaceholder.sz === placeholder.sz) {
score += 10;
}
// Bonus points for similar position if available
const position = (placeholder === null || placeholder === void 0 ? void 0 : placeholder.position) || element.position;
if (position && availablePlaceholder.position) {
score += this.calculateDistanceScore(position, availablePlaceholder.position);
}
return score;
}
static calculateDistanceScore(pos1, pos2) {
// Calculate center points of both elements
const center1X = pos1.x + pos1.cx / 2;
const center1Y = pos1.y + pos1.cy / 2;
const center2X = pos2.x + pos2.cx / 2;
const center2Y = pos2.y + pos2.cy / 2;
// Calculate distance between center points
const distance = Math.sqrt(Math.pow(center1X - center2X, 2) + Math.pow(center1Y - center2Y, 2)) / 100000;
// Convert distance to score - closer elements get higher scores
const distanceScore = Math.max(0, 10 - distance); // Max 10 points for very close elements
// Calculate size similarity bonus (area comparison)
const area1 = pos1.cx * pos1.cy;
const area2 = pos2.cx * pos2.cy;
const sizeRatio = Math.min(area1, area2) / Math.max(area1, area2);
const sizeBonus = sizeRatio * 10; // Up to 10 points for identical sizes
return distanceScore + sizeBonus;
}
/**
* @param unmatchedElements - Elements that couldn't be matched initially
* @returns groupedByType
* @private
*/
static groupElements(unmatchedElements) {
const groupedByType = {};
unmatchedElements.forEach((element) => {
if (!groupedByType[element.visualType]) {
groupedByType[element.visualType] = [];
}
element.fromTopRank = 0;
element.fromLeftRank = 0;
element.sizeRank = 0;
groupedByType[element.visualType].push(element);
});
// Process each group
Object.keys(groupedByType).forEach((shapeType) => {
const elementsOfType = groupedByType[shapeType];
// Sort by position from top-left to bottom-right for ranking
const sortedByPosition = [...elementsOfType].sort((a, b) => {
// First sort by Y position (top to bottom)
if (a.position.y !== b.position.y) {
return a.position.y - b.position.y;
}
// Then sort by X position (left to right)
return a.position.x - b.position.x;
});
// Sort by size (area) for size ranking
const sortedBySize = [...elementsOfType].sort((a, b) => {
const areaA = a.position.cx * a.position.cy;
const areaB = b.position.cx * b.position.cy;
return areaB - areaA; // Descending order (largest first)
});
// Create ranking maps
const topRankMap = new Map();
const leftRankMap = new Map();
const sizeRankMap = new Map();
// Assign fromTopRank and fromLeftRank based on position sorting
sortedByPosition.forEach((element, index) => {
const elementKey = element.creationId || element.name + element.nameIdx;
topRankMap.set(elementKey, index + 1);
leftRankMap.set(elementKey, index + 1);
});
// Assign sizeRank based on size sorting
sortedBySize.forEach((element, index) => {
const elementKey = element.creationId || element.name + element.nameIdx;
sizeRankMap.set(elementKey, index + 1);
});
// Create enriched elements with rankings
groupedByType[shapeType] = elementsOfType.map((element) => {
const elementKey = element.creationId || element.name + element.nameIdx;
element.fromTopRank = topRankMap.get(elementKey) || 0;
element.fromLeftRank = leftRankMap.get(elementKey) || 0;
element.sizeRank = sizeRankMap.get(elementKey) || 0;
return element;
});
});
return groupedByType;
}
/**
* Adds or updates coordinates in a shape element
* @param element The XML element of the shape
* @param coords The coordinates to set
*/
static assertShapeCoordinates(element, coords) {
// Find or create the transform element
let xfrm = element.getElementsByTagName('a:xfrm').item(0) ||
element.getElementsByTagName('p:xfrm').item(0);
const spPr = element.getElementsByTagName('p:spPr').item(0);
if (!spPr) {
return; // Cannot add coordinates without spPr element
}
if (!xfrm) {
// Create a new transform element
xfrm = element.ownerDocument.createElement('a:xfrm');
spPr.appendChild(xfrm);
}
// Create or update the offset element (position)
let off = xfrm.getElementsByTagName('a:off').item(0);
if (!off) {
off = element.ownerDocument.createElement('a:off');
xfrm.appendChild(off);
if (coords.x !== undefined)
off.setAttribute('x', coords.x.toString());
if (coords.y !== undefined)
off.setAttribute('y', coords.y.toString());
}
// Create or update the extent element (size)
let ext = xfrm.getElementsByTagName('a:ext').item(0);
if (!ext) {
ext = element.ownerDocument.createElement('a:ext');
xfrm.appendChild(ext);
if (coords.cx !== undefined)
ext.setAttribute('cx', coords.cx.toString());
if (coords.cy !== undefined)
ext.setAttribute('cy', coords.cy.toString());
}
}
}
exports.default = XmlPlaceholderHelper;
//# sourceMappingURL=xml-placeholder-helper.js.map