242 lines
12 KiB
Markdown
242 lines
12 KiB
Markdown
|
|
---
|
||
|
|
name: pptx-planta-progresso
|
||
|
|
description: Metodologia para construir um gerador de PPTX de progresso de obra (ou qualquer overlay colorido sobre uma planta/imagem técnica) a partir de uma imagem de planta com grelha de eixos, uma planilha de cronograma e um PPTX-modelo. Use quando o usuário pedir para pintar/marcar status (andamento/concluído) sobre uma planta em um PowerPoint, gerar um slide por período (quinzena/semana/mês) a partir de um cronograma, ou reproduzir esse tipo de relatório visual em um projeto novo (edificação, planta, cronograma diferentes dos já usados).
|
||
|
|
---
|
||
|
|
|
||
|
|
# Overlay de progresso sobre planta técnica em PPTX
|
||
|
|
|
||
|
|
Metodologia genérica extraída de um caso real (planta de fundações dividida
|
||
|
|
em quadrantes, cronograma por Geotecnia/Infraestrutura/Superestrutura,
|
||
|
|
1 slide por quinzena). Os nomes aqui são genéricos de propósito — adapte aos
|
||
|
|
nomes reais do projeto em mãos.
|
||
|
|
|
||
|
|
Esta skill é sobre **método**, não sobre um script fixo: os detalhes (nomes
|
||
|
|
de coluna, quantidade de eixos, cores) sempre vão mudar de projeto para
|
||
|
|
projeto; o que se repete é a sequência de passos e as pegadinhas abaixo.
|
||
|
|
|
||
|
|
## Visão geral do pipeline
|
||
|
|
|
||
|
|
```
|
||
|
|
imagem(ns) da planta ──┐
|
||
|
|
├─→ medir pixels dos eixos → mapear quadrante→retângulo
|
||
|
|
planilha de cronograma ─┘ │
|
||
|
|
▼
|
||
|
|
PPTX-modelo (1 slide-tipo) ──→ duplicar N vezes (1 por período) ──→ pintar
|
||
|
|
cada
|
||
|
|
quadrante
|
||
|
|
conforme
|
||
|
|
status
|
||
|
|
```
|
||
|
|
|
||
|
|
## Passo 1 — Medir os eixos da planta em pixels
|
||
|
|
|
||
|
|
Se a planta tem uma grelha de eixos numerados/rotulados (comum em plantas de
|
||
|
|
estrutura/fundação: colunas numéricas, linhas em letras), NÃO estime a
|
||
|
|
posição visualmente — meça programaticamente:
|
||
|
|
|
||
|
|
1. Carregar a imagem em escala de cinza (`PIL` + `numpy`).
|
||
|
|
2. Binarizar (`pixel < limiar`) e restringir a análise a uma FAIXA estreita
|
||
|
|
onde só existem os círculos/marcas de eixo (topo da imagem para colunas,
|
||
|
|
margem esquerda para linhas) — se usar a imagem inteira, hachuras e texto
|
||
|
|
de dentro do desenho viram ruído e atrapalham a detecção.
|
||
|
|
3. Somar pixels escuros por coluna (ou por linha) dentro dessa faixa.
|
||
|
|
4. Agrupar posições consecutivas com valor > 0 (tolerância de alguns pixels
|
||
|
|
de "buraco") em clusters; o centro de cada cluster (média ponderada pela
|
||
|
|
intensidade) é a posição do eixo.
|
||
|
|
5. Filtrar clusters espúrios por LARGURA — um círculo de eixo tem uma largura
|
||
|
|
característica (~mesma para todos); qualquer cluster muito mais estreito
|
||
|
|
ou muito mais largo é ruído (seta, cota, texto cruzando a faixa) e deve
|
||
|
|
ser descartado. Conferir sempre que o número de clusters batidos é igual
|
||
|
|
ao número de eixos esperado.
|
||
|
|
6. Fazer o mesmo para linhas, cuidado com textos de cota grandes/rotacionados
|
||
|
|
que caem na mesma faixa (ex. uma cota vertical entre dois eixos pode gerar
|
||
|
|
um cluster fantasma — filtrar por largura resolve isso também).
|
||
|
|
|
||
|
|
Sempre medir na imagem **de fato usada no PPTX final** (extraia
|
||
|
|
`ppt/media/imageN.png` de dentro do `.zip` do `.pptx`, não confie numa cópia
|
||
|
|
enviada separadamente — arquivos "iguais" podem ter poucos pixels de
|
||
|
|
diferença de crop/exportação e isso já causou desalinhamento em produção).
|
||
|
|
|
||
|
|
## Passo 2 — Mapear "quadrante" (ou elemento) → retângulo/ponto em pixels
|
||
|
|
|
||
|
|
Se o cronograma se refere a células/trechos da planta por um código (ex.
|
||
|
|
`"10-11/A1-B"` = colunas 10 a 11, linhas A1 a B), parseie esse código com
|
||
|
|
regex e resolva os 2 pares de coordenadas usando os dicionários do passo 1.
|
||
|
|
Se a planta estiver dividida em mais de uma imagem (ex. planta muito larga
|
||
|
|
exportada em 2 metades), identifique de qual imagem cada elemento faz parte
|
||
|
|
(geralmente por faixa de coluna) — mas confirme que a granularidade do
|
||
|
|
cronograma nunca faz um elemento "atravessar" duas imagens; se atravessar,
|
||
|
|
esse caso precisa de tratamento especial.
|
||
|
|
|
||
|
|
## Passo 3 — Pixel → coordenada do PPTX (EMU)
|
||
|
|
|
||
|
|
Cada imagem colocada num slide tem uma posição/tamanho (`off`/`ext`, em EMU)
|
||
|
|
no XML do PPTX. A conversão ingênua é interpolação linear:
|
||
|
|
`emu = off + (pixel / tamanho_da_imagem_em_px) * ext`.
|
||
|
|
|
||
|
|
**Gotcha crítico**: se a imagem tiver um corte aplicado no PowerPoint
|
||
|
|
(elemento `<a:srcRect t="…" b="…" l="…" r="…"/>` dentro do `<p:blipFill>`),
|
||
|
|
a interpolação tem que ser feita sobre a região VISÍVEL da imagem, não sobre
|
||
|
|
a imagem inteira — do contrário tudo fica deslocado (geralmente para baixo
|
||
|
|
ou para o lado que foi cortado). Sempre inspecionar o XML de cada `<p:pic>`
|
||
|
|
do slide-modelo antes de assumir que pixel 0 = canto exibido:
|
||
|
|
|
||
|
|
```python
|
||
|
|
import re
|
||
|
|
xml = open('slide_extraido.xml').read()
|
||
|
|
for m in re.finditer(r'<p:pic>.*?</p:pic>', xml, re.S):
|
||
|
|
print(re.search(r'name="([^"]+)"', m.group(0)).group(1),
|
||
|
|
re.search(r'<a:srcRect[^/]*/>', m.group(0)))
|
||
|
|
```
|
||
|
|
|
||
|
|
Se houver corte, a fórmula fica:
|
||
|
|
```
|
||
|
|
vis0 = corte_inicio_fraction * tamanho_px
|
||
|
|
vis1 = (1 - corte_fim_fraction) * tamanho_px
|
||
|
|
escala = ext / (vis1 - vis0)
|
||
|
|
emu = off + (pixel - vis0) * escala
|
||
|
|
```
|
||
|
|
|
||
|
|
Se a planta estiver em mais de uma imagem lado a lado/empilhada, ainda dá
|
||
|
|
para conferir que a escala px→EMU bate entre elas (compare a distância entre
|
||
|
|
dois eixos conhecidos em cada imagem, convertida para EMU) — se bater, dá
|
||
|
|
para usar um offset constante para alinhar uma imagem à outra visualmente
|
||
|
|
mesmo que tenham sido exportadas com recortes/margens diferentes.
|
||
|
|
|
||
|
|
## Passo 4 — Decidir COMO pintar cada tipo de status
|
||
|
|
|
||
|
|
Nem todo "status" deve ser um retângulo do tamanho do quadrante inteiro.
|
||
|
|
Dois padrões comuns, que podem coexistir na mesma planta:
|
||
|
|
|
||
|
|
- **Status de área** (ex. escavação/estaqueamento, algo que ocupa a área
|
||
|
|
toda do trecho): retângulo cobrindo o quadrante inteiro, com transparência
|
||
|
|
(alpha < 100%) para a planta de baixo continuar legível.
|
||
|
|
- **Status de ponto** (ex. pilar, bloco de fundação — elementos que existem
|
||
|
|
em pontos discretos, normalmente nos vértices/interseções da grelha, não
|
||
|
|
espalhados pela área): marcador pequeno (quadrado/círculo) centrado em
|
||
|
|
cada vértice relevante, não um retângulo proporcional ao quadrante. Se um
|
||
|
|
vértice é compartilhado por elementos vizinhos, faça DEDUPE por ponto
|
||
|
|
(usar o status mais avançado entre os que compartilham aquele vértice —
|
||
|
|
ex. "concluído" prevalece sobre "andamento") em vez de desenhar
|
||
|
|
marcadores sobrepostos.
|
||
|
|
|
||
|
|
Peça sempre uma imagem de referência de como o cliente já visualiza isso
|
||
|
|
(ou já visualizou em outra ferramenta) antes de assumir qual dos dois
|
||
|
|
padrões usar — a diferença visual é grande e already causou retrabalho.
|
||
|
|
|
||
|
|
## Passo 5 — Fidelidade de cor
|
||
|
|
|
||
|
|
Se o usuário fornecer uma legenda de referência (imagem), AMOSTRE OS PIXELS
|
||
|
|
em vez de "chutar" a cor no olho — e use a imagem em maior resolução
|
||
|
|
disponível (uma captura de tela pequena/comprimida pode alterar o tom o
|
||
|
|
suficiente pra ficar "quase certo, mas não fiel", que foi motivo de reclamação
|
||
|
|
real numa entrega). Amostre separadamente o preenchimento (centro do ícone) e
|
||
|
|
a borda (poucos pixels para dentro da borda visível) — normalmente são cores
|
||
|
|
diferentes.
|
||
|
|
|
||
|
|
```python
|
||
|
|
import numpy as np
|
||
|
|
from PIL import Image
|
||
|
|
arr = np.array(Image.open('legenda.png').convert('RGB'))
|
||
|
|
patch = arr[y-5:y+5, x-5:x+5].reshape(-1, 3)
|
||
|
|
hexcolor = '#%02X%02X%02X' % tuple(patch.mean(axis=0).round().astype(int))
|
||
|
|
```
|
||
|
|
|
||
|
|
Exponha as cores (preenchimento, borda, opacidade) num **arquivo de
|
||
|
|
configuração separado do código** (JSON simples) para o cliente poder ajustar
|
||
|
|
sem depender de você re-editar script.
|
||
|
|
|
||
|
|
## Passo 6 — Duplicar o slide-modelo N vezes (1 por período)
|
||
|
|
|
||
|
|
`python-pptx` não tem API pública para duplicar slide. Receita:
|
||
|
|
|
||
|
|
```python
|
||
|
|
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) # limpa placeholders da layout
|
||
|
|
|
||
|
|
rid_map = {}
|
||
|
|
for rId, rel in source.part.rels.items():
|
||
|
|
if rel.reltype == RT.SLIDE_LAYOUT or rel.is_external:
|
||
|
|
continue
|
||
|
|
rid_map[rId] = dest.part.relate_to(rel.target_part, rel.reltype)
|
||
|
|
|
||
|
|
for shp in source.shapes:
|
||
|
|
new_el = copy.deepcopy(shp._element)
|
||
|
|
# IMPORTANTE: reescrever r:embed/r:link direto no atributo XML,
|
||
|
|
# nunca com replace de string — se o remapeamento de rIds for uma
|
||
|
|
# permutação (ex. rId2 vira rId3 e rId3 vira rId2), um replace
|
||
|
|
# sequencial de texto corrompe as duas referências.
|
||
|
|
for el in new_el.iter():
|
||
|
|
for attr in (qn('r:embed'), qn('r:link')):
|
||
|
|
old = el.get(attr)
|
||
|
|
if old in rid_map:
|
||
|
|
el.set(attr, rid_map[old])
|
||
|
|
dest.shapes._spTree.append(new_el)
|
||
|
|
return dest
|
||
|
|
```
|
||
|
|
|
||
|
|
Depois de duplicar, reordenar slides manipulando `prs.slides._sldIdLst`
|
||
|
|
diretamente (mover/remover elementos `<p:sldId>`) já que também não há API
|
||
|
|
pública para isso.
|
||
|
|
|
||
|
|
## Passo 7 — Validar sem PowerPoint/LibreOffice instalado
|
||
|
|
|
||
|
|
Quando não há como renderizar o `.pptx` de verdade:
|
||
|
|
|
||
|
|
1. **Integridade estrutural**: `zipfile.ZipFile(path).testzip()` (sem
|
||
|
|
corrupção) + `lxml.etree.fromstring` em cada `.xml`/`.rels` do pacote
|
||
|
|
(sem erro de parse).
|
||
|
|
2. **Reconstrução visual manual**: ler `left/top/width/height` (EMU) de cada
|
||
|
|
`<p:pic>` e forma via `python-pptx`, converter para pixels, montar um PNG
|
||
|
|
com Pillow (`Image.alpha_composite`) reproduzindo o slide. É assim que se
|
||
|
|
detectam desalinhamentos e cores erradas ANTES de entregar — não confie
|
||
|
|
só na leitura do XML.
|
||
|
|
|
||
|
|
## Passo 8 — Empacotar como "sem dependências" para o usuário final
|
||
|
|
|
||
|
|
- **Linux**: `pyinstaller --onefile --name NomeDoApp script.py` — direto.
|
||
|
|
- **Windows, sem acesso a uma máquina/VM Windows nem Wine**: não dá para
|
||
|
|
compilar um `.exe` de verdade. Alternativa que funciona: montar um Python
|
||
|
|
"portátil" —
|
||
|
|
1. Baixar o zip *embeddable* oficial do python.org
|
||
|
|
(`python-<versão>-embed-amd64.zip`).
|
||
|
|
2. Editar o `python<XY>._pth` de dentro do zip para incluir
|
||
|
|
`Lib\site-packages` e descomentar `import site` (por padrão o
|
||
|
|
embeddable vem sem suporte a pacotes de terceiros).
|
||
|
|
3. Baixar as wheels certas **mesmo rodando de Linux**, sem precisar de
|
||
|
|
Wine, com `pip download --platform win_amd64 --python-version <XY>
|
||
|
|
--implementation cp --abi cp<XY> --only-binary=:all: <pacotes>`
|
||
|
|
(`pip download` só baixa o arquivo, não executa nada — funciona
|
||
|
|
cross-platform).
|
||
|
|
4. Extrair (unzip) cada `.whl` dentro de `Lib\site-packages`.
|
||
|
|
5. Lançador: um `.bat` de uma linha chamando
|
||
|
|
`python\python.exe script.py`, com `pause` no final pra não fechar a
|
||
|
|
janela sozinho.
|
||
|
|
- Deixe claro pro usuário que isso é uma alternativa válida a um `.exe`
|
||
|
|
"de verdade" — funciona igual (zero instalação), mas é uma pasta em vez
|
||
|
|
de um arquivo único.
|
||
|
|
- Se for gerar um `.zip` desse pacote, gere em um diretório TEMPORÁRIO
|
||
|
|
fora da pasta que está sendo zipada, e só depois mova o `.zip` para
|
||
|
|
dentro — gerar direto dentro da própria pasta faz o zip se
|
||
|
|
autoincluir (bug real, já aconteceu).
|
||
|
|
|
||
|
|
## Passo 9 — Descoberta automática de arquivos de entrada
|
||
|
|
|
||
|
|
Se o programa final vai rodar numa pasta com 2-3 arquivos de entrada
|
||
|
|
(config, modelo, dados), não force nomes fixos — detecte por extensão
|
||
|
|
(`glob.glob('*.xlsx')` etc.), e falhe com mensagem clara se encontrar 0 ou
|
||
|
|
mais de 1 candidato ("deixe só um arquivo .xlsx na pasta"). Isso deixa o
|
||
|
|
programa reutilizável em outro projeto só trocando os arquivos de entrada,
|
||
|
|
sem precisar editar nada.
|
||
|
|
|
||
|
|
## Nota sobre git neste tipo de ambiente sandbox
|
||
|
|
|
||
|
|
Ambientes de execução isolados (containers efêmeros) às vezes não têm
|
||
|
|
identidade de git configurada e não se deve rodar `git config --global`
|
||
|
|
(pode ser bloqueado por política, e além disso não é uma boa prática mexer
|
||
|
|
em config global do usuário). Use override só no comando do commit:
|
||
|
|
`git -c user.name="..." -c user.email="..." commit -m "..."`.
|