"""Заполнение одной формы XLSX по шаблону.""" import copy import datetime import io from pathlib import Path from openpyxl import load_workbook from openpyxl.drawing.image import Image as XlImage from openpyxl.drawing.spreadsheet_drawing import OneCellAnchor, AnchorMarker from openpyxl.styles import Font from services.data_loader import ki_val from services.mif_processor import get_contours, build_coord_rows # ─────────────────────────────── Константы ──────────────────────────────── # BASE_FONT = Font(name="Times New Roman", size=11) # Позиции строк шаблона (1-based, до вставки доп. строк координат) TMPL = dict( KV=2, # A2: кадастровый квартал DATE=5, # F5: дата подготовки FIO=11, # F11: ФИО КИ SNILS=12, # F12: СНИЛС PHONE=13, # F13: телефон EMAIL=15, # A15: email + адрес SRO=17, # A17: наименование СРО REGNR=19, # A19: рег. номер SOGL=24, # A24: соглашение LOC_ZU=29, # F29: местоположение ЗУ AREA_ZU=31, # F31: площадь ЗУ OBOSN_ZU=33, # F33: обоснование ЗУ ZU_COORD_START=37, # первая строка данных координат ЗУ ZU_COORD_PRE=5, # кол-во предзаполненных строк (37-41) ZU_SCHEME_START=42, # строка-заголовок раздела 6 (до смещения) ZU_SCHEME_PRE=1, # r43 = изображение, r44 = масштаб, r45-r50 = легенда LOC_OKS=54, # F54: местоположение ОКС (до смещения) OBOSN_OKS=58, # F58: обоснование ОКС (до смещения) OKS_COORD_START=62, # первая строка данных ОКС (до смещения) OKS_COORD_PRE=1, # кол-во предзаполненных строк ОКС OKS_SCHEME_START=63, # строка-заголовок раздела 9 «9. Схема ОКС» (до смещения) OKS_SCHEME_PRE=1, # r64 = изображение, r65 = масштаб ) # ──────────────────────────── Вспомогательные ───────────────────────────── # def _sv(ws, row: int, col: int, value, font=None): cell = ws.cell(row=row, column=col) cell.value = value if font: cell.font = copy.copy(font) def _safe_insert_rows(ws, idx: int, amount: int): """ Вставить строки с корректным сдвигом слиянных ячеек. openpyxl's ws.insert_rows() имеет известный баг: не сдвигает все merge-диапазоны. Решение: вручную снимаем/пересчитываем merge до вызова insert_rows. """ # Собираем все диапазоны, которые пересекаются с точкой вставки или ниже неё to_readjust = [] for rng in list(ws.merged_cells.ranges): if rng.max_row >= idx: to_readjust.append((rng.min_row, rng.min_col, rng.max_row, rng.max_col)) ws.merged_cells.remove(rng) ws.insert_rows(idx, amount) # Восстанавливаем диапазоны с поправкой for min_r, min_c, max_r, max_c in to_readjust: new_min_r = min_r + amount if min_r >= idx else min_r new_max_r = max_r + amount if max_r >= idx else max_r ws.merge_cells( start_row=new_min_r, start_column=min_c, end_row=new_max_r, end_column=max_c, ) def _safe_delete_rows(ws, idx: int, amount: int): """ Удалить `amount` строк начиная с `idx`, корректно сдвинув merge-диапазоны. Диапазоны, целиком попавшие в удаляемый участок, удаляются. Диапазоны, пересекающие границу — обрезаются/сдвигаются. """ end_idx = idx + amount # первая строка ПОСЛЕ удаляемого блока to_remove = [] to_readjust = [] for rng in list(ws.merged_cells.ranges): mn, mc, mx, xc = rng.min_row, rng.min_col, rng.max_row, rng.max_col if mx < idx: # полностью до зоны удаления — ничего не трогаем continue to_remove.append(rng) # нужно снять, а затем, возможно, заново добавить if mn >= end_idx: # полностью после — просто сдвигаем вверх to_readjust.append((mn - amount, mc, mx - amount, xc)) elif mn >= idx and mx < end_idx: # полностью внутри удаляемого — удаляем без восстановления pass elif mn < idx and mx < end_idx: # начинается до, заканчивается внутри — обрезаем конец new_mx = idx - 1 if new_mx >= mn: to_readjust.append((mn, mc, new_mx, xc)) elif mn < idx and mx >= end_idx: # охватывает весь удаляемый блок — уменьшаем to_readjust.append((mn, mc, mx - amount, xc)) else: # начинается внутри, заканчивается после — обрезаем начало new_mn = idx to_readjust.append((new_mn, mc, mx - amount, xc)) for rng in to_remove: ws.merged_cells.remove(rng) ws.delete_rows(idx, amount) for min_r, min_c, max_r, max_c in to_readjust: if min_r <= max_r: ws.merge_cells( start_row=min_r, start_column=min_c, end_row=max_r, end_column=max_c, ) def _copy_row_style(ws, src_row: int, dst_row: int): for col in range(1, ws.max_column + 1): src = ws.cell(row=src_row, column=col) dst = ws.cell(row=dst_row, column=col) if src.has_style: dst.font = copy.copy(src.font) dst.border = copy.copy(src.border) dst.fill = copy.copy(src.fill) dst.number_format = src.number_format dst.alignment = copy.copy(src.alignment) def _insert_coord_rows(ws, coord_data: list, start_row: int, pre_allocated: int) -> int: """Вставить строки координат. Возвращает кол-во добавленных строк (смещение).""" needed = len(coord_data) extra = max(0, needed - pre_allocated) if extra > 0: _safe_insert_rows(ws, start_row + pre_allocated, extra) for i in range(extra): _copy_row_style(ws, start_row, start_row + pre_allocated + i) F = BASE_FONT for i, entry in enumerate(coord_data): row_idx = start_row + i if entry is None: continue num, x_str, y_str, method, mt = entry _sv(ws, row_idx, 1, num, F) _sv(ws, row_idx, 3, x_str, F) _sv(ws, row_idx, 4, y_str, F) _sv(ws, row_idx, 5, method, F) _sv(ws, row_idx, 6, mt, F) return extra def _apply_font_all(ws): """Применить Times New Roman 11 ко всем заполненным ячейкам листа.""" for row in ws.iter_rows(): for cell in row: if cell.value is not None: old = cell.font cell.font = Font( name="Times New Roman", size=11, bold=old.bold if old else False, italic=old.italic if old else False, underline=old.underline if old else None, ) # ─────────────────────────────── Публичная функция ──────────────────────── # def _embed_image(ws, png_bytes: bytes, anchor_row: int, pre_rows: int) -> int: """ Вставить PNG ровно в ОДНУ ячейку (строку) начиная с A{anchor_row}. Стратегия «одна ячейка»: • Все pre_rows строк шаблона, зарезервированных под схему, кроме первой, удаляются (_safe_delete_rows), чтобы осталась единственная объединённая строка A{anchor_row}:F{anchor_row}. • Высота этой строки выставляется равной реальной высоте PNG (pt). • TwoCellAnchor с editAs='twoCell' привязывает изображение точно к этой одной строке — от её верхнего левого угла до нижнего правого. Возвращает изменение числа строк (всегда -(pre_rows-1), т.е. отрицательное). """ from PIL import Image as PILImage pil = PILImage.open(io.BytesIO(png_bytes)) px_w, px_h = pil.size dpi = pil.info.get("dpi", (96, 96)) if not isinstance(dpi, (tuple, list)) or dpi[0] == 0: dpi = (96, 96) pt_w = px_w * 72.0 / dpi[0] pt_h = px_h * 72.0 / dpi[1] # Масштаб по ширине листа (~420 pt для 6 колонок) MAX_PT_W = 420.0 if pt_w > MAX_PT_W: scale = MAX_PT_W / pt_w pt_w = MAX_PT_W pt_h *= scale # Удаляем все строки зоны схемы кроме первой (anchor_row). # Строка anchor_row уже является частью объединённой ячейки в шаблоне; # после удаления нижних строк она станет单独ной строкой высотой pt_h. rows_to_delete = pre_rows - 1 if rows_to_delete > 0: _safe_delete_rows(ws, anchor_row + 1, rows_to_delete) # Задаём высоту единственной оставшейся строки = высоте изображения ws.row_dimensions[anchor_row].height = pt_h # OneCellAnchor: верхний-левый угол прибит к ячейке A{anchor_row}, # размер задан явно в EMU (1 pt = 12700 EMU). # В отличие от TwoCellAnchor, LibreOffice не пересчитывает позицию # через сумму высот строк → картинка не уплывает вверх при склейке форм. img = XlImage(io.BytesIO(png_bytes)) # Установим размер картинки в пикселях с учётом масштабирования scale = 1.0 if pt_w > MAX_PT_W: scale = MAX_PT_W / pt_w img.width = int(px_w * scale) img.height = int(px_h * scale) # Добавляем изображение привязкой к ячейке A{anchor_row} try: ws.add_image(img, f"A{anchor_row}") except Exception: try: cell_anchor = OneCellAnchor() cell_anchor._from = AnchorMarker(col=0, colOff=0, row=anchor_row - 1, rowOff=0) cell_anchor.ext.cx = int(pt_w * 12700) # pt → EMU cell_anchor.ext.cy = int(pt_h * 12700) img.anchor = cell_anchor ws.add_image(img) except Exception: pass return -rows_to_delete # отрицательное смещение: строки были удалены def fill_form(template_path: str, output_path: str, ki: dict, t2_row: tuple, mif_index: dict, zu_scheme: bytes = None, oks_scheme: bytes = None, zu_scale: str = None, oks_scale: str = None): """Заполнить шаблон данными одной строки Таблицы 2 и сохранить файл.""" def col(i, default=None): return t2_row[i] if len(t2_row) > i and t2_row[i] is not None else default zu_file = col(0, "") kv = col(1, "") date_prep = col(2) sogl = col(3, "") loc_zu = col(4, "") area_zu = col(5, "") err_zu = col(6, "") obosn_zu = col(7, "") method_zu = col(8, "") mt_zu = col(9, "") oks_file = col(10, "") loc_oks = col(11, "") obosn_oks = col(12, "") method_oks = col(13, "") mt_oks = col(14, "") wb = load_workbook(template_path) ws = wb.active F = BASE_FONT T = TMPL # Шапка _sv(ws, T["KV"], 1, str(kv), F) # Дата: конвертируем datetime в строку dd.mm.yyyy if isinstance(date_prep, datetime.datetime): date_str = date_prep.strftime("%d.%m.%Y") else: date_str = str(date_prep) if date_prep else "" _sv(ws, T["DATE"], 6, date_str, F) # Раздел 2: КИ _sv(ws, T["FIO"], 6, ki_val(ki, "ФИО"), F) _sv(ws, T["SNILS"], 6, ki_val(ki, "Страховой"), F) _sv(ws, T["PHONE"], 6, ki_val(ki, "Контактный"), F) _sv(ws, T["EMAIL"], 1, ki_val(ki, "Адрес электронной почты"), F) _sv(ws, T["SRO"], 1, ki_val(ki, "Наименование саморегулируемой"), F) _sv(ws, T["REGNR"], 1, ki_val(ki, "Уникальный"), F) # Раздел 3: Основания _sv(ws, T["SOGL"], 1, sogl, F) # Раздел 4: ЗУ _sv(ws, T["LOC_ZU"], 6, loc_zu, F) area_str = f"{area_zu}±{err_zu}" if (area_zu or err_zu) else "" _sv(ws, T["AREA_ZU"], 6, area_str, F) _sv(ws, T["OBOSN_ZU"], 6, obosn_zu, F) # Раздел 5: координаты ЗУ zu_path = mif_index.get(str(zu_file).strip()) zu_contours = get_contours(zu_path) if zu_path else [] zu_coord_data = build_coord_rows(zu_contours, str(method_zu), mt_zu) zu_offset = _insert_coord_rows(ws, zu_coord_data, T["ZU_COORD_START"], T["ZU_COORD_PRE"]) # Раздел 6 (схема ЗУ): # r43+offset = изображение (ZU_SCHEME_PRE=1, строки не удаляются) # r44+offset = масштаб (c1) if zu_scheme: scheme_anchor = T["ZU_SCHEME_START"] + zu_offset + 1 _embed_image(ws, zu_scheme, scheme_anchor, T["ZU_SCHEME_PRE"]) if zu_scale: _sv(ws, scheme_anchor + 1, 1, zu_scale, F) # Разделы 7-9: ОКС (смещение только от доп. строк координат ЗУ; схема не изменяет кол-во строк) ofs = zu_offset _sv(ws, T["LOC_OKS"] + ofs, 6, loc_oks or "-", F) _sv(ws, T["OBOSN_OKS"] + ofs, 6, obosn_oks or "-", F) oks_path = mif_index.get(str(oks_file).strip()) if oks_file else None oks_contours = get_contours(oks_path) if oks_path else [] oks_coord_data = build_coord_rows(oks_contours, str(method_oks or ""), mt_oks) oks_offset = 0 if oks_coord_data: oks_offset = _insert_coord_rows(ws, oks_coord_data, T["OKS_COORD_START"] + ofs, T["OKS_COORD_PRE"]) # Раздел 9: схема ОКС (из Таблицы3 по Table2 столбец K «Название файла ОКС») # r64+ofs+oks_offset = изображение, r65+ofs+oks_offset = масштаб if oks_scheme: oks_scheme_anchor = T["OKS_SCHEME_START"] + ofs + oks_offset + 1 _embed_image(ws, oks_scheme, oks_scheme_anchor, T["OKS_SCHEME_PRE"]) if oks_scale: _sv(ws, oks_scheme_anchor + 1, 1, oks_scale, F) _apply_font_all(ws) wb.save(output_path)