@internetarchive/bookreader 5.0.0-61 → 5.0.0-62

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,6 @@
1
1
  //@ts-check
2
- import { createSVGPageLayer } from '../BookReader/PageContainer.js';
3
- import { SelectionStartedObserver } from '../BookReader/utils/SelectionStartedObserver.js';
4
- import { isFirefox, isSafari } from '../util/browserSniffing.js';
2
+ import { createDIVPageLayer } from '../BookReader/PageContainer.js';
3
+ import { SelectionObserver } from '../BookReader/utils/SelectionObserver.js';
5
4
  import { applyVariables } from '../util/strings.js';
6
5
  /** @typedef {import('../util/strings.js').StringWithVars} StringWithVars */
7
6
  /** @typedef {import('../BookReader/PageContainer.js').PageContainer} PageContainer */
@@ -41,25 +40,18 @@ export class Cache {
41
40
  }
42
41
 
43
42
  export class TextSelectionPlugin {
44
-
45
- constructor(options = DEFAULT_OPTIONS, optionVariables, avoidTspans = isFirefox(), pointerEventsOnParagraph = isSafari()) {
43
+ /**
44
+ * @param {'lr' | 'rl'} pageProgression In the future this should be in the ocr file
45
+ * since a book being right to left doesn't mean the ocr is right to left. But for
46
+ * now we do make that assumption.
47
+ */
48
+ constructor(options = DEFAULT_OPTIONS, optionVariables, pageProgression = 'lr') {
46
49
  this.options = options;
47
50
  this.optionVariables = optionVariables;
48
51
  /**@type {PromiseLike<JQuery<HTMLElement>|undefined>} */
49
52
  this.djvuPagesPromise = null;
50
- // Using text elements instead of tspans for words because Firefox does not allow svg tspan stretch.
51
- // Tspans are necessary on Chrome because they prevent newline character after every word when copying
52
- this.svgParagraphElement = "text";
53
- this.svgWordElement = "tspan";
54
- this.insertNewlines = avoidTspans;
55
- // Safari has a bug where `pointer-events` doesn't work on `<tspans>`. So
56
- // there we will set `pointer-events: all` on the paragraph element. We don't
57
- // do this everywhere, because it's a worse experience. Thanks Safari :/
58
- this.pointerEventsOnParagraph = pointerEventsOnParagraph;
59
- if (avoidTspans) {
60
- this.svgParagraphElement = "g";
61
- this.svgWordElement = "text";
62
- }
53
+ /** Whether the book is right-to-left */
54
+ this.rtl = pageProgression === 'rl';
63
55
 
64
56
  /** @type {Cache<{index: number, response: any}>} */
65
57
  this.pageTextCache = new Cache();
@@ -69,9 +61,27 @@ export class TextSelectionPlugin {
69
61
  * unusable. For now don't render text layer for pages with too many words.
70
62
  */
71
63
  this.maxWordRendered = 2500;
64
+
65
+ this.selectionObserver = new SelectionObserver('.BRtextLayer', this._onSelectionChange);
66
+ }
67
+
68
+ /**
69
+ * @param {'started' | 'cleared'} type
70
+ * @param {HTMLElement} target
71
+ */
72
+ _onSelectionChange = (type, target) => {
73
+ if (type === 'started') {
74
+ this.textSelectingMode(target);
75
+ } else if (type === 'cleared') {
76
+ this.defaultMode(target);
77
+ } else {
78
+ throw new Error(`Unknown type ${type}`);
79
+ }
72
80
  }
73
81
 
74
82
  init() {
83
+ this.selectionObserver.attach();
84
+
75
85
  // Only fetch the full djvu xml if the single page url isn't there
76
86
  if (this.options.singlePageDjvuXmlUrl) return;
77
87
  this.djvuPagesPromise = $.ajax({
@@ -135,53 +145,73 @@ export class TextSelectionPlugin {
135
145
 
136
146
  /**
137
147
  * Applies mouse events when in default mode
138
- * @param {SVGElement} svg
148
+ * @param {HTMLElement} textLayer
139
149
  */
140
- defaultMode(svg) {
141
- svg.classList.remove("selectingSVG");
142
- $(svg).on("mousedown.textSelectPluginHandler", (event) => {
143
- if (!$(event.target).is(".BRwordElement")) return;
144
- event.stopPropagation();
145
- svg.classList.add("selectingSVG");
146
- $(svg).one("mouseup.textSelectPluginHandler", (event) => {
147
- if (window.getSelection().toString() != "") {
148
- event.stopPropagation();
149
- $(svg).off(".textSelectPluginHandler");
150
- this.textSelectingMode(svg);
151
- }
152
- else svg.classList.remove("selectingSVG");
153
- });
150
+ defaultMode(textLayer) {
151
+ const $pageContainer = $(textLayer).closest('.BRpagecontainer');
152
+ textLayer.style.pointerEvents = "none";
153
+ $pageContainer.find("img").css("pointer-events", "auto");
154
+
155
+ $(textLayer).off(".textSelectPluginHandler");
156
+ const startedMouseDown = this.mouseIsDown;
157
+ let skipNextMouseup = this.mouseIsDown;
158
+ if (startedMouseDown) {
159
+ textLayer.style.pointerEvents = "auto";
160
+ }
161
+
162
+ // Need to stop propagation to prevent DragScrollable from
163
+ // blocking selection
164
+ $(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
165
+ this.mouseIsDown = true;
166
+ if ($(event.target).is(".BRwordElement, .BRspace")) {
167
+ event.stopPropagation();
168
+ }
169
+ });
170
+
171
+ $(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
172
+ this.mouseIsDown = false;
173
+ textLayer.style.pointerEvents = "none";
174
+ if (skipNextMouseup) {
175
+ skipNextMouseup = false;
176
+ event.stopPropagation();
177
+ }
154
178
  });
155
179
  }
156
180
 
157
181
  /**
158
- * Applies mouse events when in textSelecting mode
159
- * @param {SVGElement} svg
182
+ * This mode is active while there is a selection on the given textLayer
183
+ * @param {HTMLElement} textLayer
160
184
  */
161
- textSelectingMode(svg) {
162
- $(svg).on('mousedown.textSelectPluginHandler', (event) => {
163
- if (!$(event.target).is(".BRwordElement")) {
164
- if (window.getSelection().toString() != "") window.getSelection().removeAllRanges();
165
- }
185
+ textSelectingMode(textLayer) {
186
+ const $pageContainer = $(textLayer).closest('.BRpagecontainer');
187
+ // Make text layer consume all events
188
+ textLayer.style.pointerEvents = "all";
189
+ // Block img from getting long-press to save while selecting
190
+ $pageContainer.find("img").css("pointer-events", "none");
191
+
192
+ $(textLayer).off(".textSelectPluginHandler");
193
+
194
+ $(textLayer).on('mousedown.textSelectPluginHandler', (event) => {
195
+ this.mouseIsDown = true;
166
196
  event.stopPropagation();
167
197
  });
168
- $(svg).on('mouseup.textSelectPluginHandler', (event) => {
198
+
199
+ // Prevent page flip on click
200
+ $(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
201
+ this.mouseIsDown = false;
169
202
  event.stopPropagation();
170
- if (window.getSelection().toString() == "") {
171
- $(svg).off(".textSelectPluginHandler");
172
- this.defaultMode(svg); }
173
203
  });
174
204
  }
175
205
 
176
206
  /**
177
- * Initializes text selection modes if there is an svg on the page
207
+ * Initializes text selection modes if there is a text layer on the page
178
208
  * @param {JQuery} $container
179
209
  */
180
210
  stopPageFlip($container) {
181
- /** @type {JQuery<SVGElement>} */
182
- const $svg = $container.find('svg.textSelectionSVG');
183
- if (!$svg.length) return;
184
- $svg.each((i, s) => this.defaultMode(s));
211
+ /** @type {JQuery<HTMLElement>} */
212
+ const $textLayer = $container.find('.BRtextLayer');
213
+ if (!$textLayer.length) return;
214
+ $textLayer.each((i, s) => this.defaultMode(s));
185
215
  this.interceptCopy($container);
186
216
  }
187
217
 
@@ -191,10 +221,11 @@ export class TextSelectionPlugin {
191
221
  async createTextLayer(pageContainer) {
192
222
  const pageIndex = pageContainer.page.index;
193
223
  const $container = pageContainer.$container;
194
- const $svgLayers = $container.find('.textSelectionSVG');
195
- if ($svgLayers.length) return;
224
+ const $textLayers = $container.find('.BRtextLayer');
225
+ if ($textLayers.length) return;
196
226
  const XMLpage = await this.getPageText(pageIndex);
197
227
  if (!XMLpage) return;
228
+ recursivelyAddCoords(XMLpage);
198
229
 
199
230
  const totalWords = $(XMLpage).find("WORD").length;
200
231
  if (totalWords > this.maxWordRendered) {
@@ -202,66 +233,174 @@ export class TextSelectionPlugin {
202
233
  return;
203
234
  }
204
235
 
205
- const svg = createSVGPageLayer(pageContainer.page, 'textSelectionSVG');
206
- $container.append(svg);
207
-
208
- $(XMLpage).find("PARAGRAPH").each((i, paragraph) => {
209
- // Adding text element for each paragraph in the page
210
- const words = $(paragraph).find("WORD");
211
- if (!words.length) return;
212
- const paragSvg = document.createElementNS("http://www.w3.org/2000/svg", this.svgParagraphElement);
213
- paragSvg.setAttribute("class", "BRparagElement");
214
- if (this.pointerEventsOnParagraph) {
215
- paragSvg.style.pointerEvents = "all";
216
- }
236
+ const textLayer = createDIVPageLayer(pageContainer.page, 'BRtextLayer');
237
+ const ratioW = parseFloat(pageContainer.$container[0].style.width) / pageContainer.page.width;
238
+ const ratioH = parseFloat(pageContainer.$container[0].style.height) / pageContainer.page.height;
239
+ textLayer.style.transform = `scale(${ratioW}, ${ratioH})`;
240
+ textLayer.setAttribute("dir", this.rtl ? "rtl" : "ltr");
241
+
242
+ const ocrParagraphs = $(XMLpage).find("PARAGRAPH[coords]").toArray();
243
+ const paragEls = ocrParagraphs.map(p => {
244
+ const el = this.renderParagraph(p);
245
+ textLayer.appendChild(el);
246
+ return el;
247
+ });
248
+
249
+ // Fix up paragraph positions
250
+ const paragraphRects = determineRealRects(textLayer, '.BRparagraphElement');
251
+ let yAdded = 0;
252
+ for (const [ocrParagraph, paragEl] of zip(ocrParagraphs, paragEls)) {
253
+ const ocrParagBounds = $(ocrParagraph).attr("coords").split(",").map(parseFloat);
254
+ const realRect = paragraphRects.get(paragEl);
255
+ const [ocrLeft, , ocrRight, ocrTop] = ocrParagBounds;
256
+ const newStartMargin = this.rtl ? (realRect.right - ocrRight) : (ocrLeft - realRect.left);
257
+ const newTop = ocrTop - (realRect.top + yAdded);
258
+
259
+ paragEl.style[this.rtl ? 'marginRight' : 'marginLeft'] = `${newStartMargin}px`;
260
+ paragEl.style.marginTop = `${newTop}px`;
261
+ yAdded += newTop;
262
+ textLayer.appendChild(paragEl);
263
+ }
264
+ $container.append(textLayer);
265
+ this.stopPageFlip($container);
266
+ }
267
+
268
+ /**
269
+ * @param {HTMLElement} ocrParagraph
270
+ * @returns {HTMLParagraphElement}
271
+ */
272
+ renderParagraph(ocrParagraph) {
273
+ const paragEl = document.createElement('p');
274
+ paragEl.classList.add('BRparagraphElement');
275
+ const [paragLeft, paragBottom, paragRight, paragTop] = $(ocrParagraph).attr("coords").split(",").map(parseFloat);
276
+ const wordHeightArr = [];
277
+ const lines = $(ocrParagraph).find("LINE[coords]").toArray();
278
+ if (!lines.length) return paragEl;
217
279
 
218
- const wordHeightArr = [];
219
280
 
220
- for (let i = 0; i < words.length; i++) {
221
- // Adding tspan for each word in paragraph
222
- const currWord = words[i];
223
- // eslint-disable-next-line no-unused-vars
224
- const [left, bottom, right, top] = $(currWord).attr("coords").split(',').map(parseFloat);
281
+ for (const [prevLine, line, nextLine] of lookAroundWindow(genMap(lines, augmentLine))) {
282
+ const isLastLineOfParagraph = line.ocrElement == lines[lines.length - 1];
283
+ const lineEl = document.createElement('span');
284
+ lineEl.classList.add('BRlineElement');
285
+
286
+ for (const [wordIndex, currWord] of line.words.entries()) {
287
+ const [, bottom, right, top] = $(currWord).attr("coords").split(',').map(parseFloat);
225
288
  const wordHeight = bottom - top;
226
289
  wordHeightArr.push(wordHeight);
227
290
 
228
- const wordTspan = document.createElementNS("http://www.w3.org/2000/svg", this.svgWordElement);
229
- wordTspan.setAttribute("class", "BRwordElement");
230
- wordTspan.setAttribute("x", left.toString());
231
- wordTspan.setAttribute("y", bottom.toString());
232
- wordTspan.setAttribute("textLength", (right - left).toString());
233
- wordTspan.setAttribute("lengthAdjust", "spacingAndGlyphs");
234
- wordTspan.textContent = currWord.textContent;
235
- paragSvg.appendChild(wordTspan);
236
-
237
- // Adding spaces after words except at the end of the paragraph
238
- // TODO: assumes left-to-right text
239
- if (i < words.length - 1) {
240
- const nextWord = words[i + 1];
241
- // eslint-disable-next-line no-unused-vars
242
- const [leftNext, bottomNext, rightNext, topNext] = $(nextWord).attr("coords").split(',').map(parseFloat);
243
- const spaceTspan = document.createElementNS("http://www.w3.org/2000/svg", this.svgWordElement);
244
- spaceTspan.setAttribute("class", "BRwordElement");
245
- spaceTspan.setAttribute("x", right.toString());
246
- spaceTspan.setAttribute("y", bottom.toString());
247
- if ((leftNext - right) > 0) spaceTspan.setAttribute("textLength", (leftNext - right).toString());
248
- spaceTspan.setAttribute("lengthAdjust", "spacingAndGlyphs");
249
- spaceTspan.textContent = " ";
250
- paragSvg.appendChild(spaceTspan);
291
+ if (wordIndex == 0 && prevLine?.lastWord.textContent.trim().endsWith('-')) {
292
+ // ideally prefer the next line to determine the left position,
293
+ // since the previous line could be the first line of the paragraph
294
+ // and hence have an incorrectly indented first word.
295
+ // E.g. https://archive.org/details/driitaleofdaring00bachuoft/page/360/mode/2up
296
+ const [newLeft, , , ] = $((nextLine || prevLine).firstWord).attr("coords").split(',').map(parseFloat);
297
+ $(currWord).attr("coords", `${newLeft},${bottom},${right},${top}`);
251
298
  }
252
299
 
253
- // Adds newline at the end of paragraph on Firefox
254
- if ((i == words.length - 1 && (this.insertNewlines))) {
255
- paragSvg.appendChild(document.createTextNode("\n"));
300
+ const wordEl = document.createElement('span');
301
+ wordEl.setAttribute("class", "BRwordElement");
302
+ wordEl.textContent = currWord.textContent.trim();
303
+
304
+ if (wordIndex > 0) {
305
+ const space = document.createElement('span');
306
+ space.classList.add('BRspace');
307
+ space.textContent = ' ';
308
+ lineEl.append(space);
309
+
310
+ // Edge ignores empty elements (like BRspace), so add another
311
+ // space to ensure Edge's ReadAloud works correctly.
312
+ lineEl.appendChild(document.createTextNode(' '));
256
313
  }
314
+
315
+ lineEl.appendChild(wordEl);
257
316
  }
258
317
 
259
- wordHeightArr.sort();
260
- const paragWordHeight = wordHeightArr[Math.floor(wordHeightArr.length * 0.85)];
261
- paragSvg.setAttribute("font-size", paragWordHeight.toString());
262
- svg.appendChild(paragSvg);
263
- });
264
- this.stopPageFlip($container);
318
+ const hasHyphen = line.lastWord.textContent.trim().endsWith('-');
319
+ const lastWordEl = lineEl.children[lineEl.children.length - 1];
320
+ if (hasHyphen && !isLastLineOfParagraph) {
321
+ lastWordEl.textContent = lastWordEl.textContent.trim().slice(0, -1);
322
+ lastWordEl.classList.add('BRwordElement--hyphen');
323
+ }
324
+
325
+ paragEl.appendChild(lineEl);
326
+ if (!isLastLineOfParagraph && !hasHyphen) {
327
+ // Edge does not correctly have spaces between the lines.
328
+ paragEl.appendChild(document.createTextNode(' '));
329
+ }
330
+ }
331
+
332
+ wordHeightArr.sort((a, b) => a - b);
333
+ const paragWordHeight = wordHeightArr[Math.floor(wordHeightArr.length * 0.85)] + 4;
334
+ paragEl.style.left = `${paragLeft}px`;
335
+ paragEl.style.top = `${paragTop}px`;
336
+ paragEl.style.width = `${paragRight - paragLeft}px`;
337
+ paragEl.style.height = `${paragBottom - paragTop}px`;
338
+ paragEl.style.fontSize = `${paragWordHeight}px`;
339
+
340
+ // Fix up sizes - stretch/crush words as necessary using letter spacing
341
+ let wordRects = determineRealRects(paragEl, '.BRwordElement');
342
+ const ocrWords = $(ocrParagraph).find("WORD").toArray();
343
+ const wordEls = paragEl.querySelectorAll('.BRwordElement');
344
+ for (const [ocrWord, wordEl] of zip(ocrWords, wordEls)) {
345
+ const realRect = wordRects.get(wordEl);
346
+ const [left, , right ] = $(ocrWord).attr("coords").split(',').map(parseFloat);
347
+ let ocrWidth = right - left;
348
+ // Some books (eg theworksofplato01platiala) have a space _inside_ the <WORD>
349
+ // element. That makes it impossible to determine the correct positining
350
+ // of everything, but to avoid the BRspace's being width 0, which makes selection
351
+ // janky on Chrome Android, assume the space is the same width as one of the
352
+ // letters.
353
+ if (ocrWord.textContent.endsWith(' ')) {
354
+ ocrWidth = ocrWidth * (ocrWord.textContent.length - 1) / ocrWord.textContent.length;
355
+ }
356
+ const diff = ocrWidth - realRect.width;
357
+ wordEl.style.letterSpacing = `${diff / (ocrWord.textContent.length - 1)}px`;
358
+ }
359
+
360
+ // Stretch/crush lines as necessary using line spacing
361
+ // Recompute rects after letter spacing
362
+ wordRects = determineRealRects(paragEl, '.BRwordElement');
363
+ const spaceRects = determineRealRects(paragEl, '.BRspace');
364
+
365
+ const ocrLines = $(ocrParagraph).find("LINE[coords]").toArray();
366
+ const lineEls = Array.from(paragEl.querySelectorAll('.BRlineElement'));
367
+
368
+ let ySoFar = paragTop;
369
+ for (const [ocrLine, lineEl] of zip(ocrLines, lineEls)) {
370
+ // shift words using marginLeft to align with the correct x position
371
+ const words = $(ocrLine).find("WORD").toArray();
372
+ // const ocrLineLeft = Math.min(...words.map(w => parseFloat($(w).attr("coords").split(',')[0])));
373
+ let xSoFar = this.rtl ? paragRight : paragLeft;
374
+ for (const [ocrWord, wordEl] of zip(words, lineEl.querySelectorAll('.BRwordElement'))) {
375
+ // start of line, need to compute the offset relative to the OCR words
376
+ const wordRect = wordRects.get(wordEl);
377
+ const [ocrLeft, , ocrRight ] = $(ocrWord).attr("coords").split(',').map(parseFloat);
378
+ const diff = (this.rtl ? -(ocrRight - xSoFar) : ocrLeft - xSoFar);
379
+
380
+ if (wordEl.previousElementSibling) {
381
+ const space = wordEl.previousElementSibling;
382
+ space.style.letterSpacing = `${diff - spaceRects.get(space).width}px`;
383
+ } else {
384
+ wordEl.style[this.rtl ? 'paddingRight' : 'paddingLeft'] = `${diff}px`;
385
+ }
386
+ if (this.rtl) xSoFar -= diff + wordRect.width;
387
+ else xSoFar += diff + wordRect.width;
388
+ }
389
+ // And also fix y position
390
+ const ocrLineTop = Math.min(...words.map(w => parseFloat($(w).attr("coords").split(',')[3])));
391
+ const diff = ocrLineTop - ySoFar;
392
+ if (lineEl.previousElementSibling) {
393
+ lineEl.previousElementSibling.style.lineHeight = `${diff}px`;
394
+ ySoFar += diff;
395
+ }
396
+ }
397
+
398
+ // The last line will have a line height subtracting from the paragraph height
399
+ lineEls[lineEls.length - 1].style.lineHeight = `${paragBottom - ySoFar}px`;
400
+
401
+ // Edge does not include a newline for some reason when copying/pasting the <p> els
402
+ paragEl.appendChild(document.createElement('br'));
403
+ return paragEl;
265
404
  }
266
405
  }
267
406
 
@@ -269,23 +408,22 @@ export class BookreaderWithTextSelection extends BookReader {
269
408
  init() {
270
409
  const options = Object.assign({}, DEFAULT_OPTIONS, this.options.plugins.textSelection);
271
410
  if (options.enabled) {
272
- this.textSelectionPlugin = new TextSelectionPlugin(options, this.options.vars);
411
+ this.textSelectionPlugin = new TextSelectionPlugin(options, this.options.vars, this.pageProgression);
273
412
  // Write this back; this way the plugin is the source of truth, and BR just
274
413
  // contains a reference to it.
275
414
  this.options.plugins.textSelection = options;
276
415
  this.textSelectionPlugin.init();
277
416
 
278
- // Track how often selection is used
279
- const sso = new SelectionStartedObserver('.textSelectionSVG', () => {
280
- // Don't assume the order of the plugins ; the analytics plugin could
281
- // have been added later. But at this point we should know for certain.
282
- if (!this.archiveAnalyticsSendEvent) {
283
- sso.detach();
284
- } else {
285
- this.archiveAnalyticsSendEvent('BookReader', 'SelectStart');
417
+ new SelectionObserver('.BRtextLayer', (selectEvent) => {
418
+ // Track how often selection is used
419
+ if (selectEvent == 'started') {
420
+ this.archiveAnalyticsSendEvent?.('BookReader', 'SelectStart');
421
+
422
+ // Set a class on the page to avoid hiding it when zooming/etc
423
+ this.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
424
+ $(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
286
425
  }
287
- });
288
- sso.attach();
426
+ }).attach();
289
427
  }
290
428
 
291
429
  super.init();
@@ -306,3 +444,190 @@ export class BookreaderWithTextSelection extends BookReader {
306
444
  }
307
445
  window.BookReader = BookreaderWithTextSelection;
308
446
  export default BookreaderWithTextSelection;
447
+
448
+
449
+ /**
450
+ * @param {HTMLElement} parentEl
451
+ * @param {string} selector
452
+ * @returns {Map<Element, Rect>}
453
+ */
454
+ function determineRealRects(parentEl, selector) {
455
+ const initals = {
456
+ position: parentEl.style.position,
457
+ visibility: parentEl.style.visibility,
458
+ top: parentEl.style.top,
459
+ left: parentEl.style.left,
460
+ transform: parentEl.style.transform,
461
+ };
462
+ parentEl.style.position = 'absolute';
463
+ parentEl.style.visibility = 'hidden';
464
+ parentEl.style.top = '0';
465
+ parentEl.style.left = '0';
466
+ parentEl.style.transform = 'none';
467
+ document.body.appendChild(parentEl);
468
+ const rects = new Map(
469
+ Array.from(parentEl.querySelectorAll(selector))
470
+ .map(wordEl => {
471
+ const origRect = wordEl.getBoundingClientRect();
472
+ return [wordEl, new Rect(
473
+ origRect.left + window.scrollX,
474
+ origRect.top + window.scrollY,
475
+ origRect.width,
476
+ origRect.height,
477
+ )];
478
+ })
479
+ );
480
+ document.body.removeChild(parentEl);
481
+ Object.assign(parentEl.style, initals);
482
+ return rects;
483
+ }
484
+
485
+ /**
486
+ * @param {HTMLElement} line
487
+ */
488
+ function augmentLine(line) {
489
+ const words = $(line).find("WORD").toArray();
490
+ return {
491
+ ocrElement: line,
492
+ words,
493
+ firstWord: words[0],
494
+ lastWord: words[words.length - 1],
495
+ };
496
+ }
497
+
498
+ /**
499
+ * @template TFrom, TTo
500
+ * Generator version of map
501
+ * @param {Iterable<TFrom>} gen
502
+ * @param {function(TFrom): TTo} fn
503
+ * @returns {Iterable<TTo>}
504
+ */
505
+ export function* genMap(gen, fn) {
506
+ for (const x of gen) yield fn(x);
507
+ }
508
+
509
+ /**
510
+ * @template T
511
+ * Generator that provides a sliding window of 3 elements,
512
+ * prev, current, and next.
513
+ * @param {Iterable<T>} gen
514
+ * @returns {Iterable<[T | undefined, T, T | undefined]>}
515
+ */
516
+ export function* lookAroundWindow(gen) {
517
+ let prev = undefined;
518
+ let cur = undefined;
519
+ let next = undefined;
520
+ for (const x of gen) {
521
+ if (typeof cur !== 'undefined') {
522
+ next = x;
523
+ yield [prev, cur, next];
524
+ }
525
+ prev = cur;
526
+ cur = x;
527
+ next = undefined;
528
+ }
529
+
530
+ if (typeof cur !== 'undefined') {
531
+ yield [prev, cur, next];
532
+ }
533
+ }
534
+
535
+ /**
536
+ * @template T1, T2
537
+ * Lazy zip implementation to avoid importing lodash
538
+ * Expects iterators to be of the same length
539
+ * @param {Iterable<T1>} gen1
540
+ * @param {Iterable<T2>} gen2
541
+ * @returns {Iterable<[T1, T2]>}
542
+ */
543
+ export function* zip(gen1, gen2) {
544
+ const it1 = gen1[Symbol.iterator]();
545
+ const it2 = gen2[Symbol.iterator]();
546
+ while (true) {
547
+ const r1 = it1.next();
548
+ const r2 = it2.next();
549
+ if (r1.done && r2.done) {
550
+ return;
551
+ }
552
+ if (r1.done || r2.done) {
553
+ throw new Error('zip: one of the iterators is done');
554
+ }
555
+ yield [r1.value, r2.value];
556
+ }
557
+ }
558
+
559
+ /**
560
+ * [left, bottom, right, top]
561
+ * @param {Array<[number, number, number, number]>} bounds
562
+ * @returns {[number, number, number, number]}
563
+ */
564
+ function determineBounds(bounds) {
565
+ let leftMost = Infinity;
566
+ let bottomMost = -Infinity;
567
+ let rightMost = -Infinity;
568
+ let topMost = Infinity;
569
+
570
+ for (const [left, bottom, right, top] of bounds) {
571
+ leftMost = Math.min(leftMost, left);
572
+ bottomMost = Math.max(bottomMost, bottom);
573
+ rightMost = Math.max(rightMost, right);
574
+ topMost = Math.min(topMost, top);
575
+ }
576
+
577
+ return [leftMost, bottomMost, rightMost, topMost];
578
+ }
579
+
580
+ /**
581
+ * Recursively traverses the XML tree and adds coords
582
+ * which are the bounding box of all child coords
583
+ * @param {Element} xmlEl
584
+ */
585
+ function recursivelyAddCoords(xmlEl) {
586
+ if ($(xmlEl).attr('coords') || !xmlEl.children) {
587
+ return;
588
+ }
589
+
590
+ const children = $(xmlEl).children().toArray();
591
+ if (children.length === 0) {
592
+ return;
593
+ }
594
+
595
+ for (const child of children) {
596
+ recursivelyAddCoords(child);
597
+ }
598
+
599
+ const childCoords = [];
600
+
601
+ for (const child of children) {
602
+ if (!$(child).attr('coords')) continue;
603
+ childCoords.push($(child).attr('coords').split(',').map(parseFloat));
604
+ }
605
+
606
+ const boundingCoords = determineBounds(childCoords);
607
+ if (Math.abs(boundingCoords[0]) != Infinity) {
608
+ $(xmlEl).attr('coords', boundingCoords.join(','));
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Basically a polyfill for the native DOMRect class
614
+ */
615
+ class Rect {
616
+ /**
617
+ * @param {number} x
618
+ * @param {number} y
619
+ * @param {number} width
620
+ * @param {number} height
621
+ */
622
+ constructor(x, y, width, height) {
623
+ this.x = x;
624
+ this.y = y;
625
+ this.width = width;
626
+ this.height = height;
627
+ }
628
+
629
+ get right() { return this.x + this.width; }
630
+ get bottom() { return this.y + this.height; }
631
+ get top() { return this.y; }
632
+ get left() { return this.x; }
633
+ }
@@ -383,14 +383,6 @@ class SearchView {
383
383
  }
384
384
  }
385
385
 
386
- /**
387
- * @param {Event} e
388
- */
389
- handleNavToggledCallback(e) {
390
- const is_visible = this.br.navigationIsVisible();
391
- this.togglePinsFor(is_visible);
392
- }
393
-
394
386
  handleSearchStarted() {
395
387
  this.emptyMatches();
396
388
  this.br.removeSearchHilites();
@@ -425,7 +417,6 @@ class SearchView {
425
417
 
426
418
  window.addEventListener(`${namespace}SearchCallbackError`, this.handleSearchCallbackError.bind(this));
427
419
  $(document).on(`${namespace}SearchCallback`, this.handleSearchCallback.bind(this))
428
- .on(`${namespace}navToggled`, this.handleNavToggledCallback.bind(this))
429
420
  .on(`${namespace}SearchStarted`, this.handleSearchStarted.bind(this))
430
421
  .on(`${namespace}SearchCallbackBookNotIndexed`, this.handleSearchCallbackBookNotIndexed.bind(this))
431
422
  .on(`${namespace}SearchCallbackEmpty`, this.handleSearchCallbackEmpty.bind(this))