Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ client_secrets.json
credentials.json
token.json

# Google Drive API key (copy config.example.json → config.json and fill in your key)
config.json

# Generated by build_exe.py — contains the XOR-obfuscated API key; never commit
src/_bundled_key.py

# Generated by build_exe.py — contains build-time flags; never commit
gui/_build_flags.py

Expand Down
8 changes: 2 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,7 @@ MPCFillToPDF/
## Key implementation notes

### Image download
- **Primary (API key configured):** `GET https://www.googleapis.com/drive/v3/files/{id}?alt=media&key={KEY}` via `requests`. Works for public Drive files; avoids anonymous rate limiting (HTTP 429). The key is read from `config.json` in dev and from the XOR-obfuscated `src/_bundled_key.py` module in the .exe.
- **Fallback (no API key):** `gdown.download(f"https://drive.google.com/uc?id={drive_id}", ...)` — the original behaviour; may hit rate limits on large batches.
- `src/config.py` → `get_drive_api_key()` handles resolution order (bundled → config.json → None).
- `src/_bundled_key.py` is generated by `build_exe.py` at build time and deleted afterwards; it is gitignored and never committed.
- `config.json` (gitignored) is the dev-time key store; `config.example.json` is the committed template.
- `GET https://lh4.googleusercontent.com/d/{id}=d` via `requests` with `stream=True`. Works for both "Anyone with the link" and "Public on the web" Drive sharing modes; returns the original file unchanged (same approach as mpc-autofill).
- Download with 5 parallel threads (matches mpc-autofill behaviour)

### Image cropping
Expand Down Expand Up @@ -168,4 +164,4 @@ MPCFillToPDF/
- Mock only at module boundaries (`patch("src.pipeline.download_all")`), never inside `src/`.
- Use `tmp_path` (pytest built-in) for all temporary files.
- Group related tests in a class named `TestFeatureName`; keep each test focused on one behaviour.
- Run `python -m pytest tests/ --ignore=tests/test_downloader.py` to run the suite without network-dependent tests. All 170 tests must pass before committing.
- Run `python -m pytest tests/` to run the full suite. All tests must pass before committing.
68 changes: 0 additions & 68 deletions build_exe.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,6 @@
folder and it will create `MPCFillToPDF/archivos generados/` and
`MPCFillToPDF/procesamiento/` next to itself.

API key embedding
-----------------
If `config.json` contains a valid `google_drive_api_key`, it is XOR-encoded
with a random mask and baked into `src/_bundled_key.py` before packaging.
PyInstaller bundles that module; at runtime the app decodes and uses the key
in memory. The key never appears as a plain string inside the binary.

`src/_bundled_key.py` is listed in .gitignore and deleted after the build.

Debug logging
-------------
Pass `--debug-logging` to bake `gui/_build_flags.py` with `DEBUG_LOGGING = True`.
Expand Down Expand Up @@ -60,8 +51,6 @@
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
Expand All @@ -74,63 +63,9 @@
ICONS = ROOT / "icons"
RESOURCES = ROOT / "resources"
VERSION_FILE = ROOT / "version_file.txt"
BUNDLED_KEY_PATH = ROOT / "src" / "_bundled_key.py"
BUILD_FLAGS_PATH = ROOT / "gui" / "_build_flags.py"


def _embed_api_key() -> bool:
"""Read API key from config.json or DRIVE_API_KEY env var, XOR-encode it, write src/_bundled_key.py.

Returns True if a key was successfully embedded, False otherwise.
The file must be deleted after the build regardless.
"""
key = ""

config_path = ROOT / "config.json"
if config_path.exists():
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
key = str(data.get("google_drive_api_key", "")).strip()
if key.startswith("YOUR_"):
key = ""
except Exception as exc:
print(f"WARNING: Could not parse config.json ({exc}).")

if not key:
key = os.environ.get("DRIVE_API_KEY", "").strip()

if not key:
print(
"WARNING: No API key found (config.json or DRIVE_API_KEY env var) — "
"API key will NOT be embedded."
)
return False

key_bytes = key.encode("utf-8")
mask = os.urandom(len(key_bytes))
encoded = bytes(a ^ b for a, b in zip(key_bytes, mask))

BUNDLED_KEY_PATH.write_text(
"# Auto-generated by build_exe.py — do not commit.\n"
f"_E = {encoded!r}\n"
f"_M = {mask!r}\n"
"\n"
"def _get_key() -> str:\n"
" return bytes(a ^ b for a, b in zip(_E, _M)).decode('utf-8')\n",
encoding="utf-8",
)
print(f"API key embedded (XOR-obfuscated) -> {BUNDLED_KEY_PATH.name}")
return True


def _remove_bundled_key() -> None:
try:
BUNDLED_KEY_PATH.unlink()
print(f"Cleaned up {BUNDLED_KEY_PATH.name}")
except FileNotFoundError:
pass


def _write_build_flags(debug_logging: bool) -> None:
BUILD_FLAGS_PATH.write_text(
f"# Auto-generated by build_exe.py — do not commit.\nDEBUG_LOGGING = {debug_logging}\n",
Expand Down Expand Up @@ -172,7 +107,6 @@ def main() -> None:
sys.exit(1)

_check_paths()
_embed_api_key()
_write_build_flags(cli_args.debug_logging)

sep = ";" if sys.platform == "win32" else ":"
Expand All @@ -191,7 +125,6 @@ def main() -> None:
f"--add-data={RESOURCES}{sep}resources",
"--hidden-import=PIL.Image",
"--hidden-import=reportlab.pdfgen",
"--hidden-import=gdown",
# Strip stdlib modules that are never imported at runtime.
# Reduces archive surface scanned by AV heuristics.
"--exclude-module=unittest",
Expand All @@ -215,7 +148,6 @@ def main() -> None:
try:
subprocess.run(args, check=True, cwd=ROOT)
finally:
_remove_bundled_key()
_remove_build_flags()

suffix = ".exe" if sys.platform == "win32" else ""
Expand Down
10 changes: 6 additions & 4 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _progress(stage: str, done: int, total: int) -> None:


def _cleanup(workdir: Path) -> None:
for sub in ("raw", "bled"):
for sub in ("raw", "bled", "scryfall"):
target = workdir / sub
if target.exists():
shutil.rmtree(target)
Expand All @@ -80,9 +80,11 @@ def _print_permission_error(e: DownloadPermissionError) -> None:
if e.position:
print(f" Posición en el PDF: {e.position}", file=sys.stderr)
print(file=sys.stderr)
print("Esto no es un fallo del programa.", file=sys.stderr)
print("La imagen ha perdido los permisos de acceso público en Google Drive.", file=sys.stderr)
print("Pide al creador del proxy que restaure los permisos.", file=sys.stderr)
print("Esto no es un fallo del programa. Posibles causas:", file=sys.stderr)
print(" • El archivo solo permite descarga con cuenta de Google", file=sys.stderr)
print(" («Cualquiera con el enlace» no basta para descarga anónima).", file=sys.stderr)
print(" • Le han quitado los permisos de acceso público.", file=sys.stderr)
print("Pide al creador que comparta las imágenes como «Público en Internet».", file=sys.stderr)


def _setup_logging(log_path: Path, verbose: bool) -> None:
Expand Down
3 changes: 0 additions & 3 deletions config.example.json

This file was deleted.

18 changes: 13 additions & 5 deletions gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,8 +1247,13 @@ def _handle(self, ev: tuple) -> None:
names = ", ".join(f"«{name}»" for _, name in perm_errors[:3])
more = f" y {len(perm_errors) - 3} más" if len(perm_errors) > 3 else ""
parts.append(
f"{len(perm_errors)} imagen(es) sin permiso de descarga: {names}{more}.\n"
"Pide al creador del proxy que restaure los permisos de Google Drive."
f"{len(perm_errors)} imagen(es) no se pudieron descargar: {names}{more}.\n"
"Posibles causas:\n"
" • El archivo en Google Drive solo permite descarga con cuenta de Google "
"(«Cualquiera con el enlace» no basta para descarga anónima).\n"
" • Le han quitado los permisos de acceso público.\n"
"Pide al creador del proxy que comparta las imágenes como "
"«Público en Internet» en Google Drive."
)
if timeout_errors:
names = ", ".join(f"«{name}»" for _, name in timeout_errors[:3])
Expand Down Expand Up @@ -1325,9 +1330,12 @@ def _handle(self, ev: tuple) -> None:
else:
parts[0] += "."
parts.append(
"Esto no es un fallo del programa: le han quitado los permisos "
"de acceso público a la imagen en Google Drive.\n"
"Pide al creador del proxy que restaure los permisos."
"Esto no es un fallo del programa. Posibles causas:\n"
" • El archivo en Google Drive solo permite descarga con cuenta de Google "
"(«Cualquiera con el enlace» no basta para descarga anónima).\n"
" • Le han quitado los permisos de acceso público.\n"
"Pide al creador del proxy que comparta las imágenes como "
"«Público en Internet» en Google Drive."
)
self.status_var.set("Error de descarga.")
self._finish_running()
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
Pillow>=10.0,<12
reportlab>=4.0,<5
gdown>=5.0,<6
requests>=2.28,<3
windnd>=1.0,<2
plyer>=2.0,<3
4 changes: 3 additions & 1 deletion src/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def save_settings(settings: AppSettings, base_dir: Path) -> None:
"cut_line_over_fronts": settings.cut_line_over_fronts,
"cut_line_over_backs": settings.cut_line_over_backs,
}
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
except Exception as exc:
_log.warning("Could not save settings.json: %s", exc)
37 changes: 0 additions & 37 deletions src/config.py

This file was deleted.

1 change: 1 addition & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ class Stage(str, Enum):

ProgressCallback = Callable[[int, int], None] | None
StageCallback = Callable[[str, int, int], None] | None
JobPdfStartCallback = Callable[[int, int, str], None] | None
SpeedCallback = Callable[[float, float], None] | None
ImageDoneCallback = Callable[[str], None] | None
56 changes: 26 additions & 30 deletions src/cropper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import math
from pathlib import Path

from PIL import Image, ImageOps
Expand All @@ -21,8 +20,6 @@
_CORNER_RADIUS_FRAC = 0.04
# Minimum luminance difference to consider a corner pixel "anomalous"
_CORNER_LUMA_THRESHOLD = 60
# Offset past the corner zone where border color is sampled
_CORNER_SAMPLE_OFFSET = 1.0


def _luminance(r: int, g: int, b: int) -> float:
Expand Down Expand Up @@ -83,45 +80,44 @@ def _fill_rounded_corners(img: Image.Image) -> Image.Image:
"""
w, h = img.size
radius = max(1, round(min(w, h) * _CORNER_RADIUS_FRAC))

# corner_name → (origin_x, origin_y, dx_sign, dy_sign)
corners = {
"tl": (0, 0, 1, 1),
"tr": (w - 1, 0, -1, 1),
"bl": (0, h - 1, 1, -1),
"br": (w - 1, h - 1, -1, -1),
}
radius_sq = radius * radius

# Precompute quarter-circle offsets once (integer comparison, no sqrt)
offsets = [
(dx, dy)
for dy in range(radius + 1)
for dx in range(radius + 1)
if dx * dx + dy * dy <= radius_sq
]

corner_defs = (
("tl", 0, 0, 1, 1),
("tr", w - 1, 0, -1, 1),
("bl", 0, h - 1, 1, -1),
("br", w - 1, h - 1, -1, -1),
)

any_filled = False
pixels = img.load()

for name, (ox, oy, sx, sy) in corners.items():
for name, ox, oy, sx, sy in corner_defs:
border_color = _sample_border_color(img, name, radius)
border_luma = _luminance(*border_color)

filled_this = False
for dy in range(radius):
for dx in range(radius):
# Distance from the corner vertex
dist = math.sqrt(dx * dx + dy * dy)
if dist > radius * _CORNER_SAMPLE_OFFSET:
continue
px = ox + dx * sx
py = oy + dy * sy
if not (0 <= px < w and 0 <= py < h):
continue
r, g, b = pixels[px, py][:3]
luma = _luminance(r, g, b)
if abs(luma - border_luma) > _CORNER_LUMA_THRESHOLD:
pixels[px, py] = border_color
filled_this = True

for dx, dy in offsets:
px = ox + dx * sx
py = oy + dy * sy
if not (0 <= px < w and 0 <= py < h):
continue
r, g, b = pixels[px, py][:3]
if abs(_luminance(r, g, b) - border_luma) > _CORNER_LUMA_THRESHOLD:
pixels[px, py] = border_color
filled_this = True
if filled_this:
any_filled = True

if any_filled:
_log.debug("Filled rounded corners")

return img


Expand Down
Loading
Loading