[{"data":1,"prerenderedAt":2113},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export":3,"surround:\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export":2085,"ancestors:\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export":2092,"children:\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export":2096,"related:\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export":2097},{"id":4,"title":5,"body":6,"dateModified":2051,"datePublished":2051,"description":2052,"extension":2053,"faq":2054,"keyword":2063,"kind":2064,"meta":2065,"navigation":126,"path":2074,"seo":2075,"slug":2078,"stem":2079,"topics":2080,"type":2083,"__hash__":2084},"docs\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export\u002Findex.md","Add Bookmarks and a Cover Page to an Excel PDF Export",{"type":7,"value":8,"toc":2039},"minimark",[9,28,33,67,83,87,90,309,317,466,470,475,1054,1069,1073,1076,1407,1421,1481,1485,1488,1732,1735,1739,1776,1784,1925,1929,1964,1968,1975,1979,1985,1991,1997,2003,2007,2036],[10,11,12,13,17,18,21,22,27],"p",{},"A workbook with a summary sheet and a sheet per region converts to a perfectly good PDF — but readers of a 30-page board pack expect a cover with the title and date, a contents list, and bookmarks in the side panel that jump to each section. LibreOffice already writes one bookmark per sheet, which is a good start. With ",[14,15,16],"code",{},"pypdf"," and ",[14,19,20],{},"reportlab"," you can put a cover page in front, turn the flat list of sheet bookmarks into a nested tree, number the pages so the report starts at page 1 after the cover, and make the PDF open with the bookmarks panel showing. This page builds that pipeline and finishes with a test that every bookmark lands on the right page. It belongs to ",[23,24,26],"a",{"href":25},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002F","Exporting Excel Reports to PDF",".",[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 pypdf reportlab pandas openpyxl\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}," pypdf",[43,59,60],{"class":53}," reportlab",[43,62,63],{"class":53}," pandas",[43,65,66],{"class":53}," openpyxl\n",[10,68,69,72,73,76,77,79,80,82],{},[14,70,71],{},"board_pack.xlsx"," has a Summary sheet and four region sheets of different lengths, so some regions span more than one PDF page. LibreOffice (",[14,74,75],{},"soffice",") converts it; ",[14,78,20],{}," draws the cover page and ",[14,81,16],{}," assembles the final file.",[29,84,86],{"id":85},"step-1-convert-and-read-the-sheet-bookmarks","Step 1 — Convert and read the sheet bookmarks",[10,88,89],{},"Convert the workbook as usual, then look at the outline LibreOffice wrote:",[34,91,95],{"className":92,"code":93,"language":94,"meta":39,"style":39},"language-python shiki shiki-themes github-light github-dark-high-contrast","import subprocess\nfrom pypdf import PdfReader\n\nsubprocess.run([\"soffice\", \"--headless\", \"--convert-to\", \"pdf\", \"board_pack.xlsx\"], check=True, capture_output=True)\nreport = PdfReader(\"board_pack.pdf\")\n\nsections = [(item.title, report.get_destination_page_number(item)) for item in report.outline]\nprint(len(report.pages), \"pages\")\nfor title, page in sections:\n    print(f\"  {title:8} starts on page {page + 1}\")\n","python",[14,96,97,107,121,128,184,200,205,228,248,261],{"__ignoreMap":39},[43,98,99,103],{"class":45,"line":46},[43,100,102],{"class":101},"s-kum","import",[43,104,106],{"class":105},"skGVy"," subprocess\n",[43,108,110,113,116,118],{"class":45,"line":109},2,[43,111,112],{"class":101},"from",[43,114,115],{"class":105}," pypdf ",[43,117,102],{"class":101},[43,119,120],{"class":105}," PdfReader\n",[43,122,124],{"class":45,"line":123},3,[43,125,127],{"emptyLinePlaceholder":126},true,"\n",[43,129,131,134,137,140,143,145,148,150,153,155,158,161,165,168,172,174,177,179,181],{"class":45,"line":130},4,[43,132,133],{"class":105},"subprocess.run([",[43,135,136],{"class":53},"\"soffice\"",[43,138,139],{"class":105},", ",[43,141,142],{"class":53},"\"--headless\"",[43,144,139],{"class":105},[43,146,147],{"class":53},"\"--convert-to\"",[43,149,139],{"class":105},[43,151,152],{"class":53},"\"pdf\"",[43,154,139],{"class":105},[43,156,157],{"class":53},"\"board_pack.xlsx\"",[43,159,160],{"class":105},"], ",[43,162,164],{"class":163},"sa561","check",[43,166,167],{"class":101},"=",[43,169,171],{"class":170},"sP0c6","True",[43,173,139],{"class":105},[43,175,176],{"class":163},"capture_output",[43,178,167],{"class":101},[43,180,171],{"class":170},[43,182,183],{"class":105},")\n",[43,185,187,190,192,195,198],{"class":45,"line":186},5,[43,188,189],{"class":105},"report ",[43,191,167],{"class":101},[43,193,194],{"class":105}," PdfReader(",[43,196,197],{"class":53},"\"board_pack.pdf\"",[43,199,183],{"class":105},[43,201,203],{"class":45,"line":202},6,[43,204,127],{"emptyLinePlaceholder":126},[43,206,208,211,213,216,219,222,225],{"class":45,"line":207},7,[43,209,210],{"class":105},"sections ",[43,212,167],{"class":101},[43,214,215],{"class":105}," [(item.title, report.get_destination_page_number(item)) ",[43,217,218],{"class":101},"for",[43,220,221],{"class":105}," item ",[43,223,224],{"class":101},"in",[43,226,227],{"class":105}," report.outline]\n",[43,229,231,234,237,240,243,246],{"class":45,"line":230},8,[43,232,233],{"class":170},"print",[43,235,236],{"class":105},"(",[43,238,239],{"class":170},"len",[43,241,242],{"class":105},"(report.pages), ",[43,244,245],{"class":53},"\"pages\"",[43,247,183],{"class":105},[43,249,251,253,256,258],{"class":45,"line":250},9,[43,252,218],{"class":101},[43,254,255],{"class":105}," title, page ",[43,257,224],{"class":101},[43,259,260],{"class":105}," sections:\n",[43,262,264,267,269,272,275,279,282,285,288,291,293,296,299,302,304,307],{"class":45,"line":263},10,[43,265,266],{"class":170},"    print",[43,268,236],{"class":105},[43,270,271],{"class":101},"f",[43,273,274],{"class":53},"\"  ",[43,276,278],{"class":277},"sSjpA","{",[43,280,281],{"class":105},"title",[43,283,284],{"class":101},":8",[43,286,287],{"class":277},"}",[43,289,290],{"class":53}," starts on page ",[43,292,278],{"class":277},[43,294,295],{"class":105},"page ",[43,297,298],{"class":101},"+",[43,300,301],{"class":170}," 1",[43,303,287],{"class":277},[43,305,306],{"class":53},"\"",[43,308,183],{"class":105},[10,310,311,312,316],{},"LibreOffice adds a bookmark for each sheet, pointing at the first page that sheet prints on, and it sets the PDF to open with the bookmarks panel visible. Longer sheets take more pages, so the start pages are uneven — exactly the information a contents list needs. Reading the outline is more reliable than guessing page counts from row counts, because page breaks depend on fonts, margins and print scaling. If you set print areas or fit-to-page options, as in ",[23,313,315],{"href":314},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Ffit-an-excel-sheet-to-one-page-in-pdf\u002F","Fit an Excel Sheet to One Page in PDF",", the outline reflects them automatically.",[318,319,327,328,327,331,327,335,327,343,327,353,327,364,327,371,327,376,327,380,327,386,327,391,327,395,327,402,327,408,327,412,327,416,327,419,327,424,327,431,327,436,327,439,327,443,327,446,327,451,327,458,327,463],"svg",{"viewBox":320,"role":321,"ariaLabelledBy":322,"xmlns":325,"style":326},"4 0 752 142","img",[323,324],"bm-pipeline-t","bm-pipeline-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  ",[281,329,330],{"id":323},"From workbook to finished PDF",[332,333,334],"desc",{"id":324},"LibreOffice converts the workbook and writes a bookmark per sheet. Python reads the start pages from those bookmarks, draws a cover with a contents list, joins cover and report, and writes a nested bookmark tree with page labels.",[336,337],"rect",{"x":338,"y":339,"width":340,"height":341,"fill":342},"4","0","752","142","#ffffff",[336,344],{"x":345,"y":346,"width":347,"height":348,"rx":349,"fill":350,"stroke":351,"style":352},"20.0","16.0","153.0","110.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[354,355,357],"g",{"transform":356},"translate(84.5,29.0) scale(1.000)",[358,359],"path",{"d":360,"fill":361,"stroke":362,"style":363},"M6 3h9l4 4v14H6z M15 3v4h4 M9 12h6 M9 15.5h6 M9 19h4","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[365,366,370],"text",{"x":367,"y":368,"style":369},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Convert",[365,372,375],{"x":367,"y":373,"style":374},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","soffice writes sheet",[365,377,379],{"x":367,"y":378,"style":374},"112.5","bookmarks",[45,381],{"x1":382,"y1":383,"x2":384,"y2":383,"stroke":385,"style":352},"178.0","71.2","200.0","var(--brand,#5b5cf0)",[387,388],"polygon",{"points":389,"fill":390},"204.0,71.2 195.0,76.2 195.0,66.2","#5b5cf0",[336,392],{"x":393,"y":346,"width":347,"height":348,"rx":349,"fill":394,"stroke":385,"style":352},"209.0","#f0f4ff",[354,396,398],{"transform":397},"translate(273.5,37.8) scale(1.000)",[358,399],{"d":400,"fill":361,"stroke":401,"style":363},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5","var(--brand-strong,#4338ca)",[365,403,407],{"x":404,"y":405,"style":406},"285.5","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Read outline",[365,409,411],{"x":404,"y":410,"style":374},"103.8","sheet → start page",[45,413],{"x1":414,"y1":383,"x2":415,"y2":383,"stroke":385,"style":352},"367.0","389.0",[387,417],{"points":418,"fill":390},"393.0,71.2 384.0,76.2 384.0,66.2",[336,420],{"x":421,"y":346,"width":347,"height":348,"rx":349,"fill":422,"stroke":423,"style":352},"398.0","#d9f4f1","var(--teal,#0f9488)",[354,425,427],{"transform":426},"translate(462.5,37.8) scale(1.000)",[358,428],{"d":429,"fill":361,"stroke":430,"style":363},"M6 3h9l4 4v14H6z M15 3v4h4","var(--teal-ink,#0b6157)",[365,432,435],{"x":433,"y":405,"style":434},"474.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Draw cover",[365,437,438],{"x":433,"y":410,"style":374},"reportlab, contents list",[45,440],{"x1":441,"y1":383,"x2":442,"y2":383,"stroke":385,"style":352},"556.0","578.0",[387,444],{"points":445,"fill":390},"582.0,71.2 573.0,76.2 573.0,66.2",[336,447],{"x":448,"y":346,"width":347,"height":348,"rx":349,"fill":449,"stroke":450,"style":352},"587.0","#fdefd8","var(--gold,#b4740a)",[354,452,454],{"transform":453},"translate(651.5,37.8) scale(1.000)",[358,455],{"d":456,"fill":361,"stroke":457,"style":363},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--gold-ink,#7a4e06)",[365,459,462],{"x":460,"y":405,"style":461},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Assemble",[365,464,465],{"x":460,"y":410,"style":374},"pypdf join, nest, label",[29,467,469],{"id":468},"step-2-draw-a-cover-page-with-a-contents-list","Step 2 — Draw a cover page with a contents list",[10,471,472,474],{},[14,473,20],{}," draws a PDF page directly. The cover holds a title, the date and a contents list whose page numbers come from Step 1. Since the cover is added in front and the report's own numbering will start after it, the numbers printed are the report page numbers:",[34,476,478],{"className":92,"code":477,"language":94,"meta":39,"style":39},"from datetime import date\nfrom reportlab.lib.colors import HexColor\nfrom reportlab.lib.pagesizes import letter\nfrom reportlab.pdfgen import canvas\n\ndef draw_cover(path, title, subtitle, contents):\n    c = canvas.Canvas(path, pagesize=letter)\n    width, height = letter\n    c.setFillColor(HexColor(\"#4338CA\"))\n    c.rect(0, height - 220, width, 220, stroke=0, fill=1)\n    c.setFillColor(HexColor(\"#FFFFFF\"))\n    c.setFont(\"Helvetica-Bold\", 28)\n    c.drawString(60, height - 120, title)\n    c.setFont(\"Helvetica\", 14)\n    c.drawString(60, height - 150, subtitle)\n    c.setFillColor(HexColor(\"#172033\"))\n    c.setFont(\"Helvetica-Bold\", 14)\n    c.drawString(60, height - 290, \"Contents\")\n    c.setFont(\"Helvetica\", 12)\n    y = height - 320\n    for name, page in contents:\n        c.drawString(72, y, name)\n        c.drawRightString(width - 72, y, str(page))\n        c.setDash(1, 3)\n        c.line(72 + c.stringWidth(name, \"Helvetica\", 12) + 6, y + 3, width - 84 - c.stringWidth(str(page), \"Helvetica\", 12), y + 3)\n        c.setDash()\n        y -= 22\n    c.showPage()\n    c.save()\n\ncontents = [(title, page + 1) for title, page in sections]\ndraw_cover(\"cover.pdf\", \"Quarterly Board Pack\", f\"Prepared {date(2026, 10, 2):%d %B %Y}\", contents)\nprint(\"cover with\", len(contents), \"contents lines\")\n",[14,479,480,492,504,516,528,532,544,562,571,582,625,635,651,670,685,702,712,725,746,759,775,789,801,821,836,906,912,924,930,936,941,967,1032],{"__ignoreMap":39},[43,481,482,484,487,489],{"class":45,"line":46},[43,483,112],{"class":101},[43,485,486],{"class":105}," datetime ",[43,488,102],{"class":101},[43,490,491],{"class":105}," date\n",[43,493,494,496,499,501],{"class":45,"line":109},[43,495,112],{"class":101},[43,497,498],{"class":105}," reportlab.lib.colors ",[43,500,102],{"class":101},[43,502,503],{"class":105}," HexColor\n",[43,505,506,508,511,513],{"class":45,"line":123},[43,507,112],{"class":101},[43,509,510],{"class":105}," reportlab.lib.pagesizes ",[43,512,102],{"class":101},[43,514,515],{"class":105}," letter\n",[43,517,518,520,523,525],{"class":45,"line":130},[43,519,112],{"class":101},[43,521,522],{"class":105}," reportlab.pdfgen ",[43,524,102],{"class":101},[43,526,527],{"class":105}," canvas\n",[43,529,530],{"class":45,"line":186},[43,531,127],{"emptyLinePlaceholder":126},[43,533,534,537,541],{"class":45,"line":202},[43,535,536],{"class":101},"def",[43,538,540],{"class":539},"s_Opv"," draw_cover",[43,542,543],{"class":105},"(path, title, subtitle, contents):\n",[43,545,546,549,551,554,557,559],{"class":45,"line":207},[43,547,548],{"class":105},"    c ",[43,550,167],{"class":101},[43,552,553],{"class":105}," canvas.Canvas(path, ",[43,555,556],{"class":163},"pagesize",[43,558,167],{"class":101},[43,560,561],{"class":105},"letter)\n",[43,563,564,567,569],{"class":45,"line":230},[43,565,566],{"class":105},"    width, height ",[43,568,167],{"class":101},[43,570,515],{"class":105},[43,572,573,576,579],{"class":45,"line":250},[43,574,575],{"class":105},"    c.setFillColor(HexColor(",[43,577,578],{"class":53},"\"#4338CA\"",[43,580,581],{"class":105},"))\n",[43,583,584,587,589,592,595,598,601,604,606,609,611,613,615,618,620,623],{"class":45,"line":263},[43,585,586],{"class":105},"    c.rect(",[43,588,339],{"class":170},[43,590,591],{"class":105},", height ",[43,593,594],{"class":101},"-",[43,596,597],{"class":170}," 220",[43,599,600],{"class":105},", width, ",[43,602,603],{"class":170},"220",[43,605,139],{"class":105},[43,607,608],{"class":163},"stroke",[43,610,167],{"class":101},[43,612,339],{"class":170},[43,614,139],{"class":105},[43,616,617],{"class":163},"fill",[43,619,167],{"class":101},[43,621,622],{"class":170},"1",[43,624,183],{"class":105},[43,626,628,630,633],{"class":45,"line":627},11,[43,629,575],{"class":105},[43,631,632],{"class":53},"\"#FFFFFF\"",[43,634,581],{"class":105},[43,636,638,641,644,646,649],{"class":45,"line":637},12,[43,639,640],{"class":105},"    c.setFont(",[43,642,643],{"class":53},"\"Helvetica-Bold\"",[43,645,139],{"class":105},[43,647,648],{"class":170},"28",[43,650,183],{"class":105},[43,652,654,657,660,662,664,667],{"class":45,"line":653},13,[43,655,656],{"class":105},"    c.drawString(",[43,658,659],{"class":170},"60",[43,661,591],{"class":105},[43,663,594],{"class":101},[43,665,666],{"class":170}," 120",[43,668,669],{"class":105},", title)\n",[43,671,673,675,678,680,683],{"class":45,"line":672},14,[43,674,640],{"class":105},[43,676,677],{"class":53},"\"Helvetica\"",[43,679,139],{"class":105},[43,681,682],{"class":170},"14",[43,684,183],{"class":105},[43,686,688,690,692,694,696,699],{"class":45,"line":687},15,[43,689,656],{"class":105},[43,691,659],{"class":170},[43,693,591],{"class":105},[43,695,594],{"class":101},[43,697,698],{"class":170}," 150",[43,700,701],{"class":105},", subtitle)\n",[43,703,705,707,710],{"class":45,"line":704},16,[43,706,575],{"class":105},[43,708,709],{"class":53},"\"#172033\"",[43,711,581],{"class":105},[43,713,715,717,719,721,723],{"class":45,"line":714},17,[43,716,640],{"class":105},[43,718,643],{"class":53},[43,720,139],{"class":105},[43,722,682],{"class":170},[43,724,183],{"class":105},[43,726,728,730,732,734,736,739,741,744],{"class":45,"line":727},18,[43,729,656],{"class":105},[43,731,659],{"class":170},[43,733,591],{"class":105},[43,735,594],{"class":101},[43,737,738],{"class":170}," 290",[43,740,139],{"class":105},[43,742,743],{"class":53},"\"Contents\"",[43,745,183],{"class":105},[43,747,749,751,753,755,757],{"class":45,"line":748},19,[43,750,640],{"class":105},[43,752,677],{"class":53},[43,754,139],{"class":105},[43,756,349],{"class":170},[43,758,183],{"class":105},[43,760,762,765,767,770,772],{"class":45,"line":761},20,[43,763,764],{"class":105},"    y ",[43,766,167],{"class":101},[43,768,769],{"class":105}," height ",[43,771,594],{"class":101},[43,773,774],{"class":170}," 320\n",[43,776,778,781,784,786],{"class":45,"line":777},21,[43,779,780],{"class":101},"    for",[43,782,783],{"class":105}," name, page ",[43,785,224],{"class":101},[43,787,788],{"class":105}," contents:\n",[43,790,792,795,798],{"class":45,"line":791},22,[43,793,794],{"class":105},"        c.drawString(",[43,796,797],{"class":170},"72",[43,799,800],{"class":105},", y, name)\n",[43,802,804,807,809,812,815,818],{"class":45,"line":803},23,[43,805,806],{"class":105},"        c.drawRightString(width ",[43,808,594],{"class":101},[43,810,811],{"class":170}," 72",[43,813,814],{"class":105},", y, ",[43,816,817],{"class":170},"str",[43,819,820],{"class":105},"(page))\n",[43,822,824,827,829,831,834],{"class":45,"line":823},24,[43,825,826],{"class":105},"        c.setDash(",[43,828,622],{"class":170},[43,830,139],{"class":105},[43,832,833],{"class":170},"3",[43,835,183],{"class":105},[43,837,839,842,844,847,850,852,854,856,859,861,864,867,869,872,875,877,880,883,886,888,891,893,895,897,900,902,904],{"class":45,"line":838},25,[43,840,841],{"class":105},"        c.line(",[43,843,797],{"class":170},[43,845,846],{"class":101}," +",[43,848,849],{"class":105}," c.stringWidth(name, ",[43,851,677],{"class":53},[43,853,139],{"class":105},[43,855,349],{"class":170},[43,857,858],{"class":105},") ",[43,860,298],{"class":101},[43,862,863],{"class":170}," 6",[43,865,866],{"class":105},", y ",[43,868,298],{"class":101},[43,870,871],{"class":170}," 3",[43,873,874],{"class":105},", width ",[43,876,594],{"class":101},[43,878,879],{"class":170}," 84",[43,881,882],{"class":101}," -",[43,884,885],{"class":105}," c.stringWidth(",[43,887,817],{"class":170},[43,889,890],{"class":105},"(page), ",[43,892,677],{"class":53},[43,894,139],{"class":105},[43,896,349],{"class":170},[43,898,899],{"class":105},"), y ",[43,901,298],{"class":101},[43,903,871],{"class":170},[43,905,183],{"class":105},[43,907,909],{"class":45,"line":908},26,[43,910,911],{"class":105},"        c.setDash()\n",[43,913,915,918,921],{"class":45,"line":914},27,[43,916,917],{"class":105},"        y ",[43,919,920],{"class":101},"-=",[43,922,923],{"class":170}," 22\n",[43,925,927],{"class":45,"line":926},28,[43,928,929],{"class":105},"    c.showPage()\n",[43,931,933],{"class":45,"line":932},29,[43,934,935],{"class":105},"    c.save()\n",[43,937,939],{"class":45,"line":938},30,[43,940,127],{"emptyLinePlaceholder":126},[43,942,944,947,949,952,954,956,958,960,962,964],{"class":45,"line":943},31,[43,945,946],{"class":105},"contents ",[43,948,167],{"class":101},[43,950,951],{"class":105}," [(title, page ",[43,953,298],{"class":101},[43,955,301],{"class":170},[43,957,858],{"class":105},[43,959,218],{"class":101},[43,961,255],{"class":105},[43,963,224],{"class":101},[43,965,966],{"class":105}," sections]\n",[43,968,970,973,976,978,981,983,985,988,990,993,996,998,1001,1003,1006,1009,1012,1015,1017,1020,1022,1025,1027,1029],{"class":45,"line":969},32,[43,971,972],{"class":105},"draw_cover(",[43,974,975],{"class":53},"\"cover.pdf\"",[43,977,139],{"class":105},[43,979,980],{"class":53},"\"Quarterly Board Pack\"",[43,982,139],{"class":105},[43,984,271],{"class":101},[43,986,987],{"class":53},"\"Prepared ",[43,989,278],{"class":277},[43,991,992],{"class":105},"date(",[43,994,995],{"class":170},"2026",[43,997,139],{"class":105},[43,999,1000],{"class":170},"10",[43,1002,139],{"class":105},[43,1004,1005],{"class":170},"2",[43,1007,1008],{"class":105},"):",[43,1010,1011],{"class":101},"%",[43,1013,1014],{"class":105},"d ",[43,1016,1011],{"class":101},[43,1018,1019],{"class":105},"B ",[43,1021,1011],{"class":101},[43,1023,1024],{"class":105},"Y",[43,1026,287],{"class":277},[43,1028,306],{"class":53},[43,1030,1031],{"class":105},", contents)\n",[43,1033,1035,1037,1039,1042,1044,1046,1049,1052],{"class":45,"line":1034},33,[43,1036,233],{"class":170},[43,1038,236],{"class":105},[43,1040,1041],{"class":53},"\"cover with\"",[43,1043,139],{"class":105},[43,1045,239],{"class":170},[43,1047,1048],{"class":105},"(contents), ",[43,1050,1051],{"class":53},"\"contents lines\"",[43,1053,183],{"class":105},[10,1055,1056,1057,1060,1061,1064,1065,1068],{},"The cover uses the same page size as the report (US Letter here, which is what this LibreOffice installation printed; use ",[14,1058,1059],{},"A4"," from ",[14,1062,1063],{},"reportlab.lib.pagesizes"," if your sheets print on A4). Dotted leaders between each name and its page number are drawn with a dashed line. A logo can be added with ",[14,1066,1067],{},"c.drawImage()",". Keep the cover simple and data-driven: the title, the reporting period and the preparation date are passed in as arguments, so a monthly job produces the right cover without anyone editing a template, and the contents list can never disagree with the bookmarks because both come from the same outline.",[29,1070,1072],{"id":1071},"step-3-assemble-with-a-nested-bookmark-tree","Step 3 — Assemble with a nested bookmark tree",[10,1074,1075],{},"Joining the cover and the report shifts every page by one, so the sheet bookmarks must be rebuilt rather than copied. Building them afresh is also the chance to group them — here the region sheets go under a \"Regions\" parent:",[34,1077,1079],{"className":92,"code":1078,"language":94,"meta":39,"style":39},"from pypdf import PdfWriter\n\nwriter = PdfWriter()\nwriter.append(\"cover.pdf\")\nwriter.append(\"board_pack.pdf\", import_outline=False)\noffset = 1                                            # pages before the report\n\nwriter.add_outline_item(\"Cover\", 0)\nregions_parent = None\nfor title, page in sections:\n    if title == \"Summary\":\n        writer.add_outline_item(\"Summary\", page + offset)\n    else:\n        if regions_parent is None:\n            regions_parent = writer.add_outline_item(\"Regions\", page + offset)\n        writer.add_outline_item(title, page + offset, parent=regions_parent)\n\nwriter.set_page_label(0, 0, style=\"\u002Fr\")               # cover shows as \"i\"\nwriter.set_page_label(1, len(writer.pages) - 1, style=\"\u002FD\", start=1)\nwriter.page_mode = \"\u002FUseOutlines\"\nwriter.add_metadata({\"\u002FTitle\": \"Quarterly Board Pack\", \"\u002FAuthor\": \"Finance\"})\nwriter.write(\"board_pack_final.pdf\")\nprint(len(writer.pages), \"pages written\")\n",[14,1080,1081,1092,1096,1106,1115,1133,1146,1150,1164,1174,1184,1201,1217,1224,1240,1259,1277,1281,1308,1345,1355,1381,1391],{"__ignoreMap":39},[43,1082,1083,1085,1087,1089],{"class":45,"line":46},[43,1084,112],{"class":101},[43,1086,115],{"class":105},[43,1088,102],{"class":101},[43,1090,1091],{"class":105}," PdfWriter\n",[43,1093,1094],{"class":45,"line":109},[43,1095,127],{"emptyLinePlaceholder":126},[43,1097,1098,1101,1103],{"class":45,"line":123},[43,1099,1100],{"class":105},"writer ",[43,1102,167],{"class":101},[43,1104,1105],{"class":105}," PdfWriter()\n",[43,1107,1108,1111,1113],{"class":45,"line":130},[43,1109,1110],{"class":105},"writer.append(",[43,1112,975],{"class":53},[43,1114,183],{"class":105},[43,1116,1117,1119,1121,1123,1126,1128,1131],{"class":45,"line":186},[43,1118,1110],{"class":105},[43,1120,197],{"class":53},[43,1122,139],{"class":105},[43,1124,1125],{"class":163},"import_outline",[43,1127,167],{"class":101},[43,1129,1130],{"class":170},"False",[43,1132,183],{"class":105},[43,1134,1135,1138,1140,1142],{"class":45,"line":202},[43,1136,1137],{"class":105},"offset ",[43,1139,167],{"class":101},[43,1141,301],{"class":170},[43,1143,1145],{"class":1144},"s-wDw","                                            # pages before the report\n",[43,1147,1148],{"class":45,"line":207},[43,1149,127],{"emptyLinePlaceholder":126},[43,1151,1152,1155,1158,1160,1162],{"class":45,"line":230},[43,1153,1154],{"class":105},"writer.add_outline_item(",[43,1156,1157],{"class":53},"\"Cover\"",[43,1159,139],{"class":105},[43,1161,339],{"class":170},[43,1163,183],{"class":105},[43,1165,1166,1169,1171],{"class":45,"line":250},[43,1167,1168],{"class":105},"regions_parent ",[43,1170,167],{"class":101},[43,1172,1173],{"class":170}," None\n",[43,1175,1176,1178,1180,1182],{"class":45,"line":263},[43,1177,218],{"class":101},[43,1179,255],{"class":105},[43,1181,224],{"class":101},[43,1183,260],{"class":105},[43,1185,1186,1189,1192,1195,1198],{"class":45,"line":627},[43,1187,1188],{"class":101},"    if",[43,1190,1191],{"class":105}," title ",[43,1193,1194],{"class":101},"==",[43,1196,1197],{"class":53}," \"Summary\"",[43,1199,1200],{"class":105},":\n",[43,1202,1203,1206,1209,1212,1214],{"class":45,"line":637},[43,1204,1205],{"class":105},"        writer.add_outline_item(",[43,1207,1208],{"class":53},"\"Summary\"",[43,1210,1211],{"class":105},", page ",[43,1213,298],{"class":101},[43,1215,1216],{"class":105}," offset)\n",[43,1218,1219,1222],{"class":45,"line":653},[43,1220,1221],{"class":101},"    else",[43,1223,1200],{"class":105},[43,1225,1226,1229,1232,1235,1238],{"class":45,"line":672},[43,1227,1228],{"class":101},"        if",[43,1230,1231],{"class":105}," regions_parent ",[43,1233,1234],{"class":101},"is",[43,1236,1237],{"class":170}," None",[43,1239,1200],{"class":105},[43,1241,1242,1245,1247,1250,1253,1255,1257],{"class":45,"line":687},[43,1243,1244],{"class":105},"            regions_parent ",[43,1246,167],{"class":101},[43,1248,1249],{"class":105}," writer.add_outline_item(",[43,1251,1252],{"class":53},"\"Regions\"",[43,1254,1211],{"class":105},[43,1256,298],{"class":101},[43,1258,1216],{"class":105},[43,1260,1261,1264,1266,1269,1272,1274],{"class":45,"line":704},[43,1262,1263],{"class":105},"        writer.add_outline_item(title, page ",[43,1265,298],{"class":101},[43,1267,1268],{"class":105}," offset, ",[43,1270,1271],{"class":163},"parent",[43,1273,167],{"class":101},[43,1275,1276],{"class":105},"regions_parent)\n",[43,1278,1279],{"class":45,"line":714},[43,1280,127],{"emptyLinePlaceholder":126},[43,1282,1283,1286,1288,1290,1292,1294,1297,1299,1302,1305],{"class":45,"line":727},[43,1284,1285],{"class":105},"writer.set_page_label(",[43,1287,339],{"class":170},[43,1289,139],{"class":105},[43,1291,339],{"class":170},[43,1293,139],{"class":105},[43,1295,1296],{"class":163},"style",[43,1298,167],{"class":101},[43,1300,1301],{"class":53},"\"\u002Fr\"",[43,1303,1304],{"class":105},")               ",[43,1306,1307],{"class":1144},"# cover shows as \"i\"\n",[43,1309,1310,1312,1314,1316,1318,1321,1323,1325,1327,1329,1331,1334,1336,1339,1341,1343],{"class":45,"line":748},[43,1311,1285],{"class":105},[43,1313,622],{"class":170},[43,1315,139],{"class":105},[43,1317,239],{"class":170},[43,1319,1320],{"class":105},"(writer.pages) ",[43,1322,594],{"class":101},[43,1324,301],{"class":170},[43,1326,139],{"class":105},[43,1328,1296],{"class":163},[43,1330,167],{"class":101},[43,1332,1333],{"class":53},"\"\u002FD\"",[43,1335,139],{"class":105},[43,1337,1338],{"class":163},"start",[43,1340,167],{"class":101},[43,1342,622],{"class":170},[43,1344,183],{"class":105},[43,1346,1347,1350,1352],{"class":45,"line":761},[43,1348,1349],{"class":105},"writer.page_mode ",[43,1351,167],{"class":101},[43,1353,1354],{"class":53}," \"\u002FUseOutlines\"\n",[43,1356,1357,1360,1363,1366,1368,1370,1373,1375,1378],{"class":45,"line":777},[43,1358,1359],{"class":105},"writer.add_metadata({",[43,1361,1362],{"class":53},"\"\u002FTitle\"",[43,1364,1365],{"class":105},": ",[43,1367,980],{"class":53},[43,1369,139],{"class":105},[43,1371,1372],{"class":53},"\"\u002FAuthor\"",[43,1374,1365],{"class":105},[43,1376,1377],{"class":53},"\"Finance\"",[43,1379,1380],{"class":105},"})\n",[43,1382,1383,1386,1389],{"class":45,"line":791},[43,1384,1385],{"class":105},"writer.write(",[43,1387,1388],{"class":53},"\"board_pack_final.pdf\"",[43,1390,183],{"class":105},[43,1392,1393,1395,1397,1399,1402,1405],{"class":45,"line":803},[43,1394,233],{"class":170},[43,1396,236],{"class":105},[43,1398,239],{"class":170},[43,1400,1401],{"class":105},"(writer.pages), ",[43,1403,1404],{"class":53},"\"pages written\"",[43,1406,183],{"class":105},[10,1408,1409,1412,1413,1416,1417,1420],{},[14,1410,1411],{},"import_outline=False"," stops pypdf copying the old bookmarks, which would point at the wrong pages after the shift. ",[14,1414,1415],{},"add_outline_item(title, page, parent=…)"," returns the new item, so it can be the parent of later ones; nesting can go as deep as the report needs. Page labels change what the viewer's page box shows: the cover reads \"i\" and the first report page reads \"1\", which matches the numbers printed on the cover. ",[14,1418,1419],{},"page_mode"," makes the bookmarks panel open with the file, and the metadata title appears in the viewer's title bar instead of the file name.",[318,1422,327,1428,327,1431,327,1434,327,1439,327,1444,327,1449,327,1453,327,1456,327,1459,327,1463,327,1467,327,1470,327,1473,327,1477],{"viewBox":1423,"role":321,"ariaLabelledBy":1424,"xmlns":325,"style":1427},"84 0 592 275",[1425,1426],"bm-tree-t","bm-tree-d","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[281,1429,1430],{"id":1425},"The bookmark tree in the finished PDF",[332,1432,1433],{"id":1426},"The top level holds Cover, Summary and Regions. Under Regions sit North, South, East and West, each pointing at the first page of its sheet. The cover is labelled i and the report pages are numbered from 1.",[336,1435],{"x":1436,"y":339,"width":1437,"height":1438,"fill":342},"84","592","275",[336,1440],{"x":1441,"y":346,"width":1442,"height":1443,"rx":349,"fill":350,"stroke":351,"style":352},"100.0","560.0","56.0",[365,1445,1448],{"x":1446,"y":1447,"style":369},"380.0","48.8","Cover · page i",[45,1450],{"x1":1446,"y1":1451,"x2":1446,"y2":1452,"stroke":385,"style":352},"77.0","99.0",[387,1454],{"points":1455,"fill":390},"380.0,103.0 375.0,94.0 385.0,94.0",[336,1457],{"x":1441,"y":1458,"width":1442,"height":1443,"rx":349,"fill":394,"stroke":385,"style":352},"108.0",[365,1460,1462],{"x":1446,"y":1461,"style":406},"140.8","Summary · page 1",[45,1464],{"x1":1446,"y1":1465,"x2":1446,"y2":1466,"stroke":385,"style":352},"169.0","191.0",[387,1468],{"points":1469,"fill":390},"380.0,195.0 375.0,186.0 385.0,186.0",[336,1471],{"x":1441,"y":384,"width":1442,"height":1472,"rx":349,"fill":422,"stroke":423,"style":352},"59.0",[365,1474,1476],{"x":1446,"y":1475,"style":434},"225.5","Regions",[365,1478,1480],{"x":1446,"y":1479,"style":374},"245.0","North · South · East · West",[29,1482,1484],{"id":1483},"step-4-test-the-finished-pdf","Step 4 — Test the finished PDF",[10,1486,1487],{},"A short check confirms the structure — that each bookmark lands on the page whose text belongs to its sheet, and that the labels and page mode are set:",[34,1489,1491],{"className":92,"code":1490,"language":94,"meta":39,"style":39},"final = PdfReader(\"board_pack_final.pdf\")\n\ndef walk(items, depth=0):\n    for item in items:\n        if isinstance(item, list):\n            yield from walk(item, depth + 1)\n        else:\n            yield depth, item.title, final.get_destination_page_number(item)\n\nfor depth, title, page in walk(final.outline):\n    label = final.page_labels[page]\n    lines = final.pages[page].extract_text().split(\"\\n\")\n    print(f\"{'  ' * depth}{title:8} → page {label:>2}  first row: {lines[1][:22] if len(lines) > 1 else lines[0]}\")\nprint(\"opens with:\", final.page_mode)\n",[14,1492,1493,1506,1510,1527,1538,1553,1567,1574,1582,1586,1598,1608,1627,1720],{"__ignoreMap":39},[43,1494,1495,1498,1500,1502,1504],{"class":45,"line":46},[43,1496,1497],{"class":105},"final ",[43,1499,167],{"class":101},[43,1501,194],{"class":105},[43,1503,1388],{"class":53},[43,1505,183],{"class":105},[43,1507,1508],{"class":45,"line":109},[43,1509,127],{"emptyLinePlaceholder":126},[43,1511,1512,1514,1517,1520,1522,1524],{"class":45,"line":123},[43,1513,536],{"class":101},[43,1515,1516],{"class":539}," walk",[43,1518,1519],{"class":105},"(items, depth",[43,1521,167],{"class":101},[43,1523,339],{"class":170},[43,1525,1526],{"class":105},"):\n",[43,1528,1529,1531,1533,1535],{"class":45,"line":130},[43,1530,780],{"class":101},[43,1532,221],{"class":105},[43,1534,224],{"class":101},[43,1536,1537],{"class":105}," items:\n",[43,1539,1540,1542,1545,1548,1551],{"class":45,"line":186},[43,1541,1228],{"class":101},[43,1543,1544],{"class":170}," isinstance",[43,1546,1547],{"class":105},"(item, ",[43,1549,1550],{"class":170},"list",[43,1552,1526],{"class":105},[43,1554,1555,1558,1561,1563,1565],{"class":45,"line":202},[43,1556,1557],{"class":101},"            yield from",[43,1559,1560],{"class":105}," walk(item, depth ",[43,1562,298],{"class":101},[43,1564,301],{"class":170},[43,1566,183],{"class":105},[43,1568,1569,1572],{"class":45,"line":207},[43,1570,1571],{"class":101},"        else",[43,1573,1200],{"class":105},[43,1575,1576,1579],{"class":45,"line":230},[43,1577,1578],{"class":101},"            yield",[43,1580,1581],{"class":105}," depth, item.title, final.get_destination_page_number(item)\n",[43,1583,1584],{"class":45,"line":250},[43,1585,127],{"emptyLinePlaceholder":126},[43,1587,1588,1590,1593,1595],{"class":45,"line":263},[43,1589,218],{"class":101},[43,1591,1592],{"class":105}," depth, title, page ",[43,1594,224],{"class":101},[43,1596,1597],{"class":105}," walk(final.outline):\n",[43,1599,1600,1603,1605],{"class":45,"line":627},[43,1601,1602],{"class":105},"    label ",[43,1604,167],{"class":101},[43,1606,1607],{"class":105}," final.page_labels[page]\n",[43,1609,1610,1613,1615,1618,1620,1623,1625],{"class":45,"line":637},[43,1611,1612],{"class":105},"    lines ",[43,1614,167],{"class":101},[43,1616,1617],{"class":105}," final.pages[page].extract_text().split(",[43,1619,306],{"class":53},[43,1621,1622],{"class":277},"\\n",[43,1624,306],{"class":53},[43,1626,183],{"class":105},[43,1628,1629,1631,1633,1635,1637,1639,1642,1645,1648,1651,1653,1655,1657,1660,1662,1665,1668,1670,1673,1675,1678,1680,1683,1686,1689,1692,1695,1698,1701,1703,1706,1709,1711,1714,1716,1718],{"class":45,"line":653},[43,1630,266],{"class":170},[43,1632,236],{"class":105},[43,1634,271],{"class":101},[43,1636,306],{"class":53},[43,1638,278],{"class":277},[43,1640,1641],{"class":53},"'  '",[43,1643,1644],{"class":101}," *",[43,1646,1647],{"class":105}," depth",[43,1649,1650],{"class":277},"}{",[43,1652,281],{"class":105},[43,1654,284],{"class":101},[43,1656,287],{"class":277},[43,1658,1659],{"class":53}," → page ",[43,1661,278],{"class":277},[43,1663,1664],{"class":105},"label",[43,1666,1667],{"class":101},":>2",[43,1669,287],{"class":277},[43,1671,1672],{"class":53},"  first row: ",[43,1674,278],{"class":277},[43,1676,1677],{"class":105},"lines[",[43,1679,622],{"class":170},[43,1681,1682],{"class":105},"][:",[43,1684,1685],{"class":170},"22",[43,1687,1688],{"class":105},"] ",[43,1690,1691],{"class":101},"if",[43,1693,1694],{"class":170}," len",[43,1696,1697],{"class":105},"(lines) ",[43,1699,1700],{"class":101},">",[43,1702,301],{"class":170},[43,1704,1705],{"class":101}," else",[43,1707,1708],{"class":105}," lines[",[43,1710,339],{"class":170},[43,1712,1713],{"class":105},"]",[43,1715,287],{"class":277},[43,1717,306],{"class":53},[43,1719,183],{"class":105},[43,1721,1722,1724,1726,1729],{"class":45,"line":672},[43,1723,233],{"class":170},[43,1725,236],{"class":105},[43,1727,1728],{"class":53},"\"opens with:\"",[43,1730,1731],{"class":105},", final.page_mode)\n",[10,1733,1734],{},"Each region bookmark lands on a page whose first data row belongs to that region — order N-001 for North, S-001 for South and so on — and the labels run from \"i\" on the cover to \"1\" on the summary. The checks are cheap enough to run on every generated pack, and they catch the usual mistakes: an off-by-one after adding a cover, or bookmarks copied from the original file.",[29,1736,1738],{"id":1737},"variations","Variations",[1740,1741,1742,1759,1765],"ul",{},[1743,1744,1745,1749,1750,1754,1755,1758],"li",{},[1746,1747,1748],"strong",{},"One PDF per sheet, then join."," If sheets come from separate conversions — see ",[23,1751,1753],{"href":1752},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-only-selected-sheets-of-a-workbook-to-pdf\u002F","Convert Only Selected Sheets of a Workbook to PDF"," — append each file with ",[14,1756,1757],{},"writer.append(path, outline_item=name)",", which adds a bookmark for it at the right page as it goes.",[1743,1760,1761,1764],{},[1746,1762,1763],{},"A cover sheet in the workbook."," Instead of reportlab, put a styled cover sheet first in the workbook. The contents numbers then have to be filled in after a first conversion, so the reportlab route is simpler.",[1743,1766,1767,1770,1771,1775],{},[1746,1768,1769],{},"Page numbers in the footer."," Footer numbers come from the sheet's page setup; see ",[23,1772,1774],{"href":1773},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-headers-footers-and-page-numbers-to-an-excel-pdf\u002F","Add Headers, Footers and Page Numbers to an Excel PDF",". They count from the sheet, not the pack, so keep them consistent with the labels.",[10,1777,1778,1779,1783],{},"Joining several exported files without a cover is covered in ",[23,1780,1782],{"href":1781},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fmerge-excel-pdf-exports-into-one-file\u002F","Merge Excel PDF Exports into One File",". If the pack must be locked, encrypt it as the very last step, after bookmarks and labels are in place.",[318,1785,327,1791,327,1794,327,1797,327,1802,327,1808,327,1812,327,1818,327,1822,327,1826,327,1830,327,1833,327,1836,327,1840,327,1842,327,1845,327,1848,327,1851,327,1853,327,1857,327,1859,327,1862,327,1865,327,1868,327,1870,327,1873,327,1875,327,1877,327,1880,327,1883,327,1885,327,1888,327,1890,327,1892,327,1895,327,1899,327,1901,327,1904,327,1906,327,1908,327,1911,327,1915,327,1917,327,1920,327,1922],{"viewBox":1786,"role":321,"ariaLabelledBy":1787,"xmlns":325,"style":1790},"259 0 242 224",[1788,1789],"bm-cover-t","bm-cover-d","width:100%;max-width:242px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[281,1792,1793],{"id":1788},"The contents list on the cover",[332,1795,1796],{"id":1789},"The cover's contents list shows each section with the report page it starts on, taken from the bookmarks LibreOffice wrote. Longer region sheets push later sections further down.",[336,1798],{"x":1799,"y":339,"width":1800,"height":1801,"fill":342},"259","242","224",[336,1803],{"x":1804,"y":346,"width":1805,"height":1806,"rx":339,"fill":350,"stroke":351,"style":1807},"275.1","32.0","24.0","stroke-width:1px",[336,1809],{"x":1810,"y":346,"width":1811,"height":1806,"rx":339,"fill":350,"stroke":351,"style":1807},"307.1","75.8",[365,1813,1817],{"x":1814,"y":1815,"style":1816},"344.9","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[336,1819],{"x":1820,"y":346,"width":1821,"height":1806,"rx":339,"fill":350,"stroke":351,"style":1807},"382.8","102.1",[365,1823,1825],{"x":1824,"y":1815,"style":1816},"433.9","B",[336,1827],{"x":1804,"y":1828,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"40.0","28.0",[365,1831,622],{"x":1832,"y":1472,"style":1816},"291.1",[336,1834],{"x":1810,"y":1828,"width":1811,"height":1829,"rx":339,"fill":394,"stroke":385,"style":1835},"stroke-width:1.5px",[365,1837,1839],{"x":1814,"y":1472,"style":1838},"font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Section",[336,1841],{"x":1820,"y":1828,"width":1821,"height":1829,"rx":339,"fill":394,"stroke":385,"style":1835},[365,1843,1844],{"x":1824,"y":1472,"style":1838},"Starts on page",[336,1846],{"x":1804,"y":1847,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"68.0",[365,1849,1005],{"x":1832,"y":1850,"style":1816},"87.0",[336,1852],{"x":1810,"y":1847,"width":1811,"height":1829,"rx":339,"fill":342,"stroke":351,"style":1807},[365,1854,1856],{"x":1814,"y":1850,"style":1855},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Summary",[336,1858],{"x":1820,"y":1847,"width":1821,"height":1829,"rx":339,"fill":422,"stroke":423,"style":1835},[365,1860,622],{"x":1824,"y":1850,"style":1861},"font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[336,1863],{"x":1804,"y":1864,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"96.0",[365,1866,833],{"x":1832,"y":1867,"style":1816},"115.0",[336,1869],{"x":1810,"y":1864,"width":1811,"height":1829,"rx":339,"fill":342,"stroke":351,"style":1807},[365,1871,1872],{"x":1814,"y":1867,"style":1855},"North",[336,1874],{"x":1820,"y":1864,"width":1821,"height":1829,"rx":339,"fill":422,"stroke":423,"style":1835},[365,1876,1005],{"x":1824,"y":1867,"style":1861},[336,1878],{"x":1804,"y":1879,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"124.0",[365,1881,338],{"x":1832,"y":1882,"style":1816},"143.0",[336,1884],{"x":1810,"y":1879,"width":1811,"height":1829,"rx":339,"fill":342,"stroke":351,"style":1807},[365,1886,1887],{"x":1814,"y":1882,"style":1855},"South",[336,1889],{"x":1820,"y":1879,"width":1821,"height":1829,"rx":339,"fill":422,"stroke":423,"style":1835},[365,1891,338],{"x":1824,"y":1882,"style":1861},[336,1893],{"x":1804,"y":1894,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"152.0",[365,1896,1898],{"x":1832,"y":1897,"style":1816},"171.0","5",[336,1900],{"x":1810,"y":1894,"width":1811,"height":1829,"rx":339,"fill":342,"stroke":351,"style":1807},[365,1902,1903],{"x":1814,"y":1897,"style":1855},"East",[336,1905],{"x":1820,"y":1894,"width":1821,"height":1829,"rx":339,"fill":422,"stroke":423,"style":1835},[365,1907,1898],{"x":1824,"y":1897,"style":1861},[336,1909],{"x":1804,"y":1910,"width":1805,"height":1829,"rx":339,"fill":350,"stroke":351,"style":1807},"180.0",[365,1912,1914],{"x":1832,"y":1913,"style":1816},"199.0","6",[336,1916],{"x":1810,"y":1910,"width":1811,"height":1829,"rx":339,"fill":342,"stroke":351,"style":1807},[365,1918,1919],{"x":1814,"y":1913,"style":1855},"West",[336,1921],{"x":1820,"y":1910,"width":1821,"height":1829,"rx":339,"fill":422,"stroke":423,"style":1835},[365,1923,1924],{"x":1824,"y":1913,"style":1861},"7",[29,1926,1928],{"id":1927},"common-pitfalls","Common pitfalls",[1740,1930,1931,1940,1946,1952,1958],{},[1743,1932,1933,1936,1937,1939],{},[1746,1934,1935],{},"Copying the old bookmarks after adding a cover."," They point one page early; pass ",[14,1938,1411],{}," and rebuild.",[1743,1941,1942,1945],{},[1746,1943,1944],{},"Guessing start pages from row counts."," Page breaks depend on layout; read LibreOffice's outline.",[1743,1947,1948,1951],{},[1746,1949,1950],{},"Cover numbered page 1."," Set page labels so the report starts at 1.",[1743,1953,1954,1957],{},[1746,1955,1956],{},"A cover in a different page size."," Match the report's paper size.",[1743,1959,1960,1963],{},[1746,1961,1962],{},"Encrypting before adding bookmarks."," Lock the file last.",[29,1965,1967],{"id":1966},"conclusion","Conclusion",[10,1969,1970,1971,1974],{},"LibreOffice's PDF export already writes a bookmark per sheet, which gives you each sheet's start page. Draw a cover with reportlab using those numbers, join it in front with pypdf while skipping the old outline, rebuild a nested bookmark tree with ",[14,1972,1973],{},"add_outline_item",", set page labels so the cover is \"i\" and the report starts at 1, and open the file with the bookmarks panel. A test that walks the outline and checks each target page keeps the pack correct as sheets grow.",[29,1976,1978],{"id":1977},"frequently-asked-questions","Frequently asked questions",[10,1980,1981,1984],{},[1746,1982,1983],{},"Does LibreOffice add bookmarks when converting Excel to PDF?","\nYes. In our test each sheet became a bookmark pointing at its first page, and the PDF was set to open with the bookmarks panel. pypdf reads them from the reader's outline.",[10,1986,1987,1990],{},[1746,1988,1989],{},"How do I add bookmarks to a PDF with Python?","\nUse pypdf's PdfWriter.add_outline_item with a title and page index. It returns the item, which can be passed as parent to nest further bookmarks underneath it.",[10,1992,1993,1996],{},[1746,1994,1995],{},"How do I add a cover page to a PDF with Python?","\nDraw the cover with reportlab, then create a pypdf PdfWriter, append the cover first and the report after it, and rebuild the bookmarks because every page has moved by one.",[10,1998,1999,2002],{},[1746,2000,2001],{},"How do I stop the cover page counting as page 1?","\nSet page labels with set_page_label: roman numerals for the cover and decimal numbers starting at 1 for the first report page.",[29,2004,2006],{"id":2005},"related","Related",[1740,2008,2009,2015,2022,2029],{},[1743,2010,2011,2012,2014],{},"Up one level: ",[23,2013,26],{"href":25}," — turning workbooks into PDFs.",[1743,2016,2017,2021],{},[23,2018,2020],{"href":2019},"\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 step.",[1743,2023,2024,2028],{},[23,2025,2027],{"href":2026},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fpassword-protect-a-pdf-exported-from-excel\u002F","Password-Protect a PDF Exported from Excel"," — locking the finished pack.",[1743,2030,2031,2035],{},[23,2032,2034],{"href":2033},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002F","Building Multi-Sheet Excel Dashboards"," — the workbook behind a board pack.",[1296,2037,2038],{},"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}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":39,"searchDepth":109,"depth":109,"links":2040},[2041,2042,2043,2044,2045,2046,2047,2048,2049,2050],{"id":31,"depth":109,"text":32},{"id":85,"depth":109,"text":86},{"id":468,"depth":109,"text":469},{"id":1071,"depth":109,"text":1072},{"id":1483,"depth":109,"text":1484},{"id":1737,"depth":109,"text":1738},{"id":1927,"depth":109,"text":1928},{"id":1966,"depth":109,"text":1967},{"id":1977,"depth":109,"text":1978},{"id":2005,"depth":109,"text":2006},"2026-10-02","Finish a multi-sheet Excel PDF export like a real report: read the sheet bookmarks LibreOffice already writes, prepend a cover page with a contents list built with reportlab, rebuild a nested bookmark tree with pypdf, number pages so the cover is not page 1, open the PDF with the bookmarks panel showing, and test the result.","md",[2055,2057,2059,2061],{"q":1983,"a":2056},"Yes. In our test each sheet became a bookmark pointing at its first page, and the PDF was set to open with the bookmarks panel. pypdf reads them from the reader's outline.",{"q":1989,"a":2058},"Use pypdf's PdfWriter.add_outline_item with a title and page index. It returns the item, which can be passed as parent to nest further bookmarks underneath it.",{"q":1995,"a":2060},"Draw the cover with reportlab, then create a pypdf PdfWriter, append the cover first and the report after it, and rebuild the bookmarks because every page has moved by one.",{"q":2001,"a":2062},"Set page labels with set_page_label: roman numerals for the cover and decimal numbers starting at 1 for the first report page.","add bookmarks and a cover page to an excel pdf export",null,{"breadcrumb":2066},[2067,2070,2073],{"name":2068,"item":2069},"Home","\u002F",{"name":2071,"item":2072},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":26,"item":25},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export",{"title":2076,"description":2077},"Add Bookmarks and a Cover Page to an Excel PDF with Python","Turn a multi-sheet Excel PDF export into a navigable report: sheet bookmarks from LibreOffice, a reportlab cover with contents, nested pypdf outline items, page labels and a test of the bookmark targets.","add-bookmarks-and-a-cover-page-to-an-excel-pdf-export","automating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-bookmarks-and-a-cover-page-to-an-excel-pdf-export\u002Findex",[2081,2082],"pdf-export","report-layouts","how-to","smyWDujJ_JCFO7rUyegU8cagQvnSjcVOK171pi7Exg4",[2086,2089],{"title":26,"path":2087,"stem":2088,"children":-1},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf","automating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Findex",{"title":1774,"path":2090,"stem":2091,"children":-1},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-headers-footers-and-page-numbers-to-an-excel-pdf","automating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fadd-headers-footers-and-page-numbers-to-an-excel-pdf\u002Findex",[2093,2094],{"path":2087,"title":26},{"path":2095,"title":2071},"\u002Fautomating-reporting-workflows",[],[2098,2101,2104,2105,2108,2111],{"path":2099,"title":2100,"kind":2083},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-pdf-forms-from-excel-rows-with-python","Fill PDF Forms from Excel Rows with Python",{"path":2102,"title":2103,"kind":2083},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-pdf-per-excel-row","Generate One PDF per Excel Row",{"path":2090,"title":1774,"kind":2083},{"path":2106,"title":2107,"kind":2083},"\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":2109,"title":2110,"kind":2083},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-file-to-pdf-with-python","Convert an Excel File to PDF with Python",{"path":2112,"title":2020,"kind":2083},"\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Fconvert-excel-to-pdf-on-linux-with-libreoffice",1790972132319]