PageSourceSearch

https://scribeocr.com/scribe.js/scribe-ui/js/viewerHighlights.js

js scribeocr.com collected 2026-09-25 20:05:37 UTC 9,841 bytes, 294 lines download raw bytes

1/* eslint-disable import/no-cycle */
2import { KonvaOcrWord } from './viewerWordObjects.js';
3import Konva from './konva/index.js';
4
5/**
6 * Checks if a word bbox is highlighted by an annotation.
7 * Uses bbox containment as a pre-filter, then checks quads overlap if present.
8 * @param {AnnotationHighlight} annot
9 * @param {bbox} wb - Word bounding box
10 */
11export function annotMatchesWord(annot, wb) {
12  if (!(annot.bbox.left <= wb.left && annot.bbox.right >= wb.right
13    && annot.bbox.top <= wb.top && annot.bbox.bottom >= wb.bottom)) return false;
14  if (annot.quads) {
15    return annot.quads.some((quad) => quad.left < wb.right && quad.right > wb.left
16      && quad.top < wb.bottom && quad.bottom > wb.top);
17  }
18  return true;
19}
20
21/**
22 * Draws outline rectangles around all words in the same highlight group as the selected word.
23 * Clears any previous outlines first.
24 * @param {import('../viewer.js').ScribeViewer} viewer
25 */
26export function updateHighlightGroupOutline(viewer) {
27  for (const rect of viewer._highlightOutlineRects) rect.destroy();
28  viewer._highlightOutlineRects.length = 0;
29
30  const selectedWords = viewer.CanvasSelection.getKonvaWords();
31  if (!selectedWords || selectedWords.length === 0) {
32    viewer.layerText.batchDraw();
33    return;
34  }
35
36  const firstWord = selectedWords[0];
37  if (!firstWord.highlightGroupId) {
38    viewer.layerText.batchDraw();
39    return;
40  }
41
42  const groupId = firstWord.highlightGroupId;
43  const allWords = viewer.getKonvaWords();
44  const groupWords = allWords.filter((kw) => kw.highlightGroupId === groupId);
45  if (groupWords.length === 0) {
46    viewer.layerText.batchDraw();
47    return;
48  }
49
50  const pageMap = new Map();
51  for (const kw of groupWords) {
52    const pageN = kw.word.line.page.n;
53    if (!pageMap.has(pageN)) pageMap.set(pageN, []);
54    pageMap.get(pageN).push(kw);
55  }
56
57  const scale = viewer.layerText.getAbsoluteScale()?.x || 1;
58
59  for (const [pageN, pageWords] of pageMap) {
60    const lineMap = new Map();
61    for (const kw of pageWords) {
62      const lineId = kw.word.line.id;
63      if (!lineMap.has(lineId)) lineMap.set(lineId, []);
64      lineMap.get(lineId).push(kw);
65    }
66
67    const group = viewer.getTextGroup(pageN);
68    const pad = pageWords[0].height() * 0.2;
69
70    for (const lineWords of lineMap.values()) {
71      lineWords.sort((a, b) => a.x() - b.x());
72      const first = lineWords[0];
73      const last = lineWords[lineWords.length - 1];
74      const left = first.x() - first.highlightGapLeft;
75      const right = last.x() + last.width() + last.highlightGapRight;
76      const top = first.y() - pad;
77      const height = first.height() + pad * 2;
78
79      const rect = new Konva.Rect({
80        x: left,
81        y: top,
82        width: right - left,
83        height,
84        stroke: 'rgba(40,123,181,0.8)',
85        strokeWidth: 2 / scale,
86        dash: [8 / scale, 5 / scale],
87        draggable: false,
88        listening: false,
89      });
90      group.add(rect);
91      viewer._highlightOutlineRects.push(rect);
92    }
93  }
94
95  viewer.layerText.batchDraw();
96}
97
98/**
99 * Recalculates highlight gap extensions for words on the same lines as the given words.
100 * @param {import('../viewer.js').ScribeViewer} viewer
101 * @param {Array<InstanceType<typeof KonvaOcrWord>>} changedWords
102 */
103function updateHighlightGaps(viewer, changedWords) {
104  const allWords = viewer.getKonvaWords();
105  const affectedLineIds = new Set(changedWords.map((kw) => kw.word.line.id));
106  const lineWords = new Map();
107  for (const kw of allWords) {
108    if (!affectedLineIds.has(kw.word.line.id)) continue;
109    if (!lineWords.has(kw.word.line.id)) lineWords.set(kw.word.line.id, []);
110    lineWords.get(kw.word.line.id).push(kw);
111  }
112  for (const words of lineWords.values()) {
113    words.sort((a, b) => a.x() - b.x());
114    for (let i = 0; i < words.length; i++) {
115      const wc = words[i];
116      wc.highlightGapLeft = 0;
117      wc.highlightGapRight = 0;
118      if (!wc.highlightColor) continue;
119      if (i > 0 && words[i - 1].highlightColor) {
120        wc.highlightGapLeft = (wc.x() - (words[i - 1].x() + words[i - 1].width())) / 2;
121      }
122      if (i < words.length - 1 && words[i + 1].highlightColor) {
123        wc.highlightGapRight = (words[i + 1].x() - (wc.x() + wc.width())) / 2;
124      }
125    }
126  }
127}
128
129/**
130 * Removes highlight from the given words and their annotation data.
131 * @param {import('../viewer.js').ScribeViewer} viewer
132 * @param {Array<InstanceType<typeof KonvaOcrWord>>} selectedWords
133 * @param {number} pageIndex
134 */
135export function removeHighlight(viewer, selectedWords, pageIndex) {
136  if (!selectedWords || selectedWords.length === 0) return;
137
138  for (const kw of selectedWords) {
139    kw.highlightColor = null;
140    kw.highlightOpacity = 1;
141  }
142  for (const kw of selectedWords) {
143    const wb = kw.word.bbox;
144    viewer.doc.annotations.pages[pageIndex] = viewer.doc.annotations.pages[pageIndex].filter(
145      (annot) => !annotMatchesWord(annot, wb),
146    );
147  }
148  updateHighlightGaps(viewer, selectedWords);
149  updateHighlightGroupOutline(viewer);
150  viewer.layerText.batchDraw();
151  KonvaOcrWord.updateUI();
152}
153
154/**
155 * Applies highlight color to the given words and creates/updates annotation data.
156 * @param {import('../viewer.js').ScribeViewer} viewer
157 * @param {Array<InstanceType<typeof KonvaOcrWord>>} selectedWords
158 * @param {number} pageIndex
159 * @param {string} color
160 * @param {number} opacity
161 */
162export function applyHighlight(viewer, selectedWords, pageIndex, color, opacity) {
163  if (!selectedWords || selectedWords.length === 0) return;
164
165  for (const kw of selectedWords) {
166    const wb = kw.word.bbox;
167    const existingAnnot = viewer.doc.annotations.pages[pageIndex].find(
168      (annot) => annotMatchesWord(annot, wb),
169    );
170    if (existingAnnot) {
171      existingAnnot.color = color;
172      existingAnnot.opacity = opacity;
173      if (!existingAnnot.groupId) {
174        existingAnnot.groupId = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
175        existingAnnot.comment = existingAnnot.comment || '';
176      }
177    }
178    kw.highlightColor = color;
179    kw.highlightOpacity = opacity;
180  }
181
182  const wordsWithoutAnnot = selectedWords.filter((kw) => {
183    const wb = kw.word.bbox;
184    return !viewer.doc.annotations.pages[pageIndex].some(
185      (annot) => annotMatchesWord(annot, wb),
186    );
187  });
188
189  if (wordsWithoutAnnot.length > 0) {
190    const allWords = viewer.getKonvaWords();
191    const selectedSet = new Set(wordsWithoutAnnot.map((kw) => kw.word.id));
192
193    const lineMap = new Map();
194    for (const kw of wordsWithoutAnnot) {
195      const lineId = kw.word.line.id;
196      if (!lineMap.has(lineId)) lineMap.set(lineId, []);
197      lineMap.get(lineId).push(kw);
198    }
199
200    for (const words of lineMap.values()) {
201      words.sort((a, b) => a.x() - b.x());
202    }
203
204    const lineRuns = [];
205    for (const [lineId, words] of lineMap) {
206      const lineAllWords = allWords.filter((kw) => kw.word.line.id === lineId);
207      lineAllWords.sort((a, b) => a.x() - b.x());
208
209      let currentRun = [words[0]];
210      for (let i = 1; i < words.length; i++) {
211        const prev = words[i - 1];
212        const curr = words[i];
213        const hasGap = lineAllWords.some((kw) => !selectedSet.has(kw.word.id)
214          && kw.x() > prev.x() && kw.x() < curr.x());
215        if (hasGap) {
216          lineRuns.push({ lineId, words: currentRun });
217          currentRun = [curr];
218        } else {
219          currentRun.push(curr);
220        }
221      }
222      lineRuns.push({ lineId, words: currentRun });
223    }
224
225    lineRuns.sort((a, b) => a.words[0].word.line.bbox.top - b.words[0].word.line.bbox.top);
226
227    const groups = [[lineRuns[0]]];
228    for (let i = 1; i < lineRuns.length; i++) {
229      const prevGroup = groups[groups.length - 1];
230      const prevRun = prevGroup[prevGroup.length - 1];
231      const currRun = lineRuns[i];
232      const prevBottom = prevRun.words[0].word.line.bbox.bottom;
233      const currTop = currRun.words[0].word.line.bbox.top;
234      const lineHeight = prevRun.words[0].word.line.bbox.bottom - prevRun.words[0].word.line.bbox.top;
235      if (currTop - prevBottom < lineHeight * 2) {
236        prevGroup.push(currRun);
237      } else {
238        groups.push([currRun]);
239      }
240    }
241
242    for (const group of groups) {
243      const groupId = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
244      for (const run of group) {
245        for (const kw of run.words) {
246          const wb = kw.word.bbox;
247          viewer.doc.annotations.pages[pageIndex].push({
248            bbox: {
249              left: wb.left, top: wb.top, right: wb.right, bottom: wb.bottom,
250            },
251            color,
252            opacity,
253            groupId,
254            comment: '',
255          });
256          kw.highlightGroupId = groupId;
257          kw.highlightComment = '';
258        }
259      }
260    }
261  }
262
263  updateHighlightGaps(viewer, selectedWords);
264  updateHighlightGroupOutline(viewer);
265  viewer.layerText.batchDraw();
266  KonvaOcrWord.updateUI();
267}
268
269/**
270 * Updates the comment on the highlight group of the first selected word.
271 * @param {import('../viewer.js').ScribeViewer} viewer
272 * @param {Array<InstanceType<typeof KonvaOcrWord>>} selectedWords
273 * @param {number} pageIndex
274 * @param {string} comment
275 */
276export function modifyHighlightComment(viewer, selectedWords, pageIndex, comment) {
277  if (!selectedWords || selectedWords.length === 0) return;
278  const wb = selectedWords[0].word.bbox;
279  const matchingAnnot = viewer.doc.annotations.pages[pageIndex].find(
280    (annot) => annotMatchesWord(annot, wb),
281  );
282  if (!matchingAnnot || !matchingAnnot.groupId) return;
283  for (const annot of viewer.doc.annotations.pages[pageIndex]) {
284    if (annot.groupId === matchingAnnot.groupId) {
285      annot.comment = comment;
286    }
287  }
288  for (const kw of viewer.getKonvaWords()) {
289    if (kw.highlightGroupId === matchingAnnot.groupId) {
290      kw.highlightComment = comment;
291    }
292  }
293  viewer.layerText.batchDraw();
294}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.