416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""
|
||
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)")
|