Как уместить генератор обложек, визиток и постеров в один HTML-файл: Canvas, миллиметры и никакого сервера

—

от автора

Каждый раз после написания статьи я ещё полчаса возился с обложкой: открыть редактор, подобрать шрифт, выровнять заголовок, экспортировать. В какой-то момент я решил, что обложка — это функция от заголовка, и её должна рисовать программа. Так появился небольшой инструмент, который сначала делал только картинку 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

Если найдёте ошибку в раскладке для печати или в русском интерфейсе — напишите в комментариях, исправлю.

ссылка на оригинал статьи https://habr.com/ru/articles/1107224/