Xournal++ 插件开发终极指南:用 Lua 让手写笔记软件长出“外挂“能力

📅 2026/8/18 0:45:49
Xournal++ 插件开发终极指南:用 Lua 让手写笔记软件长出“外挂“能力
Xournal 插件开发终极指南用 Lua 让手写笔记软件长出外挂能力【免费下载链接】xournalppXournal is a handwriting notetaking software with PDF annotation support. Written in C with GTK3, supporting Linux (e.g. Ubuntu, Debian, Arch, SUSE), macOS and Windows 10. Supports pen input from devices such as Wacom Tablets.项目地址: https://gitcode.com/gh_mirrors/xo/xournalpp你有没有过这样的时刻在 Xournal 里批注 PDF 讲义画完一张就要手动切一次笔颜色每节课结束要把十几页笔记挨个导出成 PNG明明只是想在每页顶部加个页码却得重复几十次同样的操作。这些重复劳动正是 Xournal 插件系统要解决的问题——它内置了一个完整的 Lua 脚本引擎让你可以用几十行代码给这款开源手写笔记软件加上任何你想要的自动化能力。这篇文章会带你从零写出一套属于自己的插件让你真正理解插件的入口在哪里、app 对象能干什么、怎么把想法变成菜单项这一整条链路。你缺的从来不是功能而是把重复操作交给脚本的意识先说结论Xournal 并不是一个功能贫瘠的软件恰恰相反它的绝大多数日常操作都能通过动作Action驱动而插件系统就是把这些动作重新编排的遥控器。你不需要改一行 C 代码不需要重新编译只需要写一个纯文本的 Lua 脚本放进指定目录就能在菜单里多出一排自己的命令。最直接的证据就在项目自带的plugins/目录里。克隆源码后你就能看到官方为你准备的 11 个开箱即用的示例插件plugins/ ├── ColorCycle/ # 一键循环切换画笔颜色 ├── Export/ # 一键导出 PDF / SVG / PNG ├── LayerActions/ # 批量克隆、隐藏、新建图层 ├── FitToContent/ # 让页面尺寸贴合图层或选区内容 ├── ToggleGrid/ # 一键切换网格背景与对齐 └── ... # 其余还有 BeamerPresentation、HighlightPosition 等这些插件是官方最用心的教学素材。比如你想体会插件到底能省多少事先打开插件管理器启用Export以后按ShiftAltP当前文档就会被直接导出成 PDF连文件名都自动从.xopp派生出来。这就是插件的第一个价值——它把软件里已有的能力重新包装成你的快捷键。第一个最小插件10 分钟跑通脚本→菜单→动作闭环任何插件都只有两个文件这是整个插件体系的最小骨架plugin.ini元信息与入口声明main.lua真正的脚本逻辑。先别急着写业务逻辑我们用一个弹出对话框的最小例子跑通全流程。在你的用户配置目录下建一个插件文件夹Linux 下通常是~/.config/xournalpp/plugins/命名为HelloXournalpp然后创建plugin.ini[about] authorYour Name descriptionA minimal plugin to say hello versionxournalpp [default] enabledfalse [plugin] mainfilemain.lua注意三个细节versionxournalpp是个特殊占位符插件管理器会把它自动替换成当前 Xournal 的版本号省得你每次升级都手改enabledfalse意味着默认关闭需要在插件管理器中手动勾选这是安全设计避免脚本一装上就自动跑mainfile就是入口脚本名。接着创建main.lua-- 启动时被调用负责注册 UI 入口 function initUi() app.registerUi({ [menu] Hello Xournal, [callback] sayHello, [accelerator] ControlShifth }) end -- 菜单被点击后执行的函数 function sayHello() app.openDialog(插件跑通了, {太好了}, ) end在插件管理器中启用它重启 Xournal或重开插件你就会在插件菜单里看到Hello Xournal点击即弹出对话框。到这里你已经掌握了插件的全部连接件initUi程序启动时被调用的钩子只能在这里注册 UIapp.registerUi把 Lua 函数绑定到菜单项callback是函数名字符串accelerator可绑快捷键app.openDialog弹出自定义按钮的对话框第三个参数同样是个回调函数名。这一步做对了剩下的就是往这个框架里填你真正想要的逻辑。拆开引擎盖插件系统内部到底发生了什么知其然也要知其所以然这一步帮你建立对插件机制的完整心智模型之后排查问题会顺手很多。先看入口处src/core/plugin/Plugin.cpp。每个插件在启用后都会创建一个独立的 Lua 虚拟机luaL_newstate()开一个新的lua_State然后做三件事——打开标准库、把 C 侧写好的app表注册进去registerXournalppLibs、把插件自己的目录塞进package.pathaddPluginToLuaPath最后用lua_pcall运行你的脚本。也就是说你的main.lua里那个魔法般的全局app对象其实是一个从 C 侧暴露出来的绑定表底层是一个名为luaopen_app的原生模块。再往下看有两点值得你记住。其一loadScript里明确检查了mainfile中不能出现..这是防止插件越权读取任意路径的安全护栏。其二所有 Lua 函数的调用都包在callFunction里一旦执行出错错误信息会通过XojMsgBox::showPluginMessage直接弹给你——所以写插件时遇到弹窗报错不用慌那是 Lua 的报错信息看字符串就能定位问题。至于app对象具体能做什么不必去啃 C直接读项目里的接口定义文件plugins/luapi_application.def.lua这是一个 1200 多行的带注释的 Lua 定义文件每个函数都有参数说明和示例。这是全项目最被低估的文档把它当成你的 API 手册。真正的利器app 对象的四类核心能力看完了机制我们看武器库。app对象的能力可以分成四类掌握了这四类你就掌握了插件开发的 80%。第一类读改写文档内容这是最硬核的一类直接操作笔记数据app.getStrokes(selection)/(layer)/(page)/(all)取回笔画数据每个笔画含x、y、pressure坐标数组以及tool、width、color等属性还带ref引用app.addStrokes批量写回笔画支持按笔画指定样式还支持allowUndoRedoAction决定这批操作是合并成一个撤销步骤还是逐个记录app.addTexts/app.getTexts程序化插入和读取文本框app.addSplines用三次样条曲线绘制更平滑的笔迹。注意app.addStrokes只支持pen和highlighter两种工具传入的X、Y、pressure三张表长度必须一致否则会直接抛错。这是官方FitToContent插件赖以工作的底层能力它读取某一层的全部笔画计算包围盒平移坐标再用app.addStrokes写回新图层最后app.setPageSize调整页面尺寸——一整套页面自适应内容就完成了。第二类驱动内置动作Xournal 几乎每个 UI 操作背后都是一个动作插件可以通过三个函数直接驱动它们app.activateAction(action-name)执行动作比如app.activateAction(layer-new-above-current)新建图层app.changeActionState(action-name, value)改变状态类动作比如app.changeActionState(tool-color, 0xff0000)把当前颜色设为红色app.getActionState(action-name)读取当前状态。这里有个容易踩的坑changeActionState和activateAction的动作名是字符串而且很多状态来自 C 枚举。别硬记app.C这个常量表就是为这个准备的例如切换文字工具应写成app.changeActionState(select-tool, app.C.Tool_text)而不是塞一个魔法数字。官方ColorCycle插件里的app.changeToolColor({[color] 0xff0000, [selection] true})则是另一个更专用的快捷方式——直接改当前工具颜色还能顺带处理选中元素的着色。第三类获取文档结构app.getDocumentStructure()返回整份文档的骨架当前页索引、总页数、每页的背景类型pageTypeFormat、是否已有批注isAnnotated、PDF 背景页码等。LayerActions插件就是靠它判断下一页是否是 PDF 背景、是否已有批注再决定是直接执行还是弹窗让用户确认。这类先侦察再行动的写法是判断一个插件是否成熟的标志。第四类导出与交互app.export({[outputFile] ..., [backend] cairo, [range] ..., [pngDpi] ...})导出支持 PDF/SVG/PNG可指定范围、分辨率app.openDialog/app.fileDialogOpen/app.fileDialogSave对话框交互app.setCurrentPage(i)、app.setLayerVisibility(false)、app.refreshPage()页面与图层控制其中改完页面结构后记得调app.refreshPage()刷新画布。实战30 行代码写一个全文档页码批注插件理论说完了来点真格的。我们来写一个官方没有、但学习场景里非常实用的插件给整份文档每一页的顶部添加页码文字。它会把上面说的四类能力串成一条完整流水线。先想清楚思路再动手1. 用 getDocumentStructure 拿到总页数 2. 用 setCurrentPage 逐页跳转 3. 用 addTexts 在每页顶部插入第 N 页 4. 跳回原页刷新画布local originalPage function initUi() app.registerUi({ [menu] Add page numbers to all pages, [callback] addPageNumbers, [accelerator] ControlShiftn }) end function addPageNumbers() local doc app.getDocumentStructure() local numPages #doc[pages] originalPage doc[currentPage] for i 1, numPages do app.setCurrentPage(i) app.addTexts({ texts { { text 第 .. i .. 页 / 共 .. numPages .. 页, font { name Sans, size 12.0 }, color 0x666666, x 20.0, -- 距离左边距 y 10.0 -- 距离顶边距 } } }) end app.setCurrentPage(originalPage) app.refreshPage() app.openDialog(已为全部 .. numPages .. 页添加页码。, {好的}, ) end要点复盘#doc[pages]拿到总页数Lua 数组从 1 开始计数所以for i 1, numPages别从 0 开始app.addTexts里每个文本框是独立表x、y是文本框左上角坐标记得先记住原页面索引结束后跳回去避免脚本跑完用户页面却被切走的惊悚体验。把这个脚本放进plugins/AddPageNumbers/目录记得配一个上面写过的最小plugin.ini启用、运行你的整本讲义就有了统一页码。这就是插件的终极形态——把你会做但不想重复做的事变成一次点击。进阶玩法从菜单项走向工具栏按钮与子菜单如果你觉得菜单还不过瘾app.registerUi还有三个隐藏参数值得解锁toolbarIdiconName注册一个工具栏按钮但注意在工具栏配置toolbar.ini里引用时ID 必须带Plugin::前缀比如Plugin::CUSTOM_PEN_1parentPath把菜单项放进嵌套子菜单传Tools/Custom就会生成插件 → 你的插件 → Tools → Custom这样的层级mode多个菜单项共享一个回调时用整数mode区分回调函数会收到这个参数。app.registerUi({ [menu] 红色粗笔, [callback] applyPen, [mode] 1, [accelerator] Altr }) app.registerUi({ [menu] 蓝色细笔, [callback] applyPen, [mode] 2, [accelerator] Altb }) function applyPen(mode) if mode 1 then app.changeToolColor({[color] 0xff0000}) app.changeActionState(tool-pen-size, 2.0) elseif mode 2 then app.changeToolColor({[color] 0x3333cc}) app.changeActionState(tool-pen-size, 0.5) end end到这里你已经能拼装出足够复杂的插件了。剩下的深度玩法——比如用app.addSplines生成贝塞尔曲线图形、监听文档状态做自动化、给插件加配置项——原理都一样只是 API 的组合游戏。最容易踩的 5 个坑一次排完坑 1registerUi写在initUi之外。initUi是唯一合法的注册时机程序只在启动时调用它一次。在回调函数里再调registerUi不会生效。坑 2callback 传的是函数名字符串。app.registerUi({callback sayHello})里的sayHello是字符串对应全局函数sayHello。如果你传了函数引用不带引号运行时会直接报错。坑 3跨分区移动文件用os.rename。同一文件系统内没问题跨分区会失败。官方luapi_application.def.lua明确建议用app.glib_rename(from, to)它基于 glib 实现跨分区也可靠。坑 4改完画布不刷新。app.setCurrentPage、app.setLayerVisibility、页面增删之后界面不会自动重绘务必调用app.refreshPage()。否则你会看到脚本跑了、界面纹丝不动的诡异现象。坑 5依赖被弃用的 API。老教程里的app.msgbox、app.saveAs、app.uiAction都已被标记为deprecated会在未来移除。写新插件时以plugins/luapi_application.def.lua中带deprecated标注的说明为准用app.openDialog、app.fileDialogSave等新接口替换。快问快答Q插件写错了会崩掉 Xournal 吗A不会。每个插件跑在独立的 Lua 虚拟机里运行时错误只会弹出错误对话框主程序不受影响。这也是为什么initUi里宁可多做防御性检查比如LayerActions在操作前先判断有没有下一页也不要把错误留给运行时。Q插件存在哪、怎么分发A用户插件目录放个人插件系统的plugins/目录存放随程序分发的插件。想分享给别人把整个插件文件夹打包即可对方放进自己的插件目录、启用就完事。Q想读官方插件的实现从哪里入手A直接看plugins/下每个插件的main.lua它们都是真实可用的代码比任何教程都权威。想深入机制再看src/core/plugin/Plugin.cpp加载与调用流程和src/core/plugin/luapi_application.hC 与 Lua 的绑定层。需要本地源码的话用git clone https://gitcode.com/gh_mirrors/xo/xournalpp拉一份即可。Q插件能做的和不能做的边界在哪A凡是能通过菜单/工具栏/快捷键完成的操作插件基本都能驱动程序化读写笔画、文本框、页面结构也完全开放。但插件不能修改程序本身的行为——比如你不能用插件改变工具栏的渲染方式那是需要 C 层面的改动。现在关掉这个页面打开你的 Xournal建一个属于你的插件文件夹。当第一个自己写的菜单项弹出对话框、当脚本替你把几十页笔记一次整理干净的时候你会意识到那些重复劳动从此不再是你的工作了。下一节课开始前试着给你的讲义加个一键页码吧——你的手写笔记该学会自己照顾自己了。【免费下载链接】xournalppXournal is a handwriting notetaking software with PDF annotation support. Written in C with GTK3, supporting Linux (e.g. Ubuntu, Debian, Arch, SUSE), macOS and Windows 10. Supports pen input from devices such as Wacom Tablets.项目地址: https://gitcode.com/gh_mirrors/xo/xournalpp创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考