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.