基于pdf.js實(shí)現(xiàn)文本劃詞高亮效果
前言
本司最近有一個(gè)需求,需要對(duì)于pdf文本進(jìn)行操作,對(duì)接ai大模型對(duì)pdf文檔進(jìn)行高效解讀,其中一個(gè)功能就是對(duì)于pdf的文本進(jìn)行劃詞高亮,用戶可進(jìn)行閱讀標(biāo)記。
這是最簡易版的demo:
話不多說 開始吧
劃詞高亮
基于項(xiàng)目需求需要實(shí)現(xiàn)一個(gè)劃詞的功能,最開始的方法是將DOM元素進(jìn)行切割,這樣需要破壞頁面 DOM 結(jié)構(gòu),但是由于pdf的特殊性,如果破壞了 DOM 結(jié)構(gòu)這樣對(duì)展示以及再對(duì)文本進(jìn)行某些操作就會(huì)存在問題。在經(jīng)過多次嘗試最后基于 canvas 實(shí)現(xiàn)了這一功能。
主要思路:如何在不破壞原本的頁面DOM結(jié)構(gòu)的前提下實(shí)現(xiàn)高亮的效果呢,那就不能再在原來的文本元素上進(jìn)行操作了,最好就是新生成一個(gè)元素作為一個(gè)畫布,將我們想要的效果畫下來蓋在原本的文本容器元素上。最后在輾轉(zhuǎn)反側(cè)的時(shí)候想到了這樣一個(gè)解決方案:生成一個(gè) canvas 元素,讓 canvas 元素與需要?jiǎng)澰~高亮功能的文本容器元素等寬高,并且重疊在文本容器上,劃詞的時(shí)候獲取劃詞區(qū)域的文本節(jié)點(diǎn)相對(duì)于文本容器的位置信息,然后通過這些位置信息進(jìn)行高亮背景的渲染。最終在不斷調(diào)試以及細(xì)節(jié)完善之后實(shí)現(xiàn)了該功能。
實(shí)現(xiàn)細(xì)節(jié)
- 讓 canvas 與文本容器元素重疊
讓 canvas 與文本容器元素重疊最好的實(shí)現(xiàn)方式就是將 canvas 做為文本容器的直接子節(jié)點(diǎn),然后設(shè)置文本容易為相對(duì)定位,將 canvas 設(shè)置為絕對(duì)定位,然后將 top、left、right、bottom 都設(shè)置為 0,這樣就可以時(shí)刻保證 canvas 元素與文本容器元素始終等寬高,且 canvas 重疊在文本容器上。不過這種實(shí)現(xiàn)方式也有一個(gè)問題,我把 canvas 的層級(jí)提高了,蓋住了文本容器中的其他文本節(jié)點(diǎn),這樣就沒辦法進(jìn)行劃詞了,所以這時(shí)候需要給 canvas 再添加一個(gè) css 屬性:pointerEvents: 'none'
,這樣就可以讓 canvas 不響應(yīng)鼠標(biāo)事件,從而讓底部文本節(jié)點(diǎn)可以正常劃詞了。
private createContainer() { const el = document.createElement('div'); el.style.position = 'absolute'; el.style.top = '0'; el.style.left = '0'; el.style.right = '0'; el.style.bottom = '0'; el.style.pointerEvents = 'none'; return el; }
- 獲取劃詞區(qū)域文本節(jié)點(diǎn)的位置信息
獲取劃詞區(qū)域信息需要使用 document.getSelection().getRangeAt(0)
來獲得當(dāng)前劃詞區(qū)域的 range 對(duì)象,在這個(gè)對(duì)象上可以獲取到劃詞區(qū)域的起始和終止文本節(jié)點(diǎn)以及偏移量信息。
雖然拿到了節(jié)點(diǎn)信息,但是怎么獲得具體的位置信息呢?這時(shí)候就需要借助 Range
對(duì)象的強(qiáng)大功能了。
// 創(chuàng)建一個(gè) range 對(duì)象 const range = document.createRange() // 設(shè)置需要獲取位置信息的文本節(jié)點(diǎn)以及偏移量 range.setStart(startContainer, startOffset) range.setEnd(startContainer, startContainer.textContent.length) // 通過 getBoundingClientRect 獲取位置信息 const rect = range.getBoundingClientRect()
通過創(chuàng)建 range 對(duì)象可以獲得任何一個(gè)文本節(jié)點(diǎn)中的任何一段文本相對(duì)與整個(gè)頁面的位置信息,然后再通過減去文本容器元素相對(duì)于整個(gè)頁面的位置信息,就可以得到劃詞區(qū)域文本相對(duì)與文本容器的位置信息了。
- 獲取頭尾中間的文本節(jié)點(diǎn)
雖然通過 document.getSelection().getRangeAt(0)
獲得了劃詞頭尾節(jié)點(diǎn)的信息,但是頭尾中間如果有其他的文本節(jié)點(diǎn)也需要進(jìn)行背景高亮,那么中間的文本節(jié)點(diǎn)該怎么獲得呢?這里我想到的辦法是從頭節(jié)點(diǎn)開始進(jìn)行深度優(yōu)先遍歷,遍歷到尾節(jié)點(diǎn)為止,然后收集遍歷過程中的所有文本節(jié)點(diǎn),這樣就得到了整個(gè)劃詞區(qū)域內(nèi)的所有文本節(jié)點(diǎn),然后通過上面第 2 點(diǎn)的辦法也可以得到所有文本節(jié)點(diǎn)的位置信息。
// 獲取 start 到 end 深度優(yōu)先遍歷之間的所有 Text Node 節(jié)點(diǎn) export function getTextNodesByDfs(start: Text, end: Text) { if (start === end) return []; const iterator = nodeDfsGenerator(start, false); const textNodes = []; iterator.next(); let value = iterator.next().value; while (value) { if (value === end) { textNodes.push(value); return textNodes; } else if (isTextNode(value)) { textNodes.push(value); } value = iterator.next().value; } if (!value) { return []; } return textNodes; }
- 處理跨行文本節(jié)點(diǎn)的位置信息
其實(shí)之前第 2 點(diǎn)獲取劃詞區(qū)域文本節(jié)點(diǎn)的位置信息的方案還有缺陷,對(duì)于跨行的文本節(jié)點(diǎn)如果仍然采用一個(gè) range 去獲取位置信息,那么得到的就是下面這種情況:
沒錯(cuò),位置信息是錯(cuò)誤的,因?yàn)楹苊黠@ range 只能是一個(gè)矩形,并沒有辦法表示跨行選中時(shí)的不規(guī)則圖形的位置信息。
既然一個(gè) range 不行,那么多個(gè)呢?所以我的解決思路就是將一個(gè)跨行的 range 拆分成多個(gè)不跨行的 range。
怎么拆呢?我使用的辦法是通過判斷起始和結(jié)束節(jié)點(diǎn)是否為同一個(gè)節(jié)點(diǎn),如果起始和結(jié)束節(jié)點(diǎn)不是同一個(gè)節(jié)點(diǎn),說明文本范圍跨越了多個(gè)節(jié)點(diǎn)。
createRects(range: IRange) { const rects: DOMRect[] = []; const { start, end } = range; const startNode = this.getNodeByPath(start.path, range); const endNode = this.getNodeByPath(end.path, range); if (startNode === endNode) { rects.push(...getTextNodeRects(startNode, start.offset, end.offset)); } else { const textNodes = getTextNodesByDfs(startNode, endNode); rects.push(...getTextNodeRects(startNode, start.offset)); textNodes.forEach((i, index) => { if (index === 0 || index === textNodes?.length - 1) { return; } const nodeRects = getTextNodeRects(i); if (nodeRects.length === 1 && (nodeRects[0].width === 0 || nodeRects[0].height === 0)) { // 過濾空 Text return; } else { rects.push(...nodeRects); } }); rects.push(...getTextNodeRects(endNode, 0, end.offset)); } return rects; } // 獲取文本節(jié)點(diǎn) DOMRect 對(duì)象,支持跨行場景 export function getTextNodeRects(node: Text, startOffset?: number, endOffset?: number): DOMRect[] { const iframe = document.getElementById('iframe') as HTMLElement | any; const iframeDocument = iframe?.contentDocument || iframe?.contentWindow?.document; if (!node) { return []; } if (startOffset === undefined) { startOffset = 0; } if (endOffset === undefined) { endOffset = node.textContent!.length as number; } let TextNode = isTextNode(node) ? node : node.firstChild; const range = document.createRange(); range.setStart(TextNode, startOffset); range.setEnd(TextNode, endOffset); return Array.from(range.getClientRects()); }
- 高亮
高亮區(qū)域采用 konva 庫進(jìn)行渲染,會(huì)在劃詞區(qū)域渲染一個(gè) Rect 和底部渲染一個(gè) Line,可通過修改傳入 range 對(duì)象上的 config 屬性進(jìn)行自定義。
renderRange(domRects: DOMRect[], id: string, config: IRangeConfig) { const { group, rectGroup, lineGroup, shapeGroup } = this.createGroup(id, config); const { top, left } = this.getRootPosition(); const positions: IRectPosition[] = []; domRects.forEach((i, index) => { const x = i.left - left; const y = i.top - top; const position = { x, y, width: i.width, height: i.height, }; positions.push(position); const shapeConstructors = this.config.shapeConstructors; if (shapeGroup && shapeConstructors) { shapeConstructors.forEach((fn) => { shapeGroup.add(fn(position, id, domRects, index)); }); } rectGroup.add(this.createRect(position, config.rect)); lineGroup.add(this.createLine(position, config.line)); }); this.groups.push({ id, group, positions }); this.layer.add(group); } private createGroup(id: string, config: IRangeConfig) { const group = new Konva.Group({ id, x: 0, y: 0 }); const rectGroup = new Konva.Group({ id: RECT_PREFIX + id, x: 0, y: 0, visible: config.rect?.visible || true, }); const lineGroup = new Konva.Group({ id: LINE_PREFIX + id, x: 0, y: 0, visible: config.line?.visible || true, }); const shapeConstructors = this.config.shapeConstructors; let shapeGroup: Konva.Group | null = null; if (shapeConstructors && shapeConstructors.length > 0) { shapeGroup = new Konva.Group({ id: SHAPE_PREFIX + id, x: 0, y: 0, }); group.add(shapeGroup); } group.add(rectGroup); group.add(lineGroup); return { group, rectGroup, lineGroup, shapeGroup }; } private createRect(position: IRectPosition, config: IRangeConfig['rect']) { return new Konva.Rect({ ...position, fill: config.fill, ...config.konvaConfig, }); } private createLine(position: IRectPosition, config: IRangeConfig['line']) { const { x, y, width, height } = position; return new Konva.Line({ points: [x, y + height, x + width, y + height], stroke: config.stroke, strokeWidth: config.strokeWidth, ...config.konvaConfig, }); }
- 劃詞信息持久化與返顯
雖然實(shí)現(xiàn)了高亮的功能,但是想要實(shí)現(xiàn)劃詞信息持久化與返顯功能,那么肯定還涉及到將劃詞信息保存到后端,但是這一切的開頭都是從系統(tǒng)提供的一個(gè) range 對(duì)象開始的,但是 range 對(duì)象上的 startContainer 和 endContainer 是保存著 DOM 節(jié)點(diǎn)的引用,這肯定沒辦法序列化存儲(chǔ)到后端的,所以需要一種方式能讓我們準(zhǔn)確的找到想要的文本節(jié)點(diǎn)。
function createRange(selection: Selection): IRange | null { if (!isValidSelection(selection)) return null; const { startContainer: start, startOffset, endContainer: end, endOffset } = selection.getRangeAt(0); const sPath = getPath(start); const ePath = start === end ? sPath : getPath(end); const page = Number( findParentWithAriaLabel(selection.anchorNode, 'aria-label')?.getAttribute('data-page-number') ); if (!sPath || !ePath) return null; const text = getStartAndEndRangeText(start, startOffset, end, endOffset); return { id: uuid(8), page, text: selection.toString(), start: { path: sPath, offset: startOffset, text: text.start, }, end: { path: ePath, offset: endOffset, text: text.end, }, config: { color: newColor, rect: { fill: newColor, visible: true, }, line: { stroke: newColor, visible: false, strokeWidth: 0, }, }, }; } function getPath(textNode: Node) { const path = [0]; let parentNode = textNode.parentNode; let cur = textNode; let shouldBreak = false; while (parentNode && !shouldBreak) { if (cur === parentNode.firstChild) { if (parentNode?.classList.contains('markedContent')) { shouldBreak = true; path.push(parentNode?.getAttribute('id')); break; } if (parentNode.hasAttribute('data-main-rotation')) { shouldBreak = true; break; } else { cur = parentNode; parentNode = cur.parentNode; path.unshift(0); } } else { cur = cur?.previousSibling; path[0]++; } } return parentNode ? path : null; } function getStartAndEndRangeText(start: Text, startOffset: number, end: Text, endOffset: number) { let startText = ''; let endText = ''; if (start === end) { startText = start.textContent ? start.textContent.slice(startOffset, endOffset) : ''; endText = startText; } else { startText = start.textContent ? start.textContent.slice(startOffset) : ''; endText = end.textContent ? end.textContent.slice(0, endOffset) : ''; } return { start: startText, end: endText, }; }
這里我采用的是類似 XPath 的方式進(jìn)行儲(chǔ)存,對(duì)于頭尾節(jié)點(diǎn),我們保存一個(gè)路徑數(shù)組,里面儲(chǔ)存的是從文本容器通過 childNodes 屬性遍歷下去找到該節(jié)點(diǎn)的信息,這樣對(duì)于任何的頁面結(jié)構(gòu)都可以使用了。
- 移除高亮效果
上面已經(jīng)儲(chǔ)存下來了劃詞信息,可以看到每條數(shù)據(jù)我們都給到了一個(gè)唯一的id值,這樣當(dāng)用戶點(diǎn)擊到想要清除高亮效果的文本元素的時(shí)候,即可根據(jù)這個(gè)id移除掉其持久化信息的保存和繪制的canvas圖像
removeHighlight() { let _this = this; _this.rootDocument.addEventListener('click', function (e: MouseEvent) { clearCanvas(); if (_this.eraser.classList.contains('eraser-click')) { const targetEvent = e.target as HTMLElement; if (!targetEvent?.className) { _this.btn.style.display = 'none'; } // 通過傳入點(diǎn)擊位置獲取 range id const id = _this.stage.getGroupIdByPointer(e.clientX, e.clientY); if (id) { let temList = _this.highList.filter((item) => item.id !== id); _this.stage.deleteRange(id); _this.setHighlight(temList); _this.highList = temList; } } }); } deleteRange(id: string) { const index = this.groups.findIndex((i) => i.id === id); if (index === -1) return false; this.groups.splice(index, 1); const group = this.layer.find('#' + id)[0]; if (group) group.destroy(); }
以上就是基于pdf.js實(shí)現(xiàn)文本劃詞高亮效果的詳細(xì)內(nèi)容,更多關(guān)于pdf.js文本劃詞高亮的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
使用Webpack壓縮與轉(zhuǎn)譯JavaScript代碼的操作方法
在Web開發(fā)中,代碼的性能和加載時(shí)間是用戶體驗(yàn)的重要組成部分,為此,將JavaScript代碼壓縮和優(yōu)化是發(fā)布前一個(gè)必不可少的步驟,所以本文給大家介紹了如何使用Webpack壓縮與轉(zhuǎn)譯JavaScript代碼,需要的朋友可以參考下2024-05-05用VsCode編輯TypeScript的實(shí)現(xiàn)方法
這篇文章主要介紹了用VsCode編輯TypeScript的實(shí)現(xiàn)方法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-05-05JS表格組件BootstrapTable行內(nèi)編輯解決方案x-editable
這篇文章主要介紹了JS組件系列BootstrapTable行內(nèi)編輯解決方案:x-editable,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-09-09用JavaScript做一個(gè)簡易計(jì)算器的三種方法舉例
這篇文章主要給大家介紹了關(guān)于用JavaScript做一個(gè)簡易計(jì)算器的三種方法,JS中實(shí)現(xiàn)一個(gè)簡單的計(jì)算器并不困難,我們只需利用基本的數(shù)學(xué)運(yùn)算符和JavaScript的語法即可,需要的朋友可以參考下2023-10-1010個(gè)經(jīng)典的網(wǎng)頁鼠標(biāo)特效代碼
小編為廣大讀者們整理了10個(gè)經(jīng)典的網(wǎng)頁鼠標(biāo)特效代碼,并對(duì)代碼進(jìn)行了編譯和解釋,需要的朋友收藏下吧。2018-01-01微信小程序?qū)崿F(xiàn)搜索指定景點(diǎn)周邊美食、酒店
這篇文章主要為大家詳細(xì)介紹了微信小程序?qū)崿F(xiàn)搜索指定景點(diǎn)周邊美食、酒店的功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-05-05微信小程序用戶后臺(tái)定位及錄音授權(quán)及請(qǐng)求示例
這篇文章主要為大家介紹了微信小程序用戶后臺(tái)定位及錄音授權(quán)及請(qǐng)求示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-04-04