杂记 › 技术笔记 › 转完悄悄少了 271 个字符:Markdown 转 HTML 的洞协议、64 轮回填与 20 条提示上限

转完悄悄少了 271 个字符:Markdown 转 HTML 的洞协议、64 轮回填与 20 条提示上限

旧日志粘进转换页少 5 个码元、成品比原文少 271 字符:这页自写的洞协议、64 轮回填上限、归一化删控制字符、20 条提示额度、pass 换序真值表与两份复制产物差,逐笔量过。

少了 5 个码元,界面一个字都没提

先是一笔小账:把一段旧日志粘进转换页,源文 11 个码元,渲染只读到 6 个,少掉的 5 个在预览和成品里都找不到痕迹,mdWarnings 是空数组。另一笔是有人问:复制走的 HTML 比原文少了一截,谁吃掉的。读完这页脚本才确认,「悄悄少字」是三处各自静默的机制,标题里的 271 是其中一处的极值。

被测页是Markdown 转 HTML,模板 1135 行、41623 字节、md* 函数 72 个;测法是把函数逐字摘进纯 Node,DOM 走 mdGet 的 null 分支。

中间态埋在正文串里

这页不边解析边输出:行内代码、链接、图片、强调先算一步的结果,都换成一枚「洞」留在串里——L235、L236 两枚常量做标记,mdStashPut(L323)返回「码位 2 + 下标 + 码位 3」,真正的 HTML 存在 stash.list,块级拼完后主链只在 L668 调一次 mdUnstash 回填。

下标是入库时 list 的长度,跟文档顺序无关:# A **b** `c` 起头那篇,回填前串里的下标序列是 2,0,1,3,4,6,5,粗体的 2 反而排在行内代码的 0 前面。

64 轮上限,只有构造的链撞得上

mdUnstash(L333)的循环条件是「串里还有码位 2 且轮数没到 64」;退出后 L343 无条件删残留洞,L329 到 L345 整段没调用 mdWarn。撞上限要造洞值套洞的链(每层 = A + 内层洞 + B,最内层 Z):depth 64 时轮数恰好 64、全展开 127 码元;depth 65 起轮数锁在 64、输出锁在 128 码元,丢字符 = 2*(depth - 64) - 1,于是 65 丢 1、66 丢 3、70 丢 11、100 丢 71、200 丢 271,全程零提示。

洞链深度与实得码元 1 10 64 65 100 200 1 19 127 128 描边=轮数停在 64;depth 200 期望 399、实得 128。

图 1:链深与回填轮数。

这条上限跟文档长无关:mdSample() 是 53 行 / 655 码元,回填前 17 枚洞,逐轮记录只有一条「第 0 轮:洞 17 变 0」,1 轮完、删 0 字符;2000 段、97778 码元的文档有 6000 枚洞,轮数仍是 1,产物 179779 码元,探针机器耗时 58 ms。洞互不嵌套时一轮就换完,64 轮给典型文档留了 63 轮余量。

越界洞是另一条静默路径:输入 X洞0-洞7-洞2Y(list 只有 3 项),洞 7 取不到值被 L337 原样留着,第 2 轮 break,随后 L343 把它按 3 字符删掉,成品是 17 码元的 X<b>0</b>--<u>2</u>Y,合法洞照常回填。

归一化先吃字符

mdNormalize(L523–529)的 L527 删码位 0 到 4 五档:甲 + 码位 n + 乙 由 3 码元变 2,五枚各命中 1,连粘 5 枚时 7 码元变 2,U+0005、U+0006 一枚不删。开头那笔 11 码元读到 6 码元就在这一步;mdWarn 全页只有 L696 与 L766 两个调用点,所以提示 0 条。同一枚正则顺带堵住伪造:正文写「码位 2 0 码位 3」冒充洞,9 码元进来、归一化后 7 码元,标记没了、只剩正文里那个 0。

L526 把每枚 tab 恒换成 4 枚空格,档位只认 4:3 空格出 <ul>,正好 4 空格出 <pre><code>,5 空格退回 <p> - 甲</p>;tab 恒折进 4 空格档、空格加 tab 落 5 空格档、两枚 tab 的 8 空格是段落;子项缩进够 4 空格就算下一层,这层账列表编号与标题锚点算过。

提示只收 20 条

表头 2 列加 34 行 3 列的表格每行多一格,L766 逐行出一条,mdWarn 被调用 34 次,而 L498 只在前 20 条入库:数组停在 20,14 条无声消失。#md-err 文本 513 码元、按 ' | ' 恰好 20 条、末条正好是「表格第 20 行…」,第 21 到 34 行连字符都不出现。额度也不跨次累加:L660 每次渲染重置数组,连渲 5 次依次 20,20,20,20,20。

12 个 pass 的顺序真值表

mdInline(L480–495)固定 12 步,行内代码在前、软换行收尾;把相邻两步换序,28 例输入里只有 4 对在输出上看得见差别。

换序的那一对差异看到什么
images 与 links1/28括号被 links 先吃,感叹号裸在外面
links 与 urls2/28右括号被一起吞进地址
escapes 与 emphasis1/28反斜杠漏进成品
rawTags 与 esc4/28标签先被转义,兜不回来
另外 7 对0/28本组样本没触发到,不等于永远可交换

图片那例输入 ![alt *x*](https://example.com/i.png),换序后成 !<a href="…">alt <em>x</em></a>;\*不是斜体\* 那例换序后两枚反斜杠留在标签两侧。mdEmphUnderscore(L456)把汉字列进「不许相邻」,snake_case_word、_斜体_中文 洞数全 0、原样输出。

两个复制按钮差 106

66 码元那篇(含 <b>、引号、a&b、带标题链接)默认档渲染:「复制 HTML」(L1051)给 135 码元原样,「复制后转义」(L1054)再套一层实体给 241 码元。差值可逐字符对账:每枚 & 加 4、每枚 < 与 > 各加 3、每枚 " 加 5,本例 4×7 + 3×8 + 3×8 + 5×6 = 106,同式在示例产物上给出 1559 → 2565。字符集跟 mdEsc(L265)不同:它把单引号写成 &#39;,L1055 那四步不动单引号;只补这一层实体是 HTML 转义的活。

清空再刷新,样例又回来

mdStore(L1101)只写两键:原文进 md2html-input,选项进 md2html-opts(7 字段 = 6 勾选加 charset)。mdRestore(L1117)在 store 空、opts 坏 JSON、window.localStorage 为 null、点「清空」后 store 成空串这四种情况下都回落 mdSample();第四种最反直觉:空串让 raw && raw.length 走假分支,刷回来的是 655 码元示例。

常见问题

Q:少掉的字为什么不报警?

只有两个来源,L696 的围栏未闭合与 L766 的表格超列;L527 删控制字符、L343 删残留洞、第 21 条起的截断都不在提示路径上。

Q:怎么分清是归一化吃的还是回填吃的?

少的枚数落在码位 0 到 4、且 U+0005 之后不动,是 L527;整段尾巴不见或一次少 3 字符,是 L343 删的洞。

Q:长文会不会撞 64 轮?

实测不会,97778 码元、6000 枚洞仍是 1 轮;只有洞值套洞的人造链撞得到,撞到就锁在 128 码元。

📎 相关文章

拖到 4 和拖到 8,屏幕上都是 43890 个块:马赛克预览与导出的两套块网格、三档会崩与 6px 误触阈值 2026-11-10 位图进 SVG 只换了个盒子:232 字节容器常数、一个从不生效的「base64 嵌入」勾选,与四档缩放下纹丝不动的 142064 个字符 2026-11-10 解码是四步连做:1194 条语料在 unicode-convert 页上只有 798 条能原样回来 2026-11-09 保留集缩成 66 个字符:这一页三档编码在 128 个码位上的逐字符实测与 9 格编解码矩阵 2026-11-09 八个框的界面顺序和代码执行顺序是反的:文本清理页硬编码八步顺序、逐对换序真值表与统计码元口径实测 2026-11-09