[{"data":1,"prerenderedAt":2156},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2":3,"surround:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2":2120,"ancestors:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2":2129,"children:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2":2134,"related:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2":2135},{"id":4,"title":5,"body":6,"dateModified":2088,"datePublished":2088,"description":2089,"extension":2090,"faq":2091,"keyword":2100,"kind":2101,"meta":2102,"navigation":130,"path":2110,"seo":2111,"slug":2113,"stem":2114,"topics":2115,"type":2118,"__hash__":2119},"docs\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2\u002Findex.md","Generate an HTML Report from Excel Data with Jinja2",{"type":7,"value":8,"toc":2075},"minimark",[9,28,33,67,81,85,88,589,595,745,749,756,1011,1046,1050,1058,1274,1281,1285,1292,1526,1556,1622,1626,1629,1829,1843,1930,1934,1942,1950,1954,2000,2004,2010,2014,2020,2026,2032,2038,2042,2071],[10,11,12,13,17,18,21,22,27],"p",{},"A pandas table converted with ",[14,15,16],"code",{},"to_html()"," is a table, not a report. A report has a title and date, a row of headline figures, a section per region with its own table and chart, notes, and a consistent look from month to month. Building that HTML with string concatenation in Python gets unreadable within a page. Jinja2 separates the two jobs: Python reads the workbook and computes everything, and a template file — HTML with a few loops and placeholders — decides how it looks. The result is a single ",[14,19,20],{},".html"," file that opens in any browser, can be attached to an email or published to an intranet, and needs no server. This page builds such a report, covering escaping, number formatting, CSS bar charts, shared layouts and tests. It belongs to ",[23,24,26],"a",{"href":25},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002F","Generating Excel Reports from Templates",".",[29,30,32],"h2",{"id":31},"prerequisites","Prerequisites",[34,35,40],"pre",{"className":36,"code":37,"language":38,"meta":39,"style":39},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl jinja2 lxml\n","bash","",[14,41,42],{"__ignoreMap":39},[43,44,47,51,55,58,61,64],"span",{"class":45,"line":46},"line",1,[43,48,50],{"class":49},"sMTad","pip",[43,52,54],{"class":53},"srMev"," install",[43,56,57],{"class":53}," pandas",[43,59,60],{"class":53}," openpyxl",[43,62,63],{"class":53}," jinja2",[43,65,66],{"class":53}," lxml\n",[10,68,69,72,73,76,77,80],{},[14,70,71],{},"sales.xlsx"," has one row per region and product with units and revenue. One product name, ",[14,74,75],{},"Gloves \u003Cbulk>",", contains angle brackets — the kind of cell text that breaks naive HTML generation. ",[14,78,79],{},"lxml"," is used only in the test at the end.",[29,82,84],{"id":83},"step-1-shape-the-data-in-python","Step 1 — Shape the data in Python",[10,86,87],{},"The template should not do arithmetic. Compute every number it will show — totals, shares, the largest value for scaling bars — and pass plain Python structures:",[34,89,93],{"className":90,"code":91,"language":92,"meta":39,"style":39},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\nfrom datetime import date\n\nsales = pd.read_excel(\"sales.xlsx\", sheet_name=\"Sales\")\ntotal = sales[\"Revenue\"].sum()\n\nregions = []\nfor name, grp in sales.groupby(\"Region\", sort=False):\n    rows = grp.sort_values(\"Revenue\", ascending=False).to_dict(\"records\")\n    regions.append({\n        \"name\": name,\n        \"revenue\": grp[\"Revenue\"].sum(),\n        \"share\": grp[\"Revenue\"].sum() \u002F total,\n        \"rows\": rows,\n        \"max\": grp[\"Revenue\"].max(),\n    })\n\ncontext = {\n    \"title\": \"Monthly Sales Report\",\n    \"period\": \"September 2026\",\n    \"generated\": date(2026, 10, 2),\n    \"kpis\": [\n        {\"label\": \"Revenue\", \"value\": total, \"fmt\": \"money\"},\n        {\"label\": \"Units\", \"value\": int(sales[\"Units\"].sum()), \"fmt\": \"int\"},\n        {\"label\": \"Regions\", \"value\": sales[\"Region\"].nunique(), \"fmt\": \"int\"},\n    ],\n    \"regions\": regions,\n}\nprint([(r[\"name\"], f\"{r['share']:.1%}\") for r in regions])\n","python",[14,94,95,111,125,132,162,179,184,195,227,257,263,272,286,305,314,327,333,338,349,364,377,402,411,443,481,513,519,528,534],{"__ignoreMap":39},[43,96,97,101,105,108],{"class":45,"line":46},[43,98,100],{"class":99},"s-kum","import",[43,102,104],{"class":103},"skGVy"," pandas ",[43,106,107],{"class":99},"as",[43,109,110],{"class":103}," pd\n",[43,112,114,117,120,122],{"class":45,"line":113},2,[43,115,116],{"class":99},"from",[43,118,119],{"class":103}," datetime ",[43,121,100],{"class":99},[43,123,124],{"class":103}," date\n",[43,126,128],{"class":45,"line":127},3,[43,129,131],{"emptyLinePlaceholder":130},true,"\n",[43,133,135,138,141,144,147,150,154,156,159],{"class":45,"line":134},4,[43,136,137],{"class":103},"sales ",[43,139,140],{"class":99},"=",[43,142,143],{"class":103}," pd.read_excel(",[43,145,146],{"class":53},"\"sales.xlsx\"",[43,148,149],{"class":103},", ",[43,151,153],{"class":152},"sa561","sheet_name",[43,155,140],{"class":99},[43,157,158],{"class":53},"\"Sales\"",[43,160,161],{"class":103},")\n",[43,163,165,168,170,173,176],{"class":45,"line":164},5,[43,166,167],{"class":103},"total ",[43,169,140],{"class":99},[43,171,172],{"class":103}," sales[",[43,174,175],{"class":53},"\"Revenue\"",[43,177,178],{"class":103},"].sum()\n",[43,180,182],{"class":45,"line":181},6,[43,183,131],{"emptyLinePlaceholder":130},[43,185,187,190,192],{"class":45,"line":186},7,[43,188,189],{"class":103},"regions ",[43,191,140],{"class":99},[43,193,194],{"class":103}," []\n",[43,196,198,201,204,207,210,213,215,218,220,224],{"class":45,"line":197},8,[43,199,200],{"class":99},"for",[43,202,203],{"class":103}," name, grp ",[43,205,206],{"class":99},"in",[43,208,209],{"class":103}," sales.groupby(",[43,211,212],{"class":53},"\"Region\"",[43,214,149],{"class":103},[43,216,217],{"class":152},"sort",[43,219,140],{"class":99},[43,221,223],{"class":222},"sP0c6","False",[43,225,226],{"class":103},"):\n",[43,228,230,233,235,238,240,242,245,247,249,252,255],{"class":45,"line":229},9,[43,231,232],{"class":103},"    rows ",[43,234,140],{"class":99},[43,236,237],{"class":103}," grp.sort_values(",[43,239,175],{"class":53},[43,241,149],{"class":103},[43,243,244],{"class":152},"ascending",[43,246,140],{"class":99},[43,248,223],{"class":222},[43,250,251],{"class":103},").to_dict(",[43,253,254],{"class":53},"\"records\"",[43,256,161],{"class":103},[43,258,260],{"class":45,"line":259},10,[43,261,262],{"class":103},"    regions.append({\n",[43,264,266,269],{"class":45,"line":265},11,[43,267,268],{"class":53},"        \"name\"",[43,270,271],{"class":103},": name,\n",[43,273,275,278,281,283],{"class":45,"line":274},12,[43,276,277],{"class":53},"        \"revenue\"",[43,279,280],{"class":103},": grp[",[43,282,175],{"class":53},[43,284,285],{"class":103},"].sum(),\n",[43,287,289,292,294,296,299,302],{"class":45,"line":288},13,[43,290,291],{"class":53},"        \"share\"",[43,293,280],{"class":103},[43,295,175],{"class":53},[43,297,298],{"class":103},"].sum() ",[43,300,301],{"class":99},"\u002F",[43,303,304],{"class":103}," total,\n",[43,306,308,311],{"class":45,"line":307},14,[43,309,310],{"class":53},"        \"rows\"",[43,312,313],{"class":103},": rows,\n",[43,315,317,320,322,324],{"class":45,"line":316},15,[43,318,319],{"class":53},"        \"max\"",[43,321,280],{"class":103},[43,323,175],{"class":53},[43,325,326],{"class":103},"].max(),\n",[43,328,330],{"class":45,"line":329},16,[43,331,332],{"class":103},"    })\n",[43,334,336],{"class":45,"line":335},17,[43,337,131],{"emptyLinePlaceholder":130},[43,339,341,344,346],{"class":45,"line":340},18,[43,342,343],{"class":103},"context ",[43,345,140],{"class":99},[43,347,348],{"class":103}," {\n",[43,350,352,355,358,361],{"class":45,"line":351},19,[43,353,354],{"class":53},"    \"title\"",[43,356,357],{"class":103},": ",[43,359,360],{"class":53},"\"Monthly Sales Report\"",[43,362,363],{"class":103},",\n",[43,365,367,370,372,375],{"class":45,"line":366},20,[43,368,369],{"class":53},"    \"period\"",[43,371,357],{"class":103},[43,373,374],{"class":53},"\"September 2026\"",[43,376,363],{"class":103},[43,378,380,383,386,389,391,394,396,399],{"class":45,"line":379},21,[43,381,382],{"class":53},"    \"generated\"",[43,384,385],{"class":103},": date(",[43,387,388],{"class":222},"2026",[43,390,149],{"class":103},[43,392,393],{"class":222},"10",[43,395,149],{"class":103},[43,397,398],{"class":222},"2",[43,400,401],{"class":103},"),\n",[43,403,405,408],{"class":45,"line":404},22,[43,406,407],{"class":53},"    \"kpis\"",[43,409,410],{"class":103},": [\n",[43,412,414,417,420,422,424,426,429,432,435,437,440],{"class":45,"line":413},23,[43,415,416],{"class":103},"        {",[43,418,419],{"class":53},"\"label\"",[43,421,357],{"class":103},[43,423,175],{"class":53},[43,425,149],{"class":103},[43,427,428],{"class":53},"\"value\"",[43,430,431],{"class":103},": total, ",[43,433,434],{"class":53},"\"fmt\"",[43,436,357],{"class":103},[43,438,439],{"class":53},"\"money\"",[43,441,442],{"class":103},"},\n",[43,444,446,448,450,452,455,457,459,461,464,467,469,472,474,476,479],{"class":45,"line":445},24,[43,447,416],{"class":103},[43,449,419],{"class":53},[43,451,357],{"class":103},[43,453,454],{"class":53},"\"Units\"",[43,456,149],{"class":103},[43,458,428],{"class":53},[43,460,357],{"class":103},[43,462,463],{"class":222},"int",[43,465,466],{"class":103},"(sales[",[43,468,454],{"class":53},[43,470,471],{"class":103},"].sum()), ",[43,473,434],{"class":53},[43,475,357],{"class":103},[43,477,478],{"class":53},"\"int\"",[43,480,442],{"class":103},[43,482,484,486,488,490,493,495,497,500,502,505,507,509,511],{"class":45,"line":483},25,[43,485,416],{"class":103},[43,487,419],{"class":53},[43,489,357],{"class":103},[43,491,492],{"class":53},"\"Regions\"",[43,494,149],{"class":103},[43,496,428],{"class":53},[43,498,499],{"class":103},": sales[",[43,501,212],{"class":53},[43,503,504],{"class":103},"].nunique(), ",[43,506,434],{"class":53},[43,508,357],{"class":103},[43,510,478],{"class":53},[43,512,442],{"class":103},[43,514,516],{"class":45,"line":515},26,[43,517,518],{"class":103},"    ],\n",[43,520,522,525],{"class":45,"line":521},27,[43,523,524],{"class":53},"    \"regions\"",[43,526,527],{"class":103},": regions,\n",[43,529,531],{"class":45,"line":530},28,[43,532,533],{"class":103},"}\n",[43,535,537,540,543,546,549,552,555,559,562,565,568,571,574,576,579,581,584,586],{"class":45,"line":536},29,[43,538,539],{"class":222},"print",[43,541,542],{"class":103},"([(r[",[43,544,545],{"class":53},"\"name\"",[43,547,548],{"class":103},"], ",[43,550,551],{"class":99},"f",[43,553,554],{"class":53},"\"",[43,556,558],{"class":557},"sSjpA","{",[43,560,561],{"class":103},"r[",[43,563,564],{"class":53},"'share'",[43,566,567],{"class":103},"]",[43,569,570],{"class":99},":.1%",[43,572,573],{"class":557},"}",[43,575,554],{"class":53},[43,577,578],{"class":103},") ",[43,580,200],{"class":99},[43,582,583],{"class":103}," r ",[43,585,206],{"class":99},[43,587,588],{"class":103}," regions])\n",[10,590,591,594],{},[14,592,593],{},"to_dict(\"records\")"," turns each group into a list of dictionaries, which Jinja2 loops over naturally. Keeping the calculations here means they can be unit-tested without rendering anything, and the template stays readable by someone who knows HTML but not pandas.",[596,597,605,606,605,610,605,614,605,622,605,632,605,643,605,650,605,655,605,659,605,664,605,669,605,674,605,681,605,687,605,691,605,695,605,698,605,703,605,710,605,715,605,718,605,722,605,725,605,730,605,737,605,742],"svg",{"viewBox":598,"role":599,"ariaLabelledBy":600,"xmlns":603,"style":604},"4 0 752 142","img",[601,602],"jr-flow-t","jr-flow-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  ",[607,608,609],"title",{"id":601},"From workbook to HTML report",[611,612,613],"desc",{"id":602},"pandas reads the workbook and computes totals, shares and groups. The results go into a context dictionary. Jinja2 renders a template file with that context into one self-contained HTML file, which a test then checks.",[615,616],"rect",{"x":617,"y":618,"width":619,"height":620,"fill":621},"4","0","752","142","#ffffff",[615,623],{"x":624,"y":625,"width":626,"height":627,"rx":628,"fill":629,"stroke":630,"style":631},"20.0","16.0","153.0","110.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[633,634,636],"g",{"transform":635},"translate(84.5,29.0) scale(1.000)",[637,638],"path",{"d":639,"fill":640,"stroke":641,"style":642},"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","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[644,645,649],"text",{"x":646,"y":647,"style":648},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Read and compute",[644,651,654],{"x":646,"y":652,"style":653},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","pandas groupby,",[644,656,658],{"x":646,"y":657,"style":653},"112.5","totals",[45,660],{"x1":661,"y1":662,"x2":663,"y2":662,"stroke":630,"style":631},"178.0","71.2","200.0",[665,666],"polygon",{"points":667,"fill":668},"204.0,71.2 195.0,76.2 195.0,66.2","#5b5cf0",[615,670],{"x":671,"y":625,"width":626,"height":627,"rx":628,"fill":672,"stroke":673,"style":631},"209.0","#e7ebef","var(--line,#cdd5e6)",[633,675,677],{"transform":676},"translate(273.5,37.8) scale(1.000)",[637,678],{"d":679,"fill":640,"stroke":680,"style":642},"M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6 M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3","var(--text,#172033)",[644,682,686],{"x":683,"y":684,"style":685},"285.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Context",[644,688,690],{"x":683,"y":689,"style":653},"103.8","plain dicts and lists",[45,692],{"x1":693,"y1":662,"x2":694,"y2":662,"stroke":630,"style":631},"367.0","389.0",[665,696],{"points":697,"fill":668},"393.0,71.2 384.0,76.2 384.0,66.2",[615,699],{"x":700,"y":625,"width":626,"height":627,"rx":628,"fill":701,"stroke":702,"style":631},"398.0","#d9f4f1","var(--teal,#0f9488)",[633,704,706],{"transform":705},"translate(462.5,37.8) scale(1.000)",[637,707],{"d":708,"fill":640,"stroke":709,"style":642},"M9 8l-4 4 4 4 M15 8l4 4-4 4","var(--teal-ink,#0b6157)",[644,711,714],{"x":712,"y":684,"style":713},"474.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Render template",[644,716,717],{"x":712,"y":689,"style":653},"Jinja2, autoescape",[45,719],{"x1":720,"y1":662,"x2":721,"y2":662,"stroke":630,"style":631},"556.0","578.0",[665,723],{"points":724,"fill":668},"582.0,71.2 573.0,76.2 573.0,66.2",[615,726],{"x":727,"y":625,"width":626,"height":627,"rx":628,"fill":728,"stroke":729,"style":631},"587.0","#fdefd8","var(--gold,#b4740a)",[633,731,733],{"transform":732},"translate(651.5,37.8) scale(1.000)",[637,734],{"d":735,"fill":640,"stroke":736,"style":642},"M12 3a9 9 0 1 0 .01 0z M3 12h18 M12 3c3.2 3 3.2 15 0 18 M12 3c-3.2 3-3.2 15 0 18","var(--gold-ink,#7a4e06)",[644,738,741],{"x":739,"y":684,"style":740},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","report.html",[644,743,744],{"x":739,"y":689,"style":653},"one file, no server",[29,746,748],{"id":747},"step-2-set-up-the-environment-with-escaping-and-filters","Step 2 — Set up the environment with escaping and filters",[10,750,751,752,755],{},"A Jinja2 ",[14,753,754],{},"Environment"," loads templates from a folder. Three settings matter for reports: autoescaping, strict handling of missing values, and custom filters for number formats:",[34,757,759],{"className":90,"code":758,"language":92,"meta":39,"style":39},"from pathlib import Path\nfrom jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape\n\nPath(\"templates\").mkdir(exist_ok=True)\n\nenv = Environment(\n    loader=FileSystemLoader(\"templates\"),\n    autoescape=select_autoescape([\"html\"]),\n    undefined=StrictUndefined,\n    trim_blocks=True, lstrip_blocks=True,\n)\nenv.filters[\"money\"] = lambda v: f\"€{v:,.0f}\"\nenv.filters[\"int\"] = lambda v: f\"{v:,}\"\nenv.filters[\"pct\"] = lambda v: f\"{v:.0%}\"\nprint(env.filters[\"money\"](23072.0), env.filters[\"pct\"](0.4069))\n",[14,760,761,773,785,789,810,814,824,838,854,864,884,888,924,953,983],{"__ignoreMap":39},[43,762,763,765,768,770],{"class":45,"line":46},[43,764,116],{"class":99},[43,766,767],{"class":103}," pathlib ",[43,769,100],{"class":99},[43,771,772],{"class":103}," Path\n",[43,774,775,777,780,782],{"class":45,"line":113},[43,776,116],{"class":99},[43,778,779],{"class":103}," jinja2 ",[43,781,100],{"class":99},[43,783,784],{"class":103}," Environment, FileSystemLoader, StrictUndefined, select_autoescape\n",[43,786,787],{"class":45,"line":127},[43,788,131],{"emptyLinePlaceholder":130},[43,790,791,794,797,800,803,805,808],{"class":45,"line":134},[43,792,793],{"class":103},"Path(",[43,795,796],{"class":53},"\"templates\"",[43,798,799],{"class":103},").mkdir(",[43,801,802],{"class":152},"exist_ok",[43,804,140],{"class":99},[43,806,807],{"class":222},"True",[43,809,161],{"class":103},[43,811,812],{"class":45,"line":164},[43,813,131],{"emptyLinePlaceholder":130},[43,815,816,819,821],{"class":45,"line":181},[43,817,818],{"class":103},"env ",[43,820,140],{"class":99},[43,822,823],{"class":103}," Environment(\n",[43,825,826,829,831,834,836],{"class":45,"line":186},[43,827,828],{"class":152},"    loader",[43,830,140],{"class":99},[43,832,833],{"class":103},"FileSystemLoader(",[43,835,796],{"class":53},[43,837,401],{"class":103},[43,839,840,843,845,848,851],{"class":45,"line":197},[43,841,842],{"class":152},"    autoescape",[43,844,140],{"class":99},[43,846,847],{"class":103},"select_autoescape([",[43,849,850],{"class":53},"\"html\"",[43,852,853],{"class":103},"]),\n",[43,855,856,859,861],{"class":45,"line":229},[43,857,858],{"class":152},"    undefined",[43,860,140],{"class":99},[43,862,863],{"class":103},"StrictUndefined,\n",[43,865,866,869,871,873,875,878,880,882],{"class":45,"line":259},[43,867,868],{"class":152},"    trim_blocks",[43,870,140],{"class":99},[43,872,807],{"class":222},[43,874,149],{"class":103},[43,876,877],{"class":152},"lstrip_blocks",[43,879,140],{"class":99},[43,881,807],{"class":222},[43,883,363],{"class":103},[43,885,886],{"class":45,"line":265},[43,887,161],{"class":103},[43,889,890,893,895,898,900,903,906,908,911,913,916,919,921],{"class":45,"line":274},[43,891,892],{"class":103},"env.filters[",[43,894,439],{"class":53},[43,896,897],{"class":103},"] ",[43,899,140],{"class":99},[43,901,902],{"class":99}," lambda",[43,904,905],{"class":103}," v: ",[43,907,551],{"class":99},[43,909,910],{"class":53},"\"€",[43,912,558],{"class":557},[43,914,915],{"class":103},"v",[43,917,918],{"class":99},":,.0f",[43,920,573],{"class":557},[43,922,923],{"class":53},"\"\n",[43,925,926,928,930,932,934,936,938,940,942,944,946,949,951],{"class":45,"line":288},[43,927,892],{"class":103},[43,929,478],{"class":53},[43,931,897],{"class":103},[43,933,140],{"class":99},[43,935,902],{"class":99},[43,937,905],{"class":103},[43,939,551],{"class":99},[43,941,554],{"class":53},[43,943,558],{"class":557},[43,945,915],{"class":103},[43,947,948],{"class":99},":,",[43,950,573],{"class":557},[43,952,923],{"class":53},[43,954,955,957,960,962,964,966,968,970,972,974,976,979,981],{"class":45,"line":307},[43,956,892],{"class":103},[43,958,959],{"class":53},"\"pct\"",[43,961,897],{"class":103},[43,963,140],{"class":99},[43,965,902],{"class":99},[43,967,905],{"class":103},[43,969,551],{"class":99},[43,971,554],{"class":53},[43,973,558],{"class":557},[43,975,915],{"class":103},[43,977,978],{"class":99},":.0%",[43,980,573],{"class":557},[43,982,923],{"class":53},[43,984,985,987,990,992,995,998,1001,1003,1005,1008],{"class":45,"line":316},[43,986,539],{"class":222},[43,988,989],{"class":103},"(env.filters[",[43,991,439],{"class":53},[43,993,994],{"class":103},"](",[43,996,997],{"class":222},"23072.0",[43,999,1000],{"class":103},"), env.filters[",[43,1002,959],{"class":53},[43,1004,994],{"class":103},[43,1006,1007],{"class":222},"0.4069",[43,1009,1010],{"class":103},"))\n",[10,1012,1013,1016,1017,1019,1020,1022,1023,1026,1027,149,1030,1033,1034,1037,1038,1041,1042,1045],{},[14,1014,1015],{},"select_autoescape([\"html\"])"," escapes every value inserted into a ",[14,1018,20],{}," template, so ",[14,1021,75],{}," becomes ",[14,1024,1025],{},"Gloves &lt;bulk&gt;"," and displays as written instead of being parsed as a tag. Without it, any cell containing ",[14,1028,1029],{},"\u003C",[14,1031,1032],{},"&"," or a stray ",[14,1035,1036],{},"\u003Cscript>"," would corrupt or compromise the page. ",[14,1039,1040],{},"StrictUndefined"," raises an error when the template uses a name the context does not contain, instead of silently rendering an empty string — a renamed column then fails the build rather than producing a report with blank figures. The filters keep formatting decisions in one place: ",[14,1043,1044],{},"{{ value | money }}"," everywhere money appears.",[29,1047,1049],{"id":1048},"step-3-write-a-base-layout-and-a-report-template","Step 3 — Write a base layout and a report template",[10,1051,1052,1053,1057],{},"Template inheritance lets several reports share one layout. The base defines the page skeleton and styles, with ",[1054,1055,1056],"em",{},"blocks"," that child templates fill:",[34,1059,1061],{"className":90,"code":1060,"language":92,"meta":39,"style":39},"Path(\"templates\u002Fbase.html\").write_text(\"\"\"\u003C!doctype html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n\u003Cmeta charset=\"utf-8\">\n\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\u003Ctitle>{{ title }} · {{ period }}\u003C\u002Ftitle>\n\u003Cstyle>\n  body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 860px; color: #172033; padding: 0 16px; }\n  .kpis { display: flex; gap: 1rem; flex-wrap: wrap; }\n  .kpi { background: #EEF2FF; border-radius: 10px; padding: .8rem 1.2rem; }\n  .kpi b { display: block; font-size: 1.5rem; color: #4338CA; }\n  table { border-collapse: collapse; width: 100%; margin: .5rem 0 1.5rem; }\n  th, td { padding: .35rem .6rem; border-bottom: 1px solid #E2E8F0; text-align: left; }\n  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }\n  footer { color: #5B6780; font-size: .85rem; }\n  .bar-row { display: flex; align-items: center; gap: .5rem; margin: .2rem 0; max-width: 420px; }\n  .bar-row span { flex: 0 0 110px; font-size: .8rem; }\n  .bar { display: block; height: 14px; background: #4338CA; border-radius: 3px;\n         -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n\u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n\u003Cheader>\u003Ch1>{{ title }}\u003C\u002Fh1>\u003Cp>{{ period }}\u003C\u002Fp>\u003C\u002Fheader>\n{% block content %}{% endblock %}\n\u003Cfooter>Generated {{ generated.strftime(\"%d %B %Y\") }} from sales.xlsx\u003C\u002Ffooter>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n\"\"\", encoding=\"utf-8\")\nprint(\"base.html written\")\n",[14,1062,1063,1076,1081,1086,1091,1096,1123,1128,1133,1138,1143,1148,1153,1158,1163,1168,1173,1178,1183,1188,1193,1198,1203,1208,1224,1235,1240,1245,1262],{"__ignoreMap":39},[43,1064,1065,1067,1070,1073],{"class":45,"line":46},[43,1066,793],{"class":103},[43,1068,1069],{"class":53},"\"templates\u002Fbase.html\"",[43,1071,1072],{"class":103},").write_text(",[43,1074,1075],{"class":53},"\"\"\"\u003C!doctype html>\n",[43,1077,1078],{"class":45,"line":113},[43,1079,1080],{"class":53},"\u003Chtml lang=\"en\">\n",[43,1082,1083],{"class":45,"line":127},[43,1084,1085],{"class":53},"\u003Chead>\n",[43,1087,1088],{"class":45,"line":134},[43,1089,1090],{"class":53},"\u003Cmeta charset=\"utf-8\">\n",[43,1092,1093],{"class":45,"line":164},[43,1094,1095],{"class":53},"\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n",[43,1097,1098,1101,1104,1107,1110,1113,1115,1118,1120],{"class":45,"line":181},[43,1099,1100],{"class":53},"\u003Ctitle>",[43,1102,1103],{"class":557},"{{",[43,1105,1106],{"class":53}," title ",[43,1108,1109],{"class":557},"}}",[43,1111,1112],{"class":53}," · ",[43,1114,1103],{"class":557},[43,1116,1117],{"class":53}," period ",[43,1119,1109],{"class":557},[43,1121,1122],{"class":53},"\u003C\u002Ftitle>\n",[43,1124,1125],{"class":45,"line":186},[43,1126,1127],{"class":53},"\u003Cstyle>\n",[43,1129,1130],{"class":45,"line":197},[43,1131,1132],{"class":53},"  body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 860px; color: #172033; padding: 0 16px; }\n",[43,1134,1135],{"class":45,"line":229},[43,1136,1137],{"class":53},"  .kpis { display: flex; gap: 1rem; flex-wrap: wrap; }\n",[43,1139,1140],{"class":45,"line":259},[43,1141,1142],{"class":53},"  .kpi { background: #EEF2FF; border-radius: 10px; padding: .8rem 1.2rem; }\n",[43,1144,1145],{"class":45,"line":265},[43,1146,1147],{"class":53},"  .kpi b { display: block; font-size: 1.5rem; color: #4338CA; }\n",[43,1149,1150],{"class":45,"line":274},[43,1151,1152],{"class":53},"  table { border-collapse: collapse; width: 100%; margin: .5rem 0 1.5rem; }\n",[43,1154,1155],{"class":45,"line":288},[43,1156,1157],{"class":53},"  th, td { padding: .35rem .6rem; border-bottom: 1px solid #E2E8F0; text-align: left; }\n",[43,1159,1160],{"class":45,"line":307},[43,1161,1162],{"class":53},"  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }\n",[43,1164,1165],{"class":45,"line":316},[43,1166,1167],{"class":53},"  footer { color: #5B6780; font-size: .85rem; }\n",[43,1169,1170],{"class":45,"line":329},[43,1171,1172],{"class":53},"  .bar-row { display: flex; align-items: center; gap: .5rem; margin: .2rem 0; max-width: 420px; }\n",[43,1174,1175],{"class":45,"line":335},[43,1176,1177],{"class":53},"  .bar-row span { flex: 0 0 110px; font-size: .8rem; }\n",[43,1179,1180],{"class":45,"line":340},[43,1181,1182],{"class":53},"  .bar { display: block; height: 14px; background: #4338CA; border-radius: 3px;\n",[43,1184,1185],{"class":45,"line":351},[43,1186,1187],{"class":53},"         -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n",[43,1189,1190],{"class":45,"line":366},[43,1191,1192],{"class":53},"\u003C\u002Fstyle>\n",[43,1194,1195],{"class":45,"line":379},[43,1196,1197],{"class":53},"\u003C\u002Fhead>\n",[43,1199,1200],{"class":45,"line":404},[43,1201,1202],{"class":53},"\u003Cbody>\n",[43,1204,1205],{"class":45,"line":413},[43,1206,1207],{"class":53},"\u003Cheader>\u003Ch1>{{ title }}\u003C\u002Fh1>\u003Cp>{{ period }}\u003C\u002Fp>\u003C\u002Fheader>\n",[43,1209,1210,1212,1215,1218,1221],{"class":45,"line":445},[43,1211,558],{"class":53},[43,1213,1214],{"class":557},"% b",[43,1216,1217],{"class":53},"lock content %}{",[43,1219,1220],{"class":557},"% e",[43,1222,1223],{"class":53},"ndblock %}\n",[43,1225,1226,1229,1232],{"class":45,"line":483},[43,1227,1228],{"class":53},"\u003Cfooter>Generated {{ generated.strftime(\"",[43,1230,1231],{"class":557},"%d",[43,1233,1234],{"class":53}," %B %Y\") }} from sales.xlsx\u003C\u002Ffooter>\n",[43,1236,1237],{"class":45,"line":515},[43,1238,1239],{"class":53},"\u003C\u002Fbody>\n",[43,1241,1242],{"class":45,"line":521},[43,1243,1244],{"class":53},"\u003C\u002Fhtml>\n",[43,1246,1247,1250,1252,1255,1257,1260],{"class":45,"line":530},[43,1248,1249],{"class":53},"\"\"\"",[43,1251,149],{"class":103},[43,1253,1254],{"class":152},"encoding",[43,1256,140],{"class":99},[43,1258,1259],{"class":53},"\"utf-8\"",[43,1261,161],{"class":103},[43,1263,1264,1266,1269,1272],{"class":45,"line":536},[43,1265,539],{"class":222},[43,1267,1268],{"class":103},"(",[43,1270,1271],{"class":53},"\"base.html written\"",[43,1273,161],{"class":103},[10,1275,1276,1277,1280],{},"All styles are inline in a ",[14,1278,1279],{},"\u003Cstyle>"," element and there are no external scripts, fonts or images, so the single file renders the same when opened from an email attachment or a file share, offline. The viewport meta tag and the flexible KPI row keep it readable on a phone.",[29,1282,1284],{"id":1283},"step-4-loops-sections-and-css-bar-charts","Step 4 — Loops, sections and CSS bar charts",[10,1286,1287,1288,1291],{},"The report template extends the base and fills the content block. Each region gets a heading, a table and a small horizontal bar chart drawn with plain HTML and CSS — a ",[14,1289,1290],{},"div"," per product whose width is a percentage — so there is no charting library and no JavaScript, and it prints cleanly:",[34,1293,1295],{"className":90,"code":1294,"language":92,"meta":39,"style":39},"Path(\"templates\u002Freport.html\").write_text(\"\"\"{% extends \"base.html\" %}\n{% block content %}\n\u003Csection class=\"kpis\">\n{% for k in kpis %}\n  \u003Cdiv class=\"kpi\">{{ k.label }}\u003Cb>{{ k.value | money if k.fmt == \"money\" else k.value | int }}\u003C\u002Fb>\u003C\u002Fdiv>\n{% endfor %}\n\u003C\u002Fsection>\n{% for r in regions %}\n\u003Csection>\n  \u003Ch2>{{ r.name }} — {{ r.revenue | money }} ({{ r.share | pct }} of total)\u003C\u002Fh2>\n  \u003Cdiv class=\"bars\" role=\"img\" aria-label=\"Revenue by product in {{ r.name }}\">\n  {% for row in r.rows %}\n    \u003Cdiv class=\"bar-row\">\u003Cspan>{{ row.Product }}\u003C\u002Fspan>\n      \u003Ci class=\"bar\" style=\"width: {{ (row.Revenue \u002F r.max * 100) | round(1) }}%\">\u003C\u002Fi>\u003C\u002Fdiv>\n  {% endfor %}\n  \u003C\u002Fdiv>\n  \u003Ctable>\n    \u003Cthead>\u003Ctr>\u003Cth>Product\u003C\u002Fth>\u003Cth class=\"num\">Units\u003C\u002Fth>\u003Cth class=\"num\">Revenue\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\n    \u003Ctbody>\n    {% for row in r.rows %}\n      \u003Ctr>\u003Ctd>{{ row.Product }}\u003C\u002Ftd>\u003Ctd class=\"num\">{{ row.Units | int }}\u003C\u002Ftd>\u003Ctd class=\"num\">{{ row.Revenue | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n    {% endfor %}\n    \u003C\u002Ftbody>\n  \u003C\u002Ftable>\n\u003C\u002Fsection>\n{% endfor %}\n{% endblock %}\n\"\"\", encoding=\"utf-8\")\n\nhtml = env.get_template(\"report.html\").render(**context)\nPath(\"report.html\").write_text(html, encoding=\"utf-8\")\nprint(len(html), \"characters;\", html.count(\"\u003Csection\"), \"sections (KPI row + 3 regions)\")\n",[14,1296,1297,1309,1314,1319,1324,1329,1334,1339,1344,1349,1354,1359,1364,1369,1374,1379,1384,1389,1394,1399,1404,1409,1414,1419,1424,1428,1432,1437,1451,1455,1478,1496],{"__ignoreMap":39},[43,1298,1299,1301,1304,1306],{"class":45,"line":46},[43,1300,793],{"class":103},[43,1302,1303],{"class":53},"\"templates\u002Freport.html\"",[43,1305,1072],{"class":103},[43,1307,1308],{"class":53},"\"\"\"{% extends \"base.html\" %}\n",[43,1310,1311],{"class":45,"line":113},[43,1312,1313],{"class":53},"{% block content %}\n",[43,1315,1316],{"class":45,"line":127},[43,1317,1318],{"class":53},"\u003Csection class=\"kpis\">\n",[43,1320,1321],{"class":45,"line":134},[43,1322,1323],{"class":53},"{% for k in kpis %}\n",[43,1325,1326],{"class":45,"line":164},[43,1327,1328],{"class":53},"  \u003Cdiv class=\"kpi\">{{ k.label }}\u003Cb>{{ k.value | money if k.fmt == \"money\" else k.value | int }}\u003C\u002Fb>\u003C\u002Fdiv>\n",[43,1330,1331],{"class":45,"line":181},[43,1332,1333],{"class":53},"{% endfor %}\n",[43,1335,1336],{"class":45,"line":186},[43,1337,1338],{"class":53},"\u003C\u002Fsection>\n",[43,1340,1341],{"class":45,"line":197},[43,1342,1343],{"class":53},"{% for r in regions %}\n",[43,1345,1346],{"class":45,"line":229},[43,1347,1348],{"class":53},"\u003Csection>\n",[43,1350,1351],{"class":45,"line":259},[43,1352,1353],{"class":53},"  \u003Ch2>{{ r.name }} — {{ r.revenue | money }} ({{ r.share | pct }} of total)\u003C\u002Fh2>\n",[43,1355,1356],{"class":45,"line":265},[43,1357,1358],{"class":53},"  \u003Cdiv class=\"bars\" role=\"img\" aria-label=\"Revenue by product in {{ r.name }}\">\n",[43,1360,1361],{"class":45,"line":274},[43,1362,1363],{"class":53},"  {% for row in r.rows %}\n",[43,1365,1366],{"class":45,"line":288},[43,1367,1368],{"class":53},"    \u003Cdiv class=\"bar-row\">\u003Cspan>{{ row.Product }}\u003C\u002Fspan>\n",[43,1370,1371],{"class":45,"line":307},[43,1372,1373],{"class":53},"      \u003Ci class=\"bar\" style=\"width: {{ (row.Revenue \u002F r.max * 100) | round(1) }}%\">\u003C\u002Fi>\u003C\u002Fdiv>\n",[43,1375,1376],{"class":45,"line":316},[43,1377,1378],{"class":53},"  {% endfor %}\n",[43,1380,1381],{"class":45,"line":329},[43,1382,1383],{"class":53},"  \u003C\u002Fdiv>\n",[43,1385,1386],{"class":45,"line":335},[43,1387,1388],{"class":53},"  \u003Ctable>\n",[43,1390,1391],{"class":45,"line":340},[43,1392,1393],{"class":53},"    \u003Cthead>\u003Ctr>\u003Cth>Product\u003C\u002Fth>\u003Cth class=\"num\">Units\u003C\u002Fth>\u003Cth class=\"num\">Revenue\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\n",[43,1395,1396],{"class":45,"line":351},[43,1397,1398],{"class":53},"    \u003Ctbody>\n",[43,1400,1401],{"class":45,"line":366},[43,1402,1403],{"class":53},"    {% for row in r.rows %}\n",[43,1405,1406],{"class":45,"line":379},[43,1407,1408],{"class":53},"      \u003Ctr>\u003Ctd>{{ row.Product }}\u003C\u002Ftd>\u003Ctd class=\"num\">{{ row.Units | int }}\u003C\u002Ftd>\u003Ctd class=\"num\">{{ row.Revenue | money }}\u003C\u002Ftd>\u003C\u002Ftr>\n",[43,1410,1411],{"class":45,"line":404},[43,1412,1413],{"class":53},"    {% endfor %}\n",[43,1415,1416],{"class":45,"line":413},[43,1417,1418],{"class":53},"    \u003C\u002Ftbody>\n",[43,1420,1421],{"class":45,"line":445},[43,1422,1423],{"class":53},"  \u003C\u002Ftable>\n",[43,1425,1426],{"class":45,"line":483},[43,1427,1338],{"class":53},[43,1429,1430],{"class":45,"line":515},[43,1431,1333],{"class":53},[43,1433,1434],{"class":45,"line":521},[43,1435,1436],{"class":53},"{% endblock %}\n",[43,1438,1439,1441,1443,1445,1447,1449],{"class":45,"line":530},[43,1440,1249],{"class":53},[43,1442,149],{"class":103},[43,1444,1254],{"class":152},[43,1446,140],{"class":99},[43,1448,1259],{"class":53},[43,1450,161],{"class":103},[43,1452,1453],{"class":45,"line":536},[43,1454,131],{"emptyLinePlaceholder":130},[43,1456,1458,1461,1463,1466,1469,1472,1475],{"class":45,"line":1457},30,[43,1459,1460],{"class":103},"html ",[43,1462,140],{"class":99},[43,1464,1465],{"class":103}," env.get_template(",[43,1467,1468],{"class":53},"\"report.html\"",[43,1470,1471],{"class":103},").render(",[43,1473,1474],{"class":99},"**",[43,1476,1477],{"class":103},"context)\n",[43,1479,1481,1483,1485,1488,1490,1492,1494],{"class":45,"line":1480},31,[43,1482,793],{"class":103},[43,1484,1468],{"class":53},[43,1486,1487],{"class":103},").write_text(html, ",[43,1489,1254],{"class":152},[43,1491,140],{"class":99},[43,1493,1259],{"class":53},[43,1495,161],{"class":103},[43,1497,1499,1501,1503,1506,1509,1512,1515,1518,1521,1524],{"class":45,"line":1498},32,[43,1500,539],{"class":222},[43,1502,1268],{"class":103},[43,1504,1505],{"class":222},"len",[43,1507,1508],{"class":103},"(html), ",[43,1510,1511],{"class":53},"\"characters;\"",[43,1513,1514],{"class":103},", html.count(",[43,1516,1517],{"class":53},"\"\u003Csection\"",[43,1519,1520],{"class":103},"), ",[43,1522,1523],{"class":53},"\"sections (KPI row + 3 regions)\"",[43,1525,161],{"class":103},[10,1527,1528,1531,1532,1535,1536,1539,1540,1543,1544,1547,1548,1551,1552,1555],{},[14,1529,1530],{},"{% extends %}"," pulls in the base layout, and ",[14,1533,1534],{},"{% block content %}"," replaces its empty block. Each bar's width is the product's revenue as a percentage of the region's largest, computed in the template with ",[14,1537,1538],{},"round"," — simple presentation arithmetic is fine there; business logic is not. The bar block carries ",[14,1541,1542],{},"role=\"img\""," and an ",[14,1545,1546],{},"aria-label"," so screen readers announce what it shows, and ",[14,1549,1550],{},"print-color-adjust: exact"," stops browsers dropping the bar colour when printing. A second report — say, a stock report — can extend the same ",[14,1553,1554],{},"base.html"," and get the same header, footer and styles for free.",[596,1557,605,1563,605,1566,605,1569,605,1574,605,1579,605,1583,605,1587,605,1591,605,1594,605,1597,605,1601,605,1605,605,1609,605,1612,605,1615,605,1618],{"viewBox":1558,"role":599,"ariaLabelledBy":1559,"xmlns":603,"style":1562},"84 0 592 281",[1560,1561],"jr-inherit-t","jr-inherit-d","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[607,1564,1565],{"id":1560},"Template inheritance",[611,1567,1568],{"id":1561},"base.html defines the document skeleton, styles, header and footer, and an empty content block. report.html extends it and fills the content block with KPI cards and a section per region. Other reports can extend the same base.",[615,1570],{"x":1571,"y":618,"width":1572,"height":1573,"fill":621},"84","592","281",[615,1575],{"x":1576,"y":625,"width":1577,"height":1578,"rx":628,"fill":672,"stroke":673,"style":631},"100.0","560.0","59.0",[644,1580,1554],{"x":1581,"y":1582,"style":685},"380.0","41.5",[644,1584,1586],{"x":1581,"y":1585,"style":653},"61.0","skeleton · styles · header · footer",[45,1588],{"x1":1581,"y1":1589,"x2":1581,"y2":1590,"stroke":630,"style":631},"80.0","102.0",[665,1592],{"points":1593,"fill":668},"380.0,106.0 375.0,97.0 385.0,97.0",[615,1595],{"x":1576,"y":1596,"width":1577,"height":1578,"rx":628,"fill":629,"stroke":630,"style":631},"111.0",[644,1598,1600],{"x":1581,"y":1599,"style":648},"136.5","block content",[644,1602,1604],{"x":1581,"y":1603,"style":653},"156.0","filled by each child template",[45,1606],{"x1":1581,"y1":1607,"x2":1581,"y2":1608,"stroke":630,"style":631},"175.0","197.0",[665,1610],{"points":1611,"fill":668},"380.0,201.0 375.0,192.0 385.0,192.0",[615,1613],{"x":1576,"y":1614,"width":1577,"height":1578,"rx":628,"fill":701,"stroke":702,"style":631},"206.0",[644,1616,741],{"x":1581,"y":1617,"style":713},"231.5",[644,1619,1621],{"x":1581,"y":1620,"style":653},"251.0","KPI cards · sections · CSS bars · tables",[29,1623,1625],{"id":1624},"step-5-test-the-rendered-report","Step 5 — Test the rendered report",[10,1627,1628],{},"Because the output is HTML, it can be parsed and checked like data. A test confirms the escaping, the figures and the structure:",[34,1630,1632],{"className":90,"code":1631,"language":92,"meta":39,"style":39},"from lxml import html as lh\n\ndoc = lh.fromstring(Path(\"report.html\").read_text(encoding=\"utf-8\"))\nprint(\"title:\", doc.findtext(\".\u002F\u002Ftitle\"))\nprint(\"KPIs:\", [\" \".join(k.itertext()).strip() for k in doc.xpath('\u002F\u002Fdiv[@class=\"kpi\"]')])\nprint(\"sections:\", [h.text_content() for h in doc.xpath(\"\u002F\u002Fh2\")])\nprint(\"escaped product kept as text:\", \"Gloves \u003Cbulk>\" in doc.text_content(), \"| raw tag in page:\", \"\u003Cbulk>\" in Path(\"report.html\").read_text())\nprint(\"bars per region:\", [len(s.xpath('.\u002F\u002Fi[@class=\"bar\"]')) for s in doc.xpath(\"\u002F\u002Fsection[h2]\")])\n",[14,1633,1634,1651,1655,1678,1695,1729,1755,1793],{"__ignoreMap":39},[43,1635,1636,1638,1641,1643,1646,1648],{"class":45,"line":46},[43,1637,116],{"class":99},[43,1639,1640],{"class":103}," lxml ",[43,1642,100],{"class":99},[43,1644,1645],{"class":103}," html ",[43,1647,107],{"class":99},[43,1649,1650],{"class":103}," lh\n",[43,1652,1653],{"class":45,"line":113},[43,1654,131],{"emptyLinePlaceholder":130},[43,1656,1657,1660,1662,1665,1667,1670,1672,1674,1676],{"class":45,"line":127},[43,1658,1659],{"class":103},"doc ",[43,1661,140],{"class":99},[43,1663,1664],{"class":103}," lh.fromstring(Path(",[43,1666,1468],{"class":53},[43,1668,1669],{"class":103},").read_text(",[43,1671,1254],{"class":152},[43,1673,140],{"class":99},[43,1675,1259],{"class":53},[43,1677,1010],{"class":103},[43,1679,1680,1682,1684,1687,1690,1693],{"class":45,"line":134},[43,1681,539],{"class":222},[43,1683,1268],{"class":103},[43,1685,1686],{"class":53},"\"title:\"",[43,1688,1689],{"class":103},", doc.findtext(",[43,1691,1692],{"class":53},"\".\u002F\u002Ftitle\"",[43,1694,1010],{"class":103},[43,1696,1697,1699,1701,1704,1707,1710,1713,1715,1718,1720,1723,1726],{"class":45,"line":164},[43,1698,539],{"class":222},[43,1700,1268],{"class":103},[43,1702,1703],{"class":53},"\"KPIs:\"",[43,1705,1706],{"class":103},", [",[43,1708,1709],{"class":53},"\" \"",[43,1711,1712],{"class":103},".join(k.itertext()).strip() ",[43,1714,200],{"class":99},[43,1716,1717],{"class":103}," k ",[43,1719,206],{"class":99},[43,1721,1722],{"class":103}," doc.xpath(",[43,1724,1725],{"class":53},"'\u002F\u002Fdiv[@class=\"kpi\"]'",[43,1727,1728],{"class":103},")])\n",[43,1730,1731,1733,1735,1738,1741,1743,1746,1748,1750,1753],{"class":45,"line":181},[43,1732,539],{"class":222},[43,1734,1268],{"class":103},[43,1736,1737],{"class":53},"\"sections:\"",[43,1739,1740],{"class":103},", [h.text_content() ",[43,1742,200],{"class":99},[43,1744,1745],{"class":103}," h ",[43,1747,206],{"class":99},[43,1749,1722],{"class":103},[43,1751,1752],{"class":53},"\"\u002F\u002Fh2\"",[43,1754,1728],{"class":103},[43,1756,1757,1759,1761,1764,1766,1769,1772,1775,1778,1780,1783,1785,1788,1790],{"class":45,"line":186},[43,1758,539],{"class":222},[43,1760,1268],{"class":103},[43,1762,1763],{"class":53},"\"escaped product kept as text:\"",[43,1765,149],{"class":103},[43,1767,1768],{"class":53},"\"Gloves \u003Cbulk>\"",[43,1770,1771],{"class":99}," in",[43,1773,1774],{"class":103}," doc.text_content(), ",[43,1776,1777],{"class":53},"\"| raw tag in page:\"",[43,1779,149],{"class":103},[43,1781,1782],{"class":53},"\"\u003Cbulk>\"",[43,1784,1771],{"class":99},[43,1786,1787],{"class":103}," Path(",[43,1789,1468],{"class":53},[43,1791,1792],{"class":103},").read_text())\n",[43,1794,1795,1797,1799,1802,1804,1806,1809,1812,1815,1817,1820,1822,1824,1827],{"class":45,"line":197},[43,1796,539],{"class":222},[43,1798,1268],{"class":103},[43,1800,1801],{"class":53},"\"bars per region:\"",[43,1803,1706],{"class":103},[43,1805,1505],{"class":222},[43,1807,1808],{"class":103},"(s.xpath(",[43,1810,1811],{"class":53},"'.\u002F\u002Fi[@class=\"bar\"]'",[43,1813,1814],{"class":103},")) ",[43,1816,200],{"class":99},[43,1818,1819],{"class":103}," s ",[43,1821,206],{"class":99},[43,1823,1722],{"class":103},[43,1825,1826],{"class":53},"\"\u002F\u002Fsection[h2]\"",[43,1828,1728],{"class":103},[10,1830,1831,1832,1834,1835,1838,1839,1842],{},"The KPI cards read Revenue €38,072, Units 934 and Regions 3, each region heading shows its revenue and share, and every region has one bar per product. The product ",[14,1833,75],{}," appears as text, while the raw string ",[14,1836,1837],{},"\u003Cbulk>"," does not occur in the HTML source — it was escaped to ",[14,1840,1841],{},"&lt;bulk&gt;",". A test like this, run against a small fixture workbook, catches a template change that drops a section or a filter that formats money wrongly.",[596,1844,605,1849,605,1852,605,1855,605,1858,605,1865,605,1873,605,1879,605,1885,605,1889,605,1893,605,1897,605,1901,605,1903,605,1909,605,1914,605,1918,605,1921,605,1924,605,1927],{"viewBox":1845,"role":599,"ariaLabelledBy":1846,"xmlns":603,"style":604},"4 0 752 233",[1847,1848],"jr-escape-t","jr-escape-d",[607,1850,1851],{"id":1847},"The same cell with and without autoescaping",[611,1853,1854],{"id":1848},"Without autoescaping, the cell text Gloves less-than bulk greater-than is inserted raw and the browser treats bulk as an unknown tag, so the word disappears. With autoescaping the brackets become entities and the text displays exactly as typed.",[615,1856],{"x":617,"y":618,"width":619,"height":1857,"fill":621},"233",[615,1859],{"x":624,"y":625,"width":1860,"height":1861,"rx":1862,"fill":1863,"stroke":1864,"style":631},"351.0","201.0","14","#fce9e9","var(--danger,#dc2626)",[633,1866,1868],{"transform":1867},"translate(182.5,29.0) scale(1.083)",[637,1869],{"d":1870,"fill":640,"stroke":1871,"style":1872},"M6 6l12 12 M18 6L6 18","var(--accent-ink,#be185d)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[644,1874,1878],{"x":1875,"y":1876,"style":1877},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","autoescape off",[45,1880],{"x1":1881,"y1":1882,"x2":1883,"y2":1882,"stroke":1864,"style":1884},"36.0","91.0","355.0","stroke-width:1px",[644,1886,1888],{"x":1875,"y":1596,"style":1887},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","source: Gloves \u003Cbulk>",[644,1890,1892],{"x":1875,"y":1891,"style":1887},"137.0","browser sees a \u003Cbulk> tag",[644,1894,1896],{"x":1875,"y":1895,"style":1887},"163.0","page shows: Gloves",[644,1898,1900],{"x":1875,"y":1899,"style":1887},"189.0","script tags would run",[615,1902],{"x":694,"y":625,"width":1860,"height":1861,"rx":1862,"fill":701,"stroke":702,"style":631},[633,1904,1906],{"transform":1905},"translate(551.5,29.0) scale(1.083)",[637,1907],{"d":1908,"fill":640,"stroke":709,"style":1872},"M5 12.5l4.5 4.5L19 7",[644,1910,1913],{"x":1911,"y":1876,"style":1912},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","autoescape on",[45,1915],{"x1":1916,"y1":1882,"x2":1917,"y2":1882,"stroke":702,"style":1884},"405.0","724.0",[644,1919,1920],{"x":1911,"y":1596,"style":1887},"source: Gloves &lt;bulk&gt;",[644,1922,1923],{"x":1911,"y":1891,"style":1887},"plain text to the browser",[644,1925,1926],{"x":1911,"y":1895,"style":1887},"page shows: Gloves \u003Cbulk>",[644,1928,1929],{"x":1911,"y":1899,"style":1887},"cell text can't inject markup",[29,1931,1933],{"id":1932},"delivering-the-report","Delivering the report",[10,1935,1936,1937,1941],{},"The single file can go wherever a document goes. Attach it to an email, or put the key figures in the email body and attach the full report — see ",[23,1938,1940],{"href":1939},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-an-excel-report-with-an-html-summary-body\u002F","Email an Excel Report with an HTML Summary Body",", which covers the inline-style restrictions of email clients. Upload it to a bucket or intranet share for a link everyone can open. To produce a PDF from the same HTML, print it from a headless browser; the CSS bars print as crisply as the text around them. And if readers also need the data itself, render the HTML and write the workbook in the same run, so the two never disagree.",[10,1943,1944,1945,1949],{},"For simple tables without sections, a pandas Styler may be enough — see ",[23,1946,1948],{"href":1947},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fconvert-an-excel-sheet-to-an-html-table\u002F","Convert an Excel Sheet to an HTML Table",". Reach for Jinja2 templates when the page has structure beyond one table.",[29,1951,1953],{"id":1952},"common-pitfalls","Common pitfalls",[1955,1956,1957,1974,1982,1988,1994],"ul",{},[1958,1959,1960,1964,1965,1967,1968,1970,1971,27],"li",{},[1961,1962,1963],"strong",{},"Autoescape off."," Cell text with ",[14,1966,1029],{}," or ",[14,1969,1032],{}," breaks the page or injects markup; use ",[14,1972,1973],{},"select_autoescape",[1958,1975,1976,1979,1980,27],{},[1961,1977,1978],{},"Silent blanks."," The default undefined renders missing names as empty strings; use ",[14,1981,1040],{},[1958,1983,1984,1987],{},[1961,1985,1986],{},"Business logic in the template."," Compute totals and shares in Python where they can be tested.",[1958,1989,1990,1993],{},[1961,1991,1992],{},"External assets."," Linked CSS, fonts or scripts break when the file is opened offline; inline them.",[1958,1995,1996,1999],{},[1961,1997,1998],{},"Formatting numbers in many places."," Register filters once and use them everywhere.",[29,2001,2003],{"id":2002},"conclusion","Conclusion",[10,2005,2006,2007,2009],{},"To generate an HTML report from Excel, read and summarise the workbook with pandas into a context of plain dictionaries, and render it with a Jinja2 template file. Turn on autoescaping so cell text cannot break the page, use ",[14,2008,1040],{}," so missing values fail loudly, register filters for money and percentages, share a base layout through template inheritance, and draw small charts with HTML and CSS so the report stays a single self-contained file. Parse the output in a test to confirm the figures and structure.",[29,2011,2013],{"id":2012},"frequently-asked-questions","Frequently asked questions",[10,2015,2016,2019],{},[1961,2017,2018],{},"How do I create an HTML report from an Excel file in Python?","\nRead the workbook with pandas, compute the figures into a dictionary, and render a Jinja2 template with Environment.get_template and render. Write the resulting string to an .html file.",[10,2021,2022,2025],{},[1961,2023,2024],{},"Why should I turn on autoescaping in Jinja2?","\nIt converts characters such as less-than and ampersand in cell values into HTML entities, so text displays as typed and cannot inject tags or scripts into the report.",[10,2027,2028,2031],{},[1961,2029,2030],{},"How do I add charts to an HTML report without JavaScript?","\nDraw them with HTML and CSS in the template, for example a div per value whose width is a percentage of the largest value. They render in any browser and print cleanly.",[10,2033,2034,2037],{},[1961,2035,2036],{},"How do I make Jinja2 fail when a value is missing?","\nCreate the Environment with undefined set to StrictUndefined. Using a name that is not in the context then raises an error instead of rendering an empty string.",[29,2039,2041],{"id":2040},"related","Related",[1955,2043,2044,2050,2057,2064],{},[1958,2045,2046,2047,2049],{},"Up one level: ",[23,2048,26],{"href":25}," — documents built from spreadsheet data.",[1958,2051,2052,2056],{},[23,2053,2055],{"href":2054},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-word-documents-from-excel-rows\u002F","Generate Word Documents from Excel Rows"," — the same idea for Word files.",[1958,2058,2059,2063],{},[23,2060,2062],{"href":2061},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fconvert-an-excel-sheet-to-a-latex-table\u002F","Convert an Excel Sheet to a LaTeX Table"," — another template-driven output format.",[1958,2065,2066,2070],{},[23,2067,2069],{"href":2068},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fprint-mailing-labels-from-an-excel-address-list\u002F","Print Mailing Labels from an Excel Address List"," — a PDF output from the same kind of data.",[2072,2073,2074],"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 .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}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":39,"searchDepth":113,"depth":113,"links":2076},[2077,2078,2079,2080,2081,2082,2083,2084,2085,2086,2087],{"id":31,"depth":113,"text":32},{"id":83,"depth":113,"text":84},{"id":747,"depth":113,"text":748},{"id":1048,"depth":113,"text":1049},{"id":1283,"depth":113,"text":1284},{"id":1624,"depth":113,"text":1625},{"id":1932,"depth":113,"text":1933},{"id":1952,"depth":113,"text":1953},{"id":2002,"depth":113,"text":2003},{"id":2012,"depth":113,"text":2013},{"id":2040,"depth":113,"text":2041},"2026-10-02","Build a complete, self-contained HTML report from an Excel workbook with pandas and Jinja2 templates: shape the data in Python and keep the layout in a template, autoescape cell text, format numbers with custom filters, loop over sections, draw bar charts with plain HTML and CSS, share a base layout through template inheritance, fail fast on missing values, and test the output.","md",[2092,2094,2096,2098],{"q":2018,"a":2093},"Read the workbook with pandas, compute the figures into a dictionary, and render a Jinja2 template with Environment.get_template and render. Write the resulting string to an .html file.",{"q":2024,"a":2095},"It converts characters such as less-than and ampersand in cell values into HTML entities, so text displays as typed and cannot inject tags or scripts into the report.",{"q":2030,"a":2097},"Draw them with HTML and CSS in the template, for example a div per value whose width is a percentage of the largest value. They render in any browser and print cleanly.",{"q":2036,"a":2099},"Create the Environment with undefined set to StrictUndefined. Using a name that is not in the context then raises an error instead of rendering an empty string.","generate an html report from excel data with jinja2",null,{"breadcrumb":2103},[2104,2106,2109],{"name":2105,"item":301},"Home",{"name":2107,"item":2108},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":26,"item":25},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2",{"title":5,"description":2112},"Render Excel data into a full HTML report with Jinja2: a template file, autoescaping, number filters, per-region sections, CSS bar charts, template inheritance, StrictUndefined and lxml tests.","generate-an-html-report-from-excel-data-with-jinja2","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2\u002Findex",[2116,2117],"report-layouts","publishing","how-to","q6t-U4pweVtia4cbItJvzzBlm9_AHMzghoQ5H297IWw",[2121,2125],{"title":2122,"path":2123,"stem":2124,"children":-1},"Fill PDF Forms from Excel Rows with Python","\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-pdf-forms-from-excel-rows-with-python","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-pdf-forms-from-excel-rows-with-python\u002Findex",{"title":2126,"path":2127,"stem":2128,"children":-1},"Generate Customer Account Statements from a Ledger","\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-customer-account-statements-from-a-ledger","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-customer-account-statements-from-a-ledger\u002Findex",[2130,2132],{"path":2131,"title":26},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates",{"path":2133,"title":2107},"\u002Fautomating-reporting-workflows",[],[2136,2140,2143,2146,2149,2152],{"path":2137,"title":2138,"kind":2139},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports","Building Finance and Operations Reports in Excel with Python","guide",{"path":2141,"title":2142,"kind":2118},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fallocate-shared-costs-across-departments","Allocate Shared Costs Across Departments with Python",{"path":2144,"title":2145,"kind":2118},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-bank-reconciliation-report","Build a Bank Reconciliation Report with Python and Excel",{"path":2147,"title":2148,"kind":2118},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-bill-of-materials-cost-rollup","Build a Bill of Materials Cost Rollup",{"path":2150,"title":2151,"kind":2118},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-break-even-analysis-in-excel-with-python","Build a Break-Even Analysis in Excel with Python",{"path":2153,"title":2154,"kind":2155},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-budget-vs-actual-variance-report","Build a Budget vs Actual Variance Report in Excel with Python","compare",1790972138000]