"""Парсер ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067). Источник: PDF-документы планировочных проектов (ППТ/ПМТ) Екатеринбурга. Геометрия ППТ/ПМТ уже в БД (planning_projects, 1170 объектов, WFS). Три целевые таблицы: Табл.11 — Баланс территории по зонам (га, %); Табл.12 — Основные технико-экономические показатели; Табл.13 — Очередность строительства (зона / состав / финансирование). Особенности: - Таблицы обнаруживаются по заголовку-caption через extract_text() на каждой странице. - Каждая таблица может занимать несколько страниц (особенно Табл.12 и 13). - Структура ППТ разнится — парсер работает best-effort: не найдена таблица → соответствующее поле = [] (не исключение). - Кириллица: используем ТОЛЬКО pdfplumber (pdftotext/poppler даёт mojibake). - Нормализация числовых значений: «15,41 га», «(42,61 %)» → float. """ from __future__ import annotations import io import logging import re from dataclasses import dataclass, field from typing import Any import pdfplumber logger = logging.getLogger(__name__) # ── Нормализация значений ──────────────────────────────────────────────────── _RE_NUM = re.compile(r"[\d\s]+(?:[.,]\d+)?") def _norm_value(raw: str | None) -> str: """Очищаем ячейку: убираем лишние пробелы, переносы, управляющие символы.""" if raw is None: return "" # Убираем управляющие глифы (например  — «») text = re.sub(r"[-]", "", raw) # Нормализуем переносы строк внутри ячейки → пробел text = re.sub(r"\s*\n\s*", " ", text) return text.strip() def _try_float(value: str) -> float | None: """Пытаемся превратить строку вида '15,41 га (42,61 %)' в float первого числа.""" m = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)", value) if not m: return None num_str = m.group(1).replace(" ", "").replace(",", ".") try: return float(num_str) except ValueError: return None # ── Обнаружение таблиц ─────────────────────────────────────────────────────── # Паттерны для определения номера таблицы в тексте страницы. # Ищем «Таблица 11», «Таблица 12», «Таблица 13» (латинская «а» вместо кириллической # тоже встречается в некоторых PDF — поэтому без локали). _TABLE_CAPTION_RE = { 11: re.compile(r"Таблица\s+11\b", re.IGNORECASE), 12: re.compile(r"Таблица\s+12\b", re.IGNORECASE), 13: re.compile(r"Таблица\s+13\b", re.IGNORECASE), } def _page_contains_table(text: str, table_no: int) -> bool: """Возвращает True если текст страницы содержит заголовок таблицы N. Требуем явный caption «Таблица N» — hint-только режим (оглавление, перекрёстные ссылки) даёт false positive и подавляется. Если caption присутствует — достаточно. """ cap = _TABLE_CAPTION_RE[table_no] return bool(cap.search(text)) # ── Парсинг конкретных таблиц ──────────────────────────────────────────────── def _parse_table11(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: """Баланс территории по зонам (Табл.11). Структура: № | Наименование зоны | Сущ., га (%) | Проект, га (%) Заголовок (первые 2 строки) пропускается по признаку: №/None в col[0]. """ result: list[dict[str, Any]] = [] header_skipped = False for row in raw_rows: if not row: continue no = _norm_value(row[0] if len(row) > 0 else None) zone = _norm_value(row[1] if len(row) > 1 else None) existing = _norm_value(row[2] if len(row) > 2 else None) project = _norm_value(row[3] if len(row) > 3 else None) # Строка-заголовок: col[0] в {№, None, ''} и col[1] содержит # «Наименование» или «Площадь» — пропускаем. if not header_skipped: zone_lo = zone.lower() if no in ("", "№", None) and ("наименование" in zone_lo or "площадь" in zone_lo): header_skipped = True continue if no in ("", "№", None) and not zone: continue # Пропускаем строки без номера — вложенные подзаголовки. # Итоговую строку включаем (распознаём по «Итого»). is_total = "итого" in (no + zone).lower() if not no and not is_total: continue record: dict[str, Any] = { "no": no, "zone_name": zone, "existing_ha": _norm_value(existing), "existing_ha_num": _try_float(existing), "project_ha": _norm_value(project), "project_ha_num": _try_float(project), } if is_total: record["is_total"] = True result.append(record) return result def _parse_table12(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: """Основные ТЭП (Табл.12). Структура: № | Наименование показателей | Единица измерения | Современное состояние | Расчётный срок. Таблица может содержать дополнительный столбец «I этап» (на страницах 112-113 этапы разнесены). """ result: list[dict[str, Any]] = [] header_skipped = False for row in raw_rows: if not row: continue # Чистим все ячейки cells = [_norm_value(c) for c in row] if not any(cells): continue no = cells[0] if cells else "" name = cells[1] if len(cells) > 1 else "" unit = cells[2] if len(cells) > 2 else "" current = cells[3] if len(cells) > 3 else "" target = cells[4] if len(cells) > 4 else "" # Пропуск строки-заголовка таблицы if not header_skipped: if "наименование" in name.lower() or "показател" in name.lower(): header_skipped = True continue if no in ("", "№") and ("единица" in unit.lower() or "измерени" in unit.lower()): header_skipped = True continue # Пропускаем полностью пустые строки if not (no or name or current or target): continue record: dict[str, Any] = { "no": no, "indicator": name, "unit": unit, "current": current, "target": target, } # Если 6 колонок — есть этапная разбивка (I этап / II этап) if len(cells) > 5: record["stage1"] = cells[4] record["target"] = cells[5] result.append(record) return result def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: """Очередность строительства (Табл.13). Структура сложная (20 колонок из-за merged cells): Нас интересуют: фаза (col[0]), состав объекта (col[1..2]), зона (col[13]), площадь (col[15]), финансирование (col[17]), программа (col[19]). Извлекаем только строки с непустым полем «зона» или «состав объекта». """ result: list[dict[str, Any]] = [] current_phase: str = "" header_skipped = False for row in raw_rows: if not row: continue cells = [_norm_value(c) for c in row] # Пропуск заголовка (первые 2 строки) if not header_skipped: first_meaningful = next((c for c in cells if c), "") if "номер" in first_meaningful.lower() or "состав" in first_meaningful.lower(): header_skipped = True continue # Строка с «1 2 4 5 6 7» (номера колонок) if first_meaningful in ("1", ""): continue # Определяем фазу/этап строительства (ячейка 0, начинается с «I», «II», «III»). col0 = cells[0] if cells else "" if re.match(r"^(I{1,3}V?|IV|V)\s*(этап|очередь)?", col0, re.IGNORECASE) and col0: current_phase = col0 # Состав объекта — из колонок 1 или 3 (после слияния). # При 20-колоночной таблице ищем непустой текст в диапазоне 1..5. composition = "" for ci in range(1, min(6, len(cells))): if cells[ci]: composition = cells[ci] break # Зона планируемого размещения. zone = "" area_ha_str = "" funding = "" program = "" if len(cells) >= 14: zone = cells[13] if len(cells) >= 16: area_ha_str = cells[15] if len(cells) >= 18: funding = cells[17] if len(cells) >= 20: program = cells[19] # Пропускаем строки без осмысленного содержания if not (composition or zone): continue # Пропускаем строки-секционные заголовки (нет зоны, состав = подзаголовок раздела) if not zone and "объект" in composition.lower() and len(composition) < 50: continue result.append( { "phase": current_phase, "composition": composition, "zone": zone, "area_ha": area_ha_str, "area_ha_num": _try_float(area_ha_str), "funding": funding, "program": program, } ) return result # ── Сборка сырых строк со всех страниц блока таблицы ──────────────────────── def _collect_table_rows( pages: list[pdfplumber.page.Page], start_page_idx: int, table_no: int, next_table_no: int | None, ) -> tuple[list[list[str | None]], list[dict[str, Any]]]: """Собирает строки таблицы N начиная со страницы start_page_idx. Останавливается когда встречает заголовок следующей таблицы (next_table_no) или «Рис.» / другой раздел. Returns: (all_rows, raw_table_records) — строки для парсинга и сырые данные для поля raw_tables. """ all_rows: list[list[str | None]] = [] raw_records: list[dict[str, Any]] = [] found_start = False for pi in range(start_page_idx, len(pages)): page = pages[pi] page_text = page.extract_text() or "" # Обнаружение начала нашей таблицы if not found_start: if _page_contains_table(page_text, table_no): found_start = True else: continue # Стоп-условие: начался следующий раздел/таблица if found_start and pi > start_page_idx: if next_table_no and _page_contains_table(page_text, next_table_no): break # Извлекаем таблицы со страницы page_tables = page.extract_tables() for ti, tbl in enumerate(page_tables): if not tbl: continue # Берём только таблицы с ≥2 строками и ≥2 колонками if len(tbl) < 2 or (tbl[0] and len(tbl[0]) < 2): continue # На первой странице таблицы пропускаем маленькие таблицы (заголовки/рамки) # — они идут ДО основной таблицы данных. if pi == start_page_idx and len(tbl) < 4: continue all_rows.extend(tbl) raw_records.append( { "page": pi + 1, # 1-based для отладки "table_index": ti, "rows": [[_norm_value(c) for c in row] for row in tbl[:10]], # cap 10 rows } ) return all_rows, raw_records # ── Публичный API ──────────────────────────────────────────────────────────── @dataclass class PptTep: """Результат парсинга пояснительной записки ППТ/ПМТ. Каждое поле — list[dict] с нормализованными строками таблицы. Пустой список означает «таблица не найдена» (best-effort, не ошибка). """ zone_balance: list[dict[str, Any]] = field(default_factory=list) tep: list[dict[str, Any]] = field(default_factory=list) phasing: list[dict[str, Any]] = field(default_factory=list) raw_tables: list[dict[str, Any]] = field(default_factory=list) def parse_ppt_tep(pdf_bytes: bytes) -> PptTep: """Парсит PDF пояснительной записки ППТ/ПМТ → ТЭП (Табл.11/12/13). Чистая функция от bytes; сеть/fetch — вне этой функции. Graceful при отсутствии таблиц: поле = [] вместо исключения. Args: pdf_bytes: сырые байты PDF-документа. Returns: PptTep с заполненными полями zone_balance / tep / phasing / raw_tables. """ result = PptTep() try: with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: pages = pdf.pages n_pages = len(pages) logger.info("parse_ppt_tep: opened PDF, %d pages", n_pages) # Находим страницы-начала Табл.11/12/13 page_11 = page_12 = page_13 = None for pi, page in enumerate(pages): text = page.extract_text() or "" if page_11 is None and _page_contains_table(text, 11): page_11 = pi if page_12 is None and _page_contains_table(text, 12): page_12 = pi if page_13 is None and _page_contains_table(text, 13): page_13 = pi logger.info( "parse_ppt_tep: Табл.11 на стр.%s, Табл.12 на стр.%s, Табл.13 на стр.%s", page_11 and page_11 + 1, page_12 and page_12 + 1, page_13 and page_13 + 1, ) # Табл.11 — Баланс территории if page_11 is not None: rows_11, raw_11 = _collect_table_rows(pages, page_11, 11, 12) result.zone_balance = _parse_table11(rows_11) result.raw_tables.extend(raw_11) logger.info("parse_ppt_tep: Табл.11 → %d строк", len(result.zone_balance)) else: logger.warning("parse_ppt_tep: Таблица 11 не найдена в PDF") # Табл.12 — ТЭП if page_12 is not None: rows_12, raw_12 = _collect_table_rows(pages, page_12, 12, 13) result.tep = _parse_table12(rows_12) result.raw_tables.extend(raw_12) logger.info("parse_ppt_tep: Табл.12 → %d строк", len(result.tep)) else: logger.warning("parse_ppt_tep: Таблица 12 не найдена в PDF") # Табл.13 — Очередность строительства if page_13 is not None: rows_13, raw_13 = _collect_table_rows(pages, page_13, 13, None) result.phasing = _parse_table13(rows_13) result.raw_tables.extend(raw_13) logger.info("parse_ppt_tep: Табл.13 → %d строк", len(result.phasing)) else: logger.warning("parse_ppt_tep: Таблица 13 не найдена в PDF") except Exception as exc: logger.error("parse_ppt_tep: ошибка парсинга PDF: %s", exc) raise return result