From e66be2e60bd1646bc097340355a9bc0e82e99904 Mon Sep 17 00:00:00 2001 From: matsavage Date: Tue, 4 May 2021 18:02:22 +0100 Subject: [PATCH] moves template pdf functions to new file --- dungeonsheets/fill_pdf_template.py | 559 ++++++++++++++++++++++++++++ dungeonsheets/make_sheets.py | 561 +---------------------------- tests/test_make_sheets.py | 6 +- 3 files changed, 569 insertions(+), 557 deletions(-) create mode 100644 dungeonsheets/fill_pdf_template.py diff --git a/dungeonsheets/fill_pdf_template.py b/dungeonsheets/fill_pdf_template.py new file mode 100644 index 0000000..e166ffd --- /dev/null +++ b/dungeonsheets/fill_pdf_template.py @@ -0,0 +1,559 @@ +import os +import subprocess +import logging +import warnings + +import pdfrw +from fdfgen import forge_fdf + +from dungeonsheets.stats import mod_str + +CHECKBOX_ON = "Yes" +CHECKBOX_OFF = "Off" +PDFTK_CMD = "pdftk" + +log = logging.getLogger(__name__) + + +def text_box(string): + """Format a string for displaying in a text box.""" + # remove multiple whitespace without removing linebreaks + new_string = " ".join(string.replace("\n", "\m").split()) # noqa: W605 + # Remove *single* line breaks, swap *multi* line breaks to single (fdf: \r) + new_string = ( + new_string.replace("\m \m", "\r") # noqa: W605 + .replace("\m\m", "\r") # noqa: W605 + .replace("\m", " ") # noqa: W605 + ) + return new_string + +def create_character_pdf_template(character, basename, flatten=False): + # Prepare the list of fields + fields = { + # Character description + "CharacterName": character.name, + "ClassLevel": character.classes_and_levels, + "Background": str(character.background), + "PlayerName": character.player_name, + "Race ": str(character.race), + "Alignment": character.alignment, + "XP": str(character.xp), + "Inspiration": str("Yes" if character.inspiration else "No"), + # Abilities + "ProfBonus": mod_str(character.proficiency_bonus), + "STRmod": str(character.strength.value), + "STR": mod_str(character.strength.modifier), + "DEXmod ": str(character.dexterity.value), + "DEX": mod_str(character.dexterity.modifier), + "CONmod": str(character.constitution.value), + "CON": mod_str(character.constitution.modifier), + "INTmod": str(character.intelligence.value), + "INT": mod_str(character.intelligence.modifier), + "WISmod": str(character.wisdom.value), + "WIS": mod_str(character.wisdom.modifier), + "CHamod": str(character.charisma.value), + "CHA": mod_str(character.charisma.modifier), + "AC": str(character.armor_class), + "Initiative": str(character.initiative), + "Speed": str(character.speed), + "Passive": 10 + character.perception, + # Saving throws (proficiencies handled later) + "ST Strength": mod_str(character.strength.saving_throw), + "ST Dexterity": mod_str(character.dexterity.saving_throw), + "ST Constitution": mod_str(character.constitution.saving_throw), + "ST Intelligence": mod_str(character.intelligence.saving_throw), + "ST Wisdom": mod_str(character.wisdom.saving_throw), + "ST Charisma": mod_str(character.charisma.saving_throw), + # Skills (proficiencies handled below) + "Acrobatics": mod_str(character.acrobatics), + "Animal": mod_str(character.animal_handling), + "Arcana": mod_str(character.arcana), + "Athletics": mod_str(character.athletics), + "Deception ": mod_str(character.deception), + "History ": mod_str(character.history), + "Insight": mod_str(character.insight), + "Intimidation": mod_str(character.intimidation), + "Investigation ": mod_str(character.investigation), + "Medicine": mod_str(character.medicine), + "Nature": mod_str(character.nature), + "Perception ": mod_str(character.perception), + "Performance": mod_str(character.performance), + "Persuasion": mod_str(character.persuasion), + "Religion": mod_str(character.religion), + "SleightofHand": mod_str(character.sleight_of_hand), + "Stealth ": mod_str(character.stealth), + "Survival": mod_str(character.survival), + # Hit points + "HDTotal": character.hit_dice, + "HPMax": str(character.hp_max), + # Personality traits and other features + "PersonalityTraits ": text_box(character.personality_traits), + "Ideals": text_box(character.ideals), + "Bonds": text_box(character.bonds), + "Flaws": text_box(character.flaws), + "Features and Traits": text_box( + character.features_text + character.features_and_traits + ), + # Inventory + "CP": character.cp, + "SP": character.sp, + "EP": character.ep, + "GP": character.gp, + "PP": character.pp, + "Equipment": text_box(character.magic_items_text + character.equipment), + } + # Check boxes for proficiencies + ST_boxes = { + "strength": "Check Box 11", + "dexterity": "Check Box 18", + "constitution": "Check Box 19", + "intelligence": "Check Box 20", + "wisdom": "Check Box 21", + "charisma": "Check Box 22", + } + for ability in character.saving_throw_proficiencies: + fields[ST_boxes[ability]] = CHECKBOX_ON + # Add skill proficiencies + skill_boxes = { + "acrobatics": "Check Box 23", + "animal_handling": "Check Box 24", + "arcana": "Check Box 25", + "athletics": "Check Box 26", + "deception": "Check Box 27", + "history": "Check Box 28", + "insight": "Check Box 29", + "intimidation": "Check Box 30", + "investigation": "Check Box 31", + "medicine": "Check Box 32", + "nature": "Check Box 33", + "perception": "Check Box 34", + "performance": "Check Box 35", + "persuasion": "Check Box 36", + "religion": "Check Box 37", + "sleight_of_hand": "Check Box 38", + "stealth": "Check Box 39", + "survival": "Check Box 40", + } + for skill in character.skill_proficiencies: + try: + fields[skill_boxes[skill.replace(" ", "_").lower()]] = CHECKBOX_ON + except KeyError: + raise KeyError(f"Unknown skill: '{skill}'") + # Add weapons + weapon_fields = [ + ("Wpn Name", "Wpn1 AtkBonus", "Wpn1 Damage"), + ("Wpn Name 2", "Wpn2 AtkBonus ", "Wpn2 Damage "), + ("Wpn Name 3", "Wpn3 AtkBonus ", "Wpn3 Damage "), + ] + if len(character.weapons) == 0: + character.wield_weapon("unarmed") + for _fields, weapon in zip(weapon_fields, character.weapons): + name_field, atk_field, dmg_field = _fields + fields[name_field] = weapon.name + fields[atk_field] = "{:+d}".format(weapon.attack_modifier) + fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}" + # Other attack information + attack = [] + if character.armor: + attack.append(f"Armor: {character.armor}") + if character.shield: + attack.append(f"Shield: {character.shield}") + attack.append(character.attacks_and_spellcasting) + attack_str = "\n\n".join(attack) + fields["AttacksSpellcasting"] = text_box(attack_str) + # Other proficiencies and languages + prof_text = "Proficiencies:\n" + text_box(character.proficiencies_text) + prof_text += "\n\nLanguages:\n" + text_box(character.languages) + fields["ProficienciesLang"] = prof_text + # 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) + + +def create_spells_pdf_template(character, basename, flatten=False): + classes_and_levels = " / ".join( + [c.name + " " + str(c.level) for c in character.spellcasting_classes] + ) + abilities = " / ".join( + [c.spellcasting_ability.upper()[:3] for c in character.spellcasting_classes] + ) + DCs = " / ".join( + [str(character.spell_save_dc(c)) for c in character.spellcasting_classes] + ) + bonuses = " / ".join( + [ + mod_str(character.spell_attack_bonus(c)) + for c in character.spellcasting_classes + ] + ) + + def spell_level(x): + return x or 0 + + fields = { + "Spellcasting Class 2": classes_and_levels, + "SpellcastingAbility 2": abilities, + "SpellSaveDC 2": DCs, + "SpellAtkBonus 2": bonuses, + # Number of spell slots + "SlotsTotal 19": spell_level(character.spell_slots(1)), + "SlotsTotal 20": spell_level(character.spell_slots(2)), + "SlotsTotal 21": spell_level(character.spell_slots(3)), + "SlotsTotal 22": spell_level(character.spell_slots(4)), + "SlotsTotal 23": spell_level(character.spell_slots(5)), + "SlotsTotal 24": spell_level(character.spell_slots(6)), + "SlotsTotal 25": spell_level(character.spell_slots(7)), + "SlotsTotal 26": spell_level(character.spell_slots(8)), + "SlotsTotal 27": spell_level(character.spell_slots(9)), + } + # Cantrips + cantrip_fields = (f"Spells 10{i}" for i in (14, 16, 17, 18, 19, 20, 21, 22)) + 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 + field_numbers = { + 1: ( + 1015, + 1023, + 1024, + 1025, + 1026, + 1027, + 1028, + 1029, + 1030, + 1031, + 1032, + 1033, + ), + 2: ( + 1046, + 1034, + 1035, + 1036, + 1037, + 1038, + 1039, + 1040, + 1041, + 1042, + 1043, + 1044, + 1045, + ), + 3: ( + 1048, + 1047, + 1049, + 1050, + 1051, + 1052, + 1053, + 1054, + 1055, + 1056, + 1057, + 1058, + 1059, + ), + 4: ( + 1061, + 1060, + 1062, + 1063, + 1064, + 1065, + 1066, + 1067, + 1068, + 1069, + 1070, + 1071, + 1072, + ), + 5: ( + 1074, + 1073, + 1075, + 1076, + 1077, + 1078, + 1079, + 1080, + 1081, + ), + 6: ( + 1083, + 1082, + 1084, + 1085, + 1086, + 1087, + 1088, + 1089, + 1090, + ), + 7: ( + 1092, + 1091, + 1093, + 1094, + 1095, + 1096, + 1097, + 1098, + 1099, + ), + 8: ( + 10101, + 10100, + 10102, + 10103, + 10104, + 10105, + 10106, + ), + 9: (10108, 10107, 10109, 101010, 101011, 101012, 101013), + } + prep_numbers = { + 1: ( + 251, + 309, + 3010, + 3011, + 3012, + 3013, + 3014, + 3015, + 3016, + 3017, + 3018, + 3019, + ), + 2: ( + 313, + 310, + 3020, + 3021, + 3022, + 3023, + 3024, + 3025, + 3026, + 3027, + 3028, + 3029, + 3030, + ), + 3: ( + 315, + 314, + 3031, + 3032, + 3033, + 3034, + 3035, + 3036, + 3037, + 3038, + 3039, + 3040, + 3041, + ), + 4: ( + 317, + 316, + 3042, + 3043, + 3044, + 3045, + 3046, + 3047, + 3048, + 3049, + 3050, + 3051, + 3052, + ), + 5: ( + 319, + 318, + 3053, + 3054, + 3055, + 3056, + 3057, + 3058, + 3059, + ), + 6: ( + 321, + 320, + 3060, + 3061, + 3062, + 3063, + 3064, + 3065, + 3066, + ), + 7: ( + 323, + 322, + 3067, + 3068, + 3069, + 3070, + 3071, + 3072, + 3073, + ), + 8: ( + 325, + 324, + 3074, + 3075, + 3076, + 3077, + 3078, + ), + 9: ( + 327, + 326, + 3079, + 3080, + 3081, + 3082, + 3083, + ), + } + for level in field_numbers.keys(): + spells = tuple(spl for spl in character.spells if spl.level == level) + field_names = tuple(f"Spells {i}" for i in field_numbers[level]) + prep_names = tuple(f"Check Box {i}" for i in prep_numbers[level]) + for spell, field, chk_field in zip(spells, field_names, prep_names): + fields[field] = str(spell) + is_prepared = any([spell == Spl for Spl in character.spells_prepared]) + fields[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)) + # Make the actual pdf + dirname = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forms/") + src_pdf = os.path.join(dirname, "blank-spell-sheet-default.pdf") + make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten) + +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 + ========== + fields : + Data to fill into the form. The keys are field names in the PDF + form, and the values will be entered as the value in the PDF. + src_pdf : + Path to the PDF that will serve as the template. + basename : + The path of the destination PDF without the file extensions. The + resulting pdf will be {basename}.pdf + flatten : + If truthy, the PDF will be collapsed so it is no longer + editable. + + """ + try: + _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) + + +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 + BUTTON = "/Btn" + # Names for entries in PDF annotation list + # DEFAULT_VALUE = "/DV" + # APPEARANCE = "/MK" + FIELD = "/T" + # PROPS = "/P" + TYPE = "/FT" + # FLAGS = "/Ff" + # SUBTYPE = "/Subtype" + # ALL_KEYS = [ + # "/DV", + # "/F", + # "/FT", + # "/Ff", + # "/MK", + # "/P", + # "/Rect", + # "/Subtype", + # "/T", + # "/Type", + # ] + annots = template.pages[0]["/Annots"] + # Update each annotation if it's in the requested dictionary + for annot in annots: + this_field = annot[FIELD][1:-1] + # Check if the field has a new value passed + if this_field in fields.keys(): + val = fields[this_field] + # Convert integers to strings + if isinstance(val, int): + val = str(val) + log.debug( + f"Set field '{this_field}' " + f"({annot[TYPE]}) " + f"to `{val}` ({val.__class__}) " + f"in file '{basename}.pdf'" + ) + # Prepare a PDF dictionary based on the fields properties + if annot[TYPE] == BUTTON: + # Radio buttons require special appearance streams + if val == CHECKBOX_ON: + val = bytes(val, "utf-8") + pdf_dict = pdfrw.PdfDict(V=val, AS=val) + else: + continue + else: + # All other widget types + pdf_dict = pdfrw.PdfDict(V=val) + annot.update(pdf_dict) + else: + log.debug(f"Skipping unused field '{this_field}' in file '{basename}.pdf'") + # Now write the PDF to the new pdf file + pdfrw.PdfWriter().write(f"{basename}.pdf", template) + + +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" + + fdf = forge_fdf("", fields, [], [], []) + fdf_file = open(fdfname, "wb") + fdf_file.write(fdf) + fdf_file.close() + # Build the final flattened PDF documents + dest_pdf = basename + ".pdf" + popenargs = [ + PDFTK_CMD, + src_pdf, + "fill_form", + fdfname, + "output", + dest_pdf, + ] + if flatten: + popenargs.append("flatten") + subprocess.call(popenargs) + # Clean up temporary files + os.remove(fdfname) \ No newline at end of file diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index ad595d8..c964574 100755 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -10,14 +10,14 @@ from pathlib import Path from multiprocessing import Pool, cpu_count from itertools import product -from fdfgen import forge_fdf -import pdfrw from jinja2 import Environment, PackageLoader from dungeonsheets import character as _char from dungeonsheets import exceptions, readers, latex from dungeonsheets.stats import mod_str +from dungeonsheets.fill_pdf_template import create_character_pdf_template, create_spells_pdf_template +PDFTK_CMD = "pdftk" log = logging.getLogger(__name__) @@ -116,24 +116,8 @@ jinja_env.filters["rst_to_latex"] = latex.rst_to_latex jinja_env.filters["mod_str"] = mod_str -CHECKBOX_ON = "Yes" -CHECKBOX_OFF = "Off" PDFTK_CMD = "pdftk" - -def text_box(string): - """Format a string for displaying in a text box.""" - # remove multiple whitespace without removing linebreaks - new_string = " ".join(string.replace("\n", "\m").split()) # noqa: W605 - # Remove *single* line breaks, swap *multi* line breaks to single (fdf: \r) - new_string = ( - new_string.replace("\m \m", "\r") # noqa: W605 - .replace("\m\m", "\r") # noqa: W605 - .replace("\m", " ") # noqa: W605 - ) - return new_string - - def create_druid_shapes_pdf( character, basename, keep_temp_files=False, use_dnd_decorations=False ): @@ -185,542 +169,8 @@ def create_features_pdf( use_dnd_decorations=use_dnd_decorations, ) - -def create_spells_pdf(character, basename, flatten=False): - classes_and_levels = " / ".join( - [c.name + " " + str(c.level) for c in character.spellcasting_classes] - ) - abilities = " / ".join( - [c.spellcasting_ability.upper()[:3] for c in character.spellcasting_classes] - ) - DCs = " / ".join( - [str(character.spell_save_dc(c)) for c in character.spellcasting_classes] - ) - bonuses = " / ".join( - [ - mod_str(character.spell_attack_bonus(c)) - for c in character.spellcasting_classes - ] - ) - - def spell_level(x): - return x or 0 - - fields = { - "Spellcasting Class 2": classes_and_levels, - "SpellcastingAbility 2": abilities, - "SpellSaveDC 2": DCs, - "SpellAtkBonus 2": bonuses, - # Number of spell slots - "SlotsTotal 19": spell_level(character.spell_slots(1)), - "SlotsTotal 20": spell_level(character.spell_slots(2)), - "SlotsTotal 21": spell_level(character.spell_slots(3)), - "SlotsTotal 22": spell_level(character.spell_slots(4)), - "SlotsTotal 23": spell_level(character.spell_slots(5)), - "SlotsTotal 24": spell_level(character.spell_slots(6)), - "SlotsTotal 25": spell_level(character.spell_slots(7)), - "SlotsTotal 26": spell_level(character.spell_slots(8)), - "SlotsTotal 27": spell_level(character.spell_slots(9)), - } - # Cantrips - cantrip_fields = (f"Spells 10{i}" for i in (14, 16, 17, 18, 19, 20, 21, 22)) - 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 - field_numbers = { - 1: ( - 1015, - 1023, - 1024, - 1025, - 1026, - 1027, - 1028, - 1029, - 1030, - 1031, - 1032, - 1033, - ), - 2: ( - 1046, - 1034, - 1035, - 1036, - 1037, - 1038, - 1039, - 1040, - 1041, - 1042, - 1043, - 1044, - 1045, - ), - 3: ( - 1048, - 1047, - 1049, - 1050, - 1051, - 1052, - 1053, - 1054, - 1055, - 1056, - 1057, - 1058, - 1059, - ), - 4: ( - 1061, - 1060, - 1062, - 1063, - 1064, - 1065, - 1066, - 1067, - 1068, - 1069, - 1070, - 1071, - 1072, - ), - 5: ( - 1074, - 1073, - 1075, - 1076, - 1077, - 1078, - 1079, - 1080, - 1081, - ), - 6: ( - 1083, - 1082, - 1084, - 1085, - 1086, - 1087, - 1088, - 1089, - 1090, - ), - 7: ( - 1092, - 1091, - 1093, - 1094, - 1095, - 1096, - 1097, - 1098, - 1099, - ), - 8: ( - 10101, - 10100, - 10102, - 10103, - 10104, - 10105, - 10106, - ), - 9: (10108, 10107, 10109, 101010, 101011, 101012, 101013), - } - prep_numbers = { - 1: ( - 251, - 309, - 3010, - 3011, - 3012, - 3013, - 3014, - 3015, - 3016, - 3017, - 3018, - 3019, - ), - 2: ( - 313, - 310, - 3020, - 3021, - 3022, - 3023, - 3024, - 3025, - 3026, - 3027, - 3028, - 3029, - 3030, - ), - 3: ( - 315, - 314, - 3031, - 3032, - 3033, - 3034, - 3035, - 3036, - 3037, - 3038, - 3039, - 3040, - 3041, - ), - 4: ( - 317, - 316, - 3042, - 3043, - 3044, - 3045, - 3046, - 3047, - 3048, - 3049, - 3050, - 3051, - 3052, - ), - 5: ( - 319, - 318, - 3053, - 3054, - 3055, - 3056, - 3057, - 3058, - 3059, - ), - 6: ( - 321, - 320, - 3060, - 3061, - 3062, - 3063, - 3064, - 3065, - 3066, - ), - 7: ( - 323, - 322, - 3067, - 3068, - 3069, - 3070, - 3071, - 3072, - 3073, - ), - 8: ( - 325, - 324, - 3074, - 3075, - 3076, - 3077, - 3078, - ), - 9: ( - 327, - 326, - 3079, - 3080, - 3081, - 3082, - 3083, - ), - } - for level in field_numbers.keys(): - spells = tuple(spl for spl in character.spells if spl.level == level) - field_names = tuple(f"Spells {i}" for i in field_numbers[level]) - prep_names = tuple(f"Check Box {i}" for i in prep_numbers[level]) - for spell, field, chk_field in zip(spells, field_names, prep_names): - fields[field] = str(spell) - is_prepared = any([spell == Spl for Spl in character.spells_prepared]) - fields[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)) - # Make the actual pdf - dirname = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forms/") - src_pdf = os.path.join(dirname, "blank-spell-sheet-default.pdf") - make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten) - - -def create_character_pdf(character, basename, flatten=False): - # Prepare the list of fields - fields = { - # Character description - "CharacterName": character.name, - "ClassLevel": character.classes_and_levels, - "Background": str(character.background), - "PlayerName": character.player_name, - "Race ": str(character.race), - "Alignment": character.alignment, - "XP": str(character.xp), - "Inspiration": str("Yes" if character.inspiration else "No"), - # Abilities - "ProfBonus": mod_str(character.proficiency_bonus), - "STRmod": str(character.strength.value), - "STR": mod_str(character.strength.modifier), - "DEXmod ": str(character.dexterity.value), - "DEX": mod_str(character.dexterity.modifier), - "CONmod": str(character.constitution.value), - "CON": mod_str(character.constitution.modifier), - "INTmod": str(character.intelligence.value), - "INT": mod_str(character.intelligence.modifier), - "WISmod": str(character.wisdom.value), - "WIS": mod_str(character.wisdom.modifier), - "CHamod": str(character.charisma.value), - "CHA": mod_str(character.charisma.modifier), - "AC": str(character.armor_class), - "Initiative": str(character.initiative), - "Speed": str(character.speed), - "Passive": 10 + character.perception, - # Saving throws (proficiencies handled later) - "ST Strength": mod_str(character.strength.saving_throw), - "ST Dexterity": mod_str(character.dexterity.saving_throw), - "ST Constitution": mod_str(character.constitution.saving_throw), - "ST Intelligence": mod_str(character.intelligence.saving_throw), - "ST Wisdom": mod_str(character.wisdom.saving_throw), - "ST Charisma": mod_str(character.charisma.saving_throw), - # Skills (proficiencies handled below) - "Acrobatics": mod_str(character.acrobatics), - "Animal": mod_str(character.animal_handling), - "Arcana": mod_str(character.arcana), - "Athletics": mod_str(character.athletics), - "Deception ": mod_str(character.deception), - "History ": mod_str(character.history), - "Insight": mod_str(character.insight), - "Intimidation": mod_str(character.intimidation), - "Investigation ": mod_str(character.investigation), - "Medicine": mod_str(character.medicine), - "Nature": mod_str(character.nature), - "Perception ": mod_str(character.perception), - "Performance": mod_str(character.performance), - "Persuasion": mod_str(character.persuasion), - "Religion": mod_str(character.religion), - "SleightofHand": mod_str(character.sleight_of_hand), - "Stealth ": mod_str(character.stealth), - "Survival": mod_str(character.survival), - # Hit points - "HDTotal": character.hit_dice, - "HPMax": str(character.hp_max), - # Personality traits and other features - "PersonalityTraits ": text_box(character.personality_traits), - "Ideals": text_box(character.ideals), - "Bonds": text_box(character.bonds), - "Flaws": text_box(character.flaws), - "Features and Traits": text_box( - character.features_text + character.features_and_traits - ), - # Inventory - "CP": character.cp, - "SP": character.sp, - "EP": character.ep, - "GP": character.gp, - "PP": character.pp, - "Equipment": text_box(character.magic_items_text + character.equipment), - } - # Check boxes for proficiencies - ST_boxes = { - "strength": "Check Box 11", - "dexterity": "Check Box 18", - "constitution": "Check Box 19", - "intelligence": "Check Box 20", - "wisdom": "Check Box 21", - "charisma": "Check Box 22", - } - for ability in character.saving_throw_proficiencies: - fields[ST_boxes[ability]] = CHECKBOX_ON - # Add skill proficiencies - skill_boxes = { - "acrobatics": "Check Box 23", - "animal_handling": "Check Box 24", - "arcana": "Check Box 25", - "athletics": "Check Box 26", - "deception": "Check Box 27", - "history": "Check Box 28", - "insight": "Check Box 29", - "intimidation": "Check Box 30", - "investigation": "Check Box 31", - "medicine": "Check Box 32", - "nature": "Check Box 33", - "perception": "Check Box 34", - "performance": "Check Box 35", - "persuasion": "Check Box 36", - "religion": "Check Box 37", - "sleight_of_hand": "Check Box 38", - "stealth": "Check Box 39", - "survival": "Check Box 40", - } - for skill in character.skill_proficiencies: - try: - fields[skill_boxes[skill.replace(" ", "_").lower()]] = CHECKBOX_ON - except KeyError: - raise KeyError(f"Unknown skill: '{skill}'") - # Add weapons - weapon_fields = [ - ("Wpn Name", "Wpn1 AtkBonus", "Wpn1 Damage"), - ("Wpn Name 2", "Wpn2 AtkBonus ", "Wpn2 Damage "), - ("Wpn Name 3", "Wpn3 AtkBonus ", "Wpn3 Damage "), - ] - if len(character.weapons) == 0: - character.wield_weapon("unarmed") - for _fields, weapon in zip(weapon_fields, character.weapons): - name_field, atk_field, dmg_field = _fields - fields[name_field] = weapon.name - fields[atk_field] = "{:+d}".format(weapon.attack_modifier) - fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}" - # Other attack information - attack = [] - if character.armor: - attack.append(f"Armor: {character.armor}") - if character.shield: - attack.append(f"Shield: {character.shield}") - attack.append(character.attacks_and_spellcasting) - attack_str = "\n\n".join(attack) - fields["AttacksSpellcasting"] = text_box(attack_str) - # Other proficiencies and languages - prof_text = "Proficiencies:\n" + text_box(character.proficiencies_text) - prof_text += "\n\nLanguages:\n" + text_box(character.languages) - fields["ProficienciesLang"] = prof_text - # 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) - - -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 - ========== - fields : - Data to fill into the form. The keys are field names in the PDF - form, and the values will be entered as the value in the PDF. - src_pdf : - Path to the PDF that will serve as the template. - basename : - The path of the destination PDF without the file extensions. The - resulting pdf will be {basename}.pdf - flatten : - If truthy, the PDF will be collapsed so it is no longer - editable. - - """ - try: - _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) - - -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 - BUTTON = "/Btn" - # Names for entries in PDF annotation list - # DEFAULT_VALUE = "/DV" - # APPEARANCE = "/MK" - FIELD = "/T" - # PROPS = "/P" - TYPE = "/FT" - # FLAGS = "/Ff" - # SUBTYPE = "/Subtype" - # ALL_KEYS = [ - # "/DV", - # "/F", - # "/FT", - # "/Ff", - # "/MK", - # "/P", - # "/Rect", - # "/Subtype", - # "/T", - # "/Type", - # ] - annots = template.pages[0]["/Annots"] - # Update each annotation if it's in the requested dictionary - for annot in annots: - this_field = annot[FIELD][1:-1] - # Check if the field has a new value passed - if this_field in fields.keys(): - val = fields[this_field] - # Convert integers to strings - if isinstance(val, int): - val = str(val) - log.debug( - f"Set field '{this_field}' " - f"({annot[TYPE]}) " - f"to `{val}` ({val.__class__}) " - f"in file '{basename}.pdf'" - ) - # Prepare a PDF dictionary based on the fields properties - if annot[TYPE] == BUTTON: - # Radio buttons require special appearance streams - if val == CHECKBOX_ON: - val = bytes(val, "utf-8") - pdf_dict = pdfrw.PdfDict(V=val, AS=val) - else: - continue - else: - # All other widget types - pdf_dict = pdfrw.PdfDict(V=val) - annot.update(pdf_dict) - else: - log.debug(f"Skipping unused field '{this_field}' in file '{basename}.pdf'") - # Now write the PDF to the new pdf file - pdfrw.PdfWriter().write(f"{basename}.pdf", template) - - -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" - - fdf = forge_fdf("", fields, [], [], []) - fdf_file = open(fdfname, "wb") - fdf_file.write(fdf) - fdf_file.close() - # Build the final flattened PDF documents - dest_pdf = basename + ".pdf" - popenargs = [ - PDFTK_CMD, - src_pdf, - "fill_form", - fdfname, - "output", - dest_pdf, - ] - if flatten: - popenargs.append("flatten") - subprocess.call(popenargs) - # Clean up temporary files - os.remove(fdfname) - - def make_sheet( - character_file, character=None, flatten=False, fancy_decorations=False, debug=False + character_file, character=None, flatten=False, latex_template=True, fancy_decorations=False, debug=False ): """Prepare a PDF character sheet from the given character file. @@ -743,18 +193,19 @@ def make_sheet( """ if character is None: character = _char.Character.load(character_file) + # Set the fields in the FDF char_base = os.path.splitext(character_file)[0] + "_char" sheets = [char_base + ".pdf"] pages = [] - char_pdf = create_character_pdf( + char_pdf = create_character_pdf_template( character=character, basename=char_base, flatten=flatten ) pages.append(char_pdf) if character.is_spellcaster: # Create spell sheet spell_base = "{:s}_spells".format(os.path.splitext(character_file)[0]) - create_spells_pdf(character=character, basename=spell_base, flatten=flatten) + create_spells_pdf_template(character=character, basename=spell_base, flatten=flatten) sheets.append(spell_base + ".pdf") if len(character.features) > 0: feat_base = "{:s}_feats".format(os.path.splitext(character_file)[0]) diff --git a/tests/test_make_sheets.py b/tests/test_make_sheets.py index e77a912..c0e0ebd 100644 --- a/tests/test_make_sheets.py +++ b/tests/test_make_sheets.py @@ -3,6 +3,8 @@ import os from pathlib import Path from dungeonsheets import make_sheets, character +from dungeonsheets.fill_pdf_template import create_character_pdf_template, create_spells_pdf_template + EG_DIR = os.path.abspath(os.path.join(os.path.split(__file__)[0], "../examples/")) CHARFILE = os.path.join(EG_DIR, "rogue1.py") @@ -17,7 +19,7 @@ class CharacterFileTestCase(unittest.TestCase): self.assertEqual(result["strength"], 10) -class PdfOutputTeestCase(unittest.TestCase): +class PdfOutputTestCase(unittest.TestCase): basename = "clara" def tearDown(self): @@ -32,5 +34,5 @@ class PdfOutputTeestCase(unittest.TestCase): # self.assertFalse(os.path.exists(pdf_name), f'{pdf_name} already exists.') char = character.Character(name="Clara") char.saving_throw_proficiencies = ["strength"] - make_sheets.create_character_pdf(character=char, basename=self.basename) + make_sheets.create_character_pdf_template(character=char, basename=self.basename) self.assertTrue(os.path.exists(pdf_name), f"{pdf_name} not created.")