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/.
627 lines
30 KiB
Python
Executable File
627 lines
30 KiB
Python
Executable File
#!/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())
|