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,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.4678 7.86641C13.4678 7.29758 13.4671 6.90292 13.442 6.5961C13.4174 6.29551 13.372 6.12642 13.3076 6.00001C13.1669 5.7238 12.9424 5.49935 12.6662 5.3586C12.5398 5.2942 12.3707 5.24879 12.0701 5.22423C11.7633 5.19916 11.3686 5.19845 10.7998 5.19845H5.19981C4.63097 5.19845 4.23631 5.19916 3.92949 5.22423C3.62893 5.24879 3.4598 5.2942 3.3334 5.3586C3.0572 5.49935 2.83273 5.7238 2.69199 6.00001C2.62758 6.12642 2.58218 6.29551 2.55762 6.5961C2.53255 6.90292 2.53184 7.29758 2.53184 7.86641V9.86641C2.53184 10.4352 2.53255 10.8299 2.55762 11.1367C2.58218 11.4373 2.62759 11.6064 2.69199 11.7328C2.83273 12.009 3.05721 12.2335 3.3334 12.3742C3.45981 12.4386 3.62893 12.484 3.92949 12.5086C4.23631 12.5337 4.63097 12.5344 5.19981 12.5344H10.7998C11.3686 12.5344 11.7633 12.5337 12.0701 12.5086C12.3707 12.484 12.5398 12.4386 12.6662 12.3742C12.9424 12.2335 13.1669 12.009 13.3076 11.7328C13.372 11.6064 13.4174 11.4373 13.442 11.1367C13.4671 10.8299 13.4678 10.4352 13.4678 9.86641V7.86641ZM9.81465 2.11563L12.0053 2.13438L12.1123 2.1461C12.3542 2.19814 12.5344 2.41482 12.5318 2.67188C12.5292 2.92875 12.3448 3.14096 12.1021 3.18829L11.9943 3.19845L9.80996 3.1797C7.87499 3.16721 6.50265 3.17347 4.00527 3.19845C3.71153 3.20138 3.4708 2.96561 3.46777 2.67188C3.46484 2.37813 3.7006 2.1374 3.99434 2.13438C6.49686 2.10936 7.87466 2.10313 9.81465 2.11563ZM14.5318 9.86641C14.5318 10.4177 14.5324 10.8633 14.5029 11.2234C14.473 11.5897 14.4095 11.9143 14.2561 12.2156C14.0133 12.6921 13.6255 13.0799 13.149 13.3227C12.8477 13.4761 12.5231 13.5396 12.1568 13.5695C11.7966 13.599 11.3511 13.5984 10.7998 13.5984H5.19981C4.64853 13.5984 4.20297 13.599 3.84277 13.5695C3.47655 13.5396 3.15187 13.4761 2.85059 13.3227C2.37416 13.0799 1.98631 12.6921 1.74356 12.2156C1.5901 11.9143 1.52661 11.5897 1.49668 11.2234C1.46725 10.8633 1.46777 10.4177 1.46777 9.86641V7.86641C1.46777 7.31514 1.46725 6.86958 1.49668 6.50938C1.52661 6.14315 1.59012 5.81849 1.74356 5.5172C1.98631 5.04077 2.37416 4.65292 2.85059 4.41016C3.15188 4.25671 3.47653 4.19322 3.84277 4.16329C4.20297 4.13386 4.64853 4.13438 5.19981 4.13438H10.7998C11.3511 4.13438 11.7966 4.13386 12.1568 4.16329C12.5231 4.19322 12.8477 4.25671 13.149 4.41016C13.6255 4.65292 14.0133 5.04077 14.2561 5.5172C14.4095 5.81849 14.473 6.14315 14.5029 6.50938C14.5324 6.86958 14.5318 7.31514 14.5318 7.86641V9.86641Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Reference in new issue
Block a user