思源笔记插件丨文本处理插件:减少无谓的修改操作,改善复制粘贴体验

插件地址:https://github.com/Achuan-2/siyuan-plugin-text-process

🤔 开发背景

从外部粘贴内容到思源笔记时,需要进行处理,希望这些处理能自动化,省去重复琐碎的操作,节省时间,有更多时间去思考创作。

比如很多 ai 默认生成的数学公式是 LaTeX 格式,粘贴到思源笔记不会渲染为公式

比如从 PPT、word 复制列表到思源,列表结构会丢失,需要自己重新写一个列表

比如从 pdf 复制的文字有多余的换行和空格,总是要手动删除

比如从维基百科复制到文字总是有很多链接和上标
……

✨ 插件功能

插件主要有两个功能

  • 粘贴时自动处理
  • 对块进行处理

粘贴时自动处理

在思源笔记的顶栏添加一个按钮,可以选择开启或关闭某个处理功能。

目前具有的功能:

  • LaTeX 行间数学公式(\[...\])转为 $$...$$ 格式,行内数学公式(\(...\))转为 $...$ 格式

  • 列表样式保留

    • 用途:支持将 PPT、word 的列表样式粘贴思源笔记依然保留列表样式和层级结构,支持将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式)
  • 去除上标

    • 用途:去除维基百科等网站多余的上标
  • 去除链接

    • 用途:去除维基百科等网站多余的关键词链接
  • 去除换行

    • 用途:去除 pdf 复制的多余换行
  • 去除空格

    • 用途:去除 pdf 复制的多余空格
  • 去除空行

    • 用途:让粘贴内容全部都在一个块里
  • 添加空行

    • 用途:让粘贴内容一段一个块

注意:插件只影响外部纯文本粘贴和部分 html 粘贴,可能不影响 html 复制和思源笔记内部的富文本粘贴,如果发现不生效可以用右键菜单的纯文本粘贴来实现自动处理(虽然会丢格式,但暂时没法解决)。

对块进行处理

目前插件会给块菜单添加如下按钮

  • 合并块【选中两个块及以上出现】

  • 拆分块

  • 列表符号转 markdown 列表

    • 用途:将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式
  • 仅复制一级列表(带符号)【仅选中一个列表块时出现】

    • 用途:仅需要复制一级列表,分享给别人时使用
  • 复制到小红书

    • 用途:发小红书、发微信等纯文本场景,会将列表符号替换为指定符号
    • 备注:有序列表使用数字 emoji1️⃣2️⃣3️⃣,无序列表可以在设置里指定符号,默认为 ■○

  • 去除链接

  • 去除上标

❤️ 用爱发电

如果喜欢我的插件,欢迎给 GitHub 仓库点 star 和捐赠,这会激励我继续完善此插件和开发新插件。

捐赠者列表见:https://www.yuque.com/achuan-2

开发笔记

加载插件数据和保存插件数据

加载插件数据

// 加载插件数据 await this.loadData(STORAGE_NAME); console.log(this.data[STORAGE_NAME]);

保存插件数据

// 在前面设置配置默认值 this.data[STORAGE_NAME] = { LaTeXConversion: false, removeNewlines: false, removeSpaces: false, removeEmptyLines: false, addEmptyLines: false, pptList: false }/ // 保存数据 this.saveData(STORAGE_NAME, this.data[STORAGE_NAME]);

LaTeX 行内公式和行间公式

if (this.data[STORAGE_NAME].LaTeXConversion) { text = text.replace(/\\$$(.*?)\\$$/gs, '$$$$$1$$$$'); // LaTeX 行间数学公式块,允许中间有换行 text = text.replace(/\\$(.*?)\\$/g, '$$$1$$'); // LaTeX 行内数学公式 siyuan = siyuan.replace(/\\$$(.*?)\\$$/gs, '$$$$$1$$$$'); // LaTeX 行间数学公式块,允许中间有换行 siyuan = siyuan.replace(/\\$(.*?)\\$/g, '$$$1$$'); // LaTeX 行内数学公式 }

合并块

只有当前选中的块 detail.blockElements.length>1 时才出现,获取第一个 detail.blockElements 的 data-node-id,用 getBlockKramdown API 获取所有 detail.blockElements 的文本内容,去除每个块的 id 后,内容添加换行拼接在一起,用 updateBlock API 更新第一个块内容,之后需要删除其余的所有块

const firstBlockId = detail.blockElements[0].dataset.nodeId; let mergedContent = ''; // Gather content from all blocks using SQL for (const block of detail.blockElements) { const blockId = block.dataset.nodeId; const content = (await getBlockKramdown(blockId)).kramdown; // Split content into lines function cleanText(text) { let lines = text.split('\n'); lines.pop(); // Remove last line return lines.join('\n'); } let contentClean = cleanText(content); if (contentClean && contentClean.length > 0) { console.log(contentClean) mergedContent += contentClean + '\n'; } } // Update first block with merged content await updateBlock('markdown', mergedContent.trim(), firstBlockId); // Delete other blocks for (let i = 1; i < detail.blockElements.length; i++) { const blockId = detail.blockElements[i].dataset.nodeId; await deleteBlock(blockId); }

拆分块

获取每一个块 detail.blockElement 的 data-node-id 属性,根据 id 用 getBlockKramdown API 获取文本内容,过滤掉每个块的 id 之后,根据换行拆成一个个块

for (const block of detail.blockElements) { const blockId = block.dataset.nodeId; const content = (await getBlockKramdown(blockId)).kramdown; console.log(content) if (content && content.length > 0) { // Split content into lines function cleanText(text) { return text .split('\n') .map(line => line.replace(/^[\s]*\{:[^}]*id="[^"]*"[^}]*\}/g, '').trim()) .filter(line => line) // 移除空行 .join('\n'); } let contentClean = cleanText(content); const lines = contentClean.split('\n'); console.log(lines); if (lines.length > 1) { // Update original block with first line await updateBlock('markdown', lines[0], blockId); // Insert remaining lines as new blocks let previousId = blockId; for (let i = 1; i < lines.length; i++) { if (lines[i].trim()) { // Skip empty lines await refreshSql(); const newBlock = await insertBlock('markdown', lines[i], null, previousId,null) if (newBlock) { previousId = newBlock[0].doOperations[0].id; } } } } } }

纯文本列表转 markdown 列表

for (const block of detail.blockElements) { const blockId = block.dataset.nodeId; const content = (await getBlockKramdown(blockId)).kramdown; console.log(content) if (content && content.length > 0) { // Replace bullet points with markdown list syntax const updatedContent = content.replace(/(^|\n)[•○▪▫◆◇►▻❖✦✴✿❀⚪☐][\s]*/g, '$1- '); await updateBlock('markdown', updatedContent, blockId); } }

只复制列表第一层级

在 handleBlockMenu 的子菜单里在添加一个功能,如果选中的块只有一个,且是列表块(data.type="NodeList")只复制列表第一层级,先用 sql 获取 content,才通过正则,只提取第一层级的列表结构

word、ppt 列表粘贴保留样式

全部代码

private convertOfficeListToHtml(htmlString, type = 'auto') { // 自动检测文档类型 const isWord = htmlString.includes('mso-list:l0 level'); const isPpt = htmlString.includes('mso-special-format'); // 如果没有检测到任何列表结构,直接返回原始HTML if (!isWord && !isPpt) { return htmlString; } // 自动判断类型 if (type === 'auto') { if (isWord) type = 'word'; else if (isPpt) type = 'ppt'; } // 根据类型调用对应的处理函数 switch (type.toLowerCase()) { case 'word': return isWord ? this.convertWordListToHtml(htmlString) : htmlString; case 'ppt': return isPpt ? this.convertPPTListToHtml(htmlString) : htmlString; default: return htmlString; } } private convertWordListToHtml(htmlString) { const parser = new DOMParser(); const doc = parser.parseFromString(htmlString, 'text/html'); const elements = Array.from(doc.body.children); const result = []; let listElements = []; // 判断列表类型 function determineListType(element) { const listMarker = element.querySelector('span[style*="mso-list:Ignore"]'); if (!listMarker) return 'ul'; // 默认无序列表 // 获取列表标记的实际文本内容 const markerText = listMarker.textContent.trim(); // 检查是否为有序列表的常见标记 // const isOrderedList = /^[0-9]+[.)]|^[a-zA-Z][.)]/.test(markerText); const isOrderedList = markerText.length > 1; return isOrderedList ? 'ol' : 'ul'; } // 处理连续的列表组 function processListGroup(elements) { if (elements.length === 0) return ''; const fragment = document.createDocumentFragment(); let currentList = null; let previousLevel = 0; let listStack = []; // 用于跟踪列表类型 elements.forEach(p => { const style = p.getAttribute('style') || ''; const levelMatch = style.match(/level(\d+)/); const currentLevel = parseInt(levelMatch[1]); const listType = determineListType(p); if (!currentList) { // 创建第一个列表 currentList = document.createElement(listType); fragment.appendChild(currentList); listStack.push({ element: currentList, type: listType }); } else if (currentLevel > previousLevel) { // 创建新的嵌套列表 const newList = document.createElement(listType); currentList.lastElementChild.appendChild(newList); currentList = newList; listStack.push({ element: currentList, type: listType }); } else if (currentLevel < previousLevel) { // 返回上层列表 for (let i = 0; i < previousLevel - currentLevel; i++) { listStack.pop(); currentList = listStack[listStack.length - 1].element; } } else if (currentLevel === previousLevel && listType !== listStack[listStack.length - 1].type) { // 同级但列表类型不同,创建新列表 const newList = document.createElement(listType); if (listStack.length > 1) { // 如果在嵌套中,添加到父列表项 currentList.parentElement.parentElement.appendChild(newList); } else { // 顶层列表,直接添加到片段 fragment.appendChild(newList); } currentList = newList; listStack[listStack.length - 1] = { element: currentList, type: listType }; } // 创建列表项 const li = document.createElement('li'); const pClone = p.cloneNode(true); // 删除Word特有的列表标记 pClone.querySelectorAll('span[style*="mso-list:Ignore"]').forEach(span => { span.remove(); }); li.innerHTML = pClone.innerHTML; currentList.appendChild(li); previousLevel = currentLevel; }); const wrapper = document.createElement('div'); wrapper.appendChild(fragment); return wrapper.innerHTML; } // 遍历所有元素 elements.forEach((element) => { const style = element.getAttribute('style') || ''; const isListItem = style.includes('level') && style.includes('mso-list:'); if (isListItem) { listElements.push(element); } else { if (listElements.length > 0) { result.push(processListGroup(listElements)); listElements = []; } result.push(element.outerHTML); } }); // 处理最后一组列表元素 if (listElements.length > 0) { result.push(processListGroup(listElements)); } return result.join('\n'); } private convertPPTListToHtml(htmlString) { // 创建一个DOM解析器 const parser = new DOMParser(); const doc = parser.parseFromString(htmlString, 'text/html'); // 找到所有元素 const elements = Array.from(doc.body.children); const result = []; let listElements = []; // 判断列表类型 function determineListType(element) { const bulletSpan = element.querySelector('span[style*="mso-special-format"]'); if (!bulletSpan) return 'ul'; // 默认无序列表 const style = bulletSpan.getAttribute('style') || ''; // PPT中有序列表通常包含"numbullet" const isOrderedList = style.includes('numbullet'); console.log(isOrderedList); return isOrderedList ? 'ol' : 'ul'; } // 处理连续的列表组 function processListGroup(elements) { if (elements.length === 0) return ''; const fragment = document.createDocumentFragment(); let currentList = null; let previousMargin = 0; let listStack = []; // 用于跟踪列表类型 elements.forEach(div => { const style = div.getAttribute('style') || ''; const marginMatch = style.match(/margin-left:([.\d]+)in/); const currentMargin = parseFloat(marginMatch[1]); const listType = determineListType(div); if (!currentList) { // 创建第一个列表 currentList = document.createElement(listType); fragment.appendChild(currentList); listStack.push({ element: currentList, type: listType, margin: currentMargin }); } else if (currentMargin > previousMargin) { // 创建新的嵌套列表 const newList = document.createElement(listType); currentList.lastElementChild.appendChild(newList); currentList = newList; listStack.push({ element: currentList, type: listType, margin: currentMargin }); } else if (currentMargin < previousMargin) { // 返回上层列表 while (listStack.length > 0 && listStack[listStack.length - 1].margin > currentMargin) { listStack.pop(); } currentList = listStack[listStack.length - 1].element; } else if (currentMargin === previousMargin && listType !== listStack[listStack.length - 1].type) { // 同级但列表类型不同,创建新列表 const newList = document.createElement(listType); if (listStack.length > 1) { // 如果在嵌套中,添加到父列表项 currentList.parentElement.parentElement.appendChild(newList); } else { // 顶层列表,直接添加到片段 fragment.appendChild(newList); } currentList = newList; listStack[listStack.length - 1] = { element: currentList, type: listType, margin: currentMargin }; } // 创建列表项 const li = document.createElement('li'); const divClone = div.cloneNode(true); // 删除PPT特有的列表标记 divClone.querySelectorAll('span[style*="mso-special-format"]').forEach(span => { span.remove(); }); li.innerHTML = divClone.innerHTML; currentList.appendChild(li); previousMargin = currentMargin; }); const wrapper = document.createElement('div'); wrapper.appendChild(fragment); return wrapper.innerHTML; } // 遍历所有元素 elements.forEach((element) => { const style = element.getAttribute('style') || ''; const hasBullet = element.querySelector('span[style*="mso-special-format"]'); if (hasBullet && style.includes('margin-left')) { // 收集列表元素 listElements.push(element); } else { // 如果有待处理的列表元素,先处理它们 if (listElements.length > 0) { result.push(processListGroup(listElements)); listElements = []; } // 保持非列表元素不变 result.push(element.outerHTML); } }); // 处理最后一组列表元素 if (listElements.length > 0) { result.push(processListGroup(listElements)); } return result.join('\n'); }

word、ppt 列表转换

由于 word 的 html 没有结构,复制到思源笔记里会丢失无序列表层级,我希望把 style 中含有 level 的 p 节点替换为 html 列表(ul>li 结构),并根据 level 后面的数字判断列表层级,完美还原 word 里的列表层级,并不影响列表前后的内容,这样我就可以把 word 的内容无损粘贴到思源笔记里了

word 的列表层级怎么区分

根据属性 mso-list: 中 level 后面的数字

  • 第一层级就是 level1
  • 第二层级就是 level2

PPT 的列表层级怎么区分

根据列表的 margin-left 来区别

word 有序列表和无序列表的区别

  • 有序列表:里数字,比如 1.、1)、a.,都为两位以上字符
  • 无序列表:里是字符,都只有两个字符

PPT 有序列表和无序列表结构的区别

  • 无序列表:'span[style*="mso-special-format"]mso-special-format 的值为 bullet
  • 有序列表:'span[style*="mso-special-format"]mso-special-format 的值包含 numbullet
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    24520 引用 • 100349 回帖
4 操作
Achuan-2 在 2024-12-16 08:59:52 更新了该帖
Achuan-2 在 2024-12-15 19:54:36 更新了该帖
Achuan-2 在 2024-12-15 19:54:07 更新了该帖
Achuan-2 在 2024-12-15 19:53:09 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • tongzi 1 赞同

    这个思路非常好!就像文本工具里的功能。

    把一些粘贴操作需要常用的后期处理简化,像红一样批处理,很棒

  • Mayrain

    佬!我终于等到了我想要的 😭 😭 😭

  • LazyXP via Android

    膜拜大佬

  • Achuan-2

    改进了下列表复制纯文本的效果

    PixPin20241215105315.png

    可以在设置里设置用什么符号,有序列表就不提供设置,默认用 1️⃣2️⃣3️⃣

    PixPin20241215105328.png

  • Achuan-2

    @participants

    粘贴时自动处理新增

    • 去除上标
    • 去除链接

    块菜单新增:复制到小红书

    PixPin20241215175020.png

    ⭐ 标题 2

    💡 标题 3

    ■ 内容
    ○ 内容
    ■ 内容
    ○ 内容

    ⭐ 标题 2

    💡 标题 3

    ■ 内容
    ○ 内容
    ■ 内容
    ○ 内容

  • MasterYS

    @Achuan-2

    川哥,我有个需求啊,就是能否将选中的段落块中被标记的部分提取出来后,复制到文档的末端,按顺序排列。这样就可以结合你的高亮挖空插件生成试卷了。复制出来在文档末端的部分就相当于答案。我让 AI 写了段代码,可以提取整个文档中被标记的部分,但是怎么只提取选中块,还有在文档末端排序,就怎么也不成功,而且我不会开发插件。能不能帮忙实现一下。

    // ... 其他代码保持不变 ... class U extends $.Plugin { constructor() { super(...arguments); x(this, "isMobile"); x(this, "settingUtils"); x(this, "topBarElement"); } async onload() { // ... 其他初始化代码保持不变 ... // 新增功能:复制被标记的文本到文档末端 this.eventBus.on("click-blockicon", this.handleBlockMenu.bind(this)); } // ... 其他方法保持不变 ... async handleBlockMenu({ detail: t }) { let n = t.menu; const s = []; // 新增功能:复制被标记的文本到文档末端 s.push({ label: "复制标记文本到文档末端", click: async () => { try { // 获取当前文档的 ID const currentDocId = t.protyle.block.rootID; // 获取当前文档中被标记的文本 const markedText = await this.getMarkedText(currentDocId); if (markedText) { // 将标记文本插入到文档末端 await this.insertTextToEnd(currentDocId, markedText); $.showMessage("标记文本已复制到文档末端"); } else { $.showMessage("未找到标记文本"); } } catch (error) { console.error("复制标记文本失败:", error); $.showMessage("复制标记文本失败"); } }, }); // ... 其他菜单项保持不变 ... n.addItem({ icon: "iconPaste", label: "文本处理", submenu: s }); } /** * 获取当前文档中被标记的文本 * @param {string} docId 当前文档的 ID * @returns {Promise<string>} 被标记的文本 */ async getMarkedText(docId) { const sql = `SELECT * FROM blocks WHERE root_id = '${docId}' AND markdown LIKE '%==%'`; // 查找包含标记(==)的块 const result = await $.fetchSyncPost("/api/query/sql", { stmt: sql }); if (result.code === 0 && result.data.length > 0) { // 提取所有标记文本 const markedTexts = result.data.map((block) => { const markdown = block.markdown; const matches = markdown.match(/==(.*?)==/g); // 匹配 ==标记文本== return matches ? matches.join("\n").replace(/==/g, "") : ""; // 去除标记符号 }); return markedTexts.join("\n"); } return null; } /** * 将文本插入到文档的末端 * @param {string} docId 当前文档的 ID * @param {string} text 要插入的文本 */ async insertTextToEnd(docId, text) { // 获取文档的最后一个块的 ID const sql = `SELECT * FROM blocks WHERE root_id = '${docId}' ORDER BY created DESC LIMIT 1`; const result = await $.fetchSyncPost("/api/query/sql", { stmt: sql }); if (result.code === 0 && result.data.length > 0) { const lastBlockId = result.data[0].id; // 在最后一个块之后插入新块 await D("markdown", text, null, lastBlockId, docId); } } } module.exports = U;
    1 回复
  • Achuan-2 1 评论

    打印两份 pdf,一份挖空做测试,一份不挖空当答案就好了
    我目前没空帮忙

    好的好的
    MasterYS
  • MasterYS

    川哥有新问题,合并的块,被拆分的时候,按 F5 刷新,多有多余的块出现。

    我是在保存的时候发现,重新打开内容不对才发现的这个 bug

  • 如果能支持 word 里面的颜色富文本 无损粘贴到思源就好了,比如一连串的不同字符颜色不一样,能复制到思源且保留颜色就好了。
    有些文档在 word 里面做了很多标注,然后复制到思源这些颜色标注都没了。很难搞

    1 回复
  • Achuan-2 1

    其实可以支持的,特殊解析 word 复制的内容即可

    word 列表我就是这样搞的

请输入回帖内容 ...