摘要:本文记录 zibll 子主题「图片宽度排版」模块从需求到落地的完整过程:如何在不动编辑器、不给图片加自定义字段的前提下,用 alt 后缀驱动宽度档位;为什么必须弃用 float 改用 flex-wrap 画廊;以及「25% 宽的列里上下叠两张图」这种嵌套拼图怎么实现。文中给出核心算法代码、线上实测验证数据与 7 条踩坑记录。
适合阅读:WordPress 主题 / 插件开发者,以及需要图片墙排版的站长。
一、需求:到底要什么样的密集墙
需求最初只有一句话:「文章里加一个段落,全放图片,不要出现小段落」。真正做下去才发现,这句话背后压着五条硬约束:
- 纯图片:段内不能混文字,否则图会跟着文字流走;
- 每行宽度和恰好 100%:既不能超(溢出换行),也不能少(右侧留白);
- 底边齐平:同一行的图片下沿必须在一条线上;
- 支持纵向叠放:一个 25% 宽的格子里上下叠 2 张甚至 4 张图,做出嵌套拼图;
- 写法极简:作者只在图库里改 alt,不需要记任何布局语法或短代码。
其中第 2 条是踩过坑才定下来的。最初我理解成「只要不超过 100% 就行」,于是把各种组合装箱上墙,结果页面上出现大量右侧空白的半行——被当场打回。必须恰好等于 100%,这条后来成了所有算法的验收标准。
二、方案设计
2.1 语法:把宽度藏进 alt
WordPress 后台没有给图片提供「宽度」字段,加自定义字段又太重。alt 是唯一随手可改、且语义上允许带额外信息的属性。于是约定在 alt 描述末尾加后缀:
描述-宽度 [left|right] [s] [0]
档位:20 / 25 / 33 / 50 / 75 / 100
s → 这张图参与纵向叠放(stack)
0 → 这张图进无缝贴边墙(间距归零)
left/right → 图文混排时浮动方向
示例:
alt="雪山日落-50" → 占正文 50% 宽
alt="雪山日落-25 s" → 与相邻同档位 s 图叠成一列
alt="雪山日落-33 0" → 1/3 宽,且所在段落升级为无缝墙
宽度信息只做一件事:转成 class(fig-50)挂到 img 上,然后把 alt 的宽票后缀剥掉——alt 最终只留纯描述,不影响无障碍语义和 SEO。
2.2 布局模型:为什么必须弃用 float
这套功能最早是从 Typecho 主题 Yodu 移植来的。Yodu 的原实现是纯 float 模型:
.figure.fig-25,.figure.fig-50,.figure.fig-75 { float:left; margin:2px; }
.figure.fig-25 { width:calc(25% - 4px); }
.figure.fig-50 { width:calc(50% - 4px); }
float 的好处是「作者随便写都能自动挤」,但代价恰恰是需求里的第 3 条:图片高度不一致时,下一行的图会卡进矮图下方的空隙,整墙错行、底边参差。v1 的源码注释里就把这个现象当成 bug 记着,改成 flex 画廊后才解决。两种模型对比:
| 维度 | float + margin | flex-wrap 画廊 |
|---|---|---|
| 行内底边 | 天然不齐(高度参差) | 由 align-items 决定,可强制等高 |
| 换行行为 | 矮图下方会被下一张插入 | 严格按宽度换行,绝不交错 |
| 宽度精度 | margin 参与盒模型,易算错 | flex-basis 扣掉 gap,精确 |
| 叠放支持 | 不支持(无列容器) | 列容器是行内 flex item,天然支持 |
最终采用 display:flex; flex-wrap:wrap; gap,每个格子 flex: 0 0 calc(N% - var(--zl-gap)),间距用 CSS 变量注入、后台可配。
2.3 数学约束:只认「和恰好 = 100」的组合
既然每行必须铺满,那能用的组合就不再是「随便挑」,而是六档位里凑满 100 的有限解——正好 10 种有序排法:
100
75 + 25 | 25 + 75
50 + 50
50 + 25 + 25 | 25 + 50 + 25 | 25 + 25 + 50
33 + 33 + 33 (33 实际按 33.3333% 渲染,三张恰好铺满)
25 + 25 + 25 + 25
20 + 20 + 20 + 20 + 20
⚠️ 这里有个容易写错的校验:33 必须按 100/3 参与求和,不能拿字面值 33 去硬卡 array_sum() === 100——那样 33×3=99 会被判为不合法,而 CSS 侧三张 33.3333% 其实是恰好铺满的。
2.4 叠列:列宽与列内图数无关
叠列是整个功能里最关键的一个抽象:一个叠列在行宽账上只占「档位」这一格,不管里面叠了几张图。
alt="A-25 s" + alt="B-25 s" + alt="C-75"
↓
┌──────┬────────────────────┐
│ A │ │
├──────┤ C (75%) │
│ B │ │
└──────┴────────────────────┘
25% 75% → 行宽 = 100%
所以「1 张 25% 的图」和「25% 宽、内叠 4 张的列」在行宽账上完全等价——这是后面能保证每行恒定 100% 的数学基础。
三、实现
3.1 单图打标:一条正则 + 边界判定
第一步是扫出所有 img,解析 alt 后缀,把宽度写进 class。正则要同时认档位、方向、叠放、贴边四个可选段:
#^(.*S)s*-s*(d{2,3})(?:s+(left|right))?(?:s+(s))?(?:s+(0))?$#i
解析完还要做几组互斥判定,宁可「不认」也不能产出半失效的组合:left/right 与 0 互斥(float 靠 margin 造间距,无缝墙靠 gap 归零,同时开会打架);s 与两者都互斥(叠列是纵向布局)。
3.2 按「行」装格:叠列分组算法(核心)
这是唯一一处真正有难度的逻辑。第一版我写成「连续 + 同档位就合并」,线上实测铺满率直接掉到 0.74,两个坑:① 上一行末尾的 25s 25s 和下一行开头的 25s 25s 连成一段,被并成一个 4 图列,后面整行宽度账错位;② 连续图里混进一个不同档位,整段被放弃,页面上残留一堆没有样式的孤立标记。
根因是只看了图、没看行。正确做法是先按 100% 装行,行内再决定列:
$row = 0.0; // 当前行已占宽度百分比
$colKey = null; // 当前打开的叠列
foreach ($meta as $i => $info) {
$u = span($info['w']); // 33 → 100/3,其余取字面档位
// ① 能并入当前打开的列?列宽不变 → 行宽也不变
if ($info['st'] && null !== $colKey) {
$col = $groups[$colKey];
if ($col['w'] === $info['w'] && count($col['idxs']) < 4) {
$groups[$colKey]['idxs'][] = $i;
continue;
}
$colKey = null; // 换档位 / 到上限 → 收列
}
// ② 放不下就先换行
if ($row + $u > 100.5) { $row = 0.0; }
$groups[] = array('idxs' => array($i), 'w' => $info['w'], 'stack' => $info['st']);
$colKey = $info['st'] ? count($groups) - 1 : null;
$row += $u;
}
因为「并入列」这一步完全不改变行宽,所以无论一列里叠 2 张还是 4 张,每行宽度之和都恒等于 100%——这就是验收标准能被程序保证的原因。
3.3 段落包裹:捕获组必须包住整个序列
最后一步是把「纯图片段落」包成画廊容器。正则有个经典陷阱:重复组 (?:...)+ 的括号要写在外面,写在里面的话 $m[1] 只保留最后一张图,其余全被吞掉。
#<p(?:s[^>]*)?>s*((?:(?:<imgb[^>]*bfigureb[^>]*>|<divb[^>]*bzl-stackb[^>]*>.*?</div>)s*)+)</p>#is
四、效果验证
功能不能靠「看起来对」交付。写了浏览器脚本按画廊的直接子元素测量每一行,断言铺满率、行内高度极差、叠列数量三项。线上实测(单段落 136 张图):
| 指标 | 实测值 | 判定 |
|---|---|---|
| 每行铺满率 | min 0.99 / max 1.00 | 通过 |
| 行内高度极差 | 0 px | 通过(底边齐平) |
| 叠列 | 14 个(2 图列 ×10、4 图列 ×4) | 通过 |
| 全站画廊 / 行 / 不齐行 | 40 / 86 / 0 | 通过 |
铺满率上限是 1.00 而非 1.0 的原因:格子的 flex-basis 扣掉了一个 gap,n 个格子的宽度和是 100% - gap + (n-1)×gap,剩下约 1 个 gap 的零头由小数误差吸收,肉眼无感。
五、踩坑记录
下面 7 条全部是线上实测踩出来的,每一条的共同点是:不报错,只是静默不生效。
1. PHP 的 !empty('0') 是 false
贴边标记要判断字符串 '0',但 PHP 把 '0' 当空值,!empty('0') 恒为 false → 无缝墙静默失效。必须写 isset($m) && '0' === trim($m)。
2. 跨行的同宽图会被误并成一列
即 3.2 的坑①。代码侧改成按行装格后已能自动切断;但仍有内容侧的残留歧义:一组排法以 25% 叠列收尾、下一组又从 25% 叠列开头,两段之间没有任何图能表达「这里要断」。约定是组与组之间插一张 100% 整幅图做隔断。
3. flex 子项写 1 1 0 会整体塌陷
叠列容器高度是 auto 时,flex-basis:0 会让列内所有图塌成 0 高,整列消失。必须写 flex: 1 1 auto,同时补 min-height:0(否则图片被自身内容高度顶住无法收缩)。
4. 多行 flex 里不要写 height:100%
百分比高度参照的是容器总高而不是行高,多行布局下会算错。等高应该交给 align-items:stretch,它给的正是本行行高。
5. wp_add_inline_style() 必须晚于句柄入队
间距是后台可配的,用内联样式注入 CSS 变量。但内联样式要求句柄已经入队,早于入队执行会静默无输出——页面上看不到任何错误,只是 --zl-gap 消失了。框架里资源统一在优先级 99 入队,内联必须挂 100。
6. 素材自带黑边,CSS 拉不掉
上线后发现不少格子四周有黑边。查下来不是样式问题——黑边是游戏原画图片像素的一部分,object-fit 只裁不缩,黑边在画面内部就永远跟着。最后写了个 GD 脚本批量处理:原图缩到 200px 宽做行列扫描,四周 ≥98% 纯黑的边判定为黑边,每边最多裁 18% 防误裁,裁完重新生成缩略图。230 张里裁掉 41 张。
7. cover 中心取景会把人物头顶切掉
小格被行高拉得比原图竖长,cover 垂直裁切时默认取画面正中间,人物立绘的头正好被切。把 object-position 从 50% 50% 改成 50% 25%,取景中心上移,头部回来了;对风景图只是取景略偏上,无感。
六、总结
回过头看,这个功能真正难的地方不在 CSS,而在把”作者意图”翻译成”行宽账”:作者只写 -25 s 这种极简记号,程序要还原出他想拼成什么样。一旦承认「列宽与列内图数无关」这个不变量,行宽就能被严格约束在 100%,底边齐平、不留白、不溢出这三条就都变成了可以自动断言的指标,而不是靠眼睛看。
下一步可以考虑的方向:把取景焦点(object-position)也做成 alt 可写的可选项,让竖构图的主体能单独指定;以及把「整幅隔断图」这条内容侧约定,换成显式的分段标记。
附:效果示例(本文用同一套语法渲染)
下面这组图没有任何额外标记,只在 alt 里写了档位,由本文讲的模块自动排成墙:




再叠一列试试(-25 s ×2 + -50):



📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列 (本文)
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容