From 5599e6b0d771ca7cd545919daddcd1286b49597a Mon Sep 17 00:00:00 2001 From: Ammon Sarver Date: Wed, 13 Mar 2024 01:53:52 -0600 Subject: [PATCH 1/6] feat: add pdfreader libreader importer --- .../libreader/pdfreader.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 beancount_reds_importers/libreader/pdfreader.py diff --git a/beancount_reds_importers/libreader/pdfreader.py b/beancount_reds_importers/libreader/pdfreader.py new file mode 100644 index 0000000..f9dc6a5 --- /dev/null +++ b/beancount_reds_importers/libreader/pdfreader.py @@ -0,0 +1,185 @@ + +from pprint import pformat +import pdfplumber +import pandas as pd +import petl as etl +from beancount_reds_importers.libreader import csvreader + +LEFT = 0 +TOP = 1 +RIGHT = 2 +BOTTOM = 3 + +BLACK = (0, 0, 0) +RED = (255, 0, 0) +PURPLE = (135,0,255) +TRANSPARENT = (0, 0, 0, 0) + +class Importer(csvreader.Importer): + """ + A reader that converts a pdf with tables into a multi-petl-table format understood by transaction builders. + + + ### Attributes customized in `custom_init` + self.pdf_table_extraction_settings: `{}` + a dictionary containing settings used to extract tables, see [pdfplumber documentation](https://github.com/jsvine/pdfplumber?tab=readme-ov-file#table-extraction-settings) for what settings are available + + self.pdf_table_extraction_crop: `(int,int,int,int)` + a tuple with 4 values representing distance from left, top, right, bottom of the page respectively, + this will crop the input (each page) before searching for tables + + self.pdf_table_title_height: `int` + an integer representing how far up from the top of the table should we look for a table title. + Set to 0 to not extract table titles, in which case sections will be labelled as `table_#` in the order + they were encountered + + self.pdf_page_break_top: `int` + an integer representing the threshold where a table can be considered page-broken. If the top of a table is + lower than the provided value, it will be in consideration for amending to the previous page's last table. + Set to 0 to never consider page-broken tables + + self.debug: `boolean` + When debug is True a few images and text file are generated: + .debug-pdf-metadata-page_#.png + shows the text available in self.meta_text with table data blacked out + + .debug-pdf-table-detection-page_#.png + shows the tables detected with cells outlined in red, and the background light blue. The purple box shows where we are looking for the table title. + + .debug-pdf-data.txt + is a printout of the meta_text and table data found before being processed into petl tables, as well as some generated helper objects to add to new importers or import configs + + ### Outputs + self.meta_text: `str` + contains all text found in the document outside of tables + + self.alltables: `{'table_1': , ...}` + contains all the tables found in the document keyed by the extracted title if available, otherwise by the 1-based index in the form of `table_#` + """ + FILE_EXTS = ['pdf'] + + def initialize_reader(self, file): + if getattr(self, 'file', None) != file: + self.pdf_table_extraction_settings = {} + self.pdf_table_extraction_crop = (0, 0, 0, 0) + self.pdf_table_title_height = 20 + self.pdf_page_break_top = 45 + self.debug = False + + self.meta_text = '' + self.file = file + self.file_read_done = False + self.reader_ready = True + + def file_date(self, file): + raise "Not implemented, must overwrite, check self.alltables, or self.meta_text for the data" + pass + + def prepare_tables(self): + return + + def read_file(self, file): + tables = [] + + with pdfplumber.open(file.name) as pdf: + for page_idx, page in enumerate(pdf.pages): + # all bounding boxes are (left, top, right, bottom) + adjusted_crop = ( + min(0 + self.pdf_table_extraction_crop[LEFT], page.width), + min(0 + self.pdf_table_extraction_crop[TOP], page.height), + max(page.width - self.pdf_table_extraction_crop[RIGHT],0), + max(page.height - self.pdf_table_extraction_crop[BOTTOM],0) + ) + + # Debug image + image = page.crop(adjusted_crop).to_image() + image.debug_tablefinder(tf=self.pdf_table_extraction_settings) + + table_ref = page.crop(adjusted_crop).find_tables(table_settings=self.pdf_table_extraction_settings) + page_tables = [{'table':i.extract(), 'bbox': i.bbox} for i in table_ref] + + # Get Metadata (all data outside tables) + meta_page = page + meta_image = meta_page.to_image() + for table in page_tables: + meta_page = meta_page.outside_bbox(table['bbox']) + meta_image.draw_rect(table['bbox'], BLACK, RED) + + meta_text = meta_page.extract_text() + self.meta_text = self.meta_text + meta_text + + # Attach section headers + for table_idx, table in enumerate(page_tables): + section_title_bbox = ( + table['bbox'][LEFT], + max(table['bbox'][TOP] - self.pdf_table_title_height, 0), + table['bbox'][RIGHT], + table['bbox'][TOP] + ) + section_title = meta_page.crop(section_title_bbox).extract_text() + image.draw_rect(section_title_bbox, TRANSPARENT, PURPLE) + page_tables[table_idx]['section'] = section_title + + tables = tables + page_tables + + if self.debug: + image.save('.debug-pdf-table-detection-page_{}.png'.format(page_idx)) + meta_image.save('.debug-pdf-metadata-page_{}.png'.format(page_idx)) + + + # Find and fix page broken tables + for table_idx, table in enumerate(tables[:]): + if ( + table_idx >= 1 and # if not the first table, + table['bbox'][TOP] < self.pdf_page_break_top and # and the top of the table is close to the top of the page + table['section'] == '' and # and there is no section title + tables[table_idx - 1]['table'][0] == tables[table_idx]['table'][0] # and the header rows are the same, + ): #assume a page break + tables[table_idx - 1]['table'] = tables[table_idx - 1]['table'] + tables[table_idx]['table'][1:] + del tables[table_idx] + continue + + # if there is no table section give it one + if table['section'] == '': + tables[table_idx]['section'] = 'table_{}'.format(table_idx + 1) + + if self.debug: + # generate helpers + paycheck_template = {} + header_map = {} + for table in tables: + for header in table['table'][0]: + header_map[header]='overwrite_me' + paycheck_template[table['section']] = {} + for row_idx, row in enumerate(table['table']): + if row_idx == 0: + continue + paycheck_template[table['section']][row[0]] = 'overwrite_me' + if not hasattr(self, 'header_map'): + self.header_map = header_map + if not hasattr(self, 'paycheck_template'): + self.paycheck_template = paycheck_template + with open('.debug-pdf-data.txt', "w") as debug_file: + debug_file.write(pformat({ + '_output': { + 'tables':tables, + 'meta_text':self.meta_text + }, + '_input': { + 'table_settings': self.pdf_table_extraction_settings, + 'crop_settings': self.pdf_table_extraction_crop + }, + 'helpers': { + 'header_map':self.header_map, + 'paycheck_template':self.paycheck_template + } + })) + + + + self.alltables = {} + for table in tables: + self.alltables[table['section']] = etl.fromdataframe(pd.DataFrame(table['table'][1:], columns=table['table'][0])) + + self.prepare_tables() + self.file_read_done = True From 041e00617989fc103ad10431bca78c9fa0cde763 Mon Sep 17 00:00:00 2001 From: Ammon Sarver Date: Wed, 13 Mar 2024 01:54:13 -0600 Subject: [PATCH 2/6] feat: add bamboohr paycheck importer --- .../importers/bamboohr/__init__.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 beancount_reds_importers/importers/bamboohr/__init__.py diff --git a/beancount_reds_importers/importers/bamboohr/__init__.py b/beancount_reds_importers/importers/bamboohr/__init__.py new file mode 100644 index 0000000..2818190 --- /dev/null +++ b/beancount_reds_importers/importers/bamboohr/__init__.py @@ -0,0 +1,62 @@ +"""BambooHR paycheck importer""" + +import re +from dateparser.search import search_dates +from beancount_reds_importers.libreader import pdfreader +from beancount_reds_importers.libtransactionbuilder import paycheck + +# BambooHR exports paycheck stubs to pdf, with multiple tables across multiple pages. +# Call this importer with a config that looks like: +# +# bamboohr.Importer({"desc":"Paycheck (My Company)", +# "main_account":"Income:Employment", +# "paycheck_template": {}, # See beancount_reds_importers/libtransactionbuilder/paycheck.py for sample template +# "currency": "PENNIES", +# }), +# + +class Importer(paycheck.Importer, pdfreader.Importer): + IMPORTER_NAME = 'BambooHR Paycheck' + + def custom_init(self): + self.max_rounding_error = 0.04 + self.filename_pattern_def = 'PayStub.*\.pdf' + self.pdf_table_extraction_settings = {"join_tolerance":4, "snap_tolerance": 4} + self.pdf_table_extraction_crop = (0, 40, 0, 0) + self.debug = False + + self.funds_db_txt = 'funds_by_ticker' + self.header_map = { + 'Deduction Type': 'description', + 'Pay Type': 'description', + 'Paycheck Total': 'amount', + 'Tax Type': 'description' + } + + self.currency_fields = ['ytd_total', 'amount'] + + def paycheck_date(self, input_file): + if not self.file_read_done: + self.read_file(input_file) + dates = [date for _, date in search_dates(self.meta_text)] + return dates[2].date() + + def prepare_tables(self): + def valid_header(label): + if label in self.header_map: + return self.header_map[header] + + label = label.lower().replace(' ', '_') + return re.sub(r'20\d{2}', 'ytd', label) + + for section, table in self.alltables.items(): + # rename columns + for header in table.header(): + table = table.rename(header,valid_header(header)) + # convert columns + table = self.convert_columns(table) + + self.alltables[section] = table + + def build_metadata(self, file, metatype=None, data={}): + return {'filing_account': self.config['main_account']} \ No newline at end of file From b93a854435538e0a3bc298edee68f07987a2c9cc Mon Sep 17 00:00:00 2001 From: Ammon Sarver Date: Sat, 30 Mar 2024 17:32:02 -0600 Subject: [PATCH 3/6] feat: add genericpdf paycheck importer --- .gitignore | 1 + .../importers/bamboohr/__init__.py | 32 ++-- .../importers/genericpdf/__init__.py | 71 +++++++++ .../genericpdf/tests/genericpdf_test.py | 33 ++++ .../genericpdf/tests/paystub.sample.pdf | Bin 0 -> 49181 bytes .../tests/paystub.sample.pdf.extract | 11 ++ .../tests/paystub.sample.pdf.file_account | 1 + .../tests/paystub.sample.pdf.file_date | 1 + .../tests/paystub.sample.pdf.file_name | 1 + .../libreader/pdfreader.py | 144 +++++++++--------- requirements.txt | 21 +-- 11 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 beancount_reds_importers/importers/genericpdf/__init__.py create mode 100644 beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py create mode 100644 beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf create mode 100644 beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.extract create mode 100644 beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_account create mode 100644 beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_date create mode 100644 beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_name diff --git a/.gitignore b/.gitignore index ce0145f..f72a6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.debug-* # Translations *.mo diff --git a/beancount_reds_importers/importers/bamboohr/__init__.py b/beancount_reds_importers/importers/bamboohr/__init__.py index 2818190..6d05acc 100644 --- a/beancount_reds_importers/importers/bamboohr/__init__.py +++ b/beancount_reds_importers/importers/bamboohr/__init__.py @@ -7,7 +7,7 @@ # BambooHR exports paycheck stubs to pdf, with multiple tables across multiple pages. # Call this importer with a config that looks like: -# +# # bamboohr.Importer({"desc":"Paycheck (My Company)", # "main_account":"Income:Employment", # "paycheck_template": {}, # See beancount_reds_importers/libtransactionbuilder/paycheck.py for sample template @@ -15,25 +15,25 @@ # }), # + class Importer(paycheck.Importer, pdfreader.Importer): - IMPORTER_NAME = 'BambooHR Paycheck' + IMPORTER_NAME = "BambooHR Paycheck" def custom_init(self): self.max_rounding_error = 0.04 - self.filename_pattern_def = 'PayStub.*\.pdf' - self.pdf_table_extraction_settings = {"join_tolerance":4, "snap_tolerance": 4} + self.filename_pattern_def = r"PayStub.*\.pdf" + self.pdf_table_extraction_settings = {"join_tolerance": 4, "snap_tolerance": 4} self.pdf_table_extraction_crop = (0, 40, 0, 0) self.debug = False - self.funds_db_txt = 'funds_by_ticker' self.header_map = { - 'Deduction Type': 'description', - 'Pay Type': 'description', - 'Paycheck Total': 'amount', - 'Tax Type': 'description' + "Deduction Type": "description", + "Pay Type": "description", + "Paycheck Total": "amount", + "Tax Type": "description", } - self.currency_fields = ['ytd_total', 'amount'] + self.currency_fields = ["ytd_total", "amount"] def paycheck_date(self, input_file): if not self.file_read_done: @@ -45,18 +45,18 @@ def prepare_tables(self): def valid_header(label): if label in self.header_map: return self.header_map[header] - - label = label.lower().replace(' ', '_') - return re.sub(r'20\d{2}', 'ytd', label) - for section, table in self.alltables.items(): + label = label.lower().replace(" ", "_") + return re.sub(r"20\d{2}", "ytd", label) + + for section, table in self.alltables.items(): # rename columns for header in table.header(): - table = table.rename(header,valid_header(header)) + table = table.rename(header, valid_header(header)) # convert columns table = self.convert_columns(table) self.alltables[section] = table def build_metadata(self, file, metatype=None, data={}): - return {'filing_account': self.config['main_account']} \ No newline at end of file + return {"filing_account": self.config["main_account"]} diff --git a/beancount_reds_importers/importers/genericpdf/__init__.py b/beancount_reds_importers/importers/genericpdf/__init__.py new file mode 100644 index 0000000..b5f7595 --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/__init__.py @@ -0,0 +1,71 @@ +"""Generic pdf paycheck importer""" + +import datetime +from beancount_reds_importers.libreader import pdfreader +from beancount_reds_importers.libtransactionbuilder import paycheck + +# Generic pdf paystub importer. Use this to build your own pdf paystub importer. +# Call this importer with a config that looks like: +# +# genericpdf.Importer({"desc":"Paycheck (My Company)", +# "main_account":"Income:Employment", +# "paycheck_template": {}, # See beancount_reds_importers/libtransactionbuilder/paycheck.py for sample template +# "currency": "PENNIES", +# }), +# + + +class Importer(paycheck.Importer, pdfreader.Importer): + IMPORTER_NAME = "Generic PDF Paycheck" + + def custom_init(self): + self.max_rounding_error = 0.04 + self.filename_pattern_def = r"paystub.*\.pdf" + self.pdf_table_extraction_settings = {"join_tolerance": 4, "snap_tolerance": 4} + self.pdf_table_extraction_crop = (0, 0, 0, 0) + self.pdf_table_title_height = 0 + # Set this true as you play with the extraction settings and crop to view images of what the pdf parser detects + self.debug = True + + self.header_map = { + "CURRENT": "amount", + "CURRENT PAY": "amount", + "PAY DESCRIPTION": "description", + "DEDUCTIONS": "description", + "TAX TYPE": "description", + "TOTAL NET PAY": "description", + "YTD": "ytd", + "YTD PAY": "ytd", + } + + self.currency_fields = ["ytd", "amount"] + self.date_format = "%m/%d/%Y" + + def paycheck_date(self, input_file): + if not self.file_read_done: + self.read_file(input_file) + *_, d = self.alltables["table_1"].header() + self.date = datetime.datetime.strptime(d, self.date_format) + return self.date.date() + + def prepare_tables(self): + def valid_header(label): + if label in self.header_map: + return self.header_map[header] + + return label.lower().replace(" ", "_") + + for section, table in self.alltables.items(): + # rename columns + for header in table.header(): + if section == "table_6" and header == "": + table = table.rename(header, "amount") + else: + table = table.rename(header, valid_header(header)) + # convert columns + table = self.convert_columns(table) + + self.alltables[section] = table + + def build_metadata(self, file, metatype=None, data={}): + return {"filing_account": self.config["main_account"]} diff --git a/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py new file mode 100644 index 0000000..9c64531 --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py @@ -0,0 +1,33 @@ +from os import path +from beancount.ingest import regression_pytest as regtest +from beancount_reds_importers.importers import genericpdf + + +@regtest.with_importer( + genericpdf.Importer( + { + "desc": "Paycheck", + "main_account": "Income:Salary:FakeCompany", + "paycheck_template": { + "table_4": { + "Bonus": "Income:Bonus:FakeCompany", + "Overtime": "Income:Overtime:FakeCompany", + "Regular": "Income:Salary:FakeCompany", + }, + "table_5": { + "Federal MED/EE": "Expenses:Taxes:Medicare", + "Federal OASDI/EE": "Expenses:Taxes:SocialSecurity", + "Federal Withholding": "Expenses:Taxes:FederalIncome", + "State Withholding": "Expenses:Taxes:StateIncome", + }, + "table_6": { + "CURRENT": "Assets:Checking:ABCBank" + } + }, + "currency": "USD", + } + ) +) +@regtest.with_testdir(path.dirname(__file__)) +class TestGenericPDF(regtest.ImporterTestBase): + pass diff --git a/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5890f3a0010f802e2c69729192e18a526872e847 GIT binary patch literal 49181 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 literal 0 HcmV?d00001 diff --git a/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.extract b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.extract new file mode 100644 index 0000000..9f54acb --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.extract @@ -0,0 +1,11 @@ + +2023-12-03 * "Paycheck" + filing_account: "Income:Salary:FakeCompany" + Assets:Checking:ABCBank 4228.00 USD + Expenses:Taxes:FederalIncome 416.00 USD + Expenses:Taxes:Medicare 128.00 USD + Expenses:Taxes:SocialSecurity 96.00 USD + Expenses:Taxes:StateIncome 32.00 USD + Income:Bonus:FakeCompany -3000.00 USD + Income:Overtime:FakeCompany -300.00 USD + Income:Salary:FakeCompany -1600.00 USD diff --git a/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_account b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_account new file mode 100644 index 0000000..e80daef --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_account @@ -0,0 +1 @@ +Income:Salary:FakeCompany diff --git a/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_date b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_date new file mode 100644 index 0000000..ba67902 --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_date @@ -0,0 +1 @@ +2023-12-03 diff --git a/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_name b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_name new file mode 100644 index 0000000..0307945 --- /dev/null +++ b/beancount_reds_importers/importers/genericpdf/tests/paystub.sample.pdf.file_name @@ -0,0 +1 @@ +paystub.sample.pdf diff --git a/beancount_reds_importers/libreader/pdfreader.py b/beancount_reds_importers/libreader/pdfreader.py index f9dc6a5..ec8f1a4 100644 --- a/beancount_reds_importers/libreader/pdfreader.py +++ b/beancount_reds_importers/libreader/pdfreader.py @@ -1,7 +1,5 @@ - from pprint import pformat import pdfplumber -import pandas as pd import petl as etl from beancount_reds_importers.libreader import csvreader @@ -12,9 +10,10 @@ BLACK = (0, 0, 0) RED = (255, 0, 0) -PURPLE = (135,0,255) +PURPLE = (135, 0, 255) TRANSPARENT = (0, 0, 0, 0) + class Importer(csvreader.Importer): """ A reader that converts a pdf with tables into a multi-petl-table format understood by transaction builders. @@ -40,15 +39,15 @@ class Importer(csvreader.Importer): self.debug: `boolean` When debug is True a few images and text file are generated: - .debug-pdf-metadata-page_#.png + .debug-pdf-metadata-page_#.png shows the text available in self.meta_text with table data blacked out - + .debug-pdf-table-detection-page_#.png shows the tables detected with cells outlined in red, and the background light blue. The purple box shows where we are looking for the table title. - + .debug-pdf-data.txt is a printout of the meta_text and table data found before being processed into petl tables, as well as some generated helper objects to add to new importers or import configs - + ### Outputs self.meta_text: `str` contains all text found in the document outside of tables @@ -56,21 +55,22 @@ class Importer(csvreader.Importer): self.alltables: `{'table_1': , ...}` contains all the tables found in the document keyed by the extracted title if available, otherwise by the 1-based index in the form of `table_#` """ - FILE_EXTS = ['pdf'] + + FILE_EXTS = ["pdf"] def initialize_reader(self, file): - if getattr(self, 'file', None) != file: - self.pdf_table_extraction_settings = {} + if getattr(self, "file", None) != file: + self.pdf_table_extraction_settings = {} self.pdf_table_extraction_crop = (0, 0, 0, 0) self.pdf_table_title_height = 20 self.pdf_page_break_top = 45 self.debug = False - self.meta_text = '' + self.meta_text = "" self.file = file self.file_read_done = False self.reader_ready = True - + def file_date(self, file): raise "Not implemented, must overwrite, check self.alltables, or self.meta_text for the data" pass @@ -87,23 +87,23 @@ def read_file(self, file): adjusted_crop = ( min(0 + self.pdf_table_extraction_crop[LEFT], page.width), min(0 + self.pdf_table_extraction_crop[TOP], page.height), - max(page.width - self.pdf_table_extraction_crop[RIGHT],0), - max(page.height - self.pdf_table_extraction_crop[BOTTOM],0) + max(page.width - self.pdf_table_extraction_crop[RIGHT], 0), + max(page.height - self.pdf_table_extraction_crop[BOTTOM], 0), ) - + # Debug image image = page.crop(adjusted_crop).to_image() image.debug_tablefinder(tf=self.pdf_table_extraction_settings) - + table_ref = page.crop(adjusted_crop).find_tables(table_settings=self.pdf_table_extraction_settings) - page_tables = [{'table':i.extract(), 'bbox': i.bbox} for i in table_ref] + page_tables = [{"table": i.extract(), "bbox": i.bbox} for i in table_ref] # Get Metadata (all data outside tables) meta_page = page meta_image = meta_page.to_image() for table in page_tables: - meta_page = meta_page.outside_bbox(table['bbox']) - meta_image.draw_rect(table['bbox'], BLACK, RED) + meta_page = meta_page.outside_bbox(table["bbox"]) + meta_image.draw_rect(table["bbox"], BLACK, RED) meta_text = meta_page.extract_text() self.meta_text = self.meta_text + meta_text @@ -111,75 +111,83 @@ def read_file(self, file): # Attach section headers for table_idx, table in enumerate(page_tables): section_title_bbox = ( - table['bbox'][LEFT], - max(table['bbox'][TOP] - self.pdf_table_title_height, 0), - table['bbox'][RIGHT], - table['bbox'][TOP] + table["bbox"][LEFT], + max(table["bbox"][TOP] - self.pdf_table_title_height, 0), + table["bbox"][RIGHT], + table["bbox"][TOP], ) - section_title = meta_page.crop(section_title_bbox).extract_text() - image.draw_rect(section_title_bbox, TRANSPARENT, PURPLE) - page_tables[table_idx]['section'] = section_title + + bbox_area = pdfplumber.utils.calculate_area(section_title_bbox) + if bbox_area > 0: + section_title = meta_page.crop(section_title_bbox).extract_text() + image.draw_rect(section_title_bbox, TRANSPARENT, PURPLE) + page_tables[table_idx]["section"] = section_title + else: + page_tables[table_idx]["section"] = "" + + # replace None with '' + for row_idx, row in enumerate(table["table"]): + page_tables[table_idx]["table"][row_idx] = ["" if v is None else v for v in row] tables = tables + page_tables if self.debug: - image.save('.debug-pdf-table-detection-page_{}.png'.format(page_idx)) - meta_image.save('.debug-pdf-metadata-page_{}.png'.format(page_idx)) + image.save(".debug-pdf-table-detection-page_{}.png".format(page_idx)) + meta_image.save(".debug-pdf-metadata-page_{}.png".format(page_idx)) - # Find and fix page broken tables for table_idx, table in enumerate(tables[:]): if ( - table_idx >= 1 and # if not the first table, - table['bbox'][TOP] < self.pdf_page_break_top and # and the top of the table is close to the top of the page - table['section'] == '' and # and there is no section title - tables[table_idx - 1]['table'][0] == tables[table_idx]['table'][0] # and the header rows are the same, - ): #assume a page break - tables[table_idx - 1]['table'] = tables[table_idx - 1]['table'] + tables[table_idx]['table'][1:] + # if not the first table, + table_idx >= 1 + # and the top of the table is close to the top of the page + and table["bbox"][TOP] < self.pdf_page_break_top + # and there is no section title + and table["section"] == "" + # and the header rows are the same, + and tables[table_idx - 1]["table"][0] == tables[table_idx]["table"][0] + ): # assume a page break + tables[table_idx - 1]["table"] = tables[table_idx - 1]["table"] + tables[table_idx]["table"][1:] del tables[table_idx] continue # if there is no table section give it one - if table['section'] == '': - tables[table_idx]['section'] = 'table_{}'.format(table_idx + 1) - + if table["section"] == "": + tables[table_idx]["section"] = "table_{}".format(table_idx + 1) + if self.debug: # generate helpers paycheck_template = {} header_map = {} for table in tables: - for header in table['table'][0]: - header_map[header]='overwrite_me' - paycheck_template[table['section']] = {} - for row_idx, row in enumerate(table['table']): + for header in table["table"][0]: + header_map[header] = "overwrite_me" + paycheck_template[table["section"]] = {} + for row_idx, row in enumerate(table["table"]): if row_idx == 0: continue - paycheck_template[table['section']][row[0]] = 'overwrite_me' - if not hasattr(self, 'header_map'): - self.header_map = header_map - if not hasattr(self, 'paycheck_template'): - self.paycheck_template = paycheck_template - with open('.debug-pdf-data.txt', "w") as debug_file: - debug_file.write(pformat({ - '_output': { - 'tables':tables, - 'meta_text':self.meta_text - }, - '_input': { - 'table_settings': self.pdf_table_extraction_settings, - 'crop_settings': self.pdf_table_extraction_crop - }, - 'helpers': { - 'header_map':self.header_map, - 'paycheck_template':self.paycheck_template - } - })) - - - - self.alltables = {} - for table in tables: - self.alltables[table['section']] = etl.fromdataframe(pd.DataFrame(table['table'][1:], columns=table['table'][0])) + paycheck_template[table["section"]][row[0]] = "overwrite_me" + with open(".debug-pdf-data.txt", "w") as debug_file: + debug_file.write( + pformat( + { + "_output": {"tables": tables, "meta_text": self.meta_text}, + "_input": { + "table_settings": self.pdf_table_extraction_settings, + "crop_settings": self.pdf_table_extraction_crop, + "pdf_table_title_height": self.pdf_table_title_height, + "pdf_page_break_top": self.pdf_page_break_top, + }, + "helpers": {"header_map_generated": header_map, "paycheck_template_generated": paycheck_template}, + } + ) + ) + self.alltables = {table["section"]: etl.wrap(table["table"]) for table in tables} self.prepare_tables() + + if self.debug: + with open(".debug-pdf-prepared-tables.txt", "w") as debug_file: + debug_file.write(pformat({"prepared_tables": self.alltables})) + self.file_read_done = True diff --git a/requirements.txt b/requirements.txt index ffd5956..4c59517 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,12 @@ # Automatically generated by https://github.com/damnever/pigar. -beancount>=2.3.5 -beautifulsoup4>=4.12.2 -click>=8.1.4 -click-aliases>=1.0.1 -importlib-metadata>=6.8.0 +beancount>=2.3.6 +bs4>=0.0.2 +click-aliases>=1.0.4 +dateparser>=1.2.0 ofxparse>=0.21 openpyxl>=3.1.2 -packaging>=23.1 -pdbpp>=0.10.3 -petl>=1.7.12 -setuptools>=69.0.2 -setuptools-scm>=8.0.4 +pdfplumber>=0.11.0 +petl>=1.7.15 tabulate>=0.9.0 -tomli>=2.0.1 -tqdm>=4.65.0 -typing_extensions>=4.7.1 -xlrd>=2.0.1 +tqdm>=4.66.2 From 44690951c65c69fbc216055dbb24fb62d25ea51a Mon Sep 17 00:00:00 2001 From: Ammon Sarver Date: Sun, 14 Apr 2024 11:33:41 -0600 Subject: [PATCH 4/6] fix: update requirements to add back lost packages I'm not sure what happened, but pigar would not detect imports from other tests. So I manually updated the requirements.txt to include all needed files. --- .../importers/genericpdf/tests/genericpdf_test.py | 1 - requirements.txt | 12 ++++++++++-- setup.py | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py index 9c64531..8ac67e9 100644 --- a/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py +++ b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py @@ -2,7 +2,6 @@ from beancount.ingest import regression_pytest as regtest from beancount_reds_importers.importers import genericpdf - @regtest.with_importer( genericpdf.Importer( { diff --git a/requirements.txt b/requirements.txt index 4c59517..53470d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,20 @@ # Automatically generated by https://github.com/damnever/pigar. -beancount>=2.3.6 -bs4>=0.0.2 +beancount>=2.3.5 +beautifulsoup4>=4.12.3 +click>=8.1.7 click-aliases>=1.0.4 dateparser>=1.2.0 +importlib-metadata>=6.8.0 ofxparse>=0.21 openpyxl>=3.1.2 +packaging>=23.1 pdfplumber>=0.11.0 petl>=1.7.15 +setuptools>=69.0.2 +setuptools-scm>=8.0.4 tabulate>=0.9.0 +tomli>=2.0.1 tqdm>=4.66.2 +typing_extensions>=4.7.1 +xlrd>=2.0.1 diff --git a/setup.py b/setup.py index fd3debf..a23c92f 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,11 @@ 'Click >= 7.0', 'beancount >= 2.3.5', 'click_aliases >= 1.0.1', + 'dateparser >= 1.2.0', 'ofxparse >= 0.21', 'openpyxl >= 3.0.9', 'packaging >= 20.3', + 'pdfplumber>=0.11.0', 'petl >= 1.7.4', 'tabulate >= 0.8.9', 'tqdm >= 4.64.0', From 6432b2025455657686f1cec9f66edfc8223f8597 Mon Sep 17 00:00:00 2001 From: Red S Date: Mon, 15 Apr 2024 22:39:10 -0700 Subject: [PATCH 5/6] ci: enable workflows to run automatically on PRs --- .github/workflows/conventionalcommits.yml | 1 + .github/workflows/pythonpackage.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/conventionalcommits.yml b/.github/workflows/conventionalcommits.yml index add48c6..ae2d6a7 100644 --- a/.github/workflows/conventionalcommits.yml +++ b/.github/workflows/conventionalcommits.yml @@ -3,6 +3,7 @@ name: Conventional Commits on: pull_request: branches: [ main ] + types: [opened, reopened, edited] jobs: build: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 0312cb2..05a9b9a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,6 +8,7 @@ on: branches: [ main ] pull_request: branches: [ main ] + types: [opened, reopened, edited] jobs: build: From ebbcfeb7e1cd8bb1f1d948abba3d405c5cf0c0c1 Mon Sep 17 00:00:00 2001 From: Ammon Sarver Date: Tue, 16 Apr 2024 10:46:56 -0600 Subject: [PATCH 6/6] chore: formatting --- .../importers/bamboohr/__init__.py | 2 ++ .../importers/genericpdf/__init__.py | 1 + .../genericpdf/tests/genericpdf_test.py | 7 +++--- .../libreader/pdfreader.py | 19 ++++++++++++---- setup.py | 22 +++++++++---------- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/beancount_reds_importers/importers/bamboohr/__init__.py b/beancount_reds_importers/importers/bamboohr/__init__.py index 6d05acc..a1ac1bc 100644 --- a/beancount_reds_importers/importers/bamboohr/__init__.py +++ b/beancount_reds_importers/importers/bamboohr/__init__.py @@ -1,7 +1,9 @@ """BambooHR paycheck importer""" import re + from dateparser.search import search_dates + from beancount_reds_importers.libreader import pdfreader from beancount_reds_importers.libtransactionbuilder import paycheck diff --git a/beancount_reds_importers/importers/genericpdf/__init__.py b/beancount_reds_importers/importers/genericpdf/__init__.py index b5f7595..91b210c 100644 --- a/beancount_reds_importers/importers/genericpdf/__init__.py +++ b/beancount_reds_importers/importers/genericpdf/__init__.py @@ -1,6 +1,7 @@ """Generic pdf paycheck importer""" import datetime + from beancount_reds_importers.libreader import pdfreader from beancount_reds_importers.libtransactionbuilder import paycheck diff --git a/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py index 8ac67e9..2d9eaa2 100644 --- a/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py +++ b/beancount_reds_importers/importers/genericpdf/tests/genericpdf_test.py @@ -1,7 +1,10 @@ from os import path + from beancount.ingest import regression_pytest as regtest + from beancount_reds_importers.importers import genericpdf + @regtest.with_importer( genericpdf.Importer( { @@ -19,9 +22,7 @@ "Federal Withholding": "Expenses:Taxes:FederalIncome", "State Withholding": "Expenses:Taxes:StateIncome", }, - "table_6": { - "CURRENT": "Assets:Checking:ABCBank" - } + "table_6": {"CURRENT": "Assets:Checking:ABCBank"}, }, "currency": "USD", } diff --git a/beancount_reds_importers/libreader/pdfreader.py b/beancount_reds_importers/libreader/pdfreader.py index ec8f1a4..20a2815 100644 --- a/beancount_reds_importers/libreader/pdfreader.py +++ b/beancount_reds_importers/libreader/pdfreader.py @@ -1,6 +1,8 @@ from pprint import pformat + import pdfplumber import petl as etl + from beancount_reds_importers.libreader import csvreader LEFT = 0 @@ -95,7 +97,9 @@ def read_file(self, file): image = page.crop(adjusted_crop).to_image() image.debug_tablefinder(tf=self.pdf_table_extraction_settings) - table_ref = page.crop(adjusted_crop).find_tables(table_settings=self.pdf_table_extraction_settings) + table_ref = page.crop(adjusted_crop).find_tables( + table_settings=self.pdf_table_extraction_settings + ) page_tables = [{"table": i.extract(), "bbox": i.bbox} for i in table_ref] # Get Metadata (all data outside tables) @@ -127,7 +131,9 @@ def read_file(self, file): # replace None with '' for row_idx, row in enumerate(table["table"]): - page_tables[table_idx]["table"][row_idx] = ["" if v is None else v for v in row] + page_tables[table_idx]["table"][row_idx] = [ + "" if v is None else v for v in row + ] tables = tables + page_tables @@ -147,7 +153,9 @@ def read_file(self, file): # and the header rows are the same, and tables[table_idx - 1]["table"][0] == tables[table_idx]["table"][0] ): # assume a page break - tables[table_idx - 1]["table"] = tables[table_idx - 1]["table"] + tables[table_idx]["table"][1:] + tables[table_idx - 1]["table"] = ( + tables[table_idx - 1]["table"] + tables[table_idx]["table"][1:] + ) del tables[table_idx] continue @@ -178,7 +186,10 @@ def read_file(self, file): "pdf_table_title_height": self.pdf_table_title_height, "pdf_page_break_top": self.pdf_page_break_top, }, - "helpers": {"header_map_generated": header_map, "paycheck_template_generated": paycheck_template}, + "helpers": { + "header_map_generated": header_map, + "paycheck_template_generated": paycheck_template, + }, } ) ) diff --git a/setup.py b/setup.py index 78474f3..2018a5c 100644 --- a/setup.py +++ b/setup.py @@ -26,17 +26,17 @@ ] }, install_requires=[ - 'Click >= 7.0', - 'beancount >= 2.3.5', - 'click_aliases >= 1.0.1', - 'dateparser >= 1.2.0', - 'ofxparse >= 0.21', - 'openpyxl >= 3.0.9', - 'packaging >= 20.3', - 'pdfplumber >= 0.11.0', - 'petl >= 1.7.4', - 'tabulate >= 0.8.9', - 'tqdm >= 4.64.0', + "Click >= 7.0", + "beancount >= 2.3.5", + "click_aliases >= 1.0.1", + "dateparser >= 1.2.0", + "ofxparse >= 0.21", + "openpyxl >= 3.0.9", + "packaging >= 20.3", + "pdfplumber>=0.11.0", + "petl >= 1.7.4", + "tabulate >= 0.8.9", + "tqdm >= 4.64.0", ], entry_points={ "console_scripts": [