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:
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,
|
||||
};
|
||||
Reference in new issue
Block a user