mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-08 09:55:32 +00:00
Fixes #3174. Google Sheets can export TSV files with formatted numbers, including having commas as thousands separators. Let's ignore those commas.
511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
import { readFileSync } from "fs";
|
|
import { log, KeymanCompilerError } from "../errors";
|
|
|
|
// Supports LF or CRLF line terminators.
|
|
const NEWLINE_SEPARATOR = /\u000d?\u000a/;
|
|
|
|
/**
|
|
* A word list is (conceptually) an array of pairs: the concrete word form itself + a
|
|
* non-negative count.
|
|
*
|
|
* Since each word should only appear once within the list, we represent it with
|
|
* an associative array pattern keyed by the wordform.
|
|
*/
|
|
export type WordList = {[wordform: string]: number};
|
|
|
|
/**
|
|
* Returns a data structure that can be loaded by the TrieModel.
|
|
*
|
|
* It implements a **weighted** trie, whose indices (paths down the trie) are
|
|
* generated by a search key, and not concrete wordforms themselves.
|
|
*
|
|
* @param sourceFiles an array of source files that will be read to generate the trie.
|
|
*/
|
|
export function createTrieDataStructure(filenames: string[], searchTermToKey?: (wf: string) => string): string {
|
|
if (typeof searchTermToKey !== "function") {
|
|
throw new TypeError("searchTermToKey must be explicitly specified")
|
|
}
|
|
// Make one big word list out of all of the filenames provided.
|
|
let wordlist: WordList = {};
|
|
filenames.forEach(filename => parseWordListFromFilename(wordlist, filename));
|
|
|
|
let trie = Trie.buildTrie(wordlist, searchTermToKey as Trie.SearchTermToKey);
|
|
return JSON.stringify(trie);
|
|
}
|
|
|
|
/**
|
|
* Parses a word list from a file, merging duplicate entries.
|
|
*
|
|
* The word list may be encoded in:
|
|
*
|
|
* - UTF-8, with or without BOM [exported by most software]
|
|
* - UTF-16, little endian, with BOM [exported by Microsoft Excel]
|
|
*
|
|
* @param wordlist word list to merge entries into (may have existing entries)
|
|
* @param filename filename of the word list
|
|
*/
|
|
export function parseWordListFromFilename(wordlist: WordList, filename: string): void {
|
|
_parseWordList(wordlist, new WordListFromFilename(filename));
|
|
}
|
|
|
|
/**
|
|
* Parses a word list from a string. The string should have multiple lines
|
|
* with LF or CRLF line terminators.
|
|
*
|
|
* @param wordlist word list to merge entries into (may have existing entries)
|
|
* @param filename filename of the word list
|
|
*/
|
|
export function parseWordListFromContents(wordlist: WordList, contents: string): void {
|
|
_parseWordList(wordlist, new WordListFromMemory(contents));
|
|
}
|
|
|
|
/**
|
|
* Reads a tab-separated values file into a word list. This function converts all
|
|
* entries into NFC and merges duplicate entries across wordlists. Duplication is
|
|
* on the basis of character-for-character equality after normalisation to NFC.
|
|
*
|
|
* Format specification:
|
|
*
|
|
* - the file is a UTF-8 encoded text file.
|
|
* - new lines are either LF or CRLF.
|
|
* - the file MAY start with the UTF-8 byte-order mark (BOM); that is, if the
|
|
* first three bytes of the file are EF BB BF, these will be interepreted as
|
|
* the BOM and will be ignored.
|
|
* - the file either consists of a comment or an entry.
|
|
* - comment lines MUST start with the '#' character on the very first column.
|
|
* - entries are one to three columns, separated by the (horizontal) tab
|
|
* character.
|
|
* - column 1 (REQUIRED): the wordform: can have any character except tab, CR,
|
|
* LF. Surrounding whitespace characters are trimmed.
|
|
* - column 2 (optional): the count: a non-negative integer specifying how many
|
|
* times this entry has appeared in the corpus. Blank means 'indeterminate';
|
|
* commas are permissible in the digits.
|
|
* - column 3 (optional): comment: an informative comment, ignored by the tool.
|
|
*
|
|
* @param wordlist word list to merge entries into (may have existing entries)
|
|
* @param contents contents of the file to import
|
|
*/
|
|
function _parseWordList(wordlist: WordList, source: WordListSource): void {
|
|
const TAB = "\t";
|
|
|
|
let wordsSeenInThisFile = new Set<string>();
|
|
|
|
for (let [lineno, line] of source.lines()) {
|
|
// Remove the byte-order mark (BOM) from the beginning of the string.
|
|
// Because `contents` can be the concatenation of several files, we have to remove
|
|
// the BOM from every possible start of file -- i.e., beginning of every line.
|
|
line = line.replace(/^\uFEFF/, '').trim();
|
|
|
|
if (line.startsWith('#') || line === "") {
|
|
continue; // skip comments and empty lines
|
|
}
|
|
|
|
// The third column is the comment. Always ignored!
|
|
let [wordform, countText] = line.split(TAB);
|
|
|
|
// Clean the word form.
|
|
let original = wordform;
|
|
|
|
wordform = wordform.normalize('NFC');
|
|
if (original !== wordform) {
|
|
// Mixed normalization forms are yucky! Warn about it.
|
|
log(
|
|
KeymanCompilerError.CWARN_MixedNormalizationForms,
|
|
`“${wordform}” is not in Unicode NFC. Automatically converting to NFC.`,
|
|
{filename: source.name, lineno}
|
|
)
|
|
}
|
|
|
|
wordform = wordform.trim()
|
|
|
|
countText = (countText || '').trim().replace(/,/g, '');
|
|
let count = parseInt(countText, 10);
|
|
|
|
// When parsing a decimal integer fails (e.g., blank or something else):
|
|
if (!isFinite(count) || count < 0) {
|
|
// TODO: is this the right thing to do?
|
|
// Treat it like a hapax legonmenom -- it exist, but only once.
|
|
count = 1;
|
|
}
|
|
|
|
if (wordsSeenInThisFile.has(wordform)) {
|
|
// The same word seen across multiple files is fine,
|
|
// but a word seen multiple times in one file is a problem!
|
|
log(
|
|
KeymanCompilerError.CWARN_DuplicateWordInSameFile,
|
|
`duplicate word “${wordform}” found in same file; summing counts`,
|
|
{filename: source.name, lineno}
|
|
)
|
|
}
|
|
wordsSeenInThisFile.add(wordform);
|
|
|
|
wordlist[wordform] = (isNaN(wordlist[wordform]) ? 0 : wordlist[wordform] || 0) + count;
|
|
}
|
|
}
|
|
|
|
type LineNoAndText = [number, string];
|
|
|
|
interface WordListSource {
|
|
readonly name: string;
|
|
lines(): Iterable<LineNoAndText>;
|
|
}
|
|
|
|
class WordListFromMemory implements WordListSource {
|
|
readonly name = '<memory>';
|
|
private readonly _contents: string;
|
|
|
|
constructor(contents: string) {
|
|
this._contents = contents;
|
|
}
|
|
|
|
*lines() {
|
|
yield *enumerateLines(this._contents.split(NEWLINE_SEPARATOR));
|
|
}
|
|
}
|
|
|
|
class WordListFromFilename {
|
|
readonly name: string;
|
|
constructor(filename: string) {
|
|
this.name = filename;
|
|
}
|
|
|
|
*lines() {
|
|
let contents = readFileSync(this.name, detectEncoding(this.name));
|
|
yield *enumerateLines(contents.split(NEWLINE_SEPARATOR));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Yields pairs of [lineno, line], given an Array of lines.
|
|
*/
|
|
function* enumerateLines(lines: string[]): Generator<LineNoAndText> {
|
|
let i = 1;
|
|
for (let line of lines) {
|
|
yield [i, line];
|
|
i++;
|
|
}
|
|
}
|
|
|
|
namespace Trie {
|
|
/**
|
|
* An **opaque** type for a string that is exclusively used as a search key in
|
|
* the trie. There should be a function that converts arbitrary strings
|
|
* (queries) and converts them into a standard search key for a given language
|
|
* model.
|
|
*
|
|
* Fun fact: This opaque type has ALREADY saved my bacon and found a bug!
|
|
*/
|
|
type SearchKey = string & { _: 'SearchKey'};
|
|
|
|
/**
|
|
* A function that converts a string (word form or query) into a search key
|
|
* (secretly, this is also a string).
|
|
*/
|
|
export interface SearchTermToKey {
|
|
(wordform: string): SearchKey;
|
|
}
|
|
|
|
// The following trie implementation has been (heavily) derived from trie-ing
|
|
// by Conrad Irwin.
|
|
//
|
|
// trie-ing is distributed under the terms of the MIT license, reproduced here:
|
|
//
|
|
// The MIT License
|
|
// Copyright (c) 2015-2017 Conrad Irwin <conrad.irwin@gmail.com>
|
|
// Copyright (c) 2011 Marc Campbell <marc.e.campbell@gmail.com>
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
// THE SOFTWARE.
|
|
//
|
|
// See: https://github.com/ConradIrwin/trie-ing/blob/df55d7af7068d357829db9e0a7faa8a38add1d1d/LICENSE
|
|
|
|
/**
|
|
* An entry in the prefix trie. The matched word is "content".
|
|
*/
|
|
interface Entry {
|
|
content: string;
|
|
key: SearchKey;
|
|
weight: number;
|
|
}
|
|
|
|
/**
|
|
* The trie is made up of nodes. A node can be EITHER an internal node (whose
|
|
* only children are other nodes) OR a leaf, which actually contains the word
|
|
* form entries.
|
|
*/
|
|
type Node = InternalNode | Leaf;
|
|
|
|
/**
|
|
* An internal node.
|
|
*/
|
|
interface InternalNode {
|
|
type: 'internal';
|
|
weight: number;
|
|
// TODO: As an optimization, "values" can be a single string!
|
|
values: string[];
|
|
children: { [codeunit: string]: Node };
|
|
unsorted?: true;
|
|
}
|
|
|
|
/**
|
|
* A leaf node.
|
|
*/
|
|
interface Leaf {
|
|
type: 'leaf';
|
|
weight: number;
|
|
entries: Entry[];
|
|
unsorted?: true;
|
|
}
|
|
|
|
/**
|
|
* A sentinel value for when an internal node has contents and requires an
|
|
* "internal" leaf. That is, this internal node has content. Instead of placing
|
|
* entries as children in an internal node, a "fake" leaf is created, and its
|
|
* key is this special internal value.
|
|
*
|
|
* The value is a valid Unicode BMP code point, but it is a "non-character".
|
|
* Unicode will never assign semantics to these characters, as they are
|
|
* intended to be used internally as sentinel values.
|
|
*/
|
|
const INTERNAL_VALUE = '\uFDD0';
|
|
|
|
/**
|
|
* Builds a trie from a word list.
|
|
*
|
|
* @param wordlist The wordlist with non-negative weights.
|
|
* @param keyFunction Function that converts word forms into indexed search keys
|
|
* @returns A JSON-serialiable object that can be given to the TrieModel constructor.
|
|
*/
|
|
export function buildTrie(wordlist: WordList, keyFunction: SearchTermToKey): object {
|
|
let root = new Trie(keyFunction).buildFromWordList(wordlist).root;
|
|
return {
|
|
totalWeight: sumWeights(root),
|
|
root: root
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrapper class for the trie and its nodes and wordform to search
|
|
*/
|
|
class Trie {
|
|
readonly root = createRootNode();
|
|
toKey: SearchTermToKey;
|
|
constructor(wordform2key: SearchTermToKey) {
|
|
this.toKey = wordform2key;
|
|
}
|
|
|
|
/**
|
|
* Populates the trie with the contents of an entire wordlist.
|
|
* @param words a list of word and count pairs.
|
|
*/
|
|
buildFromWordList(words: WordList): Trie {
|
|
for (let [wordform, weight] of Object.entries(words)) {
|
|
let key = this.toKey(wordform);
|
|
addUnsorted(this.root, { key, weight, content: wordform }, 0);
|
|
}
|
|
sortTrie(this.root);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
// "Constructors"
|
|
function createRootNode(): Node {
|
|
return {
|
|
type: 'leaf',
|
|
weight: 0,
|
|
entries: []
|
|
};
|
|
}
|
|
|
|
// Implement Trie creation.
|
|
|
|
/**
|
|
* Adds an entry to the trie.
|
|
*
|
|
* Note that the trie will likely be unsorted after the add occurs. Before
|
|
* performing a lookup on the trie, use call sortTrie() on the root note!
|
|
*
|
|
* @param node Which node should the entry be added to?
|
|
* @param entry the wordform/weight/key to add to the trie
|
|
* @param index the index in the key and also the trie depth. Should be set to
|
|
* zero when adding onto the root node of the trie.
|
|
*/
|
|
function addUnsorted(node: Node, entry: Entry, index: number = 0) {
|
|
// Each node stores the MAXIMUM weight out of all of its decesdents, to
|
|
// enable a greedy search through the trie.
|
|
node.weight = Math.max(node.weight, entry.weight);
|
|
|
|
// When should a leaf become an interior node?
|
|
// When it already has a value, but the key of the current value is longer
|
|
// than the prefix.
|
|
if (node.type === 'leaf' && index < entry.key.length && node.entries.length >= 1) {
|
|
convertLeafToInternalNode(node, index);
|
|
}
|
|
|
|
if (node.type === 'leaf') {
|
|
// The key matches this leaf node, so add yet another entry.
|
|
addItemToLeaf(node, entry);
|
|
} else {
|
|
// Push the node down to a lower node.
|
|
addItemToInternalNode(node, entry, index);
|
|
}
|
|
|
|
node.unsorted = true;
|
|
}
|
|
|
|
/**
|
|
* Adds an item to the internal node at a given depth.
|
|
* @param item
|
|
* @param index
|
|
*/
|
|
function addItemToInternalNode(node: InternalNode, item: Entry, index: number) {
|
|
let char = item.key[index];
|
|
if (!node.children[char]) {
|
|
node.children[char] = createRootNode();
|
|
node.values.push(char);
|
|
}
|
|
addUnsorted(node.children[char], item, index + 1);
|
|
}
|
|
|
|
function addItemToLeaf(leaf: Leaf, item: Entry) {
|
|
leaf.entries.push(item);
|
|
}
|
|
|
|
/**
|
|
* Mutates the given Leaf to turn it into an InternalNode.
|
|
*
|
|
* NOTE: the node passed in will be DESTRUCTIVELY CHANGED into a different
|
|
* type when passed into this function!
|
|
*
|
|
* @param depth depth of the trie at this level.
|
|
*/
|
|
function convertLeafToInternalNode(leaf: Leaf, depth: number): void {
|
|
let entries = leaf.entries;
|
|
|
|
// Alias the current node, as the desired type.
|
|
let internal = (<unknown> leaf) as InternalNode;
|
|
internal.type = 'internal';
|
|
|
|
delete leaf.entries;
|
|
internal.values = [];
|
|
internal.children = {};
|
|
|
|
// Convert the old values array into the format for interior nodes.
|
|
for (let item of entries) {
|
|
let char: string;
|
|
if (depth < item.key.length) {
|
|
char = item.key[depth];
|
|
} else {
|
|
char = INTERNAL_VALUE;
|
|
}
|
|
|
|
if (!internal.children[char]) {
|
|
internal.children[char] = createRootNode();
|
|
internal.values.push(char);
|
|
}
|
|
addUnsorted(internal.children[char], item, depth + 1);
|
|
}
|
|
|
|
internal.unsorted = true;
|
|
}
|
|
|
|
/**
|
|
* Recursively sort the trie, in descending order of weight.
|
|
* @param node any node in the trie
|
|
*/
|
|
function sortTrie(node: Node) {
|
|
if (node.type === 'leaf') {
|
|
if (!node.unsorted) {
|
|
return;
|
|
}
|
|
|
|
node.entries.sort(function (a, b) { return b.weight - a.weight; });
|
|
} else {
|
|
// We MUST recurse and sort children before returning.
|
|
for (let char of node.values) {
|
|
sortTrie(node.children[char]);
|
|
}
|
|
|
|
if (!node.unsorted) {
|
|
return;
|
|
}
|
|
|
|
node.values.sort((a, b) => {
|
|
return node.children[b].weight - node.children[a].weight;
|
|
});
|
|
}
|
|
|
|
delete node.unsorted;
|
|
}
|
|
|
|
/**
|
|
* O(n) recursive traversal to sum the total weight of all leaves in the
|
|
* trie, starting at the provided node.
|
|
*
|
|
* @param node The node to start summing weights.
|
|
*/
|
|
function sumWeights(node: Node): number {
|
|
let val: number;
|
|
if (node.type === 'leaf') {
|
|
val = node.entries
|
|
.map(entry => entry.weight)
|
|
//.map(entry => isNaN(entry.weight) ? 1 : entry.weight)
|
|
.reduce((acc, count) => acc + count, 0);
|
|
} else {
|
|
val = Object.keys(node.children)
|
|
.map((key) => sumWeights(node.children[key]))
|
|
.reduce((acc, count) => acc + count, 0);
|
|
}
|
|
|
|
if(isNaN(val)) {
|
|
console.error("Unexpected NaN has appeared!");
|
|
}
|
|
return val;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects the encoding of a text file.
|
|
*
|
|
* Supported encodings are:
|
|
*
|
|
* - UTF-8, with or without BOM
|
|
* - UTF-16, little endian, with BOM
|
|
*
|
|
* UTF-16 in big endian is explicitly NOT supported! The reason is two-fold:
|
|
* 1) Node does not support it without resorting to an external library (or
|
|
* swapping every byte in the file!); and 2) I'm not sure anything actually
|
|
* outputs in this format anyway!
|
|
*
|
|
* @param filename filename of the file to detect encoding
|
|
*/
|
|
function detectEncoding(filename: string): 'utf8' | 'utf16le' {
|
|
let buffer = readFileSync(filename);
|
|
// Note: BOM is U+FEFF
|
|
// In little endian, this is 0xFF 0xFE
|
|
if (buffer[0] == 0xFF && buffer[1] == 0xFE) {
|
|
return 'utf16le';
|
|
} else if (buffer[0] == 0xFE && buffer[1] == 0xFF) {
|
|
// Big Endian, is NOT supported because Node does not support it (???)
|
|
// See: https://stackoverflow.com/a/14551669/6626414
|
|
throw new Error('UTF-16BE is unsupported')
|
|
} else {
|
|
// Assume its in UTF-8, with or without a BOM.
|
|
return 'utf8';
|
|
}
|
|
}
|