10 июля 2026 г.

Как измерить размер текста до отрисовки браузером.

Чтобы разбить текст на слайды 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.