#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Детектор полів форми для локалізованих уроків bibletime (Скіл 3, §3.1 і §3.3).

Нічого не вставляє у PDF — лише читає готовий (перекладений) урок і будує
`ua_build/fields.json`:

    {
      "source": "...",
      "pages": {
        "3": {
          "tasks":       [ ...перелік завдань §3.1... ],
          "score_boxes": [ ...чорний список §3.3.1... ],
          "fields":      [ ...поля §3.2... ]
        }
      }
    }

Порядок проходів строго за §3.3; пізніший прохід ніколи не перезаписує раніший
(усе, що вже зайняте, лежить у `taken` і перевіряється на перетин).

Запуск:
    .venv/bin/python ua_build/fields_detect.py \
        forms/Level2_UA_WhatChristiansBelieve_v04.pdf --pages 3
"""
import argparse
import json
import os
import re
import sys
import unicodedata

import fitz

# --------------------------------------------------------------------------
#  константи макета bibletime
# --------------------------------------------------------------------------
# Фірмовий колір рівня — ним набрані варіанти відповідей. У кожного рівня свій
# (§3.3): 0 #b3579d, 1 #f49944, 2 #ea515a, 3 #009d78, 4 #3055a3. Раніше тут
# стояв жорсткий червоний рівня 2, і на рівнях 3–4 детектор не знаходив жодного
# варіанта. Значення підставляє `set_level()` за номером рівня з імені файла.
BRAND = 0xEA515A
SCORE_X = 500.0           # ліва межа колонки боксів балів
CONTENT_RIGHT = 508.0     # права межа корисної колонки
FOOTER_Y = 790.0          # нижче — колонтитул і бокс TOTAL
HEADER_Y = 90.0           # вище — шапка з логотипом

MIN_CELL, MAX_CELL = 11.0, 46.0     # §3.3.2 сторона клітинки сітки
CELL_SQUARENESS = 8.0               # |w-h| для «квадрата»
AREA_H = 26.0                       # §3.2 рамка вища за це -> area
TASK_TOP_SLACK = 8.0                # графіка може починатися трохи вище за Q:
OVERLAP_TOL = 1.0                   # §3.3 поля не перетинаються більш ніж на 1 pt

# «рамка з підписом угорі»: друкований текст займає лише верх рамки, а нижче
# лишається порожня смуга — саме туди дитина пише (стор. 6 Q1)
CAP_FREE_H = 12.0                   # мінімальна висота порожньої частини
CAP_TOP_FRAC = 0.62                 # підпис має закінчуватися у верхніх 62 % рамки
# «рамка з підписом ЛІВОРУЧ»: «День 1: [____]», «Людям — [____]». Підпис
# займає лівий край, решта рамки порожня. Без цього правила такі рамки
# відкидалися як `full`, завдання лишалося без полів, а перелік слів поруч
# ставав «варіантами вибору», бо писати нібито не було куди.
SIDE_LEFT_FRAC = 0.55               # підпис має закінчуватися в лівих 55 %
SIDE_FREE_W = 30.0                  # мінімальна ширина порожньої частини
TICK_LABEL_MAX = 260.0              # як далеко шукати мітку біля квадрата ✓
TICK_MIN, TICK_MAX = 13.0, 32.0     # сторона квадрата ✓ / квадрата нумерації

# «обвідка» навколо варіанта вибору малюється всередині прямокутника поля,
# тож поле має бути трохи більшим за саме слово (§3.2, вигляд `choice`)
OPT_PAD_X, OPT_PAD_Y = 2.2, 0.5

# --------------------------------------------------------------------------
#  §3.3.2a — сітка філворда («квадрат слів»)
# --------------------------------------------------------------------------
#  Філворд відрізняється від кросворда НЕ намальованими клітинками (у цьому
#  буклеті з чотирьох сіток лінії має лише одна), а тим, що надрукована літера
#  стоїть майже в КОЖНІЙ позиції прямокутної ґратки. Тому ґратку шукаємо по
#  самих літерах: рівномірний крок по x, рівномірний крок по y, заповненість
#  >= WS_FILL. Кросворд має літери лише в кількох клітинках-підказках, а ряд
#  клітинок під відповідь — жодної.
WS_MIN_ROWS = 3          # один ряд клітинок з літерами — це не філворд
WS_MIN_COLS = 3
WS_MIN_CELLS = 12
WS_FILL = 0.90           # >= 90 % позицій ґратки мають надруковану літеру
WS_PITCH_TOL = 0.16      # відносний допуск на рівномірність кроку
WS_MIN_PITCH_W = 1.6     # крок помітно більший за ширину літери (у прозі ~1.15)
WS_MAX_PITCH_W = 6.0
WS_ASPECT = (0.5, 2.0)   # співвідношення кроків по x і по y
WS_INSET = 1.8           # кільце малюється по краю поля -> зазор до сусідів
                         # і до надрукованої рамки сітки (на стор. 20 вони
                         # збігаються з точністю до 0.5 pt)

# --------------------------------------------------------------------------
#  §3.3.2b — контурні літери під зафарбовування (тип `paint`)
# --------------------------------------------------------------------------
#  «Розфарбуй ці слова» друкується КОНТУРНИМ текстом (render_mode = stroke),
#  тобто це звичайний текст, у якого відомі гліф, кегль, товщина обвідки й
#  базова лінія з точністю до соті пункта. Кожна така літера стає окремим
#  чекбоксом, а його потік вигляду малює ТОЙ САМИЙ гліф режимом «залити +
#  обвести»: заливка лягає рівно всередину контуру, обведення лишається.
#  Шукаємо саме за режимом малювання, а не за словником: на 120 уроках
#  формулювання інші, а `render_mode = stroke` у макеті означає рівно одне.
PAINT_MIN_SIZE = 20.0    # кегль: контурний напис завжди великий
#  Мінімум по одній літері на прогін: на стор. 3 («зафарбуй літери, під якими
#  є крапка») кожна літера має власний x, тож рендерер видає її окремим спаном.
#
#  Прямокутник поля має вміщати ВСЮ фарбу гліфа: потік вигляду обрізається по
#  `/BBox`, а `/BBox` дорівнює `/Rect`. Прямокутник наступу, який віддає
#  `get_texttrace()`, для більшості літер достатній, але діакритика вища за
#  нього: бревіс над «Й» при кеглі 48 виступає на 5.5 pt, і без розширення
#  заливка різала його рівною горизонтальною лінією. Тому кожен прямокутник
#  розширюємо до реальних меж фарби гліфа.
PAINT_INK_PAD = 0.4      # запас на округлення виміру й на половину обвідки
PAINT_FONTFILES = {      # накреслення з гліфами цільової мови (macOS)
    "arialblackregular": "/System/Library/Fonts/Supplemental/Arial Black.ttf",
    "arialblack": "/System/Library/Fonts/Supplemental/Arial Black.ttf",
    "arialbold": "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
    "arialregular": "/System/Library/Fonts/Supplemental/Arial.ttf",
    "arialbolditalic": "/System/Library/Fonts/Supplemental/Arial Bold Italic.ttf",
}

# рамка, всередині якої лежить векторна ілюстрація, — НЕ рамка відповіді
# (на стор. 17 поле `area` накривало малюнок Ісуса, що молиться)
ART_MIN_SHAPES = 3

# --------------------------------------------------------------------------
#  §3.1 — ПІДКАЗКА за ключовими словами інструкції
# --------------------------------------------------------------------------
#  Це НЕ основний механізм класифікації (він геометричний, див. decide_type):
#  словник лише розрізняє те, що геометрія розрізнити не може — «обведи одне»
#  від «викресли кілька», рамку під ПРАВДА/НЕПРАВДА від рамки під речення,
#  квадрати нумерації від квадратів ✓. На 120 уроках формулювання інші, тож
#  відсутність збігу тут не має ламати розставляння полів.
#  Порядок перевірки важливий: спеціальніші правила стоять раніше.
KEYWORDS = [
    # (тип, [ключі])
    ("find",   ["філворд", "філворді", "філворда"]),          # знайди й обведи -> done
    ("bool",   ["правда або неправда", "правда чи неправда"]),
    ("order",  ["пронумеруй", "постав по порядку"]),
    ("done",   ["розфарбуй малюнок", "намалюй", "домалюй", "з'єднай", "зєднай",
                "проведи лінію", "проведи лінії", "проведи пряму лінію",
                "простеж лінію", "зафарбуй усі літери", "зафарбуй клітинки",
                "розфарбуй слова", "розфарбуй ці слова"]),
    # «викресли літери …, щоб дізнатися слово» — це не вибір варіантів,
    # відповідь усе одно вписують у пропуски нижче
    ("write",  ["викресли всі літери", "викресли літери", "викресли букви"]),
    ("multi",  ["викресли", "закресли", "розфарбуй правильні"]),
    ("choice", ["обведи", "підкресли", "познач", "постав (✓)", "постав ✓",
                "постав галочку", "постав позначку", "вибери", "обери",
                "розфарбуй правильну", "розфарбуй правильне"]),
    ("write",  ["впиши", "впишіть", "запиши", "запишіть", "склади", "заповни",
                "встав", "допиши", "розшифруй", "переставши", "переставивши",
                "знайди", "знайти", "напиши"]),
]

TYPE_TITLE = {
    "paint":  "зафарбовування контурних літер",
    "write":  "письмо в клітинки/рамку",
    "choice": "вибір з варіантів",
    "multi":  "кілька варіантів",
    "bool":   "так/ні (ПРАВДА/НЕПРАВДА)",
    "order":  "порядок (односимвольні поля)",
    "find":   "знайди й обведи (без вводу)",
    "done":   "виконання без вводу",
    "?":      "не розпізнано",
}

# імена, які СВІДОМО спільні для всіх сторінок: однакове ім'я = одне поле,
# переглядач сам синхронізує значення (рішення замовника №6)
SHARED_NAMES = ("child_name", "teacher")

# символи, наявність яких у прямокутнику НЕ заважає поставити поле (§3.3)
FILLER_CHARS = set(" \t\n\r\xa0_.·-–—:")


def is_letter(ch):
    return ch.isalpha()


def only_filler(txt):
    """Чи складається текст лише з дозволеного «шуму» (риски, крапки, цифри)."""
    for ch in txt:
        if ch in FILLER_CHARS or ch.isdigit():
            continue
        if unicodedata.category(ch).startswith("Z"):
            continue
        return False
    return True


def norm(s):
    """Нормалізація для порівняння й для значень полів: NBSP -> пробіл,
    типографські лапки/тире -> прості (§2.5: у значеннях полів вони ламаються)."""
    s = s.replace("\xa0", " ").replace("­", "")
    for a, b in (("’", "'"), ("‘", "'"), ("“", '"'),
                 ("”", '"'), ("«", '"'), ("»", '"'),
                 ("—", "-"), ("–", "-")):
        s = s.replace(a, b)
    return re.sub(r"\s+", " ", s).strip()


class Namer:
    """Глобальний реєстр імен полів: два поля з однаковим іменем в AcroForm
    синхронізують значення, тож дитина вписала б відповідь один раз на весь
    буклет. Дублікати розводимо суфіксом (кнопки однієї radio-групи сюди не
    потрапляють — там спільне ім'я обов'язкове).

    Виняток — `SHARED_NAMES`: для «Ім'я» й «Учитель» синхронізація саме
    потрібна, тож вони йдуть повз реєстр (literal=True) і збираються в
    `self.shared`."""

    def __init__(self):
        self.used = set()
        self.collisions = []
        self.shared = set()

    def take(self, name):
        if name not in self.used:
            self.used.add(name)
            return name
        k = 2
        while "%s_b%d" % (name, k) in self.used:
            k += 1
        new = "%s_b%d" % (name, k)
        self.collisions.append((name, new))
        self.used.add(new)
        return new


# --------------------------------------------------------------------------
#  читання сторінки
# --------------------------------------------------------------------------
def read_text(page):
    """Плоский список рядків: {bbox, block, spans[{bbox,text,color,size,chars}]}."""
    raw = page.get_text("rawdict")
    lines = []
    for bi, b in enumerate(raw["blocks"]):
        if b["type"] != 0:
            continue
        for l in b["lines"]:
            spans = []
            for s in l["spans"]:
                chars = [{"c": c["c"], "bbox": list(c["bbox"])} for c in s["chars"]]
                txt = "".join(c["c"] for c in s["chars"])
                spans.append({"bbox": list(s["bbox"]), "text": txt,
                              "color": s["color"], "font": s["font"],
                              "size": s["size"], "chars": chars})
            if not spans:
                continue
            lines.append({"block": bi, "bbox": list(l["bbox"]), "spans": spans,
                          "text": "".join(sp["text"] for sp in spans)})
    lines.sort(key=lambda l: (round(l["bbox"][1], 1), l["bbox"][0]))
    return lines


def read_rects(page):
    """Прямокутники сторінки з урахуванням z-порядку й перекриття білим.

    `hidden=True` — фігуру повністю накрито пізнішим непрозорим прямокутником.
    Скіл 2 стару графіку не видаляє, а закриває (§2.1), тож у `get_drawings()`
    далі лежать англійські рамки й клітинки, яких на сторінці не видно; без
    цього фільтра детектор ставить на них фантомні поля (§3.3).
    """
    out = []
    for i, dr in enumerate(page.get_drawings()):
        items = dr.get("items") or []
        if not items or any(it[0] != "re" for it in items):
            continue                      # криві/лінії — не рамка
        r = fitz.Rect(dr["rect"])
        if r.width < 3 or r.height < 3:
            continue
        fill = dr.get("fill")
        stroked = dr.get("color") is not None and "s" in (dr.get("type") or "")
        opaque = fill is not None and (dr.get("fill_opacity", 1) or 1) >= 0.99
        white = opaque and min(fill) > 0.95
        out.append({"i": i, "rect": r, "stroked": bool(stroked),
                    "filled": fill is not None, "opaque": bool(opaque),
                    "white": bool(white), "hidden": False})

    covers = [o for o in out if o["opaque"]]
    for o in out:
        for c in covers:
            if c["i"] <= o["i"] or c is o:
                continue
            if c["rect"].contains(o["rect"] + (-0.6, -0.6, 0.6, 0.6)):
                o["hidden"] = True
                break
    return out


# --------------------------------------------------------------------------
#  §3.1 — інвентаризація завдань
# --------------------------------------------------------------------------
Q_MARK_RE = re.compile(r"^\s*Q\s*[:.]\s*$")
NAME_RE = re.compile(r"^\s*ім\s*['’]?\s*я\s*:", re.I)
TEACHER_RE = re.compile(r"^\s*(вчител|учител)", re.I)


def hint_of(instr):
    """Підказка словника (не остаточний тип, див. decide_type)."""
    low = norm(instr).lower()
    for typ, keys in KEYWORDS:
        for k in keys:
            if k in low:
                return typ, k
    return None, None


def decide_type(t, ev):
    """Тип завдання ЗА ГЕОМЕТРІЄЮ (рішення замовника №4).

    Основа — що реально знайдено в зоні завдання; словник лише розрізняє
    те, що геометрія розрізнити не може. Повертає (тип, пояснення).
    """
    h = t["hint"]
    write_geo = ev["cells"] or ev["frames"] or ev["gaps"]
    # червоні слова — це перелік варіантів лише тоді, коли писати нікуди:
    # інакше це банк слів до пропусків («Впиши ці слова у правильні місця»)
    if ev["opt_rows"] and (h in ("choice", "multi") or not write_geo):
        return (("multi" if h == "multi" else "choice"),
                "геометрія: %d рядк. червоних варіантів%s"
                % (ev["opt_rows"], " + словник" if write_geo else ""))
    if ev["ticks"] >= 2:
        if h == "order":
            return "order", "геометрія: %d окремих квадратів + «пронумеруй»" % ev["ticks"]
        return (("multi" if h == "multi" else "choice"),
                "геометрія: %d окремих квадратів з мітками" % ev["ticks"])
    if ev["framed"] >= 2 and not (ev["cells"] or ev["frames"] or ev["gaps"]):
        return (("multi" if h == "multi" else "choice"),
                "геометрія: ряд із %d однакових рамок з варіантами" % ev["framed"])
    # філворд: кожна літерна клітинка — окремий чекбокс із кільцем (§3.2 `ws`),
    # тож завдання виконуване на екрані й чекбокса «виконано» не потребує
    if ev.get("ws"):
        if write_geo:
            return "write", ("геометрія: сітка філворда %d кл. + клітинок %d, "
                             "рамок %d, пропусків %d"
                             % (ev["ws"], ev["cells"], ev["frames"], ev["gaps"]))
        return "find", "геометрія: сітка філворда %d клітинок" % ev["ws"]
    # контурний текст: кожна літера — окремий чекбокс, який заливає її
    # всередині обведення (§3.2, тип `paint`). Завдання стає виконуваним.
    if ev.get("paint") and not write_geo:
        return "paint", "геометрія: %d контурних літер -> заливка" % ev["paint"]
    if write_geo:
        what = "клітинок %d, рамок %d, пропусків %d" % (ev["cells"], ev["frames"], ev["gaps"])
        if h == "bool" and ev["frames"]:
            return "bool", "геометрія (%s) + «ПРАВДА/НЕПРАВДА»" % what
        if h == "order" and ev["cells"]:
            return "order", "геометрія (%s) + «пронумеруй»" % what
        return "write", "геометрія: " + what
    if h in ("done", "find"):
        return h, "геометрії немає, словник: %r" % t["hint_key"]
    if h:
        return "done", "геометрії немає (словник казав %r) -> чекбокс «виконано»" % h
    return "done", "геометрії немає й словник мовчить -> чекбокс «виконано»"


def q_marks(lines):
    """Позиції маркерів `Q:` на рівні СПАНІВ.

    Різати треба саме по спанах, а не по текстових блоках: у макеті трапляється
    два завдання в одному блоці — розбір по блоках зливав їх в одне.
    """
    out = []
    for li, l in enumerate(lines):
        for si, sp in enumerate(l["spans"]):
            if Q_MARK_RE.match(sp["text"]):
                out.append((li, si))
    out.sort()
    return out


def inventory(lines, score_boxes, footer_y=FOOTER_Y):
    marks = q_marks(lines)
    tasks = []
    for n, (li, si) in enumerate(marks, 1):
        nxt = marks[n] if n < len(marks) else None
        blk = lines[li]["block"]
        own, own_lines = [], set()
        last_li = li
        for lj in range(li, len(lines)):
            if lines[lj]["block"] != blk:
                break
            if nxt and lj > nxt[0]:
                break
            for sj, sp in enumerate(lines[lj]["spans"]):
                if lj == li and sj <= si:
                    continue
                if nxt and lj == nxt[0] and sj >= nxt[1]:
                    continue
                own.append(sp)
                own_lines.add(lj)
                last_li = lj
            if nxt and lj == nxt[0]:
                break
        # продовження інструкції в СУСІДНЬОМУ блоці: та сама ліва межа,
        # наступна базова лінія. Без цього «Знайди відповіді на подані /
        # запитання у філворді.» класифікується лише за першим рядком.
        if own:
            x_ref = min(sp["bbox"][0] for sp in own if sp["bbox"][1] <= own[0]["bbox"][1] + 1)
            for lj in range(last_li + 1, len(lines)):
                if nxt and lj >= nxt[0]:
                    break
                prev = lines[last_li]
                cur = lines[lj]
                lh = max(10.0, prev["bbox"][3] - prev["bbox"][1])
                if abs(cur["bbox"][0] - x_ref) > 4:
                    break
                if not (0 <= cur["bbox"][1] - prev["bbox"][3] <= lh * 0.8):
                    break
                if any(Q_MARK_RE.match(sp["text"]) for sp in cur["spans"]):
                    break
                own.extend(cur["spans"])
                own_lines.add(lj)
                last_li = lj
        instr = norm("".join(sp["text"] for sp in own))
        hint, key = hint_of(instr)
        if own:
            ymax = max(sp["bbox"][3] for sp in own)
            tail = [sp for sp in own if sp["bbox"][3] >= ymax - 1.5]
            last = [min(sp["bbox"][0] for sp in tail),
                    min(sp["bbox"][1] for sp in tail),
                    max(sp["bbox"][2] for sp in tail), ymax]
        else:
            last = list(lines[li]["bbox"])
        own_lines.add(li)
        tasks.append({
            "n": n, "instr": instr, "type": None, "hint": hint, "hint_key": key,
            "q_bbox": list(lines[li]["spans"][si]["bbox"]),
            "head_bbox": list(lines[li]["bbox"]),
            "last_line_bbox": last,
            "instr_lines": sorted(own_lines),
        })

    for i, t in enumerate(tasks):
        t["y0"] = t["q_bbox"][1] - TASK_TOP_SLACK
        if i == 0:
            # матеріал першого завдання (карта, сітка) часто надрукований ВИЩЕ
            # за свою інструкцію — віддаємо йому все під шапкою сторінки
            t["y0"] = min(t["y0"], 182.0)
        nxt_y = (tasks[i + 1]["q_bbox"][1] - TASK_TOP_SLACK
                 if i + 1 < len(tasks) else footer_y)
        t["y1"] = max(nxt_y, t["last_line_bbox"][3] + 2)
    for t in tasks:
        near = [s for s in score_boxes
                if t["y0"] <= (s["rect"].y0 + s["rect"].y1) / 2 <= t["y1"]]
        t["score"] = near[0]["label"] if near else None
    return tasks


def cover_labels(page, lines, rects):
    """Рядки «Ім'я:» / «Учитель:», яким належить поле вводу.

    На стор. 2 буклету «ІМ'Я:» — це підпис до персонажа (ІМ'Я: B.E.S.), а не
    поле. Відрізняємо за контекстом: поле є або на самій обкладинці (стор. 1),
    або коли підпис лежить усередині шапкової рамки сторінки.
    """
    out = []
    frames = [o["rect"] for o in rects
              if o["stroked"] and not o["hidden"] and o["rect"].y1 < 120
              and o["rect"].width > 60]
    for l in lines:
        txt = norm(l["text"])
        kind = None
        if NAME_RE.match(txt) and len(txt) < 30:
            kind = "cover_name"
        elif TEACHER_RE.match(txt) and len(txt) < 30:
            kind = "cover_teacher"
        if not kind:
            continue
        r = fitz.Rect(l["bbox"])
        host = next((f for f in frames if f.contains(r)), None)
        if page.number != 0 and host is None:
            continue                      # підпис до персонажа — не поле
        out.append({"kind": kind, "bbox": list(l["bbox"]),
                    "host": [round(v, 2) for v in host] if host else None})
    return out


SCORE_TABLE_RE = re.compile(
    r"(бали\s*\(|заповнює керівник|коментар|повернi|поверни свої уроки|"
    r"загальний підсумок|^разом\s*:)", re.I)


def footer_limit(lines):
    """Нижня межа корисної області сторінки.

    На звороті буклету надрукована таблиця балів учителя («ІСТОРІЯ 1: ____»).
    Це графіка, а не поля (§3.0) — усе від її заголовка й нижче виключаємо,
    інакше кожен підкреслений рядок таблиці стане текстовим полем.
    """
    y = FOOTER_Y
    for l in lines:
        if SCORE_TABLE_RE.search(norm(l["text"])):
            y = min(y, l["bbox"][1] - 4)
    return y


def task_of(tasks, rect):
    cy = (rect.y0 + rect.y1) / 2
    for t in tasks:
        if t["y0"] <= cy <= t["y1"]:
            return t
    return None


# --------------------------------------------------------------------------
#  прохід 1 (§3.3.1) — бокси балів у чорний список
# --------------------------------------------------------------------------
SCORE_TXT_RE = re.compile(r"^\s*/\s*\d+")


def find_score_boxes(page, rects):
    found = []
    for o in rects:
        r = o["rect"]
        if o["hidden"] or not o["stroked"]:
            continue
        if abs(r.width - r.height) > 3 or not (20 <= r.width <= 32):
            continue
        if r.x0 < SCORE_X:
            continue
        probe = fitz.Rect(r.x1, r.y0 - 6, min(page.rect.x1, r.x1 + 46), r.y1 + 6)
        txt = norm(page.get_text("text", clip=probe))
        label = txt if SCORE_TXT_RE.match(txt) else None
        found.append({"rect": r, "label": label or "(без /N)",
                      "why": "текст /N праворуч" if label
                             else "квадрат у колонці балів x>=%.0f" % SCORE_X})
    found.sort(key=lambda s: s["rect"].y0)
    return found


# --------------------------------------------------------------------------
#  розміщення полів із перевірками §3.3
# --------------------------------------------------------------------------
class Placer:
    def __init__(self, page, namer, footer_y=FOOTER_Y):
        self.page = page
        self.namer = namer
        self.footer_y = footer_y
        self.taken = []
        self.fields = []

    def text_inside(self, rect, pad=1.0):
        clip = fitz.Rect(rect.x0 - pad, rect.y0 - pad, rect.x1 + pad, rect.y1 + pad)
        return self.page.get_text("text", clip=clip)

    def free(self, rect):
        for r, _ in self.taken:
            inter = fitz.Rect(rect) & r
            if inter.is_valid and inter.width > OVERLAP_TOL and inter.height > OVERLAP_TOL:
                return False
        return True

    def fits(self, rect, check_text=True, pad=1.0):
        rect = fitz.Rect(rect)
        if rect.x0 >= CONTENT_RIGHT or rect.x0 < 20 or rect.y1 > self.footer_y:
            return "поза корисною областю"
        if not self.free(rect):
            return "перетин з іншим полем"
        if check_text and not only_filler(self.text_inside(rect, pad)):
            return "друкований текст усередині: %r" % self.text_inside(rect, pad)[:40]
        return None

    def add(self, name, ftype, rect, task, extra=None, check_text=True,
            literal=False, pad=1.0):
        """literal=True — ім'я вже узгоджене (кнопки radio-групи мають спільне)."""
        rect = fitz.Rect(rect)
        why = self.fits(rect, check_text, pad)
        if why:
            return None, why
        name = name if literal else self.namer.take(name)
        f = {"name": name, "type": ftype,
             "rect": [round(v, 2) for v in rect],
             "task": task["n"] if task else None}
        if extra:
            f.update(extra)
        self.fields.append(f)
        self.taken.append((rect, name))
        return f, None

    def drop_group(self, group):
        keep = [f for f in self.fields if f.get("group") != group]
        dropped = {id(f) for f in self.fields if f.get("group") == group}
        rects = [tuple(f["rect"]) for f in self.fields if id(f) in dropped]
        self.fields = keep
        self.taken = [(r, n) for r, n in self.taken
                      if tuple(round(v, 2) for v in r) not in rects]


# --------------------------------------------------------------------------
#  прохід 2 (§3.3.2) — клітинки сіток
# --------------------------------------------------------------------------
def cell_candidates(rects, blacklist):
    cells = []
    for o in rects:
        r = o["rect"]
        if o["hidden"] or not o["stroked"]:
            continue
        if abs(r.width - r.height) > CELL_SQUARENESS:
            continue
        if not (MIN_CELL <= r.width <= MAX_CELL and MIN_CELL <= r.height <= MAX_CELL):
            continue
        if any(r.intersects(b["rect"]) for b in blacklist):
            continue
        cells.append(r)
    return cells


def h_neighbour(c, cells):
    """Чи є в клітинки горизонтальний сусід упритул.

    Це головна геометрична ознака, що відрізняє літерні клітинки слова
    (завжди стоять у ряд) від окремих квадратів під ✓ або номер.
    """
    for o in cells:
        if o is c:
            continue
        if abs(o.width - c.width) > 2 or abs(o.height - c.height) > 2:
            continue
        if abs(o.y0 - c.y0) <= 2 and (abs(o.x1 - c.x0) <= 3.5 or abs(c.x1 - o.x0) <= 3.5):
            return True
    return False


def cell_empty(page, c):
    inner = fitz.Rect(c.x0 + 1.5, c.y0 + 1.5, c.x1 - 1.5, c.y1 - 1.5)
    return not any(is_letter(ch) for ch in page.get_text("text", clip=inner))


def group_grids(cells, minsize=2):
    n = len(cells)
    parent = list(range(n))

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra

    for i in range(n):
        for j in range(i + 1, n):
            a, b = cells[i], cells[j]
            if abs(a.width - b.width) > 2 or abs(a.height - b.height) > 2:
                continue
            same_row = abs(a.y0 - b.y0) <= 2 and (abs(a.x1 - b.x0) <= 3.5
                                                  or abs(b.x1 - a.x0) <= 3.5)
            same_col = abs(a.x0 - b.x0) <= 2 and (abs(a.y1 - b.y0) <= 3.5
                                                  or abs(b.y1 - a.y0) <= 3.5)
            if same_row or same_col:
                union(i, j)
    grids = {}
    for i, c in enumerate(cells):
        grids.setdefault(find(i), []).append(c)
    out = [g for g in grids.values() if len(g) >= minsize]
    for g in out:
        g.sort(key=lambda r: (round(r.y0, 1), r.x0))
    out.sort(key=lambda g: (round(g[0].y0, 1), g[0].x0))
    return out


# --------------------------------------------------------------------------
#  прохід 2a (§3.3.2a) — сітка філворда по надрукованих літерах
# --------------------------------------------------------------------------
def _median(v):
    v = sorted(v)
    n = len(v)
    if not n:
        return 0.0
    return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2.0


def page_glyphs(page):
    """Усі одиночні надруковані літери сторінки з центрами й розмірами."""
    raw = page.get_text("rawdict")
    out = []
    for b in raw["blocks"]:
        if b["type"] != 0:
            continue
        for l in b["lines"]:
            for s in l["spans"]:
                for c in s["chars"]:
                    ch = c["c"]
                    if not ch.isalpha():
                        continue
                    x0, y0, x1, y1 = c["bbox"]
                    if x1 - x0 <= 0.3 or y1 - y0 <= 0.3:
                        continue
                    out.append({"c": ch, "cx": (x0 + x1) / 2, "cy": (y0 + y1) / 2,
                                "w": x1 - x0, "h": y1 - y0, "size": s["size"]})
    return out


def _glyph_rows(glyphs):
    """Кластери літер за спільною базовою лінією."""
    if not glyphs:
        return []
    tol = max(2.0, 0.45 * _median([g["h"] for g in glyphs]))
    rows, cur = [], []
    for g in sorted(glyphs, key=lambda g: g["cy"]):
        if cur and g["cy"] - cur[-1]["cy"] > tol:
            rows.append(cur)
            cur = []
        cur.append(g)
    rows.append(cur)
    return rows


def _row_runs(row, med_w):
    """Прогони >= WS_MIN_COLS літер із рівномірним і ШИРОКИМ кроком.

    Саме «широкий крок» відрізняє ряд клітинок від звичайного слова: у прозі
    крок між центрами літер ~1.15 ширини гліфа, у сітці — 2.5–3.5.
    """
    row = sorted(row, key=lambda g: g["cx"])
    runs = []
    i = 0
    while i < len(row) - (WS_MIN_COLS - 1):
        pitch = row[i + 1]["cx"] - row[i]["cx"]
        if not (WS_MIN_PITCH_W * med_w <= pitch <= WS_MAX_PITCH_W * med_w):
            i += 1
            continue
        j = i + 1
        while j + 1 < len(row):
            if abs((row[j + 1]["cx"] - row[j]["cx"]) - pitch) > WS_PITCH_TOL * pitch:
                break
            j += 1
        if j - i + 1 >= WS_MIN_COLS:
            gs = row[i:j + 1]
            runs.append({"pitch": (gs[-1]["cx"] - gs[0]["cx"]) / (len(gs) - 1),
                         "g": gs, "cy": sum(x["cy"] for x in gs) / len(gs),
                         "x0": gs[0]["cx"], "x1": gs[-1]["cx"]})
            i = j + 1
        else:
            i += 1
    return runs


def _aligned(dx, pitch):
    """Зсув між рядами кратний кроку (дозволяє «дірку» з краю ряду)."""
    if abs(dx) > 1.6 * pitch:
        return False
    k = dx / pitch
    return abs(k - round(k)) <= 0.28


def word_grids(page, blacklist=(), footer_y=FOOTER_Y):
    """Сітки філворда: прямокутні ґратки, заповнені літерами на >= WS_FILL.

    Повертає перелік
        {"rows", "cols", "fill", "pitch": (px, py), "bbox",
         "cells": [{"r", "c", "ch", "rect"}]}
    """
    glyphs = [g for g in page_glyphs(page)
              if HEADER_Y - 40 < g["cy"] < footer_y]
    if len(glyphs) < WS_MIN_CELLS:
        return []
    med_w = _median([g["w"] for g in glyphs])
    runs = []
    for row in _glyph_rows(glyphs):
        rw = _median([g["w"] for g in row]) or med_w
        runs.extend(_row_runs(row, rw))
    runs.sort(key=lambda r: r["cy"])

    out, used = [], [False] * len(runs)
    for i, base in enumerate(runs):
        if used[i]:
            continue
        px = base["pitch"]
        group, dy, take = [base], None, [i]
        for j in range(i + 1, len(runs)):
            if used[j]:
                continue
            d = runs[j]
            if abs(d["pitch"] - px) > WS_PITCH_TOL * px:
                continue
            if not (_aligned(d["x0"] - base["x0"], px)
                    and _aligned(d["x1"] - base["x1"], px)):
                continue
            gap = d["cy"] - group[-1]["cy"]
            if gap <= 0.5:
                continue
            if dy is None:
                if gap > 3.0 * px:
                    continue
                dy = gap
            elif abs(gap - dy) > WS_PITCH_TOL * dy:
                continue
            group.append(d)
            take.append(j)
        if len(group) < WS_MIN_ROWS or not dy:
            continue
        if not (WS_ASPECT[0] <= px / dy <= WS_ASPECT[1]):
            continue

        gs = [g for r in group for g in r["g"]]
        x_min = min(g["cx"] for g in gs)
        y_min = min(g["cy"] for g in gs)
        cols = int(round((max(g["cx"] for g in gs) - x_min) / px)) + 1
        rows = len(group)
        if rows < WS_MIN_ROWS or cols < WS_MIN_COLS or rows * cols < WS_MIN_CELLS:
            continue
        slots = {}
        for g in gs:
            r = int(round((g["cy"] - y_min) / dy))
            c = int(round((g["cx"] - x_min) / px))
            if 0 <= r < rows and 0 <= c < cols:
                slots.setdefault((r, c), g)
        fill = len(slots) / float(rows * cols)
        if fill < WS_FILL:
            continue

        cells, bad = [], False
        for (r, c), g in sorted(slots.items()):
            rect = fitz.Rect(x_min + c * px - px / 2 + WS_INSET,
                             y_min + r * dy - dy / 2 + WS_INSET,
                             x_min + c * px + px / 2 - WS_INSET,
                             y_min + r * dy + dy / 2 - WS_INSET)
            if any(rect.intersects(b["rect"]) for b in blacklist):
                bad = True
                break
            cells.append({"r": r + 1, "c": c + 1, "ch": g["c"], "rect": rect})
        if bad or not cells:
            continue
        bbox = fitz.Rect(x_min - px / 2, y_min - dy / 2,
                         x_min + (cols - 1) * px + px / 2,
                         y_min + (rows - 1) * dy + dy / 2)
        for k in take:
            used[k] = True
        out.append({"rows": rows, "cols": cols, "fill": round(fill, 3),
                    "pitch": (round(px, 2), round(dy, 2)), "bbox": bbox,
                    "cells": cells})
    out.sort(key=lambda w: (round(w["bbox"].y0, 1), w["bbox"].x0))
    return out


# --------------------------------------------------------------------------
#  прохід 4 (§3.3.4) — варіанти вибору
# --------------------------------------------------------------------------
def _mk_option(chars):
    """Прямокутник і текст варіанта з переліку гліфів (беремо лише червоні)."""
    vis = [c for c in chars if c.get("red") and not c["c"].isspace()
           and c["c"] not in ("\xa0", "­")]
    if not vis:
        return None
    txt = norm("".join(c["c"] for c in chars if c.get("red")))
    txt = txt.strip(" .,:;\"'")
    if not txt:
        return None
    x0 = min(c["bbox"][0] for c in vis)
    x1 = max(c["bbox"][2] for c in vis)
    y0 = min(c["bbox"][1] for c in vis)
    y1 = max(c["bbox"][3] for c in vis)
    return txt, fitz.Rect(x0 - OPT_PAD_X, y0 - OPT_PAD_Y,
                          x1 + OPT_PAD_X, y1 + OPT_PAD_Y)


def tick_label(lines, cell, side):
    """Текст-мітка з одного боку від квадрата (`side` = 'left' / 'right').

    Мітка — це варіант відповіді, вона стає `on`-значенням кнопки, тож має
    бути осмисленою: щонайменше два слова. Колонка балів виключена, інакше
    міткою стає «/1» із сусіднього боксу.
    """
    best, bestd = None, 1e9
    for l in lines:
        b = l["bbox"]
        cy = (b[1] + b[3]) / 2
        if not (cell.y0 - 3 <= cy <= cell.y1 + 3):
            continue
        if b[0] >= SCORE_X:
            continue
        txt = norm(l["text"])
        if len(txt) < 6 or len(txt.split()) < 2 or SCORE_TXT_RE.match(txt):
            continue
        if side == "left" and b[2] <= cell.x0:
            d = cell.x0 - b[2]
        elif side == "right" and b[0] >= cell.x1:
            d = b[0] - cell.x1
        else:
            continue
        if d < bestd and d < TICK_LABEL_MAX:
            best, bestd = txt, d
    return (best or "")[:60].strip(" .,:;")


def tick_labels(lines, cells):
    """Мітки для цілого набору квадратів: бік вибирається для НАБОРУ, а не
    для кожного квадрата окремо. Інакше у два стовпчики варіантів лівий
    квадрат «притягує» мітку правого стовпчика (стор. 11 Q3)."""
    for side in ("left", "right"):
        labs = [tick_label(lines, c, side) for c in cells]
        if all(labs) and len(set(labs)) == len(labs):
            return labs
    return None


def tick_sets(page, lines, cells):
    """Набори окремих порожніх квадратів із текстовими мітками.

    Геометрично (а не за інструкцією) це кнопки вибору або клітинки
    нумерації: у групі жодна клітинка не має горизонтального сусіда, усі
    порожні, у кожної є мітка-речення поруч. Літерні клітинки слова цю
    перевірку не проходять — вони завжди стоять у ряд.
    """
    out = []
    for g in group_grids(cells, minsize=1):
        if not (1 <= len(g) <= 8):
            continue
        if any(h_neighbour(c, cells) for c in g):
            continue
        if not all(TICK_MIN <= c.width <= TICK_MAX and cell_empty(page, c) for c in g):
            continue
        labs = tick_labels(lines, g)
        if not labs:
            continue
        out.append(list(zip(g, labs)))
    return out


def merge_wrapped(rows_lines):
    """Рядок варіантів, що закінчується на «/», продовжується наступним:
    «А / Б /» + «В» — це один перелік, а не дві групи."""
    out = []
    skip = set()
    for i, l in enumerate(rows_lines):
        if i in skip:
            continue
        cur = dict(l)
        cur["spans"] = list(l["spans"])
        j = i
        while j + 1 < len(rows_lines):
            txt = norm("".join(sp["text"] for sp in cur["spans"]))
            if not txt.endswith("/"):
                break
            nxt = rows_lines[j + 1]
            if nxt["bbox"][1] - cur["bbox"][3] > 12:
                break
            cur["spans"] = cur["spans"] + list(nxt["spans"])
            cur["text"] = cur.get("text", "") + " " + nxt.get("text", "")
            skip.add(j + 1)
            j += 1
        out.append(cur)
    return out


def _only_separators_between(chars):
    """Між першим і останнім червоним гліфом рядка не має бути чорних СЛІВ.

    Так відрізняємо перелік варіантів («ПОБІГ / ПІШОВ / ПОПОВЗ») від речення
    з виділеними словами («Біблія каже, що БОГ любить нас. Він ПРОЩАЄ нас») —
    у другому випадку червоні слова не є варіантами на вибір.
    """
    idx = [i for i, c in enumerate(chars) if c["red"]]
    if not idx:
        return False
    for c in chars[idx[0]:idx[-1] + 1]:
        if not c["red"] and (c["c"].isalpha() or c["c"].isdigit()):
            return False
    return True


def line_options(line):
    """Варіанти відповіді з одного рядка. Три способи нарізки (§3.3.4):

    1) « / » між варіантами — роздільник буває набраний чорним, тож ріжемо
       перелік гліфів усього рядка, а не лише червоні спани;
    2) кілька окремих червоних спанів у рядку — кожен спан = варіант;
    3) один червоний спан, у якому варіанти розділені кількома пробілами
       («веслував    їв    спав»).
    """
    chars = []
    for sp in line.get("spans", []):
        red = sp["color"] == BRAND
        for c in sp["chars"]:
            chars.append({"c": c["c"], "bbox": c["bbox"], "red": red})
    if not any(c["red"] for c in chars):
        return [], None
    if not _only_separators_between(chars):
        return [], None

    if any(c["c"] == "/" for c in chars):
        segs, cur = [], []
        for c in chars:
            if c["c"] == "/":
                segs.append(cur)
                cur = []
            else:
                cur.append(c)
        segs.append(cur)
        opts = [o for o in (_mk_option(s) for s in segs) if o]
        if len(opts) >= 2:
            return opts, "роздільник /"

    reds = [sp for sp in line.get("spans", [])
            if sp["color"] == BRAND and norm(sp["text"])]
    if len(reds) >= 2:
        opts = []
        for sp in reds:
            o = _mk_option([{"c": c["c"], "bbox": c["bbox"], "red": True}
                            for c in sp["chars"]])
            if o:
                opts.append(o)
        if len(opts) >= 2:
            return opts, "окремі червоні спани"

    if len(reds) == 1:
        cs = [{"c": c["c"], "bbox": c["bbox"], "red": True}
              for c in reds[0]["chars"]]
        segs, cur, gap = [], [], 0
        for c in cs:
            if c["c"].isspace() or c["c"] == "\xa0":
                gap += 1
                if gap >= 2 and cur:
                    segs.append(cur)
                    cur = []
                continue
            gap = 0
            cur.append(c)
        if cur:
            segs.append(cur)
        opts = [o for o in (_mk_option(s) for s in segs) if o]
        if len(opts) >= 2:
            return opts, "пробіли між варіантами"
    return [], None


def framed_options(page, rects, blacklist, task):
    """Ряд однакових рамок під інструкцією-вибором (§3.3.4): «сонце / хмара /
    темрява» — варіанти надруковані всередині рамок."""
    cand = []
    for o in rects:
        r = o["rect"]
        if o["hidden"] or not o["stroked"] or r.x0 >= CONTENT_RIGHT:
            continue
        if any(r.intersects(b["rect"]) for b in blacklist):
            continue
        if not (task["y0"] <= (r.y0 + r.y1) / 2 <= task["y1"]):
            continue
        if r.width < 30 or not (12 <= r.height <= 40):
            continue
        txt = norm(page.get_text("text", clip=r + (2, 2, -2, -2)))
        if not txt or only_filler(txt):
            continue
        cand.append((r, txt))
    rows = {}
    for r, txt in cand:
        rows.setdefault(round(r.y0 / 3), []).append((r, txt))
    best = max(rows.values(), key=len) if rows else []
    if len(best) < 2:
        return []
    ws = [r.width for r, _ in best]
    if max(ws) - min(ws) > 6:
        return []
    best.sort(key=lambda x: x[0].x0)
    return [(txt, r) for r, txt in best]


# --------------------------------------------------------------------------
#  прохід 2b (§3.3.2b) — контурні літери під зафарбовування
# --------------------------------------------------------------------------
_INK_CACHE = {}
_INK_REF = 200.0                        # кегль, яким міряємо (1 px = 1 pt)
_INK_TABLE = bytes(0 if v < 250 else 255 for v in range(256))


def glyph_ink(fontname, ch):
    """Межі фарби гліфа в частках кегля, відносно початку базової лінії:
    `(вліво, вправо, вгору, вниз)`.

    Міряємо рендером, а не метриками: `fitz.Font.glyph_bbox()` віддає bbox
    УСЬОГО шрифту, однаковий для всіх гліфів (перевірено), а вбудований у
    документ підмножинний шрифт не має придатної для `insert_text` розкладки.
    Тож малюємо гліф великим кеглем на чистій сторінці й дивимося, які пікселі
    зафарбовані. Результат кешується — унікальних літер на урок кілька
    десятків.
    """
    key = (fontname, ch)
    if key in _INK_CACHE:
        return _INK_CACHE[key]
    path = PAINT_FONTFILES.get(fontname.replace(" ", "").lower())
    res = None
    if path and os.path.exists(path):
        d = fitz.open()
        p = d.new_page(width=600, height=600)
        p.insert_font(fontname="X", fontfile=path)
        p.insert_text((150, 400), ch, fontname="X", fontsize=_INK_REF)
        pix = p.get_pixmap(dpi=72, colorspace=fitz.csGRAY)
        w, h = pix.width, pix.height
        data = pix.samples.translate(_INK_TABLE)
        x0 = y0 = 10 ** 9
        x1 = y1 = -10 ** 9
        for y in range(h):
            row = data[y * w:(y + 1) * w]
            i = row.find(0)
            if i < 0:
                continue
            y0, y1 = min(y0, y), max(y1, y)
            x0, x1 = min(x0, i), max(x1, row.rfind(0))
        d.close()
        if x1 >= 0:
            res = ((x0 - 150) / _INK_REF, (x1 + 1 - 150) / _INK_REF,
                   (400 - y0) / _INK_REF, (y1 + 1 - 400) / _INK_REF)
    _INK_CACHE[key] = res
    return res


def paint_ink_box(ch, font, size, lw, ox, oy):
    """Прямокутник фарби гліфа на сторінці (з половиною обвідки й запасом)."""
    ink = glyph_ink(font, ch)
    if not ink:
        return None
    dx0, dx1, dtop, dbot = ink
    pad = lw / 2 + PAINT_INK_PAD
    return fitz.Rect(ox + dx0 * size - pad, oy - dtop * size - pad,
                     ox + dx1 * size + pad, oy + dbot * size + pad)


def grow_to_ink(glyphs):
    """Розширити прямокутники полів до меж фарби гліфів.

    Вертикально — без обмежень: діакритика й виносні елементи виступають за
    прямокутник наступу, а сусідів згори-знизу в рядка немає.
    Горизонтально — тільки до прямокутника наступу СУСІДА: у цій гарнітурі
    літери подекуди заходять одна на одну, і якби поле росло за сусіда, вони
    перетиналися б на 2–3 pt, а `Placer` відкидав би кожну другу літеру.
    Сховане в сусідній прямокутник вістря лишається незалитим, але воно вужче
    за обвідку й на друкованому контурі не помітне (перевірено дифом рендерів).
    """
    for i, g in enumerate(glyphs):
        box = g.pop("ink", None)
        adv = g["rect"]
        if box is None:
            g["grown"] = False
            continue
        lo = glyphs[i - 1]["adv"].x1 if i else -1e9
        hi = glyphs[i + 1]["adv"].x0 if i + 1 < len(glyphs) else 1e9
        box.x0 = max(box.x0, min(lo, adv.x0))
        box.x1 = min(box.x1, max(hi, adv.x1))
        out = fitz.Rect(adv) | box
        g["rect"] = out
        g["grown"] = round(out.y0, 1) != round(adv.y0, 1) or \
            round(out.y1, 1) != round(adv.y1, 1)
    for g in glyphs:
        g.pop("adv", None)
    return glyphs


def outline_runs(page, footer_y):
    """Прогони контурного тексту сторінки — те, що дитина розфарбовує.

    Ознака — режим малювання `stroke` (`type == 1` у `get_texttrace()`), а не
    слово «розфарбуй» в інструкції: контурний текст у макеті буває тільки в
    завданнях-розмальовках. `get_texttrace()` віддає на кожен гліф його номер
    у шрифті (`gid`), початок базової лінії й прямокутник наступу — рівно те,
    що потрібно, щоб перемалювати той самий гліф у потоці вигляду віджета.
    """
    runs = []
    for s in page.get_texttrace():
        if s.get("type") != 1 or s.get("size", 0) < PAINT_MIN_SIZE:
            continue
        glyphs = []
        for ucs, gid, (ox, oy), bb in s["chars"]:
            ch = chr(ucs)
            if not ch.strip():                 # пробіл зафарбовувати нічого
                continue
            adv = fitz.Rect(bb)
            if adv.width < 1 or adv.height < 1 or adv.y1 > footer_y:
                continue
            glyphs.append({
                "ch": ch, "gid": gid, "ox": ox, "oy": oy,
                "rect": fitz.Rect(adv), "adv": adv,
                "ink": paint_ink_box(ch, s["font"], s["size"],
                                     s.get("linewidth") or 1.0, ox, oy)})
        if not glyphs:
            continue
        runs.append({"font": s["font"], "size": s["size"],
                     "lw": s.get("linewidth") or 1.0,
                     "bbox": fitz.Rect(s["bbox"]), "glyphs": glyphs})
    runs.sort(key=lambda r: (round(r["bbox"].y0, 1), r["bbox"].x0))

    # злити прогони одного рядка в один: на стор. 3 кожна літера має власний x
    # і приходить окремим спаном, а нумерувати поля треба наскрізно по рядку
    merged = []
    for r in runs:
        p = merged[-1] if merged else None
        if (p and abs(p["size"] - r["size"]) < 0.01
                and abs(p["glyphs"][-1]["oy"] - r["glyphs"][0]["oy"]) < 0.5
                and r["bbox"].x0 >= p["bbox"].x0):
            p["glyphs"].extend(r["glyphs"])
            p["bbox"] |= r["bbox"]
        else:
            merged.append(r)
    for r in merged:
        grow_to_ink(r["glyphs"])
        r["bbox"] = fitz.Rect(min(g["rect"].x0 for g in r["glyphs"]),
                              min(g["rect"].y0 for g in r["glyphs"]),
                              max(g["rect"].x1 for g in r["glyphs"]),
                              max(g["rect"].y1 for g in r["glyphs"]))
    return merged


# --------------------------------------------------------------------------
#  прохід 3 (§3.3.3) — рамки відповідей
# --------------------------------------------------------------------------
def page_art(page):
    """Векторні ілюстрації сторінки: фігури, що містять криві (§1.5).

    Рамки, лінійки й клітинки сіток складені з прямих — вони сюди не входять.
    """
    out = []
    for dr in page.get_drawings():
        if not any(it[0] == "c" for it in (dr.get("items") or ())):
            continue
        r = fitz.Rect(dr["rect"])
        if r.is_valid and r.width > 1 and r.height > 1:
            out.append(r)
    return out


def frame_candidates(page, rects, score_boxes, footer_y, art=()):
    """Рамки завдань із готовим прямокутником поля.

    Чотири різновиди:
      * `free`   — рамка порожня, поле = вся рамка;
      * `capped` — друкований текст займає лише ВЕРХ рамки, а нижче лишається
        порожня смуга: дитина пише **під** підписом у тій самій рамці
        (стор. 6 Q1 «запиши під кожним іменем його значення»). Поле — у
        порожню частину;
      * `full`   — текст займає рамку цілком: це підпис, а не місце для
        відповіді. Поля не отримує й у `skipped` не потрапляє.
      * `art`    — усередині рамки лежить векторна ілюстрація. Тексту в ній
        немає, тож правило «порожня рамка -> поле» помилково давало поле, і
        його блідо-жовта заливка накривала малюнок (стор. 17). Це така сама
        свідома відмова, як `full`: у `skipped` не потрапляє.
    """
    out = []
    for o in rects:
        r = o["rect"]
        if o["hidden"] or not o["stroked"]:
            continue
        if r.x0 >= CONTENT_RIGHT or r.y1 <= HEADER_Y or r.y0 >= footer_y:
            continue
        if any(r.intersects(b["rect"]) for b in score_boxes):
            continue
        if abs(r.width - r.height) <= CELL_SQUARENESS and r.width <= MAX_CELL:
            continue                      # це клітинка, її розглядає прохід 2
        if r.width < 30 or r.height < 12 or r.height > 120:
            continue
        inner = fitz.Rect(r.x0 + 1, r.y0 + 1, r.x1 - 1, r.y1 - 1)
        txt = page.get_text("text", clip=inner)
        if sum(1 for a in art if inner.contains(a)) >= ART_MIN_SHAPES:
            out.append({"rect": r, "field": None, "kind": "art", "cap": norm(txt)})
            continue
        if only_filler(txt):
            out.append({"rect": r, "field": inner, "kind": "free", "cap": ""})
            continue
        words = page.get_text("words", clip=inner)
        ymax = max([w[3] for w in words], default=r.y1)
        free = fitz.Rect(r.x0 + 2, ymax + 2, r.x1 - 2, r.y1 - 2)
        if (words and ymax <= r.y0 + r.height * CAP_TOP_FRAC
                and free.height >= CAP_FREE_H):
            out.append({"rect": r, "field": free, "kind": "capped",
                        "cap": norm(txt)})
            continue
        xmax = max([w[2] for w in words], default=r.x1)
        side = fitz.Rect(xmax + 3, r.y0 + 1.5, r.x1 - 2, r.y1 - 1.5)
        if (words and xmax <= r.x0 + r.width * SIDE_LEFT_FRAC
                and side.width >= SIDE_FREE_W):
            out.append({"rect": r, "field": side, "kind": "sided",
                        "cap": norm(txt)})
        else:
            out.append({"rect": r, "field": None, "kind": "full",
                        "cap": norm(txt)})
    out.sort(key=lambda f: (round(f["rect"].y0, 1), f["rect"].x0))
    return out


# --------------------------------------------------------------------------
#  прохід 5 (§3.3.5) — пропуски ____ без рамки
# --------------------------------------------------------------------------
#  один пропуск = одне поле: «д __ __» -> одне поле на «__ __», а не два
# Пропуск буває двох виглядів: підкресленнями «__ __ __» і крапками
# «................». У рівні 4 вживають саме крапки, і без них детектор
# вважав, що писати нікуди, — а тоді перелік слів поруч ставав «варіантами».
GAP_RE = re.compile(r"_(?:[ \xa0]{0,3}_)+|\.{4,}")


def underscore_gaps(lines, task):
    out = []
    for l in lines:
        cy = (l["bbox"][1] + l["bbox"][3]) / 2
        if not (task["y0"] <= cy <= task["y1"]):
            continue
        for sp in l["spans"]:
            for m in GAP_RE.finditer(sp["text"]):
                cs = sp["chars"][m.start():m.end()]
                if not cs:
                    continue
                x0 = min(c["bbox"][0] for c in cs)
                x1 = max(c["bbox"][2] for c in cs)
                y1 = max(c["bbox"][3] for c in cs)
                h = max(12.0, sp["size"] * 1.15)
                out.append(fitz.Rect(x0, y1 - h, x1, y1 + 1))
    return out


# --------------------------------------------------------------------------
#  прохід 6 (§3.3.6) — чекбокс «виконано» у вільному місці
# --------------------------------------------------------------------------
def done_spots(task):
    """Кандидати на місце чекбокса 11×11, від найкращого до запасних:
    праворуч від останнього слова -> ліворуч від `Q:` -> під інструкцією ->
    вільна смуга біля колонки балів (згори вниз по зоні завдання)."""
    b = task["last_line_bbox"]
    q = task["q_bbox"]
    S = 11.0
    yield fitz.Rect(b[2] + 6, b[3] - S - 1.5, b[2] + 6 + S, b[3] - 1.5)
    yield fitz.Rect(q[0] - S - 6, q[1] + 1, q[0] - 6, q[1] + 1 + S)
    y = b[3] + 4
    yield fitz.Rect(q[0], y, q[0] + S, y + S)
    yy = max(task["y0"], HEADER_Y)
    while yy + S < min(task["y1"], FOOTER_Y):
        yield fitz.Rect(CONTENT_RIGHT - S - 2, yy, CONTENT_RIGHT - 2, yy + S)
        yy += 7


# --------------------------------------------------------------------------
#  головний прохід по сторінці
# --------------------------------------------------------------------------
def option_rows(page, lines, rects, score_boxes, t):
    """Рядки варіантів вибору в зоні завдання (червоні слова або ряд рамок)."""
    cand = [l for li, l in enumerate(lines)
            if t["y0"] <= (l["bbox"][1] + l["bbox"][3]) / 2 <= t["y1"]
            and li not in t["instr_lines"]]
    rows = []
    for l in merge_wrapped(cand):
        opts, how = line_options(l)
        if len(opts) >= 2:
            rows.append((norm(l.get("text", "")), opts, how))
    framed = []
    if not rows:
        fr = framed_options(page, rects, score_boxes, t)
        if len(fr) >= 2:
            framed = [("(рамки)", fr, "ряд однакових рамок")]
    return rows, framed


def detect_page(page, namer):
    pno = page.number
    tag = "p%02d" % (pno + 1)
    lines = read_text(page)
    rects = read_rects(page)

    foot = footer_limit(lines)
    score_boxes = find_score_boxes(page, rects)          # 1
    tasks = inventory(lines, score_boxes, foot)          # §3.1
    covers = cover_labels(page, lines, rects)

    P = Placer(page, namer, foot)
    skipped = []
    char_seq = {}                 # наскрізний лічильник клітинок у межах завдання

    # ------------------------------------------------------------------
    #  геометрична розвідка — ДО класифікації (рішення замовника №4)
    # ------------------------------------------------------------------
    # 2a. сітки філворда — шукаємо ПЕРШИМИ: їхні клітинки не мають потрапити
    # ні до `char`-проходу, ні до рамок відповідей
    wgrids = word_grids(page, score_boxes, foot)
    for w in wgrids:
        w["task"] = task_of(tasks, w["bbox"])
    ws_boxes = [w["bbox"] for w in wgrids]

    def in_ws(r):
        return any(b.intersects(r) for b in ws_boxes)

    rects = [o for o in rects if not in_ws(o["rect"])]

    # 2b. контурні написи — теж до класифікації: вони роблять завдання
    # виконуваним, тож чекбокс «виконано» йому вже не потрібен
    oruns = outline_runs(page, foot)
    for r in oruns:
        r["task"] = task_of(tasks, r["bbox"])

    art = page_art(page)
    cells = cell_candidates(rects, score_boxes)
    ticks = tick_sets(page, lines, cells)
    tick_cells = {id(c) for s in ticks for c, _ in s}

    grids = group_grids(cells)
    gridinfo = []
    plan = []                    # (ґратка, завдання, порожні клітинки)
    for g in grids:
        gbox = fitz.Rect(min(c.x0 for c in g), min(c.y0 for c in g),
                         max(c.x1 for c in g), max(c.y1 for c in g))
        t = task_of(tasks, gbox)
        empties = [c for c in g if cell_empty(page, c) and id(c) not in tick_cells]
        gridinfo.append({"cells": len(g), "empty": len(empties),
                         "task": t["n"] if t else None,
                         "bbox": [round(v, 1) for v in gbox]})
        if not empties:
            # Ряд клітинок із надрукованими літерами, під деякими — крапка:
            # «розфарбуй клітинки з крапкою». Це не філворд (рядів замало),
            # але завдання виконуване: кожна клітинка стає чекбоксом, як у
            # сітці філворда. Пункт 2 беклогу — механізм був, бракувало
            # правила; ознака — «розфарбуй»/«познач» в інструкції завдання.
            # У рівнях 0 і 1 маркера `Q:` немає, тож завдання не розпізнане
            # і інструкції в `t` теж. Беремо найближчий рядок над ґраткою —
            # саме там вона й стоїть.
            instr = norm(t["instr"]).lower() if t else ""
            if not instr:
                above = [l for l in lines
                         if l["bbox"][3] <= gbox.y0 + 2
                         and gbox.y0 - l["bbox"][3] < 60
                         and l["bbox"][0] < gbox.x1]
                if above:
                    above.sort(key=lambda l: -l["bbox"][3])
                    instr = " ".join(norm(l.get("text", ""))
                                     for l in above[:2]).lower()
            if any(k in instr for k in ("розфарбуй", "зафарбуй", "познач")) \
                    and len(g) >= 6:
                for ci, c in enumerate(sorted(g, key=lambda r: (round(r.y0, 1), r.x0)), 1):
                    box = fitz.Rect(c.x0 + WS_INSET, c.y0 + WS_INSET,
                                    c.x1 - WS_INSET, c.y1 - WS_INSET)
                    nm = "%s_q%s_dot%02d" % (tag, t["n"] if t else 0, ci)
                    f, why = P.add(nm, "ws", box, t, check_text=False, literal=True)
                    if not f:
                        skipped.append("клітинка з крапкою %s: %s" % (nm, why))
                continue
            if not any(id(c) in tick_cells for c in g):
                skipped.append("ґратка %d кл. %s — усі клітинки з надрукованими "
                               "літерами, але це не сітка філворда (замало "
                               "рядів/стовпців), полів не ставимо"
                               % (len(g), gridinfo[-1]["bbox"]))
            continue
        plan.append((g, t, empties))

    frames = frame_candidates(page, rects, score_boxes, foot, art)
    frames_full = [f for f in frames if f["kind"] == "full"]
    frames_art = [f for f in frames if f["kind"] == "art"]
    frames_use = [f for f in frames if f["field"] is not None]

    ev = {}
    for t in tasks:
        rows, framed = option_rows(page, lines, rects, score_boxes, t)
        tk = [(c, lab) for s in ticks for c, lab in s
              if t["y0"] <= (c.y0 + c.y1) / 2 <= t["y1"]]
        ev[t["n"]] = {
            "opt_rows": len(rows), "rows": rows,
            "framed": len(framed[0][1]) if framed else 0, "framed_rows": framed,
            "ticks": len(tk) if 2 <= len(tk) <= 8 else 0, "tick_cells": tk,
            "cells": sum(len(e) for g, tt, e in plan if tt and tt["n"] == t["n"]),
            "frames": sum(1 for f in frames_use
                          if task_of(tasks, f["rect"]) is t),
            "gaps": len(underscore_gaps(lines, t)),
            "ws": sum(len(w["cells"]) for w in wgrids
                      if w["task"] and w["task"]["n"] == t["n"]),
            "paint": sum(len(r["glyphs"]) for r in oruns
                         if r["task"] and r["task"]["n"] == t["n"]),
        }
        t["type"], t["type_src"] = decide_type(t, ev[t["n"]])
        # «знайди й обведи» та «розфарбуй слова» більше не паперові: сітка
        # філворда й контурні літери виконувані просто в переглядачі
        t["needs_done"] = (t["hint"] in ("done", "find")
                           and not ev[t["n"]]["ws"] and not ev[t["n"]]["paint"])

    # ------------------------------------------------------------------
    #  2a. сітка філворда: КОЖНА літерна клітинка -> чекбокс із кільцем
    # ------------------------------------------------------------------
    #  Обвести слово мишею неможливо, тож дитина «обводить» його, натискаючи
    #  літери по черзі: натиснута клітинка отримує червоне кільце. Автоматичної
    #  перевірки порядку літер немає й не має бути (§3.2, тип `ws`).
    for wi, w in enumerate(wgrids, 1):
        t = w["task"]
        base = "%s_q%s_ws" % (tag, t["n"]) if t else "%s_ws%d" % (tag, wi)
        if len(wgrids) > 1 and t:
            base = "%s_q%s_ws%d" % (tag, t["n"], wi)
        for c in w["cells"]:
            name = "%s_r%dc%02d" % (base, c["r"], c["c"])
            f, why = P.add(name, "ws", c["rect"], t,
                           extra={"r": c["r"], "col": c["c"], "ch": c["ch"],
                                  "grid": wi},
                           check_text=False)
            if not f:
                skipped.append("клітинка філворда %s: %s" % (name, why))

    # ------------------------------------------------------------------
    #  2b. контурні літери -> `paint`: клік заливає літеру всередині контуру
    # ------------------------------------------------------------------
    #  Прямокутник поля = прямокутник наступу гліфа (від базової лінії ліворуч
    #  до наступного гліфа), тобто сусідні літери стикаються, але не
    #  перетинаються — клік завжди потрапляє в ту літеру, по якій дитина
    #  влучила. Розширювати прямокутник не можна: тоді сусіди накладаються.
    paint_seq = {}
    for ri, run in enumerate(oruns, 1):
        t = run["task"]
        key = t["n"] if t else "r%d" % ri
        base = "%s_q%s" % (tag, key) if t else "%s_%s" % (tag, key)
        for g in run["glyphs"]:
            paint_seq[key] = paint_seq.get(key, 0) + 1
            name = "%s_col%02d" % (base, paint_seq[key])
            f, why = P.add(name, "paint", g["rect"], t,
                           extra={"ch": g["ch"], "gid": g["gid"],
                                  "ox": round(g["ox"], 3), "oy": round(g["oy"], 3),
                                  "size": round(run["size"], 3),
                                  "lw": round(run["lw"], 3),
                                  "font": run["font"], "run": ri,
                                  "group": base},
                           check_text=False)
            if not f:
                skipped.append("контурна літера %s (%r): %s" % (name, g["ch"], why))

    # ------------------------------------------------------------------
    #  2c. окремі квадрати: кнопки ✓ (choice/multi) або нумерація (order)
    # ------------------------------------------------------------------
    done_tick = set()
    for t in tasks:
        cs = ev[t["n"]]["tick_cells"]
        if not ev[t["n"]]["ticks"]:
            continue
        cs = sorted(cs, key=lambda x: (round(x[0].y0, 1), x[0].x0))
        if t["type"] in ("choice", "multi"):
            group = namer.take("%s_q%s" % (tag, t["n"]))
            made = 0
            for oi, (c, lab) in enumerate(cs, 1):
                nm = group if t["type"] == "choice" else namer.take("%s_opt%d" % (group, oi))
                f, _ = P.add(nm, t["type"], c, t,
                             extra={"group": group, "on": lab or "варіант %d" % oi,
                                    "opt": oi, "how": "квадрат для ✓"},
                             check_text=False, literal=True)
                made += 1 if f else 0
            if made >= 2:
                done_tick.add(t["n"])
            else:
                P.drop_group(group)
        else:                              # order -> односимвольні поля
            for oi, (c, lab) in enumerate(cs, 1):
                f, why = P.add("%s_q%s_n%d" % (tag, t["n"], oi), "char",
                               fitz.Rect(c.x0 + 0.8, c.y0 + 0.8, c.x1 - 0.8, c.y1 - 0.8),
                               t, extra={"label": lab}, check_text=False)
                if f:
                    done_tick.add(t["n"])
                else:
                    skipped.append("квадрат нумерації %s Q%s #%d: %s"
                                   % (tag, t["n"], oi, why))

    # 2d. решта порожніх клітинок -> `char`, з наскрізною нумерацією в межах завдання
    for gi, (g, t, empties) in enumerate(plan, 1):
        key = t["n"] if t else "g%d" % gi
        base = "%s_q%s" % (tag, key) if t else "%s_%s" % (tag, key)
        for c in empties:
            char_seq[key] = char_seq.get(key, 0) + 1
            name = "%s_c%02d" % (base, char_seq[key])
            f, why = P.add(name, "char",
                           fitz.Rect(c.x0 + 0.8, c.y0 + 0.8, c.x1 - 0.8, c.y1 - 0.8),
                           t, check_text=False)
            if not f:
                skipped.append("клітинка %s: %s" % (name, why))

    # 3. рамки відповідей (порожні й «з підписом угорі»)
    per_task = {}
    for fr in frames_use:
        r, box = fr["rect"], fr["field"]
        t = task_of(tasks, r)
        if t and ev[t["n"]]["framed"] and t["type"] in ("choice", "multi"):
            continue                      # рамка — сам варіант, її бере прохід 4
        key = t["n"] if t else 0
        per_task[key] = per_task.get(key, 0) + 1
        ftype = "text" if box.height < AREA_H else "area"
        if t and t["type"] == "bool":
            ftype = "bool"
        name = "%s_q%s_%d" % (tag, key, per_task[key])
        f, why = P.add(name, ftype, box, t,
                       extra={"under": fr["cap"]} if fr["kind"] == "capped" else None)
        if not f:
            skipped.append("рамка %s %s (%s): %s"
                           % (name, [round(v, 1) for v in r], fr["kind"], why))

    # 4. варіанти вибору
    for t in tasks:
        if t["type"] not in ("choice", "multi"):
            continue
        if t["n"] in done_tick:
            continue
        rows = ev[t["n"]]["rows"] or ev[t["n"]]["framed_rows"]
        for si, (qtext, opts, how) in enumerate(rows, 1):
            group = namer.take("%s_q%s_s%d" % (tag, t["n"], si) if len(rows) > 1
                               else "%s_q%s" % (tag, t["n"]))
            made = 0
            for oi, (txt, r) in enumerate(opts, 1):
                if t["type"] == "choice":
                    name, ftype = group, "choice"
                else:
                    name, ftype = namer.take("%s_opt%d" % (group, oi)), "multi"
                f, why = P.add(name, ftype, r, t,
                               extra={"group": group, "on": txt, "opt": oi,
                                      "how": how, "question": qtext},
                               check_text=False, literal=True)
                if f:
                    made += 1
                else:
                    skipped.append("варіант %s=%r: %s" % (group, txt, why))
            if made < 2:
                skipped.append("група %s: лишилось %d кнопок — знято" % (group, made))
                P.drop_group(group)

    # 5. пропуски ____
    # Якщо в завданні вже є сітка клітинок, крапки в тексті — це підказки
    # кросворда («3. Бог ............ людину за Своїм образом»), а не місце
    # для відповіді: відповідь пишеться в сітку. Без цієї умови на кожну
    # підказку ставало зайве поле поверх друкованого тексту.
    with_cells = {t["n"] for t in tasks if ev[t["n"]]["cells"]}
    for t in tasks:
        if t["n"] in with_cells:
            continue
        for gi2, r in enumerate(underscore_gaps(lines, t), 1):
            name = "%s_q%s_gap%d" % (tag, t["n"], gi2)
            f, why = P.add(name, "text", fitz.Rect(r.x0 + 0.8, r.y0, r.x1, r.y1),
                           t, pad=0.0)
            if not f:
                skipped.append("пропуск %s: %s" % (name, why))

    # 5b. поля обкладинки / шапки сторінки
    for c in covers:
        b = c["bbox"]
        # рішення замовника №6: СПІЛЬНЕ ім'я на всіх сторінках — однакове ім'я
        # означає одне поле, і переглядач сам синхронізує значення. Префікса
        # сторінки тут бути не має, тож literal=True повз реєстр унікальності.
        name = "child_name" if c["kind"] == "cover_name" else "teacher"
        namer.shared.add(name)
        if c["host"]:
            h = fitz.Rect(c["host"])
            r = fitz.Rect(b[2] + 4, h.y0 + 2, h.x1 - 3, h.y1 - 2)
        else:
            r = fitz.Rect(b[2] + 4, b[1] - 0.5, min(CONTENT_RIGHT, b[2] + 190), b[3] + 1)
        f, why = P.add(name, "text", r, None, literal=True)
        if not f:
            skipped.append("поле шапки %s: %s" % (name, why))
        else:
            f["task"] = c["kind"]
            f["shared"] = True

    # 6. завдання без полів -> чекбокс «виконано»
    have = {f.get("task") for f in P.fields}
    for t in tasks:
        if t["n"] in have and not t["needs_done"]:
            continue
        placed = None
        for r in done_spots(t):
            f, _ = P.add("%s_done%s" % (tag, t["n"]), "done", r, t,
                         extra={"reason": TYPE_TITLE.get(t["type"], t["type"])})
            if f:
                placed = f
                break
        if not placed:
            skipped.append("завдання %s: не знайшлося вільного місця під чекбокс"
                           % t["n"])

    for t in tasks:
        t.pop("instr_lines", None)
        for k in ("q_bbox", "head_bbox", "last_line_bbox"):
            t[k] = [round(v, 1) for v in t[k]]
        t["y0"], t["y1"] = round(t["y0"], 1), round(t["y1"], 1)

    return {
        "tasks": tasks,
        "covers": covers,
        "score_boxes": [{"rect": [round(v, 2) for v in s["rect"]],
                         "label": s["label"], "why": s["why"]} for s in score_boxes],
        "grids": gridinfo,
        "word_grids": [{"rows": w["rows"], "cols": w["cols"], "fill": w["fill"],
                        "pitch": list(w["pitch"]),
                        "bbox": [round(v, 1) for v in w["bbox"]],
                        "task": w["task"]["n"] if w["task"] else None,
                        "letters": ["".join(
                            next((c["ch"] for c in w["cells"]
                                  if c["r"] == r and c["c"] == cc), ".")
                            for cc in range(1, w["cols"] + 1))
                            for r in range(1, w["rows"] + 1)]}
                       for w in wgrids],
        "frames_full": [{"rect": [round(v, 1) for v in f["rect"]], "text": f["cap"]}
                        for f in frames_full],
        "frames_art": [{"rect": [round(v, 1) for v in f["rect"]]}
                       for f in frames_art],
        "outline_runs": [{"bbox": [round(v, 1) for v in r["bbox"]],
                          "text": "".join(g["ch"] for g in r["glyphs"]),
                          "size": round(r["size"], 2), "lw": round(r["lw"], 2),
                          "task": r["task"]["n"] if r["task"] else None}
                         for r in oruns],
        "fields": P.fields,
        "skipped": skipped,
    }


def set_level(n):
    """Підставити фірмовий колір рівня перед розбором."""
    global BRAND
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from lessons import LEVELS
    BRAND = LEVELS[int(n)].colour
    return BRAND


LEVEL_RE = re.compile(r"Level(\d)_")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("src", nargs="?",
                    default="forms/Level2_UA_WhatChristiansBelieve_v04.pdf")
    ap.add_argument("--pages", default="", help="напр. 3 або 0,3,5 (порожньо = всі)")
    ap.add_argument("--out", default="ua_build/fields.json")
    ap.add_argument("--level", default=None,
                    help="номер рівня 0–4 (типово — з імені файла)")
    ap.add_argument("--quiet", action="store_true")
    a = ap.parse_args()

    m = LEVEL_RE.search(os.path.basename(a.src))
    if a.level is not None:
        set_level(a.level)
    elif m:
        set_level(m.group(1))
    print(f"фірмовий колір рівня: #{BRAND:06x}")

    doc = fitz.open(a.src)
    want = ([int(x) for x in a.pages.split(",") if x.strip() != ""]
            if a.pages else list(range(doc.page_count)))

    namer = Namer()
    res = {"source": a.src, "pages": {}}
    for pno in want:
        res["pages"][str(pno)] = detect_page(doc[pno], namer)
    doc.close()
    res["name_collisions"] = namer.collisions
    res["shared_names"] = sorted(namer.shared)

    os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
    with open(a.out, "w", encoding="utf-8") as fh:
        json.dump(res, fh, ensure_ascii=False, indent=1)

    total = {}
    for pno in want:
        d = res["pages"][str(pno)]
        cnt = {}
        for f in d["fields"]:
            cnt[f["type"]] = cnt.get(f["type"], 0) + 1
            total[f["type"]] = total.get(f["type"], 0) + 1
        print("=== стор. %d (index %d) — %s" % (pno + 1, pno, cnt))
        if a.quiet:
            continue
        for t in d["tasks"]:
            mine = [f["name"] for f in d["fields"] if f.get("task") == t["n"]]
            print("  Q%-3s %-7s бал %-6s %s" % (t["n"], t["type"], t["score"],
                                                t["instr"][:62]))
            print("        [%s]" % t["type_src"])
            print("        поля: %s" % (", ".join(sorted(set(mine))) or "— НЕМАЄ —"))
        for s in d["skipped"]:
            print("   - ", s)
    print("\nРАЗОМ:", total, "=", sum(total.values()))
    print("колізії імен:", res["name_collisions"] or "немає")
    print("спільні імена (синхронізуються):", res["shared_names"] or "немає")
    print("->", a.out)


if __name__ == "__main__":
    sys.exit(main())
