01_canton快速上手.r
r · 8126 bytes
# ============================================================
# Canton 0.0.8 快速上手:一张图,多种格式,一次保存
# ------------------------------------------------------------
# 对应教程:https://mp.weixin.qq.com/s/iYTJBmnntuoxwK5Bpk53LQ
# CRAN 页面:https://CRAN.R-project.org/package=Canton
# 源码仓库:https://github.com/ScienceAdvances/Canton
#
# 本脚本把原文示例整理成可直接运行的一条龙流程:
# 1. 多格式导出 ggplot2 2. pheatmap 热图
# 3. ComplexHeatmap 热图 4. 基础绘图函数
# 5. figure_preset 预设 6. 字体检查与临时设置
# 7. theme_canton + 配色 8. using()/mkdir()/pwd() 小工具
#
# 运行:Rscript 01_Canton快速上手.R
# 产物:output/ 目录(本地运行产物,已 gitignore)
# ============================================================
# ---------- 0. 环境准备 ----------
# Canton 已在 CRAN,普通安装即可(本机实测 0.0.8)
if (!requireNamespace("Canton", quietly = TRUE)) {
install.packages("Canton", repos = "https://cloud.r-project.org")
}
# pheatmap / ComplexHeatmap 是 Suggests(可选依赖),用到才装
if (!requireNamespace("pheatmap", quietly = TRUE)) {
install.packages("pheatmap", repos = "https://cloud.r-project.org")
}
library(Canton) # 统一保存接口 + 主题 + 配色 + 字体工具
library(ggplot2) # 常规统计图
# 输出目录:Canton 的 outdir 必须显式指定,且不存在时会递归创建
fig_dir <- Canton::mkdir(file.path("output", "canton-figures"))
message("当前工作目录:", Canton::pwd())
message("输出目录:", fig_dir)
# ---------- 1. 多格式导出:ggplot2 ----------
# 一次调用同时输出 PDF / PNG / TIFF,尺寸与 dpi 只写一遍
p <- ggplot(mtcars, aes(mpg, wt, colour = factor(cyl))) +
geom_point(size = 2.5) +
labs(
x = "Fuel economy (mpg)",
y = "Weight (1000 lbs)",
colour = "Cylinders"
) +
theme_canton(base_size = 10) + # Canton 的科研主题(基于 theme_classic)
scale_colour_canton_d("NPG") # Canton 配色(离散)
paths <- imagesave(
p,
name = "figure_1",
outdir = fig_dir,
format = c("pdf", "png", "tiff"),
width = 180,
height = 120,
units = "mm",
dpi = 300,
overwrite = FALSE # 已存在则报错,避免误覆盖
)
print(paths) # 返回命名向量:名字是格式,值是全路径
print(file.info(paths)[, "size"]) # 确认三个文件都真的写出来了
# ---------- 2. pheatmap 热图 ----------
# pheatmap 返回 list,真正的图在 $gtable;Canton 会自动取出来保存
set.seed(1)
mat <- matrix(rnorm(100), nrow = 10)
ph <- pheatmap::pheatmap(
mat,
main = "Expression heatmap",
silent = TRUE # 关键:先不画,交给 imagesave 输出
)
imagesave(
ph,
name = "expression_heatmap",
outdir = fig_dir,
format = c("pdf", "tiff"),
width = 7,
height = 7,
units = "in",
dpi = 300
)
# ---------- 3. ComplexHeatmap 热图(可选) ----------
# Heatmap / HeatmapList 没有 $gtable,Canton 会在每个输出设备上调用
# ComplexHeatmap::draw() 重新绘制
if (requireNamespace("ComplexHeatmap", quietly = TRUE)) {
ht <- ComplexHeatmap::Heatmap(
mat,
name = "z-score",
column_title = "ComplexHeatmap example"
)
imagesave(
ht,
name = "complex_heatmap",
outdir = fig_dir,
format = c("pdf", "png"),
width = 7,
height = 7,
units = "in",
dpi = 300
)
} else {
message("未安装 ComplexHeatmap,跳过第 3 节")
}
# ---------- 4. 基础绘图:传一个零参数函数 ----------
# 基础绘图没有可复用的对象,最稳的写法是包成函数,
# Canton 会在每个输出设备上重新执行一次
draw_scatter <- function() {
plot(
mtcars$mpg,
mtcars$wt,
pch = 19,
xlab = "Fuel economy (mpg)",
ylab = "Weight (1000 lbs)"
)
abline(lm(wt ~ mpg, data = mtcars), col = "red", lwd = 2)
}
imagesave(
draw_scatter,
name = "base_scatter",
outdir = fig_dir,
format = c("png", "pdf"),
width = 7,
height = 5,
dpi = 300
)
# 补充:捕获"当前设备上的图"(交互式会话里方便,脚本里不推荐)
pdf(tempfile(fileext = ".pdf"))
dev.control("enable") # 打开显示列表,否则录制不到
plot(mtcars$mpg, mtcars$wt, main = "recorded from current device")
imagesave(name = "current_plot", outdir = fig_dir, format = "png")
dev.off()
# ---------- 5. figure_preset:把常用导出参数变成预设 ----------
# 内置 5 种:publication / single_column / double_column /
# high_resolution / presentation
print(figure_preset("publication"))
imagesave(
p,
name = "single_column_figure",
outdir = fig_dir,
preset = "single_column" # 3.5 x 3 in, 300 dpi, pdf+tiff
)
# 自定义预设:以 publication 为底,改尺寸与分辨率
paper_preset <- figure_preset(
"publication",
format = c("pdf", "tiff"),
width = 180,
height = 120,
units = "mm",
dpi = 600
)
imagesave(
p,
name = "paper_figure",
outdir = fig_dir,
preset = paper_preset
)
# 单张图有特殊需求时,显式参数会覆盖预设的对应项
imagesave(
p,
name = "preset_override",
outdir = fig_dir,
preset = paper_preset,
width = 90, # 只改宽度,其余仍用预设
format = "pdf"
)
# ---------- 6. 字体:先检查,再统一 ----------
fontcheck("Arial") # 返回 TRUE/FALSE,并打印提示
print(head(fontlist(), 10)) # 列出系统 + systemfonts 注册字体
print(fontlist("Arial")) # 按名称筛选
# 用法 A:只验证并返回字体名,不改变全局设置
family <- setfont("Arial", quiet = TRUE)
p_font <- p + theme_canton(base_family = family)
imagesave(
p_font,
name = "font_checked_figure",
outdir = fig_dir,
family = family,
format = "pdf"
)
# 用法 B:在一段代码里临时使用,退出(含报错)自动恢复
old_family <- getOption("Canton.font_family")
setfont("Arial", quiet = TRUE, code = {
p_local <- ggplot(mtcars, aes(mpg, wt)) +
geom_point() +
theme_canton()
imagesave(
p_local,
name = "local_font_figure",
outdir = fig_dir,
format = "pdf"
)
})
stopifnot(identical(getOption("Canton.font_family"), old_family))
# 注意:Canton 不会下载字体;Arial 需自行安装到系统字体目录后 fc-cache -f
# 中文标签请用带中文字形的字体(如 Droid Sans Fallback / WenQuanYi Zen Hei),
# 并把字体名写进 theme_canton(base_family=),不要只在 imagesave(family=) 里给
# ---------- 7. theme_canton + 配色 ----------
p_grid <- p + theme_canton(
base_size = 11,
grid = "major", # "none" / "major" / "both"
legend_position = "bottom"
)
imagesave(p_grid, name = "theme_grid", outdir = fig_dir, format = "png")
print(hue()) # 列出所有内置色板名
print(hue("NPG")) # 取 NPG 的具体色值
print(hue("Dark2"))
# 离散/连续、colour/fill 四种组合都有对应标度
p_bar <- ggplot(mtcars, aes(factor(cyl), fill = factor(am))) +
geom_bar(position = "dodge") +
labs(x = "Cylinders", y = "Count", fill = "Transmission") +
scale_fill_canton_d("Dark2") +
theme_canton()
imagesave(p_bar, name = "bar_dark2", outdir = fig_dir, format = "pdf")
p_cont <- ggplot(mtcars, aes(mpg, wt, colour = qsec)) +
geom_point(size = 2.5) +
scale_colour_canton_c("NPG") + # 连续色标
theme_canton(grid = "major")
imagesave(p_cont, name = "continuous_scale", outdir = fig_dir, format = "pdf")
# ---------- 8. 三个顺手的小工具 ----------
print(using(ggplot2, grid)) # 安静加载,返回命名逻辑向量
print(mkdir(file.path(fig_dir, "nested", "dir"))) # 递归建目录,幂等
print(pwd()) # 当前工作目录
# ---------- 9. 结果清单 ----------
cat("\n生成的文件:\n")
print(list.files(fig_dir, recursive = TRUE))
02_论文级出图工作流.r
r · 7392 bytes
# ============================================================
# Canton 论文级出图工作流:从分析结果到可交付图件
# ------------------------------------------------------------
# 演示一套"一个项目、多种图形、统一导出"的实际写法:
# · 多面板组合图(patchwork + theme_canton + Canton 色板)
# · pheatmap / ComplexHeatmap 热图
# · 基础绘图函数
# · 统一预设(尺寸/分辨率/格式)+ 字体检查
#
# 运行:Rscript 02_论文级出图工作流.R
# 产物:output/(本地运行产物)+ 根目录 Canton示例_科研图.pdf(图册预览图)
# ============================================================
suppressPackageStartupMessages({
library(Canton)
library(ggplot2)
})
if (!requireNamespace("patchwork", quietly = TRUE)) {
install.packages("patchwork", repos = "https://cloud.r-project.org")
}
library(patchwork)
fig_dir <- Canton::mkdir(file.path("output", "paper-workflow"))
# ---------- 0. 造一份可复现的示例数据 ----------
set.seed(20261022)
n <- 60
expr <- data.frame(
sample = sprintf("S%02d", 1:n),
group = rep(c("Control", "Treatment"), each = n / 2),
subtype = rep(c("A", "B", "C"), length.out = n),
score = c(rnorm(n / 2, 5.2, 1.1), rnorm(n / 2, 7.4, 1.3)),
response = c(rnorm(n / 2, 0.42, 0.10), rnorm(n / 2, 0.63, 0.12))
)
expr$group <- factor(expr$group, levels = c("Control", "Treatment"))
# 基因表达矩阵(20 基因 × 12 样本,供热图使用)
mat <- matrix(rnorm(20 * 12, mean = 6, sd = 1.5), nrow = 20,
dimnames = list(paste0("Gene", sprintf("%02d", 1:20)),
paste0("Sample", 1:12)))
# ---------- 1. 统一的论文预设 ----------
# 以 publication 为底:180 x 120 mm、600 dpi、PDF + TIFF(多数期刊的通用组合)
paper <- figure_preset(
"publication",
format = c("pdf", "tiff"),
width = 180, height = 120, units = "mm", dpi = 600
)
# ---------- 2. 三个 ggplot 面板 ----------
pA <- ggplot(expr, aes(group, score, fill = group)) +
geom_boxplot(width = 0.55, outlier.shape = NA, linewidth = 0.4) +
geom_jitter(width = 0.12, size = 1.1, alpha = 0.65) +
labs(x = NULL, y = "Module score", title = "Module score by group") +
scale_fill_canton_d("NPG") +
theme_canton(base_size = 9, legend_position = "none")
pB <- ggplot(expr, aes(score, response, colour = subtype)) +
geom_point(size = 1.8, alpha = 0.85) +
geom_smooth(method = "lm", se = FALSE, linewidth = 0.6) +
labs(x = "Module score", y = "Response index", colour = "Subtype",
title = "Score-response correlation") +
scale_colour_canton_d("Dark2") +
theme_canton(base_size = 9, grid = "major")
pC <- ggplot(expr, aes(x = response, fill = group)) +
geom_density(alpha = 0.45, linewidth = 0.4) +
labs(x = "Response index", y = "Density", fill = NULL,
title = "Response distribution") +
scale_fill_canton_d("Lancet") +
theme_canton(base_size = 9, legend_position = "bottom")
# patchwork 组合 + 面板编号(Nature/Cell 常用的 A/B/C 大写加粗)
fig1 <- (pA | pB | pC) +
plot_layout(widths = c(1, 1.15, 1)) +
plot_annotation(tag_levels = "A") &
theme(plot.tag = element_text(face = "bold", size = 11))
# 组合图按预设一次导出 PDF + TIFF
imagesave(
fig1,
name = "Fig1_组合图",
outdir = fig_dir,
preset = paper
)
# ---------- 3. pheatmap:热图 + 列注释 ----------
ann_col <- data.frame(
Group = rep(c("Control", "Treatment"), each = 6),
row.names = colnames(mat)
)
ann_colors <- list(Group = c(Control = hue("NPG")[4], Treatment = hue("NPG")[1]))
ph <- pheatmap::pheatmap(
mat,
annotation_col = ann_col,
annotation_colors = ann_colors,
cluster_cols = FALSE,
show_colnames = TRUE,
fontsize = 9,
main = "Expression heatmap",
silent = TRUE
)
imagesave(
ph,
name = "Fig2_pheatmap",
outdir = fig_dir,
preset = paper
)
# ---------- 4. ComplexHeatmap:行拆分 + 连续色标 ----------
if (requireNamespace("ComplexHeatmap", quietly = TRUE)) {
ht <- ComplexHeatmap::Heatmap(
mat,
name = "z-score",
column_title = "ComplexHeatmap: split by subtype",
row_split = rep(c("Up", "Down"), each = 10),
cluster_rows = TRUE,
cluster_columns = FALSE,
column_names_rot = 45,
heatmap_legend_param = list(title_gp = grid::gpar(fontsize = 9))
)
imagesave(
ht,
name = "Fig3_ComplexHeatmap",
outdir = fig_dir,
preset = paper
)
} else {
message("未安装 ComplexHeatmap,跳过第 4 节")
}
# ---------- 5. 基础绘图:零参数函数 ----------
draw_volcano_like <- function() {
set.seed(7)
log2fc <- rnorm(800, 0, 1)
pval <- -log10(runif(800, 1e-6, 1))
plot(log2fc, pval,
pch = 19, cex = 0.5,
col = ifelse(abs(log2fc) > 1.5 & pval > 2, "#E54B34", "grey65"),
xlab = expression(log[2]~"fold change"),
ylab = expression(-log[10]~"P"),
main = "Base graphics: volcano-like plot")
abline(h = 2, v = c(-1.5, 1.5), lty = 2, col = "grey40")
}
imagesave(
draw_volcano_like,
name = "Fig4_base_graphics",
outdir = fig_dir,
preset = paper
)
# ---------- 6. 字体:先检查再统一 ----------
# 论文图统一用 Arial(本机已安装);PDF 走 cairo_pdf 可嵌入字体
#
# ⚠️ 0.0.8 的已知坑:imagesave(family=) 只对 PDF 有效。
# PNG/JPG/TIFF 走 ggplot2::ggsave() 时会把这个参数透传给设备,
# 而 grDevices::png()/tiff() 没有 family 形参 → "unused argument"。
# 所以带 family 的调用必须把 format 限定为 "pdf",
# 位图版本单独导出(字体由 theme(base_family=) 控制)。
if (fontcheck("Arial", quiet = TRUE)) {
fam <- setfont("Arial", quiet = TRUE)
fig1_arial <- fig1 & theme(text = element_text(family = fam))
# PDF(矢量,可嵌入字体)
imagesave(
fig1_arial,
name = "Fig1_组合图_Arial",
outdir = fig_dir,
format = "pdf",
width = 180, height = 120, units = "mm", dpi = 600,
family = fam
)
# TIFF(位图):不传 family,仅靠 theme 的 base_family
imagesave(
fig1_arial,
name = "Fig1_组合图_Arial",
outdir = fig_dir,
format = "tiff",
width = 180, height = 120, units = "mm", dpi = 600
)
}
# ---------- 7. 图册预览图 ----------
# 2 x 2 拼接:散点 / 箱线 / 热图(ggplot 版)/ 密度
# 矩阵是 基因×样本,转成 样本×基因 的 data.frame 才能直接写列名
mat_df <- as.data.frame(t(mat))
pD <- ggplot(mat_df, aes(x = Gene01, y = Gene02)) +
geom_point(size = 1.6, colour = hue("NPG")[2]) +
geom_smooth(method = "lm", se = FALSE, colour = hue("NPG")[1], linewidth = 0.6) +
labs(x = "Gene01 (log2)", y = "Gene02 (log2)", title = "Gene-gene correlation") +
theme_canton(base_size = 9, grid = "major")
preview <- (pA | pB) / (pC | pD) +
plot_annotation(
title = "Canton: one interface, consistent publication figures",
subtitle = "theme_canton() + scale_*_canton_*() + imagesave()",
tag_levels = "A"
) &
theme(plot.tag = element_text(face = "bold", size = 11))
# 同时出 PDF(本地产物)与 PNG(图册卡片图,随仓库提交)
imagesave(
preview,
name = "Canton示例_科研图",
outdir = ".",
format = c("pdf", "png"),
width = 180, height = 130, units = "mm", dpi = 300
)
cat("\n完成。输出目录:", normalizePath(fig_dir), "\n")
print(list.files(fig_dir))
03_已知坑复现与规避.r
r · 8626 bytes
# ============================================================
# Canton 0.0.8 已知坑:复现 + 规避(本机 R 4.5.1 / ggplot2 4.0.3 实测)
# ------------------------------------------------------------
# 这个脚本不画正式图,只做"行为断言":
# 每条坑都先复现预期报错/警告,再给出可用的规避写法。
# 运行:Rscript 03_已知坑复现与规避.R
# 输出:output/known-issues/(本地产物)
# ============================================================
suppressPackageStartupMessages({
library(Canton)
library(ggplot2)
})
fig_dir <- Canton::mkdir(file.path("output", "known-issues"))
p <- ggplot(mtcars, aes(mpg, wt)) + geom_point() + labs(title = "known issues")
# 极简断言器:记录 PASS / FAIL,最后统一汇总
results <- data.frame(issue = character(), expectation = character(),
status = character(), detail = character(),
stringsAsFactors = FALSE)
check <- function(issue, expectation, expr, expect_error = FALSE) {
caught <- NULL
value <- withCallingHandlers(
tryCatch(expr, error = function(e) { caught <<- conditionMessage(e); NULL }),
warning = function(w) invokeRestart("muffleWarning")
)
failed <- if (expect_error) is.null(caught) else !is.null(caught)
results <<- rbind(results, data.frame(
issue = issue,
expectation = expectation,
status = if (failed) "FAIL" else "PASS",
detail = if (!is.null(caught)) substr(gsub("\n", " ", caught), 1, 90) else "ok",
stringsAsFactors = FALSE
))
invisible(value)
}
# ------------------------------------------------------------
# 坑 1|imagesave(family=) 只对 PDF 生效
# 0.0.8 的 .imagesave_ggsave() 会把 family 透传给 ggsave();
# ggplot2 默认的 PNG/TIFF 设备(ragg::agg_png / agg_tiff)没有 family 形参,
# 于是必然报 "unused argument (family = ...)"。
# 原文示例恰好只对 format="pdf" 传 family,所以没暴露。
# ------------------------------------------------------------
check("坑1 PNG 传 family",
"报 unused argument(预期失败)",
imagesave(p, name = "k1_png_family", outdir = fig_dir,
format = "png", family = "Arial"),
expect_error = TRUE)
check("坑1 TIFF 传 family",
"报 unused argument(预期失败)",
imagesave(p, name = "k1_tiff_family", outdir = fig_dir,
format = "tiff", family = "Arial"),
expect_error = TRUE)
# ✅ 规避:family 只配 PDF;位图用 theme(base_family=) 控制字体
check("坑1 规避:PDF 传 family",
"正常出图",
imagesave(p, name = "k1_pdf_family", outdir = fig_dir,
format = "pdf", family = "Arial"))
check("坑1 规避:PNG 不传 family",
"正常出图",
imagesave(p + theme_canton(base_family = "Arial"),
name = "k1_png_theme", outdir = fig_dir, format = "png"))
# ------------------------------------------------------------
# 坑 2|PDF 里的中文:默认 pdf() 设备不嵌中文字形
# - 不传 family 时走 grDevices::pdf(),中文会报
# "conversion failure ... mbcsToSbcs" 并渲染成空框
# - theme_canton(base_family="Droid Sans Fallback") 只改 theme,
# 设备仍是 pdf() → "invalid font type"
# - 正确做法:imagesave(family="Droid Sans Fallback"),
# Canton 内部会切 cairo_pdf(.imagesave_pdf_device)
# ------------------------------------------------------------
p_cn <- ggplot(mtcars, aes(mpg, wt)) + geom_point() +
labs(title = "中文标题测试", x = "油耗", y = "重量")
check("坑2 中文+PDF 默认设备",
"产生转换警告(预期)",
imagesave(p_cn + theme_canton(), name = "k2_pdf_default",
outdir = fig_dir, format = "pdf"))
check("坑2 中文+theme 字体但设备不是 cairo",
"报 invalid font type(预期失败)",
imagesave(p_cn + theme_canton(base_family = "Droid Sans Fallback"),
name = "k2_pdf_theme", outdir = fig_dir, format = "pdf"),
expect_error = TRUE)
check("坑2 规避:family 走 cairo_pdf",
"正常出图且中文可提取",
imagesave(p_cn + theme_canton(), name = "k2_pdf_cairo",
outdir = fig_dir, format = "pdf", family = "Droid Sans Fallback"))
check("坑2 规避:PNG 用 theme 中文字体",
"正常出图",
imagesave(p_cn + theme_canton(base_family = "Droid Sans Fallback"),
name = "k2_png_theme", outdir = fig_dir, format = "png"))
# ------------------------------------------------------------
# 坑 3|overwrite = FALSE 是"全格式一起检查"
# 只要任意一个目标文件已存在,整次调用直接报错,不会"只补缺失格式"。
# ------------------------------------------------------------
imagesave(p, name = "k3_multi", outdir = fig_dir, format = c("pdf", "png"))
check("坑3 overwrite=FALSE 任一存在",
"整体报错(预期失败)",
imagesave(p, name = "k3_multi", outdir = fig_dir,
format = c("pdf", "png"), overwrite = FALSE),
expect_error = TRUE)
# ------------------------------------------------------------
# 坑 4|色板容量:hue() 的 NPG/Lancet/NEJM/JAMA/Science 最多 50 色
# 分组数 > 50 时 scale_*_canton_d() 报
# "Insufficient values in manual scale"
# ------------------------------------------------------------
p_many <- ggplot(data.frame(x = 1:60, y = 1:60, g = factor(1:60)),
aes(x, y, colour = g)) +
geom_point() +
scale_colour_canton_d("NPG")
check("坑4 >50 组用 50 色板",
"报 Insufficient values(预期失败)",
imagesave(p_many, name = "k4_many", outdir = fig_dir, format = "png"),
expect_error = TRUE)
# ------------------------------------------------------------
# 坑 5|outdir 是必填项(0.0.8 新规则,0.0.7 之前可不填)
# ------------------------------------------------------------
check("坑5 缺 outdir",
"报 Supply an explicit outdir(预期失败)",
imagesave(p, name = "k5_no_dir", format = "png"),
expect_error = TRUE)
# ------------------------------------------------------------
# 坑 6|不支持的对象 / 格式要尽早失败
# 对象类型在创建目录之前校验;格式只接受 pdf/png/jpg/jpeg/tif/tiff
# ------------------------------------------------------------
check("坑6 传数值向量",
"报 Unsupported plot object(预期失败)",
imagesave(1:10, name = "k6_obj", outdir = fig_dir, format = "png"),
expect_error = TRUE)
check("坑6 传 svg 格式",
"报 Unsupported format(预期失败)",
imagesave(p, name = "k6_svg", outdir = fig_dir, format = "svg"),
expect_error = TRUE)
# ------------------------------------------------------------
# 坑 7|setfont(code=...) 的临时设置会在报错时恢复
# (0.0.8 的卖点之一:不再安装持久字体钩子)
# ------------------------------------------------------------
old_family <- getOption("Canton.font_family")
try(setfont("Arial", quiet = TRUE, code = {
stop("故意报错,验证恢复")
}), silent = TRUE)
check("坑7 setfont 报错后恢复",
"全局选项回到调用前状态",
stopifnot(identical(getOption("Canton.font_family"), old_family)))
# ------------------------------------------------------------
# 坑 8|失败的调用可能留下"半成品"文件
# imagesave 先建目录、再逐个格式渲染;若第 2 个格式报错,
# 第 1 个格式(以及失败的那个)可能已经留在磁盘上。
# 自动化流程里建议:要么每次运行前清空 outdir,要么用
# tryCatch + 事后清理,别只看"函数报错"就以为没写文件。
# ------------------------------------------------------------
before <- list.files(fig_dir)
try(imagesave(p, name = "k8_partial", outdir = fig_dir,
format = c("pdf", "png"), family = "Arial"), silent = TRUE)
after <- setdiff(list.files(fig_dir), before)
cat("\n坑 8:失败调用残留文件 ->", paste(after, collapse = ", "), "\n")
check("坑8 失败调用可能留脏文件",
"能观察到残留(记录现象,不视为 bug)",
stopifnot(length(after) >= 0))
# ------------------------------------------------------------
# 汇总
# ------------------------------------------------------------
cat("\n================ 已知坑断言汇总 ================\n")
print(results, row.names = FALSE)
cat("\nPASS:", sum(results$status == "PASS"),
"/", nrow(results), "\n")
cat("产物:", normalizePath(fig_dir), "\n")
print(list.files(fig_dir))