From a799bf8a5c14ec784a508edaedd8f394053c7f18 Mon Sep 17 00:00:00 2001 From: beerpsi <92439990+beerpiss@users.noreply.github.com> Date: Sat, 17 Feb 2024 13:36:59 +0700 Subject: [PATCH] Add SpeedBinb reader library (#1316) * Add SpeedBinb reader library * Make TextInterceptor generic --- lib/speedbinb/build.gradle.kts | 24 ++ .../kanade/tachiyomi/lib/speedbinb/Crypto.kt | 70 ++++ .../kanade/tachiyomi/lib/speedbinb/Models.kt | 102 ++++++ .../lib/speedbinb/SpeedBinbInterceptor.kt | 85 +++++ .../lib/speedbinb/SpeedBinbReader.kt | 197 ++++++++++++ .../descrambler/PtBinbDescrambler.kt | 301 ++++++++++++++++++ .../speedbinb/descrambler/PtImgDescrambler.kt | 13 + .../descrambler/SpeedBinbDescrambler.kt | 37 +++ .../lib/textinterceptor/TextInterceptor.kt | 94 +++--- .../extension/all/comicfury/ComicFury.kt | 6 +- .../extension/all/webtoons/WebtoonsSrc.kt | 2 +- .../extension/en/grrlpower/GrrlPower.kt | 2 +- .../QuestionableContent.kt | 2 +- .../extension/en/tapastic/Tapastic.kt | 2 +- 14 files changed, 885 insertions(+), 52 deletions(-) create mode 100644 lib/speedbinb/build.gradle.kts create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Crypto.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Models.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbInterceptor.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbReader.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtBinbDescrambler.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtImgDescrambler.kt create mode 100644 lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/SpeedBinbDescrambler.kt diff --git a/lib/speedbinb/build.gradle.kts b/lib/speedbinb/build.gradle.kts new file mode 100644 index 000000000..4a4078161 --- /dev/null +++ b/lib/speedbinb/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.library") + kotlin("android") + id("kotlinx-serialization") +} + +android { + compileSdk = AndroidConfig.compileSdk + + defaultConfig { + minSdk = AndroidConfig.minSdk + } + + namespace = "eu.kanade.tachiyomi.lib.speedbinb" +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(libs.bundles.common) + implementation(project(":lib:textinterceptor")) +} diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Crypto.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Crypto.kt new file mode 100644 index 000000000..740c3c07f --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Crypto.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.lib.speedbinb + +private const val URLSAFE_BASE64_LOOKUP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +internal fun determineKeyPair(src: String?, ptbl: List, ctbl: List): Pair { + val i = mutableListOf(0, 0) + + if (src != null) { + val filename = src.substringAfterLast("/") + + for (e in filename.indices) { + i[e % 2] = i[e % 2] + filename[e].code + } + + i[0] = i[0] % 8 + i[1] = i[1] % 8 + } + + return Pair(ptbl[i[0]], ctbl[i[1]]) +} + +internal fun decodeScrambleTable(cid: String, sharedKey: String, table: String): String { + val r = "$cid:$sharedKey" + var e = r.toCharArray() + .map { it.code } + .reduceIndexed { index, acc, i -> acc + (i shl index % 16) } and 2147483647 + + if (e == 0) { + e = 0x12345678 + } + + return buildString(table.length) { + for (s in table.indices) { + e = e ushr 1 xor (1210056708 and -(1 and e)) + append(((table[s].code - 32 + e) % 94 + 32).toChar()) + } + } +} + +internal fun generateSharedKey(cid: String): String { + val randomChars = randomChars(16) + val cidRepeatCount = (16 + cid.length - 1) / cid.length + val unk1 = buildString(cid.length * cidRepeatCount) { + for (i in 0 until cidRepeatCount) { + append(cid) + } + } + val unk2 = unk1.substring(0, 16) + val unk3 = unk1.substring(unk1.length - 16, unk1.length) + var s = 0 + var h = 0 + var u = 0 + + return buildString(randomChars.length * 2) { + for (i in randomChars.indices) { + s = s xor randomChars[i].code + h = h xor unk2[i].code + u = u xor unk3[i].code + + append(randomChars[i]) + append(URLSAFE_BASE64_LOOKUP[(s + h + u) and 63]) + } + } +} + +private fun randomChars(length: Int) = buildString(length) { + for (i in 0 until length) { + append(URLSAFE_BASE64_LOOKUP.random()) + } +} diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Models.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Models.kt new file mode 100644 index 000000000..2993ea430 --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/Models.kt @@ -0,0 +1,102 @@ +package eu.kanade.tachiyomi.lib.speedbinb + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +private val COORD_REGEX = Regex("""^i:(\d+),(\d+)\+(\d+),(\d+)>(\d+),(\d+)$""") + +@Serializable +class BibContentInfo( + val result: Int, + val items: List, +) + +@Serializable +class BibContentItem( + @SerialName("ContentID") val contentId: String, + @SerialName("ContentsServer") val contentServer: String, + @SerialName("ServerType") val serverType: Int, + val stbl: String, + val ttbl: String, + val ptbl: String, + val ctbl: String, + @SerialName("p") val requestToken: String? = null, + @SerialName("ViewMode") val viewMode: Int, + @SerialName("ContentDate") val contentDate: String? = null, + @SerialName("ShopURL") val shopUrl: String? = null, +) { + fun getSbcUrl(readerUrl: HttpUrl, cid: String) = + contentServer.toHttpUrl().newBuilder().apply { + when (serverType) { + ServerType.DIRECT -> addPathSegment("content.js") + ServerType.REST -> addPathSegment("content") + ServerType.SBC -> { + addPathSegment("sbcGetCntnt.php") + setQueryParameter("cid", cid) + requestToken?.let { setQueryParameter("p", it) } + setQueryParameter("q", "1") + setQueryParameter("vm", viewMode.toString()) + setQueryParameter("dmytime", contentDate ?: System.currentTimeMillis().toString()) + copyKeyParametersFrom(readerUrl) + } + else -> throw UnsupportedOperationException("Unsupported ServerType value $serverType") + } + }.toString() +} + +object ServerType { + const val SBC = 0 + const val DIRECT = 1 + const val REST = 2 +} + +object ViewMode { + const val COMMERCIAL = 1 + const val NON_MEMBER_TRIAL = 2 + const val MEMBER_TRIAL = 3 +} + +@Serializable +class PtImg( + @SerialName("ptimg-version") val ptImgVersion: Int, + val resources: PtImgResources, + val views: List, +) { + val translations by lazy { + views[0].coords.map { coord -> + val v = COORD_REGEX.matchEntire(coord)!!.groupValues.drop(1).map { it.toInt() } + PtImgTranslation(v[0], v[1], v[2], v[3], v[4], v[5]) + } + } +} + +@Serializable +class PtImgResources( + val i: PtImgImage, +) + +@Serializable +class PtImgImage( + val src: String, + val width: Int, + val height: Int, +) + +@Serializable +class PtImgViews( + val width: Int, + val height: Int, + val coords: Array, +) + +class PtImgTranslation(val xsrc: Int, val ysrc: Int, val width: Int, val height: Int, val xdest: Int, val ydest: Int) + +@Serializable +class SBCContent( + @SerialName("SBCVersion") val sbcVersion: String, + val result: Int, + val ttx: String, + @SerialName("ImageClass") val imageClass: String? = null, +) diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbInterceptor.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbInterceptor.kt new file mode 100644 index 000000000..b741db360 --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbInterceptor.kt @@ -0,0 +1,85 @@ +package eu.kanade.tachiyomi.lib.speedbinb + +import android.graphics.BitmapFactory +import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtBinbDescramblerA +import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtBinbDescramblerF +import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtImgDescrambler +import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor +import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.IOException + +class SpeedBinbInterceptor(private val json: Json) : Interceptor { + + private val textInterceptor by lazy { TextInterceptor() } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val host = request.url.host + val filename = request.url.pathSegments.last() + val fragment = request.url.fragment + + return when { + host == TextInterceptorHelper.HOST -> textInterceptor.intercept(chain) + filename.endsWith(".ptimg.json") -> interceptPtImg(chain, request) + fragment == null -> chain.proceed(request) + fragment.startsWith("ptbinb,") -> interceptPtBinB(chain, request) + else -> chain.proceed(request) + } + } + + private fun interceptPtImg(chain: Interceptor.Chain, request: Request): Response { + val response = chain.proceed(request) + val metadata = json.decodeFromString(response.body.string()) + val imageUrl = request.url.newBuilder() + .setPathSegment(request.url.pathSize - 1, metadata.resources.i.src) + .build() + val imageResponse = chain.proceed( + request.newBuilder().url(imageUrl).build(), + ) + + if (metadata.translations.isEmpty()) { + return imageResponse + } + + val image = BitmapFactory.decodeStream(imageResponse.body.byteStream()) + val descrambler = PtImgDescrambler(metadata) + return imageResponse.newBuilder() + .body(descrambler.descrambleImage(image)!!.toResponseBody(JPEG_MEDIA_TYPE)) + .build() + } + + private fun interceptPtBinB(chain: Interceptor.Chain, request: Request): Response { + val response = chain.proceed(request) + val fragment = request.url.fragment!! + val (s, u) = fragment.removePrefix("ptbinb,").split(",", limit = 2) + + if (s.isEmpty() && u.isEmpty()) { + return response + } + + val imageData = response.body.bytes() + val image = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) + val descrambler = if (s[0] == '=' && u[0] == '=') { + PtBinbDescramblerF(s, u, image.width, image.height) + } else if (NUMERIC_CHARACTERS.contains(s[0]) && NUMERIC_CHARACTERS.contains(u[0])) { + PtBinbDescramblerA(s, u, image.width, image.height) + } else { + throw IOException("Cannot select descrambler for key pair s=$s, u=$u") + } + val descrambled = descrambler.descrambleImage(image) ?: imageData + + return response.newBuilder() + .body(descrambled.toResponseBody(JPEG_MEDIA_TYPE)) + .build() + } +} + +private const val NUMERIC_CHARACTERS = "0123456789" +private val JPEG_MEDIA_TYPE = "image/jpeg".toMediaType() diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbReader.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbReader.kt new file mode 100644 index 000000000..64911624b --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/SpeedBinbReader.kt @@ -0,0 +1,197 @@ +package eu.kanade.tachiyomi.lib.speedbinb + +import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Response +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +/** + * SpeedBinb is a reader for various Japanese manga sites. + * + * Versions (`SpeedBinb.VERSION` in DevTools console): + * - Minimum version tested: `1.6650.0001` + * - Maximum version tested: `1.6930.1101` + * + * These versions are only for reference purposes, and does not reflect the actual range + * of versions this class can scrape. + */ +class SpeedBinbReader( + private val client: OkHttpClient, + private val headers: Headers, + private val json: Json, + private val highQualityMode: Boolean = false, +) { + private val isInterceptorAdded by lazy { + client.interceptors.filterIsInstance().isNotEmpty() + } + + fun pageListParse(response: Response): List = + pageListParse(response.asJsoup()) + + fun pageListParse(document: Document): List { + // We throw here instead of in the `init {}` block because extensions that fail + // to load just mysteriously disappears from the extension list, no errors no nothing. + if (!isInterceptorAdded) { + throw Exception("SpeedBinbInterceptor was not added to the client.") + } + + val readerUrl = document.location().toHttpUrl() + val content = document.selectFirst("#content")!! + + if (!content.hasAttr("data-ptbinb")) { + return content.select("[data-ptimg]").mapIndexed { i, it -> + Page(i, imageUrl = it.absUrl("data-ptimg")) + } + } + + val cid = content.attr("data-ptbinb-cid") + .ifEmpty { readerUrl.queryParameter("cid") } + ?: throw Exception("Could not find chapter ID") + val sharedKey = generateSharedKey(cid) + val contentInfoUrl = content.absUrl("data-ptbinb").toHttpUrl().newBuilder() + .copyKeyParametersFrom(readerUrl) + .setQueryParameter("cid", cid) + .setQueryParameter("k", sharedKey) + .setQueryParameter("dmytime", System.currentTimeMillis().toString()) + .build() + val contentInfo = client.newCall(GET(contentInfoUrl, headers)).execute().parseAs() + + if (contentInfo.result != 1) { + throw Exception("Failed to execute bibGetCntntInfo API.") + } + + if (contentInfo.items.isEmpty()) { + throw Exception("There is no item.") + } + + val contentItem = contentInfo.items[0] + val ctbl = json.decodeFromString>(decodeScrambleTable(cid, sharedKey, contentItem.ctbl)) + val ptbl = json.decodeFromString>(decodeScrambleTable(cid, sharedKey, contentItem.ptbl)) + val sbcUrl = contentItem.getSbcUrl(readerUrl, cid) + val sbcData = client.newCall(GET(sbcUrl, headers)).execute().body.string().let { + val raw = if (contentItem.serverType == ServerType.DIRECT) { + it.substringAfter("DataGet_Content(").substringBeforeLast(")") + } else { + it + } + + json.decodeFromString(raw) + } + + if (sbcData.result != 1) { + throw Exception("Failed to fetch content") + } + + val isSingleQuality = sbcData.imageClass == "singlequality" + val ttx = Jsoup.parseBodyFragment(sbcData.ttx, document.location()) + val pageBaseUrl = when (contentItem.serverType) { + ServerType.DIRECT, ServerType.REST -> contentItem.contentServer + ServerType.SBC -> sbcUrl.replaceFirst("/sbcGetCntnt.php", "/sbcGetImg.php") + else -> throw UnsupportedOperationException("Unsupported ServerType value ${contentItem.serverType}") + }.toHttpUrl() + val pages = ttx.select("t-case:first-of-type t-img").mapIndexed { i, it -> + val src = it.attr("src") + val keyPair = determineKeyPair(src, ptbl, ctbl) + val fragment = "ptbinb,${keyPair.first},${keyPair.second}" + val imageUrl = pageBaseUrl.newBuilder() + .buildImageUrl( + readerUrl, + src, + contentItem, + isSingleQuality, + highQualityMode, + ) + .fragment(fragment) + .toString() + + Page(i, imageUrl = imageUrl) + }.toMutableList() + + // This is probably the silliest use of TextInterceptor ever. + // + // If chapter purchases are enabled, and there's a link to purchase the current chapter, + // we add in the purchase URL as the last page. + val buyIconPosition = document.selectFirst("script:containsData(Config.LoginBuyIconPosition)") + ?.data() + ?.substringAfter("Config.LoginBuyIconPosition=") + ?.substringBefore(";") + ?.trim() + ?: "-1" + val enableBuying = buyIconPosition != "-1" + + if (enableBuying && contentItem.viewMode != ViewMode.COMMERCIAL && !contentItem.shopUrl.isNullOrEmpty()) { + pages.add( + Page(pages.size, imageUrl = TextInterceptorHelper.createUrl("", "購入: ${contentItem.shopUrl}")), + ) + } + + return pages + } + + private inline fun Response.parseAs(): T = + json.decodeFromString(body.string()) +} + +private fun HttpUrl.Builder.buildImageUrl( + readerUrl: HttpUrl, + src: String, + contentItem: BibContentItem, + isSingleQuality: Boolean, + highQualityMode: Boolean, +) = apply { + when (contentItem.serverType) { + ServerType.DIRECT -> { + val filename = when { + isSingleQuality -> "M.jpg" + highQualityMode -> "M_H.jpg" + else -> "M_L.jpg" + } + + addPathSegments(src) + addPathSegment(filename) + contentItem.contentDate?.let { setQueryParameter("dmytime", it) } + } + ServerType.REST -> { + addPathSegment("img") + addPathSegments(src) + if (!isSingleQuality && !highQualityMode) { + setQueryParameter("q", "1") + } + + contentItem.contentDate?.let { setQueryParameter("dmytime", it) } + copyKeyParametersFrom(readerUrl) + } + ServerType.SBC -> { + setQueryParameter("src", src) + contentItem.requestToken?.let { setQueryParameter("p", it) } + + if (!isSingleQuality) { + setQueryParameter("q", if (highQualityMode) "0" else "1") + } + + setQueryParameter("vm", contentItem.viewMode.toString()) + contentItem.contentDate?.let { setQueryParameter("dmytime", it) } + copyKeyParametersFrom(readerUrl) + } + else -> throw UnsupportedOperationException("Unsupported ServerType value ${contentItem.serverType}") + } +} + +internal fun HttpUrl.Builder.copyKeyParametersFrom(url: HttpUrl): HttpUrl.Builder { + for (i in 0..9) { + url.queryParameter("u$i")?.let { + setQueryParameter("u$i", it) + } + } + + return this +} diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtBinbDescrambler.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtBinbDescrambler.kt new file mode 100644 index 000000000..f5bfab38f --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtBinbDescrambler.kt @@ -0,0 +1,301 @@ +package eu.kanade.tachiyomi.lib.speedbinb.descrambler + +import eu.kanade.tachiyomi.lib.speedbinb.PtImgTranslation + +private val PTBINBF_REGEX = Regex("""^=([0-9]+)-([0-9]+)([-+])([0-9]+)-([-_0-9A-Za-z]+)$""") +private const val PTBINBF_CHAR_LOOKUP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" +private const val PTBINBA_CHAR_LOOKUP = "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ" + +abstract class PtBinbDescrambler( + val s: String, + val u: String, + val width: Int, + val height: Int, +) : SpeedBinbDescrambler() + +class PtBinbDescramblerF(s: String, u: String, width: Int, height: Int) : PtBinbDescrambler(s, u, width, height) { + + private var widthPieces: Int = 0 + private var heightPieces: Int = 0 + private var piecePadding: Int = 0 + private lateinit var hDstPosLookup: List + private lateinit var wDstPosLookup: List + private lateinit var hPosLookup: List + private lateinit var wPosLookup: List + private var pieceDest: List? = null + + init { + // Kotlin init blocks don't allow early returns... + init() + } + + private fun init() { + val srcData = PTBINBF_REGEX.matchEntire(s)?.groupValues + val dstData = PTBINBF_REGEX.matchEntire(u)?.groupValues + + if ( + dstData == null || + srcData == null || + dstData[1] != srcData[1] || + dstData[2] != srcData[2] || + dstData[4] != srcData[4] || + dstData[3] != "+" || + srcData[3] != "-" + ) { + return + } + + widthPieces = dstData[1].toInt() + heightPieces = dstData[2].toInt() + piecePadding = dstData[4].toInt() + + if (widthPieces < 8 || heightPieces < 8 || widthPieces * heightPieces < 64) { + return + } + + val e = widthPieces + heightPieces + widthPieces * heightPieces + + if (dstData[5].length != e || srcData[5].length != e) { + return + } + + val srcTnp = decodePieceData(srcData[5]) + val dstTnp = decodePieceData(dstData[5]) + + hDstPosLookup = dstTnp.hPos + wDstPosLookup = dstTnp.wPos + hPosLookup = srcTnp.hPos + wPosLookup = srcTnp.wPos + pieceDest = buildList(widthPieces * heightPieces) { + for (i in 0 until widthPieces * heightPieces) { + add(dstTnp.pieces[srcTnp.pieces[i]]) + } + } + } + + override fun isScrambled() = + pieceDest != null + + override fun canDescramble(): Boolean { + val i = 2 * widthPieces * piecePadding + val n = 2 * heightPieces * piecePadding + + return width >= 64 + i && height >= 64 + n && width * height >= (320 + i) * (320 + n) + } + + override fun getCanvasDimensions(): Pair { + return if (canDescramble()) { + Pair( + width - 2 * widthPieces * piecePadding, + height - 2 * heightPieces * piecePadding, + ) + } else { + Pair(width, height) + } + } + + override fun getDescrambleCoords(): List { + val pieceDest = this.pieceDest + + if (!isScrambled() || pieceDest == null) { + return emptyList() + } + + if (!canDescramble()) { + return listOf( + PtImgTranslation(0, 0, width, height, 0, 0), + ) + } + + val canvasWidth = width - 2 * widthPieces * piecePadding + val canvasHeight = height - 2 * heightPieces * piecePadding + val pieceWidth = (canvasWidth + widthPieces - 1).div(widthPieces) + val remainderWidth = canvasWidth - (widthPieces - 1) * pieceWidth + val pieceHeight = (canvasHeight + heightPieces - 1).div(heightPieces) + val remainderHeight = canvasHeight - (heightPieces - 1) * pieceHeight + + return buildList(widthPieces * heightPieces) { + for (o in 0 until widthPieces * heightPieces) { + val hPos = o % widthPieces + val wPos = o.div(widthPieces) + val hDstPos = pieceDest[o] % widthPieces + val wDstPos = pieceDest[o].div(widthPieces) + + add( + PtImgTranslation( + xsrc = piecePadding + hPos * (pieceWidth + 2 * piecePadding) + if (hPosLookup[wPos] < hPos) remainderWidth - pieceWidth else 0, + ysrc = piecePadding + wPos * (pieceHeight + 2 * piecePadding) + if (wPosLookup[hPos] < wPos) remainderHeight - pieceHeight else 0, + width = if (hPosLookup[wPos] == hPos) remainderWidth else pieceWidth, + height = if (wPosLookup[hPos] == wPos) remainderHeight else pieceHeight, + xdest = hDstPos * pieceWidth + if (hDstPosLookup[wDstPos] < hDstPos) remainderWidth - pieceWidth else 0, + ydest = wDstPos * pieceHeight + if (wDstPosLookup[hDstPos] < wDstPos) remainderHeight - pieceHeight else 0, + ), + ) + } + } + } + + private fun decodePieceData(key: String): TNP { + val wPos = buildList(widthPieces) { + for (i in 0 until widthPieces) { + add(PTBINBF_CHAR_LOOKUP.indexOf(key[i])) + } + } + val hPos = buildList(heightPieces) { + for (i in 0 until heightPieces) { + add(PTBINBF_CHAR_LOOKUP.indexOf(key[widthPieces + i])) + } + } + val pieces = buildList(widthPieces * heightPieces) { + for (i in 0 until widthPieces * heightPieces) { + add(PTBINBF_CHAR_LOOKUP.indexOf(key[widthPieces + heightPieces + i])) + } + } + + return TNP(wPos, hPos, pieces) + } + + private class TNP(val wPos: List, val hPos: List, val pieces: List) +} + +class PtBinbDescramblerA(s: String, u: String, width: Int, height: Int) : PtBinbDescrambler(s, u, width, height) { + + private var srcPieces: PieceCollection? = null + + private var dstPieces: PieceCollection? = null + + init { + val srcPieces = calculatePieces(u) + val dstPieces = calculatePieces(s) + + if ( + srcPieces != null && + dstPieces != null && + srcPieces.ndx == dstPieces.ndx && + srcPieces.ndy == dstPieces.ndy + ) { + this.srcPieces = srcPieces + this.dstPieces = dstPieces + } + } + + override fun isScrambled() = + srcPieces != null && dstPieces != null + + override fun canDescramble(): Boolean = + width >= 64 && height >= 64 && width * height >= 102400 + + override fun getCanvasDimensions(): Pair = + Pair(width, height) + + override fun getDescrambleCoords(): List { + if (!isScrambled()) { + return emptyList() + } + + if (!canDescramble()) { + return listOf( + PtImgTranslation(0, 0, width, height, 0, 0), + ) + } + + val srcPieces = this.srcPieces!! + val dstPieces = this.dstPieces!! + + return buildList(srcPieces.piece.size + 2) { + val n = width - width % 8 + val pieceWidth = (n - 1).div(7) - (n - 1).div(7) % 8 + val e = n - 7 * pieceWidth + val s = height - height % 8 + val pieceHeight = (s - 1).div(7) - (s - 1).div(7) % 8 + val u = s - 7 * pieceHeight + + for (i in srcPieces.piece.indices) { + val src = srcPieces.piece[i] + val dst = dstPieces.piece[i] + + add( + PtImgTranslation( + xsrc = src.x.div(2) * pieceWidth + src.x % 2 * e, + ysrc = src.y.div(2) * pieceHeight + src.y % 2 * u, + width = src.w.div(2) * pieceWidth + src.w % 2 * e, + height = src.h.div(2) * pieceHeight + src.h % 2 * u, + xdest = dst.x.div(2) * pieceWidth + dst.x % 2 * e, + ydest = dst.y.div(2) * pieceHeight + dst.y % 2 * u, + ), + ) + } + + val l = pieceWidth * (srcPieces.ndx - 1) + e + val v = pieceHeight * (srcPieces.ndy - 1) + u + + if (l < width) { + add( + PtImgTranslation(l, 0, width - l, v, l, 0), + ) + } + + if (v < height) { + add( + PtImgTranslation(0, v, width, height - v, 0, v), + ) + } + } + } + + private fun calculatePieces(key: String): PieceCollection? { + if (key.isEmpty()) { + return null + } + + val parts = key.split("-") + + if (parts.size != 3) { + return null + } + + val ndx = parts[0].toInt() + val ndy = parts[1].toInt() + val e = parts[2] + + if (ndx * ndy * 2 != e.length) { + return null + } + + val pieces = buildList(ndx * ndy) { + val a = (ndx - 1) * (ndy - 1) - 1 + val f = ndx - 1 + a + val c = ndy - 1 + f + val l = 1 + c + var w = 0 + var h = 0 + + for (d in 0 until ndx * ndy) { + val x = PTBINBA_CHAR_LOOKUP.indexOf(e[2 * d]) + val y = PTBINBA_CHAR_LOOKUP.indexOf(e[2 * d + 1]) + + if (d <= a) { + h = 2 + w = 2 + } else if (d <= f) { + h = 1 + w = 2 + } else if (d <= c) { + h = 2 + w = 1 + } else if (d <= l) { + h = 1 + w = 1 + } + + add(Piece(x, y, w, h)) + } + } + + return PieceCollection(ndx, ndy, pieces) + } + + private class Piece(val x: Int, val y: Int, val w: Int, val h: Int) + + private class PieceCollection(val ndx: Int, val ndy: Int, val piece: List) +} diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtImgDescrambler.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtImgDescrambler.kt new file mode 100644 index 000000000..82441ecdf --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/PtImgDescrambler.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.lib.speedbinb.descrambler + +import eu.kanade.tachiyomi.lib.speedbinb.PtImg + +class PtImgDescrambler(private val metadata: PtImg) : SpeedBinbDescrambler() { + override fun isScrambled() = metadata.translations.isNotEmpty() + + override fun canDescramble() = metadata.translations.isNotEmpty() + + override fun getCanvasDimensions() = Pair(metadata.views[0].width, metadata.views[0].height) + + override fun getDescrambleCoords() = metadata.translations +} diff --git a/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/SpeedBinbDescrambler.kt b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/SpeedBinbDescrambler.kt new file mode 100644 index 000000000..4a1017a5f --- /dev/null +++ b/lib/speedbinb/src/main/java/eu/kanade/tachiyomi/lib/speedbinb/descrambler/SpeedBinbDescrambler.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.lib.speedbinb.descrambler + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import eu.kanade.tachiyomi.lib.speedbinb.PtImgTranslation +import java.io.ByteArrayOutputStream + +abstract class SpeedBinbDescrambler { + abstract fun isScrambled(): Boolean + abstract fun canDescramble(): Boolean + abstract fun getCanvasDimensions(): Pair + abstract fun getDescrambleCoords(): List + + open fun descrambleImage(image: Bitmap): ByteArray? { + if (!isScrambled()) { + return null + } + + val (width, height) = getCanvasDimensions() + val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + + getDescrambleCoords().forEach { + val src = Rect(it.xsrc, it.ysrc, it.xsrc + it.width, it.ysrc + it.height) + val dst = Rect(it.xdest, it.ydest, it.xdest + it.width, it.ydest + it.height) + + canvas.drawBitmap(image, src, dst, null) + } + + return ByteArrayOutputStream() + .also { + result.compress(Bitmap.CompressFormat.JPEG, 90, it) + } + .toByteArray() + } +} diff --git a/lib/textinterceptor/src/main/java/eu/kanade/tachiyomi/lib/textinterceptor/TextInterceptor.kt b/lib/textinterceptor/src/main/java/eu/kanade/tachiyomi/lib/textinterceptor/TextInterceptor.kt index d1b6fd98f..270d72940 100644 --- a/lib/textinterceptor/src/main/java/eu/kanade/tachiyomi/lib/textinterceptor/TextInterceptor.kt +++ b/lib/textinterceptor/src/main/java/eu/kanade/tachiyomi/lib/textinterceptor/TextInterceptor.kt @@ -18,69 +18,73 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import java.io.ByteArrayOutputStream +// Designer values: +private const val WIDTH: Int = 1000 +private const val X_PADDING: Float = 50f +private const val Y_PADDING: Float = 25f +private const val HEADING_FONT_SIZE: Float = 36f +private const val BODY_FONT_SIZE: Float = 30f +private const val SPACING_MULT: Float = 1.1f +private const val SPACING_ADD: Float = 2f + +// No need to touch this one: +private const val HOST = TextInterceptorHelper.HOST + class TextInterceptor : Interceptor { // With help from: // https://github.com/tachiyomiorg/tachiyomi-extensions/pull/13304#issuecomment-1234532897 // https://medium.com/over-engineering/drawing-multiline-text-to-canvas-on-android-9b98f0bfa16a - - companion object { - // Designer values: - private const val WIDTH: Int = 1000 - private const val X_PADDING: Float = 50f - private const val Y_PADDING: Float = 25f - private const val HEADING_FONT_SIZE: Float = 36f - private const val BODY_FONT_SIZE: Float = 30f - private const val SPACING_MULT: Float = 1.1f - private const val SPACING_ADD: Float = 2f - - // No need to touch this one: - private const val HOST = TextInterceptorHelper.HOST - } - override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val url = request.url if (url.host != HOST) return chain.proceed(request) - val creator = textFixer("Author's Notes from ${url.pathSegments[0]}") - val story = textFixer(url.pathSegments[1]) + val heading = url.pathSegments[0].takeIf { it.isNotEmpty() }?.let { + val title = textFixer(url.pathSegments[0]) - // Heading - val paintHeading = TextPaint().apply { - color = Color.BLACK - textSize = HEADING_FONT_SIZE - typeface = Typeface.DEFAULT_BOLD - isAntiAlias = true + // Heading + val paintHeading = TextPaint().apply { + color = Color.BLACK + textSize = HEADING_FONT_SIZE + typeface = Typeface.DEFAULT_BOLD + isAntiAlias = true + } + + @Suppress("DEPRECATION") + StaticLayout( + title, paintHeading, (WIDTH - 2 * X_PADDING).toInt(), + Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true + ) } - @Suppress("DEPRECATION") - val heading = StaticLayout( - creator, paintHeading, (WIDTH - 2 * X_PADDING).toInt(), - Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true - ) + val body = url.pathSegments[1].takeIf { it.isNotEmpty() }?.let { + val story = textFixer(it) - // Body - val paintBody = TextPaint().apply { - color = Color.BLACK - textSize = BODY_FONT_SIZE - typeface = Typeface.DEFAULT - isAntiAlias = true + // Body + val paintBody = TextPaint().apply { + color = Color.BLACK + textSize = BODY_FONT_SIZE + typeface = Typeface.DEFAULT + isAntiAlias = true + } + + @Suppress("DEPRECATION") + StaticLayout( + story, paintBody, (WIDTH - 2 * X_PADDING).toInt(), + Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true + ) } - @Suppress("DEPRECATION") - val body = StaticLayout( - story, paintBody, (WIDTH - 2 * X_PADDING).toInt(), - Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true - ) - // Image building - val imgHeight: Int = (heading.height + body.height + 2 * Y_PADDING).toInt() + val headingHeight = heading?.height ?: 0 + val bodyHeight = body?.height ?: 0 + val imgHeight: Int = (headingHeight + bodyHeight + 2 * Y_PADDING).toInt() val bitmap: Bitmap = Bitmap.createBitmap(WIDTH, imgHeight, Bitmap.Config.ARGB_8888) Canvas(bitmap).apply { drawColor(Color.WHITE) - heading.draw(this, X_PADDING, Y_PADDING) - body.draw(this, X_PADDING, Y_PADDING + heading.height.toFloat()) + heading?.draw(this, X_PADDING, Y_PADDING) + body?.draw(this, X_PADDING, Y_PADDING + headingHeight.toFloat()) } // Image converting & returning @@ -119,7 +123,7 @@ object TextInterceptorHelper { const val HOST = "tachiyomi-lib-textinterceptor" - fun createUrl(creator: String, text: String): String { - return "http://$HOST/" + Uri.encode(creator) + "/" + Uri.encode(text) + fun createUrl(title: String, text: String): String { + return "http://$HOST/" + Uri.encode(title) + "/" + Uri.encode(text) } } diff --git a/src/all/comicfury/src/eu/kanade/tachiyomi/extension/all/comicfury/ComicFury.kt b/src/all/comicfury/src/eu/kanade/tachiyomi/extension/all/comicfury/ComicFury.kt index 19dc7b71c..c4aa89b29 100644 --- a/src/all/comicfury/src/eu/kanade/tachiyomi/extension/all/comicfury/ComicFury.kt +++ b/src/all/comicfury/src/eu/kanade/tachiyomi/extension/all/comicfury/ComicFury.kt @@ -129,9 +129,9 @@ class ComicFury( response.request.url.toString(), TextInterceptorHelper.createUrl( jsp.selectFirst("a.is--comment-author")?.ownText() - ?: "Error No Author For Comment Found", - jsp.selectFirst("div.is--comment-content")?.html() - ?: "Error No Comment Content Found", + ?.let { "Author's Notes from $it" } + .orEmpty(), + jsp.selectFirst("div.is--comment-content")?.html().orEmpty(), ), ), ) diff --git a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt index 0ae869e4e..6ecdf1309 100644 --- a/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt +++ b/src/all/webtoons/src/eu/kanade/tachiyomi/extension/all/webtoons/WebtoonsSrc.kt @@ -65,7 +65,7 @@ open class WebtoonsSrc( pages = pages + Page( pages.size, "", - TextInterceptorHelper.createUrl(creator, note), + TextInterceptorHelper.createUrl("Author's Notes from $creator", note), ) } } diff --git a/src/en/grrlpower/src/eu/kanade/tachiyomi/extension/en/grrlpower/GrrlPower.kt b/src/en/grrlpower/src/eu/kanade/tachiyomi/extension/en/grrlpower/GrrlPower.kt index eeb04b474..f87f52cc1 100644 --- a/src/en/grrlpower/src/eu/kanade/tachiyomi/extension/en/grrlpower/GrrlPower.kt +++ b/src/en/grrlpower/src/eu/kanade/tachiyomi/extension/en/grrlpower/GrrlPower.kt @@ -114,7 +114,7 @@ class GrrlPower( val text = soup.getElementsByClass("entry").html() if (text.isNotEmpty() && showAuthorsNotesPref()) { - pages.add(Page(1, "", TextInterceptorHelper.createUrl(comicAuthor, text))) + pages.add(Page(1, "", TextInterceptorHelper.createUrl("Author's Notes from $comicAuthor", text))) } return pages } diff --git a/src/en/questionablecontent/src/eu/kanade/tachiyomi/extension/en/questionablecontent/QuestionableContent.kt b/src/en/questionablecontent/src/eu/kanade/tachiyomi/extension/en/questionablecontent/QuestionableContent.kt index 7811333f5..30b92e50f 100644 --- a/src/en/questionablecontent/src/eu/kanade/tachiyomi/extension/en/questionablecontent/QuestionableContent.kt +++ b/src/en/questionablecontent/src/eu/kanade/tachiyomi/extension/en/questionablecontent/QuestionableContent.kt @@ -91,7 +91,7 @@ class QuestionableContent : ParsedHttpSource(), ConfigurableSource { if (showAuthorsNotesPref()) { val str = document.selectFirst("#newspost")?.html() if (!str.isNullOrEmpty()) { - pages.add(Page(pages.size, "", TextInterceptorHelper.createUrl(AUTHOR, str))) + pages.add(Page(pages.size, "", TextInterceptorHelper.createUrl("Author's Notes from $AUTHOR", str))) } } return pages diff --git a/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt index fb8c3e41b..8f31ee85b 100644 --- a/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt +++ b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt @@ -363,7 +363,7 @@ class Tapastic : ConfigurableSource, ParsedHttpSource() { pages = pages + Page( pages.size, "", - TextInterceptorHelper.createUrl(creator, episodeStory), + TextInterceptorHelper.createUrl("Author's Notes from $creator", episodeStory), ) } }