fix: compressão real p/ stream DCTDecode + auth c/ chave vazia
- Substitui xobj._data por page.images[].replace() em compress.py e pdfa.py - Adiciona compress_with_stats() com feedback honesto na UI - Trata PDFTOOLKIT_API_KEY vazia como modo dev (auth desabilitado) - Atualiza engine.py e app.py para exibir redução real ou 'já otimizado'
This commit is contained in:
16
app.py
16
app.py
@@ -148,13 +148,16 @@ try:
|
|||||||
if operation == "📦 Comprimir PDF":
|
if operation == "📦 Comprimir PDF":
|
||||||
progress.progress(0.1, "Comprimindo...")
|
progress.progress(0.1, "Comprimindo...")
|
||||||
for i, (fname, fobj) in enumerate(files_data.items()):
|
for i, (fname, fobj) in enumerate(files_data.items()):
|
||||||
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"])
|
result_data, stats = engine.compress_with_stats(fobj["data"], preset=extra_opts["preset"])
|
||||||
orig_mb = fobj["info"]["file_size_mb"]
|
orig_mb = fobj["info"]["file_size_mb"]
|
||||||
new_mb = round(len(result_data) / (1024 * 1024), 2)
|
new_mb = round(len(result_data) / (1024 * 1024), 2)
|
||||||
reduction = round((1 - (new_mb / orig_mb)) * 100, 1) if orig_mb > 0 else 0
|
reduction = round((1 - (new_mb / orig_mb)) * 100, 1) if orig_mb > 0 else 0
|
||||||
results.append((fname.replace(".pdf", f"_comprimido.pdf"), result_data))
|
results.append((fname.replace(".pdf", f"_comprimido.pdf"), result_data))
|
||||||
progress.progress((i + 1) / len(files_data),
|
if stats["reduced"]:
|
||||||
f"{fname}: {orig_mb}MB → {new_mb}MB ({reduction}% redução)")
|
msg = f"{fname}: {orig_mb}MB → {new_mb}MB ({reduction}% redução)"
|
||||||
|
else:
|
||||||
|
msg = f"{fname}: já otimizado — {len(result_data)} bytes (sem redução adicional)" if stats["images_replaced"] == 0 else f"{fname}: {orig_mb}MB → {new_mb}MB (compressão não reduziu o tamanho, original preservado)"
|
||||||
|
progress.progress((i + 1) / len(files_data), msg)
|
||||||
|
|
||||||
elif operation == "📄 PDF/A → PDF comum":
|
elif operation == "📄 PDF/A → PDF comum":
|
||||||
progress.progress(0.1, "Convertendo PDF/A...")
|
progress.progress(0.1, "Convertendo PDF/A...")
|
||||||
@@ -174,12 +177,13 @@ try:
|
|||||||
if fobj["info"].get("is_pdfa"):
|
if fobj["info"].get("is_pdfa"):
|
||||||
result_data = engine.compress_pdfa(fobj["data"], quality=q)
|
result_data = engine.compress_pdfa(fobj["data"], quality=q)
|
||||||
else:
|
else:
|
||||||
# Se não for PDF/A, aplica compressão normal
|
# Se não for PDF/A, aplica compressão normal com stats
|
||||||
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"])
|
result_data, _ = engine.compress_with_stats(fobj["data"], preset=extra_opts["preset"])
|
||||||
orig_mb = fobj["info"]["file_size_mb"]
|
orig_mb = fobj["info"]["file_size_mb"]
|
||||||
new_mb = round(len(result_data) / (1024 * 1024), 2)
|
new_mb = round(len(result_data) / (1024 * 1024), 2)
|
||||||
results.append((fname.replace(".pdf", f"_otimizado.pdf"), result_data))
|
results.append((fname.replace(".pdf", f"_otimizado.pdf"), result_data))
|
||||||
progress.progress((i + 1) / len(files_data))
|
progress.progress((i + 1) / len(files_data),
|
||||||
|
f"{fname}: {orig_mb}MB → {new_mb}MB")
|
||||||
|
|
||||||
elif operation == "🔗 Merge (juntar PDFs)":
|
elif operation == "🔗 Merge (juntar PDFs)":
|
||||||
progress.progress(0.3, "Mesclando PDFs...")
|
progress.progress(0.3, "Mesclando PDFs...")
|
||||||
|
|||||||
18
core/auth.py
18
core/auth.py
@@ -2,8 +2,8 @@
|
|||||||
Autenticação via API Key para a API REST.
|
Autenticação via API Key para a API REST.
|
||||||
|
|
||||||
A chave é lida da variável de ambiente PDFTOOLKIT_API_KEY.
|
A chave é lida da variável de ambiente PDFTOOLKIT_API_KEY.
|
||||||
Se a variável não estiver definida, a autenticação fica desabilitada
|
Se a variável não estiver definida **ou estiver vazia**, a
|
||||||
(útil para desenvolvimento local).
|
autenticação fica desabilitada (útil para desenvolvimento local).
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui"
|
export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui"
|
||||||
@@ -15,8 +15,9 @@ from fastapi.security import APIKeyHeader
|
|||||||
|
|
||||||
API_KEY_ENV = "PDFTOOLKIT_API_KEY"
|
API_KEY_ENV = "PDFTOOLKIT_API_KEY"
|
||||||
|
|
||||||
# Lê a chave do ambiente (None se não configurada)
|
# Lê a chave do ambiente (None se não configurada ou vazia)
|
||||||
_API_KEY = os.environ.get(API_KEY_ENV, None)
|
_API_KEY_CANDIDATE = os.environ.get(API_KEY_ENV, None)
|
||||||
|
_API_KEY = _API_KEY_CANDIDATE if _API_KEY_CANDIDATE else None
|
||||||
|
|
||||||
# Schema de header: X-API-Key
|
# Schema de header: X-API-Key
|
||||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
@@ -26,11 +27,12 @@ def verify_api_key(api_key: str = Security(api_key_header)) -> str:
|
|||||||
"""
|
"""
|
||||||
Dependência FastAPI que valida a API Key.
|
Dependência FastAPI que valida a API Key.
|
||||||
|
|
||||||
- Se PDFTOOLKIT_API_KEY não foi definida no ambiente → permite acesso livre
|
- Se PDFTOOLKIT_API_KEY não foi definida ou está vazia → permite
|
||||||
(modo desenvolvimento).
|
acesso livre (modo desenvolvimento).
|
||||||
- Se foi definida → exige que o header X-API-Key corresponda exatamente.
|
- Se foi definida → exige que o header X-API-Key corresponda
|
||||||
|
exatamente.
|
||||||
|
|
||||||
Retorna a própria chave se válida, ou levanta 401.
|
Retorna a própria chave se válida, ou levanta 401/403.
|
||||||
"""
|
"""
|
||||||
# Modo desenvolvimento: sem chave configurada
|
# Modo desenvolvimento: sem chave configurada
|
||||||
if _API_KEY is None:
|
if _API_KEY is None:
|
||||||
|
|||||||
254
core/compress.py
254
core/compress.py
@@ -1,20 +1,64 @@
|
|||||||
"""
|
"""
|
||||||
Estratégias de compressão e otimização de PDF.
|
Estratégias de compressão e otimização de PDF.
|
||||||
|
|
||||||
Usa pypdf + Pillow para re-amostragem de imagens e remoção de
|
Usa pypdf (API oficial de ImageFile.replace) + Pillow para
|
||||||
objetos redundantes. Mantém a estrutura original do PDF (inclusive
|
re-amostragem de imagens. A abordagem antiga de gravar em
|
||||||
PDF/A) quando possível.
|
`xobj._data` quebrava o /Filter do stream e falhava em silêncio
|
||||||
|
na maioria dos PDFs (FlateDecode / pixels crus).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from typing import Optional
|
import logging
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
from pypdf.constants import ImageAttributes as IA
|
|
||||||
|
|
||||||
from config import QUALITY_PRESETS
|
from config import QUALITY_PRESETS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_image(
|
||||||
|
img: Image.Image,
|
||||||
|
quality: int = 60,
|
||||||
|
max_dim: int = 2000,
|
||||||
|
) -> Tuple[Image.Image, dict]:
|
||||||
|
"""
|
||||||
|
Normaliza modo de cor e redimensiona para o preset.
|
||||||
|
|
||||||
|
Retorna (imagem_pronta, kwargs para Image.save / replace).
|
||||||
|
"""
|
||||||
|
# JPEG (via replace→PDF) não aceita alpha/CMYK/paleta de forma confiável
|
||||||
|
if img.mode in ("RGBA", "LA"):
|
||||||
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
|
alpha = img.split()[-1]
|
||||||
|
background.paste(img.convert("RGBA"), mask=alpha)
|
||||||
|
img = background
|
||||||
|
elif img.mode == "P":
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
if "A" in img.getbands():
|
||||||
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
|
background.paste(img, mask=img.split()[-1])
|
||||||
|
img = background
|
||||||
|
else:
|
||||||
|
img = img.convert("RGB")
|
||||||
|
elif img.mode == "CMYK":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
elif img.mode not in ("RGB", "L"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
if max(w, h) > max_dim:
|
||||||
|
ratio = max_dim / max(w, h)
|
||||||
|
new_size = (max(1, int(w * ratio)), max(1, int(h * ratio)))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
|
||||||
|
save_kwargs = {"quality": quality, "optimize": True}
|
||||||
|
return img, save_kwargs
|
||||||
|
|
||||||
|
|
||||||
def _downsample_image(
|
def _downsample_image(
|
||||||
img_bytes: bytes,
|
img_bytes: bytes,
|
||||||
@@ -23,34 +67,23 @@ def _downsample_image(
|
|||||||
fmt: str = "JPEG",
|
fmt: str = "JPEG",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Redimensiona e re-comprime uma imagem.
|
Redimensiona e re-comprime bytes de imagem (helper legado / pdfa).
|
||||||
- quality: 1-100 (JPEG)
|
|
||||||
- max_dim: lado máximo em px
|
Aceita apenas containers que o Pillow abra (JPEG/PNG/...).
|
||||||
- fmt: "JPEG" ou "PNG"
|
Pixels crus de streams FlateDecode NÃO funcionam aqui — use
|
||||||
|
compress_pdf(), que passa por page.images (PIL Image já montada).
|
||||||
"""
|
"""
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
img, save_kwargs = _prepare_image(img, quality=quality, max_dim=max_dim)
|
||||||
# Converte RGBA/CMYK/P (palette) para RGB para compressão JPEG
|
|
||||||
if img.mode not in ("RGB", "L"):
|
|
||||||
if fmt == "JPEG":
|
|
||||||
img = img.convert("RGB")
|
|
||||||
elif img.mode == "P":
|
|
||||||
img = img.convert("RGB")
|
|
||||||
|
|
||||||
# Redimensiona proporcionalmente se exceder max_dim
|
|
||||||
w, h = img.size
|
|
||||||
if max(w, h) > max_dim:
|
|
||||||
ratio = max_dim / max(w, h)
|
|
||||||
new_size = (int(w * ratio), int(h * ratio))
|
|
||||||
# Usa ANTIALIAS/LANCZOS para melhor qualidade na redução
|
|
||||||
img = img.resize(new_size, Image.LANCZOS)
|
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
if fmt == "JPEG":
|
out_fmt = "JPEG" if fmt.upper() == "JPEG" else "PNG"
|
||||||
img.save(buf, format="JPEG", quality=quality, optimize=True)
|
if out_fmt == "JPEG" and img.mode not in ("RGB", "L"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
if out_fmt == "JPEG":
|
||||||
|
img.save(buf, format="JPEG", **save_kwargs)
|
||||||
else:
|
else:
|
||||||
img.save(buf, format="PNG", optimize=True)
|
img.save(buf, format="PNG", optimize=True)
|
||||||
|
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@@ -60,75 +93,148 @@ def compress_pdf(
|
|||||||
remove_unused: bool = True,
|
remove_unused: bool = True,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Comprime um PDF re-amostrando imagens e otimizando estrutura.
|
Comprime um PDF re-amostrando imagens embutidas.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Conteúdo do PDF original.
|
data: Conteúdo do PDF original.
|
||||||
preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress").
|
preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress").
|
||||||
remove_unused: Remove objetos não referenciados.
|
remove_unused: Tenta deduplicar objetos idênticos no writer.
|
||||||
|
|
||||||
Retorna bytes do PDF comprimido.
|
Retorna bytes do PDF (pode ser maior em PDFs só-texto; o engine
|
||||||
|
decide se devolve o original nesse caso).
|
||||||
"""
|
"""
|
||||||
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
|
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
|
||||||
quality = opts["quality"]
|
quality = opts["quality"]
|
||||||
max_dim = opts["max_dim"]
|
max_dim = opts["max_dim"]
|
||||||
|
|
||||||
reader = PdfReader(io.BytesIO(data))
|
reader = PdfReader(io.BytesIO(data))
|
||||||
writer = PdfWriter()
|
# clone_from é obrigatório para ImageFile.replace() funcionar
|
||||||
|
writer = PdfWriter(clone_from=reader)
|
||||||
|
|
||||||
# Copia metadados
|
replaced = 0
|
||||||
writer.metadata = reader.metadata
|
errors = 0
|
||||||
|
|
||||||
# Registra fontes e objetos que usamos (para limpeza)
|
for page in writer.pages:
|
||||||
used_objects = set()
|
try:
|
||||||
|
images = list(page.images)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Falha ao listar imagens da página: %s", exc)
|
||||||
|
continue
|
||||||
|
|
||||||
for page_num, page in enumerate(reader.pages):
|
for img_file in images:
|
||||||
used_objects.add(id(page))
|
# Imagens inline não têm indirect_reference — replace() recusa
|
||||||
|
if getattr(img_file, "indirect_reference", None) is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pil = img_file.image
|
||||||
|
if pil is None:
|
||||||
|
continue
|
||||||
|
prepared, save_kwargs = _prepare_image(
|
||||||
|
pil, quality=quality, max_dim=max_dim
|
||||||
|
)
|
||||||
|
img_file.replace(prepared, **save_kwargs)
|
||||||
|
replaced += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors += 1
|
||||||
|
logger.debug(
|
||||||
|
"Falha ao recomprimir imagem %s: %s",
|
||||||
|
getattr(img_file, "name", "?"),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
# Verifica se há imagens na página
|
if remove_unused:
|
||||||
if "/XObject" in page.get("/Resources", {}):
|
try:
|
||||||
resources = page["/Resources"]
|
writer.compress_identical_objects()
|
||||||
if "/XObject" in resources:
|
except Exception:
|
||||||
xobjects = resources["/XObject"]
|
pass
|
||||||
for obj_name in list(xobjects.keys()):
|
|
||||||
xobj = xobjects[obj_name]
|
|
||||||
subtype = xobj.get("/Subtype", "")
|
|
||||||
if subtype == "/Image":
|
|
||||||
try:
|
|
||||||
raw = xobj.get_data()
|
|
||||||
if raw:
|
|
||||||
# Determina formato — PNG se tiver transparência
|
|
||||||
fmt = "PNG" if xobj.get("/ColorSpace") == "/DeviceCMYK" else "JPEG"
|
|
||||||
compressed = _downsample_image(raw, quality, max_dim, fmt)
|
|
||||||
xobj._data = compressed
|
|
||||||
xobj[IA.WIDTH], xobj[IA.HEIGHT] = Image.open(
|
|
||||||
io.BytesIO(compressed)
|
|
||||||
).size
|
|
||||||
except Exception:
|
|
||||||
# Se falhar na recompressão, mantém original
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Adiciona página modificada
|
|
||||||
writer.add_page(page)
|
|
||||||
|
|
||||||
# Escreve com compressão de streams
|
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
writer.write_stream(out_buf)
|
writer.write(out_buf)
|
||||||
|
result = out_buf.getvalue()
|
||||||
|
|
||||||
return out_buf.getvalue()
|
logger.info(
|
||||||
|
"compress_pdf preset=%s images_replaced=%d errors=%d "
|
||||||
|
"in=%d out=%d",
|
||||||
|
preset,
|
||||||
|
replaced,
|
||||||
|
errors,
|
||||||
|
len(data),
|
||||||
|
len(result),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compress_pdf_with_stats(
|
||||||
|
data: bytes,
|
||||||
|
preset: str = "ebook",
|
||||||
|
remove_unused: bool = True,
|
||||||
|
) -> Tuple[bytes, dict]:
|
||||||
|
"""
|
||||||
|
Igual a compress_pdf, mas devolve estatísticas úteis para UI/API.
|
||||||
|
"""
|
||||||
|
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
|
||||||
|
quality = opts["quality"]
|
||||||
|
max_dim = opts["max_dim"]
|
||||||
|
|
||||||
|
reader = PdfReader(io.BytesIO(data))
|
||||||
|
writer = PdfWriter(clone_from=reader)
|
||||||
|
|
||||||
|
replaced = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for page in writer.pages:
|
||||||
|
try:
|
||||||
|
images = list(page.images)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for img_file in images:
|
||||||
|
if getattr(img_file, "indirect_reference", None) is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pil = img_file.image
|
||||||
|
if pil is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
prepared, save_kwargs = _prepare_image(
|
||||||
|
pil, quality=quality, max_dim=max_dim
|
||||||
|
)
|
||||||
|
img_file.replace(prepared, **save_kwargs)
|
||||||
|
replaced += 1
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if remove_unused:
|
||||||
|
try:
|
||||||
|
writer.compress_identical_objects()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out_buf = io.BytesIO()
|
||||||
|
writer.write(out_buf)
|
||||||
|
result = out_buf.getvalue()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"images_replaced": replaced,
|
||||||
|
"images_skipped": skipped,
|
||||||
|
"images_errors": errors,
|
||||||
|
"original_bytes": len(data),
|
||||||
|
"result_bytes": len(result),
|
||||||
|
"reduced": len(result) < len(data),
|
||||||
|
"preset": preset,
|
||||||
|
}
|
||||||
|
return result, stats
|
||||||
|
|
||||||
|
|
||||||
def linearize_pdf(data: bytes) -> bytes:
|
def linearize_pdf(data: bytes) -> bytes:
|
||||||
"""
|
"""
|
||||||
Re-escreve o PDF de forma linearizada (otimizada para web).
|
Re-escreve o PDF (clone limpo). Não é linearização real de web-fast,
|
||||||
Usa pypdf para reestruturar.
|
mas reestrutura o arquivo via pypdf.
|
||||||
"""
|
"""
|
||||||
reader = PdfReader(io.BytesIO(data))
|
reader = PdfReader(io.BytesIO(data))
|
||||||
writer = PdfWriter()
|
writer = PdfWriter(clone_from=reader)
|
||||||
writer.metadata = reader.metadata
|
|
||||||
for page in reader.pages:
|
|
||||||
writer.add_page(page)
|
|
||||||
|
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
writer.write_stream(out_buf)
|
writer.write(out_buf)
|
||||||
return out_buf.getvalue()
|
return out_buf.getvalue()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import io
|
|||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from .validators import validate_pdf, assert_safe_filename
|
from .validators import validate_pdf, assert_safe_filename
|
||||||
from .compress import compress_pdf
|
from .compress import compress_pdf, compress_pdf_with_stats
|
||||||
from .pdfa import detect_pdfa, strip_pdfa, compress_pdfa
|
from .pdfa import detect_pdfa, strip_pdfa, compress_pdfa
|
||||||
from .operations import merge_pdfs, split_pdf, extract_pages, rotate_pages
|
from .operations import merge_pdfs, split_pdf, extract_pages, rotate_pages
|
||||||
from .operations import extract_text, extract_images, preview_pages
|
from .operations import extract_text, extract_images, preview_pages
|
||||||
@@ -38,8 +38,18 @@ class PDFEngine:
|
|||||||
ou que não possuem imagens adequadas para reamostragem. Nesse caso,
|
ou que não possuem imagens adequadas para reamostragem. Nesse caso,
|
||||||
preservamos o original em vez de entregar um arquivo maior.
|
preservamos o original em vez de entregar um arquivo maior.
|
||||||
"""
|
"""
|
||||||
result = compress_pdf(data, preset=preset)
|
result, stats = compress_pdf_with_stats(data, preset=preset)
|
||||||
return result if len(result) < len(data) else data
|
if stats["reduced"]:
|
||||||
|
return result
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compress_with_stats(data: bytes, preset: str = "ebook") -> Tuple[bytes, dict]:
|
||||||
|
"""Comprime e retorna (bytes, estatísticas) para feedback na UI."""
|
||||||
|
result, stats = compress_pdf_with_stats(data, preset=preset)
|
||||||
|
if stats["reduced"]:
|
||||||
|
return result, stats
|
||||||
|
return data, stats
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def pdfa_to_regular(data: bytes) -> bytes:
|
def pdfa_to_regular(data: bytes) -> bytes:
|
||||||
|
|||||||
46
core/pdfa.py
46
core/pdfa.py
@@ -139,30 +139,36 @@ def strip_pdfa(data: bytes) -> bytes:
|
|||||||
def compress_pdfa(data: bytes, quality: int = 60, max_dim: int = 2000) -> bytes:
|
def compress_pdfa(data: bytes, quality: int = 60, max_dim: int = 2000) -> bytes:
|
||||||
"""
|
"""
|
||||||
Comprime imagens internas mantendo a estrutura PDF/A.
|
Comprime imagens internas mantendo a estrutura PDF/A.
|
||||||
Usa a mesma lógica de compress.compress_pdf mas preserva
|
Usa a mesma lógica de compress_pdf (ImageFile.replace)
|
||||||
metadados XMP e OutputIntents.
|
mas preserva metadados XMP e OutputIntents.
|
||||||
"""
|
"""
|
||||||
from .compress import _downsample_image
|
from PIL import Image as PILImage
|
||||||
|
from .compress import _prepare_image
|
||||||
|
|
||||||
reader = PdfReader(io.BytesIO(data))
|
reader = PdfReader(io.BytesIO(data))
|
||||||
writer = PdfWriter()
|
writer = PdfWriter(clone_from=reader)
|
||||||
writer.metadata = reader.metadata
|
|
||||||
|
|
||||||
for page in reader.pages:
|
replaced = 0
|
||||||
if "/XObject" in page.get("/Resources", {}):
|
for page in writer.pages:
|
||||||
xobjects = page["/Resources"]["/XObject"]
|
try:
|
||||||
for obj_name in list(xobjects.keys()):
|
images = list(page.images)
|
||||||
xobj = xobjects[obj_name]
|
except Exception:
|
||||||
if xobj.get("/Subtype") == "/Image":
|
continue
|
||||||
try:
|
for img_file in images:
|
||||||
raw = xobj.get_data()
|
if getattr(img_file, "indirect_reference", None) is None:
|
||||||
if raw:
|
continue
|
||||||
compressed = _downsample_image(raw, quality, max_dim)
|
try:
|
||||||
xobj._data = compressed
|
pil = img_file.image
|
||||||
except Exception:
|
if pil is None:
|
||||||
pass
|
continue
|
||||||
writer.add_page(page)
|
prepared, save_kwargs = _prepare_image(
|
||||||
|
pil, quality=quality, max_dim=max_dim
|
||||||
|
)
|
||||||
|
img_file.replace(prepared, **save_kwargs)
|
||||||
|
replaced += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
writer.write_stream(out_buf)
|
writer.write(out_buf)
|
||||||
return out_buf.getvalue()
|
return out_buf.getvalue()
|
||||||
Reference in New Issue
Block a user