From 50db7a2c9b751a179515b9654629d4121965dfd7 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 26 Sep 2022 20:05:52 +1000 Subject: [PATCH] feat(developer): add schemas for .kvk and .kvks Also minor tweaks around reading .xml files. --- common/schemas/kvk/kvk.ksy | 122 ++++++++++++++ common/schemas/kvks/README.md | 14 ++ common/schemas/kvks/kvks.schema.json | 155 +++++++++++++++++ common/schemas/kvks/kvks.xsd | 92 ++++++++++ developer/src/kmc-keyboard/build.sh | 1 + developer/src/kmc-keyboard/package.json | 3 +- .../kmc-keyboard/src/compiler/callbacks.ts | 1 + .../src/kmc-keyboard/src/compiler/messages.ts | 2 +- .../kmc-keyboard/src/kvk/kvk-file-writer.ts | 67 ++++---- .../kmc-keyboard/src/kvk/kvks-file-reader.ts | 158 ++++++++++-------- .../ldml-keyboard/ldml-keyboard-xml-reader.ts | 1 + developer/src/kmc/src/kmc.ts | 4 + 12 files changed, 515 insertions(+), 105 deletions(-) create mode 100644 common/schemas/kvk/kvk.ksy create mode 100644 common/schemas/kvks/README.md create mode 100644 common/schemas/kvks/kvks.schema.json create mode 100644 common/schemas/kvks/kvks.xsd diff --git a/common/schemas/kvk/kvk.ksy b/common/schemas/kvk/kvk.ksy new file mode 100644 index 0000000000..11d8f2faa7 --- /dev/null +++ b/common/schemas/kvk/kvk.ksy @@ -0,0 +1,122 @@ +meta: + id: kvk + title: Keyman Visual Keyboard + file-extension: kvk + license: MIT + ks-version: 0.9 + endian: le + bit-endian: le +doc: | + KVK is the binary file format for Keyman Visual Keyboard + files. KVKS is the equivalent XML source file format +doc-ref: + - https://github.com/keymanapp/keyman/ +seq: + - id: header + type: header + - id: keys + type: keys +types: + header: + seq: + - id: identifier + contents: 'KVKF' + doc: Magic file identifier, always KVKF + - id: version + contents: [0, 6, 0, 0] + doc: Version number of KVK file format, always 0x00000600 + - id: flag + type: header_flags + - id: associated_keyboard + type: string + - id: ansi_font + type: font + - id: unicode_font + type: font + + header_flags: + seq: + - id: display_102 + type: b1 + doc: kvkh102, Keyboard should display 102nd key + - id: display_underlying + type: b1 + doc: kvkhDisplayUnderlying, Keyboard should display underlying characters + - id: use_underlying + type: b1 + doc: kvkhUseUnderlying, + - id: altgr + type: b1 + doc: kvkhAltGr, Keyboard should treat left/right Ctrl and Alt separately + + keys: + seq: + - id: count + type: u4 + - id: key + type: key + repeat: expr + repeat-expr: count + key: + seq: + - id: flags + type: key_flags + - id: modifiers + type: key_modifiers + - id: vkey + type: u2 + - id: text + type: string + - id: bitmap + type: u4 + + key_modifiers: + seq: + - id: shift + type: b1 + - id: ctrl + type: b1 + - id: alt + type: b1 + - id: lctrl + type: b1 + - id: rctrl + type: b1 + - id: lalt + type: b1 + - id: ralt + type: b1 + - id: padding + type: b1 + doc: reserved, + - id: zeropad + contents: [0] + + key_flags: + seq: + - id: bitmap + type: b1 + - id: unicode + type: b1 + - id: padding + type: b6 + + font: + seq: + - id: name + type: string + - id: size + type: u4 + - id: color + type: u4 + + string: + seq: + - id: len + type: u2 + - id: str + type: str + size: len*2 - 2 + encoding: utf-16 + - id: zero_terminator + contents: [0,0] diff --git a/common/schemas/kvks/README.md b/common/schemas/kvks/README.md new file mode 100644 index 0000000000..fb4f2a8a68 --- /dev/null +++ b/common/schemas/kvks/README.md @@ -0,0 +1,14 @@ +# .kvks schema + +This schema validates .kvks files, according to the reference implementation +from VisualKeyboardLoaderXML.pas. + +## Notes on conversion from xsd to json-schema + +Converted using xsd2json. Following structural changes: + +* kvk-version base type from km-version to string, copy km-version pattern in +* remove xs:all bracketing +* remove format:double from fontsize, change type to string +* encoding property changed type to array +* kvk-key added _ property for base text value \ No newline at end of file diff --git a/common/schemas/kvks/kvks.schema.json b/common/schemas/kvks/kvks.schema.json new file mode 100644 index 0000000000..fd58f56e38 --- /dev/null +++ b/common/schemas/kvks/kvks.schema.json @@ -0,0 +1,155 @@ +{ + "title": "kvks.xsd", + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "visualkeyboard": { + "properties": { + "header": { + "$ref": "#/definitions/kvk-header" + }, + "encoding": { + "type": "array", + "items": { + "$ref": "#/definitions/kvk-encoding" + } + } + }, + "required": [ + "header" + ], + "additionalProperties": false, + "type": "object" + } + }, + "required": [ + "visualkeyboard" + ], + "additionalProperties": false, + "definitions": { + "kvk-header": { + "type": "object", + "properties": { + "version": { + "$ref": "#/definitions/kvk-version" + }, + "kbdname": { + "type": "string" + }, + "flags": { + "$ref": "#/definitions/kvk-header-flags" + }, + "layout": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false + }, + "kvk-header-flags": { + "type": "object", + "properties": { + "key102": { + "$ref": "#/definitions/km-empty" + }, + "displayunderlying": { + "$ref": "#/definitions/km-empty" + }, + "usealtgr": { + "$ref": "#/definitions/km-empty" + }, + "useunderlying": { + "$ref": "#/definitions/km-empty" + } + }, + "additionalProperties": false + }, + "kvk-encoding": { + "type": "object", + "properties": { + "layer": { + "type": "array", + "items": { + "$ref": "#/definitions/kvk-layer" + } + }, + "name": { + "$ref": "#/definitions/kvk-encoding-name" + }, + "fontname": { + "type": "string" + }, + "fontsize": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "kvk-layer": { + "type": "object", + "properties": { + "key": { + "type": "array", + "items": { + "$ref": "#/definitions/kvk-key" + } + }, + "shift": { + "$ref": "#/definitions/kvk-layer-shift" + } + }, + "required": [ + "shift" + ], + "additionalProperties": false + }, + "kvk-key": { + "type": "object", + "properties": { + "bitmap": { + "type": "string" + }, + "vkey": { + "type": "string" + }, + "_": { + "type": "string" + } + }, + "required": [ + "vkey" + ], + "additionalProperties": false + }, + "km-empty": { + "type": "string" + }, + "kvk-encoding-name": { + "type": "string", + "enum": [ + "ansi", + "unicode" + ] + }, + "kvk-layer-shift": { + "type": "string", + "pattern": "S?(C|LC|RC)?(A|LA|RA)?" + }, + "kvk-version": { + "type": "string", + "pattern": "(\\d+\\.)+(\\d+)", + "enum": [ + "10.0" + ] + }, + "km-version": { + "type": "string", + "pattern": "(\\d+\\.)+(\\d+)" + } + } +} \ No newline at end of file diff --git a/common/schemas/kvks/kvks.xsd b/common/schemas/kvks/kvks.xsd new file mode 100644 index 0000000000..17056a5a57 --- /dev/null +++ b/common/schemas/kvks/kvks.xsd @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/developer/src/kmc-keyboard/build.sh b/developer/src/kmc-keyboard/build.sh index 27aeccf27e..938e0e1eca 100755 --- a/developer/src/kmc-keyboard/build.sh +++ b/developer/src/kmc-keyboard/build.sh @@ -36,6 +36,7 @@ else # We need the schema file at runtime and bundled, so always copy it for all actions except `clean` mkdir -p "$THIS_SCRIPT_PATH/build/src/" cp "$KEYMAN_ROOT/resources/standards-data/ldml-keyboards/techpreview/ldml-keyboard.schema.json" "$THIS_SCRIPT_PATH/build/src/" + cp "$KEYMAN_ROOT/common/schemas/kvks/kvks.schema.json" "$THIS_SCRIPT_PATH/build/src/" fi #------------------------------------------------------------------------------------------------------------------- diff --git a/developer/src/kmc-keyboard/package.json b/developer/src/kmc-keyboard/package.json index d5ea89d5bd..864f6967c8 100644 --- a/developer/src/kmc-keyboard/package.json +++ b/developer/src/kmc-keyboard/package.json @@ -13,8 +13,7 @@ }, "scripts": { "build": "tsc -b", - "test": "cd test && tsc -b && cd .. && mocha", - "coverage": "cd test && tsc -b && cd .. && c8 mocha", + "test": "cd test && tsc -b && cd .. && c8 mocha", "prepublishOnly": "npm run build" }, "author": "Marc Durdin (https://github.com/mcdurdin)", diff --git a/developer/src/kmc-keyboard/src/compiler/callbacks.ts b/developer/src/kmc-keyboard/src/compiler/callbacks.ts index 8d32f5e8c4..f6cd673b56 100644 --- a/developer/src/kmc-keyboard/src/compiler/callbacks.ts +++ b/developer/src/kmc-keyboard/src/compiler/callbacks.ts @@ -6,5 +6,6 @@ export interface CompilerEvent { export default interface CompilerCallbacks { loadFile(baseFilename: string, filename: string): Buffer; loadLdmlKeyboardSchema(): Buffer; + loadKvksJsonSchema(): Buffer; reportMessage(event: CompilerEvent): void; }; diff --git a/developer/src/kmc-keyboard/src/compiler/messages.ts b/developer/src/kmc-keyboard/src/compiler/messages.ts index 24c4370767..409b37acab 100644 --- a/developer/src/kmc-keyboard/src/compiler/messages.ts +++ b/developer/src/kmc-keyboard/src/compiler/messages.ts @@ -20,7 +20,7 @@ export class CompilerMessages { static Error_InvalidNormalization = (o:{form: string}) => m(this.ERROR_InvalidNormalization, `Invalid normalization form '${o.form}`); static ERROR_InvalidNormalization = SevError | 0x0001; - static Error_InvalidLocale = (o:{tag: string}) => m(this.ERROR_InvalidLocale, `Invalid BCP 47 locale form '${o.tag}`); + static Error_InvalidLocale = (o:{tag: string}) => m(this.ERROR_InvalidLocale, `Invalid BCP 47 locale form '${o.tag}'`); static ERROR_InvalidLocale = SevError | 0x0002; static Error_HardwareLayerHasTooManyRows = () => m(this.ERROR_HardwareLayerHasTooManyRows, `'hardware' layer has too many rows`); diff --git a/developer/src/kmc-keyboard/src/kvk/kvk-file-writer.ts b/developer/src/kmc-keyboard/src/kvk/kvk-file-writer.ts index 49c0655d4b..78a484284b 100644 --- a/developer/src/kmc-keyboard/src/kvk/kvk-file-writer.ts +++ b/developer/src/kmc-keyboard/src/kvk/kvk-file-writer.ts @@ -2,52 +2,47 @@ import KVKFile, { BUILDER_KVK_FILE, BUILDER_KVK_HEADER_IDENTIFIER, BUILDER_KVK_H import { VisualKeyboard } from "./visual-keyboard.js"; export default class KvkFileWriter { - private source: VisualKeyboard; - - constructor(source: VisualKeyboard) { - this.source = source; - } - /** - * Fills a kvk string from a source string. Returns byte size of s as UTF-16 - * zero terminated string for KVK format Note that the format includes both a - * length word and zero termination. - * @param str - * @param value - * @returns number + * Writes the visual keyboard to a binary .kvk format byte array. + * @param source VisualKeyboard + * @returns Uint8Array, the .kvk file */ - private setString(str: BUILDER_KVK_STRING, value: string): void { - str.len = value.length + 1; - str.str = value; + write(source: VisualKeyboard): Uint8Array { + const binary = this.build(source); + const kvk = new KVKFile(); + const file: Uint8Array = new Uint8Array(kvk.KVK_FILE.size(binary)); + const data = kvk.KVK_FILE.toBuffer(binary); + file.set(data, 0); + return file; } - private build() { - let binary: BUILDER_KVK_FILE = { + private build(source: VisualKeyboard) { + const binary: BUILDER_KVK_FILE = { header: { identifier: BUILDER_KVK_HEADER_IDENTIFIER, version: BUILDER_KVK_HEADER_VERSION, associatedKeyboard: {len:0,str:''}, - flags: this.source.header.flags, + flags: source.header.flags, ansiFont:{ - color: this.source.header.ansiFont.color, - size: this.source.header.ansiFont.size, + color: source.header.ansiFont.color, + size: source.header.ansiFont.size, name: {len:0,str:''} }, unicodeFont:{ - color: this.source.header.unicodeFont.color, - size: this.source.header.unicodeFont.size, + color: source.header.unicodeFont.color, + size: source.header.unicodeFont.size, name: {len:0,str:''} }, }, - keyCount: this.source.keys.length, + keyCount: source.keys.length, keys:[] }; - this.setString(binary.header.associatedKeyboard, this.source.header.associatedKeyboard); - this.setString(binary.header.ansiFont.name, this.source.header.ansiFont.name); - this.setString(binary.header.unicodeFont.name, this.source.header.unicodeFont.name); + this.setString(binary.header.associatedKeyboard, source.header.associatedKeyboard); + this.setString(binary.header.ansiFont.name, source.header.ansiFont.name); + this.setString(binary.header.unicodeFont.name, source.header.unicodeFont.name); - for(let sourceKey of this.source.keys) { + for(let sourceKey of source.keys) { const binaryKey: BUILDER_KVK_KEY = { flags: sourceKey.flags, vkey: sourceKey.vkey, @@ -62,12 +57,16 @@ export default class KvkFileWriter { return binary; } - compile(): Uint8Array { - const binary = this.build(); - const kvk = new KVKFile(); - const file: Uint8Array = new Uint8Array(kvk.KVK_FILE.size(binary)); - const data = kvk.KVK_FILE.toBuffer(binary); - file.set(data, 0); - return file; + /** + * Fills a kvk string from a source string. Note that the format includes both + * a length word and zero termination. + * + * @param str + * @param value + * @returns number + */ + private setString(str: BUILDER_KVK_STRING, value: string): void { + str.len = value.length + 1; + str.str = value; } }; \ No newline at end of file diff --git a/developer/src/kmc-keyboard/src/kvk/kvks-file-reader.ts b/developer/src/kmc-keyboard/src/kvk/kvks-file-reader.ts index 09bcf6b262..3490c709b5 100644 --- a/developer/src/kmc-keyboard/src/kvk/kvks-file-reader.ts +++ b/developer/src/kmc-keyboard/src/kvk/kvks-file-reader.ts @@ -1,92 +1,85 @@ import * as xml2js from 'xml2js'; import KVKSourceFile from './kvks-file.js'; import CompilerCallbacks from '../compiler/callbacks.js'; -// import Ajv from 'ajv'; +import Ajv from 'ajv'; import { CompilerMessages } from '../compiler/messages.js'; import { boxXmlArray } from '../util/util.js'; import { VisualKeyboard, VisualKeyboardHeaderFlags, VisualKeyboardKey, VisualKeyboardKeyFlags, VisualKeyboardLegalShiftStates, VisualKeyboardShiftState } from './visual-keyboard.js'; import { USVirtualKeyCodes } from '../ldml-keyboard/virtual-key-constants.js'; import { BUILDER_KVK_HEADER_VERSION } from './kvk-file.js'; -export default class LDMLKeyboardXMLSourceFileReader { +export default class KVKSFileReader { private readonly callbacks: CompilerCallbacks; constructor (callbacks: CompilerCallbacks) { this.callbacks = callbacks; } - /** - * xml2js will not place single-entry objects into arrays. - * Easiest way to fix this is to box them ourselves as needed - * @param source KVKSourceFile - */ - private boxArrays(source: KVKSourceFile) { - boxXmlArray(source.visualkeyboard, 'encoding'); - for(let encoding of source.visualkeyboard.encoding) { - boxXmlArray(encoding, 'layer'); - for(let layer of encoding.layer) { - boxXmlArray(layer, 'key'); - } - } - return source; - } - - public validate(source: KVKSourceFile): KVKSourceFile { - /* LDML-TODO: - const schema = JSON.parse(this.callbacks.loadKvksSchema().toString('utf8')); - const ajv = new Ajv(); - if(!ajv.validate(schema, source)) { - this.callbacks.reportMessage(CompilerMessages.Error_InvalidFile({errorText: ajv.errorsText()})); - return null; - }*/ - return source; - } - - public loadFile(filename: string) { - const buf = this.callbacks.loadFile(filename, filename); - return this.load(buf); - } - - public load(file: Uint8Array): KVKSourceFile { - let source = (() => { - let a: KVKSourceFile; - let parser = new xml2js.Parser({ - explicitArray: false, - mergeAttrs: true, - includeWhiteChars: true, - normalize: false, - emptyTag: {} as any - // Why "as any"? xml2js is broken: - // https://github.com/Leonidas-from-XIV/node-xml2js/issues/648 means - // that an old version of `emptyTag` is used which doesn't support - // functions, but DefinitelyTyped is requiring use of function or a - // string. See also notes at - // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/59259#issuecomment-1254405470 - // An alternative fix would be to pull xml2js directly from github - // rather than using the version tagged on npmjs.com. - }); - parser.parseString(file, (e: unknown, r: unknown) => { a = r as KVKSourceFile }); - return a; - })(); - - return this.validate(this.boxArrays(source)); - } - - public loadVisualKeyboard(file: Uint8Array): VisualKeyboard { - let source = this.load(file); + public read(file: Uint8Array): VisualKeyboard { + let source = this.internalRead(file); return this.transform(source); } - public kvksShiftToKvkShift(shift: string): VisualKeyboardShiftState { - shift = shift.toUpperCase(); + public internalRead(file: Uint8Array): KVKSourceFile { + let source: KVKSourceFile; - // TODO-LDML(lowpri): make a map of this? - for(let state of VisualKeyboardLegalShiftStates) { - if(state.name == shift) { - return state.shift; + const parser = new xml2js.Parser({ + explicitArray: false, + mergeAttrs: true, + includeWhiteChars: true, + normalize: false, + emptyTag: {} as any + // Why "as any"? xml2js is broken: + // https://github.com/Leonidas-from-XIV/node-xml2js/issues/648 means + // that an old version of `emptyTag` is used which doesn't support + // functions, but DefinitelyTyped is requiring use of function or a + // string. See also notes at + // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/59259#issuecomment-1254405470 + // An alternative fix would be to pull xml2js directly from github + // rather than using the version tagged on npmjs.com. + }); + + parser.parseString(file, (e: unknown, r: unknown) => { source = r as KVKSourceFile }); + source = this.boxArrays(source); + this.cleanupUnderscore('visualkeyboard', source.visualkeyboard); + return this.validate(source); + } + + /** + * The only element that allows spaces is . Remove + * all other empty whitespace-only values. + * @param root + * @param source + */ + private cleanupUnderscore(root: string, source: any) { + if(root != 'key') { + if(source?.['_']?.trim() === '') { + delete source['_']; } } - return 0; + + for(let key of Object.keys(source)) { + if(Array.isArray(source[key])) { + for(let item of source[key]) { + if(typeof(item) === 'object') { + this.cleanupUnderscore(key, item); + } + } + } else if(typeof source[key] === 'object') { + this.cleanupUnderscore(key, source[key]); + } + } + } + + public validate(source: KVKSourceFile): KVKSourceFile { + const schema = JSON.parse(this.callbacks.loadKvksJsonSchema().toString('utf8')); + const ajv = new Ajv(); + if(!ajv.validate(schema, source)) { + console.dir(source, {depth:8}); + this.callbacks.reportMessage(CompilerMessages.Error_InvalidFile({errorText: ajv.errorsText()})); + return null; + } + return source; } public transform(source: KVKSourceFile): VisualKeyboard { @@ -142,4 +135,33 @@ export default class LDMLKeyboardXMLSourceFileReader { return result; } + + /** + * xml2js will not place single-entry objects into arrays. + * Easiest way to fix this is to box them ourselves as needed + * @param source KVKSourceFile + */ + private boxArrays(source: KVKSourceFile) { + boxXmlArray(source.visualkeyboard, 'encoding'); + for(let encoding of source.visualkeyboard.encoding) { + boxXmlArray(encoding, 'layer'); + for(let layer of encoding.layer) { + boxXmlArray(layer, 'key'); + } + } + return source; + } + + + public kvksShiftToKvkShift(shift: string): VisualKeyboardShiftState { + shift = shift.toUpperCase(); + + // TODO-LDML(lowpri): make a map of this? + for(let state of VisualKeyboardLegalShiftStates) { + if(state.name == shift) { + return state.shift; + } + } + return 0; + } } \ No newline at end of file diff --git a/developer/src/kmc-keyboard/src/ldml-keyboard/ldml-keyboard-xml-reader.ts b/developer/src/kmc-keyboard/src/ldml-keyboard/ldml-keyboard-xml-reader.ts index 601a1ef997..f820271567 100644 --- a/developer/src/kmc-keyboard/src/ldml-keyboard/ldml-keyboard-xml-reader.ts +++ b/developer/src/kmc-keyboard/src/ldml-keyboard/ldml-keyboard-xml-reader.ts @@ -65,6 +65,7 @@ export default class LDMLKeyboardXMLSourceFileReader { let parser = new xml2js.Parser({ explicitArray: false, mergeAttrs: true, + includeWhiteChars: false, emptyTag: {} as any // Why "as any"? xml2js is broken: // https://github.com/Leonidas-from-XIV/node-xml2js/issues/648 means diff --git a/developer/src/kmc/src/kmc.ts b/developer/src/kmc/src/kmc.ts index 9c2e8a8cf3..9606e15b82 100644 --- a/developer/src/kmc/src/kmc.ts +++ b/developer/src/kmc/src/kmc.ts @@ -49,6 +49,10 @@ class CompilerCallbacks { let schemaPath = new URL('ldml-keyboard.schema.json', import.meta.url); return fs.readFileSync(schemaPath); } + loadKvksJsonSchema(): Buffer { + let schemaPath = new URL('kvks.schema.json', import.meta.url); + return fs.readFileSync(schemaPath); + } } function compileKeyboard(inputFilename: string, options: kmc.CompilerOptions): Uint8Array {