把 Word / Excel / PowerPoint / 图片 / 文本 / PDF 等格式统一转换成 PDF,在窗口内渲染,用手写笔直接批注,最后导出成带批注的 PDF。
| 环节 | 方案 | 理由 |
|---|---|---|
| UI 框架 | .NET 8 + WPF | InkCanvas 是 Windows 上唯一原生接入 Windows Ink 的托管控件:压感、倾斜、笔尾擦除、低延迟湿墨渲染全部开箱即用。Electron / Qt 都要自己模拟压感,手感差一大截 |
| 文档 → PDF | LibreOffice 无头模式(主)+ Office COM(备)+ 内建图片/文本转换 | LibreOffice 免费、格式覆盖最广、可随程序分发;装了 Office 的机器自动走 COM 保真度更高 |
| PDF 渲染 | pdfium(Docnet.Core) |
Chrome 同款渲染内核,速度和保真度最好,NuGet 自带原生库免安装 |
| PDF 写回 | PdfSharp 6 | MIT 协议,可以把墨迹当矢量路径写进原页面内容流,导出后放大不糊 |
| 墨迹存储 | ISF(StrokeCollection.Save) |
微软原生墨迹格式,完整保留压感与笔属性,体积小 |
核心设计决策:墨迹坐标与缩放解耦。
每页的 InkCanvas 尺寸永远固定为「PDF 点尺寸 × 96/72」的逻辑尺寸,缩放通过外层 ScaleTransform 实现。这样无论用户怎么缩放、怎么换分辨率重渲底图,已有笔迹的坐标都不会漂移——这是很多同类软件做错的地方。
InkAnnotator/
├── InkAnnotator.sln
├── .vscode/ VSCode 调试配置
│ ├── launch.json
│ ├── tasks.json
│ └── settings.json
└── src/
├── InkAnnotator.Core/ 与界面无关的引擎层
│ ├── Conversion/
│ │ ├── IDocumentConverter.cs 转换器接口
│ │ ├── DocumentConversionService.cs 调度:按扩展名挑转换器、失败自动降级
│ │ ├── LibreOfficeConverter.cs soffice --headless --convert-to pdf
│ │ ├── OfficeInteropConverter.cs Word/Excel/PPT COM 后期绑定
│ │ └── BuiltInConverters.cs PDF 直通 / 图片 / 纯文本
│ ├── Rendering/
│ │ ├── PdfRenderService.cs pdfium 光栅化(串行化 + DocReader 缓存)
│ │ └── PageComposer.cs 底图+墨迹+图章+文字框的统一绘制(打印复用)
│ ├── Documents/
│ │ ├── AnnotationPackage.cs .ianno 工程包读写(含 notes/ 分区)
│ │ ├── Annotations.cs 文字批注框 / 图章的数据模型
│ │ └── StampLibrary.cs 图章矢量几何(汉字也转成路径)
│ ├── Export/
│ │ ├── PdfInkExporter.cs 批注矢量烧录进 PDF(三种墨迹模式)
│ │ └── InkOutlineBuilder.cs 压感 → 变宽多边形轮廓(自算模式用)
│ └── Fonts/
│ └── WindowsFontResolver.cs 给 PdfSharp 提供中文字体
└── InkAnnotator.App/ WPF 界面层
├── App.xaml(.cs) 全局异常兜底、命令行参数
├── MainWindow.xaml(.cs) 三段式布局 / 缩略图 / 页面视图 / 滚动虚拟化
├── AboutWindow.xaml(.cs) 关于:版本 / 快捷键 / 组件与运行环境
├── Assets/ AppIcon.ico(exe 图标,8 个尺寸)/ AppLogo.png
├── ViewModels/
│ ├── ViewModelBase.cs INotifyPropertyChanged + RelayCommand
│ ├── MainViewModel.cs 应用状态与全部命令
│ └── PageViewModel.cs 单页:底图 + 墨迹 + 文字框/图章 + 按需渲染
├── Controls/
│ ├── PenInkCanvas.cs 仅笔书写 / 防手掌误触 / 笔尾擦除
│ └── AnnotationLayer.xaml(.cs) 文字批注框 / 图章的放置、拖动、缩放、删除
├── Services/
│ ├── UndoRedoManager.cs 通用撤销栈(墨迹 + 文字框 + 图章)
│ ├── PrintService.cs 自定义 DocumentPaginator,按打印分辨率重渲
│ └── AutoSaveService.cs 自动保存与崩溃恢复
├── Converters/
└── Themes/Styles.xaml 设计系统:色板 / 图标几何 / 扁平控件样式
-
.NET 8 SDK(必须)
winget install Microsoft.DotNet.SDK.8 -
LibreOffice(可选,只影响 Office 格式)
winget install TheDocumentFoundation.LibreOffice
装在默认路径即可,程序会自动从注册表和常见路径探测
soffice.exe。 没装 LibreOffice 但装了 Microsoft Office 也能跑,走 COM 通道。不装也能用:PDF、图片、纯文本走的是内建转换器,零外部依赖。 只有 Word / Excel / PowerPoint 需要这两者之一,程序会按探测结果 动态调整文件对话框的筛选器,不会让你挑到一个注定打不开的 docx。
-
VSCode 扩展
ms-dotnettools.csdevkit(C# Dev Kit,含调试器)ms-dotnettools.csharp
⚠️ 必须是 x64。Docnet.Core带的是 64 位 pdfium,两个 csproj 都已写死<PlatformTarget>x64</PlatformTarget>。
cd InkAnnotator
dotnet restore
dotnet build然后在 VSCode 中按 F5,选「调试 InkAnnotator (WPF)」。
第二个配置「调试并直接打开某个文件」会弹框让你输入文档路径,直接进入已加载状态——调转换和渲染逻辑时很省事。
断点建议放这几处:
| 想调什么 | 断点位置 |
|---|---|
| 格式转换 | DocumentConversionService.ConvertToPdfAsync 的 foreach (var converter in available) |
| LibreOffice 命令行 | LibreOfficeConverter.ConvertAsync 的 proc.Start() |
| 页面渲染 | PdfRenderService.RenderCore |
| 可视区调度 | MainWindow.UpdateVisiblePagesAsync |
| 墨迹落笔 | MainViewModel.OnPageStrokesEdited |
| 导出坐标换算 | PdfInkExporter.MapPoint |
| 压感导出(默认路径) | PdfInkExporter.TryDrawExactGeometry |
| 压感轮廓展开(自算路径) | InkOutlineBuilder.Build |
| 打印每页合成 | AnnotatedDocumentPaginator.GetPage |
| 批注放置/拖动 | AnnotationLayer.Root_MouseLeftButtonDown / OnDragDelta |
- 打开:Ctrl+O,或直接把文件拖进窗口,或把文件拖到 exe 上
- 始终支持:PDF、图片(png/jpg/bmp/tif/gif/webp)、纯文本、
.ianno批注工程 - 装了 LibreOffice 或 Office 才支持:Word / Excel / PowerPoint / ODF / RTF / HTML
- 始终支持:PDF、图片(png/jpg/bmp/tif/gif/webp)、纯文本、
- 书写:手写笔 / 荧光笔,8 色调色板,粗细 0.6–14
- 擦除:整笔擦、局部擦,笔尾倒置自动切擦除
- 选择:套索选中墨迹后可拖动、缩放、删除(
InkCanvas原生能力) - 撤销/重做:Ctrl+Z / Ctrl+Y,深度 500 步
- 缩放:Ctrl+滚轮(以鼠标为锚点)、Ctrl +/−、Ctrl+0、适应宽度
- 翻页:PageUp / PageDown、左侧缩略图点击
- 仅笔书写:勾选后手指只平移页面,避免手掌误触
- 文字批注框:选「文字」工具后在页面上点一下即插入并直接进入编辑;标题条拖动、右下角缩放、Esc 结束编辑;字号 / 文字色 / 底色 / 加粗可调
- 图章:对勾、叉、问号、圈、三角、星、叹号、优、良、阅、订正,点哪盖哪,大小与颜色可调
- 打印:Ctrl+P,可选打印范围(全部 / 当前页 / 自定义),可选是否包含批注;横竖不一致时自动转 90°
- 找批注:缩略图右上角显示批注数量徽标、左侧有竖条;Ctrl+PageUp / Ctrl+PageDown 在「有批注的页」之间跳;选项浮层里可勾选「缩略图只显示有批注的页」,把缩略图栏筛成一份批注索引
主栏只放「写」相关的东西:左侧文件菜单与文档名,中间七个工具,右侧撤销重做与选项。工具的参数放在下面的上下文栏里,只显示当前工具的那一组——选到「选择」或「浏览」时整条收起,画布多出 44px。页码、缩放、批注跳转这类「看」的控制全部下沉到底部状态栏。一次性开关(仅笔书写、打印含批注、只看批注页)收进右上角的选项浮层。
缩略图栏与正文双向联动:点缩略图跳到该页,滚动正文时缩略图选中项跟着走并自动滚进视野。
- 文件(带底色的那一个)打开后台菜单:打开 / 保存 / 另存为 / 导出 PDF / 打印,以及「打印时包含批注」。
- 绘图(默认选项卡)分三组 —— 工具(笔、荧光笔、文字框、图章、橡皮、选择、浏览)、样式、编辑(撤销 / 重做 / 清除本页)。 「样式」组的内容随当前工具切换:笔和荧光笔给色板 + 粗细滑块 + 实时预览,文字框给字号 / 字色 / 底色 / 加粗,图章给两行图形库 + 大小 + 颜色,橡皮给整笔 / 局部两种方式。
- 视图分四组:显示(导航窗格、仅笔书写、只显示有批注的页)、缩放、页面、批注。
- 选项卡条右端可以折叠整个功能区,画布多出 88px。
- 状态栏承担高频的「看」的操作:页码、批注跳转、缩放滑块与适应宽度 —— 和 Office 一样。
文档区背景是浅灰(#E6E6E6),页面靠 1px 边框加淡阴影浮起来,不用深色画布。
-
旋转页面:
PdfInkExporter.HandlePageRotation默认按/Rotate做坐标映射。极少数 PdfSharp 版本会自行处理旋转导致重复变换,若导出后旋转页墨迹错位,把这个静态属性设为false再试。 -
MediaBox 原点偏移已修:PageMapping现在按 pdfium 的规则取MediaBox ∩ CropBox作为可见区,并补偿 PdfSharp 不处理的页面框原点(OffsetX = box.X1、OffsetY = page.Height − box.Y2)。扫描件的非零 MediaBox 原点、印刷排版件的偏移 CropBox 都不再错位;常规页面两个偏移都算出 0,行为与旧版逐点一致。若某个 PdfSharp 版本将来自己处理了原点,把PdfInkExporter.HandlePageBoxOffset置 false。同一条坐标链上还有一处小误差也一起修了:
PdfRenderService原先用PageDimensions(1.0)量页面尺寸,而 Docnet 的GetPageWidth()是(int)(FPDF_GetPageWidth * scaling)——截断,A4 的 595.276×841.890 会被报成 595×841。现在改成放大MeasureScale = 100再除回来,截断误差 ≤0.01pt。Docnet 的PageReader构造函数只做FPDF_LoadPage,位图是GetImage时才分配,所以放大量尺寸不占内存。需要在 Windows 上验证的两个用例(本轮改动只做了代码审查,没有编译运行):
- 非零 MediaBox 原点的扫描件 + 带偏移 CropBox 的印刷排版件,导出后墨迹是否与屏幕重合;
/Rotate = 90/270的页面。OffsetY用的是page.Height,若某个 PdfSharp 版本对旋转页自行交换了 Width/Height,这里会连带偏移;出现错位就先关HandlePageRotation定位是哪一层重复变换了。
-
压感导出的三种模式:由
PdfInkExporter.Mode控制。模式 做法 取舍 ExactGeometry(默认)直接取 Stroke.GetGeometry(),即 WPF 渲染用的那份实心轮廓,展平成多边形与屏幕完全一致;密集手写时顶点多、PDF 偏大。可用 FlattenTolerance/MinVertexSpacing抽稀PressureOutlineInkOutlineBuilder自己按相邻采样点压力算半宽、沿法线偏移、左正右反闭合顶点少、文件小、参数可调( MinPressureFactor/MaxPressureFactor/SmoothIterations);FitToCurve的曲线拟合靠两轮 Chaikin 逼近,锐角处与屏幕有细微差异FixedWidth定宽折线 旧行为,丢笔锋,仅用于对比排查 注意:采集压力、屏幕渲染笔锋、ISF 存盘三件事 Ink 组件本来就内置,这里补的只是「WPF 墨迹 → PDF 矢量路径」这一段桥接,PdfSharp 不认识 WPF 墨迹。
-
无 LibreOffice 也无 Office 时:只能打开 PDF、图片、纯文本。这是设计如此,不是降级 ——
DocumentConversionService把这两个通道标成RequiresExternalSoftware,筛选器里不出现相应格式,误开时抛MissingConverterException,UI 弹的是一条安装指引而不是错误。「关于」窗口的"可选组件"一栏如实显示探测结果。 -
超大文档:页面容器目前不做 UI 虚拟化(只虚拟化位图)。上千页时可以改用
VirtualizingStackPanel+ 像素滚动。 -
打印仍占 UI 线程:
DocumentPaginator.GetPage是同步接口,WPF 打印栈全程占着 UI 线程,这一点改不了。已经做的是把过程可见化:PrintProgressWindow显示「第 N / M 页」进度条并提供取消,靠在每页回调里手动排一次消息队列(DispatcherFrame+PushFrame)来刷新;排队期间主窗口被置为IsEnabled = false,不会命令重入。取消后剩余页面产出空白页快速排空任务,而不是抛异常打断XpsDocumentWriter(有些驱动会留下卡住的作业),并如实告知"前 N 页可能已进入打印队列"。真正的异步化要把分页器搬到独立 STA 线程,前提是先把墨迹和批注快照成非线程亲和的数据。 -
PDF 里的文字批注字重:
WindowsFontResolver只提供一种字重,加粗用微偏移模拟,不是真正的 Bold 字形。 -
旋转页上的文字批注:靠
RotateAtTransform(-rotate, 锚点)把坐标系转回直立方向。/Rotate = 0(绝大多数情况)完全不走这条分支。 -
可以加的功能:朗读、OCR 文字层(Tesseract)、多设备墨迹同步、按页导出 PNG、批注清单侧栏。
dotnet publish src/InkAnnotator.App/InkAnnotator.App.csproj -c Release -r win-x64 --self-contained false -o publishVSCode 里也可以直接跑 tasks 里的「publish (单文件 x64)」。
分发时记得连同 runtimes/win-x64/native/pdfium.dll 一起打包(publish 会自动带上)。
本项目以 MIT 协议 发布:可自由使用、修改、商用与再分发,保留版权声明即可。
依赖的第三方组件(Docnet.Core / PDFium / PDFsharp)均为 MIT 或 BSD-3-Clause, 详见 THIRD-PARTY-NOTICES.md。 LibreOffice 与 Microsoft Office 只是被当作外部程序调用,不随本项目分发。
- 发现问题或想要新功能:提 Issue,附上版本号(设置 → 关于 → 复制版本信息)、 系统版本、复现步骤;如果和某个文档相关,尽量给一个可以公开的最小样例。
- 提交代码:从
main拉分支 → 改动 → PR。改到墨迹坐标、导出映射、打印分页这三块时, 请在 PR 里说明用什么文档验证过(尤其是非零 MediaBox 的扫描件、/Rotate非 0 的页面)。 - 「六、已知限制」里的条目欢迎认领。