arkham-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.1
2
+ Name: arkham-utils
3
+ Version: 0.1.0
4
+ Summary: Arkham Horror LCG utilities
5
+ Author: Ian Su
6
+ Author-email: iansu1979+github@gmail.com
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Dist: Pillow (>=9.5.0,<10.0.0)
11
+ Requires-Dist: PyMuPDF (>=1.24.5,<2.0.0)
12
+ Requires-Dist: fpdf2 (>=2.7.4,<3.0.0)
13
+ Requires-Dist: platformdirs (>=4.3.7,<5.0.0)
14
+ Requires-Dist: requests (>=2.29.0,<3.0.0)
15
+ Requires-Dist: requests-cache (>=1.2.1,<2.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Arkham Horror LCG utilities
19
+
20
+ A simple set of utilities to read from [ArkhamDB](https://arkhamdb.com/) and [OCTGN files](https://github.com/GeckoTH/arkham-horror) mainly used to generate printable proxies in PDF format.
21
+
22
+ ```sh
23
+ pip install arkham-utils
24
+ ```
25
+
26
+ ## Using ArkhamDB images
27
+
28
+ Note the images from ArkhamDB have an FFG watermark on them.
29
+
30
+ ```py
31
+ from arkham_utils.arkhamdb import db
32
+ from arkham_utils.pdf.builder import PDFBuilder
33
+
34
+ cards = [db.search(name) for name in [
35
+ 'Blackjack',
36
+ 'Guts',
37
+ 'Emergency Cache',
38
+ 'Elder Sign',
39
+ 'Shrivelling',
40
+ 'Daisy Walker'
41
+ ]]
42
+ pdf = PDFBuilder(cards)
43
+ pdf.write('proxies.pdf')
44
+ ```
45
+
46
+ ## Using OCTGN images
47
+
48
+ You will need to first download the image packs from <https://ahlcgoctgn.wordpress.com/image-packs/> into your application support directory (`Library/Application Support/arkham_utils` on MacOS).
49
+
50
+ ```py
51
+ from arkham_utils.octgn import db
52
+ from arkham_utils.pdf.builder import PDFBuilder
53
+
54
+ cards = [db.find(name) for name in [
55
+ 'Blackjack',
56
+ 'Guts',
57
+ 'Emergency Cache',
58
+ 'Elder Sign',
59
+ 'Shrivelling',
60
+ 'Daisy Walker'
61
+ ]]
62
+ pdf = PDFBuilder(cards)
63
+ pdf.write('proxies.pdf')
64
+ ```
65
+
66
+ ### Proxying an entire set
67
+
68
+ ```py
69
+ from arkham_utils.octgn import db
70
+ from arkham_utils.pdf.builder import PDFBuilder, PDFBuilderConfig
71
+
72
+ # write all the non-mini cards as a PDF
73
+ barkham = db.find_set('Meowlathotep')
74
+ PDFBuilder([c for c in barkham.cards if c.type != 'Mini']).write('barkham.pdf')
75
+
76
+ # write the mini investigator cards
77
+ mini_cfg = PDFBuilderConfig()
78
+ mini_cfg.cards_per_row = 4
79
+ mini_cfg.rows_per_page = 4
80
+ mini_cfg.card_height = 2.5
81
+ mini_cfg.card_width = 1.625
82
+ PDFBuilder([c for c in barkham.cards if c.type == 'Mini'], mini_cfg).write('barkham_minis.pdf')
83
+ ```
84
+
@@ -0,0 +1,66 @@
1
+ # Arkham Horror LCG utilities
2
+
3
+ A simple set of utilities to read from [ArkhamDB](https://arkhamdb.com/) and [OCTGN files](https://github.com/GeckoTH/arkham-horror) mainly used to generate printable proxies in PDF format.
4
+
5
+ ```sh
6
+ pip install arkham-utils
7
+ ```
8
+
9
+ ## Using ArkhamDB images
10
+
11
+ Note the images from ArkhamDB have an FFG watermark on them.
12
+
13
+ ```py
14
+ from arkham_utils.arkhamdb import db
15
+ from arkham_utils.pdf.builder import PDFBuilder
16
+
17
+ cards = [db.search(name) for name in [
18
+ 'Blackjack',
19
+ 'Guts',
20
+ 'Emergency Cache',
21
+ 'Elder Sign',
22
+ 'Shrivelling',
23
+ 'Daisy Walker'
24
+ ]]
25
+ pdf = PDFBuilder(cards)
26
+ pdf.write('proxies.pdf')
27
+ ```
28
+
29
+ ## Using OCTGN images
30
+
31
+ You will need to first download the image packs from <https://ahlcgoctgn.wordpress.com/image-packs/> into your application support directory (`Library/Application Support/arkham_utils` on MacOS).
32
+
33
+ ```py
34
+ from arkham_utils.octgn import db
35
+ from arkham_utils.pdf.builder import PDFBuilder
36
+
37
+ cards = [db.find(name) for name in [
38
+ 'Blackjack',
39
+ 'Guts',
40
+ 'Emergency Cache',
41
+ 'Elder Sign',
42
+ 'Shrivelling',
43
+ 'Daisy Walker'
44
+ ]]
45
+ pdf = PDFBuilder(cards)
46
+ pdf.write('proxies.pdf')
47
+ ```
48
+
49
+ ### Proxying an entire set
50
+
51
+ ```py
52
+ from arkham_utils.octgn import db
53
+ from arkham_utils.pdf.builder import PDFBuilder, PDFBuilderConfig
54
+
55
+ # write all the non-mini cards as a PDF
56
+ barkham = db.find_set('Meowlathotep')
57
+ PDFBuilder([c for c in barkham.cards if c.type != 'Mini']).write('barkham.pdf')
58
+
59
+ # write the mini investigator cards
60
+ mini_cfg = PDFBuilderConfig()
61
+ mini_cfg.cards_per_row = 4
62
+ mini_cfg.rows_per_page = 4
63
+ mini_cfg.card_height = 2.5
64
+ mini_cfg.card_width = 1.625
65
+ PDFBuilder([c for c in barkham.cards if c.type == 'Mini'], mini_cfg).write('barkham_minis.pdf')
66
+ ```
File without changes
@@ -0,0 +1,50 @@
1
+ import re
2
+ from typing import Generator
3
+ import requests
4
+ from arkham_utils.arkhamdb.card import ArkhamDBCard
5
+ from arkham_utils.arkhamdb.constants import API
6
+ from arkham_utils.arkhamdb.set import ArkhamDBSet
7
+ import requests_cache
8
+ from platformdirs import user_cache_dir
9
+
10
+ requests_cache.install_cache(
11
+ f'{user_cache_dir('arkham_utils')}/arkhamdb_cache', expire_after=1800)
12
+
13
+
14
+ class ArkhamDB(object):
15
+ def __init__(self):
16
+ print('loading packs', f'{API}/packs/')
17
+ r = requests.get(f'{API}/packs/')
18
+ self.packs_data = r.json()
19
+ self.packs = [ArkhamDBSet(pack) for pack in self.packs_data]
20
+ print(f'loaded {len(self.packs)} packs')
21
+
22
+ def find_all_sets(self, regex: str) -> Generator[ArkhamDBSet, None, None]:
23
+ for s in self.packs:
24
+ m = re.search(regex, s.name)
25
+ if m:
26
+ yield s
27
+
28
+ def find_set(self, regex: str) -> ArkhamDBSet:
29
+ return next(self.find_all_sets(regex))
30
+
31
+ # beware that this will return Revised Core and Core together
32
+ def find_sets_in_cycle(self, regex: str) -> Generator[ArkhamDBSet, None, None]:
33
+ root = self.find_set(regex)
34
+ cycle = root.data['cycle_position']
35
+ for s in self.packs:
36
+ if s.data['cycle_position'] == cycle:
37
+ yield s
38
+
39
+ def find_all_cards(self, regex: str) -> Generator[ArkhamDBCard, None, None]:
40
+ for s in self.packs:
41
+ for c in s.cards:
42
+ m = re.search(regex, c.name)
43
+ if m:
44
+ yield c
45
+
46
+ def search(self, regex: str) -> ArkhamDBCard:
47
+ return next(self.find_all_cards(regex))
48
+
49
+
50
+ db = ArkhamDB()
@@ -0,0 +1,61 @@
1
+ import requests
2
+ from arkham_utils.card import ArkhamCard
3
+ from PIL import Image
4
+ import io
5
+ import requests
6
+ from arkham_utils.arkhamdb.constants import API
7
+
8
+
9
+ def _get_image_from_url(url) -> Image.Image:
10
+ r = requests.get(url)
11
+ return Image.open(io.BytesIO(r.content))
12
+
13
+
14
+ class ArkhamDBCard(ArkhamCard):
15
+ def __init__(self, data):
16
+ self.data = data
17
+ self._images = None
18
+
19
+ def _lazy_load_images(self):
20
+ if self._images is not None:
21
+ return
22
+ if 'backimagesrc' in self.data:
23
+ self._images = _get_image_from_url(f'https://arkhamdb.com{self.data["imagesrc"]}'), _get_image_from_url(
24
+ f'https://arkhamdb.com{self.data["backimagesrc"]}')
25
+ elif 'imagesrc' in self.data:
26
+ self._images = _get_image_from_url(
27
+ f'https://arkhamdb.com{self.data["imagesrc"]}'),
28
+ else:
29
+ self._images = []
30
+ print(f"{self.name} has no images!")
31
+
32
+ @property
33
+ def code(self):
34
+ return self.data['code']
35
+
36
+ @property
37
+ def name(self):
38
+ return self.data['name']
39
+
40
+ @property
41
+ def image(self):
42
+ self._lazy_load_images()
43
+ return self._images[0]
44
+
45
+ @property
46
+ def faction(self):
47
+ return self.data['faction_code']
48
+
49
+ @property
50
+ def type(self):
51
+ return self.data['type_code']
52
+
53
+ @property
54
+ def images(self):
55
+ self._lazy_load_images()
56
+ return self._images
57
+
58
+ @staticmethod
59
+ def from_id(id: str):
60
+ r = requests.get(f"{API}/card/{id}.json")
61
+ return ArkhamDBCard(r.json())
@@ -0,0 +1 @@
1
+ API = 'https://arkhamdb.com/api/public'
@@ -0,0 +1,38 @@
1
+ from typing import Dict, List
2
+ from arkham_utils.arkhamdb.card import ArkhamDBCard
3
+ from arkham_utils.arkhamdb.constants import API
4
+ import requests
5
+
6
+
7
+ class ArkhamDBPublicDeck(object):
8
+ id: int
9
+ data: object
10
+ _cards: Dict[str, ArkhamDBCard]
11
+
12
+ def __init__(self, id: int, is_decklist: bool = False):
13
+ self.id = id
14
+ key = 'decklist' if is_decklist else 'deck'
15
+ r = requests.get(f"{API}/{key}/{self.id}.json")
16
+ print(f"{API}/{key}/{self.id}.json")
17
+ print(r.content)
18
+ self.data = r.json()
19
+ self._cards = None
20
+
21
+ @property
22
+ def cards(self):
23
+ if self._cards is None:
24
+ self._cards = {}
25
+ for id in self.data['slots']:
26
+ self._cards[id] = ArkhamDBCard.from_id(id)
27
+
28
+ all_cards = []
29
+ for id, count in self.data['slots'].items():
30
+ all_cards.extend([self._cards[id]] * count)
31
+
32
+ return all_cards
33
+
34
+ def dump(self):
35
+ self.cards
36
+ print(f"{self.data['name']}")
37
+ for id, count in self.data['slots'].items():
38
+ print(f" {self._cards[id].name} x{count}")
@@ -0,0 +1,32 @@
1
+ from typing import Generator
2
+ import requests
3
+ from arkham_utils.arkhamdb.card import ArkhamDBCard
4
+ from arkham_utils.arkhamdb.constants import API
5
+ import re
6
+
7
+ class ArkhamDBSet(object):
8
+ def __init__(self, data):
9
+ self.data = data
10
+ self._cards = None
11
+
12
+ def _lazy_load_cards(self, include_encounter=True):
13
+ if self._cards is not None:
14
+ return
15
+ r = requests.get(
16
+ f"{API}/cards/{self.data['code']}?encounter={1 if include_encounter else 0}")
17
+ cards_data = r.json()
18
+ self._cards = [ArkhamDBCard(data) for data in cards_data]
19
+
20
+ @property
21
+ def name(self):
22
+ return self.data['name']
23
+
24
+ @property
25
+ def cards(self):
26
+ self._lazy_load_cards()
27
+ return self._cards
28
+
29
+ def find_by_regex(self, regex: str | re.Pattern[str]) -> Generator[ArkhamDBCard, None, None]:
30
+ for c in self.cards:
31
+ if re.match(regex, c.name):
32
+ yield c
@@ -0,0 +1,18 @@
1
+ from abc import ABC,abstractmethod
2
+
3
+ class ArkhamCard(ABC):
4
+ def __init__(self, front, back=None):
5
+ self._images = (front, back) if back else front,
6
+
7
+ @property
8
+ def image(self):
9
+ return self._images[0]
10
+
11
+ @property
12
+ def images(self):
13
+ return self._images
14
+
15
+ @property
16
+ @abstractmethod
17
+ def type(self):
18
+ return 'Card'
@@ -0,0 +1,63 @@
1
+ import glob
2
+ import re
3
+ import time
4
+ from typing import List
5
+ import zipfile
6
+
7
+ import requests
8
+ import os
9
+
10
+ from arkham_utils.octgn.set import OctgnSet
11
+ from platformdirs import user_data_dir
12
+
13
+ class OctgnSetDatabase(object):
14
+ sets: List[OctgnSet]
15
+ path: str
16
+
17
+ @property
18
+ def gitzip(self):
19
+ return f'{self.path}/o8g.zip'
20
+
21
+ def download(self):
22
+ if os.path.exists(self.gitzip) and os.path.getmtime(self.gitzip) > time.time() - 60 * 60 * 24:
23
+ return
24
+ os.makedirs(self.path, exist_ok=True)
25
+ r = requests.get('https://github.com/GeckoTH/arkham-horror/archive/refs/heads/master.zip', allow_redirects=True)
26
+ open(self.gitzip, 'wb').write(r.content)
27
+
28
+ def __init__(self, path='o8g'):
29
+ self.path = path
30
+ self.sets = []
31
+ self.download()
32
+ with zipfile.ZipFile(self.gitzip) as z:
33
+ for f in z.filelist:
34
+ if f.filename.endswith('/set.xml'):
35
+ with z.open(f.filename) as fd:
36
+ s = OctgnSet(fd)
37
+ self.sets.append(s)
38
+ print(f'loaded {len(self.sets)} sets, {sum([len(s.cards) for s in self.sets])} cards')
39
+
40
+ def lookup(self, card_id):
41
+ for s in self.sets:
42
+ card = s.find(card_id)
43
+ if card is not None:
44
+ return card
45
+ return None
46
+
47
+ def find_all(self, regex, xp=None):
48
+ for s in self.sets:
49
+ for found in s.find_by_regex(regex, xp):
50
+ yield found
51
+
52
+ def find(self, regex, xp=None):
53
+ return next(self.find_all(regex, xp))
54
+
55
+ def find_all_sets(self, regex):
56
+ for s in self.sets:
57
+ if re.search(regex, s.name):
58
+ yield s
59
+
60
+ def find_set(self, regex):
61
+ return next(self.find_all_sets(regex))
62
+
63
+ db = OctgnSetDatabase(f'{user_data_dir('arkham_utils')}/o8g')
@@ -0,0 +1,25 @@
1
+ from xml.etree.ElementTree import Element
2
+ from arkham_utils.card import ArkhamCard
3
+ from arkham_utils.octgn.image_db import image_db
4
+
5
+ class OctgnCard(ArkhamCard):
6
+ def __init__(self, card_elem: Element, image_db=image_db):
7
+ self.elem = card_elem
8
+ self.image_db = image_db
9
+ @property
10
+ def id(self):
11
+ return self.elem.attrib['id']
12
+ @property
13
+ def name(self):
14
+ return self.elem.text
15
+ @property
16
+ def image(self):
17
+ return self.image_db.get_image(self.id)
18
+ @property
19
+ def images(self):
20
+ return self.image_db.get_images(self.id)
21
+ @property
22
+ def quantity(self):
23
+ return int(self.elem.attrib['qty'])
24
+ def __str__(self):
25
+ return f"[{self.id}] {self.name}"
@@ -0,0 +1,16 @@
1
+ import csv
2
+ import sys
3
+ import xml.etree.ElementTree as ET
4
+
5
+ from arkham_utils.octgn.card import OctgnCard
6
+
7
+
8
+ class OctgnDeck(object):
9
+ def __init__(self, o8d='starter-deck.o8d'):
10
+ tree = ET.parse(o8d)
11
+ self.cards = [OctgnCard(card) for card in tree.findall('.//card')]
12
+ def csv(self, fh=sys.stdout):
13
+ writer = csv.writer(fh)
14
+ for card in self.cards:
15
+ writer.writerow([card.id, card.name, card.quantity])
16
+
@@ -0,0 +1,52 @@
1
+ import glob
2
+ import zipfile
3
+ import re
4
+ from PIL import Image
5
+ from platformdirs import user_data_dir
6
+ import os
7
+
8
+ # download files from https://ahlcgoctgn.wordpress.com/image-packs/ into o8c/
9
+ class OctgnImageDatabase(object):
10
+ def __init__(self, path='o8c'):
11
+ if not os.path.exists(path):
12
+ raise RuntimeError(f'{path} does not exist! Please download the image packs from https://ahlcgoctgn.wordpress.com/image-packs/ into {path}')
13
+
14
+ self.images = {} # uuid -> (zip_name, zip_internal_file)
15
+ self.images_back = {}
16
+ for o8c in glob.glob(f'{path}/*.o8c'):
17
+ with zipfile.ZipFile(o8c) as z:
18
+ for f in z.filelist:
19
+ m = re.match(
20
+ r'[^/]+/Sets/[^/]+/Cards/([^\.]+)\.(jpg|png)$', f.filename)
21
+ if m is not None:
22
+ uuid = m.group(1)
23
+ self.images[uuid] = (o8c, f.filename)
24
+ else:
25
+ m = re.match(
26
+ r'[^/]+/Sets/[^/]+/Cards/([^\.]+)\.[A-Za-z]\.(jpg|png)$', f.filename)
27
+ if m is not None:
28
+ uuid = m.group(1)
29
+ self.images_back[uuid] = (o8c, f.filename)
30
+
31
+ print(f'loaded {len(self.images)} card images and {len(self.images_back)} back images')
32
+
33
+ def get_image(self, card_id, front=True):
34
+ images = self.images if front else self.images_back
35
+ if card_id not in images:
36
+ raise KeyError('unknown card_id', card_id)
37
+ o8c, fname = images[card_id]
38
+ with zipfile.ZipFile(o8c) as z:
39
+ with z.open(fname) as f:
40
+ i = Image.open(f)
41
+ i.load()
42
+ return i
43
+
44
+ def get_images(self, card_id):
45
+ front = self.get_image(card_id)
46
+ try:
47
+ back = self.get_image(card_id, False)
48
+ return front, back
49
+ except KeyError:
50
+ return front,
51
+
52
+ image_db = OctgnImageDatabase(f'{user_data_dir('arkham_utils')}/o8c')
@@ -0,0 +1,56 @@
1
+ import csv
2
+ import re
3
+ import sys
4
+ from typing import List
5
+ import xml.etree.ElementTree as ET
6
+
7
+ from arkham_utils.octgn.set_card import OctgnSetCard
8
+
9
+
10
+ # check out repo https://github.com/GeckoTH/arkham-horror/tree/master/o8g
11
+ class OctgnSet(object):
12
+ cards: List[OctgnSetCard]
13
+
14
+ def __init__(self, set_xml='o8g/Sets/Core Set/set.xml'):
15
+ self.xml = set_xml
16
+ tree = ET.parse(set_xml)
17
+ self.name = tree.getroot().attrib['name']
18
+ self.cards = [OctgnSetCard(card) for card in tree.findall('.//card')]
19
+ self.counts = {}
20
+ for card in self.cards:
21
+ try:
22
+ self.counts[card.id] = card.quantity
23
+ except AttributeError:
24
+ self.counts[card.id] = 1
25
+ # logging.warning(f'{set_xml} card {card.name} ({card.id}) has no quantity')
26
+ self.proxies = []
27
+
28
+ def csv(self, fh=sys.stdout):
29
+ writer = csv.writer(fh)
30
+ for card in self.cards:
31
+ writer.writerow([card.id, card.set_number, card.name, card.quantity, card.xp])
32
+
33
+ def find(self, card_id):
34
+ return next((x for x in self.cards if x.id == card_id), None)
35
+
36
+ def find_by_regex(self, pattern, xp=None):
37
+ return [x for x in self.cards if re.search(pattern, x.name) is not None and (xp is None or xp == x.xp)]
38
+
39
+ def remove(self, card_id):
40
+ if card_id in self.counts:
41
+ self.counts[card_id] -= 1
42
+ # print(f'ok removing {self.find(card_id).name}, {self.counts[card_id]} left')
43
+ if self.counts[card_id] == 0:
44
+ del self.counts[card_id]
45
+ return True
46
+ if not self.find(card_id):
47
+ raise KeyError(f'Unknown card id: {card_id}')
48
+ return False
49
+
50
+ def remove_deck(self, deck):
51
+ for card in deck.cards:
52
+ for _ in range(card.quantity):
53
+ ok = self.remove(card.id)
54
+ if not ok:
55
+ # print(f'proxy: {card.name}')
56
+ self.proxies.append(card)
@@ -0,0 +1,29 @@
1
+ from arkham_utils.octgn.card import OctgnCard
2
+
3
+ class OctgnSetCard(OctgnCard):
4
+ def get_property(self, key):
5
+ return self.elem.find(f'property[@name="{key}"]').attrib['value']
6
+ @property
7
+ def name(self):
8
+ return self.elem.attrib['name']
9
+ @property
10
+ def size(self):
11
+ try:
12
+ return self.elem.attrib['size']
13
+ except:
14
+ return None
15
+ @property
16
+ def set_number(self):
17
+ return int(self.get_property("Card Number"))
18
+ @property
19
+ def quantity(self):
20
+ return int(self.get_property("Quantity"))
21
+ @property
22
+ def type(self):
23
+ return self.get_property("Type")
24
+ @property
25
+ def xp(self):
26
+ try:
27
+ return int(self.get_property("Level"))
28
+ except:
29
+ return 'n/a'
File without changes
@@ -0,0 +1,105 @@
1
+ from typing import Dict, List, Tuple
2
+
3
+ from fpdf import FPDF
4
+
5
+ from arkham_utils.card import ArkhamCard
6
+ from PIL import Image
7
+
8
+
9
+ MAX_PAGES = 100
10
+ card_width = 2.4
11
+ card_height = 3.5
12
+ cards_per_row = 3
13
+ rows_per_page = 3
14
+ a4_width = 8.27
15
+ a4_height = 11.69
16
+ bleed_thickness = 0.05
17
+ padding = 0.015
18
+
19
+
20
+ class PDFBuilderConfig(object):
21
+ card_width: float = card_width
22
+ card_height: float = card_height
23
+ cards_per_row: int = cards_per_row
24
+ rows_per_page: int = rows_per_page
25
+ bleed_thickness: float = bleed_thickness
26
+ paper_width: float = a4_width
27
+ paper_height: float = a4_height
28
+ padding: float = padding
29
+
30
+
31
+ class PDFBuilder(object):
32
+ def __init__(self, cards: List[ArkhamCard], config: PDFBuilderConfig = PDFBuilderConfig()):
33
+ self.cards = cards
34
+ self.config = config
35
+ self.layout = self.get_layout()
36
+
37
+ def get_layout(self) -> Dict[Tuple[int, int, int], Image.Image]:
38
+ layout = {}
39
+ twosided = [c for c in self.cards if len(c.images) == 2]
40
+ onesided = [c for c in self.cards if len(c.images) == 1]
41
+ for card in twosided:
42
+ self.layout_2side(layout, card)
43
+ for card in onesided:
44
+ self.layout_1side(layout, card)
45
+ return layout
46
+
47
+ def layout_2side(self, layout, card: ArkhamCard):
48
+ front, back = card.images
49
+ if front.width < front.height:
50
+ front = front.rotate(180)
51
+ for p in range(MAX_PAGES):
52
+ for r in range(0, self.config.rows_per_page, 2):
53
+ if r + 1 == self.config.rows_per_page:
54
+ continue
55
+ for c in range(self.config.cards_per_row):
56
+ if (p, r, c) not in layout:
57
+ layout[(p, r, c)] = back
58
+ layout[(p, r + 1, c)] = front
59
+ return
60
+ raise RuntimeError('Exceeded last page')
61
+
62
+ def layout_1side(self, layout, card: ArkhamCard):
63
+ for p in range(MAX_PAGES):
64
+ for r in range(self.config.rows_per_page):
65
+ for c in range(self.config.cards_per_row):
66
+ if (p, r, c) not in layout:
67
+ layout[(p, r, c)] = card.image
68
+ return
69
+ raise RuntimeError('Exceeded last page')
70
+
71
+ def write(self, filename: str):
72
+ padding_left = (self.config.paper_width -
73
+ (self.config.cards_per_row * self.config.card_width) - ((self.config.cards_per_row - 1) * self.config.padding)) / 2
74
+ padding_top = (self.config.paper_height - (self.config.rows_per_page *
75
+ self.config.card_height) - ((self.config.rows_per_page - 1) * self.config.padding)) / 2
76
+
77
+ pdf = FPDF(orientation='P', format='A4', unit='in')
78
+ pages = max([p for (p, r, c) in self.layout]) + 1
79
+ for p in range(pages):
80
+ pdf.add_page()
81
+ # add bleed bg
82
+ for row in range(self.config.rows_per_page):
83
+ for col in range(self.config.cards_per_row):
84
+ if (p, row, col) in self.layout:
85
+ pdf.rect(w=self.config.card_width + 2 * self.config.bleed_thickness,
86
+ h=self.config.card_height + 2 * self.config.bleed_thickness,
87
+ x=padding_left + col *
88
+ (self.config.card_width + self.config.padding) -
89
+ self.config.bleed_thickness,
90
+ y=padding_top + row *
91
+ (self.config.card_height + self.config.padding) -
92
+ self.config.bleed_thickness,
93
+ style='F')
94
+ # add actual images
95
+ for row in range(self.config.rows_per_page):
96
+ for col in range(self.config.cards_per_row):
97
+ if (p, row, col) in self.layout:
98
+ image = self.layout[(p, row, col)]
99
+ if image.width > image.height:
100
+ image = image.rotate(-90, expand=True)
101
+ pdf.image(image, w=self.config.card_width, h=self.config.card_height, x=padding_left +
102
+ col * (self.config.card_width +
103
+ self.config.padding),
104
+ y=padding_top + row * (self.config.card_height + self.config.padding))
105
+ pdf.output(filename)
@@ -0,0 +1,22 @@
1
+ from typing import Generator
2
+ from arkham_utils.card import ArkhamCard
3
+ from arkham_utils.pdf.builder import PDFBuilder
4
+ import fitz
5
+ from PIL import Image
6
+
7
+
8
+ class PDFImageReader(object):
9
+ def __init__(self, filename, zoom=4):
10
+ self.filename = filename
11
+ self.zoom = zoom
12
+
13
+ def get_images(self) -> Generator[Image.Image, None, None]:
14
+ with fitz.open(self.filename) as pdf:
15
+ mat = fitz.Matrix(self.zoom, self.zoom)
16
+ for i in range(len(pdf)):
17
+ page = pdf.load_page(i)
18
+ pix = page.get_pixmap(matrix=mat)
19
+ yield Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
20
+
21
+ def build_pdf(self):
22
+ return PDFBuilder([ArkhamCard(im) for im in self.get_images()])
@@ -0,0 +1,161 @@
1
+ from typing import List, Tuple
2
+ import math
3
+
4
+ from fpdf import FPDF
5
+
6
+ from PIL import Image
7
+
8
+ a4_width = 210
9
+ a4_height = 297
10
+
11
+
12
+ class PDFTiledImageBuilder(object):
13
+ def __init__(self, image_width_mm, image_height_mm, image_files: List[str], bleed_color: Tuple[int, int, int] = (0, 0, 0), space_between: int = 0.2):
14
+ self.image_width = image_width_mm
15
+ self.image_height = image_height_mm
16
+ self.images = [Image.open(fname) for fname in image_files]
17
+ self.bleed_thickness = 0.5
18
+ self.minimum_page_padding = 5
19
+ self.bleed_color = bleed_color # rgb
20
+ self.space_between = space_between
21
+
22
+ def write(self, filename: str):
23
+ # work out which orientation maximizes tiling
24
+ printable_width = a4_width - 2 * \
25
+ (self.bleed_thickness + self.minimum_page_padding)
26
+ printable_height = a4_height - 2 * \
27
+ (self.bleed_thickness + self.minimum_page_padding)
28
+ rows0 = int(printable_height //
29
+ (self.image_height + self.space_between))
30
+ cols0 = int(printable_width // (self.image_width + self.space_between))
31
+ tiles_per_page0 = rows0 * cols0
32
+ rows1 = int(printable_height //
33
+ (self.image_width + self.space_between))
34
+ cols1 = int(printable_width //
35
+ (self.image_height + self.space_between))
36
+ tiles_per_page1 = rows1 * cols1
37
+
38
+ self.rotate_90deg = tiles_per_page1 > tiles_per_page0
39
+ if not self.rotate_90deg:
40
+ self.rows_per_page = rows0
41
+ self.cols_per_page = cols0
42
+ self.tiles_per_page = tiles_per_page0
43
+ self.tile_width = self.image_width
44
+ self.tile_height = self.image_height
45
+ self.pages = math.ceil(len(self.images) / tiles_per_page0)
46
+ else:
47
+ self.rows_per_page = rows1
48
+ self.cols_per_page = cols1
49
+ self.tiles_per_page = tiles_per_page1
50
+ self.tile_width = self.image_height
51
+ self.tile_height = self.image_width
52
+ self.pages = math.ceil(len(self.images) / tiles_per_page1)
53
+ print(
54
+ f"max tiles_per_page (rotated? {self.rotate_90deg}):{self.tiles_per_page} ({self.cols_per_page}x{self.rows_per_page}) pages:{self.pages}")
55
+
56
+ # work out padding
57
+ self.padding_left = (a4_width - self.cols_per_page * self.tile_width -
58
+ (self.cols_per_page - 1) * self.space_between) / 2
59
+ self.padding_top = (a4_height - self.rows_per_page * self.tile_height -
60
+ (self.rows_per_page - 1) * self.space_between) / 2
61
+ print(f"padding left:{self.padding_left} top:{self.padding_top}")
62
+
63
+ pdf = FPDF(orientation='P', format='A4', unit='mm')
64
+ for p in range(self.pages):
65
+ pdf.add_page()
66
+ # add bleed bg if needed
67
+ if self.bleed_thickness > 0:
68
+ for row in range(self.rows_per_page):
69
+ for col in range(self.cols_per_page):
70
+ idx = p * self.tiles_per_page + row * self.cols_per_page + col
71
+ if idx < len(self.images):
72
+ pdf.set_fill_color(*self.bleed_color)
73
+ pdf.rect(w=self.tile_width + 2 * self.bleed_thickness,
74
+ h=self.tile_height + 2 * self.bleed_thickness,
75
+ x=self.padding_left + col *
76
+ (self.tile_width + self.space_between) -
77
+ self.bleed_thickness,
78
+ y=self.padding_top + row *
79
+ (self.tile_height + self.space_between) -
80
+ self.bleed_thickness,
81
+ style='F')
82
+ # add images
83
+ for row in range(self.rows_per_page):
84
+ for col in range(self.cols_per_page):
85
+ idx = p * self.tiles_per_page + row * self.cols_per_page + col
86
+ if idx < len(self.images):
87
+ print('adding image', idx + 1)
88
+ img = self.images[idx]
89
+ if self.rotate_90deg:
90
+ img = img.rotate(-90, expand=True)
91
+ pdf.image(img, w=self.tile_width, h=self.tile_height, x=self.padding_left +
92
+ col * (self.tile_width + self.space_between), y=self.padding_top + row * (self.tile_height + self.space_between))
93
+
94
+ # # add bleed bg
95
+ # for row in range(3):
96
+ # for col in range(3):
97
+ # if (p, row, col) in self.layout:
98
+ # image = self.layout[(p, row, col)]
99
+ # if image.width > image.height:
100
+ # image = image.rotate(-90, expand=True)
101
+ # pdf.image(image, w=card_width, h=card_height, x=padding_left +
102
+ # col * card_width, y=padding_top + row * card_height)
103
+ pdf.output(filename)
104
+
105
+ # def get_layout(self) -> Dict[Tuple[int, int, int], Image.Image]:
106
+ # layout = {}
107
+ # twosided = [c for c in self.cards if len(c.images) == 2]
108
+ # onesided = [c for c in self.cards if len(c.images) == 1]
109
+ # for card in twosided:
110
+ # self.layout_2side(layout, card)
111
+ # for card in onesided:
112
+ # self.layout_1side(layout, card)
113
+ # return layout
114
+
115
+ # @staticmethod
116
+ # def layout_2side(layout, card: ArkhamCard):
117
+ # front, back = card.images
118
+ # if front.width < front.height:
119
+ # front = front.rotate(180)
120
+ # for p in range(MAX_PAGES):
121
+ # for c in range(3):
122
+ # if (p, 0, c) not in layout:
123
+ # layout[(p, 0, c)] = back
124
+ # layout[(p, 1, c)] = front
125
+ # return
126
+ # raise RuntimeError('Exceeded last page')
127
+
128
+ # @staticmethod
129
+ # def layout_1side(layout, card: ArkhamCard):
130
+ # for p in range(MAX_PAGES):
131
+ # for r in range(3):
132
+ # for c in range(3):
133
+ # if (p, r, c) not in layout:
134
+ # layout[(p, r, c)] = card.image
135
+ # return
136
+ # raise RuntimeError('Exceeded last page')
137
+
138
+ # def write(self, filename: str):
139
+ # pdf = FPDF(orientation='P', format='A4', unit='in')
140
+ # pages = max([p for (p, r, c) in self.layout]) + 1
141
+ # for p in range(pages):
142
+ # pdf.add_page()
143
+ # # add bleed bg
144
+ # for row in range(3):
145
+ # for col in range(3):
146
+ # if (p, row, col) in self.layout:
147
+ # pdf.rect(w=card_width + 2 * bleed_thickness,
148
+ # h=card_height + 2 * bleed_thickness,
149
+ # x=padding_left + col * card_width - bleed_thickness,
150
+ # y=padding_top + row * card_height - bleed_thickness,
151
+ # style='F')
152
+ # # add bleed bg
153
+ # for row in range(3):
154
+ # for col in range(3):
155
+ # if (p, row, col) in self.layout:
156
+ # image = self.layout[(p, row, col)]
157
+ # if image.width > image.height:
158
+ # image = image.rotate(-90, expand=True)
159
+ # pdf.image(image, w=card_width, h=card_height, x=padding_left +
160
+ # col * card_width, y=padding_top + row * card_height)
161
+ # pdf.output(filename)
@@ -0,0 +1,22 @@
1
+ [tool.poetry]
2
+ name = "arkham-utils"
3
+ version = "0.1.0"
4
+ description = "Arkham Horror LCG utilities"
5
+ authors = ["Ian Su <iansu1979+github@gmail.com>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.12"
10
+ requests = "^2.29.0"
11
+ Pillow = "^9.5.0"
12
+ fpdf2 = "^2.7.4"
13
+ PyMuPDF = "^1.24.5"
14
+ requests-cache = "^1.2.1"
15
+ platformdirs = "^4.3.7"
16
+
17
+ [tool.poetry.group.dev.dependencies]
18
+ pytest = "^8.3.5"
19
+
20
+ [build-system]
21
+ requires = ["poetry-core"]
22
+ build-backend = "poetry.core.masonry.api"