[{"data":1,"prerenderedAt":1684},["ShallowReactive",2],{"blog-en-why-gpdf-is-faster":3},{"id":4,"title":5,"author":6,"body":9,"date":1670,"description":1671,"draft":1672,"extension":1673,"howTo":1674,"image":1674,"meta":1675,"navigation":954,"path":1676,"seo":1677,"stem":1678,"tags":1679,"updated":1674,"__hash__":1683},"blog/blog/011.why-gpdf-is-faster.md","Why gpdf is 10–30× faster than other Go PDF libraries",{"name":7,"url":8},"gpdf team","https://gpdf.dev",{"type":10,"value":11,"toc":1648},"minimark",[12,17,43,69,72,85,88,92,99,215,225,228,235,239,242,252,263,266,273,607,644,667,670,675,678,695,698,709,712,719,722,732,829,859,862,865,1018,1024,1034,1038,1044,1050,1056,1059,1065,1068,1072,1082,1085,1089,1092,1095,1098,1128,1135,1138,1145,1276,1279,1283,1296,1299,1303,1316,1323,1327,1330,1408,1411,1414,1418,1421,1427,1437,1451,1460,1463,1467,1470,1513,1522,1525,1537,1541,1551,1557,1570,1576,1582,1586,1589,1604,1617,1621,1644],[13,14,16],"h2",{"id":15},"tldr","TL;DR",[18,19,20,21,25,26,29,30,33,34,38,39,42],"p",{},"gpdf renders a single page in ",[22,23,24],"strong",{},"13 µs",", a 4×10 invoice table in ",[22,27,28],{},"108 µs",", and a 100-page paginated report in ",[22,31,32],{},"683 µs",". The next-fastest maintained Go PDF library — ",[35,36,37],"code",{},"jung-kurt/gofpdf"," — does the same 100-page job in ",[22,40,41],{},"11.7 ms",", roughly 17× slower. It's not a tuning difference. It's three design choices that stack:",[44,45,46,53,63],"ol",{},[47,48,49,52],"li",{},[22,50,51],{},"Single-pass layout."," No intermediate AST between the builder API and the PDF content stream.",[47,54,55,58,59,62],{},[22,56,57],{},"Concrete types on the hot path."," No reflection, no ",[35,60,61],{},"interface{}",", no virtual dispatch inside the layout loop.",[47,64,65,68],{},[22,66,67],{},"A TrueType subsetter that resolves the cmap once."," Not once per glyph. Not once per page. Once.",[18,70,71],{},"Any one of these gets you 2–3×. Stacked, you're at an order of magnitude.",[18,73,74,75,84],{},"This post walks the code path that produces those numbers. The benchmark source is public — ",[76,77,81],"a",{"href":78,"rel":79},"https://github.com/gpdf-dev/gpdf/tree/main/_benchmark",[80],"nofollow",[35,82,83],{},"_benchmark/benchmark_test.go"," — clone it, re-run on your hardware, open an issue if the numbers disagree.",[18,86,87],{},"Bias disclosure up front: we ship gpdf. The honest version of \"we're faster\" is \"we made a different set of trade-offs,\" and the interesting question is what we gave up to get here. That's the second half of the post.",[13,89,91],{"id":90},"what-fast-actually-means-here","What \"fast\" actually means here",[18,93,94,95,98],{},"Before the architecture, the scoreboard we're explaining (Apple M1, Go 1.25, no CGO, ",[35,96,97],{},"-benchmem"," enabled):",[100,101,102,127],"table",{},[103,104,105],"thead",{},[106,107,108,112,115,118,121,124],"tr",{},[109,110,111],"th",{},"Workload",[109,113,114],{},"gpdf",[109,116,117],{},"gofpdf",[109,119,120],{},"go-pdf/fpdf",[109,122,123],{},"signintech/gopdf",[109,125,126],{},"Maroto v2",[128,129,130,152,173,193],"tbody",{},[106,131,132,136,140,143,146,149],{},[133,134,135],"td",{},"Single page hello world",[133,137,138],{},[22,139,24],{},[133,141,142],{},"132 µs",[133,144,145],{},"135 µs",[133,147,148],{},"423 µs",[133,150,151],{},"237 µs",[106,153,154,157,161,164,167,170],{},[133,155,156],{},"4×10 invoice table",[133,158,159],{},[22,160,28],{},[133,162,163],{},"241 µs",[133,165,166],{},"243 µs",[133,168,169],{},"835 µs",[133,171,172],{},"8,600 µs",[106,174,175,178,182,185,188,190],{},[133,176,177],{},"100-page paginated report",[133,179,180],{},[22,181,32],{},[133,183,184],{},"11,700 µs",[133,186,187],{},"11,900 µs",[133,189,172],{},[133,191,192],{},"19,800 µs",[106,194,195,198,203,206,209,212],{},[133,196,197],{},"Complex CJK invoice",[133,199,200],{},[22,201,202],{},"133 µs",[133,204,205],{},"254 µs",[133,207,208],{},"n/a",[133,210,211],{},"997 µs",[133,213,214],{},"10,400 µs",[18,216,217,218,221,222,224],{},"Two things you'll notice before we explain them. The gap ",[22,219,220],{},"widens"," with page count — 10× on a hello world, 17× on 100 pages. And the gap ",[22,223,220],{}," with complexity — 108 µs for a table versus 8.6 ms for the same table through Maroto's gofpdf backend.",[18,226,227],{},"Both of those shapes come from the same root cause: the cost per element in gpdf is nearly flat, because the layout loop doesn't allocate on the common path. We'll get to why.",[18,229,230,231,234],{},"Quick disclaimer nobody wants to read but we're writing anyway: ",[22,232,233],{},"absolute speed matters less than people think for most PDF workloads",". If your biggest document is a one-page receipt, every maintained library in that table is fast enough to generate on the request path. The threshold that matters is \"can I generate 100 of these synchronously in a batch without pushing to a queue,\" and that's where the gap starts mattering.",[13,236,238],{"id":237},"decision-1-no-intermediate-ast","Decision 1: No intermediate AST",[18,240,241],{},"Most PDF builder libraries work like this:",[243,244,249],"pre",{"className":245,"code":247,"language":248},[246],"language-text","builder API → document tree (AST) → layout pass → serializer → bytes\n","text",[35,250,247],{"__ignoreMap":251},"",[18,253,254,255,258,259,262],{},"The document-tree step is the problem. Every ",[35,256,257],{},".Text()"," call allocates a node. Every ",[35,260,261],{},".Row()"," allocates a container. The layout pass walks the tree to compute positions. Then the serializer walks it again to emit bytes. Three passes, three sets of allocations, three trips over the same data through the CPU cache.",[18,264,265],{},"gpdf doesn't have step 2. The builder writes directly into a layout context that writes directly into the content stream. One pass.",[18,267,268,269,272],{},"Here's the concrete code path for a text element, lightly edited for length. The real version is in ",[35,270,271],{},"template/col_builder.go",":",[243,274,278],{"className":275,"code":276,"language":277,"meta":251,"style":251},"language-go shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","func (c *ColBuilder) Text(s string, opts ...TextOption) {\n    opt := c.resolveOptions(opts)\n    box := c.currentBox()\n    w := c.measureText(s, opt)\n    h := opt.FontSize.Pt() * opt.LineHeight\n    c.writer.BeginText()\n    c.writer.SetFont(opt.Font, opt.FontSize)\n    c.writer.MoveTo(box.X, box.Y-opt.FontSize.Pt())\n    c.writer.ShowString(s)\n    c.writer.EndText()\n    c.advance(w, h)\n}\n","go",[35,279,280,337,364,382,408,441,459,493,543,563,579,601],{"__ignoreMap":251},[281,282,285,289,292,296,299,303,306,310,313,316,320,323,326,329,332,334],"span",{"class":283,"line":284},"line",1,[281,286,288],{"class":287},"sMK4o","func",[281,290,291],{"class":287}," (",[281,293,295],{"class":294},"sHdIc","c ",[281,297,298],{"class":287},"*",[281,300,302],{"class":301},"sBMFI","ColBuilder",[281,304,305],{"class":287},")",[281,307,309],{"class":308},"s2Zo4"," Text",[281,311,312],{"class":287},"(",[281,314,315],{"class":294},"s",[281,317,319],{"class":318},"spNyl"," string",[281,321,322],{"class":287},",",[281,324,325],{"class":294}," opts",[281,327,328],{"class":287}," ...",[281,330,331],{"class":301},"TextOption",[281,333,305],{"class":287},[281,335,336],{"class":287}," {\n",[281,338,340,344,347,350,353,356,358,361],{"class":283,"line":339},2,[281,341,343],{"class":342},"sTEyZ","    opt ",[281,345,346],{"class":287},":=",[281,348,349],{"class":342}," c",[281,351,352],{"class":287},".",[281,354,355],{"class":308},"resolveOptions",[281,357,312],{"class":287},[281,359,360],{"class":342},"opts",[281,362,363],{"class":287},")\n",[281,365,367,370,372,374,376,379],{"class":283,"line":366},3,[281,368,369],{"class":342},"    box ",[281,371,346],{"class":287},[281,373,349],{"class":342},[281,375,352],{"class":287},[281,377,378],{"class":308},"currentBox",[281,380,381],{"class":287},"()\n",[281,383,385,388,390,392,394,397,399,401,403,406],{"class":283,"line":384},4,[281,386,387],{"class":342},"    w ",[281,389,346],{"class":287},[281,391,349],{"class":342},[281,393,352],{"class":287},[281,395,396],{"class":308},"measureText",[281,398,312],{"class":287},[281,400,315],{"class":342},[281,402,322],{"class":287},[281,404,405],{"class":342}," opt",[281,407,363],{"class":287},[281,409,411,414,416,418,420,423,425,428,431,434,436,438],{"class":283,"line":410},5,[281,412,413],{"class":342},"    h ",[281,415,346],{"class":287},[281,417,405],{"class":342},[281,419,352],{"class":287},[281,421,422],{"class":342},"FontSize",[281,424,352],{"class":287},[281,426,427],{"class":308},"Pt",[281,429,430],{"class":287},"()",[281,432,433],{"class":287}," *",[281,435,405],{"class":342},[281,437,352],{"class":287},[281,439,440],{"class":342},"LineHeight\n",[281,442,444,447,449,452,454,457],{"class":283,"line":443},6,[281,445,446],{"class":342},"    c",[281,448,352],{"class":287},[281,450,451],{"class":342},"writer",[281,453,352],{"class":287},[281,455,456],{"class":308},"BeginText",[281,458,381],{"class":287},[281,460,462,464,466,468,470,473,475,478,480,483,485,487,489,491],{"class":283,"line":461},7,[281,463,446],{"class":342},[281,465,352],{"class":287},[281,467,451],{"class":342},[281,469,352],{"class":287},[281,471,472],{"class":308},"SetFont",[281,474,312],{"class":287},[281,476,477],{"class":342},"opt",[281,479,352],{"class":287},[281,481,482],{"class":342},"Font",[281,484,322],{"class":287},[281,486,405],{"class":342},[281,488,352],{"class":287},[281,490,422],{"class":342},[281,492,363],{"class":287},[281,494,496,498,500,502,504,507,509,512,514,517,519,522,524,527,530,532,534,536,538,540],{"class":283,"line":495},8,[281,497,446],{"class":342},[281,499,352],{"class":287},[281,501,451],{"class":342},[281,503,352],{"class":287},[281,505,506],{"class":308},"MoveTo",[281,508,312],{"class":287},[281,510,511],{"class":342},"box",[281,513,352],{"class":287},[281,515,516],{"class":342},"X",[281,518,322],{"class":287},[281,520,521],{"class":342}," box",[281,523,352],{"class":287},[281,525,526],{"class":342},"Y",[281,528,529],{"class":287},"-",[281,531,477],{"class":342},[281,533,352],{"class":287},[281,535,422],{"class":342},[281,537,352],{"class":287},[281,539,427],{"class":308},[281,541,542],{"class":287},"())\n",[281,544,546,548,550,552,554,557,559,561],{"class":283,"line":545},9,[281,547,446],{"class":342},[281,549,352],{"class":287},[281,551,451],{"class":342},[281,553,352],{"class":287},[281,555,556],{"class":308},"ShowString",[281,558,312],{"class":287},[281,560,315],{"class":342},[281,562,363],{"class":287},[281,564,566,568,570,572,574,577],{"class":283,"line":565},10,[281,567,446],{"class":342},[281,569,352],{"class":287},[281,571,451],{"class":342},[281,573,352],{"class":287},[281,575,576],{"class":308},"EndText",[281,578,381],{"class":287},[281,580,582,584,586,589,591,594,596,599],{"class":283,"line":581},11,[281,583,446],{"class":342},[281,585,352],{"class":287},[281,587,588],{"class":308},"advance",[281,590,312],{"class":287},[281,592,593],{"class":342},"w",[281,595,322],{"class":287},[281,597,598],{"class":342}," h",[281,600,363],{"class":287},[281,602,604],{"class":283,"line":603},12,[281,605,606],{"class":287},"}\n",[18,608,609,610,613,614,617,618,621,622,624,625,624,627,629,630,633,634,633,637,633,640,643],{},"No node gets pushed to a tree. No positions get deferred. The writer is a ",[35,611,612],{},"*pdf.Writer"," that holds an ",[35,615,616],{},"io.Writer"," (typically a ",[35,619,620],{},"bytes.Buffer","), and ",[35,623,456],{}," / ",[35,626,506],{},[35,628,556],{}," write the PDF operators (",[35,631,632],{},"BT",", ",[35,635,636],{},"Td",[35,638,639],{},"Tj",[35,641,642],{},"ET",") to that buffer immediately.",[18,645,646,647,650,651,654,655,658,659,662,663,666],{},"Compare to how gofpdf does the same logical operation. gofpdf maintains a ",[35,648,649],{},"page"," object with a slice of operations. Each ",[35,652,653],{},"SetXY"," + ",[35,656,657],{},"Cell"," call appends to that slice. ",[35,660,661],{},"Output"," (or ",[35,664,665],{},"OutputFileAndClose",") walks the slice at the end and emits the bytes. That's two allocations per cell — one for the operation struct, one for the string copy — and one extra pass over the data.",[18,668,669],{},"For a 100-page report with ~40 lines per page, that's 4,000 extra allocations gpdf doesn't make.",[671,672,674],"h3",{"id":673},"where-single-pass-hurts","Where single-pass hurts",[18,676,677],{},"The obvious question: how do you do anything that needs to know the final page layout before you start emitting bytes? Headers with page numbers. Tables that span pages. Footers anchored to the bottom of the last line of body text.",[18,679,680,681,683,684,687,688,633,691,694],{},"Two answers. One, we buffer the current ",[22,682,649],{},", not the document. A page is a bounded unit — tens of kilobytes, not megabytes. When ",[35,685,686],{},"AddPage()"," runs for the next page, the current page's content stream is finalized (",[35,689,690],{},"Length",[35,692,693],{},"Filter",", offsets), its xref entry is written, and the page buffer is reset. Memory high-water mark stays O(one page).",[18,696,697],{},"Two, for genuinely global elements (page count \"Page 3 of 27\"), we defer those specific spans to a fix-up pass. The rest of the content is already in the stream. The fix-up walks a short list of deferred-reference markers and patches them. This is the one place in the codebase where we pay something like an AST cost, and we pay it only for the content that actually needs it.",[18,699,700,701,704,705,708],{},"The trade we made: you can't do arbitrary post-processing on a node tree, because there's no node tree. You can't write a plugin that reorders \"all ",[35,702,703],{},"Text"," nodes with ",[35,706,707],{},"bold: true",".\" If you need that shape of API, Maroto v2 does it; gpdf does not.",[18,710,711],{},"We think that's the right trade for the use cases gpdf targets. Most PDFs are produced left-to-right, top-to-bottom, in a known-at-construction-time layout. The cost of keeping an AST around for the minority of use cases that need it is paid on every page of the majority.",[13,713,715,716,718],{"id":714},"decision-2-no-reflection-no-interface-on-the-hot-path","Decision 2: No reflection, no ",[35,717,61],{}," on the hot path",[18,720,721],{},"This one is less interesting to write about than to profile. But it's where half the remaining speed came from.",[18,723,724,725,727,728,731],{},"Look at ",[35,726,117],{},"'s ",[35,729,730],{},"CellFormat"," signature:",[243,733,735],{"className":275,"code":734,"language":277,"meta":251,"style":251},"func (f *Fpdf) CellFormat(w, h float64, txtStr, borderStr string,\n    ln int, alignStr string, fill bool, link int, linkStr string) { ... }\n",[35,736,737,782],{"__ignoreMap":251},[281,738,739,741,743,746,748,751,753,756,758,760,762,764,767,769,772,774,777,779],{"class":283,"line":284},[281,740,288],{"class":287},[281,742,291],{"class":287},[281,744,745],{"class":294},"f ",[281,747,298],{"class":287},[281,749,750],{"class":301},"Fpdf",[281,752,305],{"class":287},[281,754,755],{"class":308}," CellFormat",[281,757,312],{"class":287},[281,759,593],{"class":294},[281,761,322],{"class":287},[281,763,598],{"class":294},[281,765,766],{"class":318}," float64",[281,768,322],{"class":287},[281,770,771],{"class":294}," txtStr",[281,773,322],{"class":287},[281,775,776],{"class":294}," borderStr",[281,778,319],{"class":318},[281,780,781],{"class":287},",\n",[281,783,784,787,790,792,795,797,799,802,805,807,810,812,814,817,819,821,824,826],{"class":283,"line":339},[281,785,786],{"class":294},"    ln",[281,788,789],{"class":318}," int",[281,791,322],{"class":287},[281,793,794],{"class":294}," alignStr",[281,796,319],{"class":318},[281,798,322],{"class":287},[281,800,801],{"class":294}," fill",[281,803,804],{"class":318}," bool",[281,806,322],{"class":287},[281,808,809],{"class":294}," link",[281,811,789],{"class":318},[281,813,322],{"class":287},[281,815,816],{"class":294}," linkStr",[281,818,319],{"class":318},[281,820,305],{"class":287},[281,822,823],{"class":287}," {",[281,825,328],{"class":287},[281,827,828],{"class":287}," }\n",[18,830,831,832,835,836,839,840,843,844,847,848,851,852,854,855,858],{},"Fine. Now look at Maroto's component tree. A ",[35,833,834],{},"Row"," holds ",[35,837,838],{},"[]Component",". A ",[35,841,842],{},"Component"," is an interface. Every layout operation is a virtual dispatch: ",[35,845,846],{},"component.Render(ctx)",". For a single ",[35,849,850],{},"Col"," with a ",[35,853,703],{}," and a ",[35,856,857],{},"Spacer",", that's three interface dispatches. On a 100-page report with ~30 rows per page and ~3 components per row, that's ~9,000 dispatches.",[18,860,861],{},"Individually, an interface dispatch in Go is ~2-3 ns. Not a crime. But the dispatch also forces the compiler to keep the boxed value on the heap — you can't stack-allocate through an interface without a devirtualization pass the Go compiler doesn't always do. So the cost isn't just the dispatch; it's the allocation that feeds it.",[18,863,864],{},"gpdf's layout engine uses concrete structs:",[243,866,868],{"className":275,"code":867,"language":277,"meta":251,"style":251},"type RowBuilder struct {\n    doc    *Document\n    parent *pageState\n    spans  [12]int\n    cols   [12]ColBuilder  // value, not pointer, not interface\n    n      uint8\n}\n\ntype ColBuilder struct {\n    row    *RowBuilder\n    span   int\n    cursor document.Point\n    writer *pdf.Writer\n}\n",[35,869,870,883,893,903,921,938,946,950,956,967,977,984,997,1013],{"__ignoreMap":251},[281,871,872,875,878,881],{"class":283,"line":284},[281,873,874],{"class":287},"type",[281,876,877],{"class":301}," RowBuilder",[281,879,880],{"class":287}," struct",[281,882,336],{"class":287},[281,884,885,888,890],{"class":283,"line":339},[281,886,887],{"class":342},"    doc    ",[281,889,298],{"class":287},[281,891,892],{"class":301},"Document\n",[281,894,895,898,900],{"class":283,"line":366},[281,896,897],{"class":342},"    parent ",[281,899,298],{"class":287},[281,901,902],{"class":301},"pageState\n",[281,904,905,908,911,915,918],{"class":283,"line":384},[281,906,907],{"class":342},"    spans  ",[281,909,910],{"class":287},"[",[281,912,914],{"class":913},"sbssI","12",[281,916,917],{"class":287},"]",[281,919,920],{"class":318},"int\n",[281,922,923,926,928,930,932,934],{"class":283,"line":410},[281,924,925],{"class":342},"    cols   ",[281,927,910],{"class":287},[281,929,914],{"class":913},[281,931,917],{"class":287},[281,933,302],{"class":301},[281,935,937],{"class":936},"sHwdD","  // value, not pointer, not interface\n",[281,939,940,943],{"class":283,"line":443},[281,941,942],{"class":342},"    n      ",[281,944,945],{"class":318},"uint8\n",[281,947,948],{"class":283,"line":461},[281,949,606],{"class":287},[281,951,952],{"class":283,"line":495},[281,953,955],{"emptyLinePlaceholder":954},true,"\n",[281,957,958,960,963,965],{"class":283,"line":545},[281,959,874],{"class":287},[281,961,962],{"class":301}," ColBuilder",[281,964,880],{"class":287},[281,966,336],{"class":287},[281,968,969,972,974],{"class":283,"line":565},[281,970,971],{"class":342},"    row    ",[281,973,298],{"class":287},[281,975,976],{"class":301},"RowBuilder\n",[281,978,979,982],{"class":283,"line":581},[281,980,981],{"class":342},"    span   ",[281,983,920],{"class":318},[281,985,986,989,992,994],{"class":283,"line":603},[281,987,988],{"class":342},"    cursor ",[281,990,991],{"class":301},"document",[281,993,352],{"class":287},[281,995,996],{"class":301},"Point\n",[281,998,1000,1003,1005,1008,1010],{"class":283,"line":999},13,[281,1001,1002],{"class":342},"    writer ",[281,1004,298],{"class":287},[281,1006,1007],{"class":301},"pdf",[281,1009,352],{"class":287},[281,1011,1012],{"class":301},"Writer\n",[281,1014,1016],{"class":283,"line":1015},14,[281,1017,606],{"class":287},[18,1019,1020,1023],{},[35,1021,1022],{},"cols"," is a value array, sized to the maximum column count (12, from the grid system). No heap allocation. No interface dispatch when the row iterates its columns. The builder holds a pointer to the writer, not the other way around — the writer has no knowledge of the builder tree.",[18,1025,1026,1027,1030,1031,1033],{},"The callback pattern (",[35,1028,1029],{},"r.Col(4, func(c *ColBuilder) { ... })",") is not an accident. Every other shape we prototyped — a chainable struct-returning API, a tree of boxed Component interfaces — was slower. The closure has zero allocations because the ",[35,1032,302],{}," is a value the caller holds by pointer via the parameter; the closure itself is escape-analyzed to the stack in the common case.",[671,1035,1037],{"id":1036},"how-we-know-this-worked","How we know this worked",[18,1039,1040,1043],{},[35,1041,1042],{},"go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out"," on gpdf gives one number we're proud of:",[243,1045,1048],{"className":1046,"code":1047,"language":248},[246],"BenchmarkSinglePage-8   91270   13120 ns/op   8321 B/op   52 allocs/op\n",[35,1049,1047],{"__ignoreMap":251},[18,1051,1052,1053,1055],{},"Fifty-two allocations for an entire PDF page. Almost all of them are the initial page buffer, the font metrics lookup (once per font, not once per glyph), and the final ",[35,1054,620],{}," growth. The layout loop allocates zero — look at the profile.",[18,1057,1058],{},"gofpdf on the same page:",[243,1060,1063],{"className":1061,"code":1062,"language":248},[246],"BenchmarkGofpdfSinglePage-8   7500   132400 ns/op   71200 B/op   430 allocs/op\n",[35,1064,1062],{"__ignoreMap":251},[18,1066,1067],{},"430 allocations. Most of them are the operation slice and the string copies feeding it. Move that factor of ~8 difference in allocations through the GC, and the runtime gap of ~10× follows mechanically.",[671,1069,1071],{"id":1070},"what-we-gave-up","What we gave up",[18,1073,1074,1075,1077,1078,1081],{},"Zero ergonomics on the hot path means fewer extension points. If you want to write a custom element type that plugs into gpdf's layout — the equivalent of implementing ",[35,1076,842],{}," in Maroto — you can't. There's no interface to satisfy. What we offer instead is ",[35,1079,1080],{},"template.WithWriterSetup()",", which gives you a hook into the PDF writer for things like custom annotations, PDF/A metadata, or encryption. For layout extension, you write it as a helper that calls the same builder methods a user would.",[18,1083,1084],{},"Fewer extension points is a real cost. We've decided it's worth it. If the project's shape changes in a direction where it isn't, we'll revisit.",[13,1086,1088],{"id":1087},"decision-3-truetype-subsetting-without-re-walks","Decision 3: TrueType subsetting without re-walks",[18,1090,1091],{},"This is the one where the CJK benchmark (133 µs vs 254 µs for gofpdf) gets most of its gap.",[18,1093,1094],{},"A quick summary of what TrueType subsetting does. When you embed a Japanese font in a PDF, you don't want to embed all 20,000+ glyphs — that's 15 MB of font data in a 100 KB document. You want to embed only the glyphs your document actually uses, packaged as a valid subset TTF that a PDF reader can decode.",[18,1096,1097],{},"To do that, you:",[44,1099,1100,1119,1122,1125],{},[47,1101,1102,1103,1106,1107,1110,1111,1114,1115,1118],{},"Parse the full TTF tables: ",[35,1104,1105],{},"cmap"," (character-to-glyph mapping), ",[35,1108,1109],{},"glyf"," (outlines), ",[35,1112,1113],{},"loca"," (offsets into glyf), ",[35,1116,1117],{},"hmtx"," (horizontal metrics), etc.",[47,1120,1121],{},"For each character the document uses, look up its glyph ID via the cmap.",[47,1123,1124],{},"Transitively collect the glyphs that compound glyphs reference.",[47,1126,1127],{},"Emit a new TTF with only those glyphs, renumbered.",[18,1129,1130,1131,1134],{},"Step 2 — the cmap lookup — is the hot path. gofpdf's implementation walks the cmap table from the top on ",[22,1132,1133],{},"every glyph lookup",". For a Latin-only page, that's fine; the cmap is small and the cache behaves. For a CJK page with 150 unique glyphs, it's 150 full table walks.",[18,1136,1137],{},"The cmap format 12 (used by most modern CJK fonts) is a sorted array of (start, end, startGlyphID) triples. A single walk is O(n) in the number of ranges, ~200-500 for NotoSansJP. 150 glyph lookups × 400 ranges × per-range comparison = way more work than necessary.",[18,1139,1140,1141,1144],{},"gpdf resolves the entire cmap into a ",[35,1142,1143],{},"map[rune]uint16"," on first font load. After that, every lookup is O(1). For NotoSansJP, the one-time cost is ~150 µs; after that, 10 ns per character.",[243,1146,1148],{"className":275,"code":1147,"language":277,"meta":251,"style":251},"// Simplified from pdf/font/ttf.go\ntype Font struct {\n    runeToGID map[rune]uint16  // resolved once at load\n    glyphs    []glyph          // indexed by GID\n    metrics   []glyphMetric\n}\n\nfunc (f *Font) GlyphFor(r rune) uint16 {\n    return f.runeToGID[r]  // O(1), cache-friendly, no table walk\n}\n",[35,1149,1150,1155,1166,1185,1199,1209,1213,1217,1249,1272],{"__ignoreMap":251},[281,1151,1152],{"class":283,"line":284},[281,1153,1154],{"class":936},"// Simplified from pdf/font/ttf.go\n",[281,1156,1157,1159,1162,1164],{"class":283,"line":339},[281,1158,874],{"class":287},[281,1160,1161],{"class":301}," Font",[281,1163,880],{"class":287},[281,1165,336],{"class":287},[281,1167,1168,1171,1174,1177,1179,1182],{"class":283,"line":366},[281,1169,1170],{"class":342},"    runeToGID ",[281,1172,1173],{"class":287},"map[",[281,1175,1176],{"class":318},"rune",[281,1178,917],{"class":287},[281,1180,1181],{"class":318},"uint16",[281,1183,1184],{"class":936},"  // resolved once at load\n",[281,1186,1187,1190,1193,1196],{"class":283,"line":384},[281,1188,1189],{"class":342},"    glyphs    ",[281,1191,1192],{"class":287},"[]",[281,1194,1195],{"class":301},"glyph",[281,1197,1198],{"class":936},"          // indexed by GID\n",[281,1200,1201,1204,1206],{"class":283,"line":410},[281,1202,1203],{"class":342},"    metrics   ",[281,1205,1192],{"class":287},[281,1207,1208],{"class":301},"glyphMetric\n",[281,1210,1211],{"class":283,"line":443},[281,1212,606],{"class":287},[281,1214,1215],{"class":283,"line":461},[281,1216,955],{"emptyLinePlaceholder":954},[281,1218,1219,1221,1223,1225,1227,1229,1231,1234,1236,1239,1242,1244,1247],{"class":283,"line":495},[281,1220,288],{"class":287},[281,1222,291],{"class":287},[281,1224,745],{"class":294},[281,1226,298],{"class":287},[281,1228,482],{"class":301},[281,1230,305],{"class":287},[281,1232,1233],{"class":308}," GlyphFor",[281,1235,312],{"class":287},[281,1237,1238],{"class":294},"r",[281,1240,1241],{"class":318}," rune",[281,1243,305],{"class":287},[281,1245,1246],{"class":318}," uint16",[281,1248,336],{"class":287},[281,1250,1251,1255,1258,1260,1263,1265,1267,1269],{"class":283,"line":545},[281,1252,1254],{"class":1253},"s7zQu","    return",[281,1256,1257],{"class":342}," f",[281,1259,352],{"class":287},[281,1261,1262],{"class":342},"runeToGID",[281,1264,910],{"class":287},[281,1266,1238],{"class":342},[281,1268,917],{"class":287},[281,1270,1271],{"class":936},"  // O(1), cache-friendly, no table walk\n",[281,1273,1274],{"class":283,"line":565},[281,1275,606],{"class":287},[18,1277,1278],{},"One map, indexed by rune, populated by a single linear scan of the cmap table. For a document that uses the same font on multiple pages (all of them), this moves glyph lookup from \"quadratic-ish in pages × glyphs\" to \"linear in total glyphs plus a fixed constant.\"",[671,1280,1282],{"id":1281},"why-format-12-is-the-detail-that-matters","Why \"format 12\" is the detail that matters",[18,1284,1285,1286,727,1288,1291,1292,1295],{},"Most older Go PDF libraries were written when Latin text was the only text anyone cared about, and they implemented cmap format 4 — a segmented range for the Basic Multilingual Plane (U+0000–U+FFFF). Japanese text outside the BMP (less common, but some Kanji variants) needs format 12. ",[35,1287,120],{},[35,1289,1290],{},"AddUTF8Font"," ",[22,1293,1294],{},"panics"," on NotoSansJP-Regular.ttf because its format-12 parser was never finished.",[18,1297,1298],{},"This is not a roast. It's an artifact: gofpdf was a great library for what Latin-heavy web apps needed in 2015, and the fork inherited its scope. The world shifted; CJK went from \"someone else's problem\" to \"most of the Japanese and Chinese Go ecosystems.\" gpdf implemented the full cmap spec because the alternative was an invoice that shows tofu boxes for 品目 — a real bug report we got in the first week of public release.",[671,1300,1302],{"id":1301},"caching-that-scales-with-font-count-not-document-size","Caching that scales with font count, not document size",[18,1304,1305,1306,1309,1310,1312,1313,352],{},"The font cache is per-",[35,1307,1308],{},"Document",", not global. If you generate 10,000 PDFs with the same font, you pay the 150 µs resolve cost 10,000 times — unless you share a ",[35,1311,482],{}," instance across documents, which the API allows via ",[35,1314,1315],{},"gpdf.WithSharedFont(preloadedFont)",[18,1317,1318,1319,1322],{},"For high-volume batch generation (the ",[35,1320,1321],{},"gpdf-api"," SaaS runs this way), the shared-font pattern is what makes P95 latency predictable. We publish it in the docs; most OSS users don't need it.",[13,1324,1326],{"id":1325},"the-combined-effect","The combined effect",[18,1328,1329],{},"Let's put the three decisions side by side on the 100-page benchmark (683 µs for gpdf, 11.7 ms for gofpdf):",[100,1331,1332,1345],{},[103,1333,1334],{},[106,1335,1336,1339,1342],{},[109,1337,1338],{},"Source of time",[109,1340,1341],{},"gofpdf (per-page, approx)",[109,1343,1344],{},"gpdf (per-page, approx)",[128,1346,1347,1358,1369,1380,1391],{},[106,1348,1349,1352,1355],{},[133,1350,1351],{},"Operation slice build-up",[133,1353,1354],{},"~60 µs",[133,1356,1357],{},"0 (streams direct)",[106,1359,1360,1363,1366],{},[133,1361,1362],{},"Operation serialization",[133,1364,1365],{},"~35 µs",[133,1367,1368],{},"0 (already written)",[106,1370,1371,1374,1377],{},[133,1372,1373],{},"Glyph lookups (40 chars)",[133,1375,1376],{},"~6 µs",[133,1378,1379],{},"~0.4 µs",[106,1381,1382,1385,1388],{},[133,1383,1384],{},"Allocation / GC pressure",[133,1386,1387],{},"~20 µs",[133,1389,1390],{},"~2 µs",[106,1392,1393,1398,1403],{},[133,1394,1395],{},[22,1396,1397],{},"Total",[133,1399,1400],{},[22,1401,1402],{},"~120 µs",[133,1404,1405],{},[22,1406,1407],{},"~7 µs",[18,1409,1410],{},"The numbers there are estimates from profiling; the real breakdown depends on content. But the shape is right. None of the three designs wins 10× alone. They compound.",[18,1412,1413],{},"The corollary: if you copy just one design into an existing library, you'll get a 2–3× gain. If you want the 10×, you need all three, and you can't retrofit the first one onto an AST-based library without rewriting it.",[13,1415,1417],{"id":1416},"what-we-gave-up-the-honest-section","What we gave up (the honest section)",[18,1419,1420],{},"We've been dancing around this. Here's the list in full:",[18,1422,1423,1426],{},[22,1424,1425],{},"AST-based post-processing."," No plugin architecture. No \"walk the node tree and apply this transform.\" If you want to edit text styles globally across a document before rendering, you do it before you call the builder, not after.",[18,1428,1429,1432,1433,1436],{},[22,1430,1431],{},"Introspection."," There's no ",[35,1434,1435],{},"doc.Components()"," that returns everything you put in. The document is a stream of operators by the time any meaningful method can run on it. For most users this never comes up; for the minority writing document-manipulation tools, it does.",[18,1438,1439,1442,1443,1446,1447,1450],{},[22,1440,1441],{},"Reflection-based serialization."," We don't have a ",[35,1444,1445],{},"json.Unmarshal","-style API that turns arbitrary structs into PDF. The JSON Schema entry point (",[35,1448,1449],{},"template.FromJSON",") is explicit about its supported shapes, by design. If you want to point a library at a generic Go struct and get a PDF, that's unidoc's territory.",[18,1452,1453,1456,1457,1459],{},[22,1454,1455],{},"The extensibility of an interface."," You can't implement ",[35,1458,842],{}," and register a custom element. You can write a helper function that wraps the builder calls, and in practice that covers 95% of what people ask for, but it's a different model.",[18,1461,1462],{},"These are deliberate. Each of them, individually, would kill the speed. We picked the bucket of users whose work benefits from \"fast and opinionated\" over the bucket who need \"flexible and plugin-rich.\" If you're in the second bucket, Maroto v2 or unidoc is probably a better fit.",[13,1464,1466],{"id":1465},"can-i-re-run-the-benchmark","Can I re-run the benchmark?",[18,1468,1469],{},"Yes. That's the whole point of publishing the code.",[243,1471,1475],{"className":1472,"code":1473,"language":1474,"meta":251,"style":251},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","git clone https://github.com/gpdf-dev/gpdf\ncd gpdf/_benchmark\ngo test -bench=. -benchmem -benchtime=5s\n","bash",[35,1476,1477,1489,1497],{"__ignoreMap":251},[281,1478,1479,1482,1486],{"class":283,"line":284},[281,1480,1481],{"class":301},"git",[281,1483,1485],{"class":1484},"sfazB"," clone",[281,1487,1488],{"class":1484}," https://github.com/gpdf-dev/gpdf\n",[281,1490,1491,1494],{"class":283,"line":339},[281,1492,1493],{"class":308},"cd",[281,1495,1496],{"class":1484}," gpdf/_benchmark\n",[281,1498,1499,1501,1504,1507,1510],{"class":283,"line":366},[281,1500,277],{"class":301},[281,1502,1503],{"class":1484}," test",[281,1505,1506],{"class":1484}," -bench=.",[281,1508,1509],{"class":1484}," -benchmem",[281,1511,1512],{"class":1484}," -benchtime=5s\n",[18,1514,1515,1516,1521],{},"The README in that directory documents the four workloads and what they measure. If your numbers differ materially (>20%) on the same CPU architecture and Go version, ",[76,1517,1520],{"href":1518,"rel":1519},"https://github.com/gpdf-dev/gpdf/issues",[80],"open an issue"," — drift is real and we want to know.",[18,1523,1524],{},"Two caveats worth naming:",[1526,1527,1528,1534],"ul",{},[47,1529,1530,1531,1533],{},"The benchmark runs with ",[35,1532,97],{},". If you disable it, numbers improve by ~5% across the board, which we don't count in public claims because it's not how anyone runs real code.",[47,1535,1536],{},"CGO is off. A few readers have asked whether a CGO-linked FreeType backend would be faster for font operations; we tested it, and the marshaling cost across the FFI boundary dominated any gain. The pure-Go subsetter wins for the access patterns a PDF generator has.",[13,1538,1540],{"id":1539},"faq","FAQ",[18,1542,1543,1546,1547,352],{},[22,1544,1545],{},"Why compare against gofpdf if it's archived?","\nBecause it's still the number-one result in GitHub search for \"go pdf,\" and most teams landing on gpdf are migrating from it. The benchmark needs to answer \"is the migration worth the effort\" for that audience. Short version: yes, and we wrote a ",[76,1548,1550],{"href":1549},"/blog/gofpdf-migration","migration guide",[18,1552,1553,1556],{},[22,1554,1555],{},"Is 10× faster actually meaningful for PDF generation?","\nDepends on your workload. For one document per user request, not really — both libraries clear the \"generate on request\" bar. For batch operations (nightly statements, bulk invoices, report generation from a DB query), the gap translates directly into fewer machines. We've heard \"10× fewer workers\" from the first team that migrated their batch pipeline; we didn't audit their math but it tracks with the benchmark.",[18,1558,1559,1562,1563,1566,1567,1569],{},[22,1560,1561],{},"What's the catch in the CJK number?","\nYou still need to ship the font file. gpdf subsets it for you, but a 3 MB NotoSansJP TTF is 3 MB you either embed in your Go binary or ",[35,1564,1565],{},"os.ReadFile"," at startup. For distroless images this matters. The ",[35,1568,1321],{}," SaaS solves it by shipping the common fonts in the image; OSS users handle it themselves.",[18,1571,1572,1575],{},[22,1573,1574],{},"Will gpdf slow down as features are added?","\nThis is the question we care most about. The answer is: we benchmark every release against the previous one, and a regression greater than 5% on any of the four workloads blocks the release. The benchmarks live in the same repo as the library for exactly this reason.",[18,1577,1578,1581],{},[22,1579,1580],{},"Where does the name come from?","\ngpdf = Go + PDF. Not clever. Intentional.",[13,1583,1585],{"id":1584},"try-gpdf","Try gpdf",[18,1587,1588],{},"gpdf is a Go library for generating PDFs. MIT, zero dependencies, native CJK.",[243,1590,1592],{"className":1472,"code":1591,"language":1474,"meta":251,"style":251},"go get github.com/gpdf-dev/gpdf\n",[35,1593,1594],{"__ignoreMap":251},[281,1595,1596,1598,1601],{"class":283,"line":284},[281,1597,277],{"class":301},[281,1599,1600],{"class":1484}," get",[281,1602,1603],{"class":1484}," github.com/gpdf-dev/gpdf\n",[18,1605,1606,1611,1612],{},[76,1607,1610],{"href":1608,"rel":1609},"https://github.com/gpdf-dev/gpdf",[80],"⭐ Star on GitHub"," · ",[76,1613,1616],{"href":1614,"rel":1615},"https://gpdf.dev/docs/quickstart",[80],"Read the docs",[13,1618,1620],{"id":1619},"next-reads","Next reads",[1526,1622,1623,1630,1636],{},[47,1624,1625,1629],{},[76,1626,1628],{"href":1627},"/blog/go-pdf-library-showdown-2026","Go PDF Library Showdown 2026"," — the full library comparison with license and dependency details.",[47,1631,1632,1635],{},[76,1633,1634],{"href":1549},"gofpdf is archived. Here's how to migrate to gpdf."," — five before/after API pairs, runnable.",[47,1637,1638,1639,352],{},"The benchmark code: ",[76,1640,1642],{"href":78,"rel":1641},[80],[35,1643,83],{},[1645,1646,1647],"style",{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":251,"searchDepth":339,"depth":339,"links":1649},[1650,1651,1652,1655,1660,1664,1665,1666,1667,1668,1669],{"id":15,"depth":339,"text":16},{"id":90,"depth":339,"text":91},{"id":237,"depth":339,"text":238,"children":1653},[1654],{"id":673,"depth":366,"text":674},{"id":714,"depth":339,"text":1656,"children":1657},"Decision 2: No reflection, no interface{} on the hot path",[1658,1659],{"id":1036,"depth":366,"text":1037},{"id":1070,"depth":366,"text":1071},{"id":1087,"depth":339,"text":1088,"children":1661},[1662,1663],{"id":1281,"depth":366,"text":1282},{"id":1301,"depth":366,"text":1302},{"id":1325,"depth":339,"text":1326},{"id":1416,"depth":339,"text":1417},{"id":1465,"depth":339,"text":1466},{"id":1539,"depth":339,"text":1540},{"id":1584,"depth":339,"text":1585},{"id":1619,"depth":339,"text":1620},"2026-04-19","gpdf generates a single PDF page in 13 µs and a 100-page report in 683 µs. Not a tuning trick — three architectural choices that compound. Here's the code path.",false,"md",null,{},"/blog/why-gpdf-is-faster",{"title":5,"description":1671},"blog/011.why-gpdf-is-faster",[1680,1681,1682],"benchmark","internals","comparison","MXuU7x4K6cOEN9tslOoERVRSDxY_EzrfWujZK1Aothw",1776537629243]