mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-06-07 13:15:53 +02:00
Added a fallback PDF routine that uses pdfrw package instead of pdftk utility.
This commit is contained in:
+16
-12
@@ -28,24 +28,28 @@ Installation
|
|||||||
|
|
||||||
.. _f-strings: https://www.python.org/dev/peps/pep-0498/
|
.. _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).
|
* 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:
|
||||||
|
|
||||||
Different linux distributions have different names for packages. While
|
- Produces v1.3 PDF files
|
||||||
pdftk is available in Debian and derivatives as **pdftk**, the package
|
- Not able to flatten PDF forms
|
||||||
is not available in some RPM distributions, such as Fedora and CentOS.
|
- Will produce separate character-sheets, spell-lists and spell-books.
|
||||||
One alternative would be to build your PC sheets using docker.
|
|
||||||
|
|
||||||
.. note::
|
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.
|
||||||
|
|
||||||
If the ``pdflatex`` command is available on your system,
|
If the ``pdflatex`` command is available on your system, spellcasters
|
||||||
spellcasters will include a spellbook with descriptions of each
|
will include a spellbook with descriptions of each spell known. If
|
||||||
spell known. If not, then this feature will be skipped.
|
not, then this feature will be skipped.
|
||||||
|
|
||||||
Usage
|
Usage
|
||||||
=====
|
=====
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class Character():
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def speed(self):
|
def speed(self):
|
||||||
return self.race.speed
|
return getattr(self.race, 'speed', 30)
|
||||||
|
|
||||||
def set_attrs(self, **attrs):
|
def set_attrs(self, **attrs):
|
||||||
"""Bulk setting of attributes. Useful for loading a character from a
|
"""Bulk setting of attributes. Useful for loading a character from a
|
||||||
|
|||||||
+207
-95
@@ -10,6 +10,7 @@ import warnings
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from fdfgen import forge_fdf
|
from fdfgen import forge_fdf
|
||||||
|
import pdfrw
|
||||||
|
|
||||||
from dungeonsheets import character, exceptions
|
from dungeonsheets import character, exceptions
|
||||||
from dungeonsheets.stats import mod_str
|
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
|
"""Program to take character definitions and build a PDF of the
|
||||||
character sheet."""
|
character sheet."""
|
||||||
|
|
||||||
|
|
||||||
|
CHECKBOX_ON = 'Yes'
|
||||||
|
CHECKBOX_OFF = 'Off'
|
||||||
|
PDFTK_CMD = 'pdftk'
|
||||||
|
|
||||||
def text_box(string):
|
def text_box(string):
|
||||||
"""Format a string for displaying in a text box."""
|
"""Format a string for displaying in a text box."""
|
||||||
# Remove line breaks
|
# Remove line breaks
|
||||||
@@ -112,27 +118,27 @@ def create_spellbook_pdf(character, basename):
|
|||||||
def create_spells_pdf(character, basename, flatten=False):
|
def create_spells_pdf(character, basename, flatten=False):
|
||||||
class_level = (character.class_name + ' ' + str(character.level))
|
class_level = (character.class_name + ' ' + str(character.level))
|
||||||
spell_level = lambda x : (x or '')
|
spell_level = lambda x : (x or '')
|
||||||
fields = [
|
fields = {
|
||||||
('Spellcasting Class 2', class_level),
|
'Spellcasting Class 2': class_level,
|
||||||
("SpellcastingAbility 2", character.spellcasting_ability.capitalize()),
|
'SpellcastingAbility 2': character.spellcasting_ability.capitalize(),
|
||||||
('SpellSaveDC 2', character.spell_save_dc),
|
'SpellSaveDC 2': character.spell_save_dc,
|
||||||
('SpellAtkBonus 2', mod_str(character.spell_attack_bonus)),
|
'SpellAtkBonus 2': mod_str(character.spell_attack_bonus),
|
||||||
# Number of spell slots
|
# Number of spell slots
|
||||||
('SlotsTotal 19', spell_level(character.spell_slots(1))),
|
'SlotsTotal 19': spell_level(character.spell_slots(1)),
|
||||||
('SlotsTotal 20', spell_level(character.spell_slots(2))),
|
'SlotsTotal 20': spell_level(character.spell_slots(2)),
|
||||||
('SlotsTotal 21', spell_level(character.spell_slots(3))),
|
'SlotsTotal 21': spell_level(character.spell_slots(3)),
|
||||||
('SlotsTotal 22', spell_level(character.spell_slots(4))),
|
'SlotsTotal 22': spell_level(character.spell_slots(4)),
|
||||||
('SlotsTotal 23', spell_level(character.spell_slots(5))),
|
'SlotsTotal 23': spell_level(character.spell_slots(5)),
|
||||||
('SlotsTotal 24', spell_level(character.spell_slots(6))),
|
'SlotsTotal 24': spell_level(character.spell_slots(6)),
|
||||||
('SlotsTotal 25', spell_level(character.spell_slots(7))),
|
'SlotsTotal 25': spell_level(character.spell_slots(7)),
|
||||||
('SlotsTotal 26', spell_level(character.spell_slots(8))),
|
'SlotsTotal 26': spell_level(character.spell_slots(8)),
|
||||||
('SlotsTotal 27', spell_level(character.spell_slots(9))),
|
'SlotsTotal 27': spell_level(character.spell_slots(9)),
|
||||||
]
|
}
|
||||||
# Cantrips
|
# Cantrips
|
||||||
cantrip_fields = (f'Spells 10{i}' for i in (14, 16, 17, 18, 19, 20, 21, 22))
|
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)
|
cantrips = (spl for spl in character.spells if spl.level == 0)
|
||||||
for spell, field_name in zip(cantrips, cantrip_fields):
|
for spell, field_name in zip(cantrips, cantrip_fields):
|
||||||
fields.append((field_name, str(spell)))
|
fields[field_name] = str(spell)
|
||||||
# Spells for each level
|
# Spells for each level
|
||||||
field_numbers = {
|
field_numbers = {
|
||||||
1: (1015, 1023, 1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, ),
|
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])
|
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])
|
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):
|
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])
|
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:
|
# # Uncomment to post field names instead:
|
||||||
# for field in field_names:
|
# for field in field_names:
|
||||||
# fields.append((field, field))
|
# fields.append((field, field))
|
||||||
@@ -175,77 +181,77 @@ def create_spells_pdf(character, basename, flatten=False):
|
|||||||
|
|
||||||
def create_character_pdf(character, basename, flatten=False):
|
def create_character_pdf(character, basename, flatten=False):
|
||||||
# Prepare the list of fields
|
# Prepare the list of fields
|
||||||
class_level = (character.class_name + ' ' + str(character.level))
|
class_level = f"{character.class_name} {character.level}"
|
||||||
fields = [
|
fields = {
|
||||||
# Character description
|
# Character description
|
||||||
('CharacterName', character.name),
|
'CharacterName': character.name,
|
||||||
('ClassLevel', class_level),
|
'ClassLevel': class_level,
|
||||||
('Background', character.background),
|
'Background': character.background,
|
||||||
('PlayerName', character.player_name),
|
'PlayerName': character.player_name,
|
||||||
('Race ', str(character.race)),
|
'Race ': str(character.race),
|
||||||
('Alignment', character.alignment),
|
'Alignment': character.alignment,
|
||||||
('XP', character.xp),
|
'XP': str(character.xp),
|
||||||
# Abilities
|
# Abilities
|
||||||
('ProfBonus', mod_str(character.proficiency_bonus)),
|
'ProfBonus': mod_str(character.proficiency_bonus),
|
||||||
('STRmod', str(character.strength.value)),
|
'STRmod': str(character.strength.value),
|
||||||
('STR', mod_str(character.strength.modifier)),
|
'STR': mod_str(character.strength.modifier),
|
||||||
('DEXmod ', character.dexterity.value),
|
'DEXmod ': str(character.dexterity.value),
|
||||||
('DEX', mod_str(character.dexterity.modifier)),
|
'DEX': mod_str(character.dexterity.modifier),
|
||||||
('CONmod', character.constitution.value),
|
'CONmod': str(character.constitution.value),
|
||||||
('CON', mod_str(character.constitution.modifier)),
|
'CON': mod_str(character.constitution.modifier),
|
||||||
('INTmod', character.intelligence.value),
|
'INTmod': str(character.intelligence.value),
|
||||||
('INT', mod_str(character.intelligence.modifier)),
|
'INT': mod_str(character.intelligence.modifier),
|
||||||
('WISmod', character.wisdom.value),
|
'WISmod': str(character.wisdom.value),
|
||||||
('WIS', mod_str(character.wisdom.modifier)),
|
'WIS': mod_str(character.wisdom.modifier),
|
||||||
('CHamod', character.charisma.value),
|
'CHamod': str(character.charisma.value),
|
||||||
('CHA', mod_str(character.charisma.modifier)),
|
'CHA': mod_str(character.charisma.modifier),
|
||||||
('AC', character.armor_class),
|
'AC': str(character.armor_class),
|
||||||
('Initiative', mod_str(character.dexterity.modifier)),
|
'Initiative': mod_str(character.dexterity.modifier),
|
||||||
('Speed', character.speed),
|
'Speed': str(character.speed),
|
||||||
('Passive', 10 + character.perception),
|
'Passive': 10 + character.perception,
|
||||||
# Saving throws (proficiencies handled later)
|
# Saving throws (proficiencies handled later)
|
||||||
('ST Strength', mod_str(character.strength.saving_throw)),
|
'ST Strength': mod_str(character.strength.saving_throw),
|
||||||
('ST Dexterity', mod_str(character.dexterity.saving_throw)),
|
'ST Dexterity': mod_str(character.dexterity.saving_throw),
|
||||||
('ST Constitution', mod_str(character.constitution.saving_throw)),
|
'ST Constitution': mod_str(character.constitution.saving_throw),
|
||||||
('ST Intelligence', mod_str(character.intelligence.saving_throw)),
|
'ST Intelligence': mod_str(character.intelligence.saving_throw),
|
||||||
('ST Wisdom', mod_str(character.wisdom.saving_throw)),
|
'ST Wisdom': mod_str(character.wisdom.saving_throw),
|
||||||
('ST Charisma', mod_str(character.charisma.saving_throw)),
|
'ST Charisma': mod_str(character.charisma.saving_throw),
|
||||||
# Skills (proficiencies handled below)
|
# Skills (proficiencies handled below)
|
||||||
('Acrobatics', mod_str(character.acrobatics)),
|
'Acrobatics': mod_str(character.acrobatics),
|
||||||
('Animal', mod_str(character.animal_handling)),
|
'Animal': mod_str(character.animal_handling),
|
||||||
('Arcana', mod_str(character.arcana)),
|
'Arcana': mod_str(character.arcana),
|
||||||
('Athletics', mod_str(character.athletics)),
|
'Athletics': mod_str(character.athletics),
|
||||||
('Deception ', mod_str(character.deception)),
|
'Deception ': mod_str(character.deception),
|
||||||
('History ', mod_str(character.history)),
|
'History ': mod_str(character.history),
|
||||||
('Insight', mod_str(character.insight)),
|
'Insight': mod_str(character.insight),
|
||||||
('Intimidation', mod_str(character.intimidation)),
|
'Intimidation': mod_str(character.intimidation),
|
||||||
('Investigation ', mod_str(character.investigation)),
|
'Investigation ': mod_str(character.investigation),
|
||||||
('Medicine', mod_str(character.medicine)),
|
'Medicine': mod_str(character.medicine),
|
||||||
('Nature', mod_str(character.nature)),
|
'Nature': mod_str(character.nature),
|
||||||
('Perception ', mod_str(character.perception)),
|
'Perception ': mod_str(character.perception),
|
||||||
('Performance', mod_str(character.performance)),
|
'Performance': mod_str(character.performance),
|
||||||
('Persuasion', mod_str(character.persuasian)),
|
'Persuasion': mod_str(character.persuasian),
|
||||||
('Religion', mod_str(character.religion)),
|
'Religion': mod_str(character.religion),
|
||||||
('SleightofHand', mod_str(character.sleight_of_hand)),
|
'SleightofHand': mod_str(character.sleight_of_hand),
|
||||||
('Stealth ', mod_str(character.stealth)),
|
'Stealth ': mod_str(character.stealth),
|
||||||
('Survival', mod_str(character.survival)),
|
'Survival': mod_str(character.survival),
|
||||||
# Hit points
|
# Hit points
|
||||||
('HDTotal', character.hit_dice),
|
'HDTotal': character.hit_dice,
|
||||||
('HPMax', character.hp_max),
|
'HPMax': str(character.hp_max),
|
||||||
# Personality traits and other features
|
# Personality traits and other features
|
||||||
('PersonalityTraits ', text_box(character.personality_traits)),
|
'PersonalityTraits ': text_box(character.personality_traits),
|
||||||
('Ideals', text_box(character.ideals)),
|
'Ideals': text_box(character.ideals),
|
||||||
('Bonds', text_box(character.bonds)),
|
'Bonds': text_box(character.bonds),
|
||||||
('Flaws', text_box(character.flaws)),
|
'Flaws': text_box(character.flaws),
|
||||||
('Features and Traits', text_box(character.features_and_traits)),
|
'Features and Traits': text_box(character.features_and_traits),
|
||||||
# Inventory
|
# Inventory
|
||||||
('CP', character.cp),
|
'CP': character.cp,
|
||||||
('SP', character.sp),
|
'SP': character.sp,
|
||||||
('EP', character.ep),
|
'EP': character.ep,
|
||||||
('GP', character.gp),
|
'GP': character.gp,
|
||||||
('PP', character.pp),
|
'PP': character.pp,
|
||||||
('Equipment', text_box(character.equipment)),
|
'Equipment': text_box(character.equipment),
|
||||||
]
|
}
|
||||||
# Check boxes for proficiencies
|
# Check boxes for proficiencies
|
||||||
ST_boxes = {
|
ST_boxes = {
|
||||||
'strength': 'Check Box 11',
|
'strength': 'Check Box 11',
|
||||||
@@ -256,7 +262,8 @@ def create_character_pdf(character, basename, flatten=False):
|
|||||||
'charisma': 'Check Box 22',
|
'charisma': 'Check Box 22',
|
||||||
}
|
}
|
||||||
for ability in character.saving_throw_proficiencies:
|
for ability in character.saving_throw_proficiencies:
|
||||||
fields.append((ST_boxes[ability], 'Yes'))
|
fields[ST_boxes[ability]] = CHECKBOX_ON
|
||||||
|
# Add skill proficiencies
|
||||||
skill_boxes = {
|
skill_boxes = {
|
||||||
'acrobatics': 'Check Box 23',
|
'acrobatics': 'Check Box 23',
|
||||||
'animal_handling': 'Check Box 24',
|
'animal_handling': 'Check Box 24',
|
||||||
@@ -277,10 +284,9 @@ def create_character_pdf(character, basename, flatten=False):
|
|||||||
'stealth': 'Check Box 39',
|
'stealth': 'Check Box 39',
|
||||||
'survival': 'Check Box 40',
|
'survival': 'Check Box 40',
|
||||||
}
|
}
|
||||||
# Add skill proficienies
|
|
||||||
for skill in character.skill_proficiencies:
|
for skill in character.skill_proficiencies:
|
||||||
try:
|
try:
|
||||||
fields.append((skill_boxes[skill.replace(' ', '_').lower()], 'Yes'))
|
fields[skill_boxes[skill.replace(' ', '_').lower()]] = CHECKBOX_ON
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise KeyError(f"Unknown skill: '{skill}'")
|
raise KeyError(f"Unknown skill: '{skill}'")
|
||||||
# Add weapons
|
# Add weapons
|
||||||
@@ -289,27 +295,104 @@ def create_character_pdf(character, basename, flatten=False):
|
|||||||
('Wpn Name 3', 'Wpn3 AtkBonus ', 'Wpn3 Damage '),]
|
('Wpn Name 3', 'Wpn3 AtkBonus ', 'Wpn3 Damage '),]
|
||||||
for _fields, weapon in zip(weapon_fields, character.weapons):
|
for _fields, weapon in zip(weapon_fields, character.weapons):
|
||||||
name_field, atk_field, dmg_field = _fields
|
name_field, atk_field, dmg_field = _fields
|
||||||
fields.append((name_field, weapon.name))
|
fields[name_field] = weapon.name
|
||||||
fields.append((atk_field, mod_str(weapon.attack_bonus)))
|
fields[atk_field] = str(weapon.attack_bonus)
|
||||||
fields.append((dmg_field, f'{weapon.damage} {weapon.damage_type}'))
|
fields[dmg_field] = f'{weapon.damage} {weapon.damage_type}'
|
||||||
# Other attack information
|
# Other attack information
|
||||||
attack_str = f'Armor: {character.armor}'
|
attack_str = f'Armor: {character.armor}'
|
||||||
attack_str += f'Shield: {character.shield}\n\n'
|
attack_str += f'Shield: {character.shield}\n\n'
|
||||||
attack_str += character.attacks_and_spellcasting
|
attack_str += character.attacks_and_spellcasting
|
||||||
fields.append(('AttacksSpellcasting', text_box(attack_str)))
|
fields['AttacksSpellcasting'] = text_box(attack_str)
|
||||||
# Other proficiencies and languages
|
# Other proficiencies and languages
|
||||||
prof_text = "Proficiencies:\n" + text_box(character.proficiencies_text)
|
prof_text = "Proficiencies:\n" + text_box(character.proficiencies_text)
|
||||||
prof_text += "\n\nLanguages:\n" + text_box(character.languages)
|
prof_text += "\n\nLanguages:\n" + text_box(character.languages)
|
||||||
fields.append(('ProficienciesLang', prof_text))
|
fields['ProficienciesLang'] = prof_text
|
||||||
# Prepare the actual PDF
|
# Prepare the actual PDF
|
||||||
dirname = os.path.dirname(os.path.abspath(__file__))
|
dirname = os.path.dirname(os.path.abspath(__file__))
|
||||||
src_pdf = os.path.join(dirname, 'blank-character-sheet-default.pdf')
|
src_pdf = os.path.join(dirname, 'blank-character-sheet-default.pdf')
|
||||||
return make_pdf(fields, src_pdf=src_pdf, basename=basename, flatten=flatten)
|
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
|
# Create the actual FDF file
|
||||||
fdfname = basename + '.fdf'
|
fdfname = basename + '.fdf'
|
||||||
|
|
||||||
fdf = forge_fdf("", fields, [], [], [])
|
fdf = forge_fdf("", fields, [], [], [])
|
||||||
fdf_file = open(fdfname, "wb")
|
fdf_file = open(fdfname, "wb")
|
||||||
fdf_file.write(fdf)
|
fdf_file.write(fdf)
|
||||||
@@ -317,7 +400,7 @@ def make_pdf(fields, src_pdf, basename, flatten=False):
|
|||||||
# Build the final flattened PDF documents
|
# Build the final flattened PDF documents
|
||||||
dest_pdf = basename + '.pdf'
|
dest_pdf = basename + '.pdf'
|
||||||
popenargs = [
|
popenargs = [
|
||||||
'pdftk', src_pdf, 'fill_form', fdfname, 'output', dest_pdf,
|
PDFTK_CMD, src_pdf, 'fill_form', fdfname, 'output', dest_pdf,
|
||||||
]
|
]
|
||||||
if flatten:
|
if flatten:
|
||||||
popenargs.append('flatten')
|
popenargs.append('flatten')
|
||||||
@@ -327,6 +410,7 @@ def make_pdf(fields, src_pdf, basename, flatten=False):
|
|||||||
|
|
||||||
|
|
||||||
def make_sheet(character_file, flatten=False):
|
def make_sheet(character_file, flatten=False):
|
||||||
|
|
||||||
"""Prepare a PDF character sheet from the given character file.
|
"""Prepare a PDF character sheet from the given character file.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
@@ -343,7 +427,9 @@ def make_sheet(character_file, flatten=False):
|
|||||||
# Set the fields in the FDF
|
# Set the fields in the FDF
|
||||||
char_base = os.path.splitext(character_file)[0] + '_char'
|
char_base = os.path.splitext(character_file)[0] + '_char'
|
||||||
sheets = [char_base + '.pdf']
|
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:
|
if char.is_spellcaster:
|
||||||
# Create spell sheet
|
# Create spell sheet
|
||||||
spell_base = os.path.splitext(character_file)[0] + '_spells'
|
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')
|
sheets.append(spellbook_base + '.pdf')
|
||||||
# Combine sheets into final pdf
|
# Combine sheets into final pdf
|
||||||
final_pdf = os.path.splitext(character_file)[0] + '.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)
|
subprocess.call(popenargs)
|
||||||
|
except FileNotFoundError:
|
||||||
|
warnings.warn(f'Could not run `{PDFTK_CMD}`; skipping file concatenation.',
|
||||||
|
RuntimeWarning)
|
||||||
|
else:
|
||||||
# Remove temporary files
|
# Remove temporary files
|
||||||
for sheet in sheets:
|
if clean_up:
|
||||||
|
for sheet in src_filenames:
|
||||||
os.remove(sheet)
|
os.remove(sheet)
|
||||||
|
|
||||||
|
|
||||||
@@ -375,7 +482,12 @@ def main():
|
|||||||
help="Python file with character definition")
|
help="Python file with character definition")
|
||||||
parser.add_argument('--editable', '-e', action="store_true",
|
parser.add_argument('--editable', '-e', action="store_true",
|
||||||
help="Keep the PDF fields in place once processed.")
|
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()
|
args = parser.parse_args()
|
||||||
|
# Prepare logging if necessary
|
||||||
|
if args.debug:
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
# Process the requested files
|
# Process the requested files
|
||||||
if args.filename is None:
|
if args.filename is None:
|
||||||
filenames = [f for f in os.listdir('.') if os.path.splitext(f)[1] == '.py']
|
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
@@ -45,7 +45,7 @@ equipment = (
|
|||||||
|
|
||||||
# List of known spells
|
# List of known spells
|
||||||
spells = ('blindness deafness', 'burning hands', 'detect magic',
|
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',
|
'prestidigitation', 'ray of frost', 'ray of sickness', 'shield',
|
||||||
'shocking grasp', 'sleep',)
|
'shocking grasp', 'sleep',)
|
||||||
# Which spells have been prepared (not including cantrips)
|
# Which spells have been prepared (not including cantrips)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ npyscreen
|
|||||||
jinja2
|
jinja2
|
||||||
pytest
|
pytest
|
||||||
sphinx
|
sphinx
|
||||||
|
pdfrw
|
||||||
|
|||||||
@@ -153,3 +153,12 @@ class TestCharacter(TestCase):
|
|||||||
# Try passing an Armor object directly
|
# Try passing an Armor object directly
|
||||||
char.wield_shield(Shield)
|
char.wield_shield(Shield)
|
||||||
self.assertEqual(char.armor_class, 15)
|
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)
|
||||||
|
|||||||
@@ -11,3 +11,22 @@ class CharacterFileTestCase(unittest.TestCase):
|
|||||||
charfile = CHARFILE
|
charfile = CHARFILE
|
||||||
result = make_sheets.load_character_file(charfile)
|
result = make_sheets.load_character_file(charfile)
|
||||||
self.assertEqual(result['strength'], 8)
|
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.')
|
||||||
|
|||||||
Reference in New Issue
Block a user