feat(agents): добавлены skills для PDF, docs, slides, jupyter и spreadsheet

- Зачем:
  - расширены возможности AI-ассистентов для работы с документами и презентациями
- Что:
  - добавлен skill pdf для работы с PDF файлами
  - добавлен skill doc для работы с Word документами
  - добавлен skill slides для создания презентаций PowerPoint
  - добавлен skill jupyter-notebook для работы с Jupyter ноутбуками
  - добавлен skill spreadsheet для работы с таблицами (Excel/CSV)
  - изменена модель Claude с sonnet на opus
- Проверка:
  - проверка доступности skills в AI-ассистентах
This commit is contained in:
ddadmin committed 2026-03-13 23:35:45 +03:00
1 parent bd06d5734c
commit 0009270c6e
53 files changed
+6773 -1

No files matched your search

@@ -0,0 +1,104 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const fs = require("fs");
const Prism = require("prismjs");
let THEME_MAP;
function loadPrismLanguage(lang) {
const normalized = String(lang || "plaintext").toLowerCase();
const known = new Set([
"markup",
"html",
"xml",
"svg",
"mathml",
"css",
"clike",
"javascript",
"js",
"typescript",
"ts",
"python",
"py",
"bash",
"sh",
"json",
"yaml",
"yml",
]);
const map = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
html: "markup",
xml: "markup",
};
const id = map[normalized] || normalized;
if (!Prism.languages[id]) {
try {
require(`prismjs/components/prism-${id}`);
} catch (_e) {}
}
return Prism.languages[id] || Prism.languages.plain || {};
}
function buildThemeMap(themeCssModule = "prismjs/themes/prism-okaidia.css") {
try {
const css = fs.readFileSync(require.resolve(themeCssModule), "utf8");
return Object.fromEntries(
[
...css.matchAll(
/\.token\.([\w-]+)[^{]*\{[^}]*color:\s*([^;\s]+)[^}]*\}/g
),
].map(([, t, c]) => [t, c.replace(/#|!important/g, "").trim()])
);
} catch (err) {
return { plain: "FFFFFF", comment: "999999" };
}
}
function getThemeMap() {
if (!THEME_MAP) THEME_MAP = buildThemeMap();
return THEME_MAP;
}
function run(text, type = "plain") {
const theme = getThemeMap();
return {
text,
options: {
fontFace: "Consolas",
color: theme[type] || theme.plain || "FFFFFF",
fontSize: 14,
},
};
}
function tokensToRuns(tokens) {
return tokens.flatMap((t) =>
typeof t === "string"
? [run(t)]
: Array.isArray(t.content)
? tokensToRuns(t.content)
: [run(t.content, t.type)]
);
}
function codeToRuns(code, lang) {
const grammar = loadPrismLanguage(lang);
const lines = String(code || "").split("\n");
const pad = lines.length.toString().length;
return lines.flatMap((line, i) => [
run(`${(i + 1).toString().padStart(pad, " ")} `, "comment"),
...tokensToRuns(Prism.tokenize(line, grammar)),
...(i < lines.length - 1 ? [run("\n")] : []),
]);
}
module.exports = {
codeToRuns,
buildThemeMap,
};
@@ -0,0 +1,333 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const fs = require("fs");
// Accept either a filesystem path, a data URI, raw SVG string, or a Buffer
// and normalize to a Buffer for type/size probing.
function readInputAsBuffer(source) {
if (!source) throw new Error("Image source is empty");
if (Buffer.isBuffer(source)) return { buffer: source, type: "buffer" };
if (typeof source === "string") {
// data URI (we primarily emit base64 data URIs for SVG via helpers)
if (source.startsWith("data:")) {
const type = "dataUri";
const comma = source.indexOf(",");
const payload = comma !== -1 ? source.slice(comma + 1) : source;
// Our helpers use base64; if not, try URI decode then treat as raw text
try {
return { buffer: Buffer.from(payload, "base64"), type: type };
} catch (_e) {
try {
return {
buffer: Buffer.from(decodeURIComponent(payload), "utf8"),
type: type,
};
} catch (_e2) {
return { buffer: Buffer.from(payload, "utf8"), type: type };
}
}
}
// Raw inline SVG string
if (source.includes("<svg")) {
return { buffer: Buffer.from(source, "utf8"), type: "rawSvg" };
}
// Treat as filesystem path
return { buffer: fs.readFileSync(source), type: "path" };
}
throw new Error("Unsupported image source type");
}
function isPng(buf) {
return (
buf.length >= 24 &&
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47 &&
buf[4] === 0x0d &&
buf[5] === 0x0a &&
buf[6] === 0x1a &&
buf[7] === 0x0a
);
}
function isJpeg(buf) {
return (
buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff
);
}
function isGif(buf) {
return (
buf.length >= 10 &&
buf[0] === 0x47 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x38 &&
(buf[4] === 0x39 || buf[4] === 0x37) &&
buf[5] === 0x61
);
}
function isWebp(buf) {
return (
buf.length >= 16 &&
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
);
}
function isSvg(buf) {
const head = buf.slice(0, 200).toString("utf8");
return head.includes("<svg");
}
function readPngSize(buf) {
// IHDR chunk: width/height at offset 16, big-endian
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
return { width, height, type: "png" };
}
function readGifSize(buf) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
return { width, height, type: "gif" };
}
function readWebpSize(buf) {
// Handle VP8X, VP8L, VP8
// Reference: https://developers.google.com/speed/webp/docs/riff_container
const riffSize = buf.readUInt32LE(4) + 8;
let offset = 12; // start of first chunk
while (offset + 8 <= riffSize && offset + 8 <= buf.length) {
const chunkTag = buf.slice(offset, offset + 4).toString("ascii");
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkTag === "VP8X") {
// Canvas size stored at bytes 12..17 (6 bytes), 24 bits each minus 1
const wMinus1 = buf.readUIntLE(offset + 12, 3);
const hMinus1 = buf.readUIntLE(offset + 15, 3);
return { width: wMinus1 + 1, height: hMinus1 + 1, type: "webp" };
}
if (chunkTag === "VP8 ") {
// Lossy bitstream: frame header at start of data
// Parse minimally for width/height
const start = offset + 8;
if (start + 10 < buf.length) {
const width = buf.readUInt16LE(start + 6) & 0x3fff;
const height = buf.readUInt16LE(start + 8) & 0x3fff;
return { width, height, type: "webp" };
}
}
if (chunkTag === "VP8L") {
// Lossless bitstream: 14-bit width/height encoded
const start = offset + 8;
if (start + 5 <= buf.length) {
const b0 = buf[start + 1];
const b1 = buf[start + 2];
const b2 = buf[start + 3];
const b3 = buf[start + 4];
const width = 1 + (((b1 & 0x3f) << 8) | b0);
const height =
1 + (((b3 & 0xf) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
return { width, height, type: "webp" };
}
}
offset += 8 + ((chunkSize + 1) & ~1); // chunks are padded to even size
}
throw new Error("Unsupported WEBP variant for size detection");
}
function readJpegSize(buf) {
let offset = 2;
while (offset < buf.length) {
if (buf[offset] !== 0xff) {
offset++;
continue;
}
const marker = buf[offset + 1];
// SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
const blockLength = buf.readUInt16BE(offset + 2);
const height = buf.readUInt16BE(offset + 5);
const width = buf.readUInt16BE(offset + 7);
return { width, height, type: "jpeg" };
}
const blockLength = buf.readUInt16BE(offset + 2);
if (!Number.isFinite(blockLength) || blockLength < 2) break;
offset += 2 + blockLength;
}
throw new Error("JPEG size not found");
}
function parseSvgSize(buf) {
const text = buf.toString("utf8");
const a = text.indexOf("<svg");
const b = text.indexOf("</svg>");
const inner = a !== -1 && b !== -1 ? text.slice(a, b + 6) : text;
const widthMatch = inner.match(/\bwidth\s*=\s*"([^"]+)"/i);
const heightMatch = inner.match(/\bheight\s*=\s*"([^"]+)"/i);
const viewBoxMatch = inner.match(/\bviewBox\s*=\s*"([^"]+)"/i);
function toPx(v) {
if (!v) return null;
const m = String(v)
.trim()
.match(/([0-9.]+)\s*(px|pt|em|ex|cm|mm|in|%)?/i);
if (!m) return null;
const n = parseFloat(m[1]);
const unit = (m[2] || "px").toLowerCase();
const dpi = 96;
switch (unit) {
case "px":
return n;
case "pt":
return (n * dpi) / 72;
case "in":
return n * dpi;
case "cm":
return (n * dpi) / 2.54;
case "mm":
return (n * dpi) / 25.4;
case "em":
case "ex":
return n * 16; // rough fallback
default:
return null;
}
}
let widthPx = widthMatch ? toPx(widthMatch[1]) : null;
let heightPx = heightMatch ? toPx(heightMatch[1]) : null;
if ((widthPx == null || heightPx == null) && viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
if (parts.length === 4) {
const vbw = parts[2];
const vbh = parts[3];
if (!widthPx && vbh) widthPx = vbw;
if (!heightPx && vbw) heightPx = vbh;
}
}
if (!widthPx || !heightPx) {
// Fallback if sizes missing
widthPx = widthPx || 100;
heightPx = heightPx || 100;
}
return { width: widthPx, height: heightPx, type: "svg" };
}
function getImageDimensions(pathOrData) {
const { buffer: buf, type } = readInputAsBuffer(pathOrData);
let meta;
if (isPng(buf)) meta = readPngSize(buf);
else if (isJpeg(buf)) meta = readJpegSize(buf);
else if (isGif(buf)) meta = readGifSize(buf);
else if (isWebp(buf)) meta = readWebpSize(buf);
else if (isSvg(buf)) meta = parseSvgSize(buf);
else {
const suffix =
type === "path" && typeof pathOrData === "string"
? ` (path: ${pathOrData})`
: "";
throw new Error("Unsupported image format for provided source" + suffix);
}
const aspectRatio =
meta.width > 0 && meta.height > 0 ? meta.width / meta.height : 1;
return {
width: meta.width,
height: meta.height,
aspectRatio,
type: meta.type,
};
}
function imageSizingCrop(source, x, y, w, h, cx, cy, cw, ch) {
const { aspectRatio } = getImageDimensions(source);
const boxAspect = w / h;
if (
cx === undefined ||
cy === undefined ||
cw === undefined ||
ch === undefined
) {
let cropXFrac, cropYFrac, cropWFrac, cropHFrac;
if (aspectRatio >= boxAspect) {
cropHFrac = 1;
cropWFrac = boxAspect / aspectRatio;
cropXFrac = (1 - cropWFrac) / 2;
cropYFrac = 0;
} else {
cropWFrac = 1;
cropHFrac = aspectRatio / boxAspect;
cropXFrac = 0;
cropYFrac = (1 - cropHFrac) / 2;
}
cx = cropXFrac;
cy = cropYFrac;
cw = cropWFrac;
ch = cropHFrac;
}
let virtualW = w / cw;
let virtualH = virtualW / aspectRatio;
const eps = 1e-6;
if (Math.abs(virtualH * ch - h) > eps) {
virtualH = h / ch;
virtualW = virtualH * aspectRatio;
}
const cropXIn = cx * virtualW;
const cropYIn = cy * virtualH;
return {
x,
y,
w: virtualW,
h: virtualH,
sizing: {
type: "crop",
x: cropXIn,
y: cropYIn,
w: w,
h: h,
},
};
}
function imageSizingContain(source, x, y, w, h) {
const { aspectRatio } = getImageDimensions(source);
let w2, h2;
const boxAspect = w / h;
if (aspectRatio >= boxAspect) {
w2 = w;
h2 = w2 / aspectRatio;
} else {
h2 = h;
w2 = h2 * aspectRatio;
}
return {
x: x + (w - w2) / 2,
y: y + (h - h2) / 2,
w: w2,
h: h2,
};
}
module.exports = {
getImageDimensions,
imageSizingCrop,
imageSizingContain,
};
@@ -0,0 +1,33 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const VERSION = "1.2.0";
const text = require("./text");
const image = require("./image");
const svg = require("./svg");
const latex = require("./latex");
const code = require("./code");
const layout = require("./layout");
const layoutBuilders = require("./layout_builders");
const util = require("./util");
module.exports = {
VERSION,
// text layout
...text,
// images
...image,
// svg helpers
...svg,
// LaTeX -> SVG
...latex,
// code block -> pptx text runs
...code,
// slide layout analyzers
...layout,
// slide layout builders
...layoutBuilders,
// text layout helpers and utilities
...util,
};
@@ -0,0 +1,51 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
let _mathjax;
let _adaptor;
let _doc;
function ensureMathJax() {
if (_mathjax && _adaptor && _doc) return;
try {
const { mathjax } = require("mathjax-full/js/mathjax.js");
const { TeX } = require("mathjax-full/js/input/tex.js");
const { SVG } = require("mathjax-full/js/output/svg.js");
const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js");
const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js");
const { AllPackages } = require("mathjax-full/js/input/tex/AllPackages.js");
_adaptor = liteAdaptor();
RegisterHTMLHandler(_adaptor);
const tex = new TeX({ packages: AllPackages });
const out = new SVG({ fontCache: "local" });
_doc = mathjax.document("", { InputJax: tex, OutputJax: out });
_mathjax = mathjax;
} catch (err) {
throw new Error(
"mathjax-full is not installed. Run `npm i mathjax-full` or avoid latexToSvgDataUri()."
);
}
}
function latexToSvgDataUri(latex, display = true) {
ensureMathJax();
const html = _adaptor.outerHTML(_doc.convert(latex, { display }));
const a = html.indexOf("<svg");
const b = html.indexOf("</svg>");
let svg = a !== -1 && b !== -1 ? html.slice(a, b + 6) : html;
svg = svg.replace(/<\?xml[^>]*>/g, "");
if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(svg)) {
svg = svg.replace(/<svg /, '<svg xmlns="http://www.w3.org/2000/svg" ');
}
svg = svg.replace(/(width|height)=\"([0-9.]+)(ex|em)\"/g, (_m, attr, num) => {
const px = Math.round(parseFloat(num) * 8.5);
return `${attr}="${px}px"`;
});
svg = svg.replace(/currentColor/g, "#000000");
return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64");
}
module.exports = {
latexToSvgDataUri,
};
@@ -0,0 +1,643 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
function inferElementType(obj) {
if (!obj) return "unknown";
const data = obj.data || obj.options || {};
// Distinguish lines explicitly via type only. Many objects have a 'line' style; don't misclassify those.
if (obj.type === "line") return "line";
if (obj.type && typeof obj.type === "string") return obj.type;
if (obj.text || typeof data.text === "string") return "text";
if (data.path || obj.image) return "image";
if (data.chartType) return "chart";
if (data.shape || data.line) return "shape";
if (data.mediaType) return "media";
if (data.table || Array.isArray(data.rows)) return "table";
if (data.smartArt) return "smartart";
return "unknown";
}
const TEXT_OVERLAP_ERROR_THRESHOLD = 0.1;
const RECTIFY_DIRECTION_EQUALITY_TOLERANCE = 0.15;
function warnIfSlideHasOverlaps(slide, pptx, options = {}) {
if (!slide || !Array.isArray(slide._slideObjects)) {
console.warn("Invalid slide object passed to warnIfSlideOverlaps()");
return;
}
const opts = {
// By default, containment cases are very common (e.g., full-slide backgrounds)
// and usually not actionable. Mute them unless explicitly requested.
muteContainment:
options.muteContainment !== undefined ? options.muteContainment : true,
// Do NOT ignore lines or decorative shapes by default; users want true overlaps.
ignoreLines:
options.ignoreLines !== undefined ? options.ignoreLines : false,
ignoreDecorativeShapes:
options.ignoreDecorativeShapes !== undefined
? options.ignoreDecorativeShapes
: false,
};
const slideIndex =
pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1;
const slideLabel =
slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)";
const formatElement = (el) => {
const cx = (el.x + el.w / 2).toFixed(3);
const cy = (el.y + el.h / 2).toFixed(3);
return `element ${el.index} (${el.type}, center_x=${cx}, center_y=${cy})`;
};
const elements = slide._slideObjects.map((obj, i) => {
const {
x = 0,
y = 0,
w = 0,
h = 0,
fill,
line,
} = obj.data || obj.options || {};
const type = inferElementType(obj);
const isDecorative = (() => {
if (!opts.ignoreDecorativeShapes) return false;
// Border rectangles used as frames: transparent fill (or fully transparent) with a stroke
const transparency =
typeof fill?.transparency === "number" ? fill.transparency : null;
const hasOnlyBorder = !!line && (!fill || transparency !== null);
const fullyTransparent = transparency !== null && transparency >= 99;
return type === "shape" && hasOnlyBorder && fullyTransparent;
})();
const ignorable = (opts.ignoreLines && type === "line") || isDecorative;
return { index: i, type, x, y, w, h, ignorable };
});
let overlapCount = 0;
let containmentCount = 0;
for (let i = 0; i < elements.length; i++) {
const a = elements[i];
if (a.ignorable) continue;
for (let j = i + 1; j < elements.length; j++) {
const b = elements[j];
if (b.ignorable) continue;
const comparison = compareElementPosition(slide, a.index, b.index);
if (comparison.relation === "overlapping") {
// Special-case: diagonal line's bounding box overlapping a rectangle is often a false positive.
const EPS = 1e-6;
const getBounds = (e) => ({
x: e.x,
y: e.y,
x2: e.x + e.w,
y2: e.y + e.h,
});
const lineRectFalsePositive = (() => {
const oneIsLine = (a.type === "line") ^ (b.type === "line");
if (!oneIsLine) return false;
const line = a.type === "line" ? a : b;
const rect = a.type === "line" ? b : a;
// If line is diagonal, verify actual segment intersects rect; if not, ignore.
const isDiagonal = line.w > EPS && line.h > EPS;
const lineSeg = {
x1: line.x,
y1: line.y,
x2: line.x + line.w,
y2: line.y + line.h,
};
const rectB = getBounds(rect);
const pointInRect = (px, py, rb) =>
px >= rb.x - EPS &&
px <= rb.x2 + EPS &&
py >= rb.y - EPS &&
py <= rb.y2 + EPS;
const segsIntersect = (p1, p2, q1, q2) => {
const cross = (ax, ay, bx, by) => ax * by - ay * bx;
const d1x = p2.x - p1.x,
d1y = p2.y - p1.y;
const d2x = q2.x - q1.x,
d2y = q2.y - q1.y;
const denom = cross(d1x, d1y, d2x, d2y);
if (Math.abs(denom) < EPS) {
// Parallel: check colinearity and overlapping projections
const crossCol = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y);
if (Math.abs(crossCol) > EPS) return false;
const proj = (a, b, c) =>
Math.min(Math.max(a, b), Math.max(Math.min(a, b), c));
const overlapX = !(
Math.max(p1.x, p2.x) < Math.min(q1.x, q2.x) - EPS ||
Math.max(q1.x, q2.x) < Math.min(p1.x, p2.x) - EPS
);
const overlapY = !(
Math.max(p1.y, p2.y) < Math.min(q1.y, q2.y) - EPS ||
Math.max(q1.y, q2.y) < Math.min(p1.y, p2.y) - EPS
);
return overlapX && overlapY;
}
const t = cross(q1.x - p1.x, q1.y - p1.y, d2x, d2y) / denom;
const u = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y) / denom;
return t >= -EPS && t <= 1 + EPS && u >= -EPS && u <= 1 + EPS;
};
const intersectsRect = (seg, rb) => {
if (
pointInRect(seg.x1, seg.y1, rb) ||
pointInRect(seg.x2, seg.y2, rb)
)
return true;
const r1 = { x: rb.x, y: rb.y },
r2 = { x: rb.x2, y: rb.y },
r3 = { x: rb.x2, y: rb.y2 },
r4 = { x: rb.x, y: rb.y2 };
const p1 = { x: seg.x1, y: seg.y1 },
p2 = { x: seg.x2, y: seg.y2 };
return (
segsIntersect(p1, p2, r1, r2) ||
segsIntersect(p1, p2, r2, r3) ||
segsIntersect(p1, p2, r3, r4) ||
segsIntersect(p1, p2, r4, r1)
);
};
return isDiagonal && !intersectsRect(lineSeg, rectB);
})();
if (!lineRectFalsePositive) {
overlapCount++;
const severeTextOverlap = (() => {
if (!comparison.intersection) return false;
const exceedsThreshold = (element) =>
element.type === "text" &&
comparison.intersection.w >= TEXT_OVERLAP_ERROR_THRESHOLD &&
comparison.intersection.h >= TEXT_OVERLAP_ERROR_THRESHOLD;
return exceedsThreshold(a) || exceedsThreshold(b);
})();
if (severeTextOverlap) {
const overlapW = comparison.intersection.w;
const overlapH = comparison.intersection.h;
let rectificationSuggestion = "";
if (overlapW > EPS && overlapH > EPS) {
const maxOverlap = Math.max(overlapW, overlapH);
const diffRatio = Math.abs(overlapW - overlapH) / maxOverlap;
const directions = [];
// Attempt to determine the primary direction of the overlap. This is the direction
// in which the overlap is smaller (and so requires the smallest adjustment to rectify).
if (diffRatio <= RECTIFY_DIRECTION_EQUALITY_TOLERANCE) {
directions.push("horizontally", "vertically");
} else if (overlapW < overlapH) {
directions.push("horizontally");
} else {
directions.push("vertically");
}
rectificationSuggestion = `Suggestion: reposition elements ${directions.join(
" and "
)}.`;
}
console.error(
`❌ ${slideLabel}: Severe text overlap detected between ${formatElement(
a
)} and ${formatElement(
b
)} (overlap_horizontal=${comparison.intersection.w.toFixed(
3
)}, overlap_vertical=${comparison.intersection.h.toFixed(
3
)}). THIS MUST BE FIXED. ${rectificationSuggestion}`
);
} else {
console.warn(
`⚠️ ${slideLabel}: Overlap detected between ${formatElement(
a
)} and ${formatElement(b)}.`
);
}
}
} else if (comparison.relation === "contained") {
if (!opts.muteContainment) {
containmentCount++;
const container = elements[comparison.containerIndex];
const contained = elements[comparison.containedIndex];
console.warn(
`⚠️ ${slideLabel}: ${formatElement(
contained
)} is fully contained within ${formatElement(container)}`
);
} else {
// Still count internally when muted? We keep for summary only when un-muted
}
}
}
}
if (!(overlapCount === 0 && (!containmentCount || opts.muteContainment))) {
const issues = [];
if (overlapCount > 0) issues.push(`${overlapCount} overlapping pair(s)`);
if (!opts.muteContainment && containmentCount > 0)
issues.push(`${containmentCount} containment case(s)`);
console.log(`⚠️ ${slideLabel}: Found ${issues.join(" and ")}.`);
}
}
function compareElementPosition(slide, firstIndex, secondIndex) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to compareElementPosition()");
}
if (
typeof firstIndex !== "number" ||
typeof secondIndex !== "number" ||
!Number.isInteger(firstIndex) ||
!Number.isInteger(secondIndex)
) {
throw new Error("Element indices must be integer values.");
}
const elements = slide._slideObjects;
if (
firstIndex < 0 ||
firstIndex >= elements.length ||
secondIndex < 0 ||
secondIndex >= elements.length
) {
throw new Error(
"Element index out of bounds for compareElementPosition()."
);
}
const EPS = 1e-4;
const getBounds = (obj) => {
const source = obj?.data || obj?.options || {};
let x = typeof source.x === "number" ? source.x : 0;
let y = typeof source.y === "number" ? source.y : 0;
let w = typeof source.w === "number" ? source.w : 0;
let h = typeof source.h === "number" ? source.h : 0;
if (source.sizing && source.sizing.type === "crop") {
if (typeof source.sizing.w === "number") w = source.sizing.w;
if (typeof source.sizing.h === "number") h = source.sizing.h;
}
return { x, y, w, h, x2: x + w, y2: y + h };
};
const boundsA = getBounds(elements[firstIndex]);
const boundsB = getBounds(elements[secondIndex]);
const separated =
boundsA.x2 < boundsB.x - EPS ||
boundsB.x2 < boundsA.x - EPS ||
boundsA.y2 < boundsB.y - EPS ||
boundsB.y2 < boundsA.y - EPS;
if (separated) {
return {
relation: "disjoint",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection: null,
};
}
const aContainsB =
boundsA.x <= boundsB.x + EPS &&
boundsA.y <= boundsB.y + EPS &&
boundsA.x2 >= boundsB.x2 - EPS &&
boundsA.y2 >= boundsB.y2 - EPS;
const bContainsA =
boundsB.x <= boundsA.x + EPS &&
boundsB.y <= boundsA.y + EPS &&
boundsB.x2 >= boundsA.x2 - EPS &&
boundsB.y2 >= boundsA.y2 - EPS;
const ix1 = Math.max(boundsA.x, boundsB.x);
const iy1 = Math.max(boundsA.y, boundsB.y);
const ix2 = Math.min(boundsA.x2, boundsB.x2);
const iy2 = Math.min(boundsA.y2, boundsB.y2);
const intersectionWidth = Math.max(0, ix2 - ix1);
const intersectionHeight = Math.max(0, iy2 - iy1);
const intersection =
intersectionWidth > EPS && intersectionHeight > EPS
? { x: ix1, y: iy1, w: intersectionWidth, h: intersectionHeight }
: null;
if (aContainsB && !bContainsA) {
return {
relation: "contained",
containerIndex: firstIndex,
containedIndex: secondIndex,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
if (bContainsA && !aContainsB) {
return {
relation: "contained",
containerIndex: secondIndex,
containedIndex: firstIndex,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
if (intersection) {
return {
relation: "overlapping",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
return {
relation: "touching",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection: null,
};
}
const VALID_ALIGNMENTS = new Set([
"left",
"right",
"top",
"bottom",
"verticallyCenter",
"horizontallyCenter",
]);
const getElementBounds = (obj) => {
const source = obj?.data || obj?.options || {};
let x = typeof source.x === "number" ? source.x : 0;
let y = typeof source.y === "number" ? source.y : 0;
let w = typeof source.w === "number" ? source.w : 0;
let h = typeof source.h === "number" ? source.h : 0;
// If an image is placed with crop sizing, pptxgenjs stores a larger virtual image w/h
// and a viewport in source.sizing.{w,h}. For visual overlap purposes, use the viewport.
if (source.sizing && source.sizing.type === "crop") {
if (typeof source.sizing.w === "number") w = source.sizing.w;
if (typeof source.sizing.h === "number") h = source.sizing.h;
}
return { x, y, w, h, x2: x + w, y2: y + h };
};
const setElementPosition = (obj, coords) => {
const ensureTarget = (targetObj) => {
if (!targetObj || typeof targetObj !== "object") return null;
return targetObj;
};
const targets = [];
const dataTarget = ensureTarget(obj.data);
if (dataTarget) targets.push(dataTarget);
const optionsTarget =
obj.options && obj.options !== obj.data ? ensureTarget(obj.options) : null;
if (optionsTarget) targets.push(optionsTarget);
if (targets.length === 0) {
obj.data = obj.data && typeof obj.data === "object" ? obj.data : {};
targets.push(obj.data);
}
targets.forEach((target) => {
if (coords.x !== undefined) target.x = coords.x;
if (coords.y !== undefined) target.y = coords.y;
});
};
const dimensionKeyPairs = [
["width", "height"],
["w", "h"],
["cx", "cy"],
["slideWidth", "slideHeight"],
["slideWidthInches", "slideHeightInches"],
["widthInches", "heightInches"],
];
const toNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const readDimensionsFromObject = (candidate, seen = new Set()) => {
if (!candidate || typeof candidate !== "object") return null;
if (seen.has(candidate)) return null;
seen.add(candidate);
for (const [wKey, hKey] of dimensionKeyPairs) {
const width = toNumber(candidate[wKey]);
const height = toNumber(candidate[hKey]);
if (width !== null && height !== null && width > 0 && height > 0) {
return { width, height };
}
}
const nestedKeys = ["size", "slideSize", "layout", "slideLayout"];
for (const key of nestedKeys) {
const nested = readDimensionsFromObject(candidate[key], seen);
if (nested) return nested;
}
return null;
};
const getSlideDimensions = (slide, pptx) => {
const candidates = [
slide?._presLayout,
slide?._slideLayout,
slide?._pres?.layout,
slide?._parent?.layout,
slide?._layout,
pptx?._presLayout,
pptx?._layout,
pptx?.layout,
pptx?.presLayout,
];
for (const candidate of candidates) {
const dims = readDimensionsFromObject(candidate);
if (dims) {
// Some internals are in EMUs; convert if values look too large for inches
const EMU_PER_IN = 914400;
const looksEmu = dims.width > 1000 || dims.height > 1000;
if (looksEmu) {
return {
width: dims.width / EMU_PER_IN,
height: dims.height / EMU_PER_IN,
source: "emu_converted",
};
}
return { ...dims, source: "detected" };
}
}
throw new Error(
"getSlideDimensions(): Unable to determine slide dimensions from pptxgenjs internals."
);
};
function alignSlideElements(slide, indices, alignment) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to alignSlideElements()");
}
if (!Array.isArray(indices) || indices.length === 0) {
throw new Error("indices must be a non-empty array.");
}
if (!VALID_ALIGNMENTS.has(alignment)) {
throw new Error(`Unsupported alignment option: ${alignment}`);
}
const uniqueIndices = [...new Set(indices)];
const elements = slide._slideObjects;
const selected = uniqueIndices.map((idx) => {
if (typeof idx !== "number" || !Number.isInteger(idx)) {
throw new Error("Element indices must be integers.");
}
if (idx < 0 || idx >= elements.length) {
throw new Error("Element index out of bounds for alignSlideElements().");
}
const obj = elements[idx];
const bounds = getElementBounds(obj);
return { index: idx, obj, bounds };
});
if (selected.length < 2) return;
const minX = Math.min(...selected.map((item) => item.bounds.x));
const maxX2 = Math.max(...selected.map((item) => item.bounds.x2));
const minY = Math.min(...selected.map((item) => item.bounds.y));
const maxY2 = Math.max(...selected.map((item) => item.bounds.y2));
const centerX = (minX + maxX2) / 2;
const centerY = (minY + maxY2) / 2;
selected.forEach(({ obj, bounds }) => {
const { w, h } = bounds;
switch (alignment) {
case "left":
setElementPosition(obj, { x: minX });
break;
case "right":
setElementPosition(obj, { x: maxX2 - w });
break;
case "top":
setElementPosition(obj, { y: minY });
break;
case "bottom":
setElementPosition(obj, { y: maxY2 - h });
break;
case "horizontallyCenter":
setElementPosition(obj, { x: centerX - w / 2 });
break;
case "verticallyCenter":
setElementPosition(obj, { y: centerY - h / 2 });
break;
default:
throw new Error(`Unhandled alignment option: ${alignment}`);
}
});
}
function distributeSlideElements(slide, indices, direction) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to distributeSlideElements()");
}
if (!Array.isArray(indices) || indices.length === 0) {
throw new Error("indices must be a non-empty array.");
}
if (direction !== "horizontal" && direction !== "vertical") {
throw new Error(`Unsupported distribution direction: ${direction}`);
}
const uniqueIndices = [...new Set(indices)];
if (uniqueIndices.length < 2) return;
const elements = slide._slideObjects;
const selected = uniqueIndices.map((idx) => {
if (typeof idx !== "number" || !Number.isInteger(idx)) {
throw new Error("Element indices must be integers.");
}
if (idx < 0 || idx >= elements.length) {
throw new Error(
"Element index out of bounds for distributeSlideElements()."
);
}
const obj = elements[idx];
const bounds = getElementBounds(obj);
return { index: idx, obj, bounds };
});
const axisStartKey = direction === "horizontal" ? "x" : "y";
const axisEndKey = direction === "horizontal" ? "x2" : "y2";
const sizeKey = direction === "horizontal" ? "w" : "h";
selected.sort((a, b) => {
const delta = a.bounds[axisStartKey] - b.bounds[axisStartKey];
return Math.abs(delta) > 1e-6 ? delta : a.index - b.index;
});
const minCoord = Math.min(
...selected.map((item) => item.bounds[axisStartKey])
);
const maxCoord = Math.max(...selected.map((item) => item.bounds[axisEndKey]));
const totalSpan = maxCoord - minCoord;
const gaps = selected.length - 1;
const totalSize = selected.reduce(
(sum, item) => sum + item.bounds[sizeKey],
0
);
const gapSize = gaps > 0 ? (totalSpan - totalSize) / gaps : 0;
let cursor = minCoord;
selected.forEach(({ obj, bounds }) => {
if (direction === "horizontal") {
setElementPosition(obj, { x: cursor });
cursor += bounds.w + gapSize;
} else {
setElementPosition(obj, { y: cursor });
cursor += bounds.h + gapSize;
}
});
}
function warnIfSlideElementsOutOfBounds(slide, pptx) {
if (!slide || !Array.isArray(slide._slideObjects)) {
console.warn(
"Invalid slide object passed to warnIfSlideElementsOutOfBounds()"
);
return;
}
const {
width: slideWidth,
height: slideHeight,
source,
} = getSlideDimensions(slide, pptx);
const slideIndex =
pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1;
const slideLabel =
slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)";
if (source === "default") {
console.warn(
`⚠️ ${slideLabel}: Unable to determine slide dimensions from pptxgenjs internals; assuming width=${slideWidth}, height=${slideHeight}.`
);
}
const EPS = 1e-4;
let outOfBoundsCount = 0;
const formatElement = (idx, type, bounds) => {
const cx = (bounds.x + bounds.w / 2).toFixed(3);
const cy = (bounds.y + bounds.h / 2).toFixed(3);
return `Element ${idx} (${type}, center_x=${cx}, center_y=${cy})`;
};
slide._slideObjects.forEach((obj, index) => {
const bounds = getElementBounds(obj);
const type = inferElementType(obj);
const violations = [];
if (bounds.x < -EPS) violations.push(`left=${bounds.x.toFixed(3)} < 0`);
if (bounds.y < -EPS) violations.push(`top=${bounds.y.toFixed(3)} < 0`);
if (bounds.x2 > slideWidth + EPS)
violations.push(
`right=${bounds.x2.toFixed(3)} > width=${slideWidth.toFixed(3)}`
);
if (bounds.y2 > slideHeight + EPS)
violations.push(
`bottom=${bounds.y2.toFixed(3)} > height=${slideHeight.toFixed(3)}`
);
if (violations.length > 0) {
outOfBoundsCount++;
console.warn(
`⚠️ ${slideLabel}: ${formatElement(
index,
type,
bounds
)} exceeds slide bounds (${violations.join(", ")}).`
);
}
});
if (outOfBoundsCount > 0) {
console.log(
`⚠️ ${slideLabel}: Found ${outOfBoundsCount} element(s) extending beyond the slide bounds.`
);
}
}
module.exports = {
inferElementType,
compareElementPosition,
warnIfSlideHasOverlaps,
alignSlideElements,
distributeSlideElements,
warnIfSlideElementsOutOfBounds,
getSlideDimensions,
};
@@ -0,0 +1,358 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const { calcTextBox, autoFontSize } = require("./text");
const { imageSizingCrop, imageSizingContain } = require("./image");
const { getSlideDimensions } = require("./layout");
module.exports = {
addImageTextCard,
addCardRow,
addThreeLevelTree,
};
function addImageTextCard(slide, opts = {}) {
const x = toNumberOr(opts.x, 0);
const y = toNumberOr(opts.y, 0);
const w = toNumberOr(opts.width, 3.0);
const gap = toNumberOr(opts.gap, 0.15);
const image = opts.image || {};
const text = opts.text || "";
const textBox = opts.textBox || {};
const boxH = toNumberOr(image.boxHeight, 2.2);
const sizing = (image.sizing || "crop").toLowerCase();
let imgPlacement;
if (image.path || image.data) {
const base = image.path ? { path: image.path } : { data: image.data };
if (sizing === "contain") {
imgPlacement = imageSizingContain(
image.path || image.data,
x,
y,
w,
boxH
);
slide.addImage({ ...base, ...imgPlacement });
} else {
const c = image.crop || {};
imgPlacement = imageSizingCrop(
image.path || image.data,
x,
y,
w,
boxH,
c.cx,
c.cy,
c.cw,
c.ch
);
slide.addImage({ ...base, ...imgPlacement });
}
}
const textY = y + boxH + gap;
const fontSize = toNumberOr(textBox.fontSize, 14);
const fontFaceRaw = textBox.fontFace;
const fontFace =
typeof fontFaceRaw === "string" && fontFaceRaw.trim().length > 0
? fontFaceRaw.trim()
: null;
if (!fontFace) {
throw new Error(
"addImageTextCard(): textBox.fontFace is required for text measurement."
);
}
let hText;
let textOptions;
if (textBox.h != null && Number.isFinite(toNumberOr(textBox.h, NaN))) {
// Layout-first: caller fixed the box height, so adjust font size to fit via autoFontSize.
const fixedH = toNumberOr(textBox.h, 0);
const baseOpts = {
x,
y: textY,
w,
h: fixedH,
mode: textBox.mode || "auto",
fontSize,
minFontSize: textBox.minFontSize,
maxFontSize: textBox.maxFontSize,
margin: textBox.margin,
paraSpaceAfter: textBox.paraSpaceAfter,
};
const autoOpts = autoFontSize(text, fontFace, baseOpts);
hText = fixedH;
textOptions = {
...autoOpts,
fontFace,
color: textBox.color,
align: textBox.align,
valign: textBox.valign || "top",
fill: opts.background,
};
} else {
// Content-first: fixed font size, let calcTextBox derive the required height.
const layout = calcTextBox(fontSize, {
text,
w,
fontFace,
margin: textBox.margin,
paraSpaceAfter: textBox.paraSpaceAfter,
});
hText = layout.h;
textOptions = {
x,
y: textY,
w,
h: hText,
fontFace,
fontSize,
color: textBox.color,
align: textBox.align,
valign: textBox.valign || "top",
paraSpaceAfter: textBox.paraSpaceAfter,
margin: textBox.margin,
fill: opts.background,
};
}
slide.addText(text, textOptions);
return {
x,
y,
w,
image: {
x: imgPlacement?.x ?? x,
y,
w: imgPlacement?.w ?? w,
h: imgPlacement?.h ?? boxH,
},
text: { x, y: textY, w, h: hText },
};
}
function addCardRow(slide, region, cards = [], options = {}) {
const rx = toNumberOr(region.x, 0.4);
const ry = toNumberOr(region.y, 1.6);
const slideWidth = getSlideDimensions(slide).width;
const rw = toNumberOr(region.w, slideWidth - rx * 2);
const gap = toNumberOr(options.gap, 0.25);
const count = cards.length;
if (count === 0) return [];
let cardW;
if (options.widthStrategy === "fixed") {
cardW = toNumberOr(
options.cardWidth,
rw / count - (gap * (count - 1)) / count
);
} else {
cardW = (rw - gap * (count - 1)) / count;
}
const totalWidth = cardW * count + gap * (count - 1);
const align = options.align || "left";
const ox =
align === "center"
? (rw - totalWidth) / 2
: align === "right"
? rw - totalWidth
: 0;
const placements = [];
for (let i = 0; i < count; i++) {
const x = rx + ox + i * (cardW + gap);
placements.push(
addImageTextCard(slide, { ...cards[i], x, y: ry, width: cardW })
);
}
return placements;
}
function addThreeLevelTree(slide, opts = {}) {
const slideWidth = getSlideDimensions(slide).width;
const cx = toNumberOr(opts.centerX, slideWidth / 2);
const topY = toNumberOr(opts.topY, 1.6);
const rootW = toNumberOr(opts.root?.w, 3.3333333);
const rootH = toNumberOr(opts.root?.h, 0.93333333);
const rootX = cx - rootW / 2;
const rootFontFaceRaw = opts.root?.fontFace;
const rootFontFace =
typeof rootFontFaceRaw === "string" && rootFontFaceRaw.trim().length > 0
? rootFontFaceRaw.trim()
: null;
if (!rootFontFace) {
throw new Error(
"addThreeLevelTree(): opts.root.fontFace is required for text measurement."
);
}
const rootFontSize = toNumberOr(opts.root?.fontSize, 16);
const rootText = opts.root?.text || "";
const rootTextOpts = autoFontSize(rootText, rootFontFace, {
x: rootX,
y: topY,
w: rootW,
h: rootH,
mode: opts.root?.mode || "shrink",
fontSize: rootFontSize,
minFontSize: opts.root?.minFontSize,
maxFontSize: opts.root?.maxFontSize,
});
slide.addText(rootText, {
...rootTextOpts,
align: "center",
valign: "mid",
fontFace: rootFontFace,
color: opts.root?.color || "FFFFFF",
fill: { color: opts.root?.fill || "0B0F1A" },
line: { color: opts.root?.line || opts.root?.fill || "0B0F1A" },
});
const midLabels = Array.isArray(opts.mid?.labels) ? opts.mid.labels : [];
const midFontFaceRaw = opts.mid?.fontFace;
const midFontFace =
typeof midFontFaceRaw === "string" && midFontFaceRaw.trim().length > 0
? midFontFaceRaw.trim()
: null;
if (!midFontFace) {
throw new Error(
"addThreeLevelTree(): opts.mid.fontFace is required for text measurement."
);
}
let midW = toNumberOr(opts.mid?.w, NaN);
const midH = toNumberOr(opts.mid?.h, rootH);
const midY = toNumberOr(opts.mid?.y, topY + rootH + 1.2);
const requestedSpacing = toNumberOr(opts.mid?.spacing, NaN); // center-to-center distance if provided
const leftRightMargin = toNumberOr(opts.mid?.marginX, 0.6);
const availableRowWidth = slideWidth - leftRightMargin * 2;
const countMid = midLabels.length;
const minGap = 0.4;
if (!Number.isFinite(midW) && Number.isFinite(requestedSpacing)) {
// Derive midW from spacing and available width
const totalSpan = requestedSpacing * (countMid - 1) + 0; // span between first and last centers
const maxW = Math.min(rootW, (availableRowWidth - totalSpan) / countMid);
midW = Math.max(0.8, maxW);
}
if (!Number.isFinite(midW)) {
// Fit equally within available width with minimum gaps
midW = Math.max(
0.8,
(availableRowWidth - minGap * (countMid - 1)) / countMid
);
}
// Compute gap to center-group horizontally without overlap
let gap = Math.max(
minGap,
(availableRowWidth - midW * countMid) / Math.max(1, countMid - 1)
);
const totalWidth = midW * countMid + gap * (countMid - 1);
const startLeft = cx - totalWidth / 2;
for (let i = 0; i < midLabels.length; i++) {
const x = startLeft + i * (midW + gap);
const midText = midLabels[i] || "";
const midFontSize = toNumberOr(opts.mid?.fontSize, 16);
const midTextOpts = autoFontSize(midText, midFontFace, {
x,
y: midY,
w: midW,
h: midH,
mode: opts.mid?.mode || "shrink",
fontSize: midFontSize,
minFontSize: opts.mid?.minFontSize,
maxFontSize: opts.mid?.maxFontSize,
});
slide.addText(midText, {
...midTextOpts,
align: "center",
valign: "mid",
fontFace: midFontFace,
color: opts.mid?.color || "000000",
fill: { color: opts.mid?.fill || "A0BEC2" },
line: { color: opts.mid?.line || opts.mid?.fill || "A0BEC2" },
});
addConnector(slide, cx, topY + rootH, x + midW / 2, midY, opts.line);
}
const leavesPerMid = Array.isArray(opts.leaf?.labelsPerMid)
? opts.leaf.labelsPerMid
: [];
const leafFontFaceRaw = opts.leaf?.fontFace;
const leafFontFace =
typeof leafFontFaceRaw === "string" && leafFontFaceRaw.trim().length > 0
? leafFontFaceRaw.trim()
: null;
if (!leafFontFace) {
throw new Error(
"addThreeLevelTree(): opts.leaf.fontFace is required for text measurement."
);
}
const leafW = toNumberOr(opts.leaf?.w, 1.05);
const leafH = toNumberOr(opts.leaf?.h, 1.0666667);
const leafY = toNumberOr(opts.leaf?.y, midY + midH + 1.0);
const minLeafGap = 0.2;
for (let i = 0; i < midLabels.length; i++) {
const xBase = startLeft + i * (midW + gap);
const childLabels = Array.isArray(leavesPerMid[i]) ? leavesPerMid[i] : [];
const childCount = childLabels.length || 3;
// Compute per-mid gap to fit children within midW without overlap
const leafGap = Math.max(
minLeafGap,
(midW - childCount * leafW) / Math.max(1, childCount - 1)
);
const totalWidth = childCount * leafW + (childCount - 1) * leafGap;
const leftX = xBase + (midW - totalWidth) / 2;
for (let j = 0; j < childCount; j++) {
const x = leftX + j * (leafW + leafGap);
const leafText = childLabels[j] || "";
const leafFontSize = toNumberOr(opts.leaf?.fontSize, 16);
const leafTextOpts = autoFontSize(leafText, leafFontFace, {
x,
y: leafY,
w: leafW,
h: leafH,
mode: opts.leaf?.mode || "shrink",
fontSize: leafFontSize,
minFontSize: opts.leaf?.minFontSize,
maxFontSize: opts.leaf?.maxFontSize,
});
slide.addText(leafText, {
...leafTextOpts,
align: "center",
valign: "mid",
fontFace: leafFontFace,
color: opts.leaf?.color || "000000",
fill: { color: opts.leaf?.fill || "A6C1EE" },
line: { color: opts.leaf?.line || opts.leaf?.fill || "A6C1EE" },
});
addConnector(
slide,
xBase + midW / 2,
midY + midH,
x + leafW / 2,
leafY,
opts.line
);
}
}
}
function addConnector(slide, x1, y1, x2, y2, line = {}) {
const x = Math.min(x1, x2);
const y = Math.min(y1, y2);
slide.addShape("line", {
x,
y,
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
line: { color: line.color || "000000", pt: line.pt || 1 },
flipH: x2 < x1 ? true : undefined,
});
}
function toNumberOr(v, fallback) {
const n = typeof v === "string" ? parseFloat(v) : v;
return Number.isFinite(n) ? n : fallback;
}
@@ -0,0 +1,36 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
function toDataUri(svg) {
return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64");
}
function sanitizeSvg(svg) {
let inner = svg;
const a = inner.indexOf("<svg");
const b = inner.indexOf("</svg>");
if (a !== -1 && b !== -1) inner = inner.slice(a, b + 6);
inner = inner.replace(/<\?xml[^>]*>/g, "");
if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(inner)) {
inner = inner.replace(/<svg /, '<svg xmlns="http://www.w3.org/2000/svg" ');
}
inner = inner.replace(
/(width|height)=\"([0-9.]+)(ex|em)\"/g,
(_m, attr, num) => {
const px = Math.round(parseFloat(num) * 8.5);
return `${attr}="${px}px"`;
}
);
inner = inner.replace(/currentColor/g, "#000000");
return inner;
}
function svgToDataUri(svg) {
return toDataUri(sanitizeSvg(svg));
}
module.exports = {
toDataUri,
sanitizeSvg,
svgToDataUri,
};
@@ -0,0 +1,789 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const { spawnSync } = require("child_process");
const { Canvas } = require("skia-canvas");
// Unicode line-break iterator (UAX #14) so we mimic PPT/LibreOffice wrapping rules.
const LineBreaker = require("linebreak");
const fontkit = require("fontkit");
const TEXT_MEASURER = getTextMeasurer();
const registeredFontVariants = new Set();
const fontPathCache = new Map();
const fontKitCache = new Map();
// Estimate the text box height for a given font size and line count.
// NOTE: This is an analytical approximation, not an exact reproduction of
// PowerPoint/LibreOffice layout. Always verify visually and adjust based on
// actual rendering if precise fit is required.
function calcTextBoxHeightSimple(
fontSize,
lines = 1,
leading = 1.15,
padding = 0.3
) {
const lineHeightIn = (fontSize / 72) * leading;
return lines * lineHeightIn + padding;
}
// Compute font size that fits given text within a fixed box.
// NOTE: autoFontSize uses skia-canvas measurement stack to approximate the font size
// that will fit in a given box. Rendering engines may differ slightly, so
// treat the result as an estimate and tweak as needed after visual inspection.
// Signature:
// autoFontSize(textOrRuns, fontFace, opts?)
// - fontFace must be provided as the 2nd positional argument and cannot be in opts.
// - All modes always respect [minFontSize, maxFontSize] as a CLOSED interval when provided.
// Modes:
// - mode: "shrink" => shrink only (search [minFontSize, min(maxFontSize, fontSize)])
// - mode: "enlarge" => enlarge only (search [max(minFontSize, fontSize), maxFontSize])
// - mode: "auto" => shrink + enlarge (search [minFontSize, maxFontSize]); fontSize optional.
// In "auto" mode fontSize is not required; when omitted we simply search the whole [minFontSize, maxFontSize] range.
// Returns a cloned options object with computed fontSize. fit: "shrink" is appended only when mode === "shrink".
function autoFontSize(textOrRuns, fontFace, opts = {}) {
const x = toNumber(opts.x, 0);
const y = toNumber(opts.y, 0);
const w = toNumber(opts.w, 0);
const h = toNumber(opts.h, 0);
if (!(w > 0 && h > 0)) throw new Error("autoFontSize(): non-positive w or h");
const face = typeof fontFace === "string" ? fontFace.trim() : "";
if (face.length === 0) {
throw new Error(
"autoFontSize(): fontFace is required as the 2nd positional argument."
);
}
// Fast-path: if there is no visible text content, just return the
// (optionally clamped) reference fontSize; there is nothing to fit.
const hasAnyText =
normalizeText(textOrRuns).trim().length > 0 ||
(Array.isArray(textOrRuns) &&
textOrRuns.some(
(run) => run && typeof run.text === "string" && run.text.trim().length
));
const fontStyle =
opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: "normal";
const leading = toNumber(opts.leading, 1.15) || 1.15;
const modeRaw = typeof opts.mode === "string" ? opts.mode : "auto"; // 'auto' (default) | 'shrink' | 'enlarge'
const mode = modeRaw.toLowerCase();
const isShrink = mode === "shrink";
const isEnlarge = mode === "enlarge";
const isAuto = mode === "auto";
const refPtRaw = toNumber(opts.fontSize, NaN);
const hasRefPt = Number.isFinite(refPtRaw);
const refPt = hasRefPt ? refPtRaw : NaN;
// Base bounds (closed interval). Defaults:
// - minFontSize: 1pt
// - maxFontSize: 1000pt (unless the caller provided a tighter bound)
let minPt = toNumber(opts.minFontSize, NaN);
let maxPt = toNumber(opts.maxFontSize, NaN);
const userProvidedMax = Number.isFinite(maxPt);
if (!Number.isFinite(minPt)) {
minPt = 1;
}
if (!Number.isFinite(maxPt)) {
maxPt = 1000;
}
if (isShrink || isEnlarge) {
if (!hasRefPt) {
throw new Error(
"autoFontSize(): mode 'shrink' or 'enlarge' requires fontSize"
);
}
}
if (isShrink) {
// Shrink only: never exceed the requested size (and respect maxFontSize).
maxPt = Math.min(maxPt, refPt);
} else if (isEnlarge) {
// Enlarge only: never go below the requested size (and respect minFontSize).
minPt = Math.max(minPt, refPt);
} else if (isAuto && hasRefPt && userProvidedMax) {
// Auto mode with an explicit maxFontSize: honor [minFontSize, maxFontSize]
// as the search band while allowing both shrink and enlarge within it.
} else if (!isAuto) {
throw new Error(
`autoFontSize(): unsupported mode "${modeRaw}", expected "auto" | "shrink" | "enlarge"`
);
}
if (!(maxPt > 0 && maxPt >= minPt)) {
throw new Error(
"autoFontSize(): invalid minFontSize/maxFontSize bounds after normalization"
);
}
// If there is no actual text, we can skip measurement entirely and just
// clamp the reference size to [minPt, maxPt].
if (!hasAnyText) {
const chosen =
(hasRefPt && Math.max(minPt, Math.min(maxPt, refPt))) || minPt;
const out = { ...opts, x, y, w, h, fontSize: chosen };
if (isShrink) out.fit = "shrink";
return out;
}
// Search the space of candidate font sizes with a small step and a safety
// bias baked into the fit test:
// - precision: 0.05pt (~1/20pt) so we land very close to the true max-fit.
// - safetyFactor: we require that the calcTextBox()-measured height is
// within a small margin of the caller-provided box height, so that the
// same layout engine used by calcTextBox drives autoFontSize decisions.
const precision = 0.05; // point precision for search (~1/20pt)
const safetyFactor = 0.97;
let lo = minPt;
let hi = maxPt;
let best = lo;
while (hi - lo > precision) {
const mid = (lo + hi) / 2;
// Delegate measurement to calcTextBox so that autoFontSize and
// calcTextBox share the exact same layout pipeline (paragraph modeling,
// bullet handling, margins, padding, width scaling, etc.).
const layout = calcTextBox(mid, {
text: textOrRuns,
w,
fontFace: face,
fontStyle,
fontWeight,
leading,
margin: opts.margin,
padding: opts.padding,
paraSpaceAfter: opts.paraSpaceAfter,
});
const fits = layout.h <= h * safetyFactor + 1e-6;
if (fits) {
best = mid;
lo = mid; // try larger
} else {
hi = mid; // shrink
}
}
// Closed interval: clamp to [minPt, maxPt].
const finalPt = Math.max(minPt, Math.min(maxPt, best));
// Pass through all original options, override fontSize and append fit: "shrink"
const out = { ...opts, x, y, w, h, fontSize: finalPt };
if (isShrink) out.fit = "shrink";
return out;
}
// Calculate text box metrics using skia-canvas measurement (lines, height,
// width) for a given font size and text payload.
// NOTE: calcTextBox approximates how many lines and how much space text will
// occupy using our JS measurement pipeline. It is designed to be close to
// PowerPoint/LibreOffice but is not guaranteed pixel-perfect—always adjust
// based on actual slide rendering when precision matters.
// Signature:
// calcTextBox(fontSizePt, opts)
// - fontSizePt: number (points)
// - opts (keywords): {
// text?: string | runs[],
// w?: number (inches),
// h?: number (inches),
// lines?: number,
// fontFace?: string, // required when measuring by width/height with text
// fontStyle?: 'normal' | 'italic', italic?: boolean,
// fontWeight?: 'normal' | 'bold', bold?: boolean,
// leading?: number (line height multiplier, default 1.15),
// padding?: number (inches, default 0.3),
// paraSpaceAfter?: number (points, default 0)
// }
// Modes (auto-detected):
// a) Given lines -> compute height
// b) Given width + text -> compute height and lines
// c) Given height + text -> compute width and lines
// Throws when insufficient info is provided.
function calcTextBox(fontSizePt, opts = {}) {
const textInput = opts.text ?? "";
const text = normalizeText(textInput || "");
const face =
typeof opts.fontFace === "string" && opts.fontFace.trim().length > 0
? opts.fontFace.trim()
: "";
const fontStyle =
opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: "normal";
const leading = toNumber(opts.leading, 1.15) || 1.15;
const padding = toNumber(opts.padding, 0.3); // inches (allow 0)
const paraSpaceAfterPt = toNumber(opts.paraSpaceAfter, 0) || 0; // points
const lineHeightIn = (fontSizePt / 72) * leading;
const margins = normalizeMargins(opts.margin);
const measurer = TEXT_MEASURER;
const hasLines = Number.isFinite(toNumber(opts.lines, NaN));
const hasWidth = Number.isFinite(toNumber(opts.w, NaN));
const hasHeight = Number.isFinite(toNumber(opts.h, NaN));
const paragraphs = buildParagraphModels(textInput, {
fontSizePt,
// Do not silently substitute a default font here; callers measuring by
// width/height are required to pass an explicit fontFace so that our
// metrics match the actual slide theme.
fontFace: face,
fontStyle,
fontWeight,
leading,
paraSpaceAfterPt,
});
const hasAnyText = paragraphs.some((p) => p.text.length > 0);
// Empirical top inset: PPT text frames render a small gutter above the first line
// even with zero margins. Model it as a fraction of the font size so callers can
// visually trim by shifting y up and growing h by the same amount.
const topInsetIn = (fontSizePt / 72) * 0.2; // ~20% of font size (inches)
if (hasLines) {
// Mode (a): Given lines -> compute height only
const lines = toNumber(opts.lines, 1);
const contentH = Math.max(0, lines * lineHeightIn + padding);
const h = contentH + margins.top + margins.bottom;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: toNumber(opts.w, NaN) || null,
h,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
if (hasWidth && hasAnyText) {
// Mode (b): Given width + text -> compute height and lines
if (face.length === 0) {
throw new Error(
"calcTextBox(): opts.fontFace is required when measuring by width."
);
}
const boxW = toNumber(opts.w, 0);
if (!(boxW > 0))
throw new Error("calcTextBox(): width must be > 0 in mode 'width'");
const innerW = Math.max(0, boxW - margins.left - margins.right);
const { lines, heightIn } = layoutGivenWidth(paragraphs, innerW);
const contentH = Math.max(0, heightIn + padding);
const h = contentH + margins.top + margins.bottom;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: boxW,
h,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
if (hasHeight && hasAnyText) {
// Mode (c): Given height + text -> compute minimal width and lines to fit
if (face.length === 0) {
throw new Error(
"calcTextBox(): opts.fontFace is required when measuring by height."
);
}
const boxH = toNumber(opts.h, 0);
if (!(boxH > 0))
throw new Error("calcTextBox(): height must be > 0 in mode 'height'");
const innerH = Math.max(0, boxH - margins.top - margins.bottom);
// Upper bound: single-line width across paragraphs
const singleLineWidth = paragraphs.reduce((mx, p) => {
const width = measureRunWidth(p, p.text) + p.textIndentIn;
return Math.max(mx, width);
}, 0);
const minHeightOneLine = Math.max(
0,
paragraphs.reduce((sum, p, idx) => {
const lineHeight = (p.fontSizePt / 72) * p.leading;
sum += lineHeight;
if (idx !== paragraphs.length - 1) sum += p.paraSpaceAfterIn;
return sum;
}, 0)
);
if (minHeightOneLine + padding - innerH > 1e-6) {
throw new Error(
"calcTextBox(): height too small for one-line layout at this font size"
);
}
// Lower bound: longest token width
const longestTokenWidth = paragraphs.reduce((mx, p) => {
const tokens = splitTextIntoTokens(p.text);
for (const tk of tokens) {
if (tk.length === 0) continue;
const wIn = measureRunWidth(p, tk) + p.textIndentIn;
if (wIn > mx) mx = wIn;
}
return mx;
}, 0);
let lo = Math.max(0.01, longestTokenWidth);
let hi = Math.max(lo, singleLineWidth);
let best = hi;
for (let iter = 0; iter < 32; iter++) {
const mid = (lo + hi) / 2;
const { lines, heightIn } = layoutGivenWidth(paragraphs, mid);
const totalH = heightIn + padding;
if (totalH <= innerH + 1e-6) {
best = mid;
hi = mid;
} else {
lo = mid;
}
}
const { lines, heightIn } = layoutGivenWidth(paragraphs, best);
const contentH = heightIn + padding;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: best + margins.left + margins.right,
h: contentH + margins.top + margins.bottom,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
throw new Error(
"calcTextBox(): insufficient information. Provide {lines} or ({w,text}) or ({h,text})."
);
}
function layoutGivenWidth(paragraphs, boxW) {
let totalLines = 0;
let heightIn = 0;
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i];
const widthScale = getWidthScaleForParagraph(para);
const usableWidth = Math.max(0.01, boxW - para.textIndentIn) * widthScale;
const lines = greedyWrap(para, usableWidth);
const count = Math.max(1, lines.length);
totalLines += count;
const lineHeightIn = (para.fontSizePt / 72) * para.leading;
heightIn += count * lineHeightIn;
if (i !== paragraphs.length - 1) heightIn += para.paraSpaceAfterIn;
}
return { lines: totalLines, heightIn };
}
function greedyWrap(paragraph, maxWidthIn) {
const text = paragraph.text || "";
if (text.length === 0) return [""];
const breaker = new LineBreaker(text);
const breakpoints = [];
let bk;
while ((bk = breaker.nextBreak())) {
breakpoints.push({ pos: bk.position, required: bk.required });
}
const lines = [];
let start = skipTextWhitespace(text, 0);
let idx = 0;
while (start < text.length) {
while (idx < breakpoints.length && breakpoints[idx].pos <= start) idx++;
let chosen = null;
let probe = idx;
while (probe < breakpoints.length) {
const br = breakpoints[probe];
const slice = text.slice(start, br.pos);
const width = measureRunWidth(paragraph, trimLineEnd(slice));
if (width <= maxWidthIn + 1e-6) {
chosen = br;
probe++;
if (br.required) break;
} else {
break;
}
}
if (!chosen) {
const forced = forceBreakSegment(text, start, maxWidthIn, paragraph);
if (forced.segment.length === 0) break;
lines.push(trimLineEnd(forced.segment));
start = skipTextWhitespace(text, forced.nextIndex);
continue;
}
const lineText = text.slice(start, chosen.pos);
lines.push(trimLineEnd(lineText));
start = skipTextWhitespace(text, chosen.pos);
}
if (!lines.length) lines.push("");
return lines;
}
function splitTextIntoTokens(text) {
if (typeof text !== "string") return [""];
const tokens = text.split(/(\s+)/);
return tokens.length ? tokens : [""];
}
function trimLineEnd(value) {
return typeof value === "string" ? value.replace(/\s+$/u, "") : "";
}
function measureRunWidth(paragraph, text) {
if (!text || text.length === 0) return 0;
const fontData = getFontData(
paragraph.fontFace,
paragraph.fontStyle,
paragraph.fontWeight
);
if (fontData && fontData.font) {
const layout = fontData.font.layout(text);
const widthPts =
(layout.advanceWidth / fontData.font.unitsPerEm) * paragraph.fontSizePt;
return Math.max(0, widthPts / 72);
}
return TEXT_MEASURER(
text,
paragraph.fontSizePt,
paragraph.fontFace,
paragraph.fontStyle,
paragraph.fontWeight
);
}
function forceBreakSegment(text, start, maxWidthIn, paragraph) {
const chars = Array.from(text.slice(start));
if (chars.length === 0) return { segment: "", nextIndex: text.length };
let buffer = "";
let consumedUnits = 0;
for (let i = 0; i < chars.length; i++) {
const candidate = buffer + chars[i];
const width = measureRunWidth(paragraph, trimLineEnd(candidate));
if (width <= maxWidthIn + 1e-6) {
buffer = candidate;
consumedUnits += chars[i].length;
continue;
}
if (buffer.length === 0) {
buffer = chars[i];
consumedUnits += chars[i].length;
}
break;
}
if (buffer.length === 0) {
buffer = chars[0] || "";
consumedUnits = buffer.length;
}
return { segment: buffer, nextIndex: start + consumedUnits };
}
function skipTextWhitespace(text, index) {
let idx = index;
while (idx < text.length && /\s/.test(text[idx])) idx++;
return idx;
}
function buildParagraphModels(textOrRuns, baseStyle) {
const entries = collectParagraphEntries(textOrRuns);
if (entries.length === 0) {
return [resolveParagraphStyle({ text: "" }, baseStyle)];
}
return entries.map((entry) => resolveParagraphStyle(entry, baseStyle));
}
function collectParagraphEntries(textOrRuns) {
const result = [];
if (Array.isArray(textOrRuns)) {
for (const entry of textOrRuns) {
if (typeof entry === "string") {
pushParagraphSegments(entry, undefined, result);
} else if (entry && typeof entry === "object") {
pushParagraphSegments(entry.text ?? "", entry.options || {}, result);
}
}
return result;
}
pushParagraphSegments(textOrRuns ?? "", undefined, result);
return result;
}
function pushParagraphSegments(text, options, target) {
const normalized = String(text ?? "");
const parts = normalized.split(/\r?\n/);
if (parts.length === 0) {
target.push({ text: "", options });
return;
}
for (const part of parts) {
target.push({ text: part, options });
}
}
function resolveParagraphStyle(entry, baseStyle) {
const opts = entry.options || {};
const fontFace =
(opts.fontFace && String(opts.fontFace).trim()) ||
baseStyle.fontFace ||
"Arial";
const fontStyle =
opts.italic === true || opts.fontStyle === "italic"
? "italic"
: baseStyle.fontStyle || "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: baseStyle.fontWeight || "normal";
const fontSizePt =
toNumber(opts.fontSize, baseStyle.fontSizePt) || baseStyle.fontSizePt;
const leading =
toNumber(opts.leading, baseStyle.leading) || baseStyle.leading || 1.15;
const paraSpaceAfterPt =
toNumber(opts.paraSpaceAfter, baseStyle.paraSpaceAfterPt) ||
baseStyle.paraSpaceAfterPt ||
0;
const hasBullet = !!opts.bullet;
let indentPt = toNumber(opts.indent, NaN);
if (!Number.isFinite(indentPt) && hasBullet) {
indentPt = toNumber(opts.bullet.indent, NaN);
}
if (!Number.isFinite(indentPt)) indentPt = 0;
const hangingPt = toNumber(opts.hanging, 0) || 0;
let textIndentIn = 0;
if (indentPt > 0) {
if (hasBullet) {
// PowerPoint-style bullets: "indent" is the distance from the left edge
// of the text box to the start of the text (the bullet itself is hung
// using the hanging value). This means the available width for the text
// is boxWidth - indent, not boxWidth - (indent - hanging). Modeling it
// this way matches the manual line counts from PowerPoint/LibreOffice.
textIndentIn = indentPt / 72;
} else {
// Non-bullet paragraphs keep the prior behavior where hanging reduces
// the effective indent (similar to CSS text-indent).
textIndentIn = Math.max(0, (indentPt - hangingPt) / 72);
}
}
return {
text: entry.text || "",
fontFace,
fontStyle,
fontWeight,
fontSizePt,
leading,
paraSpaceAfterIn: paraSpaceAfterPt / 72,
textIndentIn,
};
}
function getFontData(face, fontStyle, fontWeight) {
const key = makeFontCacheKey(face, fontStyle, fontWeight);
if (fontKitCache.has(key)) return fontKitCache.get(key);
const fontPath = findFontPath(face, fontStyle, fontWeight);
if (!fontPath) {
fontKitCache.set(key, null);
return null;
}
try {
let font = fontkit.openSync(fontPath);
if (font && typeof font.fonts === "object") {
font = selectCollectionFont(font, fontStyle, fontWeight);
}
if (!font || typeof font.layout !== "function") {
fontKitCache.set(key, null);
return null;
}
registerCanvasFontVariant(fontPath, face, fontStyle, fontWeight, key);
const payload = { font, path: fontPath };
fontKitCache.set(key, payload);
return payload;
} catch (err) {
fontKitCache.set(key, null);
return null;
}
}
function makeFontCacheKey(face, fontStyle, fontWeight) {
const family = (face || "Arial").trim();
const style = (fontStyle || "normal").toLowerCase();
const weight = (fontWeight || "normal").toLowerCase();
return `${family}::${style}::${weight}`;
}
function registerCanvasFontVariant(
fontPath,
face,
fontStyle,
fontWeight,
cacheKey
) {
if (registeredFontVariants.has(cacheKey)) return;
try {
Canvas.registerFont(fontPath, {
family: face,
style: fontStyle || "normal",
weight: fontWeight || "normal",
});
registeredFontVariants.add(cacheKey);
} catch (err) {
// ignore registration failure; measurement will fall back to Skia default
}
}
function findFontPath(face, fontStyle, fontWeight) {
const family = (face || "").trim();
if (family.length === 0) return null;
const key = makeFontCacheKey(family, fontStyle, fontWeight);
if (fontPathCache.has(key)) return fontPathCache.get(key);
const styleParts = [];
if ((fontWeight || "").toLowerCase() === "bold") styleParts.push("Bold");
if ((fontStyle || "").toLowerCase() === "italic") styleParts.push("Italic");
const styleQuery =
styleParts.length > 0 ? `:style=${styleParts.join(" ")}` : "";
const query = `${family}${styleQuery}`;
const result = spawnSync("fc-match", ["-f", "%{file}", query], {
encoding: "utf8",
});
if (result.status === 0) {
const output = String(result.stdout || "").trim();
if (output.length > 0) {
fontPathCache.set(key, output);
return output;
}
}
fontPathCache.set(key, null);
return null;
}
function selectCollectionFont(collection, fontStyle, fontWeight) {
const fonts = collection.fonts || [];
if (fonts.length === 0) return null;
const wantItalic = (fontStyle || "").toLowerCase() === "italic";
const wantBold = (fontWeight || "").toLowerCase() === "bold";
let best = fonts[0];
let bestScore = scoreFontVariant(best, wantItalic, wantBold);
for (let i = 1; i < fonts.length; i++) {
const candidate = fonts[i];
const score = scoreFontVariant(candidate, wantItalic, wantBold);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return best;
}
function scoreFontVariant(font, wantItalic, wantBold) {
if (!font) return -1;
const name = String(font.fullName || font.postscriptName || "").toLowerCase();
const isItalic = /italic|oblique/.test(name);
const isBold = /bold|black|heavy|semibold|extrabold/.test(name);
let score = 0;
if (isItalic === wantItalic) score += 1;
if (isBold === wantBold) score += 1;
return score;
}
// Empirical width scaling to better match PowerPoint/LibreOffice line breaks.
// A tiny global shrink (about -1.5%) nudges borderline words to wrap the same
// way Office does, with per-script tweaks for cases where our measurer
// systematically under- or over-estimates glyph widths. We intentionally avoid
// per-font calibration so this helper generalizes beyond the regression deck.
function getWidthScaleForParagraph(paragraph) {
if (!paragraph || typeof paragraph.text !== "string") return 1;
const text = paragraph.text;
// Thai script: our measurer tends to slightly over-estimate, which can cause
// extra wraps. Give it a bit more room horizontally.
if (/[ก-๛]/u.test(text)) {
return 1.2;
}
// Arabic: we usually underestimate, so shrink available width a bit more to
// encourage earlier breaks.
if (/[\u0600-\u06FF]/u.test(text)) {
return 0.97;
}
// Base shrink for most Latin and other scripts.
return 0.985;
}
// Build options to pass directly to pptx.addText. We exclude measurement-only
// fields and fill sensible defaults (e.g., fontSize) so callers can spread
// the result into addText just like the image sizing helpers.
function buildPassthroughOptions(opts, fontSizePt, margins) {
const exclude = new Set([
"text",
"lines",
"w", // will be set by calcTextBox
"h", // will be set by calcTextBox
// fontFace/style/weight are useful for addText; allow passthrough
"leading",
"padding",
]);
const out = {};
for (const k of Object.keys(opts)) {
if (!exclude.has(k)) out[k] = opts[k];
}
if (out.fontSize == null) out.fontSize = fontSizePt;
if (opts.margin != null) out.margin = margins;
return out;
}
function getTextMeasurer() {
// Skia-canvas only for accurate shaping and Fontconfig-based resolution.
// Throws if skia-canvas is not available.
const canvas = new Canvas(2, 2);
const ctx = canvas.getContext("2d");
const PX_PER_IN = 96;
return (text, fontSizePt, fontFace, fontStyle, fontWeight) => {
const px = (fontSizePt / 72) * PX_PER_IN;
const style = fontStyle || "normal";
const weight = fontWeight || "normal";
// CSS shorthand: style weight size family
ctx.font = `${style} ${weight} ${px}px ${fontFace || "Arial"}`;
const metrics = ctx.measureText(text);
return (metrics.width || 0) / PX_PER_IN;
};
}
function normalizeMargins(m) {
const toInches = (value) =>
typeof value === "number" && Number.isFinite(value) ? value / 72 : 0;
if (m && typeof m === "object") {
if (Number.isFinite(m.left) || Number.isFinite(m.top)) {
return {
left: toInches(m.left),
right: toInches(m.right),
top: toInches(m.top),
bottom: toInches(m.bottom),
};
}
}
const all = toInches(m);
return { left: all, right: all, top: all, bottom: all };
}
function normalizeText(textOrRuns) {
if (Array.isArray(textOrRuns)) {
return textOrRuns
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item.text === "string") return item.text;
return "";
})
.join("");
}
return typeof textOrRuns === "string" ? textOrRuns : String(textOrRuns ?? "");
}
function toNumber(v, fallback) {
const n = typeof v === "string" ? parseFloat(v) : v;
return Number.isFinite(n) ? n : fallback;
}
module.exports = {
calcTextBoxHeightSimple,
calcTextBox,
autoFontSize,
};
@@ -0,0 +1,24 @@
// Copyright (c) OpenAI. All rights reserved.
"use strict";
// Safe outer shadow helper (avoid inner/outer mix and XML pitfalls)
function safeOuterShadow(
color = "000000",
opacity = 0.25,
angle = 45,
blur = 3,
offset = 2
) {
return {
type: "outer",
color,
opacity,
angle,
blur,
offset,
};
}
module.exports = {
safeOuterShadow,
};