From e5ce7fbb83ecfbdc635fa1811a3420d4fa8604ac Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 20:53:15 +0300 Subject: [PATCH 1/8] feat: add Arabic search ranges and Android highlighting (#4) Implement original-text UTF-16 match ranges, first/all/overlapping normalized search, Android Spannable highlighting, sample updates, tests, and bilingual documentation for ArabicKit 0.2.0. --- CHANGELOG.md | 9 ++ README.md | 46 +++++- README_AR.md | 36 ++++- ROADMAP.md | 12 +- .../arabickit/ArabicHighlighter.kt | 61 ++++++++ .../arabickit/ArabicMatchRange.kt | 24 ++++ .../arabickit/ArabicNormalizer.kt | 35 +---- .../khalid567cpu/arabickit/ArabicSearch.kt | 84 ++++++++++- .../internal/ArabicNormalizationEngine.kt | 136 ++++++++++++++++++ .../arabickit/ArabicSearchRangesTest.kt | 87 +++++++++++ docs/0.2.0-search-ranges-plan.md | 26 ++++ .../arabickit/sample/MainActivity.kt | 18 ++- sample/src/main/res/layout/activity_main.xml | 17 +++ sample/src/main/res/values/strings.xml | 10 +- 14 files changed, 543 insertions(+), 58 deletions(-) create mode 100644 arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighter.kt create mode 100644 arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicMatchRange.kt create mode 100644 arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/internal/ArabicNormalizationEngine.kt create mode 100644 arabickit/src/test/kotlin/io/github/khalid567cpu/arabickit/ArabicSearchRangesTest.kt create mode 100644 docs/0.2.0-search-ranges-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a11ab..11e7384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format follows Keep a Changelog, and releases will follow Semantic Versionin ## [Unreleased] +### Added + +- `ArabicMatchRange` with original UTF-16 source offsets. +- `ArabicSearch.findFirst(...)` and `ArabicSearch.findAll(...)`. +- Optional overlapping matches and result limits. +- Offset-aware normalization across diacritics, Quranic marks, tatweel, alef variants, whitespace collapse, and Latin lowercase expansion. +- Android background highlighting with `ArabicHighlighter`. +- Range-search coverage in the sample app and unit tests. + ## [0.1.0] - 2026-08-02 ### Added diff --git a/README.md b/README.md index 1fd112a..1b892b8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ArabicKit -Production-ready Kotlin utilities for Arabic text normalization, search, and numeral conversion on Android. +Production-ready Kotlin utilities for Arabic text normalization, search, range mapping, highlighting, and numeral conversion on Android. > ArabicKit `0.1.0` is the first public foundation release. The API is tested, but `0.x` releases may still evolve before the first stable release. @@ -11,6 +11,9 @@ Production-ready Kotlin utilities for Arabic text normalization, search, and num - Normalize alef variants and alef maksura. - Normalize whitespace and Latin casing. - Search through normalized Arabic text. +- Return match ranges in the original UTF-16 source text. +- Find first, multiple, or overlapping normalized matches. +- Highlight normalized matches with Android `SpannableString`. - Convert Western, Arabic-Indic, and Eastern Arabic-Indic digits. - Java-friendly APIs through `@JvmStatic` and `@JvmOverloads`. - A dependency-free Android sample app. @@ -35,16 +38,45 @@ On Windows: ## Quick usage +### Normalize Arabic text + ```kotlin val normalized = ArabicNormalizer.normalize("إِنَّ الــلَّهَ غَفُورٌ") // "ان الله غفور" +``` + +### Find original-text ranges + +```kotlin +val source = "إِنَّ اللَّهَ غَفُورٌ، إن الله رحيم" -val matches = ArabicSearch.contains( - text = "إِنَّ اللَّهَ غَفُورٌ", +val ranges = ArabicSearch.findAll( + text = source, query = "ان الله", ) -// true +ranges.forEach { range -> + println(source.substring(range.start, range.endExclusive)) +} +``` + +The returned offsets target the original source, including its diacritics and tatweel, so they can be used directly with `substring` or Android spans. + +### Highlight matches on Android + +```kotlin +val highlighted = ArabicHighlighter.highlightBackground( + text = source, + query = "ان الله", + color = Color.YELLOW, +) + +textView.text = highlighted +``` + +### Convert numerals + +```kotlin val western = ArabicNumerals.convert( input = "الإصدار ٢٠٢٦", target = ArabicNumeralSystem.WESTERN, @@ -59,9 +91,13 @@ val western = ArabicNumerals.convert( - Java bytecode target 17 - Kotlin support is provided by Android Gradle Plugin built-in Kotlin. +## Releases + +The first public release is `v0.1.0`. Binary AAR assets are available from GitHub Releases. + ## Project status -ArabicKit is preparing the public `0.1.0` release. See [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md). +ArabicKit is developing `0.2.0`, focused on original-text search ranges and Android highlighting. See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the [0.2.0 implementation plan](docs/0.2.0-search-ranges-plan.md). ## Contributing diff --git a/README_AR.md b/README_AR.md index f1454ec..57a9dad 100644 --- a/README_AR.md +++ b/README_AR.md @@ -1,6 +1,6 @@ # ArabicKit — العربية -مكتبة Kotlin إنتاجية لمعالجة النص العربي والبحث وتحويل الأرقام داخل تطبيقات Android. +مكتبة Kotlin إنتاجية لمعالجة النص العربي والبحث وإرجاع مواقع النتائج والتظليل وتحويل الأرقام داخل تطبيقات Android. > الإصدار `0.1.0` هو أول إصدار تأسيسي عام للمكتبة. الواجهات الحالية مختبرة، لكن إصدارات `0.x` قد تتطور قبل الوصول إلى أول إصدار مستقر. @@ -11,6 +11,9 @@ - توحيد أشكال الألف والألف المقصورة. - تنظيم المسافات وحالة الأحرف اللاتينية. - البحث بعد التطبيع. +- إرجاع مواقع النتائج داخل النص الأصلي باستخدام فهارس UTF-16. +- العثور على أول نتيجة أو جميع النتائج أو النتائج المتداخلة. +- تظليل النتائج داخل Android باستخدام `SpannableString`. - التحويل بين الأرقام الغربية والعربية والهندية الشرقية. - واجهات سهلة للاستخدام من Kotlin وJava. - تطبيق عينة مبني بـXML وViewBinding. @@ -24,9 +27,38 @@ .\gradlew.bat verifyFoundation ``` +## مثال: إرجاع مواقع النتائج داخل النص الأصلي + +```kotlin +val source = "إِنَّ اللَّهَ غَفُورٌ، إن الله رحيم" + +val ranges = ArabicSearch.findAll( + text = source, + query = "ان الله", +) + +ranges.forEach { range -> + println(source.substring(range.start, range.endExclusive)) +} +``` + +المواقع الناتجة تشير إلى النص الأصلي المشكّل، وليست إلى النسخة المطبّعة، ولذلك يمكن استخدامها مباشرة مع `substring` أو Android spans. + +## مثال: تظليل النتائج + +```kotlin +val highlighted = ArabicHighlighter.highlightBackground( + text = source, + query = "ان الله", + color = Color.YELLOW, +) + +textView.text = highlighted +``` + ## حالة المشروع -المشروع يجهّز الآن الإصدار العام الأول `0.1.0`. قد تتطور واجهات `0.x` قبل الوصول إلى الإصدار المستقر الأول. +تم نشر الإصدار العام الأول `0.1.0`. يجري الآن تطوير `0.2.0` لإضافة مواقع البحث الأصلية والتظليل على Android. ## الرخصة diff --git a/ROADMAP.md b/ROADMAP.md index 8636de6..be45355 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,14 +7,16 @@ - [x] Numeral conversion. - [x] Unit tests and CI. - [x] API review. -- [ ] Publish the first public release. +- [x] Publish the first public release. ## 0.2.0 — Search ranges and highlighting -- Original-text index mapping. -- Safe match ranges. -- Android `Spannable` highlighting. -- Multi-match support. +- [x] Original-text index mapping. +- [x] Safe match ranges. +- [x] Android `Spannable` highlighting. +- [x] Multi-match and overlapping-match support. +- [x] Sample and documentation updates. +- [ ] Complete CI review and publish `v0.2.0`. ## 0.3.0 — Advanced Arabic utilities diff --git a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighter.kt b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighter.kt new file mode 100644 index 0000000..b805971 --- /dev/null +++ b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighter.kt @@ -0,0 +1,61 @@ +package io.github.khalid567cpu.arabickit + +import android.text.SpannableString +import android.text.Spanned +import android.text.style.BackgroundColorSpan + +/** + * Android text-highlighting helpers backed by [ArabicSearch] ranges. + */ +public object ArabicHighlighter { + /** + * Finds all normalized matches and applies a background-color span to each. + */ + @JvmStatic + @JvmOverloads + public fun highlightBackground( + text: CharSequence, + query: CharSequence, + color: Int, + options: ArabicNormalizationOptions = ArabicNormalizationOptions(), + allowOverlaps: Boolean = false, + ): SpannableString = applyBackground( + text = text, + ranges = ArabicSearch.findAll( + text = text, + query = query, + options = options, + allowOverlaps = allowOverlaps, + ), + color = color, + ) + + /** + * Applies a background-color span to precomputed original-text [ranges]. + */ + @JvmStatic + public fun applyBackground( + text: CharSequence, + ranges: Iterable, + color: Int, + ): SpannableString { + val highlighted = SpannableString(text) + + ranges.forEach { range -> + require(range.endExclusive <= text.length) { + "Match range $range exceeds source length ${text.length}." + } + + if (range.length > 0) { + highlighted.setSpan( + BackgroundColorSpan(color), + range.start, + range.endExclusive, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + return highlighted + } +} diff --git a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicMatchRange.kt b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicMatchRange.kt new file mode 100644 index 0000000..585e029 --- /dev/null +++ b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicMatchRange.kt @@ -0,0 +1,24 @@ +package io.github.khalid567cpu.arabickit + +/** + * A match range expressed in UTF-16 offsets of the original source text. + * + * The [start] offset is inclusive and [endExclusive] is exclusive, matching + * Android `Spannable` and Kotlin substring conventions. + */ +public data class ArabicMatchRange( + public val start: Int, + public val endExclusive: Int, +) { + init { + require(start >= 0) { "start must be non-negative." } + require(endExclusive >= start) { "endExclusive must be greater than or equal to start." } + } + + /** Number of UTF-16 code units covered by this range. */ + public val length: Int + get() = endExclusive - start + + /** Returns this range as a Kotlin [IntRange]. */ + public fun toIntRange(): IntRange = start until endExclusive +} diff --git a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicNormalizer.kt b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicNormalizer.kt index ca3a02e..72f6f67 100644 --- a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicNormalizer.kt +++ b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicNormalizer.kt @@ -1,46 +1,15 @@ package io.github.khalid567cpu.arabickit -import java.util.Locale +import io.github.khalid567cpu.arabickit.internal.ArabicNormalizationEngine /** * Deterministic Arabic text normalization for search and comparison. */ public object ArabicNormalizer { - private val arabicDiacritics = Regex("[\\u064B-\\u065F\\u0670]") - private val quranicAnnotationMarks = Regex("[\\u0610-\\u061A\\u06D6-\\u06ED]") - private val alefVariants = Regex("[\\u0622\\u0623\\u0625\\u0671]") - private val whitespace = Regex("\\s+") - @JvmStatic @JvmOverloads public fun normalize( input: CharSequence, options: ArabicNormalizationOptions = ArabicNormalizationOptions(), - ): String { - var result = input.toString() - - if (options.removeDiacritics) { - result = result.replace(arabicDiacritics, "") - } - if (options.removeQuranicMarks) { - result = result.replace(quranicAnnotationMarks, "") - } - if (options.removeTatweel) { - result = result.replace("\u0640", "") - } - if (options.normalizeAlef) { - result = result.replace(alefVariants, "\u0627") - } - if (options.normalizeAlefMaksura) { - result = result.replace('\u0649', '\u064A') - } - if (options.normalizeWhitespace) { - result = result.replace(whitespace, " ").trim() - } - if (options.lowercaseLatin) { - result = result.lowercase(Locale.ROOT) - } - - return result - } + ): String = ArabicNormalizationEngine.normalize(input, options).text } diff --git a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicSearch.kt b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicSearch.kt index 7edbe3c..2ceef8f 100644 --- a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicSearch.kt +++ b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/ArabicSearch.kt @@ -1,5 +1,7 @@ package io.github.khalid567cpu.arabickit +import io.github.khalid567cpu.arabickit.internal.ArabicNormalizationEngine + /** * Arabic-aware search helpers built on deterministic normalization. */ @@ -10,12 +12,7 @@ public object ArabicSearch { text: CharSequence, query: CharSequence, options: ArabicNormalizationOptions = ArabicNormalizationOptions(), - ): Boolean { - val normalizedQuery = ArabicNormalizer.normalize(query, options) - if (normalizedQuery.isEmpty()) return false - - return ArabicNormalizer.normalize(text, options).contains(normalizedQuery) - } + ): Boolean = findFirst(text, query, options) != null @JvmStatic @JvmOverloads @@ -25,4 +22,79 @@ public object ArabicSearch { options: ArabicNormalizationOptions = ArabicNormalizationOptions(), ): Boolean = ArabicNormalizer.normalize(first, options) == ArabicNormalizer.normalize(second, options) + + /** + * Returns the first normalized match as offsets in the original [text]. + */ + @JvmStatic + @JvmOverloads + public fun findFirst( + text: CharSequence, + query: CharSequence, + options: ArabicNormalizationOptions = ArabicNormalizationOptions(), + ): ArabicMatchRange? = findAll( + text = text, + query = query, + options = options, + allowOverlaps = false, + maxResults = 1, + ).firstOrNull() + + /** + * Returns normalized matches as UTF-16 ranges in the original [text]. + * + * An empty query after normalization produces an empty result. By default, + * matches do not overlap. Set [allowOverlaps] to `true` to advance one + * normalized UTF-16 code unit after every match. + */ + @JvmStatic + @JvmOverloads + public fun findAll( + text: CharSequence, + query: CharSequence, + options: ArabicNormalizationOptions = ArabicNormalizationOptions(), + allowOverlaps: Boolean = false, + maxResults: Int = Int.MAX_VALUE, + ): List { + require(maxResults >= 0) { "maxResults must be non-negative." } + if (maxResults == 0) return emptyList() + + val normalizedQuery = ArabicNormalizer.normalize(query, options) + if (normalizedQuery.isEmpty()) return emptyList() + + val normalizedText = ArabicNormalizationEngine.normalize(text, options) + if (normalizedText.text.isEmpty()) return emptyList() + + val results = ArrayList() + var searchFrom = 0 + + while (searchFrom <= normalizedText.text.length - normalizedQuery.length) { + val normalizedStart = normalizedText.text.indexOf( + string = normalizedQuery, + startIndex = searchFrom, + ) + + if (normalizedStart < 0) break + + val normalizedEndExclusive = normalizedStart + normalizedQuery.length + val sourceRange = normalizedText.sourceRange( + normalizedStart = normalizedStart, + normalizedEndExclusive = normalizedEndExclusive, + ) + + if (results.lastOrNull() != sourceRange) { + results += sourceRange + } + + if (results.size >= maxResults) break + + searchFrom = normalizedStart + if (allowOverlaps) { + 1 + } else { + normalizedQuery.length + } + } + + return results + } } diff --git a/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/internal/ArabicNormalizationEngine.kt b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/internal/ArabicNormalizationEngine.kt new file mode 100644 index 0000000..23c763c --- /dev/null +++ b/arabickit/src/main/kotlin/io/github/khalid567cpu/arabickit/internal/ArabicNormalizationEngine.kt @@ -0,0 +1,136 @@ +package io.github.khalid567cpu.arabickit.internal + +import io.github.khalid567cpu.arabickit.ArabicMatchRange +import io.github.khalid567cpu.arabickit.ArabicNormalizationOptions +import java.util.Locale + +internal data class NormalizedArabicText( + val text: String, + private val sourceStarts: IntArray, + private val sourceEndsExclusive: IntArray, +) { + init { + check(text.length == sourceStarts.size) + check(text.length == sourceEndsExclusive.size) + } + + fun sourceRange( + normalizedStart: Int, + normalizedEndExclusive: Int, + ): ArabicMatchRange { + require(normalizedStart in text.indices) + require(normalizedEndExclusive in 1..text.length) + require(normalizedStart < normalizedEndExclusive) + + return ArabicMatchRange( + start = sourceStarts[normalizedStart], + endExclusive = sourceEndsExclusive[normalizedEndExclusive - 1], + ) + } +} + +internal object ArabicNormalizationEngine { + fun normalize( + input: CharSequence, + options: ArabicNormalizationOptions, + ): NormalizedArabicText { + val normalized = StringBuilder(input.length) + val starts = ArrayList(input.length) + val endsExclusive = ArrayList(input.length) + var pendingLeadingStart: Int? = null + + input.forEachIndexed { index, character -> + val sourceStart = pendingLeadingStart ?: index + val sourceEndExclusive = index + 1 + + if (shouldRemove(character, options)) { + if (normalized.isNotEmpty()) { + endsExclusive[endsExclusive.lastIndex] = sourceEndExclusive + } else if (pendingLeadingStart == null) { + pendingLeadingStart = index + } + return@forEachIndexed + } + + if (options.normalizeWhitespace && isAsciiWhitespace(character)) { + when { + normalized.isEmpty() -> { + pendingLeadingStart = null + } + + normalized.last() == ' ' -> { + endsExclusive[endsExclusive.lastIndex] = sourceEndExclusive + } + + else -> { + normalized.append(' ') + starts += sourceStart + endsExclusive += sourceEndExclusive + pendingLeadingStart = null + } + } + return@forEachIndexed + } + + val transformed = transform(character, options) + val emitted = if (options.lowercaseLatin) { + transformed.toString().lowercase(Locale.ROOT) + } else { + transformed.toString() + } + + emitted.forEach { emittedCharacter -> + normalized.append(emittedCharacter) + starts += sourceStart + endsExclusive += sourceEndExclusive + } + + pendingLeadingStart = null + } + + if (options.normalizeWhitespace && normalized.lastOrNull() == ' ') { + normalized.deleteCharAt(normalized.lastIndex) + starts.removeAt(starts.lastIndex) + endsExclusive.removeAt(endsExclusive.lastIndex) + } + + return NormalizedArabicText( + text = normalized.toString(), + sourceStarts = starts.toIntArray(), + sourceEndsExclusive = endsExclusive.toIntArray(), + ) + } + + private fun shouldRemove( + character: Char, + options: ArabicNormalizationOptions, + ): Boolean = + (options.removeDiacritics && isArabicDiacritic(character)) || + (options.removeQuranicMarks && isQuranicAnnotationMark(character)) || + (options.removeTatweel && character == '\u0640') + + private fun transform( + character: Char, + options: ArabicNormalizationOptions, + ): Char = when { + options.normalizeAlef && character in ALEF_VARIANTS -> '\u0627' + options.normalizeAlefMaksura && character == '\u0649' -> '\u064A' + else -> character + } + + private fun isArabicDiacritic(character: Char): Boolean = + character in '\u064B'..'\u065F' || character == '\u0670' + + private fun isQuranicAnnotationMark(character: Char): Boolean = + character in '\u0610'..'\u061A' || character in '\u06D6'..'\u06ED' + + private fun isAsciiWhitespace(character: Char): Boolean = + character == ' ' || character in '\t'..'\r' + + private val ALEF_VARIANTS: Set = setOf( + '\u0622', + '\u0623', + '\u0625', + '\u0671', + ) +} diff --git a/arabickit/src/test/kotlin/io/github/khalid567cpu/arabickit/ArabicSearchRangesTest.kt b/arabickit/src/test/kotlin/io/github/khalid567cpu/arabickit/ArabicSearchRangesTest.kt new file mode 100644 index 0000000..afdf521 --- /dev/null +++ b/arabickit/src/test/kotlin/io/github/khalid567cpu/arabickit/ArabicSearchRangesTest.kt @@ -0,0 +1,87 @@ +package io.github.khalid567cpu.arabickit + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +public class ArabicSearchRangesTest { + @Test + public fun mapsDiacriticsAndTatweelBackToOriginalText() { + val source = "إِنَّ الــلَّهَ غَفُورٌ" + + val range = requireNotNull(ArabicSearch.findFirst(source, "ان الله")) + + assertEquals("إِنَّ الــلَّهَ", source.substring(range.start, range.endExclusive)) + } + + @Test + public fun returnsMultipleRangesInOriginalText() { + val source = "إِنَّ اللَّهَ غفور، إن الله رحيم" + + val ranges = ArabicSearch.findAll(source, "ان الله") + + assertEquals(2, ranges.size) + assertEquals("إِنَّ اللَّهَ", source.substring(ranges[0].start, ranges[0].endExclusive)) + assertEquals("إن الله", source.substring(ranges[1].start, ranges[1].endExclusive)) + } + + @Test + public fun collapsedWhitespaceMapsAcrossTheOriginalWhitespaceRun() { + val source = " مرحبا بالعالم " + + val range = requireNotNull(ArabicSearch.findFirst(source, "مرحبا بالعالم")) + + assertEquals("مرحبا بالعالم", source.substring(range.start, range.endExclusive)) + } + + @Test + public fun supportsOptionalOverlappingMatches() { + val source = "ااا" + + assertEquals(1, ArabicSearch.findAll(source, "اا").size) + assertEquals(2, ArabicSearch.findAll(source, "اا", allowOverlaps = true).size) + } + + @Test + public fun respectsMaximumResultCount() { + val ranges = ArabicSearch.findAll( + text = "الله الله الله", + query = "الله", + maxResults = 2, + ) + + assertEquals(2, ranges.size) + } + + @Test + public fun returnsNoMatchForAnEmptyNormalizedQuery() { + assertTrue(ArabicSearch.findAll("نص عربي", " ").isEmpty()) + assertNull(ArabicSearch.findFirst("نص عربي", "َـ")) + } + + @Test + public fun preservesOptionsWhenMappingRanges() { + val options = ArabicNormalizationOptions(normalizeAlef = false) + + assertNull(ArabicSearch.findFirst("إسلام", "اسلام", options)) + } + + @Test + public fun mapsLowercaseExpansionBackToOneSourceCharacter() { + val source = "İ" + + val range = requireNotNull(ArabicSearch.findFirst(source, "i̇")) + + assertEquals(ArabicMatchRange(0, 1), range) + } + + @Test + public fun mapsQuranicMarksAndAlefWasla() { + val source = "ٱللَّهُۚ" + + val range = requireNotNull(ArabicSearch.findFirst(source, "الله")) + + assertEquals(source, source.substring(range.start, range.endExclusive)) + } +} diff --git a/docs/0.2.0-search-ranges-plan.md b/docs/0.2.0-search-ranges-plan.md new file mode 100644 index 0000000..428efe2 --- /dev/null +++ b/docs/0.2.0-search-ranges-plan.md @@ -0,0 +1,26 @@ +# ArabicKit 0.2.0 — Search Ranges and Highlighting + +This document tracks the implementation plan for the next ArabicKit release. + +## Goals + +- Preserve original UTF-16 offsets while normalizing Arabic text. +- Return one or more match ranges in the original source text. +- Support optional overlapping matches. +- Highlight matches safely with Android `SpannableString`. +- Cover diacritics, Quranic marks, tatweel, whitespace collapse, and multiple matches with tests. +- Update the sample app and public documentation. + +## Public API candidates + +- `ArabicMatchRange` +- `ArabicSearch.findFirst(...)` +- `ArabicSearch.findAll(...)` +- `ArabicHighlighter.highlightBackground(...)` + +## Quality gates + +- Unit tests for range mapping. +- Android Lint with no errors. +- Sample debug build. +- GitHub Actions CI. diff --git a/sample/src/main/kotlin/io/github/khalid567cpu/arabickit/sample/MainActivity.kt b/sample/src/main/kotlin/io/github/khalid567cpu/arabickit/sample/MainActivity.kt index f960108..8ccaa24 100644 --- a/sample/src/main/kotlin/io/github/khalid567cpu/arabickit/sample/MainActivity.kt +++ b/sample/src/main/kotlin/io/github/khalid567cpu/arabickit/sample/MainActivity.kt @@ -1,7 +1,9 @@ package io.github.khalid567cpu.arabickit.sample import android.app.Activity +import android.graphics.Color import android.os.Bundle +import io.github.khalid567cpu.arabickit.ArabicHighlighter import io.github.khalid567cpu.arabickit.ArabicNormalizer import io.github.khalid567cpu.arabickit.ArabicNumeralSystem import io.github.khalid567cpu.arabickit.ArabicNumerals @@ -16,21 +18,29 @@ public class MainActivity : Activity() { binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - binding.inputText.setText("إِنَّ الــلَّهَ غَفُورٌ ٢٠٢٦") + binding.inputText.setText("إِنَّ الــلَّهَ غَفُورٌ، إن الله رحيم ٢٠٢٦") binding.runButton.setOnClickListener { renderResult() } renderResult() } private fun renderResult() { val input = binding.inputText.text.toString() + val query = "ان الله" val normalized = ArabicNormalizer.normalize(input) val westernDigits = ArabicNumerals.convert(input, ArabicNumeralSystem.WESTERN) - val matches = ArabicSearch.contains(input, "ان الله") + val ranges = ArabicSearch.findAll(input, query) binding.normalizedValue.text = normalized binding.numeralsValue.text = westernDigits - binding.searchValue.text = getString( - if (matches) R.string.search_match else R.string.search_no_match, + binding.searchValue.text = resources.getQuantityString( + R.plurals.search_matches, + ranges.size, + ranges.size, + ) + binding.highlightedValue.text = ArabicHighlighter.highlightBackground( + text = input, + query = query, + color = Color.YELLOW, ) } } diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 62f0e41..088964e 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -32,6 +32,7 @@ android:layout_marginTop="24dp" android:gravity="start|top" android:hint="@string/input_hint" + android:importantForAutofill="no" android:inputType="textMultiLine" android:minHeight="120dp" android:padding="16dp" @@ -89,5 +90,21 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:padding="12dp" /> + + + + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 56685ab..994042b 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -7,7 +7,11 @@ Run ArabicKit Normalized text Western numerals - Search for “ان الله” - Match found - No match + Matches for “ان الله” + Original-text highlighting + + + %d match + %d matches + From 671ff63e0eeea8efe79b0c59de9e0d1e98da493e Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:01:17 +0300 Subject: [PATCH 2/8] test: verify Arabic highlighting on Android runtime (#6) Adds connected-device instrumentation coverage for ArabicHighlighter, updates AndroidX test dependencies, and records successful physical-device verification before ArabicKit 0.2.0. --- arabickit/build.gradle.kts | 4 ++ .../ArabicHighlighterInstrumentedTest.kt | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 arabickit/src/androidTest/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighterInstrumentedTest.kt diff --git a/arabickit/build.gradle.kts b/arabickit/build.gradle.kts index d15ad02..d9fb526 100644 --- a/arabickit/build.gradle.kts +++ b/arabickit/build.gradle.kts @@ -11,6 +11,7 @@ android { defaultConfig { minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -37,4 +38,7 @@ android { dependencies { testImplementation("junit:junit:4.13.2") + + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") } diff --git a/arabickit/src/androidTest/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighterInstrumentedTest.kt b/arabickit/src/androidTest/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighterInstrumentedTest.kt new file mode 100644 index 0000000..1983ff4 --- /dev/null +++ b/arabickit/src/androidTest/kotlin/io/github/khalid567cpu/arabickit/ArabicHighlighterInstrumentedTest.kt @@ -0,0 +1,45 @@ +package io.github.khalid567cpu.arabickit + +import android.graphics.Color +import android.text.style.BackgroundColorSpan +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +public class ArabicHighlighterInstrumentedTest { + @Test + public fun highlightBackground_mapsNormalizedMatchesToOriginalArabicText() { + val source = "إِنَّ الــلَّهَ غَفُورٌ، إن الله رحيم ٢٠٢٦" + + val highlighted = ArabicHighlighter.highlightBackground( + text = source, + query = "ان الله", + color = Color.YELLOW, + ) + + val spans = highlighted + .getSpans(0, highlighted.length, BackgroundColorSpan::class.java) + .sortedBy(highlighted::getSpanStart) + + assertEquals(2, spans.size) + assertTrue(spans.all { it.backgroundColor == Color.YELLOW }) + + val highlightedSubstrings = spans.map { span -> + source.substring( + highlighted.getSpanStart(span), + highlighted.getSpanEnd(span), + ) + } + + assertEquals( + listOf( + "إِنَّ الــلَّهَ", + "إن الله", + ), + highlightedSubstrings, + ) + } +} From 9973cfade59d1baec8c5130a95b89e64afeb1a9a Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:01:59 +0300 Subject: [PATCH 3/8] release: set library version to 0.2.0 --- arabickit/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arabickit/build.gradle.kts b/arabickit/build.gradle.kts index d9fb526..a4e9aea 100644 --- a/arabickit/build.gradle.kts +++ b/arabickit/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "io.github.khalid567cpu" -version = "0.1.0" +version = "0.2.0" android { namespace = "io.github.khalid567cpu.arabickit" From 938fc8c0615f1a016a737371291976355c6b8992 Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:02:08 +0300 Subject: [PATCH 4/8] release: bump sample app to 0.2.0 --- sample/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c92371a..20df145 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "io.github.khalid567cpu.arabickit.sample" minSdk = 24 targetSdk = 37 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0" } compileOptions { @@ -38,4 +38,4 @@ android { dependencies { implementation(project(":arabickit")) -} \ No newline at end of file +} From a27702097c2930373cfb4a21105ccd8ee194a8de Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:02:20 +0300 Subject: [PATCH 5/8] release: finalize changelog for 0.2.0 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e7384..5c03979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format follows Keep a Changelog, and releases will follow Semantic Versionin ## [Unreleased] +## [0.2.0] - 2026-08-02 + ### Added - `ArabicMatchRange` with original UTF-16 source offsets. @@ -14,6 +16,7 @@ The format follows Keep a Changelog, and releases will follow Semantic Versionin - Offset-aware normalization across diacritics, Quranic marks, tatweel, alef variants, whitespace collapse, and Latin lowercase expansion. - Android background highlighting with `ArabicHighlighter`. - Range-search coverage in the sample app and unit tests. +- Connected-device instrumentation coverage for `ArabicHighlighter` on a physical Android runtime. ## [0.1.0] - 2026-08-02 From 2b95be784ede37b072cad76a67327bfecbc0dc4f Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:02:29 +0300 Subject: [PATCH 6/8] docs: record 0.2.0 verification status --- ROADMAP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index be45355..5333d5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,9 @@ - [x] Android `Spannable` highlighting. - [x] Multi-match and overlapping-match support. - [x] Sample and documentation updates. -- [ ] Complete CI review and publish `v0.2.0`. +- [x] Physical-device instrumentation verification. +- [x] Complete CI review. +- [ ] Publish `v0.2.0`. ## 0.3.0 — Advanced Arabic utilities From 0525b878d6678b9904e2748532aaccab3853452b Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:03:06 +0300 Subject: [PATCH 7/8] docs: prepare README for 0.2.0 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b892b8..50fa590 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Production-ready Kotlin utilities for Arabic text normalization, search, range mapping, highlighting, and numeral conversion on Android. -> ArabicKit `0.1.0` is the first public foundation release. The API is tested, but `0.x` releases may still evolve before the first stable release. +> ArabicKit `0.2.0` adds original-text search ranges, multiple-match support, and Android highlighting. The API is tested, but `0.x` releases may still evolve before the first stable release. ## Current capabilities @@ -17,7 +17,7 @@ Production-ready Kotlin utilities for Arabic text normalization, search, range m - Convert Western, Arabic-Indic, and Eastern Arabic-Indic digits. - Java-friendly APIs through `@JvmStatic` and `@JvmOverloads`. - A dependency-free Android sample app. -- Unit tests, Android Lint, and GitHub Actions CI. +- Unit tests, Android Lint, GitHub Actions CI, and connected-device instrumentation coverage. ## Modules @@ -93,11 +93,11 @@ val western = ArabicNumerals.convert( ## Releases -The first public release is `v0.1.0`. Binary AAR assets are available from GitHub Releases. +The current release is `v0.2.0`. Binary AAR assets are available from GitHub Releases. ## Project status -ArabicKit is developing `0.2.0`, focused on original-text search ranges and Android highlighting. See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the [0.2.0 implementation plan](docs/0.2.0-search-ranges-plan.md). +ArabicKit `0.2.0` delivers original-text search ranges and Android highlighting. The next planned release is `0.3.0`, focused on advanced Arabic utilities. See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the [0.2.0 implementation plan](docs/0.2.0-search-ranges-plan.md). ## Contributing From a97512f14865f92a7022289f225405cecf42bf1a Mon Sep 17 00:00:00 2001 From: Khaled Khaled Date: Sun, 2 Aug 2026 23:03:21 +0300 Subject: [PATCH 8/8] docs: prepare Arabic README for 0.2.0 --- README_AR.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README_AR.md b/README_AR.md index 57a9dad..205cd22 100644 --- a/README_AR.md +++ b/README_AR.md @@ -2,7 +2,7 @@ مكتبة Kotlin إنتاجية لمعالجة النص العربي والبحث وإرجاع مواقع النتائج والتظليل وتحويل الأرقام داخل تطبيقات Android. -> الإصدار `0.1.0` هو أول إصدار تأسيسي عام للمكتبة. الواجهات الحالية مختبرة، لكن إصدارات `0.x` قد تتطور قبل الوصول إلى أول إصدار مستقر. +> يضيف الإصدار `0.2.0` مواقع البحث داخل النص الأصلي، ودعم النتائج المتعددة، والتظليل على Android. الواجهات الحالية مختبرة، لكن إصدارات `0.x` قد تتطور قبل الوصول إلى أول إصدار مستقر. ## الإمكانات الحالية @@ -17,7 +17,7 @@ - التحويل بين الأرقام الغربية والعربية والهندية الشرقية. - واجهات سهلة للاستخدام من Kotlin وJava. - تطبيق عينة مبني بـXML وViewBinding. -- اختبارات آلية وAndroid Lint وGitHub Actions. +- اختبارات وحدات وAndroid Lint وGitHub Actions واختبار آلي على جهاز Android حقيقي. ## التحقق محليًا @@ -58,7 +58,7 @@ textView.text = highlighted ## حالة المشروع -تم نشر الإصدار العام الأول `0.1.0`. يجري الآن تطوير `0.2.0` لإضافة مواقع البحث الأصلية والتظليل على Android. +الإصدار الحالي هو `0.2.0`، ويضيف مواقع البحث داخل النص الأصلي والتظليل على Android. الإصدار المخطط التالي هو `0.3.0` للأدوات العربية المتقدمة. ## الرخصة