""" ReportReti — generazione Excel + PDF. Excel: 3 fogli (Germania, Spagna, Portogallo) con openpyxl. PDF: copertina per paese + schede titolo con poster, fpdf2. """ import logging import tempfile import os import urllib.request from datetime import date from pathlib import Path from typing import Optional import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter logger = logging.getLogger(__name__) # ── Costanti V_* ───────────────────────────────────────────────────────────── V_LABELS = ['RDA','C5','I1','R4','LA5','I2','IRIS','TOP','FOC','C20','CI34','C27','INF'] _VAL_FILL_HEX = { 'NO': 'EF9A9A', 'PO': 'FFF3CC', 'SS': 'FFD28A', 'MA': 'E0E0E0', 'PT': 'A5D6A7', 'VV': '81C784', 'DT': '90CAF9', } _VAL_FILL_RGB = { 'NO': (239, 154, 154), 'PO': (255, 243, 204), 'SS': (255, 210, 138), 'MA': (224, 224, 224), 'PT': (165, 214, 167), 'VV': (129, 199, 132), 'DT': (144, 202, 249), } _WHITE_RGB = (255, 255, 255) PAESE_ORDER = ['Germania', 'Spagna', 'Portogallo'] _PAESE_COLOR = { 'Germania': (30, 80, 160), 'Spagna': (160, 20, 20), 'Portogallo': (0, 110, 60), } # ═══════════════════════════════════════════════════════════════════════════════ # EXCEL # ═══════════════════════════════════════════════════════════════════════════════ # Grid order: INFO(6) → V_*(13) → EMESSO → ACQ/VEND _XL_INFO_COLS = [ ('PAESE', 10), ('COD IMDB', 14), ('TITOLO', 36), ('REGIA/CAST', 30), ('DATA', 10), ('TIPOLOGIA', 14), ] _XL_VAL_COLS = [('RDA', 18)] + [(lbl, 6) for lbl in V_LABELS[1:]] _XL_EXTRA_COLS = [ ('EMESSO', 22), ('ACQ/VEND', 12), ] _XL_COLS = _XL_INFO_COLS + _XL_VAL_COLS + _XL_EXTRA_COLS _N_INFO = len(_XL_INFO_COLS) # 6 _COL_VAL_START = _N_INFO + 1 # 7 _COL_EXTRA_START = _N_INFO + len(_XL_VAL_COLS) + 1 # 20 _SIDE = Side(style='thin', color='C0C0C0') _BORDER = Border(left=_SIDE, right=_SIDE, top=_SIDE, bottom=_SIDE) _HDR_FONT = Font(name='Calibri', size=9, bold=True) _CELL_FONT = Font(name='Calibri', size=9) _FILL_HDR_INFO = PatternFill('solid', fgColor='BDD7EE') # blue — info cols _FILL_HDR_VAL = PatternFill('solid', fgColor='C8B0E0') # purple — val cols _FILL_ROW_RED = PatternFill('solid', fgColor='FFCDD2') # concorrenza / venduto _FILL_ROW_GREEN = PatternFill('solid', fgColor='C8E6C9') # acquistato _ALIGN_CENTER = Alignment(horizontal='center', vertical='top') _ALIGN_LEFT = Alignment(horizontal='left', vertical='top', wrap_text=True) _ALIGN_HDR = Alignment(horizontal='center', vertical='center', wrap_text=True) # 1-based col indices with wrap_text (TITOLO=3, REGIA/CAST=4, EMESSO=20) _WRAP_COLS = {3, 4, 20} def _fmt_date_xl(d: str) -> str: """YYYY-MM-DD → dd-mm-yyyy.""" if not d: return '' parts = d.split('-') return f'{parts[2]}-{parts[1]}-{parts[0]}' if len(parts) == 3 else d def _fmt_ora_xl(v) -> str: """HHMMSS int → HH:MM string.""" if v is None: return '' s = str(int(v)).zfill(6) return s[:2] + ':' + s[2:4] def _val_code(val) -> str: """'PO G' → 'PO', 'VV MA' → 'MA' (mattina/residuale), '' → ''.""" raw = str(val or '').strip().upper() if not raw: return '' words = raw.split() return 'MA' if 'MA' in words else words[0] def _xl_val_fill(val) -> Optional[PatternFill]: h = _VAL_FILL_HEX.get(_val_code(val)) return PatternFill('solid', fgColor=h) if h else None def _title_to_row(t: dict, paese: str) -> list: o = t.get('omdb', {}) s = lambda x: str(x or '').strip() r = s(t.get('regia_gem')) or s(o.get('registi')) c = s(t.get('cast_gem')) or s(o.get('attori')) regia_cast = '\n'.join(filter(None, [ f'R: {r}' if r else None, f'C: {c}' if c else None, ])) em_line1 = ' · '.join(filter(None, [ t.get('em_rete', ''), _fmt_date_xl(t.get('em_data', '')), ])) em_line2 = ' · '.join(filter(None, [ _fmt_ora_xl(t.get('em_ora')), t.get('em_fascia', ''), f"a:{t['em_audience']}" if t.get('em_audience') is not None else '', f"s:{t['em_share']:.1f}%" if t.get('em_share') is not None else '', ])) emesso_str = '\n'.join(filter(None, [em_line1, em_line2])) info = [ paese, s(t.get('cod_imdb')), s(t.get('titolo')), regia_cast, s(t.get('data_gem')), s(t.get('tipologia_gem')), ] vals = [s(t.get(f'V_{lbl}')) for lbl in V_LABELS] av = s(t.get('acq_vend')) if not av and t.get('em_concorrenza'): av = 'Concorrenza' extra = [emesso_str, av] return info + vals + extra def _write_xl_sheet(wb: openpyxl.Workbook, paese: str, titles: list): ws = wb.create_sheet(title=paese) ws.freeze_panes = 'A2' ws.row_dimensions[1].height = 28 for ci, (hdr, w) in enumerate(_XL_COLS, 1): cell = ws.cell(row=1, column=ci, value=hdr) cell.font = _HDR_FONT cell.alignment = _ALIGN_HDR cell.border = _BORDER is_val = _COL_VAL_START <= ci < _COL_EXTRA_START cell.fill = _FILL_HDR_VAL if is_val else _FILL_HDR_INFO ws.column_dimensions[get_column_letter(ci)].width = w sorted_titles = sorted(titles, key=lambda t: t.get('titolo', '').lower()) for ri, t in enumerate(sorted_titles, 2): ws.row_dimensions[ri].height = 40 av = (t.get('acq_vend') or '').lower() row_fill = ( _FILL_ROW_RED if t.get('em_concorrenza') or av == 'venduto' else _FILL_ROW_GREEN if av == 'acquistato' else None ) row_data = _title_to_row(t, paese) for ci, val in enumerate(row_data, 1): cell = ws.cell(row=ri, column=ci, value=val) cell.font = _CELL_FONT cell.border = _BORDER is_val = _COL_VAL_START <= ci < _COL_EXTRA_START if is_val: cell.alignment = _ALIGN_CENTER vf = _xl_val_fill(val) if vf: cell.fill = vf elif row_fill: cell.fill = row_fill else: cell.alignment = _ALIGN_LEFT if ci in _WRAP_COLS else _ALIGN_CENTER if row_fill: cell.fill = row_fill if ci == 2 and val: cell.hyperlink = f'https://www.imdb.com/title/{val}/' cell.font = Font(name='Calibri', size=9, color='0563C1', underline='single') ws.auto_filter.ref = ws.dimensions def generate_excel(data: dict, output_path: Path): wb = openpyxl.Workbook() if wb.active: wb.remove(wb.active) any_sheet = False for paese in PAESE_ORDER: titles = data.get(paese, []) if titles: _write_xl_sheet(wb, paese, titles) any_sheet = True if not any_sheet: ws = wb.create_sheet('Nessun dato') ws['A1'] = 'Nessun titolo trovato nel periodo selezionato.' wb.save(str(output_path)) logger.info(f'[SisterCo] Excel: {output_path}') # ═══════════════════════════════════════════════════════════════════════════════ # PDF # ═══════════════════════════════════════════════════════════════════════════════ _WIN_FONTS = Path(r'C:\Windows\Fonts') _PDF_FONT_CANDIDATES = [ ('calibri.ttf', 'calibrib.ttf', 'calibrii.ttf', 'Calibri'), ('arial.ttf', 'arialbd.ttf', 'ariali.ttf', 'Arial'), ('segoeui.ttf', 'segoeuib.ttf', 'segoeuii.ttf', 'Segoe UI'), ] def _resolve_pdf_font() -> str: """Return the name of the first available Unicode Windows font.""" for reg, _b, _i, family in _PDF_FONT_CANDIDATES: if (_WIN_FONTS / reg).exists(): return family return 'Helvetica' def _load_pdf_fonts(pdf, family: str): """Add TTF font files for *family* into a fpdf2 instance.""" if family == 'Helvetica': return # built-in, no files needed for reg, bold, italic, name in _PDF_FONT_CANDIDATES: if name == family: rf = _WIN_FONTS / reg bf = _WIN_FONTS / bold itf = _WIN_FONTS / italic if rf.exists(): pdf.add_font(family, '', str(rf)) if bf.exists(): pdf.add_font(family, 'B', str(bf)) if itf.exists(): pdf.add_font(family, 'I', str(itf)) return _POSTER_W = 42.0 # mm _POSTER_H = 62.0 # mm _PAGE_W = 210.0 _PAGE_H = 297.0 _MARGIN = 14.0 _CONTENT_W = _PAGE_W - 2 * _MARGIN # 182 mm def _download_poster(url: str, tmpdir: str) -> Optional[str]: if not url: return None try: fname = os.path.join(tmpdir, url.split('/')[-1].split('?')[0]) if not fname.lower().endswith(('.jpg', '.jpeg', '.png')): fname += '.jpg' urllib.request.urlretrieve(url, fname) return fname except Exception as e: logger.debug(f'[SisterCo] poster download failed ({url}): {e}') return None def generate_pdf(data: dict, output_path: Path, date_from: date, date_to: date): from fpdf import FPDF, XPos, YPos _font = _resolve_pdf_font() class _PDF(FPDF): def footer(self): self.set_y(-11) self.set_font(_font, 'I', 7) self.set_text_color(160, 160, 160) self.cell(0, 8, f'MyICR Suite - SisterCo MFE - pag. {self.page_no()}', align='C') pdf = _PDF(orientation='P', unit='mm', format='A4') pdf.set_margins(_MARGIN, _MARGIN, _MARGIN) pdf.set_auto_page_break(auto=True, margin=14) pdf.set_creator('MyICR Suite') pdf.set_title('SisterCo MFE') _load_pdf_fonts(pdf, _font) periodo = f"{date_from.strftime('%d/%m/%Y')} - {date_to.strftime('%d/%m/%Y')}" with tempfile.TemporaryDirectory() as tmpdir: # Pre-download posters poster_cache: dict[str, Optional[str]] = {} for paese in PAESE_ORDER: for t in data.get(paese, []): url = (t.get('omdb') or {}).get('url_poster', '') if url and url not in poster_cache: poster_cache[url] = _download_poster(url, tmpdir) # Render pages for paese in PAESE_ORDER: titles = data.get(paese, []) if not titles: continue # ── Cover page per paese ───────────────────────────────────── pdf.add_page() r, g, b = _PAESE_COLOR.get(paese, (50, 50, 100)) pdf.set_fill_color(r, g, b) pdf.rect(0, 0, _PAGE_W, _PAGE_H, 'F') pdf.set_text_color(255, 255, 255) pdf.set_y(90) pdf.set_font(_font, 'B', 42) pdf.cell(0, 20, paese, align='C', new_x=XPos.LEFT, new_y=YPos.NEXT) pdf.set_font(_font, '', 16) pdf.cell(0, 12, f'{len(titles)} titoli', align='C', new_x=XPos.LEFT, new_y=YPos.NEXT) pdf.set_font(_font, '', 11) pdf.cell(0, 8, periodo, align='C', new_x=XPos.LEFT, new_y=YPos.NEXT) # ── Schede titolo ──────────────────────────────────────────── for t in titles: pdf.add_page() omdb = t.get('omdb', {}) s = lambda x: str(x or '').strip() # Header band pdf.set_fill_color(235, 240, 248) pdf.rect(0, 8, _PAGE_W, 20, 'F') # Titolo pdf.set_text_color(15, 35, 80) pdf.set_font(_font, 'B', 13) pdf.set_xy(_MARGIN, 11) tit = s(t.get('titolo'))[:90] pdf.cell(_CONTENT_W - 45, 7, tit, new_x=XPos.RIGHT, new_y=YPos.TOP) # Distributore + data (top right) pdf.set_font(_font, '', 7.5) pdf.set_text_color(100, 100, 100) pdf.set_xy(_PAGE_W - _MARGIN - 45, 11) pdf.cell(45, 5, s(t.get('distributore')), align='R', new_x=XPos.LEFT, new_y=YPos.NEXT) pdf.set_x(_PAGE_W - _MARGIN - 45) pdf.cell(45, 5, s(t.get('data_gem')), align='R') y_body = 32.0 poster_url = s(omdb.get('url_poster')) poster_path = poster_cache.get(poster_url) if poster_url else None has_poster = bool(poster_path and os.path.exists(poster_path)) if has_poster: try: pdf.image(poster_path, x=_MARGIN, y=y_body, w=_POSTER_W, h=_POSTER_H) except Exception as e: logger.debug(f'[SisterCo] image insert failed: {e}') has_poster = False x_det = _MARGIN + (_POSTER_W + 4 if has_poster else 0) w_det = _CONTENT_W - (_POSTER_W + 4 if has_poster else 0) def _detail(label: str, val: str, bold_val: bool = False): val = val.strip() if not val: return pdf.set_x(x_det) pdf.set_font(_font, 'B', 8) pdf.set_text_color(60, 60, 60) lw = 18.0 # Save cursor before multi_cell y_before = pdf.get_y() pdf.cell(lw, 5, label + ':', new_x=XPos.RIGHT, new_y=YPos.TOP) pdf.set_font(_font, 'B' if bold_val else '', 8) pdf.set_text_color(20, 20, 20) pdf.multi_cell(w_det - lw, 5, val[:260], new_x=XPos.LEFT, new_y=YPos.NEXT) pdf.set_xy(x_det, y_body) anno = s(omdb.get('anno_produzione')) durata = s(omdb.get('durata')) _detail('Anno/Dur', ' · '.join(filter(None, [anno, durata]))) _detail('Generi', s(omdb.get('generi'))) _detail('Regia', s(omdb.get('registi')) or s(t.get('regia_gem'))) _detail('Attori', s(omdb.get('attori'))) imdb = s(omdb.get('imdb_rating')) if imdb: _detail('IMDB', f'* {imdb}', bold_val=True) premi = s(omdb.get('premi')) if premi: _detail('Premi', premi[:160]) # Synopsis — starts below poster or below detail block y_after_detail = pdf.get_y() y_syn = max(y_after_detail, y_body + _POSTER_H + 3.0) + 4.0 sin = s(omdb.get('sinossi')) if sin: if pdf.get_y() < y_syn: pdf.set_y(y_syn) pdf.set_x(_MARGIN) pdf.set_font(_font, 'I', 8) pdf.set_text_color(70, 70, 70) pdf.multi_cell(_CONTENT_W, 4.5, sin[:600], new_x=XPos.LEFT, new_y=YPos.NEXT) # ── Valutazioni grid ───────────────────────────────────── y_val = pdf.get_y() + 5.0 if y_val > 256: pdf.add_page() y_val = 20.0 cell_w = round(_CONTENT_W / len(V_LABELS), 2) # ~14 mm # Label row pdf.set_xy(_MARGIN, y_val) pdf.set_font(_font, 'B', 7) pdf.set_text_color(50, 50, 50) pdf.set_fill_color(225, 230, 240) for lbl in V_LABELS: pdf.cell(cell_w, 5, lbl, align='C', border=1, fill=True, new_x=XPos.RIGHT, new_y=YPos.TOP) pdf.ln(5) # Value row pdf.set_xy(_MARGIN, pdf.get_y()) pdf.set_font(_font, 'B', 8) for lbl in V_LABELS: val = s(t.get(f'V_{lbl}')) rr, gg, bb = _VAL_FILL_RGB.get(_val_code(val), _WHITE_RGB) pdf.set_fill_color(rr, gg, bb) pdf.set_text_color(30, 30, 30) display = val if val not in ('!', '') else '' pdf.cell(cell_w, 7, display, align='C', border=1, fill=True, new_x=XPos.RIGHT, new_y=YPos.TOP) pdf.output(str(output_path)) logger.info(f'[SisterCo] PDF: {output_path}') # ═══════════════════════════════════════════════════════════════════════════════ # SCHEDA PDF — single title, from edited form data # ═══════════════════════════════════════════════════════════════════════════════ def generate_scheda_pdf(scheda: dict, output_path: Path): """ Generate a single A4 PDF from editor form data. scheda keys: titolo, anno, durata, generi, regia, attori, imdb_rating, premi, sinossi, distributore, paese, data_gem, valutazioni {label: value}, poster_path, poster_url """ from fpdf import FPDF _font = _resolve_pdf_font() class _PDF(FPDF): def footer(self): self.set_y(-11) self.set_font(_font, 'I', 7) self.set_text_color(160, 160, 160) self.cell(0, 8, f'MyICR Suite - SisterCo MFE', align='C') pdf = _PDF(orientation='P', unit='mm', format='A4') pdf.set_margins(_MARGIN, _MARGIN, _MARGIN) pdf.set_auto_page_break(auto=True, margin=14) _load_pdf_fonts(pdf, _font) pdf.add_page() s = lambda x: str(x or '').strip() # ── Resolve poster ─────────────────────────────────────────────────────── poster_file: Optional[str] = None _tmpdir_obj = None poster_path = s(scheda.get('poster_path')) poster_url = s(scheda.get('poster_url')) if poster_path and os.path.exists(poster_path): poster_file = poster_path elif poster_url: import tempfile as _tmp _tmpdir_obj = _tmp.TemporaryDirectory() poster_file = _download_poster(poster_url, _tmpdir_obj.name) if poster_file and not os.path.exists(poster_file): poster_file = None try: _render_scheda_page(pdf, scheda, poster_file, _font) pdf.output(str(output_path)) logger.info(f'[SisterCo] Scheda PDF: {output_path}') finally: if _tmpdir_obj is not None: _tmpdir_obj.cleanup() def _render_scheda_page(pdf, scheda: dict, poster_file: Optional[str], font: str): """Render one scheda styled like the HTML scheda page.""" from fpdf import XPos, YPos # noqa: PLC0415 from app.modules.report_reti.report_db import V_LABELS as _V_LABELS paese = scheda.get('paese', '') s = lambda x: str(x or '').strip() pr, pg, pb = _PAESE_COLOR.get(paese, (29, 111, 164)) M = _MARGIN CW = _CONTENT_W # ── White page background ───────────────────────────────────────────────── pdf.set_fill_color(255, 255, 255) pdf.rect(0, 0, _PAGE_W, _PAGE_H, 'F') # ── Toolbar ─────────────────────────────────────────────────────────────── TB_H = 22.0 pdf.set_fill_color(29, 111, 164) pdf.rect(0, 0, _PAGE_W, TB_H, 'F') badge_w = 36.0 title_w = CW - (badge_w + 4 if paese else 0) # Paese badge if paese: pdf.set_fill_color(pr, pg, pb) pdf.rect(_PAGE_W - M - badge_w, 4, badge_w, 14, 'F') pdf.set_font(font, 'B', 8) pdf.set_text_color(255, 255, 255) pdf.set_xy(_PAGE_W - M - badge_w, 9) pdf.cell(badge_w, 5, paese, align='C') # Title pdf.set_text_color(255, 255, 255) pdf.set_font(font, 'B', 12) pdf.set_xy(M, 4) pdf.cell(title_w, 7, s(scheda.get('titolo'))[:80]) # Subtitle: distributore · data sub = ' · '.join(filter(None, [s(scheda.get('distributore')), s(scheda.get('data_gem'))])) pdf.set_font(font, '', 7) pdf.set_text_color(180, 220, 245) pdf.set_xy(M, 13) pdf.cell(title_w, 5, sub) # ── Section helpers ─────────────────────────────────────────────────────── def _section(title: str, y: float) -> float: """Draw section header bar. Returns y of content start.""" pdf.set_fill_color(248, 250, 252) pdf.rect(M, y, CW, 8, 'F') pdf.set_draw_color(226, 232, 240) pdf.set_line_width(0.2) pdf.rect(M, y, CW, 8) pdf.set_fill_color(29, 111, 164) pdf.rect(M, y, 2.5, 8, 'F') pdf.set_font(font, 'B', 7.5) pdf.set_text_color(100, 116, 139) pdf.set_xy(M + 5, y + 1.5) pdf.cell(CW - 5, 5, title.upper()) return y + 10 def _lbl(text: str, x: float, w: float): pdf.set_xy(x, pdf.get_y()) pdf.set_font(font, 'B', 6.5) pdf.set_text_color(100, 116, 139) pdf.cell(w, 3.5, text.upper(), new_x=XPos.LEFT, new_y=YPos.NEXT) def _val(text: str, x: float, w: float, bold: bool = False, sz: float = 9.0): if not text.strip(): return pdf.set_x(x) pdf.set_font(font, 'B' if bold else '', sz) pdf.set_text_color(30, 41, 59) pdf.multi_cell(w, 4.8, text[:260], new_x=XPos.LEFT, new_y=YPos.NEXT) def _field(label: str, text: str, x: float, w: float, bold: bool = False, sz: float = 9.0): if not text.strip(): return _lbl(label, x, w) _val(text, x, w, bold=bold, sz=sz) pdf.ln(2) # ───────────────────────────────────────────────────────────────────────── # SECTION 1 — Informazioni Principali # ───────────────────────────────────────────────────────────────────────── y = TB_H + 4 y = _section('Informazioni Principali', y) PCOL = _POSTER_W + 5 # poster column width x_f = M + PCOL # fields x w_f = CW - PCOL # fields width half = (w_f - 4) / 2 # Poster y0 = y if poster_file: try: pdf.image(poster_file, x=M, y=y0, w=_POSTER_W, h=_POSTER_H) except Exception as e: logger.debug(f'[Scheda] poster: {e}') pdf.set_xy(x_f, y0) # Anno / Durata — 2 columns anno = s(scheda.get('anno')) durata = s(scheda.get('durata')) if anno or durata: y_save = pdf.get_y() if anno: pdf.set_xy(x_f, y_save) pdf.set_font(font, 'B', 6.5); pdf.set_text_color(100, 116, 139) pdf.cell(half, 3.5, 'ANNO PRODUZIONE') if durata: pdf.set_xy(x_f + half + 4, y_save) pdf.set_font(font, 'B', 6.5); pdf.set_text_color(100, 116, 139) pdf.cell(half, 3.5, 'DURATA') y_v = y_save + 3.5 if anno: pdf.set_xy(x_f, y_v) pdf.set_font(font, '', 9); pdf.set_text_color(30, 41, 59) pdf.cell(half, 5, anno) if durata: pdf.set_xy(x_f + half + 4, y_v) pdf.set_font(font, '', 9); pdf.set_text_color(30, 41, 59) pdf.cell(half, 5, durata) pdf.set_xy(x_f, y_v + 6.5) pdf.ln(2) _field('Generi', s(scheda.get('generi')), x_f, w_f) _field('Regia', s(scheda.get('regia')), x_f, w_f) _field('Attori principali', s(scheda.get('attori')), x_f, w_f) imdb = s(scheda.get('imdb_rating')) if imdb: _field('Rating IMDB', f'★ {imdb} / 10', x_f, w_f, bold=True, sz=10.0) premi = s(scheda.get('premi')) if premi: _field('Premi', premi[:180], x_f, w_f) canale = s(scheda.get('canale_piattaforma')) if canale: _field('Canale / Piattaforma', canale[:120], x_f, w_f) y_after_fields = pdf.get_y() y_after_poster = y0 + _POSTER_H + 3 y = max(y_after_fields, y_after_poster) + 5 # ───────────────────────────────────────────────────────────────────────── # SECTION 2 — Sinossi # ───────────────────────────────────────────────────────────────────────── sin = s(scheda.get('sinossi')) if sin: y = _section('Sinossi', y) pdf.set_xy(M, y) pdf.set_font(font, 'I', 8.5) pdf.set_text_color(70, 80, 100) pdf.multi_cell(CW, 4.8, sin[:700], new_x=XPos.LEFT, new_y=YPos.NEXT) y = pdf.get_y() + 5 # ───────────────────────────────────────────────────────────────────────── # SECTION 3 — Valutazioni reti # ───────────────────────────────────────────────────────────────────────── valutazioni = scheda.get('valutazioni', {}) if y > 248: pdf.add_page() pdf.set_fill_color(255, 255, 255) pdf.rect(0, 0, _PAGE_W, _PAGE_H, 'F') y = 16.0 y = _section('Valutazioni reti', y) cell_w = round(CW / len(_V_LABELS), 2) # Labels row pdf.set_xy(M, y) pdf.set_font(font, 'B', 7) pdf.set_fill_color(248, 250, 252) for lbl in _V_LABELS: pdf.set_text_color(100, 116, 139) pdf.cell(cell_w, 5.5, lbl, align='C', border=1, fill=True, new_x=XPos.RIGHT, new_y=YPos.TOP) pdf.ln(5.5) # Values row (colored pills) — 7pt to avoid overflow on long codes like 'VV MA' VAL_ROW_H = 7.0 pdf.set_xy(M, pdf.get_y()) pdf.set_font(font, 'B', 7) for lbl in _V_LABELS: v = s(valutazioni.get(lbl, '')) rr, gg, bb = _VAL_FILL_RGB.get(_val_code(v), _WHITE_RGB) pdf.set_fill_color(rr, gg, bb) pdf.set_text_color(30, 30, 30) display = v if v not in ('!', '') else '' pdf.cell(cell_w, VAL_ROW_H, display, align='C', border=1, fill=True, new_x=XPos.RIGHT, new_y=YPos.TOP) # ───────────────────────────────────────────────────────────────────────── # SECTION 4 — Note # ───────────────────────────────────────────────────────────────────────── note = s(scheda.get('note', '')) if note: # get_y() is at TOP of val row (YPos.TOP was used) — advance past it + gap y = pdf.get_y() + VAL_ROW_H + 8 if y > 248: pdf.add_page() pdf.set_fill_color(255, 255, 255) pdf.rect(0, 0, _PAGE_W, _PAGE_H, 'F') y = 16.0 y = _section('Note', y) pdf.set_xy(M, y) pdf.set_font(font, '', 8.5) pdf.set_text_color(30, 41, 59) pdf.multi_cell(CW, 4.8, note[:1500], new_x=XPos.LEFT, new_y=YPos.NEXT)