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