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 @@
+
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 .
+ */
+
+/**
+ * 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 @@
+
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 @@
+
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 @@
+ Image