插件地址:https://github.com/Achuan-2/siyuan-plugin-text-process
🤔 开发背景
从外部粘贴内容到思源笔记时,需要进行处理,希望这些处理能自动化,省去重复琐碎的操作,节省时间,有更多时间去思考创作。
例子:
比如很多 ai 默认生成的数学公式是 LaTeX 格式,粘贴到思源笔记不会渲染为公式
比如从 PPT、word 复制列表到思源,列表结构会丢失,需要自己重新写一个列表
比如从 pdf 复制的文字有多余的换行,总是要手动删除换行
……
✨ 插件功能
插件主要有两个功能
- 粘贴时自动处理
- 对块进行处理
粘贴时自动处理
在思源笔记的顶栏添加一个按钮,可以选择开启或关闭某个处理功能。
目前具有的功能:
-
LaTeX 行间数学公式(
\[...\]
)转为$$...$$
格式,行内数学公式(\(...\)
)转为$...$
格式 -
去除换行
- 用途:去除 pdf 复制的多余换行
-
去除空格
- 用途:去除 pdf 复制的多余空格
-
去除空行
- 用途:让粘贴内容全部都在一个块里
-
添加空行
- 用途:让粘贴内容一段一个块
-
富文本列表转换
- 用途:支持将 PPT、word 的列表样式粘贴思源笔记依然保留列表样式和层级结构,支持将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式)
注意:插件只影响外部纯文本粘贴和部分 html 粘贴,可能不影响 html 复制和思源笔记内部的富文本粘贴,如果发现不生效可以用右键菜单的纯文本粘贴来实现自动处理(虽然会丢格式,但暂时没法解决)。
对块进行处理
目前插件会给块菜单添加三个按钮
-
合并块(选中两个块及以上出现)
-
拆分块
-
仅复制一级列表内容(选中一个列表块时出现)
-
列表符号转 markdown 列表
- 将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式
❤️ 用爱发电
如果喜欢我的插件,欢迎给 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 string 如下
由于 word 的 html 没有结构,复制到思源笔记里会丢失无序列表层级,我希望把 style 中含有 level 的 p 节点替换为 html 列表(ul>li 结构),并根据 level 后面的数字判断列表层级,完美还原 word 里的列表层级,并不影响列表前后的内容,这样我就可以把 word 的内容无损粘贴到思源笔记里了
word 的列表层级怎么区分
根据属性 mso-list:
中 level 后面的数字
- 第一层级就是 level1
- 第二层级就是 level2
PPT 的列表层级怎么区分
根据列表的 margin-left 来区别
word 有序列表和无序列表的区别
之前的策略是把 word 的列表统统变为无序列表,但是还需要考虑有序列表的转换,word 的有序列表和无序列表也是一样的结构,只不过,即里的字符串大于 2,而无序列表都是 1,所以请根据这个思路进行改进,使得支持无序列表也支持有序列表转标准 html
- 有序列表:里数字,比如 1.、1)、a.,都为两位以上字符
- 无序列表:里是字符,都只有两个字符
PPT 有序列表和无序列表结构的区别
- 无序列表:
'span[style*="mso-special-format"]
中mso-special-format
的值为 bullet - 有序列表:
'span[style*="mso-special-format"]
中mso-special-format
的值包含 numbullet
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于