summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ext/bg/background.html1
-rw-r--r--ext/bg/data/dictionary-term-bank-v3-schema.json81
-rw-r--r--ext/bg/js/backend.js7
-rw-r--r--ext/bg/js/database.js43
-rw-r--r--ext/bg/js/dictionary-importer.js90
-rw-r--r--ext/bg/js/media-utility.js98
-rw-r--r--ext/bg/search.html1
-rw-r--r--ext/fg/float.html1
-rw-r--r--ext/mixed/css/display-dark.css7
-rw-r--r--ext/mixed/css/display-default.css7
-rw-r--r--ext/mixed/css/display.css115
-rw-r--r--ext/mixed/display-templates.html1
-rw-r--r--ext/mixed/js/api.js4
-rw-r--r--ext/mixed/js/display-generator.js85
-rw-r--r--ext/mixed/js/display.js6
-rw-r--r--ext/mixed/js/media-loader.js107
-rw-r--r--test/data/dictionaries/valid-dictionary1/image.gifbin0 -> 45 bytes
-rw-r--r--test/data/dictionaries/valid-dictionary1/term_bank_1.json3
-rw-r--r--test/test-database.js52
-rw-r--r--test/yomichan-test.js15
20 files changed, 707 insertions, 17 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index afe9c5d1..f1006f8d 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -36,6 +36,7 @@
<script src="/bg/js/handlebars.js"></script>
<script src="/bg/js/japanese.js"></script>
<script src="/bg/js/json-schema.js"></script>
+ <script src="/bg/js/media-utility.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json
index bb982e36..4790e561 100644
--- a/ext/bg/data/dictionary-term-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-bank-v3-schema.json
@@ -31,8 +31,85 @@
"type": "array",
"description": "Array of definitions for the term/expression.",
"items": {
- "type": "string",
- "description": "Single definition for the term/expression."
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ },
+ {
+ "type": "object",
+ "description": "Single detailed definition for the term/expression.",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of the data for this definition.",
+ "enum": ["text", "image"]
+ }
+ },
+ "oneOf": [
+ {
+ "required": [
+ "type",
+ "text"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["text"]
+ },
+ "text": {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ }
+ }
+ },
+ {
+ "required": [
+ "type",
+ "path"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["image"]
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to the image file in the archive."
+ },
+ "width": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "title": {
+ "type": "string",
+ "description": "Hover text for the image."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of the image."
+ },
+ "pixelated": {
+ "type": "boolean",
+ "description": "Whether or not the image should appear pixelated at sizes larger than the image's native resolution.",
+ "default": false
+ }
+ }
+ }
+ ]
+ }
+ ]
}
},
{
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index e0814c17..8a19203f 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -111,7 +111,8 @@ class Backend {
['getAnkiModelFieldNames', {handler: this._onApiGetAnkiModelFieldNames.bind(this), async: true}],
['getDictionaryInfo', {handler: this._onApiGetDictionaryInfo.bind(this), async: true}],
['getDictionaryCounts', {handler: this._onApiGetDictionaryCounts.bind(this), async: true}],
- ['purgeDatabase', {handler: this._onApiPurgeDatabase.bind(this), async: true}]
+ ['purgeDatabase', {handler: this._onApiPurgeDatabase.bind(this), async: true}],
+ ['getMedia', {handler: this._onApiGetMedia.bind(this), async: true}]
]);
this._commandHandlers = new Map([
@@ -762,6 +763,10 @@ class Backend {
return await this.translator.purgeDatabase();
}
+ async _onApiGetMedia({targets}) {
+ return await this.database.getMedia(targets);
+ }
+
// Command handlers
async _onCommandSearch(params) {
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index 260c815a..16612403 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -33,7 +33,7 @@ class Database {
}
try {
- this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => {
+ this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => {
Database._upgrade(db, transaction, oldVersion, [
{
version: 2,
@@ -90,6 +90,15 @@ class Database {
indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
}
}
+ },
+ {
+ version: 6,
+ stores: {
+ media: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'path']
+ }
+ }
}
]);
});
@@ -268,6 +277,34 @@ class Database {
return result;
}
+ async getMedia(targets) {
+ this._validate();
+
+ const count = targets.length;
+ const promises = [];
+ const results = new Array(count).fill(null);
+ const createResult = Database._createMedia;
+ const processRow = (row, [index, dictionaryName]) => {
+ if (row.dictionary === dictionaryName) {
+ results[index] = createResult(row, index);
+ }
+ };
+
+ const transaction = this.db.transaction(['media'], 'readonly');
+ const objectStore = transaction.objectStore('media');
+ const index = objectStore.index('path');
+
+ for (let i = 0; i < count; ++i) {
+ const {path, dictionaryName} = targets[i];
+ const only = IDBKeyRange.only(path);
+ promises.push(Database._getAll(index, only, [i, dictionaryName], processRow));
+ }
+
+ await Promise.all(promises);
+
+ return results;
+ }
+
async getDictionaryInfo() {
this._validate();
@@ -432,6 +469,10 @@ class Database {
return {character, mode, data, dictionary, index};
}
+ static _createMedia(row, index) {
+ return Object.assign({}, row, {index});
+ }
+
static _getAll(dbIndex, query, context, processRow) {
const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor;
return fn(dbIndex, query, context, processRow);
diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js
index bf6809ec..3727f7ee 100644
--- a/ext/bg/js/dictionary-importer.js
+++ b/ext/bg/js/dictionary-importer.js
@@ -18,6 +18,7 @@
/* global
* JSZip
* JsonSchema
+ * mediaUtility
* requestJson
*/
@@ -148,6 +149,22 @@ class DictionaryImporter {
}
}
+ // Extended data support
+ const extendedDataContext = {
+ archive,
+ media: new Map()
+ };
+ for (const entry of termList) {
+ const glossaryList = entry.glossary;
+ for (let i = 0, ii = glossaryList.length; i < ii; ++i) {
+ const glossary = glossaryList[i];
+ if (typeof glossary !== 'object' || glossary === null) { continue; }
+ glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry);
+ }
+ }
+
+ const media = [...extendedDataContext.media.values()];
+
// Add dictionary
const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported});
@@ -188,6 +205,7 @@ class DictionaryImporter {
await bulkAdd('kanji', kanjiList);
await bulkAdd('kanjiMeta', kanjiMetaList);
await bulkAdd('tagMeta', tagList);
+ await bulkAdd('media', media);
return {result: summary, errors};
}
@@ -275,4 +293,76 @@ class DictionaryImporter {
return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
}
+
+ async _formatDictionaryTermGlossaryObject(data, context, entry) {
+ switch (data.type) {
+ case 'text':
+ return data.text;
+ case 'image':
+ return await this._formatDictionaryTermGlossaryImage(data, context, entry);
+ default:
+ throw new Error(`Unhandled data type: ${data.type}`);
+ }
+ }
+
+ async _formatDictionaryTermGlossaryImage(data, context, entry) {
+ const dictionary = entry.dictionary;
+ const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data;
+ if (context.media.has(path)) {
+ // Already exists
+ return data;
+ }
+
+ let errorSource = entry.expression;
+ if (entry.reading.length > 0) {
+ errorSource += ` (${entry.reading});`;
+ }
+
+ const file = context.archive.file(path);
+ if (file === null) {
+ throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const content = await file.async('base64');
+ const mediaType = mediaUtility.getImageMediaTypeFromFileName(path);
+ if (mediaType === null) {
+ throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ let image;
+ try {
+ image = await mediaUtility.loadImageBase64(mediaType, content);
+ } catch (e) {
+ throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const width = image.naturalWidth;
+ const height = image.naturalHeight;
+
+ // Create image data
+ const mediaData = {
+ dictionary,
+ path,
+ mediaType,
+ width,
+ height,
+ content
+ };
+ context.media.set(path, mediaData);
+
+ // Create new data
+ const newData = {
+ type: 'image',
+ path,
+ width,
+ height
+ };
+ if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; }
+ if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; }
+ if (typeof title === 'string') { newData.title = title; }
+ if (typeof description === 'string') { newData.description = description; }
+ if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; }
+
+ return newData;
+ }
}
diff --git a/ext/bg/js/media-utility.js b/ext/bg/js/media-utility.js
new file mode 100644
index 00000000..1f93b2b4
--- /dev/null
+++ b/ext/bg/js/media-utility.js
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * 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/>.
+ */
+
+/**
+ * mediaUtility is an object containing helper methods related to media processing.
+ */
+const mediaUtility = (() => {
+ /**
+ * Gets the file extension of a file path. URL search queries and hash
+ * fragments are not handled.
+ * @param path The path to the file.
+ * @returns The file extension, including the '.', or an empty string
+ * if there is no file extension.
+ */
+ function getFileNameExtension(path) {
+ const match = /\.[^./\\]*$/.exec(path);
+ return match !== null ? match[0] : '';
+ }
+
+ /**
+ * Gets an image file's media type using a file path.
+ * @param path The path to the file.
+ * @returns The media type string if it can be determined from the file path,
+ * otherwise null.
+ */
+ function getImageMediaTypeFromFileName(path) {
+ switch (getFileNameExtension(path).toLowerCase()) {
+ case '.apng':
+ return 'image/apng';
+ case '.bmp':
+ return 'image/bmp';
+ case '.gif':
+ return 'image/gif';
+ case '.ico':
+ case '.cur':
+ return 'image/x-icon';
+ case '.jpg':
+ case '.jpeg':
+ case '.jfif':
+ case '.pjpeg':
+ case '.pjp':
+ return 'image/jpeg';
+ case '.png':
+ return 'image/png';
+ case '.svg':
+ return 'image/svg+xml';
+ case '.tif':
+ case '.tiff':
+ return 'image/tiff';
+ case '.webp':
+ return 'image/webp';
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Attempts to load an image using a base64 encoded content and a media type.
+ * @param mediaType The media type for the image content.
+ * @param content The binary content for the image, encoded in base64.
+ * @returns A Promise which resolves with an HTMLImageElement instance on
+ * successful load, otherwise an error is thrown.
+ */
+ function loadImageBase64(mediaType, content) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ const eventListeners = new EventListenerCollection();
+ eventListeners.addEventListener(image, 'load', () => {
+ eventListeners.removeAllEventListeners();
+ resolve(image);
+ }, false);
+ eventListeners.addEventListener(image, 'error', () => {
+ eventListeners.removeAllEventListeners();
+ reject(new Error('Image failed to load'));
+ }, false);
+ image.src = `data:${mediaType};base64,${content}`;
+ });
+ }
+
+ return {
+ getImageMediaTypeFromFileName,
+ loadImageBase64
+ };
+})();
diff --git a/ext/bg/search.html b/ext/bg/search.html
index eacc1893..fe88e264 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -85,6 +85,7 @@
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
+ <script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
<script src="/mixed/js/template-handler.js"></script>
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 3ccf68eb..c8ea9b67 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -51,6 +51,7 @@
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
+ <script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/template-handler.js"></script>
diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css
index e4549bbf..acfa2782 100644
--- a/ext/mixed/css/display-dark.css
+++ b/ext/mixed/css/display-dark.css
@@ -94,3 +94,10 @@ h2 { border-bottom-color: #2f2f2f; }
#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
fill: #ffffff;
}
+
+.term-glossary-image-container {
+ background-color: #2f2f2f;
+}
+.term-glossary-image-container-overlay {
+ color: #888888;
+}
diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css
index 7bcb1014..70f81eb6 100644
--- a/ext/mixed/css/display-default.css
+++ b/ext/mixed/css/display-default.css
@@ -94,3 +94,10 @@ h2 { border-bottom-color: #eeeeee; }
#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
fill: #000000;
}
+
+.term-glossary-image-container {
+ background-color: #eeeeee;
+}
+.term-glossary-image-container-overlay {
+ color: #777777;
+}
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index d1a54064..4f43af6f 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -611,6 +611,121 @@ button.action-button {
stroke-width: 5;
}
+.term-glossary-image-container {
+ display: inline-block;
+ white-space: nowrap;
+ max-width: 100%;
+ position: relative;
+ vertical-align: top;
+ line-height: 0;
+ font-size: 0.07142857em; /* 14px => 1px */
+ overflow: hidden;
+}
+
+.term-glossary-image-link {
+ cursor: inherit;
+ color: inherit;
+}
+
+.term-glossary-image-link[href]:hover {
+ cursor: pointer;
+}
+
+.term-glossary-image-container-overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 14em; /* 1px => 14px; */
+ line-height: 1.42857143; /* 14px => 20px */
+ display: table;
+ table-layout: fixed;
+ white-space: normal;
+}
+
+.term-glossary-item[data-has-image=true][data-image-load-state=load-error] .term-glossary-image-container-overlay:after {
+ content: "Image failed to load";
+ display: table-cell;
+ width: 100%;
+ height: 100%;
+ vertical-align: middle;
+ text-align: center;
+ padding: 0.25em;
+}
+
+.term-glossary-image {
+ display: inline-block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ vertical-align: top;
+ object-fit: contain;
+ border: none;
+ outline: none;
+}
+
+.term-glossary-image:not([src]) {
+ display: none;
+}
+
+.term-glossary-image[data-pixelated=true] {
+ image-rendering: auto;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
+
+.term-glossary-image-aspect-ratio-sizer {
+ content: "";
+ display: inline-block;
+ width: 0;
+ vertical-align: top;
+ font-size: 0;
+}
+
+.term-glossary-image-link-text:before {
+ content: "[";
+}
+
+.term-glossary-image-link-text:after {
+ content: "]";
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-container {
+ display: none;
+ position: absolute;
+ left: 0;
+ top: 100%;
+ z-index: 1;
+}
+
+:root[data-compact-glossaries=true] .entry:nth-last-of-type(1):not(:nth-of-type(1)) .term-glossary-image-container {
+ bottom: 100%;
+ top: auto;
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-link {
+ position: relative;
+ display: inline-block;
+}
+
+:root[data-compact-glossaries=true] .term-glossary-image-link:hover .term-glossary-image-container,
+:root[data-compact-glossaries=true] .term-glossary-image-link:focus .term-glossary-image-container {
+ display: block;
+}
+
+:root:not([data-compact-glossaries=true]) .term-glossary-image-link-text {
+ display: none;
+}
+
+:root:not([data-compact-glossaries=true]) .term-glossary-image-description {
+ display: block;
+}
+
/*
* Kanji
diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html
index 3baa8293..fc0558a9 100644
--- a/ext/mixed/display-templates.html
+++ b/ext/mixed/display-templates.html
@@ -35,6 +35,7 @@
</li></template>
<template id="term-definition-disambiguation-template"><span class="term-definition-disambiguation"></span></template>
<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-glossary-item-image-template"><li class="term-glossary-item" data-has-image="true"><span class="term-glossary-separator"> </span><span class="term-glossary"><a class="term-glossary-image-link" target="_blank" rel="noreferrer noopener"><span class="term-glossary-image-container"><span class="term-glossary-image-aspect-ratio-sizer"></span><img class="term-glossary-image" alt="" /><span class="term-glossary-image-container-overlay"></span></span><span class="term-glossary-image-link-text">Image</span></a> <span class="term-glossary-image-description"></span></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;">
diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js
index c97dc687..52f41646 100644
--- a/ext/mixed/js/api.js
+++ b/ext/mixed/js/api.js
@@ -140,6 +140,10 @@ function apiPurgeDatabase() {
return _apiInvoke('purgeDatabase');
}
+function apiGetMedia(targets) {
+ return _apiInvoke('getMedia', {targets});
+}
+
function _apiInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index 0f991362..a2b2b139 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -22,7 +22,8 @@
*/
class DisplayGenerator {
- constructor() {
+ constructor({mediaLoader}) {
+ this._mediaLoader = mediaLoader;
this._templateHandler = null;
this._termPitchAccentStaticTemplateIsSetup = false;
}
@@ -176,16 +177,30 @@ class DisplayGenerator {
const onlyListContainer = node.querySelector('.term-definition-disambiguation-list');
const glossaryContainer = node.querySelector('.term-glossary-list');
- node.dataset.dictionary = details.dictionary;
+ const dictionary = details.dictionary;
+ node.dataset.dictionary = dictionary;
this._appendMultiple(tagListContainer, this._createTag.bind(this), details.definitionTags);
this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only);
- this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary);
+ this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary);
return node;
}
- _createTermGlossaryItem(glossary) {
+ _createTermGlossaryItem(glossary, dictionary) {
+ if (typeof glossary === 'string') {
+ return this._createTermGlossaryItemText(glossary);
+ } else if (typeof glossary === 'object' && glossary !== null) {
+ switch (glossary.type) {
+ case 'image':
+ return this._createTermGlossaryItemImage(glossary, dictionary);
+ }
+ }
+
+ return null;
+ }
+
+ _createTermGlossaryItemText(glossary) {
const node = this._templateHandler.instantiate('term-glossary-item');
const container = node.querySelector('.term-glossary');
if (container !== null) {
@@ -194,6 +209,68 @@ class DisplayGenerator {
return node;
}
+ _createTermGlossaryItemImage(data, dictionary) {
+ const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data;
+
+ const usedWidth = (
+ typeof preferredWidth === 'number' ?
+ preferredWidth :
+ width
+ );
+ const aspectRatio = (
+ typeof preferredWidth === 'number' &&
+ typeof preferredHeight === 'number' ?
+ preferredWidth / preferredHeight :
+ width / height
+ );
+
+ const node = this._templateHandler.instantiate('term-glossary-item-image');
+ node.dataset.path = path;
+ node.dataset.dictionary = dictionary;
+ node.dataset.imageLoadState = 'not-loaded';
+
+ const imageContainer = node.querySelector('.term-glossary-image-container');
+ imageContainer.style.width = `${usedWidth}em`;
+ if (typeof title === 'string') {
+ imageContainer.title = title;
+ }
+
+ const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer');
+ aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`;
+
+ const image = node.querySelector('img.term-glossary-image');
+ const imageLink = node.querySelector('.term-glossary-image-link');
+ image.dataset.pixelated = `${pixelated === true}`;
+
+ if (this._mediaLoader !== null) {
+ this._mediaLoader.loadMedia(
+ path,
+ dictionary,
+ (url) => this._setImageData(node, image, imageLink, url, false),
+ () => this._setImageData(node, image, imageLink, null, true)
+ );
+ }
+
+ if (typeof description === 'string') {
+ const container = node.querySelector('.term-glossary-image-description');
+ this._appendMultilineText(container, description);
+ }
+
+ return node;
+ }
+
+ _setImageData(container, image, imageLink, url, unloaded) {
+ if (url !== null) {
+ image.src = url;
+ imageLink.href = url;
+ container.dataset.imageLoadState = 'loaded';
+ } else {
+ image.removeAttribute('src');
+ imageLink.removeAttribute('href');
+ container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error';
+ }
+ }
+
_createTermDisambiguation(disambiguation) {
const node = this._templateHandler.instantiate('term-definition-disambiguation');
node.dataset.term = disambiguation;
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index c2284ffe..9587ec3b 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -20,6 +20,7 @@
* DOM
* DisplayContext
* DisplayGenerator
+ * MediaLoader
* WindowScroll
* apiAudioGetUri
* apiBroadcastTab
@@ -62,7 +63,8 @@ class Display {
this.clickScanPrevent = false;
this.setContentToken = null;
- this.displayGenerator = new DisplayGenerator();
+ this.mediaLoader = new MediaLoader();
+ this.displayGenerator = new DisplayGenerator({mediaLoader: this.mediaLoader});
this.windowScroll = new WindowScroll();
this._onKeyDownHandlers = new Map([
@@ -479,6 +481,8 @@ class Display {
const token = {}; // Unique identifier token
this.setContentToken = token;
try {
+ this.mediaLoader.unloadAll();
+
switch (type) {
case 'terms':
await this.setContentTerms(details.definitions, details.context, token);
diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js
new file mode 100644
index 00000000..64ccd715
--- /dev/null
+++ b/ext/mixed/js/media-loader.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * 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/>.
+ */
+
+/* global
+ * apiGetMedia
+ */
+
+class MediaLoader {
+ constructor() {
+ this._token = {};
+ this._mediaCache = new Map();
+ this._loadMediaData = [];
+ }
+
+ async loadMedia(path, dictionaryName, onLoad, onUnload) {
+ const token = this.token;
+ const data = {onUnload, loaded: false};
+
+ this._loadMediaData.push(data);
+
+ const media = await this.getMedia(path, dictionaryName);
+ if (token !== this.token) { return; }
+
+ onLoad(media.url);
+ data.loaded = true;
+ }
+
+ unloadAll() {
+ for (const {onUnload, loaded} of this._loadMediaData) {
+ if (typeof onUnload === 'function') {
+ onUnload(loaded);
+ }
+ }
+ this._loadMediaData = [];
+
+ for (const map of this._mediaCache.values()) {
+ for (const {url} of map.values()) {
+ if (url !== null) {
+ URL.revokeObjectURL(url);
+ }
+ }
+ }
+ this._mediaCache.clear();
+
+ this._token = {};
+ }
+
+ async getMedia(path, dictionaryName) {
+ let cachedData;
+ let dictionaryCache = this._mediaCache.get(dictionaryName);
+ if (typeof dictionaryCache !== 'undefined') {
+ cachedData = dictionaryCache.get(path);
+ } else {
+ dictionaryCache = new Map();
+ this._mediaCache.set(dictionaryName, dictionaryCache);
+ }
+
+ if (typeof cachedData === 'undefined') {
+ cachedData = {
+ promise: null,
+ data: null,
+ url: null
+ };
+ dictionaryCache.set(path, cachedData);
+ cachedData.promise = this._getMediaData(path, dictionaryName, cachedData);
+ }
+
+ return cachedData.promise;
+ }
+
+ async _getMediaData(path, dictionaryName, cachedData) {
+ const token = this._token;
+ const data = (await apiGetMedia([{path, dictionaryName}]))[0];
+ if (token === this._token && data !== null) {
+ const contentArrayBuffer = this._base64ToArrayBuffer(data.content);
+ const blob = new Blob([contentArrayBuffer], {type: data.mediaType});
+ const url = URL.createObjectURL(blob);
+ cachedData.data = data;
+ cachedData.url = url;
+ }
+ return cachedData;
+ }
+
+ _base64ToArrayBuffer(content) {
+ const binaryContent = window.atob(content);
+ const length = binaryContent.length;
+ const array = new Uint8Array(length);
+ for (let i = 0; i < length; ++i) {
+ array[i] = binaryContent.charCodeAt(i);
+ }
+ return array.buffer;
+ }
+}
diff --git a/test/data/dictionaries/valid-dictionary1/image.gif b/test/data/dictionaries/valid-dictionary1/image.gif
new file mode 100644
index 00000000..f089d07c
--- /dev/null
+++ b/test/data/dictionaries/valid-dictionary1/image.gif
Binary files differ
diff --git a/test/data/dictionaries/valid-dictionary1/term_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_bank_1.json
index 755d9f6a..a62ad117 100644
--- a/test/data/dictionaries/valid-dictionary1/term_bank_1.json
+++ b/test/data/dictionaries/valid-dictionary1/term_bank_1.json
@@ -30,5 +30,6 @@
["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 29, ["definition1a (打ち込む, ぶちこむ)", "definition1b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"],
["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 30, ["definition2a (打ち込む, ぶちこむ)", "definition2b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"],
["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 31, ["definition3a (打ち込む, ぶちこむ)", "definition3b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"],
- ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 32, ["definition4a (打ち込む, ぶちこむ)", "definition4b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"]
+ ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 32, ["definition4a (打ち込む, ぶちこむ)", "definition4b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"],
+ ["画像", "がぞう", "tag1 tag2", "", 33, ["definition1a (画像, がぞう)", {"type": "image", "path": "image.gif", "width": 350, "height": 350, "description": "An image", "pixelated": true}], 7, "tag3 tag4 tag5"]
] \ No newline at end of file
diff --git a/test/test-database.js b/test/test-database.js
index 8b7a163a..e9ec3f0b 100644
--- a/test/test-database.js
+++ b/test/test-database.js
@@ -92,9 +92,56 @@ class XMLHttpRequest {
}
}
+class Image {
+ constructor() {
+ this._src = '';
+ this._loadCallbacks = [];
+ }
+
+ get src() {
+ return this._src;
+ }
+
+ set src(value) {
+ this._src = value;
+ this._delayTriggerLoad();
+ }
+
+ get naturalWidth() {
+ return 100;
+ }
+
+ get naturalHeight() {
+ return 100;
+ }
+
+ addEventListener(eventName, callback) {
+ if (eventName === 'load') {
+ this._loadCallbacks.push(callback);
+ }
+ }
+
+ removeEventListener(eventName, callback) {
+ if (eventName === 'load') {
+ const index = this._loadCallbacks.indexOf(callback);
+ if (index >= 0) {
+ this._loadCallbacks.splice(index, 1);
+ }
+ }
+ }
+
+ async _delayTriggerLoad() {
+ await Promise.resolve();
+ for (const callback of this._loadCallbacks) {
+ callback();
+ }
+ }
+}
+
const vm = new VM({
chrome,
+ Image,
XMLHttpRequest,
indexedDB: global.indexedDB,
IDBKeyRange: global.IDBKeyRange,
@@ -106,6 +153,7 @@ vm.execute([
'bg/js/json-schema.js',
'bg/js/dictionary.js',
'mixed/js/core.js',
+ 'bg/js/media-utility.js',
'bg/js/request.js',
'bg/js/dictionary-importer.js',
'bg/js/database.js'
@@ -235,8 +283,8 @@ async function testDatabase1() {
true
);
vm.assert.deepStrictEqual(counts, {
- counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 12, tagMeta: 14}],
- total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 12, tagMeta: 14}
+ counts: [{kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14}],
+ total: {kanji: 2, kanjiMeta: 2, terms: 33, termMeta: 12, tagMeta: 14}
});
// Test find* functions
diff --git a/test/yomichan-test.js b/test/yomichan-test.js
index 3351ecdf..b4f5ac7c 100644
--- a/test/yomichan-test.js
+++ b/test/yomichan-test.js
@@ -38,12 +38,17 @@ function createTestDictionaryArchive(dictionary, dictionaryName) {
const archive = new (getJSZip())();
for (const fileName of fileNames) {
- const source = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'});
- const json = JSON.parse(source);
- if (fileName === 'index.json' && typeof dictionaryName === 'string') {
- json.title = dictionaryName;
+ if (/\.json$/.test(fileName)) {
+ const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'});
+ const json = JSON.parse(content);
+ if (fileName === 'index.json' && typeof dictionaryName === 'string') {
+ json.title = dictionaryName;
+ }
+ archive.file(fileName, JSON.stringify(json, null, 0));
+ } else {
+ const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: null});
+ archive.file(fileName, content);
}
- archive.file(fileName, JSON.stringify(json, null, 0));
}
return archive;