換深度解析:preserveNestedTables 機(jī)制與 preserve_nested_tables 測(cè)試夾具)
Joplin 嵌套表格 HTML→Markdown 保真轉(zhuǎn)換深度解析preserveNestedTables 機(jī)制與 preserve_nested_tables 測(cè)試夾具【免費(fèi)下載鏈接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.項(xiàng)目地址: https://gitcode.com/GitHub_Trending/jo/joplin本文以packages/app-cli/tests/html_to_md/preserve_nested_tables.md含同名.html輸入這一對(duì)測(cè)試夾具為切入點(diǎn)逐層拆解 Joplin 中「表格里再套表格nested tables」的 HTML 內(nèi)容在轉(zhuǎn)換為 Markdown 時(shí)為何能原樣保真其開(kāi)關(guān)preserveNestedTables的完整實(shí)現(xiàn)鏈路、默認(rèn)行為差異以及桌面端/移動(dòng)端富文本編輯器在生產(chǎn)代碼中的真實(shí)調(diào)用場(chǎng)景。讀完你將理解 Joplin HTML→Markdown 轉(zhuǎn)換管線的決策模型并能舉一反三讀懂同目錄下其他幾十組 HTML/Markdown 成對(duì)夾具的用法。先認(rèn)識(shí)這份「文檔」它是一組 HTML→Markdown 轉(zhuǎn)換的可執(zhí)行契約packages/app-cli/tests/html_to_md/preserve_nested_tables.md本身并不是一篇說(shuō)明文字而是一個(gè)測(cè)試夾具test fixture中的期望輸出文件。它與同目錄下的packages/app-cli/tests/html_to_md/preserve_nested_tables.html成對(duì)存在.html是輸入.md是斷言值。測(cè)試代碼會(huì)把 HTML 輸入經(jīng) Joplin 的轉(zhuǎn)換器處理后得到的結(jié)果與這份.md期望值做逐字節(jié)比對(duì)從而把「嵌套表格必須被完整保留」固化為一條可回歸驗(yàn)證的轉(zhuǎn)換契約。該.md文件的完整內(nèi)容只有一行div classjoplin-table-wrappertabletbodytrtdLeft side of the main table/tdtdbNested Table/btabletbodytrtdnested table C1/tdtdnested table C2/td/trtrtdnested table/tdtdnested table/td/tr/tbody/table/td/tr/tbody/table/div而對(duì)應(yīng)的輸入 preserve_nested_tables.html 結(jié)構(gòu)為一個(gè)外層table其中第二個(gè)td單元格內(nèi)依次包含文本加粗標(biāo)簽bNested Table/b與一個(gè) 2 行 2 列的嵌套table。換句話說(shuō)這是一份典型的多層表格嵌套輸入。對(duì)比輸入與期望輸出可以立刻讀出三條關(guān)鍵契約整個(gè)外表格沒(méi)有被轉(zhuǎn)成 GFM 表格語(yǔ)法而是以原始 HTMLnode.outerHTML的形式整體保留內(nèi)層嵌套表格、單元格內(nèi)的b加粗、文本全部原樣進(jìn)入輸出沒(méi)有被扁平化或降級(jí)輸出 HTML 外層被包上了div classjoplin-table-wrapper容器這是 Joplin 為寬表格水平滾動(dòng)而約定的專用包裹 div。這套測(cè)試的驅(qū)動(dòng)方式按文件名前綴自動(dòng)裝配轉(zhuǎn)換選項(xiàng)要理解該夾具為何“期望保留嵌套表格”必須看測(cè)試宿主 packages/app-cli/tests/HtmlToMd.ts。它的核心用例should convert from Html to Markdown會(huì)遍歷html_to_md目錄下所有.html文件并約定同名.md為期望輸出見(jiàn) HtmlToMd.ts 測(cè)試循環(huán)。關(guān)鍵在于不同夾具需要不同的轉(zhuǎn)換選項(xiàng)測(cè)試通過(guò)文件名前綴來(lái)裝配ParseOptionsif (htmlFilename.indexOf(preserve_nested_tables) 0) { htmlToMdOptions.preserveNestedTables true; }這一段HtmlToMd.ts意味著凡是文件名為preserve_nested_tables開(kāi)頭的夾具都會(huì)以preserveNestedTables: true調(diào)用轉(zhuǎn)換器。同目錄下其它前綴也有各自的裝配規(guī)則例如image_preserve_size前綴啟用preserveImageTagsWithSize、text_color前綴啟用preserveColorStyles、table_with*/table_default*前綴啟用preserveTableStyles。把“何種輸入需要何種行為”顯式編碼進(jìn)文件名是這個(gè)夾具體系保持幾十組用例仍高度可讀的設(shè)計(jì)核心。最終斷言發(fā)生在同一文件后半段若實(shí)際輸出與期望.md不一致測(cè)試會(huì)打印Got:與Expected:的逐行對(duì)比每行都加引號(hào)以便觀察空白差異再判定失敗。因此這份preserve_nested_tables.md的職責(zé)就是當(dāng)某次重構(gòu)試圖把嵌套表格扁平化或錯(cuò)誤地包上第二層 wrapper 時(shí)測(cè)試立即紅燈報(bào)警。對(duì)比實(shí)驗(yàn)關(guān)閉開(kāi)關(guān)時(shí)嵌套表格走的是另一條路preserveNestedTables并不是 Joplin 轉(zhuǎn)換器的全局默認(rèn)值。與其形成鮮明對(duì)照的是同目錄下的另一組夾具 table_within_table.html 與 table_within_table.md。這組輸入同樣是“表格里嵌套表格”但因?yàn)槲募詔able_with開(kāi)頭只裝配了preserveTableStyles: true而未裝配preserveNestedTables其期望輸出截然不同F(xiàn)irst column, and an inner table: | | | | --- | --- | | One | Two | | One | Two | Second column輸入文件頂部甚至用 HTML 注釋寫(xiě)明了這組夾具的設(shè)計(jì)意圖!-- The inner table is rendered but not the outer one. Basically if any table contains another table, it is rendered as plain text --也就是說(shuō)默認(rèn)無(wú)preserveNestedTables行為是外層表格被“跳過(guò)”其單元格內(nèi)容退化成普通段落文本只有內(nèi)層表格被轉(zhuǎn)換成標(biāo)準(zhǔn) Markdown 表格語(yǔ)法。這正對(duì)應(yīng) Web Clipper 抓取網(wǎng)頁(yè)時(shí)的場(chǎng)景——很多老網(wǎng)頁(yè)用嵌套table做頁(yè)面布局此時(shí)保留外層的“布局表”沒(méi)有意義反而應(yīng)該剝掉外層、只留下承載真實(shí)數(shù)據(jù)的內(nèi)部表格。而preserve_nested_tables這組夾具驗(yàn)證的是相反方向當(dāng)用戶在 Joplin 富文本編輯器里主動(dòng)插入的“數(shù)據(jù)型”嵌套表格被導(dǎo)出為 Markdown 時(shí)必須逐字節(jié)保真——因?yàn)橐坏┙导?jí)成純文本或丟失嵌套層級(jí)切回 Markdown 編輯器再渲染用戶精心排版的嵌套結(jié)構(gòu)就永久損壞了。兩條路徑并存正是 Joplin 針對(duì)「布局表 vs 內(nèi)容表」兩種語(yǔ)義給出的差異化處理。源碼級(jí)拆解preserveNestedTables 在 turndown 插件里到底做了什么Joplin 的 HTML→Markdown 核心位于 packages/lib/HtmlToMd.ts。HtmlToMd.parse()在內(nèi)部構(gòu)造 TurndownService并把各選項(xiàng)映射進(jìn) turndown 配置見(jiàn) HtmlToMd.ts#L22-L44preserveNestedTables: !!options.preserveNestedTables,隨后掛載joplin/turndown-plugin-gfm提供的gfm插件HtmlToMd.ts#L65。真正決定“表是否保留為 HTML”的分支邏輯全部集中在 packages/turndown-plugin-gfm/src/tables.js這條決策鏈可以概括為三步。第一步判定“這個(gè)表應(yīng)保持為 HTML 嗎”——tableShouldBeHtml核心函數(shù)tableShouldBeHtml(tableNode, options)tables.js#L300-L324維護(hù)一份possibleTags黑名單UL、OL、H1–H6、HR、BLOCKQUOTE并遞歸掃描該表內(nèi)是否含有這些元素或code一旦命中說(shuō)明該表的內(nèi)容無(wú)法用 GFM 表格單元格表達(dá)例如單元格里塞了標(biāo)題、列表、引用、水平線于是判定整表“應(yīng)保持為 HTML”。而當(dāng)options.preserveNestedTables為真時(shí)代碼會(huì)把TABLE追加進(jìn)possibleTagsif (options.preserveNestedTables) possibleTags.push(TABLE);于是“包含另一個(gè)table的表”同樣命中判定走保留 HTML 的分支——這就是整個(gè)機(jī)制的最小開(kāi)關(guān)。此外若preserveTableStyles為真且表攜帶用戶自定義樣式tableHasCustomStyles會(huì)逐一檢查表格/行/單元格的背景色、邊框、內(nèi)邊距、bgcolor等見(jiàn) tables.js#L213-L298同樣觸發(fā)保留。第二步用keep把整表按原始 HTML 輸出當(dāng)判定成立后插件向 turndown 注冊(cè)的keep規(guī)則生效tables.js#L386-L389TABLE節(jié)點(diǎn)不再參與任何內(nèi)容遞歸轉(zhuǎn)換其node.outerHTML被整體當(dāng)作輸出。這也解釋了為何夾具期望輸出中bNested Table/b、內(nèi)層table、所有單元格文本都原封不動(dòng)——它們?nèi)刻幱诒?keep 的外層表內(nèi)部。第三步包上.joplin-table-wrapper并在重復(fù)包裹時(shí)去重rules.table的replacementtables.js#L75-L129負(fù)責(zé)產(chǎn)出最終字符串。當(dāng)判定需要保留為 HTML 時(shí)它默認(rèn)返回return \n\ndiv classjoplin-table-wrapper${html}/div\n\n;同時(shí)有一段非常精細(xì)的去重邏輯若該表最近的DIV祖先已經(jīng)帶有joplin-table-wrapperclass就不再二次包裹直接返回原 HTMLtables.js#L97-L101。這個(gè)判斷對(duì)往返轉(zhuǎn)換的冪等性至關(guān)重要Markdown→HTML 渲染時(shí)會(huì)為每個(gè) Markdown 表格補(bǔ)上 wrapper div見(jiàn)下文若用戶隨后把這個(gè) HTML 再轉(zhuǎn)回 Markdown第二次轉(zhuǎn)換不能疊加出wrapper 套 wrapper的畸形結(jié)構(gòu)。代碼注釋也明確把 preserve_nested_tables.html 列為該邏輯的回歸測(cè)試用例之一tables.js#L89。與之相對(duì)走到 Markdown 分支判定不需要保留時(shí)函數(shù)會(huì)先檢查tableShouldBeSkipped(node)tables.js#L338-L344凡是nodeContainsTable即“表內(nèi)含表”的外層表直接返回content不產(chǎn)生任何表格語(yǔ)法——table_within_table夾具里外層表的文本因此被攤平成普通段落僅內(nèi)層表被繼續(xù)處理成 GFM 表格。若表內(nèi)無(wú)嵌套且需要輸出 Markdown 表格則自動(dòng)補(bǔ)空表頭分隔行、把單元格里的換行轉(zhuǎn)成br、并對(duì)|轉(zhuǎn)義確保產(chǎn)物是合法的 GFM 表格tables.js#L102-L127 與 tables.js#L178-L187。此外值得注意turndown 核心的默認(rèn)選項(xiàng)里preserveNestedTables: false見(jiàn) packages/turndown/src/turndown.js#L55因此“默認(rèn)扁平化外層布局表”是引擎級(jí)缺省行為HtmlToMd只有顯式收到true才會(huì)切換為保真模式。生產(chǎn)代碼中誰(shuí)在開(kāi)啟 preserveNestedTables既然默認(rèn)是關(guān)閉的那么preserve_nested_tables夾具對(duì)應(yīng)的真實(shí)場(chǎng)景必然有顯式調(diào)用方。搜索倉(cāng)庫(kù)可以發(fā)現(xiàn)兩處富文本編輯器的 HTML→Markdown 導(dǎo)出都固定開(kāi)啟了該選項(xiàng)桌面端packages/app-desktop/gui/NoteEditor/utils/index.ts 中preserveNestedTables: true。這里把 TinyMCE 富文本編輯器當(dāng)前內(nèi)容序列化成的 HTML 交給HtmlToMd轉(zhuǎn)成 Markdown——典型觸發(fā)點(diǎn)是用戶在富文本與 Markdown 編輯模式間切換、或保存筆記時(shí)把富文本內(nèi)容落盤(pán)為 Markdown 筆記體。移動(dòng)端packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts 同樣是preserveNestedTables: true職責(zé)與桌面端一致。正是這兩處生產(chǎn)調(diào)用讓preserve_nested_tables夾具變得不可或缺TinyMCE 允許用戶在單元格內(nèi)再次插入表格屬于用戶在編輯器中主動(dòng)構(gòu)建的內(nèi)容結(jié)構(gòu)區(qū)別于網(wǎng)頁(yè)抓取里的“布局表”。若不開(kāi)啟該選項(xiàng)任何嵌套表格筆記在模式切換或保存時(shí)會(huì)不可逆地退化為純文本散落的內(nèi)表屬于數(shù)據(jù)損壞級(jí)別的事故。也正因如此tables.js的注釋強(qiáng)調(diào)Web Clipper 場(chǎng)景走“剝外層留內(nèi)表”邏輯而富文本編輯器場(chǎng)景“永遠(yuǎn)想保留嵌套表”。反向渲染.joplin-table-wrapper 在 Markdown→HTML 一側(cè)的閉環(huán)保留成 HTML 只是單向過(guò)程的一半。當(dāng)這份 Markdown內(nèi)含div classjoplin-table-wrapper包裹的原始表格 HTML被 Joplin 渲染器重新渲染成筆記視圖時(shí)wrapper 還有配套的樣式與規(guī)則支撐樣式定義渲染用核心樣式表 packages/renderer/noteStyle.ts 中為.joplin-table-wrapper聲明了overflow-x: auto; overflow-y: hidden;使寬表格在受限寬度內(nèi)可橫向滾動(dòng)而不撐破頁(yè)面。渲染規(guī)則反過(guò)來(lái)對(duì)于純 Markdown 語(yǔ)法的表格markdown-it 渲染規(guī)則插件 packages/renderer/MdToHtml/rules/tableHorizontallyScrollable.ts 會(huì)在table_open/table_close處為每個(gè)普通 Markdown 表格補(bǔ)包同樣的div classjoplin-table-wrapper見(jiàn) 該文件 L12-L14 的注釋。至此形成完整閉環(huán)富文本里嵌著表格的 HTML →HtmlToMd preserveNestedTables→ 原樣 HTML 存入 Markdown 筆記 →markdown-it 渲染→ 重新渲染為帶 wrapper 的可橫向滾動(dòng)表格。wrapper class 成為 HTML/Markdown 兩條轉(zhuǎn)換路徑共享的同一約定而 preserve_nested_tables.md 恰好是這個(gè)約定在“保真轉(zhuǎn)換”方向上被固化的錨點(diǎn)。兩個(gè)可觀察的細(xì)節(jié)對(duì)照夾具輸入與期望輸出還能印證兩點(diǎn)實(shí)現(xiàn)事實(shí)保留下來(lái)的 HTML 是經(jīng)過(guò) DOM 歸一化后的序列化結(jié)果輸入 preserve_nested_tables.html 中外層table直接跟tr未寫(xiě)tbody而期望輸出里出現(xiàn)了tbody內(nèi)層嵌套表同樣被補(bǔ)上。這說(shuō)明轉(zhuǎn)換前 HTML 已被解析為 DOM 樹(shù)outerHTML反映的是規(guī)范化后的 DOM 結(jié)構(gòu)。若哪天期望輸出里出現(xiàn)thead/tbody的增刪差異通常是 DOM 解析層而非表格規(guī)則的變化。輸出是單行緊湊 HTMLkeep 路徑不經(jīng)過(guò) Markdown 的行結(jié)構(gòu)重組因此期望.md中整段內(nèi)容擠在一行測(cè)試比對(duì)時(shí)對(duì)換行與空格極度敏感——Got:/Expected:的逐行加引號(hào)打印正是為了暴露這類空白差異。如何親手運(yùn)行這條契約驗(yàn)證該夾具的驗(yàn)證入口是測(cè)試宿主文件 packages/app-cli/tests/HtmlToMd.ts。倉(cāng)庫(kù)采用 pnpm/yarn workspace 多包結(jié)構(gòu)packages/app-cli自帶 jest 配置packages/app-cli/jest.config.js在packages/app-cli目錄下執(zhí)行npx jest HtmlToMd即可運(yùn)行全部 HTML→Markdown 用例包括本夾具與table_within_table對(duì)比組。若修改了 tables.js 或 HtmlToMd.ts 中與表格相關(guān)的邏輯這條命令會(huì)立即驗(yàn)證嵌套表格保真契約是否仍然成立。小結(jié)以preserve_nested_tables.md這個(gè)單行文件為索引可以串起 Joplin 表格轉(zhuǎn)換的全貌HtmlToMdpackages/lib/HtmlToMd.ts把preserveNestedTables透?jìng)鹘o turndownturndown 的 GFM 表格插件tables.js在“表內(nèi)含表”時(shí)把整表 keep 為原始 HTML 并包裹.joplin-table-wrapper桌面端與移動(dòng)端富文本編輯器桌面 utils/index.ts、移動(dòng)端 convertHtmlToMarkdown.ts在生產(chǎn)中固定開(kāi)啟該選項(xiàng)以保護(hù)用戶數(shù)據(jù)渲染側(cè)再由 noteStyle 的 CSS 與 markdown-it 規(guī)則完成視覺(jué)閉環(huán)。理解這條鏈路后再去看html_to_md目錄下table_with_colspan、table_with_code_*、table_with_blockquote等成對(duì)夾具你會(huì)發(fā)現(xiàn)它們共享同一套“判定—keep—包裹”骨架區(qū)別只在于觸發(fā)的possibleTags與樣式判定不同罷了?!久赓M(fèi)下載鏈接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.項(xiàng)目地址: https://gitcode.com/GitHub_Trending/jo/joplin創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考