diff --git a/HISTORY.md b/HISTORY.md index 0e676f9040..6ffda9827b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,36 @@ # Keyman Version History +## 18.0.174 alpha 2025-01-21 + +* chore(developer): validate targets parameter in kmc-generate (#12955) +* fix(developer): support `&displayMap` font in web debugger (#12929) + +## 18.0.173 alpha 2025-01-20 + +* feat(developer): rewrite font data in .kvk from package metadata (#12949) +* docs(ios): document gestures within iOS app help (#12900) +* fix(developer): include developer-utils deps in server build (#12951) + +## 18.0.172 alpha 2025-01-19 + +* chore(common): Add 17.0.333 to version history (#12926) + +## 18.0.171 alpha 2025-01-18 + +* fix(common/web): add StrsItem.isEqual() method (#12868) +* fix(common/web): handle invalid order and tertiary arguments to ElementString.fromString() (#12882) + +## 18.0.170 alpha 2025-01-17 + +* chore(linux): update copyright year (#12918) +* fix(web): patch unit test with execution dependent on autocorrect state (#12940) +* test(common/web): unit tests for element-string (#12811) +* chore(linux): Update debian changelog (#12917) + +## 18.0.169 alpha 2025-01-17 + +* chore(windows): remove `postinstall` state from mermaid diagram (#12923) + ## 18.0.168 alpha 2025-01-16 * chore: update macOS environment-variable shell script (#12878) @@ -949,7 +980,7 @@ ## 18.0.41 alpha 2024-05-22 * fix(developer): handle `KM_CORE_IT_INVALIDATE_CONTEXT` in debugger (#11488) -* chore(linux): Trigger GHA packaging for stable builds :cherries: (#11495) +* chore(linux): Trigger GHA packaging for stable builds (#11495) * chore(android,mac,windows): Update crowdin strings for DE (#11497) * feat(web): custom infrastructure for @web/test-runner use (#11403) * chore(web): conversion of lm-worker browser-test for @web/test-runner use (#11404) @@ -1145,6 +1176,17 @@ * chore(common): move to 18.0 alpha (#10713) * chore: move to 18.0 alpha +## 17.0.333 stable 2025-01-16 + +* fix(core): permanently disable logging (#12674) +* fix(linux): pushing of updated changelog branch (#12819) +* fix(linux): work around Lintian errors (#12817) +* fix(core): implement ldml_processor::get_key_list() (#12816) +* chore(linux): Update debian changelog (#12022) +* fix(android): use main looper to dispatch key events when OSK is hidden (#12875) +* chore: use GitHub PR titles when writing HISTORY.md (#12908) +* fix(developer): filter incorrect fonts out of .keyboard_info (#12913) + ## 17.0.332 stable 2024-11-06 * fix(developer): create Server config directory before options save (#12609) diff --git a/VERSION.md b/VERSION.md index b7db0fe155..e1b3341dda 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -18.0.169 \ No newline at end of file +18.0.175 \ No newline at end of file diff --git a/common/web/types/src/kmx/kmx-plus/element-string.ts b/common/web/types/src/kmx/kmx-plus/element-string.ts index b389107cce..d14c4a9011 100644 --- a/common/web/types/src/kmx/kmx-plus/element-string.ts +++ b/common/web/types/src/kmx/kmx-plus/element-string.ts @@ -19,8 +19,8 @@ export class ElemElement { order: number; // -128 to +127; used only by reorder element values tertiary: number; // -128 to +127; used only by reorder element values flags: ElemElementFlags; - isEqual(a: ElemElement) { - return a.value === this.value && + isEqual(a: ElemElement): boolean { + return a.value.isEqual(this.value) && a.order === this.order && a.tertiary === this.tertiary && a.flags === this.flags; @@ -104,8 +104,8 @@ export class ElementString extends Array { typeFlag |= constants.elem_flags_type_str; } } - elem.order = orders.length ? parseInt(orders[i], 10) : 0; - elem.tertiary = tertiaries.length ? parseInt(tertiaries[i], 10) : 0; + elem.order = orders.length ? this.parseIntOrZero(orders[i]) : 0; + elem.tertiary = tertiaries.length ? this.parseIntOrZero(tertiaries[i]) : 0; elem.flags = ElemElementFlags.none | (ElemElementFlags.type & typeFlag) | (tertiary_bases?.[i] == '1' /* TODO-LDML: or 'true'? */ ? ElemElementFlags.tertiary_base : 0) | @@ -125,5 +125,9 @@ export class ElementString extends Array { } return true; } + private static parseIntOrZero(str: string): number { + const num = parseInt(str, 10); + return !Number.isNaN(num) ? num : 0; + } } ; diff --git a/common/web/types/src/kmx/kmx-plus/kmx-plus.ts b/common/web/types/src/kmx/kmx-plus/kmx-plus.ts index a88688f7ab..d0de2989d0 100644 --- a/common/web/types/src/kmx/kmx-plus/kmx-plus.ts +++ b/common/web/types/src/kmx/kmx-plus/kmx-plus.ts @@ -131,6 +131,10 @@ export class StrsItem { get isOneChar() { return this.char !== undefined; } + + isEqual(a: StrsItem): boolean { + return a.value === this.value && a.char === this.char; + } }; /** diff --git a/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts b/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts new file mode 100644 index 0000000000..49a4c2c954 --- /dev/null +++ b/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts @@ -0,0 +1,498 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Dr Mark C. Sinclair on 2024-12-09 + * + * Test code for element-string.ts + */ + +import 'mocha'; +import { assert } from 'chai'; +import { constants } from '@keymanapp/ldml-keyboard-constants'; +import { ElemElementFlags, ElemElement, ElementString } from '../../../src/kmx/kmx-plus/element-string.js'; +import { StrsItem, UsetItem, Strs, StrsOptions, DependencySections, CharStrsItem, Uset } from '../../../src/kmx/kmx-plus/kmx-plus.js'; +import { UnicodeSet, UnicodeSetParser } from '../../../src/ldml-keyboard/unicodeset-parser-api.js'; +import { ElementParser, ElementSegment, ElementType } from '../../../src/ldml-keyboard/pattern-parser.js'; + +const GOTHIC_A = new StrsItem("𐌰", 0x10330); +const GOTHIC_B = new StrsItem("𐌱", 0x10331); +const GOTHIC_C = new StrsItem("𐌲", 0x10332); +const GOTHIC_D = new StrsItem("𐌳", 0x10333); +const HI_GOTHIC_A = new StrsItem('\ud800', 0xd800); +const LO_GOTHIC_A = new StrsItem('\udf30', 0xdf30); +const GOTHIC_PATTERN = "[𐌰-𐍊]"; +const GOTHIC_STRSITEM = new StrsItem(GOTHIC_PATTERN); +const GOTHIC_SET = new UnicodeSet(GOTHIC_PATTERN, [[0x10330,0x1034A]]); +const GOTHIC_USETITEM = new UsetItem(GOTHIC_SET, GOTHIC_STRSITEM); +const UGARITIC_PATTERN = "[πŽ€-𐎟]"; +const UGARITIC_STRSITEM = new StrsItem(UGARITIC_PATTERN); +const UGARITIC_SET = new UnicodeSet(UGARITIC_PATTERN, [[0x10380,0x1039F]]); +const UGARITIC_USETITEM = new UsetItem(UGARITIC_SET, UGARITIC_STRSITEM); + +class TestUnicodeSetParser implements UnicodeSetParser { + parseUnicodeSet = (pattern: string, rangeCount: number) : UnicodeSet | null => GOTHIC_SET; + sizeUnicodeSet = (pattern: string) : number => 1; +}; + +const origElementParserSegment = ElementParser.segment; +let sections: DependencySections = null; + +describe('Test of ElementString file', () => { + describe('Test of ElemElement', () => { + describe('Test of isEqual()', () => { + it('returns true when elems identical', () => { + const one = initElemElement(); + const two = initElemElement(); + assert.isTrue(one.isEqual(two)); + }); + it('returns true when elems are clones', () => { + const one = initElemElement(new StrsItem("𐌰", 0x10330)); + const two = initElemElement(new StrsItem("𐌰", 0x10330)); + assert.isTrue(one.isEqual(two)); + }); + it('returns false when value differs', () => { + const one = initElemElement(GOTHIC_A); + const two = initElemElement(GOTHIC_B); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when order differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 1); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when tertiary differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 1); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when flags differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0, ElemElementFlags.none); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0, ElemElementFlags.type); + assert.isFalse(one.isEqual(two)); + }); + it('returns true even though uset differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM); + const two = initElemElement(GOTHIC_A, UGARITIC_USETITEM); + assert.isTrue(one.isEqual(two)); + }); + }); + }); + describe('Test of ElementString class', () => { + beforeEach(() => { + sections = { + strs: new Strs(), + uset: new Uset(), + usetparser: new TestUnicodeSetParser(), + }; + ElementParser.segment = stubElementParserSegment_CodePoint; + }); + afterEach(() => { + ElementParser.segment = origElementParserSegment; + }); + describe('Test of fromStrings()', () => { + it('returns an empty ElementString if source is null', () => { + const es = ElementString.fromStrings({}, null); + assert.deepEqual(es, new ElementString()); + }); + it('can create an ElementString from a string array', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings(sections, ["𐌰", "𐌱", "𐌲"]); + const expected = [ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]; + assert.deepEqual(actual, expected); + }); + it('can create an ElementString from a string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings(sections, "𐌰𐌱𐌲"); + const expected = [ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply order string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 2 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 2), + initElemElement(GOTHIC_C, undefined, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single order to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 1), + initElemElement(GOTHIC_C, undefined, 1), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle order string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 2", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 2), + initElemElement(GOTHIC_C, undefined, 0), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle non-number in order string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 A 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 0), + initElemElement(GOTHIC_C, undefined, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply tertiary string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 2 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 2), + initElemElement(GOTHIC_C, undefined, 0, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single tertiary to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 1), + initElemElement(GOTHIC_C, undefined, 0, 1), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle tertiary string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 2", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 2), + initElemElement(GOTHIC_C, undefined, 0, 0), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle non-number in tertiary string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 A 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 0), + initElemElement(GOTHIC_C, undefined, 0, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply tertiary_base string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1 0 1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.tertiary_base), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single tertiary_base to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.tertiary_base), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle tertiary_base string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1 0", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.none), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply prebase string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1 0 1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.prebase), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single prebase to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.prebase), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle prebase string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1 0", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.none), + ]; + assert.deepEqual(actual, expected); + }); + it('can create an ElementString from a uset string', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.strs.allocString = stubStrsAllocString_Str; + sections.uset.allocUset = stubUsetAllocUset; + const actual = ElementString.fromStrings(sections, "[𐌰-𐍊]"); + const expected = [ + initElemElement( + new StrsItem(''), + GOTHIC_USETITEM, + 0, + 0, + constants.elem_flags_type_uset, + ), + ]; + assert.deepEqual(actual, expected); + }); + it('returns null for an invalid unicode set size', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.usetparser.sizeUnicodeSet = (pattern: string) : number => -1; + assert.isNull(ElementString.fromStrings(sections, "[𐌰-𐍊]")); + }); + it('returns null if it cannot parse the unicode set', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.usetparser.parseUnicodeSet = (pattern: string, rangeCount: number) : UnicodeSet | null => null; + assert.isNull(ElementString.fromStrings(sections, "[𐌰-𐍊]")); + }); + it('can handle quad strings', () => { + sections.strs.allocString = stubStrsAllocString_Char; + ElementParser.segment = stubElementParserSegment_Escaped; + const actual = ElementString.fromStrings(sections, "\\ud800\\udf30"); + const expected = [ + initElemElement(HI_GOTHIC_A), + initElemElement(LO_GOTHIC_A), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle ElemElement of string type', () => { + sections.strs.allocString = stubStrsAllocString_Str; + const actual = ElementString.fromStrings(sections, ["𐌰𐌱𐌲",]); + const expected = [ + initElemElement( + new StrsItem("𐌰𐌱𐌲"), + undefined, + 0, + 0, + constants.elem_flags_type_str, + ), + ]; + assert.deepEqual(actual, expected); + }); + }); + describe('Test of isEqual()', () => { + it('returns true when ElementStrings are identical', () => { + const es = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + assert.isTrue(es.isEqual(es)); + }); + it('returns true when ElementStrings are clones', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + assert.isTrue(one.isEqual(two)); + }); + it('returns false when ElementStrings are different lengths', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + ]); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when ElementStrings have different ElemElements', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_D), + ]); + assert.isFalse(one.isEqual(two)); + }); + }); + describe('Test of parseIntOrZero()', () => { + it('returns a number for a valid string', () => { + const num = ElementString['parseIntOrZero']('1'); + assert.equal(num, 1); + }); + it('returns zero for an invalid string', () => { + const num = ElementString['parseIntOrZero']('A'); + assert.equal(num, 0); + }); + it('returns zero for undefined', () => { + const num = ElementString['parseIntOrZero'](undefined); + assert.equal(num, 0); + }); + it('returns zero for a null string', () => { + const num = ElementString['parseIntOrZero'](null); + assert.equal(num, 0); + }); + }); + }); +}); + +function initElemElement( + value: StrsItem = GOTHIC_A, + uset: UsetItem = undefined, + order: number = 0, + tertiary: number = 0, + flags: ElemElementFlags = ElemElementFlags.none, +): ElemElement { + const ee = new ElemElement(); + ee.value = value; + ee.uset = uset; + ee.order = order; + ee.tertiary = tertiary; + ee.flags = flags; + return ee; +}; + +function initElementString(elemElements: ElemElement[]): ElementString { + const es: ElementString = new ElementString(); + elemElements.forEach((ee) => {es.push(ee)}); + return es; +}; + +function stubStrsAllocString_Char(s?: string, opts?: StrsOptions, sections?: DependencySections): StrsItem { + return new CharStrsItem(s); +}; + +function stubStrsAllocString_Str(s?: string, opts?: StrsOptions, sections?: DependencySections): StrsItem { + return new StrsItem(s); +}; + +function stubElementParserSegment_CodePoint(str: string): ElementSegment[] { + return [...str].map(s => new ElementSegment(s, ElementType.codepoint)); +}; + +function stubElementParserSegment_Uset(str: string): ElementSegment[] { + return [new ElementSegment(str, ElementType.uset)]; +}; + +function stubElementParserSegment_Escaped(str: string): ElementSegment[] { + const strs = str.match(/\\u[0-9a-fA-F]{4}/g); + return strs.map((s) => new ElementSegment(s, ElementType.escaped)); +}; + +function stubUsetAllocUset(set: UnicodeSet, sections: DependencySections) : UsetItem { + return new UsetItem(set, new StrsItem(set.pattern)); +}; + diff --git a/common/windows/delphi/keyboards/kmxfileconsts.pas b/common/windows/delphi/keyboards/kmxfileconsts.pas index e8f0e0d740..8e1955aaa6 100644 --- a/common/windows/delphi/keyboards/kmxfileconsts.pas +++ b/common/windows/delphi/keyboards/kmxfileconsts.pas @@ -230,7 +230,8 @@ type ssPlatform = 31, ssBaseLayout = 32, ssLayer = 33, ssVKDictionary = 34, ssLayoutFile = 35, // I3438 // I3483 ssKeyboardVersion = 36, ssKMW_EmbedCSS = 37, ssTargets = 38, ssCasedKeys = 39, - ssBegin_NewContext = 40, ssBegin_PostKeystroke = 41, ssNewLayer = 42, ssOldLayer = 43); // I4140 // I4368 // I4504 + ssBegin_NewContext = 40, ssBegin_PostKeystroke = 41, ssNewLayer = 42, ssOldLayer = 43, // I4140 // I4368 // I4504 + ssDisplayMap = 44); const SystemStoreNames: array[TSystemStore] of WideString = ( @@ -245,7 +246,8 @@ const '', '', '', '', 'LAYOUTFILE', 'KEYBOARDVERSION', 'KMW_EMBEDCSS', 'TARGETS', //9.0 // I3483 // I4140 // I4368 // I4504 'CASEDKEYS', //14.0, - '', '', 'NEWLAYER', 'OLDLAYER'); //15.0 + '', '', 'NEWLAYER', 'OLDLAYER', //15.0 + 'DISPLAYMAP'); //17.0 implementation diff --git a/developer/src/common/delphi/general/KeyboardFonts.pas b/developer/src/common/delphi/general/KeyboardFonts.pas index 5c80392521..f1a2bac2b9 100644 --- a/developer/src/common/delphi/general/KeyboardFonts.pas +++ b/developer/src/common/delphi/general/KeyboardFonts.pas @@ -21,7 +21,8 @@ unit KeyboardFonts; interface type - TKeyboardFont = (kfontCode, kfontChar, kfontOSK, kfontTouchLayoutPhone, kfontTouchLayoutTablet, kfontTouchLayoutDesktop); + TKeyboardFont = (kfontCode, kfontChar, kfontOSK, kfontTouchLayoutPhone, kfontTouchLayoutTablet, kfontTouchLayoutDesktop, + kfontDisplayMap); TKeyboardFontArray = array[TKeyboardFont] of string; // I4409 @@ -34,7 +35,7 @@ type const KeyboardFontId: array[TKeyboardFont] of string = ( - 'Code', 'Char', 'Osk', 'TouchLayoutPhone', 'TouchLayoutTablet', 'TouchLayoutDesktop' + 'Code', 'Char', 'Osk', 'TouchLayoutPhone', 'TouchLayoutTablet', 'TouchLayoutDesktop', 'DisplayMap' ); implementation diff --git a/developer/src/kmc-keyboard-info/src/font-family.ts b/developer/src/common/web/utils/src/font-family.ts similarity index 51% rename from developer/src/kmc-keyboard-info/src/font-family.ts rename to developer/src/common/web/utils/src/font-family.ts index a78e341de7..d8fad53e37 100644 --- a/developer/src/kmc-keyboard-info/src/font-family.ts +++ b/developer/src/common/web/utils/src/font-family.ts @@ -24,5 +24,32 @@ export async function getFontFamily(source: Uint8Array) { return null; } + return font.meta.property.find(prop => prop.name == 'font-family')?.text ?? null; +} + +/** + * Extracts the font-family from an in-memory TTF or WOFF blob in `source` + * parameter. + * + * @param source In-memory TTF or WOFF font blob + * + * @throws Uncaught exceptions from ttfMeta.promise if the font file is invalid. + * + * @returns If the file is invalid or cannot be parsed, returns `null`, + * otherwise returns the font family as a string. + */ +export function getFontFamilySync(source: Uint8Array) { + /* c8 ignore next 3 */ + if(!source) { + return null; + } + + const buffer = Buffer.from(source); + const font = ttfMeta.ttfInfoSync(buffer); + /* c8 ignore next 3 */ + if(!font) { + return null; + } + return font.meta.property.find(prop => prop.name == 'font-family')?.text ?? null; } \ No newline at end of file diff --git a/developer/src/common/web/utils/src/index.ts b/developer/src/common/web/utils/src/index.ts index 290efd5fc7..f8ee6246da 100644 --- a/developer/src/common/web/utils/src/index.ts +++ b/developer/src/common/web/utils/src/index.ts @@ -68,4 +68,8 @@ export * as SourceFilenamePatterns from './source-filename-patterns.js'; export { KeymanXMLType, KeymanXMLWriter, KeymanXMLReader } from './xml-utils.js'; export * as GitHubUrls from './github-urls.js'; -export * as CloudUrls from './cloud-urls.js'; \ No newline at end of file +export * as CloudUrls from './cloud-urls.js'; + +export { getFontFamily, getFontFamilySync } from './font-family.js'; + +export * as ValidIds from './valid-ids.js'; diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/LICENSE b/developer/src/common/web/utils/src/ttfmeta/LICENSE similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/LICENSE rename to developer/src/common/web/utils/src/ttfmeta/LICENSE diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/README.md b/developer/src/common/web/utils/src/ttfmeta/README.md similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/README.md rename to developer/src/common/web/utils/src/ttfmeta/README.md diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js b/developer/src/common/web/utils/src/ttfmeta/lib/index.js similarity index 73% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js rename to developer/src/common/web/utils/src/ttfmeta/lib/index.js index eb94d53c1f..53972297f1 100644 --- a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js +++ b/developer/src/common/web/utils/src/ttfmeta/lib/index.js @@ -31,6 +31,20 @@ export function ttfInfo(pathOrData, callback) { } } +/** + * @namespace + * @param {string | number | Buffer | URL} pathOrData + * @returns {typeof ttf.result} + */ +export function ttfInfoSync(pathOrData) { + if (pathOrData instanceof Buffer) { + return ttf.ttfInfoSync(ttf.view(pathOrData)); + } else { + const data = fs.readFileSync(pathOrData); + return ttf.ttfInfoSync(ttf.view(data)); + } +} + /** * @param {string | number | Buffer | URL} pathOrData * @returns {Promise} @@ -48,5 +62,5 @@ export function promise(pathOrData) { } /** @namespace */ -export const ttfMeta = { ttfInfo, promise }; +export const ttfMeta = { ttfInfo, ttfInfoSync, promise }; export default ttfMeta; diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js b/developer/src/common/web/utils/src/ttfmeta/lib/main.js similarity index 97% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js rename to developer/src/common/web/utils/src/ttfmeta/lib/main.js index 239a6244dc..a952512f26 100644 --- a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js +++ b/developer/src/common/web/utils/src/ttfmeta/lib/main.js @@ -300,6 +300,16 @@ export function ttfInfo(data, callback) { } } +/** + * @namespace + * @param {*} data + * @returns {typeof result} + */ +export function ttfInfoSync(data) { + resultTables(data); + return result; +} + /** * @param {string | number | Buffer | URL | DataView} pathOrData * @returns {Promise} diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/meta.js b/developer/src/common/web/utils/src/ttfmeta/lib/meta.js similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/meta.js rename to developer/src/common/web/utils/src/ttfmeta/lib/meta.js diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/test/local.mjs b/developer/src/common/web/utils/src/ttfmeta/test/local.mjs similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/test/local.mjs rename to developer/src/common/web/utils/src/ttfmeta/test/local.mjs diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/test/mocha-index.mjs b/developer/src/common/web/utils/src/ttfmeta/test/mocha-index.mjs similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/test/mocha-index.mjs rename to developer/src/common/web/utils/src/ttfmeta/test/mocha-index.mjs diff --git a/developer/src/common/web/utils/src/valid-ids.ts b/developer/src/common/web/utils/src/valid-ids.ts new file mode 100644 index 0000000000..717a173f5d --- /dev/null +++ b/developer/src/common/web/utils/src/valid-ids.ts @@ -0,0 +1,38 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + + +/** + * Verifies that an id passed in is valid as an identifier for a Keyman .kmn + * keyboard: starts with alpha or underscore and remainder is alphanumeric + * @param id + * @returns + */ +export function isValidKeymanKeyboardId(id: string) { + return /^[a-z_][a-z0-9_]*$/.test(id); +} + +/** + * Verifies that an id passed in is valid as an identifier for an LDML xml + * keyboard: starts with alpha or underscore and remainder is alphanumeric. + * Note that this test is not the same as the CLDR test for valid LDML xml + * filename, as this test has a narrower validity policy to match the Keyman + * implementation requirements. + * @param id + * @returns + */ +export function isValidLdmlKeyboardId(id: string) { + return /^[a-z_][a-z0-9_]*$/.test(id); +} + +/** + * Verifies that an id passed in is valid as an identifier for a Keyman + * lexical model: starts with alpha or underscore and remainder is alphanumeric, + * in three components -- author.bcp47.uniq + * @param id + * @returns + */ +export function isValidLexicalModelId(id: string) { + return /^[a-z_][a-z0-9_]*\.[a-z][a-z0-9_]+\.[a-z_][a-z0-9_]*$/.test(id); +} diff --git a/developer/src/common/web/utils/test/valid-ids.tests.ts b/developer/src/common/web/utils/test/valid-ids.tests.ts new file mode 100644 index 0000000000..c285332b66 --- /dev/null +++ b/developer/src/common/web/utils/test/valid-ids.tests.ts @@ -0,0 +1,76 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { assert } from 'chai'; +import 'mocha'; +import { isValidKeymanKeyboardId, isValidLdmlKeyboardId, isValidLexicalModelId } from '../src/valid-ids.js'; + +describe('valid-ids', function () { + it('should accept a valid Keyman keyboard ID', function() { + [ + 'khmer_angkor', + 'm', + 'fancypants1_2', + 'keyboard___', + '_100', + ].forEach(id => assert.isTrue(isValidKeymanKeyboardId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid Keyman keyboard IDs', function() { + [ + 'khmer_angkor@example.com', + 'keyboard ', + 'Khmer', + ' keyboard', + '111', + '?', + 'keyboard.version', + ].forEach(id => assert.isFalse(isValidKeymanKeyboardId(id), `expected '${id}' to be invalid`)); + }); + + it('should accept a valid LDML keyboard ID', function() { + [ + 'khmer_angkor', + 'm', + 'fancypants1_2', + 'keyboard___', + '_100', + ].forEach(id => assert.isTrue(isValidLdmlKeyboardId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid LDML keyboard IDs', function() { + [ + 'khmer_angkor@example.com', + 'keyboard ', + ' keyboard', + 'Khmer', + '111', + '?', + 'keyboard.version', + ].forEach(id => assert.isFalse(isValidLdmlKeyboardId(id), `expected '${id}' to be invalid`)); + }); + + it('should accept a valid lexical model ID', function() { + [ + 'khmer.km.khmer_angkor', + 'nrc.en.mtnt', + ].forEach(id => assert.isTrue(isValidLexicalModelId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid lexical model IDs', function() { + [ + 'khmer', + 'en-us', + 'khmer.km-KM.angkor', + 'Khmer.km_km.angkor', + 'model ', + ' model', + '111', + '?', + 'lexical.model', + 'lexical..model', + '.en.model', + ].forEach(id => assert.isFalse(isValidLexicalModelId(id), `expected '${id}' to be invalid`)); + }); +}); diff --git a/developer/src/common/web/utils/tsconfig.json b/developer/src/common/web/utils/tsconfig.json index 6f96f75ee7..362ec3a972 100644 --- a/developer/src/common/web/utils/tsconfig.json +++ b/developer/src/common/web/utils/tsconfig.json @@ -5,8 +5,12 @@ "rootDir": ".", "outDir": "./build/", "baseUrl": ".", + + /** for ttfmeta */ + "allowJs": true, }, "include": [ "src/**/*.ts", + "src/ttfmeta/lib/*.js", ], } diff --git a/developer/src/kmc-generate/package.json b/developer/src/kmc-generate/package.json index a8e0810338..ba488ff575 100644 --- a/developer/src/kmc-generate/package.json +++ b/developer/src/kmc-generate/package.json @@ -54,7 +54,8 @@ "exclude-after-remap": true, "exclude": [ "test/", - "src/template/" + "src/template/", + "src/main.ts" ] }, "repository": { diff --git a/developer/src/kmc-generate/src/basic-generator.ts b/developer/src/kmc-generate/src/basic-generator.ts index cb69ef5fdd..fe5a5cd304 100644 --- a/developer/src/kmc-generate/src/basic-generator.ts +++ b/developer/src/kmc-generate/src/basic-generator.ts @@ -7,6 +7,7 @@ import { KeymanTargets } from "@keymanapp/common-types"; import KEYMAN_VERSION from "@keymanapp/keyman-version"; import { AbstractGenerator, GeneratorArtifacts } from "./abstract-generator.js"; +import { GeneratorMessages } from "./generator-messages.js"; /** * @internal @@ -28,7 +29,19 @@ export class BasicGenerator extends AbstractGenerator { this.languageTags = this.options.languageTags.length ? this.options.languageTags.map(tag => new Intl.Locale(tag).minimize().toString()) : [this.DEFAULT_LOCALE]; - //TODO-GENERATE: validate targets + + // Verify that targets passed in are valid + let failed = false; + for(const target of this.options.targets) { + if(!KeymanTargets.AllKeymanTargets.includes(target)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidTarget({target})); + failed = true; + } + } + if(failed) { + return false; + } + if(this.options.targets.includes(KeymanTargets.KeymanTarget.any) || this.options.targets.length == 0) { this.resolvedTargets = KeymanTargets.AllKeymanTargets.filter(target => target != KeymanTargets.KeymanTarget.any); } else { @@ -50,6 +63,8 @@ export class BasicGenerator extends AbstractGenerator { dt.getDate().toString().padStart(2, '0'); this.tokenMap['$PACKAGE_LANGUAGES'] = this.generateLanguageListForPackage(); this.tokenMap['$PLATFORMS_DOTLIST_README'] = this.getPlatformDotListForReadme(); + + return true; } protected generate(artifacts: GeneratorArtifacts): boolean { @@ -89,6 +104,8 @@ export class BasicGenerator extends AbstractGenerator { sourceFile = this.callbacks.path.join(this.templateBasePath, this.templatePath, sourceFile); const sourceData = this.callbacks.loadFile(sourceFile); + + /* c8 ignore next 4 */ if(sourceData == null) { // internal error -- source file should be readable throw new Error(`source file ${sourceFile} does not exist`); diff --git a/developer/src/kmc-generate/src/generator-messages.ts b/developer/src/kmc-generate/src/generator-messages.ts index cda0d82fd2..ad562dbd29 100644 --- a/developer/src/kmc-generate/src/generator-messages.ts +++ b/developer/src/kmc-generate/src/generator-messages.ts @@ -17,30 +17,65 @@ const SevFatal = CompilerErrorSeverity.Fatal | Namespace; * @internal */ export class GeneratorMessages { - static Fatal_UnexpectedException = (o:{e: any}) => m(this.FATAL_UnexpectedException, null, o.e ?? 'unknown error'); static FATAL_UnexpectedException = SevFatal | 0x0001; + static Fatal_UnexpectedException = (o:{e: any}) => m( + this.FATAL_UnexpectedException, null, o.e ?? 'unknown error' + ); - static Info_GeneratingProject = (o:{type: string, id: string}) => m(this.INFO_GeneratingProject, - `Generating project of type ${def(o.type)} with id ${def(o.id)}`); static INFO_GeneratingProject = SevInfo | 0x0002; + static Info_GeneratingProject = (o:{type: string, id: string}) => m( + this.INFO_GeneratingProject, + `Generating project of type ${def(o.type)} with id ${def(o.id)}` + ); static ERROR_CannotCreateFolder = SevError | 0x0003; - static Error_CannotCreateFolder = (o:{folderName:string, e: any}) => CompilerMessageSpecWithException(this.ERROR_CannotCreateFolder, null, - `Unable to create folder ${def(o.folderName)}: ${o.e ?? 'unknown error'}`); + static Error_CannotCreateFolder = (o:{folderName:string, e: any}) => CompilerMessageSpecWithException( + this.ERROR_CannotCreateFolder, null, + `Unable to create folder ${def(o.folderName)}: ${o.e ?? 'unknown error'}` + ); static ERROR_OutputPathAlreadyExists = SevError | 0x0004; static Error_OutputPathAlreadyExists = (o:{outPath:string}) => m( this.ERROR_OutputPathAlreadyExists, - `Output path ${def(o.outPath)} already exists, not overwriting`); + `Output path ${def(o.outPath)} already exists, not overwriting` + ); static ERROR_CannotWriteOutputFile = SevError | 0x0005; - static Error_CannotWriteOutputFile = (o:{filename:string, e: any}) => CompilerMessageSpecWithException(this.ERROR_CannotWriteOutputFile, null, - `Unable to write file ${def(o.filename)}: ${o.e ?? 'unknown error'}`); + static Error_CannotWriteOutputFile = (o:{filename:string, e: any}) => CompilerMessageSpecWithException( + this.ERROR_CannotWriteOutputFile, null, + `Unable to write file ${def(o.filename)}: ${o.e ?? 'unknown error'}` + ); // See also PackageCompilerMessages.WARN_PackageNameDoesNotFollowLexicalModelConventions static WARN_ModelIdDoesNotFollowLexicalModelConventions = SevWarn | 0x0006; - static Warn_ModelIdDoesNotFollowLexicalModelConventions = (o:{id: string}) => m(this.WARN_ModelIdDoesNotFollowLexicalModelConventions, + static Warn_ModelIdDoesNotFollowLexicalModelConventions = (o:{id: string}) => m( + this.WARN_ModelIdDoesNotFollowLexicalModelConventions, `The id ${def(o.id)} does not follow the recommended model id conventions. The id should be all lower case, `+ `include only alphanumeric characters and underscore (_), not start with a digit, and should have the structure `+ - `..`); + `..` + ); + + static ERROR_InvalidTarget = SevError | 0x0007; + static Error_InvalidTarget = (o:{target:string}) => m( + this.ERROR_InvalidTarget, + `The specified target platform '${def(o.target)}' is not a valid target.` + ); + + static ERROR_InvalidKeymanKeyboardId = SevError | 0x0008; + static Error_InvalidKeymanKeyboardId = (o:{id:string}) => m( + this.ERROR_InvalidKeymanKeyboardId, + `The specified keyboard id '${def(o.id)}' contains characters that are not permitted for a .kmn keyboard id or filename.` + ); + + static ERROR_InvalidLdmlKeyboardId = SevError | 0x0009; + static Error_InvalidLdmlKeyboardId = (o:{id:string}) => m( + this.ERROR_InvalidLdmlKeyboardId, + `The specified keyboard id '${def(o.id)}' contains characters that are not permitted for an LDML .xml keyboard id or filename.` + ); + + static ERROR_InvalidLexicalModelId = SevError | 0x000A; + static Error_InvalidLexicalModelId = (o:{id:string}) => m( + this.ERROR_InvalidLexicalModelId, + `The specified lexical model id '${def(o.id)}' contains characters that are not permitted or does not match the required pattern of 'author.bcp47.uniq'.` + ); }; diff --git a/developer/src/kmc-generate/src/keyman-keyboard-generator.ts b/developer/src/kmc-generate/src/keyman-keyboard-generator.ts index c3d5c7665a..ec76beff65 100644 --- a/developer/src/kmc-generate/src/keyman-keyboard-generator.ts +++ b/developer/src/kmc-generate/src/keyman-keyboard-generator.ts @@ -5,9 +5,10 @@ */ import { KeymanFileTypes, KeymanTargets } from '@keymanapp/common-types'; -import { KeymanCompiler, } from '@keymanapp/developer-utils'; +import { KeymanCompiler, ValidIds, } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; +import { GeneratorMessages } from './generator-messages.js'; /** * @public @@ -39,7 +40,15 @@ export class KeymanKeyboardGenerator extends BasicGenerator implements KeymanCom * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidKeymanKeyboardId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidKeymanKeyboardId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); diff --git a/developer/src/kmc-generate/src/ldml-keyboard-generator.ts b/developer/src/kmc-generate/src/ldml-keyboard-generator.ts index e9885e7fb3..f8afb49013 100644 --- a/developer/src/kmc-generate/src/ldml-keyboard-generator.ts +++ b/developer/src/kmc-generate/src/ldml-keyboard-generator.ts @@ -5,9 +5,10 @@ */ import { KeymanFileTypes } from '@keymanapp/common-types'; -import { KeymanCompiler, } from '@keymanapp/developer-utils'; +import { KeymanCompiler, ValidIds, } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; +import { GeneratorMessages } from './generator-messages.js'; /** * @public @@ -28,7 +29,15 @@ export class LdmlKeyboardGenerator extends BasicGenerator implements KeymanCompi * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidLdmlKeyboardId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidLdmlKeyboardId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); this.templatePath = 'ldml-keyboard'; diff --git a/developer/src/kmc-generate/src/lexical-model-generator.ts b/developer/src/kmc-generate/src/lexical-model-generator.ts index f78807798c..86316549cf 100644 --- a/developer/src/kmc-generate/src/lexical-model-generator.ts +++ b/developer/src/kmc-generate/src/lexical-model-generator.ts @@ -5,7 +5,7 @@ */ import { KeymanFileTypes } from '@keymanapp/common-types'; -import { KeymanCompiler, SourceFilenamePatterns } from '@keymanapp/developer-utils'; +import { KeymanCompiler, SourceFilenamePatterns, ValidIds } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; import { GeneratorMessages } from './generator-messages.js'; @@ -31,7 +31,15 @@ export class LexicalModelGenerator extends BasicGenerator implements KeymanCompi * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidLexicalModelId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidLexicalModelId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); diff --git a/developer/src/kmc-generate/test/basic-generator.tests.ts b/developer/src/kmc-generate/test/basic-generator.tests.ts index b1eb1e420d..f5cff4044a 100644 --- a/developer/src/kmc-generate/test/basic-generator.tests.ts +++ b/developer/src/kmc-generate/test/basic-generator.tests.ts @@ -2,22 +2,71 @@ * Keyman is copyright (C) SIL International. MIT License. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import 'mocha'; import { assert } from 'chai'; -import { BasicGenerator } from '../src/basic-generator.js'; import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; +import { BasicGenerator } from '../src/basic-generator.js'; +import { AbstractGenerator, GeneratorArtifacts } from '../src/abstract-generator.js'; import { options } from './shared-options.js'; +import { makePathToFixture } from './helpers/index.js'; + +// this class is here to make it possible to access protected members +class BasicGeneratorTest extends BasicGenerator { + static files() { + return [ + 'sample/' + AbstractGenerator.SFile_WelcomeHTM, + 'sample/' + AbstractGenerator.SFile_ReadmeHTM, + 'sample/' + AbstractGenerator.SFile_HistoryMD, + 'sample/' + AbstractGenerator.SFile_LicenseMD, + 'sample/' + AbstractGenerator.SFile_ReadmeMD, + 'sample/' + AbstractGenerator.SFile_GitIgnore, + ]; + }; + + setup() { + this.templatePath = 'kmn-keyboard'; + } +} describe('BasicGenerator', function () { it('should configure default settings', async function() { const bg = new BasicGenerator(); const callbacks = new TestCompilerCallbacks(); - assert(await bg.init(callbacks, options)); - bg.test_preGenerate(); + assert.isTrue(await bg.init(callbacks, options)); + assert.isTrue(bg.test_preGenerate()); assert.equal(bg.test_tokenMap['$NAME'], 'Sample Project'); }); - // TODO-GENERATE: test transform + it('should successfully transform common project files', async function() { + const bg = new BasicGeneratorTest(); + bg.setup(); + + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await bg.init(callbacks, options)); + assert.isTrue(bg.test_preGenerate()); + + const artifacts: GeneratorArtifacts = { "kmc-generate:outputPath": { data: null, filename: '' } }; + assert.isTrue(bg.test_generate(artifacts)); + + // standard artifacts are listed in AbstractGenerator + const keys = [].concat( + 'kmc-generate:outputPath', + BasicGeneratorTest.files().map(f => f.replaceAll('/', path.sep)) + ); + + assert.hasAllKeys(artifacts, keys); + + // pick one file and compare it + const filename = BasicGeneratorTest.files()[0].replaceAll('/', path.sep); // welcome.htm + assert.equal(artifacts[filename].filename, filename); + const fixturePath = makePathToFixture('basic', 'welcome.htm'); + const fixture = fs.readFileSync(fixturePath, 'utf-8').replace(/\r\n/g, '\n'); + const actual = new TextDecoder().decode(artifacts[filename].data); + assert.equal(actual, fixture); + }); }); diff --git a/developer/src/kmc-generate/test/fixtures/basic/welcome.htm b/developer/src/kmc-generate/test/fixtures/basic/welcome.htm new file mode 100644 index 0000000000..1454a604b6 --- /dev/null +++ b/developer/src/kmc-generate/test/fixtures/basic/welcome.htm @@ -0,0 +1,26 @@ + + + + + + Start Using Sample Project + + + + +

Start Using Sample Project

+ +

+ # A mighty description +

+ +

Keyboard Layout

+ + + + + \ No newline at end of file diff --git a/developer/src/kmc-generate/test/messages.tests.ts b/developer/src/kmc-generate/test/generator-messages.tests.ts similarity index 50% rename from developer/src/kmc-generate/test/messages.tests.ts rename to developer/src/kmc-generate/test/generator-messages.tests.ts index 68c7f741fa..b5d3a8837a 100644 --- a/developer/src/kmc-generate/test/messages.tests.ts +++ b/developer/src/kmc-generate/test/generator-messages.tests.ts @@ -2,15 +2,24 @@ * Keyman is copyright (C) SIL International. MIT License. */ +import * as path from 'node:path'; +import * as fs from 'node:fs'; + import 'mocha'; -import * as path from 'path'; -import * as fs from 'fs'; import { fileURLToPath } from 'url'; import { assert } from 'chai'; -import { GeneratorMessages } from '../src/generator-messages.js'; + import { TestCompilerCallbacks, verifyCompilerMessagesObject } from '@keymanapp/developer-test-helpers'; import { CompilerErrorNamespace } from '@keymanapp/developer-utils'; + +import { GeneratorMessages } from '../src/generator-messages.js'; import { AbstractGenerator, GeneratorOptions } from '../src/abstract-generator.js'; +import { BasicGenerator } from '../src/basic-generator.js'; +import { KeymanKeyboardGenerator } from '../src/keyman-keyboard-generator.js'; +import { LdmlKeyboardGenerator } from '../src/ldml-keyboard-generator.js'; +import { LexicalModelGenerator } from '../src/lexical-model-generator.js'; + +import { options } from './shared-options.js'; describe('GeneratorMessages', function () { const callbacks = new TestCompilerCallbacks(); @@ -62,4 +71,49 @@ describe('GeneratorMessages', function () { assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_CannotWriteOutputFile), `messageId ERROR_CannotWriteOutputFile not generated, instead got: `+JSON.stringify(callbacks.messages,null,2)); }); + + it('should generate ERROR_InvalidTarget if an invalid target is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + // @ts-ignore ('invalid' could be passed by a user, even if would be a compile error here) + targets: ['invalid'] + }; + const bg = new BasicGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert(await bg.init(callbacks, testOptions)); + assert.isFalse(bg.test_preGenerate()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidTarget)); + }); + + it('should generate ERROR_InvalidKeymanKeyboardId if an invalid keyboard id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: '???invalid', + }; + const kkg = new KeymanKeyboardGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await kkg.init(callbacks, testOptions)); + assert.isNull(await kkg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidKeymanKeyboardId)); + }); + + it('should generate ERROR_InvalidLdmlKeyboardId if an invalid keyboard id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: '???invalid', + }; + const lkg = new LdmlKeyboardGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await lkg.init(callbacks, testOptions)); + assert.isNull(await lkg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidLdmlKeyboardId)); + }); + + it('should generate ERROR_InvalidLexicalModelId if an invalid lexical model id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: 'example.???.invalid', + }; + const lmg = new LexicalModelGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await lmg.init(callbacks, testOptions)); + assert.isNull(await lmg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidLexicalModelId)); + }); }); diff --git a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts index ee784429b9..3e1db45aed 100644 --- a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts @@ -12,7 +12,7 @@ import { CompilerCallbacks, KeymanCompiler, CompilerOptions, KeymanCompilerResul import { KmpCompiler } from "@keymanapp/kmc-package"; import { SchemaValidators } from "@keymanapp/common-types"; -import { getFontFamily } from "./font-family.js"; +import { getFontFamily } from "@keymanapp/developer-utils"; const regionNames = new Intl.DisplayNames(['en'], { type: "region" }); const scriptNames = new Intl.DisplayNames(['en'], { type: "script" }); diff --git a/developer/src/kmc-keyboard-info/test/font-family.tests.ts b/developer/src/kmc-keyboard-info/test/font-family.tests.ts index b27fad8d74..d926e75c65 100644 --- a/developer/src/kmc-keyboard-info/test/font-family.tests.ts +++ b/developer/src/kmc-keyboard-info/test/font-family.tests.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import { assert } from 'chai'; import 'mocha'; import { makePathToFixture } from './helpers/index.js'; -import { getFontFamily } from '../src/font-family.js'; +import { getFontFamily } from '@keymanapp/developer-utils'; const AFGHAN_TURKMEN_DISPLAY_FONT = makePathToFixture('afghan_turkmen', "Lateef-Regular.ttf"); const AFGHAN_TURKMEN_OSK_FONT = makePathToFixture('afghan_turkmen', "Lateef-Bold.ttf"); diff --git a/developer/src/kmc-keyboard-info/tsconfig.json b/developer/src/kmc-keyboard-info/tsconfig.json index d09b25c560..124c812ecf 100644 --- a/developer/src/kmc-keyboard-info/tsconfig.json +++ b/developer/src/kmc-keyboard-info/tsconfig.json @@ -13,7 +13,6 @@ }, "include": [ "src/**/*.ts", - "src/ttfmeta/lib/*.js", "src/imports/langtags.js", ], "references": [ diff --git a/developer/src/kmc-package/src/compiler/kmp-compiler.ts b/developer/src/kmc-package/src/compiler/kmp-compiler.ts index 607823c7dd..cd6cf6df36 100644 --- a/developer/src/kmc-package/src/compiler/kmp-compiler.ts +++ b/developer/src/kmc-package/src/compiler/kmp-compiler.ts @@ -18,6 +18,8 @@ import { PackageKeyboardTargetValidator } from './package-keyboard-target-valida import { PackageMetadataUpdater } from './package-metadata-updater.js'; import { markdownToHTML } from './markdown.js'; import { PackageValidation } from './package-validation.js'; +import { getFontFamilySync } from "@keymanapp/developer-utils"; +import { setKvkFontData } from './kvk-font-facename.js'; const KMP_JSON_FILENAME = 'kmp.json'; const KMP_INF_FILENAME = 'kmp.inf'; @@ -483,36 +485,36 @@ export class KmpCompiler implements KeymanCompiler { this.callbacks.reportMessage(PackageCompilerMessages.Warn_AbsolutePath({filename: filename})); } + let memberFileData = this.getMemberFileData(kpsFilename, filename); + if(!memberFileData) { + failed = true; + return; + } filename = this.callbacks.resolveFilename(kpsFilename, filename); const basename = this.callbacks.path.basename(filename); - if(!this.callbacks.fs.existsSync(filename)) { - this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename: filename})); - failed = true; - return; + if(KeymanFileTypes.filenameIs(filename, KeymanFileTypes.Binary.VisualKeyboard)) { + if(!this.isKvkFileBinary(memberFileData)) { + // warn the few users who are still doing this -- non-binary .kvk should not be included in package! + this.callbacks.reportMessage(PackageCompilerMessages.Warn_FileIsNotABinaryKvkFile({filename: filename})); + } else { + memberFileData = this.setKvkFontData(kpsFilename, data, filename, memberFileData); + } } - let memberFileData; - try { - memberFileData = this.callbacks.loadFile(filename); - } catch(e) { - this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e})); - failed = true; - return; - } - - this.warnIfKvkFileIsNotBinary(filename, memberFileData); - zip.file(basename, memberFileData); - - // Remove path data from files before JSON save - value.name = basename; }); if(failed) { return null; } + data.files.forEach((value) => { + const basename = this.callbacks.path.basename(value.name); + // Remove path data from files before JSON save + value.name = basename; + }); + // TODO #9477: transform .md to .htm // Remove path data from file references in options @@ -557,26 +559,87 @@ export class KmpCompiler implements KeymanCompiler { return transcodeToCP1252(s); } + private getMemberFileData(kpsFilename: string, filename: string) { + filename = this.callbacks.resolveFilename(kpsFilename, filename); + + if(!this.callbacks.fs.existsSync(filename)) { + this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename: filename})); + return null; + } + + try { + return this.callbacks.loadFile(filename); + } catch(e) { + this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e})); + return null; + } + } + + private setKvkFontData(kpsFilename: string, data: KmpJsonFile.KmpJsonFile, filename: string, kvk: Uint8Array) { + // find the appropriate font to set + const kvkId = this.callbacks.path.basename(filename.toLowerCase(), '.kvk'); + const kbd = data.keyboards.find(k => k.id == kvkId); + if(!kbd) { + // cannot find a matching keyboard + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindMatchingKeyboardForVisualKeyboard({filename})); + return kvk; + } + const fontFilename = kbd.oskFont || kbd.displayFont; + if(!fontFilename) { + // no font data to set + return kvk; + } + + // Look up the full font filename + const fontFile = data.files.find(file => this.callbacks.path.basename(file.name.toLowerCase()) == fontFilename.toLowerCase()); + if(!fontFile) { + // font cannot be found + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindFontForKeyboard({id: kbd.id, fontFilename})); + return kvk; + } + + // the font is a filename, included in the .kps + const fontData = this.getMemberFileData(kpsFilename, fontFile.name); + if(!fontData) { + // cannot find source font + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindFontForKeyboard({id: kbd.id, fontFilename: fontFile.name})); + return kvk; + } + + const fontFacename = getFontFamilySync(fontData); + if(!fontFacename) { + // Font facename could not be extracted from ttf + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotReadFont({fontFilename: fontFile.name})); + return kvk; + } + + const result = setKvkFontData(kvk, fontFacename); + if(!result) { + // KVK is invalid + this.callbacks.reportMessage(PackageCompilerMessages.Warn_VisualKeyboardFileIsInvalid({filename})); + return kvk; + } + + return result; + } + /** * Legacy .kmp compiler would transform xml-format .kvk files into a binary .kvk file; now - * we want that to remain the responsibility of the keyboard compiler, so we'll warn the - * few users who are still doing this + * we want that to remain the responsibility of the keyboard compiler. */ - private warnIfKvkFileIsNotBinary(filename: string, data: Uint8Array) { - if(!KeymanFileTypes.filenameIs(filename, KeymanFileTypes.Binary.VisualKeyboard)) { - return; - } - + private isKvkFileBinary(data: Uint8Array) { if(data.byteLength < 4) { // TODO: Not a valid .kvk file; should we be reporting this? - return; + return false; } + // Must start with 'KVKF' if(data[0] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[0] || data[1] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[1] || data[2] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[2] || data[3] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[3]) { - this.callbacks.reportMessage(PackageCompilerMessages.Warn_FileIsNotABinaryKvkFile({filename: filename})); + return false; } + return true; } } diff --git a/developer/src/kmc-package/src/compiler/kvk-font-facename.ts b/developer/src/kmc-package/src/compiler/kvk-font-facename.ts new file mode 100644 index 0000000000..b7c69da8e8 --- /dev/null +++ b/developer/src/kmc-package/src/compiler/kvk-font-facename.ts @@ -0,0 +1,16 @@ +import { KvkFileReader, KvkFileWriter } from "@keymanapp/common-types"; + +export function setKvkFontData(fileData: Uint8Array, fontFacename: string) { + const reader = new KvkFileReader(); + const kvk = reader.read(fileData); + if(!kvk) { + // kvk is invalid + return null; + } + + kvk.header.ansiFont.name = fontFacename; + kvk.header.unicodeFont.name = fontFacename; + + const writer = new KvkFileWriter(); + return writer.write(kvk); +} \ No newline at end of file diff --git a/developer/src/kmc-package/src/compiler/package-compiler-messages.ts b/developer/src/kmc-package/src/compiler/package-compiler-messages.ts index 9a593a3a11..6545b7d963 100644 --- a/developer/src/kmc-package/src/compiler/package-compiler-messages.ts +++ b/developer/src/kmc-package/src/compiler/package-compiler-messages.ts @@ -164,5 +164,25 @@ export class PackageCompilerMessages { static Error_RequiredParameterMissing = (o:{param: string}) => m( this.ERROR_RequiredParameterMissing, `Source parameter '${def(o.param)}' is required.` ); + + static WARN_CannotFindMatchingKeyboardForVisualKeyboard = SevWarn | 0x0023; + static Warn_CannotFindMatchingKeyboardForVisualKeyboard = (o:{filename: string}) => m( + this.WARN_CannotFindMatchingKeyboardForVisualKeyboard, `Cannot find matching keyboard for visual keyboard file '${def(o.filename)}'.` + ); + + static WARN_CannotFindFontForKeyboard = SevWarn | 0x0024; + static Warn_CannotFindFontForKeyboard = (o:{id: string, fontFilename: string}) => m( + this.WARN_CannotFindFontForKeyboard, `Cannot find font file '${def(o.fontFilename)}' for keyboard '${def(o.id)}'.` + ); + + static WARN_CannotReadFont = SevWarn | 0x0025; + static Warn_CannotReadFont = (o:{fontFilename: string}) => m( + this.WARN_CannotReadFont, `Cannot parse font file '${def(o.fontFilename)}' to get facename.` + ); + + static WARN_VisualKeyboardFileIsInvalid = SevWarn | 0x0026; + static Warn_VisualKeyboardFileIsInvalid = (o:{filename: string}) => m( + this.WARN_VisualKeyboardFileIsInvalid, `Visual keyboard file '${def(o.filename)}' is invalid.` + ); } diff --git a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx new file mode 100644 index 0000000000..bd6b832cab Binary files /dev/null and b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx differ diff --git a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps index de17212a84..1a02984beb 100644 --- a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps +++ b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps @@ -27,7 +27,7 @@ .kvk - ../../invalid/basic.kmx + binary_kvk_file.kmx Keyboard Basic 0 .kmx @@ -36,7 +36,7 @@ Basic - basic + binary_kvk_file 1.0 Khmer diff --git a/developer/src/kmc/src/commands/generate.ts b/developer/src/kmc/src/commands/generate.ts index bac724d9d4..c9d8dde391 100644 --- a/developer/src/kmc/src/commands/generate.ts +++ b/developer/src/kmc/src/commands/generate.ts @@ -45,7 +45,7 @@ function declareGenerateLdmlKeyboard(command: Command) { .option('-o, --out-path ', 'Output path (may exist)') .option('-n, --name ', 'Keyboard descriptive name') .option('-c, --copyright ', 'Copyright holder') /* Β© yyyy */ - .option('-v, --version ', 'Keyboard version', '1.0') + .option('-v, --version ', 'Keyboard version', '1.0.0') .option('-L, --language-tag ', 'BCP-47 language tag', (value, previous) => previous.concat([value]), []) .option('-a, --author ', 'Name of keyboard author') @@ -59,12 +59,12 @@ function declareGenerateLexicalModel(command: Command) { modelSubCommand .description('Generate a wordlist lexical model project') .option('-o, --out-path ', 'Output path (may exist)') - .option('-n, --name ', 'Keyboard descriptive name') + .option('-n, --name ', 'Lexical model descriptive name') .option('-c, --copyright ', 'Copyright holder') /* Β© yyyy */ - .option('-v, --version ', 'Keyboard version', '1.0') + .option('-v, --version ', 'Lexical model version', '1.0') .option('-L, --language-tag ', 'BCP-47 language tag', (value, previous) => previous.concat([value]), []) - .option('-a, --author ', 'Name of keyboard author') + .option('-a, --author ', 'Name of lexical model author') .option('-d, --description ', 'Short description of the project, Markdown') .action(generateLexicalModel); diff --git a/developer/src/server/build.sh b/developer/src/server/build.sh index c73ed853f7..e0e82e3617 100755 --- a/developer/src/server/build.sh +++ b/developer/src/server/build.sh @@ -113,7 +113,17 @@ function installer_server() { # monorepo context cat package.json | "$JQ" \ '. | del(.devDependencies."@keymanapp/resources-gosh") | del(.devDependencies."@keymanapp/keyman-version") | del(.dependencies."@keymanapp/developer-utils")' \ + > "$PRODBUILDTEMP/package0.json" + + # #12950: Merge dependencies from @keymanapp/developer-utils and Server; this + # is a bit messy but will mean we don't lose the needed dependencies. + "$JQ" -s '.[0] + { dependencies: (.[1].dependencies * .[0].dependencies) }' "$PRODBUILDTEMP/package0.json" \ + "$KEYMAN_ROOT/developer/src/common/web/utils/package.json" > "$PRODBUILDTEMP/package1.json" + cat $PRODBUILDTEMP/package1.json | "$JQ" \ + '. | del(.dependencies."@keymanapp/common-types")' \ > "$PRODBUILDTEMP/package.json" + rm "$PRODBUILDTEMP/package0.json" + rm "$PRODBUILDTEMP/package1.json" pushd "$PRODBUILDTEMP" npm install --omit=dev --omit=optional diff --git a/developer/src/tike/child/UfrmKeymanWizard.pas b/developer/src/tike/child/UfrmKeymanWizard.pas index 59e0f8a341..afb1e715ed 100644 --- a/developer/src/tike/child/UfrmKeymanWizard.pas +++ b/developer/src/tike/child/UfrmKeymanWizard.pas @@ -369,6 +369,8 @@ type FLayoutSetup: Boolean; FCheckboxGridHelper: TCheckboxGridHelper; + FDisplayMapFont: TFont; + function GetCurrentRule: TKeyboardParser_LayoutRule; procedure EnableControls; @@ -475,6 +477,7 @@ type procedure OrderDetailsPanels; function ValidateFileHasNoUnicodeCharacters(sw: WideString): Boolean; procedure UpdateQRCode; + procedure SetDisplayMapFont(const Value: TFont); protected function GetHelpTopic: string; override; @@ -495,6 +498,8 @@ type function ShouldRememberFocus(Control: TWinControl): Boolean; override; // I4679 + property DisplayMapFont: TFont read FDisplayMapFont write SetDisplayMapFont; + public procedure StartDebugging(FStartTest: Boolean = False); procedure StopDebugging; @@ -630,6 +635,8 @@ procedure TfrmKeymanWizard.FormCreate(Sender: TObject); begin FLoading := True; + FDisplayMapFont := TFont.Create; + //SetWindowTheme(pages.Handle, 'Explorer', ''); cmdAddToProject.Caption := '&Add to Project'; @@ -681,6 +688,7 @@ begin FreeAndNil(FKeyboardParser); // I4557 FreeAndNil(FDebugForm); FreeAndNil(FCheckboxGridHelper); + FreeAndNil(FDisplayMapFont); UninitSystemKeyboard; end; @@ -1825,6 +1833,15 @@ begin Delete(Result.Size, Length(Result.Size)-1, 2); end; end; + kfontDisplayMap: + begin + Result.Enabled := FKeyboardParser.GetSystemStoreValue(ssDisplayMap) <> ''; + if Result.Enabled then + begin + Result.Name := DisplayMapFont.Name; + Result.Size := IntToStr(TFontUtils.FontSizeInPoints(DisplayMapFont.Name, DisplayMapFont.Size)); // I4872 + end; + end; end; end; @@ -2535,6 +2552,8 @@ begin FFont.Free; end; end; + + SetFontFromString(DisplayMapFont, ProjectFile.IDEState['DisplayMapFont']); end; procedure TfrmKeymanWizard.SaveSettings(SaveProject: Boolean); @@ -2548,6 +2567,7 @@ begin else ProjectFile.IDEState['DebugDefaultFont'] := '0'; // I4702 ProjectFile.IDEState['DebugFont'] := FontAsString(FDebugForm.memo.Font); // I4702 + ProjectFile.IDEState['DisplayMapFont'] := FontAsString(DisplayMapFont); inherited; end; @@ -2781,6 +2801,13 @@ begin TUtilExecute.URL(lbDebugHosts.Items[lbDebugHosts.ItemIndex]); end; +procedure TfrmKeymanWizard.SetDisplayMapFont(const Value: TFont); +begin + FDisplayMapFont.Assign(Value); + if ProjectFile <> nil then + ProjectFile.IDEState['DisplayMapFont'] := FontAsString(DisplayMapFont); +end; + procedure TfrmKeymanWizard.SetFontInfo(Index: TKeyboardFont; const Value: TKeyboardFontInfo); // I4057 var f: TFont; @@ -2814,6 +2841,11 @@ begin CharFont.Name := Value.Name; CharFont.Size := StrToIntDef(Value.Size, 12); end; + kfontDisplayMap: + begin + DisplayMapFont.Name := Value.Name; + DisplayMapFont.Size := StrToIntDef(Value.Size, 12); + end; kfontOSK: begin if FKeyboardParser.Features.ContainsKey(kfOSK) then // I4258 diff --git a/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm b/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm index bd8ba4de5b..b874db58a8 100644 --- a/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm +++ b/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm @@ -2,11 +2,11 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts BorderIcons = [biSystemMenu] BorderStyle = bsDialog Caption = 'Keyboard Fonts' - ClientHeight = 291 + ClientHeight = 322 ClientWidth = 512 Position = poScreenCenter ExplicitWidth = 518 - ExplicitHeight = 320 + ExplicitHeight = 351 PixelsPerInch = 96 TextHeight = 13 object lblDevEnv: TLabel @@ -147,6 +147,20 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts Height = 13 Caption = 'em' end + object lblDisplayMap: TLabel + Left = 32 + Top = 248 + Width = 109 + Height = 13 + Caption = '&displayMap target font' + end + object Label4: TLabel + Left = 463 + Top = 248 + Width = 14 + Height = 13 + Caption = 'em' + end object fcbCode: TscFontComboBox Left = 176 Top = 41 @@ -260,23 +274,23 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts end object cmdOK: TButton Left = 162 - Top = 248 + Top = 280 Width = 91 Height = 25 Caption = 'OK' Default = True - TabOrder = 12 + TabOrder = 14 OnClick = cmdOKClick end object cmdCancel: TButton Left = 259 - Top = 248 + Top = 280 Width = 90 Height = 25 Cancel = True Caption = 'Cancel' ModalResult = 2 - TabOrder = 13 + TabOrder = 15 end object fcbTouchLayoutPhone: TscFontComboBox Left = 176 @@ -347,4 +361,34 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts ShowPreviewFontName = False ShowPreviewInList = False end + object editDisplayMapSize: TEdit + Left = 391 + Top = 245 + Width = 66 + Height = 21 + TabOrder = 13 + end + object fcbDisplayMap: TscFontComboBox + Left = 176 + Top = 245 + Width = 209 + Height = 24 + DropDownCount = 12 + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -13 + Font.Name = 'Tahoma' + Font.Style = [] + ItemHeight = 18 + ParentFont = False + TabOrder = 12 + MoveUsedToTop = False + ColorUsed = clWindow + PreviewWidth = 240 + PreviewHeight = 60 + FontTypes = [ftTrueTypeAnsi, ftTrueTypeSymbol] + ShowPreview = False + ShowPreviewFontName = False + ShowPreviewInList = False + end end diff --git a/developer/src/tike/dialogs/UfrmKeyboardFonts.pas b/developer/src/tike/dialogs/UfrmKeyboardFonts.pas index 8c5bc103c2..7f5d4546bd 100644 --- a/developer/src/tike/dialogs/UfrmKeyboardFonts.pas +++ b/developer/src/tike/dialogs/UfrmKeyboardFonts.pas @@ -66,6 +66,10 @@ type fcbTouchLayoutPhone: TscFontComboBox; fcbTouchLayoutTablet: TscFontComboBox; fcbTouchLayoutDesktop: TscFontComboBox; + lblDisplayMap: TLabel; + editDisplayMapSize: TEdit; + fcbDisplayMap: TscFontComboBox; + Label4: TLabel; procedure FormCreate(Sender: TObject); procedure cmdOKClick(Sender: TObject); private @@ -120,6 +124,10 @@ begin FControls[kfontTouchLayoutDesktop].CaptionLabel := lblTouchLayoutDesktopFont; FControls[kfontTouchLayoutDesktop].NameCombo := fcbTouchLayoutDesktop; FControls[kfontTouchLayoutDesktop].SizeEdit := editTouchLayoutDesktopSize; + + FControls[kfontDisplayMap].CaptionLabel := lblDisplayMap; + FControls[kfontDisplayMap].NameCombo := fcbDisplayMap; + FControls[kfontDisplayMap].SizeEdit := editDisplayMapSize; end; function TfrmKeyboardFonts.GetFontInfo(Index: TKeyboardFont): TKeyboardFontInfo; diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas index 863b737dc0..10e9dcdd3c 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas @@ -200,7 +200,7 @@ end; function TkmnProjectFileUI.TestKeymanWeb(FSilent: Boolean): Boolean; // I4409 var - FCompiledName: string; + FCompiledName, OSKFont: string; editor: TfrmTikeEditor; wizard: TfrmKeymanWizard; i: TKeyboardFont; @@ -250,7 +250,7 @@ begin // We register all fonts that are used by the layout, // but just once for each reference! - for i := kfontChar to kfontTouchLayoutDesktop do + for i := kfontChar to kfontDisplayMap do begin Found := False; for j := kfontChar to TKeyboardFont(Ord(i)-1) do @@ -265,13 +265,18 @@ begin if TServerUI.VerifyServerRunning then begin + if Wizard.FontInfo[kfontDisplayMap].Enabled then + begin + OSKFont := Wizard.FontInfo[kfontDisplayMap].Name + end + else + OSKFont := Wizard.FontInfo[kfontOSK].Name; + TServerDebugAPI.RegisterKeyboard( FCompiledName, ProjectFile.FileVersion, - // We only need to specify the char + osk fonts here - // as the others are referenced in the touch layout definition directly Wizard.FontInfo[kfontChar].Name, - Wizard.FontInfo[kfontOSK].Name + OSKFont ); wizard.NotifyStartedWebDebug; // I4021 diff --git a/ios/docs/help/context/gestures.md b/ios/docs/help/context/gestures.md new file mode 100644 index 0000000000..251de0f1ce --- /dev/null +++ b/ios/docs/help/context/gestures.md @@ -0,0 +1,15 @@ +--- +title: Gesture Hints and Tips +--- + +## Using Gestures on the Keyboard Keys +There are now several gestures available to interact with some keys on the keyboard. Keys which have gestures available will normally show them as a hint in the top right of the key: + +![](../ios_images/gesture-hint.png) + +* **Long press**: Press and hold a key, and after a moment a submenu will appear. Slide the finger to the submenu to select a key. Release the finger, and the highlighted key from the submenu will be output. + +* **Flick**: Press and hold a key, and then slide the finger in various directions on a key to reach alternate outputs. The key will animate to show the expected output when the finger gets released. A common flick is to slide down to reach numerals on the top row of the keyboard. + + An up-flick on keys that support longpresses but do not have a defined up-flick will skip the wait and immediately display their longpress submenu. +* **Multitap**: Some keys can be pressed repeatedly and rapidly to reach alternate outputs. Tapping rapidly twice on Shift will activate Caps Lock on many keyboards. \ No newline at end of file diff --git a/ios/docs/help/context/index.md b/ios/docs/help/context/index.md index 99e41dd65c..90cfbfd5f6 100644 --- a/ios/docs/help/context/index.md +++ b/ios/docs/help/context/index.md @@ -2,6 +2,8 @@ title: Keyman for iPhone and iPad Help --- +[Keyboard gestures](gestures) + | | Keyboard Keys
(Keyboard Present) | |---|---| | ![](../ios_images/globe.png) | Install or select another language/keyboard | diff --git a/ios/docs/help/index.md b/ios/docs/help/index.md index c71c703e72..994bbbda9e 100644 --- a/ios/docs/help/index.md +++ b/ios/docs/help/index.md @@ -18,6 +18,7 @@ title: Keyman for iPhone and iPad 18.0 Help * [Using the Banner on the Keyboard](basic/using-the-banner) * [Switching Between Keyboards](basic/switching-between-keyboards) * [Using a Keyboard](basic/keyboard-usage) +* [Keyboard Gestures](context/gestures) * [Using the Menu](context/) * [Using the Settings Screen](basic/config/) * [Language Settings](basic/language-settings) diff --git a/ios/docs/help/ios_images/gesture-hint.png b/ios/docs/help/ios_images/gesture-hint.png new file mode 100644 index 0000000000..155ba67418 Binary files /dev/null and b/ios/docs/help/ios_images/gesture-hint.png differ diff --git a/linux/debian/changelog b/linux/debian/changelog index 3a2dae55f3..22fd40caad 100644 --- a/linux/debian/changelog +++ b/linux/debian/changelog @@ -1,3 +1,9 @@ +keyman (17.0.333-1) unstable; urgency=medium + + * New upstream release. + + -- Eberhard Beilharz Thu, 16 Jan 2025 16:29:44 +0100 + keyman (17.0.332-1) unstable; urgency=medium * set environment variable for rendering of downloads dialog (#12617) diff --git a/linux/debian/copyright b/linux/debian/copyright index 3956e1463d..46f156e19b 100644 --- a/linux/debian/copyright +++ b/linux/debian/copyright @@ -4,24 +4,24 @@ Upstream-Contact: Keyman team Source: https://github.com/keymanapp/keyman Files: * -Copyright: 2018-2024 SIL Global +Copyright: 2018-2025 SIL Global License: MIT Files: linux/ibus-keyman/* -Copyright: 2004-2024 SIL Global +Copyright: 2004-2025 SIL Global License: GPL-2+ Files: linux/ibus-keyman/src/keymanutil.c linux/ibus-keyman/src/keymanutil.h linux/ibus-keyman/src/kmpdetails.c linux/ibus-keyman/src/kmpdetails.h -Copyright: 2009-2024 SIL Global +Copyright: 2009-2025 SIL Global License: GPL-2+ or MIT Files: linux/ibus-keyman/src/keyman-service.c linux/ibus-keyman/src/keyman-service.h linux/keyman-config/buildtools/help2md -Copyright: 2018-2024 SIL Global +Copyright: 2018-2025 SIL Global License: GPL-3+ Files: linux/ibus-keyman/tests/ibusimcontext.c @@ -31,7 +31,7 @@ Copyright: 2008-2010, Peng Huang 2008-2013, Peng Huang 2008-2021, Red Hat, Inc. 2015-2021, Takao Fujiwara - 2021-2024, SIL Global + 2021-2025, SIL Global License: LGPL-2.1+ Files: linux/keyman-config/buildtools/help2man @@ -41,7 +41,7 @@ License: GPL-3+ Files: debian/com.keyman.config.appdata.xml debian/com.keyman.ibus_keyman.metainfo.xml Copyright: 2019 Daniel Glassey - 2022-2024 SIL Global + 2022-2025 SIL Global License: MIT License: MIT diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index 9e1786c6d7..e7fc2768c6 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -20,9 +20,12 @@ +

Automatic Mode Example

+ +Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__control-by-control.html b/web/docs/engine/guide/examples/__control-by-control.html index b1f7ac5e42..440a7862d0 100644 --- a/web/docs/engine/guide/examples/__control-by-control.html +++ b/web/docs/engine/guide/examples/__control-by-control.html @@ -33,6 +33,7 @@ +

Control-by-Control Example

Email to (KeymanWeb not enabled)

@@ -43,5 +44,6 @@
+Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__first-example.html b/web/docs/engine/guide/examples/__first-example.html index 3b46160f65..1657db9440 100644 --- a/web/docs/engine/guide/examples/__first-example.html +++ b/web/docs/engine/guide/examples/__first-example.html @@ -18,6 +18,9 @@ +

First Example

Click me and type:

+ +Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__full-manual-control.html b/web/docs/engine/guide/examples/__full-manual-control.html index c9d76d90fb..24b0d004a7 100644 --- a/web/docs/engine/guide/examples/__full-manual-control.html +++ b/web/docs/engine/guide/examples/__full-manual-control.html @@ -43,7 +43,7 @@ window.addEventListener( 'load' , SetupDocument ); - +

Manual Control - Custom Interface

Keyboard:

@@ -51,5 +51,6 @@ window.addEventListener( 'load' , SetupDocument );

+Back to Document diff --git a/web/docs/engine/guide/examples/__manual-control.html b/web/docs/engine/guide/examples/__manual-control.html index 37776a7850..e9f5f219ca 100644 --- a/web/docs/engine/guide/examples/__manual-control.html +++ b/web/docs/engine/guide/examples/__manual-control.html @@ -32,7 +32,7 @@ - +

Manual Mode Example

KeymanWeb

@@ -40,6 +40,7 @@

+Back to Document
\ No newline at end of file diff --git a/web/src/engine/osk/src/input/gestures/browser/multitap.ts b/web/src/engine/osk/src/input/gestures/browser/multitap.ts index 99e50810f9..76280f63e7 100644 --- a/web/src/engine/osk/src/input/gestures/browser/multitap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/multitap.ts @@ -8,6 +8,7 @@ import { distributionFromDistanceMaps } from '../../../corrections.js'; import Modipress from './modipress.js'; import { keySupportsModipress } from '../specsForLayout.js'; import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; +import { correctionKeyFilter } from '../../../correctionLayout.js'; /** * Represents a potential multitap gesture's implementation within KeymanWeb. @@ -104,20 +105,33 @@ export default class Multitap implements GestureHandler { keyEvent.baseTranscriptionToken = this.baseContextToken; const coord = tap.sources[0].currentSample; - const baseDistances = vkbd.getSimpleTapCorrectionDistances(coord, this.baseKey.key.spec as ActiveKey); + + const baseSpec = this.baseKey.key.spec; + const baseDistances = vkbd.getSimpleTapCorrectionDistances(coord, baseSpec as ActiveKey); + // If the key is on a different layer than is shown, what if the user meant to hit a different key? + // (Bypass if the tap is part of a modipress gesture.) if(coord.stateToken != vkbd.layerId && !tap.matchedId.includes('modipress')) { const matchKey = vkbd.layerGroup.findNearestKey({...coord, stateToken: vkbd.layerId}); - // Replace the key at the current location for the current layer key - // with the multitap base key. - const p = baseDistances.get(matchKey.key.spec); - if(p == null) { - console.warn("Could not find current layer's key") + // That said, if they directly touched a non-output key, simplify and assume + // that they hit the intended key. + if(correctionKeyFilter(matchKey.key.spec)) { + // Replace the key at the current location for the current layer key + // with the multitap base key. + const p = baseDistances.get(matchKey.key.spec); + if(p == null) { + console.warn("Could not find current layer's key") + } else { + baseDistances.delete(matchKey.key.spec); + baseDistances.set(coord.item.key.spec, p); + } } - baseDistances.delete(matchKey.key.spec); - baseDistances.set(coord.item.key.spec, p); } - keyEvent.keyDistribution = this.currentStageKeyDistribution(baseDistances); + + // Will also bypass if the base key is modipress-compatible. + if(coord.item && correctionKeyFilter(coord.item.key.spec)) { + keyEvent.keyDistribution = this.currentStageKeyDistribution(baseDistances); + } // TODO for future: multitap previews. diff --git a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js index 4217aeb69b..9aa9cd8015 100644 --- a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js +++ b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js @@ -161,15 +161,21 @@ describe("PredictionContext", () => { assert.equal(updateFake.callCount, 3); suggestions = updateFake.thirdCall.args[0]; + // Note: this unit test was originally written with auto-correct on! + // #11941 was written 2024-07-25 (added unit test for auto-correction method) + // #12169 was written 2024-08-14, which is what added THIS unit test. + // This does re-use the apply-revert oriented mocking. // Should skip the (second) "apple", "apply", "apps" round, as it became outdated // by its following request before its response could be received. - assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['β€œapple”', 'applied']); - assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'β€œapple”'); + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['applied']); // 'β€œapple”' included with auto-correct enabled. + // Is not displayed; we only display it if auto-correct is on, as 'applied' would be automatic then. + assert.equal(predictiveContext.keepSuggestion.displayAs, 'β€œapple”'); + // assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'β€œapple”'); // with auto-correct enabled. assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'applied'); // Our reused mocking doesn't directly provide the 'keep' suggestion; we // need to remove it before testing for set equality. - assert.deepEqual(suggestions.splice(1), expected); + assert.deepEqual(suggestions /*.splice(1)*/, expected); }); it('sendUpdateState retrieves the most recent suggestion set', async function() { diff --git a/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md b/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md index 160d54b2c1..34383de72e 100644 --- a/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md +++ b/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md @@ -6,6 +6,5 @@ stateDiagram Downloading --> Installing Downloading --> WaitingRestart WaitingRestart --> Installing - Installing --> PostInstall - PostInstall --> Idle + Installing --> Idle ```