"""Check generated content and export contact sheets for visual review.""" from pathlib import Path from collections import Counter import json import re import unicodedata import argparse import zipfile import pymupdf import openpyxl from PIL import Image, ImageDraw from pptx import Presentation ROOT=Path(__file__).resolve().parents[1] parser=argparse.ArgumentParser() parser.add_argument('--recalculated-workbook',type=Path,help='Optional independent LibreOffice/Excel recalculation copy') args=parser.parse_args() OUT=ROOT/'materials' PREV=OUT/'preview' PREV.mkdir(exist_ok=True) p=Presentation(OUT/'excel-ai-lecture.pptx') assert len(p.slides)==26 manifest=json.loads((OUT/'slides-manifest.json').read_text(encoding='utf-8')) assert sum(s['seconds'] for s in manifest)==3600 notes=(OUT/'speaker-notes.md').read_text(encoding='utf-8') durations=[int(v) for v in re.findall(r'\*\*Длительность:\*\* (\d+) секунд',notes)] assert durations==[s['seconds'] for s in manifest],durations assert all(s.notes_slide.notes_text_frame.text.strip() for s in p.slides) for n,s in enumerate(p.slides,1): for sh in s.shapes: assert sh.left>=0 and sh.top>=0,(n,sh.name,'negative position') assert sh.left+sh.width<=p.slide_width+10000,(n,sh.name,'too wide') assert sh.top+sh.height<=p.slide_height+10000,(n,sh.name,'too tall') book=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=True) rows=list(book['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True)) assert len(rows)==60 and len({r[0] for r in rows})==60 assert all(all(v is not None for v in r[:6]) for r in rows) assert Counter(r[7] for r in rows)=={'Завершено':24,'Просрочено':18,'В срок':18} assert sum(r[5]=='Выполнено' and r[6]>r[4] for r in rows)==12 assert all(r[6] is None or r[6]<=book['Параметры']['B2'].value for r in rows) assert book['Проверка']['B6'].value==.5 assert len(book['Реестр'].tables)==1 assert len(book['Старт'].tables)==0 and len(book['Старт 2'].tables)==0 # Teaching progression: simple IF on Старт, nested IF on Старт 2, identical to Реестр. simple=[book['Старт'].cell(r,8).value for r in range(2,62)] nested=[book['Старт 2'].cell(r,8).value for r in range(2,62)] assert simple==['Закрыто' if r[5]=='Выполнено' else 'В работе' for r in rows] assert nested==[r[7] for r in rows] formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False) assert all(formulas['Реестр'].cell(r,8).data_type=='f' for r in range(2,62)) assert all(formulas['Старт'].cell(r,8).data_type=='f' for r in range(2,62)) assert all(formulas['Старт 2'].cell(r,8).data_type=='f' for r in range(2,62)) assert 'IF(F2="Выполнено","Закрыто","В работе")' in formulas['Старт'].cell(2,8).value assert formulas['Старт 2'].cell(2,8).value.count('IF(')==2 # Dropdowns: every editable field is a pick list backed by a named range on Параметры. statuses=[book['Параметры'].cell(r,1).value for r in range(5,8)] depts=[book['Параметры'].cell(r,1).value for r in range(10,15)] cats=[book['Параметры'].cell(r,1).value for r in range(17,20)] expected_dv={('Старт','B'):'AllowedDepartments',('Старт','C'):'AllowedCategories',('Старт','F'):'AllowedStatuses', ('Старт 2','B'):'AllowedDepartments',('Старт 2','C'):'AllowedCategories',('Старт 2','F'):'AllowedStatuses', ('Реестр','B'):'AllowedDepartments',('Реестр','C'):'AllowedCategories',('Реестр','F'):'AllowedStatuses'} for (sheet,column),name in expected_dv.items(): found=[dv for dv in formulas[sheet].data_validations.dataValidation if f'{column}2:{column}1000'==str(dv.sqref)] assert found,(sheet,column,'нет выпадающего списка') assert found[0].type=='list' and found[0].formula1==name,(sheet,column,found[0].formula1) assert all(r[1] in depts for r in rows),( 'подразделение вне списка') assert all(r[2] in cats for r in rows),'категория вне списка' assert all(r[5] in statuses for r in rows),'статус вне списка' # Формульный отчёт на листе «Эталон отчёта» (с 15-й строки) должен сходиться со статическим эталоном. rep=book['Эталон отчёта'] static={rep.cell(r,1).value:(rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value,rep.cell(r,5).value) for r in range(4,9)} assert len(static)==5 for r in range(17,22): dept=rep.cell(r,1).value done,late,soon,total=static[dept] assert (rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value)==(total,done,late),(dept,'отчёт на формулах') assert rep.cell(r,5).value==soon,(dept,'в срок') assert abs(rep.cell(r,6).value-late/(late+soon))<1e-9,(dept,'доля просроченных среди открытых') assert (rep.cell(22,2).value,rep.cell(22,3).value,rep.cell(22,4).value,rep.cell(22,5).value)==(60,24,18,18) assert abs(rep.cell(22,6).value-.5)<1e-9 assert rep.cell(24,2).value=='Совпадает' and rep.cell(25,2).value=='Совпадает' report_formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False)['Эталон отчёта'] assert all(report_formulas.cell(r,2).data_type=='f' for r in list(range(17,23))+[24,25]) assert 'COUNTIFS' in report_formulas.cell(17,3).value and 'Реестр' in report_formulas.cell(17,3).value # Лист с диаграммами включается флагом --with-chart-sheet. Проверяем его только если он есть. chart_sheet=book['Диаграмма'] if 'Диаграмма' in book.sheetnames else None if chart_sheet is None: chart_status='лист «Диаграмма» не включён (ожидаемо: диаграммы строятся на лекции вживую)' with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive: assert not [n for n in archive.namelist() if n.startswith('xl/charts/')],'найдены диаграммы без листа' else: for n,dept in enumerate(static): r=2+n done,late,soon,total=static[dept] assert chart_sheet.cell(r,1).value==dept assert (chart_sheet.cell(r,2).value,chart_sheet.cell(r,3).value,chart_sheet.cell(r,4).value)==(done,late,soon),(dept,'блок диаграммы') assert (chart_sheet.cell(7,2).value,chart_sheet.cell(7,3).value,chart_sheet.cell(7,4).value)==(24,18,18) with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive: chart_parts=[n for n in archive.namelist() if n.startswith('xl/charts/chart') and n.endswith('.xml')] assert len(chart_parts)==2,chart_parts blob=''.join(archive.read(n).decode('utf-8') for n in chart_parts) assert 'Диаграмма!' in blob and 'Реестр!' not in blob,'диаграммы должны ссылаться на лист Диаграмма' assert 'Диаграмма!$A$2:$A$6' in blob,'нет категорий по подразделениям' assert 'Диаграмма!$B$7:$D$7' in blob,'круговая диаграмма должна брать строку ИТОГО' chart_status='лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном' for r in rows: expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]page.rect.width+1 or y1>page.rect.height+1: warnings.append(f'Slide {i+1}: text exceeds page: {span["text"]}') page.get_pixmap(matrix=pymupdf.Matrix(1.4,1.4)).save(PREV/f'slide-{i+1:02}.png') for start in range(0,26,8): ids=list(range(start,min(start+8,26))) sheet=Image.new('RGB',(1200,360*((len(ids)+1)//2)),'#dedee8') for k,i in enumerate(ids): pm=pdf[i].get_pixmap(matrix=pymupdf.Matrix(.625,.625)) im=Image.frombytes('RGB',(pm.width,pm.height),pm.samples) x=(k%2)*600;y=(k//2)*360 sheet.paste(im,(x,y)) ImageDraw.Draw(sheet).text((x+12,y+339),f'SLIDE {i+1:02}',fill='black') sheet.save(PREV/f'contact-{start//8+1}.png') try: import zxingcpp result=zxingcpp.read_barcode(Image.open(OUT/'telegram-qr.png')) assert result and result.text=='https://t.me/kirill_bouko_it' # Verify QR survives PDF export, not only its standalone source image. result=zxingcpp.read_barcode(Image.open(PREV/'slide-25.png')) assert result and result.text=='https://t.me/kirill_bouko_it' qr_status='decoded from PNG and PDF slide 25' except ImportError: qr_status='decoder unavailable; image generated from exact URL' report={ 'slides':26,'total_seconds':3600,'notes_embedded':True,'pdf_pages':len(pdf), 'synthetic_rows':60,'completed':24,'open':36,'overdue_open':18,'overdue_share_open':.5, 'qr':qr_status,'render_text_warnings':warnings,'chart_sheet':chart_status, 'recalculated_workbook':recalc_status, } (OUT/'validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') print(json.dumps(report,ensure_ascii=False,indent=2)) assert not warnings,'Inspect PDF text warnings before delivering'