commit 9e81d7a3e022692bc1a00974f2e622639f95748f Author: second_constantine Date: Sat Aug 22 15:12:01 2026 +0300 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/. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eb7b59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.pyc +.venv/ + +# macOS +.DS_Store + +# Вывод рендера (генерируется через run.sh) +out/ + +# Временные картинки +*.png +!examples/*.png diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..feb8983 --- /dev/null +++ b/AGENTS.md @@ -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` по ширине). +- Цвет чернил по умолчанию — синий. Палитры/градиенты намеренно убраны + (раньше были, но усложняли). Не возвращайте мульти-цвет без явной просьбы. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fdf296 --- /dev/null +++ b/README.md @@ -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`. diff --git a/examples/blue_pen.png b/examples/blue_pen.png new file mode 100644 index 0000000..5cecf02 Binary files /dev/null and b/examples/blue_pen.png differ diff --git a/examples/blue_pen_lined.png b/examples/blue_pen_lined.png new file mode 100644 index 0000000..25bc98d Binary files /dev/null and b/examples/blue_pen_lined.png differ diff --git a/examples/multiline.png b/examples/multiline.png new file mode 100644 index 0000000..3b2b7d8 Binary files /dev/null and b/examples/multiline.png differ diff --git a/examples/sample_input.txt b/examples/sample_input.txt new file mode 100644 index 0000000..3214478 --- /dev/null +++ b/examples/sample_input.txt @@ -0,0 +1,3 @@ +Многострочный текст +с переносами строк. +Точки, запятые — внизу. diff --git a/handwrite.py b/handwrite.py new file mode 100755 index 0000000..0df5677 --- /dev/null +++ b/handwrite.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3fd7e2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Pillow>=9.0.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e3c24e9 --- /dev/null +++ b/run.sh @@ -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 + diff --git a/text.txt b/text.txt new file mode 100644 index 0000000..529a9a4 --- /dev/null +++ b/text.txt @@ -0,0 +1,6 @@ +Привет, мир! Это рукописный текст на кириллице. УУуууу АаааАА. x + y = 5. +Что еще нужно? +Проверяем проверяем проверяем... +скор-ие, сильное вос-тие! +~$100 ййй ёёё +{ скобки } (в скобках) "да"