[{"data":1,"prerenderedAt":2305},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row":3,"surround:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row":2272,"ancestors:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row":2280,"children:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row":2285,"related:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row":2286},{"id":4,"title":5,"body":6,"dateModified":2239,"datePublished":2239,"description":2240,"extension":2241,"faq":2242,"keyword":2251,"kind":2252,"meta":2253,"navigation":288,"path":2261,"seo":2262,"slug":2265,"stem":2266,"topics":2267,"type":2270,"__hash__":2271},"docs\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row\u002Findex.md","Generate One PDF per Excel Row",{"type":7,"value":8,"toc":2226},"minimark",[9,19,24,80,90,232,236,254,505,515,519,527,1140,1163,1167,1170,1332,1335,1454,1458,1461,1834,1855,1859,1862,1949,1964,2092,2096,2107,2111,2154,2158,2161,2165,2171,2177,2183,2189,2193,2222],[10,11,12,13,18],"p",{},"Month-end statements for each customer, a certificate for every course attendee, a remittance advice for each supplier paid this week: the data is one row per recipient in a sheet, and each recipient needs their own PDF. Mail merge in Word can do it with enough clicking; a script does it every month without anyone clicking at all. There are two good routes. Drawing each PDF directly with reportlab gives full control and runs fast. Filling an Excel template per row and converting with LibreOffice keeps the layout in a workbook that non-developers can edit. This page, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002F","Generating Excel Reports from Templates",", builds both and checks what they produce.",[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 pandas reportlab pypdf openpyxl\n# for the template route\nsudo apt install libreoffice-calc\n","bash","",[32,33,34,59,66],"code",{"__ignoreMap":30},[35,36,39,43,47,50,53,56],"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}," pandas",[35,51,52],{"class":45}," reportlab",[35,54,55],{"class":45}," pypdf",[35,57,58],{"class":45}," openpyxl\n",[35,60,62],{"class":37,"line":61},2,[35,63,65],{"class":64},"s-wDw","# for the template route\n",[35,67,69,72,75,77],{"class":37,"line":68},3,[35,70,71],{"class":41},"sudo",[35,73,74],{"class":45}," apt",[35,76,46],{"class":45},[35,78,79],{"class":45}," libreoffice-calc\n",[10,81,82,85,86,89],{},[32,83,84],{},"statements.xlsx"," has five customers with an ID, name, address, balance and due date. One name contains a slash — which is not allowed in a file name — and one balance is zero. ",[32,87,88],{},"statement_template.xlsx"," is a one-page statement layout with labelled cells for the fields.",[91,92,100,101,100,105,100,109,100,117,100,127,100,138,100,145,100,151,100,157,100,165,100,169,100,174,100,178,100,185,100,189,100,196,100,201,100,205,100,209,100,214,100,217,100,222,100,225],"svg",{"viewBox":93,"role":94,"ariaLabelledBy":95,"xmlns":98,"style":99},"4 0 752 207","img",[96,97],"pdf-routes-t","pdf-routes-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[102,103,104],"title",{"id":96},"Two routes from rows to PDFs",[106,107,108],"desc",{"id":97},"Two panels. Drawing with reportlab is fast, needs no office software and gives full control but the layout lives in code. Filling an Excel template and converting with LibreOffice keeps the layout editable in Excel but needs LibreOffice and converts more slowly.",[110,111],"rect",{"x":112,"y":113,"width":114,"height":115,"fill":116},"4","0","752","207","#ffffff",[110,118],{"x":119,"y":120,"width":121,"height":122,"rx":123,"fill":124,"stroke":125,"style":126},"20.0","16.0","351.0","175.0","14","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[128,129,131],"g",{"transform":130},"translate(182.5,29.0) scale(1.083)",[132,133],"path",{"d":134,"fill":135,"stroke":136,"style":137},"M9 8l-4 4 4 4 M15 8l4 4-4 4","none","var(--brand-strong,#4338ca)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[139,140,144],"text",{"x":141,"y":142,"style":143},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Draw with reportlab",[37,146],{"x1":147,"y1":148,"x2":149,"y2":148,"stroke":125,"style":150},"36.0","91.0","355.0","stroke-width:1px",[139,152,156],{"x":153,"y":154,"style":155},"60.0","111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","fast, pure Python",[128,158,160],{"transform":159},"translate(38.5,99.5) scale(0.625)",[132,161],{"d":162,"fill":135,"stroke":163,"style":164},"M5 12.5l4.5 4.5L19 7","var(--teal,#0f9488)","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[139,166,168],{"x":153,"y":167,"style":155},"137.0","exact control of the page",[128,170,172],{"transform":171},"translate(38.5,125.5) scale(0.625)",[132,173],{"d":162,"fill":135,"stroke":163,"style":164},[139,175,177],{"x":153,"y":176,"style":155},"163.0","layout lives in code",[128,179,181],{"transform":180},"translate(38.5,151.5) scale(0.625)",[132,182],{"d":183,"fill":135,"stroke":184,"style":164},"M6 6l12 12 M18 6L6 18","var(--accent,#f43f8f)",[110,186],{"x":187,"y":120,"width":121,"height":122,"rx":123,"fill":188,"stroke":163,"style":126},"389.0","#d9f4f1",[128,190,192],{"transform":191},"translate(551.5,29.0) scale(1.083)",[132,193],{"d":194,"fill":135,"stroke":195,"style":137},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","var(--teal-ink,#0b6157)",[139,197,200],{"x":198,"y":142,"style":199},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Fill a template + LibreOffice",[37,202],{"x1":203,"y1":148,"x2":204,"y2":148,"stroke":163,"style":150},"405.0","724.0",[139,206,208],{"x":207,"y":154,"style":155},"429.0","layout edited in Excel",[128,210,212],{"transform":211},"translate(407.5,99.5) scale(0.625)",[132,213],{"d":162,"fill":135,"stroke":163,"style":164},[139,215,216],{"x":207,"y":167,"style":155},"number formats from the sheet",[128,218,220],{"transform":219},"translate(407.5,125.5) scale(0.625)",[132,221],{"d":162,"fill":135,"stroke":163,"style":164},[139,223,224],{"x":207,"y":176,"style":155},"needs LibreOffice, slower",[128,226,228],{"transform":227},"translate(407.5,151.5) scale(0.625)",[132,229],{"d":230,"fill":135,"stroke":231,"style":164},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","var(--gold,#b4740a)",[20,233,235],{"id":234},"step-1-safe-unique-file-names","Step 1 — Safe, unique file names",[10,237,238,239,242,243,242,246,249,250,253],{},"File names come from the data, so clean them first: characters such as ",[32,240,241],{},"\u002F",", ",[32,244,245],{},"\\",[32,247,248],{},":"," and ",[32,251,252],{},"*"," are not allowed on one operating system or another, and two customers with the same name would overwrite each other. Build the name from the ID, which is unique, plus a readable slug:",[25,255,259],{"className":256,"code":257,"language":258,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","import re\nimport pandas as pd\n\nrows = pd.read_excel(\"statements.xlsx\", parse_dates=[\"DueDate\"])\n\ndef pdf_name(row):\n    slug = re.sub(r\"[^A-Za-z0-9]+\", \"-\", row.Name).strip(\"-\").lower()\n    return f\"statement_{row.CustomerID}_{slug}.pdf\"\n\nrows[\"File\"] = [pdf_name(r) for r in rows.itertuples()]\nassert rows[\"File\"].is_unique\nprint(rows[[\"CustomerID\", \"Name\", \"File\"]].to_string(index=False))\n","python",[32,260,261,271,284,290,322,327,340,388,422,427,456,470],{"__ignoreMap":30},[35,262,263,267],{"class":37,"line":38},[35,264,266],{"class":265},"s-kum","import",[35,268,270],{"class":269},"skGVy"," re\n",[35,272,273,275,278,281],{"class":37,"line":61},[35,274,266],{"class":265},[35,276,277],{"class":269}," pandas ",[35,279,280],{"class":265},"as",[35,282,283],{"class":269}," pd\n",[35,285,286],{"class":37,"line":68},[35,287,289],{"emptyLinePlaceholder":288},true,"\n",[35,291,293,296,299,302,305,307,311,313,316,319],{"class":37,"line":292},4,[35,294,295],{"class":269},"rows ",[35,297,298],{"class":265},"=",[35,300,301],{"class":269}," pd.read_excel(",[35,303,304],{"class":45},"\"statements.xlsx\"",[35,306,242],{"class":269},[35,308,310],{"class":309},"sa561","parse_dates",[35,312,298],{"class":265},[35,314,315],{"class":269},"[",[35,317,318],{"class":45},"\"DueDate\"",[35,320,321],{"class":269},"])\n",[35,323,325],{"class":37,"line":324},5,[35,326,289],{"emptyLinePlaceholder":288},[35,328,330,333,337],{"class":37,"line":329},6,[35,331,332],{"class":265},"def",[35,334,336],{"class":335},"s_Opv"," pdf_name",[35,338,339],{"class":269},"(row):\n",[35,341,343,346,348,351,354,357,360,363,367,370,373,375,377,380,383,385],{"class":37,"line":342},7,[35,344,345],{"class":269},"    slug ",[35,347,298],{"class":265},[35,349,350],{"class":269}," re.sub(",[35,352,353],{"class":265},"r",[35,355,356],{"class":45},"\"",[35,358,315],{"class":359},"sP0c6",[35,361,362],{"class":265},"^",[35,364,366],{"class":365},"sSjpA","A-Za-z0-9",[35,368,369],{"class":359},"]",[35,371,372],{"class":265},"+",[35,374,356],{"class":45},[35,376,242],{"class":269},[35,378,379],{"class":45},"\"-\"",[35,381,382],{"class":269},", row.Name).strip(",[35,384,379],{"class":45},[35,386,387],{"class":269},").lower()\n",[35,389,391,394,397,400,403,406,409,412,414,417,419],{"class":37,"line":390},8,[35,392,393],{"class":265},"    return",[35,395,396],{"class":265}," f",[35,398,399],{"class":45},"\"statement_",[35,401,402],{"class":365},"{",[35,404,405],{"class":269},"row.CustomerID",[35,407,408],{"class":365},"}",[35,410,411],{"class":45},"_",[35,413,402],{"class":365},[35,415,416],{"class":269},"slug",[35,418,408],{"class":365},[35,420,421],{"class":45},".pdf\"\n",[35,423,425],{"class":37,"line":424},9,[35,426,289],{"emptyLinePlaceholder":288},[35,428,430,433,436,439,441,444,447,450,453],{"class":37,"line":429},10,[35,431,432],{"class":269},"rows[",[35,434,435],{"class":45},"\"File\"",[35,437,438],{"class":269},"] ",[35,440,298],{"class":265},[35,442,443],{"class":269}," [pdf_name(r) ",[35,445,446],{"class":265},"for",[35,448,449],{"class":269}," r ",[35,451,452],{"class":265},"in",[35,454,455],{"class":269}," rows.itertuples()]\n",[35,457,459,462,465,467],{"class":37,"line":458},11,[35,460,461],{"class":265},"assert",[35,463,464],{"class":269}," rows[",[35,466,435],{"class":45},[35,468,469],{"class":269},"].is_unique\n",[35,471,473,476,479,482,484,487,489,491,494,497,499,502],{"class":37,"line":472},12,[35,474,475],{"class":359},"print",[35,477,478],{"class":269},"(rows[[",[35,480,481],{"class":45},"\"CustomerID\"",[35,483,242],{"class":269},[35,485,486],{"class":45},"\"Name\"",[35,488,242],{"class":269},[35,490,435],{"class":45},[35,492,493],{"class":269},"]].to_string(",[35,495,496],{"class":309},"index",[35,498,298],{"class":265},[35,500,501],{"class":359},"False",[35,503,504],{"class":269},"))\n",[10,506,507,510,511,514],{},[32,508,509],{},"Evans \u002F Partners"," becomes ",[32,512,513],{},"evans-partners",", and the customer ID in front guarantees uniqueness even if two customers share a name. The assertion makes that guarantee explicit and stops the run before any file is written if it ever fails.",[20,516,518],{"id":517},"step-2-route-a-draw-each-pdf-with-reportlab","Step 2 — Route A: draw each PDF with reportlab",[10,520,521,522,526],{},"reportlab's ",[523,524,525],"em",{},"platypus"," layer lays out paragraphs and tables for you. Build one document per row:",[25,528,530],{"className":256,"code":529,"language":258,"meta":30,"style":30},"from pathlib import Path\nfrom reportlab.lib import colors\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.lib.styles import getSampleStyleSheet\nfrom reportlab.lib.units import mm\nfrom reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle\n\nstyles = getSampleStyleSheet()\nout = Path(\"pdf_reportlab\")\nout.mkdir(exist_ok=True)\n\ndef statement_pdf(row, path):\n    doc = SimpleDocTemplate(str(path), pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=20 * mm)\n    table = Table([\n        [\"Customer\", f\"{row.Name} ({row.CustomerID})\"],\n        [\"Address\", row.Address],\n        [\"Balance due\", f\"£{row.Balance:,.2f}\"],\n        [\"Due date\", f\"{row.DueDate:%-d %B %Y}\"],\n    ], colWidths=[40 * mm, 120 * mm])\n    table.setStyle(TableStyle([(\"FONTNAME\", (0, 0), (0, -1), \"Helvetica-Bold\"),\n                               (\"LINEBELOW\", (0, 0), (-1, -1), 0.25, colors.lightgrey),\n                               (\"BOTTOMPADDING\", (0, 0), (-1, -1), 6)]))\n    note = (\"No payment is due this month.\" if row.Balance == 0\n            else \"Please pay by bank transfer quoting your customer number.\")\n    doc.build([Paragraph(\"Statement of account\", styles[\"Title\"]), Spacer(1, 8 * mm),\n               table, Spacer(1, 8 * mm), Paragraph(note, styles[\"Normal\"])])\n\nfor row in rows.itertuples():\n    statement_pdf(row, out \u002F row.File)\nprint(sorted(p.name for p in out.iterdir()))\n",[32,531,532,545,557,569,581,593,605,609,619,635,650,654,664,726,737,775,786,815,857,885,925,962,998,1023,1034,1064,1087,1092,1105,1116],{"__ignoreMap":30},[35,533,534,537,540,542],{"class":37,"line":38},[35,535,536],{"class":265},"from",[35,538,539],{"class":269}," pathlib ",[35,541,266],{"class":265},[35,543,544],{"class":269}," Path\n",[35,546,547,549,552,554],{"class":37,"line":61},[35,548,536],{"class":265},[35,550,551],{"class":269}," reportlab.lib ",[35,553,266],{"class":265},[35,555,556],{"class":269}," colors\n",[35,558,559,561,564,566],{"class":37,"line":68},[35,560,536],{"class":265},[35,562,563],{"class":269}," reportlab.lib.pagesizes ",[35,565,266],{"class":265},[35,567,568],{"class":269}," A4\n",[35,570,571,573,576,578],{"class":37,"line":292},[35,572,536],{"class":265},[35,574,575],{"class":269}," reportlab.lib.styles ",[35,577,266],{"class":265},[35,579,580],{"class":269}," getSampleStyleSheet\n",[35,582,583,585,588,590],{"class":37,"line":324},[35,584,536],{"class":265},[35,586,587],{"class":269}," reportlab.lib.units ",[35,589,266],{"class":265},[35,591,592],{"class":269}," mm\n",[35,594,595,597,600,602],{"class":37,"line":329},[35,596,536],{"class":265},[35,598,599],{"class":269}," reportlab.platypus ",[35,601,266],{"class":265},[35,603,604],{"class":269}," Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle\n",[35,606,607],{"class":37,"line":342},[35,608,289],{"emptyLinePlaceholder":288},[35,610,611,614,616],{"class":37,"line":390},[35,612,613],{"class":269},"styles ",[35,615,298],{"class":265},[35,617,618],{"class":269}," getSampleStyleSheet()\n",[35,620,621,624,626,629,632],{"class":37,"line":424},[35,622,623],{"class":269},"out ",[35,625,298],{"class":265},[35,627,628],{"class":269}," Path(",[35,630,631],{"class":45},"\"pdf_reportlab\"",[35,633,634],{"class":269},")\n",[35,636,637,640,643,645,648],{"class":37,"line":429},[35,638,639],{"class":269},"out.mkdir(",[35,641,642],{"class":309},"exist_ok",[35,644,298],{"class":265},[35,646,647],{"class":359},"True",[35,649,634],{"class":269},[35,651,652],{"class":37,"line":458},[35,653,289],{"emptyLinePlaceholder":288},[35,655,656,658,661],{"class":37,"line":472},[35,657,332],{"class":265},[35,659,660],{"class":335}," statement_pdf",[35,662,663],{"class":269},"(row, path):\n",[35,665,667,670,672,675,678,681,684,686,689,692,694,697,700,703,706,708,710,712,714,717,719,721,723],{"class":37,"line":666},13,[35,668,669],{"class":269},"    doc ",[35,671,298],{"class":265},[35,673,674],{"class":269}," SimpleDocTemplate(",[35,676,677],{"class":359},"str",[35,679,680],{"class":269},"(path), ",[35,682,683],{"class":309},"pagesize",[35,685,298],{"class":265},[35,687,688],{"class":269},"A4, ",[35,690,691],{"class":309},"leftMargin",[35,693,298],{"class":265},[35,695,696],{"class":359},"20",[35,698,699],{"class":265}," *",[35,701,702],{"class":269}," mm, ",[35,704,705],{"class":309},"rightMargin",[35,707,298],{"class":265},[35,709,696],{"class":359},[35,711,699],{"class":265},[35,713,702],{"class":269},[35,715,716],{"class":309},"topMargin",[35,718,298],{"class":265},[35,720,696],{"class":359},[35,722,699],{"class":265},[35,724,725],{"class":269}," mm)\n",[35,727,729,732,734],{"class":37,"line":728},14,[35,730,731],{"class":269},"    table ",[35,733,298],{"class":265},[35,735,736],{"class":269}," Table([\n",[35,738,740,743,746,748,751,753,755,758,760,763,765,767,769,772],{"class":37,"line":739},15,[35,741,742],{"class":269},"        [",[35,744,745],{"class":45},"\"Customer\"",[35,747,242],{"class":269},[35,749,750],{"class":265},"f",[35,752,356],{"class":45},[35,754,402],{"class":365},[35,756,757],{"class":269},"row.Name",[35,759,408],{"class":365},[35,761,762],{"class":45}," (",[35,764,402],{"class":365},[35,766,405],{"class":269},[35,768,408],{"class":365},[35,770,771],{"class":45},")\"",[35,773,774],{"class":269},"],\n",[35,776,778,780,783],{"class":37,"line":777},16,[35,779,742],{"class":269},[35,781,782],{"class":45},"\"Address\"",[35,784,785],{"class":269},", row.Address],\n",[35,787,789,791,794,796,798,801,803,806,809,811,813],{"class":37,"line":788},17,[35,790,742],{"class":269},[35,792,793],{"class":45},"\"Balance due\"",[35,795,242],{"class":269},[35,797,750],{"class":265},[35,799,800],{"class":45},"\"£",[35,802,402],{"class":365},[35,804,805],{"class":269},"row.Balance",[35,807,808],{"class":265},":,.2f",[35,810,408],{"class":365},[35,812,356],{"class":45},[35,814,774],{"class":269},[35,816,818,820,823,825,827,829,831,834,837,840,843,846,848,851,853,855],{"class":37,"line":817},18,[35,819,742],{"class":269},[35,821,822],{"class":45},"\"Due date\"",[35,824,242],{"class":269},[35,826,750],{"class":265},[35,828,356],{"class":45},[35,830,402],{"class":365},[35,832,833],{"class":269},"row.DueDate:",[35,835,836],{"class":265},"%-",[35,838,839],{"class":269},"d ",[35,841,842],{"class":265},"%",[35,844,845],{"class":269},"B ",[35,847,842],{"class":265},[35,849,850],{"class":269},"Y",[35,852,408],{"class":365},[35,854,356],{"class":45},[35,856,774],{"class":269},[35,858,860,863,866,868,870,873,875,877,880,882],{"class":37,"line":859},19,[35,861,862],{"class":269},"    ], ",[35,864,865],{"class":309},"colWidths",[35,867,298],{"class":265},[35,869,315],{"class":269},[35,871,872],{"class":359},"40",[35,874,699],{"class":265},[35,876,702],{"class":269},[35,878,879],{"class":359},"120",[35,881,699],{"class":265},[35,883,884],{"class":269}," mm])\n",[35,886,888,891,894,897,899,901,903,906,908,910,913,916,919,922],{"class":37,"line":887},20,[35,889,890],{"class":269},"    table.setStyle(TableStyle([(",[35,892,893],{"class":45},"\"FONTNAME\"",[35,895,896],{"class":269},", (",[35,898,113],{"class":359},[35,900,242],{"class":269},[35,902,113],{"class":359},[35,904,905],{"class":269},"), (",[35,907,113],{"class":359},[35,909,242],{"class":269},[35,911,912],{"class":265},"-",[35,914,915],{"class":359},"1",[35,917,918],{"class":269},"), ",[35,920,921],{"class":45},"\"Helvetica-Bold\"",[35,923,924],{"class":269},"),\n",[35,926,928,931,934,936,938,940,942,944,946,948,950,952,954,956,959],{"class":37,"line":927},21,[35,929,930],{"class":269},"                               (",[35,932,933],{"class":45},"\"LINEBELOW\"",[35,935,896],{"class":269},[35,937,113],{"class":359},[35,939,242],{"class":269},[35,941,113],{"class":359},[35,943,905],{"class":269},[35,945,912],{"class":265},[35,947,915],{"class":359},[35,949,242],{"class":269},[35,951,912],{"class":265},[35,953,915],{"class":359},[35,955,918],{"class":269},[35,957,958],{"class":359},"0.25",[35,960,961],{"class":269},", colors.lightgrey),\n",[35,963,965,967,970,972,974,976,978,980,982,984,986,988,990,992,995],{"class":37,"line":964},22,[35,966,930],{"class":269},[35,968,969],{"class":45},"\"BOTTOMPADDING\"",[35,971,896],{"class":269},[35,973,113],{"class":359},[35,975,242],{"class":269},[35,977,113],{"class":359},[35,979,905],{"class":269},[35,981,912],{"class":265},[35,983,915],{"class":359},[35,985,242],{"class":269},[35,987,912],{"class":265},[35,989,915],{"class":359},[35,991,918],{"class":269},[35,993,994],{"class":359},"6",[35,996,997],{"class":269},")]))\n",[35,999,1001,1004,1006,1008,1011,1014,1017,1020],{"class":37,"line":1000},23,[35,1002,1003],{"class":269},"    note ",[35,1005,298],{"class":265},[35,1007,762],{"class":269},[35,1009,1010],{"class":45},"\"No payment is due this month.\"",[35,1012,1013],{"class":265}," if",[35,1015,1016],{"class":269}," row.Balance ",[35,1018,1019],{"class":265},"==",[35,1021,1022],{"class":359}," 0\n",[35,1024,1026,1029,1032],{"class":37,"line":1025},24,[35,1027,1028],{"class":265},"            else",[35,1030,1031],{"class":45}," \"Please pay by bank transfer quoting your customer number.\"",[35,1033,634],{"class":269},[35,1035,1037,1040,1043,1046,1049,1052,1054,1056,1059,1061],{"class":37,"line":1036},25,[35,1038,1039],{"class":269},"    doc.build([Paragraph(",[35,1041,1042],{"class":45},"\"Statement of account\"",[35,1044,1045],{"class":269},", styles[",[35,1047,1048],{"class":45},"\"Title\"",[35,1050,1051],{"class":269},"]), Spacer(",[35,1053,915],{"class":359},[35,1055,242],{"class":269},[35,1057,1058],{"class":359},"8",[35,1060,699],{"class":265},[35,1062,1063],{"class":269}," mm),\n",[35,1065,1067,1070,1072,1074,1076,1078,1081,1084],{"class":37,"line":1066},26,[35,1068,1069],{"class":269},"               table, Spacer(",[35,1071,915],{"class":359},[35,1073,242],{"class":269},[35,1075,1058],{"class":359},[35,1077,699],{"class":265},[35,1079,1080],{"class":269}," mm), Paragraph(note, styles[",[35,1082,1083],{"class":45},"\"Normal\"",[35,1085,1086],{"class":269},"])])\n",[35,1088,1090],{"class":37,"line":1089},27,[35,1091,289],{"emptyLinePlaceholder":288},[35,1093,1095,1097,1100,1102],{"class":37,"line":1094},28,[35,1096,446],{"class":265},[35,1098,1099],{"class":269}," row ",[35,1101,452],{"class":265},[35,1103,1104],{"class":269}," rows.itertuples():\n",[35,1106,1108,1111,1113],{"class":37,"line":1107},29,[35,1109,1110],{"class":269},"    statement_pdf(row, out ",[35,1112,241],{"class":265},[35,1114,1115],{"class":269}," row.File)\n",[35,1117,1119,1121,1124,1127,1130,1132,1135,1137],{"class":37,"line":1118},30,[35,1120,475],{"class":359},[35,1122,1123],{"class":269},"(",[35,1125,1126],{"class":359},"sorted",[35,1128,1129],{"class":269},"(p.name ",[35,1131,446],{"class":265},[35,1133,1134],{"class":269}," p ",[35,1136,452],{"class":265},[35,1138,1139],{"class":269}," out.iterdir()))\n",[10,1141,1142,1143,1146,1147,1150,1151,1154,1155,1158,1159,1162],{},"The zero-balance customer, Dunmore, gets a different closing line — the kind of per-row rule that is trivial in code and awkward in a mail merge. Text in ",[32,1144,1145],{},"Paragraph"," and table cells is plain strings here; if the data can contain ",[32,1148,1149],{},"&"," or ",[32,1152,1153],{},"\u003C",", which reportlab's paragraph markup treats specially, escape it with ",[32,1156,1157],{},"html.escape"," first. ",[32,1160,1161],{},"Birch & Co"," works in the table because table cells are not parsed as markup.",[20,1164,1166],{"id":1165},"step-3-check-every-pdf-says-what-it-should","Step 3 — Check every PDF says what it should",[10,1168,1169],{},"A batch of PDFs is only useful if each one contains the right recipient and figure. Extract the text of each file with pypdf and compare it with its row:",[25,1171,1173],{"className":256,"code":1172,"language":258,"meta":30,"style":30},"from pypdf import PdfReader\n\ndef check(folder):\n    problems = []\n    for row in rows.itertuples():\n        text = \" \".join(page.extract_text() for page in PdfReader(folder \u002F row.File).pages)\n        for expected in (row.CustomerID, f\"{row.Balance:,.2f}\"):\n            if expected not in text:\n                problems.append((row.File, expected))\n    return problems\n\nprint(\"reportlab PDFs:\", check(out) or \"all correct\")\n",[32,1174,1175,1187,1191,1201,1211,1222,1250,1280,1296,1301,1308,1312],{"__ignoreMap":30},[35,1176,1177,1179,1182,1184],{"class":37,"line":38},[35,1178,536],{"class":265},[35,1180,1181],{"class":269}," pypdf ",[35,1183,266],{"class":265},[35,1185,1186],{"class":269}," PdfReader\n",[35,1188,1189],{"class":37,"line":61},[35,1190,289],{"emptyLinePlaceholder":288},[35,1192,1193,1195,1198],{"class":37,"line":68},[35,1194,332],{"class":265},[35,1196,1197],{"class":335}," check",[35,1199,1200],{"class":269},"(folder):\n",[35,1202,1203,1206,1208],{"class":37,"line":292},[35,1204,1205],{"class":269},"    problems ",[35,1207,298],{"class":265},[35,1209,1210],{"class":269}," []\n",[35,1212,1213,1216,1218,1220],{"class":37,"line":324},[35,1214,1215],{"class":265},"    for",[35,1217,1099],{"class":269},[35,1219,452],{"class":265},[35,1221,1104],{"class":269},[35,1223,1224,1227,1229,1232,1235,1237,1240,1242,1245,1247],{"class":37,"line":329},[35,1225,1226],{"class":269},"        text ",[35,1228,298],{"class":265},[35,1230,1231],{"class":45}," \" \"",[35,1233,1234],{"class":269},".join(page.extract_text() ",[35,1236,446],{"class":265},[35,1238,1239],{"class":269}," page ",[35,1241,452],{"class":265},[35,1243,1244],{"class":269}," PdfReader(folder ",[35,1246,241],{"class":265},[35,1248,1249],{"class":269}," row.File).pages)\n",[35,1251,1252,1255,1258,1260,1263,1265,1267,1269,1271,1273,1275,1277],{"class":37,"line":342},[35,1253,1254],{"class":265},"        for",[35,1256,1257],{"class":269}," expected ",[35,1259,452],{"class":265},[35,1261,1262],{"class":269}," (row.CustomerID, ",[35,1264,750],{"class":265},[35,1266,356],{"class":45},[35,1268,402],{"class":365},[35,1270,805],{"class":269},[35,1272,808],{"class":265},[35,1274,408],{"class":365},[35,1276,356],{"class":45},[35,1278,1279],{"class":269},"):\n",[35,1281,1282,1285,1287,1290,1293],{"class":37,"line":390},[35,1283,1284],{"class":265},"            if",[35,1286,1257],{"class":269},[35,1288,1289],{"class":265},"not",[35,1291,1292],{"class":265}," in",[35,1294,1295],{"class":269}," text:\n",[35,1297,1298],{"class":37,"line":424},[35,1299,1300],{"class":269},"                problems.append((row.File, expected))\n",[35,1302,1303,1305],{"class":37,"line":429},[35,1304,393],{"class":265},[35,1306,1307],{"class":269}," problems\n",[35,1309,1310],{"class":37,"line":458},[35,1311,289],{"emptyLinePlaceholder":288},[35,1313,1314,1316,1318,1321,1324,1327,1330],{"class":37,"line":472},[35,1315,475],{"class":359},[35,1317,1123],{"class":269},[35,1319,1320],{"class":45},"\"reportlab PDFs:\"",[35,1322,1323],{"class":269},", check(out) ",[35,1325,1326],{"class":265},"or",[35,1328,1329],{"class":45}," \"all correct\"",[35,1331,634],{"class":269},[10,1333,1334],{},"This check catches the failures that matter in a statement run, and it is cheap enough to run on every batch, not just the first one: a customer's PDF showing another customer's balance because of an indexing bug, or a missing figure because a column was renamed. It runs in well under a second for hundreds of files.",[91,1336,100,1341,100,1344,100,1347,100,1350,100,1357,100,1365,100,1371,100,1376,100,1381,100,1386,100,1389,100,1395,100,1400,100,1403,100,1406,100,1409,100,1412,100,1418,100,1422,100,1425,100,1429,100,1432,100,1435,100,1440,100,1446,100,1450],{"viewBox":1337,"role":94,"ariaLabelledBy":1338,"xmlns":98,"style":99},"4 0 752 142",[1339,1340],"pdf-check-t","pdf-check-d",[102,1342,1343],{"id":1339},"The check after generation",[106,1345,1346],{"id":1340},"For each row, the matching PDF is opened with pypdf, its text extracted, and the customer ID and formatted balance looked for; any row whose values are missing from its PDF is reported.",[110,1348],{"x":112,"y":113,"width":114,"height":1349,"fill":116},"142",[110,1351],{"x":119,"y":120,"width":1352,"height":1353,"rx":1354,"fill":1355,"stroke":1356,"style":126},"153.0","110.5","12","#e7ebef","var(--line,#cdd5e6)",[128,1358,1360],{"transform":1359},"translate(84.5,37.8) scale(1.000)",[132,1361],{"d":1362,"fill":135,"stroke":1363,"style":1364},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[139,1366,1370],{"x":1367,"y":1368,"style":1369},"96.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Row",[139,1372,1375],{"x":1367,"y":1373,"style":1374},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","C-1003, 4,960.75",[37,1377],{"x1":1378,"y1":1379,"x2":1380,"y2":1379,"stroke":125,"style":126},"178.0","71.2","200.0",[1382,1383],"polygon",{"points":1384,"fill":1385},"204.0,71.2 195.0,76.2 195.0,66.2","#5b5cf0",[110,1387],{"x":1388,"y":120,"width":1352,"height":1353,"rx":1354,"fill":124,"stroke":125,"style":126},"209.0",[128,1390,1392],{"transform":1391},"translate(273.5,37.8) scale(1.000)",[132,1393],{"d":1394,"fill":135,"stroke":136,"style":1364},"M6 3h9l4 4v14H6z M15 3v4h4 M9 12h6 M9 15.5h6 M9 19h4",[139,1396,1399],{"x":1397,"y":1368,"style":1398},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Its PDF",[139,1401,1402],{"x":1397,"y":1373,"style":1374},"statement_C-1003_….pdf",[37,1404],{"x1":1405,"y1":1379,"x2":187,"y2":1379,"stroke":125,"style":126},"367.0",[1382,1407],{"points":1408,"fill":1385},"393.0,71.2 384.0,76.2 384.0,66.2",[110,1410],{"x":1411,"y":120,"width":1352,"height":1353,"rx":1354,"fill":124,"stroke":125,"style":126},"398.0",[128,1413,1415],{"transform":1414},"translate(462.5,37.8) scale(1.000)",[132,1416],{"d":1417,"fill":135,"stroke":136,"style":1364},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5",[139,1419,1421],{"x":1420,"y":1368,"style":1398},"474.5","Extract text",[139,1423,1424],{"x":1420,"y":1373,"style":1374},"pypdf",[37,1426],{"x1":1427,"y1":1379,"x2":1428,"y2":1379,"stroke":125,"style":126},"556.0","578.0",[1382,1430],{"points":1431,"fill":1385},"582.0,71.2 573.0,76.2 573.0,66.2",[110,1433],{"x":1434,"y":120,"width":1352,"height":1353,"rx":1354,"fill":188,"stroke":163,"style":126},"587.0",[128,1436,1438],{"transform":1437},"translate(651.5,29.0) scale(1.000)",[132,1439],{"d":162,"fill":135,"stroke":195,"style":1364},[139,1441,1445],{"x":1442,"y":1443,"style":1444},"663.5","75.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Compare",[139,1447,1449],{"x":1442,"y":1448,"style":1374},"95.0","ID and amount",[139,1451,1453],{"x":1442,"y":1452,"style":1374},"112.5","present?",[20,1455,1457],{"id":1456},"step-4-route-b-fill-an-excel-template-per-row","Step 4 — Route B: fill an Excel template per row",[10,1459,1460],{},"When the layout should be maintained in Excel — by the finance team, say — fill a copy of the template for each row with openpyxl, then convert all of them to PDF in one LibreOffice run:",[25,1462,1464],{"className":256,"code":1463,"language":258,"meta":30,"style":30},"import subprocess\nfrom openpyxl import load_workbook\n\nxl_dir, pdf_dir = Path(\"filled\"), Path(\"pdf_template\")\nxl_dir.mkdir(exist_ok=True)\npdf_dir.mkdir(exist_ok=True)\nfor row in rows.itertuples():\n    wb = load_workbook(\"statement_template.xlsx\")\n    ws = wb[\"Statement\"]\n    ws[\"B3\"], ws[\"B4\"] = f\"{row.Name} ({row.CustomerID})\", row.Address\n    ws[\"B6\"], ws[\"B7\"] = row.Balance, row.DueDate.to_pydatetime()\n    if row.Balance == 0:\n        ws[\"A9\"] = \"No payment is due this month.\"\n    wb.save(xl_dir \u002F row.File.replace(\".pdf\", \".xlsx\"))\n\nsubprocess.run([\"soffice\", f\"-env:UserInstallation=file:\u002F\u002F{Path.cwd()}\u002Flo_profile\", \"--headless\",\n                \"--convert-to\", \"pdf\", \"--outdir\", str(pdf_dir), *map(str, sorted(xl_dir.glob(\"*.xlsx\")))],\n               check=True, capture_output=True, timeout=300)\nprint(sorted(p.name for p in pdf_dir.iterdir()))\nprint(\"template PDFs:\", check(pdf_dir) or \"all correct\")\n",[32,1465,1466,1473,1485,1489,1509,1522,1535,1545,1560,1576,1617,1636,1651,1666,1686,1690,1723,1767,1797,1816],{"__ignoreMap":30},[35,1467,1468,1470],{"class":37,"line":38},[35,1469,266],{"class":265},[35,1471,1472],{"class":269}," subprocess\n",[35,1474,1475,1477,1480,1482],{"class":37,"line":61},[35,1476,536],{"class":265},[35,1478,1479],{"class":269}," openpyxl ",[35,1481,266],{"class":265},[35,1483,1484],{"class":269}," load_workbook\n",[35,1486,1487],{"class":37,"line":68},[35,1488,289],{"emptyLinePlaceholder":288},[35,1490,1491,1494,1496,1498,1501,1504,1507],{"class":37,"line":292},[35,1492,1493],{"class":269},"xl_dir, pdf_dir ",[35,1495,298],{"class":265},[35,1497,628],{"class":269},[35,1499,1500],{"class":45},"\"filled\"",[35,1502,1503],{"class":269},"), Path(",[35,1505,1506],{"class":45},"\"pdf_template\"",[35,1508,634],{"class":269},[35,1510,1511,1514,1516,1518,1520],{"class":37,"line":324},[35,1512,1513],{"class":269},"xl_dir.mkdir(",[35,1515,642],{"class":309},[35,1517,298],{"class":265},[35,1519,647],{"class":359},[35,1521,634],{"class":269},[35,1523,1524,1527,1529,1531,1533],{"class":37,"line":329},[35,1525,1526],{"class":269},"pdf_dir.mkdir(",[35,1528,642],{"class":309},[35,1530,298],{"class":265},[35,1532,647],{"class":359},[35,1534,634],{"class":269},[35,1536,1537,1539,1541,1543],{"class":37,"line":342},[35,1538,446],{"class":265},[35,1540,1099],{"class":269},[35,1542,452],{"class":265},[35,1544,1104],{"class":269},[35,1546,1547,1550,1552,1555,1558],{"class":37,"line":390},[35,1548,1549],{"class":269},"    wb ",[35,1551,298],{"class":265},[35,1553,1554],{"class":269}," load_workbook(",[35,1556,1557],{"class":45},"\"statement_template.xlsx\"",[35,1559,634],{"class":269},[35,1561,1562,1565,1567,1570,1573],{"class":37,"line":424},[35,1563,1564],{"class":269},"    ws ",[35,1566,298],{"class":265},[35,1568,1569],{"class":269}," wb[",[35,1571,1572],{"class":45},"\"Statement\"",[35,1574,1575],{"class":269},"]\n",[35,1577,1578,1581,1584,1587,1590,1592,1594,1596,1598,1600,1602,1604,1606,1608,1610,1612,1614],{"class":37,"line":429},[35,1579,1580],{"class":269},"    ws[",[35,1582,1583],{"class":45},"\"B3\"",[35,1585,1586],{"class":269},"], ws[",[35,1588,1589],{"class":45},"\"B4\"",[35,1591,438],{"class":269},[35,1593,298],{"class":265},[35,1595,396],{"class":265},[35,1597,356],{"class":45},[35,1599,402],{"class":365},[35,1601,757],{"class":269},[35,1603,408],{"class":365},[35,1605,762],{"class":45},[35,1607,402],{"class":365},[35,1609,405],{"class":269},[35,1611,408],{"class":365},[35,1613,771],{"class":45},[35,1615,1616],{"class":269},", row.Address\n",[35,1618,1619,1621,1624,1626,1629,1631,1633],{"class":37,"line":458},[35,1620,1580],{"class":269},[35,1622,1623],{"class":45},"\"B6\"",[35,1625,1586],{"class":269},[35,1627,1628],{"class":45},"\"B7\"",[35,1630,438],{"class":269},[35,1632,298],{"class":265},[35,1634,1635],{"class":269}," row.Balance, row.DueDate.to_pydatetime()\n",[35,1637,1638,1641,1643,1645,1648],{"class":37,"line":472},[35,1639,1640],{"class":265},"    if",[35,1642,1016],{"class":269},[35,1644,1019],{"class":265},[35,1646,1647],{"class":359}," 0",[35,1649,1650],{"class":269},":\n",[35,1652,1653,1656,1659,1661,1663],{"class":37,"line":666},[35,1654,1655],{"class":269},"        ws[",[35,1657,1658],{"class":45},"\"A9\"",[35,1660,438],{"class":269},[35,1662,298],{"class":265},[35,1664,1665],{"class":45}," \"No payment is due this month.\"\n",[35,1667,1668,1671,1673,1676,1679,1681,1684],{"class":37,"line":728},[35,1669,1670],{"class":269},"    wb.save(xl_dir ",[35,1672,241],{"class":265},[35,1674,1675],{"class":269}," row.File.replace(",[35,1677,1678],{"class":45},"\".pdf\"",[35,1680,242],{"class":269},[35,1682,1683],{"class":45},"\".xlsx\"",[35,1685,504],{"class":269},[35,1687,1688],{"class":37,"line":739},[35,1689,289],{"emptyLinePlaceholder":288},[35,1691,1692,1695,1698,1700,1702,1705,1707,1710,1712,1715,1717,1720],{"class":37,"line":777},[35,1693,1694],{"class":269},"subprocess.run([",[35,1696,1697],{"class":45},"\"soffice\"",[35,1699,242],{"class":269},[35,1701,750],{"class":265},[35,1703,1704],{"class":45},"\"-env:UserInstallation=file:\u002F\u002F",[35,1706,402],{"class":365},[35,1708,1709],{"class":269},"Path.cwd()",[35,1711,408],{"class":365},[35,1713,1714],{"class":45},"\u002Flo_profile\"",[35,1716,242],{"class":269},[35,1718,1719],{"class":45},"\"--headless\"",[35,1721,1722],{"class":269},",\n",[35,1724,1725,1728,1730,1733,1735,1738,1740,1742,1745,1747,1750,1752,1754,1756,1758,1761,1764],{"class":37,"line":788},[35,1726,1727],{"class":45},"                \"--convert-to\"",[35,1729,242],{"class":269},[35,1731,1732],{"class":45},"\"pdf\"",[35,1734,242],{"class":269},[35,1736,1737],{"class":45},"\"--outdir\"",[35,1739,242],{"class":269},[35,1741,677],{"class":359},[35,1743,1744],{"class":269},"(pdf_dir), ",[35,1746,252],{"class":265},[35,1748,1749],{"class":359},"map",[35,1751,1123],{"class":269},[35,1753,677],{"class":359},[35,1755,242],{"class":269},[35,1757,1126],{"class":359},[35,1759,1760],{"class":269},"(xl_dir.glob(",[35,1762,1763],{"class":45},"\"*.xlsx\"",[35,1765,1766],{"class":269},")))],\n",[35,1768,1769,1772,1774,1776,1778,1781,1783,1785,1787,1790,1792,1795],{"class":37,"line":817},[35,1770,1771],{"class":309},"               check",[35,1773,298],{"class":265},[35,1775,647],{"class":359},[35,1777,242],{"class":269},[35,1779,1780],{"class":309},"capture_output",[35,1782,298],{"class":265},[35,1784,647],{"class":359},[35,1786,242],{"class":269},[35,1788,1789],{"class":309},"timeout",[35,1791,298],{"class":265},[35,1793,1794],{"class":359},"300",[35,1796,634],{"class":269},[35,1798,1799,1801,1803,1805,1807,1809,1811,1813],{"class":37,"line":859},[35,1800,475],{"class":359},[35,1802,1123],{"class":269},[35,1804,1126],{"class":359},[35,1806,1129],{"class":269},[35,1808,446],{"class":265},[35,1810,1134],{"class":269},[35,1812,452],{"class":265},[35,1814,1815],{"class":269}," pdf_dir.iterdir()))\n",[35,1817,1818,1820,1822,1825,1828,1830,1832],{"class":37,"line":887},[35,1819,475],{"class":359},[35,1821,1123],{"class":269},[35,1823,1824],{"class":45},"\"template PDFs:\"",[35,1826,1827],{"class":269},", check(pdf_dir) ",[35,1829,1326],{"class":265},[35,1831,1329],{"class":45},[35,1833,634],{"class":269},[10,1835,1836,1837,1840,1841,1844,1845,1849,1850,1854],{},"The template's number formats do the formatting — ",[32,1838,1839],{},"£#,##0.00"," for the balance and a long date for the due date — so the Python code writes plain numbers and dates. Passing every file to one ",[32,1842,1843],{},"soffice"," call matters: LibreOffice's start-up takes about a second, so converting files one at a time costs a second each, while a batch pays it once. ",[14,1846,1848],{"href":1847},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fpopulate-excel-template-without-losing-formatting\u002F","Populate an Excel Template Without Losing Formatting"," covers the template side in depth, and ",[14,1851,1853],{"href":1852},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-to-pdf-on-linux-with-libreoffice\u002F","Convert Excel to PDF on Linux with LibreOffice"," the conversion.",[20,1856,1858],{"id":1857},"step-5-one-combined-file-for-printing","Step 5 — One combined file for printing",[10,1860,1861],{},"Individual files go to email; the print room wants one file. Merge them in customer order with pypdf:",[25,1863,1865],{"className":256,"code":1864,"language":258,"meta":30,"style":30},"from pypdf import PdfWriter\n\nmerged = PdfWriter()\nfor row in rows.itertuples():\n    merged.append(str(out \u002F row.File))\nmerged.write(\"all_statements.pdf\")\nprint(len(PdfReader(\"all_statements.pdf\").pages), \"pages in all_statements.pdf\")\n",[32,1866,1867,1878,1882,1892,1902,1917,1927],{"__ignoreMap":30},[35,1868,1869,1871,1873,1875],{"class":37,"line":38},[35,1870,536],{"class":265},[35,1872,1181],{"class":269},[35,1874,266],{"class":265},[35,1876,1877],{"class":269}," PdfWriter\n",[35,1879,1880],{"class":37,"line":61},[35,1881,289],{"emptyLinePlaceholder":288},[35,1883,1884,1887,1889],{"class":37,"line":68},[35,1885,1886],{"class":269},"merged ",[35,1888,298],{"class":265},[35,1890,1891],{"class":269}," PdfWriter()\n",[35,1893,1894,1896,1898,1900],{"class":37,"line":292},[35,1895,446],{"class":265},[35,1897,1099],{"class":269},[35,1899,452],{"class":265},[35,1901,1104],{"class":269},[35,1903,1904,1907,1909,1912,1914],{"class":37,"line":324},[35,1905,1906],{"class":269},"    merged.append(",[35,1908,677],{"class":359},[35,1910,1911],{"class":269},"(out ",[35,1913,241],{"class":265},[35,1915,1916],{"class":269}," row.File))\n",[35,1918,1919,1922,1925],{"class":37,"line":329},[35,1920,1921],{"class":269},"merged.write(",[35,1923,1924],{"class":45},"\"all_statements.pdf\"",[35,1926,634],{"class":269},[35,1928,1929,1931,1933,1936,1939,1941,1944,1947],{"class":37,"line":342},[35,1930,475],{"class":359},[35,1932,1123],{"class":269},[35,1934,1935],{"class":359},"len",[35,1937,1938],{"class":269},"(PdfReader(",[35,1940,1924],{"class":45},[35,1942,1943],{"class":269},").pages), ",[35,1945,1946],{"class":45},"\"pages in all_statements.pdf\"",[35,1948,634],{"class":269},[10,1950,1951,1954,1955,1958,1959,1963],{},[32,1952,1953],{},"append"," copies every page of each file, so multi-page statements stay together, and the loop over ",[32,1956,1957],{},"rows"," rather than over the folder keeps the printed order the same as the sheet's. ",[14,1960,1962],{"href":1961},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fmerge-excel-pdf-exports-into-one-file\u002F","Merge Excel PDF Exports into One File"," adds bookmarks per customer.",[91,1965,100,1970,100,1973,100,1976,100,1979,100,1984,100,1989,100,1993,100,1997,100,2002,100,2007,100,2013,100,2018,100,2021,100,2024,100,2027,100,2032,100,2036,100,2040,100,2043,100,2046,100,2049,100,2054,100,2058,100,2062,100,2065,100,2068,100,2071,100,2077,100,2082,100,2086,100,2089],{"viewBox":1966,"role":94,"ariaLabelledBy":1967,"xmlns":98,"style":99},"4 0 752 436",[1968,1969],"pdf-fan-t","pdf-fan-d",[102,1971,1972],{"id":1968},"One sheet, one PDF per row, one print file",[106,1974,1975],{"id":1969},"The statements sheet fans out to five individual PDFs, one per customer, which are also merged into a single file for printing.",[110,1977],{"x":112,"y":113,"width":114,"height":1978,"fill":116},"436",[110,1980],{"x":119,"y":1981,"width":1982,"height":1983,"rx":1354,"fill":1355,"stroke":1356,"style":126},"171.5","220.0","93.0",[128,1985,1987],{"transform":1986},"translate(118.0,184.5) scale(1.000)",[132,1988],{"d":1362,"fill":135,"stroke":1363,"style":1364},[139,1990,84],{"x":1991,"y":1992,"style":1369},"130.0","231.0",[139,1994,1996],{"x":1991,"y":1995,"style":1374},"250.5","one row per customer",[110,1998],{"x":1999,"y":120,"width":2000,"height":2001,"rx":1354,"fill":124,"stroke":125,"style":126},"490.0","250.0","92.0",[128,2003,2005],{"transform":2004},"translate(603.0,29.0) scale(1.000)",[132,2006],{"d":1394,"fill":135,"stroke":136,"style":1364},[139,2008,2012],{"x":2009,"y":2010,"style":2011},"615.0","75.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","statement_C-1001_arden-ltd.pdf",[139,2014,2017],{"x":2009,"y":2015,"style":2016},"94.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","emailed to Arden",[132,2019],{"d":2020,"fill":135,"stroke":125,"style":126},"M244.0,218.0 C364.0,218.0 364.0,62.0 480.0,62.0",[1382,2022],{"points":2023,"fill":1385},"484.0,62.0 475.0,67.0 475.0,57.0",[110,2025],{"x":1999,"y":2026,"width":2000,"height":2001,"rx":1354,"fill":124,"stroke":125,"style":126},"120.0",[128,2028,2030],{"transform":2029},"translate(603.0,133.0) scale(1.000)",[132,2031],{"d":1394,"fill":135,"stroke":136,"style":1364},[139,2033,2035],{"x":2009,"y":2034,"style":2011},"179.0","statement_C-1003_cobalt-gmbh.pdf",[139,2037,2039],{"x":2009,"y":2038,"style":2016},"198.0","emailed to Cobalt",[132,2041],{"d":2042,"fill":135,"stroke":125,"style":126},"M244.0,218.0 C364.0,218.0 364.0,166.0 480.0,166.0",[1382,2044],{"points":2045,"fill":1385},"484.0,166.0 475.0,171.0 475.0,161.0",[110,2047],{"x":1999,"y":2048,"width":2000,"height":2001,"rx":1354,"fill":124,"stroke":125,"style":126},"224.0",[128,2050,2052],{"transform":2051},"translate(603.0,237.0) scale(1.000)",[132,2053],{"d":1394,"fill":135,"stroke":136,"style":1364},[139,2055,2057],{"x":2009,"y":2056,"style":2011},"283.0","… one per customer",[139,2059,2061],{"x":2009,"y":2060,"style":2016},"302.0","same check on each",[132,2063],{"d":2064,"fill":135,"stroke":125,"style":126},"M244.0,218.0 C364.0,218.0 364.0,270.0 480.0,270.0",[1382,2066],{"points":2067,"fill":1385},"484.0,270.0 475.0,275.0 475.0,265.0",[110,2069],{"x":1999,"y":2070,"width":2000,"height":2001,"rx":1354,"fill":188,"stroke":163,"style":126},"328.0",[128,2072,2074],{"transform":2073},"translate(603.0,341.0) scale(1.000)",[132,2075],{"d":2076,"fill":135,"stroke":195,"style":1364},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5",[139,2078,2081],{"x":2009,"y":2079,"style":2080},"387.0","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","all_statements.pdf",[139,2083,2085],{"x":2009,"y":2084,"style":2016},"406.0","merged for printing",[132,2087],{"d":2088,"fill":135,"stroke":125,"style":126},"M244.0,218.0 C364.0,218.0 364.0,374.0 480.0,374.0",[1382,2090],{"points":2091,"fill":1385},"484.0,374.0 475.0,379.0 475.0,369.0",[20,2093,2095],{"id":2094},"performance-notes","Performance notes",[10,2097,2098,2099,2102,2103,2106],{},"The two routes differ by more than an order of magnitude. In a quick test on this machine, reportlab wrote 200 simple one-page PDFs in under a tenth of a second, while LibreOffice converted 100 filled templates in one batch call in about two and a half seconds — roughly a second of start-up plus a fraction of a second per file. For a few hundred statements a month either is fine. For tens of thousands, reportlab is the practical choice, or split the template route into several LibreOffice batches running in parallel, each with its own ",[32,2100,2101],{},"-env:UserInstallation"," profile so they do not collide. Filling the templates with openpyxl costs time too: loading the template once per row re-reads it from disk each time, which is simple and safe; for very large runs, keep the template bytes in memory and load from a ",[32,2104,2105],{},"BytesIO"," copy instead.",[20,2108,2110],{"id":2109},"common-pitfalls","Common pitfalls",[2112,2113,2114,2122,2128,2142,2148],"ul",{},[2115,2116,2117,2121],"li",{},[2118,2119,2120],"strong",{},"Unsafe file names."," Slashes and colons from the data break paths; slugify and prefix with a unique ID.",[2115,2123,2124,2127],{},[2118,2125,2126],{},"One LibreOffice call per file."," Batch every file into one conversion.",[2115,2129,2130,2133,2134,249,2136,2138,2139,2141],{},[2118,2131,2132],{},"reportlab markup characters."," ",[32,2135,1149],{},[32,2137,1153],{}," in ",[32,2140,1145],{}," text need escaping.",[2115,2143,2144,2147],{},[2118,2145,2146],{},"Fonts."," reportlab's built-in Helvetica covers Western European text; register a TrueType font for other scripts.",[2115,2149,2150,2153],{},[2118,2151,2152],{},"Sending without checking."," Extract each PDF's text and compare it with its row before anything is emailed.",[20,2155,2157],{"id":2156},"conclusion","Conclusion",[10,2159,2160],{},"Give every output a safe, unique file name built from the row's ID, then either draw each PDF with reportlab's platypus layouts or fill a copy of an Excel template per row and convert them all in one LibreOffice call. Apply per-row rules in code, extract the text of every PDF with pypdf to confirm it carries the right ID and amount, and merge the files with pypdf when someone needs a single document to print.",[20,2162,2164],{"id":2163},"frequently-asked-questions","Frequently asked questions",[10,2166,2167,2170],{},[2118,2168,2169],{},"How do I create a separate PDF for each row in Excel with Python?","\nRead the sheet with pandas and loop over the rows. For each row, either build a PDF directly with reportlab, or fill a copy of an Excel template with openpyxl and convert the copies to PDF with headless LibreOffice.",[10,2172,2173,2176],{},[2118,2174,2175],{},"Which is better, reportlab or an Excel template?","\nreportlab is faster and needs no office software, but the layout lives in code. A template keeps the layout editable in Excel by people who do not write Python, at the cost of needing LibreOffice or Excel for the conversion.",[10,2178,2179,2182],{},[2118,2180,2181],{},"How do I avoid invalid characters in the PDF file names?","\nReplace everything except letters and digits with hyphens, and prefix the name with a unique ID from the row so two rows can never produce the same file name.",[10,2184,2185,2188],{},[2118,2186,2187],{},"How can I check the generated PDFs are correct?","\nExtract each file's text with pypdf and check that the row's identifying values, such as the customer ID and the formatted amount, appear in it.",[20,2190,2192],{"id":2191},"related","Related",[2112,2194,2195,2201,2208,2215],{},[2115,2196,2197,2198,2200],{},"Up one level: ",[14,2199,17],{"href":16}," — template-driven report generation.",[2115,2202,2203,2207],{},[14,2204,2206],{"href":2205},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop\u002F","Generate One Excel Report per Region in a Loop"," — the same loop producing workbooks.",[2115,2209,2210,2214],{},[14,2211,2213],{"href":2212},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-pdf-forms-from-excel-rows-with-python\u002F","Fill PDF Forms from Excel Rows with Python"," — when the layout is a fixed PDF form.",[2115,2216,2217,2221],{},[14,2218,2220],{"href":2219},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-each-recipient-their-own-excel-report\u002F","Send Each Recipient Their Own Excel Report"," — delivering the files one by one.",[2223,2224,2225],"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 pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}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}",{"title":30,"searchDepth":61,"depth":61,"links":2227},[2228,2229,2230,2231,2232,2233,2234,2235,2236,2237,2238],{"id":22,"depth":61,"text":23},{"id":234,"depth":61,"text":235},{"id":517,"depth":61,"text":518},{"id":1165,"depth":61,"text":1166},{"id":1456,"depth":61,"text":1457},{"id":1857,"depth":61,"text":1858},{"id":2094,"depth":61,"text":2095},{"id":2109,"depth":61,"text":2110},{"id":2156,"depth":61,"text":2157},{"id":2163,"depth":61,"text":2164},{"id":2191,"depth":61,"text":2192},"2026-09-29","Turn every row of a sheet into its own PDF — customer statements, certificates, remittance advices — either drawn directly with reportlab or by filling an Excel template per row and converting all of them in one LibreOffice call; with safe file names, a combined print file, and a check that each PDF contains the right customer and amount.","md",[2243,2245,2247,2249],{"q":2169,"a":2244},"Read the sheet with pandas and loop over the rows. For each row, either build a PDF directly with reportlab, or fill a copy of an Excel template with openpyxl and convert the copies to PDF with headless LibreOffice.",{"q":2175,"a":2246},"reportlab is faster and needs no office software, but the layout lives in code. A template keeps the layout editable in Excel by people who do not write Python, at the cost of needing LibreOffice or Excel for the conversion.",{"q":2181,"a":2248},"Replace everything except letters and digits with hyphens, and prefix the name with a unique ID from the row so two rows can never produce the same file name.",{"q":2187,"a":2250},"Extract each file's text with pypdf and check that the row's identifying values, such as the customer ID and the formatted amount, appear in it.","generate one pdf per excel row",null,{"breadcrumb":2254},[2255,2257,2260],{"name":2256,"item":241},"Home",{"name":2258,"item":2259},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row",{"title":2263,"description":2264},"Create a PDF for Each Row of an Excel Sheet with Python","Generate one PDF per Excel row with Python: reportlab platypus layouts or an openpyxl-filled Excel template batch-converted with headless LibreOffice, safe unique file names, a merged PDF with pypdf, and text-extraction checks.","generate-one-pdf-per-excel-row","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row\u002Findex",[2268,2269],"pdf-export","report-layouts","how-to","VmuSqgkad9YXx9msP3mm4wV7E2KuSAGoOa3lNCfnXdM",[2273,2276],{"title":2206,"path":2274,"stem":2275,"children":-1},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop\u002Findex",{"title":2277,"path":2278,"stem":2279,"children":-1},"Generate PowerPoint Slides from Excel Data","\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-powerpoint-slides-from-excel-data","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-powerpoint-slides-from-excel-data\u002Findex",[2281,2283],{"path":2282,"title":17},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates",{"path":2284,"title":2258},"\u002Fautomating-reporting-workflows",[],[2287,2291,2294,2297,2300,2303],{"path":2288,"title":2289,"kind":2290},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf","Exporting Excel Reports to PDF","guide",{"path":2292,"title":2293,"kind":2270},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export","Add Bookmarks and a Cover Page to an Excel PDF Export",{"path":2295,"title":2296,"kind":2270},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-headers-footers-and-page-numbers-to-an-excel-pdf","Add Headers, Footers and Page Numbers to an Excel PDF",{"path":2298,"title":2299,"kind":2270},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fcheck-a-pdf-export-for-cut-off-columns","Check a PDF Export for Cut-Off Columns",{"path":2301,"title":2302,"kind":2270},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-file-to-pdf-with-python","Convert an Excel File to PDF with Python",{"path":2304,"title":1853,"kind":2270},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-to-pdf-on-linux-with-libreoffice",1790972138105]