diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 7fffd12f..079bca17 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 6fddae77..3246b730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ # Generated output PDFs (and intermediate files) -examples/*.pdf -examples/*.aux -examples/*.tex +*.pdf +*.aux +*.tex # Generated epub files -examples/*.epub +*.epub # Emacs temp files *~ @@ -117,4 +117,4 @@ ENV/ # mypy .mypy_cache/ -add_spell.sh \ No newline at end of file +add_spell.sh diff --git a/docs/character_files.rst b/docs/character_files.rst index a5c31699..5f5b40dc 100644 --- a/docs/character_files.rst +++ b/docs/character_files.rst @@ -54,16 +54,29 @@ standard 5e rules, and are case-insensitive. Refer to the D&D Character Portrait ================== -.. code:: python +The *portrait* and *symbol* variables in the user character python +file can be set to a filepath (str) of a corresponding image file. + +The images generated are centered around an anchor-point where they +are allowed to expand up to a maximum value while keeping their aspect +ratio. + +**Custom images** can be inserted with arbitrary location and +dimensions at an arbitrary page. For this, set the *images* list to a +list of **(path_to_image_file, page_index, x_center_coordinate, +y_center_coordinate, max_width, max_height)** - portrait = True +Image paths can be absolute, or relative to the variable +*source_file_location* (defaults to the same folder as the character +sheet file). -If this is set to True and a corresponding portrait file exists, -the portrait will be added to the character personality sheet. -For now, the file must have a .jpeg extension and be named exactly -the same as the character file. This might not work with every Image size. -ca 550 * 700px seems to be the right format. Anything smaller should work, too. -See the Bard1 example for a demonstration of this feature. +.. code-block:: python + :caption: bard1.py + + images = [("bard1.jpeg", 0, 320, 110, 100, 100)] + portrait = "shifter_2.png" + symbol = "bard1.jpeg" + Ability Scores ============== diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 9c877c1c..cb40aca3 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -1,9 +1,10 @@ """Tools for describing a player character.""" +import logging +import math import os import re import warnings -import math -import logging +from pathlib import Path from types import ModuleType from typing import Sequence, Union, MutableMapping @@ -22,16 +23,14 @@ spells, weapons, ) -from dungeonsheets.content_registry import find_content -from dungeonsheets.weapons import Weapon from dungeonsheets.content import Creature +from dungeonsheets.content_registry import find_content from dungeonsheets.dice import combine_dice from dungeonsheets.equipment_reader import equipment_weight_parser - +from dungeonsheets.weapons import Weapon log = logging.getLogger(__name__) - dice_re = re.compile(r"(\d+)d(\d+)") __all__ = ( @@ -78,6 +77,7 @@ class Character(Creature): """A generic player character.""" + # Character-specific name = "Unknown Hero" player_name = "" @@ -109,7 +109,16 @@ class Character(Creature): _proficiencies_text = list() # Appearance - portrait = False + portrait: Path | None = None # Path to image file + symbol: Path | None = None # Path to image file + # List of custom images: + # [(path_to_image_file, page_index, + # x_center_coordinate, y_center_coordinate, + # max_width, max_height)] + images: list[tuple[Path, int, int, int, int, int]] = [] + + source_file_location: Path | None + age = 0 height = "" weight = "" @@ -179,6 +188,35 @@ def __init__( # parse race and background self.race = attrs.pop("race", None) self.background = attrs.pop("background", None) + # parse images + self.symbol = attrs.pop("symbol", None) + self.images = attrs.pop("images", []) + if self.symbol: + self.images = [(self.symbol, 1, 492, 564, 145, 113)] + self.images + self.portrait = attrs.pop("portrait", None) + if self.portrait: + self.images = [(self.portrait, 1, 117, 551, 170, 220)] + self.images + self.source_file_location = attrs.pop("source_file_location", None) + self.images = [ + ( + Path(path), + page_index, + x_center_coordinate, + y_center_coordinate, + max_width, + max_height, + ) + if Path(path).is_absolute() + else ( + Path(self.source_file_location) / path, + page_index, + x_center_coordinate, + y_center_coordinate, + max_width, + max_height, + ) + for path, page_index, x_center_coordinate, y_center_coordinate, max_width, max_height in self.images + ] # parse all other attributes self.set_attrs(**attrs) self.__set_max_hp(attrs.get("hp_max", None)) @@ -476,12 +514,12 @@ def spellcasting_classes(self): @property def spellcasting_classes_excluding_warlock(self): - return [c for c in self.spellcasting_classes if not type(c) == classes.Warlock] - + return [c for c in self.spellcasting_classes if c is not classes.Warlock] + @property def is_spellcaster(self): return len(self.spellcasting_classes) > 0 - + def spell_slots(self, spell_level): warlock_slots = 0 for c in self.spellcasting_classes: @@ -553,6 +591,8 @@ def set_attrs(self, **attrs): for attr, val in attrs.items(): if attr == "dungeonsheets_version": pass # Maybe we'll verify this later? + elif attr == "character_file_location": + pass elif attr == "weapons": if isinstance(val, str): val = [val] @@ -723,17 +763,20 @@ def features_text(self): s = "(See Features Page)\n\n--" + s s += "\n\n=================\n\n" return s - + @property def features_summary(self): # save space for informed features and traits if hasattr(self, "features_and_traits"): info_list = ["**Other Features**"] - info_list += [text.strip() for text in self.features_and_traits.split("\n") - if not(text.isspace())] + info_list += [ + text.strip() + for text in self.features_and_traits.split("\n") + if not (text.isspace()) + ] N = len(info_list) for text in info_list: - if len(text) > 26: # 26 is just a guess for expected size of lines + if len(text) > 26: # 26 is just a guess for expected size of lines N += 1 if N > 30: return "\n".join(info_list[:30]) + "\n(...)" @@ -760,23 +803,28 @@ def features_summary(self): @property def carrying_capacity(self): - _ccModD = {"tiny":0.5, "small":1, "medium":1, - "large":2, "huge":4, "gargantum":8} + _ccModD = { + "tiny": 0.5, + "small": 1, + "medium": 1, + "large": 2, + "huge": 4, + "gargantum": 8, + } cc_mod = _ccModD[self.race.size.lower()] - return 15*self.strength.value*cc_mod - + return 15 * self.strength.value * cc_mod + @property def carrying_weight(self): - weight = equipment_weight_parser(self.equipment, - self.equipment_weight_dict) + weight = equipment_weight_parser(self.equipment, self.equipment_weight_dict) weight += sum([w.weight for w in self.weapons]) if self.armor: weight += self.armor.weight if self.shield: weight += 6 - weight += sum([self.cp, self.sp, self.ep, self.gp, self.pp])/50 + weight += sum([self.cp, self.sp, self.ep, self.gp, self.pp]) / 50 return round(weight, 2) - + @property def equipment_text(self): eq_list = [] @@ -785,13 +833,16 @@ def equipment_text(self): eq_list += [item.name for item in self.magic_items] if hasattr(self, "equipment") and len(self.equipment.strip()) > 0: eq_list += ["**Other Equipment**"] - eq_list += [text.strip() for text in self.equipment.split("\n") - if not(text.isspace())] + eq_list += [ + text.strip() + for text in self.equipment.split("\n") + if not (text.isspace()) + ] cw, cc = self.carrying_weight, self.carrying_capacity eq_list += [f"**Weight:** {cw} lb\n\n**Capacity:** {cc} lb"] - + return "\n\n".join(eq_list) - + @property def proficiencies_by_type(self): prof_dict = {} @@ -801,58 +852,71 @@ def proficiencies_by_type(self): elif weapons.SimpleWeapon in w_pro: prof_dict["Weapons"] = ["Simple weapons"] for w in w_pro: - if not(issubclass(w, weapons.SimpleWeapon)): + if not (issubclass(w, weapons.SimpleWeapon)): prof_dict["Weapons"] += [w.name] else: prof_dict["Weapons"] = [w.name for w in w_pro] if "Weapons" in prof_dict.keys(): prof_dict["Weapons"] = ", ".join(prof_dict["Weapons"]) + "." - armor_types = ["all armor", "light armor", "medium armor", - "heavy armor"] - prof_set = set([prof.lower().strip().strip('.') - for prof in self.proficiencies_text.split(',')]) + armor_types = ["all armor", "light armor", "medium armor", "heavy armor"] + prof_set = set( + [ + prof.lower().strip().strip(".") + for prof in self.proficiencies_text.split(",") + ] + ) prof_dict["Armor"] = [ar for ar in armor_types if ar in prof_set] - if len(prof_dict["Armor"]) > 2 or 'all armor' in prof_set: + if len(prof_dict["Armor"]) > 2 or "all armor" in prof_set: prof_dict["Armor"] = ["All armor"] - if 'shields' in prof_set: + if "shields" in prof_set: prof_dict["Armor"] += ["shields"] prof_dict["Armor"] = ", ".join(prof_dict["Armor"]) + "." - if hasattr(self, 'chosen_tools'): + if hasattr(self, "chosen_tools"): prof_dict["Other"] = self.chosen_tools return prof_dict - + @property def spell_casting_info(self): """Returns a ready-to-use dictionary for spellsheets.""" - level_names = ["Cantrip", - 'FirstLevelSpell', - 'SecondLevelSpell', - 'ThirdLevelSpell', - 'FourthLevelSpell', - 'FifthLevelSpell', - 'SixthLevelSpell', - 'SeventhLevelSpell', - 'EighthLevelSpell', - 'NinthLevelSpell'] - spell_info = {'head':{ - "classes_and_levels": " / ".join( - [c.name + " " + str(c.level) for c in self.spellcasting_classes] - ), - "abilities": " / ".join( - [c.spellcasting_ability.upper()[:3] - for c in self.spellcasting_classes] - ), - "DCs": " / ".join( - [str(self.spell_save_dc(c)) - for c in self.spellcasting_classes] - ), - "bonuses": " / ".join( - ["{:+d}".format(self.spell_attack_bonus(c)) - for c in self.spellcasting_classes] - ), - }} - slots = {level_names[k]:self.spell_slots(k) for k in range(1, 10) - if self.spell_slots(k) > 0} + level_names = [ + "Cantrip", + "FirstLevelSpell", + "SecondLevelSpell", + "ThirdLevelSpell", + "FourthLevelSpell", + "FifthLevelSpell", + "SixthLevelSpell", + "SeventhLevelSpell", + "EighthLevelSpell", + "NinthLevelSpell", + ] + spell_info = { + "head": { + "classes_and_levels": " / ".join( + [c.name + " " + str(c.level) for c in self.spellcasting_classes] + ), + "abilities": " / ".join( + [ + c.spellcasting_ability.upper()[:3] + for c in self.spellcasting_classes + ] + ), + "DCs": " / ".join( + [str(self.spell_save_dc(c)) for c in self.spellcasting_classes] + ), + "bonuses": " / ".join( + [ + "{:+d}".format(self.spell_attack_bonus(c)) + for c in self.spellcasting_classes + ] + ), + } + } + slots = { + level_names[k]: self.spell_slots(k) + for k in range(1, 10) + if self.spell_slots(k) > 0 + } spell_info["slots"] = slots spell_list = {} for s in self.spells: @@ -907,7 +971,9 @@ def wield_shield(self, shield): """ if shield not in ("", "None", None): msg = 'Unknown shield "{}". Please ad it to ``shields.py``.' - NewShield = self._resolve_mechanic(shield, SuperClass=armor.Shield, warning_message=msg) + NewShield = self._resolve_mechanic( + shield, SuperClass=armor.Shield, warning_message=msg + ) self.shield = NewShield() def wield_weapon(self, weapon): @@ -936,11 +1002,11 @@ def weapons(self): if len(my_weapons) == 0 or hasattr(self, "Monk"): my_weapons.append(weapons.Unarmed(wielder=self)) return my_weapons - + @property def hit_dice(self): """What type and how many dice to use for re-gaining hit points. - + To change, set hit_dice_num and hit_dice_faces.""" dice_s = " + ".join([f"{c.level}d{c.hit_dice_faces}" for c in self.class_list]) dice_s = combine_dice(dice_s) @@ -999,15 +1065,13 @@ def ranger_beast(self): return self.Ranger.ranger_beast else: return None - + @ranger_beast.setter def ranger_beast(self, beast): - msg = ( - f"Companion '{beast}' not found. Please add it to" - " ``monsters.py``" ) + msg = f"Companion '{beast}' not found. Please add it to" " ``monsters.py``" beast = self._resolve_mechanic(beast, monsters.Monster, msg) self.Ranger.ranger_beast = (beast(), self.proficiency_bonus) - + @property def companions(self): """Return the list of companions and summonables""" @@ -1021,9 +1085,7 @@ def companions(self, compas): companions_list = [] # Retrieve the actual monster classes if possible for compa in compas: - msg = ( - f"Companion '{compa}' not found. Please add it to" - " ``monsters.py``" ) + msg = f"Companion '{compa}' not found. Please add it to" " ``monsters.py``" new_compa = self._resolve_mechanic(compa, monsters.Monster, msg) companions_list.append(new_compa()) # Save the updated list for later diff --git a/dungeonsheets/fill_pdf_template.py b/dungeonsheets/fill_pdf_template.py index 2bcfa335..23be8966 100644 --- a/dungeonsheets/fill_pdf_template.py +++ b/dungeonsheets/fill_pdf_template.py @@ -1,12 +1,10 @@ +import logging import os import subprocess -import logging import warnings -import io import pdfrw from fdfgen import forge_fdf -from reportlab.pdfgen import canvas from dungeonsheets.forms import mod_str @@ -177,10 +175,10 @@ def create_character_pdf_template(character, basename, flatten=False): # Prepare the actual PDF dirname = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forms/") src_pdf = os.path.join(dirname, "blank-character-sheet-default.pdf") - return make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten, portrait="") + return make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten) -def create_personality_pdf_template(character, basename, portrait_file="", flatten=False): +def create_personality_pdf_template(character, basename, flatten=False): # Prepare the list of fields fields = { "CharacterName 2": character.name, @@ -201,7 +199,12 @@ def create_personality_pdf_template(character, basename, portrait_file="", flatt # Prepare the actual PDF dirname = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forms/") src_pdf = os.path.join(dirname, "blank-personality-sheet-default.pdf") - return make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten, portrait=portrait_file) + return make_pdf( + fields, + src_pdf=src_pdf, + basename=basename, + flatten=flatten, + ) def create_spells_pdf_template(character, basename, flatten=False): @@ -223,71 +226,91 @@ def create_spells_pdf_template(character, basename, flatten=False): def spell_level(x): return x or 0 - + # Record fields caster_sheet_fields = { - 'fields': { - "Spellcasting Class 2": classes_and_levels, - "SpellcastingAbility 2": abilities, - "SpellSaveDC 2": DCs, - "SpellAtkBonus 2": bonuses, - # Number of spell slots - "SlotsTotal 1": spell_level(character.spell_slots(1)), - "SlotsTotal 2": spell_level(character.spell_slots(2)), - "SlotsTotal 3": spell_level(character.spell_slots(3)), - "SlotsTotal 4": spell_level(character.spell_slots(4)), - "SlotsTotal 5": spell_level(character.spell_slots(5)), - "SlotsTotal 6": spell_level(character.spell_slots(6)), - "SlotsTotal 7": spell_level(character.spell_slots(7)), - "SlotsTotal 8": spell_level(character.spell_slots(8)), - "SlotsTotal 9": spell_level(character.spell_slots(9)), + "fields": { + "Spellcasting Class 2": classes_and_levels, + "SpellcastingAbility 2": abilities, + "SpellSaveDC 2": DCs, + "SpellAtkBonus 2": bonuses, + # Number of spell slots + "SlotsTotal 1": spell_level(character.spell_slots(1)), + "SlotsTotal 2": spell_level(character.spell_slots(2)), + "SlotsTotal 3": spell_level(character.spell_slots(3)), + "SlotsTotal 4": spell_level(character.spell_slots(4)), + "SlotsTotal 5": spell_level(character.spell_slots(5)), + "SlotsTotal 6": spell_level(character.spell_slots(6)), + "SlotsTotal 7": spell_level(character.spell_slots(7)), + "SlotsTotal 8": spell_level(character.spell_slots(8)), + "SlotsTotal 9": spell_level(character.spell_slots(9)), }, - 'cantrip_fields': [f"Spells 10{i:02}" for i in range(1,8)], - 'spell_fields': { - level: [f'Spells 1{level}{i:02}' for i in range(1,n_spells+1)] - for level, n_spells in [(1,12), (2,13), (3,13), (4,13), (5,9), (6,9), (7,9), (8,7), (9,7)] + "cantrip_fields": [f"Spells 10{i:02}" for i in range(1, 8)], + "spell_fields": { + level: [f"Spells 1{level}{i:02}" for i in range(1, n_spells + 1)] + for level, n_spells in [ + (1, 12), + (2, 13), + (3, 13), + (4, 13), + (5, 9), + (6, 9), + (7, 9), + (8, 7), + (9, 7), + ] + }, + "prep_fields": { + level: [f"prepared {level}{i:02}" for i in range(1, n_spells + 1)] + for level, n_spells in [ + (1, 12), + (2, 13), + (3, 13), + (4, 13), + (5, 9), + (6, 9), + (7, 9), + (8, 7), + (9, 7), + ] }, - 'prep_fields': { - level: [f'prepared {level}{i:02}' for i in range(1,n_spells+1)] - for level, n_spells in [(1,12), (2,13), (3,13), (4,13), (5,9), (6,9), (7,9), (8,7), (9,7)] - } } half_caster_sheet_fields = { - 'fields': { - "Spellcasting Class 2": classes_and_levels, - "SpellcastingAbility 2": abilities, - "SpellSaveDC 2": DCs, - "SpellAtkBonus 2": bonuses, - # Number of spell slots - "SlotsTotal 1": spell_level(character.spell_slots(1)), - "SlotsTotal 2": spell_level(character.spell_slots(2)), - "SlotsTotal 3": spell_level(character.spell_slots(3)), - "SlotsTotal 4": spell_level(character.spell_slots(4)), - "SlotsTotal 5": spell_level(character.spell_slots(5)), + "fields": { + "Spellcasting Class 2": classes_and_levels, + "SpellcastingAbility 2": abilities, + "SpellSaveDC 2": DCs, + "SpellAtkBonus 2": bonuses, + # Number of spell slots + "SlotsTotal 1": spell_level(character.spell_slots(1)), + "SlotsTotal 2": spell_level(character.spell_slots(2)), + "SlotsTotal 3": spell_level(character.spell_slots(3)), + "SlotsTotal 4": spell_level(character.spell_slots(4)), + "SlotsTotal 5": spell_level(character.spell_slots(5)), + }, + "cantrip_fields": [f"Spells 10{i:02}" for i in range(1, 12)], + "spell_fields": { + level: [f"Spells 1{level}{i:02}" for i in range(1, n_spells + 1)] + for level, n_spells in [(1, 25), (2, 19), (3, 19), (4, 19), (5, 19)] }, - 'cantrip_fields': [f"Spells 10{i:02}" for i in range(1,12)], - 'spell_fields': { - level: [f'Spells 1{level}{i:02}' for i in range(1,n_spells+1)] - for level, n_spells in [(1,25), (2,19), (3,19), (4,19), (5,19)] + "prep_fields": { + level: [f"prepared {level}{i:02}" for i in range(1, n_spells + 1)] + for level, n_spells in [(1, 25), (2, 19), (3, 19), (4, 19), (5, 19)] }, - 'prep_fields': { - level: [f'prepared {level}{i:02}' for i in range(1,n_spells+1)] - for level, n_spells in [(1,25), (2,19), (3,19), (4,19), (5,19)] - } } # Determine which sheet to use (caster or half-caster). # Prefer caster, unless we have no spells > 5th level and # would overflow the caster sheet, then use half-caster. - only_low_level = all((character.spell_slots(level) == 0 for level in range(6,10))) - would_overflow_fullcaster = any(( - len( - [spl for spl in character.spells if spl.level == level] - ) > len( - caster_sheet_fields['spell_fields'][level] - ) for level in range(1,6) - )) + only_low_level = all((character.spell_slots(level) == 0 for level in range(6, 10))) + would_overflow_fullcaster = any( + ( + len([spl for spl in character.spells if spl.level == level]) + > len(caster_sheet_fields["spell_fields"][level]) + for level in range(1, 6) + ) + ) if only_low_level and would_overflow_fullcaster: selected_sheet_fields = half_caster_sheet_fields template_filename = "blank-halfcaster-spell-sheet-default.pdf" @@ -295,22 +318,24 @@ def spell_level(x): selected_sheet_fields = caster_sheet_fields template_filename = "blank-spell-sheet-default.pdf" - fields = selected_sheet_fields['fields'] - cantrip_fields = selected_sheet_fields['cantrip_fields'] - spell_fields = selected_sheet_fields['spell_fields'] - prep_fields = selected_sheet_fields['prep_fields'] + fields = selected_sheet_fields["fields"] + cantrip_fields = selected_sheet_fields["cantrip_fields"] + spell_fields = selected_sheet_fields["spell_fields"] + prep_fields = selected_sheet_fields["prep_fields"] cantrips = (spl for spl in character.spells if spl.level == 0) for spell, field_name in zip(cantrips, cantrip_fields): fields[field_name] = str(spell) # Spells for each level fields_per_page = {} + def spell_paginator(spells, n_fields): yield spells[:n_fields] consumed = n_fields while consumed < len(spells): - yield spells[consumed:consumed + (n_fields - 1)] + yield spells[consumed : consumed + (n_fields - 1)] consumed += n_fields - 1 + # Prepare the lists of spells for each level for level in spell_fields.keys(): spells = [spl for spl in character.spells if spl.level == level] @@ -319,7 +344,9 @@ def spell_paginator(spells, n_fields): # The first page has len(field_numbers) spells, the further pages have # len(field_numbers - 1) - for page, page_spells in enumerate(spell_paginator(spells, len(spell_fields[level]))): + for page, page_spells in enumerate( + spell_paginator(spells, len(spell_fields[level])) + ): if page not in fields_per_page: fields_per_page[page] = {} # Build the list of PDF controls to set/toggle @@ -334,7 +361,9 @@ def spell_paginator(spells, n_fields): for spell, field, chk_field in zip(page_spells, field_names, prep_names): fields_per_page[page][field] = str(spell) is_prepared = any([spell == Spl for Spl in character.spells_prepared]) - fields_per_page[page][chk_field] = CHECKBOX_ON if is_prepared else CHECKBOX_OFF + fields_per_page[page][chk_field] = ( + CHECKBOX_ON if is_prepared else CHECKBOX_OFF + ) # # Uncomment to post field names instead: # for field in field_names: # fields.append((field, field)) @@ -344,21 +373,28 @@ def spell_paginator(spells, n_fields): basenames = [] for page, page_fields in fields_per_page.items(): - combined_basename = basename if page == 0 else f'{basename}-extra{page}' + combined_basename = basename if page == 0 else f"{basename}-extra{page}" basenames.append(combined_basename) output_fields = {**fields, **page_fields} if page > 0: - output_fields.update({ - "Spellcasting Class 2": f'{classes_and_levels} (Overflow)', - # Number of spell slots - **{f"SlotsTotal {i}": '-' for i in range(19,28)} - }) - make_pdf(output_fields, src_pdf=src_pdf, basename=combined_basename, flatten=flatten, portrait="") + output_fields.update( + { + "Spellcasting Class 2": f"{classes_and_levels} (Overflow)", + # Number of spell slots + **{f"SlotsTotal {i}": "-" for i in range(19, 28)}, + } + ) + make_pdf( + output_fields, + src_pdf=src_pdf, + basename=combined_basename, + flatten=flatten, + ) return basenames -def make_pdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False, portrait = ""): +def make_pdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False): """Create a new PDF by applying fields to a src PDF document. Parameters @@ -377,17 +413,17 @@ def make_pdf(fields: dict, src_pdf: str, basename: str, flatten: bool = False, p """ try: - _make_pdf_pdftk(fields, src_pdf, basename, flatten, portrait) + _make_pdf_pdftk(fields, src_pdf, basename, flatten) except FileNotFoundError: # pdftk could not run, so alert the user and use pdfrw warnings.warn( f"Could not run `{PDFTK_CMD}`, using fallback; forcing `--editable`.", RuntimeWarning, ) - _make_pdf_pdfrw(fields, src_pdf, basename, flatten, portrait) + _make_pdf_pdfrw(fields, src_pdf, basename, flatten) -def _make_pdf_pdfrw(fields: dict, src_pdf: str, basename: str, flatten: bool = False, portrait = ""): +def _make_pdf_pdfrw(fields: dict, src_pdf: str, basename: str, flatten: bool = False): """Backup make_pdf function in case pdftk is not available.""" template = pdfrw.PdfReader(src_pdf) # Different types of PDF fields @@ -446,7 +482,7 @@ def _make_pdf_pdfrw(fields: dict, src_pdf: str, basename: str, flatten: bool = F pdfrw.PdfWriter().write(f"{basename}.pdf", template) -def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False, portrait=""): +def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False): """More robust way to make a PDF, but has a hard dependency.""" # Create the actual FDF file fdfname = basename + ".fdf" @@ -456,12 +492,7 @@ def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False, portrait=""): fdf_file.write(fdf) fdf_file.close() # Build the final flattened PDF documents - if portrait != "": - dest_pdf = basename + "-temp.pdf" - image_pdf = basename + "_image_tmp.pdf" - make_image_pdf(portrait, image_pdf) - else: - dest_pdf = basename + ".pdf" + dest_pdf = basename + ".pdf" popenargs = [ PDFTK_CMD, src_pdf, @@ -473,37 +504,5 @@ def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False, portrait=""): if flatten: popenargs.append("flatten") subprocess.call(popenargs) - # stamp with image - if portrait != "": - src_pdf = dest_pdf - stamped_pdf = basename + ".pdf" - popenargs = [ - PDFTK_CMD, - src_pdf, - "stamp", - image_pdf, - "output", - stamped_pdf, - ] - popenargs.append("flatten") - subprocess.call(popenargs) - # Clean up - os.remove(image_pdf) - os.remove(dest_pdf) # Clean up temporary files os.remove(fdfname) - -def make_image_pdf(src_img:str, dest_pdf:str): - packet = io.BytesIO() - can = canvas.Canvas(packet) - x_start = 10 - y_start = 240 - can.drawImage(src_img, x_start, y_start, width=175, preserveAspectRatio=True, mask='auto') - can.showPage() - can.save() - - #move to the beginning of the StringIO buffer - packet.seek(0) - - new_pdf = pdfrw.PdfReader(packet) - pdfrw.PdfWriter().write(dest_pdf, new_pdf) diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 673f8b85..8c36c031 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -3,15 +3,15 @@ """Program to take character definitions and build a PDF of the character sheet.""" -import logging import argparse +import logging import os +import re import subprocess import warnings -import re -from pathlib import Path -from multiprocessing import Pool, cpu_count from itertools import product +from multiprocessing import Pool, cpu_count +from pathlib import Path from typing import Union, Sequence, Optional, List from dungeonsheets import ( @@ -24,15 +24,15 @@ forms, random_tables, ) +from dungeonsheets.character import Character +from dungeonsheets.content import Creature from dungeonsheets.content_registry import find_content from dungeonsheets.fill_pdf_template import ( create_character_pdf_template, create_personality_pdf_template, create_spells_pdf_template, ) -from dungeonsheets.character import Character -from dungeonsheets.content import Creature - +from dungeonsheets.pdf_image_insert import insert_image_into_pdf log = logging.getLogger(__name__) @@ -48,10 +48,8 @@ 9: "9th", } - PDFTK_CMD = "pdftk" - jinja_env = forms.jinja_environment() jinja_env.filters["rst_to_latex"] = latex.rst_to_latex jinja_env.filters["rst_to_html"] = epub.rst_to_html @@ -63,14 +61,24 @@ File = Union[Path, str] -class CharacterRenderer(): +class CharacterRenderer: def __init__(self, template_name: str): self.template_name = template_name - - def __call__(self, character: Character, content_suffix: str = "tex", use_dnd_decorations: bool = False): - template = jinja_env.get_template(self.template_name.format(suffix=content_suffix)) - return template.render(character=character, - use_dnd_decorations=use_dnd_decorations, ordinals=ORDINALS) + + def __call__( + self, + character: Character, + content_suffix: str = "tex", + use_dnd_decorations: bool = False, + ): + template = jinja_env.get_template( + self.template_name.format(suffix=content_suffix) + ) + return template.render( + character=character, + use_dnd_decorations=use_dnd_decorations, + ordinals=ORDINALS, + ) create_character_sheet_content = CharacterRenderer("character_sheet_template.{suffix}") @@ -86,13 +94,16 @@ def create_monsters_content( monsters: Sequence[Union[monsters.Monster, str]], suffix: str, use_dnd_decorations: bool = False, - base_template: str = "monsters_template" + base_template: str = "monsters_template", ) -> str: # Convert strings to Monster objects - template = jinja_env.get_template(base_template+f".{suffix}") + template = jinja_env.get_template(base_template + f".{suffix}") spell_list = [Spell() for monster in monsters for Spell in monster.spells] - return template.render(monsters=monsters, - use_dnd_decorations=use_dnd_decorations, spell_list=spell_list) + return template.render( + monsters=monsters, + use_dnd_decorations=use_dnd_decorations, + spell_list=spell_list, + ) def create_gm_spellbook(spell_list, suffix): @@ -125,20 +136,20 @@ def create_random_tables_content( ) -def create_extra_gm_content(sections: Sequence, suffix: str, use_dnd_decorations: bool=False): +def create_extra_gm_content( + sections: Sequence, suffix: str, use_dnd_decorations: bool = False +): """Create content for arbitrary additional text provided in a GM sheet. - + Parameters ========== sections Subclasses of Content that will each be included as new sections in the output document. - + """ template = jinja_env.get_template(f"extra_gm_content.{suffix}") - return template.render( - sections=sections, use_dnd_decorations=use_dnd_decorations - ) + return template.render(sections=sections, use_dnd_decorations=use_dnd_decorations) def make_sheet( @@ -166,7 +177,7 @@ def make_sheet( Provide extra info and preserve temporary files. use_tex_template : bool, optional (experimental) Use the DnD LaTeX character sheet instead of the fillable PDF. - + """ # Parse the file sheet_file = Path(sheet_file) @@ -186,7 +197,7 @@ def make_sheet( output_format=output_format, fancy_decorations=fancy_decorations, debug=debug, - use_tex_template=use_tex_template + use_tex_template=use_tex_template, ) return ret @@ -262,9 +273,11 @@ def make_gm_sheet( ) # Parse any extra homebrew sections, etc. content.append( - create_extra_gm_content(sections=gm_props.pop("extra_content", []), - suffix=content_suffix, - use_dnd_decorations=fancy_decorations) + create_extra_gm_content( + sections=gm_props.pop("extra_content", []), + suffix=content_suffix, + use_dnd_decorations=fancy_decorations, + ) ) # Add the monsters monsters_ = [] @@ -289,10 +302,12 @@ def make_gm_sheet( if len(monsters_) > 0: content.append( create_monsters_content( - set(monsters_), suffix=content_suffix, use_dnd_decorations=fancy_decorations + set(monsters_), + suffix=content_suffix, + use_dnd_decorations=fancy_decorations, ) ) - + # Add the GM Spellbook spells = [] for monster in monsters_: @@ -325,6 +340,7 @@ def make_gm_sheet( # Warn about any unhandled sheet properties gm_props.pop("dungeonsheets_version") gm_props.pop("sheet_type") + gm_props.pop("source_file_location") if len(gm_props.keys()) > 0: msg = f"Unhandled attributes in '{str(gm_file)}': {','.join(gm_props.keys())}" log.warning(msg) @@ -346,8 +362,9 @@ def make_gm_sheet( chapters = {session_title: "".join(content)} # Make sheets in the epub for each party member for char in party: - char_html = make_character_content(char, "html", - fancy_decorations=fancy_decorations) + char_html = make_character_content( + char, "html", fancy_decorations=fancy_decorations + ) chapters[char.name] = "".join(char_html) # Create the combined HTML file epub.create_epub( @@ -364,9 +381,10 @@ def make_gm_sheet( def make_character_content( - character: Character, - content_format: str, - fancy_decorations: bool = False,) -> List[str]: + character: Character, + content_format: str, + fancy_decorations: bool = False, +) -> List[str]: """Prepare the inner content for a character sheet. This will produce a fully renderable document, suitable for @@ -388,7 +406,7 @@ def make_character_content( fancy_decorations Use fancy page layout and decorations for extra sheets, namely the dnd style file for *tex*, or extended CSS for *html*. - + Returns ------- content @@ -405,73 +423,99 @@ def make_character_content( ] # Make the character sheet, and background pages if producing HTML if content_format != "tex": - content.append(create_character_sheet_content(character, - content_suffix=content_format, - use_dnd_decorations=fancy_decorations)) + content.append( + create_character_sheet_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) + ) # Create a list of subcasses, features, spells, etc - if len(getattr(character, 'subclasses', [])) > 0: - content.append(create_subclasses_content(character, - content_suffix=content_format, - use_dnd_decorations=fancy_decorations) - ) - if len(getattr(character, 'features', [])) > 0: + if len(getattr(character, "subclasses", [])) > 0: + content.append( + create_subclasses_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) + ) + if len(getattr(character, "features", [])) > 0: content.append( - create_features_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) + create_features_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) ) if character.magic_items: content.append( - create_magic_items_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) + create_magic_items_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) ) - if len(getattr(character, 'spells', [])) > 0: + if len(getattr(character, "spells", [])) > 0: content.append( - create_spellbook_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) + create_spellbook_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) ) if len(getattr(character, "infusions", [])) > 0: content.append( - create_infusions_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) + create_infusions_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) ) # Create a list of Druid wild_shapes if len(getattr(character, "all_wild_shapes", [])) > 0: content.append( - create_druid_shapes_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) + create_druid_shapes_content( + character, + content_suffix=content_format, + use_dnd_decorations=fancy_decorations, + ) ) - + # Create a list of companions if len(getattr(character, "companions", [])) > 0: content.append( - create_monsters_content(character.companions, suffix=content_format, - use_dnd_decorations=fancy_decorations, base_template="companions_template") + create_monsters_content( + character.companions, + suffix=content_format, + use_dnd_decorations=fancy_decorations, + base_template="companions_template", + ) ) # Postamble, empty for HTML content.append( jinja_env.get_template(f"postamble.{content_format}").render( use_dnd_decorations=fancy_decorations ) - ) + ) return content -def msavage_sheet(character, basename, portrait_file="", debug=False): + +def msavage_sheet(character, basename, debug=False): """Another adaption. All changes can be easily included as options in the orignal functions, though.""" - - # Load image file if present - portrait_command="" - if character.portrait and portrait_file: - portrait_command = r"\includegraphics[width=5.75cm]{"+ \ - portrait_file + "}" - - + tex = jinja_env.get_template("MSavage_template.tex").render( - char=character, portrait=portrait_command - ) + char=character, portrait="" + ) latex.create_latex_pdf( - tex, - basename=basename, - keep_temp_files=debug, - use_dnd_decorations=True, - comm1="xelatex" - ) + tex, + basename=basename, + keep_temp_files=debug, + use_dnd_decorations=True, + comm1="xelatex", + ) + def make_character_sheet( char_file: Union[str, Path], @@ -507,12 +551,6 @@ def make_character_sheet( if character is None: character_props = readers.read_sheet_file(char_file) character = _char.Character.load(character_props) - # Load image file if present - portrait_file = character.portrait - if portrait_file is True: - portrait_file=char_file.stem + ".jpeg" - elif portrait_file is False: - portrait_file="" # Set the fields in the FDF basename = char_file.stem char_base = basename + "_char" @@ -522,29 +560,33 @@ def make_character_sheet( # Prepare the tex/html content content_suffix = format_suffixes[output_format] # Create a list of features and magic items - content = make_character_content(character=character, - content_format=content_suffix, - fancy_decorations=fancy_decorations) + content = make_character_content( + character=character, + content_format=content_suffix, + fancy_decorations=fancy_decorations, + ) # Typeset combined LaTeX file if output_format == "pdf": if use_tex_template: msavage_sheet( - character=character, basename=char_base, - portrait_file=portrait_file, debug=debug - ) + character=character, + basename=char_base, + debug=debug, + ) # Fillable PDF forms else: sheets.append(person_base + ".pdf") char_pdf = create_character_pdf_template( - character=character, basename=char_base, flatten=flatten + character=character, basename=char_base, flatten=flatten ) pages.append(char_pdf) person_pdf = create_personality_pdf_template( - character=character, basename=person_base, - portrait_file=portrait_file, flatten=flatten - ) + character=character, + basename=person_base, + flatten=flatten, + ) pages.append(person_pdf) - if character.is_spellcaster and not(use_tex_template): + if character.is_spellcaster and not (use_tex_template): # Create spell sheet spell_base = "{:s}_spells".format(basename) created_basenames = create_spells_pdf_template( @@ -564,7 +606,9 @@ def make_character_sheet( ) sheets.append(features_base + ".pdf") final_pdf = f"{basename}.pdf" - merge_pdfs(sheets, final_pdf, clean_up=not(debug)) + merge_pdfs(sheets, final_pdf, clean_up=not (debug)) + for image in character.images: + insert_image_into_pdf(final_pdf, *image) except exceptions.LatexNotFoundError: log.warning( f"``pdflatex`` not available. Skipping features for {character.name}" @@ -575,7 +619,7 @@ def make_character_sheet( basename=basename, title=character.name, use_dnd_decorations=fancy_decorations, - ) + ) else: raise exceptions.UnknownOutputFormat( f"Unknown output format requested: {output_format}. Valid options are:" diff --git a/dungeonsheets/pdf_image_insert.py b/dungeonsheets/pdf_image_insert.py new file mode 100644 index 00000000..4c7aad2f --- /dev/null +++ b/dungeonsheets/pdf_image_insert.py @@ -0,0 +1,42 @@ +import io +from pathlib import Path + +from pypdf import PdfReader, PdfWriter +from reportlab.pdfgen import canvas + + +def insert_image_into_pdf( + destination_pdf: Path, + source_image_path: Path, + page_target_index: int, + x_center: int, + y_center: int, + max_width: int, + max_height: int, +): + packet = io.BytesIO() + pdf_canvas = canvas.Canvas(packet) + pdf_canvas.drawImage( + image=source_image_path, + x=x_center, + y=y_center, + width=max_width, + height=max_height, + anchor="c", + anchorAtXY=True, + preserveAspectRatio=True, + mask="auto", + ) + pdf_canvas.showPage() + pdf_canvas.save() + packet.seek(0) + + reader = PdfReader(destination_pdf) + stamp = PdfReader(packet) + writer = PdfWriter() + + for i, page in enumerate(reader.pages): + if i == page_target_index: + page.merge_page(stamp.pages[0]) + writer.add_page(page) + writer.write(destination_pdf) diff --git a/dungeonsheets/readers.py b/dungeonsheets/readers.py index e80b8d5e..88ec41a0 100644 --- a/dungeonsheets/readers.py +++ b/dungeonsheets/readers.py @@ -1,4 +1,5 @@ import importlib +import os.path import warnings import json import re @@ -439,24 +440,29 @@ def magic_items(self): """ item_types = ["weapon", "armor", "equipment"] - items = [item for item in self.json_data()['items'] - if item['type'] in item_types] + items = [ + item for item in self.json_data()["items"] if item["type"] in item_types + ] from pprint import pprint - magic_items = [item for item in items if item['data']['rarity'] not in ["Common", ""]] + + magic_items = [ + item for item in items if item["data"]["rarity"] not in ["Common", ""] + ] + # Convert magic items into classes def make_magic_item(data): try: - item = find_content(data['name'], valid_classes=[MagicItem]) + item = find_content(data["name"], valid_classes=[MagicItem]) except exceptions.ContentNotFound: # Make a generic version based on the JSON attributes - warnings.warn("Skipping unknown magic item: " + data['name']) - item_name = data['name'].replace(' ', '') + warnings.warn("Skipping unknown magic item: " + data["name"]) + item_name = data["name"].replace(" ", "") item = type(item_name, (MagicItem,), {}) return item - + magic_items = [make_magic_item(item) for item in magic_items] return magic_items - + def class_levels(self): for item in self.json_data()["items"]: if item["type"] == "class": @@ -478,8 +484,8 @@ def spells(self, prepared: bool = False): yield from spell_names def features(self): - all_items = self.json_data()['items'] - feat_names = (item['name'] for item in all_items if item['type'] == "feat") + all_items = self.json_data()["items"] + feat_names = (item["name"] for item in all_items if item["type"] == "feat") # Clean up the names for consistency feat_names = (name.lower() for name in feat_names) yield from feat_names @@ -638,6 +644,7 @@ def __call__(self): for prop_name in dir(module): if prop_name[0:2] != "__": char_props[prop_name] = getattr(module, prop_name) + char_props["source_file_location"] = os.path.dirname(filename) return char_props diff --git a/examples/bard1.py b/examples/bard1.py index b3dabe0f..f7999632 100644 --- a/examples/bard1.py +++ b/examples/bard1.py @@ -14,7 +14,7 @@ player_name = "Ben" # Be sure to list Primary class first -classes = ['Bard'] # ex: ['Wizard'] or ['Rogue', 'Fighter'] +classes = ["Bard"] # ex: ['Wizard'] or ['Rogue', 'Fighter'] levels = [20] # ex: [10] or [3, 2] subclasses = ["College of Valor"] # ex: ['Necromacy'] or ['Thief', None] background = "Criminal" @@ -35,10 +35,17 @@ # Select what skills you're proficient with # ex: skill_proficiencies = ('athletics', 'acrobatics', 'arcana') -skill_proficiencies = ('animal handling', 'athletics', 'history', 'deception', 'stealth', 'perception') +skill_proficiencies = ( + "animal handling", + "athletics", + "history", + "deception", + "stealth", + "perception", +) # Any skills you have "expertise" (Bard/Rogue) in -skill_expertise = ('history', 'deception') +skill_expertise = ("history", "deception") # Named features / feats that aren't part of your classes, race, or background. # Also include Eldritch Invocations and features you make multiple selection of @@ -69,7 +76,7 @@ pp = 0 # TODO: Put your equipped weapons and armor here -weapons = ('shortsword',) # Example: ('shortsword', 'longsword') +weapons = ("shortsword",) # Example: ('shortsword', 'longsword') magic_items = () # Example: ('ring of protection',) armor = "studded leather armor" # Eg "leather armor" shield = "" # Eg "shield" @@ -81,13 +88,24 @@ # List of known spells # Example: spells_prepared = ('magic missile', 'mage armor') -spells_prepared = ('blade ward', 'light', 'minor illusion', - 'bane', 'charm person', 'identify', 'sleep', - 'invisibility', 'fear', 'confusion', 'dream', 'eyebite', - 'teleport') # Todo: Learn some spells +spells_prepared = ( + "blade ward", + "light", + "minor illusion", + "bane", + "charm person", + "identify", + "sleep", + "invisibility", + "fear", + "confusion", + "dream", + "eyebite", + "teleport", +) # Todo: Learn some spells # Which spells have not been prepared -__spells_unprepared = ('silent image', 'bestow curse') +__spells_unprepared = ("silent image", "bestow curse") # all spells known spells = spells_prepared + __spells_unprepared @@ -108,4 +126,6 @@ features_and_traits = """TODO: Describe other features and abilities your character has.""" -portrait = True +images = [("bard1.jpeg", 0, 320, 110, 100, 100)] +portrait = "shifter_2.png" +symbol = "bard1.jpeg" diff --git a/requirements.txt b/requirements.txt index 3d26667c..b8fe857a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,13 @@ certifi>=2018.1.18 fdfgen>=0.16 -npyscreen -jinja2 -sphinx -pdfrw -EbookLib -reportlab +npyscreen~=4.10.5 +jinja2~=3.1.2 +sphinx~=7.2.6 +pdfrw~=0.4 +EbookLib~=0.18 +reportlab~=4.0.8 + +dungeonsheets~=0.19.0 +setuptools==69.0.2 +docutils~=0.20.1 +pypdf==3.17.3