handwrite.py: рукописный рендер кириллицы синими чернилами
CLI-утилита (Pillow) для превращения текста в картинку рукописного текста. Полная поддержка кириллицы, синие чернила с имитацией письма ручкой (дрейф давления, полупрозрачность, неровный край), корректное выравнивание (б/й/н на baseline, р/у/д с хвостами, точки внизу, спецсимволы), перенос по словам + сохранение переносов строк, тетрадная бумага, --list-fonts. run.sh: команды-обёртки (init/upd/render/lined/big/calm/plain/poetry/render-file/render-stdin/list-fonts/examples). Документация: README.md, AGENTS.md. Примеры в examples/.
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Вывод рендера (генерируется через run.sh)
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Временные картинки
|
||||||
|
*.png
|
||||||
|
!examples/*.png
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# AGENTS.md — hand-writter
|
||||||
|
|
||||||
|
Руководство для агентов (и людей), работающих с репозиторием `hand-writter`.
|
||||||
|
|
||||||
|
## О проекте
|
||||||
|
|
||||||
|
`handwrite.py` — CLI-утилита на Python, превращающая текст в картинку рукописного
|
||||||
|
текста с **поддержкой кириллицы** (полный русский алфавит, включая `Ё`, `щ`, `ъ`,
|
||||||
|
`э`, `ю`, `я`). Единственная зависимость — **Pillow** (`PIL`).
|
||||||
|
|
||||||
|
Ключевые особенности:
|
||||||
|
- Автоподбор рукописного шрифта из системы с проверкой покрытия кириллицы
|
||||||
|
(без сторонних библиотек — только Pillow: рендер символа и сравнение с
|
||||||
|
`notdef`-глифом). Приоритет: `Marker Felt`, затем `Snell Roundhand`, затем
|
||||||
|
любой шрифт с кириллицей.
|
||||||
|
- Синие чернила по умолчанию (`#1a3b8c`) с имитацией письма ручкой: коррелированный
|
||||||
|
дрейф «давления» (тёмные/светлые участки), полупрозрачные чернила, неровный край мазка.
|
||||||
|
- Выравнивание глифов — стандартное PIL ``text((x, line_top))`` (через маску в
|
||||||
|
``_stamp_glyph``). Все обычные буквы (а, о, н, б, й) стоят на одной baseline,
|
||||||
|
буквы с нижними выносными (р, у, д, ц, щ) опускают хвосты ниже — строчная
|
||||||
|
«р» ниже обычных букв, точки/запятые внизу, спецсимволы ($ ~ % @ …) — по
|
||||||
|
дизайну шрифта. Не вводите ручное смещение «по ink_bottom» — оно ломает
|
||||||
|
б/й/н и обрезает глифы.
|
||||||
|
- Перенос по словам + сохранение `\n` из входного файла/STDIN.
|
||||||
|
- Тетрадная «линейная» бумага (`--lined`), цвета чернил/бумаги/линий,
|
||||||
|
синие палитры и градиенты оттенков.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
hand-writter/
|
||||||
|
├── handwrite.py # основной скрипт (точка входа)
|
||||||
|
├── requirements.txt # Pillow>=9.0.0
|
||||||
|
├── run.sh # обёртка-команды: init/upd/render/...
|
||||||
|
├── README.md # пользовательская документация
|
||||||
|
├── AGENTS.md # этот файл
|
||||||
|
└── examples/ # примеры вывода
|
||||||
|
├── blue_pen.png
|
||||||
|
├── blue_pen_lined.png
|
||||||
|
├── multiline.png
|
||||||
|
└── sample_input.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Окружение и запуск
|
||||||
|
|
||||||
|
- Python 3.8+ (в `run.sh` предполагается `python3.13`; если его нет —
|
||||||
|
отредактируйте `PY` в `run.sh` или используйте системный `python3`).
|
||||||
|
- Активация venv: `source .venv/bin/activate` (через `./run.sh init`).
|
||||||
|
- Прямой запуск: `python3 handwrite.py "Текст" -o out.png`.
|
||||||
|
- Команды-обёртки: `./run.sh render "Текст"` и др. (см. ниже).
|
||||||
|
|
||||||
|
## Правила работы с кодом
|
||||||
|
|
||||||
|
1. **Не добавляйте новых зависимостей** без крайней необходимости. Скрипт
|
||||||
|
намеренно зависит только от Pillow — это упрощает установку в
|
||||||
|
изолированных средах. Любая новая зависимость должна быть обоснована
|
||||||
|
и добавлена в `requirements.txt`.
|
||||||
|
|
||||||
|
2. **Сохраняйте поддержку кириллицы.** Любое изменение рендера/шрифтов
|
||||||
|
должно проверяться на полном русском алфавите (см. `CYRILLIC_TEST` в
|
||||||
|
`handwrite.py`). Не полагайтесь на «визуальную» проверку — шрифт может
|
||||||
|
отрисовывать `notdef` для отсутствующих глифов; используйте
|
||||||
|
`_font_covers_cyrillic()`.
|
||||||
|
|
||||||
|
3. **Выравнивание — стандартное PIL.** В ``_stamp_glyph`` глиф рисуется маской
|
||||||
|
с origin в канвасе и композируется так, чтобы совпадать с ``text((x, line_top))``.
|
||||||
|
НЕ прижимайте ink_bottom вручную к baseline и НЕ сдвигайте глифы по bbox —
|
||||||
|
это ломает б/й (обрезает) и делает их «слишком низкими». Канвас берётся по
|
||||||
|
абсолютным границам bbox (включая отрицательные bbox[0]/bbox[1] для Ё, р).
|
||||||
|
Знаки препинания рисуются с jitter=0 (чёткие точки), но тем же выравниванием.
|
||||||
|
|
||||||
|
4. **Сохраняйте переносы строк.** `wrap_text` использует `splitlines()` —
|
||||||
|
каждая строка входа становится отдельной строкой на картинке. Не
|
||||||
|
«схлоптывайте» пустые строки без явного флага.
|
||||||
|
|
||||||
|
5. **Не ломайте CLI-совместимость.** Новые параметры добавляйте с значениями
|
||||||
|
по умолчанию, не меняйте семантику существующих.
|
||||||
|
|
||||||
|
6. **Внесение изменений:** после правок проверяйте
|
||||||
|
- `python3 -c "import ast; ast.parse(open('handwrite.py').read())"` — синтаксис;
|
||||||
|
- запуск на кириллице с точками/запятыми и многострочным файлом;
|
||||||
|
- что точки-знаки остаются в нижней зоне строки.
|
||||||
|
|
||||||
|
## Команды (run.sh)
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `./run.sh init` | Создать venv и установить зависимости |
|
||||||
|
| `./run.sh upd` | Обновить зависимости |
|
||||||
|
| `./run.sh render "текст"` | Рендер текста-аргумента → `out/handwriting.png` |
|
||||||
|
| `./run.sh lined "текст"` | Рендер на тетрадной бумаге |
|
||||||
|
| `./run.sh big "текст"` | Крупный шрифт (64pt) на тёплой бумаге |
|
||||||
|
| `./run.sh calm "текст"` | «Спокойный» почерк (jitter 0.3, seed 42) |
|
||||||
|
| `./run.sh plain "текст"` | Ровный текст (без дрожания/поворота строк) |
|
||||||
|
| `./run.sh render-file file.txt` | Рендер файла (с переносами строк) |
|
||||||
|
| `./run.sh poetry file.txt` | Многострочный файл → тетрадь, крупный шрифт → `out/poetry.png` |
|
||||||
|
| `./run.sh render-stdin` | Чтение из STDIN (echo "..." \| ./run.sh render-stdin) |
|
||||||
|
| `./run.sh list-fonts` | Показать доступные рукописные шрифты с кириллицей |
|
||||||
|
| `./run.sh examples` | Перегенерировать примеры в `examples/` |
|
||||||
|
|
||||||
|
Все команды рендера (`render`, `lined`, `big`, `calm`, `plain`, `poetry`,
|
||||||
|
`render-file`, `render-stdin`) принимают доп. опции `handwrite.py` после `--`,
|
||||||
|
например: `./run.sh render "Текст" -- --jitter 0.4 --seed 7 --lined`.
|
||||||
|
|
||||||
|
## Тестовые сценарии (обязательно прогонять при правках)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# кириллица с б/й/р, точками и спецсимволами (б/й на baseline, р ниже, точки внизу)
|
||||||
|
python3 handwrite.py "байты йогурт река. \$ ~ % @ # конец!" -o /tmp/t1.png
|
||||||
|
|
||||||
|
# многострочный файл (каждая строка файла → строка на картинке)
|
||||||
|
printf 'Строка 1.\nСтрока 2.\nСтрока 3.\n' > /tmp/m.txt
|
||||||
|
python3 handwrite.py -i /tmp/m.txt -o /tmp/t2.png --lined
|
||||||
|
|
||||||
|
# синий (по умолчанию) + тетрадь
|
||||||
|
python3 handwrite.py "Текст" --lined -o /tmp/t3.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Чего не делать
|
||||||
|
|
||||||
|
- Не коммитьте `.DS_Store`, `.venv/`, временные `*.png` вне `examples/`.
|
||||||
|
- Не удаляйте `examples/` — это демонстрация возможностей.
|
||||||
|
- Не «упрощайте» проверку глифов кириллицы до `textbbox`/ширины — это
|
||||||
|
ненадёжно (узкие буквы, например «г», неотличимы от `notdef` по ширине).
|
||||||
|
- Цвет чернил по умолчанию — синий. Палитры/градиенты намеренно убраны
|
||||||
|
(раньше были, но усложняли). Не возвращайте мульти-цвет без явной просьбы.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# handwrite.py
|
||||||
|
|
||||||
|
Python-скрипт, превращающий текст в картинку рукописного текста с **поддержкой
|
||||||
|
кириллицы** (полный русский алфавит, включая `Ё`, `щ`, `ъ`, `э`, `ю`, `я`).
|
||||||
|
Чернила — **синие**, с имитацией письма ручкой.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- 🖋️ **Синие чернила «как рукой»**: имитация письма ручкой — дрейф «давления»
|
||||||
|
(коррелированные тёмные/светлые участки), полупрозрачные чернила, неровный
|
||||||
|
край мазка
|
||||||
|
- ✅ Полная поддержка кириллицы (автоподбор рукописного шрифта с проверкой
|
||||||
|
наличия всех глифов; приоритет — `Marker Felt`)
|
||||||
|
- 📐 Корректное выравнивание: обычные буквы (`а`, `о`, `н`, `б`, `й`) на одной
|
||||||
|
базовой линии, буквы с нижними выносными (`р`, `у`, `д`, `ц`, `щ`) — с
|
||||||
|
хвостами ниже, точки/запятые — внизу строки, спецсимволы (`$ ~ % @ # & …`) —
|
||||||
|
по дизайну шрифта
|
||||||
|
- 📝 Перенос по словам + сохранение `\n` из входного файла/STDIN
|
||||||
|
- 📓 «Тетрадная» линейная бумага (`--lined`)
|
||||||
|
- 🎨 Настройка цвета чернил/бумаги/линий, размера шрифта, полей
|
||||||
|
- 📥 Ввод: аргументом, из файла (`-i`, с переносами строк), через STDIN
|
||||||
|
- 🔒 Воспроизводимый результат через `--seed`
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # нужна только Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
или через обёртку (создаст venv):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh init
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# текст аргументом
|
||||||
|
python3 handwrite.py "Привет, мир!" -o hello.png
|
||||||
|
|
||||||
|
# тетрадная бумага
|
||||||
|
python3 handwrite.py "Привет, мир!" -o hello.png --lined
|
||||||
|
|
||||||
|
# из файла с переносами строк (каждая строка файла → строка на картинке)
|
||||||
|
python3 handwrite.py -i text.txt -o letter.png --lined
|
||||||
|
|
||||||
|
# через STDIN
|
||||||
|
echo "Мама мыла раму" | python3 handwrite.py -o out.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры
|
||||||
|
|
||||||
|
| Параметр | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `text` | — | Текст для рендера (позиционный). |
|
||||||
|
| `-i, --input` | — | Файл с текстом (UTF-8); переносы строк сохраняются. `-` = STDIN, иначе при отсутствии текста — STDIN. |
|
||||||
|
| `-o, --output` | `handwriting.png` | Выходной PNG. |
|
||||||
|
| `--font` | авто | Путь к шрифту (`path` или `path:index` в `.ttc`). Иначе авто-подбор рукописного с кириллицей. |
|
||||||
|
| `--font-size` | `44` | Размер шрифта (pt). |
|
||||||
|
| `--line-spacing` | `1.6` | Множитель межстрочного интервала. |
|
||||||
|
| `--margin` | `64` | Поля вокруг текста (px). |
|
||||||
|
| `--chars-per-line` | `48` | Примерная ширина строки в символах для переноса. |
|
||||||
|
| `--ink` | `#1a3b8c` | Цвет чернил (`#RRGGBB`). По умолчанию — синий. |
|
||||||
|
| `--paper` | `#ffffff` | Цвет бумаги (`#RRGGBB`). |
|
||||||
|
| `--lined` | выкл. | Рисовать «тетрадные» линии. |
|
||||||
|
| `--lined-color` | `#cfe8ff` | Цвет линий бумаги. |
|
||||||
|
| `--jitter` | `1.0` | Сила «рукописного» дрожания (`0` — ровный текст). |
|
||||||
|
| `--no-rotate-lines` | выкл. | Отключить лёгкий поворот строк. |
|
||||||
|
| `--seed` | случайный | Зерно ГПСЧ для воспроизводимости. |
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# крупный текст на тёплой бумаге
|
||||||
|
python3 handwrite.py "Стихи на полях" -o poem.png --font-size 56 --paper "#fdf6e3" --lined
|
||||||
|
|
||||||
|
# «спокойный» почерк — меньше колебаний давления, фиксированный seed
|
||||||
|
python3 handwrite.py "Ровный аккуратный текст" -o neat.png --jitter 0.3 --seed 42
|
||||||
|
|
||||||
|
# полностью ровный текст (без дрожания и поворота)
|
||||||
|
python3 handwrite.py "Как печатный" -o plain.png --jitter 0 --no-rotate-lines
|
||||||
|
|
||||||
|
# другой синий оттенок чернил
|
||||||
|
python3 handwrite.py "Глубокий синий" -o deep.png --ink "#0f2d78"
|
||||||
|
|
||||||
|
# широкий формат (длинные строки)
|
||||||
|
python3 handwrite.py -i long_text.txt -o wide.png --chars-per-line 80 --margin 40
|
||||||
|
|
||||||
|
# другой рукописный шрифт (Snell Roundhand)
|
||||||
|
python3 handwrite.py "Текст" --font "/System/Library/Fonts/Supplemental/SnellRoundhand.ttc:0"
|
||||||
|
|
||||||
|
# многострочный файл через STDIN
|
||||||
|
cat text.txt | python3 handwrite.py -o out.png --lined
|
||||||
|
|
||||||
|
# тёмная бумага, светлые чернила (например, для презентации)
|
||||||
|
python3 handwrite.py "Белым по тёмному" -o dark.png --ink "#cfe8ff" --paper "#1a2238"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды run.sh
|
||||||
|
|
||||||
|
Удобные обёртки для типовых задач (вывод — в `out/`):
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `./run.sh init` | Создать venv и установить зависимости |
|
||||||
|
| `./run.sh upd` | Обновить зависимости |
|
||||||
|
| `./run.sh render "текст"` | Рендер текста → `out/handwriting.png` |
|
||||||
|
| `./run.sh lined "текст"` | Рендер на тетрадной бумаге → `out/handwriting.png` |
|
||||||
|
| `./run.sh big "текст"` | Крупный шрифт (64pt) на тёплой бумаге |
|
||||||
|
| `./run.sh calm "текст"` | «Спокойный» почерк (jitter 0.3, seed 42) |
|
||||||
|
| `./run.sh plain "текст"` | Ровный текст (без дрожания/поворота) |
|
||||||
|
| `./run.sh render-file file` | Рендер файла (с переносами строк) |
|
||||||
|
| `./run.sh poetry file` | Многострочный файл → тетрадь, крупный шрифт |
|
||||||
|
| `./run.sh render-stdin` | Чтение из STDIN (`echo "..." \| ./run.sh render-stdin`) |
|
||||||
|
| `./run.sh list-fonts` | Показать доступные рукописные шрифты с кириллицей |
|
||||||
|
| `./run.sh examples` | Перегенерировать примеры в `examples/` |
|
||||||
|
|
||||||
|
Доп. опции `handwrite.py` передаются после `--` (или просто в конце):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh render "Текст" -- --jitter 0.4 --seed 7
|
||||||
|
./run.sh render-file text.txt --chars-per-line 60
|
||||||
|
./run.sh lined "Текст" -- --ink "#0f2d78"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```
|
||||||
|
examples/
|
||||||
|
├── blue_pen.png # синий текст «от руки»
|
||||||
|
├── blue_pen_lined.png # синий текст на тетради
|
||||||
|
├── multiline.png # из файла с переносами строк
|
||||||
|
└── sample_input.txt # пример входного файла
|
||||||
|
```
|
||||||
|
|
||||||
|
Перегенерировать: `./run.sh examples`.
|
||||||
|
|
||||||
|
## Подбор шрифта
|
||||||
|
|
||||||
|
Скрипт автоматически ищет рукописный шрифт с **полным покрытием кириллицы**:
|
||||||
|
|
||||||
|
1. Проверяет приоритетные системные шрифты (`Marker Felt`, затем `Snell Roundhand`).
|
||||||
|
2. Сканирует системные каталоги, отбирая «рукописные» имена и проверяя все
|
||||||
|
глифы кириллицы.
|
||||||
|
3. В крайнем случае берёт любой шрифт с поддержкой кириллицы.
|
||||||
|
|
||||||
|
Проверка глифов — без сторонних библиотек (только Pillow): каждый символ
|
||||||
|
рендерится и сравнивается с формой `notdef`-глифа. Узкие буквы (например «г»)
|
||||||
|
неотличимы от `notdef` по ширине, поэтому сравнение делается по форме, а не по
|
||||||
|
ширине.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- Pillow (`pip install pillow`)
|
||||||
|
|
||||||
|
## Файлы проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
hand-writter/
|
||||||
|
├── handwrite.py # основной скрипт
|
||||||
|
├── requirements.txt # Pillow
|
||||||
|
├── run.sh # команды-обёртки
|
||||||
|
├── README.md # этот файл
|
||||||
|
├── AGENTS.md # руководство для агентов/разработчиков
|
||||||
|
└── examples/ # примеры вывода
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробности о внутреннем устройстве и правилах изменения кода — в `AGENTS.md`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
Многострочный текст
|
||||||
|
с переносами строк.
|
||||||
|
Точки, запятые — внизу.
|
||||||
Executable
+626
@@ -0,0 +1,626 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
handwrite.py — Рендер текста в виде рукописной картинки.
|
||||||
|
Поддержка кириллицы (русский алфавит) за счёт подбора системных
|
||||||
|
рукописных шрифтов с полной кириллической таблицей (Marker Felt,
|
||||||
|
Snell Roundhand) и автоматического fallback-поиска.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python3 handwrite.py "Привет, мир!" -o hello.png
|
||||||
|
echo "Длинный текст..." | python3 handwrite.py -o out.png
|
||||||
|
python3 handwrite.py -i text.txt -o out.png --lined
|
||||||
|
python3 handwrite.py "Текст" --font-size 48 --paper "#fdf6e3"
|
||||||
|
python3 handwrite.py "Текст" --lined
|
||||||
|
|
||||||
|
Зависимости: Pillow (PIL) -> pip install pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
sys.stderr.write("Нужна библиотека Pillow. Установите: pip install pillow\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Подбор шрифта
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Приоритетный список системных рукописных/маркерных шрифтов (путь, индекс в .ttc).
|
||||||
|
# Они проверены на полное покрытие русской кириллицы на macOS.
|
||||||
|
PREFERRED_FONTS: List[Tuple[str, int]] = [
|
||||||
|
("/System/Library/Fonts/MarkerFelt.ttc", 0), # Marker Felt Thin
|
||||||
|
("/System/Library/Fonts/MarkerFelt.ttc", 1), # Marker Felt Wide
|
||||||
|
("/System/Library/Fonts/Supplemental/SnellRoundhand.ttc", 0), # Snell Roundhand
|
||||||
|
("/System/Library/Fonts/Supplemental/SnellRoundhand.ttc", 1),
|
||||||
|
("/System/Library/Fonts/Supplemental/SnellRoundhand.ttc", 2),
|
||||||
|
("/System/Library/Fonts/Supplemental/Comic Sans MS.ttf", 0), # запасной, с кириллицей
|
||||||
|
]
|
||||||
|
|
||||||
|
# Каталоги для поиска дополнительных шрифтов-кандидатов.
|
||||||
|
FONT_DIRS = [
|
||||||
|
"/System/Library/Fonts",
|
||||||
|
"/System/Library/Fonts/Supplemental",
|
||||||
|
"/Library/Fonts",
|
||||||
|
os.path.expanduser("~/Library/Fonts"),
|
||||||
|
"/usr/share/fonts",
|
||||||
|
"/usr/local/share/fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Имена файлов, похожие на рукописные.
|
||||||
|
HANDWRITING_HINTS = (
|
||||||
|
"marker", "bradley", "comic", "hand", "script", "chalk",
|
||||||
|
"caveat", "snan", "note", "pen", "sign", "cursive",
|
||||||
|
"brush", "crayon", "kristen", "noteworthy", "snell", "marck",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Полный набор кириллических символов для проверки покрытия.
|
||||||
|
CYRILLIC_TEST = "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяЁё"
|
||||||
|
|
||||||
|
|
||||||
|
_GLYPH_BOX = 72
|
||||||
|
|
||||||
|
|
||||||
|
def _glyph_pixels(path: str, ch: str, index: int = 0, size: int = 60,
|
||||||
|
box: int = _GLYPH_BOX) -> "Image.Image":
|
||||||
|
"""Рендерит символ в фиксированный квадрат для попиксельного сравнения."""
|
||||||
|
f = ImageFont.truetype(path, size, index=index)
|
||||||
|
img = Image.new("L", (1, 1))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
b = d.textbbox((0, 0), ch, font=f)
|
||||||
|
img = Image.new("L", (box, box), 0)
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
x = (box - (b[2] - b[0])) // 2 - b[0] if (b[2] - b[0]) > 0 else box // 4
|
||||||
|
y = (box - (b[3] - b[1])) // 2 - b[1] if (b[3] - b[1]) > 0 else box // 4
|
||||||
|
d.text((x, y), ch, font=f, fill=255)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _glyph_diff(a: "Image.Image", b: "Image.Image") -> int:
|
||||||
|
"""Сумма абсолютных разностей пикселей двух L-изображений."""
|
||||||
|
ba = a.tobytes()
|
||||||
|
bb = b.tobytes()
|
||||||
|
return sum(abs(x - y) for x, y in zip(ba, bb))
|
||||||
|
|
||||||
|
|
||||||
|
def _font_covers_cyrillic(path: str, index: int = 0) -> bool:
|
||||||
|
"""Проверяет, что шрифт реально содержит глифы для всей кириллицы.
|
||||||
|
|
||||||
|
Надёжный критерий без fontTools: каждый символ рендерится в фиксированный
|
||||||
|
квадрат и сравнивается с формой notdef-глифа (\uFFF0 заведомо отсутствует).
|
||||||
|
Если рендер совпадает с notdef — глифа в шрифте нет.
|
||||||
|
|
||||||
|
Любая ошибка шрифта (включая OSError: stack overflow у некоторых битых
|
||||||
|
шрифтов) трактуется как «не покрывает» — шрифт пропускается.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ImageFont.truetype(path, 40, index=index)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
notdef = _glyph_pixels(path, "\uFFF0", index)
|
||||||
|
for ch in CYRILLIC_TEST:
|
||||||
|
glyph = _glyph_pixels(path, ch, index)
|
||||||
|
if _glyph_diff(glyph, notdef) < 2000: # почти неотличим от notdef
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_handwriting_font(explicit: Optional[str] = None) -> Tuple[str, int]:
|
||||||
|
"""Возвращает (путь, индекс) подходящего рукописного шрифта с кириллицей."""
|
||||||
|
if explicit:
|
||||||
|
# Пользовательский шрифт: поддерживаем запись "path:index".
|
||||||
|
if ":" in explicit and os.path.exists(explicit.rsplit(":", 1)[0]):
|
||||||
|
path, idx = explicit.rsplit(":", 1)
|
||||||
|
return path, int(idx)
|
||||||
|
if os.path.exists(explicit):
|
||||||
|
return explicit, 0
|
||||||
|
# Может быть именем шрифта — попытаемся найти среди каталогов.
|
||||||
|
target = explicit.lower()
|
||||||
|
for d in FONT_DIRS:
|
||||||
|
for p in _iter_font_files(d):
|
||||||
|
if target in os.path.basename(p).lower():
|
||||||
|
for idx in range(4):
|
||||||
|
if _font_covers_cyrillic(p, idx):
|
||||||
|
return p, idx
|
||||||
|
raise FileNotFoundError(f"Шрифт не найден или не покрывает кириллицу: {explicit}")
|
||||||
|
|
||||||
|
# 1) Приоритетные проверенные шрифты.
|
||||||
|
for path, idx in PREFERRED_FONTS:
|
||||||
|
if os.path.exists(path) and _font_covers_cyrillic(path, idx):
|
||||||
|
return path, idx
|
||||||
|
|
||||||
|
# 2) Поиск по всем каталогам среди «рукописных» имён.
|
||||||
|
for d in FONT_DIRS:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for p in _iter_font_files(d):
|
||||||
|
base = os.path.basename(p).lower()
|
||||||
|
if not any(h in base for h in HANDWRITING_HINTS):
|
||||||
|
continue
|
||||||
|
for idx in range(4):
|
||||||
|
if _font_covers_cyrillic(p, idx):
|
||||||
|
return p, idx
|
||||||
|
|
||||||
|
# 3) Любой шрифт с кириллицей (последний рубеж).
|
||||||
|
for d in FONT_DIRS:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for p in _iter_font_files(d):
|
||||||
|
for idx in range(4):
|
||||||
|
if _font_covers_cyrillic(p, idx):
|
||||||
|
return p, idx
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"Не найден системный шрифт с поддержкой кириллицы. "
|
||||||
|
"Укажите путь через --font или установите рукописный шрифт "
|
||||||
|
"(например, Caveat, Marck Script) с кириллицей."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_handwriting_fonts() -> None:
|
||||||
|
"""Печатает все найденные рукописные шрифты с поддержкой кириллицы."""
|
||||||
|
seen = set()
|
||||||
|
found = []
|
||||||
|
|
||||||
|
# Приоритетные
|
||||||
|
for path, idx in PREFERRED_FONTS:
|
||||||
|
if os.path.exists(path) and _font_covers_cyrillic(path, idx):
|
||||||
|
key = (path, idx)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
found.append(("приоритетный", path, idx))
|
||||||
|
|
||||||
|
# Поиск по «рукописным» именам
|
||||||
|
for d in FONT_DIRS:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for p in _iter_font_files(d):
|
||||||
|
base = os.path.basename(p).lower()
|
||||||
|
if not any(h in base for h in HANDWRITING_HINTS):
|
||||||
|
continue
|
||||||
|
for idx in range(4):
|
||||||
|
key = (p, idx)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if _font_covers_cyrillic(p, idx):
|
||||||
|
seen.add(key)
|
||||||
|
found.append(("рукописный", p, idx))
|
||||||
|
|
||||||
|
# (Третий блок «любой шрифт с кириллицей» намеренно пропущен: он медленный
|
||||||
|
# и выдаёт сотни не-рукописных шрифтов. Для рендера нужен именно рукописный.)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
sys.stdout.write("Рукописные шрифты с кириллицей не найдены.\n")
|
||||||
|
return
|
||||||
|
sys.stdout.write(f"Найдено шрифтов с поддержкой кириллицы: {len(found)}\n")
|
||||||
|
for kind, path, idx in found:
|
||||||
|
mark = " ← используется по умолчанию" if (path, idx) == (PREFERRED_FONTS[0][0], PREFERRED_FONTS[0][1]) and os.path.exists(path) else ""
|
||||||
|
sys.stdout.write(f" [{kind}] {path}#{idx}{mark}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_font_files(root: str):
|
||||||
|
for dirpath, _dirs, files in os.walk(root):
|
||||||
|
for fn in files:
|
||||||
|
if fn.lower().endswith((".ttf", ".ttc", ".otf")):
|
||||||
|
yield os.path.join(dirpath, fn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Рендеринг
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_font(path: str, index: int, size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(path, size, index=index)
|
||||||
|
|
||||||
|
|
||||||
|
def _word_widths(text: str, font: ImageFont.FreeTypeFont, draw: ImageDraw.ImageDraw) -> List[Tuple[str, int]]:
|
||||||
|
"""Возвращает список (слово, ширина в пикселях) с пробелом-разделителем."""
|
||||||
|
space = draw.textlength(" ", font=font)
|
||||||
|
parts = []
|
||||||
|
for i, word in enumerate(text.split(" ")):
|
||||||
|
w = draw.textlength(word, font=font)
|
||||||
|
parts.append((word, w))
|
||||||
|
# между словами — пробел
|
||||||
|
result = []
|
||||||
|
for i, (word, w) in enumerate(parts):
|
||||||
|
result.append((word, w))
|
||||||
|
if i < len(parts) - 1:
|
||||||
|
result.append((" ", space))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text(text: str, font: ImageFont.FreeTypeFont, draw: ImageDraw.ImageDraw,
|
||||||
|
max_width: int) -> List[str]:
|
||||||
|
"""Перенос текста по словам с учётом ширины строки."""
|
||||||
|
lines_in = text.splitlines() or [""]
|
||||||
|
out: List[str] = []
|
||||||
|
for line in lines_in:
|
||||||
|
if not line.strip():
|
||||||
|
out.append("")
|
||||||
|
continue
|
||||||
|
tokens = _word_widths(line, font, draw)
|
||||||
|
cur = ""
|
||||||
|
cur_w = 0.0
|
||||||
|
for tok, w in tokens:
|
||||||
|
if cur_w + w <= max_width or not cur:
|
||||||
|
cur += tok
|
||||||
|
cur_w += w
|
||||||
|
else:
|
||||||
|
out.append(cur.rstrip())
|
||||||
|
cur = tok if tok.strip() else ""
|
||||||
|
cur_w = w if tok.strip() else 0.0
|
||||||
|
if cur:
|
||||||
|
out.append(cur.rstrip())
|
||||||
|
return out or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def render_handwriting(
|
||||||
|
text: str,
|
||||||
|
output: str,
|
||||||
|
font_path: str,
|
||||||
|
font_index: int,
|
||||||
|
font_size: int = 44,
|
||||||
|
line_spacing: float = 1.6,
|
||||||
|
margin: int = 64,
|
||||||
|
max_chars_per_line: Optional[int] = None,
|
||||||
|
ink_color: str = "#1a3b8c", # синие чернила (можно переопределить через --ink)
|
||||||
|
paper_color: str = "#ffffff",
|
||||||
|
lined: bool = False,
|
||||||
|
lined_color: str = "#cfe8ff",
|
||||||
|
jitter: float = 1.0,
|
||||||
|
rotate_lines: bool = True,
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""Рендерит текст в рукописную картинку и сохраняет в ``output``."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
ink = _parse_color(ink_color)
|
||||||
|
paper = _parse_color(paper_color)
|
||||||
|
line_color = _parse_color(lined_color)
|
||||||
|
|
||||||
|
# предварительный шрифт для замера
|
||||||
|
probe_font = _load_font(font_path, font_index, font_size)
|
||||||
|
probe_img = Image.new("RGB", (10, 10), paper)
|
||||||
|
probe_draw = ImageDraw.Draw(probe_img)
|
||||||
|
|
||||||
|
# ширина строки по символам
|
||||||
|
avg_char = probe_draw.textlength("А", font=probe_font)
|
||||||
|
if max_chars_per_line is None:
|
||||||
|
max_chars_per_line = 48
|
||||||
|
target_width = int(avg_char * max_chars_per_line) + margin * 2
|
||||||
|
|
||||||
|
lines = wrap_text(text, probe_font, probe_draw, target_width - margin * 2)
|
||||||
|
|
||||||
|
# размеры строк
|
||||||
|
line_h = int(font_size * line_spacing)
|
||||||
|
ascent, descent = probe_font.getmetrics()
|
||||||
|
text_h = ascent + descent
|
||||||
|
|
||||||
|
# Реальная ширина самой длинной строки (после переноса) + запас под поворот
|
||||||
|
# строки и лёгкое смещение. Иначе правый край (например «5.» в конце формулы)
|
||||||
|
# обрезался бы границей картинки при повороте строки.
|
||||||
|
max_lw = 0.0
|
||||||
|
for line in lines:
|
||||||
|
if line:
|
||||||
|
max_lw = max(max_lw, probe_draw.textlength(line, font=probe_font))
|
||||||
|
# запас под поворот: при угле до ~0.8° и высоте строки text_h дополнительная
|
||||||
|
# ширина невелика, берём с запасом.
|
||||||
|
rot_pad = int(text_h * 0.1) + 6
|
||||||
|
needed_w = int(max_lw) + margin * 2 + rot_pad + 6
|
||||||
|
img_w = max(target_width, needed_w)
|
||||||
|
img_h = margin * 2 + line_h * len(lines)
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (img_w, img_h), paper + (255,))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# линейная бумага
|
||||||
|
if lined:
|
||||||
|
y = margin + line_h
|
||||||
|
while y < img_h - margin // 2:
|
||||||
|
draw.line([(margin // 2, y), (img_w - margin // 2, y)],
|
||||||
|
fill=line_color, width=1)
|
||||||
|
y += line_h
|
||||||
|
|
||||||
|
for li, line in enumerate(lines):
|
||||||
|
# небольшое базовое смещение строки по горизонтали и наклон
|
||||||
|
x0 = margin + rng.randint(-3, 3)
|
||||||
|
y0 = margin + li * line_h + rng.randint(-2, 2)
|
||||||
|
|
||||||
|
# лёгкий поворот строки для «живости»
|
||||||
|
line_layer = None
|
||||||
|
if rotate_lines and line.strip():
|
||||||
|
angle = rng.uniform(-0.8, 0.8) * jitter
|
||||||
|
lw = draw.textlength(line, font=probe_font)
|
||||||
|
lh = text_h + 6
|
||||||
|
limg = Image.new("RGBA", (int(lw) + 10, lh + 10), (0, 0, 0, 0))
|
||||||
|
ldraw = ImageDraw.Draw(limg)
|
||||||
|
_draw_jittered_line(ldraw, line, 5, 5, probe_font, ink,
|
||||||
|
jitter=jitter, rng=rng)
|
||||||
|
lrot = limg.rotate(angle, expand=True, resample=Image.BICUBIC,
|
||||||
|
fillcolor=(0, 0, 0, 0))
|
||||||
|
img.paste(lrot, (x0, y0), lrot)
|
||||||
|
else:
|
||||||
|
lw = draw.textlength(line, font=probe_font)
|
||||||
|
_draw_jittered_line(draw, line, x0, y0 + 4, probe_font, ink,
|
||||||
|
jitter=jitter, rng=rng)
|
||||||
|
|
||||||
|
img.convert("RGB").save(output)
|
||||||
|
return img.size
|
||||||
|
|
||||||
|
|
||||||
|
_PUNCT_CHARS = frozenset([
|
||||||
|
".", ",", ";", ":", "!", "?",
|
||||||
|
"-", "\u2014", "\u2013", "\u2026",
|
||||||
|
"\u00ab", "\u00bb", "(", ")", "[", "]",
|
||||||
|
"\u201e", "\u201c", "\u201d", "\u2018", "\u2019",
|
||||||
|
"\u2010", "\u2011", "\u2012",
|
||||||
|
"'", '"',
|
||||||
|
"/", "@", "#", "%", "*", "+", "=", "<", ">",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_jittered_line(draw: ImageDraw.ImageDraw, line: str, x: int, y: int,
|
||||||
|
font: ImageFont.FreeTypeFont,
|
||||||
|
ink: Tuple[int, int, int], jitter: float,
|
||||||
|
rng: random.Random) -> None:
|
||||||
|
"""Рисует строку в стиле «рукописной ручки» (синие чернила).
|
||||||
|
|
||||||
|
Эффект «как-будто рукой написано» строится из трёх составляющих:
|
||||||
|
1. Давление — коррелированное случайное блуждание яркости (соседние буквы
|
||||||
|
получаются близкими по тону, иногда рука «надавливает» сильнее/слабее).
|
||||||
|
2. Полупрозрачные чернила — каждый символ рисуется с переменной альфой
|
||||||
|
(pressure), поверх бумаги, поэтому тонкие/слабые участки светлее.
|
||||||
|
3. Неровный край мазка — лёгкое эрозионное «истончение» маски символа,
|
||||||
|
имитирующее, как чернила ложатся на бумагу неидеально ровно.
|
||||||
|
"""
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
|
||||||
|
# коррелированное «давление»: яркость/прозрачность медленно дрейфует,
|
||||||
|
# имитируя непрерывное ведение пера рукой (а не независимый шум).
|
||||||
|
pressure = 1.0 # 1.0 — нормальное, >1 — темнее/гуще, <1 — слабее/светлее
|
||||||
|
cx = float(x)
|
||||||
|
|
||||||
|
# ЕДИНОЕ вертикальное смещение для всей строки: все символы (и буквы, и
|
||||||
|
# знаки препинания) лежат на одной базовой линии, поэтому точка не
|
||||||
|
# «отлетает» вверх/вниз относительно букв.
|
||||||
|
line_dy = rng.randint(-1, 1) if jitter else 0
|
||||||
|
|
||||||
|
# накопление лёгкой «неравномерности» хода пера по горизонтали. Для знаков
|
||||||
|
# препинания зазор минимален — точка/запятая примыкают к слову.
|
||||||
|
advance_noise = 0.0
|
||||||
|
|
||||||
|
# верх строки на экране (одно смещение для всей строки). Все глифы
|
||||||
|
# рисуются со стандартным PIL-выравниванием от этого верха — поэтому
|
||||||
|
# б/й/н на одной baseline, р/у/д — с хвостами ниже, точки — внизу.
|
||||||
|
line_top = y + line_dy
|
||||||
|
|
||||||
|
for ch in line:
|
||||||
|
# дрейф давления: случайный шаг с возвратом к норме (mean-reverting)
|
||||||
|
step = rng.uniform(-0.10, 0.13) * jitter
|
||||||
|
pressure += step
|
||||||
|
pressure = max(0.62, min(1.28, pressure * 0.92 + 0.08 * 1.0))
|
||||||
|
|
||||||
|
is_punct = ch in _PUNCT_CHARS
|
||||||
|
|
||||||
|
# яркость/насыщенность синего управляются давлением (все каналы в одну сторону)
|
||||||
|
col = _apply_pressure_color(ink, pressure, rng, jitter)
|
||||||
|
# прозрачность тоже зависит от давления (слабое нажатие → светлее)
|
||||||
|
alpha = int(max(120, min(255, 200 + 55 * (pressure - 0.6))))
|
||||||
|
|
||||||
|
# рисуем символ-маску и композируем с альфой поверх «бумаги».
|
||||||
|
# Знаки препинания рисуем чётко (без «рваного» края), чтобы точка
|
||||||
|
# оставалась аккуратной, чёткой и строго на базовой линии.
|
||||||
|
_stamp_glyph(draw, ch, cx, line_top, font, col, alpha, rng,
|
||||||
|
jitter=0.0 if is_punct else jitter)
|
||||||
|
|
||||||
|
adv = draw.textlength(ch, font=font)
|
||||||
|
# для знаков препинания почти не добавляем горизонтальный зазор,
|
||||||
|
# чтобы точка/запятая примыкали к слову
|
||||||
|
step_j = 0.0 if is_punct else rng.uniform(-0.4, 0.6) * jitter
|
||||||
|
advance_noise = advance_noise * 0.5 + step_j
|
||||||
|
cx += adv + advance_noise
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_glyph(draw: ImageDraw.ImageDraw, ch: str, x: float, line_top: int,
|
||||||
|
font: ImageFont.FreeTypeFont, color: Tuple[int, int, int],
|
||||||
|
alpha: int, rng: random.Random, jitter: float,
|
||||||
|
is_punct: bool = False) -> None:
|
||||||
|
"""Отрисовывает один символ «мазком пера» со стандартным выравниванием PIL.
|
||||||
|
|
||||||
|
Глиф позиционируется в точности как ``text((x, line_top))`` — с одинаковым
|
||||||
|
верхом строки для всех символов. Благодаря этому выравнивание совпадает
|
||||||
|
с обычным текстом:
|
||||||
|
- обычные буквы (а, о, н, б, й) и заглавные стоят на одной baseline;
|
||||||
|
- буквы с нижними выносными (р, у, д, ц, щ) опускают хвосты ниже baseline
|
||||||
|
(строчная «р» ниже обычных букв — как в рукописи);
|
||||||
|
- точки/запятые находятся внизу строки (на baseline);
|
||||||
|
- спецсимволы ($ ~ % @ # ...) располагаются согласно дизайну шрифта.
|
||||||
|
|
||||||
|
«Рукописный» эффект (полупрозрачные чернила + неровный край) применяется к
|
||||||
|
маске глифа, которая композируется поверх бумаги по тем же координатам, что
|
||||||
|
и text(). Знаки препинания рисуются с jitter=0 (без «рваного» края), чтобы
|
||||||
|
точка/запятая оставались чёткими.
|
||||||
|
"""
|
||||||
|
if ch == " " or not ch.strip():
|
||||||
|
return # пробел/пробельный — курсор продвигается выше
|
||||||
|
bbox = draw.textbbox((0, 0), ch, font=font)
|
||||||
|
# bbox отсчитывается от origin (0,0) text(): может иметь отрицательные
|
||||||
|
# bbox[0]/bbox[1] (глиф выпирает влево/вверх, напр. Й, р с хвостом вниз).
|
||||||
|
# Размер канваса берём по абсолютным границам bbox + запас PAD с каждой стороны.
|
||||||
|
PAD = 8
|
||||||
|
x0 = min(bbox[0], 0)
|
||||||
|
y0 = min(bbox[1], 0)
|
||||||
|
x1 = max(bbox[2], 0)
|
||||||
|
y1 = max(bbox[3], 0)
|
||||||
|
mask_w = (x1 - x0) + PAD * 2
|
||||||
|
mask_h = (y1 - y0) + PAD * 2
|
||||||
|
mask = Image.new("L", (mask_w, mask_h), 0)
|
||||||
|
mdraw = ImageDraw.Draw(mask)
|
||||||
|
# Рисуем глиф с origin (PAD - x0, PAD - y0), чтобы bbox целиком попал в канвас.
|
||||||
|
mdraw.text((PAD - x0, PAD - y0), ch, font=font, fill=255)
|
||||||
|
|
||||||
|
if jitter > 0:
|
||||||
|
_roughen_mask(mask, rng, jitter)
|
||||||
|
|
||||||
|
stamp = Image.new("RGBA", mask.size, color + (alpha,))
|
||||||
|
stamp.putalpha(mask)
|
||||||
|
# origin глифа в канвасе = (PAD - x0, PAD - y0). Чтобы он совпал с экранной
|
||||||
|
# точкой (x, line_top), левый-верх канваса = (x - (PAD - x0), line_top - (PAD - y0)).
|
||||||
|
left = int(x) - (PAD - x0)
|
||||||
|
top = line_top - (PAD - y0)
|
||||||
|
draw._image.alpha_composite(stamp, (left, top))
|
||||||
|
|
||||||
|
|
||||||
|
def _roughen_mask(mask: "Image.Image", rng: random.Random, jitter: float) -> None:
|
||||||
|
"""Слегка «рвёт» край маски символа, имитируя неровный мазок пера."""
|
||||||
|
# дёшево и быстро: точечное прореживание по краю через случайные пиксели
|
||||||
|
w, h = mask.size
|
||||||
|
px = mask.load()
|
||||||
|
# выборочно затемняем/осветляем граничные пиксели (где значение 1..254)
|
||||||
|
n = int(w * h * 0.02 * jitter)
|
||||||
|
for _ in range(n):
|
||||||
|
xg = rng.randint(1, w - 2)
|
||||||
|
yg = rng.randint(1, h - 2)
|
||||||
|
v = px[xg, yg]
|
||||||
|
if 0 < v < 255: # крайний (полупрозрачный от антиалиасинга) пиксель
|
||||||
|
px[xg, yg] = max(0, v - rng.randint(40, 110))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_color(s: str) -> Tuple[int, int, int]:
|
||||||
|
raw = s.strip().lstrip("#")
|
||||||
|
if len(raw) == 3:
|
||||||
|
raw = "".join(c * 2 for c in raw)
|
||||||
|
if len(raw) == 6:
|
||||||
|
try:
|
||||||
|
return (int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise SystemExit(f"Некорректный цвет {s!r} — используйте формат #RRGGBB (например #1a3b8c).")
|
||||||
|
|
||||||
|
|
||||||
|
def _jitter_color(c, rng: random.Random, amount: int = 6):
|
||||||
|
return (
|
||||||
|
max(0, min(255, c[0] + rng.randint(-amount, amount))),
|
||||||
|
max(0, min(255, c[1] + rng.randint(-amount, amount))),
|
||||||
|
max(0, min(255, c[2] + rng.randint(-amount, amount))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pressure_color(base: Tuple[int, int, int], pressure: float,
|
||||||
|
rng: random.Random, jitter: float) -> Tuple[int, int, int]:
|
||||||
|
"""Цвет чернил с учётом «давления пера».
|
||||||
|
|
||||||
|
pressure > 1 — рука надавила сильнее → темнее/гуще;
|
||||||
|
pressure < 1 — слабее → светлее. Все каналы смещаются согласованно
|
||||||
|
(в одну сторону), плюс крошечный случайный «оттенок» для живости.
|
||||||
|
"""
|
||||||
|
# фактор яркости: pressure>1 → затемнение, <1 → высветление
|
||||||
|
shift = int((1.0 - pressure) * 70) # насколько изменить яркость
|
||||||
|
base_amt = int(4 * jitter)
|
||||||
|
return (
|
||||||
|
max(0, min(255, base[0] + shift + rng.randint(-base_amt, base_amt))),
|
||||||
|
max(0, min(255, base[1] + shift + rng.randint(-base_amt, base_amt))),
|
||||||
|
max(0, min(255, base[2] + shift + rng.randint(-base_amt, base_amt))),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Палитры / градиенты чернил
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_input(args) -> str:
|
||||||
|
if args.input and args.input != "-":
|
||||||
|
with open(args.input, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
data = sys.stdin.read()
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
if args.text:
|
||||||
|
return args.text
|
||||||
|
sys.stderr.write("Нет входного текста. Укажите текст аргументом, -i файл или STDIN.\n")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="handwrite",
|
||||||
|
description="Рендер текста в рукописную картинку с поддержкой кириллицы.",
|
||||||
|
)
|
||||||
|
p.add_argument("text", nargs="?", default=None, help="Текст для рендера.")
|
||||||
|
p.add_argument("-i", "--input", default=None,
|
||||||
|
help="Файл с текстом (UTF-8); переносы строк сохраняются. "
|
||||||
|
"'-' = STDIN, иначе при отсутствии текста — STDIN.")
|
||||||
|
p.add_argument("-o", "--output", default="handwriting.png", help="Выходной PNG.")
|
||||||
|
p.add_argument("--font", default=None,
|
||||||
|
help="Путь к шрифту (или path:index). Иначе авто-подбор рукописного.")
|
||||||
|
p.add_argument("--font-size", type=int, default=44, help="Размер шрифта (pt).")
|
||||||
|
p.add_argument("--line-spacing", type=float, default=1.6, help="Множитель межстрочного интервала.")
|
||||||
|
p.add_argument("--margin", type=int, default=64, help="Поля вокруг текста (px).")
|
||||||
|
p.add_argument("--chars-per-line", type=int, default=48,
|
||||||
|
help="Примерная ширина строки в символах для переноса.")
|
||||||
|
p.add_argument("--ink", default="#1a3b8c",
|
||||||
|
help="Цвет чернил (#RRGGBB). По умолчанию — синий.")
|
||||||
|
p.add_argument("--paper", default="#ffffff", help="Цвет бумаги (#RRGGBB).")
|
||||||
|
p.add_argument("--lined", action="store_true", help="Рисовать линейную «тетрадную» бумагу.")
|
||||||
|
p.add_argument("--lined-color", default="#cfe8ff", help="Цвет линий бумаги.")
|
||||||
|
p.add_argument("--jitter", type=float, default=1.0,
|
||||||
|
help="Сила «рукописного» дрожания (0 — ровный текст).")
|
||||||
|
p.add_argument("--no-rotate-lines", dest="rotate_lines", action="store_false",
|
||||||
|
help="Отключить лёгкий поворот строк.")
|
||||||
|
p.add_argument("--seed", type=int, default=None, help="Зерно ГПСЧ для воспроизводимости.")
|
||||||
|
p.add_argument("--list-fonts", action="store_true",
|
||||||
|
help="Вывести доступные рукописные шрифты с кириллицей и выйти.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
if args.list_fonts:
|
||||||
|
list_handwriting_fonts()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
text = _read_input(args)
|
||||||
|
text = text.replace("\r\n", "\n").rstrip() + "\n"
|
||||||
|
|
||||||
|
font_path, font_index = find_handwriting_font(args.font)
|
||||||
|
w, h = render_handwriting(
|
||||||
|
text=text,
|
||||||
|
output=args.output,
|
||||||
|
font_path=font_path,
|
||||||
|
font_index=font_index,
|
||||||
|
font_size=args.font_size,
|
||||||
|
line_spacing=args.line_spacing,
|
||||||
|
margin=args.margin,
|
||||||
|
max_chars_per_line=args.chars_per_line,
|
||||||
|
ink_color=args.ink,
|
||||||
|
paper_color=args.paper,
|
||||||
|
lined=args.lined,
|
||||||
|
lined_color=args.lined_color,
|
||||||
|
jitter=args.jitter,
|
||||||
|
rotate_lines=args.rotate_lines,
|
||||||
|
seed=args.seed,
|
||||||
|
)
|
||||||
|
sys.stdout.write(
|
||||||
|
f"Готово: {args.output} ({w}x{h}px), шрифт: "
|
||||||
|
f"{os.path.basename(font_path)}#{font_index}\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=9.0.0
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# run.sh — команды-обёртки для hand-writter.
|
||||||
|
# Использование: ./run.sh <команда> [аргументы] [-- опции handwrite.py]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OS_NAME=$(uname -s)
|
||||||
|
# Python-интерпретатор. По умолчанию python3.13; если нет — системный python3.
|
||||||
|
PY="python3.13"
|
||||||
|
command -v "$PY" >/dev/null 2>&1 || PY="python3"
|
||||||
|
SCRIPT="$(dirname "$0")/handwrite.py"
|
||||||
|
OUT_DIR="$(dirname "$0")/out"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
source "$(dirname "$0")/.venv/bin/activate" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Команды:
|
||||||
|
init Создать venv и установить зависимости
|
||||||
|
upd Обновить зависимости
|
||||||
|
render "текст" Рендер текста-аргумента → out/handwriting.png
|
||||||
|
lined "текст" Рендер на тетрадной бумаге → out/handwriting.png
|
||||||
|
big "текст" Крупный шрифт (64pt) на тёплой бумаге
|
||||||
|
calm "текст" «Спокойный» почерк (jitter 0.3, seed 42)
|
||||||
|
plain "текст" Ровный текст (без дрожания/поворота строк)
|
||||||
|
render-file file Рендер файла (с переносами строк) → out/handwriting.png
|
||||||
|
poetry file Многострочный файл → тетрадь, крупный шрифт → out/poetry.png
|
||||||
|
render-stdin Чтение из STDIN (echo "..." | ./run.sh render-stdin)
|
||||||
|
list-fonts Показать доступные рукописные шрифты с кириллицей
|
||||||
|
examples Перегенерировать примеры в examples/
|
||||||
|
|
||||||
|
Доп. опции handwrite.py можно передать после -- (или просто в конце):
|
||||||
|
./run.sh render "Текст" -- --jitter 0.4 --seed 7
|
||||||
|
./run.sh lined "Текст" -- --ink "#0f2d78"
|
||||||
|
./run.sh render-file text.txt --chars-per-line 60
|
||||||
|
./run.sh poetry poem.txt -- --seed 1
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
"$PY" -m venv "$(dirname "$0")/.venv"
|
||||||
|
upd
|
||||||
|
}
|
||||||
|
|
||||||
|
upd() {
|
||||||
|
activate
|
||||||
|
pip install -r "$(dirname "$0")/requirements.txt" --upgrade
|
||||||
|
git submodule update --init --recursive 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Удалить ведущий "--" из оставшихся аргументов (разделитель опций handwrite.py)
|
||||||
|
strip_dashdash() {
|
||||||
|
if [[ "$1" == "--" ]]; then shift; fi
|
||||||
|
echo "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Рендер текста-аргумента в out/handwriting.png
|
||||||
|
render() {
|
||||||
|
activate
|
||||||
|
local text="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" "$text" -o "$out" $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Рендер из файла (с сохранением переносов строк)
|
||||||
|
render_file() {
|
||||||
|
activate
|
||||||
|
local file="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" -i "$file" -o "$out" $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Чтение из STDIN
|
||||||
|
render_stdin() {
|
||||||
|
activate
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" -o "$out" $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Тетрадная бумага (синие чернила + линии)
|
||||||
|
lined() {
|
||||||
|
activate
|
||||||
|
local text="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" "$text" -o "$out" --lined $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Крупный шрифт (64pt) на тёплой бумаге
|
||||||
|
big() {
|
||||||
|
activate
|
||||||
|
local text="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" "$text" -o "$out" --font-size 64 --paper "#fdf6e3" $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# «Спокойный» почерк: меньше дрожания, воспроизводимо (seed 42)
|
||||||
|
calm() {
|
||||||
|
activate
|
||||||
|
local text="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" "$text" -o "$out" --jitter 0.3 --seed 42 $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ровный текст: без дрожания и без поворота строк
|
||||||
|
plain() {
|
||||||
|
activate
|
||||||
|
local text="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/handwriting.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" "$text" -o "$out" --jitter 0 --no-rotate-lines $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Многострочный файл как «тетрадь»: тетрадные линии + крупный шрифт
|
||||||
|
poetry() {
|
||||||
|
activate
|
||||||
|
local file="$1"; shift || true
|
||||||
|
local out="$OUT_DIR/poetry.png"
|
||||||
|
local opts; opts=$(strip_dashdash "$@")
|
||||||
|
"$PY" "$SCRIPT" -i "$file" -o "$out" --lined --font-size 56 --paper "#fdf6e3" $opts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Список доступных рукописных шрифтов с кириллицей
|
||||||
|
list_fonts() {
|
||||||
|
activate
|
||||||
|
"$PY" "$SCRIPT" --list-fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Перегенерировать примеры в examples/
|
||||||
|
examples() {
|
||||||
|
activate
|
||||||
|
local E="$(dirname "$0")/examples"
|
||||||
|
mkdir -p "$E"
|
||||||
|
printf 'Многострочный текст\nс переносами строк.\nТочки, запятые — внизу.\n' > "$E/sample_input.txt"
|
||||||
|
"$PY" "$SCRIPT" "Синий текст написанный от руки на кириллице: щипцы, ёж, юла, река." \
|
||||||
|
-o "$E/blue_pen.png" --lined
|
||||||
|
"$PY" "$SCRIPT" "Глеб шёл по тропинке, и тетрадь ложилась ровно под перо." \
|
||||||
|
-o "$E/blue_pen_lined.png" --lined
|
||||||
|
"$PY" "$SCRIPT" -i "$E/sample_input.txt" -o "$E/multiline.png" --lined
|
||||||
|
echo "Примеры перегенерированы в $E/"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "_= hand-writter =_"
|
||||||
|
if [ -n "$1" ]; then
|
||||||
|
case "$1" in
|
||||||
|
init) init ;;
|
||||||
|
upd) upd ;;
|
||||||
|
render) shift; render "$@" ;;
|
||||||
|
lined) shift; lined "$@" ;;
|
||||||
|
big) shift; big "$@" ;;
|
||||||
|
calm) shift; calm "$@" ;;
|
||||||
|
plain) shift; plain "$@" ;;
|
||||||
|
render-file) shift; render_file "$@" ;;
|
||||||
|
poetry) shift; poetry "$@" ;;
|
||||||
|
render-stdin) shift; render_stdin "$@" ;;
|
||||||
|
list-fonts) list_fonts ;;
|
||||||
|
examples) examples ;;
|
||||||
|
help|--help|-h) shift; usage ;;
|
||||||
|
*)
|
||||||
|
echo "Неизвестная команда: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
Reference in New Issue
Block a user