This commit is contained in:
Timothy Jaeryang Baek 2026-08-13 16:13:47 -06:00
parent f96b717566
commit b7292890cc

View file

@ -5,7 +5,7 @@
* directly to canvas, returning PNG data URLs.
*
* Uses jszip (dynamically imported) and the browser Canvas 2D API.
* No theme resolution, charts, SmartArt, or animations preview only.
* No full theme resolution, SmartArt, or animations preview only.
*/
const EMU_PER_PX = 9525;
@ -48,6 +48,24 @@ type TextToken = {
type TextStyle = Omit<TextToken, 'text' | 'width'>;
type TextInsets = {
left: number;
right: number;
top: number;
bottom: number;
};
type ChartSeries = {
name: string;
categories: string[];
values: number[];
color: string;
};
type ThemeColors = Record<string, string>;
let activeThemeColors: ThemeColors = {};
const getTag = (el: Element) => el.tagName.split(':').pop();
const normalizePath = (path: string) => {
@ -99,6 +117,34 @@ const readRels = async (
return rels;
};
const readThemeColors = async (zip: ZipLike): Promise<ThemeColors> => {
const themePath =
Object.keys(zip.files)
.filter((path) => /^ppt\/theme\/theme\d+\.xml$/.test(path))
.sort()[0] ?? '';
const themeDoc = themePath ? await readXml(zip, themePath) : null;
const clrScheme = themeDoc?.getElementsByTagName('a:clrScheme')[0];
const colors: ThemeColors = {};
if (!clrScheme) return colors;
for (const child of Array.from(clrScheme.children)) {
const key = getTag(child);
if (!key) continue;
const srgb = child.getElementsByTagName('a:srgbClr')[0]?.getAttribute('val');
if (srgb) {
colors[key] = `#${srgb}`;
continue;
}
const sys = child.getElementsByTagName('a:sysClr')[0];
const lastClr = sys?.getAttribute('lastClr');
if (lastClr) colors[key] = `#${lastClr}`;
}
return colors;
};
const getRelationshipTarget = (
rels: Record<string, Relationship>,
typeSuffix: string
@ -217,6 +263,18 @@ const paragraphAlign = (
};
const schemeColor = (val: string | null) => {
const themeKey =
val === 'bg1'
? 'lt1'
: val === 'tx1'
? 'dk1'
: val === 'bg2'
? 'lt2'
: val === 'tx2'
? 'dk2'
: val;
if (themeKey && activeThemeColors[themeKey]) return activeThemeColors[themeKey];
switch (val) {
case 'bg1':
case 'lt1':
@ -241,17 +299,119 @@ const schemeColor = (val: string | null) => {
}
};
const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)));
const hexToRgb = (color: string) => {
const hex = color.replace('#', '');
if (!/^[\da-f]{6}$/i.test(hex)) return null;
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
};
const rgbToHex = ({ r, g, b }: { r: number; g: number; b: number }) =>
`#${[r, g, b].map((value) => clampByte(value).toString(16).padStart(2, '0')).join('')}`;
const colorWithTransforms = (color: string | null, colorEl: Element | undefined) => {
if (!color || !colorEl) return color;
const rgb = hexToRgb(color);
if (!rgb) return color;
const lumMod = colorEl.getElementsByTagName('a:lumMod')[0]?.getAttribute('val');
const lumOff = colorEl.getElementsByTagName('a:lumOff')[0]?.getAttribute('val');
const alpha = colorEl.getElementsByTagName('a:alpha')[0]?.getAttribute('val');
const mod = lumMod ? parseInt(lumMod, 10) / 100000 : 1;
const off = lumOff ? (parseInt(lumOff, 10) / 100000) * 255 : 0;
const transformed = {
r: rgb.r * mod + off,
g: rgb.g * mod + off,
b: rgb.b * mod + off
};
if (alpha) {
const opacity = Math.max(0, Math.min(1, parseInt(alpha, 10) / 100000));
return `rgba(${clampByte(transformed.r)}, ${clampByte(transformed.g)}, ${clampByte(
transformed.b
)}, ${opacity})`;
}
return rgbToHex(transformed);
};
const prstColor = (val: string | null) => {
switch (val) {
case 'black':
return '#000000';
case 'white':
return '#ffffff';
case 'gray':
case 'grey':
return '#808080';
default:
return null;
}
};
const colorFromElement = (el: Element | undefined): string | null => {
const srgb = el?.getElementsByTagName('a:srgbClr')[0];
const srgbVal = srgb?.getAttribute('val');
if (srgbVal) return colorWithTransforms(`#${srgbVal}`, srgb);
const prst = el?.getElementsByTagName('a:prstClr')[0];
const prstVal = prst?.getAttribute('val');
if (prstVal) return colorWithTransforms(prstColor(prstVal), prst);
const scheme = el?.getElementsByTagName('a:schemeClr')[0];
return colorWithTransforms(schemeColor(scheme?.getAttribute('val') ?? null), scheme);
};
const solidFillColor = (el: Element | Document | null): string | null => {
if (!el) return null;
const fill = el.getElementsByTagName('a:solidFill')[0];
if (!fill) return null;
const srgb = fill.getElementsByTagName('a:srgbClr')[0];
const srgbVal = srgb?.getAttribute('val');
if (srgbVal) return `#${srgbVal}`;
return colorFromElement(fill);
};
const scheme = fill.getElementsByTagName('a:schemeClr')[0];
return schemeColor(scheme?.getAttribute('val') ?? null);
const renderFill = (ctx: CanvasRenderingContext2D, el: Element | Document | null, rect: Rect) => {
if (!el) return false;
const gradFill = el.getElementsByTagName('a:gradFill')[0];
if (gradFill) {
const stops = Array.from(gradFill.getElementsByTagName('a:gs'))
.map((stop) => ({
pos: parseInt(stop.getAttribute('pos') ?? '0', 10) / 100000,
color: colorFromElement(stop)
}))
.filter((stop): stop is { pos: number; color: string } => Boolean(stop.color));
if (stops.length > 0) {
const lin = gradFill.getElementsByTagName('a:lin')[0];
const angle =
(((parseInt(lin?.getAttribute('ang') ?? '0', 10) / 60000) % 360) * Math.PI) / 180;
const dx = Math.cos(angle) * rect.w;
const dy = Math.sin(angle) * rect.h;
const gradient = ctx.createLinearGradient(
rect.x + rect.w / 2 - dx / 2,
rect.y + rect.h / 2 - dy / 2,
rect.x + rect.w / 2 + dx / 2,
rect.y + rect.h / 2 + dy / 2
);
for (const stop of stops)
gradient.addColorStop(Math.max(0, Math.min(1, stop.pos)), stop.color);
ctx.fillStyle = gradient;
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
return true;
}
}
const fill = solidFillColor(el);
if (!fill) return false;
ctx.fillStyle = fill;
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
return true;
};
const renderBackground = (
@ -260,10 +420,7 @@ const renderBackground = (
slideW: number,
slideH: number
) => {
const bgColor = solidFillColor(doc.getElementsByTagName('p:bg')[0]);
if (!bgColor) return;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, slideW, slideH);
renderFill(ctx, doc.getElementsByTagName('p:bg')[0], { x: 0, y: 0, w: slideW, h: slideH });
};
const directChild = (el: Element, tag: string) =>
@ -311,13 +468,24 @@ const readRunStyle = (run: Element, base: TextStyle) => {
return readTextStyle(rPr, base);
};
const textBodyInsets = (txBody: Element) => {
const textBodyInsets = (
txBody: Element,
fallback: TextInsets = { left: 10, right: 10, top: 5, bottom: 5 }
) => {
const bodyPr = txBody.getElementsByTagName('a:bodyPr')[0];
return {
left: emuToPx(parseEmu(bodyPr?.getAttribute('lIns') ?? '91440')),
right: emuToPx(parseEmu(bodyPr?.getAttribute('rIns') ?? '91440')),
top: emuToPx(parseEmu(bodyPr?.getAttribute('tIns') ?? '45720')),
bottom: emuToPx(parseEmu(bodyPr?.getAttribute('bIns') ?? '45720'))
left: bodyPr?.hasAttribute('lIns')
? emuToPx(parseEmu(bodyPr.getAttribute('lIns')))
: fallback.left,
right: bodyPr?.hasAttribute('rIns')
? emuToPx(parseEmu(bodyPr.getAttribute('rIns')))
: fallback.right,
top: bodyPr?.hasAttribute('tIns')
? emuToPx(parseEmu(bodyPr.getAttribute('tIns')))
: fallback.top,
bottom: bodyPr?.hasAttribute('bIns')
? emuToPx(parseEmu(bodyPr.getAttribute('bIns')))
: fallback.bottom
};
};
@ -353,6 +521,395 @@ const drawTextLine = (
}
};
const paragraphTextParts = (para: Element, defaultFontSize: number) => {
const paragraphStyle = readParagraphStyle(para, defaultFontSize);
const textParts: Array<{ text: string; style: TextStyle } | { newline: true }> = [];
const bullet = paragraphBullet(para);
for (const child of Array.from(para.children)) {
const tag = getTag(child);
if (tag === 'br') {
textParts.push({ newline: true });
continue;
}
if (tag !== 'r' && tag !== 'fld') continue;
const style = tag === 'r' ? readRunStyle(child, paragraphStyle) : paragraphStyle;
const text = child.getElementsByTagName('a:t')[0]?.textContent ?? '';
if (text) textParts.push({ text, style });
}
if (bullet && textParts.some((part) => 'text' in part && part.text.trim())) {
textParts.unshift({ text: bullet, style: paragraphStyle });
}
return { paragraphStyle, textParts };
};
const estimateTextHeight = (
ctx: CanvasRenderingContext2D,
paragraphs: HTMLCollectionOf<Element>,
defaultFontSize: number,
textW: number
) => {
let height = 0;
for (let pi = 0; pi < paragraphs.length; pi++) {
const { paragraphStyle, textParts } = paragraphTextParts(paragraphs[pi], defaultFontSize);
const maxFontPt = textParts.reduce(
(max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max),
paragraphStyle.fontPt
);
const lineHeight = maxFontPt * 1.4;
let lines = 1;
let lineWidth = 0;
for (const part of textParts) {
if ('newline' in part) {
lines++;
lineWidth = 0;
continue;
}
ctx.font = fontString(part.style);
for (const word of part.text.split(/(\n|[^\S\n]+)/)) {
if (word === '') continue;
if (word === '\n') {
lines++;
lineWidth = 0;
continue;
}
const width = ctx.measureText(word).width;
if (lineWidth + width > textW && lineWidth > 0) {
lines++;
lineWidth = word.trim() ? width : 0;
} else if (lineWidth > 0 || word.trim()) {
lineWidth += width;
}
}
}
height += lines * lineHeight;
if (pi < paragraphs.length - 1) height += lineHeight * 0.4;
}
return height;
};
const renderTextBody = (
ctx: CanvasRenderingContext2D,
txBody: Element,
rect: Rect,
defaultFontSize: number,
ph: { type: string; idx: string } | Placeholder | null = null,
fallbackInsets?: TextInsets
) => {
const { x, y, w, h } = rect;
ctx.save();
ctx.rect(x, y, w, h);
ctx.clip();
const paragraphs = txBody.getElementsByTagName('a:p');
const bodyPr = txBody.getElementsByTagName('a:bodyPr')[0];
const insets = textBodyInsets(txBody, fallbackInsets);
const textX = x + insets.left;
const textW = Math.max(1, w - insets.left - insets.right);
const textBottom = y + h - insets.bottom;
const textH = Math.max(1, textBottom - (y + insets.top));
const estimatedHeight = estimateTextHeight(ctx, paragraphs, defaultFontSize, textW);
const anchor = bodyPr?.getAttribute('anchor');
const anchorOffset =
anchor === 'b'
? Math.max(0, textH - estimatedHeight)
: anchor === 'ctr'
? Math.max(0, (textH - estimatedHeight) / 2)
: 0;
const textY = y + insets.top + anchorOffset;
let cursorY = textY;
for (let pi = 0; pi < paragraphs.length; pi++) {
const para = paragraphs[pi];
const align = paragraphAlign(para, ph);
const { paragraphStyle, textParts } = paragraphTextParts(para, defaultFontSize);
const maxFontPt = textParts.reduce(
(max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max),
paragraphStyle.fontPt
);
const lineHeight = maxFontPt * 1.4;
cursorY += maxFontPt;
let line: TextToken[] = [];
let lineWidth = 0;
const flushLine = () => {
if (line.length === 0) return;
if (cursorY <= textBottom) {
drawTextLine(ctx, line, align, textX, cursorY, textW);
}
line = [];
lineWidth = 0;
cursorY += lineHeight;
};
if (textParts.length === 0) {
cursorY += lineHeight;
continue;
}
for (const part of textParts) {
if ('newline' in part) {
if (line.length > 0) flushLine();
else cursorY += lineHeight;
continue;
}
const { text, style } = part;
ctx.font = fontString(style);
ctx.textBaseline = 'alphabetic';
const words = text.split(/(\n|[^\S\n]+)/);
for (const word of words) {
if (word === '') continue;
if (word === '\n') {
flushLine();
continue;
}
if (cursorY > textBottom) break;
const width = ctx.measureText(word).width;
if (lineWidth + width > textW && line.length > 0) {
flushLine();
if (word.trim() === '') continue;
}
if (line.length === 0 && word.trim() === '') continue;
line.push({ ...style, text: word, width });
lineWidth += width;
}
}
flushLine();
cursorY -= lineHeight * 0.6;
}
ctx.restore();
};
const renderTable = (ctx: CanvasRenderingContext2D, frame: Element, rect: Rect) => {
const table = frame.getElementsByTagName('a:tbl')[0];
if (!table) return false;
const grid = table.getElementsByTagName('a:tblGrid')[0];
const colEmus = Array.from(grid?.getElementsByTagName('a:gridCol') ?? []).map((col) =>
Math.max(1, parseEmu(col.getAttribute('w')))
);
const rows = Array.from(table.getElementsByTagName('a:tr'));
if (colEmus.length === 0 || rows.length === 0) return true;
const rowEmus = rows.map((row) => Math.max(1, parseEmu(row.getAttribute('h'))));
const colTotal = colEmus.reduce((sum, val) => sum + val, 0);
const rowTotal = rowEmus.reduce((sum, val) => sum + val, 0);
const colWidths = colEmus.map((val) => (val / colTotal) * rect.w);
const rowHeights = rowEmus.map((val) => (val / rowTotal) * rect.h);
let cy = rect.y;
for (let ri = 0; ri < rows.length; ri++) {
const row = rows[ri];
const cells = Array.from(row.children).filter((child) => getTag(child) === 'tc');
let cx = rect.x;
for (let ci = 0; ci < colWidths.length; ci++) {
const cw = colWidths[ci];
const ch = rowHeights[ri];
const cell = cells[ci];
if (cell) {
const tcPr = cell.getElementsByTagName('a:tcPr')[0];
const fill = solidFillColor(tcPr);
if (fill) {
ctx.fillStyle = fill;
ctx.fillRect(cx, cy, cw, ch);
}
ctx.strokeStyle = 'rgba(17, 24, 39, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy + ch);
ctx.lineTo(cx + cw, cy + ch);
ctx.stroke();
const txBody = cell.getElementsByTagName('a:txBody')[0];
if (txBody) {
const marL = tcPr?.hasAttribute('marL')
? emuToPx(parseEmu(tcPr.getAttribute('marL')))
: 4;
const marR = tcPr?.hasAttribute('marR')
? emuToPx(parseEmu(tcPr.getAttribute('marR')))
: 4;
const marT = tcPr?.hasAttribute('marT')
? emuToPx(parseEmu(tcPr.getAttribute('marT')))
: 2;
const marB = tcPr?.hasAttribute('marB')
? emuToPx(parseEmu(tcPr.getAttribute('marB')))
: 2;
renderTextBody(ctx, txBody, { x: cx, y: cy, w: cw, h: ch }, 10, null, {
left: marL,
right: marR,
top: marT,
bottom: marB
});
}
}
cx += cw;
}
cy += rowHeights[ri];
}
return true;
};
const chartPointValues = (parent: Element, tag: string) => {
const container = parent.getElementsByTagName(tag)[0];
const pts = Array.from(container?.getElementsByTagName('c:pt') ?? []);
return pts
.sort((a, b) => parseInt(a.getAttribute('idx') ?? '0') - parseInt(b.getAttribute('idx') ?? '0'))
.map((pt) => pt.getElementsByTagName('c:v')[0]?.textContent ?? '');
};
const chartSeries = (chartDoc: Document, chartType: 'bar' | 'line'): ChartSeries[] => {
const chartEl = chartDoc.getElementsByTagName(
chartType === 'bar' ? 'c:barChart' : 'c:lineChart'
)[0];
if (!chartEl) return [];
const colors = ['#4472c4', '#70ad47', '#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000'];
return Array.from(chartEl.getElementsByTagName('c:ser')).map((ser, index) => {
const name = chartPointValues(ser, 'c:tx')[0] || `Series ${index + 1}`;
const categories = chartPointValues(ser, 'c:cat');
const values = chartPointValues(ser, 'c:val').map((value) => Number(value) || 0);
return {
name,
categories,
values,
color: solidFillColor(ser.getElementsByTagName('c:spPr')[0]) ?? colors[index % colors.length]
};
});
};
const renderChart = (ctx: CanvasRenderingContext2D, chartDoc: Document, rect: Rect) => {
const type = chartDoc.getElementsByTagName('c:barChart')[0]
? 'bar'
: chartDoc.getElementsByTagName('c:lineChart')[0]
? 'line'
: null;
if (!type) return false;
const series = chartSeries(chartDoc, type);
const categories = series[0]?.categories ?? [];
if (series.length === 0 || categories.length === 0) return true;
const values = series.flatMap((item) => item.values);
const maxValue = Math.max(1, ...values) * 1.15;
const left = rect.x + 42;
const top = rect.y + 28;
const right = rect.x + rect.w - 14;
const bottom = rect.y + rect.h - 34;
const plotW = Math.max(1, right - left);
const plotH = Math.max(1, bottom - top);
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeStyle = 'rgba(17, 24, 39, 0.16)';
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.font = '9px Arial, sans-serif';
ctx.fillStyle = '#6b7280';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const value = (maxValue / 4) * i;
const y = bottom - (value / maxValue) * plotH;
ctx.strokeStyle = i === 0 ? '#9ca3af' : 'rgba(17, 24, 39, 0.08)';
ctx.beginPath();
ctx.moveTo(left, y);
ctx.lineTo(right, y);
ctx.stroke();
ctx.fillText(Math.round(value).toString(), left - 6, y);
}
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
categories.forEach((category, index) => {
const x = left + ((index + 0.5) / categories.length) * plotW;
ctx.fillStyle = '#6b7280';
ctx.fillText(category, x, bottom + 7);
});
if (type === 'bar') {
const groupW = plotW / categories.length;
const barW = (groupW * 0.7) / Math.max(1, series.length);
series.forEach((item, si) => {
ctx.fillStyle = item.color;
item.values.forEach((value, vi) => {
const x = left + vi * groupW + groupW * 0.15 + si * barW;
const h = (value / maxValue) * plotH;
const y = bottom - h;
ctx.fillRect(x, y, Math.max(1, barW - 2), h);
ctx.fillStyle = '#111827';
ctx.fillText(String(value), x + barW / 2, y - 12);
ctx.fillStyle = item.color;
});
});
} else {
series.forEach((item) => {
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = 2;
ctx.beginPath();
item.values.forEach((value, vi) => {
const x = left + ((vi + 0.5) / categories.length) * plotW;
const y = bottom - (value / maxValue) * plotH;
if (vi === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
item.values.forEach((value, vi) => {
const x = left + ((vi + 0.5) / categories.length) * plotW;
const y = bottom - (value / maxValue) * plotH;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111827';
ctx.fillText(String(value), x, y - 14);
ctx.fillStyle = item.color;
});
});
}
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let legendX = rect.x + rect.w * 0.48;
const legendY = rect.y + 13;
series.forEach((item) => {
ctx.fillStyle = item.color;
ctx.fillRect(legendX, legendY - 3, 6, 6);
ctx.fillStyle = '#6b7280';
ctx.fillText(item.name, legendX + 9, legendY);
legendX += ctx.measureText(item.name).width + 24;
});
ctx.restore();
return true;
};
const renderConnector = (ctx: CanvasRenderingContext2D, shape: Element, rect: Rect) => {
const spPr = shape.getElementsByTagName('p:spPr')[0];
const line = spPr?.getElementsByTagName('a:ln')[0];
ctx.save();
ctx.strokeStyle = solidFillColor(line) ?? '#9ca3af';
ctx.lineWidth = Math.max(1, emuToPx(parseEmu(line?.getAttribute('w') ?? '9525')));
ctx.beginPath();
ctx.moveTo(rect.x, rect.y);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h);
ctx.stroke();
ctx.restore();
};
/** Load a data URI into an Image element and wait for it. */
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
@ -370,6 +927,7 @@ export async function pptxToImages(
): Promise<{ images: string[]; width: number; height: number }> {
const JSZip = (await import('jszip')).default;
const zip = (await JSZip.loadAsync(buffer)) as ZipLike;
activeThemeColors = await readThemeColors(zip);
// ── Read slide dimensions from presentation.xml ──────────────────
let slideW = 960;
@ -450,7 +1008,7 @@ export async function pptxToImages(
}
const shapes = Array.from(spTree.children).filter((el) =>
['sp', 'pic'].includes(getTag(el) ?? '')
['sp', 'pic', 'graphicFrame', 'cxnSp'].includes(getTag(el) ?? '')
);
for (const shape of shapes) {
@ -462,13 +1020,24 @@ export async function pptxToImages(
const { x, y, w, h } = rect;
if (w === 0 && h === 0) continue;
const tag = getTag(shape);
if (getTag(shape) === 'sp') {
const shapeFill = solidFillColor(shape.getElementsByTagName('p:spPr')[0]);
if (shapeFill) {
ctx.fillStyle = shapeFill;
ctx.fillRect(x, y, w, h);
}
if (tag === 'cxnSp') {
renderConnector(ctx, shape, rect);
continue;
}
if (tag === 'graphicFrame') {
if (renderTable(ctx, shape, rect)) continue;
const chartRelId = shape.getElementsByTagName('c:chart')[0]?.getAttribute('r:id') ?? '';
const chartPath = rels[chartRelId]?.target;
const chartDoc = chartPath ? await readXml(zip, chartPath) : null;
if (chartDoc && renderChart(ctx, chartDoc, rect)) continue;
}
if (tag === 'sp') {
renderFill(ctx, shape.getElementsByTagName('p:spPr')[0], rect);
}
// ── Picture ──────────────────────────────────────────────
@ -495,104 +1064,8 @@ export async function pptxToImages(
const txBody = shape.getElementsByTagName('p:txBody')[0];
if (!txBody) continue;
ctx.save();
ctx.rect(x, y, w, h);
ctx.clip();
const paragraphs = txBody.getElementsByTagName('a:p');
const insets = textBodyInsets(txBody);
const textX = x + insets.left;
const textY = y + insets.top;
const textW = Math.max(1, w - insets.left - insets.right);
const textBottom = y + h - insets.bottom;
let cursorY = textY;
const defaultFontSize = placeholderFontSize(ph ?? placeholder ?? null);
for (let pi = 0; pi < paragraphs.length; pi++) {
const para = paragraphs[pi];
const align = paragraphAlign(para, ph ?? placeholder ?? null);
const paragraphStyle = readParagraphStyle(para, defaultFontSize);
const textParts: Array<{ text: string; style: TextStyle } | { newline: true }> = [];
const bullet = paragraphBullet(para);
for (const child of Array.from(para.children)) {
const tag = getTag(child);
if (tag === 'br') {
textParts.push({ newline: true });
continue;
}
if (tag !== 'r' && tag !== 'fld') continue;
const style = tag === 'r' ? readRunStyle(child, paragraphStyle) : paragraphStyle;
const text = child.getElementsByTagName('a:t')[0]?.textContent ?? '';
if (text) textParts.push({ text, style });
}
if (bullet && textParts.some((part) => 'text' in part && part.text.trim())) {
textParts.unshift({ text: bullet, style: paragraphStyle });
}
const maxFontPt = textParts.reduce(
(max, part) => ('text' in part ? Math.max(max, part.style.fontPt) : max),
paragraphStyle.fontPt
);
const lineHeight = maxFontPt * 1.4;
cursorY += maxFontPt; // baseline offset
let line: TextToken[] = [];
let lineWidth = 0;
const flushLine = () => {
if (line.length === 0) return;
if (cursorY <= textBottom) {
drawTextLine(ctx, line, align, textX, cursorY, textW);
}
line = [];
lineWidth = 0;
cursorY += lineHeight;
};
if (textParts.length === 0) {
cursorY += lineHeight;
continue;
}
for (const part of textParts) {
if ('newline' in part) {
if (line.length > 0) flushLine();
else cursorY += lineHeight;
continue;
}
const { text, style } = part;
ctx.font = fontString(style);
ctx.textBaseline = 'alphabetic';
// Simple word-wrap within the shape bounds
const words = text.split(/(\n|[^\S\n]+)/);
for (const word of words) {
if (word === '') continue;
if (word === '\n') {
flushLine();
continue;
}
if (cursorY > textBottom) break;
const width = ctx.measureText(word).width;
if (lineWidth + width > textW && line.length > 0) {
flushLine();
if (word.trim() === '') continue;
}
if (line.length === 0 && word.trim() === '') continue;
line.push({ ...style, text: word, width });
lineWidth += width;
}
}
flushLine();
cursorY -= lineHeight * 0.6; // paragraph spacing
}
ctx.restore();
renderTextBody(ctx, txBody, rect, defaultFontSize, ph ?? placeholder ?? null);
}
images.push(canvas.toDataURL('image/png'));