插件地址: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
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于