Skip to content

Commit

Permalink
Snowmtl: Adds support for multiple languages (#6563)
Browse files Browse the repository at this point in the history
* Move to src/all

* Optimize translation

* Fix image loading timeout and expired translator token

* Fix extension initialization

* Fix translator response
  • Loading branch information
choppeh authored Dec 17, 2024
1 parent 59a3d20 commit beb0829
Show file tree
Hide file tree
Showing 18 changed files with 378 additions and 57 deletions.
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions src/en/snowmtl/build.gradle → src/all/snowmtl/build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
ext {
extName = 'Snow Machine Translations'
extClass = '.Snowmtl'
extVersionCode = 3
extClass = '.SnowmltFactory'
extVersionCode = 4
isNsfw = true
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.extension.all.snowmtl

import android.os.Build
import androidx.annotation.RequiresApi
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory

@RequiresApi(Build.VERSION_CODES.O)
class SnowmltFactory : SourceFactory {
override fun createSources(): List<Source> = languageList.map(::Snowmtl)
}

data class Source(val lang: String, val target: String = lang, val origin: String = "en")

private val languageList = listOf(
Source("en"),
Source("es"),
Source("id"),
Source("it"),
Source("pt-BR", "pt"),
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl

import eu.kanade.tachiyomi.source.model.Filter

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl

import android.os.Build
import androidx.annotation.RequiresApi
import eu.kanade.tachiyomi.extension.all.snowmtl.interceptors.ComposedImageInterceptor
import eu.kanade.tachiyomi.extension.all.snowmtl.interceptors.TranslationInterceptor
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.BingTranslator
import eu.kanade.tachiyomi.extension.all.snowmtl.translator.TranslatorEngine
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.model.Filter
Expand All @@ -23,22 +27,33 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit

@RequiresApi(Build.VERSION_CODES.M)
class Snowmtl : ParsedHttpSource() {
@RequiresApi(Build.VERSION_CODES.O)
class Snowmtl(
source: Source,
) : ParsedHttpSource() {

override val name = "Snow Machine Translations"

override val baseUrl = "https://snowmtl.ru"

override val lang = "en"
override val lang = source.lang

override val supportsLatest = true

private val json: Json by injectLazy()

private val translatorClient = network.cloudflareClient.newBuilder()
.rateLimit(1, 3, TimeUnit.SECONDS)
.build()

private val translator: TranslatorEngine = BingTranslator(translatorClient, headers)

override val client = network.cloudflareClient.newBuilder()
.rateLimit(2)
.readTimeout(2, TimeUnit.MINUTES)
.addInterceptor(TranslationInterceptor(source, translator))
.addInterceptor(ComposedImageInterceptor(baseUrl, super.client))
.build()

Expand Down Expand Up @@ -158,7 +173,9 @@ class Snowmtl : ParsedHttpSource() {
dto.imageUrl.startsWith("http") -> dto.imageUrl
else -> "https://${dto.imageUrl}"
}
val fragment = json.encodeToString<List<Translation>>(dto.translations)
val fragment = json.encodeToString<List<Dialog>>(
dto.dialogues.filter { it.text.isNotBlank() },
)
Page(index, imageUrl = "$imageUrl#$fragment")
}
}
Expand Down Expand Up @@ -203,6 +220,7 @@ class Snowmtl : ParsedHttpSource() {
}

companion object {
val PAGE_REGEX = Regex(".*?\\.(webp|png|jpg|jpeg)#\\[.*?]", RegexOption.IGNORE_CASE)
const val PREFIX_SEARCH = "id:"
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.US)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl

import android.graphics.Color
import android.os.Build
Expand All @@ -18,13 +18,15 @@ import kotlinx.serialization.json.put
class PageDto(
@SerialName("img_url")
val imageUrl: String,
@Serializable(with = TranslationsListSerializer::class)
val translations: List<Translation> = emptyList(),

@SerialName("translations")
@Serializable(with = DialogListSerializer::class)
val dialogues: List<Dialog> = emptyList(),
)

@Serializable
@RequiresApi(Build.VERSION_CODES.O)
class Translation(
data class Dialog(
val x1: Float,
val y1: Float,
val x2: Float,
Expand Down Expand Up @@ -55,12 +57,12 @@ class Translation(
}
}

private object TranslationsListSerializer :
JsonTransformingSerializer<List<Translation>>(ListSerializer(Translation.serializer())) {
private object DialogListSerializer :
JsonTransformingSerializer<List<Dialog>>(ListSerializer(Dialog.serializer())) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return JsonArray(
element.jsonArray.map { jsonElement ->
val (coordinates, text) = getCoordinatesAndCaption(jsonElement)
val (coordinates, text) = getCoordinatesAndDialog(jsonElement)

buildJsonObject {
put("x1", coordinates[0])
Expand All @@ -83,7 +85,7 @@ private object TranslationsListSerializer :
)
}

private fun getCoordinatesAndCaption(element: JsonElement): Pair<JsonArray, JsonElement> {
private fun getCoordinatesAndDialog(element: JsonElement): Pair<JsonArray, JsonElement> {
return try {
val arr = element.jsonArray
arr[0].jsonArray to arr[1]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl

import android.app.Activity
import android.content.ActivityNotFoundException
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.en.snowmtl
package eu.kanade.tachiyomi.extension.all.snowmtl.interceptors

import android.graphics.Bitmap
import android.graphics.BitmapFactory
Expand All @@ -12,6 +12,8 @@ import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.annotation.RequiresApi
import eu.kanade.tachiyomi.extension.all.snowmtl.Dialog
import eu.kanade.tachiyomi.extension.all.snowmtl.Snowmtl.Companion.PAGE_REGEX
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
Expand All @@ -29,7 +31,7 @@ import java.io.InputStream
import kotlin.math.pow
import kotlin.math.sqrt

// The Interceptor joins the captions and pages of the manga.
// The Interceptor joins the dialogues and pages of the manga.
@RequiresApi(Build.VERSION_CODES.O)
class ComposedImageInterceptor(
baseUrl: String,
Expand All @@ -44,22 +46,16 @@ class ComposedImageInterceptor(
"normal" to Pair<String, Typeface?>("$baseUrl/images/normal.ttf", null),
)

private val imageRegex = Regex(
"$baseUrl.*?\\.(webp|png|jpg|jpeg)#\\[.*?]",
RegexOption.IGNORE_CASE,
)

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url.toString()

val isPageImageUrl = imageRegex.containsMatchIn(url)
if (isPageImageUrl.not()) {
if (PAGE_REGEX.containsMatchIn(url).not()) {
return chain.proceed(request)
}

val translation = request.url.fragment?.parseAs<List<Translation>>()
?: throw IOException("Translation not found")
val dialogues = request.url.fragment?.parseAs<List<Dialog>>()
?: throw IOException("Dialogues not found")

val imageRequest = request.newBuilder()
.url(url)
Expand All @@ -78,14 +74,12 @@ class ComposedImageInterceptor(

val canvas = Canvas(bitmap)

translation
.filter { it.text.isNotBlank() }
.forEach { caption ->
val textPaint = createTextPaint(selectFontFamily(caption.type))
val dialogBox = createDialogBox(caption, textPaint, bitmap)
val y = getYAxis(textPaint, caption, dialogBox)
canvas.draw(dialogBox, caption, caption.x1, y)
}
dialogues.forEach { dialog ->
val textPaint = createTextPaint(selectFontFamily(dialog.type))
val dialogBox = createDialogBox(dialog, textPaint, bitmap)
val y = getYAxis(textPaint, dialog, dialogBox)
canvas.draw(dialogBox, dialog, dialog.x1, y)
}

val output = ByteArrayOutputStream()

Expand Down Expand Up @@ -189,49 +183,49 @@ class ComposedImageInterceptor(
/**
* Adjust the text to the center of the dialog box when feasible.
*/
private fun getYAxis(textPaint: TextPaint, caption: Translation, dialogBox: StaticLayout): Float {
private fun getYAxis(textPaint: TextPaint, dialog: Dialog, dialogBox: StaticLayout): Float {
val fontHeight = textPaint.fontMetrics.let { it.bottom - it.top }

val dialogBoxLineCount = caption.height / fontHeight
val dialogBoxLineCount = dialog.height / fontHeight

/**
* Centers text in y for captions smaller than the dialog box
* Centers text in y for dialogues smaller than the dialog box
*/
return when {
dialogBox.lineCount < dialogBoxLineCount -> caption.centerY - dialogBox.lineCount / 2f * fontHeight
else -> caption.y1
dialogBox.lineCount < dialogBoxLineCount -> dialog.centerY - dialogBox.lineCount / 2f * fontHeight
else -> dialog.y1
}
}

private fun createDialogBox(caption: Translation, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
var dialogBox = createBoxLayout(caption, textPaint)
private fun createDialogBox(dialog: Dialog, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
var dialogBox = createBoxLayout(dialog, textPaint)

/**
* The best way I've found to adjust the text in the dialog box (Especially in long dialogues)
*/
while (dialogBox.height > caption.height) {
while (dialogBox.height > dialog.height) {
textPaint.textSize -= 0.5f
dialogBox = createBoxLayout(caption, textPaint)
dialogBox = createBoxLayout(dialog, textPaint)
}

// Use source setup
if (caption.isNewApi) {
textPaint.color = caption.foregroundColor
textPaint.bgColor = caption.backgroundColor
textPaint.style = if (caption.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
if (dialog.isNewApi) {
textPaint.color = dialog.foregroundColor
textPaint.bgColor = dialog.backgroundColor
textPaint.style = if (dialog.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
}

/**
* Forces font color correction if the background color of the dialog box and the font color are too similar.
* It's a source configuration problem.
*/
textPaint.adjustTextColor(caption, bitmap)
textPaint.adjustTextColor(dialog, bitmap)

return dialogBox
}

private fun createBoxLayout(caption: Translation, textPaint: TextPaint) =
StaticLayout.Builder.obtain(caption.text, 0, caption.text.length, textPaint, caption.width.toInt()).apply {
private fun createBoxLayout(dialog: Dialog, textPaint: TextPaint) =
StaticLayout.Builder.obtain(dialog.text, 0, dialog.text.length, textPaint, dialog.width.toInt()).apply {
setAlignment(Layout.Alignment.ALIGN_CENTER)
setIncludePad(false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Expand All @@ -240,12 +234,12 @@ class ComposedImageInterceptor(
}.build()

// Invert color in black dialog box.
private fun TextPaint.adjustTextColor(caption: Translation, bitmap: Bitmap) {
val pixelColor = bitmap.getPixel(caption.centerX.toInt(), caption.centerY.toInt())
private fun TextPaint.adjustTextColor(dialog: Dialog, bitmap: Bitmap) {
val pixelColor = bitmap.getPixel(dialog.centerX.toInt(), dialog.centerY.toInt())
val inverseColor = (Color.WHITE - pixelColor) or Color.BLACK

val minDistance = 80f // arbitrary
if (colorDistance(pixelColor, caption.foregroundColor) > minDistance) {
if (colorDistance(pixelColor, dialog.foregroundColor) > minDistance) {
return
}
color = inverseColor
Expand All @@ -255,10 +249,10 @@ class ComposedImageInterceptor(
return json.decodeFromString(this)
}

private fun Canvas.draw(layout: StaticLayout, caption: Translation, x: Float, y: Float) {
private fun Canvas.draw(layout: StaticLayout, dialog: Dialog, x: Float, y: Float) {
save()
translate(x, y)
rotate(caption.angle)
rotate(dialog.angle)
layout.draw(this)
restore()
}
Expand Down
Loading

0 comments on commit beb0829

Please sign in to comment.