Cards берёт кусок текста и превращает его в набор слайдов карусели. Вопрос в том, где ставить границы. Положите на каждый слайд фиксированное число символов, и вы режете предложения пополам, оставляете одинокую строку на последнем слайде и получаете слайды, которые рядом смотрятся неравномерно заполненными.
От разбиения мы хотели трёх вещей: заполнять слайды примерно одинаково, ставить границы на концах предложений или абзацев и не оставлять слайд с одной висящей строкой. Эти цели тянут в разные стороны, поэтому разбиение устроено как оптимизация, а не как цикл, который набивает каждый слайд под завязку.
Конвейер
Кусок текста проходит три стадии, прежде чем стать слайдами:
function autoSplitBlock(block: string, design: DesignConfig): string[] {
const plan = splitBodyPlannerMetrics(design);
const flow = plainFlowTokens(block); // words + line breaks
const lines = wrapPlainFlowToLines(flow, plan.usableWidthPx, plan.fontSpec);
const atoms = atomsFromWrappedLines(lines); // one atom per visual line
return balancedPackWeightedAtoms({
atoms,
maxSlotsPerCard: plan.maxSlotsPerCard,
joinInsideCard: "\n",
});
}
Первая стадия превращает текст в поток слов и переносов строк. Вторая обёртывает их в визуальные строки по реальной ширине слайда, той самой, что использует рендерер. Каждая обёрнутая строка становится «атомом», который несёт о себе несколько фактов:
type PackAtom = {
slots: number; // vertical room this line needs
tailEndsSentence: boolean;
tailEndsParagraph: boolean;
headBeginsContinuation: boolean;
chunk: string;
};
slots показывает, сколько вертикального места занимает строка. Флаги отмечают, заканчивает ли строка предложение, заканчивает ли абзац или начинается как его продолжение. Именно эти факты позволяют упаковщику ставить границы в разумных местах.
Стоимость для каждой раскладки
Упаковщик оценивает целую раскладку слайдов и оставляет самую дешёвую. Оценки берутся из одной небольшой таблицы:
const DEFAULT_PACK_COSTS = {
wVar: 100, // distance from an even fill
bonusParagraphBoundary: -30,
bonusSentenceBoundary: -10,
ripMidSentence: 12,
orphanPenalty: 50,
widowPenalty: 15,
};
Читается как набор предпочтений. wVar штрафует слайды, чьё заполнение далеко от среднего, за счёт чего они и получаются ровными. Граница, попавшая на конец абзаца или предложения, приносит бонус, отрицательную стоимость. Граница посреди предложения платит ripMidSentence. Слайд с одним коротким атомом платит orphanPenalty. Строка-продолжение, выброшенная в начало следующего слайда после уже закрытого предложения, платит widowPenalty.
Выбор границ через динамическое программирование
Как только у любой раскладки есть стоимость, поиск лучшего набора границ для фиксированного числа слайдов превращается в задачу о кратчайшем пути, из тех, что хорошо решает динамическое программирование. Упаковщик заполняет таблицу, где dp[ck][i] хранит минимальную стоимость разместить первые i атомов в ck слайдах, собирая большие ответы из меньших:
dp[0][0] = 0;
const targetMean = totalSlots / cardsK;
for (let ck = 1; ck <= cardsK; ck++) {
for (let i = ck; i <= N; i++) {
for (let j = ck - 1; j < i; j++) {
const sliceW = sliceBetween(j, i); // slots used by atoms j..i
if (sliceW > slideSlotBudget) continue; // would overflow one slide
const variance = wVar * (sliceW - targetMean) ** 2;
const rip = outgoingRipCost(atoms[i - 1]); // boundary bonus or mid-sentence cost
const cost = dp[ck - 1][j] + variance + rip + widow + orphan;
if (cost < dp[ck][i]) {
dp[ck][i] = cost;
pick[ck][i] = j;
}
}
}
}
Каждая ячейка перебирает все более ранние точки разреза j: кладёт атомы j..i на текущий слайд, добавляет дисперсию и стоимость границы этого слайда к лучшему способу упаковать первые j атомов на один слайд меньше и оставляет минимум. Проход по обратным ссылкам от dp[K][N] даёт сами разрезы.
Сколько слайдов
Число слайдов K заранее неизвестно. Слишком мало, и текст переполняется; слишком много, и каждый слайд полупустой. Упаковщик стартует с жадной нижней оценки, наименьшего числа слайдов, которое могло бы вместить текст при заполнении каждого до предела, и поднимает K на единицу, пока таблица не найдёт раскладку, которая помещается:
let k = greedyMinCards(atoms, slideBudget); // fewest slides that could fit
for (; k <= atoms.length; k++) {
const packed = balancedPackForExactK({ /* ... */ cardsK: k });
if (packed?.length) return packed;
}
return atoms.map(oneAtomPerSlide); // fallback: never hang
Если не помещается ничего, а это бывает только когда один атом выше слайда, упаковщик откатывается к одному атому на слайд, и редактор не зависает.
Что это даёт
Разбиение остаётся чистой функцией от текста и размера слайда, поэтому оно пересчитывается на каждое нажатие клавиши. Добавьте предложение в середину, и слайды после него перетекают сами, число подстраивается, а границы по-прежнему стоят на концах предложений. Ничего не нужно тащить мышью, слайды не нужно вести вручную. Тот же код срабатывает при смене формата холста: и квадратный пост, и вертикальная история перераспределяются под свои размеры.
Cards бесплатен и работает в браузере на cards.tinygods.dev.