Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ coverage.xml
.pytest_cache/
desktop-tool/export/
desktop-tool/tests/export/
desktop-tool/tests/cards/
desktop-tool/TODO.md

cover/

Expand Down Expand Up @@ -147,6 +149,7 @@ dmypy.json
cython_debug/

.idea
.cursor/
.DS_Store
MPCAutofill/card_db.db
MPCAutofill/build
Expand Down Expand Up @@ -178,3 +181,9 @@ MPCAutofill/drives.csv
frontend/public/mockServiceWorker.js
/image-cdn/node_modules/*
/image-cdn/.wrangler/*

# Plan files
*.plan.md

# Claude Code
claude.md
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.13
3.13.11
Binary file not shown.
Binary file added desktop-tool/assets/icc/USWebCoatedSWOP.icc
Binary file not shown.
Binary file added desktop-tool/assets/placeholder_cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
161 changes: 153 additions & 8 deletions desktop-tool/autofill.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# nuitka-project: --mode=onefile
# nuitka-project: --include-data-files=client_secrets.json=client_secrets.json
# nuitka-project: --include-data-files=post-launch.html=post-launch.html
# nuitka-project: --include-data-files=assets/icc/USWebCoatedSWOP.icc=assets/icc/USWebCoatedSWOP.icc
# nuitka-project: --include-data-files=assets/icc/Adobe-Color-Profile-EULA.pdf=assets/icc/Adobe-Color-Profile-EULA.pdf
# nuitka-project: --noinclude-pytest-mode=nofollow
# nuitka-project: --windows-icon-from-ico=favicon.ico
# nuitka-project-if: {OS} == "Windows":
Expand All @@ -17,8 +19,11 @@

import logging
import os
import shutil
import subprocess
import sys
from contextlib import nullcontext
from pathlib import Path
from typing import Optional, Union

import click
Expand All @@ -31,7 +36,7 @@
from src.io import DEFAULT_WORKING_DIRECTORY, create_image_directory_if_not_exists
from src.logging import configure_loggers, logger
from src.order import CardOrder, aggregate_and_split_orders
from src.pdf_maker import PdfExporter
from src.pdf_maker import PdfExporter, PdfXConversionConfig, get_ghostscript_path, get_ghostscript_version
from src.processing import ImagePostProcessingConfig
from src.web_server import WebServer

Expand All @@ -47,6 +52,94 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]:
return f"{prompt} (Press Enter if you're not sure.)" if len(sys.argv) == 1 else False


def get_default_dtc_icc_profile() -> Optional[str]:
if "__compiled__" in globals():
candidate = Path(sys.argv[0]).resolve().parent / "assets/icc/USWebCoatedSWOP.icc"
else:
candidate = Path(__file__).resolve().parent / "assets/icc/USWebCoatedSWOP.icc"
return str(candidate) if candidate.is_file() else None


def wait_for_user_to_complete_order() -> None:
input(
f"If this software has brought you joy and you'd like to throw a few bucks my way,\n"
f"you can find my tip jar here: {bold('https://www.buymeacoffee.com/chilli.axe')} Thank you!\n\n"
f"Press {bold('Enter')} to close this window - your browser window will remain open.\n"
)


def ensure_ghostscript_available() -> str:
while True:
gs_path = get_ghostscript_path()
if gs_path:
version = get_ghostscript_version(gs_path)
if version:
logger.info(f"Ghostscript detected: {bold(version)} at {bold(gs_path)}")
else:
logger.info(f"Ghostscript detected at {bold(gs_path)}")
return gs_path

logger.info(
"DriveThruCards export requires Ghostscript for PDF/X-1a compliance."
)

should_install = click.confirm("Install Ghostscript now?", default=True)
if should_install:
if sys.platform.startswith("darwin"):
if shutil.which("brew") is None:
logger.info("Homebrew not found. Please install Homebrew, then re-run.")
else:
logger.info("Installing Ghostscript via Homebrew...")
result = subprocess.run(["brew", "install", "ghostscript"], check=False)
if result.returncode != 0:
logger.warning("Ghostscript installation via Homebrew failed.")
elif sys.platform.startswith("win"):
if shutil.which("winget") is None:
logger.info(
"winget not found. Please install Ghostscript from "
"https://ghostscript.com/releases/gsdnld.html and ensure it's on PATH."
)
else:
logger.info("Installing Ghostscript via winget...")
result = subprocess.run(
["winget", "install", "--id", "ArtifexSoftware.Ghostscript", "--accept-source-agreements"],
check=False,
)
if result.returncode != 0:
logger.warning("Ghostscript installation via winget failed.")
else:
if shutil.which("sudo") is None:
logger.info(
"sudo not found. Please install Ghostscript with your package manager manually."
)
elif shutil.which("apt") is not None:
logger.info("Installing Ghostscript via apt...")
result = subprocess.run(["sudo", "apt", "install", "-y", "ghostscript"], check=False)
if result.returncode != 0:
logger.warning("Ghostscript installation via apt failed.")
elif shutil.which("dnf") is not None:
logger.info("Installing Ghostscript via dnf...")
result = subprocess.run(["sudo", "dnf", "install", "-y", "ghostscript"], check=False)
if result.returncode != 0:
logger.warning("Ghostscript installation via dnf failed.")
elif shutil.which("yum") is not None:
logger.info("Installing Ghostscript via yum...")
result = subprocess.run(["sudo", "yum", "install", "-y", "ghostscript"], check=False)
if result.returncode != 0:
logger.warning("Ghostscript installation via yum failed.")
else:
logger.info("No supported package manager found. Please install Ghostscript manually.")
else:
logger.info(
"Please install Ghostscript, then return here to continue.\n"
"macOS: brew install ghostscript\n"
"Windows: https://ghostscript.com/releases/gsdnld.html\n"
"Linux: use your package manager (e.g., apt install ghostscript)."
)

input("Press Enter to re-check for Ghostscript, or Ctrl+C to exit.")


@click.command(context_settings={"show_default": True})
@click.option("-d", "--directory", default=None, help="The directory to search for order XML files.")
@click.option(
Expand Down Expand Up @@ -127,6 +220,11 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]:
"\nhttps://pillow.readthedocs.io/en/latest/handbook/concepts.html#filters-comparison-table"
),
)
@click.option(
"--dtc-icc-profile",
default=None,
help="Optional ICC profile path to embed in DriveThruCards exports.",
)
@click.option(
"--combine-orders/--no-combine-orders",
default=True,
Expand Down Expand Up @@ -175,6 +273,7 @@ def main(
image_post_processing: bool,
max_dpi: int,
downscale_alg: str,
dtc_icc_profile: Optional[str],
combine_orders: bool,
log_level: str,
write_debug_logs: bool,
Expand Down Expand Up @@ -208,17 +307,67 @@ def main(
logger.info("System sleep is being prevented during this execution.")
if image_post_processing:
logger.info("Images are being post-processed during this execution.")
target_site = TargetSites[site]
post_processing_config = (
ImagePostProcessingConfig(max_dpi=max_dpi, downscale_alg=ImageResizeMethods[downscale_alg])
if image_post_processing
else None
)
if exportpdf:
if target_site == TargetSites.DriveThruCards:
ensure_ghostscript_available()
using_default_icc = dtc_icc_profile is None
resolved_icc_profile = dtc_icc_profile or get_default_dtc_icc_profile()
if resolved_icc_profile is None:
raise Exception(
"Default DriveThruCards ICC profile was not found. "
"Ensure assets/icc/USWebCoatedSWOP.icc is available or pass --dtc-icc-profile."
)
if resolved_icc_profile and not os.path.isfile(resolved_icc_profile):
raise Exception(
f"DriveThruCards ICC profile path does not exist or is not a file: {bold(resolved_icc_profile)}"
)
icc_source = "bundled default" if using_default_icc else "user-provided"
logger.info(f"DriveThruCards ICC profile ({icc_source}): {bold(resolved_icc_profile)}")
# Keep images as RGB JPEG during PDF generation - fpdf can embed these directly
# with DCT compression. Ghostscript will handle CMYK conversion with the ICC
# profile during PDF/X-1a conversion, which produces better results than
# pre-converting to CMYK (which fpdf re-encodes with FlateDecode, bloating file size).
dtc_post_processing_config = ImagePostProcessingConfig(
max_dpi=300,
downscale_alg=ImageResizeMethods[downscale_alg],
output_format="JPEG",
convert_to_cmyk=False, # Let Ghostscript handle CMYK conversion
)
orders = CardOrder.from_xmls_in_folder(working_directory=working_directory)
for i, order in enumerate(orders, start=1):
exporter = PdfExporter(
order=order,
export_mode="drive_thru_cards",
pdfx_config=PdfXConversionConfig(icc_profile_path=resolved_icc_profile),
)
pdf_paths = exporter.execute(post_processing_config=dtc_post_processing_config)
# Only use the Ghostscript PDF/X-1a output - no fallback
dtc_pdf_path = next((path for path in reversed(pdf_paths) if path.endswith("_pdfx.pdf")), None)
if dtc_pdf_path is None:
logger.error(
"Ghostscript PDF/X-1a conversion failed. Cannot proceed with DriveThruCards upload.\n"
"Please fix the Ghostscript conversion issue and try again."
)
continue
AutofillDriver(
browser=Browsers[browser],
target_site=target_site,
binary_location=binary_location,
starting_url=target_site.value.starting_url,
).execute_drive_thru_cards_order(order=order, pdf_path=dtc_pdf_path)
if i < len(orders):
input(f"Press {bold('Enter')} to continue with the next DriveThruCards order.\n")
wait_for_user_to_complete_order()
elif exportpdf:
PdfExporter(order=CardOrder.from_xmls_in_folder(working_directory=working_directory)[0]).execute(
post_processing_config=post_processing_config
)
else:
target_site = TargetSites[site]
card_orders = aggregate_and_split_orders(
orders=CardOrder.from_xmls_in_folder(working_directory=working_directory),
target_site=target_site,
Expand All @@ -235,11 +384,7 @@ def main(
auto_save_threshold=auto_save_threshold if auto_save else None,
post_processing_config=post_processing_config,
)
input(
f"If this software has brought you joy and you'd like to throw a few bucks my way,\n"
f"you can find my tip jar here: {bold('https://www.buymeacoffee.com/chilli.axe')} Thank you!\n\n"
f"Press {bold('Enter')} to close this window - your browser window will remain open.\n"
)
wait_for_user_to_complete_order()
except ValidationException as e:
input(f"There was a problem with your order file:\n\n{bold(e)}\n\nPress Enter to exit.")
sys.exit(0)
Expand Down
2 changes: 2 additions & 0 deletions desktop-tool/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ ratelimit~=2.2.1
requests~=2.32.5
sanitize-filename~=1.2.0
selenium~=4.35
setuptools # Required for undetected-chromedriver on Python 3.13+ (distutils removed)
undetected-chromedriver~=3.5.5
wakepy==0.6.0
34 changes: 34 additions & 0 deletions desktop-tool/src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,34 @@ def accept_settings_url(self) -> str:
return self.format_url(self.accept_settings_url_route)


@attr.s
class DriveThruCardsSelectors:
product_url: str = attr.ib()
pdf_upload_input_selector: str = attr.ib(default="input[type='file']")
pdf_upload_input_index: int = attr.ib(default=0)
quantity_selector: str = attr.ib(default="")
add_to_cart_selector: str = attr.ib(default="")
continue_selector: str = attr.ib(default="")
# Login selectors - two step process: click login button, then click "Go to Log in" link
login_button_selector: str = attr.ib(default="button[data-cy='login']")
# Target the login link in the modal, not the logout link (both can have href='/en/')
go_to_login_selector: str = attr.ib(default=".modal a[href='/en/'], .modal-content a[href='/en/']")
# Publisher Tools link only appears when logged in as a publisher
logged_in_indicator_selector: str = attr.ib(default="a[href*='pub_tools.php']")


@attr.s
class DriveThruCardsSite:
base_url: str = attr.ib(default="https://www.drivethrucards.com")
selectors: DriveThruCardsSelectors = attr.ib(
default=attr.Factory(lambda: DriveThruCardsSelectors(product_url="https://www.drivethrucards.com"))
)

@property
def starting_url(self) -> str:
return self.selectors.product_url


class TargetSites(Enum):
MakePlayingCards = TargetSite(
base_url="https://www.makeplayingcards.com", starting_url_route="design/custom-blank-card.html"
Expand Down Expand Up @@ -214,6 +242,12 @@ class TargetSites(Enum):
Cardstocks.P10: "Plastique (100%)",
},
)
DriveThruCards = DriveThruCardsSite(
selectors=DriveThruCardsSelectors(
product_url="https://www.drivethrucards.com",
pdf_upload_input_selector="input[type='file']",
)
)


DPI_HEIGHT_RATIO = 300 / 1110 # TODO: share this between desktop tool and backend
Expand Down
Loading
Loading