114 lines
5.5 KiB
Python
114 lines
5.5 KiB
Python
"""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 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
|
||
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))
|
||
for r in rows:
|
||
expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]<book['Параметры']['B2'].value else 'В срок')
|
||
assert expected==r[7]
|
||
assert all(book['Тесты'].cell(n,4).value==book['Тесты'].cell(n,5).value for n in range(2,6))
|
||
recalc_status='not performed in this check'
|
||
if args.recalculated_workbook:
|
||
recalculated=openpyxl.load_workbook(args.recalculated_workbook,data_only=True)
|
||
assert list(recalculated['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True))==rows
|
||
for n in range(2,10):
|
||
assert recalculated['Проверка'].cell(n,2).value==book['Проверка'].cell(n,2).value
|
||
assert all(recalculated['Тесты'].cell(n,4).value==recalculated['Тесты'].cell(n,5).value for n in range(2,6))
|
||
recalc_status='independent recalculation matches all 60 rows, check totals and four boundary tests'
|
||
pdf=pymupdf.open(OUT/'excel-ai-lecture.pdf')
|
||
assert len(pdf)==26
|
||
|
||
def norm(t):
|
||
return ''.join(c for c in unicodedata.normalize('NFKC',t).lower() if c.isalnum())
|
||
|
||
warnings=[]
|
||
for i,s in enumerate(p.slides):
|
||
page=pdf[i]
|
||
pdf_text=norm(page.get_text())
|
||
for sh in s.shapes:
|
||
if not sh.has_text_frame:continue
|
||
for para in sh.text_frame.paragraphs:
|
||
expected=norm(para.text)
|
||
if expected and expected not in pdf_text:
|
||
warnings.append(f'Slide {i+1}: text not found intact: {para.text}')
|
||
for block in page.get_text('dict')['blocks']:
|
||
if 'lines' not in block:continue
|
||
for line in block['lines']:
|
||
for span in line['spans']:
|
||
x0,y0,x1,y1=span['bbox']
|
||
if min(x0,y0)<-1 or x1>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,
|
||
'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'
|