#!/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())