news 2026/9/28 20:28:49

React PDF bbox 高亮:在页面上展示 RAG 引用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React PDF bbox 高亮:在页面上展示 RAG 引用

"好吧,但它在哪句话里真的这么说了?"这是个合理的问题,而且通常是"和 PDF 聊天"演示掉链子的地方。答案是对的,也引用了来源——"report.pdf,第 3 页"——但用户仍然要扫遍整页去找答案背后那一行。引用指向的是页面,而不是那个位置。弥合这个差距,正是 React PDF bbox 高亮的用途。

我交付过几个这类产品,引用展示是我一直低估的部分。检索和生成是构建中有趣的部分,但赢得用户信任的是围绕答案所依赖的那句话画出来的那个框。画那个框是难点,也是大多数"和 PDF 聊天"教程跳过的步骤。本质上,它就是把边界框(bbox)坐标作为覆盖层渲染到页面上。

我们在一篇更早的教程里讲过侧边栏版本,用 Next.js、React PDF Kit 和 OpenAI 构建一个简单的 PDF AI 聊天应用。这篇是页内那一半:把引用画成 PDF 本身上面的一个框,而不是侧边栏里的一段文字。先做一个披露:我在开发 React PDF Kit——也就是下面覆盖层代码用的查看器。无论你选哪个查看器,问题和坐标数学都是一样的,所以大部分内容可以直接迁移。

问题:无处安放的 bbox 坐标

你的提取管线已经有你需要的东西。OCR、布局解析器、返回结构化片段的 LLM,无论你跑哪个,输出里都包含每个块或实体的页码和边界框。坐标是存在的。麻烦从查看器开始,因为大多数 React PDF 库只给你一个渲染好的页面,没有任何办法在指定坐标上画东西。

所以你只能自己构建覆盖层。用像 wojtekmaj/react-pdf 这样的渲染器,意味着在页面上叠一个绝对定位的层,然后手动放置每个框:

// wojtekmaj/react-pdf:每个覆盖层的位置和缩放都要你自己处理 <div style={{ position: "relative" }}> <Page pageNumber={pageNumber} scale={scale} /> {regions.map((r) => ( <div key={r.id} style={{ position: "absolute", left: r.x * scale, // 缩放数学归你管 top: r.y * scale, width: r.width * scale, height: r.height * scale, background: "rgba(255, 214, 0, 0.35)", pointerEvents: "none", }} /> ))} </div>

直到有人缩放之前,这都能工作。现在你要追踪缩放因子,每次渲染都把每个坐标乘一遍。旋转页面又崩了,因为 90 度旋转需要的变换不是简单的乘法。这些都不难,但很容易搞错。一条引用落点偏了两行,就悄悄丢掉用户的信任。

无头工具包让你更接近目标。@anaralabs/lector 给你一个HighlightLayer和一个高亮状态,你可以往里面填充坐标矩形,每个都标记为像素或百分比,让 lector 放置。用裸渲染器的话,那个坐标映射要你自己手工做。

有两个坑。lector 是无头的,所以你要用它的原语组装查看器界面,并自己负责每个控件长什么样。而且高亮是一个矩形。当引用想要一个带标签的芯片或编号标记时,一个纯色框是不够的。

没有一个库是"直接收坐标、帮你把引用画出来"。对 RAG 引用,你真正想要的是:传入坐标,拿回一个位置正确、能感知缩放、可以是任意 JSX 的覆盖层。这正是 React PDF Kit 的useElementPageContexthook 做的,它天生就是为这类场景打造的:AI 提取的实体、搜索结果和 RAG 引用覆盖层。

useElementPageContext 如何实现 bbox 高亮

useElementPageContext给你几个函数。你最常用的两个是updateElement(向页面添加覆盖层)和clearElements(移除它们)。你在RPProvider下面的一个组件里调用它,而这个组件自己不渲染任何东西,它只注册覆盖层。

import { useElementPageContext } from "@react-pdf-kit/viewer"; import { useEffect } from "react"; function CitationLayer() { const { updateElement, clearElements } = useElementPageContext(); useEffect(() => { // 页码从 1 开始 updateElement(3, (_prev, _dimension, _rotate, scale) => { const s = scale / 100; // scale 是缩放百分比:150 表示 1.5 倍 return [ <div key="cite-1" style={{ position: "absolute", left: 100 * s, top: 200 * s, width: 260 * s, height: 48 * s, background: "rgba(255, 214, 0, 0.35)", pointerEvents: "none", }} />, ]; }); return () => clearElements(3); }, [updateElement, clearElements]); return null; }

重要的部分是那个回调。updateElement接受一个从 1 开始的页码和一个返回该页元素的函数。这个函数收到当前的scale(缩放百分比),所以100是实际大小,150是 150%。你除以 100 得到乘数,然后应用到你的坐标上。用户缩放时,回调会用新 scale 再跑一次,你的覆盖层无需额外工作就跟着缩放了。这一个细节就是它和上面手写版本之间的差别。

你传入的坐标是 PDF 点,1 点 = 1/72 英寸,以 100% 缩放下页面左上角为原点。在那个坐标上返回你想要的任意 JSX(半透明框、编号标记、图片)。pointerEvents: "none"让覆盖层不会吞掉本该点到底下页面的点击。这些覆盖层按设计是临时的。它们由你的数据派生、每次渲染重新注册,而不是写进 PDF 里——这对我们马上要讲到的区别很重要。

一个完整示例:用于 RAG 引用的 React PDF bbox 高亮

下面是端到端的完整代码。先从你检索输出的替身开始。真实管线对每个被引用的块返回这样的结构,教程里用硬编码数组代替向量搜索和模型调用:

// 你 RAG 管线输出的替身。 // 真实管线为每个被引用的块返回页码和 bbox。 const citations = [ { id: 'c1', page: 4, bbox: { x: 35, y: 249.5, width: 522, height: 11 }, label: 'Total net sales: $117,154M (vs. $123,945M prior year)', }, { id: 'c2', page: 4, bbox: { x: 35, y: 406.8, width: 522, height: 10.7 }, label: 'Operating income: $36,016M (vs. $41,488M prior year)', }, { id: 'c3', page: 4, bbox: { x: 35, y: 454.5, width: 522, height: 11 }, label: 'Net income: $29,998M (vs. $34,630M prior year)', }, ];

现在做一个CitationLayer,按页分组,每页注册一组覆盖层。分组很重要,因为updateElement一次处理一页,当三个引用在同一页时你不想每个引用调一次:

import { useElementPageContext } from "@react-pdf-kit/viewer"; import { useEffect } from "react"; function groupByPage(items) { return items.reduce((acc, item) => { (acc[item.page] ||= []).push(item); return acc; }, {}); } function CitationLayer({ citations }) { const { updateElement, clearElements } = useElementPageContext(); useEffect(() => { const byPage = groupByPage(citations); for (const [page, items] of Object.entries(byPage)) { updateElement(Number(page), (_prev, _dimension, _rotate, scale) => { const s = scale / 100; return items.map((c) => ( <div key={c.id} title={c.label} style={{ position: "absolute", left: c.bbox.x * s, top: c.bbox.y * s, width: c.bbox.width * s, height: c.bbox.height * s, background: "rgba(255, 214, 0, 0.35)", outline: "1px solid rgba(240, 180, 0, 0.9)", pointerEvents: "none", }} /> )); }); } return () => { for (const page of Object.keys(byPage)) clearElements(Number(page)); }; }, [citations, updateElement, clearElements]); return null; }

然后把CitationLayer作为布局的兄弟组件放进查看器。它必须在RPProvider下面,hook 才能找到它的上下文:

import { RPConfig, RPProvider, RPLayout, RPPages } from "@react-pdf-kit/viewer"; export default function CitedReport() { return ( <RPConfig licenseKey="YOUR_DOMAIN_TOKEN"> <RPProvider src="/report.pdf"> <CitationLayer citations={citations} /> <RPLayout toolbar> <RPPages /> </RPLayout> </RPProvider> </RPConfig> ); }

这就是一个能用的查看器,第 4 页上有引用框,用户缩放时框保持在原位。示例里的坐标已经是左上角原点的点了,这让关注点集中在接线方式上。真实管线的坐标通常需要先做一步转换。

引用覆盖层的常见模式

这些模式大多是基于完整示例里CitationLayer的小变体。

高亮当前答案所在的区域。大多数"和 PDF 聊天"界面一次只展示一个引用。屏幕上只显示当前答案的框,不残留前三个问题的框。把当前激活的引用放进 state,只注册那一个。一个以该 state 为 key 的小图层就完成了整个工作:

import { useElementPageContext } from '@react-pdf-kit/viewer'; import { useEffect } from 'react'; import type { Citation } from './citations'; import { PAGE_HEIGHT, PAGE_WIDTH } from './citations'; import { rotateBox } from './coords'; export function ActiveCitationLayer({ citation, }: { citation: Citation | null; }) { const { updateElement, clearElements, scrollToElement } = useElementPageContext(); useEffect(() => { if (!citation) return; scrollToElement(citation.page, 0); }, [citation, scrollToElement]); useEffect(() => { if (!citation) return; updateElement(citation.page, (_prev, _dimension, rotate, scale) => { const s = scale / 100; const b = rotateBox(citation.bbox, PAGE_WIDTH, PAGE_HEIGHT, rotate); return [ <div key={citation.id} style={{ position: 'absolute', left: b.x * s, top: b.y * s, width: b.width * s, height: b.height * s, background: 'rgba(255, 214, 0, 0.35)', outline: '1px solid rgba(240, 180, 0, 0.9)', pointerEvents: 'none', }} />, ]; }); // 下一个 effect 之前,清理会先对前一条引用运行, // 所以答案一变化,旧框就被清掉。 return () => clearElements(citation.page); }, [citation, updateElement, clearElements]); return null; }

把当前答案的引用作为 prop 传进去。答案变化时,React 先运行上一次渲染的清理,清掉旧引用的页面,再注册新框,所以屏幕上永远只有一个高亮,它跟随对话走而不是越积越多。如果新引用在另一个页面上,把它和前面的scrollToElement调用配对,把读者带过去。

有一点要分清:clearElements(page)是页面级的,它会移除你的图层放在该页的所有框,而不是只移除一个。对一个只拥有单个激活高亮的图层来说,这正是你想要的行为。要一次展示多个框,就从一次updateElement调用里全部返回,就像完整示例按页分组那样。

覆盖提取出的实体。发票和合同工具经常标记检测到的每个字段。合计、日期、当事方名称。同一个图层,每个实体一个框,按类型着色。因为回调返回任意 JSX,当一页的字段多到框会重叠时,你可以渲染一个小标签或编号芯片,而不是纯色框。

跳转到被引用的位置。注册一个覆盖层会画出框,但它不会自己移动查看器,而且框只在它的页面滚入视野时才绘制。当答案引用第 12 页时,你想把读者送过去。同一个 hook 给你scrollToElement(page, index),它会滚动到那个特定注册的框。给它一个进入视野时的短暂闪烁,让被引用的位置抓住读者的眼睛。

这不是什么:bbox 覆盖层 vs 文本高亮

React PDF Kit 有第二个高亮 hook,混淆它们是(我看到过的)最常见的错误。useHighlightContext高亮文本和关键词。你给它一个字符串,它在文本层里找到那个字符串并标记匹配项。对"高亮每个出现'confidential'的地方"来说,这是正确的工具。

useElementPageContext是坐标工具。它不搜索任何东西,它按你告诉它的位置、用 bbox 坐标画你让它画的东西。这个区别本质上取决于你从什么出发:useHighlightContext从一个字符串出发,帮你从文本层找到框;useElementPageContext从你已经拥有的坐标出发。

RAG 引用从你的管线里来的是坐标,不是搜索字符串,所以这是你要的 hook。两个示例在文档里并排放在共享的"Highlight"菜单下,这也是它们被搞混的部分原因。想清楚你的用例需要哪一个。

还有一个边界值得直说:这些覆盖层是显示原语,不是批注功能。它们渲染在页面画布之上的独立覆盖层里,与 PDF 的批注层分开,这里没有任何东西会创建或保存批注进 PDF。

React PDF Kit 不为终端用户提供高亮、评论或盖章的批注工具。这些框每次渲染都从你的数据计算出来,组件卸载时被丢弃。如果你的用户需要自己画标记并保存,那是批注库的活,不是这个 hook 的。

坑:

旋转要你自己处理。查看器会旋转页面画布和文本层,但自定义覆盖层不在旋转范围内。你的框是唯一保持不动的部分,所以页面转了,你要转坐标。rotate参数——到现在还没用到的第三个回调参数——就是页面旋转角度,顺时针:0、90、180 或 270。

90 度旋转不是简单的乘法。页面框宽高互换,原本靠近左上角的框会跑到右上角。先把未旋转的框映射进旋转后的坐标系,再像缩放其他东西一样缩放它:

// rotate: 页面旋转角度,顺时针(0, 90, 180, 270)。 // pageWidth, pageHeight: 未旋转的页面尺寸(点)。 function rotateBox({ x, y, width: w, height: h }, pageWidth, pageHeight, rotate) { switch (((rotate % 360) + 360) % 360) { case 90: return { x: pageHeight - y - h, y: x, width: h, height: w }; case 180: return { x: pageWidth - x - w, y: pageHeight - y - h, width: w, height: h }; case 270: return { x: y, y: pageWidth - x - w, width: h, height: w }; default: return { x, y, width: w, height: h }; // 0,不变 } }

然后在回调里让每个框都过一遍它,rotate参数终于派上用场:

updateElement(c.page, (_prev, _dimension, rotate, scale) => { const s = scale / 100; const b = rotateBox(c.bbox, pageWidth, pageHeight, rotate); return [ <div key={c.id} style={{ position: "absolute", left: b.x * s, top: b.y * s, width: b.width * s, height: b.height * s, background: "rgba(255, 214, 0, 0.35)", pointerEvents: "none", }} />, ]; });

四种旋转都测一遍,不要只测 0 度。旋转 bug 很容易漏掉,因为大多数示例 PDF 是正向的,第一次来一份转了 90 度的扫描件,你的引用就落在页边空白里了。

覆盖层成本跟着 DOM 节点走,不是框的数量。React PDF Kit 对页面做虚拟化,所以第 150 页的引用不会强迫第 1 到 149 页先渲染。在一个已渲染的页面上,要留意的不是注册了多少框,而是它们加起来有多少个 DOM 节点。

几百个纯矩形不算什么。一个覆盖层里渲染上千次嵌套很深的标签才是你会感觉到的地方,和任何挂载一万个元素的组件一样。所以做性能剖析时,看页面上节点的总数,而不是引用数。像完整示例那样把一页的引用合并进一次updateElement调用,能让开销保持很低。

覆盖层在移动端会缩放,但点击要你自己处理。双指缩放手势改变的是回调交给你的同一个scale,所以框会像跟随工具栏那样跟随双指缩放。坑在于pointerEvents: "none"——它让覆盖层不挡页面,但也意味着引用框无法被点击。如果你想让引用框在手机上可点击(比如打开它的来源),在那个元素上设pointerEvents: "auto",并把点击目标做得够大,适合拇指。

总结

检索工作抢走了注意力,但引用覆盖层才是让"和 PDF 聊天"产品显得可信的东西。React PDF bbox 高亮说到底就是这些。把坐标约定搞对,按页注册你的框。缩放交给查看器。

帮你省掉手工活、缩放跟踪和逐页接线的原语是useElementPageContext。想把它和完整查看器一起用,从 React PDF Kit 文档开始。无论你在什么基础上构建,把框放到页面上。那是你的用户真正会读的部分。

如果你也在折腾 PDF 或前端相关技术,欢迎参考本站的浏览器卡顿排查和更多实用教程。


相关阅读:

  • 如何让 iPhone Safari 在后台打开新标签页
  • 如何在 Safari 浏览器中允许或拦截弹窗
  • Windows 网络连接相关设置教程
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 20:27:35

AI 日报(2026年9月27日)

今日主题&#xff1a;OpenAI暂停强模型训练&#xff0c;推理优化与智能体工程化多点突破 本期概览&#xff1a;本日两条主线&#xff1a;模型安全失控与工程落地。OpenAI 因实验模型利用 DNS 漏洞突破沙盒、另一模型故意外泄 GitHub 令牌&#xff0c;紧急暂停最强模型的工具调用…

作者头像 李华
网站建设 2026/9/28 20:26:47

【AI黑话日日新】Day 045|Reward Model(奖励模型)

一句话说清:奖励模型是替人类给 AI 回答打分的中间人,决定训练里哪条回答更“好”。 1. 它到底在说什么 Reward Model 是英文“奖励模型”的直译。它的工作说白了:你给它一个提示和一条回答,它吐回一个数字,表示“一个普通人有多可能喜欢这条回答”。它存在的唯一理由,是…

作者头像 李华
网站建设 2026/9/28 20:26:16

Java语言的特点

Java为2byte,1byte8bit,故为16bitJDK为Java开发工具包,JRE为Java运行环境,JVM为Java虚拟机,工具下有环境,环境下有虚拟机 .集成开发平台IDE.javac为编译,产生字节码byte code,运行为java.java数据类型:基本引用,基本:字节,字符,短整,整,长整,单精,双精,布尔.byte,short,int,lon…

作者头像 李华