- Зачем: - расширены возможности AI-ассистентов для работы с документами и презентациями - Что: - добавлен skill pdf для работы с PDF файлами - добавлен skill doc для работы с Word документами - добавлен skill slides для создания презентаций PowerPoint - добавлен skill jupyter-notebook для работы с Jupyter ноутбуками - добавлен skill spreadsheet для работы с таблицами (Excel/CSV) - изменена модель Claude с sonnet на opus - Проверка: - проверка доступности skills в AI-ассистентах
790 lines
27 KiB
JavaScript
790 lines
27 KiB
JavaScript
// 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,
|
|
};
|