ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 keycite-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 licenseKeyYOUR_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 网络连接相关设置教程
返回列表