把一份带图的中文报告交出去对方回一句这字不是黑体接着又补一句你这字体没嵌进去我这边打开是空白——这类问题我这些年遇到的次数比想象中多得多。更前置的一个疑问是装字体的时候列表里黑体和黑体GB2312并排躺着看起来一模一样到底该选哪个选错了会怎样再往下走一步就是很多用 R 出图的人绕不开的坑明明系统里字体装好了pdf()一出中文全是方框、全是点或者在自己电脑上好好的换台机器就变成一片空白。这篇东西想解决的就是这条链路上的三个环节黑体与黑体GB2312在字符集、字形覆盖、命名体系上到底差在哪R 的 PDF 设备处理中文字体有几条技术路线各自的行为边界是什么在真实交付场景里怎么把指定的那个黑体稳定、可复现地写进 PDF。读者对象不限基础——刚上手 R 画图的人能照着抄步骤出过几十份报告被字体问题折磨过的人应该能在排查表和几个隐蔽坑里找到共鸣。1. 先搞清楚黑体这两个词到底在说什么1.1 GB2312 后缀不是风格后缀而是字符集后缀很多人第一反应是黑体GB2312 是不是黑体的加粗版/精简版/老版本。都不是。黑体描述的是字形风格——无衬线、笔画粗细均匀、横竖对比小、端点平切GB2312描述的是这个字体文件里装了多少个字。这是两个完全正交的维度只是被塞进同一个名字里了。GB 2312 是一套简体中文字符编码标准收录一级汉字 3755 个、二级汉字 3008 个合计 6763 个汉字外加约 682 个非汉字图形符号标点、序号、希腊字母、俄文字母、日文假名、制表符等。带 GB2312 后缀的字体通常意味着它的字符覆盖范围就落在这个圈里。这在早期是有明确工程理由的字体文件大小、内存占用、渲染性能都受限只做常用字能省下大量空间。放到今天的硬件环境里这个约束基本消失了但历史字库版本还留在很多人的字体列表里。对照一下就能明白差距有多大字符集/标准汉字数量约典型覆盖场景GB 23126763常用简体文本、早期排版系统GBK21000简体为主、兼容繁体与部分生僻字GB 1803027000随版本扩充全面覆盖含大量生僻字、人名用字Unicode 全量90000含扩展区学术、古籍、方志、少数民族文字真正的问题出在缺字这件事上。文本里只要冒出一个 GB2312 圈外的字——某些人名用字、地名用字、异体字、繁体字、生僻的科技名词——字体就渲染不出来。这时候不同软件的处理策略不一样有的直接画一个空心方框俗称豆腐块有的静默回退到系统里的另一款字体去渲染这个字。回退这件事最阴险因为它在屏幕上看起来能显示但同一行文字里两个字的字形、字重、字面大小完全不是一套混排出来非常难看而且你在自己的机器上看不出来到别人机器上才暴露。注意判断一个字体够不够用别靠眼睛看我这段文字显示正常要靠工具查覆盖范围。中文正文里出现一个缺字整段排版就会被阅读器重新分字行宽也会跟着变。1.2 就算同一个字两版字体的字形也可能不一样这一层更少人注意到。GB2312 时代的字库很多是按当年的字形规范设计的笔画写法、部件比例、字面率字形实际占据字身框的比例、重心位置跟后来重制的现代黑体有肉眼可见的差别。举几个典型的表现字面率偏大。老字体的字面撑得更满一行字排下来视觉上更挤行距显得不够现代黑体比如开源的思源黑体系列字面率收敛一些同样字号同样行距读起来更松。全角标点位置不同。逗号、句号、顿号在老字体里常常更靠左下现代字体倾向于居中偏下。混用两套字体时标点会像跳出来一样。竖排与横排的标点处理。老字体的标点旋转、避头尾规则支持往往不完整一旦要排竖排或者严格避头尾就会出问题。字重只有一档。绝大多数 GB2312 版本的字体只提供一个 Regular 字重。你在 R 里写fontface bold设备找不到粗体字形要么忽略要么做伪粗体——把同一串文字偏移一点点重复画一遍。伪粗体在 PDF 里是致命的因为它不是矢量轮廓的加粗放大看边缘会糊、会有重影打印出来尤其难看。所以用黑体GB2312和用黑体不只是字数够不够的问题而是整份文档的排版气质会变。如果一份报告里既有正文用了带 GB2312 的字体又有图用了现代黑体细看会觉得图文不是一个人做的。1.3 字体名、文件名、族名、PostScript 名四套命名各管一段这一节是纯经验也是很多人卡住的地方。一个字体在系统里至少有四个名字不同软件认的不是同一个名称类型例子谁在用文件名simhei.ttf文件管理器、font_add()这类需要路径的函数族名FamilySimHei/黑体大部分排版软件、R 的family参数部分设备全名Full nameSimHei Regular部分字体选择对话框PostScript 名SimHeiPDF 内部记录、打印工作流、部分检查工具黑体GB2312这种带后缀的字体往往族名里就实打实带着GB2312字样比如XXX黑体GB2312。这时候你在 R 里写family 黑体是匹配不上的必须写成完整族名。更麻烦的是中文族名在某些设备路径上会变成乱码或者匹配失败用英文/拼音的 PostScript 名通常更稳。我这几年养成的习惯是不确定名字的时候绝不去字体列表里凭印象猜一律用工具查实际注册名见第 3 节的实操。名字对不上是中文出方框最常见的原因比字体没装还常见。2. R 的 PDF 设备是怎么处理中文的2.1pdf()的内置 CID 路线family GB1到底做了什么R 的基础pdf()设备family参数默认是sans可选项里除了serif/mono/symbol还有一类很多文档里一笔带过的取值GB1简体中文、CNS1繁体中文、Japan1、Korea1。这些是 Adobe 定义的 CID 字体集合pdf(family GB1)输出的 PDF 里文字被标记为请用 GB1 这个 CID 集合里的字体来渲染。这里有一个关键行为要理解清楚这条路线不嵌入字体。PDF 里只记录了这是 GB1 集合的某个字形编号具体长什么样由打开 PDF 的软件自己去找字体渲染。这就解释了那个经典现象——你在自己电脑上打开有中文发给别人打开是一片空白或者方框尤其在手机、某些轻量阅读器、某些打印机 RIP 上。顺带说一句pdf()的family参数只认设备内置的那几张映射表你直接写family SimHei是没用的会被静默回退到默认字体。这一点和windowsFonts()注册出来的设备完全是两套机制混着记特别容易出错。所以pdf(family GB1)的正确使用场景是只在自己机器上看、不交付、不打印的临时草图。真要做交付件别走这条路。2.2 三条落地路线对比pdf()/cairo_pdf()/showtextR 里往 PDF 写中文基本上就三条路行为差别很大我整理成一张表后面再逐条拆路线核心调用是否嵌入字体文字可否选中/复制跨平台一致性适用场景内置 CIDpdf(family GB1)否可以但依赖阅读器字体差临时预览Cairo 设备cairo_pdf(family ...)是子集嵌入可以且可搜索好需字体存在正式交付首选字形绘路径showtextpdf()变相嵌入转矢量路径不可以好图形标题、装饰性文字字体嵌入后处理extrafontembed_fonts()是可以一般历史项目维护cairo_pdf()是我现在的默认选择。它是基于 Cairo 图形库的 PDF 设备会真正把字体子集嵌进文件里输出件在别人机器上打开字形稳定文字还能选中复制。代价是文件里会多出几十到几百 KB 的字体数据以及一个隐含前提你调用时写的字体名必须能被系统的字体查找机制fontconfig 一类解析到。Windows 上偶尔会遇到cairo_pdf(family 黑体)报找不到字体换成SimHei就好了本质还是 1.3 节说的命名匹配问题。showtext的原理不一样它是用 FreeType 把文字渲染成字形轮廓再当作普通绘图指令画到设备上。好处是只要给得出字体文件路径就一定画得出来完全绕开系统字体查找坏处是PDF 里那不再是文字而是一堆矢量路径——不能选中、不能复制、不能全文搜索、不能被辅助阅读软件识别。做论文插图、封面标题这类只要好看的内容很合适做正文报告就难受了。另外它会显著增大文件体积一页几百个汉字的图路径数据能轻松吃掉几百 KB。extrafont这条线现在用得少了。它的主力是 Type1 字体对 TTF 的导入在部分平台上有各种依赖问题font_import()跑十几分钟是常事。老项目里还能见到新项目我基本不碰。2.3 为什么能显示和能交付是两件事这句话我想单独强调一下因为它是所有字体问题的分水岭。我屏幕上看着没问题和这个 PDF 拿到任何一台机器上都长这样是两个完全不同级别的保证。判断一份 PDF 是否合格只看一个指标字体是否嵌入。检查方法很直接R 里就能做# 列出 PDF 里用到的字体以及是否嵌入、是否子集 pdftools::pdf_fonts(report.pdf)输出里重点看两列embedded为TRUE才算过关subset为TRUE说明只嵌入了用到的字形这是正常且推荐的做法体积小得多不用紧张。如果embedded是FALSE那这份文件在别人那里随时可能变脸。还有一个次要但真实存在的指标PDF 版本。cairo_pdf()默认输出 PDF 1.4 左右兼容性足够好。某些打印工作流会挑剔字体子集的编码方式遇到打印店说字体报错先确认嵌入状态再考虑换成全量嵌入或者把文字转曲转成路径代价是失去可搜索性。3. 实操把指定的黑体稳定写进 PDF3.1 第一步把系统里到底有哪些字体查清楚别猜直接查。我一般用两条命令交叉验证# 模糊匹配字体名返回命中的文件路径 systemfonts::match_font(SimHei) systemfonts::match_font(黑体) # 列出所有字体按族名筛 all_fonts - systemfonts::system_fonts() hit - subset(all_fonts, grepl(Hei|黑, family, ignore.case TRUE)) hit[, c(path, family, style, weight, italic)]path这一列是重点它告诉你这个族名最终指向哪个字体文件。这一步能帮你确认两件事一是黑体和黑体GB2312各自的真实文件路径是不是同一个有时是同一文件被注册了多个别名有时真是两个文件二是当你需要用showtext时直接拿这个路径去font_add()避免名字解析问题。Linux 和 macOS 上还有fc-list可以用输出信息更原始但更可靠# 查所有含 Hei 的字体及其族名、样式 fc-list : family style file | grep -i hei # 只看某个字体文件包含多少字形 fc-query --format %{charset}\n /path/to/simhei.ttf | wc -w最后那条fc-query挺有用能粗看字符覆盖范围。一个只覆盖 GB2312 的字体和一个覆盖 GBK 的字体输出长度差得很明显。3.2 第二步用showtext打通绘图与设备showtext的用法很固定四步走install.packages(c(showtext, sysfonts)) library(showtext) # 1. 注册一个自己起的别名指向真实字体文件 font_add(myhei, regular /path/to/simhei.ttf, bold /path/to/simhei-bold.ttf, # 没有就留空 italic /path/to/simhei-italic.ttf) # 2. 打开自动接管 showtext_auto() showtext_opts(dpi 300) # 影响位图设备PDF 下不生效但写上无害 # 3. 正常画图 pdf(report.pdf, width 7.5, height 5) plot(1:10, main 中文标题测试, xlab 横轴, ylab 纵轴) text(5, 5, 标注文字, family myhei) dev.off() # 4. 如果只想在局部生效可以用配对函数 showtext_begin() # ... 绘图 ... showtext_end()几个实操细节值得单独说bold参数留空会怎样如果字体本身没有粗体字形font_add里不指定bold绘图时请求粗体会回退到 regular看起来加粗无效。想真加粗得另找一个粗体文件比如同族的 Heavy/Bold 版本。不要指望showtext帮你合成粗体它不做这件事。showtext_auto()的作用域是全局的包加载之后所有新开的图形设备都会被接管。这在交互式会话里很方便但在脚本里批量出图时容易踩到某些设备被意外接管的坑比如你想让某张图走cairo_pdf原生的字体嵌入结果被showtext提前转成路径了。习惯做法是批处理脚本里显式用showtext_begin()/showtext_end()包住需要的那一段而不是全局开启。字体文件路径别写死绝对路径尤其是跨机器协作的项目。我现在的惯例是在项目根目录建一个assets/fonts/放 ttf 文件代码里用here::here(assets/fonts/simhei.ttf)拼路径。这样换机器、换系统都不会崩。3.3 第三步ggplot2里的字体设置与全局统一ggplot2的字体控制有三层很多人只改了一层结果图里的标题改了、坐标轴没改。第一层是几何对象和annotate/geom_text的family参数只影响单个元素。第二层是theme()里的text元素——它是所有文字元素的父级改它能一次覆盖标题、轴标签、图例、分面标签library(ggplot2) p - ggplot(mtcars, aes(wt, mpg)) geom_point() labs(title 中文标题, x 车重, y 油耗) theme_bw() theme( text element_text(family myhei, size 11), plot.title element_text(family myhei, size 14, face bold), axis.text element_text(family myhei, size 9) )第三层是设备层的family参数也就是保存文件那一刻。# 路线 Ashowtext 路线设备不需要指定 family pdf(fig_showtext.pdf, width 7.5, height 5) print(p) dev.off() # 路线 Bcairo 路线字体嵌入更干净 cairo_pdf(fig_cairo.pdf, width 7.5, height 5, family SimHei) print(p) dev.off()路线 B 值得多说一句。cairo_pdf(family ...)相当于给整个设备设了一个兜底字体族theme()里没指定family的元素会用它。但如果theme()里指定了myhei这种只有showtext认识的别名cairo 是找不到的会静默回退。所以这两条路线不要混着用要么全走showtext的别名体系要么全走系统真实族名体系。还有一个小细节ggsave()默认根据扩展名选设备.pdf后缀走的是pdf()。如果项目里必须用 cairo得显式指定ggsave(fig.pdf, plot p, device cairo_pdf, width 7.5, height 5, units in, family SimHei)ggsave的family参数是传给设备的不是传给theme的这一点搞混的人不少。3.4 字号、画布、字重的连带影响这块属于参数层面说不清就会返工的部分我按算式说。PDF 是矢量格式内部长度单位是点pt1 pt 1/72 英寸。pdf(width 7.5, height 5)的单位是英寸所以画布宽 7.5 × 72 540 pt。ggplot2的base_size或者theme里的size在这个设备上就是 pt。于是可以反推如果正文指望 11 pt一行想放 40 个汉字那正文字宽需要 40 × 11 440 pt 6.11 英寸。画布 7.5 英寸减去左右边距比如各 0.6 英寸正好 6.3 英寸能放下。要是把size调到 13 pt同样宽度只能放 33 个字标题一长就得换行或者被截断。字号和画布宽度是绑在一起的一对参数改一个就得复核另一个。字重的问题在 1.2 节提过这里给具体的处理方式如果一个字体族只有一个字重face bold在cairo_pdf下的表现是——要么静默使用 regular看起来没加粗要么 Cairo 自己做伪粗体。验伪粗体的办法很简单把 PDF 放大到 400%看笔画边缘。真正的粗体是轮廓加宽伪粗体是原字重复偏移边缘会有重影。提示正式交付的图尽量别依赖face bold去要一个粗体。要么找一个真有 Bold 字重的字体族现代开源黑体普遍提供多字重要么用颜色、字号、下划线来做视觉区分后者更稳。4. 常见问题与排查手册4.1 症状速查表症状最可能原因处理方式中文全变方框pdf()走了默认 sans未做任何中文处理改用cairo_pdf()或接入showtext自己电脑正常别人打开空白用了pdf(family GB1)字体未嵌入换cairo_pdf()用pdffonts复查部分字变方框其他正常字体字符集只到 GB2312遇到圈外字换 GBK/GB18030 覆盖的字体或换全量字体同一行字字形不一致缺字触发了阅读器的 fallback同上从根上补覆盖family写了没反应名字与系统注册名不匹配用systemfonts::match_font()查真实名中文能显示但无法搜索复制showtext把文字转成了路径换cairo_pdf原生嵌入加粗没效果字体族只有一个字重引入带 Bold 的字体或改用其他视觉区分文件体积异常大字体全量嵌入或路径数据过多用子集嵌入检查是否误开了showtext打印店说字体报错嵌入不完整或字体授权限制确认embedded TRUE必要时转曲4.2 几个特别隐蔽的坑第一个坑RStudio 的预览窗口和导出的不是一个设备。RStudio 图形面板里看到的是它自己的一套渲染路径它对中文的容错比pdf()高得多很容易造成看着挺好的错觉。判断依据永远以导出文件为准——导出后拿独立的阅读器打开看别信 IDE 面板。第二个坑pdf()的onefile参数与字体。多页 PDF 时onefile TRUE默认会把所有页放进一个文件字体信息在文件头统一记录。这本身没问题但如果中途dev.off()了又重开一个同名文件前面那个会被覆盖而不是追加。批量出图时我见过有人写循环忘了dev.off()的位置最后只剩最后一页还以为是字体问题。第三个坑字体子集化后被二次编辑工具改坏。有些 PDF 编辑/合并工具处理子集字体时会丢字符映射表合并完之后文字看着在、复制出来是乱码或者干脆显示异常。交付件最好不要用可视化工具去合并、加水印能脚本化就脚本化中间的每一步都可追溯。第四个坑中英文混排的行高。中文全角字的高度和西文的 x-height 不一样同一个size下纯中文行看着比中西混排行松。ggplot2的lineheight参数可以调我一般在theme(text element_text(lineheight 1.15))这个量级试。这个值没有标准答案跟字体本身的度量有关得实际出图看。第五个坑目录里的字体脚本里读不到。换了台机器~/.fonts或者系统字体目录里没装那个字体systemfonts就查不到cairo_pdf会静默回退到别的字体出图完全不报错但字形不对。规避方式是把字体文件随项目走用绝对路径或项目相对路径加载不依赖系统安装状态。这一条我觉得是团队协作里最重要的一条。4.3 交付前的自检清单我自己的习惯是出完图跑一个小脚本让机器替我检查而不是靠眼睛check_pdf - function(path) { fonts - pdftools::pdf_fonts(path) bad - subset(fonts, !embedded) if (nrow(bad) 0) { message(以下字体未嵌入, paste(unique(bad$name), collapse , )) } else { message(字体嵌入检查通过共 , nrow(fonts), 个字体条目) } invisible(fonts) } check_pdf(report.pdf)除此之外人工还要过三关一是渲染抽查把 PDF 在非本机的阅读器里打开或者用命令行工具转成图片看一眼二是文本抽查随便复制一两行中文粘到记事本里看是不是乱码或者缺字三是打印抽查如果有纸质需求先打一页黑白稿看字重和灰度。5. 多平台与批量出图的工程化做法5.1 把字体配置收敛成一个函数项目里图一多散落在各处的family参数就是灾难。我的做法是写一个初始化函数把字体路径、别名注册、设备选择全部收在一起脚本开头调用一次init_fonts - function(project_root here::here()) { font_dir - file.path(project_root, assets, fonts) stopifnot(dir.exists(font_dir)) hei_reg - file.path(font_dir, heiti-regular.ttf) hei_bold - file.path(font_dir, heiti-bold.ttf) stopifnot(file.exists(hei_reg)) sysfonts::font_add( family proj_hei, regular hei_reg, bold if (file.exists(hei_bold)) hei_bold else NULL ) showtext::showtext_auto() options(proj.font proj_hei) invisible(TRUE) }之后所有出图代码只引用getOption(proj.font)再也不出现硬编码的字体名字。这个改造做完之后换字体就是换目录里的 ttf 文件代码一行不用动。别小看这一步字体需求变更是常态尤其是甲方中途说标题换成稍粗一点的黑体的时候。5.2 不同平台的字体名差异要提前想好同一款字体在不同平台上注册的名字可能不一样。常见的差异是桌面系统上族名可能是SimHei也可能是黑体Linux 上 fontconfig 可能把它登记成SimHei或者带厂商前缀的完整名而移动端或者精简系统上可能压根没有这款字体。我的处理原则比较简单粗暴不依赖系统字体全部走随项目分发的字体文件 showtext或cairo_pdf的显式路径。这样跨平台的一致性最好代价是项目体积多几 MB。对于要长期维护的报告项目这几 MB 花得很值。如果非要用系统字体比如某些环境限制不能分发字体文件那就得写一层探测逻辑pick_font - function(candidates) { for (f in candidates) { hit - tryCatch(systemfonts::match_font(f), error function(e) NULL) if (!is.null(hit) nzchar(hit$path[[1]]) file.exists(hit$path[[1]])) { return(f) } } stop(候选字体一个都没找到请检查安装情况) } font_name - pick_font(c(SimHei, Source Han Sans SC, Noto Sans CJK SC))按优先级列表探测第一个命中的就用。这样在三种平台上都能跑不至于某台机器上直接崩掉。5.3 批量出图时的设备管理批量的时候最容易出的问题不是字体本身而是设备没关干净。每开一个pdf()或cairo_pdf()就必须配一个dev.off()而且要在tryCatch或on.exit里做保护不然中间某个图报错设备就悬在那儿后面所有图都往一个错乱的文件里写。render_all - function(plots, out_dir, family getOption(proj.font)) { dir.create(out_dir, showWarnings FALSE, recursive TRUE) for (nm in names(plots)) { out - file.path(out_dir, paste0(nm, .pdf)) cairo_pdf(out, width 7.5, height 5, family family) on.exit(dev.off(), add TRUE) print(plots[[nm]]) dev.off() } invisible(TRUE) }on.exit(dev.off(), add TRUE)这一行是关键函数退出时一定关设备不管中途有没有报错。这种小防御写一次省无数次排查时间。还有一点批量出图如果混用了showtext和cairo_pdf务必确认showtext没有把设备提前接管。我遇到过的情况是同一个循环里有的图走了cairo_pdf的原生字体嵌入有的图因为前面某处调了showtext_auto()而被转成路径最后检查字体嵌入状态时一堆图显示无嵌入字体——因为它们的文字根本不是文字了。回到最开始那个问题黑体和黑体GB2312 该怎么选。我的答案很直接——除非有明确的兼容性要求比如必须和老系统、老版本排版软件对齐否则不要选带 GB2312 后缀的版本。字符覆盖不够字形偏旧字重单一在今天的交付场景里几乎没有优势。真正需要关心的是字体有没有嵌入、字符覆盖够不够、字重够不够用这三件事决定了 PDF 出了门还能不能看。至于 R 里的具体做法我个人现在固定用cairo_pdf 系统真实族名出正式件用showtext 项目内字体文件兜底特殊情况两套并行跑了几年基本没再因为字体返过工。