Текст – один из самых сложных для отрисовки объектов, так как буквы бывают разной ширины, высоты и других параметров, из-за которых шрифты описываются тьюринг-полными языками. Но темой моей дипломной работы является онлайн-редактор мемов, где важной частью является возможность рисовать текст с обводкой. Браузер предоставляет такую возможность с использованием HTML Canvas API через интерфейс CanvasRenderingContext2D (в дальнейшем будем называть этот интерфейс “контекстом”). Некоторым особенностям этого API и посвящена данная статья.
Допустим, нам нужно нарисовать текст, вписав его в некую область. Для этого нам хотелось бы функцию DrawText(text, x, y, width, height), но её не существует. Методы fillText и strokeText рисуют текст в одну строку с заданными сейчас параметрами шрифта и позиционирования. Реализацию подбора нужного размера шрифта необходимо реализовать самостоятельно. Проблемой является то, что размер строки текста сильно зависит и от набора используемых букв, поскольку в общем случае шрифты не моноширинные, и от непосредственно используемого шрифта. Также, даже зная заданный кегль, сложно вычислить высоту строки в пикселях, поскольку существуют буквы разной высоты с над/подстрочными элементами (например, Й и Д). Контекст предоставляет метод measureText, который возвращает для заданной строки структуру TextMetrix с данными о размерах, которые получатся при текущих настройках контекста. И с полями этой структуры не всё так просто. Помимо некоторой ширины строки, даются данные о BoundingBox – описывающем текст прямоугольнике. Но вычисляются не просто его ширина и высота, а расстояния от точки, в которой мы нарисуем текст, до сторон этого прямоугольника (а это зависит также от параметров расположения текста относительно задаваемой точки отрисовки: textAlign (лево/право/центр) и textBaseline (верх/низ/центр)).
Поскольку нашей задаче является вписывание текста в прямоугольник, то нам необходимо работать с полями actualBoundingBox*. Чтобы получить высоту строки, надо сложить actualBoundingBoxAscent и actualBoundingBoxDescent, а для ширины actualBoundingBoxRight и actualBoundingBoxLeft. При этом, важно отметить, что мы можем получить значение ширины больше, чем находится в поле width, потому что это width – это advance на иллюстрации, т.е. расстояние от начала письма первой буквы до конца письма последней буквы.
Наиболее заметна разница при использовании курсива или рукописных шрифтов, где описывающие прямоугольники у букв пересекаются.
На иллюстрации для шрифта Lobster видно, что границы соседних букв пересекаются.
Таким образом, поле width полезно, если мы рисуем последовательно слова в одной строчке, но не поможет нам вписать текст в прямоугольник, поэтому в данной статье его использовать не будем.
Мы можем измерять размеры текста, теперь осталось подобрать такой размер шрифта (высоту букв), что текст оказывается вписан в прямоугольник с заданными шириной и высотой. Можно перебирать размер от 1 пикселя до тех пор, пока не начнём превышать размеры заданного прямоугольника, но это долго. Ширина и высота текста прямо пропорциональны размеру шрифта, а значит, монотонно возрастают при его увеличении. Это позволяет нам воспользоваться бинарным поиском.
function searchFontSize(
ctx: CanvasRenderingContext2D,
text: string,
// хранит шрифт, курсивность, жирность и прочие
// настройки, которые можно указать в ctx.font
font: FontSettings,
maxWidth: number,
maxHeight: number
): number {
let left = 1;
let right = maxHeight;
for (;;) {
const middle = (left + right) / 2;
ctx.font = fontSettingsToCSS(font, middle);
const params = ctx.measureText(text);
const textWidth = params.actualBoundingBoxLeft + params.actualBoundingBoxRight;
const textHeight = params.actualBoundingBoxAscent + params.actualBoundingBoxDescent;
if (Math.abs(left - right) < 1)
return middle;
if (textWidth > maxWidth || textHeight > maxHeight)
right = middle;
else
left = middle;
}
}
Допустим, что у прямоугольника, в который нам нужно вписать текст, заданы координаты его центра. Теперь нам нужно вычислить, какие координаты передать ctx.fillText, чтобы текст оказался по центру. Просто выставить ctx.textAlign = “center” недостаточно, потому что у части шрифтов и при курсиве центр букв оказывается не совсем там, где мы бы ожидали. Поэтому actualBoundingBoxLeft оказывается не равен actualBoundingBoxRight, т.е. центр описывающего прямоугольника из measureText оказывается не совсем там, где мы хотим. Аналогично и с высотой, нужно сделать поправку на actualBoundingBoxAscent.
const params = ctx.measureText(t);
const textWidth = params.actualBoundingBoxLeft + params.actualBoundingBoxRight;
const shiftX = textWidth / 2 - params.actualBoundingBoxLeft;
const x = centerX - shiftX;
const y = centerY - params.actualBoundingBoxAscent;
Дополнительно в дипломной работе реализованы обработка размера обводки и учёт многострочного текста, но это выходит за рамки данной статьи.
Ссылки: