blob: ef7eb53c6862297f39d278a6292ca43093e4c14b [file] [log] [blame]
/**
* @license
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import createNodeIterator from 'dom-node-iterator';
import seek from 'dom-seek';
import { TextQuoteSelector } from '../../../selector/src';
import { DomScope } from '../types';
import { ownerDocument, rangeFromScope } from '../scope';
import { createTextQuoteSelectorMatcher } from './match';
function firstTextNodeInRange(range: Range): Text {
const { startContainer } = range;
if (isTextNode(startContainer)) return startContainer;
const root = range.commonAncestorContainer;
const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
return iter.nextNode() as Text;
}
export async function describeTextQuote(
range: Range,
scope: DomScope = ownerDocument(range).documentElement,
): Promise<TextQuoteSelector> {
const exact = range.toString();
const result: TextQuoteSelector = { type: 'TextQuoteSelector', exact };
const { prefix, suffix } = await calculateContextForDisambiguation(range, result, scope);
result.prefix = prefix;
result.suffix = suffix;
return result
}
async function calculateContextForDisambiguation(
range: Range,
selector: TextQuoteSelector,
scope: DomScope,
): Promise<{ prefix?: string, suffix?: string }> {
const scopeAsRange = rangeFromScope(scope);
const root = scopeAsRange.commonAncestorContainer;
const text = scopeAsRange.toString();
const matcher = createTextQuoteSelectorMatcher(selector);
const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
const startNode = firstTextNodeInRange(range);
const startIndex =
isTextNode(range.startContainer)
? seek(iter, startNode) + range.startOffset
: seek(iter, startNode);
const endIndex = startIndex + selector.exact.length;
const affixLengthPairs: Array<[number, number]> = [];
for await (const match of matcher(scopeAsRange)) {
const matchIter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
const matchStartNode = firstTextNodeInRange(match);
const matchStartIndex =
isTextNode(match.startContainer)
? seek(matchIter, matchStartNode) + match.startOffset
: seek(matchIter, matchStartNode);
const matchEndIndex = matchStartIndex + match.toString().length;
// If the match is the same as the input range, continue.
if (matchStartIndex === startIndex || matchEndIndex === endIndex) {
continue;
}
// Determine how many prefix characters are shared.
const prefixLength = overlapRight(
text.substring(0, matchStartIndex),
text.substring(0, startIndex),
);
// Determine how many suffix characters are shared.
const suffixLength = overlap(
text.substring(matchEndIndex),
text.substring(endIndex),
);
// Record the affix lengths that would have precluded this match.
affixLengthPairs.push([prefixLength + 1, suffixLength + 1]);
}
// Construct and return an unambiguous selector.
let prefix, suffix;
if (affixLengthPairs.length) {
const [prefixLength, suffixLength] = minimalSolution(affixLengthPairs);
if (prefixLength > 0 && startIndex > 0) {
prefix = text.substring(startIndex - prefixLength, startIndex);
}
if (suffixLength > 0 && endIndex < text.length) {
suffix = text.substring(endIndex, endIndex + suffixLength);
}
}
return { prefix, suffix };
}
function overlap(text1: string, text2: string) {
let count = 0;
while (count < text1.length && count < text2.length) {
const c1 = text1[count];
const c2 = text2[count];
if (c1 !== c2) break;
count++;
}
return count;
}
function overlapRight(text1: string, text2: string) {
let count = 0;
while (count < text1.length && count < text2.length) {
const c1 = text1[text1.length - 1 - count];
const c2 = text2[text2.length - 1 - count];
if (c1 !== c2) break;
count++;
}
return count;
}
function minimalSolution(requirements: Array<[number, number]>): [number, number] {
// Build all the pairs and order them by their sums.
const pairs = requirements.flatMap(l => requirements.map<[number, number]>(r => [l[0], r[1]]));
pairs.sort((a, b) => a[0] + a[1] - (b[0] + b[1]));
// Find the first pair that satisfies every requirement.
for (const pair of pairs) {
const [p0, p1] = pair;
if (requirements.every(([r0, r1]) => r0 <= p0 || r1 <= p1)) {
return pair;
}
}
// Return the largest pairing (unreachable).
return pairs[pairs.length - 1];
}
function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE
}