From 43c5c03626fbc04b59a477817e9f384fcff8e4f4 Mon Sep 17 00:00:00 2001 From: Shaunak Kishore Date: Sun, 27 Sep 2015 15:58:34 -0400 Subject: [PATCH] Add helper functions to serialize and deserialize paths --- cjklib/data.js | 33 ++++++----- client/glyph.js | 1 + lib/stroke_extractor.js | 43 -------------- lib/svg.js | 128 ++++++++++++++++++++++++++++++++++++++++ lib/util.js | 36 +++++++++++ 5 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 lib/svg.js diff --git a/cjklib/data.js b/cjklib/data.js index 3eb85d7a..a731fd7f 100644 --- a/cjklib/data.js +++ b/cjklib/data.js @@ -1,9 +1,12 @@ +"use strict"; + const fs = maybeRequire('fs'); const path = maybeRequire('path'); const CHARACTER_FIELDS = ['character', 'decomposition', 'definition', 'kangxi_index', 'pinyin', 'strokes']; +assert(this.cjklib === undefined); this.cjklib = { characters: {}, gb2312: {}, @@ -27,7 +30,7 @@ CHARACTER_FIELDS.map((field) => cjklib.characters[field] = {}); // Input: String contents of a cjklib data file. // Output: a list of rows, each of which is a list of String columns. -getCJKLibRows = (data) => { +const getCJKLibRows = (data) => { const lines = data.split('\n'); return lines.filter((line) => line.length > 0 && line[0] !== '#') .map((line) => line.split(',').map( @@ -36,7 +39,7 @@ getCJKLibRows = (data) => { // Input: String contents of a Unihan data file. // Output: a list of rows, each of which is a list of String columns. -getUnihanRows = (data) => { +const getUnihanRows = (data) => { const lines = data.split('\n'); return lines.filter((line) => line.length > 0 && line[0] !== '#') .map((line) => line.split('\t')); @@ -44,11 +47,12 @@ getUnihanRows = (data) => { // Input: a String of the form 'U+' representing a Unicode 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. // 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) { const filepath = path.join(process.env.PWD, 'public', filename); 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. // The decompositions are formatted using Ideographic Description Sequence // 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]) => { rows.filter((row) => parseInt(row[2], 10) === (glyphs[row[0]] || 0)) .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. -fillDefinitions = (readings, result) => { +const fillDefinitions = (readings, result) => { return readings.then((rows) => { rows.filter((row) => row[1] === 'kDefinition') .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- // stroke count, which is a pair of integers [radical, extra_strokes]. -fillKangxiIndex = (readings, result) => { +const fillKangxiIndex = (readings, result) => { return readings.then((rows) => { const getIndex = (adotb) => adotb.split('.').map((x) => parseInt(x, 10)); rows.filter((row) => row[1] === 'kRSKangXi') @@ -94,7 +98,7 @@ fillKangxiIndex = (readings, result) => { } // Output: Promise that fills result with a mapping character -> Pinyin. -fillPinyin = (readings, result) => { +const fillPinyin = (readings, result) => { return readings.then((rows) => { rows.filter((row) => row[1] === 'kMandarin') .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. -fillStrokeCounts = (dictionary_like_data, result) => { +const fillStrokeCounts = (dictionary_like_data, result) => { return dictionary_like_data.then((rows) => { rows.filter((row) => row[1] === 'kTotalStrokes') .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 // - radical_to_index_map: Map from radical -> index of that radical // - primary_radical: Map from index -> primary radical at that index -fillRadicalData = (locale, radicals, result) => { +const fillRadicalData = (locale, radicals, result) => { return radicals.then((rows) => { rows.filter((row) => row[3].indexOf(locale) >= 0).map((row) => { 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 // character -> equivalent Unicode CJK-codeblock (hopefully, GB2312) character. // 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) => { rows.filter((row) => row[2].indexOf(locale) >= 0) .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. -fillGB2312 = (data, result) => { +const fillGB2312 = (data, result) => { Array.from(data).map((character) => { if (character === '\n') return; 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 // mapping from characters to the appropriate glyph in that locale. -parseLocaleGlyphMap = (locale, rows) => { +const parseLocaleGlyphMap = (locale, rows) => { const result = {}; rows.filter((row) => row[2].indexOf(locale) >= 0) .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. -cleanupCJKLibData = () => { +const cleanupCJKLibData = () => { const characters = cjklib.characters; const radicals = cjklib.radicals; const convert_astral_characters = (x) => x.length === 1 ? x : '?' diff --git a/client/glyph.js b/client/glyph.js index f6d5b5e0..3ee704cf 100644 --- a/client/glyph.js +++ b/client/glyph.js @@ -12,6 +12,7 @@ var EDIT_STROKES = true; function change_glyph(method, glyph) { glyph = glyph || Session.get('glyph.data'); Meteor.call(method, glyph, function(err, data) { + Session.set('glyph.test', data); data = fill_glyph_fields(data); Session.set('glyph.data', data); if (method === 'save_glyph') { diff --git a/lib/stroke_extractor.js b/lib/stroke_extractor.js index 76653b59..eefdaa2a 100644 --- a/lib/stroke_extractor.js +++ b/lib/stroke_extractor.js @@ -5,49 +5,6 @@ var MIN_CORNER_ANGLE = 0.1*Math.PI; var MIN_CORNER_TANGENT_DISTANCE = 4; 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. // Returns a list of lists of path segment objects that each form one contour. // Each path segment has three keys: start, end, and control. diff --git a/lib/svg.js b/lib/svg.js new file mode 100644 index 00000000..445dcb98 --- /dev/null +++ b/lib/svg.js @@ -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(' '); +} diff --git a/lib/util.js b/lib/util.js index fb492d52..ce02076b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,5 +1,6 @@ "use strict"; +// Prints the message and throws an error if the conditionis false. this.assert = (condition, message) => { if (!condition) { console.error(message); @@ -13,6 +14,8 @@ if (Meteor.isServer) { 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() { const result = []; const seen = {}; @@ -24,3 +27,36 @@ Array.prototype.unique = function() { }); 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]), +};