Added a fallback PDF routine that uses pdfrw package instead of pdftk utility.

This commit is contained in:
Mark Wolfman
2018-10-17 02:53:11 -05:00
parent 3c3d4be218
commit 3368a435cb
10 changed files with 258 additions and 113 deletions
+13 -9
View File
@@ -28,24 +28,28 @@ Installation
.. _f-strings: https://www.python.org/dev/peps/pep-0498/
External dependencies
=====================
Optional External dependencies
==============================
* You will need **pdftk** installed to generate the sheets in PDF format.
* You may use **pdftk** to generate the sheets in PDF format.
* You will need **pdflatex** installed to generate the PDF spell pages (optional).
.. note::
If **pdftk** is available, it will be used for pdf generation. If not,
a fallback python library (pdfrw) will be used. This has some
limitations:
- Produces v1.3 PDF files
- Not able to flatten PDF forms
- Will produce separate character-sheets, spell-lists and spell-books.
Different linux distributions have different names for packages. While
pdftk is available in Debian and derivatives as **pdftk**, the package
is not available in some RPM distributions, such as Fedora and CentOS.
One alternative would be to build your PC sheets using docker.
.. note::
If the ``pdflatex`` command is available on your system,
spellcasters will include a spellbook with descriptions of each
spell known. If not, then this feature will be skipped.
If the ``pdflatex`` command is available on your system, spellcasters
will include a spellbook with descriptions of each spell known. If
not, then this feature will be skipped.
Usage
=====
+1 -1
View File
@@ -100,7 +100,7 @@ class Character():
@property
def speed(self):
return self.race.speed
return getattr(self.race, 'speed', 30)
def set_attrs(self, **attrs):
"""Bulk setting of attributes. Useful for loading a character from a
+207 -95
View File
@@ -10,6 +10,7 @@ import warnings
import re
from fdfgen import forge_fdf
import pdfrw
from dungeonsheets import character, exceptions
from dungeonsheets.stats import mod_str
@@ -18,6 +19,11 @@ from dungeonsheets.stats import mod_str
"""Program to take character definitions and build a PDF of the
character sheet."""
CHECKBOX_ON = 'Yes'
CHECKBOX_OFF = 'Off'
PDFTK_CMD = 'pdftk'
def text_box(string):
"""Format a string for displaying in a text box."""
# Remove line breaks
@@ -112,27 +118,27 @@ def create_spellbook_pdf(character, basename):
def create_spells_pdf(character, basename, flatten=False):
class_level = (character.class_name + ' ' + str(character.level))
spell_level = lambda x : (x or '')
fields = [
('Spellcasting Class 2', class_level),
("SpellcastingAbility 2", character.spellcasting_ability.capitalize()),
('SpellSaveDC 2', character.spell_save_dc),
('SpellAtkBonus 2', mod_str(character.spell_attack_bonus)),
fields = {
'Spellcasting Class 2': class_level,
'SpellcastingAbility 2': character.spellcasting_ability.capitalize(),
'SpellSaveDC 2': character.spell_save_dc,
'SpellAtkBonus 2': mod_str(character.spell_attack_bonus),
# 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))),
]
'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.append((field_name, str(spell)))
fields[field_name] = str(spell)
# Spells for each level
field_numbers = {
1: (1015, 1023, 1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, ),
@@ -161,9 +167,9 @@ def create_spells_pdf(character, basename, flatten=False):
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.append((field, str(spell)))
fields[field] = str(spell)
is_prepared = any([isinstance(spell, Spl) for Spl in character.spells_prepared])
fields.append((chk_field, is_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))
@@ -175,77 +181,77 @@ def create_spells_pdf(character, basename, flatten=False):
def create_character_pdf(character, basename, flatten=False):
# Prepare the list of fields
class_level = (character.class_name + ' ' + str(character.level))
fields = [
class_level = f"{character.class_name} {character.level}"
fields = {
# Character description
('CharacterName', character.name),
('ClassLevel', class_level),
('Background', character.background),
('PlayerName', character.player_name),
('Race ', str(character.race)),
('Alignment', character.alignment),
('XP', character.xp),
'CharacterName': character.name,
'ClassLevel': class_level,
'Background': character.background,
'PlayerName': character.player_name,
'Race ': str(character.race),
'Alignment': character.alignment,
'XP': str(character.xp),
# Abilities
('ProfBonus', mod_str(character.proficiency_bonus)),
('STRmod', str(character.strength.value)),
('STR', mod_str(character.strength.modifier)),
('DEXmod ', character.dexterity.value),
('DEX', mod_str(character.dexterity.modifier)),
('CONmod', character.constitution.value),
('CON', mod_str(character.constitution.modifier)),
('INTmod', character.intelligence.value),
('INT', mod_str(character.intelligence.modifier)),
('WISmod', character.wisdom.value),
('WIS', mod_str(character.wisdom.modifier)),
('CHamod', character.charisma.value),
('CHA', mod_str(character.charisma.modifier)),
('AC', character.armor_class),
('Initiative', mod_str(character.dexterity.modifier)),
('Speed', character.speed),
('Passive', 10 + character.perception),
'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': mod_str(character.dexterity.modifier),
'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)),
'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.persuasian)),
('Religion', mod_str(character.religion)),
('SleightofHand', mod_str(character.sleight_of_hand)),
('Stealth ', mod_str(character.stealth)),
('Survival', mod_str(character.survival)),
'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.persuasian),
'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', character.hp_max),
'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_and_traits)),
'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_and_traits),
# Inventory
('CP', character.cp),
('SP', character.sp),
('EP', character.ep),
('GP', character.gp),
('PP', character.pp),
('Equipment', text_box(character.equipment)),
]
'CP': character.cp,
'SP': character.sp,
'EP': character.ep,
'GP': character.gp,
'PP': character.pp,
'Equipment': text_box(character.equipment),
}
# Check boxes for proficiencies
ST_boxes = {
'strength': 'Check Box 11',
@@ -256,7 +262,8 @@ def create_character_pdf(character, basename, flatten=False):
'charisma': 'Check Box 22',
}
for ability in character.saving_throw_proficiencies:
fields.append((ST_boxes[ability], 'Yes'))
fields[ST_boxes[ability]] = CHECKBOX_ON
# Add skill proficiencies
skill_boxes = {
'acrobatics': 'Check Box 23',
'animal_handling': 'Check Box 24',
@@ -277,10 +284,9 @@ def create_character_pdf(character, basename, flatten=False):
'stealth': 'Check Box 39',
'survival': 'Check Box 40',
}
# Add skill proficienies
for skill in character.skill_proficiencies:
try:
fields.append((skill_boxes[skill.replace(' ', '_').lower()], 'Yes'))
fields[skill_boxes[skill.replace(' ', '_').lower()]] = CHECKBOX_ON
except KeyError:
raise KeyError(f"Unknown skill: '{skill}'")
# Add weapons
@@ -289,27 +295,104 @@ def create_character_pdf(character, basename, flatten=False):
('Wpn Name 3', 'Wpn3 AtkBonus ', 'Wpn3 Damage '),]
for _fields, weapon in zip(weapon_fields, character.weapons):
name_field, atk_field, dmg_field = _fields
fields.append((name_field, weapon.name))
fields.append((atk_field, mod_str(weapon.attack_bonus)))
fields.append((dmg_field, f'{weapon.damage} {weapon.damage_type}'))
fields[name_field] = weapon.name
fields[atk_field] = str(weapon.attack_bonus)
fields[dmg_field] = f'{weapon.damage} {weapon.damage_type}'
# Other attack information
attack_str = f'Armor: {character.armor}'
attack_str += f'Shield: {character.shield}\n\n'
attack_str += character.attacks_and_spellcasting
fields.append(('AttacksSpellcasting', text_box(attack_str)))
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.append(('ProficienciesLang', prof_text))
fields['ProficienciesLang'] = prof_text
# Prepare the actual PDF
dirname = os.path.dirname(os.path.abspath(__file__))
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, src_pdf, basename, flatten=False):
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:
result = _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)
@@ -317,7 +400,7 @@ def make_pdf(fields, src_pdf, basename, flatten=False):
# Build the final flattened PDF documents
dest_pdf = basename + '.pdf'
popenargs = [
'pdftk', src_pdf, 'fill_form', fdfname, 'output', dest_pdf,
PDFTK_CMD, src_pdf, 'fill_form', fdfname, 'output', dest_pdf,
]
if flatten:
popenargs.append('flatten')
@@ -327,6 +410,7 @@ def make_pdf(fields, src_pdf, basename, flatten=False):
def make_sheet(character_file, flatten=False):
"""Prepare a PDF character sheet from the given character file.
Parameters
@@ -343,7 +427,9 @@ def make_sheet(character_file, flatten=False):
# Set the fields in the FDF
char_base = os.path.splitext(character_file)[0] + '_char'
sheets = [char_base + '.pdf']
create_character_pdf(character=char, basename=char_base, flatten=flatten)
pages = []
char_pdf = create_character_pdf(character=char, basename=char_base, flatten=flatten)
pages.append(char_pdf)
if char.is_spellcaster:
# Create spell sheet
spell_base = os.path.splitext(character_file)[0] + '_spells'
@@ -360,10 +446,31 @@ def make_sheet(character_file, flatten=False):
sheets.append(spellbook_base + '.pdf')
# Combine sheets into final pdf
final_pdf = os.path.splitext(character_file)[0] + '.pdf'
popenargs = ('pdftk', *sheets, 'cat', 'output', final_pdf)
merge_pdfs(sheets, final_pdf, clean_up=True)
def merge_pdfs(src_filenames, dest_filename, clean_up=False):
"""Merge several PDF files into a single final file.
src_filenames
Iterable of source PDF file paths to use.
dest_filename
Path to requested PDF filename, will be overwritten if it
exists.
clean_up : optional
If truthy, the ``src_filenames`` will be deleted once the
``dest_filename`` has been created.
"""
popenargs = (PDFTK_CMD, *src_filenames, 'cat', 'output', dest_filename)
try:
subprocess.call(popenargs)
except FileNotFoundError:
warnings.warn(f'Could not run `{PDFTK_CMD}`; skipping file concatenation.',
RuntimeWarning)
else:
# Remove temporary files
for sheet in sheets:
if clean_up:
for sheet in src_filenames:
os.remove(sheet)
@@ -375,7 +482,12 @@ def main():
help="Python file with character definition")
parser.add_argument('--editable', '-e', action="store_true",
help="Keep the PDF fields in place once processed.")
parser.add_argument('--debug', '-d', action="store_true",
help="Provide verbose logging for debugging purposes.")
args = parser.parse_args()
# Prepare logging if necessary
if args.debug:
logging.basicConfig(level=logging.DEBUG)
# Process the requested files
if args.filename is None:
filenames = [f for f in os.listdir('.') if os.path.splitext(f)[1] == '.py']
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -45,7 +45,7 @@ equipment = (
# List of known spells
spells = ('blindness deafness', 'burning hands', 'detect magic',
'falsee life', 'mage armor', 'mage hand', 'magic missile',
'false life', 'mage armor', 'mage hand', 'magic missile',
'prestidigitation', 'ray of frost', 'ray of sickness', 'shield',
'shocking grasp', 'sleep',)
# Which spells have been prepared (not including cantrips)
+1
View File
@@ -4,3 +4,4 @@ npyscreen
jinja2
pytest
sphinx
pdfrw
+9
View File
@@ -153,3 +153,12 @@ class TestCharacter(TestCase):
# Try passing an Armor object directly
char.wield_shield(Shield)
self.assertEqual(char.armor_class, 15)
def test_speed(self):
# Check that the speed pulls from the character's race
char = Character(race='halfling')
self.assertEqual(char.speed, 25)
# Check that a character with no race defaults to 30 feet
char = Character()
char.race = None
self.assertEqual(char.speed, 30)
+19
View File
@@ -11,3 +11,22 @@ class CharacterFileTestCase(unittest.TestCase):
charfile = CHARFILE
result = make_sheets.load_character_file(charfile)
self.assertEqual(result['strength'], 8)
class PdfOutputTeestCase(unittest.TestCase):
basename = 'clara'
def tearDown(self):
temp_files = [f'{self.basename}.pdf']
for f in temp_files:
if os.path.exists(f):
os.remove(f)
def test_file_created(self):
# Check that a file is created once the function is run
pdf_name = f'{self.basename}.pdf'
# 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)
self.assertTrue(os.path.exists(pdf_name), f'{pdf_name} not created.')