Files @ 8f8b3b38db13
Branch filter:

Location: pcgen-xml2pdf/pcgen-xml2pdf.py

chain
Fix Saving Throws for ALT sheet
#!/usr/bin/env python3

from pathlib import Path

from pikepdf import Pdf, Name, String, Array

import io, hashlib, hmac
import json
import sys
import xml.etree.ElementTree as ET

def get_fields(pdf):
    """
    Extract info about the interactive fields present in the AcroForm of the
    pikepdf.PdfObject passed.
    Also, if the PDF has attr: NeedAppearances, ensures that it's value is True.
        param: pdf - instance of<pikepdf.PdfObject>
    returns: None, if the Pdf doesn't have an AcroForm, or a nested dict containing
    the fields names as the keys, with their value being a dict following this specification:
        '/FT' : field type;
        '/V' : current value present in the field;
        '/DV': if the field is one of the text types ('/Tx' or '/Ch'),
        this shows what value is the default one when the field
        has not yet been filled;
        'options': if the field is one of the button types ('/Btn' or '/RBtn'),
        this holds a list that contains the values used to represent when the
        button is `off` or `on`, or `off` and all the values used to represent
        the different options, in case it's a '/RBtn'. This method tries to
        ensure that options[0] will always be the `off` value, but this can't be
        guaranteed to always be true, since some Pdf-Form-Maker softwares might
        not follow all the Pdf 1.7 ISO specs.
    extra_info: some common types of fields:
        '/Tx': Text Field;
        '/Ch': Options List or Combo Box;
        '/Btn': Interactive Button;
        '/RBtn': Radio Button Parent;
        '/Sig': Signature Field (not suported)
    """
    if not hasattr(pdf.Root, "AcroForm"):
        return None
    if hasattr(pdf.Root.AcroForm, "NeedAppearances"):
        # If there's NeedAppearances in the Pdf AcroForm,
        # ensure that it's value is True
        pdf.Root.AcroForm.NeedAppearances = True

    fields = {}
    radio_repeats = []
    off_values = (
        "/Off",
        "/No",
        "/0",
        "/Null",
        "/False",
        "/Nill",
    )
    # These are some `off` values that I've seen in some Pdf Forms

    for i in range(len(pdf.pages)):
        for annot in pdf.pages[i].Annots:
            if not hasattr(annot, "FT"):  # This handles kids of radio buttons
                parent_str = str(annot.Parent.T)
                if parent_str in radio_repeats:
                    # check if annot.Parent isn't yet in the repeats list.
                    # if it is, just get the '/Yes' value
                    cur_list = fields[parent_str]["options"]
                    off_value = cur_list[0]
                    options = [item for item in annot.AP.N if item != off_value]
                    cur_list.extend(options)
                    fields[parent_str]["options"] = cur_list
                    continue
#                options = [item for item in annot.AP.N]
                # access the kids keys of '/N' to get the
                # '/Off' and '/Yes' values of this first
                # radio button kid
#                if options[0] not in off_values:
#                    options.reverse()
#                info_field = {
#                    "/FT": "/RBtn",  # This '/RBtn' notation is not standartized
#                    "/V": str(annot.AS),
#                    "options": options,
#                }
#                fields.update({str(annot.Parent.T): info_field})
#                radio_repeats.append(parent_str)
            else:
                if annot.FT == "/Btn":
                    if hasattr(annot, "A"):
                        # most likely JS Code utility button.
                        # They're not truly interactive, so skip
                        continue
                    # Else, must be a normal checkbox.
                    # It's way simpler to work with them
                    off_on_values = [item for item in annot.AP.N]
                    if off_on_values[0] not in off_values:
                        # Trying to ensure that index[0] will
                        # always be the '/Off' value
                        off_on_values.reverse()
                    info_field = {
                        "/FT": "/Btn",
                        "/V": str(annot.AS),
                        "options": off_on_values,
                    }
                elif annot.FT == '/Sig':
                    # Placeholder for signatures fields
                    continue
                else:
                    # Otherwise, must be an text field
                    # ('/Tx' or '/Ch')
                    info_field = {
                        "/FT": str(annot.FT),
#                        "/V": str(annot.V),
                        "/DV": str(annot.DV),
                    }
                fields.update({str(annot.T): info_field})
    return fields


def update_fields(pdf, fields):
    """
    Update Pdf interactive form fields values
        param: pdf - instance of<pikepdf.PdfObject> to update fields values
        param: fields - dict with fields names and their desired value
    For better performance, it's recommended to send only the fields names
    that need their values updated, as this method does nested iterations.
    The desired values sent are transformed into strings before being updated.
    You can send values that are not originally in fields of the type '/Ch'.
    They'll be added to these interactive fields, and their value switched to
    the desired value.
    warning: this method doesn't try to interpret booleans as `off`/`on` values
    of buttons. Instead, make the values of buttons fields be one of those listed in
    the 'options' list when using get_fields(). Otherwise, the field might not work as expected
    """
    for i in range(len(pdf.pages)):
        for annot in pdf.pages[i].Annots:
            for field, item in fields.items():
                value = str(item)
                if not hasattr(annot, "FT"):
                    if str(annot.Parent.T) != field:
                        continue
                    else:
                        foo = Name(value)
                        annot.Parent.AS = foo
                        annot.AS = foo
                        # The Parent holds in it's .AS the `on` value of the kid
                        # that's `on`, but the kid also needs tho have it's .AS
                        # set to the `on` value to show itself the right way
                        continue
                elif field != str(annot.T):
                    continue
                field_type = str(annot.FT)
                if field_type == "/Tx":
                    foo = String(value)
                    annot.V = foo
                    annot.DV = foo
                    # DV's are also updated because this "forces"
                    # most pdfviewers to display them
                elif field_type == "/Btn":
                    if hasattr(annot, "A"):
                        # This skips most JS Code buttons
                        continue
                    foo = String(value)
                    annot.AS = Name(value)
                    annot.V = foo
                    annot.DV = foo
                    # Normal checkboxes (usually) hold their values
                    # both in the AS and V
                elif field_type == "/Ch":
                    opt_list = [str(item) for item in annot.Opt]
                    if value not in opt_list:
                        # Add the value to the original '/Opt' -- why
                        # limit yourself to only the values that the pdf form
                        # creator wants you to use?
                        opt_list.append(value)
                        opt_list.sort()
                        annot.Opt = Array(opt_list)
                    foo = String(value)
                    annot.V = foo
                    annot.DV = foo
                elif field_type == "/Sig":
                    # Placeholder for signature fields. Needs more research
                    continue


if __name__ == '__main__':

    # Get the script's directory
    script_directory = str(Path(__file__).parent.absolute())

    xmlpath = sys.argv[1]
    #outpath = xmlpath+".pdf"
    outpath = "/tmp/"+str(Path(xmlpath).name)+".pdf"

    root = ET.parse(xmlpath)

    charactertype = root.find(".//basics/charactertype").text
    print("charactertype: "+charactertype)

    if charactertype == "PC":
        source = script_directory + "/templates/DnD_5E_CharacterSheet - Form Fillable.pdf"
    elif charactertype == "NPC":
        source = script_directory + "/templates/Character Sheet - Alternative - Form Fillable.pdf"
    else:
        print("Error: Unknown charactertype")
        exit(1)

    with open(source, "rb") as f:
        digest = hashlib.file_digest(f, "sha1")
    hash = digest.hexdigest()
    print("source: '"+source+"' (SHA1="+hash+")")

    if hash == "e58f7571d0da0d7bdb552301a3cfec17d0abca37":
        pdftype = "ORG"
    elif hash == "074f7998dff511d6626febdcf6cd48c635184d76":
        pdftype = "ALT"
    else:
        print("Error: Unknown SHA1:"+hash)
        exit(1)

    new_pdf = Pdf.open(source)
    fields  = get_fields(new_pdf)
    print(json.dumps(fields, indent = 4))

    new_fields = {
#        "City Text Box": "Bababooie",
#        "Country Combo Box": "NoWhere",
#        "Gender List Box": "Helicopter",
#        "Height Formatted Field": "210",
#        "Driving License Check Box": "/Yes",

        ### BIO ###
        "ClassLevel": root.find(".//shortform").text,
        "Background": root.find(".//special_quality[type='BACKGROUND.BACKGROUND SELECTION.SPECIALQUALITY']/name").text,
        "PlayerName": root.find(".//basics/playername").text,
        "CharacterName": root.find(".//basics/name").text,
        "Race ": root.find(".//basics/race[1]").text,
        "Alignment": root.find(".//basics/alignment/long").text,
        "XP": root.find(".//basics/experience/current").text,

        ### ABILITIES ###
        "STR": root.find(".//ability/name[short='STR']/../score").text,
        "STRmod": root.find(".//ability/name[short='STR']/../modifier").text,
        "DEX": root.find(".//ability/name[short='DEX']/../score").text,
        "DEXmod ": root.find(".//ability/name[short='DEX']/../modifier").text,
        "CON": root.find(".//ability/name[short='CON']/../score").text,
        "CONmod": root.find(".//ability/name[short='CON']/../modifier").text,
        "INT": root.find(".//ability/name[short='INT']/../score").text,
        "INTmod": root.find(".//ability/name[short='INT']/../modifier").text,
        "WIS": root.find(".//ability/name[short='WIS']/../score").text,
        "WISmod": root.find(".//ability/name[short='WIS']/../modifier").text,
        "CHA": root.find(".//ability/name[short='CHA']/../score").text,
        "CHamod": root.find(".//ability/name[short='CHA']/../modifier").text,

        ### HIT POINTS ###
        "HPMax": root.find(".//points").text,

        ### SAVING THROWS ###

        # original sheet only #
        "ST Strength":     root.find(".//saving_throw/name[long='strength']/../total").text     if pdftype == "ORG" else "/False",
        "ST Dexterity":    root.find(".//saving_throw/name[long='dexterity']/../total").text    if pdftype == "ORG" else "/False",
        "ST Constitution": root.find(".//saving_throw/name[long='constitution']/../total").text if pdftype == "ORG" else "/False",
        "ST Intelligence": root.find(".//saving_throw/name[long='intelligence']/../total").text if pdftype == "ORG" else "/False",
        "ST Wisdom":       root.find(".//saving_throw/name[long='wisdom']/../total").text       if pdftype == "ORG" else "/False",
        "ST Charisma":     root.find(".//saving_throw/name[long='charisma']/../total").text     if pdftype == "ORG" else "/False",

        # alternative sheet #
        "SavingThrows":    root.find(".//saving_throw/name[long='strength']/../total").text,
        "SavingThrows2":   root.find(".//saving_throw/name[long='dexterity']/../total").text,
        "SavingThrows3":   root.find(".//saving_throw/name[long='constitution']/../total").text,
        "SavingThrows4":   root.find(".//saving_throw/name[long='intelligence']/../total").text,
        "SavingThrows5":   root.find(".//saving_throw/name[long='wisdom']/../total").text,
        "SavingThrows6":   root.find(".//saving_throw/name[long='charisma']/../total").text,

        ### COMBAT ###
        "ProfBonus": root.find(".//proficiency_bonus").text,
        "AC": root.find(".//armor_class/total").text,
        "Initiative": root.find(".//initiative/total").text,
        "Speed": int(root.find(".//move/move[name='Walk']/squares").text) * 5,
    }

    update_fields(new_pdf, new_fields)
    new_pdf.save(outpath)