typstcard/format.py

415 lines
10 KiB
Python
Raw Normal View History

2023-09-24 00:28:46 +00:00
#!/usr/bin/env python3
2024-01-20 06:49:29 +00:00
import sys
2024-01-20 06:49:29 +00:00
sys.dont_write_bytecode = True
2023-09-24 00:28:46 +00:00
import argparse
2023-09-24 05:06:59 +00:00
import base64
2023-09-24 00:28:46 +00:00
import csv
import hashlib
2024-01-20 06:49:29 +00:00
import imb
2023-09-24 00:28:46 +00:00
import json
import os
2024-01-20 06:49:29 +00:00
import requests
2023-09-24 00:28:46 +00:00
import string
2023-12-03 01:16:39 +00:00
import subprocess
2023-09-24 00:28:46 +00:00
import typing
import urllib.parse
2023-09-24 00:28:46 +00:00
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import NewType, TypedDict
_cache: Path | None = None
2023-10-01 02:13:40 +00:00
2023-09-24 00:28:46 +00:00
def cache_dir() -> Path:
global _cache
if _cache is None:
_cache = root_dir() / "cache"
_cache.mkdir(exist_ok=True)
return _cache
_root: Path | None = None
def root_dir() -> Path:
global _root
if _root is None:
_root = Path(os.path.realpath(__file__)).parent
_root.mkdir(exist_ok=True)
return _root
IsoCode = NewType("IsoCode", str)
def iso_code(s: str) -> IsoCode:
2023-09-24 00:28:46 +00:00
if len(s) != 2:
raise ValueError("must be 2 characters long")
s = s.lower()
if not (s[0] in string.ascii_lowercase and s[1] in string.ascii_lowercase):
raise ValueError("must be ascii letters")
return IsoCode(s)
2023-09-24 00:28:46 +00:00
def get_discord_avatar(
url: urllib.parse.ParseResult, secrets: dict[str, str]
) -> typing.Optional[str]:
try:
uid = url.path
token = secrets["discord_token"]
user_info = requests.get(
f"https://discord.com/api/users/{uid}",
headers={"Authorization": f"Bot {token}"},
).json()
avatar_hash = user_info["avatar"]
return f"https://cdn.discordapp.com/avatars/{uid}/{avatar_hash}.png?size=4096"
except KeyError:
return None
def get_fedi_avatar(
url: urllib.parse.ParseResult, secrets: dict[str, str]
) -> typing.Optional[str]:
try:
mastodon_api = secrets["mastodon_api"]
user_info = requests.get(
f"{mastodon_api}/api/v1/accounts/lookup", params={"acct": url.path}
).json()
avatar_url = user_info["avatar_static"]
return avatar_url
except KeyError:
return None
def get_orig_avatar(
url: str, basename: str, secrets: dict[str, str]
) -> typing.Optional[bytes]:
url_parts = urllib.parse.urlparse(url)
if url_parts.scheme == "fedi":
real_url = get_fedi_avatar(url_parts, secrets)
elif url_parts.scheme == "discord":
real_url = get_discord_avatar(url_parts, secrets)
else:
real_url = url
if real_url is None:
return None
img_file = cache_dir() / basename
if img_file.exists():
with img_file.open("rb") as infile:
2023-09-24 05:06:59 +00:00
return infile.read()
result = requests.get(real_url)
if not result.ok:
return None
with img_file.open("wb") as outfile:
outfile.write(result.content)
return result.content
2023-09-24 05:06:59 +00:00
def get_avatar(url: str, secrets: dict[str, str]) -> Path | None:
basename = hashlib.sha256(url.encode("utf-8")).hexdigest()
file_path = cache_dir() / f"{basename}.svg"
if not file_path.exists():
avatar_raster = get_orig_avatar(url, basename, secrets)
if avatar_raster is None:
return None
2023-09-24 05:06:59 +00:00
svg_text = f"""<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<clipPath id="circle">
<circle cx="256" cy="256" r="256" />
</clipPath>
<image width="512" height="512" clip-path="url(#circle)"
xlink:href="data:;base64,{base64.b64encode(avatar_raster).decode("utf-8")}" />
</svg>"""
2023-09-24 05:06:59 +00:00
with open(file_path, "w") as svgfile:
svgfile.write(svg_text)
return file_path.relative_to(root_dir())
2023-09-24 00:28:46 +00:00
def get_country_name(
root: ET.ElementTree, destination: str, alt: str | None = None
2023-09-24 00:28:46 +00:00
) -> typing.Optional[str]:
elements = root.findall(
f"./localeDisplayNames/territories/territory[@type='{destination.upper()}']"
)
normal = None
for element in elements:
if element.attrib.get("alt") == alt:
return element.text
elif element.attrib.get("alt") is None:
normal = element.text
return normal
parser = argparse.ArgumentParser(
prog="format", description="format postcards with latex"
)
2023-12-03 01:16:39 +00:00
parser.add_argument(
"template", help="template to use", nargs="?", type=Path, default="2card"
)
2023-09-24 00:28:46 +00:00
parser.add_argument(
"-o", "--origin", help="origin country code", default="us", type=iso_code
)
parser.add_argument(
"-l",
"--language",
help="language to use for countries ",
default="en",
type=iso_code,
)
parser.add_argument(
"-c",
"--count",
default=1,
type=int,
help="Number of sets of labels to print, default 1 (labels only)",
)
parser.add_argument(
"-s",
"--skip",
default=0,
type=int,
help="Number of labels to skip (label sheets only)",
)
parser.add_argument(
"-a",
"--address-file",
default="addresses.csv",
2023-12-03 00:02:58 +00:00
type=Path,
2023-09-24 00:28:46 +00:00
help="CSV file containing addresses",
)
parser.add_argument(
"-i",
"--content-path",
default="content",
2023-12-03 00:02:58 +00:00
type=Path,
help="Directory containing content files",
)
parser.add_argument(
"-n",
"--no-content",
action="store_true",
help="Skip content, e.g. to make postcard back labels",
)
2023-12-03 01:16:39 +00:00
parser.add_argument(
"--sheet-name",
default="",
type=str,
help="Which sheet to use out of a .ods",
)
2023-10-01 02:13:40 +00:00
parser.add_argument(
"-d", "--dont-compile", action="store_true", help="Don't compile to output.pdf"
)
parser.add_argument(
2024-01-20 07:14:20 +00:00
"-N",
"--no-nixowos",
action="store_true",
help="Don't print nixowos (e.g. if it would go over stamp)",
)
2024-01-20 07:14:20 +00:00
parser.add_argument(
"-g", "--svg", action="store_true", help="Output SVG files instead of PDF"
)
2023-12-01 00:31:40 +00:00
parser.add_argument("-w", "--watch", action="store_true", help="Watch input files")
class Args(argparse.Namespace):
2023-12-03 00:02:58 +00:00
template: Path
origin: IsoCode
language: IsoCode
count: int
skip: int
2023-12-03 00:02:58 +00:00
address_file: Path
content_path: Path
no_content: bool
dont_compile: bool
2023-12-03 01:16:39 +00:00
sheet_name: str
args = parser.parse_args(args=None, namespace=Args())
2023-09-24 00:28:46 +00:00
2023-10-01 02:13:40 +00:00
cldr_root = ET.parse(
2023-09-24 00:28:46 +00:00
f"{os.getenv('CLDR_ROOT')}/share/unicode/cldr/common/main/{args.language}.xml"
)
2023-12-03 01:16:39 +00:00
if args.address_file.suffix == ".csv":
csv_filename = args.address_file
elif args.address_file.suffix == ".ods":
# https://help.libreoffice.org/latest/en-US/text/shared/guide/csv_params.html
2023-12-07 23:18:17 +00:00
export_options = (
"csv:Text - txt - csv (StarCalc):"
+ ",".join( # Magic CSV export string
[
str(ord(",")), # Field Separator
str(ord('"')), # Text Delimiter
"76", # Character Set - UTF-8 => 76
"", # starting line; ignored in export
"", # cell format codes; ignored in export
"1033", # Language Identifier - en-US => 1033
"", # Quoted field as text; default False
"", # Detect special numbers; default True
"", # Save cell contents as shown; default True
"", # Export cell formulas; default false
"", # Remove spaces; ignored in export
"-1", # Export sheets - all sheets => -1
]
)
)
2023-12-03 01:16:39 +00:00
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to",
export_options,
2023-12-03 01:16:39 +00:00
"--outdir",
str(cache_dir()),
args.address_file,
]
)
assert result.returncode == 0
csv_filename = cache_dir() / f"{args.address_file.stem}-{args.sheet_name}.csv"
else:
raise Exception("Unknown file type for --address-file")
2023-12-07 23:20:56 +00:00
class CSVRow(TypedDict):
Name: str
Address: str
DPC: str
Country: str
Design: str
Type: str
Personalization: str
Avatar: str | None
US: str
2023-12-03 01:16:39 +00:00
csvfile = open(csv_filename)
2023-09-24 00:28:46 +00:00
rows = csv.DictReader(csvfile)
with open("secrets.json") as secrets_file:
secrets = json.load(secrets_file)
2023-09-24 00:28:46 +00:00
current_serial = imb.get_first_serial()
mid = secrets.get("mailer_id")
2023-09-24 00:28:46 +00:00
class Card(TypedDict):
address: str
avatar: Path | None
2023-12-07 23:20:56 +00:00
row: CSVRow
imb: str
cards: list[Card] = []
2023-09-24 00:28:46 +00:00
for row in rows:
2023-12-07 23:20:56 +00:00
row = CSVRow(**row)
2023-09-24 00:28:46 +00:00
if row["Address"] == "":
continue
if row["Country"].lower() == args.origin:
country = []
else:
name = get_country_name(cldr_root, row["Country"])
assert name is not None
country = [name.upper()]
2023-09-24 00:28:46 +00:00
address = row["Address"].split("\n") + country
2023-12-07 23:20:56 +00:00
if (avatar_url := row.get("Avatar", "")) != "":
avatar = get_avatar(avatar_url, secrets) # type: ignore
2023-09-24 00:28:46 +00:00
else:
avatar = None
2023-12-07 23:20:56 +00:00
cards.append(
{
"address": "\n".join(address),
"avatar": avatar,
"row": row,
"imb": "",
}
)
2023-09-24 00:28:46 +00:00
# Typst can't access files outside the project root, except through a symlink
# Create one in cache to use here
p = cache_dir() / "content"
p.unlink(missing_ok=True)
content_full_path = Path(os.getcwd()).joinpath(args.content_path)
p.symlink_to(content_full_path)
2023-09-24 00:28:46 +00:00
cards = cards * args.count
serial = imb.get_first_serial()
if args.origin == "us":
for card in cards:
dpc = card["row"].get("DPC", "")
if dpc != "" and mid is not None:
2023-09-24 00:28:46 +00:00
card["imb"] = imb.generate(
0, 310, mid, serial, dpc.replace(" ", "").replace("-", "")
)
serial += 1
imb.write_current_serial(serial)
2023-12-03 01:16:39 +00:00
2023-12-03 00:02:58 +00:00
def serialize_paths(obj: object):
if isinstance(obj, Path):
return str(obj)
raise TypeError("Type not Serializable")
2023-12-03 01:16:39 +00:00
2024-01-20 06:47:46 +00:00
with (cache_dir() / "options.json").open("w") as options:
2023-09-24 00:28:46 +00:00
json.dump(
fp=options,
obj={
"args": args.__dict__,
"cards": cards,
},
2023-12-03 01:16:39 +00:00
default=serialize_paths,
2023-09-24 00:28:46 +00:00
)
2023-10-01 02:13:40 +00:00
if args.dont_compile:
exit()
font_paths = os.getenv("TYPST_FONT_PATHS")
assert font_paths is not None
2024-01-20 07:14:20 +00:00
if args.svg:
if os.path.exists(root_dir() / "output"):
for f in (root_dir() / "output").glob("*.svg"):
os.remove(f)
else:
os.mkdir(root_dir() / "output")
typst_args = [
2023-12-01 00:31:40 +00:00
"typst",
"watch" if args.watch else "compile",
"--root",
root_dir(),
2023-12-01 00:31:40 +00:00
"--font-path",
cache_dir() / "content",
2023-12-01 00:31:40 +00:00
"--font-path",
font_paths,
2023-12-01 00:31:40 +00:00
args.template,
2024-01-20 07:14:20 +00:00
root_dir() / "output" / "{n}.svg" if args.svg else "output.pdf",
]
# print(*typst_args)
os.execlp(
"typst",
*typst_args,
2023-12-01 00:31:40 +00:00
)