blob: ac13d7189fa9927e6b036201b5b66bea5a4a8b9e [file] [log] [blame]
/**
* @license
* Licensed 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.
*/
/* global Range */
import normalizeRange from 'range-normalize';
import { createTextQuoteSelector } from '@annotator/text';
export async function describeTextQuoteByRange({ range, context }) {
// Shrink range to fit in context, if needed.
if (range.compareBoundaryPoints(Range.END_TO_END, context) > 0) {
range.setEnd(context.endContainer, context.endOffset);
}
if (range.compareBoundaryPoints(Range.START_TO_START, context) < 0) {
range.setStart(context.startContainer, context.startOffset);
}
const contextText = context.cloneContents().textContent;
const exact = range.cloneContents().textContent;
const descriptor = {
type: 'TextQuoteSelector',
exact,
};
// FIXME We should get range index relative to context. Look at
// dom-anchor-text-position? For now, we implement the easy case where the
// ranges are within the same container.
context = normalizeRange(context);
range = normalizeRange(range);
if (
context.startContainer !== range.startContainer ||
context.startOffset !== 0
) {
throw new Error(`Context not equal to range's container; not implemented.`);
}
const rangeIndex = range.startOffset;
const rangeEndIndex = range.endOffset;
const selector = createTextQuoteSelector();
const matches = selector({ descriptors: [descriptor], context: contextText });
const minSuffixes = [];
const minPrefixes = [];
for await (let match of matches) {
// For every match that is not our range, we look how many characters we
// have to add as prefix or suffix to disambiguate.
if (match.index !== rangeIndex) {
const matchEndIndex = match.index + match[0].length;
const suffixOverlap = overlap(
contextText.substring(matchEndIndex),
contextText.substring(rangeEndIndex),
);
minSuffixes.push(suffixOverlap + 1);
const prefixOverlap = overlapRight(
contextText.substring(0, match.index),
contextText.substring(0, rangeIndex),
);
minPrefixes.push(prefixOverlap + 1);
}
}
const [minSuffix, minPrefix] = minimalSolution(minSuffixes, minPrefixes);
if (minSuffix > 0) {
descriptor.suffix = contextText.substring(
rangeEndIndex,
rangeEndIndex + minSuffix,
);
}
if (minPrefix > 0) {
descriptor.prefix = contextText.substring(
rangeIndex - minPrefix,
rangeIndex,
);
}
return descriptor;
}
function overlap(text1, text2) {
let count = 0;
while (text1[count] === text2[count]) {
count++;
if (count >= text1.length) {
return Infinity;
}
}
return count;
}
function overlapRight(text1, text2) {
let count = 0;
while (text1[text1.length - 1 - count] === text2[text2.length - 1 - count]) {
count++;
if (count >= text1.length) {
return Infinity;
}
}
return count;
}
function minimalSolution(reqs1, reqs2) {
if (reqs1.length !== reqs2.length) {
throw new Error('unequal lengths');
}
// Add 0 as an option to try.
reqs1.push(0);
reqs2.push(0);
let bestResult = [Infinity, Infinity];
for (let i = 0; i < reqs1.length; i++) {
const req1 = reqs1[i];
// The values to satisfy for req2, given the proposed req1.
const reqsToSatisfy = reqs1.map((v, i) => (v > req1 ? reqs2[i] : 0));
// Take the lowest value that satisfies them all.
const req2 = Math.max(...reqsToSatisfy);
// If this combination is the best so far, remember it.
if (req1 + req2 < bestResult[0] + bestResult[1]) {
bestResult = [req1, req2];
}
}
return bestResult;
}