Fix Webtoons & Tapas Author's Notes (#13304)

* Bump Webtoons extension version

* Fix author's note API

* Bump Tapas extension version

* Fix author's note API

* Tapas: Render author's note image locally

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>

* Tapas: Add new dependency to gradle

* Tapas: revert dependency addition 94473a2

* Tapas: StaticLayout.draw without dependency

* Webtoons: Render author's note image locally

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
This commit is contained in:
altaccosc 2022-09-02 20:47:49 +02:00 committed by GitHub
parent 8f0a4c8903
commit 356849909c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 229 additions and 65 deletions

View File

@ -2,7 +2,14 @@ package eu.kanade.tachiyomi.multisrc.webtoons
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
@ -25,20 +32,24 @@ import okhttp3.CookieJar
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.ceil
open class Webtoons( open class Webtoons(
override val name: String, override val name: String,
@ -71,6 +82,7 @@ open class Webtoons(
} }
} }
) )
.addInterceptor(TextInterceptor)
.build() .build()
private val day: String private val day: String
@ -291,32 +303,6 @@ open class Webtoons(
override fun chapterListRequest(manga: SManga) = GET("https://m.webtoons.com" + manga.url, mobileHeaders) override fun chapterListRequest(manga: SManga) = GET("https://m.webtoons.com" + manga.url, mobileHeaders)
private fun wordwrap(t: String, lineWidth: Int) = buildString {
// TODO: Split off into library file or something, because Tapastic is using the exact same wordwrap and toImage functions
// src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt
val text = t.replace("\n", "\n ")
var charCount = 0
text.split(" ").forEach { w ->
if (w.contains("\n")) {
charCount = 0
}
if (charCount > lineWidth) {
append("\n")
charCount = 0
}
append("$w ")
charCount += w.length + 1
}
}
private fun toImage(t: String, fontSize: Int, bold: Boolean = false): String {
val text = wordwrap(t.replace("&amp;", "&").replace("\\s*<br>\\s*".toRegex(), "\n"), 65)
val imgHeight = (text.lines().size + 2) * fontSize * 1.3
return "https://placehold.jp/" + fontSize + "/ffffff/000000/1500x" + ceil(imgHeight).toInt() + ".png?" +
"css=%7B%22text-align%22%3A%22%20left%22%2C%22padding-left%22%3A%22%203%25%22" + (if (bold) "%2C%22font-weight%22%3A%22%20600%22" else "") + "%7D&" +
"text=" + Uri.encode(text)
}
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
var pages = document.select("div#_imageList > img").mapIndexed { i, element -> Page(i, "", element.attr("data-url")) } var pages = document.select("div#_imageList > img").mapIndexed { i, element -> Page(i, "", element.attr("data-url")) }
@ -324,13 +310,13 @@ open class Webtoons(
val note = document.select("div.comment_area div.info_area p").text() val note = document.select("div.comment_area div.info_area p").text()
if (note.isNotEmpty()) { if (note.isNotEmpty()) {
val noteImage = toImage(note, 42)
val creator = document.select("div.creator_note span.author a").text().trim() val creator = document.select("div.creator_note span.author a").text().trim()
val creatorImage = toImage("Author's Notes from $creator", 43, true)
pages = pages + Page(pages.size, "", creatorImage) pages = pages + Page(
pages = pages + Page(pages.size, "", noteImage) pages.size, "",
"http://note/" + Uri.encode(creator) + "/" + Uri.encode(note)
)
} }
} }
@ -359,4 +345,100 @@ open class Webtoons(
const val URL_SEARCH_PREFIX = "url:" const val URL_SEARCH_PREFIX = "url:"
private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes" private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes"
} }
// TODO: Split off into library file or something, because Webtoons is using the exact same TextInterceptor
// src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt
object 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
// 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 = "note"
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])
// Heading
val paintHeading = TextPaint().apply {
color = Color.BLACK
textSize = HEADING_FONT_SIZE
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
}
@Suppress("DEPRECATION")
val heading: StaticLayout = StaticLayout(
creator, paintHeading, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
// Body
val paintBody = TextPaint().apply {
color = Color.BLACK
textSize = BODY_FONT_SIZE
typeface = Typeface.DEFAULT
isAntiAlias = true
}
@Suppress("DEPRECATION")
val body: StaticLayout = 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 bitmap: Bitmap = Bitmap.createBitmap(WIDTH, imgHeight, Bitmap.Config.ARGB_8888)
val canvas: Canvas = Canvas(bitmap)
// Image drawing
canvas.drawColor(Color.WHITE)
heading.draw(canvas, X_PADDING, Y_PADDING)
body.draw(canvas, X_PADDING, Y_PADDING + heading.height.toFloat())
// Image converting & returning
val stream: ByteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
val responseBody = stream.toByteArray().toResponseBody("image/png".toMediaType())
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body(responseBody)
.build()
}
private fun textFixer(t: String): String {
return t
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&quot;", "\"")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("\\s*<br>\\s*".toRegex(), "\n")
}
private fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
canvas.save()
canvas.translate(x, y)
this.draw(canvas)
canvas.restore()
}
}
} }

View File

@ -13,7 +13,7 @@ class WebtoonsGenerator : ThemeSourceGenerator {
override val baseVersionCode: Int = 2 override val baseVersionCode: Int = 2
override val sources = listOf( override val sources = listOf(
MultiLang("Webtoons.com", "https://www.webtoons.com", listOf("en", "fr", "es", "id", "th", "zh-Hant", "de"), className = "WebtoonsFactory", pkgName = "webtoons", overrideVersionCode = 33), MultiLang("Webtoons.com", "https://www.webtoons.com", listOf("en", "fr", "es", "id", "th", "zh-Hant", "de"), className = "WebtoonsFactory", pkgName = "webtoons", overrideVersionCode = 34),
SingleLang("Dongman Manhua", "https://www.dongmanmanhua.cn", "zh") SingleLang("Dongman Manhua", "https://www.dongmanmanhua.cn", "zh")
) )

View File

@ -6,7 +6,7 @@ ext {
extName = 'Tapas' extName = 'Tapas'
pkgNameSuffix = 'en.tapastic' pkgNameSuffix = 'en.tapastic'
extClass = '.Tapastic' extClass = '.Tapastic'
extVersionCode = 16 extVersionCode = 17
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -2,7 +2,15 @@ package eu.kanade.tachiyomi.extension.en.tapastic
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
@ -25,18 +33,22 @@ import okhttp3.CookieJar
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import kotlin.math.ceil
class Tapastic : ConfigurableSource, ParsedHttpSource() { class Tapastic : ConfigurableSource, ParsedHttpSource() {
@ -96,6 +108,7 @@ class Tapastic : ConfigurableSource, ParsedHttpSource() {
} }
} }
) )
.addInterceptor(TextInterceptor)
.build() .build()
private val preferences: SharedPreferences by lazy { private val preferences: SharedPreferences by lazy {
@ -339,32 +352,6 @@ class Tapastic : ConfigurableSource, ParsedHttpSource() {
// Pages // Pages
// TODO: Split off into library file or something, because Webtoons is using the exact same wordwrap and toImage functions
// multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt
private fun wordwrap(t: String, lineWidth: Int) = buildString {
val text = t.replace("\n", "\n ")
var charCount = 0
text.split(" ").forEach { w ->
if (w.contains("\n")) {
charCount = 0
}
if (charCount > lineWidth) {
append("\n")
charCount = 0
}
append("$w ")
charCount += w.length + 1
}
}
private fun toImage(t: String, fontSize: Int, bold: Boolean = false): String {
val text = wordwrap(t.replace("&amp;", "&").replace("\\s*<br>\\s*".toRegex(), "\n"), 65)
val imgHeight = (text.lines().size + 2) * fontSize * 1.3
return "https://placehold.jp/" + fontSize + "/ffffff/000000/1500x" + ceil(imgHeight).toInt() + ".png?" +
"css=%7B%22text-align%22%3A%22%20left%22%2C%22padding-left%22%3A%22%203%25%22" + (if (bold) "%2C%22font-weight%22%3A%22%20600%22" else "") + "%7D&" +
"text=" + Uri.encode(text)
}
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
var pages = document.select("img.content__img").mapIndexed { i, img -> var pages = document.select("img.content__img").mapIndexed { i, img ->
Page(i, "", img.let { if (it.hasAttr("data-src")) it.attr("abs:data-src") else it.attr("abs:src") }) Page(i, "", img.let { if (it.hasAttr("data-src")) it.attr("abs:data-src") else it.attr("abs:src") })
@ -374,13 +361,12 @@ class Tapastic : ConfigurableSource, ParsedHttpSource() {
val episodeStory = document.select("p.js-episode-story").html() val episodeStory = document.select("p.js-episode-story").html()
if (episodeStory.isNotEmpty()) { if (episodeStory.isNotEmpty()) {
val storyImage = toImage(episodeStory, 42)
val creator = document.select("a.name.js-fb-tracking")[0].text() val creator = document.select("a.name.js-fb-tracking")[0].text()
val creatorImage = toImage("Author's Notes from $creator", 43, true)
pages = pages + Page(pages.size, "", creatorImage) pages = pages + Page(
pages = pages + Page(pages.size, "", storyImage) pages.size, "",
"http://note/" + Uri.encode(creator) + "/" + Uri.encode(episodeStory)
)
} }
} }
return pages return pages
@ -534,4 +520,100 @@ class Tapastic : ConfigurableSource, ParsedHttpSource() {
private const val SHOW_LOCK_PREF_KEY = "showChapterLock" private const val SHOW_LOCK_PREF_KEY = "showChapterLock"
private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes" private const val SHOW_AUTHORS_NOTES_KEY = "showAuthorsNotes"
} }
// TODO: Split off into library file or something, because Webtoons is using the exact same TextInterceptor
// multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/webtoons/Webtoons.kt
object 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
// 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 = "note"
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])
// Heading
val paintHeading = TextPaint().apply {
color = Color.BLACK
textSize = HEADING_FONT_SIZE
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
}
@Suppress("DEPRECATION")
val heading: StaticLayout = StaticLayout(
creator, paintHeading, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
// Body
val paintBody = TextPaint().apply {
color = Color.BLACK
textSize = BODY_FONT_SIZE
typeface = Typeface.DEFAULT
isAntiAlias = true
}
@Suppress("DEPRECATION")
val body: StaticLayout = 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 bitmap: Bitmap = Bitmap.createBitmap(WIDTH, imgHeight, Bitmap.Config.ARGB_8888)
val canvas: Canvas = Canvas(bitmap)
// Image drawing
canvas.drawColor(Color.WHITE)
heading.draw(canvas, X_PADDING, Y_PADDING)
body.draw(canvas, X_PADDING, Y_PADDING + heading.height.toFloat())
// Image converting & returning
val stream: ByteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(CompressFormat.PNG, 0, stream)
val responseBody = stream.toByteArray().toResponseBody("image/png".toMediaType())
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body(responseBody)
.build()
}
private fun textFixer(t: String): String {
return t
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&quot;", "\"")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("\\s*<br>\\s*".toRegex(), "\n")
}
private fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
canvas.save()
canvas.translate(x, y)
this.draw(canvas)
canvas.restore()
}
}
} }