diff --git a/common/web/keyboard-processor/src/index.ts b/common/web/keyboard-processor/src/index.ts index ad7aa49523..f800691cfa 100644 --- a/common/web/keyboard-processor/src/index.ts +++ b/common/web/keyboard-processor/src/index.ts @@ -39,6 +39,7 @@ export { default as KeyMapping } from "./text/keyMapping.js"; export { default as OutputTarget } from "./text/outputTarget.js"; export * from "./text/outputTarget.js"; export { default as RuleBehavior } from "./text/ruleBehavior.js"; +export * from "./text/stringDivergence.js"; export * from "./text/systemStores.js"; export * from "@keymanapp/web-utils"; diff --git a/common/web/keyboard-processor/src/text/outputTarget.ts b/common/web/keyboard-processor/src/text/outputTarget.ts index 306dc2170d..1ef67b1f7b 100644 --- a/common/web/keyboard-processor/src/text/outputTarget.ts +++ b/common/web/keyboard-processor/src/text/outputTarget.ts @@ -1,6 +1,7 @@ /// import { extendString } from "@keymanapp/web-utils"; +import { searchStringDivergence } from "./stringDivergence.js"; extendString(); @@ -120,85 +121,24 @@ export default abstract class OutputTarget { * @param from An output target (preferably a Mock) representing the prior state of the input/output system. */ buildTransformFrom(original: OutputTarget): Transform { - let to = this.getText(); - let from = original.getText(); + const toLeft = this.getTextBeforeCaret(); + const fromLeft = original.getTextBeforeCaret(); - let fromCaret = original.getDeadkeyCaret(); - let toCaret = this.getDeadkeyCaret(); + const leftDivergenceIndex = searchStringDivergence(fromLeft, toLeft, false)[0]; + const deletedLeft = fromLeft.substring(leftDivergenceIndex)._kmwLength(); + // No need for our specialized variant here. + const insertedText = toLeft.substring(leftDivergenceIndex); - // Step 1: Determine the number of left-deletions. - let maxSMPLeftMatch = fromCaret < toCaret ? fromCaret : toCaret; + const toRight = this.getTextAfterCaret(); + const fromRight = original.getTextAfterCaret(); + const rightDivergence1 = searchStringDivergence(fromRight, toRight, true)[0]; - // We need the corresponding non-SMP caret location in order to binary-search efficiently. - // (Examining code units is much more computationally efficient.) - let maxLeftMatch = to._kmwCodePointToCodeUnit(maxSMPLeftMatch); + // Right insertions aren't supported, but right deletions will matter in some scenarios. + // In particular, once we allow right-deletion for pred-text suggestions applied with the + // caret mid-word.. + const deletedRight = fromRight.substring(0, rightDivergence1 + 1)._kmwLength(); - // 1.1: use a non-SMP-aware binary search to determine the divergence point. - let start = 0; - let end = maxLeftMatch; // the index AFTER the last possible matching char. - - // This search is O(maxLeftMatch). 1/2 + 1/4 + 1/8 + ... converges to = 1. - while(start < end) { - let mid = Math.floor((end+start+1) / 2); // round up (compare more) - let fromLeft = from.substr(start, mid-start); - let toLeft = to.substr(start, mid-start); - - if(fromLeft == toLeft) { - start = mid; - } else { - end = mid - 1; - } - } - - // At the loop's end: `end` now holds the non-SMP-aware divergence point. - // The 'caret' is after the last matching code unit. - - // 1.2: detect a possible surrogate-pair split scenario, correcting for it - // (by moving the split before the high-surrogate) if detected. - - // If the split location is precisely on either end of the context, we can't - // have split a surrogate pair. - if(end > 0 && end < maxLeftMatch) { - let potentialHigh = from.charCodeAt(end-1); - let potentialFromLow = from.charCodeAt(end); - let potentialToLow = to.charCodeAt(end); - - // if potentialHigh is a possible high surrogate... - if(potentialHigh >= 0xD800 && potentialHigh <= 0xDBFF) { - // and at least one potential 'low' is a possible low surrogate... - let flag = potentialFromLow >= 0xDC00 && potentialFromLow <= 0xDFFF; - flag = flag || (potentialToLow >= 0XDC00 && potentialToLow <= 0xDFFF); - - // Correct the split location, moving it 'before' the high surrogate. - if(flag) { - end = end - 1; - } - } - } - - // 1.3: take substring from start to the split point; determine SMP-aware length. - // This yields the SMP-aware divergence index, which gives the number of left-deletes. - let newCaret = from._kmwCodeUnitToCodePoint(end); - let deletedLeft = fromCaret - newCaret; - - // Step 2: Determine the other properties. - // Since the 'after' OutputTarget's caret indicates the end of any inserted text, we - // can easily calculate the rest. - let insertedLength = toCaret - newCaret; - let delta = to._kmwSubstr(newCaret, insertedLength); - - let undeletedRight = to._kmwLength() - toCaret; - let originalRight = from._kmwLength() - fromCaret; - let deletedRight = originalRight - undeletedRight; - - // May occur when reverting a suggestion that had been applied mid-word. - if(deletedRight < 0) { - // Restores deleteRight characters. - delta = delta + to._kmwSubstr(toCaret, -deletedRight); - deletedRight = 0; - } - - return new TextTransform(delta, deletedLeft, deletedRight); + return new TextTransform(insertedText, deletedLeft, deletedRight); } buildTranscriptionFrom(original: OutputTarget, keyEvent: KeyEvent, readonly: boolean, alternates?: Alternate[]): Transcription { diff --git a/common/web/keyboard-processor/src/text/stringDivergence.ts b/common/web/keyboard-processor/src/text/stringDivergence.ts new file mode 100644 index 0000000000..33213f7fdb --- /dev/null +++ b/common/web/keyboard-processor/src/text/stringDivergence.ts @@ -0,0 +1,68 @@ +/** + * Returns the index for the code point divergence point in code unit coordinates. + * @param str1 + * @param str2 + * @param commonRight If false or undefined, asserts a common prefix to the strings. If true, asserts a common suffix. + * @returns The code unit indices within each string for the start of the code point not common to both. + */ +export function searchStringDivergence(str1: string, str2: string, commonRight?: boolean): [number, number] { + let maxInterval = Math.min(str1.length, str2.length) - 1; + const commonLeft = !commonRight; + + let index: number; + let end: number; + + /** + * Index shift per loop iteration. + */ + let inc: number; + /** + * Difference in index for comparison between strings. + * Mostly matters when assuming a common right-hand side. + */ + let offset: number; + + if(commonLeft) { + index = 0; + end = maxInterval; + inc = 1; + offset = 0; + } else { + index = str1.length - 1; + end = index - maxInterval; + inc = -1; + offset = str2.length - str1.length; + } + + for(; commonLeft ? index <= end: index >= end; index += inc) { + if(str1.charAt(index) != str2.charAt(index + offset)) { + break; + } + } + + // `index` corresponds to the first char that is different _in the direction indicated by inc_. + + // if commonLeft, high surrogate; if commonRight, low surrogate. + const commonPotentialSurrogate = str1.charCodeAt(index - inc); + // Opposite surrogate type from the previous variable. + const divergentChar1 = str1.charCodeAt(index); + const divergentChar2 = str2.charCodeAt(index + offset); + + const isHigh = (charCode: number) => charCode >= 0xD800 && charCode <= 0xDBFF; + const isLow = (charCode: number) => charCode >= 0xDC00 && charCode <= 0xDFFF; + const commonChecker = commonLeft ? isHigh : isLow; + const divergentChecker = commonLeft ? isLow : isHigh; + + // If the last common char qualifies as a direction-appropriate SMP surrogate... + if(commonChecker(commonPotentialSurrogate)) { + // And one of the two divergent chars is a qualifying match - a surrogate + // of the opposite type... + if(divergentChecker(divergentChar1) || divergentChecker(divergentChar2)) { + // Our current index would split a surrogate pair; decrement the index to + // preserve the pair. + return [index - inc, index - inc + offset]; + } + } + + return [index, index + offset]; +} \ No newline at end of file diff --git a/common/web/keyboard-processor/tests/node/transcriptions.js b/common/web/keyboard-processor/tests/node/transcriptions.js index 3d8519ec12..5e02cc724e 100644 --- a/common/web/keyboard-processor/tests/node/transcriptions.js +++ b/common/web/keyboard-processor/tests/node/transcriptions.js @@ -1,20 +1,129 @@ import { assert } from 'chai'; -import { Mock } from '@keymanapp/keyboard-processor'; +import { Mock, searchStringDivergence } from '@keymanapp/keyboard-processor'; import { extendString } from '@keymanapp/web-utils'; extendString(); // Ensure KMW's string-extension functionality is available. String.kmwEnableSupplementaryPlane(false); +const toSupplementaryPairString = function(code){ + var H = Math.floor((code - 0x10000) / 0x400) + 0xD800; + var L = (code - 0x10000) % 0x400 + 0xDC00; + + return String.fromCharCode(H, L); +} +// A unicode-coding like alias for use in constructing SMP strings. +const u = toSupplementaryPairString; + +/** + * Returns the "Mathematical Sans-Serif Small" SMP encoding for + * a passed-in lowercase char between 'a' and 'z', inclusive. + * @param {*} char + * @returns + */ +const ss = (char) => { + const charCodeOffset = char.charCodeAt(0) - 'a'.charCodeAt(0); + return u(0x1d5ba + charCodeOffset); +} + +describe("String divergence calculations", function() { + describe("Common prefix", () => { + it("BMP text", () => { + const result1 = searchStringDivergence("apple", "applause", false); + assert.deepEqual(result1, [4, 4]); + + const result2 = searchStringDivergence("applesauce", "applause", false); + assert.deepEqual(result2, [4, 4]); + + const result3 = searchStringDivergence("applesauce", "applesauce", false); + assert.deepEqual(result3, [10, 10]); + }); + + it("SMP text", () => { + const smp_ify = (str) => str.split('').map(ss).join(''); + + const result1 = searchStringDivergence( + smp_ify('apple'), + smp_ify('applause'), + false + ); + + // 2 per SMP char; is in code-unit... units. + // Will avoid splitting code points, though. + assert.deepEqual(result1, [8, 8]); + + const result2 = searchStringDivergence( + smp_ify('applesauce'), + smp_ify('applause'), + false + ); + + assert.deepEqual(result2, [8, 8]); + + const result3 = searchStringDivergence( + smp_ify('applesauce'), + smp_ify('applesauce'), + false + ); + + assert.deepEqual(result3, [20, 20]); + }); + }); + + describe("Common suffix", () => { + it("BMP text", () => { + // att|endance + // transc|endance + const result1 = searchStringDivergence("attendance", "transcendance", true); + assert.deepEqual(result1, [2, 5]); + + // transcend|ance + // happenst|ance + const result2 = searchStringDivergence("transcendance", "happenstance", true); + assert.deepEqual(result2, [8, 7]); + + // And if the two are equal... + const result3 = searchStringDivergence("post-caret text", "post-caret text", true); + assert.deepEqual(result3, [-1, -1]); + }); + + it("SMP text", () => { + const smp_ify = (str) => str.split('').map(ss).join(''); + + // att|endance + // trans|endance + const result1 = searchStringDivergence( + smp_ify("attendance"), + smp_ify("transcendance"), + true + ); + + // 2 per SMP char; is in code-unit... units. + // Will avoid splitting code points; is odd b/c we get the index of the LAST char of the pair. + assert.deepEqual(result1, [5, 11]); + + // transcend|ance + // happenst|ance + const result2 = searchStringDivergence( + smp_ify("transcendance"), + smp_ify("happenstance"), + true + ); + assert.deepEqual(result2, [17, 15]); + + // And if the two are equal... + const result3 = searchStringDivergence( + smp_ify("post-caret text"), + smp_ify("post-caret text"), + true + ); + assert.deepEqual(result3, [-1, -1]); + }); + }) +}); + describe("Transcriptions and Transforms", function() { - var toSupplementaryPairString = function(code){ - var H = Math.floor((code - 0x10000) / 0x400) + 0xD800; - var L = (code - 0x10000) % 0x400 + 0xDC00; - - return String.fromCharCode(H, L); - } - // Built in-line via function. Looks functionally equivalent to "apple", but with SMP characters. let u = toSupplementaryPairString; let smpApple = u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be); @@ -298,7 +407,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. } }); - it.only('from targets with existing selection', () => { + it('from targets with existing selection', () => { // | | const target = new Mock("testing testing one two three"); target.setSelection(8, 20) @@ -313,7 +422,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. }); }); - it.only('to targets with existing selection', () => { + it('to targets with existing selection', () => { // | | const target = new Mock("testing testing one two three"); target.setSelection(8, 20) diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts index 5e11fa4809..6b95cb49a0 100644 --- a/web/src/app/browser/src/keymanEngine.ts +++ b/web/src/app/browser/src/keymanEngine.ts @@ -205,9 +205,6 @@ export default class KeymanEngine extends KeymanEngineBase