aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json3
-rw-r--r--ext/bg/background.html1
-rw-r--r--ext/bg/data/dictionary-term-meta-bank-v3-schema.json64
-rw-r--r--ext/bg/data/options-schema.json17
-rw-r--r--ext/bg/js/dictionary.js32
-rw-r--r--ext/bg/js/japanese.js31
-rw-r--r--ext/bg/js/options.js5
-rw-r--r--ext/bg/js/settings/main.js6
-rw-r--r--ext/bg/js/text-source-map.js115
-rw-r--r--ext/bg/js/translator.js52
-rw-r--r--ext/bg/settings.html12
-rw-r--r--ext/mixed/css/display-dark.css27
-rw-r--r--ext/mixed/css/display-default.css27
-rw-r--r--ext/mixed/css/display.css190
-rw-r--r--ext/mixed/display-templates.html17
-rw-r--r--ext/mixed/js/core.js24
-rw-r--r--ext/mixed/js/display-generator.js246
-rw-r--r--ext/mixed/js/display.js3
-rw-r--r--ext/mixed/js/japanese.js26
-rw-r--r--package.json2
-rw-r--r--test/data/dictionaries/valid-dictionary1/tag_bank_3.json4
-rw-r--r--test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json36
-rw-r--r--test/data/html/test-document2-frame1.html42
-rw-r--r--test/data/html/test-document2-script.js41
-rw-r--r--test/data/html/test-document2.html81
-rw-r--r--test/data/html/test-stylesheet.css19
-rw-r--r--test/test-database.js9
-rw-r--r--test/test-japanese.js73
-rw-r--r--test/test-text-source-map.js234
29 files changed, 1332 insertions, 107 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index db8ff1fa..8882cb42 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -87,6 +87,8 @@
"stringReverse": "readonly",
"promiseTimeout": "readonly",
"parseUrl": "readonly",
+ "areSetsEqual": "readonly",
+ "getSetIntersection": "readonly",
"EventDispatcher": "readonly",
"EventListenerCollection": "readonly",
"EXTENSION_IS_BROWSER_EDGE": "readonly"
@@ -106,6 +108,7 @@
},
{
"files": ["test/**/*.js"],
+ "excludedFiles": ["test/data/html/*.js"],
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module"
diff --git a/ext/bg/background.html b/ext/bg/background.html
index 62802341..afe9c5d1 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -39,6 +39,7 @@
<script src="/bg/js/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
+ <script src="/bg/js/text-source-map.js"></script>
<script src="/bg/js/translator.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio-system.js"></script>
diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
index 1cc0557f..8475db81 100644
--- a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
@@ -13,13 +13,71 @@
},
{
"type": "string",
- "enum": ["freq"],
- "description": "Type of data. \"freq\" corresponds to frequency information."
+ "enum": ["freq", "pitch"],
+ "description": "Type of data. \"freq\" corresponds to frequency information; \"pitch\" corresponds to pitch information."
},
{
- "type": ["string", "number"],
"description": "Data for the term/expression."
}
+ ],
+ "oneOf": [
+ {
+ "items": [
+ {},
+ {"enum": ["freq"]},
+ {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ }
+ ]
+ },
+ {
+ "items": [
+ {},
+ {"enum": ["pitch"]},
+ {
+ "type": ["object"],
+ "description": "Pitch accent information for the term or expression.",
+ "required": [
+ "reading",
+ "pitches"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "reading": {
+ "type": "string",
+ "description": "Reading for the term or expression."
+ },
+ "pitches": {
+ "type": "array",
+ "description": "List of different pitch accent information for the term and reading combination.",
+ "additionalItems": {
+ "type": "object",
+ "required": [
+ "position"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "position": {
+ "type": "integer",
+ "description": "Mora position of the pitch accent downstep. A value of 0 indicates that the word does not have a downstep (heiban).",
+ "minimum": 0
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags for this pitch accent.",
+ "items": {
+ "type": "string",
+ "description": "Tag for this pitch accent. This typically corresponds to a certain type of part of speech."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
]
}
} \ No newline at end of file
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index d6207952..cb759b72 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -105,7 +105,10 @@
"customPopupCss",
"customPopupOuterCss",
"enableWanakana",
- "enableClipboardMonitor"
+ "enableClipboardMonitor",
+ "showPitchAccentDownstepNotation",
+ "showPitchAccentPositionNotation",
+ "showPitchAccentGraph"
],
"properties": {
"enable": {
@@ -227,6 +230,18 @@
"enableClipboardMonitor": {
"type": "boolean",
"default": false
+ },
+ "showPitchAccentDownstepNotation": {
+ "type": "boolean",
+ "default": true
+ },
+ "showPitchAccentPositionNotation": {
+ "type": "boolean",
+ "default": true
+ },
+ "showPitchAccentGraph": {
+ "type": "boolean",
+ "default": false
}
}
},
diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js
index 3dd1d0c1..74bd5a64 100644
--- a/ext/bg/js/dictionary.js
+++ b/ext/bg/js/dictionary.js
@@ -137,30 +137,6 @@ function dictTermsGroup(definitions, dictionaries) {
return dictTermsSort(results);
}
-function dictAreSetsEqual(set1, set2) {
- if (set1.size !== set2.size) {
- return false;
- }
-
- for (const value of set1) {
- if (!set2.has(value)) {
- return false;
- }
- }
-
- return true;
-}
-
-function dictGetSetIntersection(set1, set2) {
- const result = [];
- for (const value of set1) {
- if (set2.has(value)) {
- result.push(value);
- }
- }
- return result;
-}
-
function dictTermsMergeBySequence(definitions, mainDictionary) {
const sequencedDefinitions = new Map();
const nonSequencedDefinitions = [];
@@ -281,11 +257,11 @@ function dictTermsMergeByGloss(result, definitions, appendTo=null, mergedIndices
const only = [];
const expressionSet = definition.expression;
const readingSet = definition.reading;
- if (!dictAreSetsEqual(expressionSet, resultExpressionSet)) {
- only.push(...dictGetSetIntersection(expressionSet, resultExpressionSet));
+ if (!areSetsEqual(expressionSet, resultExpressionSet)) {
+ only.push(...getSetIntersection(expressionSet, resultExpressionSet));
}
- if (!dictAreSetsEqual(readingSet, resultReadingSet)) {
- only.push(...dictGetSetIntersection(readingSet, resultReadingSet));
+ if (!areSetsEqual(readingSet, resultReadingSet)) {
+ only.push(...getSetIntersection(readingSet, resultReadingSet));
}
definition.only = only;
}
diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js
index c5873cf1..2a2b39fd 100644
--- a/ext/bg/js/japanese.js
+++ b/ext/bg/js/japanese.js
@@ -158,9 +158,8 @@
return result;
}
- function convertHalfWidthKanaToFullWidth(text, sourceMapping) {
+ function convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
let result = '';
- const hasSourceMapping = Array.isArray(sourceMapping);
// This function is safe to use charCodeAt instead of codePointAt, since all
// the relevant characters are represented with a single UTF-16 character code.
@@ -192,10 +191,8 @@
}
}
- if (hasSourceMapping && index > 0) {
- index = result.length;
- const v = sourceMapping.splice(index + 1, 1)[0];
- sourceMapping[index] += v;
+ if (sourceMap !== null && index > 0) {
+ sourceMap.combine(result.length, 1);
}
result += c2;
}
@@ -203,7 +200,7 @@
return result;
}
- function convertAlphabeticToKana(text, sourceMapping) {
+ function convertAlphabeticToKana(text, sourceMap=null) {
let part = '';
let result = '';
@@ -222,7 +219,7 @@
c = 0x2d; // '-'
} else {
if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMapping, result.length);
+ result += convertAlphabeticPartToKana(part, sourceMap, result.length);
part = '';
}
result += char;
@@ -232,17 +229,16 @@
}
if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMapping, result.length);
+ result += convertAlphabeticPartToKana(part, sourceMap, result.length);
}
return result;
}
- function convertAlphabeticPartToKana(text, sourceMapping, sourceMappingStart) {
+ function convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
const result = wanakana.toHiragana(text);
// Generate source mapping
- if (Array.isArray(sourceMapping)) {
- if (typeof sourceMappingStart !== 'number') { sourceMappingStart = 0; }
+ if (sourceMap !== null) {
let i = 0;
let resultPos = 0;
const ii = text.length;
@@ -262,18 +258,15 @@
// Merge characters
const removals = iNext - i - 1;
if (removals > 0) {
- let sum = 0;
- const vs = sourceMapping.splice(sourceMappingStart + 1, removals);
- for (const v of vs) { sum += v; }
- sourceMapping[sourceMappingStart] += sum;
+ sourceMap.combine(sourceMapStart, removals);
}
- ++sourceMappingStart;
+ ++sourceMapStart;
// Empty elements
const additions = resultPosNext - resultPos - 1;
for (let j = 0; j < additions; ++j) {
- sourceMapping.splice(sourceMappingStart, 0, 0);
- ++sourceMappingStart;
+ sourceMap.insert(sourceMapStart, 0);
+ ++sourceMapStart;
}
i = iNext;
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index bd0bbe0e..b36fe812 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -124,7 +124,10 @@ function profileOptionsCreateDefaults() {
customPopupCss: '',
customPopupOuterCss: '',
enableWanakana: true,
- enableClipboardMonitor: false
+ enableClipboardMonitor: false,
+ showPitchAccentDownstepNotation: true,
+ showPitchAccentPositionNotation: true,
+ showPitchAccentGraph: false
},
audio: {
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index ebc443df..7caeaea0 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -84,6 +84,9 @@ async function formRead(options) {
options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val());
options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked');
options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked');
+ options.general.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked');
+ options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked');
+ options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked');
options.general.popupTheme = $('#popup-theme').val();
options.general.popupOuterTheme = $('#popup-outer-theme').val();
options.general.customPopupCss = $('#custom-popup-css').val();
@@ -161,6 +164,9 @@ async function formWrite(options) {
$('#popup-scaling-factor').val(options.general.popupScalingFactor);
$('#popup-scale-relative-to-page-zoom').prop('checked', options.general.popupScaleRelativeToPageZoom);
$('#popup-scale-relative-to-visual-viewport').prop('checked', options.general.popupScaleRelativeToVisualViewport);
+ $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation);
+ $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation);
+ $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph);
$('#popup-theme').val(options.general.popupTheme);
$('#popup-outer-theme').val(options.general.popupOuterTheme);
$('#custom-popup-css').val(options.general.customPopupCss);
diff --git a/ext/bg/js/text-source-map.js b/ext/bg/js/text-source-map.js
new file mode 100644
index 00000000..24970978
--- /dev/null
+++ b/ext/bg/js/text-source-map.js
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class TextSourceMap {
+ constructor(source, mapping=null) {
+ this._source = source;
+ this._mapping = (Array.isArray(mapping) ? TextSourceMap._normalizeMapping(mapping) : null);
+ }
+
+ get source() {
+ return this._source;
+ }
+
+ equals(other) {
+ if (this === other) {
+ return true;
+ }
+
+ const source = this._source;
+ if (!(other instanceof TextSourceMap && source === other._source)) {
+ return false;
+ }
+
+ let mapping = this._mapping;
+ let otherMapping = other._mapping;
+ if (mapping === null) {
+ if (otherMapping === null) {
+ return true;
+ }
+ mapping = TextSourceMap._createMapping(source);
+ } else if (otherMapping === null) {
+ otherMapping = TextSourceMap._createMapping(source);
+ }
+
+ const mappingLength = mapping.length;
+ if (mappingLength !== otherMapping.length) {
+ return false;
+ }
+
+ for (let i = 0; i < mappingLength; ++i) {
+ if (mapping[i] !== otherMapping[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ getSourceLength(finalLength) {
+ const mapping = this._mapping;
+ if (mapping === null) {
+ return finalLength;
+ }
+
+ let sourceLength = 0;
+ for (let i = 0; i < finalLength; ++i) {
+ sourceLength += mapping[i];
+ }
+ return sourceLength;
+ }
+
+ combine(index, count) {
+ if (count <= 0) { return; }
+
+ if (this._mapping === null) {
+ this._mapping = TextSourceMap._createMapping(this._source);
+ }
+
+ let sum = this._mapping[index];
+ const parts = this._mapping.splice(index + 1, count);
+ for (const part of parts) {
+ sum += part;
+ }
+ this._mapping[index] = sum;
+ }
+
+ insert(index, ...items) {
+ if (this._mapping === null) {
+ this._mapping = TextSourceMap._createMapping(this._source);
+ }
+
+ this._mapping.splice(index, 0, ...items);
+ }
+
+ static _createMapping(text) {
+ return new Array(text.length).fill(1);
+ }
+
+ static _normalizeMapping(mapping) {
+ const result = [];
+ for (const value of mapping) {
+ result.push(
+ (typeof value === 'number' && Number.isFinite(value)) ?
+ Math.floor(value) :
+ 0
+ );
+ }
+ return result;
+ }
+}
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index df19eee1..27f91c05 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -18,6 +18,7 @@
/* global
* Deinflector
+ * TextSourceMap
* dictEnabledSet
* dictTagBuildSource
* dictTagSanitize
@@ -359,17 +360,15 @@ class Translator {
const used = new Set();
for (const [halfWidth, numeric, alphabetic, katakana, hiragana] of Translator.getArrayVariants(textOptionVariantArray)) {
let text2 = text;
- let sourceMapping = null;
+ const sourceMap = new TextSourceMap(text2);
if (halfWidth) {
- if (sourceMapping === null) { sourceMapping = Translator.createTextSourceMapping(text2); }
- text2 = jp.convertHalfWidthKanaToFullWidth(text2, sourceMapping);
+ text2 = jp.convertHalfWidthKanaToFullWidth(text2, sourceMap);
}
if (numeric) {
text2 = jp.convertNumericToFullWidth(text2);
}
if (alphabetic) {
- if (sourceMapping === null) { sourceMapping = Translator.createTextSourceMapping(text2); }
- text2 = jp.convertAlphabeticToKana(text2, sourceMapping);
+ text2 = jp.convertAlphabeticToKana(text2, sourceMap);
}
if (katakana) {
text2 = jp.convertHiraganaToKatakana(text2);
@@ -383,7 +382,7 @@ class Translator {
if (used.has(text2Substring)) { break; }
used.add(text2Substring);
for (const deinflection of this.deinflector.deinflect(text2Substring)) {
- deinflection.rawSource = Translator.getDeinflectionRawSource(text, i, sourceMapping);
+ deinflection.rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i));
deinflections.push(deinflection);
}
}
@@ -399,25 +398,6 @@ class Translator {
}
}
- static getDeinflectionRawSource(source, length, sourceMapping) {
- if (sourceMapping === null) {
- return source.substring(0, length);
- }
-
- let result = '';
- let index = 0;
- for (let i = 0; i < length; ++i) {
- const c = sourceMapping[i];
- result += source.substring(index, index + c);
- index += c;
- }
- return result;
- }
-
- static createTextSourceMapping(text) {
- return new Array(text.length).fill(1);
- }
-
async findKanji(text, options) {
const dictionaries = dictEnabledSet(options);
const kanjiUnique = new Set();
@@ -482,6 +462,7 @@ class Translator {
// New data
term.frequencies = [];
+ term.pitches = [];
}
const metas = await this.database.findTermMetaBulk(expressionsUnique, dictionaries);
@@ -492,6 +473,13 @@ class Translator {
term.frequencies.push({expression, frequency: data, dictionary});
}
break;
+ case 'pitch':
+ for (const term of termsUnique[index]) {
+ const pitchData = await this.getPitchData(expression, data, dictionary, term);
+ if (pitchData === null) { continue; }
+ term.pitches.push(pitchData);
+ }
+ break;
}
}
}
@@ -575,6 +563,20 @@ class Translator {
return tagMetaList;
}
+ async getPitchData(expression, data, dictionary, term) {
+ const reading = data.reading;
+ const termReading = term.reading || expression;
+ if (reading !== termReading) { return null; }
+
+ const pitches = [];
+ for (let {position, tags} of data.pitches) {
+ tags = Array.isArray(tags) ? await this.getTagMetaList(tags, dictionary) : [];
+ pitches.push({position, tags});
+ }
+
+ return {reading, pitches, dictionary};
+ }
+
static createExpression(expression, reading, termTags=null, termFrequency=null) {
const furiganaSegments = jp.distributeFurigana(expression, reading);
return {
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index cfe20be4..0b2e4f9c 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -163,6 +163,18 @@
</div>
<div class="checkbox options-advanced">
+ <label><input type="checkbox" id="show-pitch-accent-downstep-notation"> Show downstep notation for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-position">
+ <label><input type="checkbox" id="show-pitch-accent-position-notation"> Show position notation for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-advanced">
+ <label><input type="checkbox" id="show-pitch-accent-graph"> Show graph for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-advanced">
<label><input type="checkbox" id="show-debug-info"> Show debug information</label>
</div>
diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css
index c9cd9f90..550dff3e 100644
--- a/ext/mixed/css/display-dark.css
+++ b/ext/mixed/css/display-dark.css
@@ -19,6 +19,8 @@
body { background-color: #1e1e1e; color: #d4d4d4; }
+h2 { border-bottom-color: #2f2f2f; }
+
.navigation-header {
background-color: #1e1e1e;
border-bottom-color: #2f2f2f;
@@ -39,6 +41,7 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
.tag[data-category=frequency] { background-color: #489148; }
.tag[data-category=partOfSpeech] { background-color: #565656; }
.tag[data-category=search] { background-color: #69696e; }
+.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
.term-reasons { color: #888888; }
@@ -57,12 +60,15 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
color: #666666;
}
-.term-definition-container,
-.kanji-glossary-container {
+.term-definition-list,
+.term-pitch-accent-group-list,
+.term-pitch-accent-disambiguation-list,
+.kanji-glossary-list {
color: #888888;
}
.term-glossary,
+.term-pitch-accent,
.kanji-glossary {
color: #d4d4d4;
}
@@ -72,3 +78,20 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
background-color: #d4d4d4;
color: #1e1e1e;
}
+
+.term-pitch-accent-container { border-bottom-color: #2f2f2f; }
+
+.term-pitch-accent-character:before { border-color: #ffffff; }
+
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail,
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep,
+#term-pitch-accent-graph-triangle {
+ stroke: #ffffff;
+}
+
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #ffffff;
+}
diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css
index 6eee43c4..487b8cb8 100644
--- a/ext/mixed/css/display-default.css
+++ b/ext/mixed/css/display-default.css
@@ -19,6 +19,8 @@
body { background-color: #ffffff; color: #333333; }
+h2 { border-bottom-color: #eeeeee; }
+
.navigation-header {
background-color: #ffffff;
border-bottom-color: #eeeeee;
@@ -39,6 +41,7 @@ body { background-color: #ffffff; color: #333333; }
.tag[data-category=frequency] { background-color: #5cb85c; }
.tag[data-category=partOfSpeech] { background-color: #565656; }
.tag[data-category=search] { background-color: #8a8a91; }
+.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
.term-reasons { color: #777777; }
@@ -57,12 +60,15 @@ body { background-color: #ffffff; color: #333333; }
color: #999999;
}
-.term-definition-container,
-.kanji-glossary-container {
+.term-definition-list,
+.term-pitch-accent-group-list,
+.term-pitch-accent-disambiguation-list,
+.kanji-glossary-list {
color: #777777;
}
.term-glossary,
+.term-pitch-accent,
.kanji-glossary {
color: #000000;
}
@@ -72,3 +78,20 @@ body { background-color: #ffffff; color: #333333; }
background-color: #333333;
color: #ffffff;
}
+
+.term-pitch-accent-container { border-bottom-color: #eeeeee; }
+
+.term-pitch-accent-character:before { border-color: #000000; }
+
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail,
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep,
+#term-pitch-accent-graph-triangle {
+ stroke: #000000;
+}
+
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #000000;
+}
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index 688a357c..a4432016 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -65,6 +65,14 @@ ol, ul {
height: 2.28571428em; /* 14px => 32px */
}
+h2 {
+ font-size: 1.25em;
+ font-weight: normal;
+ margin: 0.25em 0 0;
+ border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
+ border-bottom-style: solid;
+}
+
/*
* Navigation
*/
@@ -302,6 +310,7 @@ button.action-button {
width: 0;
height: 0;
visibility: hidden;
+ z-index: 1;
}
.term-expression-list[data-multi=true] .term-expression:hover .term-expression-details {
@@ -422,6 +431,187 @@ button.action-button {
display: inline;
}
+.term-entry-body[data-section-count="0"] .term-entry-body-section-header,
+.term-entry-body[data-section-count="1"] .term-entry-body-section-header {
+ display: none;
+}
+
+
+/*
+ * Pitch accent styles
+ */
+
+.entry[data-pitch-accent-count='0'] .term-pitch-accent-container {
+ display: none;
+}
+
+.term-pitch-accent-container {
+ border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
+ border-bottom-style: solid;
+ padding-bottom: 0.25em;
+ margin-bottom: 0.25em;
+}
+
+.term-pitch-accent-group-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+
+.term-pitch-accent-group-list:not([data-count="0"]):not([data-count="1"]) {
+ padding-left: 1.4em;
+ list-style-type: decimal;
+}
+
+.term-pitch-accent-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+ display: inline;
+}
+
+.term-pitch-accent-list:not([data-count="0"]):not([data-count="1"]) {
+ padding-left: 1.4em;
+ list-style-type: circle;
+ display: block;
+}
+
+.term-pitch-accent {
+ display: inline;
+ line-height: 1.5em;
+}
+
+.term-pitch-accent-list:not([data-count="0"]):not([data-count="1"])>.term-pitch-accent {
+ display: list-item;
+}
+
+.term-pitch-accent-group-tag-list {
+ margin-right: 0.375em;
+}
+
+.term-pitch-accent-disambiguation-list {
+ padding-right: 0.25em;
+}
+
+.term-pitch-accent-disambiguation-list:before {
+ content: "(";
+}
+
+.term-pitch-accent-disambiguation-list:after {
+ content: " only)";
+}
+
+.term-pitch-accent-disambiguation+.term-pitch-accent-disambiguation:before {
+ content: ", ";
+}
+
+.term-pitch-accent-disambiguation-list[data-count="0"],
+:root[data-show-pitch-accent-downstep-notation=true] .term-pitch-accent-disambiguation-list[data-expression-count="0"],
+:root[data-show-pitch-accent-downstep-notation=true] .term-pitch-accent-disambiguation[data-type=reading] {
+ display: none;
+}
+
+.term-pitch-accent-tag-list:not([data-count="0"]) {
+ margin-right: 0.375em;
+}
+
+.term-special-tags>.pitches {
+ display: inline;
+}
+
+.term-pitch-accent-character {
+ display: inline-block;
+ position: relative;
+}
+.term-pitch-accent-character[data-pitch='high']:before {
+ content: "";
+ display: block;
+ user-select: none;
+ pointer-events: none;
+ position: absolute;
+ top: 0.1em;
+ left: 0;
+ right: 0;
+ height: 0;
+ border-top-width: 0.1em;
+ border-top-style: solid;
+}
+.term-pitch-accent-character[data-pitch='high'][data-pitch-next='low']:before {
+ right: -0.1em;
+ height: 0.4em;
+ border-right-width: 0.1em;
+ border-right-style: solid;
+}
+.term-pitch-accent-character[data-pitch='high'][data-pitch-next='low'] {
+ padding-right: 0.1em;
+ margin-right: 0.1em;
+}
+
+.term-pitch-accent-position:before {
+ content: " [";
+}
+.term-pitch-accent-position:after {
+ content: "]";
+}
+
+.term-pitch-accent-details {
+ display: inline-block;
+ height: 0;
+ padding: 0 0.25em;
+ vertical-align: middle;
+}
+
+
+:root[data-show-pitch-accent-downstep-notation=false] .term-pitch-accent-characters {
+ display: none;
+}
+
+:root[data-show-pitch-accent-position-notation=false] .term-pitch-accent-position {
+ display: none;
+}
+
+:root[data-show-pitch-accent-graph=false] .term-pitch-accent-details {
+ display: none;
+}
+
+
+/*
+ * Pitch accent graph styles
+ */
+
+.term-pitch-accent-graph {
+ display: block;
+ height: 1.5em;
+ transform: translateY(-0.875em);
+}
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+.term-pitch-accent-graph-line-tail {
+ stroke-dasharray: 5 5;
+}
+#term-pitch-accent-graph-dot {
+ fill: #000000;
+ stroke: #000000;
+ stroke-width: 5;
+}
+#term-pitch-accent-graph-dot-downstep {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #000000;
+}
+#term-pitch-accent-graph-triangle {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+
/*
* Kanji
diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html
index 7ae51a62..b8d52d15 100644
--- a/ext/mixed/display-templates.html
+++ b/ext/mixed/display-templates.html
@@ -17,7 +17,10 @@
</div>
<div class="term-special-tags"><div class="frequencies tag-list"></div></div>
</div>
- <div class="term-definition-container"><ol class="term-definition-list"></ol></div>
+ <div class="term-entry-body">
+ <div class="term-entry-body-section term-pitch-accent-container"><ol class="term-entry-body-section-content term-pitch-accent-group-list"></ol></div>
+ <div class="term-entry-body-section term-definition-container"><ol class="term-entry-body-section-content term-definition-list"></ol></div>
+ </div>
<pre class="debug-info"></pre>
</div></template>
<template id="term-expression-template"><div class="term-expression"><span class="term-expression-text source-text"></span><div class="term-expression-details">
@@ -34,6 +37,18 @@
<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template>
<template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template>
+<template id="term-pitch-accent-static-template"><svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
+ <defs>
+ <g id="term-pitch-accent-graph-dot"><circle cx="0" cy="0" r="15" /></g>
+ <g id="term-pitch-accent-graph-dot-downstep"><circle cx="0" cy="0" r="15" /><circle cx="0" cy="0" r="5" /></g>
+ <g id="term-pitch-accent-graph-triangle"><path d="M0 13 L15 -13 L-15 -13 Z" /></g>
+ </defs>
+</svg></template>
+<template id="term-pitch-accent-group-template"><li class="term-pitch-accent-group"><span class="term-pitch-accent-group-tag-list tag-list"></span><ul class="term-pitch-accent-list"></ul></li></template>
+<template id="term-pitch-accent-disambiguation-template"><span class="term-pitch-accent-disambiguation"></span></template>
+<template id="term-pitch-accent-template"><li class="term-pitch-accent"><span class="term-pitch-accent-tag-list tag-list"></span><span class="term-pitch-accent-disambiguation-list"></span><span class="term-pitch-accent-characters"></span><span class="term-pitch-accent-position"></span><span class="term-pitch-accent-details"><svg class="term-pitch-accent-graph" xmlns="http://www.w3.org/2000/svg"><path class="term-pitch-accent-graph-line" /><path class="term-pitch-accent-graph-line-tail" /></svg></span></li></template>
+<template id="term-pitch-accent-character-template"><span class="term-pitch-accent-character"><span class="term-pitch-accent-character-inner"></span></span></template>
+
<template id="kanji-entry-template"><div class="entry" data-type="kanji">
<div class="entry-header1">
<div class="entry-header2">
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index 0d50e915..fd762e97 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -132,6 +132,30 @@ function parseUrl(url) {
return {baseUrl, queryParams};
}
+function areSetsEqual(set1, set2) {
+ if (set1.size !== set2.size) {
+ return false;
+ }
+
+ for (const value of set1) {
+ if (!set2.has(value)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function getSetIntersection(set1, set2) {
+ const result = [];
+ for (const value of set1) {
+ if (set2.has(value)) {
+ result.push(value);
+ }
+ }
+ return result;
+}
+
/*
* Async utilities
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index 41f7315a..f1122e3d 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -25,6 +25,7 @@
class DisplayGenerator {
constructor() {
this._templateHandler = null;
+ this._termPitchAccentStaticTemplateIsSetup = false;
}
async prepare() {
@@ -37,17 +38,33 @@ class DisplayGenerator {
const expressionsContainer = node.querySelector('.term-expression-list');
const reasonsContainer = node.querySelector('.term-reasons');
+ const pitchesContainer = node.querySelector('.term-pitch-accent-group-list');
const frequenciesContainer = node.querySelector('.frequencies');
const definitionsContainer = node.querySelector('.term-definition-list');
const debugInfoContainer = node.querySelector('.debug-info');
+ const bodyContainer = node.querySelector('.term-entry-body');
+
+ const pitches = DisplayGenerator._getPitchInfos(details);
+ const pitchCount = pitches.reduce((i, v) => i + v[1].length, 0);
const expressionMulti = Array.isArray(details.expressions);
const definitionMulti = Array.isArray(details.definitions);
+ const expressionCount = expressionMulti ? details.expressions.length : 1;
+ const definitionCount = definitionMulti ? details.definitions.length : 1;
+ const uniqueExpressionCount = Array.isArray(details.expression) ? new Set(details.expression).size : 1;
node.dataset.expressionMulti = `${expressionMulti}`;
node.dataset.definitionMulti = `${definitionMulti}`;
- node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`;
- node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`;
+ node.dataset.expressionCount = `${expressionCount}`;
+ node.dataset.definitionCount = `${definitionCount}`;
+ node.dataset.uniqueExpressionCount = `${uniqueExpressionCount}`;
+ node.dataset.pitchAccentDictionaryCount = `${pitches.length}`;
+ node.dataset.pitchAccentCount = `${pitchCount}`;
+
+ bodyContainer.dataset.sectionCount = `${
+ (definitionCount > 0 ? 1 : 0) +
+ (pitches.length > 0 ? 1 : 0)
+ }`;
const termTags = details.termTags;
let expressions = details.expressions;
@@ -56,6 +73,7 @@ class DisplayGenerator {
DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), expressions, [[details, termTags]]);
DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons);
DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies);
+ DisplayGenerator._appendMultiple(pitchesContainer, this.createPitches.bind(this), pitches);
DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]);
if (debugInfoContainer !== null) {
@@ -262,6 +280,133 @@ class DisplayGenerator {
return node;
}
+ createPitches(details) {
+ if (!this._termPitchAccentStaticTemplateIsSetup) {
+ this._termPitchAccentStaticTemplateIsSetup = true;
+ const t = this._templateHandler.instantiate('term-pitch-accent-static');
+ document.head.appendChild(t);
+ }
+
+ const [dictionary, dictionaryPitches] = details;
+
+ const node = this._templateHandler.instantiate('term-pitch-accent-group');
+ node.dataset.dictionary = dictionary;
+ node.dataset.pitchesMulti = 'true';
+ node.dataset.pitchesCount = `${dictionaryPitches.length}`;
+
+ const tag = this.createTag({notes: '', name: dictionary, category: 'pitch-accent-dictionary'});
+ node.querySelector('.term-pitch-accent-group-tag-list').appendChild(tag);
+
+ const n = node.querySelector('.term-pitch-accent-list');
+ DisplayGenerator._appendMultiple(n, this.createPitch.bind(this), dictionaryPitches);
+
+ return node;
+ }
+
+ createPitch(details) {
+ const {reading, position, tags, exclusiveExpressions, exclusiveReadings} = details;
+ const morae = jp.getKanaMorae(reading);
+
+ const node = this._templateHandler.instantiate('term-pitch-accent');
+
+ node.dataset.pitchAccentPosition = `${position}`;
+ node.dataset.tagCount = `${tags.length}`;
+
+ let n = node.querySelector('.term-pitch-accent-position');
+ n.textContent = `${position}`;
+
+ n = node.querySelector('.term-pitch-accent-tag-list');
+ DisplayGenerator._appendMultiple(n, this.createTag.bind(this), tags);
+
+ n = node.querySelector('.term-pitch-accent-disambiguation-list');
+ this.createPitchAccentDisambiguations(n, exclusiveExpressions, exclusiveReadings);
+
+ n = node.querySelector('.term-pitch-accent-characters');
+ for (let i = 0, ii = morae.length; i < ii; ++i) {
+ const mora = morae[i];
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+
+ const n1 = this._templateHandler.instantiate('term-pitch-accent-character');
+ const n2 = n1.querySelector('.term-pitch-accent-character-inner');
+
+ n1.dataset.position = `${i}`;
+ n1.dataset.pitch = highPitch ? 'high' : 'low';
+ n1.dataset.pitchNext = highPitchNext ? 'high' : 'low';
+ n2.textContent = mora;
+
+ n.appendChild(n1);
+ }
+
+ if (morae.length > 0) {
+ this.populatePitchGraph(node.querySelector('.term-pitch-accent-graph'), position, morae);
+ }
+
+ return node;
+ }
+
+ createPitchAccentDisambiguations(container, exclusiveExpressions, exclusiveReadings) {
+ const templateName = 'term-pitch-accent-disambiguation';
+ for (const exclusiveExpression of exclusiveExpressions) {
+ const node = this._templateHandler.instantiate(templateName);
+ node.dataset.type = 'expression';
+ node.textContent = exclusiveExpression;
+ container.appendChild(node);
+ }
+
+ for (const exclusiveReading of exclusiveReadings) {
+ const node = this._templateHandler.instantiate(templateName);
+ node.dataset.type = 'reading';
+ node.textContent = exclusiveReading;
+ container.appendChild(node);
+ }
+
+ container.dataset.multi = 'true';
+ container.dataset.count = `${exclusiveExpressions.length + exclusiveReadings.length}`;
+ container.dataset.expressionCount = `${exclusiveExpressions.length}`;
+ container.dataset.readingCount = `${exclusiveReadings.length}`;
+ }
+
+ populatePitchGraph(svg, position, morae) {
+ const svgns = svg.getAttribute('xmlns');
+ const ii = morae.length;
+ svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`);
+
+ const pathPoints = [];
+ for (let i = 0; i < ii; ++i) {
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+ const graphic = (highPitch && !highPitchNext ? '#term-pitch-accent-graph-dot-downstep' : '#term-pitch-accent-graph-dot');
+ const x = `${i * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', graphic);
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ let path = svg.querySelector('.term-pitch-accent-graph-line');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+
+ pathPoints.splice(0, ii - 1);
+ {
+ const highPitch = jp.isMoraPitchHigh(ii, position);
+ const x = `${ii * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', '#term-pitch-accent-graph-triangle');
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ path = svg.querySelector('.term-pitch-accent-graph-line-tail');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+ }
+
createFrequencyTag(details) {
const node = this._templateHandler.instantiate('tag-frequency');
@@ -301,22 +446,28 @@ class DisplayGenerator {
}
}
- static _appendMultiple(container, createItem, detailsArray, fallback=[]) {
+ static _appendMultiple(container, createItem, detailsIterable, fallback=[]) {
if (container === null) { return 0; }
- const isArray = Array.isArray(detailsArray);
- if (!isArray) { detailsArray = fallback; }
-
- container.dataset.multi = `${isArray}`;
- container.dataset.count = `${detailsArray.length}`;
+ const multi = (
+ detailsIterable !== null &&
+ typeof detailsIterable === 'object' &&
+ typeof detailsIterable[Symbol.iterator] !== 'undefined'
+ );
+ if (!multi) { detailsIterable = fallback; }
- for (const details of detailsArray) {
+ let count = 0;
+ for (const details of detailsIterable) {
const item = createItem(details);
if (item === null) { continue; }
container.appendChild(item);
+ ++count;
}
- return detailsArray.length;
+ container.dataset.multi = `${multi}`;
+ container.dataset.count = `${count}`;
+
+ return count;
}
static _appendFurigana(container, segments, addText) {
@@ -342,4 +493,79 @@ class DisplayGenerator {
container.appendChild(document.createTextNode(parts[i]));
}
}
+
+ static _getPitchInfos(definition) {
+ const results = new Map();
+
+ const allExpressions = new Set();
+ const allReadings = new Set();
+ const expressions = definition.expressions;
+ const sources = Array.isArray(expressions) ? expressions : [definition];
+ for (const {pitches: expressionPitches, expression} of sources) {
+ allExpressions.add(expression);
+ for (const {reading, pitches, dictionary} of expressionPitches) {
+ allReadings.add(reading);
+ let dictionaryResults = results.get(dictionary);
+ if (typeof dictionaryResults === 'undefined') {
+ dictionaryResults = [];
+ results.set(dictionary, dictionaryResults);
+ }
+
+ for (const {position, tags} of pitches) {
+ let pitchInfo = DisplayGenerator._findExistingPitchInfo(reading, position, tags, dictionaryResults);
+ if (pitchInfo === null) {
+ pitchInfo = {expressions: new Set(), reading, position, tags};
+ dictionaryResults.push(pitchInfo);
+ }
+ pitchInfo.expressions.add(expression);
+ }
+ }
+ }
+
+ for (const dictionaryResults of results.values()) {
+ for (const result of dictionaryResults) {
+ const exclusiveExpressions = [];
+ const exclusiveReadings = [];
+ const resultExpressions = result.expressions;
+ if (!areSetsEqual(resultExpressions, allExpressions)) {
+ exclusiveExpressions.push(...getSetIntersection(resultExpressions, allExpressions));
+ }
+ if (allReadings.size > 1) {
+ exclusiveReadings.push(result.reading);
+ }
+ result.exclusiveExpressions = exclusiveExpressions;
+ result.exclusiveReadings = exclusiveReadings;
+ }
+ }
+
+ return [...results.entries()];
+ }
+
+ static _findExistingPitchInfo(reading, position, tags, pitchInfoList) {
+ for (const pitchInfo of pitchInfoList) {
+ if (
+ pitchInfo.reading === reading &&
+ pitchInfo.position === position &&
+ DisplayGenerator._areTagListsEqual(pitchInfo.tags, tags)
+ ) {
+ return pitchInfo;
+ }
+ }
+ return null;
+ }
+
+ static _areTagListsEqual(tagList1, tagList2) {
+ const ii = tagList1.length;
+ if (tagList2.length !== ii) { return false; }
+
+ for (let i = 0; i < ii; ++i) {
+ const tag1 = tagList1[i];
+ const tag2 = tagList2[i];
+ if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index 6898a6eb..4a71efe0 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -385,6 +385,9 @@ class Display {
data.audioEnabled = `${options.audio.enabled}`;
data.compactGlossaries = `${options.general.compactGlossaries}`;
data.enableSearchTags = `${options.scanning.enableSearchTags}`;
+ data.showPitchAccentDownstepNotation = `${options.general.showPitchAccentDownstepNotation}`;
+ data.showPitchAccentPositionNotation = `${options.general.showPitchAccentPositionNotation}`;
+ data.showPitchAccentGraph = `${options.general.showPitchAccentGraph}`;
data.debug = `${options.general.debugInfo}`;
}
diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js
index 61a247b2..e6b9a8a0 100644
--- a/ext/mixed/js/japanese.js
+++ b/ext/mixed/js/japanese.js
@@ -64,6 +64,8 @@ const jp = (() => {
[0xffe0, 0xffee] // Currency markers
];
+ const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ'));
+
// Character code testing functions
@@ -112,6 +114,26 @@ const jp = (() => {
}
+ // Mora functions
+
+ function isMoraPitchHigh(moraIndex, pitchAccentPosition) {
+ return pitchAccentPosition === 0 ? (moraIndex > 0) : (moraIndex < pitchAccentPosition);
+ }
+
+ function getKanaMorae(text) {
+ const morae = [];
+ let i;
+ for (const c of text) {
+ if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
+ morae[i - 1] += c;
+ } else {
+ morae.push(c);
+ }
+ }
+ return morae;
+ }
+
+
// Exports
return {
@@ -119,6 +141,8 @@ const jp = (() => {
isCodePointKana,
isCodePointJapanese,
isStringEntirelyKana,
- isStringPartiallyJapanese
+ isStringPartiallyJapanese,
+ isMoraPitchHigh,
+ getKanaMorae
};
})();
diff --git a/package.json b/package.json
index 8ae103a0..b02ec179 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"scripts": {
"test": "npm run test-lint && npm run test-code",
"test-lint": "eslint . && node ./test/lint/global-declarations.js",
- "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js"
+ "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js"
},
"repository": {
"type": "git",
diff --git a/test/data/dictionaries/valid-dictionary1/tag_bank_3.json b/test/data/dictionaries/valid-dictionary1/tag_bank_3.json
new file mode 100644
index 00000000..572221fe
--- /dev/null
+++ b/test/data/dictionaries/valid-dictionary1/tag_bank_3.json
@@ -0,0 +1,4 @@
+[
+ ["ptag1", "pcategory1", 0, "ptag1 notes", 0],
+ ["ptag2", "pcategory2", 0, "ptag2 notes", 0]
+] \ No newline at end of file
diff --git a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
index 78096502..26922394 100644
--- a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
+++ b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
@@ -1,5 +1,39 @@
[
["打", "freq", 1],
["打つ", "freq", 2],
- ["打ち込む", "freq", 3]
+ ["打ち込む", "freq", 3],
+ [
+ "打ち込む",
+ "pitch",
+ {
+ "reading": "うちこむ",
+ "pitches": [
+ {"position": 0},
+ {"position": 3}
+ ]
+ }
+ ],
+ [
+ "打ち込む",
+ "pitch",
+ {
+ "reading": "ぶちこむ",
+ "pitches": [
+ {"position": 0},
+ {"position": 3}
+ ]
+ }
+ ],
+ [
+ "お手前",
+ "pitch",
+ {
+ "reading": "おてまえ",
+ "pitches": [
+ {"position": 2, "tags": ["ptag1"]},
+ {"position": 2, "tags": ["ptag2"]},
+ {"position": 0, "tags": ["ptag2"]}
+ ]
+ }
+ ]
] \ No newline at end of file
diff --git a/test/data/html/test-document2-frame1.html b/test/data/html/test-document2-frame1.html
new file mode 100644
index 00000000..3b85cdc5
--- /dev/null
+++ b/test/data/html/test-document2-frame1.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Yomichan Tests</title>
+ <script src="test-document2-script.js"></script>
+ <style>
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ padding: 0;
+ margin: 0;
+ background-color: #f8f8f8;
+}
+a, a:visited {
+ color: #1080c0;
+ text-decoration: underline;
+}
+.content {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ padding: 0.5em;
+ background-color: #f8f8f8;
+}
+ </style>
+ </head>
+<body><div class="content">
+<div>
+ ありがとう
+</div>
+<div>
+ <a href="#" id="fullscreen-link">Toggle fullscreen</a>
+ <script>
+document.querySelector('#fullscreen-link').addEventListener('click', () => toggleFullscreen(document.body), false);
+ </script>
+</div>
+</div></body>
+</html> \ No newline at end of file
diff --git a/test/data/html/test-document2-script.js b/test/data/html/test-document2-script.js
new file mode 100644
index 00000000..bd5a570d
--- /dev/null
+++ b/test/data/html/test-document2-script.js
@@ -0,0 +1,41 @@
+function requestFullscreen(element) {
+ if (element.requestFullscreen) {
+ element.requestFullscreen();
+ } else if (element.mozRequestFullScreen) {
+ element.mozRequestFullScreen();
+ } else if (element.webkitRequestFullscreen) {
+ element.webkitRequestFullscreen();
+ } else if (element.msRequestFullscreen) {
+ element.msRequestFullscreen();
+ }
+}
+
+function exitFullscreen() {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ } else if (document.msExitFullscreen) {
+ document.msExitFullscreen();
+ }
+}
+
+function getFullscreenElement() {
+ return (
+ document.fullscreenElement ||
+ document.msFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.webkitFullscreenElement ||
+ null
+ );
+}
+
+function toggleFullscreen(element) {
+ if (getFullscreenElement()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(element);
+ }
+}
diff --git a/test/data/html/test-document2.html b/test/data/html/test-document2.html
new file mode 100644
index 00000000..3a22a5bf
--- /dev/null
+++ b/test/data/html/test-document2.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Yomichan Manual Tests</title>
+ <link rel="icon" type="image/gif" href="data:image/gif;base64,R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAImFI6Zpt0B4YkS0TCpq07xbmEgcGVRUpLaI46ZG7ppalY0jDCwUAAAOw==" />
+ <link rel="stylesheet" href="test-stylesheet.css" />
+ <script src="test-document2-script.js"></script>
+ </head>
+<body>
+
+ <h1>Yomichan Manual Tests</h1>
+ <p class="description">Manual tests involving fullscreen elements, &lt;iframe&gt;s, and shadow DOMs.</p>
+
+ <div class="test">
+ <div class="description">Standard content.</div>
+ <div id="fullscreen-element1" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;">
+ <div>
+ ありがとう
+ </div>
+ <div>
+ <a href="#" id="fullscreen-link1">Toggle fullscreen</a>
+ </div>
+ </div></div>
+ <script>
+document.querySelector('#fullscreen-link1').addEventListener('click', () => toggleFullscreen(document.querySelector('#fullscreen-element1')), false);
+ </script>
+ </div>
+
+ <div class="test">
+ <div class="description">Content inside of a shadow DOM.</div>
+ <div id="shadow-content-container"></div>
+ <template id="shadow-content-container-content-template">
+ <link rel="stylesheet" href="test-stylesheet.css" />
+ <div id="fullscreen-element2" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;">
+ <div>
+ ありがとう
+ </div>
+ <div>
+ <a href="#" id="fullscreen-link2">Toggle fullscreen</a>
+ </div>
+ </div></div>
+ </template>
+ <script>
+(() => {
+ const shadowIframeContainer = document.querySelector('#shadow-content-container');
+ const shadow = shadowIframeContainer.attachShadow({mode: 'closed'});
+ const template = document.querySelector('#shadow-content-container-content-template').content;
+ const content = document.importNode(template, true);
+ const fullscreenElement = content.querySelector('#fullscreen-element2');
+ content.querySelector('#fullscreen-link2').addEventListener('click', () => toggleFullscreen(fullscreenElement), false);
+ shadow.appendChild(content);
+})();
+ </script>
+ </div>
+
+ <div class="test">
+ <div class="description">&lt;iframe&gt; element.</div>
+ <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe>
+ </div>
+
+ <div class="test">
+ <div class="description">&lt;iframe&gt; element inside of a shadow DOM.</div>
+ <div id="shadow-iframe-container"></div>
+ <template id="shadow-iframe-container-content-template">
+ <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe>
+ </template>
+ <script>
+(() => {
+ const shadowIframeContainer = document.querySelector('#shadow-iframe-container');
+ const shadow = shadowIframeContainer.attachShadow({mode: 'closed'});
+ const template = document.querySelector('#shadow-iframe-container-content-template').content;
+ const content = document.importNode(template, true);
+ shadow.appendChild(content);
+})();
+ </script>
+ </div>
+
+</body>
+</html> \ No newline at end of file
diff --git a/test/data/html/test-stylesheet.css b/test/data/html/test-stylesheet.css
index ab25732e..f63d2481 100644
--- a/test/data/html/test-stylesheet.css
+++ b/test/data/html/test-stylesheet.css
@@ -7,6 +7,7 @@ body {
margin: 0 auto;
background-color: #f8f8f8;
counter-reset: test-id;
+ overflow-y: scroll;
}
h1 {
@@ -14,6 +15,19 @@ h1 {
margin: 0.67em 0;
}
+p {
+ margin: 0.33em 0;
+}
+
+h1+p {
+ margin-top: -0.67em;
+}
+
+a, a:visited {
+ color: #1080c0;
+ text-decoration: underline;
+}
+
.test {
background-color: #ffffff;
margin: 1em 0;
@@ -30,3 +44,8 @@ h1 {
border-bottom: 1px solid #d8d8d8;
font-weight: bold;
}
+
+.description {
+ color: #444444;
+ font-style: italic;
+}
diff --git a/test/test-database.js b/test/test-database.js
index c3402b73..c4bfb793 100644
--- a/test/test-database.js
+++ b/test/test-database.js
@@ -235,8 +235,8 @@ async function testDatabase1() {
true
);
vm.assert.deepStrictEqual(counts, {
- counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}],
- total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}
+ counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14}],
+ total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14}
});
// Test find* functions
@@ -652,9 +652,10 @@ async function testFindTermMetaBulk1(database, titles) {
}
],
expectedResults: {
- total: 1,
+ total: 3,
modes: [
- ['freq', 1]
+ ['freq', 1],
+ ['pitch', 2]
]
}
},
diff --git a/test/test-japanese.js b/test/test-japanese.js
index c5d220e7..ca65dde2 100644
--- a/test/test-japanese.js
+++ b/test/test-japanese.js
@@ -23,9 +23,11 @@ const vm = new VM();
vm.execute([
'mixed/lib/wanakana.min.js',
'mixed/js/japanese.js',
+ 'bg/js/text-source-map.js',
'bg/js/japanese.js'
]);
const jp = vm.get('jp');
+const TextSourceMap = vm.get('TextSourceMap');
function testIsCodePointKanji() {
@@ -262,13 +264,13 @@ function testConvertHalfWidthKanaToFullWidth() {
];
for (const [string, expected, expectedSourceMapping] of data) {
- const sourceMapping = new Array(string.length).fill(1);
+ const sourceMap = new TextSourceMap(string);
const actual1 = jp.convertHalfWidthKanaToFullWidth(string, null);
- const actual2 = jp.convertHalfWidthKanaToFullWidth(string, sourceMapping);
+ const actual2 = jp.convertHalfWidthKanaToFullWidth(string, sourceMap);
assert.strictEqual(actual1, expected);
assert.strictEqual(actual2, expected);
- if (Array.isArray(expectedSourceMapping)) {
- vm.assert.deepStrictEqual(sourceMapping, expectedSourceMapping);
+ if (typeof expectedSourceMapping !== 'undefined') {
+ assert.ok(sourceMap.equals(new TextSourceMap(string, expectedSourceMapping)));
}
}
}
@@ -285,13 +287,13 @@ function testConvertAlphabeticToKana() {
];
for (const [string, expected, expectedSourceMapping] of data) {
- const sourceMapping = new Array(string.length).fill(1);
+ const sourceMap = new TextSourceMap(string);
const actual1 = jp.convertAlphabeticToKana(string, null);
- const actual2 = jp.convertAlphabeticToKana(string, sourceMapping);
+ const actual2 = jp.convertAlphabeticToKana(string, sourceMap);
assert.strictEqual(actual1, expected);
assert.strictEqual(actual2, expected);
- if (Array.isArray(expectedSourceMapping)) {
- vm.assert.deepStrictEqual(sourceMapping, expectedSourceMapping);
+ if (typeof expectedSourceMapping !== 'undefined') {
+ assert.ok(sourceMap.equals(new TextSourceMap(string, expectedSourceMapping)));
}
}
}
@@ -392,6 +394,59 @@ function testDistributeFuriganaInflected() {
}
}
+function testIsMoraPitchHigh() {
+ const data = [
+ [[0, 0], false],
+ [[1, 0], true],
+ [[2, 0], true],
+ [[3, 0], true],
+
+ [[0, 1], true],
+ [[1, 1], false],
+ [[2, 1], false],
+ [[3, 1], false],
+
+ [[0, 2], true],
+ [[1, 2], true],
+ [[2, 2], false],
+ [[3, 2], false],
+
+ [[0, 3], true],
+ [[1, 3], true],
+ [[2, 3], true],
+ [[3, 3], false],
+
+ [[0, 4], true],
+ [[1, 4], true],
+ [[2, 4], true],
+ [[3, 4], true]
+ ];
+
+ for (const [[moraIndex, pitchAccentPosition], expected] of data) {
+ const actual = jp.isMoraPitchHigh(moraIndex, pitchAccentPosition);
+ assert.strictEqual(actual, expected);
+ }
+}
+
+function testGetKanaMorae() {
+ const data = [
+ ['かこ', ['か', 'こ']],
+ ['かっこ', ['か', 'っ', 'こ']],
+ ['カコ', ['カ', 'コ']],
+ ['カッコ', ['カ', 'ッ', 'コ']],
+ ['コート', ['コ', 'ー', 'ト']],
+ ['ちゃんと', ['ちゃ', 'ん', 'と']],
+ ['とうきょう', ['と', 'う', 'きょ', 'う']],
+ ['ぎゅう', ['ぎゅ', 'う']],
+ ['ディスコ', ['ディ', 'ス', 'コ']]
+ ];
+
+ for (const [text, expected] of data) {
+ const actual = jp.getKanaMorae(text);
+ vm.assert.deepStrictEqual(actual, expected);
+ }
+}
+
function main() {
testIsCodePointKanji();
@@ -408,6 +463,8 @@ function main() {
testConvertAlphabeticToKana();
testDistributeFurigana();
testDistributeFuriganaInflected();
+ testIsMoraPitchHigh();
+ testGetKanaMorae();
}
diff --git a/test/test-text-source-map.js b/test/test-text-source-map.js
new file mode 100644
index 00000000..25bd8fc2
--- /dev/null
+++ b/test/test-text-source-map.js
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+const assert = require('assert');
+const {VM} = require('./yomichan-vm');
+
+const vm = new VM();
+vm.execute(['bg/js/text-source-map.js']);
+const TextSourceMap = vm.get('TextSourceMap');
+
+
+function testSource() {
+ const data = [
+ ['source1'],
+ ['source2'],
+ ['source3']
+ ];
+
+ for (const [source] of data) {
+ const sourceMap = new TextSourceMap(source);
+ assert.strictEqual(source, sourceMap.source);
+ }
+}
+
+function testEquals() {
+ const data = [
+ [['source1', null], ['source1', null], true],
+ [['source2', null], ['source2', null], true],
+ [['source3', null], ['source3', null], true],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source1', null], true],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source2', null], true],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source3', null], true],
+
+ [['source1', null], ['source1', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source2', null], ['source2', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source3', null], ['source3', [1, 1, 1, 1, 1, 1, 1]], true],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source1', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source2', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source3', [1, 1, 1, 1, 1, 1, 1]], true],
+
+ [['source1', [1, 2, 1, 3]], ['source1', [1, 2, 1, 3]], true],
+ [['source2', [1, 2, 1, 3]], ['source2', [1, 2, 1, 3]], true],
+ [['source3', [1, 2, 1, 3]], ['source3', [1, 2, 1, 3]], true],
+
+ [['source1', [1, 3, 1, 2]], ['source1', [1, 2, 1, 3]], false],
+ [['source2', [1, 3, 1, 2]], ['source2', [1, 2, 1, 3]], false],
+ [['source3', [1, 3, 1, 2]], ['source3', [1, 2, 1, 3]], false],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source4', [1, 1, 1, 1, 1, 1, 1]], false],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source5', [1, 1, 1, 1, 1, 1, 1]], false],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source6', [1, 1, 1, 1, 1, 1, 1]], false]
+ ];
+
+ for (const [[source1, mapping1], [source2, mapping2], expectedEquals] of data) {
+ const sourceMap1 = new TextSourceMap(source1, mapping1);
+ const sourceMap2 = new TextSourceMap(source2, mapping2);
+ assert.ok(sourceMap1.equals(sourceMap1));
+ assert.ok(sourceMap2.equals(sourceMap2));
+ assert.strictEqual(sourceMap1.equals(sourceMap2), expectedEquals);
+ }
+}
+
+function testGetSourceLength() {
+ const data = [
+ [['source', [1, 1, 1, 1, 1, 1]], 1, 1],
+ [['source', [1, 1, 1, 1, 1, 1]], 2, 2],
+ [['source', [1, 1, 1, 1, 1, 1]], 3, 3],
+ [['source', [1, 1, 1, 1, 1, 1]], 4, 4],
+ [['source', [1, 1, 1, 1, 1, 1]], 5, 5],
+ [['source', [1, 1, 1, 1, 1, 1]], 6, 6],
+
+ [['source', [2, 2, 2]], 1, 2],
+ [['source', [2, 2, 2]], 2, 4],
+ [['source', [2, 2, 2]], 3, 6],
+
+ [['source', [3, 3]], 1, 3],
+ [['source', [3, 3]], 2, 6],
+
+ [['source', [6, 6]], 1, 6]
+ ];
+
+ for (const [[source, mapping], finalLength, expectedValue] of data) {
+ const sourceMap = new TextSourceMap(source, mapping);
+ assert.strictEqual(sourceMap.getSourceLength(finalLength), expectedValue);
+ }
+}
+
+function testCombineInsert() {
+ const data = [
+ // No operations
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 1, 1, 1]],
+ []
+ ],
+
+ // Combine
+ [
+ ['source', null],
+ ['source', [3, 1, 1, 1]],
+ [
+ ['combine', 0, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 3]],
+ [
+ ['combine', 3, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 3]],
+ [
+ ['combine', 0, 2],
+ ['combine', 1, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 3]],
+ [
+ ['combine', 3, 2],
+ ['combine', 0, 2]
+ ]
+ ],
+
+ // Insert
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1]],
+ [
+ ['insert', 0, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 6, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 0, 0],
+ ['insert', 7, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 6, 0],
+ ['insert', 0, 0]
+ ]
+ ],
+
+ // Mixed
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['combine', 0, 2],
+ ['insert', 1, 0],
+ ['combine', 2, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['combine', 0, 2],
+ ['combine', 1, 2],
+ ['insert', 1, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['insert', 3, 0],
+ ['combine', 0, 2],
+ ['combine', 2, 2]
+ ]
+ ]
+ ];
+
+ for (const [[source, mapping], [expectedSource, expectedMapping], operations] of data) {
+ const sourceMap = new TextSourceMap(source, mapping);
+ const expectedSourceMap = new TextSourceMap(expectedSource, expectedMapping);
+ for (const [operation, ...args] of operations) {
+ switch (operation) {
+ case 'combine':
+ sourceMap.combine(...args);
+ break;
+ case 'insert':
+ sourceMap.insert(...args);
+ break;
+ }
+ }
+ assert.ok(sourceMap.equals(expectedSourceMap));
+ }
+}
+
+
+function main() {
+ testSource();
+ testEquals();
+ testGetSourceLength();
+ testCombineInsert();
+}
+
+
+if (require.main === module) { main(); }