@internetarchive/bookreader 5.0.0-115 → 5.0.0-116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
8
8
  import '@internetarchive/icon-share';
9
9
  import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
10
10
  import { isIOS, isAndroid } from './browserSniffing.js';
11
+ import { genAt, genFilter } from './generators.js';
11
12
 
12
13
  const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
13
14
  const MAX_FULL_QUOTE_URL_CHARS = 80;
@@ -247,8 +248,8 @@ export class TextSelectionManager {
247
248
  // Find the last allowed word in the selection
248
249
  const lastAllowedWord = genAt(
249
250
  genFilter(
250
- walkBetweenNodes(range.startContainer, range.endContainer),
251
- (node) => node.classList?.contains(
251
+ treeWalkRange(range, 'element'),
252
+ (node) => node.classList.contains(
252
253
  this.selectionElement[0].replace(".", ""),
253
254
  ),
254
255
  ),
@@ -267,55 +268,58 @@ export class TextSelectionManager {
267
268
  }
268
269
 
269
270
  /**
270
- * @template T
271
- * Get the i-th element of an iterable
272
- * @param {Iterable<T>} iterable
273
- * @param {number} index
271
+ * @overload
272
+ * @param {Range} range
273
+ * @param {'text'} filter
274
+ * @returns {Generator<Text>}
274
275
  */
275
- export function genAt(iterable, index) {
276
- let i = 0;
277
- for (const x of iterable) {
278
- if (i == index) {
279
- return x;
280
- }
281
- i++;
282
- }
283
- return undefined;
284
- }
285
-
286
276
  /**
287
- * @template T
288
- * Generator version of filter
289
- * @param {Iterable<T>} iterable
290
- * @param {function(T): boolean} fn
277
+ * @overload
278
+ * @param {Range} range
279
+ * @param {'element'} filter
280
+ * @returns {Generator<Element>}
281
+ */
282
+ /**
283
+ * @overload
284
+ * @param {Range} range
285
+ * @param {'text+element'} [filter]
286
+ * @returns {Generator<Text | Element>}
291
287
  */
292
- export function* genFilter(iterable, fn) {
293
- for (const x of iterable) {
294
- if (fn(x)) yield x;
295
- }
296
- }
297
-
298
288
  /**
299
289
  * Depth traverse the DOM tree starting at `start`, and ending at `end`.
300
- * @param {Node} start
301
- * @param {Node} end
290
+ * @param {Range} range
291
+ * @param {'text' | 'element' | 'text+element'} [filter]
302
292
  * @returns {Generator<Node>}
303
293
  */
304
- export function* walkBetweenNodes(start, end) {
294
+ export function* treeWalkRange(range, filter = 'text+element') {
295
+ const start = range.startContainer;
296
+ const end = range.endContainer;
305
297
  let done = false;
306
298
 
307
299
  /**
308
300
  * @param {Node} node
309
301
  */
302
+ const passesFilter = node => {
303
+ return (
304
+ (filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
305
+ (filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
306
+ (filter === 'text+element')
307
+ );
308
+ };
309
+
310
+ /**
311
+ * @param {Node} node
312
+ * @returns {Generator<Node>}
313
+ */
310
314
  function* walk(node, {children = true, parents = true, siblings = true} = {}) {
311
315
  if (node === end) {
312
316
  done = true;
313
- yield node;
317
+ if (passesFilter(node)) yield node;
314
318
  return;
315
319
  }
316
320
 
317
321
  // yield self
318
- yield node;
322
+ if (passesFilter(node)) yield node;
319
323
 
320
324
  // First iterate children (depth-first traversal)
321
325
  if (children && node.firstChild) {
@@ -585,7 +589,6 @@ class BRSelectMenu extends LitElement {
585
589
  */
586
590
  async handleCopyLinkToHighlight(e) {
587
591
  e.preventDefault();
588
- const currentParams = this.br.readQueryString();
589
592
  const currentSelection = /** @type {Selection} */ (window.getSelection());
590
593
  const range = currentSelection.getRangeAt(0);
591
594
  const textLayer = this.getNodeTextLayer(range.startContainer);
@@ -595,19 +598,18 @@ class BRSelectMenu extends LitElement {
595
598
  }
596
599
  const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
597
600
 
598
- // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
599
- let linkToHighlightParams = currentParams;
600
- if (currentParams.includes('text=')) {
601
- linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
602
- } else {
603
- const sep = linkToHighlightParams ? '&' : '?';
604
- linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
605
- }
601
+ const currentParams = new URLSearchParams(this.br.readQueryString());
602
+ if (currentParams.has('text')) currentParams.delete('text');
603
+
604
+ let linkToHighlightParams = currentParams.toString();
605
+ // Use custom toUrlString, which decodes the delimiters for better readability
606
+ linkToHighlightParams += (linkToHighlightParams ? '&' : '') + `text=${textFragment.toUrlString()}`;
607
+
606
608
  const currentUrl = window.location;
607
609
  const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
608
610
  const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
609
611
  const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
610
- const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
612
+ const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}?${linkToHighlightParams}${hash}`;
611
613
 
612
614
  await navigator.clipboard.writeText(linkToHighlight);
613
615
  this.copyLinkOption?.showTemporaryText('Copied!');
@@ -839,12 +841,23 @@ export function getLastMostNode(parent) {
839
841
  }
840
842
 
841
843
  /**
842
- * Strips the whitespace to normalize text
844
+ * Normalizes text fragment whitespace and removes special delimiter characters
845
+ * to allow for reliable url encoding/decoding
846
+ *
847
+ * Note: It's very important this *does not* change the length of the string to
848
+ * ensure matching works smoothly later.
849
+ *
843
850
  * @param {String} string
844
- * @returns
845
851
  */
846
- function replaceWhitespace(string) {
847
- return string.replace(/\s+/g, " ");
852
+ export function replaceTextFragmentDelimiters(string) {
853
+ return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
854
+ }
855
+
856
+ /**
857
+ * @param {String} s
858
+ */
859
+ export function collapseWhitespace(s) {
860
+ return s.replace(/\s+/g, ' ').trim();
848
861
  }
849
862
 
850
863
  /**
@@ -861,7 +874,7 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
861
874
  const startOffset = textNodes[0] === range.startContainer ?
862
875
  range.startOffset :
863
876
  0;
864
- const normalizedWholePageString = normalize(range.toString());
877
+ const normalizedWholePageString = normalize(textLayerRangeToString(range));
865
878
  const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
866
879
  const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
867
880
 
@@ -886,8 +899,8 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
886
899
  }
887
900
 
888
901
  const foundRange = new Range();
889
- foundRange.setStart(start.node, 0);
890
- foundRange.setEnd(end.node, 1);
902
+ foundRange.setStart(start.node, start.offset);
903
+ foundRange.setEnd(end.node, end.offset);
891
904
  return foundRange;
892
905
  });
893
906
 
@@ -897,68 +910,74 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
897
910
  }
898
911
 
899
912
  /**
900
- * Uses the index that matches the quote string and normalizes the string contents to find the correct node
913
+ * Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
901
914
  * @param {Number} index
902
915
  * @param {Node[]} nodes
903
916
  * @param {boolean} isEnd
904
917
  * @param {{ normalize?: function(string): string }} [options]
905
918
  */
906
919
  export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
907
- let counted = 0;
908
- let normalizedData;
909
- for (let i = 0; i < nodes.length; i++) {
910
- const node = nodes[i];
911
- if (node.className === 'BRlineElement') {
912
- // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
913
- normalizedData = ' ';
914
- } else {
915
- if (!normalizedData) normalizedData = normalize(node.textContent);
916
- }
917
- let nodeEnd = counted + normalizedData.length;
918
- if (isEnd) nodeEnd += 1;
919
- if (nodeEnd > index) {
920
- const normalizedOffset = index - counted;
921
- let denormalizedOffset = Math.min(index - counted, node.textContent.length);
922
-
923
- const targetSubstring = isEnd ?
924
- normalizedData.substring(0, normalizedOffset) :
925
- normalizedData.substring(normalizedOffset);
926
-
927
- let candidateSubstring = isEnd ?
928
- normalize(node.textContent.substring(0, normalizedOffset)) :
929
- normalize(node.textContent.substring(normalizedOffset));
930
-
931
- const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
932
- while (denormalizedOffset >= 0 &&
933
- denormalizedOffset <= node.textContent.length) {
934
- if (candidateSubstring.length === targetSubstring.length) {
935
- return {node : node, offset: denormalizedOffset};
920
+ // Rename to have consistent wording
921
+ const normalizedTargetIndex = index;
922
+ /** Characters in node-space counted so far */
923
+ let normalizedI = 0;
924
+
925
+ for (const node of nodes) {
926
+ const nodeText = node.textContent || '';
927
+ const normalizedText = normalize(nodeText);
928
+ const nodeNormalizedStart = normalizedI;
929
+ const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
930
+
931
+ if (nodeNormalizedEnd > normalizedTargetIndex) {
932
+ // We have found the node that contains the normalizedTargetIndex we are looking for!
933
+ const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
934
+
935
+ // But the normalized text could be a shorter length than the node text,
936
+ // so we try to continually increase the nodeOffset
937
+ let nodeOffset = Math.min(normalizedOffset, nodeText.length);
938
+
939
+ const normalizedSubstring = isEnd ?
940
+ normalizedText.substring(0, normalizedOffset) :
941
+ normalizedText.substring(normalizedOffset);
942
+
943
+ let nodeSubstring = isEnd ?
944
+ normalize(nodeText.substring(0, normalizedOffset)) :
945
+ normalize(nodeText.substring(normalizedOffset));
946
+
947
+ const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
948
+ while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
949
+ if (nodeSubstring.length === normalizedSubstring.length) {
950
+ return {node, offset: nodeOffset};
936
951
  }
937
- denormalizedOffset += direction;
952
+ nodeOffset += direction;
938
953
 
939
- candidateSubstring = isEnd ?
940
- node.textContent.substring(0, denormalizedOffset) :
941
- node.textContent.substring(denormalizedOffset);
954
+ nodeSubstring = isEnd ?
955
+ normalize(nodeText.substring(0, nodeOffset)) :
956
+ normalize(nodeText.substring(nodeOffset));
942
957
  }
943
958
  }
944
- counted += normalizedData.length;
945
-
946
- if (i + 1 < nodes.length) {
947
- const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
948
- // Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
949
- // 1st case explicitly check the node class to prevent double counted spaces
950
- // 2nd case can happen from node traversal when loading from localStorage
951
- if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
952
- counted -= 1;
953
- } else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
954
- counted -= 1;
955
- }
956
- normalizedData = nextNormalizedData;
957
- }
959
+ normalizedI += normalizedText.length;
958
960
  }
959
961
  return undefined;
960
962
  }
961
963
 
964
+ /**
965
+ * @param {Range} range
966
+ */
967
+ export function textLayerRangeToString(range) {
968
+ const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
969
+ let result = textNodes.map(node => node.textContent).join('');
970
+ const firstTextNode = textNodes[0];
971
+ const lastTextNode = textNodes[textNodes.length - 1];
972
+ if (range.startContainer === firstTextNode) {
973
+ result = result.substring(range.startOffset);
974
+ }
975
+ if (range.endContainer === lastTextNode) {
976
+ result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
977
+ }
978
+ return result;
979
+ }
980
+
962
981
  /**
963
982
  * Takes a text quote object and a container element, and wraps the quote
964
983
  * within the container element in a span to apply highlight-like styling
@@ -979,37 +998,29 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
979
998
  const wholePageRange = new Range();
980
999
  wholePageRange.setStart(firstPageNode, 0);
981
1000
  wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
982
- const normalize = replaceWhitespace;
983
1001
 
984
- // Retrieve the text nodes and relevant whitespace elements
985
- // Need to keep the BRlineElement nodes in between to keep the index count consistent, remove first BRlineElement since text starts from the first real text node
986
- const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
987
- pageWordNodes.splice(0, 1);
1002
+ const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
988
1003
 
989
1004
  const broadRanges = findRangeForRegExp(
990
- textFragment.toRegExp({ normalize, context: true }),
1005
+ textFragment.toRegExp({ context: true }),
991
1006
  wholePageRange,
992
1007
  pageWordNodes,
993
- { normalize },
1008
+ { normalize: replaceTextFragmentDelimiters },
994
1009
  );
995
1010
  if (!broadRanges) {
996
1011
  console.warn("Could not find quote with context in page");
997
1012
  return;
998
1013
  }
999
1014
 
1000
- const broadRangeWordNodes = [];
1001
- for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
1002
- if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
1003
- broadRangeWordNodes.push(el);
1004
- }
1005
- }
1015
+ const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
1006
1016
 
1007
1017
  // At which point the quote should now be unambiguous!
1018
+ // FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
1008
1019
  const exactRanges = findRangeForRegExp(
1009
- textFragment.toRegExp({ normalize, context: false }),
1020
+ textFragment.toRegExp({ context: false }),
1010
1021
  broadRanges[0],
1011
1022
  broadRangeWordNodes,
1012
- { normalize },
1023
+ { normalize: replaceTextFragmentDelimiters },
1013
1024
  );
1014
1025
  if (!exactRanges) {
1015
1026
  throw new Error("Could not find quote in page");
@@ -1037,6 +1048,14 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
1037
1048
  });
1038
1049
  }
1039
1050
 
1051
+ /**
1052
+ * @param {Text} node
1053
+ */
1054
+ export function isBRVisibleTextNode(node) {
1055
+ // Exclude the duplicated spaces between words for MS Edge
1056
+ return !node.previousElementSibling?.classList.contains("BRspace");
1057
+ }
1058
+
1040
1059
  /**
1041
1060
  * Given a Range, wraps its text contents in one or more <mark> elements.
1042
1061
  * <mark> elements can't cross block boundaries, so this function walks the
@@ -1222,39 +1241,33 @@ export class BookReaderTextFragment {
1222
1241
  });
1223
1242
  }
1224
1243
 
1225
- /**
1226
- * Extract and parse a text fragment from a URL string containing a `text=` parameter.
1227
- * @param {string} urlString
1228
- * @param {import('@/src/BookReader/BookModel.js').BookModel} book
1229
- * @param {number} fallbackPageIndex A fallback page index to use if the text
1230
- * fragment does not specify a page number or page index.
1231
- * @returns {BookReaderTextFragment|null}
1232
- */
1233
- static fromUrl(urlString, book, fallbackPageIndex) {
1234
- // Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
1235
- // commas and colons, so need to do a regex match to extract the text fragment string
1236
- const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
1237
- if (!textMatch) return null;
1238
- return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
1239
- }
1240
1244
 
1241
1245
  /**
1242
1246
  * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1243
1247
  * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1244
- * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1245
- * Note the ':' and ',' separators must not and are not encoded, but
1248
+ *
1249
+ * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1250
+ * Note the ':' and ',' separators must not and are not encoded, but
1246
1251
  * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1252
+ *
1247
1253
  * @returns {string}
1248
1254
  */
1249
1255
  toUrlString() {
1256
+ // When constructing to url, we can collapse the whitespace since the corresponding
1257
+ // toRegExp method is resilient to multiple whitespace characters.
1258
+ /** @param {string} s */
1259
+ const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
1260
+
1250
1261
  // First the page number or index
1251
- let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
1262
+ let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
1263
+ str += ':';
1252
1264
 
1253
1265
  if (this.prefix) {
1254
- str += `${encodeURIComponent(this.prefix)}-,`;
1266
+ str += normalize(this.prefix);
1267
+ str += '-,';
1255
1268
  }
1256
1269
 
1257
- const quote = this.quote?.trim() || null;
1270
+ const quote = this.quote ? normalize(this.quote) : null;
1258
1271
  let shortenedQuoteParts = null;
1259
1272
  if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
1260
1273
  const words = quote.match(/\S+/g) || [];
@@ -1268,27 +1281,33 @@ export class BookReaderTextFragment {
1268
1281
  }
1269
1282
 
1270
1283
  if (quote && !shortenedQuoteParts) {
1271
- str += encodeURIComponent(quote);
1284
+ str += quote;
1272
1285
  } else if (shortenedQuoteParts) {
1273
- str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
1286
+ str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
1274
1287
  } else if (this.quoteStart && this.quoteEnd) {
1275
- str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
1288
+ str += `${this.quoteStart},${this.quoteEnd}`;
1276
1289
  } else {
1277
1290
  throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
1278
1291
  }
1279
1292
 
1280
1293
  if (this.suffix) {
1281
- str += `,-${encodeURIComponent(this.suffix)}`;
1294
+ str += ',-';
1295
+ str += normalize(this.suffix);
1282
1296
  }
1283
- return str;
1297
+
1298
+ return encodeURIComponent(str)
1299
+ // Bring back the delimiters so it's easier to read, but note not necessary.
1300
+ // Note hyphens are not encoded by encodeURIComponent, so no need to replace them
1301
+ .replace(/%2C/g, ',')
1302
+ .replace(/%3A/g, ':');
1284
1303
  }
1285
1304
 
1286
1305
  /**
1287
1306
  * Build a regex that matches this quote payload.
1288
- * @param {{ normalize?: function(string): string, context?: boolean }} [options]
1307
+ * @param {{ context?: boolean }} [options]
1289
1308
  * @returns {RegExp}
1290
1309
  */
1291
- toRegExp({ normalize = (s) => s, context = false } = {}) {
1310
+ toRegExp({ context = false } = {}) {
1292
1311
  /** @type {[String] | [String, String]} */
1293
1312
  const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
1294
1313
 
@@ -1297,12 +1316,16 @@ export class BookReaderTextFragment {
1297
1316
  if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
1298
1317
  }
1299
1318
 
1319
+ // Make it resilient to extra whitespace
1320
+ /** @param {String} quote */
1321
+ const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
1322
+
1300
1323
  if (quotes.length === 1) {
1301
- return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
1324
+ return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
1302
1325
  } else {
1303
1326
  return new RegExp(
1304
- RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
1305
- 'g',
1327
+ quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
1328
+ 'gi',
1306
1329
  );
1307
1330
  }
1308
1331
  }
@@ -1356,11 +1379,11 @@ export class BookReaderTextFragment {
1356
1379
 
1357
1380
  const CONTEXT_WORD_COUNT = 3;
1358
1381
 
1359
- const preStartText = replaceWhitespace(preStartRange.toString());
1382
+ const preStartText = textLayerRangeToString(preStartRange);
1360
1383
  let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
1361
1384
  let prefixWords = countWords(prefix);
1362
1385
 
1363
- const postEndText = replaceWhitespace(postEndRange.toString());
1386
+ const postEndText = textLayerRangeToString(postEndRange);
1364
1387
  let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
1365
1388
  let suffixWords = countWords(suffix);
1366
1389
 
@@ -1376,8 +1399,7 @@ export class BookReaderTextFragment {
1376
1399
  prefixWords = countWords(prefix);
1377
1400
  }
1378
1401
 
1379
- // Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
1380
- const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
1402
+ const quote = textLayerRangeToString(fullQuoteRange);
1381
1403
  const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1382
1404
 
1383
1405
  return new BookReaderTextFragment({
@@ -0,0 +1,89 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @template T
5
+ * Get the i-th element of an iterable
6
+ * @param {Iterable<T>} iterable
7
+ * @param {number} index
8
+ */
9
+ export function genAt(iterable, index) {
10
+ let i = 0;
11
+ for (const x of iterable) {
12
+ if (i == index) return x;
13
+ i++;
14
+ }
15
+ return undefined;
16
+ }
17
+
18
+ /**
19
+ * @template T
20
+ * Generator version of filter
21
+ * @param {Iterable<T>} iterable
22
+ * @param {function(T): boolean} fn
23
+ */
24
+ export function* genFilter(iterable, fn) {
25
+ for (const x of iterable) {
26
+ if (fn(x)) yield x;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @template TFrom, TTo
32
+ * Generator version of map
33
+ * @param {Iterable<TFrom>} gen
34
+ * @param {function(TFrom): TTo} fn
35
+ * @returns {Iterable<TTo>}
36
+ */
37
+ export function* genMap(gen, fn) {
38
+ for (const x of gen) yield fn(x);
39
+ }
40
+
41
+ /**
42
+ * @template T
43
+ * Generator that provides a sliding window of 3 elements,
44
+ * prev, current, and next.
45
+ * @param {Iterable<T>} gen
46
+ * @returns {Iterable<[T | undefined, T, T | undefined]>}
47
+ */
48
+ export function* lookAroundWindow(gen) {
49
+ let prev = undefined;
50
+ let cur = undefined;
51
+ let next = undefined;
52
+ for (const x of gen) {
53
+ if (typeof cur !== 'undefined') {
54
+ next = x;
55
+ yield [prev, cur, next];
56
+ }
57
+ prev = cur;
58
+ cur = x;
59
+ next = undefined;
60
+ }
61
+
62
+ if (typeof cur !== 'undefined') {
63
+ yield [prev, cur, next];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * @template T1, T2
69
+ * Lazy zip implementation to avoid importing lodash
70
+ * Expects iterators to be of the same length
71
+ * @param {Iterable<T1>} gen1
72
+ * @param {Iterable<T2>} gen2
73
+ * @returns {Iterable<[T1, T2]>}
74
+ */
75
+ export function* zip(gen1, gen2) {
76
+ const it1 = gen1[Symbol.iterator]();
77
+ const it2 = gen2[Symbol.iterator]();
78
+ while (true) {
79
+ const r1 = it1.next();
80
+ const r2 = it2.next();
81
+ if (r1.done && r2.done) {
82
+ return;
83
+ }
84
+ if (r1.done || r2.done) {
85
+ throw new Error('zip: one of the iterators is done');
86
+ }
87
+ yield [r1.value, r2.value];
88
+ }
89
+ }