Skip to content

Commit

Permalink
feat(developer): kmc-package support remote fonts and files
Browse files Browse the repository at this point in the history
The concept here is that the 'Name' property for a file can now be a
remote reference, rather than a local file. There are two supported
formats in this commit:

* GitHub: This is a cutdown version of a plain github.com URL, and must
  match this exact format:

  ```
  github:<owner>/<repo>/raw/<hash>/<filepath/filename>
  ```

  This format is mandated in order to ensure that we always have a
  hashed version of a file from the origin. This gives us reproducible
  builds, which avoids churn issues when font files change.

  Example: `github:silnrsi/fonts/raw/b88c7af5d16681bd137156929ff8baec82526560/fonts/sil/alkalami/Alkalami-Regular.ttf`
  gets https://github.com/silnrsi/fonts/raw/b88c7af5d16681bd137156929ff8baec82526560/fonts/sil/alkalami/Alkalami-Regular.ttf

  An alternative could be to just have `https://github.com/silnrsi/fonts/raw/b88c7af5d16681bd137156929ff8baec82526560/fonts/sil/alkalami/Alkalami-Regular.ttf`
  which could be matched with a regex in the same way as the `github`
  prefix, and would avoid the need to munge the input URL. **Discuss!**

* fonts.languagetechnology.org: references just a font identifier. This
  is somewhat broken, because if the source file changes, we don't know
  about it and won't publish an updated version of the package. So this
  needs some more discussion (we could e.g. embed the version number in
  the request, e.g. `flo:[email protected]`). **Discuss!**

  ```
  flo:<family>
  ```

  e.g. `flo:andika` gets
  https://fonts.languagetechnology.org/fonts/sil/andika/Andika-Bold.ttf

Future sources could be considered, e.g. noto. We don't want to allow
arbitrary URLs, both for stability and for security reasons.

This change is entirely compiler-side, so we don't need to make any
changes to apps, and so packages will be backwardly compatible. A lot of
work will need to be done with the Package Editor in TIKE to support
this feature.

Fixes: #11236
  • Loading branch information
mcdurdin committed Nov 13, 2024
1 parent ce7593b commit c6badda
Show file tree
Hide file tree
Showing 16 changed files with 8,965 additions and 90 deletions.
131 changes: 131 additions & 0 deletions developer/src/kmc-package/src/compiler/get-file-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by mcdurdin on 2024-11-11
*/
import { CompilerCallbacks } from '@keymanapp/developer-utils';
import { PackageCompilerMessages } from './package-compiler-messages.js';

const FLO_SOURCE = /^flo:(?<family>.+)$/;
const GITHUB_SOURCE = /^github:(?<name>[a-zA-Z0-9-].+)\/(?<repo>[\w\.-]+)\/raw\/(?<hash>[a-f0-9]{40})\/(?<path>.+)$/;

export interface KmpCompilerFileDataResult {
data: Uint8Array;
basename: string;
};

let floFamiliesCache: any = undefined; // set to null on error, otherwise an object from JSON

const FLO_FAMILIES_URL = 'https://fonts.languagetechnology.org/families.json';

async function getFloFamilies(callbacks: CompilerCallbacks) {
if(floFamiliesCache === undefined) {
try {
floFamiliesCache = await callbacks.net.fetchJSON(FLO_FAMILIES_URL);
/* c8 ignore next 12 */
if(!floFamiliesCache) {
callbacks.reportMessage(PackageCompilerMessages.Error_FloDataCouldNotBeRead({url: FLO_FAMILIES_URL}));
floFamiliesCache = null;
}
else if(typeof floFamiliesCache != 'object') {
callbacks.reportMessage(PackageCompilerMessages.Error_FloDataIsInvalidFormat({url: FLO_FAMILIES_URL}));
floFamiliesCache = null;
}
} catch(e) {
callbacks.reportMessage(PackageCompilerMessages.Error_FloDataCouldNotBeRead({url: FLO_FAMILIES_URL, e}))
floFamiliesCache = null;
}
}
return floFamiliesCache;
}

async function getFileDataFromFlo(callbacks: CompilerCallbacks, _kpsFilename: string, inputFilename: string, matches: RegExpExecArray): Promise<KmpCompilerFileDataResult> {
const floFamilies = await getFloFamilies(callbacks);
/* c8 ignore next 4 */
if(!floFamilies) {
// Error already reported by getFloFamilies
return null;
}

const family = floFamilies[matches.groups.family];
if(!family) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontNotFoundInFlo({filename: inputFilename, family: matches.groups.family}));
return null;
}

if(!family.distributable) {
callbacks.reportMessage(PackageCompilerMessages.Warn_FontFromFloIsNotFreelyDistributable(
{filename: inputFilename, family: matches.groups.family}));
}

// TODO: consider .woff, .woff2 for web font inclusion
const ttf = family.defaults?.ttf;
if(!ttf) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontInFloDoesNotHaveDefaultTtf({filename: inputFilename, family: matches.groups.family}));
return null;
}

const file = family.files[ttf];
/* c8 ignore next 4 */
if(!file) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontInFloHasBrokenDefaultTtf({filename: inputFilename, family: matches.groups.family}));
return null;
}

if(!file.flourl && !file.url) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontInFloHasNoDownloadAvailable({filename: inputFilename, family: matches.groups.family}));
return null;
}

const url = file.flourl ?? file.url;
try {
const data = await callbacks.net.fetchBlob(url);
if(!data) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontFileCouldNotBeDownloaded({filename: inputFilename, url}));
return null;
}
return { data, basename: ttf };
/* c8 ignore next 4 */
} catch(e) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontFileCouldNotBeDownloaded({filename: inputFilename, url, e}));
return null;
}
}

async function getFileDataFromGitHub(callbacks: CompilerCallbacks, _kpsFilename: string, inputFilename: string, matches: RegExpExecArray): Promise<KmpCompilerFileDataResult> {
// /^github:(?<name>[a-zA-Z0-9-].+)\/(?<repo>[\w\.-]+)\/raw\/(?<hash>[a-f0-9]{40})\/(?<path>.+)$/
const githubUrl = `https://github.com/${matches.groups.name}/${matches.groups.repo}/raw/${matches.groups.hash}/${matches.groups.path}`;
try {
const res = await callbacks.net.fetchBlob(githubUrl);
if(!res) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontFileCouldNotBeDownloaded({filename: inputFilename, url: githubUrl}));
return null;
}
return { data: res, basename: callbacks.path.basename(matches.groups.path) };
/* c8 ignore next 4 */
} catch(e) {
callbacks.reportMessage(PackageCompilerMessages.Error_FontFileCouldNotBeDownloaded({filename: inputFilename, url: githubUrl, e}));
return null;
}
}

type KmpCompilerFileDataProc = (callbacks: CompilerCallbacks, kpsFilename: string, inputFilename: string, matches: RegExpExecArray) => Promise<KmpCompilerFileDataResult>;

interface KmpCompilerFileDataSource {
regex: RegExp;
proc: KmpCompilerFileDataProc;
};

export const EXTERNAL_FILE_DATA_SOURCES: KmpCompilerFileDataSource[] = [
{ regex: FLO_SOURCE, proc: getFileDataFromFlo },
{ regex: GITHUB_SOURCE, proc: getFileDataFromGitHub },
// TODO: noto
];

/** @internal */
export const unitTestEndpoints = {
GITHUB_SOURCE,
getFileDataFromGitHub,
FLO_SOURCE,
getFileDataFromFlo,
};
136 changes: 78 additions & 58 deletions developer/src/kmc-package/src/compiler/kmp-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 { EXTERNAL_FILE_DATA_SOURCES, KmpCompilerFileDataResult } from './get-file-data.js';

const KMP_JSON_FILENAME = 'kmp.json';
const KMP_INF_FILENAME = 'kmp.inf';
Expand Down Expand Up @@ -245,41 +246,28 @@ export class KmpCompiler implements KeymanCompiler {

if(kps.Files?.File?.length) {
kmp.files = kps.Files.File.map((file: KpsFile.KpsFileContentFile) => {
const name = this.isLocalFile(file.Name) ?
this.normalizePath(file.Name) :
file.Name;
if(!name) {
// as the filename field is missing or blank, we'll try with the description instead
this.callbacks.reportMessage(PackageCompilerMessages.Error_FileRecordIsMissingName({description: file.Description ?? '(no description)'}));
}
return {
name: this.normalizePath(file.Name),
name,
description: '', // kmp.json still requires description, but we ignore the input Description field
// note: we no longer emit copyLocation as of 18.0; it was always optional
// note: we don't emit fileType as that is not permitted in kmp.json
};
});
if(!kmp.files.reduce((result: boolean, file) => {
if(!file.name) {
// as the filename field is missing or blank, we'll try with the description instead
this.callbacks.reportMessage(PackageCompilerMessages.Error_FileRecordIsMissingName({description: file.description ?? '(no description)'}));
return false;
}
return result;
}, true)) {

if(kmp.files.find(file => !file.name)) {
// error reported above
return null;
}
}
kmp.files = kmp.files ?? [];

// Keyboard packages also include a legacy kmp.inf file (this will be removed,
// one day)
if(kps.Keyboards && kps.Keyboards.Keyboard) {
kmp.files.push({
name: KMP_INF_FILENAME,
description: ""
});
}

// Add the standard kmp.json self-referential to match existing implementations
kmp.files.push({
name: KMP_JSON_FILENAME,
description: ""
});

//
// Add keyboard metadata
//
Expand Down Expand Up @@ -335,6 +323,7 @@ export class KmpCompiler implements KeymanCompiler {
const collector = new PackageMetadataCollector(this.callbacks);
const metadata = collector.collectKeyboardMetadata(kpsFilename, kmp);
if(metadata == null) {
// error reported in collectKeyboardMetadata
return null;
}

Expand All @@ -344,6 +333,7 @@ export class KmpCompiler implements KeymanCompiler {

const versionValidator = new PackageVersionValidator(this.callbacks);
if(!versionValidator.validateAndUpdateVersions(kps, kmp, metadata)) {
// error reported in validateAndUpdateVersions
return null;
}

Expand All @@ -353,6 +343,23 @@ export class KmpCompiler implements KeymanCompiler {
kmp.system.fileVersion = MIN_LM_FILEVERSION_KMP_JSON;
}

// TODO: if targeting 18.0, drop 'description' File field entirely from kmp.json
// TODO: ONLY ADD kmp.inf if fileVersion < MIN_LM_FILEVERSION_KMP_JSON (+UNIT TEST)
// Keyboard packages also include a legacy kmp.inf file, if targeting an old
// version of Keyman
if(kps.Keyboards && kps.Keyboards.Keyboard) {
kmp.files.push({
name: KMP_INF_FILENAME,
description: ""
});
}

// Add the standard kmp.json self-referential to match existing implementations
kmp.files.push({
name: KMP_JSON_FILENAME,
description: ""
});

//
// Verify that packages that target mobile devices include a .js file
//
Expand Down Expand Up @@ -456,10 +463,9 @@ export class KmpCompiler implements KeymanCompiler {
* @param kpsFilename - Filename of the kps, not read, used only for calculating relative paths
* @param kmpJsonData - The kmp.json Object
*/
public buildKmpFile(kpsFilename: string, kmpJsonData: KmpJsonFile.KmpJsonFile): Promise<Uint8Array> {
public async buildKmpFile(kpsFilename: string, kmpJsonData: KmpJsonFile.KmpJsonFile): Promise<Uint8Array> {
const zip = JSZip();


// Make a copy of kmpJsonData, as we mutate paths for writing
const data: KmpJsonFile.KmpJsonFile = JSON.parse(JSON.stringify(kmpJsonData));
if(!data.files) {
Expand All @@ -468,49 +474,25 @@ export class KmpCompiler implements KeymanCompiler {

const hasKmpInf = !!data.files.find(file => file.name == KMP_INF_FILENAME);

let failed = false;
data.files.forEach((value) => {
for(const value of data.files) {
// Get the path of the file
let filename = value.name;

// We add this separately after zipping all other files
if(filename == KMP_JSON_FILENAME || filename == KMP_INF_FILENAME) {
return;
}

if(this.callbacks.path.isAbsolute(filename)) {
// absolute paths are not portable to other computers
this.callbacks.reportMessage(PackageCompilerMessages.Warn_AbsolutePath({filename: filename}));
}

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;
continue;
}

let memberFileData;
try {
memberFileData = this.callbacks.loadFile(filename);
} catch(e) {
this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e}));
failed = true;
return;
const memberFileData = await this.getFileData(kpsFilename, filename);
if(!memberFileData) {
return null;
}

this.warnIfKvkFileIsNotBinary(filename, memberFileData);
this.warnIfKvkFileIsNotBinary(filename, memberFileData.data);

zip.file(basename, memberFileData);
zip.file(memberFileData.basename, memberFileData.data);

// Remove path data from files before JSON save
value.name = basename;
});

if(failed) {
return null;
value.name = memberFileData.basename;
}

// TODO #9477: transform .md to .htm
Expand Down Expand Up @@ -557,6 +539,44 @@ export class KmpCompiler implements KeymanCompiler {
return transcodeToCP1252(s);
}

private isLocalFile(inputFilename: string) {
return !EXTERNAL_FILE_DATA_SOURCES.find(source => source.regex.test(inputFilename));
}

private async getFileData(kpsFilename: string, inputFilename: string): Promise<KmpCompilerFileDataResult> {
for(const source of EXTERNAL_FILE_DATA_SOURCES) {
const match = source.regex.exec(inputFilename);
if(match) {
return await source.proc(this.callbacks, kpsFilename, inputFilename, match);
}
}

return this.getFileDataLocal(kpsFilename, inputFilename);
}

private getFileDataLocal(kpsFilename: string, inputFilename: string): KmpCompilerFileDataResult {
if(this.callbacks.path.isAbsolute(inputFilename)) {
// absolute paths are not portable to other computers
this.callbacks.reportMessage(PackageCompilerMessages.Warn_AbsolutePath({filename: inputFilename}));
}

const filename = this.callbacks.resolveFilename(kpsFilename, inputFilename);

if(!this.callbacks.fs.existsSync(filename)) {
this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename: filename}));
return null;
}

try {
const basename = this.callbacks.path.basename(filename);
const data = this.callbacks.loadFile(filename);
return { data, basename };
} catch(e) {
this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e}));
return null;
}
}

/**
* 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
Expand Down
Loading

0 comments on commit c6badda

Please sign in to comment.