Add helper functions to serialize and deserialize paths

This commit is contained in:
Shaunak Kishore
2015-09-27 15:58:34 -04:00
parent 4fd741f204
commit 43c5c03626
5 changed files with 184 additions and 57 deletions

View File

@ -1,9 +1,12 @@
"use strict";
const fs = maybeRequire('fs'); const fs = maybeRequire('fs');
const path = maybeRequire('path'); const path = maybeRequire('path');
const CHARACTER_FIELDS = ['character', 'decomposition', 'definition', const CHARACTER_FIELDS = ['character', 'decomposition', 'definition',
'kangxi_index', 'pinyin', 'strokes']; 'kangxi_index', 'pinyin', 'strokes'];
assert(this.cjklib === undefined);
this.cjklib = { this.cjklib = {
characters: {}, characters: {},
gb2312: {}, gb2312: {},
@ -27,7 +30,7 @@ CHARACTER_FIELDS.map((field) => cjklib.characters[field] = {});
// Input: String contents of a cjklib data file. // Input: String contents of a cjklib data file.
// Output: a list of rows, each of which is a list of String columns. // Output: a list of rows, each of which is a list of String columns.
getCJKLibRows = (data) => { const getCJKLibRows = (data) => {
const lines = data.split('\n'); const lines = data.split('\n');
return lines.filter((line) => line.length > 0 && line[0] !== '#') return lines.filter((line) => line.length > 0 && line[0] !== '#')
.map((line) => line.split(',').map( .map((line) => line.split(',').map(
@ -36,7 +39,7 @@ getCJKLibRows = (data) => {
// Input: String contents of a Unihan data file. // Input: String contents of a Unihan data file.
// Output: a list of rows, each of which is a list of String columns. // Output: a list of rows, each of which is a list of String columns.
getUnihanRows = (data) => { const getUnihanRows = (data) => {
const lines = data.split('\n'); const lines = data.split('\n');
return lines.filter((line) => line.length > 0 && line[0] !== '#') return lines.filter((line) => line.length > 0 && line[0] !== '#')
.map((line) => line.split('\t')); .map((line) => line.split('\t'));
@ -44,11 +47,12 @@ getUnihanRows = (data) => {
// Input: a String of the form 'U+<hex>' representing a Unicode codepoint. // Input: a String of the form 'U+<hex>' representing a Unicode codepoint.
// Output: the character at that codepoint // Output: the character at that codepoint
parseUnicodeStr = (str) => String.fromCodePoint(parseInt(str.substr(2), 16)); const parseUnicodeStr =
(str) => String.fromCodePoint(parseInt(str.substr(2), 16));
// Input: the path to a Unihan data file, starting from the public directory. // Input: the path to a Unihan data file, starting from the public directory.
// Output: Promise that resolves to the String contents of that file. // Output: Promise that resolves to the String contents of that file.
readFile = (filename) => new Promise((resolve, reject) => { const readFile = (filename) => new Promise((resolve, reject) => {
if (Meteor.isServer) { if (Meteor.isServer) {
const filepath = path.join(process.env.PWD, 'public', filename); const filepath = path.join(process.env.PWD, 'public', filename);
fs.readFile(filepath, 'utf8', (error, data) => { fs.readFile(filepath, 'utf8', (error, data) => {
@ -68,7 +72,7 @@ readFile = (filename) => new Promise((resolve, reject) => {
// Output: Promise that fills result with a mapping character -> decomposition. // Output: Promise that fills result with a mapping character -> decomposition.
// The decompositions are formatted using Ideographic Description Sequence // The decompositions are formatted using Ideographic Description Sequence
// symbols - see the Unicode standard for more details. // symbols - see the Unicode standard for more details.
fillDecompositions = (decompositions, glyphs, result) => { const fillDecompositions = (decompositions, glyphs, result) => {
return Promise.all([decompositions, glyphs]).then(([rows, glyphs]) => { return Promise.all([decompositions, glyphs]).then(([rows, glyphs]) => {
rows.filter((row) => parseInt(row[2], 10) === (glyphs[row[0]] || 0)) rows.filter((row) => parseInt(row[2], 10) === (glyphs[row[0]] || 0))
.map((row) => result[row[0]] = row[1]); .map((row) => result[row[0]] = row[1]);
@ -76,7 +80,7 @@ fillDecompositions = (decompositions, glyphs, result) => {
} }
// Output: Promise that fills result with a mapping character -> Pinyin. // Output: Promise that fills result with a mapping character -> Pinyin.
fillDefinitions = (readings, result) => { const fillDefinitions = (readings, result) => {
return readings.then((rows) => { return readings.then((rows) => {
rows.filter((row) => row[1] === 'kDefinition') rows.filter((row) => row[1] === 'kDefinition')
.map((row) => result[parseUnicodeStr(row[0])] = row[2]); .map((row) => result[parseUnicodeStr(row[0])] = row[2]);
@ -85,7 +89,7 @@ fillDefinitions = (readings, result) => {
// Output: Promise that fills result with a mapping character -> Kangxi radical- // Output: Promise that fills result with a mapping character -> Kangxi radical-
// stroke count, which is a pair of integers [radical, extra_strokes]. // stroke count, which is a pair of integers [radical, extra_strokes].
fillKangxiIndex = (readings, result) => { const fillKangxiIndex = (readings, result) => {
return readings.then((rows) => { return readings.then((rows) => {
const getIndex = (adotb) => adotb.split('.').map((x) => parseInt(x, 10)); const getIndex = (adotb) => adotb.split('.').map((x) => parseInt(x, 10));
rows.filter((row) => row[1] === 'kRSKangXi') rows.filter((row) => row[1] === 'kRSKangXi')
@ -94,7 +98,7 @@ fillKangxiIndex = (readings, result) => {
} }
// Output: Promise that fills result with a mapping character -> Pinyin. // Output: Promise that fills result with a mapping character -> Pinyin.
fillPinyin = (readings, result) => { const fillPinyin = (readings, result) => {
return readings.then((rows) => { return readings.then((rows) => {
rows.filter((row) => row[1] === 'kMandarin') rows.filter((row) => row[1] === 'kMandarin')
.map((row) => result[parseUnicodeStr(row[0])] = row[2]); .map((row) => result[parseUnicodeStr(row[0])] = row[2]);
@ -102,7 +106,7 @@ fillPinyin = (readings, result) => {
} }
// Output: Promise that fills result with a mapping character -> stroke count. // Output: Promise that fills result with a mapping character -> stroke count.
fillStrokeCounts = (dictionary_like_data, result) => { const fillStrokeCounts = (dictionary_like_data, result) => {
return dictionary_like_data.then((rows) => { return dictionary_like_data.then((rows) => {
rows.filter((row) => row[1] === 'kTotalStrokes') rows.filter((row) => row[1] === 'kTotalStrokes')
.map((row) => result[parseUnicodeStr(row[0])] = parseInt(row[2], 10)); .map((row) => result[parseUnicodeStr(row[0])] = parseInt(row[2], 10));
@ -113,7 +117,7 @@ fillStrokeCounts = (dictionary_like_data, result) => {
// - index_to_radical_map: Map from index -> list of radicals at that index // - index_to_radical_map: Map from index -> list of radicals at that index
// - radical_to_index_map: Map from radical -> index of that radical // - radical_to_index_map: Map from radical -> index of that radical
// - primary_radical: Map from index -> primary radical at that index // - primary_radical: Map from index -> primary radical at that index
fillRadicalData = (locale, radicals, result) => { const fillRadicalData = (locale, radicals, result) => {
return radicals.then((rows) => { return radicals.then((rows) => {
rows.filter((row) => row[3].indexOf(locale) >= 0).map((row) => { rows.filter((row) => row[3].indexOf(locale) >= 0).map((row) => {
if (!result.index_to_radical_map.hasOwnProperty(row[0])) { if (!result.index_to_radical_map.hasOwnProperty(row[0])) {
@ -131,7 +135,8 @@ fillRadicalData = (locale, radicals, result) => {
// Output: Promise that fills result with a map from Unicode radical-codeblock // Output: Promise that fills result with a map from Unicode radical-codeblock
// character -> equivalent Unicode CJK-codeblock (hopefully, GB2312) character. // character -> equivalent Unicode CJK-codeblock (hopefully, GB2312) character.
// There may be Unicode radical characters without a CJK equivalent. // There may be Unicode radical characters without a CJK equivalent.
fillRadicalToCharacterMap = (locale, radical_equivalent_characters, result) => { const fillRadicalToCharacterMap =
(locale, radical_equivalent_characters, result) => {
return radical_equivalent_characters.then((rows) => { return radical_equivalent_characters.then((rows) => {
rows.filter((row) => row[2].indexOf(locale) >= 0) rows.filter((row) => row[2].indexOf(locale) >= 0)
.map((row) => result[row[0]] = row[1]); .map((row) => result[row[0]] = row[1]);
@ -139,7 +144,7 @@ fillRadicalToCharacterMap = (locale, radical_equivalent_characters, result) => {
} }
// Given the data from the GB2312 data file, fills the GB2312 result map. // Given the data from the GB2312 data file, fills the GB2312 result map.
fillGB2312 = (data, result) => { const fillGB2312 = (data, result) => {
Array.from(data).map((character) => { Array.from(data).map((character) => {
if (character === '\n') return; if (character === '\n') return;
assert(character.length === 1); assert(character.length === 1);
@ -152,7 +157,7 @@ fillGB2312 = (data, result) => {
// Given the rows of the locale-character map from the cjklib data, returns a // Given the rows of the locale-character map from the cjklib data, returns a
// mapping from characters to the appropriate glyph in that locale. // mapping from characters to the appropriate glyph in that locale.
parseLocaleGlyphMap = (locale, rows) => { const parseLocaleGlyphMap = (locale, rows) => {
const result = {}; const result = {};
rows.filter((row) => row[2].indexOf(locale) >= 0) rows.filter((row) => row[2].indexOf(locale) >= 0)
.map((row) => result[row[0]] = parseInt(row[1], 10)); .map((row) => result[row[0]] = parseInt(row[1], 10));
@ -161,7 +166,7 @@ parseLocaleGlyphMap = (locale, rows) => {
// Methods used for final post-processing of the loaded datasets. // Methods used for final post-processing of the loaded datasets.
cleanupCJKLibData = () => { const cleanupCJKLibData = () => {
const characters = cjklib.characters; const characters = cjklib.characters;
const radicals = cjklib.radicals; const radicals = cjklib.radicals;
const convert_astral_characters = (x) => x.length === 1 ? x : '' const convert_astral_characters = (x) => x.length === 1 ? x : ''

View File

@ -12,6 +12,7 @@ var EDIT_STROKES = true;
function change_glyph(method, glyph) { function change_glyph(method, glyph) {
glyph = glyph || Session.get('glyph.data'); glyph = glyph || Session.get('glyph.data');
Meteor.call(method, glyph, function(err, data) { Meteor.call(method, glyph, function(err, data) {
Session.set('glyph.test', data);
data = fill_glyph_fields(data); data = fill_glyph_fields(data);
Session.set('glyph.data', data); Session.set('glyph.data', data);
if (method === 'save_glyph') { if (method === 'save_glyph') {

View File

@ -5,49 +5,6 @@ var MIN_CORNER_ANGLE = 0.1*Math.PI;
var MIN_CORNER_TANGENT_DISTANCE = 4; var MIN_CORNER_TANGENT_DISTANCE = 4;
var REVERSAL_PENALTY = 0.5; var REVERSAL_PENALTY = 0.5;
// Helper methods for use with angles, which are floats in [-pi, pi).
var Angle = {
subtract: function(angle1, angle2) {
var result = angle1 - angle2;
if (result < -Math.PI) {
result += 2*Math.PI;
}
if (result >= Math.PI) {
result -= 2*Math.PI;
}
return result;
},
penalty: function(diff) {
return diff*diff;
},
};
// Helper methods for use with "points", which are pairs of integers.
var Point = {
angle: function(point) {
return Math.atan2(point[1], point[0]);
},
clone: function(point) {
return [point[0], point[1]];
},
distance2: function(point1, point2) {
var diff = Point.subtract(point1, point2);
return Math.pow(diff[0], 2) + Math.pow(diff[1], 2);
},
equal: function(point1, point2) {
return point1[0] === point2[0] && point1[1] === point2[1];
},
key: function(point) {
return point.join(',');
},
subtract: function(point1, point2) {
return [point1[0] - point2[0], point1[1] - point2[1]];
},
valid: function(point) {
return point[0] !== undefined && point[1] !== undefined;
},
};
// Takes a non-empty list of SVG commands that may contain multiple contours. // Takes a non-empty list of SVG commands that may contain multiple contours.
// Returns a list of lists of path segment objects that each form one contour. // Returns a list of lists of path segment objects that each form one contour.
// Each path segment has three keys: start, end, and control. // Each path segment has three keys: start, end, and control.

128
lib/svg.js Normal file
View File

@ -0,0 +1,128 @@
"use strict";
if (this.svg !== undefined) throw new Error('Redefining svg global!');
this.svg = {};
// A normal-form SVG path is a path data string with the following properties:
// - Every command in the path is in ['L', 'M', 'Q', 'Z'].
// - Adjacent tokens in the path are separated by exactly one space.
// - There is exactly one 'Z', and it is the last command.
//
// A segment is a section of a path, represented as an object that has a start,
// an end, and possibly a control, all of which are valid Points (that is, pairs
// of Numbers).
// Takes a normal-form SVG path and returns a list of lists of segments on it.
const splitPath = (path) => {
assert(path.length > 0);
assert(path[0] === 'M', `Path did not start with M: ${path}`);
assert(path[path.length - 1] === 'Z', `Path did not end with Z: ${path}`);
const terms = path.split(' ');
const result = [];
let start = undefined;
let current = undefined;
for (let i = 0; i < terms.length; i++) {
const command = terms[i];
assert(command.length > 0, `Path includes empty command: ${path}`);
assert('LMQZ'.indexOf(command) >= 0, command);
if (command === 'M' || command === 'Z') {
if (current !== undefined) {
assert(Point.equal(current, start), `Path has open contour: ${path}`);
assert(result[result.length - 1].length > 0,
`Path has empty contour: ${path}`);
if (command === 'Z') {
assert(i === terms.length - 1, `Path ended early: ${path}`);
return result;
}
}
result.push([]);
assert(i < terms.length - 2, `Missing point on path: ${path}`);
start = [parseFloat(terms[i + 1], 10), parseFloat(terms[i + 2], 10)];
assert(Point.valid(start));
i += 2;
current = Point.clone(start);
continue;
}
let control = undefined;
if (command === 'Q') {
assert(i < terms.length - 2, `Missing point on path: ${path}`);
control = [parseFloat(terms[i + 1], 10), parseFloat(terms[i + 2], 10)];
assert(Point.valid(control));
i += 2;
}
assert(i < terms.length - 2, `Missing point on path: ${path}`);
const end = [parseFloat(terms[i + 1], 10), parseFloat(terms[i + 2], 10)];
assert(Point.valid(end));
i += 2;
if (control !== undefined &&
(Point.equal(control, current) || Point.equal(control, end))) {
control = undefined;
}
result[result.length - 1].push({
start: Point.clone(current),
control: control,
end: end,
});
current = Point.clone(end);
}
}
// Takes a TrueType font command list (as provided by opentype.js) and returns
// a normal-form SVG path as defined above.
svg.convertCommandsToPath = (commands) => {
const terms = [];
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
assert('LMQZ'.indexOf(command.type) >= 0, command.type);
if (command.type === 'Z') {
assert(i === commands.length - 1);
break;
}
terms.push(command.type);
assert((command.x1 !== undefined) === (command.type === 'Q'));
if (command.x1 !== undefined) {
terms.push(command.x1);
terms.push(command.y1);
}
assert(command.x !== undefined);
terms.push(command.x);
terms.push(command.y);
}
terms.push('Z');
return terms.join(' ');
}
// Converts a normal-form SVG path to a list of list of segments. Each segment
// in the segment list has a start, an end, and possibly a control, all of which
// are valid Points (that is, pairs of numbers).
//
// The segment lists obey an orientation constraint: segments on external paths
// are oriented clockwise, while those on internal paths are oriented clockwise.
svg.convertPathToSegmentLists = (path) => {
return splitPath(path);
}
// Takes the given segment lists and returns a normal-form SVG path that
// represents those segments.
svg.convertSegmentListsToPath = (paths) => {
const terms = [];
for (let path of paths) {
assert(path.length > 0);
terms.push('M');
terms.push(path[0].start[0]);
terms.push(path[0].start[1]);
for (let segment of path) {
if (segment.control === undefined) {
terms.push('L');
} else {
terms.push('Q');
terms.push(segment.control[0]);
terms.push(segment.control[1]);
}
terms.push(segment.end[0]);
terms.push(segment.end[1]);
}
}
terms.push('Z');
return terms.join(' ');
}

View File

@ -1,5 +1,6 @@
"use strict"; "use strict";
// Prints the message and throws an error if the conditionis false.
this.assert = (condition, message) => { this.assert = (condition, message) => {
if (!condition) { if (!condition) {
console.error(message); console.error(message);
@ -13,6 +14,8 @@ if (Meteor.isServer) {
Meteor.npmRequire('es6-shim'); Meteor.npmRequire('es6-shim');
} }
// Returns a list of the unique values in the given array, ordered by their
// first appearance in the array.
Array.prototype.unique = function() { Array.prototype.unique = function() {
const result = []; const result = [];
const seen = {}; const seen = {};
@ -24,3 +27,36 @@ Array.prototype.unique = function() {
}); });
return result; return result;
} }
// Helper methods for use with angles, which are floats in [-pi, pi).
assert(this.Angle === undefined, 'Redefining Angle global!');
this.Angle = {
subtract: (angle1, angle2) => {
var result = angle1 - angle2;
if (result < -Math.PI) {
result += 2*Math.PI;
}
if (result >= Math.PI) {
result -= 2*Math.PI;
}
return result;
},
penalty: (diff) => diff*diff,
};
const isNumber = (x) => Number.isFinite(x) && !Number.isNaN(x);
// Helper methods for use with "points", which are pairs of integers.
assert(this.Point === undefined, 'Redefining Point global!');
this.Point = {
angle: (point) => Math.atan2(point[1], point[0]),
clone: (point) => [point[0], point[1]],
distance2(point1, point2) {
var diff = Point.subtract(point1, point2);
return Math.pow(diff[0], 2) + Math.pow(diff[1], 2);
},
equal: (point1, point2) => point1[0] === point2[0] && point1[1] === point2[1],
key: (point) => point.join(','),
subtract: (point1, point2) => [point1[0] - point2[0], point1[1] - point2[1]],
valid: (point) => isNumber(point[0]) && isNumber(point[1]),
};