Каждый раз после написания статьи я ещё полчаса возился с обложкой: открыть редактор, подобрать шрифт, выровнять заголовок, экспортировать. В какой-то момент я решил, что обложка — это функция от заголовка, и её должна рисовать программа. Так появился небольшой инструмент, который сначала делал только картинку 1200×630, а потом оброс визитками, открытками, закладками и постерами A4 с PDF для типографии.
В статье расскажу, как он устроен: один HTML-файл без сборки, отрисовка на Canvas, перевод миллиметров в пиксели для печати, раскладка нескольких визиток на лист с метками реза, перевод интерфейса на шесть языков без библиотек и офлайн-режим. Ссылка на сам инструмент — в конце.
Сразу оговорюсь: я из Кореи, русский текст подготовлен с помощью переводчика, а код я писал вместе с ИИ-ассистентом (Claude) — проектные решения, проверки и ошибки при этом мои. Буду рад поправкам и к коду, и к языку.
Почему один файл и без сервера
Требования были простые:
открыть страницу и сразу работать — без регистрации и установки;
фотографии и текст пользователя никуда не отправляются;
хостинг бесплатный и не требует присмотра.
Из этого почти автоматически следует архитектура: статическая страница на GitHub Pages, вся отрисовка — в браузере на Canvas. Файл index.html (около 1500 строк вместе со стилями и скриптом), отдельный файл со строками переводов и сервис-воркер на 20 строк. Никакого бандлера, фреймворка и node_modules: что лежит в репозитории, то и открывается в браузере.
Минус очевиден — один большой файл. Но для инструмента такого размера это оказалось удобнее, чем я ожидал: любое изменение проверяется перезагрузкой страницы.
Форматы как данные
Сначала был один формат, и код рисовал его «в лоб». Когда форматов стало десять, я вынес их описание в данные. Каждый формат — объект: размеры, список полей ввода и список текстовых блоков с ролями (заголовок, подзаголовок, подпись и т. д.). Холст делится на сетку 3×3, и каждый блок привязывается к ячейке — слева/по центру/справа и сверху/посередине/снизу, плюс тонкий сдвиг ползунком.
Отрисовка одна на все форматы: она проходит по блокам, подбирает размер шрифта так, чтобы текст влез в отведённую область, и рисует. Добавить новый формат — значит дописать ещё один объект, а не ещё одну функцию.
Заголовок вида «Серия | Заголовок — Подзаголовок» разбирается на три части автоматически: серия уходит в цветную плашку, заголовок — крупно, подзаголовок — мелко.
Печать: думать в миллиметрах
Для экрана всё просто — пиксели. Для печати нужно думать в миллиметрах и переводить их в пиксели при 300 dpi:
const MM = (mm) => Math.round(mm / 25.4 * 300);Визитка 90×50 мм превращается в холст 1063×591 пикселей, а с вылетами по 1 мм с каждой стороны — чуть больше. Вылеты нужны, чтобы после резки по краю не оставалась белая полоска: фон рисуется за линию реза.
В превью я показываю две пунктирные линии: красную — линию реза, серую — границу безопасной зоны для текста. В итоговый файл они не попадают. Это мелочь, но именно она избавила от вопроса «почему у меня обрезало телефон».
Раскладка на лист и метки реза
Отдельная история — напечатать визитки дома на A4. Нужно понять, сколько штук помещается, причём проверить и книжную, и альбомную ориентацию листа:
for (const land of [false, true]) {
const pw = land ? A4.h : A4.w, ph = land ? A4.w : A4.h;
const cols = Math.floor((pw - 2 * SHEET_MARGIN + gap) / (w + gap));
const rows = Math.floor((ph - 2 * SHEET_MARGIN + gap) / (h + gap));
const n = Math.max(0, cols) * Math.max(0, rows);
if (!best || n > best.n) best = { land, cols, rows, n /* … */ };
}Побеждает ориентация, в которой помещается больше. PDF собирается через jsPDF: картинки расставляются по сетке, а по краям листа рисуются метки реза.
С двусторонней печатью была ошибка, которую я нашёл только на бумаге: оборотные стороны оказались не под своими лицевыми. При двусторонней печати страница с оборотами должна быть отзеркалена по горизонтали — тогда при перевороте листа по длинному краю оборот попадает на своё место. Теперь страница с оборотами раскладывается в обратном порядке по столбцам.
Скорость: рисовать реже и меньше
На телефоне интерфейс начал «залипать»: каждое движение ползунка вызывало полную перерисовку холста 2480×3508 (A4 при 300 dpi). Помогли три вещи.
Во-первых, перерисовка собирается в один кадр:
function draw() {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; drawNow(); });
}Во-вторых, превью рисуется в уменьшенном масштабе, а полный размер — только при сохранении:
const k = Math.min(1, 1800 / Math.max(D.W, D.H));В-третьих, загруженная фотография сразу уменьшается до двух заранее подготовленных копий (для превью и для сохранения), чтобы не масштабировать 12-мегапиксельный снимок на каждом кадре.
Чтобы не сломать существующее, я сравнивал результат попиксельно: скрипт открывает старую и новую версии, проходит по всем форматам и сверяет холсты. Отличается хоть один пиксель в формате по умолчанию — изменение не выкладывается.
Шесть языков без библиотеки
Интерфейс изначально был на корейском. Вместо ключей вида btn.save я использую в качестве ключа саму корейскую фразу:
function t(ko, vars) {
let s = (LANG !== "ko" && I18N.dict[LANG] && I18N.dict[LANG][ko]) || ko;
if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
return s;
}Нет перевода — показывается исходная строка, ничего не ломается. Статический HTML переводится обходом текстовых узлов: при первой загрузке исходные строки запоминаются, при смене языка — подставляются переводы. Файл словаря генерируется скриптом, который падает, если в каком-то языке не хватает строки.
Язык выбирается так: параметр ?lang=ru в адресе → сохранённый выбор → список языков браузера по порядку → английский. Вторым пунктом списка я сначала пренебрёг, и человек с настройкой «вьетнамский, испанский» получал английский вместо испанского.
Отдельно пришлось заняться датами и шрифтами: формат даты у каждого языка свой, а для японского, китайского и кириллицы подгружается отдельный шрифт — иначе текст на холсте рисуется системным шрифтом и раскладка «плывёт».
Офлайн и установка
Сервис-воркер совсем короткий: свои файлы — «сначала сеть, при неудаче кэш», файлы с CDN (шрифты, jsPDF) — «сначала кэш». С манифестом страница ставится на телефон как приложение и после первого открытия работает без интернета. Для iPhone, где нет системного предложения установки, показывается подсказка про «Поделиться → На экран „Домой“».
Что не получилось
Фото из Wikimedia в браузере. Я хотел встроить набор свободных фотографий (CC0) и скачать их прямо со страницы репозитория — политика безопасности содержимого этого не позволила. В итоге картинки скачивает и уменьшает GitHub Actions по списку из JSON-файла.
Web Share API позволяет передать файл, текст и адрес, но мессенджеры обращаются с этим набором по-разному: один берёт только картинку, другой — картинку и ссылку отдельным сообщением. Универсального сочетания я не нашёл.
Перенос строк. Корейский текст по умолчанию переносится по слогам, пришлось включать перенос по словам; а скобка после длинного слова до сих пор иногда уезжает на отдельную строку.
Итог
Получился инструмент, которым я пользуюсь для каждого поста. Он бесплатный, без регистрации и водяных знаков, изображения не покидают браузер.
Попробовать: https://card.fls9.kr/?lang=ru&from=habr
Если найдёте ошибку в раскладке для печати или в русском интерфейсе — напишите в комментариях, исправлю.
Комментарии 0