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