Files
hand-writter/handwrite.py
T
second_constantine 9e81d7a3e0 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/.
2026-08-22 15:12:01 +03:00

627 lines
30 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())