[{"data":1,"prerenderedAt":2250},["ShallowReactive",2],{"doc:\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python":3,"surround:\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python":2214,"ancestors:\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python":2223,"children:\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python":2228,"related:\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python":2229},{"id":4,"title":5,"body":6,"dateModified":2180,"datePublished":2180,"description":2181,"extension":2182,"faq":2183,"keyword":2192,"kind":2193,"meta":2194,"navigation":290,"path":2203,"seo":2204,"slug":2207,"stem":2208,"topics":2209,"type":2212,"__hash__":2213},"docs\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python\u002Findex.md","Extract Tables from a PDF into Excel with Python",{"type":7,"value":8,"toc":2166},"minimark",[9,19,24,56,70,261,265,268,421,428,439,443,446,629,636,909,913,922,1057,1089,1093,1096,1313,1327,1331,1352,1607,1628,1786,1790,1793,2018,2021,2025,2079,2083,2090,2094,2097,2101,2107,2113,2119,2129,2133,2162],[10,11,12,13,18],"p",{},"Suppliers send statements as PDFs, banks send them as PDFs, and the old reporting system that nobody can change exports to PDF only. The numbers are in there — usually as a neat table — but copying them into Excel by hand is slow and error-prone, and Excel's own PDF import handles multi-page tables inconsistently. pdfplumber reads the text layer of a PDF together with the position of every character and line, which is enough to rebuild the table in Python, clean it with pandas and write it to a workbook. This page, part of ",[14,15,17],"a",{"href":16},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002F","Handling Excel File Formats and Conversions",", walks through a two-page accounts receivable statement.",[20,21,23],"h2",{"id":22},"prerequisites","Prerequisites",[25,26,31],"pre",{"className":27,"code":28,"language":29,"meta":30,"style":30},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pdfplumber pandas openpyxl\n","bash","",[32,33,34],"code",{"__ignoreMap":30},[35,36,39,43,47,50,53],"span",{"class":37,"line":38},"line",1,[35,40,42],{"class":41},"sMTad","pip",[35,44,46],{"class":45},"srMev"," install",[35,48,49],{"class":45}," pdfplumber",[35,51,52],{"class":45}," pandas",[35,54,55],{"class":45}," openpyxl\n",[10,57,58,61,62,65,66,69],{},[32,59,60],{},"statement.pdf"," lists 60 invoices in a ruled table that continues onto a second page, repeating its header row there. Amounts are formatted like ",[32,63,64],{},"2,675.14",", dates are day-first (",[32,67,68],{},"02\u002F09\u002F2026","), the statement total is printed in the introduction, and a small summary table without any ruling lines sits at the end. That mix — a gridded table, a page break, formatted numbers and a borderless table — is typical of system-generated PDFs.",[71,72,80,81,80,85,80,89,80,97,80,107,80,118,80,125,80,129,80,134,80,140,80,145,80,149,80,156,80,161,80,165,80,168,80,172,80,175,80,178,80,184,80,189,80,193,80,197,80,201,80,205,80,208,80,213,80,220,80,225,80,228,80,231,80,235,80,238,80,243,80,250,80,255,80,258],"svg",{"viewBox":73,"role":74,"ariaLabelledBy":75,"xmlns":78,"style":79},"1 0 755 160","img",[76,77],"pdf-flow-t","pdf-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:755px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[82,83,84],"title",{"id":76},"From PDF pages to a clean worksheet",[86,87,88],"desc",{"id":77},"pdfplumber extracts the table from each page, the page tables are stacked with repeated headers removed, text is converted to numbers and dates, the total is checked against the statement, and the result is written to Excel.",[90,91],"rect",{"x":92,"y":93,"width":94,"height":95,"fill":96},"1","0","755","160","#ffffff",[90,98],{"x":99,"y":100,"width":101,"height":102,"rx":103,"fill":104,"stroke":105,"style":106},"20.0","16.0","115.2","128.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[108,109,111],"g",{"transform":110},"translate(65.6,37.8) scale(1.000)",[112,113],"path",{"d":114,"fill":115,"stroke":116,"style":117},"M6 3h9l4 4v14H6z M15 3v4h4 M9 12h6 M9 15.5h6 M9 19h4","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[119,120,124],"text",{"x":121,"y":122,"style":123},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Extract per",[119,126,128],{"x":121,"y":127,"style":123},"101.8","page",[119,130,133],{"x":121,"y":131,"style":132},"121.2","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","page.extract_tables()",[37,135],{"x1":136,"y1":137,"x2":138,"y2":137,"stroke":139,"style":106},"140.2","80.0","162.2","var(--brand,#5b5cf0)",[141,142],"polygon",{"points":143,"fill":144},"166.2,80.0 157.2,85.0 157.2,75.0","#5b5cf0",[90,146],{"x":147,"y":100,"width":101,"height":102,"rx":103,"fill":148,"stroke":139,"style":106},"171.2","#f0f4ff",[108,150,152],{"transform":151},"translate(216.8,37.8) scale(1.000)",[112,153],{"d":154,"fill":115,"stroke":155,"style":117},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--brand-strong,#4338ca)",[119,157,160],{"x":158,"y":122,"style":159},"228.8","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Stack pages",[119,162,164],{"x":158,"y":163,"style":132},"103.8","drop repeated",[119,166,167],{"x":158,"y":131,"style":132},"headers",[37,169],{"x1":170,"y1":137,"x2":171,"y2":137,"stroke":139,"style":106},"291.4","313.4",[141,173],{"points":174,"fill":144},"317.4,80.0 308.4,85.0 308.4,75.0",[90,176],{"x":177,"y":100,"width":101,"height":102,"rx":103,"fill":148,"stroke":139,"style":106},"322.4",[108,179,181],{"transform":180},"translate(368.0,29.0) scale(1.000)",[112,182],{"d":183,"fill":115,"stroke":155,"style":117},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1",[119,185,188],{"x":186,"y":187,"style":159},"380.0","75.5","Convert",[119,190,192],{"x":186,"y":191,"style":159},"93.0","types",[119,194,196],{"x":186,"y":195,"style":132},"112.5","'2,675.14' →",[119,198,200],{"x":186,"y":199,"style":132},"130.0","2675.14",[37,202],{"x1":203,"y1":137,"x2":204,"y2":137,"stroke":139,"style":106},"442.6","464.6",[141,206],{"points":207,"fill":144},"468.6,80.0 459.6,85.0 459.6,75.0",[90,209],{"x":210,"y":100,"width":101,"height":102,"rx":103,"fill":211,"stroke":212,"style":106},"473.6","#d9f4f1","var(--teal,#0f9488)",[108,214,216],{"transform":215},"translate(519.2,37.8) scale(1.000)",[112,217],{"d":218,"fill":115,"stroke":219,"style":117},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[119,221,224],{"x":222,"y":122,"style":223},"531.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Check total",[119,226,227],{"x":222,"y":163,"style":132},"against the",[119,229,230],{"x":222,"y":131,"style":132},"printed figure",[37,232],{"x1":233,"y1":137,"x2":234,"y2":137,"stroke":139,"style":106},"593.8","615.8",[141,236],{"points":237,"fill":144},"619.8,80.0 610.8,85.0 610.8,75.0",[90,239],{"x":240,"y":100,"width":101,"height":102,"rx":103,"fill":241,"stroke":242,"style":106},"624.8","#fdefd8","var(--gold,#b4740a)",[108,244,246],{"transform":245},"translate(670.4,37.8) scale(1.000)",[112,247],{"d":248,"fill":115,"stroke":249,"style":117},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","var(--gold-ink,#7a4e06)",[119,251,254],{"x":252,"y":122,"style":253},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Write .xlsx",[119,256,257],{"x":252,"y":163,"style":132},"formats and",[119,259,260],{"x":252,"y":131,"style":132},"widths",[20,262,264],{"id":263},"step-1-see-what-pdfplumber-finds","Step 1 — See what pdfplumber finds",[10,266,267],{},"Open the PDF and ask each page for its tables. For tables drawn with ruling lines, the default settings use those lines to find the cell boundaries:",[25,269,273],{"className":270,"code":271,"language":272,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pdfplumber\n\nwith pdfplumber.open(\"statement.pdf\") as pdf:\n    print(len(pdf.pages), \"pages\")\n    for page in pdf.pages:\n        for table in page.extract_tables():\n            print(f\"page {page.page_number}: {len(table)} rows, first two: {table[:2]}\")\n","python",[32,274,275,285,292,313,335,350,364],{"__ignoreMap":30},[35,276,277,281],{"class":37,"line":38},[35,278,280],{"class":279},"s-kum","import",[35,282,284],{"class":283},"skGVy"," pdfplumber\n",[35,286,288],{"class":37,"line":287},2,[35,289,291],{"emptyLinePlaceholder":290},true,"\n",[35,293,295,298,301,304,307,310],{"class":37,"line":294},3,[35,296,297],{"class":279},"with",[35,299,300],{"class":283}," pdfplumber.open(",[35,302,303],{"class":45},"\"statement.pdf\"",[35,305,306],{"class":283},") ",[35,308,309],{"class":279},"as",[35,311,312],{"class":283}," pdf:\n",[35,314,316,320,323,326,329,332],{"class":37,"line":315},4,[35,317,319],{"class":318},"sP0c6","    print",[35,321,322],{"class":283},"(",[35,324,325],{"class":318},"len",[35,327,328],{"class":283},"(pdf.pages), ",[35,330,331],{"class":45},"\"pages\"",[35,333,334],{"class":283},")\n",[35,336,338,341,344,347],{"class":37,"line":337},5,[35,339,340],{"class":279},"    for",[35,342,343],{"class":283}," page ",[35,345,346],{"class":279},"in",[35,348,349],{"class":283}," pdf.pages:\n",[35,351,353,356,359,361],{"class":37,"line":352},6,[35,354,355],{"class":279},"        for",[35,357,358],{"class":283}," table ",[35,360,346],{"class":279},[35,362,363],{"class":283}," page.extract_tables():\n",[35,365,367,370,372,375,378,382,385,388,391,393,395,398,400,403,405,408,411,414,416,419],{"class":37,"line":366},7,[35,368,369],{"class":318},"            print",[35,371,322],{"class":283},[35,373,374],{"class":279},"f",[35,376,377],{"class":45},"\"page ",[35,379,381],{"class":380},"sSjpA","{",[35,383,384],{"class":283},"page.page_number",[35,386,387],{"class":380},"}",[35,389,390],{"class":45},": ",[35,392,381],{"class":380},[35,394,325],{"class":318},[35,396,397],{"class":283},"(table)",[35,399,387],{"class":380},[35,401,402],{"class":45}," rows, first two: ",[35,404,381],{"class":380},[35,406,407],{"class":283},"table[:",[35,409,410],{"class":318},"2",[35,412,413],{"class":283},"]",[35,415,387],{"class":380},[35,417,418],{"class":45},"\"",[35,420,334],{"class":283},[10,422,423,424,427],{},"Each table comes back as a list of rows, each row a list of cell strings. The first page holds 35 rows and the second 27, and both start with the header row, because the report repeats it after the page break. Before writing any cleaning code, look at this raw output: it shows whether columns were split correctly, whether any cells came back as ",[32,425,426],{},"None"," (merged or empty cells), and whether a page contains more than one table.",[10,429,430,431,434,435,438],{},"A PDF with no text layer — a scan or a photographed page — returns no tables and no text at all. Check with ",[32,432,433],{},"page.extract_text()",": if it is empty, the page is an image, and it needs OCR first. ",[32,436,437],{},"ocrmypdf"," adds a text layer to a scanned PDF, after which the steps below work, though OCR errors in the digits need the control-total check in Step 4 even more.",[20,440,442],{"id":441},"step-2-stack-the-pages-and-drop-repeated-headers","Step 2 — Stack the pages and drop repeated headers",[10,444,445],{},"Take the header from the first page, then keep every row from every page that is not a copy of it:",[25,447,449],{"className":270,"code":448,"language":272,"meta":30,"style":30},"import pandas as pd\n\nwith pdfplumber.open(\"statement.pdf\") as pdf:\n    tables = [t for page in pdf.pages for t in page.extract_tables()]\n\nheader = tables[0][0]\nbody = [row for t in tables for row in t if row != header]\nraw = pd.DataFrame(body, columns=header)\nprint(raw.shape)\nprint(raw.head(3).to_string(index=False))\n",[32,450,451,463,467,481,512,516,536,575,595,604],{"__ignoreMap":30},[35,452,453,455,458,460],{"class":37,"line":38},[35,454,280],{"class":279},[35,456,457],{"class":283}," pandas ",[35,459,309],{"class":279},[35,461,462],{"class":283}," pd\n",[35,464,465],{"class":37,"line":287},[35,466,291],{"emptyLinePlaceholder":290},[35,468,469,471,473,475,477,479],{"class":37,"line":294},[35,470,297],{"class":279},[35,472,300],{"class":283},[35,474,303],{"class":45},[35,476,306],{"class":283},[35,478,309],{"class":279},[35,480,312],{"class":283},[35,482,483,486,489,492,495,497,499,502,504,507,509],{"class":37,"line":315},[35,484,485],{"class":283},"    tables ",[35,487,488],{"class":279},"=",[35,490,491],{"class":283}," [t ",[35,493,494],{"class":279},"for",[35,496,343],{"class":283},[35,498,346],{"class":279},[35,500,501],{"class":283}," pdf.pages ",[35,503,494],{"class":279},[35,505,506],{"class":283}," t ",[35,508,346],{"class":279},[35,510,511],{"class":283}," page.extract_tables()]\n",[35,513,514],{"class":37,"line":337},[35,515,291],{"emptyLinePlaceholder":290},[35,517,518,521,523,526,528,531,533],{"class":37,"line":352},[35,519,520],{"class":283},"header ",[35,522,488],{"class":279},[35,524,525],{"class":283}," tables[",[35,527,93],{"class":318},[35,529,530],{"class":283},"][",[35,532,93],{"class":318},[35,534,535],{"class":283},"]\n",[35,537,538,541,543,546,548,550,552,555,557,560,562,564,567,569,572],{"class":37,"line":366},[35,539,540],{"class":283},"body ",[35,542,488],{"class":279},[35,544,545],{"class":283}," [row ",[35,547,494],{"class":279},[35,549,506],{"class":283},[35,551,346],{"class":279},[35,553,554],{"class":283}," tables ",[35,556,494],{"class":279},[35,558,559],{"class":283}," row ",[35,561,346],{"class":279},[35,563,506],{"class":283},[35,565,566],{"class":279},"if",[35,568,559],{"class":283},[35,570,571],{"class":279},"!=",[35,573,574],{"class":283}," header]\n",[35,576,578,581,583,586,590,592],{"class":37,"line":577},8,[35,579,580],{"class":283},"raw ",[35,582,488],{"class":279},[35,584,585],{"class":283}," pd.DataFrame(body, ",[35,587,589],{"class":588},"sa561","columns",[35,591,488],{"class":279},[35,593,594],{"class":283},"header)\n",[35,596,598,601],{"class":37,"line":597},9,[35,599,600],{"class":318},"print",[35,602,603],{"class":283},"(raw.shape)\n",[35,605,607,609,612,615,618,621,623,626],{"class":37,"line":606},10,[35,608,600],{"class":318},[35,610,611],{"class":283},"(raw.head(",[35,613,614],{"class":318},"3",[35,616,617],{"class":283},").to_string(",[35,619,620],{"class":588},"index",[35,622,488],{"class":279},[35,624,625],{"class":318},"False",[35,627,628],{"class":283},"))\n",[10,630,631,632,635],{},"60 rows, five columns, all text. Comparing whole rows with the header is the simplest reliable filter for repeated headers; on some reports the repeated header is slightly different (a \"continued\" suffix, say), in which case filter on a column instead — drop rows whose ",[32,633,634],{},"Invoice"," value does not match the invoice number pattern.",[71,637,80,643,80,646,80,649,80,654,80,660,80,664,80,670,80,674,80,678,80,682,80,686,80,690,80,694,80,697,80,700,80,702,80,705,80,707,80,710,80,713,80,716,80,718,80,722,80,724,80,726,80,728,80,730,80,733,80,736,80,738,80,741,80,743,80,745,80,747,80,749,80,752,80,755,80,759,80,762,80,764,80,766,80,768,80,770,80,773,80,777,80,779,80,782,80,784,80,787,80,789,80,792,80,795,80,798,80,801,80,804,80,807,80,810,80,813,80,815,80,818,80,820,80,822,80,824,80,826,80,828,80,830,80,832,80,834,80,836,80,838,80,840,80,842,80,844,80,846,80,848,80,850,80,852,80,854,80,856,80,858,80,860,80,862,80,864,80,866,80,868,80,870,80,872,80,874,80,876,80,878,80,884,80,888,80,893,80,896,80,904],{"viewBox":638,"role":74,"ariaLabelledBy":639,"xmlns":78,"style":642},"4 0 752 220",[640,641],"pdf-stack-t","pdf-stack-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[82,644,645],{"id":640},"Two page tables become one frame",[86,647,648],{"id":641},"On the left the tables from page 1 and page 2, each starting with the same header row. On the right a single table with one header and the rows from both pages.",[90,650],{"x":651,"y":93,"width":652,"height":653,"fill":96},"4","752","220",[90,655],{"x":99,"y":656,"width":657,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"40.0","32.0","24.0","stroke-width:1px",[90,661],{"x":662,"y":656,"width":663,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"52.0","69.1",[119,665,669],{"x":666,"y":667,"style":668},"86.6","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[90,671],{"x":672,"y":656,"width":673,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"121.1","80.9",[119,675,677],{"x":676,"y":667,"style":668},"161.6","B",[90,679],{"x":680,"y":656,"width":681,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"202.0","63.7",[119,683,685],{"x":684,"y":667,"style":668},"233.9","C",[90,687],{"x":99,"y":688,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},"64.0","28.0",[119,691,92],{"x":692,"y":693,"style":668},"36.0","83.0",[90,695],{"x":662,"y":688,"width":663,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},"stroke-width:1.5px",[119,698,634],{"x":666,"y":693,"style":699},"font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[90,701],{"x":672,"y":688,"width":673,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},[119,703,704],{"x":676,"y":693,"style":699},"Date",[90,706],{"x":680,"y":688,"width":681,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},[119,708,709],{"x":684,"y":693,"style":699},"Amount",[90,711],{"x":99,"y":712,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},"92.0",[119,714,410],{"x":692,"y":715,"style":668},"111.0",[90,717],{"x":662,"y":712,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,719,721],{"x":666,"y":715,"style":720},"font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","INV-2401",[90,723],{"x":672,"y":712,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,725,68],{"x":676,"y":715,"style":720},[90,727],{"x":680,"y":712,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,729,64],{"x":684,"y":715,"style":720},[90,731],{"x":99,"y":732,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},"120.0",[119,734,614],{"x":692,"y":735,"style":668},"139.0",[90,737],{"x":662,"y":732,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,739,740],{"x":666,"y":735,"style":720},"…",[90,742],{"x":672,"y":732,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,744,740],{"x":676,"y":735,"style":720},[90,746],{"x":680,"y":732,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,748,740],{"x":684,"y":735,"style":720},[90,750],{"x":99,"y":751,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},"148.0",[119,753,651],{"x":692,"y":754,"style":668},"167.0",[90,756],{"x":662,"y":751,"width":663,"height":689,"rx":93,"fill":757,"stroke":758,"style":696},"#fce9e9","var(--danger,#dc2626)",[119,760,634],{"x":666,"y":754,"style":761},"font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle",[90,763],{"x":672,"y":751,"width":673,"height":689,"rx":93,"fill":757,"stroke":758,"style":696},[119,765,704],{"x":676,"y":754,"style":761},[90,767],{"x":680,"y":751,"width":681,"height":689,"rx":93,"fill":757,"stroke":758,"style":696},[119,769,709],{"x":684,"y":754,"style":761},[90,771],{"x":99,"y":772,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},"176.0",[119,774,776],{"x":692,"y":775,"style":668},"195.0","5",[90,778],{"x":662,"y":772,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,780,781],{"x":666,"y":775,"style":720},"INV-2435",[90,783],{"x":672,"y":772,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,785,786],{"x":676,"y":775,"style":720},"08\u002F09\u002F2026",[90,788],{"x":680,"y":772,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,790,791],{"x":684,"y":775,"style":720},"2,228.31",[90,793],{"x":794,"y":656,"width":657,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"494.3",[90,796],{"x":797,"y":656,"width":663,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"526.3",[119,799,669],{"x":800,"y":667,"style":668},"560.8",[90,802],{"x":803,"y":656,"width":673,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"595.4",[119,805,677],{"x":806,"y":667,"style":668},"635.9",[90,808],{"x":809,"y":656,"width":681,"height":658,"rx":93,"fill":104,"stroke":105,"style":659},"676.3",[119,811,685],{"x":812,"y":667,"style":668},"708.2",[90,814],{"x":794,"y":688,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},[119,816,92],{"x":817,"y":693,"style":668},"510.3",[90,819],{"x":797,"y":688,"width":663,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},[119,821,634],{"x":800,"y":693,"style":699},[90,823],{"x":803,"y":688,"width":673,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},[119,825,704],{"x":806,"y":693,"style":699},[90,827],{"x":809,"y":688,"width":681,"height":689,"rx":93,"fill":148,"stroke":139,"style":696},[119,829,709],{"x":812,"y":693,"style":699},[90,831],{"x":794,"y":712,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},[119,833,410],{"x":817,"y":715,"style":668},[90,835],{"x":797,"y":712,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,837,721],{"x":800,"y":715,"style":720},[90,839],{"x":803,"y":712,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,841,68],{"x":806,"y":715,"style":720},[90,843],{"x":809,"y":712,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,845,64],{"x":812,"y":715,"style":720},[90,847],{"x":794,"y":732,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},[119,849,614],{"x":817,"y":735,"style":668},[90,851],{"x":797,"y":732,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,853,740],{"x":800,"y":735,"style":720},[90,855],{"x":803,"y":732,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,857,740],{"x":806,"y":735,"style":720},[90,859],{"x":809,"y":732,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,861,740],{"x":812,"y":735,"style":720},[90,863],{"x":794,"y":751,"width":657,"height":689,"rx":93,"fill":104,"stroke":105,"style":659},[119,865,651],{"x":817,"y":754,"style":668},[90,867],{"x":797,"y":751,"width":663,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,869,781],{"x":800,"y":754,"style":720},[90,871],{"x":803,"y":751,"width":673,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,873,786],{"x":806,"y":754,"style":720},[90,875],{"x":809,"y":751,"width":681,"height":689,"rx":93,"fill":96,"stroke":105,"style":659},[119,877,791],{"x":812,"y":754,"style":720},[119,879,883],{"x":880,"y":881,"style":882},"142.9","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[119,885,887],{"x":886,"y":881,"style":882},"617.1","after",[37,889],{"x1":890,"y1":891,"x2":892,"y2":891,"stroke":139,"style":106},"275.7","108.0","480.3",[141,894],{"points":895,"fill":144},"484.3,108.0 475.3,113.0 475.3,103.0",[90,897],{"x":898,"y":899,"width":900,"height":901,"rx":902,"fill":96,"stroke":105,"style":903},"304.6","75.0","150.8","22.0","11","stroke-width:1.2px",[119,905,908],{"x":186,"y":906,"style":907},"90.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","drop header copies",[20,910,912],{"id":911},"step-3-turn-text-into-numbers-and-dates","Step 3 — Turn text into numbers and dates",[10,914,915,916,918,919,921],{},"Everything pdfplumber returns is a string. Convert the columns that should be numbers and dates, and be explicit about the formats — ",[32,917,64],{}," needs its thousands separator removed, and ",[32,920,68],{}," is the 2nd of September, not the 9th of February:",[25,923,925],{"className":270,"code":924,"language":272,"meta":30,"style":30},"df = raw.copy()\ndf[\"Amount\"] = pd.to_numeric(df[\"Amount\"].str.replace(\",\", \"\", regex=False))\ndf[\"Date\"] = pd.to_datetime(df[\"Date\"], format=\"%d\u002F%m\u002F%Y\")\ndf[\"Customer\"] = df[\"Customer\"].str.strip()\nprint(df.dtypes.to_dict())\nprint(df.head(3).to_string(index=False))\n",[32,926,927,937,978,1012,1031,1038],{"__ignoreMap":30},[35,928,929,932,934],{"class":37,"line":38},[35,930,931],{"class":283},"df ",[35,933,488],{"class":279},[35,935,936],{"class":283}," raw.copy()\n",[35,938,939,942,945,948,950,953,955,958,961,964,967,969,972,974,976],{"class":37,"line":287},[35,940,941],{"class":283},"df[",[35,943,944],{"class":45},"\"Amount\"",[35,946,947],{"class":283},"] ",[35,949,488],{"class":279},[35,951,952],{"class":283}," pd.to_numeric(df[",[35,954,944],{"class":45},[35,956,957],{"class":283},"].str.replace(",[35,959,960],{"class":45},"\",\"",[35,962,963],{"class":283},", ",[35,965,966],{"class":45},"\"\"",[35,968,963],{"class":283},[35,970,971],{"class":588},"regex",[35,973,488],{"class":279},[35,975,625],{"class":318},[35,977,628],{"class":283},[35,979,980,982,985,987,989,992,994,997,1000,1002,1004,1007,1010],{"class":37,"line":294},[35,981,941],{"class":283},[35,983,984],{"class":45},"\"Date\"",[35,986,947],{"class":283},[35,988,488],{"class":279},[35,990,991],{"class":283}," pd.to_datetime(df[",[35,993,984],{"class":45},[35,995,996],{"class":283},"], ",[35,998,999],{"class":588},"format",[35,1001,488],{"class":279},[35,1003,418],{"class":45},[35,1005,1006],{"class":380},"%d",[35,1008,1009],{"class":45},"\u002F%m\u002F%Y\"",[35,1011,334],{"class":283},[35,1013,1014,1016,1019,1021,1023,1026,1028],{"class":37,"line":315},[35,1015,941],{"class":283},[35,1017,1018],{"class":45},"\"Customer\"",[35,1020,947],{"class":283},[35,1022,488],{"class":279},[35,1024,1025],{"class":283}," df[",[35,1027,1018],{"class":45},[35,1029,1030],{"class":283},"].str.strip()\n",[35,1032,1033,1035],{"class":37,"line":337},[35,1034,600],{"class":318},[35,1036,1037],{"class":283},"(df.dtypes.to_dict())\n",[35,1039,1040,1042,1045,1047,1049,1051,1053,1055],{"class":37,"line":352},[35,1041,600],{"class":318},[35,1043,1044],{"class":283},"(df.head(",[35,1046,614],{"class":318},[35,1048,617],{"class":283},[35,1050,620],{"class":588},[35,1052,488],{"class":279},[35,1054,625],{"class":318},[35,1056,628],{"class":283},[10,1058,1059,1062,1063,1066,1067,1070,1071,1075,1076,1079,1080,1083,1084,1088],{},[32,1060,1061],{},"pd.to_numeric"," raises on anything it cannot parse, which is what you want here: a stray footnote marker or a cell that swallowed two values should stop the job, not become ",[32,1064,1065],{},"NaN",". Statements from continental European systems print ",[32,1068,1069],{},"2.675,14","; for those, remove the dots and replace the comma with a point, as described in ",[14,1072,1074],{"href":1073},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fread-csv-exports-with-semicolons-and-decimal-commas\u002F","Read CSV Exports with Semicolons and Decimal Commas",". Negative amounts printed as ",[32,1077,1078],{},"(1,234.00)"," or ",[32,1081,1082],{},"1,234.00-"," need their own rule — see ",[14,1085,1087],{"href":1086},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fconvert-trailing-minus-and-cr-dr-amounts-with-pandas\u002F","Convert Trailing Minus and CR\u002FDR Amounts with pandas",".",[20,1090,1092],{"id":1091},"step-4-check-the-result-against-the-document","Step 4 — Check the result against the document",[10,1094,1095],{},"Extraction errors are silent: a row lost at a page break or a digit dropped by OCR produces a perfectly plausible table. Most statements print a total somewhere; find it in the text and compare:",[25,1097,1099],{"className":270,"code":1098,"language":272,"meta":30,"style":30},"import re\n\nwith pdfplumber.open(\"statement.pdf\") as pdf:\n    first_page_text = pdf.pages[0].extract_text()\nprinted = float(re.search(r\"Statement total: ([\\d,]+\\.\\d{2})\", first_page_text).group(1).replace(\",\", \"\"))\nextracted = round(df[\"Amount\"].sum(), 2)\nprint(f\"printed {printed:,.2f}  extracted {extracted:,.2f}  rows {len(df)}\")\nassert abs(printed - extracted) \u003C 0.005, \"extracted total does not match the statement\"\nassert df[\"Invoice\"].is_unique\n",[32,1100,1101,1108,1112,1126,1141,1202,1224,1273,1301],{"__ignoreMap":30},[35,1102,1103,1105],{"class":37,"line":38},[35,1104,280],{"class":279},[35,1106,1107],{"class":283}," re\n",[35,1109,1110],{"class":37,"line":287},[35,1111,291],{"emptyLinePlaceholder":290},[35,1113,1114,1116,1118,1120,1122,1124],{"class":37,"line":294},[35,1115,297],{"class":279},[35,1117,300],{"class":283},[35,1119,303],{"class":45},[35,1121,306],{"class":283},[35,1123,309],{"class":279},[35,1125,312],{"class":283},[35,1127,1128,1131,1133,1136,1138],{"class":37,"line":315},[35,1129,1130],{"class":283},"    first_page_text ",[35,1132,488],{"class":279},[35,1134,1135],{"class":283}," pdf.pages[",[35,1137,93],{"class":318},[35,1139,1140],{"class":283},"].extract_text()\n",[35,1142,1143,1146,1148,1151,1154,1157,1160,1163,1166,1168,1171,1175,1178,1181,1184,1186,1189,1191,1194,1196,1198,1200],{"class":37,"line":337},[35,1144,1145],{"class":283},"printed ",[35,1147,488],{"class":279},[35,1149,1150],{"class":318}," float",[35,1152,1153],{"class":283},"(re.search(",[35,1155,1156],{"class":279},"r",[35,1158,1159],{"class":45},"\"Statement total: ",[35,1161,1162],{"class":318},"([\\d",[35,1164,1165],{"class":380},",",[35,1167,413],{"class":318},[35,1169,1170],{"class":279},"+",[35,1172,1174],{"class":1173},"s_b0D","\\.",[35,1176,1177],{"class":318},"\\d",[35,1179,1180],{"class":279},"{2}",[35,1182,1183],{"class":318},")",[35,1185,418],{"class":45},[35,1187,1188],{"class":283},", first_page_text).group(",[35,1190,92],{"class":318},[35,1192,1193],{"class":283},").replace(",[35,1195,960],{"class":45},[35,1197,963],{"class":283},[35,1199,966],{"class":45},[35,1201,628],{"class":283},[35,1203,1204,1207,1209,1212,1215,1217,1220,1222],{"class":37,"line":352},[35,1205,1206],{"class":283},"extracted ",[35,1208,488],{"class":279},[35,1210,1211],{"class":318}," round",[35,1213,1214],{"class":283},"(df[",[35,1216,944],{"class":45},[35,1218,1219],{"class":283},"].sum(), ",[35,1221,410],{"class":318},[35,1223,334],{"class":283},[35,1225,1226,1228,1230,1232,1235,1237,1240,1243,1245,1248,1250,1253,1255,1257,1260,1262,1264,1267,1269,1271],{"class":37,"line":366},[35,1227,600],{"class":318},[35,1229,322],{"class":283},[35,1231,374],{"class":279},[35,1233,1234],{"class":45},"\"printed ",[35,1236,381],{"class":380},[35,1238,1239],{"class":283},"printed",[35,1241,1242],{"class":279},":,.2f",[35,1244,387],{"class":380},[35,1246,1247],{"class":45},"  extracted ",[35,1249,381],{"class":380},[35,1251,1252],{"class":283},"extracted",[35,1254,1242],{"class":279},[35,1256,387],{"class":380},[35,1258,1259],{"class":45},"  rows ",[35,1261,381],{"class":380},[35,1263,325],{"class":318},[35,1265,1266],{"class":283},"(df)",[35,1268,387],{"class":380},[35,1270,418],{"class":45},[35,1272,334],{"class":283},[35,1274,1275,1278,1281,1284,1287,1290,1293,1296,1298],{"class":37,"line":577},[35,1276,1277],{"class":279},"assert",[35,1279,1280],{"class":318}," abs",[35,1282,1283],{"class":283},"(printed ",[35,1285,1286],{"class":279},"-",[35,1288,1289],{"class":283}," extracted) ",[35,1291,1292],{"class":279},"\u003C",[35,1294,1295],{"class":318}," 0.005",[35,1297,963],{"class":283},[35,1299,1300],{"class":45},"\"extracted total does not match the statement\"\n",[35,1302,1303,1305,1307,1310],{"class":37,"line":597},[35,1304,1277],{"class":279},[35,1306,1025],{"class":283},[35,1308,1309],{"class":45},"\"Invoice\"",[35,1311,1312],{"class":283},"].is_unique\n",[10,1314,1315,1316,1318,1319,1322,1323,1088],{},"If the PDF has no total, check the row count against the invoice numbers (a gap in ",[32,1317,721],{}," … ",[32,1320,1321],{},"INV-2460"," means a missing row) or compare with a figure from another system. The idea is the same as in ",[14,1324,1326],{"href":1325},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Freconcile-excel-totals-with-control-totals\u002F","Reconcile Excel Totals with Control Totals",[20,1328,1330],{"id":1329},"step-5-borderless-tables-rebuild-rows-from-word-positions","Step 5 — Borderless tables: rebuild rows from word positions",[10,1332,1333,1334,1337,1338,1341,1342,963,1345,1341,1348,1351],{},"The summary table at the end has no lines, so the default settings do not see it as a table. Switching to the text-based strategy (",[32,1335,1336],{},"vertical_strategy=\"text\"",") guesses column edges from how words line up, and on this table it splits words in the middle — ",[32,1339,1340],{},"Inv"," \u002F ",[32,1343,1344],{},"oices",[32,1346,1347],{},"79,080",[32,1349,1350],{},".86",". When each cell is a single word or number, grouping words by their vertical position is simpler and more predictable:",[25,1353,1355],{"className":270,"code":1354,"language":272,"meta":30,"style":30},"from itertools import groupby\n\nwith pdfplumber.open(\"statement.pdf\") as pdf:\n    words = pdf.pages[-1].extract_words()\n\nlines = [[w[\"text\"] for w in grp]\n         for _, grp in groupby(sorted(words, key=lambda w: (round(w[\"top\"]), w[\"x0\"])),\n                               key=lambda w: round(w[\"top\"]))]\nstart = lines.index([\"Region\", \"Invoices\", \"Total\"])\nsummary = pd.DataFrame(lines[start + 1:start + 3], columns=lines[start])\nsummary[\"Total\"] = pd.to_numeric(summary[\"Total\"].str.replace(\",\", \"\", regex=False))\nprint(summary.to_string(index=False))\n",[32,1356,1357,1370,1374,1388,1404,1408,1433,1479,1498,1524,1556,1591],{"__ignoreMap":30},[35,1358,1359,1362,1365,1367],{"class":37,"line":38},[35,1360,1361],{"class":279},"from",[35,1363,1364],{"class":283}," itertools ",[35,1366,280],{"class":279},[35,1368,1369],{"class":283}," groupby\n",[35,1371,1372],{"class":37,"line":287},[35,1373,291],{"emptyLinePlaceholder":290},[35,1375,1376,1378,1380,1382,1384,1386],{"class":37,"line":294},[35,1377,297],{"class":279},[35,1379,300],{"class":283},[35,1381,303],{"class":45},[35,1383,306],{"class":283},[35,1385,309],{"class":279},[35,1387,312],{"class":283},[35,1389,1390,1393,1395,1397,1399,1401],{"class":37,"line":315},[35,1391,1392],{"class":283},"    words ",[35,1394,488],{"class":279},[35,1396,1135],{"class":283},[35,1398,1286],{"class":279},[35,1400,92],{"class":318},[35,1402,1403],{"class":283},"].extract_words()\n",[35,1405,1406],{"class":37,"line":337},[35,1407,291],{"emptyLinePlaceholder":290},[35,1409,1410,1413,1415,1418,1421,1423,1425,1428,1430],{"class":37,"line":352},[35,1411,1412],{"class":283},"lines ",[35,1414,488],{"class":279},[35,1416,1417],{"class":283}," [[w[",[35,1419,1420],{"class":45},"\"text\"",[35,1422,947],{"class":283},[35,1424,494],{"class":279},[35,1426,1427],{"class":283}," w ",[35,1429,346],{"class":279},[35,1431,1432],{"class":283}," grp]\n",[35,1434,1435,1438,1441,1443,1446,1449,1452,1455,1458,1461,1464,1467,1470,1473,1476],{"class":37,"line":366},[35,1436,1437],{"class":279},"         for",[35,1439,1440],{"class":283}," _, grp ",[35,1442,346],{"class":279},[35,1444,1445],{"class":283}," groupby(",[35,1447,1448],{"class":318},"sorted",[35,1450,1451],{"class":283},"(words, ",[35,1453,1454],{"class":588},"key",[35,1456,1457],{"class":279},"=lambda",[35,1459,1460],{"class":283}," w: (",[35,1462,1463],{"class":318},"round",[35,1465,1466],{"class":283},"(w[",[35,1468,1469],{"class":45},"\"top\"",[35,1471,1472],{"class":283},"]), w[",[35,1474,1475],{"class":45},"\"x0\"",[35,1477,1478],{"class":283},"])),\n",[35,1480,1481,1484,1486,1489,1491,1493,1495],{"class":37,"line":577},[35,1482,1483],{"class":588},"                               key",[35,1485,1457],{"class":279},[35,1487,1488],{"class":283}," w: ",[35,1490,1463],{"class":318},[35,1492,1466],{"class":283},[35,1494,1469],{"class":45},[35,1496,1497],{"class":283},"]))]\n",[35,1499,1500,1503,1505,1508,1511,1513,1516,1518,1521],{"class":37,"line":597},[35,1501,1502],{"class":283},"start ",[35,1504,488],{"class":279},[35,1506,1507],{"class":283}," lines.index([",[35,1509,1510],{"class":45},"\"Region\"",[35,1512,963],{"class":283},[35,1514,1515],{"class":45},"\"Invoices\"",[35,1517,963],{"class":283},[35,1519,1520],{"class":45},"\"Total\"",[35,1522,1523],{"class":283},"])\n",[35,1525,1526,1529,1531,1534,1536,1539,1542,1544,1547,1549,1551,1553],{"class":37,"line":606},[35,1527,1528],{"class":283},"summary ",[35,1530,488],{"class":279},[35,1532,1533],{"class":283}," pd.DataFrame(lines[start ",[35,1535,1170],{"class":279},[35,1537,1538],{"class":318}," 1",[35,1540,1541],{"class":283},":start ",[35,1543,1170],{"class":279},[35,1545,1546],{"class":318}," 3",[35,1548,996],{"class":283},[35,1550,589],{"class":588},[35,1552,488],{"class":279},[35,1554,1555],{"class":283},"lines[start])\n",[35,1557,1559,1562,1564,1566,1568,1571,1573,1575,1577,1579,1581,1583,1585,1587,1589],{"class":37,"line":1558},11,[35,1560,1561],{"class":283},"summary[",[35,1563,1520],{"class":45},[35,1565,947],{"class":283},[35,1567,488],{"class":279},[35,1569,1570],{"class":283}," pd.to_numeric(summary[",[35,1572,1520],{"class":45},[35,1574,957],{"class":283},[35,1576,960],{"class":45},[35,1578,963],{"class":283},[35,1580,966],{"class":45},[35,1582,963],{"class":283},[35,1584,971],{"class":588},[35,1586,488],{"class":279},[35,1588,625],{"class":318},[35,1590,628],{"class":283},[35,1592,1594,1596,1599,1601,1603,1605],{"class":37,"line":1593},12,[35,1595,600],{"class":318},[35,1597,1598],{"class":283},"(summary.to_string(",[35,1600,620],{"class":588},[35,1602,488],{"class":279},[35,1604,625],{"class":318},[35,1606,628],{"class":283},[10,1608,1609,1612,1613,1616,1617,1620,1621,1624,1625,1088],{},[32,1610,1611],{},"extract_words"," returns each word with its coordinates. Sorting by the rounded top position and then by ",[32,1614,1615],{},"x0"," puts words in reading order, and ",[32,1618,1619],{},"groupby"," on the top position collects each printed line. For borderless tables whose cells contain spaces — \"Customer B01\" — define the column boundaries yourself with ",[32,1622,1623],{},"explicit_vertical_lines",", measured once from the header positions, and pass them to ",[32,1626,1627],{},"extract_table",[71,1629,80,1634,80,1637,80,1640,80,1643,80,1648,80,1655,80,1661,80,1665,80,1670,80,1676,80,1680,80,1685,80,1689,80,1695,80,1698,80,1704,80,1708,80,1712,80,1716,80,1721,80,1724,80,1729,80,1732,80,1739,80,1742,80,1747,80,1752,80,1756,80,1760,80,1765,80,1768,80,1772,80,1777,80,1781],{"viewBox":1630,"role":74,"ariaLabelledBy":1631,"xmlns":78,"style":642},"4 0 752 224",[1632,1633],"pdf-tools-t","pdf-tools-d",[82,1635,1636],{"id":1632},"Three ways to read a table from a PDF",[86,1638,1639],{"id":1633},"Comparison of pdfplumber, camelot and tabula-py for extracting PDF tables, with their requirements and strengths.",[90,1641],{"x":651,"y":93,"width":652,"height":1642,"fill":96},"224",[90,1644],{"x":99,"y":100,"width":1645,"height":1646,"rx":1647,"fill":148,"stroke":139,"style":106},"228.0","192.0","14",[108,1649,1651],{"transform":1650},"translate(121.0,29.0) scale(1.083)",[112,1652],{"d":1653,"fill":115,"stroke":155,"style":1654},"M12 3c-4 0-4 1.6-4 3v2h4.2v1H6c-2 0-3 1.4-3 4s1 4 3 4h2v-2.6c0-1.6 1.3-2.9 2.9-2.9h4.2c1.3 0 2.4-1.1 2.4-2.4V6c0-1.6-1.8-3-5.5-3z M10 5.5h.01 M12 21c4 0 4-1.6 4-3v-2h-4.2v-1H18c2 0 3-1.4 3-4s-1-4-3-4h-2v2.6c0 1.6-1.3 2.9-2.9 2.9H8.9c-1.3 0-2.4 1.1-2.4 2.4V18c0 1.6 1.8 3 5.5 3z M14 18.5h.01","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[119,1656,1660],{"x":1657,"y":1658,"style":1659},"134.0","78.0","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","pdfplumber",[37,1662],{"x1":692,"y1":1663,"x2":1664,"y2":1663,"stroke":139,"style":659},"91.0","232.0",[119,1666,1669],{"x":1667,"y":715,"style":1668},"60.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","pure Python, no Java",[108,1671,1673],{"transform":1672},"translate(38.5,99.5) scale(0.625)",[112,1674],{"d":218,"fill":115,"stroke":212,"style":1675},"stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[119,1677,1679],{"x":1667,"y":1678,"style":1668},"137.0","words, lines and positions",[108,1681,1683],{"transform":1682},"translate(38.5,125.5) scale(0.625)",[112,1684],{"d":218,"fill":115,"stroke":212,"style":1675},[119,1686,1688],{"x":1667,"y":1687,"style":1668},"163.0","borderless tables need rules",[108,1690,1692],{"transform":1691},"translate(38.5,151.5) scale(0.625)",[112,1693],{"d":1694,"fill":115,"stroke":242,"style":1675},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4",[90,1696],{"x":1697,"y":100,"width":1645,"height":1646,"rx":1647,"fill":211,"stroke":212,"style":106},"266.0",[108,1699,1701],{"transform":1700},"translate(367.0,29.0) scale(1.083)",[112,1702],{"d":1703,"fill":115,"stroke":219,"style":1654},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14",[119,1705,1707],{"x":186,"y":1658,"style":1706},"font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","camelot",[37,1709],{"x1":1710,"y1":1663,"x2":1711,"y2":1663,"stroke":212,"style":659},"282.0","478.0",[119,1713,1715],{"x":1714,"y":715,"style":1668},"306.0","lattice and stream modes",[108,1717,1719],{"transform":1718},"translate(284.5,99.5) scale(0.625)",[112,1720],{"d":218,"fill":115,"stroke":212,"style":1675},[119,1722,1723],{"x":1714,"y":1678,"style":1668},"accuracy report per table",[108,1725,1727],{"transform":1726},"translate(284.5,125.5) scale(0.625)",[112,1728],{"d":218,"fill":115,"stroke":212,"style":1675},[119,1730,1731],{"x":1714,"y":1687,"style":1668},"needs Ghostscript for lattice",[108,1733,1735],{"transform":1734},"translate(284.5,151.5) scale(0.625)",[112,1736],{"d":1737,"fill":115,"stroke":1738,"style":1675},"M6 6l12 12 M18 6L6 18","var(--accent,#f43f8f)",[90,1740],{"x":1741,"y":100,"width":1645,"height":1646,"rx":1647,"fill":241,"stroke":242,"style":106},"512.0",[108,1743,1745],{"transform":1744},"translate(613.0,29.0) scale(1.083)",[112,1746],{"d":183,"fill":115,"stroke":249,"style":1654},[119,1748,1751],{"x":1749,"y":1658,"style":1750},"626.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","tabula-py",[37,1753],{"x1":1754,"y1":1663,"x2":1755,"y2":1663,"stroke":242,"style":659},"528.0","724.0",[119,1757,1759],{"x":1758,"y":715,"style":1668},"552.0","good defaults on simple",[108,1761,1763],{"transform":1762},"translate(530.5,99.5) scale(0.625)",[112,1764],{"d":218,"fill":115,"stroke":212,"style":1675},[119,1766,1767],{"x":1758,"y":102,"style":1668},"reports",[119,1769,1771],{"x":1758,"y":1770,"style":1668},"154.0","needs a Java runtime",[108,1773,1775],{"transform":1774},"translate(530.5,142.5) scale(0.625)",[112,1776],{"d":1737,"fill":115,"stroke":1738,"style":1675},[119,1778,1780],{"x":1758,"y":1779,"style":1668},"180.0","less control over edge cases",[108,1782,1784],{"transform":1783},"translate(530.5,168.5) scale(0.625)",[112,1785],{"d":1694,"fill":115,"stroke":242,"style":1675},[20,1787,1789],{"id":1788},"step-6-write-the-workbook","Step 6 — Write the workbook",[10,1791,1792],{},"With clean types, writing is ordinary pandas work. Give the amount column a number format, the dates a date format, and widen the columns:",[25,1794,1796],{"className":270,"code":1795,"language":272,"meta":30,"style":30},"with pd.ExcelWriter(\"statement.xlsx\", engine=\"xlsxwriter\", datetime_format=\"dd\u002Fmm\u002Fyyyy\") as xw:\n    df.to_excel(xw, sheet_name=\"Invoices\", index=False)\n    summary.to_excel(xw, sheet_name=\"Summary\", index=False)\n    money = xw.book.add_format({\"num_format\": \"#,##0.00\"})\n    ws = xw.sheets[\"Invoices\"]\n    ws.set_column(\"A:B\", 12)\n    ws.set_column(\"C:C\", 16)\n    ws.set_column(\"E:E\", 12, money)\n    ws.autofilter(0, 0, len(df), len(df.columns) - 1)\n    ws.freeze_panes(1, 0)\nprint(pd.read_excel(\"statement.xlsx\", sheet_name=None).keys())\n",[32,1797,1798,1835,1857,1879,1900,1914,1928,1942,1956,1985,1998],{"__ignoreMap":30},[35,1799,1800,1802,1805,1808,1810,1813,1815,1818,1820,1823,1825,1828,1830,1832],{"class":37,"line":38},[35,1801,297],{"class":279},[35,1803,1804],{"class":283}," pd.ExcelWriter(",[35,1806,1807],{"class":45},"\"statement.xlsx\"",[35,1809,963],{"class":283},[35,1811,1812],{"class":588},"engine",[35,1814,488],{"class":279},[35,1816,1817],{"class":45},"\"xlsxwriter\"",[35,1819,963],{"class":283},[35,1821,1822],{"class":588},"datetime_format",[35,1824,488],{"class":279},[35,1826,1827],{"class":45},"\"dd\u002Fmm\u002Fyyyy\"",[35,1829,306],{"class":283},[35,1831,309],{"class":279},[35,1833,1834],{"class":283}," xw:\n",[35,1836,1837,1840,1843,1845,1847,1849,1851,1853,1855],{"class":37,"line":287},[35,1838,1839],{"class":283},"    df.to_excel(xw, ",[35,1841,1842],{"class":588},"sheet_name",[35,1844,488],{"class":279},[35,1846,1515],{"class":45},[35,1848,963],{"class":283},[35,1850,620],{"class":588},[35,1852,488],{"class":279},[35,1854,625],{"class":318},[35,1856,334],{"class":283},[35,1858,1859,1862,1864,1866,1869,1871,1873,1875,1877],{"class":37,"line":294},[35,1860,1861],{"class":283},"    summary.to_excel(xw, ",[35,1863,1842],{"class":588},[35,1865,488],{"class":279},[35,1867,1868],{"class":45},"\"Summary\"",[35,1870,963],{"class":283},[35,1872,620],{"class":588},[35,1874,488],{"class":279},[35,1876,625],{"class":318},[35,1878,334],{"class":283},[35,1880,1881,1884,1886,1889,1892,1894,1897],{"class":37,"line":315},[35,1882,1883],{"class":283},"    money ",[35,1885,488],{"class":279},[35,1887,1888],{"class":283}," xw.book.add_format({",[35,1890,1891],{"class":45},"\"num_format\"",[35,1893,390],{"class":283},[35,1895,1896],{"class":45},"\"#,##0.00\"",[35,1898,1899],{"class":283},"})\n",[35,1901,1902,1905,1907,1910,1912],{"class":37,"line":337},[35,1903,1904],{"class":283},"    ws ",[35,1906,488],{"class":279},[35,1908,1909],{"class":283}," xw.sheets[",[35,1911,1515],{"class":45},[35,1913,535],{"class":283},[35,1915,1916,1919,1922,1924,1926],{"class":37,"line":352},[35,1917,1918],{"class":283},"    ws.set_column(",[35,1920,1921],{"class":45},"\"A:B\"",[35,1923,963],{"class":283},[35,1925,103],{"class":318},[35,1927,334],{"class":283},[35,1929,1930,1932,1935,1937,1940],{"class":37,"line":366},[35,1931,1918],{"class":283},[35,1933,1934],{"class":45},"\"C:C\"",[35,1936,963],{"class":283},[35,1938,1939],{"class":318},"16",[35,1941,334],{"class":283},[35,1943,1944,1946,1949,1951,1953],{"class":37,"line":577},[35,1945,1918],{"class":283},[35,1947,1948],{"class":45},"\"E:E\"",[35,1950,963],{"class":283},[35,1952,103],{"class":318},[35,1954,1955],{"class":283},", money)\n",[35,1957,1958,1961,1963,1965,1967,1969,1971,1974,1976,1979,1981,1983],{"class":37,"line":597},[35,1959,1960],{"class":283},"    ws.autofilter(",[35,1962,93],{"class":318},[35,1964,963],{"class":283},[35,1966,93],{"class":318},[35,1968,963],{"class":283},[35,1970,325],{"class":318},[35,1972,1973],{"class":283},"(df), ",[35,1975,325],{"class":318},[35,1977,1978],{"class":283},"(df.columns) ",[35,1980,1286],{"class":279},[35,1982,1538],{"class":318},[35,1984,334],{"class":283},[35,1986,1987,1990,1992,1994,1996],{"class":37,"line":606},[35,1988,1989],{"class":283},"    ws.freeze_panes(",[35,1991,92],{"class":318},[35,1993,963],{"class":283},[35,1995,93],{"class":318},[35,1997,334],{"class":283},[35,1999,2000,2002,2005,2007,2009,2011,2013,2015],{"class":37,"line":1558},[35,2001,600],{"class":318},[35,2003,2004],{"class":283},"(pd.read_excel(",[35,2006,1807],{"class":45},[35,2008,963],{"class":283},[35,2010,1842],{"class":588},[35,2012,488],{"class":279},[35,2014,426],{"class":318},[35,2016,2017],{"class":283},").keys())\n",[10,2019,2020],{},"The amounts are now real numbers that sum, sort and pivot in Excel, and the dates are real dates — which is the whole point of the exercise.",[20,2022,2024],{"id":2023},"common-pitfalls","Common pitfalls",[2026,2027,2028,2036,2045,2055,2073],"ul",{},[2029,2030,2031,2035],"li",{},[2032,2033,2034],"strong",{},"Scanned PDFs."," No text layer, no tables. OCR first, then check totals carefully.",[2029,2037,2038,2041,2042,2044],{},[2032,2039,2040],{},"Merged or empty cells."," They come back as ",[32,2043,426],{}," or empty strings; forward-fill group labels that were printed once per block.",[2029,2046,2047,2050,2051,2054],{},[2032,2048,2049],{},"Wrapped cell text."," A long customer name that wraps onto two lines inside a cell arrives with a newline in it; replace ",[32,2052,2053],{},"\\n"," with a space.",[2029,2056,2057,2060,2061,2064,2065,2068,2069,2072],{},[2032,2058,2059],{},"Day-first dates."," Always pass ",[32,2062,2063],{},"format="," to ",[32,2066,2067],{},"to_datetime","; guessing turns ",[32,2070,2071],{},"02\u002F09"," into February for half the rows.",[2029,2074,2075,2078],{},[2032,2076,2077],{},"Header rows that differ slightly per page."," Filter on the content of a key column instead of comparing whole rows.",[20,2080,2082],{"id":2081},"performance-notes","Performance notes",[10,2084,2085,2086,2089],{},"pdfplumber parses every character on a page, so it handles hundreds of pages per minute rather than thousands. For a large archive, crop each page to the table's area with ",[32,2087,2088],{},"page.crop(bbox)"," before extracting — less text to analyse — and process files in parallel. Keep the raw extracted rows alongside the cleaned output while you tune the rules, so a wrong conversion can be traced back to what the PDF actually said.",[20,2091,2093],{"id":2092},"conclusion","Conclusion",[10,2095,2096],{},"Extract each page's tables with pdfplumber, stack them while dropping repeated headers, and convert the text to numbers and dates with explicit formats. Check the result against a total or a sequence printed in the document before trusting it. For tables without ruling lines, rebuild rows from word positions or give pdfplumber explicit column edges. Then write the clean frame to Excel with number and date formats.",[20,2098,2100],{"id":2099},"frequently-asked-questions","Frequently asked questions",[10,2102,2103,2106],{},[2032,2104,2105],{},"Can Python convert a PDF table to Excel?","\nYes. pdfplumber, camelot and tabula-py extract tables from PDFs that have a text layer; pandas then cleans the result and writes it with to_excel. Scanned PDFs need OCR, for example with ocrmypdf, before any of them can read the text.",[10,2108,2109,2112],{},[2032,2110,2111],{},"Why does pdfplumber return no tables?","\nEither the page is a scanned image with no text layer — extract_text() returns nothing — or the table has no ruling lines. For borderless tables use the text strategy, explicit column edges, or group extract_words() output by line.",[10,2114,2115,2118],{},[2032,2116,2117],{},"How do I handle a table that continues across pages?","\nExtract the table from every page, take the header from the first page, and keep every row that is not a copy of the header. Then check the row count or total against the document.",[10,2120,2121,2124,2125,2128],{},[2032,2122,2123],{},"How do I convert \"1,234.56\" strings from a PDF into numbers?","\nRemove the thousands separator and parse: ",[32,2126,2127],{},"pd.to_numeric(col.str.replace(\",\", \"\"))",". For European formats such as 1.234,56 remove the dots and replace the comma with a point first.",[20,2130,2132],{"id":2131},"related","Related",[2026,2134,2135,2141,2148,2155],{},[2029,2136,2137,2138,2140],{},"Up one level: ",[14,2139,17],{"href":16}," — every format that ends up in a workbook.",[2029,2142,2143,2147],{},[14,2144,2146],{"href":2145},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fconvert-word-document-tables-to-excel-with-python\u002F","Convert Word Document Tables to Excel with Python"," — the same job for .docx files.",[2029,2149,2150,2154],{},[14,2151,2153],{"href":2152},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fconvert-currency-strings-to-numbers-with-pandas\u002F","Convert Currency Strings to Numbers with pandas"," — symbols, separators and brackets.",[2029,2156,2157,2161],{},[14,2158,2160],{"href":2159},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas\u002F","Scrape a Web Page Table into Excel with pandas"," — when the table lives in HTML instead.",[2163,2164,2165],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}",{"title":30,"searchDepth":287,"depth":287,"links":2167},[2168,2169,2170,2171,2172,2173,2174,2175,2176,2177,2178,2179],{"id":22,"depth":287,"text":23},{"id":263,"depth":287,"text":264},{"id":441,"depth":287,"text":442},{"id":911,"depth":287,"text":912},{"id":1091,"depth":287,"text":1092},{"id":1329,"depth":287,"text":1330},{"id":1788,"depth":287,"text":1789},{"id":2023,"depth":287,"text":2024},{"id":2081,"depth":287,"text":2082},{"id":2092,"depth":287,"text":2093},{"id":2099,"depth":287,"text":2100},{"id":2131,"depth":287,"text":2132},"2026-09-29","Get tables out of PDF statements and reports and into a workbook with pdfplumber and pandas: ruled tables that span several pages, repeated header rows, amounts with thousands separators, day-first dates, borderless tables rebuilt from word positions, a control-total check and a formatted .xlsx.","md",[2184,2186,2188,2190],{"q":2105,"a":2185},"Yes. pdfplumber, camelot and tabula-py extract tables from PDFs that have a text layer; pandas then cleans the result and writes it with to_excel. Scanned PDFs need OCR, for example with ocrmypdf, before any of them can read the text.",{"q":2111,"a":2187},"Either the page is a scanned image with no text layer — extract_text() returns nothing — or the table has no ruling lines. For borderless tables use the text strategy, explicit column edges, or group extract_words() output by line.",{"q":2117,"a":2189},"Extract the table from every page, take the header from the first page, and keep every row that is not a copy of the header. Then check the row count or total against the document.",{"q":2123,"a":2191},"Remove the thousands separator and parse: pd.to_numeric(col.str.replace(\",\", \"\")). For European formats such as 1.234,56 remove the dots and replace the comma with a point first.","extract tables from a pdf into excel with python",null,{"breadcrumb":2195},[2196,2199,2202],{"name":2197,"item":2198},"Home","\u002F",{"name":2200,"item":2201},"Getting Started with Python Excel Automation","\u002Fgetting-started-with-python-excel-automation\u002F",{"name":17,"item":16},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python",{"title":2205,"description":2206},"Extract PDF Tables to Excel in Python with pdfplumber","Convert PDF tables to Excel with pdfplumber: extract_tables across pages, drop repeated headers, parse \"2,675.14\" and dd\u002Fmm\u002Fyyyy, rebuild borderless tables from extract_words, check totals and write a formatted workbook. Scanned PDFs need OCR first.","extract-tables-from-a-pdf-into-excel-with-python","getting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fextract-tables-from-a-pdf-into-excel-with-python\u002Findex",[2210,2211],"file-formats","messy-sheets","how-to","g9SPISxEY827GKRYMKg1pKSSgDK7ybl3jdzuaiHmYs4",[2215,2219],{"title":2216,"path":2217,"stem":2218,"children":-1},"Create Calendar Events from an Excel Sheet with Python","\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fcreate-calendar-events-from-an-excel-sheet","getting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fcreate-calendar-events-from-an-excel-sheet\u002Findex",{"title":2220,"path":2221,"stem":2222,"children":-1},"Inspect the XML Inside an .xlsx File with Python","\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Finspect-the-xml-inside-an-xlsx-file-with-python","getting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Finspect-the-xml-inside-an-xlsx-file-with-python\u002Findex",[2224,2226],{"path":2225,"title":17},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions",{"path":2227,"title":2200},"\u002Fgetting-started-with-python-excel-automation",[],[2230,2233,2236,2240,2243,2247],{"path":2231,"title":2232,"kind":2212},"\u002Fadvanced-data-transformation-and-cleaning\u002Freading-excel-with-polars-and-arrow\u002Fconvert-excel-files-to-parquet-with-python","Convert Excel Files to Parquet with Python",{"path":2234,"title":2235,"kind":2212},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-large-excel-files-in-python\u002Fconvert-excel-to-csv-with-python","Convert Excel to CSV with Python",{"path":2237,"title":2238,"kind":2239},"\u002Fgetting-started-with-python-excel-automation\u002Fchoosing-a-python-excel-library\u002Fexcel-vs-csv-vs-parquet-for-python-data-pipelines","Excel vs CSV vs Parquet for Python Data Pipelines","compare",{"path":2241,"title":2242,"kind":2239},"\u002Fgetting-started-with-python-excel-automation\u002Fchoosing-a-python-excel-library\u002Fpick-an-excel-engine-for-xlsx-xlsm-xls-xlsb-and-ods","Pick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods",{"path":2244,"title":2245,"kind":2246},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Ffix-garbled-characters-in-excel-data","Fix Garbled Characters Like \"CafÃ©\" and \"â€™\" in Excel Data","fix",{"path":2248,"title":2249,"kind":2212},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-com-and-pywin32\u002Fextract-vba-macro-code-from-an-excel-file","Extract VBA Macro Code from an Excel File",1790972059272]