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:
2026-08-22 15:12:01 +03:00
commit 9e81d7a3e0
11 changed files with 1122 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.pyc
.venv/
# macOS
.DS_Store
# Вывод рендера (генерируется через run.sh)
out/
# Временные картинки
*.png
!examples/*.png
+126
View File
@@ -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` по ширине).
- Цвет чернил по умолчанию — синий. Палитры/градиенты намеренно убраны
(раньше были, но усложняли). Не возвращайте мульти-цвет без явной просьбы.
+170
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
Многострочный текст
с переносами строк.
Точки, запятые — внизу.
Executable
+626
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
Pillow>=9.0.0
Executable
+176
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
Привет, мир! Это рукописный текст на кириллице. УУуууу АаааАА. x + y = 5.
Что еще нужно?
Проверяем проверяем проверяем...
скор-ие, сильное вос-тие!
~$100 ййй ёёё
{ скобки } (в скобках) "да"