[{"data":1,"prerenderedAt":1713},["ShallowReactive",2],{"blog-zh-why-gpdf-is-faster":3},{"id":4,"title":5,"author":6,"body":9,"date":1699,"description":1700,"draft":1701,"extension":1702,"howTo":1703,"image":1703,"meta":1704,"navigation":966,"path":1705,"seo":1706,"stem":1707,"tags":1708,"updated":1703,"__hash__":1712},"blogZh/zh/blog/011.why-gpdf-is-faster.md","为什么 gpdf 比其他 Go PDF 库快 10–30 倍",{"name":7,"url":8},"gpdf team","https://gpdf.dev",{"type":10,"value":11,"toc":1678},"minimark",[12,17,43,69,72,85,92,96,103,219,229,236,243,247,250,260,271,278,285,619,655,678,681,685,688,707,717,728,731,735,738,745,842,871,874,877,1030,1039,1049,1052,1059,1065,1075,1078,1084,1087,1090,1104,1107,1111,1114,1121,1124,1154,1161,1164,1171,1302,1305,1309,1322,1325,1328,1342,1349,1352,1355,1433,1440,1443,1447,1450,1460,1470,1483,1492,1495,1498,1501,1544,1553,1556,1568,1572,1582,1588,1601,1607,1613,1617,1620,1635,1648,1651,1674],[13,14,16],"h2",{"id":15},"tldr","TL;DR",[18,19,20,21,25,26,29,30,33,34,38,39,42],"p",{},"gpdf 生成单页 ",[22,23,24],"strong",{},"13 µs","，4×10 发票表格 ",[22,27,28],{},"108 µs","，100 页分页报告 ",[22,31,32],{},"683 µs","。次快的 ",[35,36,37],"code",{},"jung-kurt/gofpdf"," 做同样的 100 页需要 ",[22,40,41],{},"11.7 ms","，大约慢 17 倍。这不是调参差异，而是三个设计决策相互叠加的结果:",[44,45,46,53,63],"ol",{},[47,48,49,52],"li",{},[22,50,51],{},"单遍布局。"," Builder API 和 PDF 内容流之间没有中间 AST。",[47,54,55,58,59,62],{},[22,56,57],{},"热点路径使用具体类型。"," 布局循环里没有反射、没有 ",[35,60,61],{},"interface{}","、没有虚拟派发。",[47,64,65,68],{},[22,66,67],{},"TrueType 子集器只解析 cmap 一次。"," 不是每个字形一次，也不是每页一次。就一次。",[18,70,71],{},"三个里任何一个单独都能带来 2–3 倍。叠起来就是一个数量级。",[18,73,74,75,84],{},"本文直接追那些产生这些数字的代码路径。基准测试源码公开在 ",[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"," — 克隆下来在自己的机器上跑，数字对不上就提 issue。",[18,86,87,88,91],{},"先声明偏向: 我们是 gpdf 团队。\"我们更快\"的诚实版本是\"我们做了不同的权衡\"，真正有意思的问题是 ",[22,89,90],{},"为了这份速度放弃了什么","。文章后半讲这个。",[13,93,95],{"id":94},"快在这里指什么","\"快\"在这里指什么",[18,97,98,99,102],{},"在讲架构之前，先把要解释的积分板列出来 (Apple M1, Go 1.25, 关闭 CGO, ",[35,100,101],{},"-benchmem"," 开启):",[104,105,106,131],"table",{},[107,108,109],"thead",{},[110,111,112,116,119,122,125,128],"tr",{},[113,114,115],"th",{},"工作负载",[113,117,118],{},"gpdf",[113,120,121],{},"gofpdf",[113,123,124],{},"go-pdf/fpdf",[113,126,127],{},"signintech/gopdf",[113,129,130],{},"Maroto v2",[132,133,134,156,177,197],"tbody",{},[110,135,136,140,144,147,150,153],{},[137,138,139],"td",{},"单页 Hello World",[137,141,142],{},[22,143,24],{},[137,145,146],{},"132 µs",[137,148,149],{},"135 µs",[137,151,152],{},"423 µs",[137,154,155],{},"237 µs",[110,157,158,161,165,168,171,174],{},[137,159,160],{},"4×10 发票表格",[137,162,163],{},[22,164,28],{},[137,166,167],{},"241 µs",[137,169,170],{},"243 µs",[137,172,173],{},"835 µs",[137,175,176],{},"8,600 µs",[110,178,179,182,186,189,192,194],{},[137,180,181],{},"100 页分页报告",[137,183,184],{},[22,185,32],{},[137,187,188],{},"11,700 µs",[137,190,191],{},"11,900 µs",[137,193,176],{},[137,195,196],{},"19,800 µs",[110,198,199,202,207,210,213,216],{},[137,200,201],{},"复杂 CJK 发票",[137,203,204],{},[22,205,206],{},"133 µs",[137,208,209],{},"254 µs",[137,211,212],{},"n/a",[137,214,215],{},"997 µs",[137,217,218],{},"10,400 µs",[18,220,221,222,225,226,228],{},"在解释之前能看到两个形状。页数越多 ",[22,223,224],{},"差距越大"," (Hello World 10 倍、100 页 17 倍)。复杂度越高 ",[22,227,224],{}," (表格单独 108 µs，Maroto 经 gofpdf 后端 8.6 ms)。",[18,230,231,232,235],{},"两个形状的根源相同: ",[22,233,234],{},"gpdf 的布局循环在公共路径上不分配内存","，所以每个元素的成本几乎是平的。原因下面讲。",[18,237,238,239,242],{},"免责声明没人想读但还是要写: ",[22,240,241],{},"对大多数 PDF 工作负载，绝对速度没想象中那么重要","。如果最大的文档只是一张一页收据，这张表里的每个维护中的库都能在请求路径上生成。起作用的阈值是\"能不能在一个批次里同步生成 100 份而不排队\"。",[13,244,246],{"id":245},"决策-1-不构造中间-ast","决策 1: 不构造中间 AST",[18,248,249],{},"大多数 PDF Builder 库是这样工作的:",[251,252,257],"pre",{"className":253,"code":255,"language":256},[254],"language-text","builder API → 文档树 (AST) → 布局遍历 → 序列化器 → 字节\n","text",[35,258,255],{"__ignoreMap":259},"",[18,261,262,263,266,267,270],{},"文档树那一步是问题。每次 ",[35,264,265],{},".Text()"," 都分配一个节点。每次 ",[35,268,269],{},".Row()"," 都分配一个容器。布局遍历走一次树算位置，序列化器再走一次树吐字节。三次遍、三组分配、三趟 CPU 缓存。",[18,272,273,274,277],{},"gpdf 没有第 2 步。Builder 直接写入布局上下文，布局上下文直接写入内容流。",[22,275,276],{},"一遍","。",[18,279,280,281,284],{},"文本元素实际的代码路径 (从 ",[35,282,283],{},"template/col_builder.go"," 裁剪):",[251,286,290],{"className":287,"code":288,"language":289,"meta":259,"style":259},"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,291,292,349,376,394,420,453,471,505,555,575,591,613],{"__ignoreMap":259},[293,294,297,301,304,308,311,315,318,322,325,328,332,335,338,341,344,346],"span",{"class":295,"line":296},"line",1,[293,298,300],{"class":299},"sMK4o","func",[293,302,303],{"class":299}," (",[293,305,307],{"class":306},"sHdIc","c ",[293,309,310],{"class":299},"*",[293,312,314],{"class":313},"sBMFI","ColBuilder",[293,316,317],{"class":299},")",[293,319,321],{"class":320},"s2Zo4"," Text",[293,323,324],{"class":299},"(",[293,326,327],{"class":306},"s",[293,329,331],{"class":330},"spNyl"," string",[293,333,334],{"class":299},",",[293,336,337],{"class":306}," opts",[293,339,340],{"class":299}," ...",[293,342,343],{"class":313},"TextOption",[293,345,317],{"class":299},[293,347,348],{"class":299}," {\n",[293,350,352,356,359,362,365,368,370,373],{"class":295,"line":351},2,[293,353,355],{"class":354},"sTEyZ","    opt ",[293,357,358],{"class":299},":=",[293,360,361],{"class":354}," c",[293,363,364],{"class":299},".",[293,366,367],{"class":320},"resolveOptions",[293,369,324],{"class":299},[293,371,372],{"class":354},"opts",[293,374,375],{"class":299},")\n",[293,377,379,382,384,386,388,391],{"class":295,"line":378},3,[293,380,381],{"class":354},"    box ",[293,383,358],{"class":299},[293,385,361],{"class":354},[293,387,364],{"class":299},[293,389,390],{"class":320},"currentBox",[293,392,393],{"class":299},"()\n",[293,395,397,400,402,404,406,409,411,413,415,418],{"class":295,"line":396},4,[293,398,399],{"class":354},"    w ",[293,401,358],{"class":299},[293,403,361],{"class":354},[293,405,364],{"class":299},[293,407,408],{"class":320},"measureText",[293,410,324],{"class":299},[293,412,327],{"class":354},[293,414,334],{"class":299},[293,416,417],{"class":354}," opt",[293,419,375],{"class":299},[293,421,423,426,428,430,432,435,437,440,443,446,448,450],{"class":295,"line":422},5,[293,424,425],{"class":354},"    h ",[293,427,358],{"class":299},[293,429,417],{"class":354},[293,431,364],{"class":299},[293,433,434],{"class":354},"FontSize",[293,436,364],{"class":299},[293,438,439],{"class":320},"Pt",[293,441,442],{"class":299},"()",[293,444,445],{"class":299}," *",[293,447,417],{"class":354},[293,449,364],{"class":299},[293,451,452],{"class":354},"LineHeight\n",[293,454,456,459,461,464,466,469],{"class":295,"line":455},6,[293,457,458],{"class":354},"    c",[293,460,364],{"class":299},[293,462,463],{"class":354},"writer",[293,465,364],{"class":299},[293,467,468],{"class":320},"BeginText",[293,470,393],{"class":299},[293,472,474,476,478,480,482,485,487,490,492,495,497,499,501,503],{"class":295,"line":473},7,[293,475,458],{"class":354},[293,477,364],{"class":299},[293,479,463],{"class":354},[293,481,364],{"class":299},[293,483,484],{"class":320},"SetFont",[293,486,324],{"class":299},[293,488,489],{"class":354},"opt",[293,491,364],{"class":299},[293,493,494],{"class":354},"Font",[293,496,334],{"class":299},[293,498,417],{"class":354},[293,500,364],{"class":299},[293,502,434],{"class":354},[293,504,375],{"class":299},[293,506,508,510,512,514,516,519,521,524,526,529,531,534,536,539,542,544,546,548,550,552],{"class":295,"line":507},8,[293,509,458],{"class":354},[293,511,364],{"class":299},[293,513,463],{"class":354},[293,515,364],{"class":299},[293,517,518],{"class":320},"MoveTo",[293,520,324],{"class":299},[293,522,523],{"class":354},"box",[293,525,364],{"class":299},[293,527,528],{"class":354},"X",[293,530,334],{"class":299},[293,532,533],{"class":354}," box",[293,535,364],{"class":299},[293,537,538],{"class":354},"Y",[293,540,541],{"class":299},"-",[293,543,489],{"class":354},[293,545,364],{"class":299},[293,547,434],{"class":354},[293,549,364],{"class":299},[293,551,439],{"class":320},[293,553,554],{"class":299},"())\n",[293,556,558,560,562,564,566,569,571,573],{"class":295,"line":557},9,[293,559,458],{"class":354},[293,561,364],{"class":299},[293,563,463],{"class":354},[293,565,364],{"class":299},[293,567,568],{"class":320},"ShowString",[293,570,324],{"class":299},[293,572,327],{"class":354},[293,574,375],{"class":299},[293,576,578,580,582,584,586,589],{"class":295,"line":577},10,[293,579,458],{"class":354},[293,581,364],{"class":299},[293,583,463],{"class":354},[293,585,364],{"class":299},[293,587,588],{"class":320},"EndText",[293,590,393],{"class":299},[293,592,594,596,598,601,603,606,608,611],{"class":295,"line":593},11,[293,595,458],{"class":354},[293,597,364],{"class":299},[293,599,600],{"class":320},"advance",[293,602,324],{"class":299},[293,604,605],{"class":354},"w",[293,607,334],{"class":299},[293,609,610],{"class":354}," h",[293,612,375],{"class":299},[293,614,616],{"class":295,"line":615},12,[293,617,618],{"class":299},"}\n",[18,620,621,622,625,626,629,630,633,634,636,637,636,639,641,642,636,645,636,648,636,651,654],{},"没有节点入树。没有位置被推迟。writer 是一个 ",[35,623,624],{},"*pdf.Writer","，持有一个 ",[35,627,628],{},"io.Writer"," (通常是 ",[35,631,632],{},"bytes.Buffer",")。",[35,635,468],{}," / ",[35,638,518],{},[35,640,568],{}," 会立刻把 ",[35,643,644],{},"BT",[35,646,647],{},"Td",[35,649,650],{},"Tj",[35,652,653],{},"ET"," 这些 PDF 算子写进 buffer。",[18,656,657,658,661,662,665,666,669,670,673,674,677],{},"对比 gofpdf 怎么做同样的逻辑操作。gofpdf 维护一个 ",[35,659,660],{},"page"," 对象，里面有一个操作切片。每次 ",[35,663,664],{},"SetXY"," + ",[35,667,668],{},"Cell"," 调用都追加到那个切片。最后 ",[35,671,672],{},"Output"," (或 ",[35,675,676],{},"OutputFileAndClose",") 走一遍切片把字节吐出来。每个 cell 两次分配 — 一次操作结构体、一次字符串拷贝 — 加上对数据的额外一遍扫描。",[18,679,680],{},"100 页报告每页约 40 行，就是 gpdf 不会做的 4,000 次额外分配。",[682,683,684],"h3",{"id":684},"单遍路线痛在哪里",[18,686,687],{},"明显的疑问: 那些必须在开始写字节之前就知道最终页面布局的功能怎么办? 带页码的页眉。跨页表格。锚在最后一行正文下的页脚。",[18,689,690,691,694,695,698,699,702,703,706],{},"两个回答。第一，缓冲的是 ",[22,692,693],{},"页","，不是整个文档。一页是有界单元，几十 KB，不是几 MB。下一次 ",[35,696,697],{},"AddPage()"," 被调用时，当前页的内容流会被敲定 (",[35,700,701],{},"Length","、",[35,704,705],{},"Filter","、偏移)，它的 xref 入口被写出，页缓冲被重置。内存高水位保持在 O(一页)。",[18,708,709,710,713,714,277],{},"第二，对真正的全局元素 (\"第 3/27 页\")，把 ",[22,711,712],{},"那部分范围"," 延迟到 fix-up 扫描。其余内容已经在流里。fix-up 扫一个短的 deferred-reference 标记列表并打补丁。这是代码库里唯一支付接近 AST 代价的地方，而且 ",[22,715,716],{},"只对真正需要的部分支付",[18,718,719,720,723,724,727],{},"代价是: 你没法对节点树做任意后处理，因为根本没有节点树。你没法写一个\"把所有 ",[35,721,722],{},"bold: true"," 的 ",[35,725,726],{},"Text"," 节点重排\"的插件。需要这种 API 形状就用 Maroto v2。",[18,729,730],{},"我们认为这个权衡对 gpdf 面向的使用场景是对的。大多数 PDF 是从左到右、从上到下按构造时就知道的布局生成的。为少数场景保留 AST 的成本被多数场景在每页都付。比例被我们反过来了。",[13,732,734],{"id":733},"决策-2-热点路径没有反射和-interface","决策 2: 热点路径没有反射和 interface",[18,736,737],{},"写起来不比上面有趣，但用 profile 看，剩下一半速度差距在这里。",[18,739,740,741,744],{},"gofpdf 的 ",[35,742,743],{},"CellFormat"," 签名:",[251,746,748],{"className":287,"code":747,"language":289,"meta":259,"style":259},"func (f *Fpdf) CellFormat(w, h float64, txtStr, borderStr string,\n    ln int, alignStr string, fill bool, link int, linkStr string) { ... }\n",[35,749,750,795],{"__ignoreMap":259},[293,751,752,754,756,759,761,764,766,769,771,773,775,777,780,782,785,787,790,792],{"class":295,"line":296},[293,753,300],{"class":299},[293,755,303],{"class":299},[293,757,758],{"class":306},"f ",[293,760,310],{"class":299},[293,762,763],{"class":313},"Fpdf",[293,765,317],{"class":299},[293,767,768],{"class":320}," CellFormat",[293,770,324],{"class":299},[293,772,605],{"class":306},[293,774,334],{"class":299},[293,776,610],{"class":306},[293,778,779],{"class":330}," float64",[293,781,334],{"class":299},[293,783,784],{"class":306}," txtStr",[293,786,334],{"class":299},[293,788,789],{"class":306}," borderStr",[293,791,331],{"class":330},[293,793,794],{"class":299},",\n",[293,796,797,800,803,805,808,810,812,815,818,820,823,825,827,830,832,834,837,839],{"class":295,"line":351},[293,798,799],{"class":306},"    ln",[293,801,802],{"class":330}," int",[293,804,334],{"class":299},[293,806,807],{"class":306}," alignStr",[293,809,331],{"class":330},[293,811,334],{"class":299},[293,813,814],{"class":306}," fill",[293,816,817],{"class":330}," bool",[293,819,334],{"class":299},[293,821,822],{"class":306}," link",[293,824,802],{"class":330},[293,826,334],{"class":299},[293,828,829],{"class":306}," linkStr",[293,831,331],{"class":330},[293,833,317],{"class":299},[293,835,836],{"class":299}," {",[293,838,340],{"class":299},[293,840,841],{"class":299}," }\n",[18,843,844,845,848,849,277,852,855,856,859,860,863,864,866,867,870],{},"这没问题。看 Maroto 的组件树。",[35,846,847],{},"Row"," 持有 ",[35,850,851],{},"[]Component",[35,853,854],{},"Component"," 是 interface。每次布局操作都是虚拟派发: ",[35,857,858],{},"component.Render(ctx)","。一个 ",[35,861,862],{},"Col"," 里放一个 ",[35,865,726],{}," 和一个 ",[35,868,869],{},"Spacer"," 就是三次派发。100 页 × 每页 30 行 × 每行 3 组件 = 9,000 次派发。",[18,872,873],{},"单次 Go interface 派发约 2–3 ns，单独不算罪。但 interface 也强制编译器把装箱的值放到堆上 — 没有 Go 编译器不总能做的去虚化，interface 后面没法栈分配。所以代价不只是派发本身，还有喂它的那次分配。",[18,875,876],{},"gpdf 的布局引擎用具体结构体:",[251,878,880],{"className":287,"code":879,"language":289,"meta":259,"style":259},"type RowBuilder struct {\n    doc    *Document\n    parent *pageState\n    spans  [12]int\n    cols   [12]ColBuilder  // 值数组，不是指针，不是 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,881,882,895,905,915,933,950,958,962,968,979,989,996,1009,1025],{"__ignoreMap":259},[293,883,884,887,890,893],{"class":295,"line":296},[293,885,886],{"class":299},"type",[293,888,889],{"class":313}," RowBuilder",[293,891,892],{"class":299}," struct",[293,894,348],{"class":299},[293,896,897,900,902],{"class":295,"line":351},[293,898,899],{"class":354},"    doc    ",[293,901,310],{"class":299},[293,903,904],{"class":313},"Document\n",[293,906,907,910,912],{"class":295,"line":378},[293,908,909],{"class":354},"    parent ",[293,911,310],{"class":299},[293,913,914],{"class":313},"pageState\n",[293,916,917,920,923,927,930],{"class":295,"line":396},[293,918,919],{"class":354},"    spans  ",[293,921,922],{"class":299},"[",[293,924,926],{"class":925},"sbssI","12",[293,928,929],{"class":299},"]",[293,931,932],{"class":330},"int\n",[293,934,935,938,940,942,944,946],{"class":295,"line":422},[293,936,937],{"class":354},"    cols   ",[293,939,922],{"class":299},[293,941,926],{"class":925},[293,943,929],{"class":299},[293,945,314],{"class":313},[293,947,949],{"class":948},"sHwdD","  // 值数组，不是指针，不是 interface\n",[293,951,952,955],{"class":295,"line":455},[293,953,954],{"class":354},"    n      ",[293,956,957],{"class":330},"uint8\n",[293,959,960],{"class":295,"line":473},[293,961,618],{"class":299},[293,963,964],{"class":295,"line":507},[293,965,967],{"emptyLinePlaceholder":966},true,"\n",[293,969,970,972,975,977],{"class":295,"line":557},[293,971,886],{"class":299},[293,973,974],{"class":313}," ColBuilder",[293,976,892],{"class":299},[293,978,348],{"class":299},[293,980,981,984,986],{"class":295,"line":577},[293,982,983],{"class":354},"    row    ",[293,985,310],{"class":299},[293,987,988],{"class":313},"RowBuilder\n",[293,990,991,994],{"class":295,"line":593},[293,992,993],{"class":354},"    span   ",[293,995,932],{"class":330},[293,997,998,1001,1004,1006],{"class":295,"line":615},[293,999,1000],{"class":354},"    cursor ",[293,1002,1003],{"class":313},"document",[293,1005,364],{"class":299},[293,1007,1008],{"class":313},"Point\n",[293,1010,1012,1015,1017,1020,1022],{"class":295,"line":1011},13,[293,1013,1014],{"class":354},"    writer ",[293,1016,310],{"class":299},[293,1018,1019],{"class":313},"pdf",[293,1021,364],{"class":299},[293,1023,1024],{"class":313},"Writer\n",[293,1026,1028],{"class":295,"line":1027},14,[293,1029,618],{"class":299},[18,1031,1032,1035,1036,277],{},[35,1033,1034],{},"cols"," 是按网格最大列数 (12) 固定大小的值数组。不在堆上。行迭代它的列时也没有 interface 派发。Builder 持 writer 的指针，而 ",[22,1037,1038],{},"writer 不知道 Builder 树的存在",[18,1040,1041,1042,1045,1046,1048],{},"回调模式 (",[35,1043,1044],{},"r.Col(4, func(c *ColBuilder) { ... })",") 不是偶然。我们原型过的其他形式 — 返回可链式调用的 struct、Component interface 装箱树 — 都更慢。这个闭包零分配的原因是 ",[35,1047,314],{}," 是调用方通过指针参数持有的值，闭包本身在多数情况下被 escape analysis 移到栈。",[682,1050,1051],{"id":1051},"怎么知道它起作用了",[18,1053,1054,1055,1058],{},"在 gpdf 跑 ",[35,1056,1057],{},"go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out","，得到一个数我们自豪:",[251,1060,1063],{"className":1061,"code":1062,"language":256},[254],"BenchmarkSinglePage-8   91270   13120 ns/op   8321 B/op   52 allocs/op\n",[35,1064,1062],{"__ignoreMap":259},[18,1066,1067,1068,1071,1072,1074],{},"整个 PDF 页 ",[22,1069,1070],{},"52 次分配","。几乎全部是初始页缓冲、字体度量查找 (每字体一次，不是每字形一次)、最后的 ",[35,1073,632],{}," 扩容。布局循环零分配 — 看 profile 就知道。",[18,1076,1077],{},"gofpdf 同一页:",[251,1079,1082],{"className":1080,"code":1081,"language":256},[254],"BenchmarkGofpdfSinglePage-8   7500   132400 ns/op   71200 B/op   430 allocs/op\n",[35,1083,1081],{"__ignoreMap":259},[18,1085,1086],{},"430 次分配。大部分是操作切片和填它的字符串拷贝。把这 8 倍的分配差走一遍 GC，10 倍的运行时差就是自然结果。",[682,1088,1089],{"id":1089},"放弃了什么",[18,1091,1092,1093,1096,1097,1099,1100,1103],{},"热点路径零人体工学意味着 ",[22,1094,1095],{},"扩展点少","。想写一个接入 gpdf 布局的自定义元素类型 — 类似于在 Maroto 里实现 ",[35,1098,854],{}," — 做不到。没有可满足的 interface。替代是 ",[35,1101,1102],{},"template.WithWriterSetup()","，提供对 PDF writer 的钩子，用来注入自定义注解、PDF/A 元数据、加密。布局层的扩展用一个调用相同 Builder 方法的辅助函数来实现。",[18,1105,1106],{},"扩展点少是真实的代价。当前判断是平衡的。如果项目方向改变让这判断不再成立，会重新考虑。",[13,1108,1110],{"id":1109},"决策-3-不重走的-truetype-子集器","决策 3: 不重走的 TrueType 子集器",[18,1112,1113],{},"CJK 基准 (gpdf 133 µs 对 gofpdf 254 µs) 的差距主要来自这里。",[18,1115,1116,1117,1120],{},"TrueType 子集化的作用简述: 把日文字体嵌入 PDF 时，你不会想嵌入所有 20,000+ 字形 — 一个 100 KB 的文档里放 15 MB 字体数据。你想 ",[22,1118,1119],{},"只嵌入文档实际用到的字形","，打包成 PDF 阅读器能解码的有效子集 TTF。",[18,1122,1123],{},"流程:",[44,1125,1126,1145,1148,1151],{},[47,1127,1128,1129,1132,1133,1136,1137,1140,1141,1144],{},"解析完整 TTF 表: ",[35,1130,1131],{},"cmap"," (字符到字形映射)、",[35,1134,1135],{},"glyf"," (轮廓)、",[35,1138,1139],{},"loca"," (到 glyf 的偏移)、",[35,1142,1143],{},"hmtx"," (水平度量) 等。",[47,1146,1147],{},"对文档每个字符，通过 cmap 查字形 ID。",[47,1149,1150],{},"递归收集复合字形引用的字形。",[47,1152,1153],{},"输出只含那些字形、重新编号的新 TTF。",[18,1155,1156,1157,1160],{},"步骤 2 — cmap 查找 — 是热点路径。gofpdf 实现 ",[22,1158,1159],{},"每次字形查找都从头走一遍 cmap 表","。只含 Latin 的页面没问题; cmap 小、缓存友好。一个 CJK 页面有 150 个唯一字形，就是对表做 150 次完整走查。",[18,1162,1163],{},"cmap format 12 (大多数现代 CJK 字体使用) 是 (start, end, startGlyphID) 三元组的有序数组。一次走查对范围数是 O(n)，NotoSansJP 大约 200–500 个范围。150 次查找 × 每范围比较 × 400 范围 = 远超必要的工作量。",[18,1165,1166,1167,1170],{},"gpdf 在字体首次加载时把整张 cmap 展开成 ",[35,1168,1169],{},"map[rune]uint16","。之后每次查找都是 O(1)。NotoSansJP 的一次性成本约 150 µs，之后每字符 10 ns。",[251,1172,1174],{"className":287,"code":1173,"language":289,"meta":259,"style":259},"// pdf/font/ttf.go 简化\ntype Font struct {\n    runeToGID map[rune]uint16  // 加载时解析一次\n    glyphs    []glyph          // 按 GID 索引\n    metrics   []glyphMetric\n}\n\nfunc (f *Font) GlyphFor(r rune) uint16 {\n    return f.runeToGID[r]  // O(1)、缓存友好、无表走查\n}\n",[35,1175,1176,1181,1192,1211,1225,1235,1239,1243,1275,1298],{"__ignoreMap":259},[293,1177,1178],{"class":295,"line":296},[293,1179,1180],{"class":948},"// pdf/font/ttf.go 简化\n",[293,1182,1183,1185,1188,1190],{"class":295,"line":351},[293,1184,886],{"class":299},[293,1186,1187],{"class":313}," Font",[293,1189,892],{"class":299},[293,1191,348],{"class":299},[293,1193,1194,1197,1200,1203,1205,1208],{"class":295,"line":378},[293,1195,1196],{"class":354},"    runeToGID ",[293,1198,1199],{"class":299},"map[",[293,1201,1202],{"class":330},"rune",[293,1204,929],{"class":299},[293,1206,1207],{"class":330},"uint16",[293,1209,1210],{"class":948},"  // 加载时解析一次\n",[293,1212,1213,1216,1219,1222],{"class":295,"line":396},[293,1214,1215],{"class":354},"    glyphs    ",[293,1217,1218],{"class":299},"[]",[293,1220,1221],{"class":313},"glyph",[293,1223,1224],{"class":948},"          // 按 GID 索引\n",[293,1226,1227,1230,1232],{"class":295,"line":422},[293,1228,1229],{"class":354},"    metrics   ",[293,1231,1218],{"class":299},[293,1233,1234],{"class":313},"glyphMetric\n",[293,1236,1237],{"class":295,"line":455},[293,1238,618],{"class":299},[293,1240,1241],{"class":295,"line":473},[293,1242,967],{"emptyLinePlaceholder":966},[293,1244,1245,1247,1249,1251,1253,1255,1257,1260,1262,1265,1268,1270,1273],{"class":295,"line":507},[293,1246,300],{"class":299},[293,1248,303],{"class":299},[293,1250,758],{"class":306},[293,1252,310],{"class":299},[293,1254,494],{"class":313},[293,1256,317],{"class":299},[293,1258,1259],{"class":320}," GlyphFor",[293,1261,324],{"class":299},[293,1263,1264],{"class":306},"r",[293,1266,1267],{"class":330}," rune",[293,1269,317],{"class":299},[293,1271,1272],{"class":330}," uint16",[293,1274,348],{"class":299},[293,1276,1277,1281,1284,1286,1289,1291,1293,1295],{"class":295,"line":557},[293,1278,1280],{"class":1279},"s7zQu","    return",[293,1282,1283],{"class":354}," f",[293,1285,364],{"class":299},[293,1287,1288],{"class":354},"runeToGID",[293,1290,922],{"class":299},[293,1292,1264],{"class":354},[293,1294,929],{"class":299},[293,1296,1297],{"class":948},"  // O(1)、缓存友好、无表走查\n",[293,1299,1300],{"class":295,"line":577},[293,1301,618],{"class":299},[18,1303,1304],{},"一个按 rune 索引的 map，对 cmap 表做一次线性扫描构建。多个页面使用同一字体的文档 (通常是所有页) 中，字形查找从\"约为页数 × 字形数的二次\"变成\"总字形数加常量\"。",[682,1306,1308],{"id":1307},"为什么-format-12-是关键细节","为什么 \"format 12\" 是关键细节",[18,1310,1311,1312,723,1314,1317,1318,1321],{},"很多老的 Go PDF 库写于只关注 Latin 文本的年代，实现的是 cmap format 4 — Basic Multilingual Plane (U+0000–U+FFFF) 的分段范围。BMP 之外的日文 (不常见但有一些异体 Kanji) 需要 format 12。",[35,1313,124],{},[35,1315,1316],{},"AddUTF8Font"," 在 NotoSansJP-Regular.ttf 上 ",[22,1319,1320],{},"panic","，因为 format 12 的解析器从来没写完。",[18,1323,1324],{},"这不是吐槽。这是历史遗物: gofpdf 在 2015 年对 Latin 为主的 Web 应用来说是一个优秀的库，fork 继承了它的范围。世界变了，CJK 从\"别人的问题\"变成\"日文和中文 Go 生态的多数问题\"。gpdf 实现了完整 cmap 规范，因为另一种选择是给\"品目\"渲染出豆腐方块的发票 — 这是公开发布第一周就收到的真实 bug 报告。",[682,1326,1327],{"id":1327},"按字体数扩展的缓存",[18,1329,1330,1331,1334,1335,1337,1338,1341],{},"字体缓存按 ",[35,1332,1333],{},"Document"," 而不是全局。用同一字体生成 10,000 份 PDF，就要付 10,000 次 150 µs 的解析成本 — 除非跨文档共享 ",[35,1336,494],{}," 实例，API 通过 ",[35,1339,1340],{},"gpdf.WithSharedFont(preloadedFont)"," 支持。",[18,1343,1344,1345,1348],{},"对高吞吐批量生成 (SaaS 的 ",[35,1346,1347],{},"gpdf-api"," 就是这样)，这个共享字体模式让 P95 延迟可预测。文档里有说明。OSS 用户多数不需要。",[13,1350,1351],{"id":1351},"三个决策叠加的效果",[18,1353,1354],{},"把三个决策拿到 100 页基准 (gpdf 683 µs, gofpdf 11.7 ms) 上:",[104,1356,1357,1370],{},[107,1358,1359],{},[110,1360,1361,1364,1367],{},[113,1362,1363],{},"时间去向",[113,1365,1366],{},"gofpdf (每页概估)",[113,1368,1369],{},"gpdf (每页概估)",[132,1371,1372,1383,1394,1405,1416],{},[110,1373,1374,1377,1380],{},[137,1375,1376],{},"操作切片构建",[137,1378,1379],{},"约 60 µs",[137,1381,1382],{},"0 (直接流)",[110,1384,1385,1388,1391],{},[137,1386,1387],{},"操作序列化",[137,1389,1390],{},"约 35 µs",[137,1392,1393],{},"0 (已写入)",[110,1395,1396,1399,1402],{},[137,1397,1398],{},"字形查找 (40 字符)",[137,1400,1401],{},"约 6 µs",[137,1403,1404],{},"约 0.4 µs",[110,1406,1407,1410,1413],{},[137,1408,1409],{},"分配 / GC 压力",[137,1411,1412],{},"约 20 µs",[137,1414,1415],{},"约 2 µs",[110,1417,1418,1423,1428],{},[137,1419,1420],{},[22,1421,1422],{},"合计",[137,1424,1425],{},[22,1426,1427],{},"约 120 µs",[137,1429,1430],{},[22,1431,1432],{},"约 7 µs",[18,1434,1435,1436,1439],{},"数字是从 profile 估的，实际分布看内容。但形状是对的。",[22,1437,1438],{},"三个里任何一个单独都赢不了 10 倍","。叠在一起才 10 倍。",[18,1441,1442],{},"推论: 把其中一个设计抄到已有库里能拿到 2–3 倍。想要 10 倍得三个都要，而且把第一个 (单遍) 改到基于 AST 的库里只能重写。",[13,1444,1446],{"id":1445},"放弃了什么-诚实那一节","放弃了什么 (诚实那一节)",[18,1448,1449],{},"之前一直绕着说。列全:",[18,1451,1452,1455,1456,1459],{},[22,1453,1454],{},"基于 AST 的后处理。"," 没有插件架构。没有\"走节点树应用变换\"。要在渲染前全局编辑整个文档的文本样式，在调用 Builder ",[22,1457,1458],{},"之前"," 做。",[18,1461,1462,1465,1466,1469],{},[22,1463,1464],{},"内省。"," 没有 ",[35,1467,1468],{},"doc.Components()"," 返回放进去的所有东西。任何有意义的方法能跑的时候，文档已经是操作符流了。多数用户用不上。写文档操作工具的少数用户用得上。",[18,1471,1472,1465,1475,1478,1479,1482],{},[22,1473,1474],{},"基于反射的序列化。",[35,1476,1477],{},"json.Unmarshal"," 风格把任意 struct 变成 PDF 的 API。JSON Schema 入口 (",[35,1480,1481],{},"template.FromJSON",") 明确支持的形状，故意的。要把通用 Go struct 喂进去得到 PDF，那是 unidoc 的领地。",[18,1484,1485,1488,1489,1491],{},[22,1486,1487],{},"interface 的扩展性。"," 不能实现 ",[35,1490,854],{}," 注册自定义元素。可以写一个包裹 Builder 调用的辅助函数。实用上能覆盖 95% 需求，但模型不同。",[18,1493,1494],{},"都是刻意的。任何一个采纳都会让速度死掉。我们选择优先服务\"快而有主见\"受益的那桶用户，\"灵活和插件丰富\"那桶用户 Maroto v2 或 unidoc 更合适。",[13,1496,1497],{"id":1497},"能复现基准吗",[18,1499,1500],{},"能。公开代码的全部意义就是这个。",[251,1502,1506],{"className":1503,"code":1504,"language":1505,"meta":259,"style":259},"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,1507,1508,1520,1528],{"__ignoreMap":259},[293,1509,1510,1513,1517],{"class":295,"line":296},[293,1511,1512],{"class":313},"git",[293,1514,1516],{"class":1515},"sfazB"," clone",[293,1518,1519],{"class":1515}," https://github.com/gpdf-dev/gpdf\n",[293,1521,1522,1525],{"class":295,"line":351},[293,1523,1524],{"class":320},"cd",[293,1526,1527],{"class":1515}," gpdf/_benchmark\n",[293,1529,1530,1532,1535,1538,1541],{"class":295,"line":378},[293,1531,289],{"class":313},[293,1533,1534],{"class":1515}," test",[293,1536,1537],{"class":1515}," -bench=.",[293,1539,1540],{"class":1515}," -benchmem",[293,1542,1543],{"class":1515}," -benchtime=5s\n",[18,1545,1546,1547,1552],{},"那个目录的 README 讲了四个工作负载和度量什么。在同架构、同 Go 版本下差距超过 20%，",[76,1548,1551],{"href":1549,"rel":1550},"https://github.com/gpdf-dev/gpdf/issues",[80],"提 issue"," — drift 真实存在。",[18,1554,1555],{},"两个补充:",[1557,1558,1559,1565],"ul",{},[47,1560,1561,1562,1564],{},"基准带 ",[35,1563,101],{},"。去掉能全面提升约 5%，但那不是真实代码的跑法，所以不写进公开数字。",[47,1566,1567],{},"关 CGO。有人问 CGO 挂 FreeType 做字体操作会不会更快，测过，FFI 边界的 marshal 代价盖过收益。对 PDF 生成器的访问模式，纯 Go 子集器赢。",[13,1569,1571],{"id":1570},"faq","FAQ",[18,1573,1574,1577,1578,277],{},[22,1575,1576],{},"为什么要和已归档的 gofpdf 比?","\n因为它还是 GitHub 搜 \"go pdf\" 的第一名，落到 gpdf 的团队多数是从那迁来的。基准需要回答这波人\"值不值得迁\"。简版: 值得，还写了 ",[76,1579,1581],{"href":1580},"/zh/blog/gofpdf-migration","迁移指南",[18,1583,1584,1587],{},[22,1585,1586],{},"PDF 生成 10 倍快真有意义吗?","\n看工作负载。单请求单文档 — 其实没差，两边都过\"请求路径内生成\"的门槛。批处理 (夜间账单、大批量发票、从 DB 查询生成报表)，差距直接对应机器数变少。第一个迁批处理流水线的团队反馈\"worker 数变成十分之一\"，没审他们的算账，但和基准的形状一致。",[18,1589,1590,1593,1594,1597,1598,1600],{},[22,1591,1592],{},"CJK 那个数字的陷阱?","\n字体文件你得自己带。gpdf 帮你做子集，但 3 MB 的 NotoSansJP TTF 是 3 MB，要嘛编进 Go 二进制要嘛启动时 ",[35,1595,1596],{},"os.ReadFile","。在 distroless 镜像里这会影响。SaaS ",[35,1599,1347],{}," 通过在镜像里带常用字体解决。OSS 用户自理。",[18,1602,1603,1606],{},[22,1604,1605],{},"加功能会不会变慢?","\n我们最在意这问题。答: 每个 release 都和上一版跑基准，四个负载任意一个退化超过 5% 就卡 release。基准和库在同一仓库，原因就在此。",[18,1608,1609,1612],{},[22,1610,1611],{},"名字哪来的?","\ngpdf = Go + PDF。没有巧思。刻意简单。",[13,1614,1616],{"id":1615},"试用-gpdf","试用 gpdf",[18,1618,1619],{},"gpdf 是 Go 的 PDF 生成库。MIT、零依赖、原生 CJK。",[251,1621,1623],{"className":1503,"code":1622,"language":1505,"meta":259,"style":259},"go get github.com/gpdf-dev/gpdf\n",[35,1624,1625],{"__ignoreMap":259},[293,1626,1627,1629,1632],{"class":295,"line":296},[293,1628,289],{"class":313},[293,1630,1631],{"class":1515}," get",[293,1633,1634],{"class":1515}," github.com/gpdf-dev/gpdf\n",[18,1636,1637,1642,1643],{},[76,1638,1641],{"href":1639,"rel":1640},"https://github.com/gpdf-dev/gpdf",[80],"⭐ Star on GitHub"," · ",[76,1644,1647],{"href":1645,"rel":1646},"https://gpdf.dev/zh/docs/quickstart",[80],"文档",[13,1649,1650],{"id":1650},"下一步阅读",[1557,1652,1653,1660,1666],{},[47,1654,1655,1659],{},[76,1656,1658],{"href":1657},"/zh/blog/go-pdf-library-showdown-2026","2026 年 Go PDF 库横评"," — 含许可证和依赖的完整库比较。",[47,1661,1662,1665],{},[76,1663,1664],{"href":1580},"gofpdf 已归档，如何迁移到 gpdf"," — 五组 Before/After API 对照，全部可运行。",[47,1667,1668,1669,277],{},"基准代码: ",[76,1670,1672],{"href":78,"rel":1671},[80],[35,1673,83],{},[1675,1676,1677],"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":259,"searchDepth":351,"depth":351,"links":1679},[1680,1681,1682,1685,1689,1693,1694,1695,1696,1697,1698],{"id":15,"depth":351,"text":16},{"id":94,"depth":351,"text":95},{"id":245,"depth":351,"text":246,"children":1683},[1684],{"id":684,"depth":378,"text":684},{"id":733,"depth":351,"text":734,"children":1686},[1687,1688],{"id":1051,"depth":378,"text":1051},{"id":1089,"depth":378,"text":1089},{"id":1109,"depth":351,"text":1110,"children":1690},[1691,1692],{"id":1307,"depth":378,"text":1308},{"id":1327,"depth":378,"text":1327},{"id":1351,"depth":351,"text":1351},{"id":1445,"depth":351,"text":1446},{"id":1497,"depth":351,"text":1497},{"id":1570,"depth":351,"text":1571},{"id":1615,"depth":351,"text":1616},{"id":1650,"depth":351,"text":1650},"2026-04-19","单页 13 µs，100 页报告 683 µs。不是调参，而是三个架构决策的叠加。本文走一遍代码路径。",false,"md",null,{},"/zh/blog/why-gpdf-is-faster",{"title":5,"description":1700},"zh/blog/011.why-gpdf-is-faster",[1709,1710,1711],"benchmark","internals","comparison","r8Xboa3Vyt-wlvC53aR8syMVr3oVZ-tUJGXgfqGmRXs",1776537632698]