#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Gerador do "Plano de Ataque" (PPTX) por quinzena. Uso: coloque este executavel na MESMA PASTA de: - um arquivo .json de cores (ex.: cores.json) - um arquivo .pptx modelo (com o slide 2 = layout de uma quinzena) - um arquivo .xlsx (planilha de planejamento) e execute. O programa detecta esses 3 arquivos automaticamente na pasta e gera "_ATUALIZADO.pptx" na mesma pasta. Nao precisa de Python nem de nenhuma instalacao no computador que for rodar o executavel -- tudo ja vai embutido nele. """ import glob import json import os import re import sys import copy import traceback from datetime import date, timedelta def base_dir(): """Pasta onde o executavel (ou este script) esta localizado.""" if getattr(sys, 'frozen', False): return os.path.dirname(os.path.abspath(sys.executable)) return os.path.dirname(os.path.abspath(__file__)) def pause_on_exit(frozen_only=True): if not frozen_only or getattr(sys, 'frozen', False): try: input('\nPressione ENTER para sair...') except EOFError: pass def find_one(patterns, folder, label, exclude_suffix=None): candidates = [] for pat in patterns: for f in glob.glob(os.path.join(folder, pat)): name = os.path.basename(f) if name.startswith('~$'): continue if exclude_suffix and name.endswith(exclude_suffix): continue candidates.append(f) candidates = sorted(set(candidates)) if not candidates: raise SystemExit( f'ERRO: nenhum arquivo {label} encontrado na pasta:\n {folder}\n' f'Coloque um arquivo {label} nessa pasta e execute novamente.') if len(candidates) > 1: lista = '\n '.join(candidates) raise SystemExit( f'ERRO: mais de um arquivo {label} encontrado na pasta:\n {lista}\n' f'Deixe apenas UM arquivo {label} na pasta e execute novamente.') return candidates[0] def main(): folder = base_dir() print(f'Pasta de trabalho: {folder}') json_path = find_one(['cores.json', '*.json'], folder, '.json (cores)') xlsx_path = find_one(['*.xlsx'], folder, '.xlsx (planilha)') pptx_path = find_one(['*.pptx'], folder, '.pptx (modelo)', exclude_suffix='_ATUALIZADO.pptx') print(f' cores : {os.path.basename(json_path)}') print(f' xlsx : {os.path.basename(xlsx_path)}') print(f' modelo : {os.path.basename(pptx_path)}') with open(json_path, 'r', encoding='utf-8') as f: cfg = json.load(f) import openpyxl from pptx import Presentation from pptx.oxml.ns import qn from pptx.opc.constants import RELATIONSHIP_TYPE as RT from lxml import etree out_name = os.path.splitext(os.path.basename(pptx_path))[0] + '_ATUALIZADO.pptx' out_path = os.path.join(folder, out_name) # ----------------------------------------------------------- # 1) Coordenadas de pixel dos eixos, medidas nas imagens do modelo # original (colunas 1-26 na "Imagem 6", colunas 26-48 na "Imagem 3"). # Isso e' fixo porque descreve a planta em si, nao a cor. # ----------------------------------------------------------- im1_cols_list = [102.1, 152.3, 203.3, 253.2, 303.9, 353.7, 404.8, 455.0, 505.3, 556.4, 606.4, 657.1, 707.2, 757.9, 808.1, 858.6, 909.4, 959.4, 1010.6, 1059.7, 1110.7, 1160.8, 1211.5, 1262.3, 1312.4, 1363.6] im2_cols_list = [83.0, 132.8, 183.7, 234.0, 284.5, 335.4, 385.2, 435.9, 486.0, 536.7, 587.0, 637.4, 688.4, 738.3, 789.0, 839.0, 889.8, 939.8, 990.6, 1041.1, 1091.4, 1142.1, 1192.1] IM1_COLPX = {i + 1: v for i, v in enumerate(im1_cols_list)} IM2_COLPX = {26 + i: v for i, v in enumerate(im2_cols_list)} IM1_ROWPX = {'D': 88.8, 'C': 178.3, 'B1': 237.7, 'B': 305.3, 'A1': 359.2, 'A': 434.9} IM2_ROWPX = {'D': 72.5, 'C': 161.5, 'B1': 221.5, 'B': 288.5, 'A1': 342.5, 'A': 418.5} IM1_PXSIZE = (1442, 500) IM2_PXSIZE = (1267, 485) def which_image(c1, c2): return 'im1' if max(c1, c2) <= 26 else 'im2' def quad_pixel_rect(c1, c2, r1, r2): img = which_image(c1, c2) colpx = IM1_COLPX if img == 'im1' else IM2_COLPX rowpx = IM1_ROWPX if img == 'im1' else IM2_ROWPX x0, x1 = sorted([colpx[c1], colpx[c2]]) y0, y1 = sorted([rowpx[r1], rowpx[r2]]) return img, x0, y0, x1, y1 # ----------------------------------------------------------- # 2) Transform pixel -> EMU, usando a posicao/tamanho reais das # imagens 'Imagem 6' (im1) e 'Imagem 3' (im2) no slide-modelo. # 'Imagem 3' tem um corte (srcRect) aplicado no PowerPoint que # precisa ser descontado, senao o desenho fica deslocado. # ----------------------------------------------------------- IM1_OFF = (16846, 989588) IM1_EXT = (8200608, 2841514) IM2_OFF = (4073548, 3846576) IM2_EXT = (8118452, 3006806) IM1_SX = IM1_EXT[0] / IM1_PXSIZE[0] IM1_SY = IM1_EXT[1] / IM1_PXSIZE[1] IM2_CROP_T = 3246 / 100000 IM2_CROP_B = 1 / 100000 IM2_VIS_Y0 = IM2_CROP_T * IM2_PXSIZE[1] IM2_VIS_Y1 = (1 - IM2_CROP_B) * IM2_PXSIZE[1] IM2_VIS_X0 = 0.0 IM2_VIS_X1 = IM2_PXSIZE[0] IM2_SX = IM2_EXT[0] / (IM2_VIS_X1 - IM2_VIS_X0) IM2_SY = IM2_EXT[1] / (IM2_VIS_Y1 - IM2_VIS_Y0) def to_emu(img, px, py): if img == 'im1': return IM1_OFF[0] + px * IM1_SX, IM1_OFF[1] + py * IM1_SY return (IM2_OFF[0] + (px - IM2_VIS_X0) * IM2_SX, IM2_OFF[1] + (py - IM2_VIS_Y0) * IM2_SY) # ----------------------------------------------------------- # 3) Ler planilha de planejamento # ----------------------------------------------------------- pat = re.compile(r'^(\d+)-(\d+)/([A-Z0-9]+)-([A-Z0-9]+)$') def to_date(v): if v is None: return None return v.date() if hasattr(v, 'date') else v def parse_quads(path): wb = openpyxl.load_workbook(path, data_only=True) ws = wb['Planejamento'] quads = [] for row in ws.iter_rows(min_row=4, max_row=ws.max_row, values_only=True): if row[1] is None: continue m = pat.match(str(row[1]).strip()) if not m: continue c1, c2, r1, r2 = int(m.group(1)), int(m.group(2)), m.group(3), m.group(4) quads.append(dict( c1=c1, c2=c2, r1=r1, r2=r2, geo=(to_date(row[2]), to_date(row[3])), inf=(to_date(row[4]), to_date(row[5])), sup=(to_date(row[6]), to_date(row[7])), )) return quads QUADS = parse_quads(xlsx_path) if not QUADS: raise SystemExit('ERRO: nenhum quadrante valido foi lido da planilha (aba "Planejamento").') def status(inicio, termino, p_end): if inicio is None or inicio > p_end: return None if termino is not None and termino <= p_end: return 'concluido' return 'andamento' # ----------------------------------------------------------- # 4) Quinzenas # ----------------------------------------------------------- qz_cfg = cfg.get('quinzena', {}) PERIOD_DAYS = int(qz_cfg.get('duracao_dias', 14)) START = date.fromisoformat(qz_cfg.get('data_inicio', '2026-12-14')) FIRST_NUM = int(qz_cfg.get('numero_inicial', 2)) all_dates = [] for q in QUADS: for k in ('geo', 'inf', 'sup'): i, f = q[k] if i: all_dates.append(i) if f: all_dates.append(f) if not all_dates: raise SystemExit('ERRO: a planilha nao tem nenhuma data preenchida.') DATA_FIM = max(all_dates) PERIODS = [] cur = START num = FIRST_NUM while cur <= DATA_FIM: p_end = cur + timedelta(days=PERIOD_DAYS - 1) PERIODS.append((num, cur, p_end)) cur = p_end + timedelta(days=1) num += 1 def fmt(d): return f'{d.day:02d}/{d.month:02d}/{d.year}' # ----------------------------------------------------------- # 5) Cores (lidas do JSON) # ----------------------------------------------------------- JSON_KEY = {'geo': 'geotecnia', 'sup': 'superestrutura', 'inf': 'infraestrutura'} COLOR_HEX, BORDER_HEX, ALPHA_DRAWING_ST = {}, {}, {} for short, jkey in JSON_KEY.items(): try: section = cfg[jkey] except KeyError: raise SystemExit(f'ERRO: o JSON de cores nao tem a secao "{jkey}".') for st in ('andamento', 'concluido'): try: entry = section[st] COLOR_HEX[(short, st)] = entry['preenchimento'].lstrip('#').upper() BORDER_HEX[(short, st)] = entry['borda'].lstrip('#').upper() ALPHA_DRAWING_ST[(short, st)] = int(round(float(entry.get('alpha_preenchimento', 100)) * 1000)) except KeyError as e: raise SystemExit(f'ERRO: falta o campo {e} em "{jkey}.{st}" no JSON de cores.') ALPHA_LEGEND = 100000 MARKER_FRAC = float(cfg.get('marcador_fracao', 0.42)) def _solid_fill_xml(hexcol, alpha): return (f'' f'') def _fill_xml(key): return _solid_fill_xml(COLOR_HEX[key], ALPHA_DRAWING_ST[key]) def _line_xml(key): return (f'' f'') FILL_XML = {k: _fill_xml(k) for k in COLOR_HEX} LINE_XML = {k: _line_xml(k) for k in COLOR_HEX} ROUND_ADJ_QUAD = 12000 ROUND_ADJ_MARK = 30000 STATUS_RANK = {'andamento': 1, 'concluido': 2} _shape_id_counter = [9000] def next_id(): _shape_id_counter[0] += 1 return _shape_id_counter[0] def make_roundrect_xml(name, x_emu, y_emu, w_emu, h_emu, fill_xml, adj, line_xml=None): sid = next_id() line = line_xml if line_xml else '' return f''' {fill_xml} {line} ''' IM1_COL_STEP = (max(im1_cols_list) - min(im1_cols_list)) / (len(im1_cols_list) - 1) IM2_COL_STEP = (max(im2_cols_list) - min(im2_cols_list)) / (len(im2_cols_list) - 1) COL_STEP_PX = {'im1': IM1_COL_STEP, 'im2': IM2_COL_STEP} def add_quadrant_overlays(slide, p_end): spTree = slide.shapes._spTree for q in QUADS: st = status(*q['geo'], p_end) if st is None: continue img, x0, y0, x1, y1 = quad_pixel_rect(q['c1'], q['c2'], q['r1'], q['r2']) ex0, ey0 = to_emu(img, x0, y0) ex1, ey1 = to_emu(img, x1, y1) xml = make_roundrect_xml('Estaqueamento', ex0, ey0, ex1 - ex0, ey1 - ey0, FILL_XML[('geo', st)], ROUND_ADJ_QUAD, LINE_XML[('geo', st)]) spTree.append(etree.fromstring(xml)) for key in ('inf', 'sup'): best = {} for q in QUADS: st = status(*q[key], p_end) if st is None: continue img = which_image(q['c1'], q['c2']) for col in (q['c1'], q['c2']): for row in (q['r1'], q['r2']): pt = (img, col, row) cur_st = best.get(pt) if cur_st is None or STATUS_RANK[st] > STATUS_RANK[cur_st]: best[pt] = st name = 'Superestrutura' if key == 'sup' else 'Infraestrutura' for (img, col, row), st in best.items(): colpx = IM1_COLPX if img == 'im1' else IM2_COLPX rowpx = IM1_ROWPX if img == 'im1' else IM2_ROWPX px, py = colpx[col], rowpx[row] ex, ey = to_emu(img, px, py) step = COL_STEP_PX[img] sx = IM1_SX if img == 'im1' else IM2_SX sy = IM1_SY if img == 'im1' else IM2_SY mw, mh = step * MARKER_FRAC * sx, step * MARKER_FRAC * sy xml = make_roundrect_xml(name, ex - mw / 2, ey - mh / 2, mw, mh, FILL_XML[(key, st)], ROUND_ADJ_MARK, LINE_XML[(key, st)]) spTree.append(etree.fromstring(xml)) # ----------------------------------------------------------- # 6) Duplicar slide (clona shapes + relacionamentos de imagem) # ----------------------------------------------------------- def duplicate_slide(prs, source_index): source = prs.slides[source_index] dest = prs.slides.add_slide(source.slide_layout) for shp in list(dest.shapes): shp._element.getparent().remove(shp._element) rid_map = {} for rId, rel in source.part.rels.items(): if rel.reltype == RT.SLIDE_LAYOUT: continue if rel.is_external: continue new_rId = dest.part.relate_to(rel.target_part, rel.reltype) rid_map[rId] = new_rId r_embed = qn('r:embed') r_link = qn('r:link') for shp in source.shapes: new_el = copy.deepcopy(shp._element) for el in new_el.iter(): old = el.get(r_embed) if old is not None and old in rid_map: el.set(r_embed, rid_map[old]) old = el.get(r_link) if old is not None and old in rid_map: el.set(r_link, rid_map[old]) dest.shapes._spTree.append(new_el) return dest def set_quinzena_title(slide, num, p_start, p_end): for sh in slide.shapes: if sh.name == 'CaixaDeTexto 5': runs = sh.text_frame.paragraphs[0].runs runs[-1].text = f'Quinzena {num:02d} ({fmt(p_start)} a {fmt(p_end)})' return raise RuntimeError('titulo (CaixaDeTexto 5) nao encontrado no slide-modelo') LEGEND_Y_TO_KEY = { 4200000: ('geo', 'andamento'), 4597600: ('geo', 'concluido'), 4993578: ('sup', 'andamento'), 5381498: ('sup', 'concluido'), 5802852: ('inf', 'andamento'), 6190772: ('inf', 'concluido'), } def update_legend_colors(slide): for sh in slide.shapes: if sh.shape_type != 6: # GROUP continue for sub in sh.shapes: if not sub.name.startswith('Rounded'): continue key = None for y, k in LEGEND_Y_TO_KEY.items(): if abs(sub.top - y) < 5000: key = k break if key is None: continue sp = sub._element.spPr for el in sp.findall(qn('a:solidFill')): sp.remove(el) fill_el = etree.fromstring(_solid_fill_xml(COLOR_HEX[key], ALPHA_LEGEND)) ln = sp.find(qn('a:ln')) sp.insert(list(sp).index(ln), fill_el) for c in ln.findall(qn('a:solidFill')): ln.remove(c) ln_fill = etree.fromstring( f'' f'') ln.append(ln_fill) # ----------------------------------------------------------- # 7) Montagem final # ----------------------------------------------------------- prs = Presentation(pptx_path) if len(prs.slides) < 3: raise SystemExit('ERRO: o modelo .pptx precisa ter pelo menos 3 slides ' '(capa, slide de quinzena, encerramento).') template_idx = 1 update_legend_colors(prs.slides[template_idx]) targets = [prs.slides[template_idx]] for _ in range(len(PERIODS) - 1): targets.append(duplicate_slide(prs, template_idx)) for slide, (num, p_start, p_end) in zip(targets, PERIODS): set_quinzena_title(slide, num, p_start, p_end) add_quadrant_overlays(slide, p_end) sldIdLst = prs.slides._sldIdLst ids = list(sldIdLst) thankyou_el = ids[2] sldIdLst.remove(thankyou_el) sldIdLst.append(thankyou_el) prs.save(out_path) print(f'\nOK! Gerado: {out_path}') print(f'Total de slides: {len(prs.slides._sldIdLst)} | Quinzenas: {len(PERIODS)}') print(f'Primeira quinzena: {PERIODS[0][0]:02d} ({fmt(PERIODS[0][1])} a {fmt(PERIODS[0][2])})') print(f'Ultima quinzena: {PERIODS[-1][0]:02d} ({fmt(PERIODS[-1][1])} a {fmt(PERIODS[-1][2])})') if __name__ == '__main__': try: main() except SystemExit as e: print(str(e)) pause_on_exit() sys.exit(1) except Exception: print('ERRO INESPERADO:') traceback.print_exc() pause_on_exit() sys.exit(1) else: pause_on_exit()