大多数对此问题的答案已有五年历史,仅限 PDF,并且要求你在看不见的页面上测量坐标。这里是 2026 版:七个调用、决定标记实际落位的两种约定,以及 Linux 容器所需的唯一一行代码。
下面的所有内容均在 .NET 8 和 .NET 10 上运行,支持 Windows 和 Linux,无需 Adobe Acrobat 和 Microsoft Office——相同的代码同样适用于 DOCX、XLSX、PPTX 和图像。只需更改文件路径。
可运行的版本在 GitHub 上: annotate-pdf-csharp-net。
安装
dotnet add package GroupDocs.Annotation
没有许可证时,库以评估模式运行,输出会带有评估标记。使用临时许可证即可去除这些标记:
new License().SetLicense("GroupDocs.Annotation.lic");
打开文档
using (Annotator annotator = new Annotator("quarterly-review.pdf"))
{
// add, read or remove markup here
}
需要提前了解的一点是:annotator 会保持文件打开直至释放,并且它不能直接覆盖正在读取的文件。请保存到其他路径,或保存到临时文件后再移动到目标位置。
不要测量坐标——搜索文本
这是大多数教程跳过的部分,也是让标注代码在文档新版本中仍能工作的方法。GetDocumentInfo() 会返回每页的尺寸以及其文本行,每行都有一个框:
IDocumentInfo info = annotator.Document.GetDocumentInfo();
foreach (PageInfo page in info.PagesInfo)
{
foreach (TextLineInfo line in page.TextLines)
{
if (line.Text.Contains("Hosting costs"))
{
// line.LeftIndent, line.TopIndent, line.Width, line.Height — points, from the top-left
}
}
}
现在标注可以基于文档当前的内容进行定位,而不是基于去年文档的旧位置。
添加带评论和回复的高亮
float left = (float)line.LeftIndent;
float right = (float)(line.LeftIndent + line.Width);
float top = (float)(page.Height - line.TopIndent);
float bottom = (float)(page.Height - line.TopIndent - line.Height);
annotator.Add(new HighlightAnnotation
{
PageNumber = page.PageNumber - 1,
BackgroundColor = 0xFFE699,
Opacity = 0.5,
Message = "Check this against the invoice",
CreatedOn = DateTime.Now,
User = new User { Id = 1, Name = "Reviewer", Role = Role.Editor },
Points = new List<Point>
{
new Point(left, top), new Point(right, top),
new Point(left, bottom), new Point(right, bottom)
},
Replies = new List<Reply>
{
new Reply { Comment = "Confirmed with finance.", RepliedOn = DateTime.Now }
}
});
annotator.Save("result.pdf");
Message 是评论内容。Replies 开启了一个线程——在 PDF 阅读器中表现为带回复的批注,在 Word 中表现为带回复的评论。User 用于在后续操作中定位并删除特定审阅者的标记,而不影响其他人的标记。
两种约定,适用于所有人
仔细观察计算 top 和 bottom 的两行代码以及 PageNumber。
页码计数出现两次。 PageInfo.PageNumber 为 从 1 开始。
AnnotationBase.PageNumber 为 从 0 开始。如果混用,两者会导致所有标注偏移一页——最后一页的标注会消失,因为指向不存在页码的标注在保存时会被直接丢弃,既没有异常也没有警告。如果标注“未出现”,请先检查页码索引。
两个原点。 TextLineInfo 和 AreaAnnotation.Box 的坐标基于 左上角。
文本标记(高亮、下划线、删除线、波浪线)则基于 左下角,这正是 PDF 的工作方式。
| 成员 | 原点 | 页码 |
|---|---|---|
PageInfo, TextLineInfo |
左上角 | 从 1 开始 |
AnnotationBase.PageNumber |
— | 从 0 开始 |
AreaAnnotation.Box |
左上角 | — |
HighlightAnnotation.Points |
左下角 | — |
PreviewOptions.PageNumbers |
— | 从 1 开始 |
四个点构成一个矩形,顺序为:左上、右上、左下、右下。相同列表中可以继续添加矩形,这就是单个评论能够覆盖跨三行的句子的方式。
读取标记
using (Annotator annotator = new Annotator("result.pdf"))
{
foreach (AnnotationBase a in annotator.Get())
{
Console.WriteLine($"#{a.Id} {a.Type} on page {a.PageNumber + 1}: {a.Message}");
foreach (Reply reply in a.Replies)
Console.WriteLine($" reply: {reply.Comment}");
}
}
Get() 读取文档实际包含的内容——包括用户在 Word 或 Acrobat 中添加的标注,这正是实现跨工具往返的关键。
需要注意的一点是:Id 在读取文档时分配,并未存储在文档中。它足够稳定,可在同一会话中删除或更新标注,但不适合作为数据库主键。若需在文件之外保存标注,请使用导入导出功能。
再次删除
annotator.Remove(1); // by id
annotator.Remove(annotator.Get()
.Where(a => a.User?.Name == "Reviewer").ToList()); // or by author
渲染页面
PreviewOptions previewOptions = new PreviewOptions(n => File.Create($"page_{n}.png"));
previewOptions.PreviewFormat = PreviewFormats.PNG;
previewOptions.PageNumbers = new[] { 1, 2 }; // 1-based
previewOptions.RenderAnnotations = true;
previewOptions.Resolution = 144;
annotator.Document.GeneratePreview(previewOptions);
此功能在三种场景中非常有用:展示文档的 Web UI、验证标记是否落在预期位置的测试,以及需要查看自身工作成果的代理程序。
在 Linux 上
代码本身无需更改,容器需要做如下修改:
FROM mcr.microsoft.com/dotnet/runtime:8.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
.NET 运行时镜像默认不包含字体,而文本渲染需要字体。仅此一行代码决定了输出是正确的还是空白页——也是关于文档库的最常见 Linux 问题。
下一步
git clone https://github.com/groupdocs-annotation/annotate-pdf-csharp-net.git
cd annotate-pdf-csharp-net
dotnet run --project src/AnnotatePdf -- highlight samples/quarterly-review.pdf --text "Hosting costs"
- 文档: 在 C# 中注释 PDF
- 让 LLM 审核合同并留下真实评论 — 同样的调用,由模型驱动
- 构建浏览器注释 UI — 同样的调用,由浏览器中的用户驱动