diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json index 4da548ca8..5a52063b7 100644 --- a/komga-webui/src/locales/en.json +++ b/komga-webui/src/locales/en.json @@ -756,8 +756,10 @@ "epubreader": { "current_chapter": "Current chapter", "page_of": "Page {page} of {count}", + "publisher_font": "Publisher", "settings": { "column_count": "Column count", + "font_family": "Font", "layout": "Layout", "layout_paginated": "Paginated", "layout_scroll": "Scroll", diff --git a/komga-webui/src/main.ts b/komga-webui/src/main.ts index db46e1c79..7cd6c167d 100644 --- a/komga-webui/src/main.ts +++ b/komga-webui/src/main.ts @@ -32,6 +32,7 @@ import komgaHistory from './plugins/komga-history.plugin' import komgaAnnouncements from './plugins/komga-announcements.plugin' import komgaReleases from './plugins/komga-releases.plugin' import komgaSettings from './plugins/komga-settings.plugin' +import komgaFonts from './plugins/komga-fonts.plugin' import vuetify from './plugins/vuetify' import logger from './plugins/logger.plugin' import './public-path' @@ -80,6 +81,7 @@ Vue.use(komgaHistory, {http: Vue.prototype.$http}) Vue.use(komgaAnnouncements, {http: Vue.prototype.$http}) Vue.use(komgaReleases, {http: Vue.prototype.$http}) Vue.use(komgaSettings, {http: Vue.prototype.$http}) +Vue.use(komgaFonts, {http: Vue.prototype.$http}) Vue.config.productionTip = false diff --git a/komga-webui/src/plugins/komga-fonts.plugin.ts b/komga-webui/src/plugins/komga-fonts.plugin.ts new file mode 100644 index 000000000..b7a2a21af --- /dev/null +++ b/komga-webui/src/plugins/komga-fonts.plugin.ts @@ -0,0 +1,17 @@ +import {AxiosInstance} from 'axios' +import _Vue from 'vue' +import KomgaFontsService from '@/services/komga-fonts.service' + +export default { + install( + Vue: typeof _Vue, + {http}: { http: AxiosInstance }) { + Vue.prototype.$komgaFonts = new KomgaFontsService(http) + }, +} + +declare module 'vue/types/vue' { + interface Vue { + $komgaFonts: KomgaFontsService; + } +} diff --git a/komga-webui/src/services/komga-fonts.service.ts b/komga-webui/src/services/komga-fonts.service.ts new file mode 100644 index 000000000..b135e21c6 --- /dev/null +++ b/komga-webui/src/services/komga-fonts.service.ts @@ -0,0 +1,23 @@ +import {AxiosInstance} from 'axios' + +const API_FONTS = '/api/v1/fonts' + +export default class KomgaFontsService { + private http: AxiosInstance + + constructor(http: AxiosInstance) { + this.http = http + } + + async getFamilies(): Promise { + try { + return (await this.http.get(`${API_FONTS}/families`)).data + } catch (e) { + let msg = 'An error occurred while trying to retrieve font families' + if (e.response.data.message) { + msg += `: ${e.response.data.message}` + } + throw new Error(msg) + } + } +} diff --git a/komga-webui/src/views/EpubReader.vue b/komga-webui/src/views/EpubReader.vue index 9c81e1cb0..334f33514 100644 --- a/komga-webui/src/views/EpubReader.vue +++ b/komga-webui/src/views/EpubReader.vue @@ -213,6 +213,14 @@ {{ $t('bookreader.settings.display') }} + + + + {{ $t('epubreader.settings.viewing_theme') }} import Vue from 'vue' import D2Reader, {Locator} from '@d-i-t-a/reader' -import {bookManifestUrl, bookPositionsUrl} from '@/functions/urls' +import urls, {bookManifestUrl, bookPositionsUrl} from '@/functions/urls' import {BookDto} from '@/types/komga-books' import {getBookTitleCompact} from '@/functions/book-title' import {SeriesDto} from '@/types/komga-series' @@ -382,6 +390,12 @@ export default Vue.extend({ {text: this.$t('enums.epubreader.column_count.one').toString(), value: '1'}, {text: this.$t('enums.epubreader.column_count.two').toString(), value: '2'}, ], + fontFamilyDefault: [{ + text: this.$t('epubreader.publisher_font'), + value: 'Original', + }], + fontFamiliesAdditional: [] as string[], + fontFamilies: [] as any[], settings: { // R2D2BC appearance: 'readium-default-on', @@ -393,6 +407,7 @@ export default Vue.extend({ fixedLayoutMargin: 0, fixedLayoutShadow: false, direction: 'auto', + fontFamily: 'Original', // Epub Reader alwaysFullscreen: false, navigationClick: true, @@ -439,10 +454,13 @@ export default Vue.extend({ screenfull.exit() } }, - mounted() { + async mounted() { Object.assign(this.settings, this.$store.state.persistedState.epubreader) this.settings.alwaysFullscreen = this.$store.state.persistedState.webreader.alwaysFullscreen + this.fontFamiliesAdditional = await this.$komgaFonts.getFamilies() + this.fontFamilies = [...this.fontFamilyDefault, ...this.fontFamiliesAdditional] + this.setup(this.bookId) }, props: { @@ -611,6 +629,16 @@ export default Vue.extend({ this.$store.commit('setEpubreaderSettings', this.settings) }, }, + fontFamily: { + get: function (): string { + return this.settings.fontFamily ?? 'Original' + }, + set: function (value: string): void { + this.settings.fontFamily = value + this.d2Reader.applyUserSettings({fontFamily: value}) + this.$store.commit('setEpubreaderSettings', this.settings) + }, + }, }, methods: { previousBook() { @@ -721,6 +749,12 @@ export default Vue.extend({ // parse query params to get incognito mode this.incognito = !!(this.$route.query.incognito && this.$route.query.incognito.toString().toLowerCase() === 'true') + const fontFamiliesInjectables = this.fontFamiliesAdditional.map(x => ({ + type: 'style', + url: new URL(`${urls.origin}api/v1/fonts/resource/${x}/css`, import.meta.url).toString(), + fontFamily: x, + })) + this.d2Reader = await D2Reader.load({ url: new URL(bookManifestUrl(this.bookId)), userSettings: this.settings, @@ -747,6 +781,7 @@ export default Vue.extend({ {type: 'style', url: new URL('../styles/r2d2bc/popup.css.resource', import.meta.url).toString()}, {type: 'style', url: new URL('../styles/r2d2bc/popover.css.resource', import.meta.url).toString()}, {type: 'style', url: new URL('../styles/r2d2bc/style.css.resource', import.meta.url).toString()}, + ...fontFamiliesInjectables, ], requestConfig: { credentials: 'include', diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt index 5eaca93d7..54532e6e1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt @@ -47,6 +47,8 @@ class KomgaProperties { var kobo = Kobo() + val fonts = Fonts() + class Cors { var allowedOrigins: List = emptyList() } @@ -72,6 +74,11 @@ class KomgaProperties { var pragmas: Map = emptyMap() } + class Fonts { + @get:NotBlank + var dataDirectory: String = "" + } + class Lucene { @get:NotBlank var dataDirectory: String = "" diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index fb11c1da5..aacc82a89 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -90,6 +90,8 @@ class SecurityConfiguration( "/api/v1/oauth2/providers", // epub resources - fonts are always requested anonymously, so we check for authorization within the controller method directly "/api/v1/books/{bookId}/resource/**", + // dynamic fonts + "/api/v1/fonts/resource/**", // OPDS authentication document "/opds/v2/auth", // KOReader user creation diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt new file mode 100644 index 000000000..891f9a460 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/FontsController.kt @@ -0,0 +1,165 @@ +package org.gotson.komga.interfaces.api.rest + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.language.contains +import org.springframework.core.io.ByteArrayResource +import org.springframework.core.io.FileSystemResource +import org.springframework.core.io.Resource +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.isReadable +import kotlin.io.path.isRegularFile +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.toPath + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping(value = ["api/v1/fonts"], produces = [MediaType.APPLICATION_JSON_VALUE]) +class FontsController( + komgaProperties: KomgaProperties, +) { + private val supportedExtensions = listOf("woff", "woff2", "ttf", "otf") + private final val fonts: Map> + + init { + val resolver = PathMatchingResourcePatternResolver() + val fontsEmbedded = + try { + resolver + .getResources("/embeddedFonts/**/*.*") + .filterNot { it.filename == null } + .filter { supportedExtensions.contains(it.uri.toPath().extension, true) } + .groupBy { + it.uri + .toPath() + .parent.name + } + } catch (e: Exception) { + logger.error(e) { "Could not load embedded fonts" } + emptyMap() + } + + val fontsDir = Path(komgaProperties.fonts.dataDirectory) + val fontsAdditional = + try { + if (fontsDir.isDirectory() && fontsDir.isReadable()) { + fontsDir + .listDirectoryEntries() + .filter { it.isDirectory() } + .associate { dir -> + dir.name to + dir + .listDirectoryEntries() + .filter { it.isRegularFile() } + .filter { it.isReadable() } + .filter { supportedExtensions.contains(it.extension, true) } + .map { FileSystemResource(it) } + } + } else { + emptyMap() + } + } catch (e: Exception) { + logger.error(e) { "Could not load additional fonts" } + emptyMap() + } + + fonts = fontsEmbedded + fontsAdditional + + logger.info { "Fonts embedded: $fontsEmbedded" } + logger.info { "Fonts discovered: $fontsAdditional" } + } + + @GetMapping("families") + fun listFonts(): Set = fonts.keys + + @GetMapping("resource/{fontFamily}/{fontFile}") + fun getFontFile( + @PathVariable fontFamily: String, + @PathVariable fontFile: String, + ): ResponseEntity { + fonts[fontFamily]?.let { resources -> + val resource = resources.firstOrNull { it.uri.toPath().name == fontFile } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + return ResponseEntity + .ok() + .headers { + it.contentDisposition = + ContentDisposition + .attachment() + .filename(fontFile) + .build() + }.contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping("resource/{fontFamily}/css", produces = ["text/css"]) + fun getFontFamilyAsCss( + @PathVariable fontFamily: String, + ): ResponseEntity { + fonts[fontFamily]?.let { files -> + val groups = files.groupBy { getFontCharacteristics(it.uri.toPath().name) } + + val css = + groups + .map { (styleWeight, resources) -> buildFontFaceBlock(fontFamily, styleWeight, resources) } + .joinToString(separator = "\n") + + return ResponseEntity + .ok() + .headers { + it.contentDisposition = + ContentDisposition + .attachment() + .filename("$fontFamily.css") + .build() + }.body(ByteArrayResource(css.toByteArray())) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + private fun buildFontFaceBlock( + fontFamily: String, + styleAndWeight: FontCharacteristics, + fonts: List, + ): String { + val srcBlock = + fonts.joinToString(separator = ",", postfix = ";") { resource -> + val path = resource.uri.toPath() + """url('${path.name}') format('${path.extension}')""" + } + // language=CSS + return """ + @font-face { + font-family: '$fontFamily'; + src: $srcBlock + font-weight: ${styleAndWeight.weight}; + font-style: ${styleAndWeight.style}; + } + + """.trimIndent() + } + + private fun getFontCharacteristics(filename: String): FontCharacteristics { + val style = if (filename.contains("italic", true)) "italic" else "normal" + val weight = if (filename.contains("bold", true)) "bold" else "normal" + return FontCharacteristics(style, weight) + } + + private data class FontCharacteristics( + val style: String, + val weight: String, + ) +} diff --git a/komga/src/main/resources/application.yml b/komga/src/main/resources/application.yml index 6b3cf1fb9..9846a9f40 100644 --- a/komga/src/main/resources/application.yml +++ b/komga/src/main/resources/application.yml @@ -19,6 +19,8 @@ komga: file: \${komga.config-dir}/database.sqlite lucene: data-directory: \${komga.config-dir}/lucene + fonts: + data-directory: \${komga.config-dir}/fonts config-dir: \${user.home}/.komga tasks-db: file: \${komga.config-dir}/tasks.sqlite diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff new file mode 100755 index 000000000..3948189b4 Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 new file mode 100755 index 000000000..aa7bcdea9 Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold-Italic.woff2 differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff new file mode 100755 index 000000000..41886ae9d Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 new file mode 100755 index 000000000..2f04ad119 Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Bold.woff2 differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff new file mode 100755 index 000000000..a23797c40 Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 new file mode 100755 index 000000000..00c19082d Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Italic.woff2 differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff new file mode 100755 index 000000000..26a2934d6 Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff differ diff --git a/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 new file mode 100755 index 000000000..47e26d82a Binary files /dev/null and b/komga/src/main/resources/embeddedFonts/OpenDyslexic/OpenDyslexic-Regular.woff2 differ