Files
dtf-system/portal/preflight.py
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

416 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Pré-flight do DTF: valida a arte, repete, fatia e carimba.
Regras acordadas com Marcus em 29/08/2026:
- unidade de venda: 57 x 100 cm
- faixa reservada no rodapé: 30 mm -> área útil 57 x 97 cm
- carimbo: QR de 20 mm + texto na mesma altura, só canal preto
- limite por arquivo gerado: 15 metros
- o carimbo vai apenas na ÚLTIMA parte do pedido
Dependências: pyvips, qrcode, pillow
apt install libvips-tools
pip install pyvips qrcode pillow
"""
from __future__ import annotations
import math
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
import pyvips
import qrcode
# --------------------------------------------------------------------------
# Constantes do processo
# --------------------------------------------------------------------------
LARGURA_MAX_CM = 57.0
FAIXA_RODAPE_MM = 30.0 # reservada para o carimbo
ALTURA_UNIDADE_CM = 100.0
AREA_UTIL_CM = ALTURA_UNIDADE_CM - FAIXA_RODAPE_MM / 10 # 97 cm
DPI_MINIMO = 150 # abaixo disso recusa
DPI_IDEAL = 300 # entre 150 e 300 aceita com aviso
LIMITE_ARQUIVO_M = 20.0 # nenhum arquivo gerado passa disso
QR_MM = 20.0 # leitura por celular
ZONA_LIMPA_MM = 3.0 # margem branca ao redor do QR
FORMATOS_OK = {".png", ".tif", ".tiff", ".psd", ".psb", ".pdf", ".ai", ".svg", ".eps"}
# --------------------------------------------------------------------------
# Normalização — o cliente manda o que quiser, o designer recebe padronizado
# --------------------------------------------------------------------------
# Não existe "formato certo" único: normalizar tudo para um só jogaria fora o
# melhor de cada tipo. A regra é por NATUREZA do conteúdo.
#
# vetor -> PDF, mantendo vetor (rasterizar aqui é perder qualidade)
# pixel -> TIF com transparência (é o que a sala já usa)
#
# Isso resolve o vai-e-volta que os designers relatam hoje: abrir PDF no Corel
# para não rasterizar, exportar, abrir no Photoshop, e esbarrar no limite de
# 5 metros do PSD.
#
# O limite de 5 m NÃO é do Photoshop: é do formato PSD, que trava em 30.000 px
# por dimensão — a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros.
# Em PDF vetorial não há esse limite; em TIF o teto é 4 GB por arquivo.
VETORIAIS = {".pdf", ".ai", ".svg", ".eps"}
RASTER = {".png", ".tif", ".tiff", ".psd", ".psb"}
FORMATOS_RECUSA = {".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
# CDR entra com etiqueta "conferir": pula o pré-flight e vai direto ao designer.
# A licença do CorelDRAW é de estação, não cobre servidor processando arquivo.
FORMATOS_SEM_VALIDACAO = {".cdr"}
CM_POR_POLEGADA = 2.54
# --------------------------------------------------------------------------
# Resultado
# --------------------------------------------------------------------------
@dataclass
class Resultado:
aprovado: bool
motivo: str = "" # mensagem ao cliente quando recusado
avisos: list[str] = field(default_factory=list)
dpi_efetivo: float = 0.0
largura_cm: float = 0.0
altura_cm: float = 0.0
metros_totais: float = 0.0
conferir_manual: bool = False # CDR: entra sem validar
natureza: str = "" # vetor | raster | conferir
partes: list[str] = field(default_factory=list) # caminhos gerados
# --------------------------------------------------------------------------
# 1. Validação
# --------------------------------------------------------------------------
def validar(caminho: str, cm_comprados: float) -> Resultado:
"""
cm_comprados = altura em centímetros que o cliente pagou por UMA arte.
Ex.: comprou 1 metro -> 100.
"""
r = Resultado(aprovado=False)
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO:
r.aprovado = True
r.conferir_manual = True
r.avisos.append("CDR não passa pelo pré-flight — vai direto para o designer.")
return r
if ext in FORMATOS_RECUSA:
r.motivo = (
f"Arquivo {ext.upper().lstrip('.')} não guarda transparência. "
"Envie PNG ou TIFF com fundo transparente."
)
return r
if ext not in FORMATOS_OK:
r.motivo = "Formato não aceito. Envie PNG ou TIFF com fundo transparente."
return r
try:
# access sequential: lê em streaming, não carrega 200 MB na memória
img = pyvips.Image.new_from_file(caminho, access="sequential")
except Exception:
r.motivo = "Não conseguimos abrir o arquivo. Ele pode estar corrompido."
return r
# --- canal alfa ---
if img.bands < 4 or not img.hasalpha():
r.motivo = (
"A arte está sem fundo transparente. Todo fundo não removido "
"sai impresso em branco na estampa."
)
return r
# --- DPI efetivo: a regra que mais pega arquivo ruim ---
# O DPI gravado no cabeçalho mente com frequência (o cliente escala a
# imagem e o programa mantém "300"). O que vale é pixel dividido pela
# medida que ele comprou.
area_util_cm = cm_comprados - FAIXA_RODAPE_MM / 10
r.dpi_efetivo = img.height / (area_util_cm / CM_POR_POLEGADA)
r.altura_cm = area_util_cm
r.largura_cm = img.width / r.dpi_efetivo * CM_POR_POLEGADA
if r.dpi_efetivo < DPI_MINIMO:
r.motivo = (
f"A resolução real da arte é de {r.dpi_efetivo:.0f} DPI no tamanho "
f"que você comprou. Precisamos de pelo menos {DPI_MINIMO} DPI — "
"o ideal é 300. Reenvie em maior resolução."
)
return r
if r.dpi_efetivo < DPI_IDEAL:
r.avisos.append(
f"Resolução de {r.dpi_efetivo:.0f} DPI. Funciona, mas detalhes "
"finos podem perder definição."
)
# --- largura física ---
if r.largura_cm > LARGURA_MAX_CM + 0.2: # 2 mm de tolerância
r.motivo = (
f"A arte tem {r.largura_cm:.1f} cm de largura. O máximo é "
f"{LARGURA_MAX_CM:.0f} cm. Use o gabarito 57 × 97 cm."
)
return r
# --- bordas com alfa parcial (sombras suaves) ---
alfa = img[img.bands - 1]
parcial = ((alfa > 10) & (alfa < 245)).avg() / 255.0
if parcial > 0.15:
r.avisos.append(
"A arte tem muitas bordas suaves. O branco de apoio pode sair "
"irregular nessas áreas."
)
r.aprovado = True
return r
# --------------------------------------------------------------------------
# 1b. Normalização
# --------------------------------------------------------------------------
def normalizar(caminho: str, pasta_saida: str, pedido: str) -> tuple[str, str]:
"""
Converte o que o cliente mandou para o formato de trabalho da sala.
Devolve (caminho_normalizado, natureza).
O ORIGINAL É SEMPRE PRESERVADO. Se a conversão sair ruim num caso
específico, o designer volta ao arquivo do cliente — pedido dos próprios
designers, e é barato perto de refazer o trabalho.
"""
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO: # CDR: sem Corel no servidor
return caminho, "conferir"
if ext in VETORIAIS:
# mantém vetor: nada de rasterizar. O FlexiPRINT rasteriza na resolução
# da máquina, na hora de imprimir — melhor que qualquer caminho manual.
destino = os.path.join(pasta_saida, f"{pedido}_norm.pdf")
if ext == ".pdf":
shutil.copy(caminho, destino)
else:
# AI, SVG e EPS convertem para PDF preservando o vetor
subprocess.run(
["inkscape", caminho, "--export-type=pdf",
f"--export-filename={destino}", "--export-text-to-path"],
check=True, capture_output=True,
)
return destino, "vetor"
# raster: TIF com alfa, sem compressão com perda
destino = os.path.join(pasta_saida, f"{pedido}_norm.tif")
img = pyvips.Image.new_from_file(caminho, access="sequential")
if not img.hasalpha():
img = img.bandjoin(255)
img = _para_rgb(img)
img.tiffsave(destino, compression="lzw", predictor="horizontal")
return destino, "raster"
def _para_rgb(img: pyvips.Image) -> pyvips.Image:
"""
CMYK vira RGB. Arquivo em CMYK é uma das causas de cor errada no DTF —
o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
"""
if img.interpretation in ("cmyk", "b-w"):
return img.colourspace("srgb")
return img
# --------------------------------------------------------------------------
# 2. Repetição
# --------------------------------------------------------------------------
def empilhar(caminho: str, repeticoes: int, destino: str) -> str:
"""Concatena N cópias da arte na vertical, sem espaço entre elas."""
if repeticoes <= 1:
return caminho
img = pyvips.Image.new_from_file(caminho, access="sequential")
empilhado = pyvips.Image.arrayjoin([img] * repeticoes, across=1)
empilhado.write_to_file(destino)
return destino
def montar_folha(caminhos: list[str], destino: str) -> str:
"""
Junta vários arquivos numa folha só, EMPILHADOS na ordem que subiram.
Decisão de 30/08/2026: empilhamento simples, sem encaixe lado a lado.
São artes de 1 metro em sequência — não é arte única e não há otimização
de largura. O cliente vê a prévia antes de fechar o pedido.
"""
if len(caminhos) == 1:
return caminhos[0]
imgs = [pyvips.Image.new_from_file(c, access="sequential") for c in caminhos]
largura = max(i.width for i in imgs)
# centraliza as artes mais estreitas, mantendo a largura da folha
ajustadas = [
i if i.width == largura
else i.embed((largura - i.width) // 2, 0, largura, i.height)
for i in imgs
]
pyvips.Image.arrayjoin(ajustadas, across=1).write_to_file(destino)
return destino
# --------------------------------------------------------------------------
# 3. Fatiamento
# --------------------------------------------------------------------------
def quantas_partes(metros_totais: float) -> int:
return max(1, math.ceil(metros_totais / LIMITE_ARQUIVO_M))
def fatiar(caminho: str, metros_totais: float, prefixo: str) -> list[str]:
"""
Corta em partes de no máximo 15 metros.
1 m x 20 -> 15 + 5 (2 arquivos)
100 m -> 15 x 6 + 10 (7 arquivos)
"""
n = quantas_partes(metros_totais)
if n == 1:
return [caminho]
img = pyvips.Image.new_from_file(caminho, access="random")
px_por_metro = img.height / metros_totais
partes, topo, restante = [], 0, metros_totais
for i in range(n):
m = min(LIMITE_ARQUIVO_M, restante)
altura = int(round(m * px_por_metro))
altura = min(altura, img.height - topo)
saida = f"{prefixo}_p{i + 1}.png"
img.crop(0, topo, img.width, altura).write_to_file(saida)
partes.append(saida)
topo += altura
restante -= m
return partes
# --------------------------------------------------------------------------
# 4. Carimbo
# --------------------------------------------------------------------------
def _qr_vips(conteudo: str, lado_px: int) -> pyvips.Image:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M, # aguenta 15% de dano
box_size=1,
border=0,
)
qr.add_data(conteudo)
qr.make(fit=True)
m = qr.get_matrix()
n = len(m)
# matriz -> imagem 1 bit -> escala para o tamanho pedido
dados = bytes(0 if v else 255 for linha in m for v in linha)
img = pyvips.Image.new_from_memory(dados, n, n, 1, "uchar")
return img.resize(lado_px / n, kernel="nearest")
def carimbar(
caminho: str,
pedido: str,
metros: float,
url_kanban: str,
dpi: float = 300.0,
quando: datetime | None = None,
) -> str:
"""
Escreve o carimbo na faixa de 30 mm do rodapé.
QR de 20 mm com a URL do card + número, metragem e data ao lado.
Só canal preto — sem branco de apoio, para não gastar a tinta mais cara.
"""
quando = quando or datetime.now()
img = pyvips.Image.new_from_file(caminho, access="random")
px_mm = dpi / CM_POR_POLEGADA / 10
qr_px = int(QR_MM * px_mm)
margem = int(ZONA_LIMPA_MM * px_mm)
faixa_px = int(FAIXA_RODAPE_MM * px_mm)
# fundo branco só sob o QR, para o celular ler com contraste
fundo = pyvips.Image.black(qr_px + margem * 2, qr_px + margem * 2) + 255
qr = _qr_vips(url_kanban, qr_px)
bloco = fundo.insert(qr, margem, margem)
# texto alinhado à altura do QR
texto = (
f"<span size='{int(qr_px*0.28)*1000}' weight='bold'>{pedido}</span>\n"
f"<span size='{int(qr_px*0.19)*1000}'>{metros:.2f} m</span>\n"
f"<span size='{int(qr_px*0.15)*1000}' foreground='#555555'>"
f"{quando:%d/%m · %H:%M}</span>"
).replace(".", ",")
txt = pyvips.Image.text(texto, dpi=int(dpi), align="low")
# base da faixa: transparente, o carimbo entra em preto
base = pyvips.Image.black(img.width, faixa_px, bands=4)
y_qr = max(0, (faixa_px - bloco.height) // 2)
base = base.insert(bloco.bandjoin(255), margem, y_qr, expand=False)
base = base.insert(
txt.bandjoin(255) if txt.bands < 4 else txt,
margem * 2 + bloco.width,
max(0, (faixa_px - txt.height) // 2),
expand=False,
)
final = pyvips.Image.arrayjoin([img, base], across=1)
saida = caminho.replace(".png", "_carimbado.png")
final.write_to_file(saida)
return saida
# --------------------------------------------------------------------------
# 5. Orquestração — é isso que o portal chama
# --------------------------------------------------------------------------
def processar(
caminho: str,
cm_comprados: float,
repeticoes: int,
pedido: str,
base_kanban: str,
pasta_saida: str,
) -> Resultado:
# 0. normaliza antes de tudo: o designer nunca mais converte à mão
caminho, natureza = normalizar(caminho, pasta_saida, pedido)
if natureza == "conferir":
r = Resultado(aprovado=True, conferir_manual=True)
r.avisos.append("CDR: sem validação automática, vai direto ao designer.")
r.partes = [caminho]
return r
r = validar(caminho, cm_comprados)
r.natureza = natureza
if not r.aprovado:
return r
r.metros_totais = cm_comprados / 100 * repeticoes
prefixo = os.path.join(pasta_saida, pedido)
empilhado = empilhar(caminho, repeticoes, f"{prefixo}_full.png")
partes = fatiar(empilhado, r.metros_totais, prefixo)
# o carimbo vai só na última parte: é ela que fecha o pedido
partes[-1] = carimbar(
partes[-1],
pedido=pedido,
metros=r.metros_totais,
url_kanban=f"{base_kanban}/p/{pedido}",
dpi=r.dpi_efetivo,
)
r.partes = partes
return r
if __name__ == "__main__":
# conferência rápida da regra de fatiamento
for m in (20, 15, 16, 45, 100):
print(f"{m:>4} m -> {quantas_partes(m)} arquivo(s)")