Files
dungeon-sheets/dungeonsheets/fill_pdf_template.py
T

604 lines
18 KiB
Python

import os
import subprocess
import logging
import warnings
import pdfrw
from fdfgen import forge_fdf
from dungeonsheets.forms 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 ""),
# 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),
"HPCurrent": str(character.hp_current)
if character.hp_current is not None
else "",
"HPTemp": str(character.hp_temp) if character.hp_temp > 0 else "",
# 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 or hasattr(character, "Monk"):
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}"
# Additional attacks beyond 3
attack = [
f"{w.name}: Atk {w.attack_modifier:+d}, Dam {w.damage}/{w.damage_type}"
for w in character.weapons[len(weapon_fields) :]
]
# Other attack information
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_personality_pdf_template(character, basename, flatten=False):
# Prepare the list of fields
fields = {
"CharacterName 2": character.name,
"Age": str(character.age),
"Height": character.height,
"Weight": character.weight,
"Eyes": character.eyes,
"Skin": character.skin,
"Hair": character.hair,
# "CHARACTER IMAGE": None
# "Faction Symbol Image": None
"Allies": text_box(character.allies),
"FactionName": character.faction_name,
"Backstory": text_box(character.backstory),
"Feat+Traits": text_box(character.other_feats_traits),
"Treasure": text_box(character.treasure),
}
# 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)
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,
),
}
# Prepare the lists of spells for each level
for level in field_numbers.keys():
spells = [spl for spl in character.spells if spl.level == level]
# Determine if we should omit un-prepared spells to save space
if len(spells) > len(field_numbers[level]):
spells = [s for s in spells if s in character.spells_prepared]
warnings.warn(
f"{character.name} knows more spells than the number of "
"lines available in spell sheet. Limited to prepared "
"spells only."
)
# Build the list of PDF controls to set/toggle
field_names = [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)