[{"data":1,"prerenderedAt":1856},["ShallowReactive",2],{"post-pure-go-pdf-compression":3,"post-nav-posts":1540,"post-project-pure-go-pdf-compression":1563},{"id":4,"title":5,"body":6,"cover":1524,"date":1525,"description":192,"extension":1526,"links":1524,"meta":1527,"navigation":1528,"path":1529,"project":1530,"seo":1531,"slug":1532,"stem":1533,"summary":1534,"tags":1535,"visible":1528,"__hash__":1539},"posts\u002Fposts\u002Fpure-go-pdf-compression.md","不引 Ghostscript：我用纯 Go 给 PDF 做了一场「压缩手术」",{"type":7,"value":8,"toc":1514},"minimark",[9,27,30,33,36,39,46,59,62,67,75,102,117,121,125,128,155,166,170,180,186,464,475,484,937,940,962,972,1115,1118,1124,1131,1212,1219,1365,1372,1391,1395,1401,1410,1416,1422,1432,1436,1482,1485,1491,1496,1499,1510],[10,11,12],"blockquote",{},[13,14,15,16,21,22,26],"p",{},"本站另有两篇 Wails 实战：《",[17,18,20],"a",{"href":19},"\u002Fblog\u002Fecom-detail-ai-part2-wails","Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用","》和《",[17,23,25],{"href":24},"\u002Fblog\u002Fwails-v3-upgrade","Wails v3 升级实战","》。本文的主角「图片工坊」与它们同栈（Go + Vue3 + Wails），但这篇只专注其中最硬的一块：PDF 压缩。",[13,28,29],{},"先从一个具体场景说起。",[13,31,32],{},"内网办公的电脑上，一份材料要交：对面系统只收 5MB 以内的附件，手里这份十几页的扫描件 PDF 是 40MB——A4 纸按 300 DPI 扫，一页就是两三 MB，页数一多轻松翻十倍。",[13,34,35],{},"这时候能想到的路基本都不通。在线压缩网站，内网打不开；就算打得开，报销单、合同、证件的扫描件也不该往陌生服务器上传。程序员的答案通常是 Ghostscript，一条命令的事——但如果你做的是要分发给别人用的桌面工具，它就是个外部进程：macOS、Windows、银河麒麟每个平台打包一份，AGPL 许可要评估，装在内网机器上还有维护成本。对一个安装包必须自包含的工具来说，这条路比看起来贵得多。",[13,37,38],{},"「图片工坊」就是为这个场景做的离线批量图片工具（macOS \u002F Windows \u002F 银河麒麟三平台），0.2.0 加了 PDF 压缩：拖进去、点开始，输出一个更小的 PDF。交材料的场景同时定了两条底线：文字一字不动，图片不能糊到看不清。实现是纯 Go 的——生态里唯一的现成库 pdfcpu 恰好缺最关键的图像重压缩（2017 年挂到今天的 open feature request），所以分工是：pdfcpu 负责解析和回写，图像处理自己写。",[13,40,41],{},[42,43],"img",{"alt":44,"src":45},"图片工坊工作台：批量处理进行中的队列与进度","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E6%89%B9%E9%87%8F%E5%A4%84%E7%90%86.gif",[13,47,48],{},[49,50,51,52,58],"em",{},"工作台批量处理进行中（示例为图片批次）。PDF 不用选规则，拖进去直接压缩。项目已开源：",[17,53,57],{"href":54,"rel":55},"https:\u002F\u002Fgithub.com\u002FBenny66\u002Ffile-processor",[56],"nofollow","github.com\u002FBenny66\u002Ffile-processor","，三平台安装包在 Release 页。",[13,60,61],{},"这篇文章讲三件事：业内压缩 PDF 的通用做法是什么；我们的实现具体怎么做；跟业内方案比，几处不同的设计换来了什么。",[63,64,66],"h2",{"id":65},"一业内是怎么压缩-pdf-的","一、业内是怎么压缩 PDF 的",[13,68,69,70,74],{},"先看 PDF 的体积构成。扫描件和图文混排文档的大头都是",[71,72,73],"strong",{},"内嵌图像流","，其次是字体，真正的结构性冗余（重复对象、未压缩的 xref 表、没用到的对象）通常只占零头。所以有效的压缩手段就三件：",[76,77,78,90,96],"ol",{},[79,80,81,84,85,89],"li",{},[71,82,83],{},"无损结构优化","：去重、对象流压缩、清理无用对象。qpdf 和 pdfcpu 的 ",[86,87,88],"code",{},"optimize"," 都是这个级别，收益稳定但有限，动不了图像流。",[79,91,92,95],{},[71,93,94],{},"按显示尺寸降采样","：这是最大的杠杆。扫描件常按 300–600 DPI 存储，屏幕阅读只需要 150。",[79,97,98,101],{},[71,99,100],{},"图像重存","：解出来、用更低的质量重新编码回去。",[13,103,104,105,108,109,112,113,116],{},"Ghostscript 把 2 和 3 打包成了三档预设：",[86,106,107],{},"\u002Fscreen"," = 72 DPI、",[86,110,111],{},"\u002Febook"," = 150 DPI、",[86,114,115],{},"\u002Fprinter"," = 300 DPI，只对高于阈值的图降采样，同时按档位配一个 JPEG 重存质量。Acrobat 的「减少文件大小」和各家在线工具的「三档强度」，拆开看同样是「DPI 阈值 + 质量档」的预设组合。这套方案成熟、有效，问题只有一个：它跑在外部进程里，我引不进来。",[63,118,120],{"id":119},"二我们的实现","二、我们的实现",[122,123,124],"h3",{"id":124},"整体管道",[13,126,127],{},"入口先过三道闸：加密的直接拒绝、带签名的直接拒绝（改一个字节签名就失效）、损坏的报「打不开」。之后的处理流程：",[76,129,130,136,142,149,152],{},[79,131,132,135],{},[71,133,134],{},"扫描页识别","——找出「满页单图」的页面，为每张图算出降采样目标（下文展开）；",[79,137,138,141],{},[71,139,140],{},"遍历 xref 表","，对每个图像 XObject 判定是否在安全子集（单一 DCTDecode、RGB\u002FGray 8bpc、无 Decode 映射）；不在子集的保持原字节，计入「不支持的图片类型」；",[79,143,144,145,148],{},"子集内的图解码出来，若有降采样目标先缩到目标像素，再按质量 75 重编码；",[71,146,147],{},"编码结果不比原来小就放弃","，保持原样（「已经很省空间」）；",[79,150,151],{},"更小的整体写回 xref 条目；",[79,153,154],{},"最后跑 pdfcpu 的无损结构优化（去重、对象流压缩、清理），输出并逐张计数。",[13,156,157,158,161,162,165],{},"两个术语说明一下，后面会反复出现：",[71,159,160],{},"xref 表","是 PDF 的对象目录，每个间接对象在这里有条目；",[71,163,164],{},"XObject"," 是被文档引用的流对象，图像就存在其中的 Image XObject 里，滤镜名为 DCTDecode 的就是 JPEG 字节。",[122,167,169],{"id":168},"内嵌-jpeg-重存","内嵌 JPEG 重存",[13,171,172,175,176,179],{},[71,173,174],{},"定位图像对象。"," 不解析页面内容流，直接遍历 xref 表，取出流字典后用 ",[86,177,178],{},"sd.Image()"," 判断是不是图像对象。这样一个好处是跨页共享的图像对象天然只处理一次。",[13,181,182,185],{},[71,183,184],{},"安全子集判定。"," 只处理同时满足这几个条件的图：",[187,188,193],"pre",{"className":189,"code":190,"language":191,"meta":192,"style":192},"language-go shiki shiki-themes github-dark github-light","\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\nfunc recompressibleColorSpace(sd types.StreamDict) bool {\n    cs, found := sd.Dict[\"ColorSpace\"]\n    if !found {\n        return false\n    }\n    name, ok := cs.(types.Name)\n    if !ok || (name != \"DeviceRGB\" && name != \"DeviceGray\") {\n        return false \u002F\u002F CMYK 转 RGB 会偏色；Indexed 是查色表语义，重编码后对不上\n    }\n    bpc, ok := sd.Dict[\"BitsPerComponent\"].(types.Integer)\n    if !ok || bpc.Value() != 8 {\n        return false \u002F\u002F 非 8bit 的 DCT 极罕见，标准库也解不了\n    }\n    _, hasDecode := sd.Dict[\"Decode\"]\n    return !hasDecode \u002F\u002F Decode 是分量重映射，解码结果和字典声明不一致\n}\n","go","",[86,194,195,204,241,260,272,282,288,310,346,357,362,387,414,424,429,444,458],{"__ignoreMap":192},[196,197,200],"span",{"class":198,"line":199},"line",1,[196,201,203],{"class":202},"si27w","\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\n",[196,205,207,211,215,219,223,226,229,232,235,238],{"class":198,"line":206},2,[196,208,210],{"class":209},"spKkM","func",[196,212,214],{"class":213},"sqoU-"," recompressibleColorSpace",[196,216,218],{"class":217},"shWlK","(",[196,220,222],{"class":221},"sdxZB","sd",[196,224,225],{"class":213}," types",[196,227,228],{"class":217},".",[196,230,231],{"class":213},"StreamDict",[196,233,234],{"class":217},") ",[196,236,237],{"class":209},"bool",[196,239,240],{"class":217}," {\n",[196,242,244,247,250,253,257],{"class":198,"line":243},3,[196,245,246],{"class":217},"    cs, found ",[196,248,249],{"class":209},":=",[196,251,252],{"class":217}," sd.Dict[",[196,254,256],{"class":255},"skb7c","\"ColorSpace\"",[196,258,259],{"class":217},"]\n",[196,261,263,266,269],{"class":198,"line":262},4,[196,264,265],{"class":209},"    if",[196,267,268],{"class":209}," !",[196,270,271],{"class":217},"found {\n",[196,273,275,278],{"class":198,"line":274},5,[196,276,277],{"class":209},"        return",[196,279,281],{"class":280},"sTU5a"," false\n",[196,283,285],{"class":198,"line":284},6,[196,286,287],{"class":217},"    }\n",[196,289,291,294,296,299,302,304,307],{"class":198,"line":290},7,[196,292,293],{"class":217},"    name, ok ",[196,295,249],{"class":209},[196,297,298],{"class":217}," cs.(",[196,300,301],{"class":213},"types",[196,303,228],{"class":217},[196,305,306],{"class":213},"Name",[196,308,309],{"class":217},")\n",[196,311,313,315,317,320,323,326,329,332,335,338,340,343],{"class":198,"line":312},8,[196,314,265],{"class":209},[196,316,268],{"class":209},[196,318,319],{"class":217},"ok ",[196,321,322],{"class":209},"||",[196,324,325],{"class":217}," (name ",[196,327,328],{"class":209},"!=",[196,330,331],{"class":255}," \"DeviceRGB\"",[196,333,334],{"class":209}," &&",[196,336,337],{"class":217}," name ",[196,339,328],{"class":209},[196,341,342],{"class":255}," \"DeviceGray\"",[196,344,345],{"class":217},") {\n",[196,347,349,351,354],{"class":198,"line":348},9,[196,350,277],{"class":209},[196,352,353],{"class":280}," false",[196,355,356],{"class":202}," \u002F\u002F CMYK 转 RGB 会偏色；Indexed 是查色表语义，重编码后对不上\n",[196,358,360],{"class":198,"line":359},10,[196,361,287],{"class":217},[196,363,365,368,370,372,375,378,380,382,385],{"class":198,"line":364},11,[196,366,367],{"class":217},"    bpc, ok ",[196,369,249],{"class":209},[196,371,252],{"class":217},[196,373,374],{"class":255},"\"BitsPerComponent\"",[196,376,377],{"class":217},"].(",[196,379,301],{"class":213},[196,381,228],{"class":217},[196,383,384],{"class":213},"Integer",[196,386,309],{"class":217},[196,388,390,392,394,396,398,401,404,407,409,412],{"class":198,"line":389},12,[196,391,265],{"class":209},[196,393,268],{"class":209},[196,395,319],{"class":217},[196,397,322],{"class":209},[196,399,400],{"class":217}," bpc.",[196,402,403],{"class":213},"Value",[196,405,406],{"class":217},"() ",[196,408,328],{"class":209},[196,410,411],{"class":280}," 8",[196,413,240],{"class":217},[196,415,417,419,421],{"class":198,"line":416},13,[196,418,277],{"class":209},[196,420,353],{"class":280},[196,422,423],{"class":202}," \u002F\u002F 非 8bit 的 DCT 极罕见，标准库也解不了\n",[196,425,427],{"class":198,"line":426},14,[196,428,287],{"class":217},[196,430,432,435,437,439,442],{"class":198,"line":431},15,[196,433,434],{"class":217},"    _, hasDecode ",[196,436,249],{"class":209},[196,438,252],{"class":217},[196,440,441],{"class":255},"\"Decode\"",[196,443,259],{"class":217},[196,445,447,450,452,455],{"class":198,"line":446},16,[196,448,449],{"class":209},"    return",[196,451,268],{"class":209},[196,453,454],{"class":217},"hasDecode ",[196,456,457],{"class":202},"\u002F\u002F Decode 是分量重映射，解码结果和字典声明不一致\n",[196,459,461],{"class":198,"line":460},17,[196,462,463],{"class":217},"}\n",[13,465,466,467,470,471,474],{},"加上滤镜必须是",[71,468,469],{},"单一 DCTDecode","（",[86,472,473],{},"HasSoleFilterNamed","），这套谓词画出的圈子很小，但每条排除都有明确的技术原因，不是拍脑袋。圈子外的图（Flate 截图、传真码 JBIG2\u002FCCITT、CMYK、索引色）保持原字节，计入「不支持的图片类型」。",[13,476,477,480,481,483],{},[71,478,479],{},"重存与替换。"," 解码出来，用标准库按质量 75 重新编码，",[71,482,147],{},"：",[187,485,487],{"className":189,"code":486,"language":191,"meta":192,"style":192},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go\nfunc recompressPDFImage(sd types.StreamDict, target *image.Point) (types.StreamDict, pdfImageResult) {\n    if !sd.HasSoleFilterNamed(filterDCTDecode) || !recompressibleColorSpace(sd) {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    width, height := pdfImageDims(sd)\n    if width \u003C= 0 || height \u003C= 0 || int64(width)*int64(height) > MaxPixels {\n        return types.StreamDict{}, pdfImageUnsupported \u002F\u002F 100 MP 上限\n    }\n    img, _, err := image.Decode(bytes.NewReader(sd.Raw))\n    if err != nil {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    result := pdfImageReplaced\n    if target != nil && (img.Bounds().Dx() != target.X || img.Bounds().Dy() != target.Y) {\n        img = resizeImage(img, target.X, target.Y) \u002F\u002F 扫描页降采样目标\n        result = pdfImageReplacedDownsampled\n    }\n    var out bytes.Buffer\n    if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: pdfJPEGQuality}); err != nil {\n        return types.StreamDict{}, pdfImageUnsupported\n    }\n    if out.Len() >= len(sd.Raw) {\n        return types.StreamDict{}, pdfImageNotSmaller \u002F\u002F 重存不更小：保持原样\n    }\n    \u002F\u002F ……写回\n}\n",[86,488,489,493,543,567,580,584,597,640,656,660,682,696,708,712,722,773,790,800,805,822,864,877,882,904,921,926,932],{"__ignoreMap":192},[196,490,491],{"class":198,"line":199},[196,492,203],{"class":202},[196,494,495,497,500,502,504,506,508,510,513,516,519,522,524,527,530,532,534,536,538,541],{"class":198,"line":206},[196,496,210],{"class":209},[196,498,499],{"class":213}," recompressPDFImage",[196,501,218],{"class":217},[196,503,222],{"class":221},[196,505,225],{"class":213},[196,507,228],{"class":217},[196,509,231],{"class":213},[196,511,512],{"class":217},", ",[196,514,515],{"class":221},"target",[196,517,518],{"class":209}," *",[196,520,521],{"class":213},"image",[196,523,228],{"class":217},[196,525,526],{"class":213},"Point",[196,528,529],{"class":217},") (",[196,531,301],{"class":213},[196,533,228],{"class":217},[196,535,231],{"class":213},[196,537,512],{"class":217},[196,539,540],{"class":213},"pdfImageResult",[196,542,345],{"class":217},[196,544,545,547,549,552,554,557,559,561,564],{"class":198,"line":243},[196,546,265],{"class":209},[196,548,268],{"class":209},[196,550,551],{"class":217},"sd.",[196,553,473],{"class":213},[196,555,556],{"class":217},"(filterDCTDecode) ",[196,558,322],{"class":209},[196,560,268],{"class":209},[196,562,563],{"class":213},"recompressibleColorSpace",[196,565,566],{"class":217},"(sd) {\n",[196,568,569,571,573,575,577],{"class":198,"line":262},[196,570,277],{"class":209},[196,572,225],{"class":213},[196,574,228],{"class":217},[196,576,231],{"class":213},[196,578,579],{"class":217},"{}, pdfImageUnsupported\n",[196,581,582],{"class":198,"line":274},[196,583,287],{"class":217},[196,585,586,589,591,594],{"class":198,"line":284},[196,587,588],{"class":217},"    width, height ",[196,590,249],{"class":209},[196,592,593],{"class":213}," pdfImageDims",[196,595,596],{"class":217},"(sd)\n",[196,598,599,601,604,607,610,613,616,618,620,622,625,628,631,634,637],{"class":198,"line":290},[196,600,265],{"class":209},[196,602,603],{"class":217}," width ",[196,605,606],{"class":209},"\u003C=",[196,608,609],{"class":280}," 0",[196,611,612],{"class":209}," ||",[196,614,615],{"class":217}," height ",[196,617,606],{"class":209},[196,619,609],{"class":280},[196,621,612],{"class":209},[196,623,624],{"class":209}," int64",[196,626,627],{"class":217},"(width)",[196,629,630],{"class":209},"*int64",[196,632,633],{"class":217},"(height) ",[196,635,636],{"class":209},">",[196,638,639],{"class":217}," MaxPixels {\n",[196,641,642,644,646,648,650,653],{"class":198,"line":312},[196,643,277],{"class":209},[196,645,225],{"class":213},[196,647,228],{"class":217},[196,649,231],{"class":213},[196,651,652],{"class":217},"{}, pdfImageUnsupported ",[196,654,655],{"class":202},"\u002F\u002F 100 MP 上限\n",[196,657,658],{"class":198,"line":348},[196,659,287],{"class":217},[196,661,662,665,667,670,673,676,679],{"class":198,"line":359},[196,663,664],{"class":217},"    img, _, err ",[196,666,249],{"class":209},[196,668,669],{"class":217}," image.",[196,671,672],{"class":213},"Decode",[196,674,675],{"class":217},"(bytes.",[196,677,678],{"class":213},"NewReader",[196,680,681],{"class":217},"(sd.Raw))\n",[196,683,684,686,689,691,694],{"class":198,"line":364},[196,685,265],{"class":209},[196,687,688],{"class":217}," err ",[196,690,328],{"class":209},[196,692,693],{"class":280}," nil",[196,695,240],{"class":217},[196,697,698,700,702,704,706],{"class":198,"line":389},[196,699,277],{"class":209},[196,701,225],{"class":213},[196,703,228],{"class":217},[196,705,231],{"class":213},[196,707,579],{"class":217},[196,709,710],{"class":198,"line":416},[196,711,287],{"class":217},[196,713,714,717,719],{"class":198,"line":426},[196,715,716],{"class":217},"    result ",[196,718,249],{"class":209},[196,720,721],{"class":217}," pdfImageReplaced\n",[196,723,724,726,729,731,733,735,738,741,744,747,749,751,754,756,759,761,763,766,768,770],{"class":198,"line":431},[196,725,265],{"class":209},[196,727,728],{"class":217}," target ",[196,730,328],{"class":209},[196,732,693],{"class":280},[196,734,334],{"class":209},[196,736,737],{"class":217}," (img.",[196,739,740],{"class":213},"Bounds",[196,742,743],{"class":217},"().",[196,745,746],{"class":213},"Dx",[196,748,406],{"class":217},[196,750,328],{"class":209},[196,752,753],{"class":217}," target.X ",[196,755,322],{"class":209},[196,757,758],{"class":217}," img.",[196,760,740],{"class":213},[196,762,743],{"class":217},[196,764,765],{"class":213},"Dy",[196,767,406],{"class":217},[196,769,328],{"class":209},[196,771,772],{"class":217}," target.Y) {\n",[196,774,775,778,781,784,787],{"class":198,"line":446},[196,776,777],{"class":217},"        img ",[196,779,780],{"class":209},"=",[196,782,783],{"class":213}," resizeImage",[196,785,786],{"class":217},"(img, target.X, target.Y) ",[196,788,789],{"class":202},"\u002F\u002F 扫描页降采样目标\n",[196,791,792,795,797],{"class":198,"line":460},[196,793,794],{"class":217},"        result ",[196,796,780],{"class":209},[196,798,799],{"class":217}," pdfImageReplacedDownsampled\n",[196,801,803],{"class":198,"line":802},18,[196,804,287],{"class":217},[196,806,808,811,814,817,819],{"class":198,"line":807},19,[196,809,810],{"class":209},"    var",[196,812,813],{"class":217}," out ",[196,815,816],{"class":213},"bytes",[196,818,228],{"class":217},[196,820,821],{"class":213},"Buffer\n",[196,823,825,827,829,831,834,837,839,842,845,847,850,852,855,858,860,862],{"class":198,"line":824},20,[196,826,265],{"class":209},[196,828,688],{"class":217},[196,830,249],{"class":209},[196,832,833],{"class":217}," jpeg.",[196,835,836],{"class":213},"Encode",[196,838,218],{"class":217},[196,840,841],{"class":209},"&",[196,843,844],{"class":217},"out, img, ",[196,846,841],{"class":209},[196,848,849],{"class":213},"jpeg",[196,851,228],{"class":217},[196,853,854],{"class":213},"Options",[196,856,857],{"class":217},"{Quality: pdfJPEGQuality}); err ",[196,859,328],{"class":209},[196,861,693],{"class":280},[196,863,240],{"class":217},[196,865,867,869,871,873,875],{"class":198,"line":866},21,[196,868,277],{"class":209},[196,870,225],{"class":213},[196,872,228],{"class":217},[196,874,231],{"class":213},[196,876,579],{"class":217},[196,878,880],{"class":198,"line":879},22,[196,881,287],{"class":217},[196,883,885,887,890,893,895,898,901],{"class":198,"line":884},23,[196,886,265],{"class":209},[196,888,889],{"class":217}," out.",[196,891,892],{"class":213},"Len",[196,894,406],{"class":217},[196,896,897],{"class":209},">=",[196,899,900],{"class":213}," len",[196,902,903],{"class":217},"(sd.Raw) {\n",[196,905,907,909,911,913,915,918],{"class":198,"line":906},24,[196,908,277],{"class":209},[196,910,225],{"class":213},[196,912,228],{"class":217},[196,914,231],{"class":213},[196,916,917],{"class":217},"{}, pdfImageNotSmaller ",[196,919,920],{"class":202},"\u002F\u002F 重存不更小：保持原样\n",[196,922,924],{"class":198,"line":923},25,[196,925,287],{"class":217},[196,927,929],{"class":198,"line":928},26,[196,930,931],{"class":202},"    \u002F\u002F ……写回\n",[196,933,935],{"class":198,"line":934},27,[196,936,463],{"class":217},[13,938,939],{},"质量档定死 75、不做多档：调研里 75 正是各家「Recommended」档的甜点值，多给档位只是把工程决策转嫁给用户。解码失败按「不支持」计入跳过而不是报错——个别图像异常不该毁掉整份文件。",[13,941,942,945,946,949,950,953,954,957,958,961],{},[71,943,944],{},"写回。"," 这一步的细节比想象中多，每处漏了都会产出损坏或语义漂移的 PDF：替换编码字节和 ",[86,947,948],{},"StreamLength","；缩放过的图要重申字典里的 ",[86,951,952],{},"Width","\u002F",[86,955,956],{},"Height","；色彩空间按实际编码通道数重申（灰度图重编码后仍是灰度）；删掉 ",[86,959,960],{},"DecodeParms","——那是原编码的解码参数，新的裸 JPEG 没有。SMask（透明蒙版）是独立对象，不在处理范围内，原样保留。",[13,963,964,965,967,968,971],{},"还有一个 pdfcpu 特有的坑值得单独记一笔：它的 ",[86,966,231],{}," 是",[71,969,970],{},"值类型","，从 xref 条目取出来的是副本，改副本没有任何效果，必须整体写回：",[187,973,975],{"className":189,"code":974,"language":191,"meta":192,"style":192},"for objNr, entry := range doc.XRefTable.Table {\n    sd, ok := entry.Object.(types.StreamDict)\n    if !ok || !sd.Image() {\n        continue\n    }\n    stats.ImagesTotal++\n    var target *image.Point\n    if t, hit := scanTargets[objNr]; hit {\n        target = &t\n    }\n    \u002F\u002F ……重编码（上一段）\n    entry.Object = recompressed \u002F\u002F 值类型：必须整体写回条目\n}\n",[86,976,977,993,1011,1031,1036,1040,1048,1064,1076,1089,1093,1098,1111],{"__ignoreMap":192},[196,978,979,982,985,987,990],{"class":198,"line":199},[196,980,981],{"class":209},"for",[196,983,984],{"class":217}," objNr, entry ",[196,986,249],{"class":209},[196,988,989],{"class":209}," range",[196,991,992],{"class":217}," doc.XRefTable.Table {\n",[196,994,995,998,1000,1003,1005,1007,1009],{"class":198,"line":206},[196,996,997],{"class":217},"    sd, ok ",[196,999,249],{"class":209},[196,1001,1002],{"class":217}," entry.Object.(",[196,1004,301],{"class":213},[196,1006,228],{"class":217},[196,1008,231],{"class":213},[196,1010,309],{"class":217},[196,1012,1013,1015,1017,1019,1021,1023,1025,1028],{"class":198,"line":243},[196,1014,265],{"class":209},[196,1016,268],{"class":209},[196,1018,319],{"class":217},[196,1020,322],{"class":209},[196,1022,268],{"class":209},[196,1024,551],{"class":217},[196,1026,1027],{"class":213},"Image",[196,1029,1030],{"class":217},"() {\n",[196,1032,1033],{"class":198,"line":262},[196,1034,1035],{"class":209},"        continue\n",[196,1037,1038],{"class":198,"line":274},[196,1039,287],{"class":217},[196,1041,1042,1045],{"class":198,"line":284},[196,1043,1044],{"class":217},"    stats.ImagesTotal",[196,1046,1047],{"class":209},"++\n",[196,1049,1050,1052,1054,1057,1059,1061],{"class":198,"line":290},[196,1051,810],{"class":209},[196,1053,728],{"class":217},[196,1055,1056],{"class":209},"*",[196,1058,521],{"class":213},[196,1060,228],{"class":217},[196,1062,1063],{"class":213},"Point\n",[196,1065,1066,1068,1071,1073],{"class":198,"line":312},[196,1067,265],{"class":209},[196,1069,1070],{"class":217}," t, hit ",[196,1072,249],{"class":209},[196,1074,1075],{"class":217}," scanTargets[objNr]; hit {\n",[196,1077,1078,1081,1083,1086],{"class":198,"line":348},[196,1079,1080],{"class":217},"        target ",[196,1082,780],{"class":209},[196,1084,1085],{"class":209}," &",[196,1087,1088],{"class":217},"t\n",[196,1090,1091],{"class":198,"line":359},[196,1092,287],{"class":217},[196,1094,1095],{"class":198,"line":364},[196,1096,1097],{"class":202},"    \u002F\u002F ……重编码（上一段）\n",[196,1099,1100,1103,1105,1108],{"class":198,"line":389},[196,1101,1102],{"class":217},"    entry.Object ",[196,1104,780],{"class":209},[196,1106,1107],{"class":217}," recompressed ",[196,1109,1110],{"class":202},"\u002F\u002F 值类型：必须整体写回条目\n",[196,1112,1113],{"class":198,"line":416},[196,1114,463],{"class":217},[122,1116,1117],{"id":1117},"扫描页降采样",[13,1119,1120,1121,1123],{},"第一版只有重存、承诺「不缩小像素尺寸」，实际用下来发现这等于把业内最大的杠杆关在门外：扫描件按 300–600 DPI 存，屏幕阅读 150 就够，光靠质量重存动不了像素总量。0.2.0 修订了这个决策——满页扫描图默认压到 150 DPI，和 Ghostscript ",[86,1122,111],{}," 同款。",[13,1125,1126,1127,1130],{},"难点在",[71,1128,1129],{},"怎么判定「这一页是扫描页」","。对任意插图降采样，得解析内容流里的变换矩阵算出图像的真实显示尺寸，复杂且容易误伤插图。我们的做法是绕开内容流，只用结构信号取一个安全子集：",[1132,1133,1134,1150],"table",{},[1135,1136,1137],"thead",{},[1138,1139,1140,1144,1147],"tr",{},[1141,1142,1143],"th",{},"判定",[1141,1145,1146],{},"条件",[1141,1148,1149],{},"不满足时",[1151,1152,1153,1165,1176,1191,1201],"tbody",{},[1138,1154,1155,1159,1162],{},[1156,1157,1158],"td",{},"页面有效",[1156,1160,1161],{},"MediaBox 可读、宽高为正",[1156,1163,1164],{},"跳过该页",[1138,1166,1167,1170,1173],{},[1156,1168,1169],{},"恰有一张图",[1156,1171,1172],{},"页面资源里只有一个图像 XObject，且是直接引用（包在 Form 里的不算）",[1156,1174,1175],{},"不是扫描页",[1138,1177,1178,1181,1188],{},[1156,1179,1180],{},"图填满页面",[1156,1182,1183,1184,1187],{},"图与页的长宽比一致，±15% 容差（",[86,1185,1186],{},"\u002FRotate"," 90\u002F270 已换轴）",[1156,1189,1190],{},"不降采样",[1138,1192,1193,1196,1199],{},[1156,1194,1195],{},"值得压",[1156,1197,1198],{},"有效 DPI = 图长边 px ÷（页长边 pt ÷ 72）> 150",[1156,1200,1190],{},[1138,1202,1203,1206,1209],{},[1156,1204,1205],{},"目标一致",[1156,1207,1208],{},"同一张图被多页共享时，各页算出的目标相同",[1156,1210,1211],{},"放弃该图",[13,1213,1214,1215,1218],{},"判定成立时，有效 DPI 可以从页面尺寸",[71,1216,1217],{},"精确算出","，完全不碰内容流——PDF 的页面单位是点，1 点 = 1\u002F72 英寸：",[187,1220,1222],{"className":189,"code":1221,"language":191,"meta":192,"style":192},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go，scanImageTargets\ndpi := float64(imgLong) \u002F (pageLong \u002F 72.0)\nif dpi \u003C= pdfScanDPIFloor { \u002F\u002F 150.0\n    continue\n}\nscale := pdfScanDPIFloor \u002F dpi\nnewLong := max(1, int(math.Round(float64(imgLong)*scale)))\nnewShort := max(1, int(math.Round(float64(imgShort)*scale)))\n",[86,1223,1224,1229,1254,1270,1275,1279,1294,1333],{"__ignoreMap":192},[196,1225,1226],{"class":198,"line":199},[196,1227,1228],{"class":202},"\u002F\u002F internal\u002Fimageproc\u002Fpdf.go，scanImageTargets\n",[196,1230,1231,1234,1236,1239,1242,1244,1247,1249,1252],{"class":198,"line":206},[196,1232,1233],{"class":217},"dpi ",[196,1235,249],{"class":209},[196,1237,1238],{"class":209}," float64",[196,1240,1241],{"class":217},"(imgLong) ",[196,1243,953],{"class":209},[196,1245,1246],{"class":217}," (pageLong ",[196,1248,953],{"class":209},[196,1250,1251],{"class":280}," 72.0",[196,1253,309],{"class":217},[196,1255,1256,1259,1262,1264,1267],{"class":198,"line":243},[196,1257,1258],{"class":209},"if",[196,1260,1261],{"class":217}," dpi ",[196,1263,606],{"class":209},[196,1265,1266],{"class":217}," pdfScanDPIFloor { ",[196,1268,1269],{"class":202},"\u002F\u002F 150.0\n",[196,1271,1272],{"class":198,"line":262},[196,1273,1274],{"class":209},"    continue\n",[196,1276,1277],{"class":198,"line":274},[196,1278,463],{"class":217},[196,1280,1281,1284,1286,1289,1291],{"class":198,"line":284},[196,1282,1283],{"class":217},"scale ",[196,1285,249],{"class":209},[196,1287,1288],{"class":217}," pdfScanDPIFloor ",[196,1290,953],{"class":209},[196,1292,1293],{"class":217}," dpi\n",[196,1295,1296,1299,1301,1304,1306,1309,1311,1314,1317,1320,1322,1325,1328,1330],{"class":198,"line":290},[196,1297,1298],{"class":217},"newLong ",[196,1300,249],{"class":209},[196,1302,1303],{"class":213}," max",[196,1305,218],{"class":217},[196,1307,1308],{"class":280},"1",[196,1310,512],{"class":217},[196,1312,1313],{"class":209},"int",[196,1315,1316],{"class":217},"(math.",[196,1318,1319],{"class":213},"Round",[196,1321,218],{"class":217},[196,1323,1324],{"class":209},"float64",[196,1326,1327],{"class":217},"(imgLong)",[196,1329,1056],{"class":209},[196,1331,1332],{"class":217},"scale)))\n",[196,1334,1335,1338,1340,1342,1344,1346,1348,1350,1352,1354,1356,1358,1361,1363],{"class":198,"line":312},[196,1336,1337],{"class":217},"newShort ",[196,1339,249],{"class":209},[196,1341,1303],{"class":213},[196,1343,218],{"class":217},[196,1345,1308],{"class":280},[196,1347,512],{"class":217},[196,1349,1313],{"class":209},[196,1351,1316],{"class":217},[196,1353,1319],{"class":213},[196,1355,218],{"class":217},[196,1357,1324],{"class":209},[196,1359,1360],{"class":217},"(imgShort)",[196,1362,1056],{"class":209},[196,1364,1332],{"class":217},[13,1366,1367,1368,1371],{},"几个边角的处理都遵循同一个原则：",[71,1369,1370],{},"拿不准就不压","。长宽比差过 15% 说明图没填满页面（旁边可能有文字），不压；同一张图被多页共享而各页算出的目标矛盾，放弃这张图；DPI 略超 150 时缩放取整可能恰好等于原尺寸，此时回落成普通重存，不当作异常跳过。",[13,1373,1374,1375,1378,1379,1382,1383,1386,1387,1390],{},"实现上还有一个 pdfcpu 的坑：",[86,1376,1377],{},"ReadContext"," 之后 ",[86,1380,1381],{},"PageCount"," 可能为 0，直接逐页遍历会",[71,1384,1385],{},"一次都不执行、静默返回空结果","，必须先 ",[86,1388,1389],{},"EnsurePageCount()"," 把页树补齐。这类不报错、只是默默不生效的问题，靠读文档是发现不了的，是写测试夹具构造 300 DPI 扫描页时撞出来修掉的。",[63,1392,1394],{"id":1393},"三跟业内方案比亮点在哪","三、跟业内方案比，亮点在哪",[13,1396,1397,1400],{},[71,1398,1399],{},"1. 逐张判断，仅更小才替换。"," 预设方案是无条件套用：一张本来已经压得很好的图，被预设再压一遍，结果要么更糊、要么反而更大——这是各在线工具最常见的翻车点。我们逐张编码、逐张比较，不更小就放回原字节。这个判断也让「已经压好的文件再跑一遍」变成无害操作：第二次跑会全部命中「已经很省空间」，一个字节都不动。",[13,1402,1403,1406,1407,1409],{},[71,1404,1405],{},"2. DPI 是算出来的，不是估的。"," 从页面尺寸精确推导有效 DPI，不需要解析内容流；识别扫描页用结构信号（单图直引 + 长宽比匹配）取安全子集，宁可少压、绝不误伤排版插图。整个降采样没有魔法数字——150 对齐 Ghostscript ",[86,1408,111],{},"，75 对齐各家 Recommended 档，±15% 容差覆盖扫描页常见的轻微裁边。",[13,1411,1412,1415],{},[71,1413,1414],{},"3. 每张图的结果如实计数。"," 输出统计是五个数：图像总数、重存数、压到屏幕清晰度数、不支持的类型数、已经很省空间数。不支持的就明说不支持（传真码、CMYK 这些纯 Go 没有对应编码器），而不是悄悄跳过。界面上用户看到的是「重新保存了 x 张图片，其中 y 张压到了屏幕清晰度，z 张保持原样」——每张图发生了什么，有交代。",[13,1417,1418,1421],{},[71,1419,1420],{},"4. 纯 Go，单二进制，三平台。"," 同一套代码出 macOS（Universal）、Windows、麒麟 amd64\u002Farm64（信创交叉构建）的安装包，零外部依赖、零 CGO，没有许可问题。据我所知，开源里做「对象级 PDF 图像重压缩」的纯 Go 实现目前没有第二个。",[13,1423,1424,1427,1428,1431],{},[71,1425,1426],{},"5. 该不动的不动，该拒绝的拒绝。"," 文字和矢量内容字节级不变；加密的拒绝处理；带签名的拒绝处理（",[86,1429,1430],{},"SigFlags"," bit0 判定，只含未签署签名表单字段的普通表单不算，照常处理）。",[63,1433,1435],{"id":1434},"四实测结果","四、实测结果",[1132,1437,1438,1448],{},[1135,1439,1440],{},[1138,1441,1442,1445],{},[1141,1443,1444],{},"项目",[1141,1446,1447],{},"数字",[1151,1449,1450,1466,1474],{},[1138,1451,1452,1455],{},[1156,1453,1454],{},"spike：2400×2400 DCT 图像，质量 75 重编码",[1156,1456,1457,1458,1461,1462,1465],{},"整文件 ",[71,1459,1460],{},"-42.9%","，pdfcpu validate 通过，渲染像素对比平均差 ",[71,1463,1464],{},"0.41\u002F765","（765 为 RGB 三通道差值上限，肉眼不可辨）",[1138,1467,1468,1471],{},[1156,1469,1470],{},"300 DPI 扫描夹具（1200px 图 + 288pt 方页）",[1156,1472,1473],{},"压到 600×600（4 英寸 × 150 DPI），测试读回输出验证像素",[1138,1475,1476,1479],{},[1156,1477,1478],{},"单文件 \u002F 单图上限",[1156,1480,1481],{},"512 MB \u002F 100 MP",[13,1483,1484],{},"测试矩阵：高噪声 JPEG（质量 100 与 75 之间体积差异巨大，断言确定性）显著变小；300 DPI 满页图降采样并读回验证；长宽比不符不降采样；Flate 图计入不支持；加密、损坏分别返回对应错误码；窄带回落普通重存。",[13,1486,1487],{},[42,1488],{"alt":1489,"src":1490},"批次完成后的队列与汇总","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E5%A4%84%E7%90%86%E7%BB%93%E6%9E%9C.png",[13,1492,1493],{},[49,1494,1495],{},"批次结束的汇总与逐项明细（示例为图片批次）：每行给出原体积 → 输出体积与节省比例，汇总条给累计节省。PDF 批次同样逐项报告——「重新保存了 x 张图片，其中 y 张压到了屏幕清晰度，z 张保持原样」。",[13,1497,1498],{},"已知限制照实写在产品文档里：Flate\u002FJBIG2\u002FCCITT\u002FJPX 编码与 CMYK\u002F索引色的图保持原样并计数；内嵌字体不做子集化；150 DPI 是屏幕清晰度，打印可能不够。",[13,1500,1501,1502,470,1506,1509],{},"完整实现在 ",[17,1503,1505],{"href":54,"rel":1504},[56],"Benny66\u002Ffile-processor",[86,1507,1508],{},"internal\u002Fimageproc\u002Fpdf.go","，363 行含注释），三平台安装包在 Release 页。文中代码与数字均出自该仓库，行号以 2026-10-24 版本为准。",[1511,1512,1513],"style",{},"html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}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 .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);}",{"title":192,"searchDepth":206,"depth":206,"links":1515},[1516,1517,1522,1523],{"id":65,"depth":206,"text":66},{"id":119,"depth":206,"text":120,"children":1518},[1519,1520,1521],{"id":124,"depth":243,"text":124},{"id":168,"depth":243,"text":169},{"id":1117,"depth":243,"text":1117},{"id":1393,"depth":206,"text":1394},{"id":1434,"depth":206,"text":1435},null,"2026-10-24","md",{},true,"\u002Fposts\u002Fpure-go-pdf-compression","image-workshop",{"title":5,"description":192},"pure-go-pdf-compression","posts\u002Fpure-go-pdf-compression","内网交材料，系统限 5MB，扫描件 40MB——在线工具进不去内网，Ghostscript 是外部进程引不进要自包含的桌面工具。这篇讲清楚业内「降采样 + 重存 + 无损优化」的本质，以及一个纯 Go 的对象级实现：安全子集怎么画、150 DPI 怎么从页面尺寸精确算出、写回有哪些坑。",[1536,1537,1538],"Go","PDF","图像处理","WAZnKLPSHdx2D65otHsOBwBX91FLsjiwDT9EYhE087w",[1541,1542,1546,1550,1554,1556,1559],{"title":5,"slug":1532,"date":1525},{"title":1543,"slug":1544,"date":1545},"Wails v3 升级实战：桌面提醒客户端从 v2 升到 v3，踩了 10 个不报错的坑","wails-v3-upgrade","2026-10-23",{"title":1547,"slug":1548,"date":1549},"用 RAG + FAISS 构建古诗词 AI 取名系统：从向量检索到六层质量过滤","chinese-poetry-rag","2026-10-15",{"title":1551,"slug":1552,"date":1553},"一个人做 AI 产品，我踩过的 9 个真实的坑","ecom-detail-ai-part3-pitfalls","2026-10-14",{"title":20,"slug":1555,"date":1553},"ecom-detail-ai-part2-wails",{"title":1557,"slug":1558,"date":1553},"我用 Go 搭了一条 AI Agent 流水线：从 1 张商品图到一整套淘宝详情页","ecom-detail-ai-part1-architecture",{"title":1560,"slug":1561,"date":1562},"AI 时代，如何把一个需求真正落地成应用？——一条\"文档驱动 + 实测修订\"的实战路径","ai-requirement-landing","2026-10-13",{"id":1564,"title":1565,"architecture":1524,"body":1566,"category":1771,"client":1772,"cover":1490,"date":1525,"demo":1524,"demoMobile":1524,"description":192,"extension":1526,"featured":1773,"features":1774,"github":54,"manual":1790,"meta":1844,"modules":312,"navigation":1528,"order":824,"path":1845,"seo":1846,"slug":1530,"stem":1847,"summary":1848,"techStack":1849,"visible":1528,"year":1854,"__hash__":1855},"projects\u002Fprojects\u002Fimage-workshop.md","图片工坊 · 离线批量图片处理",{"type":7,"value":1567,"toc":1763},[1568,1571,1574,1577,1580,1583,1586,1592,1595,1598,1601,1609,1612,1615,1618,1679,1682,1719,1722,1742,1745,1752,1755],[63,1569,1570],{"id":1570},"缘起",[13,1572,1573],{},"它起于一个很具体的观察：内网办公的人压图，靠的是系统自带的「画图」——缩到 50% 再另存，压完要么糊了、要么还是超。",[13,1575,1576],{},"这种电脑上，装软件要审批，文件出不去，在线压缩网站第一步就打不开。而真实需求非常具体：系统只收 5MB 的附件，扫描件一动 40MB。市面上没有一个现成工具是为这个环境做的。",[13,1578,1579],{},"所以做的东西也很具体：一个装进去就能用、全程不联网、把原图保护好的批量压图工具。",[63,1581,1582],{"id":1582},"背景",[13,1584,1585],{},"目标用户不是设计师也不是程序员，是单位里要交材料的人。这决定了两件事：",[13,1587,1588,1591],{},[71,1589,1590],{},"参数得替用户做掉。","「质量 75」「DPI」「色彩空间」这些词对目标用户没有意义，他们心里只有一个数——系统限的 5MB。所以产品形态是「填一个想要的体积，工具自己想办法压到那个数」，中间的工程决策（二分找最高达标质量、PNG 没有质量旋钮只能缩尺寸、压得太狠要提醒）全部沉到实现里。",[13,1593,1594],{},"**信任要靠如实报告攒。**内网装一个软件有成本，装完发现「悄悄把图压糊了」就再也不用了。所以每一张图发生了什么都有交代：重新保存了几张、哪几张压到了屏幕清晰度、哪几张不支持、哪几张本来就很省空间——不支持的就明说不支持，不悄悄跳过。",[63,1596,1597],{"id":1597},"方案",[13,1599,1600],{},"**三条图片规则，选着用。**压到指定体积、限制宽高上限、转成哪种格式——三条可以单用也可以同用；体积与尺寸冲突时体积优先。文件夹直接拖进去，递归扫描、按路径去重，默认双 worker 并行。",[13,1602,1603,1604,1608],{},"**PDF 免规则，拖进去就压。**内嵌 JPEG 按质量 75 重存、满页扫描图压到 150 DPI 屏幕清晰度、再做无损结构优化；文字和矢量内容字节级不动。加密的、带签名的直接拒绝——改了签名就失效，这种忙不能帮。这套纯 Go 实现的完整决策过程写在了博客长文",[17,1605,1607],{"href":1606},"\u002Fblog\u002Fpure-go-pdf-compression","《不引 Ghostscript：我用纯 Go 给 PDF 做了一场「压缩手术」","里。",[13,1610,1611],{},"**原图零写入。**处理结果先进应用内暂存区，原图所在的文件夹一个字节都不动；点「下载」才保存到用户选的文件夹，先写临时文件再原子落位，失败不留半成品。这是第一版直接写回原图旁边、被真实使用者纠正之后改的——隐性约束不是一次想全的，是踩出来的。",[13,1613,1614],{},"**界面说人话。**用户可见文案有一张术语表当单一真相：「压到了屏幕清晰度」而不是「降采样」、「不支持的图片类型」而不是「JBIG2\u002FCCITT」、已经满足设置的图叫「无需处理」而不是「已达标跳过」。Go 侧返回的提示与前端界面同表取词，改词先改表。",[63,1616,1617],{"id":1617},"技术架构",[1132,1619,1620,1630],{},[1135,1621,1622],{},[1138,1623,1624,1627],{},[1141,1625,1626],{},"模块",[1141,1628,1629],{},"技术选型",[1151,1631,1632,1640,1648,1655,1663,1671],{},[1138,1633,1634,1637],{},[1156,1635,1636],{},"桌面运行时",[1156,1638,1639],{},"Wails v2（Go 后端 + Vue3 前端），hash 路由适配打包形态",[1138,1641,1642,1645],{},[1156,1643,1644],{},"后端",[1156,1646,1647],{},"Go 1.26 + GORM + SQLite（纯 Go 驱动，无 CGO）",[1138,1649,1650,1652],{},[1156,1651,1538],{},[1156,1653,1654],{},"标准库 + golang.org\u002Fx\u002Fimage，目标体积两层搜索（二分质量 + 平方根缩尺寸）",[1138,1656,1657,1660],{},[1156,1658,1659],{},"PDF 压缩",[1156,1661,1662],{},"pdfcpu 做解析回写底座，图像重压缩对象级自研（质量 75、仅更小才替换、150 DPI 降采样）",[1138,1664,1665,1668],{},[1156,1666,1667],{},"工程化",[1156,1669,1670],{},"Makefile 统一入口 + 架构护栏 + 依赖登记制 + OpenSpec 变更治理",[1138,1672,1673,1676],{},[1156,1674,1675],{},"交付",[1156,1677,1678],{},"macOS dmg \u002F Windows NSIS \u002F 麒麟 deb，CI 三平台打包并附 SHA256SUMS",[13,1680,1681],{},"几个值得说的做法：",[1683,1684,1685,1691,1697,1706],"ul",{},[79,1686,1687,1690],{},[71,1688,1689],{},"编解码全纯 Go","：不引任何需要外部编译器的图像依赖，同一套代码交叉出三平台（含信创 arm64）安装包，麒麟构建钉死 Ubuntu 20.04 容器对齐 glibc 2.31。",[79,1692,1693,1696],{},[71,1694,1695],{},"约定编译成会红的检查","：仓库里有 1802 行护栏测试——前后端契约常量双向核对、日志\u002F事件\u002F版本注入这类「漏了不报错」的接线逐条验证、依赖必须登记、分层纪律强制。任何靠解析源码做断言的护栏，解析到 0 个目标必须报错，防止护栏自己失效了还亮绿灯。",[79,1698,1699,483,1702,1705],{},[71,1700,1701],{},"版本号四处同源",[86,1703,1704],{},"wails.json"," 是唯一真相，构建时渲染进安装包与二进制，护栏核对一致性。",[79,1707,1708,483,1711,1714,1715,1718],{},[71,1709,1710],{},"图标资产单源派生",[86,1712,1713],{},"build\u002F*.svg"," 是唯一源，",[86,1716,1717],{},"make icon"," 派生全部平台图标并登记哈希，派生物不许手改。",[63,1720,1721],{"id":1721},"成果",[1683,1723,1724,1730,1736],{},[79,1725,1726,1729],{},[71,1727,1728],{},"已发版","：v0.2.0 打 tag 自动构建三平台安装包与校验和，GitHub Release 可下载；从第一个提交到交付 0.1.0 用了两周，PDF 压缩作为 0.2.0 的主体功能补齐。",[79,1731,1732,1735],{},[71,1733,1734],{},"质量可数","：功能代码约 4400 行，测试与护栏约 6200 行（其中护栏 1802 行）；10 份架构决策记录；PDF 压缩 spike 实测整文件 -42.9%、渲染像素差 0.41\u002F765（肉眼不可辨）。",[79,1737,1738,1741],{},[71,1739,1740],{},"自用与家人使用中","：家里交材料的活儿现在都走它。没有对外推广，没有用户数可以报，这一栏不填好看的假数字。",[63,1743,1744],{"id":1744},"体验",[13,1746,1747,1748,1751],{},"源码在 ",[17,1749,57],{"href":54,"rel":1750},[56],"，Release 页有三平台安装包；没有网页版 demo，桌面工具想用起来就是装一个。",[13,1753,1754],{},"想验两件事的话：",[76,1756,1757,1760],{},[79,1758,1759],{},"拖一个 300 DPI 的扫描件 PDF 进去，点开始。结果消息会写「重新保存了 x 张图片，其中 y 张压到了屏幕清晰度」——体积通常能掉一个数量级。",[79,1761,1762],{},"把压好的文件再拖进去跑一遍。这一遍会全部显示「已经很省空间」，一个字节都不会再动——逐张「仅更小才替换」的意义就在这。",{"title":192,"searchDepth":206,"depth":206,"links":1764},[1765,1766,1767,1768,1769,1770],{"id":1570,"depth":206,"text":1570},{"id":1582,"depth":206,"text":1582},{"id":1597,"depth":206,"text":1597},{"id":1617,"depth":206,"text":1617},{"id":1721,"depth":206,"text":1721},{"id":1744,"depth":206,"text":1744},"desktop","个人项目",false,[1775,1778,1782,1786],{"title":1776,"description":1777,"image":1490},"批量处理与汇总","队列逐项显示状态与节省比例，汇总条给出成功、失败、跳过与累计节省；压得太狠的会明确标出「画质变差了」",{"title":1779,"description":1780,"image":1781},"单张对比","原图与结果并排，按最长边等比缩小，棋盘格表示透明区域，两侧尺寸与体积一目了然","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E5%8D%95%E5%BC%A0%E5%AF%B9%E6%AF%94.png",{"title":1783,"description":1784,"image":1785},"常用设置","把常用参数起名存档，一键套用；套用只改参数、不会自动开始处理","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E9%A2%84%E8%AE%BE%E7%AE%A1%E7%90%86.png",{"title":1787,"description":1788,"image":1789},"设置页","隐私声明与历史记录占用管理，和首屏同一口径——不联网、不上传","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Ffile-processer\u002F%E8%AE%BE%E7%BD%AE%E9%A1%B5%E9%9D%A2.png",{"specs":1791,"modules":1810,"deploy":1833,"limits":1837},[1792,1795,1798,1801,1804,1807],{"label":1793,"value":1794},"交付形态","一个安装包装进系统就能用（macOS dmg \u002F Windows NSIS \u002F 银河麒麟 deb），不需要另装运行环境（Windows 需系统自带或随装 WebView2）",{"label":1796,"value":1797},"运行要求","macOS 10.13 及以上（Universal 二进制）；Windows 10\u002F11；银河麒麟 V10 SP1（2203，glibc 2.31）",{"label":1799,"value":1800},"输入支持","JPEG、PNG、WebP、BMP、TIFF、静态 GIF 与 PDF（只压缩、输出仍是 PDF）；单文件上限 512 MB、单图上限一亿像素",{"label":1802,"value":1803},"隐私边界","全程离线——不上传、不联网、无遥测；输出是全新编码，GPS 与拍摄信息一并剥离",{"label":1805,"value":1806},"数据存放","常用设置与最近 30 次历史存本机 SQLite；没有账号、没有云同步",{"label":1808,"value":1809},"适用规模","面向个人与内网办公的单机使用，默认 2 个 worker 并行",[1811,1814,1817,1820,1823,1825,1828,1831],{"name":1812,"desc":1813},"批量导入","文件与文件夹直接拖入，递归扫描、按路径去重；处理中界面不卡，单张失败不影响整批",{"name":1815,"desc":1816},"格式转换","JPEG \u002F PNG \u002F WebP 互转，「自动」档逐格式竞速取最小；格式以内容探测为准，扩展名骗不了它",{"name":1818,"desc":1819},"目标体积","二分搜索能达标的最高质量，质量到底仍超标再按比例缩尺寸，留 5% 余量防四舍五入反弹",{"name":1821,"desc":1822},"宽高上限盒","等比缩放进盒，不裁切、不拉伸、不放大；与目标体积冲突时体积优先",{"name":1659,"desc":1824},"内嵌 JPEG 按质量 75 重存（仅更小才替换）、满页扫描图压到 150 DPI 屏幕清晰度、无损结构优化；文字一字不动",{"name":1826,"desc":1827},"暂存与下载","结果先进应用内暂存区，原图所在文件夹零写入；点「下载」才落盘，先写临时文件再原子落位，失败不留半成品",{"name":1829,"desc":1830},"历史记录","最近 30 次处理可回看——什么时候、用了什么设置、每张结果如何，没下载的随时再保存",{"name":1783,"desc":1832},"参数起名存档一键套用，应用重启恢复上次使用的设置",[1834,1835,1836],"怎么装：GitHub Release 下载对应平台安装包；个人分发未做签名，首次打开会被系统拦一次，按 README 步骤放行即可（每台机器一次）","怎么核对：安装包经网盘或聊天工具转发后，用 Release 附带的 SHA256SUMS.txt 校验和核对","怎么维护：设置与历史存在系统应用数据目录；从源码构建走 make build \u002F test \u002F lint，打包走 make package",[1838,1839,1840,1841,1842,1843],"不支持 AVIF \u002F HEIC 与动图：避免三平台系统编解码依赖；iPhone 默认存的 HEIC 需先转 JPEG","扫描页压到屏幕清晰度：满页扫描图默认 150 DPI，屏幕阅读够用、打印可能不够；非满页插图不缩像素","部分 PDF 图片不重存：传真码（JBIG2\u002FCCITT）、Flate 截图、CMYK\u002F索引色保持原样并如实计数；加密与带签名的 PDF 直接拒绝处理","不保留 ICC 色彩配置：广色域图片可能轻微偏色，交材料够用、印刷不建议","未做代码签名与公证：个人分发不办证书，他人首次打开会被 Gatekeeper \u002F SmartScreen 拦一次","麒麟与信创 arm64 尚未真机验收：deb 由 CI 产出并在容器内验证安装，真机全流程未跑",{},"\u002Fprojects\u002Fimage-workshop",{"title":1565,"description":192},"projects\u002Fimage-workshop","给「不能联网的电脑」做的批量压图工具：拖进文件夹，压到指定体积、限住宽高、转格式，PDF 直接拖进去就能压——全程不上传、不联网、不碰原图",[1536,1850,1851,1852,1853],"Wails v2","Vue3","Pinia","SQLite","2026","4zr-Rbn3HladZhKxMwFqTB3zn7LuVWTqJBk0koN9r5s",1792857941987]