Merge pull request #1432 from keymanapp/web-osk-bulk-renderer

[Web] OSK bulk renderer
This commit is contained in:
Joshua Horton 2018-12-18 15:01:40 +07:00 committed by GitHub
commit 7d4a67caa5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 482 additions and 33 deletions

View file

@ -0,0 +1,54 @@
#! /bin/bash
#
# Compile the KeymanWeb bulk-renderer module for use with developing/running engine tests.
#
# Fails the build if a specified file does not exist.
assert ( ) {
if ! [ -f $1 ]; then
fail "Build failed."
exit 1
fi
}
fail() {
FAILURE_MSG="$1"
if [[ "$FAILURE_MSG" == "" ]]; then
FAILURE_MSG="Unknown failure"
fi
echo "${ERROR_RED}$FAILURE_MSG${NORMAL}"
exit 1
}
# Ensure the dependencies are downloaded. --no-optional should help block fsevents warnings.
echo "Node.js + dependencies check"
npm install --no-optional
if [ $? -ne 0 ]; then
fail "Build environment setup error detected! Please ensure Node.js is installed!"
fi
# Definition of global compile constants
COMPILED_FILE="bulk_render.js"
OUTPUT="../release/renderer"
NODE_SOURCE="bulk_rendering"
#ENGINE_TEST_OUTPUT="../unit_tests/"
readonly OUTPUT
readonly NODE_SOURCE
#readonly ENGINE_TEST_OUTPUT
# Ensures that we rely first upon the local npm-based install of Typescript.
# (Facilitates automated setup for build agents.)
PATH="../../node_modules/.bin:$PATH"
compiler="npm run tsc --"
compilecmd="$compiler"
$compilecmd -p $NODE_SOURCE/tsconfig.json
if [ $? -ne 0 ]; then
fail "Typescript compilation failed."
fi
#cp $OUTPUT/$COMPILED_FILE $ENGINE_TEST_OUTPUT
#cp $OUTPUT/$COMPILED_FILE.map $ENGINE_TEST_OUTPUT

View file

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<!-- Set the viewport width to match phone and tablet device widths -->
<meta name="viewport" content="width=device-width,user-scalable=no" />
<!-- Allow KeymanWeb to be saved to the iPhone home screen -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Enable IE9 Standards mode -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>KeymanWeb - On-Screen Keyboard Renders</title>
<!-- Your page CSS -->
<style type='text/css'>
body {font-family: Tahoma,helvetica; margin-left: 0px}
h3 {font-size: 1em;font-weight:normal;color: darkred; margin-bottom: 4px}
</style>
<!-- Insert unminified KeymanWeb source scripts -->
<script src="../release/unminified/web/keymanweb.js" type="application/javascript"></script>
<script src="../release/renderer/bulk_render.js" type="application/javascript"></script>
<!-- Initialization: set paths to keyboards, resources and fonts as required -->
<script>
var kmw=window.keyman;
kmw.init({
attachType:'auto'
}).then(function() {
var renderer_poll = function() {
if(kmw.getKeyboards().length == 0) {
window.setTimeout(function() {
renderer_poll();
}, 1000);
} else {
kmw_renderer.run();
}
}
renderer_poll();
});
kmw.addKeyboards();
</script>
</head>
<!-- Sample page HTML -->
<body>
<h1>KeymanWeb - Bulk On-Screen Keyboard Rendering</h1>
<div id='deviceNotes'></div>
<div id='renderList'></div>
</body>
</html>

View file

@ -0,0 +1,242 @@
// Includes KeymanWeb's Device class, as it's quite a useful resource for KMW-related projects.
/// <reference path="../source/kmwdevice.ts" />
// Needed for OSK rendering to image files.
/// <reference path="../node_modules/html2canvas/dist/html2canvas.js" />
// Ensure that Promises are within scope.
/// <reference path="../node_modules/promise-polyfill/lib/polyfill.js" />
type KeyboardMap = {[id: string]: any};
namespace com.keyman.renderer {
export class BatchRenderer {
static divMaster: HTMLDivElement;
static dummy: HTMLInputElement;
// Filters the keyboard array to ensure only a single entry remains, rather than an entry per language.
private filterKeyboards(): KeyboardMap {
let keyman = window['keyman'];
let kbds = keyman['getKeyboards']();
let keyboardMap = [];
for(var i = 0; i < kbds.length; i++) {
let id: string = kbds[i]['InternalName'];
if(keyboardMap[id]) {
continue;
} else {
keyboardMap[id] = kbds[i];
}
}
return keyboardMap;
}
private render(ele: HTMLElement, isMobile?: boolean): Promise<HTMLImageElement> {
let html2canvas = window['html2canvas'];
let imgOut = document.createElement('img');
let canvasParams = {
'logging': false,
'scale': 1,
'width': window.innerWidth // Good for mobile, less-so for desktop.
}
// So, if it's desktop, we set more reasonable values.
if(!isMobile) {
canvasParams['width'] = 500;
ele.style.width = '500px';
}
return html2canvas(ele, canvasParams).then(function(canvas) {
imgOut.src = canvas.toDataURL();
return imgOut;
});
}
createKeyboardHeader(kbd, loaded: boolean): HTMLDivElement {
let divHeader = document.createElement('div');
let eleName = document.createElement('h2');
eleName.textContent = 'ID: ' + kbd['InternalName'];
divHeader.appendChild(eleName);
let eleDescription = document.createElement('p');
if(loaded) {
eleDescription.appendChild(document.createTextNode('Name: ' + kbd['Name']));
eleDescription.appendChild(document.createElement('br'));
eleDescription.appendChild(document.createTextNode('Font: ' + window['keyman'].keyboardManager.activeKeyboard.KV.F));
} else {
eleDescription.appendChild(document.createTextNode('Unable to load this keyboard!'));
}
divHeader.appendChild(eleDescription);
return divHeader;
}
private processKeyboard(kbd) {
let keyman = window['keyman'];
let p: Promise<void> = keyman.setActiveKeyboard(kbd['InternalName']);
let isMobile = keyman.util.device.formFactor != 'desktop';
// Establish common keyboard header info.
let divSummary = document.createElement('div');
// Establishes a linkable target for this keyboard's data.
divSummary.id = "summary-" + kbd['InternalName'];
BatchRenderer.divMaster.insertAdjacentElement('afterbegin', divSummary);
// A nice, closure-friendly reference for use in our callbacks.
let renderer = this;
// Once the keyboard's loaded, we can really get started.
return p.then(function() {
let box: HTMLDivElement = keyman.osk._Box;
divSummary.appendChild(renderer.createKeyboardHeader(kbd, true));
let divRenders = document.createElement('div');
divSummary.appendChild(divRenders);
// Uses 'private' APIs that may be subject to change in the future. Keep it updated!
var layers;
if(isMobile) {
layers = keyman.osk.layers;
} else {
// The desktop OSK will be overpopulated, with a number of blank layers to display in most cases.
// We instead rely upon the KLS definition to ensure we keep the renders sparse.
layers = keyman.keyboardManager.activeKeyboard.KV.KLS;
}
let renderLayer = function(i: number) {
return new Promise(function(resolve) {
// (Private API) Directly sets the keyboard layer within KMW, then uses .show to force-display it.
if(isMobile) {
keyman.osk.layerId = layers[i].id;
} else {
keyman.osk.layerId = Object.keys(layers)[i];
}
// Make sure the active element's still set!
renderer.setActiveDummy();
keyman.osk.show(true);
renderer.render(box, isMobile).then(function(imgEle: HTMLImageElement) {
let eleLayer = document.createElement('div');
let eleLayerId = document.createElement('p');
eleLayerId.textContent = 'Layer ID: ' + (isMobile ? keyman.osk.layers[i].id : Object.keys(layers)[i]);
eleLayer.appendChild(eleLayerId);
eleLayer.appendChild(imgEle);
eleLayer.appendChild(document.createElement('br'));
divRenders.appendChild(eleLayer);
resolve(i);
});
})
};
// The resulting Promise will only call it's `.then()` once all of this keyboard's renders have been completed.
return renderer.arrayPromiseIteration(renderLayer, isMobile ? keyman.osk.layers.length : Object.keys(layers).length);
}).catch(function() {
console.log("Failed to load the \"" + kbd['InternalName'] + "\" keyboard for rendering!");
divSummary.appendChild(renderer.createKeyboardHeader(kbd, false));
return Promise.resolve();
});
}
// Synchronously performs asynchronous operations across a loop, one at a time.
// Necessary due to the nature of KMW OSK rendering.
private arrayPromiseIteration(promiseGenerator: (i: number) => Promise<any>, length: number): Promise<any> {
let iteration = function(index: number): Promise<any> {
if(index < length) {
var promise = promiseGenerator(index);
return promise.then(function(index: number) {
return iteration(++index);
})
} else {
return Promise.resolve();
}
}
return iteration(0);
}
fillDeviceNotes() {
let description = document.createElement('p');
let device = new com.keyman.Device();
device.detect();
description.appendChild(document.createTextNode('Browser: ' + device.browser));
description.appendChild(document.createElement('br'));
description.appendChild(document.createTextNode('OS: ' + device.OS));
description.appendChild(document.createElement('br'));
description.appendChild(document.createTextNode('Form factor: ' + device.formFactor));
description.appendChild(document.createElement('br'));
description.appendChild(document.createTextNode('Touchable: ' + device.touchable));
document.getElementById('deviceNotes').appendChild(description);
}
setActiveDummy() {
com.keyman['DOMEventHandlers'].states.activeElement = BatchRenderer.dummy;
}
run() {
if(window['keyman']) {
let keyman = window['keyman'];
// Establish a 'dummy' element to bypass the 'nothing's active' check KMW usualy uses.
BatchRenderer.dummy = document.createElement('input');
this.setActiveDummy();
BatchRenderer.divMaster = <HTMLDivElement> document.getElementById('renderList');
if(BatchRenderer.divMaster.childElementCount > 0) {
console.log("Prior bulk-renderer run detected. Terminating execution.");
return;
}
// We want the renderer to control where the keyboard is displayed.
// Also bypasses another 'fun' OSK complication.
if(keyman.util.device.formFactor == 'desktop') {
keyman.osk.userPositioned = true;
}
// Assumes that the keyboards have been preloaded for us.
let kbds = this.filterKeyboards();
console.log("Unique keyboard ids detected: " + Object.keys(kbds).length);
let renderer = this;
let keyboardIterator = function(i) {
return new Promise(function(resolve) {
renderer.processKeyboard(kbds[Object.keys(kbds)[i]]).then(function () {
//console.log("Keyboard " + i + " processed!");
resolve(i);
});
});
};
this.arrayPromiseIteration(keyboardIterator, Object.keys(kbds).length).then(function() {
// Once all renders are done, we can now tidy the page up and prep it for final display + potential file-saving.
// This will go at the top of the page when finished, but not when actively rendering.
// We want to leave as much space visible as possible when actively rendering keyboards
// so that auto-scrolling isn't an issue.
renderer.fillDeviceNotes();
});
} else {
console.error("KeymanWeb not detected!");
}
}
}
(function(){
window['kmw_renderer'] = new com.keyman.renderer.BatchRenderer();
})();
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"allowJs": true,
"inlineSources": true,
"module": "none",
"outFile": "../release/renderer/bulk_render.js",
"sourceMap": true,
"target": "es5"
},
"files": [
"renderer_core.ts"
]
}

View file

@ -4,6 +4,7 @@
* Add `setNumericLayer()` for embedded platforms to change OSK to numeric layer.
* Fixes issue where file extensions are upper-case, e.g. ".TTF"
* Fixes keyboard layout issues after mobile device rotations. (#248) (#970)
* Adds support for Promises to init() and setActiveKeyboard(). (#100)
## 2018-07-06 10.0.103 stable
* Fixes issue for embedded Android, iOS apps where a keyboard with varying row counts in different layers could crash (#1055)

23
web/package-lock.json generated
View file

@ -701,6 +701,15 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
},
"css-line-break": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-1.0.1.tgz",
"integrity": "sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo=",
"dev": true,
"requires": {
"base64-arraybuffer": "^0.1.5"
}
},
"custom-event": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
@ -1975,6 +1984,15 @@
"integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==",
"dev": true
},
"html2canvas": {
"version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz",
"integrity": "sha1-OxmS48mz9WBjw1/WIElPN+uohRM=",
"dev": true,
"requires": {
"css-line-break": "1.0.1"
}
},
"http-errors": {
"version": "1.6.3",
"resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
@ -3034,6 +3052,11 @@
"integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=",
"dev": true
},
"promise-polyfill": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.0.tgz",
"integrity": "sha512-OzSf6gcCUQ01byV4BgwyUCswlaQQ6gzXc23aLQWhicvfX9kfsUiUhgt3CCQej8jDnl8/PhGF31JdHX2/MzF3WA=="
},
"qjobs": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz",

View file

@ -21,6 +21,7 @@
"chai": "^4.2.0",
"google-closure-compiler": "^20171203.0.0",
"google-closure-library": "^20171203.0.0",
"html2canvas": "^1.0.0-alpha.12",
"karma": "^3.1.1",
"karma-browserstack-launcher": "^1.3.0",
"karma-chai": "^0.1.0",
@ -44,5 +45,8 @@
"test": "bash ./unit_tests/test.sh",
"karma": "karma",
"modernizr": "modernizr"
},
"dependencies": {
"promise-polyfill": "^8.1.0"
}
}

View file

@ -1,5 +1,7 @@
// Includes KMW-added property declaration extensions for HTML elements.
/// <reference path="kmwexthtml.ts" />
// Includes a promise polyfill (needed for IE)
/// <reference path="../node_modules/promise-polyfill/lib/polyfill.js" />
// Defines the web-page interface object.
/// <reference path="kmwdom.ts" />
// Includes KMW-added property declaration extensions for HTML elements.
@ -283,8 +285,8 @@ namespace com.keyman {
* @param {string} PInternalName Internal name
* @param {string} PLgCode Language code
*/
['setActiveKeyboard'](PInternalName: string, PLgCode: string) {
this.keyboardManager.setActiveKeyboard(PInternalName,PLgCode);
['setActiveKeyboard'](PInternalName: string, PLgCode: string): Promise<void> {
return this.keyboardManager.setActiveKeyboard(PInternalName,PLgCode);
}
/**
@ -391,8 +393,8 @@ namespace com.keyman {
* @param {Object} arg object array of user-defined properties
* Description KMW window initialization
*/
['init'](arg) {
this.domManager.init(arg);
['init'](arg): Promise<any> {
return this.domManager.init(arg);
}
/**

View file

@ -1467,7 +1467,7 @@ namespace com.keyman {
* @param {Object} arg object array of user-defined properties
* Description KMW window initialization
*/
init(arg) {
init: (arg:any) => Promise<any> = function(arg): Promise<any> {
var i,j,c,e,p,eTextArea,eInput,opt,dTrailer,ds;
var osk = this.keyman.osk;
var util = this.keyman.util;
@ -1496,7 +1496,7 @@ namespace com.keyman {
// Otherwise, assume relative to source path
return this.keyman.srcPath+p;
}
}.bind(this);
// Explicit (user-defined) parameter initialization
opt=this.keyman.options;
@ -1537,7 +1537,7 @@ namespace com.keyman {
// Only do remainder of initialization once!
if(this.keyman.initialized) {
return;
return Promise.resolve();
}
var keyman: KeymanBase = this.keyman;
@ -1546,10 +1546,13 @@ namespace com.keyman {
// Do not initialize until the document has been fully loaded
if(document.readyState !== 'complete')
{
window.setTimeout(function(){
domManager.init(arg);
}, 50);
return;
return new Promise(function(resolve) {
window.setTimeout(function(){
domManager.init(arg).then(function() {
resolve();
});
}, 50);
});
}
this.keyman._MasterDocument = window.document;
@ -1592,7 +1595,7 @@ namespace com.keyman {
if(!this.keyman.keyboardManager.setDefaultKeyboard()) {
console.error("No keyboard stubs exist - cannot initialize keyboard!");
}
return;
return Promise.resolve();
}
// Determine the default font for mapped elements
@ -1716,7 +1719,8 @@ namespace com.keyman {
// Set exposed initialization flag to 2 to indicate deferred initialization also complete
this.keyman.setInitialized(2);
}
return Promise.resolve();
}.bind(this);
/**
* Initialize the desktop user interface as soon as it is ready

View file

@ -60,7 +60,7 @@ namespace com.keyman {
'KOskFont': KeyboardFont;
// Used when loading a stub's keyboard.
asyncLoader: any;
asyncLoader?: any;
constructor(id: string, langCode: string) {
this['KI'] = 'Keyboard_' + id;
@ -305,7 +305,7 @@ namespace com.keyman {
// Fixed OSK font issue Github #7 (9/1/2015)
if(typeof(lp['oskFont']) != 'undefined') {
sp['KOskFont'] = (typeof sp['KOskFont'] === 'undefined') ? new KeyboardFont(lp['oskfont'], fontPath) : sp['KOskFont'];
sp['KOskFont'] = (typeof sp['KOskFont'] === 'undefined') ? new KeyboardFont(lp['oskFont'], fontPath) : sp['KOskFont'];
}
// Update the UI
@ -357,7 +357,7 @@ namespace com.keyman {
* @param {string} PInternalName Internal name
* @param {string} PLgCode Language code
*/
setActiveKeyboard(PInternalName: string, PLgCode: string) {
setActiveKeyboard(PInternalName: string, PLgCode: string): Promise<void> {
//TODO: This does not make sense: the callbacks should be in _SetActiveKeyboard, not here,
// since this is always called FROM the UI, which should not need notification.
// If UI callbacks are needed at all, they should be within _SetActiveKeyboard
@ -368,7 +368,7 @@ namespace com.keyman {
}
this.doBeforeKeyboardChange(PInternalName,PLgCode);
this._SetActiveKeyboard(PInternalName,PLgCode,true);
let p = this._SetActiveKeyboard(PInternalName,PLgCode,true);
if(this.keymanweb.domManager.getLastActiveElement() != null) {
this.keymanweb.domManager.focusLastActiveElement(); // TODO: Resolve without need for the cast.
}
@ -378,6 +378,8 @@ namespace com.keyman {
// PLgCode = (<KeymanBase>keymanweb).keyboardManager.activeStub['KLC'];
// }
this.doKeyboardChange(PInternalName, PLgCode);
return p;
}
/**
@ -395,7 +397,7 @@ namespace com.keyman {
* @param {string=} PLgCode
* @param {boolean=} saveCookie
*/
_SetActiveKeyboard(PInternalName: string, PLgCode?: string, saveCookie?: boolean) {
_SetActiveKeyboard(PInternalName: string, PLgCode?: string, saveCookie?: boolean): Promise<void> {
var n, Ln;
var util = this.keymanweb.util;
@ -430,7 +432,7 @@ namespace com.keyman {
if(this.activeStub && this.activeKeyboard && this.activeKeyboard['KI'] == PInternalName
&& this.activeStub['KI'] == PInternalName //this part of test should not be necessary, but keep anyway
&& this.activeStub['KLC'] == PLgCode && !this.keymanweb.mustReloadKeyboard
) return;
) return Promise.resolve();
// Check if current keyboard matches requested keyboard, but not stub
if(this.activeKeyboard && (this.activeKeyboard['KI'] == PInternalName)) {
@ -448,7 +450,7 @@ namespace com.keyman {
if(this.keymanweb.mustReloadKeyboard) {
osk._Load();
}
return;
return Promise.resolve();
}
}
}
@ -464,7 +466,7 @@ namespace com.keyman {
util.wait(false);
}
return;
return Promise.resolve();
}
// Determine if the keyboard was previously loaded but is not active and use the prior load if so.
@ -552,12 +554,14 @@ namespace com.keyman {
// It works much more reliably if deferred (KMEW-101, build 356)
// The effect of a delay can also be tested, for example, by setting the timeout to 5000
var manager = this;
window.setTimeout(function(){
manager.installKeyboard(loadingStub);
},0);
}
loadingStub.asyncLoader.promise = new Promise(function(resolve, reject) {
window.setTimeout(function(){
manager.installKeyboard(resolve, reject, loadingStub);
},0);
});
}
this.activeStub=this.keyboardStubs[Ln];
return;
return this.keyboardStubs[Ln].asyncLoader.promise;
}
}
this.keymanweb.domManager._SetTargDir(this.keymanweb.domManager.getLastActiveElement()); // I2077 - LTR/RTL timing
@ -569,15 +573,17 @@ namespace com.keyman {
// Initialize the OSK (provided that the base code has been loaded)
osk._Load();
return Promise.resolve();
}
/**
/**
* Install a keyboard script that has been downloaded from a keyboard server
* Operates as the core of a Promise, hence the 'resolve' and 'reject' parameters.
*
* @param {Object} kbdStub keyboard stub to be loaded.
*
**/
installKeyboard(kbdStub: KeyboardStub) {
installKeyboard(resolve: () => void, reject: () => void, kbdStub: KeyboardStub) {
var util = this.keymanweb.util;
var osk = this.keymanweb.osk;
@ -607,6 +613,8 @@ namespace com.keyman {
// We already know the load has failed... why wait?
kbdStub.asyncLoader.callback('Cannot find the ' + kbdName + ' keyboard for ' + kbdLang + '.', 'warn');
kbdStub.asyncLoader = null;
reject();
}, false);
@ -644,23 +652,31 @@ namespace com.keyman {
if(!manager.keymanweb.isEmbedded) {
util.wait(false);
}
kbdStub.asyncLoader = null;
resolve();
// A handler portion for cases where the new <script> block loads, but fails to process.
} else { // Output error messages even when embedded - they're useful when debugging the apps and KMEA/KMEI engines.
kbdStub.asyncLoader.callback('Error registering the ' + kbdName + ' keyboard for ' + kbdLang + '.', 'error');
kbdStub.asyncLoader = null;
reject();
}
kbdStub.asyncLoader = null;
}, false);
// IE likes to instantly start loading the file when assigned to an element, so we do this after the rest
// of our setup. This method is not relocated here (yet) b/c it varies based upon 'native' vs 'embedded'.
Lscript.src = this.keymanweb.getKeyboardPath(kbdFile);
try {
try {
document.body.appendChild(Lscript);
this.linkedScripts.push(Lscript);
}
catch(ex) {
document.getElementsByTagName('head')[0].appendChild(Lscript);
catch(ex) {
try {
document.getElementsByTagName('head')[0].appendChild(Lscript);
} catch(ex2) {
reject();
}
}
}

View file

@ -4097,7 +4097,20 @@ if(!window['keyman']['initialized']) {
var Ls = osk._Box.style;
// Do not display OSK until it has been positioned correctly
if(device.touchable && Ls.bottom == '') Ls.visibility='hidden';
if(device.touchable && Ls.bottom == '') {
Ls.visibility='hidden';
}
if(device.touchable) {
/* In case it's still '0' from a hide() operation.
* Happens when _Show is called before the transitionend events are processed,
* which can happen in bulk-rendering contexts.
*
* (Opacity is only modified when device.touchable = true, though a couple of extra
* conditions may apply.)
*/
Ls.opacity='1';
}
// The following code will always be executed except for externally created OSK such as EuroLatin
if(osk.ddOSK)

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<KeymanDeveloperProject>
<Options>
<BuildPath></BuildPath>
<CompilerWarningsAsErrors>False</CompilerWarningsAsErrors>
<WarnDeprecatedCode>True</WarnDeprecatedCode>
</Options>
<Files>
<File>
<ID>id_d6d0aefd0ac869e89e3e93e50e54f856</ID>
<Filename>issue382.kmn</Filename>
<Filepath>issue382.kmn</Filepath>
<FileVersion>1.0</FileVersion>
<FileType>.kmn</FileType>
<Details>
<Name>Keycap Scaling Test</Name>
</Details>
</File>
</Files>
</KeymanDeveloperProject>