Skip to content

Commit

Permalink
feat(webui): support additional fonts
Browse files Browse the repository at this point in the history
added embedded font OpenDyslexic
additional fonts can be added in the configuration directory under ./fonts/{fontFamily}/
supported files are woff/woff2/ttf/otf

Closes: #1836
  • Loading branch information
gotson committed Jan 24, 2025
1 parent 42047cd commit 201c066
Show file tree
Hide file tree
Showing 17 changed files with 257 additions and 2 deletions.
2 changes: 2 additions & 0 deletions komga-webui/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions komga-webui/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions komga-webui/src/plugins/komga-fonts.plugin.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions komga-webui/src/services/komga-fonts.service.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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)
}
}
}
39 changes: 37 additions & 2 deletions komga-webui/src/views/EpubReader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@

<v-subheader class="font-weight-black text-h6">{{ $t('bookreader.settings.display') }}</v-subheader>

<v-list-item v-if="fontFamilies.length > 1">
<settings-select
:items="fontFamilies"
v-model="fontFamily"
:label="$t('epubreader.settings.font_family')"
/>
</v-list-item>

<v-list-item>
<v-list-item-title>{{ $t('epubreader.settings.viewing_theme') }}</v-list-item-title>
<v-btn
Expand Down Expand Up @@ -299,7 +307,7 @@
<script lang="ts">
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'
Expand Down Expand Up @@ -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',
Expand All @@ -393,6 +407,7 @@ export default Vue.extend({
fixedLayoutMargin: 0,
fixedLayoutShadow: false,
direction: 'auto',
fontFamily: 'Original',
// Epub Reader
alwaysFullscreen: false,
navigationClick: true,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class KomgaProperties {

var kobo = Kobo()

val fonts = Fonts()

class Cors {
var allowedOrigins: List<String> = emptyList()
}
Expand All @@ -72,6 +74,11 @@ class KomgaProperties {
var pragmas: Map<String, String> = emptyMap()
}

class Fonts {
@get:NotBlank
var dataDirectory: String = ""
}

class Lucene {
@get:NotBlank
var dataDirectory: String = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, List<Resource>>

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<String> = fonts.keys

@GetMapping("resource/{fontFamily}/{fontFile}")
fun getFontFile(
@PathVariable fontFamily: String,
@PathVariable fontFile: String,
): ResponseEntity<Resource> {
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<Resource> {
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<Resource>,
): 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,
)
}
2 changes: 2 additions & 0 deletions komga/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 comments on commit 201c066

Please sign in to comment.