# -*- coding: utf-8 -*-
"""Интерактивное содержание для томов проектной документации (ГОСТ Р 21.101, п. 4.1.5).

Находит в PDF лист «Содержание», читает строки с номерами листов, ставит на каждую строку
кликабельную ссылку на нужную страницу и собирает закладки (боковое оглавление PDF).
Текст и чертежи не трогаются — добавляются только ссылки и закладки.

    python pdf_soderzhanie.py том.pdf                 → том-ссылки.pdf рядом
    python pdf_soderzhanie.py том.pdf готово.pdf --offset 1
    python pdf_soderzhanie.py папка_томов\\ --out готово\\   → все PDF папки + отчёт.csv

Сдвиг (--offset): «лист N = страница PDF N + сдвиг». По умолчанию угадывается сам: по тому,
на какой странице стоит название из строки содержания (обложка без номера → сдвиг 1).
"""
import argparse
import csv
import pathlib
import re
import sys

import fitz  # PyMuPDF

ШТАМП = {"лист", "листов", "изм.", "изм", "стадия", "разраб.", "пров.", "гип", "н.контр.",
         "контр.", "кол.уч", "№док.", "подп.", "дата", "инв.", "взам."}
НОМЕР = re.compile(r"^(\d{1,4})(?:\s*[-–]\s*\d{1,4})?$")
ШИФР = re.compile(r"[-.]")  # токен шифра: есть дефис/точка и заглавные/цифры


def _норм(s):
    return re.sub(r"[^a-zа-я0-9]", "", s.lower().replace("ё", "е"))


def _шифр(w):
    return bool(ШИФР.search(w)) and bool(re.search(r"[A-ZА-ЯЁ0-9]", w)) and not re.match(r"^\d+\.?$", w)


def строки(page, tol=3.0):
    """Слова страницы → строки (по высоте, через все колонки таблицы), сверху вниз."""
    words = sorted(page.get_text("words"), key=lambda w: ((w[1] + w[3]) / 2, w[0]))
    rows = []
    for w in words:
        yc = (w[1] + w[3]) / 2
        if rows and abs(rows[-1]["yc"] - yc) <= tol:
            rows[-1]["w"].append(w)
        else:
            rows.append({"yc": yc, "w": [w]})
    out = []
    for r in rows:
        ws = sorted(r["w"], key=lambda w: w[0])
        out.append({"words": ws, "text": " ".join(w[4] for w in ws),
                    "rect": fitz.Rect(min(w[0] for w in ws), min(w[1] for w in ws),
                                      max(w[2] for w in ws), max(w[3] for w in ws))})
    return out


def _штамп(row):
    """Строка основной надписи: два слова штампа или «Листов» (одно «Лист 1.» — это название чертежа)."""
    сл = [w[4].lower() for w in row["words"]]
    return "листов" in сл or sum(s in ШТАМП for s in сл) >= 2


def _заголовок(row):
    return re.match(r"^\s*(содержание|оглавление)\b", row["text"], re.I) is not None


def разобрать_содержание(page):
    """Строки содержания на странице → [{title, num, rect, level}]."""
    rows = строки(page)
    start = next((i for i, r in enumerate(rows) if _заголовок(r)), None)
    if start is not None:
        rows = rows[start + 1:]
    entries = []
    for r in rows:
        if _штамп(r):
            continue
        ws = r["words"]
        m = НОМЕР.match(ws[-1][4]) if len(ws) >= 2 else None
        if m:
            title = " ".join(w[4] for w in ws[:-1]).strip()
            if not re.search(r"[A-Za-zА-Яа-яЁё]{3,}", title):
                continue
            level = 1 if _шифр(ws[0][4]) else 2
            entries.append({"title": title, "num": int(m.group(1)), "rect": fitz.Rect(r["rect"]),
                            "level": level, "name_x": ws[1][0] if level == 1 and len(ws) > 2 else ws[0][0]})
        elif entries and re.search(r"[A-Za-zА-Яа-яЁё]{3,}", r["text"]):
            # перенос длинного названия на следующую строку — приклеить к записи выше
            prev = entries[-1]
            if r["rect"].y0 - prev["rect"].y1 < 1.2 * prev["rect"].height and r["rect"].x0 >= prev["name_x"] - 3:
                prev["rect"] |= r["rect"]
                prev["title"] += " " + r["text"]
    if entries and all(e["level"] == 2 for e in entries):
        for e in entries:
            e["level"] = 1
    return entries


def найти_содержание(doc, первых=6):
    """Индексы страниц содержания: страница с заголовком «Содержание» + продолжения."""
    for i in range(min(первых, doc.page_count)):
        page = doc[i]
        if any(_заголовок(r) for r in строки(page)[:12]):
            found = [i]
            j = i + 1
            while j < doc.page_count and len(разобрать_содержание(doc[j])) >= 3:
                rows = [r for r in строки(doc[j]) if not _штамп(r)]
                if len(разобрать_содержание(doc[j])) < 0.6 * max(1, len(rows)):
                    break
                found.append(j)
                j += 1
            return found
    return []


def _ключ(title):
    слова = [w for w in title.split() if not _шифр(w)]
    return _норм(" ".join(слова[:4]))


def угадать_сдвиг(doc, entries, toc_pages, диапазон=range(-5, 11)):
    """Лучший сдвиг «страница = лист − 1 + сдвиг» по совпадению названий на страницах."""
    тексты = [_норм(p.get_text()) for p in doc]
    лучший, счёт = 0, 0
    for d in sorted(диапазон, key=abs):
        n = 0
        for e in entries:
            k = _ключ(e["title"])
            idx = e["num"] - 1 + d
            if len(k) >= 8 and 0 <= idx < doc.page_count and idx not in toc_pages and k in тексты[idx]:
                n += 1
        if n > счёт:
            лучший, счёт = d, n
    return лучший, счёт


def обработать(src, dst, offset=None, закладки=True):
    """Ставит ссылки и закладки. → отчёт dict."""
    doc = fitz.open(src)
    отчёт = {"файл": pathlib.Path(src).name, "страниц": doc.page_count, "записей": 0, "ссылок": 0,
             "сдвиг": None, "совпало_названий": 0, "закладок": 0, "замечания": []}
    toc_pages = найти_содержание(doc)
    if not toc_pages:
        отчёт["замечания"].append("не найден лист «Содержание» в первых 6 страницах")
        doc.close()
        return отчёт
    все = [(p, e) for p in toc_pages for e in разобрать_содержание(doc[p])]
    отчёт["записей"] = len(все)
    if offset is None:
        offset, счёт = угадать_сдвиг(doc, [e for _, e in все], set(toc_pages))
        отчёт["совпало_названий"] = счёт
        if счёт == 0:
            отчёт["замечания"].append("названия на листах не найдены — сдвиг 0 (лист N = страница N)")
    отчёт["сдвиг"] = offset
    toc = []
    for p, e in все:
        idx = e["num"] - 1 + offset
        if not 0 <= idx < doc.page_count:
            отчёт["замечания"].append(f"«{e['title'][:40]}» → лист {e['num']}: такой страницы нет")
            continue
        r = fitz.Rect(e["rect"].x0 - 1, e["rect"].y0 - 1, e["rect"].x1 + 1, e["rect"].y1 + 1)
        doc[p].insert_link({"kind": fitz.LINK_GOTO, "from": r, "page": idx,
                            "to": fitz.Point(0, 0), "zoom": 0})
        отчёт["ссылок"] += 1
        уровень = e["level"] if not toc or e["level"] <= toc[-1][0] + 1 else toc[-1][0] + 1
        toc.append([уровень, e["title"], idx + 1])
    if закладки and toc:
        if doc.get_toc():
            отчёт["замечания"].append("в PDF уже были закладки — оставлены как есть")
        else:
            doc.set_toc(toc)
            отчёт["закладок"] = len(toc)
    doc.save(dst, garbage=3, deflate=True)
    doc.close()
    return отчёт


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("src", help="PDF-том или папка с томами")
    ap.add_argument("dst", nargs="?", help="куда сохранить (для одного файла)")
    ap.add_argument("--out", help="папка для результатов (для папки томов)")
    ap.add_argument("--offset", type=int, default=None, help="сдвиг: страница = лист + сдвиг")
    ap.add_argument("--no-bookmarks", action="store_true", help="без закладок")
    a = ap.parse_args(argv)
    src = pathlib.Path(a.src)
    if src.is_dir():
        out = pathlib.Path(a.out or (src / "с-ссылками"))
        out.mkdir(parents=True, exist_ok=True)
        пары = [(f, out / f.name) for f in sorted(src.glob("*.pdf"))]
    else:
        пары = [(src, pathlib.Path(a.dst) if a.dst else src.with_name(src.stem + "-ссылки.pdf"))]
    отчёты = []
    for f, d in пары:
        r = обработать(f, d, a.offset, not a.no_bookmarks)
        отчёты.append(r)
        print(f"{r['файл']}: записей {r['записей']}, ссылок {r['ссылок']}, сдвиг {r['сдвиг']}, "
              f"закладок {r['закладок']}" + ("; " + "; ".join(r["замечания"]) if r["замечания"] else ""))
    if len(пары) > 1:
        with open(out / "отчёт.csv", "w", newline="", encoding="utf-8-sig") as fh:
            w = csv.writer(fh, delimiter=";")
            w.writerow(["файл", "страниц", "записей", "ссылок", "сдвиг", "закладок", "замечания"])
            for r in отчёты:
                w.writerow([r["файл"], r["страниц"], r["записей"], r["ссылок"], r["сдвиг"], r["закладок"],
                            " | ".join(r["замечания"])])
    return 0 if all(r["ссылок"] for r in отчёты) else 1


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