Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(developer): serialize KMXPlus (back) into XML 🗼 #12969

Open
wants to merge 7 commits into
base: epic/ldml-editor
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/web/types/src/kmx/kmx-plus/kmx-plus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ export class TranTransform {
to: StrsItem;
mapFrom: StrsItem; // var name
mapTo: StrsItem; // var name
_from?: string; // for serialization
_to?: string; // for serialization
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will these cause any maintenance confusion with from and to?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using the same pattern as in kmx-plus-builder where we have

  id: BUILDER_STR_REF; // str with original key id
  _id: string; // original key id, for sorting

https://github.com/keymanapp/keyman/blob/feat/developer/12874-kmx-to-xml-epic-ldml-editor/developer/src/common/web/utils/src/types/kmx/kmx-plus-builder/build-keys.ts#L23

I will add clearer documentation on the property.

}

export class TranGroup {
Expand All @@ -420,6 +422,9 @@ export class TranGroup {
export class TranReorder {
elements: ElementString;
before: ElementString;
_before?: string; // for serializing
_from?: string; // for serializing
_order?: string; // for serializing
};

export class Tran extends Section {
Expand Down
7 changes: 7 additions & 0 deletions developer/src/common/web/utils/src/xml-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ const GENERATOR_OPTIONS: KeymanXMLOptionsBag = {
textNodeName: '_',
suppressEmptyNode: true,
},
keyboard3: {
attributeNamePrefix: '$',
ignoreAttributes: false,
format: true,
textNodeName: '_',
suppressEmptyNode: true,
},
};

/** wrapper for XML parsing support */
Expand Down
24 changes: 19 additions & 5 deletions developer/src/kmc-ldml/src/compiler/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import { SubstitutionUse, Substitutions } from './substitution-tracker.js';
/** reserved name for the special gap key. space is not allowed in key ids. */
const reserved_gap = "gap (reserved)";


export class KeysCompiler extends SectionCompiler {
/** keys that are of a reserved type */
public static RESERVED_KEY = Symbol('Reserved Key');
static validateSubstitutions(
keyboard: LDMLKeyboard.LKKeyboard,
st: Substitutions
Expand Down Expand Up @@ -222,6 +223,19 @@ export class KeysCompiler extends SectionCompiler {
/** count of reserved keys, for tests */
public static readonly reserved_count = KeysCompiler.reserved_keys.length;

/** mark as reserved */
private static asReserved(k : KeysKeys) : KeysKeys {
const o = k as any;
o[KeysCompiler.RESERVED_KEY] = true;
return k;
}

/** true if a reserved key */
public static isReserved(k : KeysKeys) : boolean {
const o = k as any;
return !!o[KeysCompiler.RESERVED_KEY];
}

/** load up all reserved keys */
getReservedKeys(sections: KMXPlus.DependencySections) : Map<String, KeysKeys> {
const r = new Map<String, KeysKeys>();
Expand All @@ -231,7 +245,7 @@ export class KeysCompiler extends SectionCompiler {
const no_list = sections.list.allocList([], {}, sections);

// now add the reserved key(s).
r.set(reserved_gap, {
r.set(reserved_gap, KeysCompiler.asReserved({
flags: constants.keys_key_flags_gap | constants.keys_key_flags_extend,
id: sections.strs.allocString(reserved_gap),
flicks: '',
Expand All @@ -241,7 +255,7 @@ export class KeysCompiler extends SectionCompiler {
switch: no_string,
to: no_string,
width: 10.0, // 10 * .1
});
}));

if (r.size !== KeysCompiler.reserved_count) {
throw Error(`Internal Error: KeysCompiler.reserved_count=${KeysCompiler.reserved_count} != ${r.size} actual reserved keys.`);
Expand Down Expand Up @@ -357,7 +371,7 @@ export class KeysCompiler extends SectionCompiler {
flags |= constants.keys_key_flags_extend;
}
const width = Math.ceil((key.width || 1) * 10.0); // default, width=1
sect.keys.push({
sect.keys.push(SectionCompiler.copySymbols({
flags,
flicks: flickId,
id,
Expand All @@ -367,7 +381,7 @@ export class KeysCompiler extends SectionCompiler {
switch: keySwitch, // 'switch' is a reserved word
to,
width,
});
}, key));
}
}

Expand Down
16 changes: 16 additions & 0 deletions developer/src/kmc-ldml/src/compiler/section-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,20 @@ export abstract class SectionCompiler {
]);
return defaults;
}

/**
* Copy symbols from 'from' onto 'onto'
* This is used to propagate special symbols such as ImportStatus
* and XML
* @param onto object to copy onto
* @param from source for symbols
* @returns the onto object
*/
public static copySymbols<T>(onto: T, from: any) : T {
const o = onto as any;
for (const sym of Object.getOwnPropertySymbols(from)) {
o[sym] = from[sym];
}
return onto;
}
}
7 changes: 7 additions & 0 deletions developer/src/kmc-ldml/src/compiler/tran.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export abstract class TransformCompiler<T extends TransformCompilerType, TranBas

private compileTransform(sections: DependencySections, transform: LKTransform) : TranTransform {
let result = new TranTransform();
// setup for serializing
result._from = transform.from;
result._to = transform.to;
let cookedFrom = transform.from;

// check for incorrect \uXXXX escapes. Do this before substituting markers or sets.
Expand Down Expand Up @@ -262,6 +265,10 @@ export abstract class TransformCompiler<T extends TransformCompilerType, TranBas

private compileReorder(sections: DependencySections, reorder: LKReorder): TranReorder {
let result = new TranReorder();
// for serializing
result._before = reorder.before;
result._from = reorder.from;
result._order = reorder.order;
if (reorder.from && this.checkEscapes(reorder.from) === null) {
return null; // error'ed
}
Expand Down
256 changes: 256 additions & 0 deletions developer/src/kmc-ldml/src/util/serialize.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm clear on this file and everything after it... just not so clear on the changes before and what their role is - all the new underscore-prefixed entries.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will clarify.

Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*/

import { KMXPlus } from "@keymanapp/common-types";
import { KeymanXMLWriter, LDMLKeyboard } from "@keymanapp/developer-utils";
import { constants } from "@keymanapp/ldml-keyboard-constants";
import { KeysCompiler } from "../compiler/keys.js";

/**
* Serialize a KMXPlusFile back to XML
* TODO-EPIC-LDML: Does not handle transforms properly (marker strings).
* TODO-EPIC-LDML: Does not retain the original XML formatting.
* @param kmx input KMXPlusFile
* @returns XML String
*/
export function kmxToXml(kmx: KMXPlus.KMXPlusFile): string {
const writer = new KeymanXMLWriter("keyboard3");
const { kmxplus } = kmx;
const {
// sect,
bksp,
disp,
// elem,
keys,
layr,
// list,
loca,
meta,
// strs,
tran,
// uset,
vars,
} = kmxplus;
const data = {
keyboard3: {
...getRootAttributes(),
...getLocales(),
version: getVersion(),
info: getInfo(),
...getDisplays(),
...getKeys(),
...getFlicks(),
...getLayers(),
...getVariables(),
...getTransforms(),
}
};

return writer.write(data);

function getRootAttributes() {
return {
'$xmlns': `https://schemas.unicode.org/cldr/${constants.cldr_version_latest}/keyboard3`,
'$locale': kmx.kmxplus.loca.locales[0].value,
'$conformsTo': constants.cldr_version_latest,
};
}

function getLocales() {
if (loca?.locales?.length < 2) {
return {}; // no additional locales
} else {
return {
locales:
loca.locales.map(({ value }) => ({ '$id': value })),
}
}
}

function getInfo() {
return {
'$author': meta.author.value,
'$name': meta.name.value,
'$layout': meta.layout.value,
'$indicator': meta.indicator.value,
};
}

function getVersion() {
return { '$number': kmx.kmxplus.meta.version.value };
}

function getDisplays() {
const displays = {
display: disp?.disps.map(disp => getDisplay(disp)) || [],
...getDisplaySettings(),
};
if (displays?.display?.length || displays?.displayOptions) {
return { displays }
} else {
return {};
}
}

function stringToAttr(attr: string, s?: KMXPlus.StrsItem, override?: string) {
if (override) return asAttr(attr, override);
if (!s || !s?.value?.length) return {};
return Object.fromEntries([[`\$${attr}`, s.value]]);
}

function asAttr(attr: string, s?: any) {
if (s === undefined) return {};
return Object.fromEntries([[`\$${attr}`, s]]);
}

function numberToAttr(attr: string, s?: number) {
if (s === undefined) return {};
return Object.fromEntries([[`\$${attr}`, s.toString()]]);
}

function getDisplay(disp: KMXPlus.DispItem) {
return {
...stringToAttr('output', disp?.to),
...stringToAttr('keyId', disp?.id),
...stringToAttr('display', disp?.display),
};
}

function getDisplaySettings() {
if (!disp?.baseCharacter?.value) return {};
return {
displayOptions: {
'$baseCharacter': disp?.baseCharacter?.value,
}
};
}

function getKeys() {
if (!keys?.keys?.length) {
return {};
}
return {
keys: {
key: keys.keys
// skip reserved keys (gap)
.filter((key: KMXPlus.KeysKeys) =>
!KeysCompiler.isReserved(key) &&
!LDMLKeyboard.ImportStatus.isImpliedImport(key))
.map((key: KMXPlus.KeysKeys) => ({
...stringToAttr('id', key.id),
...stringToAttr('output', key.to),
...asAttr('longPressKeyIds', key?.longPress?.join(' ') || undefined),
})),
},
};
}

function getFlicks() {
// skip the null flicks
if (keys?.flicks?.length < 2) {
return {};
}
return {
flicks: {
// keys.key..
}
};
}

function getLayers() {
if (!layr?.lists?.length) {
return {};
}
return {
layers: layr.lists.map(({ hardware, minDeviceWidth, layers }) => ({
...stringToAttr('formId', hardware),
...numberToAttr('minDeviceWidth', minDeviceWidth),
layer: layers.map(({ id, mod, rows }) => ({
...stringToAttr('id', id),
...asAttr('modifiers', modToString(mod)),
row: rows.map(({ keys }) => ({
...asAttr('keys', keys.map(({ value }) => value).join(' ')),
})),
})),
})),
};
}

function getVariables() {
if (!vars?.strings.length && !vars?.sets.length && !vars?.usets.length) {
return {};
}
function varToObj(v: KMXPlus.VarsItem): any {
const { id, value } = v;
return {
...stringToAttr('id', id),
...stringToAttr('value', value),
};
}
function varsToArray(vars: KMXPlus.VarsItem[]): any[] {
return vars.map(varToObj);
}
const { strings, sets, usets } = vars;

return {
variables: {
string: varsToArray(strings),
set: varsToArray(sets),
uset: varsToArray(usets),
},
};
}

function getTransforms() {
return {
transforms: [
...getTransformType("simple", tran),
...getTransformType("backspace", bksp),
],
};
}

/** NB: Bksp is a child class of Tran */
function getTransformType(type: string, t: KMXPlus.Tran) {
if (!t?.groups?.length) {
return [];
}
const { groups } = t;
return [{
...asAttr('type', type),
transformGroup: groups.map((group) => {
if (group.type === constants.tran_group_type_transform) {
return {
transform: group.transforms.map(({from, to, _from, _to}) => ({
...stringToAttr('from', from, _from),
...stringToAttr('to', to, _to),
})),
};
} else if(group.type === constants.tran_group_type_reorder) {
return {
reorder: group.reorders.map(({before, elements, _before, _from, _order}) => ({
...asAttr('before', _before || before.toString()),
...asAttr('from', _from || elements.toString()),
...asAttr('order', _order),
})),
};
} else {
throw Error(`Invalid tran.group.type ${group.type}`);
}
}),
}];
}
}


/** convert a keys_mod value to a space-separated string list */
function modToString(mod: number) {
// first try exact match
const matches: string[] = [];
for (const [name, value] of constants.keys_mod_map.entries()) {
if (mod === value) return name; // exact match
if (mod & value) matches.push(name);
}
return matches.sort().join(' ');
}
Loading
Loading