#!/usr/bin/env python
import pandas as pd
import datetime
import pdfplumber
import sys, os

COLUMNS = [
    "month",
    "state",
    "permit",
    "handgun",
    "long_gun",
    "other",
    "multiple",
    "admin",
    "prepawn_handgun",
    "prepawn_long_gun",
    "prepawn_other",
    "redemption_handgun",
    "redemption_long_gun",
    "redemption_other",
    "returned_handgun",
    "returned_long_gun",
    "returned_other",
    "rentals_handgun",
    "rentals_long_gun",
    "private_sale_handgun",
    "private_sale_long_gun",
    "private_sale_other",
    "return_to_seller_handgun",
    "return_to_seller_long_gun",
    "return_to_seller_other",
    "totals"
]

# Where, in pixels from the top,
# the data starts and ends on each page.
DATA_START_TOP = 80
DATA_END_TOP = 474

def get_between(chars, x0, x1):
    return chars[
        (chars["x0"] > x0) &
        (chars["x1"] < x1)
    ]

def parse_field(text):
    if text == "": return None
    if text[0] in "0123456789":
        return int(text.replace(",", ""))
    return text

def parse_month(month_str):
    d = datetime.datetime.strptime(month_str, "%B - %Y")
    return d.strftime("%Y-%m")

def parse_pdf(file_obj):
    pdf = pdfplumber.load(file_obj)
    rects = pd.DataFrame(pdf.rects)
    chars = pd.DataFrame(pdf.chars)

    # Find the leftmost side of the rectangles that appear on each page.
    rect_counts = rects["x0"].value_counts()
    edges = rect_counts[
        rect_counts == len(pdf.pages)
    ].sort_index().index

    # Use these edges to create boundaries, defining fields.
    bounds = list(zip(edges, edges[1:]))

    def parse_line(chars):
        fields = [ "".join(get_between(chars, x0, x1)["text"])
            for x0, x1 in bounds ]

        parsed = list(map(parse_field, fields))
        return parsed

    def parse_page_chars(chars):
        c = chars[
            (chars["top"] >= DATA_START_TOP) &
            (chars["top"] < DATA_END_TOP)
        ].sort_values([ "doctop", "x0" ])

        month = parse_month("".join(chars[
            (chars["size"] == 14.183) &
            (chars["top"] > 28)
        ]["text"]))

        data = c.groupby(c["doctop"].round()).apply(parse_line)
        return pd.DataFrame([ [ month ] + d for d in data ], columns=COLUMNS)


    checks = pd.concat([ parse_page_chars(chars[chars["pageid"] == p.pageid])
        for p in pdf.pages ]).reset_index(drop=True)

    assert(len(checks) > 0)
    assert((checks.fillna(0).sum(axis=1) != (checks["totals"] * 2)).sum() == 0)
    return checks

if __name__ == "__main__":
    buf = getattr(sys.stdin, 'buffer', sys.stdin)
    checks = parse_pdf(buf)
    checks.to_csv(sys.stdout, index=False, float_format="%.0f")

@@ -137,16 +137,38 @@
 ermit%22,%0A
+    %22permit_recheck%22,%0A
     %22han
@@ -170,24 +170,24 @@
  %22handgun%22,%0A
-
     %22long_gu
@@ -1178,16 +1178,483 @@
 Y-%25m%22)%0A%0A
+def validate_data(checks):%0A    try:%0A        assert(len(checks) %3E 0)%0A    except:%0A        raise Exception(%22No data found.%22)%0A%0A    col_totals = checks.fillna(0).sum(axis=1)%0A    literal_totals = checks%5B%22totals%22%5D.fillna(0)%0A    try:%0A        assert((col_totals != (literal_totals * 2)).sum() == 0)%0A    except:%0A        msg = %22%22%22Totals don't match%0A        col_totals: %7B0%7D%0A        literal_totals: %7B1%7D%0A        %22%22%22.format(col_totals, literal_totals)%0A        raise Exception(msg)%0A%0A
 def pars
@@ -1984,16 +1984,83 @@
 ().index
+%0A    edges = ((pd.Series(edges) / 2).round() * 2).drop_duplicates()
 %0A%0A    # 
@@ -2507,40 +2507,8 @@
    %5D
-.sort_values(%5B %22doctop%22, %22x0%22 %5D)
 %0A%0A  
@@ -2666,16 +2666,17 @@
 groupby(
+(
 c%5B%22docto
@@ -2678,16 +2678,21 @@
 doctop%22%5D
+ / 3)
 .round()
@@ -2719,22 +2719,20 @@
         
-return
+df =
  pd.Data
@@ -2787,16 +2787,190 @@
 OLUMNS)%0A
+        df.loc%5B(df%5B%22state%22%5D == %22llinois%22), %22state%22%5D = %22Illinois%22%0A        try: validate_data(df)%0A        except: raise Exception(%22Invalid data for %22 + month)%0A        return df
 %0A%0A    ch
@@ -3099,116 +3099,8 @@
 e)%0A%0A
-    assert(len(checks) %3E 0)%0A    assert((checks.fillna(0).sum(axis=1) != (checks%5B%22totals%22%5D * 2)).sum() == 0)%0A
     

Tweak parser for (slightly) new format