26 июня 2026 г.

Почему экспорт из HTML в картинку берёт не тот шрифт, и как это решает HarfBuzz.

Турецкая интерлинеарная глосса с транскрипцией и кастомной типографикой, экспортированная без потери шрифта

Word-by-word Aligner позволяет задать каждой строке свой шрифт, в том числе загруженный вами, а затем экспортировать схему в PNG или PDF. Экспорт должен совпадать с предпросмотром вплоть до глифа. По дороге к этому мы упёрлись в проблему, которая всплывает в любом конвейере HTML в картинку или SVG на канвас с пользовательскими шрифтами, так что её стоит записать, даже если ваше приложение совсем на это не похоже.

Баг: экспорт запасным шрифтом

Экспорт начинается с того SVG, который уже выдаёт предпросмотр. Чтобы превратить его в PNG, стандартный ход такой: загрузить этот SVG в Image и нарисовать на канвасе:

const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const img = new Image();
await new Promise<void>((resolve, reject) => {
  img.onload = () => resolve();
  img.onerror = () => reject(new Error('SVG image decode failed'));
  img.src = dataUrl;
});
// ... drawImage на канвас, затем canvas.toBlob()

Это работает, пока SVG не ссылается на пользовательский шрифт через встроенный @font-face. Когда браузер рендерит SVG внутри Image, загрузка шрифта идёт асинхронно, и картинка часто декодируется раньше, чем шрифт готов. Первый кадр рисуется запасным семейством, и именно этот кадр захватывает канвас. Предпросмотр выглядит верно, а PNG выходит не в том шрифте.

Можно попробовать дождаться шрифтов до отрисовки. Это помогает, но путь рендеринга через Image не даёт надёжного сигнала, что встроенное начертание догрузилось, поэтому гонка остаётся нестабильной.

Решение: убрать шрифт из пути растеризации

В итоге сработало другое: вообще перестать зависеть от шрифта в момент растеризации. Перед растеризацией каждый фрагмент текста, набранный загруженным шрифтом, превращается в векторные контуры. После этого SVG несёт фигуры, поэтому грузить уже нечего и соревноваться не с чем:

export async function convertCustomFontTextToPaths(svg: string, lines: LineV2[]): Promise<string> {
  const customFamilies = collectCustomFamiliesFromLines(lines);
  if (customFamilies.size === 0) return svg;
  // ... загрузить каждый шрифт, обработать через HarfBuzz, заменить <text> на <path>
}

Работу делают две библиотеки. HarfBuzz (через harfbuzzjs) выполняет шейпинг текста, то есть решает, какие глифы рисуются и куда они встают, тем же семейством движков, что используют браузеры, поэтому лигатуры, контекстные формы и порядок справа налево выходят так же, как на экране. opentype.js затем превращает каждый обработанный глиф в SVG-путь. Шейпинг и извлечение контуров намеренно разделены: HarfBuzz решает, какие глифы и куда поставить, opentype.js их рисует.

hbFont.setScale(fontSizePx, fontSizePx);
const buffer = hb.createBuffer();
buffer.addText(text);
buffer.guessSegmentProperties();
hb.shape(hbFont, buffer, 'kern,liga,rlig,clig,calt,ccmp');
const glyphs = buffer.json(); // [{ g, cl, dx, dy, ax, ay }, ...]

Каждая запись даёт идентификатор глифа (g), ширину продвижения (ax) и смещение (dx, dy). Проход по ним с накопительной позицией пера ставит каждый глиф, а opentype.js выдаёт путь:

const glyph = otFont.glyphs.get(g.g);
const p = glyph.getPath(xPen + g.dx, baselineShift + g.dy, fontSizePx);
paths.push(`<path fill="${fill}" d="${p.toPathData(3)}"/>`);

Две детали, на которых легко споткнуться

Контуры глифов используют систему координат с осью Y, направленной вверх, как привыкли мыслить дизайнеры шрифтов. В SVG ось Y направлена вниз. Поэтому базовую линию приходится пересчитывать, а не копировать. Текст в SVG использует dominant-baseline="central", и это превращается в вертикальный сдвиг, выведенный из собственных метрик шрифта (восходящей и нисходящей):

function baselineOffsetY(baseline: string, font: opentype.Font, fontSize: number): number {
  const ascentPx = (font.ascender / font.unitsPerEm) * fontSize;
  const descentPxSigned = (font.descender / font.unitsPerEm) * fontSize;
  if (baseline === 'central' || baseline === 'middle') return (ascentPx + descentPxSigned) / 2;
  // ... другие базовые линии
  return 0;
}

Вторая деталь в том, чтобы понимать, когда текст лучше не трогать. Шрифты Google остаются настоящим <text>. Они надёжно грузятся из небольших data-URL в формате woff2, делают файл легче и оставляют текст выделяемым в SVG-экспорте, поэтому через превращение в контуры проходят только загруженные шрифты. А если HarfBuzz по какой-то причине не загрузился, код откатывается к шейпингу одним opentype.js, который тоже обрабатывает лигатуры, вместо того чтобы выдать сломанную картинку.

Турецкая интерлинеарная глосса с транскрипцией, кастомной типографикой и коннекторами, экспортированная без потери шрифта

Когда это нужно

Когда ваш экспорт сводится к скриншоту DOM-узла, который вы контролируете, библиотеки вроде html-to-image может хватить, и ничего из этого не понадобится. Превращение в контуры окупается, когда в дело идут произвольные пользовательские шрифты и результат должен быть точным, а это ситуация дизайн-инструментов, генераторов этикеток и сертификатов, всего, что печатает набранное человеком в его собственном начертании. Форма решения остаётся той же везде, где оно применимо: обработать текст через HarfBuzz, нарисовать глифы через opentype.js и отдать растеризатору SVG, которому больше не нужен шрифт.

Word-by-word Aligner бесплатен и работает в браузере на aligner.tinygods.dev. Код открыт (MIT): github.com/tinygodsdev/bitext-word-alignment; превращение в контуры лежит в src/lib/fonts/text-to-paths.ts, а растеризатор в src/lib/export/svg-raster.ts.