Initial commit

This commit is contained in:
2025-02-10 03:05:25 +08:00
commit 57a0ecc385
93 changed files with 6685 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
plugins {
autowire(libs.plugins.android.library)
autowire(libs.plugins.kotlin.android)
autowire(libs.plugins.kotlin.dokka)
autowire(libs.plugins.maven.publish)
}
group = property.project.groupName
version = property.project.pangutext.android.version
android {
namespace = property.project.pangutext.android.namespace
compileSdk = property.project.android.compileSdk
defaultConfig {
minSdk = property.project.android.minSdk
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs = listOf(
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions"
)
}
}
dependencies {
implementation(com.highcapable.yukireflection.api)
implementation(com.highcapable.betterandroid.ui.extension)
implementation(com.highcapable.betterandroid.system.extension)
implementation(androidx.core.core.ktx)
implementation(androidx.appcompat.appcompat)
testImplementation(junit.junit)
androidTestImplementation(androidx.test.ext.junit)
androidTestImplementation(androidx.test.espresso.espresso.core)
}

View File

21
pangutext-android/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,25 @@
package com.highcapable.pangutext
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.highcapable.pangutext.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,189 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/12.
*/
@file:Suppress("MemberVisibilityCanBePrivate")
package com.highcapable.pangutext.android
import android.content.res.Resources
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.CharacterStyle
import android.widget.TextView
import androidx.annotation.Px
import com.highcapable.pangutext.android.core.PanguMarginSpan
import com.highcapable.pangutext.android.core.PanguPatterns
import com.highcapable.pangutext.android.extension.injectPanguText
import com.highcapable.pangutext.android.extension.injectRealTimePanguText
import com.highcapable.pangutext.android.extension.setTextWithPangu
import com.highcapable.yukireflection.factory.classOf
/**
* The library core of Pangu text processor.
*
* Bigger thanks for [this](https://github.com/vinta/pangu.java) project.
* @see PanguPatterns
*/
object PanguText {
/**
* This is a placeholder character for replacing the content of the regular expression,
* with no actual meaning.
*/
private const val PH = '\u001C'
/**
* The global configuration of [PanguText].
*/
val globalConfig = PanguTextConfig()
/**
* Use [PanguText] to format specified text.
*
* [PanguText] will automatically set [PanguMarginSpan] for some characters in
* the text to achieve white space typesetting effect without actually inserting
* any characters or changing the length of the original text.
*
* This function will insert a style for the current given [text] without actually changing the string position in the text.
* If the current [text] is of type [Spannable], it will return the original unmodified object,
* otherwise it will return the wrapped object [SpannableString] after.
*
* - Note: Processed [Spanned] text is in experimental stage and may not be fully supported,
* if the text is not processed correctly, please disable [PanguTextConfig.isProcessedSpanned].
* @see PanguTextConfig.isProcessedSpanned
* @see PanguTextConfig.cjkSpacingRatio
* @see TextView.injectPanguText
* @see TextView.injectRealTimePanguText
* @see TextView.setTextWithPangu
* @param resources the current resources.
* @param textSize the text size (px).
* @param text text to be formatted.
* @param config the configuration of [PanguText].
* @return [CharSequence]
*/
@JvmOverloads
@JvmStatic
fun format(resources: Resources, @Px textSize: Float, text: CharSequence, config: PanguTextConfig = globalConfig): CharSequence {
if (!config.isEnabled) return text
if (text.isBlank()) return text
val formatted = format(text, PH, config)
return text.applySpans(formatted, resources, textSize, config)
}
/**
* Use [PanguText] to format the current text content.
*
* Using this function will add extra [whiteSpace] as character spacing to the text,
* changing the length of the original text.
*
* - Note: Processed [Spanned] text is in experimental stage and may not be fully supported,
* if the text is not processed correctly, please disable [PanguTextConfig.isProcessedSpanned].
* @see PanguTextConfig.isProcessedSpanned
* @param text text to be formatted.
* @param whiteSpace the spacing character, default is 'U+200A'.
* @param config the configuration of [PanguText].
* @return [CharSequence]
*/
@JvmOverloads
@JvmStatic
fun format(text: CharSequence, whiteSpace: Char = '', config: PanguTextConfig = globalConfig): CharSequence {
if (!config.isEnabled) return text
// In any case, always perform a cleanup operation before accepting text.
val processed = text.clearSpans()
val patterns = config.excludePatterns.toTypedArray()
return if ((config.isProcessedSpanned || text !is Spanned) && text.isNotBlank() && text.length > 1)
PanguPatterns.matchAndReplace(processed, whiteSpace, *patterns)
else processed
}
/**
* Apply the [PanguMarginSpan] to the text.
* @receiver [CharSequence]
* @param formatted the formatted text.
* @param resources the current resources.
* @param textSize the text size (px).
* @param config the configuration of [PanguText].
* @param whiteSpace the spacing character, default is [PH].
* @return [CharSequence]
*/
private fun CharSequence.applySpans(
formatted: CharSequence,
resources: Resources,
@Px textSize: Float,
config: PanguTextConfig = globalConfig,
whiteSpace: Char = PH
): CharSequence {
val builder = SpannableStringBuilder(formatted)
formatted.forEachIndexed { index, c ->
// Add spacing to the previous character.
if (c == whiteSpace && index in 0..formatted.lastIndex) {
val span = PanguMarginSpan.Placeholder()
builder.setSpan(span, index - 1, index, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
// Delete the placeholder character.
for (i in (builder.length - 1) downTo 0) {
if (builder[i] == whiteSpace) builder.delete(i, i + 1)
}
// Find the [PanguMarginSpan.Placeholder] subscript in [builder] and use [PanguMarginSpan] to set it to [original].
val builderSpans = builder.getSpans(0, builder.length, classOf<PanguMarginSpan.Placeholder>())
val spannable = if (this !is Spannable) SpannableString(this) else this
// Add new [PanguMarginSpan].
builderSpans.forEach {
val start = builder.getSpanStart(it)
val end = builder.getSpanEnd(it)
val span = PanguMarginSpan.create(resources, textSize, config.cjkSpacingRatio)
spannable.setSpan(span, start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}; builder.clear()
return spannable
}
/**
* Clear the [PanguMarginSpan] from the text.
*
* Workaround for the issue that the [PanguMarginSpan] repeatedly sets
* the same range causes performance degradation.
* @receiver [CharSequence]
* @return [CharSequence]
*/
private fun CharSequence.clearSpans(): CharSequence {
if (this !is Spannable || isBlank() || !hasSpan<PanguMarginSpan>()) return this
getSpans(0, length, classOf<PanguMarginSpan>()).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
// Clear the [PanguMarginSpan].
if (start < length && end > 0) removeSpan(span)
}; return this
}
/**
* Check if the text contains a specific span [T].
* @receiver [CharSequence]
* @return [Boolean]
*/
private inline fun <reified T : CharacterStyle> CharSequence.hasSpan(): Boolean {
val spannable = this as? Spanned ?: return false
val spans = spannable.getSpans(0, spannable.length, classOf<T>())
return spans.isNotEmpty()
}
}

View File

@@ -0,0 +1,94 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/2/6.
*/
package com.highcapable.pangutext.android
import android.text.Spanned
import java.io.Serializable
/**
* The [PanguText] configuration.
*/
class PanguTextConfig internal constructor() : Serializable {
private companion object {
/**
* The default CJK spacing ratio, adjusted to 7f.
* This ratio is considered to be the most comfortable size for reading after a series of comparisons.
*/
private const val DEFAULT_CJK_SPACING_RATIO = 7f
}
/**
* Enable the [PanguText].
*
* This is a global switch that can be used to enable or disable the [PanguText] processor.
*/
var isEnabled = true
/**
* Processed [Spanned] text (experimental).
*
* - Note: This feature is in experimental stage and may not be fully supported,
* if the text is not processed correctly, please disable this feature.
*/
var isProcessedSpanned = true
/**
* The regular expression for text content that needs to be excluded.
* [PanguText] processing will be skipped after matching these texts.
*
* Usage:
*
* ```kotlin
* val config: PanguTextConfig
* // Exclude all URLs.
* config.excludePatterns.add("https?://\\S+".toRegex())
* // Exclude emoji symbol placeholder like "[doge]".
* config.excludePatterns.add("\\[.*?]".toRegex())
* ```
*/
val excludePatterns = mutableSetOf<Regex>()
/**
* The CJK spacing ratio, default is [DEFAULT_CJK_SPACING_RATIO].
*
* The larger the value, the smaller the spacing, and cannot be less than 0.1f.
*
* It is recommended to adjust with caution, it will only affect the spacing of CJK characters.
*/
var cjkSpacingRatio = DEFAULT_CJK_SPACING_RATIO
/**
* Copy the current configuration.
* @param body the configuration body.
* @return [PanguTextConfig]
*/
@JvmOverloads
fun copy(body: PanguTextConfig.() -> Unit = {}) = PanguTextConfig().also {
it.isEnabled = this.isEnabled
it.isProcessedSpanned = this.isProcessedSpanned
it.excludePatterns.addAll(this.excludePatterns)
it.cjkSpacingRatio = this.cjkSpacingRatio
it.body()
}
}

View File

@@ -0,0 +1,100 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/14.
*/
package com.highcapable.pangutext.android.core
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Paint
import android.text.Spanned
import android.text.TextPaint
import android.text.style.BackgroundColorSpan
import android.text.style.CharacterStyle
import android.text.style.ReplacementSpan
import androidx.annotation.Px
import androidx.core.text.getSpans
import com.highcapable.betterandroid.ui.extension.component.base.toDp
import com.highcapable.betterandroid.ui.extension.component.base.toPx
import kotlin.math.round
/**
* Pangu span with margin.
* @param margin the margin size (px).
*/
internal class PanguMarginSpan(@Px val margin: Int) : ReplacementSpan() {
internal companion object {
/**
* Create a new instance of [PanguMarginSpan].
* @param resources the current resources.
* @param textSize the text size (px).
* @param ratio the CJK spacing ratio.
* @return [PanguMarginSpan]
*/
internal fun create(resources: Resources, @Px textSize: Float, ratio: Float) =
PanguMarginSpan(getSpanMargin(resources, textSize, ratio))
/**
* Get the margin size (px).
* @param resources the current resources.
* @param textSize the text size (px).
* @param ratio the CJK spacing ratio.
* @return [Int]
*/
private fun getSpanMargin(resources: Resources, @Px textSize: Float, ratio: Float) =
round(textSize.toDp(resources) / ratio.coerceAtLeast(0.1f)).toInt().toPx(resources)
}
override fun getContentDescription() = "PanguMarginSpan"
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?) =
(paint.measureText(text, start, end) + margin).toInt()
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
if (text is Spanned) text.getSpans<Any>(start, end).forEach { span ->
when {
span is BackgroundColorSpan -> {
// Get background color.
val color = span.backgroundColor
val originalColor = paint.color
// Save the current [paint] color.
paint.color = color
// Get the width of the text.
val textWidth = paint.measureText(text, start, end)
// Draw background rectangle.
canvas.drawRect(x, top.toFloat(), x + textWidth + margin, bottom.toFloat(), paint)
// Restore original color.
paint.color = originalColor
}
span is CharacterStyle && paint is TextPaint -> span.updateDrawState(paint)
}
}; text?.let { canvas.drawText(it, start, end, x, y.toFloat(), paint) }
}
/**
* A placeholder span.
*/
class Placeholder : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?) = 0
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {}
}
}

View File

@@ -0,0 +1,108 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/20.
*/
@file:Suppress("RegExpRedundantEscape", "RegExpSimplifiable")
package com.highcapable.pangutext.android.core
import com.highcapable.pangutext.android.PanguText
import com.highcapable.pangutext.android.extension.replaceAndPreserveSpans
/**
* The regular expression patterns for [PanguText].
*
* Some schemes are copied from [Pangu.java](https://github.com/vinta/pangu.java/blob/master/src/main/java/ws/vinta/pangu/Pangu.java),
* and some modifications have been made to adapt to the Android environment.
*/
internal object PanguPatterns {
private const val CJK = "\u2e80-\u2eff\u2f00-\u2fdf\u3040-\u309f\u30a0-\u30fa\u30fc-" +
"\u30ff\u3100-\u312f\u3200-\u32ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff"
private val ANY_CJK = "[$CJK]".toRegex()
private val DOTS_CJK = "([\\.]{2,}|\\u2026)([$CJK])".toRegex()
private val FIX_CJK_COLON_ANS = "([$CJK])\\:([A-Z0-9\\(\\)])".toRegex()
private val CJK_QUOTE = "([$CJK])([\\`\"\\u05f4])".toRegex()
private val QUOTE_CJK = "([\\`\"\\u05f4])([$CJK])".toRegex()
private val FIX_QUOTE_ANY_QUOTE = "([`\"\\u05f4]+)[ ]*(.+?)[ ]*([`\"\\u05f4]+)".toRegex()
private val CJK_SINGLE_QUOTE_BUT_POSSESSIVE = "([$CJK])('[^s])".toRegex()
private val SINGLE_QUOTE_CJK = "(')([$CJK])".toRegex()
private val HASH_ANS_CJK_HASH = "([$CJK])(#)([$CJK]+)(#)([$CJK])".toRegex()
private val CJK_HASH = "([$CJK])(#([^ ]))".toRegex()
private val HASH_CJK = "(([^ ])#)([$CJK])".toRegex()
private val CJK_OPERATOR_ANS = "([$CJK])([\\+\\-\\*\\/=&\\|<>])([A-Za-z0-9])".toRegex()
private val ANS_OPERATOR_CJK = "([A-Za-z0-9])([\\+\\-\\*\\/=&\\|<>])([$CJK])".toRegex()
private val FIX_SLASH_AS = "([/]) ([a-z\\-\\_\\./]+)".toRegex()
private val FIX_SLASH_AS_SLASH = "([/\\.])([A-Za-z\\-\\_\\./]+) ([/])".toRegex()
private val CJK_LEFT_BRACKET = "([$CJK])([\\(\\[\\{<>\\u201c])".toRegex()
private val RIGHT_BRACKET_CJK = "([\\)\\]\\}>\\u201d])([$CJK])".toRegex()
private val FIX_LEFT_BRACKET_ANY_RIGHT_BRACKET = "([\\(\\[\\{<\\u201c]+)[ ]*(.+?)[ ]*([\\)\\]\\}>\u201d]+)".toRegex()
private val AN_LEFT_BRACKET = "([A-Za-z0-9])([\\(\\[\\{])".toRegex()
private val RIGHT_BRACKET_AN = "([\\)\\]\\}])([A-Za-z0-9])".toRegex()
private val CJK_ANS = ("([$CJK])([A-Za-z\\u0370-\\u03ff0-9@\$%\\^&\\*\\-\\+\\\\=\\|" +
"/\\u00a1-\\u00ff\\u2150-\\u218f\\u2700—\\u27bf])").toRegex()
private val ANS_CJK = ("([A-Za-z\\u0370-\\u03ff0-9~\\\$%\\^&\\*\\-\\+\\\\=\\|" +
"/!;:,\\.\\?\\u00a1-\\u00ff\\u2150-\\u218f\\u2700—\\u27bf])([$CJK])").toRegex()
private val S_A = "(%)([A-Za-z])".toRegex()
/**
* Match and replace the text with the given regular expression.
* @param text text to be formatted.
* @param whiteSpace the spacing character.
* @param excludePatterns the regular expression to exclude from replacement.
* @return [CharSequence]
*/
internal fun matchAndReplace(text: CharSequence, whiteSpace: Char, vararg excludePatterns: Regex) =
if (ANY_CJK.containsMatchIn(text))
text.replaceAndPreserveSpans(DOTS_CJK, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(FIX_CJK_COLON_ANS, "$1:$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(CJK_QUOTE, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(QUOTE_CJK, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(FIX_QUOTE_ANY_QUOTE, "$1$2$3", *excludePatterns)
.replaceAndPreserveSpans(CJK_SINGLE_QUOTE_BUT_POSSESSIVE, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(SINGLE_QUOTE_CJK, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(HASH_ANS_CJK_HASH, "$1$whiteSpace$2$3$4$whiteSpace$5", *excludePatterns)
.replaceAndPreserveSpans(CJK_HASH, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(HASH_CJK, "$1$whiteSpace$3", *excludePatterns)
.replaceAndPreserveSpans(CJK_OPERATOR_ANS, "$1$whiteSpace$2$whiteSpace$3", *excludePatterns)
.replaceAndPreserveSpans(ANS_OPERATOR_CJK, "$1$whiteSpace$2$whiteSpace$3", *excludePatterns)
.replaceAndPreserveSpans(FIX_SLASH_AS, "$1$2", *excludePatterns)
.replaceAndPreserveSpans(FIX_SLASH_AS_SLASH, "$1$2$3", *excludePatterns)
.replaceAndPreserveSpans(CJK_LEFT_BRACKET, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(RIGHT_BRACKET_CJK, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(FIX_LEFT_BRACKET_ANY_RIGHT_BRACKET, "$1$2$3", *excludePatterns)
.replaceAndPreserveSpans(AN_LEFT_BRACKET, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(RIGHT_BRACKET_AN, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(CJK_ANS, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(ANS_CJK, "$1$whiteSpace$2", *excludePatterns)
.replaceAndPreserveSpans(S_A, "$1$whiteSpace$2", *excludePatterns)
else text
}

View File

@@ -0,0 +1,39 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/2/6.
*/
package com.highcapable.pangutext.android.core
import com.highcapable.pangutext.android.PanguText
import com.highcapable.pangutext.android.PanguTextConfig
/**
* The [PanguText] config interface.
*/
interface PanguTextView {
/**
* Configure the [PanguText].
*
* Configuring this item separately will override global settings.
* @see PanguText.globalConfig
*/
fun configurePanguText(config: PanguTextConfig)
}

View File

@@ -0,0 +1,44 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/26.
*/
package com.highcapable.pangutext.android.core
import android.text.Editable
import android.text.TextWatcher
import android.widget.TextView
import com.highcapable.pangutext.android.PanguText
import com.highcapable.pangutext.android.PanguTextConfig
import com.highcapable.pangutext.android.extension.injectRealTimePanguText
/**
* A [TextWatcher] that automatically applies [PanguText] to the text content.
*
* You don't need to create it manually, use [TextView.injectRealTimePanguText] instead.
* @param base the base [TextView].
* @param config the configuration of [PanguText].
*/
class PanguTextWatcher internal constructor(private val base: TextView, private val config: PanguTextConfig) : TextWatcher {
override fun afterTextChanged(editable: Editable?) {
editable?.let { PanguText.format(base.resources, base.textSize, it, config) }
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}

View File

@@ -0,0 +1,129 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/12.
*/
@file:Suppress("unused")
@file:JvmName("PanguTextUtils")
package com.highcapable.pangutext.android.extension
import android.util.Log
import android.view.ViewTreeObserver
import android.widget.TextView
import androidx.core.view.doOnAttach
import androidx.core.view.doOnDetach
import com.highcapable.betterandroid.ui.extension.view.getTag
import com.highcapable.pangutext.android.PanguText
import com.highcapable.pangutext.android.PanguTextConfig
import com.highcapable.pangutext.android.R
import com.highcapable.pangutext.android.core.PanguTextWatcher
import com.highcapable.pangutext.android.generated.PangutextAndroidProperties
/**
* Create a new instance of [PanguTextConfig].
* @see PanguTextConfig
* @param copyFromGlobal whether to copy the [PanguText.globalConfig], default is true.
* @param body the configuration body.
* @return [PanguTextConfig]
*/
@JvmOverloads
fun PanguTextConfig(copyFromGlobal: Boolean = true, body: PanguTextConfig.() -> Unit) =
if (copyFromGlobal) PanguText.globalConfig.copy(body) else PanguTextConfig().apply(body)
/**
* Inject [PanguText] to the current text content once.
* @see TextView.setTextWithPangu
* @see TextView.setHintWithPangu
* @see PanguText.format
* @receiver [TextView]
* @param injectHint whether to apply [TextView.setHint], default is true.
* @param config the configuration of [PanguText].
*/
@JvmOverloads
fun TextView.injectPanguText(injectHint: Boolean = true, config: PanguTextConfig = PanguText.globalConfig) {
if (!config.isEnabled) return
setTextWithPangu(this.text, config)
if (injectHint) setHintWithPangu(this.hint, config)
}
/**
* Inject [PanguText] to the current text content in real time.
* @see TextView.setTextWithPangu
* @see TextView.setHintWithPangu
* @see PanguText.format
* @receiver [TextView]
* @param injectHint whether to apply [TextView.setHint], default is true.
* @param config the configuration of [PanguText].
*/
@JvmOverloads
fun TextView.injectRealTimePanguText(injectHint: Boolean = true, config: PanguTextConfig = PanguText.globalConfig) {
if (!config.isEnabled) return
val observerKey = R.id.flag_inject_real_time_pangu_text
if (getTag<Boolean>(observerKey) == true) return run {
Log.w(PangutextAndroidProperties.PROJECT_NAME, "Duplicate injection of real-time PanguText ($this).")
}
setTag(observerKey, injectHint)
injectPanguText(injectHint, config)
var currentHint = this.hint
val textWatcher = PanguTextWatcher(base = this, config)
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val self = this@injectRealTimePanguText
if (self.hint != currentHint)
self.setHintWithPangu(self.hint, config)
currentHint = self.hint
}
doOnAttach {
addTextChangedListener(textWatcher)
// Add a global layout listener to monitor the hint text changes.
if (injectHint) viewTreeObserver?.addOnGlobalLayoutListener(listener)
doOnDetach {
removeTextChangedListener(textWatcher)
// Remove the global layout listener when the view is detached.
if (injectHint) viewTreeObserver?.removeOnGlobalLayoutListener(listener)
setTag(observerKey, false)
}
}
}
/**
* Use [PanguText.format] to format the text content.
* @see PanguText.format
* @receiver [TextView]
* @param text the text content.
* @param config the configuration of [PanguText].
*/
@JvmOverloads
fun TextView.setTextWithPangu(text: CharSequence?, config: PanguTextConfig = PanguText.globalConfig) {
if (!config.isEnabled) return
this.text = text?.let { PanguText.format(resources, textSize, it, config) }
}
/**
* Use [PanguText.format] to format the hint text content.
* @see PanguText.format
* @receiver [TextView]
* @param text the text content.
* @param config the configuration of [PanguText].
*/
@JvmOverloads
fun TextView.setHintWithPangu(text: CharSequence?, config: PanguTextConfig = PanguText.globalConfig) {
if (!config.isEnabled) return
this.hint = text?.let { PanguText.format(resources, textSize, it, config) }
}

View File

@@ -0,0 +1,110 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/16.
*/
@file:JvmName("ReplacementUtils")
package com.highcapable.pangutext.android.extension
import android.text.SpannableStringBuilder
import android.util.Log
import com.highcapable.pangutext.android.generated.PangutextAndroidProperties
import com.highcapable.yukireflection.factory.classOf
import com.highcapable.yukireflection.factory.field
import java.util.regex.Matcher
/**
* Replace the text content and preserve the original span style.
* @see CharSequence.replace
* @receiver [CharSequence]
* @param regex the regular expression.
* @param replacement the replacement text.
* @param excludePatterns the regular expression to exclude from replacement, default is null.
* @return [CharSequence]
*/
internal fun CharSequence.replaceAndPreserveSpans(regex: Regex, replacement: String, vararg excludePatterns: Regex) =
runCatching {
val builder = SpannableStringBuilder(this)
val matcher = regex.toPattern().matcher(this)
val excludeMatchers = excludePatterns.map { it.toPattern().matcher(this) }
val excludeIndexs = mutableSetOf<Pair<Int, Int>>()
excludeMatchers.forEach {
while (it.find()) excludeIndexs.add(it.start() to it.end())
}
var offset = 0
// Offset adjustment to account for changes in the text length after replacements.
while (matcher.find()) {
val start = matcher.start() + offset
val end = matcher.end() + offset
// Skip the replacement if the matched range is excluded.
// The character range offset is adjusted by 1 to avoid the exclusion of the matched range.
if (excludeIndexs.any { it.first <= start + 1 && it.second >= end - 1 }) continue
// Perform the replacement.
val replacementText = matcher.buildReplacementText(replacement)
builder.replace(start, end, replacementText)
// Adjust offset based on the length of the replacement.
offset += replacementText.length - (end - start)
}; builder
}.onFailure {
Log.w(PangutextAndroidProperties.PROJECT_NAME, "Failed to replace span text content.", it)
}.getOrNull() ?: this
/**
* Build the replacement text based on the matched groups.
* @receiver [Matcher]
* @param replacement the replacement text.
* @return [String]
*/
private fun Matcher.buildReplacementText(replacement: String): String {
val matcher = this
var result = replacement
// Check for group references (like $1, $2, ...).
val pattern = "\\$(\\d+)".toRegex()
result = pattern.replace(result) { matchResult ->
val groupIndex = matchResult.groupValues[1].toInt()
if (groupIndex <= matcher.groupCount())
matcher.group(groupIndex) ?: ""
else ""
}
// Check for named groups (like ${groupName}).
val namedGroupPattern = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}".toRegex()
result = namedGroupPattern.replace(result) { matchResult ->
val groupName = matchResult.groupValues[1]
val groupIndex = matcher.getNamedGroupIndex(groupName)
if (groupIndex >= 0)
matcher.group(groupIndex) ?: ""
else ""
}; return result
}
/**
* Helper method to find the group index for a named group.
* @receiver [Matcher]
* @param groupName the group name.
* @return [Int]
*/
private fun Matcher.getNamedGroupIndex(groupName: String): Int {
val namedGroups = classOf<Matcher>()
.field { name = "namedGroups" }
.ignored()
.get(this)
.cast<Map<String, Int>>()
return namedGroups?.get(groupName) ?: -1
}

View File

@@ -0,0 +1,131 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/19.
*/
@file:Suppress("unused", "MemberVisibilityCanBePrivate")
package com.highcapable.pangutext.android.factory
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import com.highcapable.betterandroid.ui.extension.view.layoutInflater
import com.highcapable.pangutext.android.generated.PangutextAndroidProperties
import com.highcapable.yukireflection.factory.field
import com.highcapable.yukireflection.type.android.LayoutInflaterClass
/**
* Pangu text factory 2 for [LayoutInflater.Factory2].
* @param base the base factory.
*/
class PanguTextFactory2 private constructor(private val base: LayoutInflater.Factory2?) : LayoutInflater.Factory2 {
companion object {
/**
* Inject [PanguTextFactory2] to the current [LayoutInflater] of [context].
*
* Simple Usage:
*
* ```kotlin
* class MainActivity : AppCompactActivity() {
*
* val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
*
* override fun onCreate(savedInstanceState: Bundle?) {
* super.onCreate(savedInstanceState)
* // Inject here.
* PanguTextFactory2.inject(this)
* setContentView(binding.root)
* }
* }
* ```
*
* Traditional Usage:
*
* ```kotlin
* class MainActivity : Activity() {
*
* override fun onCreate(savedInstanceState: Bundle?) {
* super.onCreate(savedInstanceState)
* // Inject here.
* PanguTextFactory2.inject(this)
* setContentView(R.layout.activity_main)
* }
* }
* ```
*
* Usage with BetterAndroid's AppBindingActivity:
*
* ```kotlin
* class MainActivity : AppBindingActivity<ActivityMainBinding>() {
*
* override fun onPrepareContentView(savedInstanceState: Bundle?): LayoutInflater {
* val inflater = super.onPrepareContentView(savedInstanceState)
* // Inject here.
* PanguTextFactory2.inject(inflater)
* return inflater
* }
*
* override fun onCreate(savedInstanceState: Bundle?) {
* super.onCreate(savedInstanceState)
* // Your code here.
* }
* }
* ```
* @param context the current context.
*/
@JvmStatic
fun inject(context: Context) = inject(context.layoutInflater)
/**
* Inject [PanguTextFactory2] to the current [LayoutInflater].
* @see inject
* @param inflater the current inflater.
*/
@JvmStatic
fun inject(inflater: LayoutInflater) {
val original = inflater.factory2
if (original is PanguTextFactory2) return run {
Log.w(PangutextAndroidProperties.PROJECT_NAME, "PanguTextFactory2 was already injected.")
}
val replacement = PanguTextFactory2(original)
if (original != null)
LayoutInflaterClass.field {
name = "mFactory2"
}.ignored().onNoSuchField {
Log.e(PangutextAndroidProperties.PROJECT_NAME, "LayoutInflater.mFactory2 not found.", it)
}.get(inflater).set(replacement)
else inflater.factory2 = replacement
}
}
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet) =
base?.onCreateView(parent, name, context, attrs).let {
PanguWidget.process(name, it, context, attrs) ?: it
}
override fun onCreateView(name: String, context: Context, attrs: AttributeSet) =
base?.onCreateView(name, context, attrs).let {
PanguWidget.process(name, it, context, attrs) ?: it
}
}

View File

@@ -0,0 +1,136 @@
/*
* PanguText - A typographic solution for the optimal alignment of CJK characters, English words, and half-width digits.
* Copyright (C) 2019 HighCapable
* https://github.com/BetterAndroid/PanguText
*
* Apache License Version 2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file is created by fankes on 2025/1/19.
*/
package com.highcapable.pangutext.android.factory
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.core.view.doOnAttach
import com.highcapable.betterandroid.ui.extension.component.base.getBooleanOrNull
import com.highcapable.betterandroid.ui.extension.component.base.getFloatOrNull
import com.highcapable.betterandroid.ui.extension.component.base.getStringOrNull
import com.highcapable.betterandroid.ui.extension.component.base.obtainStyledAttributes
import com.highcapable.pangutext.android.PanguText
import com.highcapable.pangutext.android.PanguTextConfig
import com.highcapable.pangutext.android.R
import com.highcapable.pangutext.android.core.PanguTextView
import com.highcapable.pangutext.android.extension.injectPanguText
import com.highcapable.pangutext.android.extension.injectRealTimePanguText
import com.highcapable.pangutext.android.generated.PangutextAndroidProperties
import com.highcapable.yukireflection.factory.classOf
import com.highcapable.yukireflection.factory.constructor
import com.highcapable.yukireflection.factory.notExtends
import com.highcapable.yukireflection.factory.toClassOrNull
import com.highcapable.yukireflection.type.android.AttributeSetClass
import com.highcapable.yukireflection.type.android.ContextClass
/**
* A widgets processor that automatically applies [PanguText] to the text content.
*/
internal object PanguWidget {
/** The text regex split symbol. */
private const val TEXT_REGEX_SPLITE_SYMBOL = "|@|"
/**
* Process the widget by the given name.
* @param name the widget name.
* @param view the current view.
* @param context the context.
* @param attrs the attributes.
* @return [View] or null.
*/
fun process(name: String, view: View?, context: Context, attrs: AttributeSet): View? {
val instance = view ?: name.let {
// There will be commonly used view class names in the XML layout, which is converted here.
if (!it.contains(".")) "android.widget.$it" else it
}.toClassOrNull()?.let {
// Avoid creating unnecessary components for waste.
if (it notExtends classOf<TextView>()) return null
val twoParams = it.constructor {
param(ContextClass, AttributeSetClass)
}.ignored().get()
val onceParam = it.constructor {
param(ContextClass)
}.ignored().get()
twoParams.newInstance<View>(context, attrs) ?: onceParam.newInstance<View>(context)
}
// Ignore if the instance is not a [TextView].
if (instance !is TextView) return null
var config = PanguText.globalConfig
if (instance is PanguTextView) {
val configCopy = config.copy()
instance.configurePanguText(configCopy)
config = configCopy
if (!config.isEnabled) return instance
} else instance.obtainStyledAttributes(attrs, R.styleable.PanguTextHelper) {
val isEnabled = it.getBooleanOrNull(R.styleable.PanguTextHelper_panguText_enabled)
val isProcessedSpanned = it.getBooleanOrNull(R.styleable.PanguTextHelper_panguText_processedSpanned)
val cjkSpacingRatio = it.getFloatOrNull(R.styleable.PanguTextHelper_panguText_cjkSpacingRatio)
val excludePatterns = it.getStringOrNull(R.styleable.PanguTextHelper_panguText_excludePatterns)
?.split(TEXT_REGEX_SPLITE_SYMBOL)?.mapNotNull { regex ->
runCatching { regex.toRegex() }.onFailure { th ->
Log.e(PangutextAndroidProperties.PROJECT_NAME, "Invalid exclude pattern of $instance: $regex", th)
}.getOrNull()
}?.toTypedArray() ?: emptyArray()
if (isEnabled == false) return instance
if (isProcessedSpanned != null || cjkSpacingRatio != null || excludePatterns.isNotEmpty()) {
val configCopy = config.copy()
configCopy.isProcessedSpanned = isProcessedSpanned ?: config.isProcessedSpanned
configCopy.cjkSpacingRatio = cjkSpacingRatio ?: config.cjkSpacingRatio
if (excludePatterns.isNotEmpty()) {
config.excludePatterns.clear()
config.excludePatterns.addAll(excludePatterns)
}; config = configCopy
}
}
when (instance.javaClass.name) {
// Specialize those components because loading "hint" style after [doOnAttachRepeatable] causes problems.
"com.google.android.material.textfield.TextInputEditText",
"com.google.android.material.textfield.MaterialAutoCompleteTextView" -> {
instance.injectPanguText(config = config)
instance.doOnAttachRepeatable(config) { it.injectRealTimePanguText(injectHint = false, config) }
}
else -> instance.doOnAttachRepeatable(config) {
it.injectRealTimePanguText(config = config)
}
}; return instance
}
/** Copied from [View.doOnAttach]. */
private inline fun <reified V : View> V.doOnAttachRepeatable(config: PanguTextConfig, crossinline action: (view: V) -> Unit) {
if (!config.isEnabled) return
if (isAttachedToWindow) action(this)
addOnAttachStateChangeListener(
object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
// Re-execute it every time to prevent layout re-creation problems
// similar to [RecyclerView.Adapter] or [BaseAdapter] after reuse.
if (config.isEnabled) action(view as V)
}
override fun onViewDetachedFromWindow(view: View) {}
}
)
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PanguTextHelper">
<!-- Enable [PanguText] for this view. -->
<attr name="panguText_enabled" format="boolean" />
<!-- Processed [Spanned] text (experimental). -->
<attr name="panguText_processedSpanned" format="boolean" />
<!-- The regular expression for text content that needs to be excluded. -->
<attr name="panguText_excludePatterns" format="string" />
<!-- The CJK spacing ratio. -->
<attr name="panguText_cjkSpacingRatio" format="float" />
</declare-styleable>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<id name="flag_inject_real_time_pangu_text" type="id" />
</resources>

View File

@@ -0,0 +1,18 @@
package com.highcapable.pangutext
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}