雜記 › 技術筆記 › 轉完悄悄少了 271 個字符:Markdown 轉 HTML 的洞協議、64 輪回填與 20 條提示上限

轉完悄悄少了 271 個字符:Markdown 轉 HTML 的洞協議、64 輪回填與 20 條提示上限

舊日誌貼進轉換頁少了 5 個碼元、複製走的 HTML 比原文少 271 字符:這頁自寫的洞協議、64 輪回填上限、正規化刪控制字符、20 條提示額度、12 個 pass 的換序真值表與兩個複製按鈕的 106 碼元差,逐筆量過。

少了 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