Add a tap-a-word dictionary, layering Wiktionary and Daneshjoo
Tapping a word in a poem now opens its meaning. Neither source covers enough alone — Daneshjoo reaches 71% of real poem vocabulary — so both ship, each row carrying its source so the credit stays attached and either can be dropped later. Together with the lookup chain that reaches 88%, and 13 of the 55 remaining misses are Arabic lines quoted inside Persian poems. Lookup widens until something matches: the word as written, the lemma it inflects from, the word with an affix stripped, then the parts of a ZWNJ compound. The lemma step is what makes classical verse readable, and it comes from Wiktionary's 149,589 form->lemma pairs: افتاد is only findable as افتادن. The Arabic definite article is stripped too, since poems quote Arabic. Two things the build taught me. Wiktionary's 93 MB export is 0.94 MB of definitions wrapped in inflection tables, etymology templates and IPA, so tools/build_dictionary.py keeps the definitions and the form index and drops the rest. And the packager silently gunzips .gz assets and strips the extension, which shipped the database under a name the code wasn't opening — every lookup failed silently until the APK listing gave it away. Hit testing needed care as well: nastaliq is set with 2.4x leading, so most of a line box is empty space, and a tap there clamps to the line's first character — which made every tap return the opening word. Taps are now checked against the baseline band, with the multipliers left as a knob for swapped fonts. Definitions are English, so they read left-to-right inside the otherwise right-to-left sheet. Verified on the release build, where R8 could have broken the SQLite path: tapping عشق returns both sources, and دانند resolves to دانستن. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b545220bbc
commit
c548e6ec07
13 files changed
+566
-16
No files matched your search
Binary file not shown.
@@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import com.ganjoor.android.data.Bookmarks
|
||||
import com.ganjoor.android.data.Dictionary
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.LocalBookmarks
|
||||
import com.ganjoor.android.ui.GanjoorApp
|
||||
@@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Ganjoor.init(applicationContext)
|
||||
Dictionary.init(applicationContext)
|
||||
|
||||
// Before the first frame: otherwise the window keeps the platform's white through
|
||||
// startup and every screen transition, whatever theme is chosen.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.ganjoor.android.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.Normalizer
|
||||
|
||||
|
||||
/** One definition, and where it came from, so the credit stays attached to the text. */
|
||||
data class Definition(val word: String, val gloss: String, val source: String)
|
||||
|
||||
/**
|
||||
* Word lookup over a bundled SQLite built from Wiktionary (CC BY-SA 3.0) and Daneshjoo.
|
||||
* See `tools/build_dictionary.py`; the asset ships gzipped and is unpacked once on first use.
|
||||
*/
|
||||
object Dictionary {
|
||||
/** Guards against a copy interrupted half-way leaving an unopenable file behind. */
|
||||
private const val ASSET_BYTES = 22_298_624L
|
||||
|
||||
// Not a .gz: the build packager silently gunzips those and drops the extension, which left
|
||||
// the asset under a different name than the code was opening.
|
||||
private const val ASSET = "dictionary.db"
|
||||
private lateinit var appContext: Context
|
||||
|
||||
@Volatile
|
||||
private var db: SQLiteDatabase? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun open(): SQLiteDatabase? {
|
||||
db?.let { return it }
|
||||
return synchronized(this) {
|
||||
db ?: runCatching {
|
||||
val file = File(appContext.filesDir, "dictionary.db")
|
||||
if (file.length() != ASSET_BYTES) {
|
||||
appContext.assets.open(ASSET).use { input ->
|
||||
file.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
}
|
||||
SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
|
||||
}.getOrNull()?.also { db = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks a word up, widening the search until something matches:
|
||||
*
|
||||
* 1. the word as written;
|
||||
* 2. the lemma it inflects from — Persian verbs conjugate heavily, and `افتاد` is only
|
||||
* findable as `افتادن`;
|
||||
* 3. the word with a common prefix or suffix removed;
|
||||
* 4. the parts of a ZWNJ compound, so `بیروزی` finds `روزی`.
|
||||
*/
|
||||
suspend fun lookup(raw: String): List<Definition> = withContext(Dispatchers.IO) {
|
||||
val database = open() ?: return@withContext emptyList()
|
||||
val word = normalise(raw)
|
||||
if (word.isEmpty()) return@withContext emptyList()
|
||||
|
||||
direct(database, word)
|
||||
.ifEmpty { lemmas(database, word).flatMap { direct(database, it) } }
|
||||
.ifEmpty { affixes(word).firstNotNullOfOrNull { direct(database, it).ifEmpty { null } }.orEmpty() }
|
||||
.ifEmpty {
|
||||
normalise(raw, keepZwnj = true).split(ZWNJ)
|
||||
.filter { it.length > 1 }
|
||||
.firstNotNullOfOrNull { direct(database, normalise(it)).ifEmpty { null } }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun direct(database: SQLiteDatabase, word: String): List<Definition> =
|
||||
database.rawQuery(
|
||||
"SELECT display, gloss, source FROM entry WHERE word = ? LIMIT 12",
|
||||
arrayOf(word),
|
||||
).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
add(Definition(cursor.getString(0), cursor.getString(1), cursor.getString(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lemmas(database: SQLiteDatabase, form: String): List<String> =
|
||||
database.rawQuery(
|
||||
"SELECT lemma FROM form WHERE form = ? LIMIT 6",
|
||||
arrayOf(form),
|
||||
).use { cursor ->
|
||||
buildList { while (cursor.moveToNext()) add(cursor.getString(0)) }
|
||||
}
|
||||
}
|
||||
|
||||
private const val ZWNJ = ''
|
||||
|
||||
private val SUFFIXES = listOf("ها", "اش", "ش", "م", "ت", "را", "ی", "ان")
|
||||
// "ال" is the Arabic definite article: poems quote Arabic, so السّاقی has to reach ساقی.
|
||||
private val PREFIXES = listOf("ال", "می", "بر", "ب")
|
||||
|
||||
/** Candidate stems after stripping one common affix. Order matters: longest affix first. */
|
||||
internal fun affixes(word: String): List<String> = buildList {
|
||||
SUFFIXES.forEach { if (word.endsWith(it) && word.length > it.length + 1) add(word.dropLast(it.length)) }
|
||||
PREFIXES.forEach { if (word.startsWith(it) && word.length > it.length + 1) add(word.drop(it.length)) }
|
||||
}
|
||||
|
||||
private val HARAKAT = (0x064B..0x0652) + listOf(0x0670, 0x0640) + (0x0610..0x0615)
|
||||
|
||||
/**
|
||||
* Folds a word to the form the dictionary is keyed by: diacritics dropped, and the Arabic
|
||||
* letters Persian writes differently (ي ك ة) folded to their Persian shapes, because poems and
|
||||
* dictionary headwords disagree about them constantly.
|
||||
*/
|
||||
internal fun normalise(text: String, keepZwnj: Boolean = false): String {
|
||||
val stripped = Normalizer.normalize(text, Normalizer.Form.NFD)
|
||||
.filter { it.code !in HARAKAT }
|
||||
val folded = Normalizer.normalize(stripped, Normalizer.Form.NFC).map { char ->
|
||||
when (char) {
|
||||
'ي', 'ى' -> 'ی'
|
||||
'ك' -> 'ک'
|
||||
'ة' -> 'ه'
|
||||
else -> char
|
||||
}
|
||||
}.joinToString("")
|
||||
return (if (keepZwnj) folded else folded.replace(ZWNJ.toString(), "")).trim()
|
||||
}
|
||||
|
||||
/** The whole word surrounding [index], for turning a tap into something to look up. */
|
||||
internal fun wordAt(text: String, index: Int): String? {
|
||||
if (text.isEmpty()) return null
|
||||
val at = index.coerceIn(0, text.length - 1)
|
||||
if (!text[at].isWordChar()) return null
|
||||
var start = at
|
||||
while (start > 0 && text[start - 1].isWordChar()) start--
|
||||
var end = at
|
||||
while (end < text.length - 1 && text[end + 1].isWordChar()) end++
|
||||
return text.substring(start, end + 1).trim(ZWNJ).takeIf { it.length > 1 }
|
||||
}
|
||||
|
||||
private fun Char.isWordChar() = this in ''..'ۿ' || this == ZWNJ
|
||||
@@ -62,6 +62,19 @@ private val CREDITS = listOf(
|
||||
"No licence stated by the publisher",
|
||||
url = "https://github.com/anas-rashid/ganjoor-data",
|
||||
),
|
||||
Credit(
|
||||
"Wiktionary",
|
||||
"Wiktionary contributors — the word definitions, and the inflected-form index that " +
|
||||
"finds a conjugated verb's dictionary entry",
|
||||
"CC BY-SA 3.0. The bundled dictionary is therefore also CC BY-SA 3.0.",
|
||||
url = "https://en.wiktionary.org",
|
||||
),
|
||||
Credit(
|
||||
"Daneshjoo Dictionary",
|
||||
"Layered under Wiktionary for the words it doesn't carry",
|
||||
"The source repository states MIT",
|
||||
url = "https://github.com/0xdolan/Daneshjoo",
|
||||
),
|
||||
Credit(
|
||||
"Noto Naskh Arabic",
|
||||
"The Noto Project Authors",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ganjoor.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,7 +35,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -43,6 +48,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Bookmark
|
||||
import com.ganjoor.android.data.breadcrumbs
|
||||
import com.ganjoor.android.data.wordAt
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.LocalBookmarks
|
||||
import com.ganjoor.android.data.PoemRef
|
||||
@@ -61,6 +67,8 @@ fun PoemScreen(
|
||||
) {
|
||||
BackHandler(onBack = onUp)
|
||||
|
||||
var tappedWord by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
|
||||
val prefs = LocalSettings.current.value
|
||||
val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight)
|
||||
@@ -128,6 +136,7 @@ fun PoemScreen(
|
||||
style = style,
|
||||
showSummaries = prefs.showSummaries,
|
||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||
onWord = { tappedWord = it },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,6 +168,10 @@ fun PoemScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tappedWord?.let { word ->
|
||||
WordSheet(word = word, onDismiss = { tappedWord = null })
|
||||
}
|
||||
}
|
||||
|
||||
/** The poem's path, with every ancestor tappable: poet » book » section » this poem. */
|
||||
@@ -216,28 +229,19 @@ private fun Couplet(
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
showSummaries: Boolean,
|
||||
source: Bookmark,
|
||||
onWord: (String) -> Unit,
|
||||
) {
|
||||
// Tap, not long-press: long-press belongs to the text selection this sits inside.
|
||||
var actionsOpen by remember(couplet) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { actionsOpen = !actionsOpen }
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
couplet.forEach { verse ->
|
||||
Text(
|
||||
text = verse.text,
|
||||
VerseText(
|
||||
verse = verse,
|
||||
style = style,
|
||||
textAlign = when (verse.position) {
|
||||
Verse.RIGHT -> TextAlign.Start
|
||||
Verse.LEFT -> TextAlign.End
|
||||
Verse.CENTERED_1, Verse.CENTERED_2 -> TextAlign.Center
|
||||
// Single / Paragraph / Comment: prose, so let it fill the column.
|
||||
else -> TextAlign.Justify
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onWord = onWord,
|
||||
// A tap that lands between words still opens the couplet's own actions.
|
||||
onElsewhere = { actionsOpen = !actionsOpen },
|
||||
)
|
||||
}
|
||||
if (showSummaries) {
|
||||
@@ -258,6 +262,67 @@ private fun Couplet(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One hemistich. Tapping a word looks it up; tapping between words falls through to the
|
||||
* couplet's save and copy actions, so both live on the same gesture without fighting.
|
||||
*/
|
||||
@Composable
|
||||
private fun VerseText(
|
||||
verse: Verse,
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
onWord: (String) -> Unit,
|
||||
onElsewhere: () -> Unit,
|
||||
) {
|
||||
var layout by remember(verse.text) { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val fontSizePx = with(LocalDensity.current) { style.fontSize.toPx() }
|
||||
|
||||
Text(
|
||||
text = verse.text,
|
||||
style = style,
|
||||
textAlign = when (verse.position) {
|
||||
Verse.RIGHT -> TextAlign.Start
|
||||
Verse.LEFT -> TextAlign.End
|
||||
Verse.CENTERED_1, Verse.CENTERED_2 -> TextAlign.Center
|
||||
// Single / Paragraph / Comment: prose, so let it fill the column.
|
||||
else -> TextAlign.Justify
|
||||
},
|
||||
onTextLayout = { layout = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(verse.text) {
|
||||
detectTapGestures { position ->
|
||||
val word = layout?.let { wordTappedAt(it, verse.text, position, fontSizePx) }
|
||||
if (word != null) onWord(word) else onElsewhere()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The word actually under [position], or null if the tap missed the glyphs.
|
||||
*
|
||||
* getOffsetForPosition alone isn't enough: nastaliq is set with 2.4x leading, so most of a line
|
||||
* box is empty space above the glyphs, and a tap there clamps to the line's first character —
|
||||
* which made every tap return the opening word. Checking the character's own bounding box is
|
||||
* what distinguishes "on a word" from "in the gap between lines".
|
||||
*/
|
||||
internal fun wordTappedAt(
|
||||
layout: TextLayoutResult,
|
||||
text: String,
|
||||
position: Offset,
|
||||
fontSizePx: Float,
|
||||
): String? {
|
||||
if (text.isEmpty() || fontSizePx <= 0f) return null
|
||||
val offset = layout.getOffsetForPosition(position).coerceIn(0, text.length - 1)
|
||||
val baseline = layout.getLineBaseline(layout.getLineForOffset(offset))
|
||||
// The band the ink actually occupies, measured from the baseline. Nastaliq hangs far above
|
||||
// it and dips a little below; these two multipliers are the knob to turn if a font is
|
||||
// swapped and taps start feeling off.
|
||||
if (position.y < baseline - fontSizePx * 1.4f) return null
|
||||
if (position.y > baseline + fontSizePx * 0.6f) return null
|
||||
return wordAt(text, offset)
|
||||
}
|
||||
|
||||
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
|
||||
@Composable
|
||||
private fun PassageActions(passage: Bookmark) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Definition
|
||||
import com.ganjoor.android.data.Dictionary
|
||||
import com.ganjoor.android.ui.theme.readingStyle
|
||||
|
||||
/** English prose inside an otherwise right-to-left sheet. */
|
||||
@Composable
|
||||
private fun LeftToRight(content: @Composable () -> Unit) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr, content = content)
|
||||
}
|
||||
|
||||
/** What the dictionary knows about a tapped word. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WordSheet(word: String, onDismiss: () -> Unit) {
|
||||
val prefs = LocalSettings.current.value
|
||||
val definitions by produceState<List<Definition>?>(null, word) { value = Dictionary.lookup(word) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The headword in the reading font, at reading size: it is a line of poetry, after all.
|
||||
Text(
|
||||
text = word,
|
||||
style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
when {
|
||||
definitions == null -> CircularProgressIndicator(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
definitions!!.isEmpty() -> LeftToRight {
|
||||
Text(
|
||||
text = stringResource(R.string.no_definition),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> definitions!!.forEach { definition ->
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// The headword actually matched, which may be the lemma rather than the
|
||||
// word as it appears in the line. Persian, so it stays right-to-left.
|
||||
if (definition.word != word) {
|
||||
Text(
|
||||
text = definition.word,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// The definitions are English; right-aligning them reads badly.
|
||||
LeftToRight {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(definition.gloss, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (definition.source == "wiktionary") {
|
||||
R.string.source_wiktionary
|
||||
} else {
|
||||
R.string.source_daneshjoo
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,8 @@
|
||||
<string name="theme_black">سیاه (OLED)</string>
|
||||
<string name="about">درباره و پروانهها</string>
|
||||
<string name="about_intro">گنجور برای اندروید نرمافزار آزاد است. شعرها، قلمها و همهٔ کتابخانههایی که این برنامه بر آنها ساخته شده در زیر آمدهاند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید.</string>
|
||||
<string name="no_definition">برای این واژه مدخلی یافت نشد.</string>
|
||||
<string name="source_wiktionary">ویکیواژه (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">فرهنگ دانشجو</string>
|
||||
<string name="dictionary">لغتنامه</string>
|
||||
</resources>
|
||||
@@ -61,4 +61,8 @@
|
||||
<string name="theme_black">سیاہ (OLED)</string>
|
||||
<string name="about">تعارف اور لائسنس</string>
|
||||
<string name="about_intro">گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔</string>
|
||||
<string name="no_definition">اس لفظ کا کوئی اندراج نہیں ملا۔</string>
|
||||
<string name="source_wiktionary">ویکی لغت (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">دانشجو لغت</string>
|
||||
<string name="dictionary">لغت نامہ</string>
|
||||
</resources>
|
||||
@@ -66,4 +66,8 @@
|
||||
<string name="theme_black">Black (OLED)</string>
|
||||
<string name="about">About & licences</string>
|
||||
<string name="about_intro">Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence.</string>
|
||||
<string name="no_definition">No entry for this word.</string>
|
||||
<string name="source_wiktionary">Wiktionary (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">Daneshjoo Dictionary</string>
|
||||
<string name="dictionary">Dictionary</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user