前言# ✨
本司最近有一个需求,需要对于pdf文本进行操作,对接ai大模型对pdf文档进行高效解读,其中一个功能就是对于pdf的文本进行划词高亮,用户可进行阅读标记。
这是最简易版的demo:
话不多说 开始吧
划词高亮
基于项目需求需要实现一个划词的功能,最开始的方法是将DOM元素进行切割,这样需要破坏页面 DOM 结构,但是由于pdf的特殊性,如果破坏了 DOM 结构这样对展示以及再对文本进行某些操作就会存在问题。在经过多次尝试最后基于 canvas 实现了这一功能。
主要思路:如何在不破坏原本的页面DOM结构的前提下实现高亮的效果呢,那就不能再在原来的文本元素上进行操作了,最好就是新生成一个元素作为一个画布,将我们想要的效果画下来盖在原本的文本容器元素上。最后在辗转反侧的时候想到了这样一个解决方案:生成一个 canvas 元素,让 canvas 元素与需要划词高亮功能的文本容器元素等宽高,并且重叠在文本容器上,划词的时候获取划词区域的文本节点相对于文本容器的位置信息,然后通过这些位置信息进行高亮背景的渲染。最终在不断调试以及细节完善之后实现了该功能。
实现细节
- 让 canvas 与文本容器元素重叠
让 canvas 与文本容器元素重叠最好的实现方式就是将 canvas 做为文本容器的直接子节点,然后设置文本容易为相对定位,将 canvas 设置为绝对定位,然后将 top、left、right、bottom 都设置为 0,这样就可以时刻保证 canvas 元素与文本容器元素始终等宽高,且 canvas 重叠在文本容器上。不过这种实现方式也有一个问题,我把 canvas 的层级提高了,盖住了文本容器中的其他文本节点,这样就没办法进行划词了,所以这时候需要给 canvas 再添加一个 css 属性:pointerEvents: 'none'
,这样就可以让 canvas 不响应鼠标事件,从而让底部文本节点可以正常划词了。
ini
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;
}
- 获取划词区域文本节点的位置信息
获取划词区域信息需要使用 document.getSelection().getRangeAt(0)
来获得当前划词区域的 range 对象,在这个对象上可以获取到划词区域的起始和终止文本节点以及偏移量信息。
虽然拿到了节点信息,但是怎么获得具体的位置信息呢?这时候就需要借助 Range
对象的强大功能了。
go
// 创建一个 range 对象
const range = document.createRange()
// 设置需要获取位置信息的文本节点以及偏移量
range.setStart(startContainer, startOffset)
range.setEnd(startContainer, startContainer.textContent.length)
// 通过 getBoundingClientRect 获取位置信息
const rect = range.getBoundingClientRect()
通过创建 range 对象可以获得任何一个文本节点中的任何一段文本相对与整个页面的位置信息,然后再通过减去文本容器元素相对于整个页面的位置信息,就可以得到划词区域文本相对与文本容器的位置信息了。
- 获取头尾中间的文本节点
虽然通过 document.getSelection().getRangeAt(0)
获得了划词头尾节点的信息,但是头尾中间如果有其他的文本节点也需要进行背景高亮,那么中间的文本节点该怎么获得呢?这里我想到的办法是从头节点开始进行深度优先遍历,遍历到尾节点为止,然后收集遍历过程中的所有文本节点,这样就得到了整个划词区域内的所有文本节点,然后通过上面第 2 点的办法也可以得到所有文本节点的位置信息。
ini
// 获取 start 到 end 深度优先遍历之间的所有 Text Node 节点
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;
}
- 处理跨行文本节点的位置信息
其实之前第 2 点获取划词区域文本节点的位置信息的方案还有缺陷,对于跨行的文本节点如果仍然采用一个 range 去获取位置信息,那么得到的就是下面这种情况:
没错,位置信息是错误的,因为很明显 range 只能是一个矩形,并没有办法表示跨行选中时的不规则图形的位置信息。
既然一个 range 不行,那么多个呢?所以我的解决思路就是将一个跨行的 range 拆分成多个不跨行的 range。
怎么拆呢?我使用的办法是通过判断起始和结束节点是否为同一个节点,如果起始和结束节点不是同一个节点,说明文本范围跨越了多个节点。
ini
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;
}
// 获取文本节点 DOMRect 对象,支持跨行场景
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());
}
- 高亮
高亮区域采用 konva 库进行渲染,会在划词区域渲染一个 Rect 和底部渲染一个 Line,可通过修改传入 range 对象上的 config 属性进行自定义。
arduino
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,
});
}
- 划词信息持久化与返显
虽然实现了高亮的功能,但是想要实现划词信息持久化与返显功能,那么肯定还涉及到将划词信息保存到后端,但是这一切的开头都是从系统提供的一个 range 对象开始的,但是 range 对象上的 startContainer 和 endContainer 是保存着 DOM 节点的引用,这肯定没办法序列化存储到后端的,所以需要一种方式能让我们准确的找到想要的文本节点。
ini
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 的方式进行储存,对于头尾节点,我们保存一个路径数组,里面储存的是从文本容器通过 childNodes 属性遍历下去找到该节点的信息,这样对于任何的页面结构都可以使用了。
- 移除高亮效果
上面已经储存下来了划词信息,可以看到每条数据我们都给到了一个唯一的id值,这样当用户点击到想要清除高亮效果的文本元素的时候,即可根据这个id移除掉其持久化信息的保存和绘制的canvas图像
js
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';
}
// 通过传入点击位置获取 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();
}