""" 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"{pedido}\n" f"{metros:.2f} m\n" f"" f"{quando:%d/%m · %H:%M}" ).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)")