Чтобы разбить текст на слайды Cards, разбивщику нужно знать, сколько текста помещается на слайде, ещё до того как что-либо нарисовано. Рендерить каждую гипотезу раскладки на страницу и измерять её нельзя. Это медленно, и это спор с React за управление DOM на каждое нажатие клавиши. Поэтому измерение идёт отдельно, на canvas, а числа передаются в разбивщик.
Измерение через canvas
Браузер измерит строку без раскладки. У 2D-контекста canvas есть measureText, который возвращает ширину строки в заданном шрифте. Один скрытый canvas, переиспользуемый на всё время жизни страницы, отвечает на каждый запрос:
let ctx: CanvasRenderingContext2D | null = null;
const cache = new Map<string, Map<string, number>>(); // font -> (text -> width)
export function measureSubstringWidth(s, start, len, spec): number {
const fragment = s.slice(start, start + len);
const bucket = cache.get(cacheKey(spec)) ?? new Map();
const hit = bucket.get(fragment);
if (hit !== undefined) return hit;
ctx ??= document.createElement("canvas").getContext("2d");
ctx.font = fontCss(spec); // "normal 600 48px 'Inter', sans-serif"
const w = ctx.measureText(fragment).width;
bucket.set(fragment, w);
return w;
}
Перенос абзаца измеряет одни и те же слова снова и снова, поэтому результаты кэшируются на двух уровнях: по шрифту, затем по фрагменту строки. Одно и то же слово того же размера измеряется один раз.
Перенос слов так, как сделает рендерер
С функцией ширины перенос строк это жадный проход: целые слова добавляются в текущую строку, пока следующее слово не вызовет переполнение, затем начинается новая строка. Интересный случай это один токен шире колонки: длинный URL или составное слово в языке, который не делится по пробелам. Такой токен режется на куски, которые помещаются.
Резать токен посимвольно по единицам UTF-16 нельзя, потому что эмодзи и многие нелатинские глифы занимают две единицы. Разрежете между ними, и получится сломанный символ. Поэтому шаг идёт по кодовой точке Unicode:
// step by Unicode scalar values so surrogate pairs stay intact
while (end < word.length) {
const cp = word.codePointAt(end)!;
const step = cp >= 0x10000 ? 2 : 1; // astral character spans two UTF-16 units
const fits = measureSubstringWidth(word, start, end + step - start, spec) <= maxWidthPx;
if (fits) end += step;
else break;
}
Перед этим фолбэком токен с ASCII-дефисами делится по дефисам, и «mother-in-law» ломается по стыкам, а не посреди слога. Каждая обёрнутая строка также запоминает, заканчивает ли она предложение, заканчивает ли абзац или начинается как продолжение, и это читает разбивщик слайдов, выбирая границы.
Поправка на расхождение между canvas и DOM
Canvas measureText и собственная раскладка текста в браузере не совпадают точно. Canvas склонен занижать ширину для кириллицы и для жирных начертаний. Доверьтесь ему напрямую, и текст, который по измерению помещался, переполнит слайд, когда DOM нарисует его по-настоящему.
Решение в том, чтобы измерять честно и держать запас. Полезную колонку ужимают перед переносом, и движок планирует строки уже, чем заявляет canvas:
// canvas measureText under-reports Cyrillic and bold widths vs the DOM,
// so shrink the wrap column rather than risk overflow at render time
const SPLIT_WRAP_HORIZONTAL_HEADROOM = 0.88;
const wrapWidthPx = Math.floor(usableWidthPx * SPLIT_WRAP_HORIZONTAL_HEADROOM);
const maxSlotsPerCard = Math.floor(bodyHeightPxBudget / lineSlotPx);
С вертикальным бюджетом так же. Внутренняя высота стартует от размера холста, затем вычитаются шапка и подвал, если они включены, поля и запас примерно в половину кегля на выносные элементы последней строки. Остаток, делённый на высоту одной строки, и есть число строк, которое вмещает слайд. Эти константы мы подобрали вручную, наблюдая, как рисуется реальный текст, и сжимая бюджет, пока переполнение не прекратилось.
Как это устроено в целом
Измерение текста вне дерева рендера делает живое разбиение быстрым: каждое нажатие клавиши пересчитывает только некэшированные фрагменты, перенос работает на чистых числах, а упаковщик слайдов исходит из числа строк, а не из узлов DOM. Цена в том, что измерение это приближение к тому, что нарисует браузер, поэтому его дополняют осознанными запасами. Именно этот размен, дёшево мерить вне экрана и оставлять место на расхождение, мы бы перенесли в любую живую раскладку текста.
Базовый функционал Cards бесплатен и работает в браузере на cards.tinygods.dev.