Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
17910d7
Add transparent image detection and math tag validation for lesson as…
nikhilkumarpanigrahi Mar 12, 2026
8db3e0e
Fix regex line length to comply with ktlint 100-char limit
nikhilkumarpanigrahi Feb 28, 2026
2609c7f
Remove KDoc comment to match codebase style
nikhilkumarpanigrahi Feb 28, 2026
e27e6ce
Address review: remove SVG fallback check, fix summary, add tests
nikhilkumarpanigrahi Mar 6, 2026
2ac8193
Fix RAW_LATEX_REGEX to correctly match standard Oppia math content fo…
nikhilkumarpanigrahi Mar 12, 2026
50b8ace
Fix Bazel cross-module visibility for math tag validation tests
nikhilkumarpanigrahi Mar 12, 2026
7c5b892
Fix ktlint formatting for MATH_TAG_REGEX multiline expression
nikhilkumarpanigrahi Mar 12, 2026
5c2a9ab
Fix #6086: address review by sharing math tag parsing utility
nikhilkumarpanigrahi Mar 19, 2026
9911111
Fix #6086: make math tag regex non-greedy in LocalizationTracker
nikhilkumarpanigrahi Mar 19, 2026
3003dae
Fix #6086: fix scripts ktlint max-line-length issues
nikhilkumarpanigrahi Mar 19, 2026
e9bbb0d
Fix #6086: address reviewer follow-ups in GAE scripts
nikhilkumarpanigrahi Mar 20, 2026
707c802
Address scripts static check issues for GAE proto updates
nikhilkumarpanigrahi Mar 20, 2026
17b72b1
Fix oppia#6086: Address reviewer feedback - revert unrelated changes …
nikhilkumarpanigrahi Mar 27, 2026
561738b
Merge remote-tracking branch 'upstream/introduce-asset-download-scrip…
nikhilkumarpanigrahi Mar 27, 2026
7d97b71
Fix scripts CI failures after base sync
nikhilkumarpanigrahi Mar 27, 2026
24f6060
Fix buildifier warning in scripts proto tests BUILD file
nikhilkumarpanigrahi Mar 27, 2026
f008503
Fix moshi runtime deps for Proguard builds
nikhilkumarpanigrahi Mar 27, 2026
b74ec9f
Fix static checks and shard test failures
nikhilkumarpanigrahi Mar 28, 2026
9ff0341
Fix ktlint line length issues
nikhilkumarpanigrahi Mar 28, 2026
dfee115
Handle maven repo key fallback for aar artifacts
nikhilkumarpanigrahi Mar 28, 2026
db7d94e
Use textproto exemption paths in static checks
nikhilkumarpanigrahi Mar 28, 2026
1ca158e
Enhance test coverage for math-tag and image validation
nikhilkumarpanigrahi Mar 28, 2026
cab5739
Fix CI failures in maven dependency checks
nikhilkumarpanigrahi Mar 28, 2026
8759fad
Remove unrelated TestEnvironmentConfig exemption
nikhilkumarpanigrahi Mar 31, 2026
825ee16
Revert "Remove unrelated TestEnvironmentConfig exemption"
nikhilkumarpanigrahi Mar 31, 2026
9e6794d
Clean PR scope: revert unrelated changes from #6144
nikhilkumarpanigrahi Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,9 @@ kt_jvm_library(
name = "image_repairer",
testonly = True,
srcs = ["ImageRepairer.kt"],
visibility = [
"//scripts/src/java/org/oppia/android/scripts/assets:__pkg__",
"//scripts/src/javatests/org/oppia/android/scripts/assets:__pkg__",
],
deps = ["//third_party:com_github_weisj_jsvg"],
)
Original file line number Diff line number Diff line change
Expand Up @@ -770,14 +770,29 @@ class LessonDownloader(
}
}

val transparentImages = memoizedLoadedImageData.filter { (file, data) ->
imageRepairer.hasTransparentPixels(data, file.extension)
}.keys.toList()
println()
println(
"Transparent image check: ${transparentImages.size} image(s) found with transparency."
)
if (transparentImages.isNotEmpty()) {
println("Images with transparent pixels (may cause dark mode visibility issues):")
transparentImages.forEach { file ->
println("- ${file.name}")
}
}

if (renamedImages.isNotEmpty() || convertedImages.isNotEmpty()) {
println("WARNING: Images needed to be auto-fixed. Please verify that they are correct")
println("(look at above output for specific images that require verification).")
}

val hasAnyFailure = (
issues.isNotEmpty() || imageDownloadFailures.isNotEmpty() ||
renamedImages.isNotEmpty() || convertedImages.isNotEmpty()
renamedImages.isNotEmpty() || convertedImages.isNotEmpty() ||
transparentImages.isNotEmpty()
)
if (hasAnyFailure && failOnError) {
throw Exception(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ class ImageRepairer {
return areImagesEqual(image1, image2)
}

fun hasTransparentPixels(imageData: ByteArray, extension: String): Boolean {
if (extension.equals("svg", ignoreCase = true)) return false
val image = imageData.inputStream().use { ImageIO.read(it) }
?: error("Failed to read image data (extension: $extension, size: ${imageData.size} bytes)")
if (!image.colorModel.hasAlpha()) return false
for (y in 0 until image.height) {
for (x in 0 until image.width) {
val alpha = (image.getRGB(x, y) shr 24) and 0xff
if (alpha < FULLY_OPAQUE_ALPHA) return true
}
Comment on lines +58 to +62
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I’m keeping this PR scoped to the reviewer-requested correctness fixes and not adding performance refactors here. I can follow up with a separate optimization PR for the transparent-pixel scan if needed.

}
return false
}

sealed class RepairedImage {
data class RenderedSvg(
val pngContents: List<Byte>,
Expand All @@ -75,6 +89,7 @@ class ImageRepairer {
private val WIDTH_REGEX by lazy { "width_(\\d+)".toRegex() }
private val HEIGHT_REGEX by lazy { "height_(\\d+)".toRegex() }
private val TRANSPARENT = Color(/* r = */ 0, /* g = */ 0, /* b = */ 0, /* a = */ 0)
private const val FULLY_OPAQUE_ALPHA = 255

private const val REFERENCE_MONITOR_PPI = 81.589f
private const val RELATIVE_SIZE_ADJUSTMENT_FACTOR = 0.15f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ kt_jvm_library(
],
visibility = [
"//scripts/src/java/org/oppia/android/scripts/gae:__subpackages__",
"//scripts/src/javatests/org/oppia/android/scripts/gae/compat:__pkg__",
],
deps = [
"//scripts/src/java/org/oppia/android/scripts/gae/json:api",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.oppia.android.scripts.gae.compat
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.AudioVoiceoverHasInvalidAudioFormat
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.HtmlInTitleOrDescription
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.HtmlUnexpectedlyInUnicodeContent
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.MathTagMissingRawLatex
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.MissingRequiredXlationLangForContentTranslation
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.MissingRequiredXlationLangForTitleOrDescFromWeb
import org.oppia.android.scripts.gae.compat.StructureCompatibilityChecker.CompatibilityFailure.StateHasInvalidInteractionId
Expand Down Expand Up @@ -42,6 +43,7 @@ import org.oppia.android.scripts.gae.json.GaeWrittenTranslation
import org.oppia.android.scripts.gae.json.GaeWrittenTranslations
import org.oppia.android.scripts.gae.json.VersionedStructure
import org.oppia.android.scripts.gae.proto.LocalizationTracker
import org.oppia.android.scripts.gae.proto.LocalizationTracker.Companion.extractMathContentsFromHtml
import org.oppia.android.scripts.gae.proto.LocalizationTracker.Companion.parseColorRgb
import org.oppia.android.scripts.gae.proto.LocalizationTracker.Companion.resolveLanguageCode
import org.oppia.android.scripts.gae.proto.LocalizationTracker.ContainerId
Expand Down Expand Up @@ -476,6 +478,11 @@ class StructureCompatibilityChecker(
val stateName: String,
override val origin: ContainerId
) : CompatibilityFailure()

data class MathTagMissingRawLatex(
val contentId: String,
override val origin: ContainerId
) : CompatibilityFailure()
}

private fun String.checkIsValidTopicId(origin: ContainerId): List<CompatibilityFailure> {
Expand Down Expand Up @@ -591,7 +598,9 @@ class StructureCompatibilityChecker(
} ?: TextHasInvalidTags(contentId, extraTags, origin)
listOf(failure)
} else emptyList()
return tagFailures + checkHasValidImageReferences(origin, contentId)
return tagFailures +
checkHasValidImageReferences(origin, contentId) +
checkHasValidMathTags(origin, contentId)
}

private fun String.checkHasValidImageReferences(
Expand All @@ -608,6 +617,11 @@ class StructureCompatibilityChecker(
)
}

private fun String.checkHasValidMathTags(
origin: ContainerId,
contentId: String
): List<CompatibilityFailure> = checkMathTagsForLatex(this, origin, contentId)

private fun Int.checkIsValidStateSchemaVersion(origin: ContainerId): List<CompatibilityFailure> {
return if (this > constraints.supportedStateSchemaVersion) {
listOf(StateSchemaVersionTooNew(schemaVersion = this, origin))
Expand All @@ -623,13 +637,27 @@ class StructureCompatibilityChecker(
} else emptyList()
}

private companion object {
companion object {
private val HTML_PRESENCE_REGEX = "</?.+?>".toRegex()
// This regex is a simplification of the standard: https://www.w3.org/TR/xml/#NT-NameStartChar.
private val HTML_TAG_REGEX = "<\\s*([^\\s/>]+)[^>]*?>".toRegex()
private val IMAGE_TAG_REGEX = "<\\s*oppia-noninteractive-image.+?>".toRegex()
private val IMAGE_FILE_PATH_REGEX = "filepath-with-value\\s*=\\s*\"(.+?)\"".toRegex()

fun checkMathTagsForLatex(
html: String,
origin: ContainerId,
contentId: String
): List<CompatibilityFailure> {
return extractMathContentsFromHtml(html).mapNotNull { mathContent ->
if (mathContent?.rawLatex.isNullOrBlank()) {
MathTagMissingRawLatex(contentId, origin)
} else {
null
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I did not apply this changeset because filtering out null parse results would suppress malformed math_content-with-value tags instead of reporting them. For now, parse failures are intentionally treated as invalid math tags and reported via the existing failure path. If desired, I can add a dedicated parse-failure CompatibilityFailure in a follow-up PR.

}

private fun String.checkTitleOrDescTextForHtml(
origin: ContainerId
): List<CompatibilityFailure> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,11 +671,17 @@ class LocalizationTracker private constructor(
private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value"
private const val CUSTOM_MATH_TAG = "oppia-noninteractive-math"
private const val CUSTOM_MATH_SVG_PATH_ATTRIBUTE = "math_content-with-value"
private val customMathTagContentRegex by lazy {
Regex(
"<\\s*$CUSTOM_MATH_TAG[^>]*?>.*?</\\s*$CUSTOM_MATH_TAG\\s*>",
setOf(RegexOption.DOT_MATCHES_ALL)
)
}
private val customImageTagRegex by lazy {
Regex("<\\s*$CUSTOM_IMG_TAG.+?$CUSTOM_IMG_FILE_PATH_ATTRIBUTE\\s*=\\s*\"(.+?)\"")
}
private val customMathTagRegex by lazy {
Regex("<\\s*$CUSTOM_MATH_TAG.+?$CUSTOM_MATH_SVG_PATH_ATTRIBUTE\\s*=\\s*\"(.+?)\"")
Regex("$CUSTOM_MATH_SVG_PATH_ATTRIBUTE\\s*=\\s*\"(.+?)\"")
}
val VALID_LANGUAGE_TYPES = LanguageType.values().filter { it.isValid() }

Expand Down Expand Up @@ -727,14 +733,32 @@ class LocalizationTracker private constructor(
}

private fun collectMathSourcesFromHtml(html: String): Set<String> {
return customMathTagRegex.findAll(html)
.map { it.destructured }
.map { (match) -> match }
.map { it.replace("&amp;quot;", "\"") }
.map { MathContentValue.parseFromHtmlValue(it) }
return extractMathContentsFromHtml(html)
.mapNotNull { it }
.map { it.svgFilename }
.filter { it.isNotEmpty() } // Ignore incorrect missing images.
.toSet()
}

fun extractMathContentsFromHtml(html: String): List<MathContentValue?> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return should be non-null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed extractMathContentsFromHtml() return type to List (non-nullable)

return extractMathTagContentValuesFromHtml(html).map { mathContentJson ->
mathContentJson?.let {
try {
MathContentValue.parseFromHtmlValue(it)
} catch (_: Exception) {
null
}
}
}
}

fun extractMathTagContentValuesFromHtml(html: String): List<String?> {
return customMathTagContentRegex.findAll(html).map { tagMatch ->
val tagContent = tagMatch.value
customMathTagRegex.find(tagContent)
?.destructured?.let { (match) -> match }
?.replace("&amp;quot;", "\"")
}.toList()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Tests corresponding to asset transformation scripts.
"""

load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test")

kt_jvm_test(
name = "ImageRepairerTest",
srcs = ["ImageRepairerTest.kt"],
deps = [
"//scripts/src/java/org/oppia/android/scripts/assets:image_repairer",
"//third_party:com_google_truth_truth",
"//third_party:org_jetbrains_kotlin_kotlin-test-junit",
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package org.oppia.android.scripts.assets

import com.google.common.truth.Truth.assertThat
import org.junit.Test
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO

// Function name: test names are conventionally named with underscores.
@Suppress("FunctionName")
class ImageRepairerTest {
private val imageRepairer = ImageRepairer()

@Test
fun testHasTransparentPixels_opaqueImage_returnsFalse() {
val imageData = createPngImageData(BufferedImage.TYPE_INT_ARGB) { graphics ->
graphics.color = Color(255, 0, 0, 255)
graphics.fillRect(0, 0, 2, 2)
}

assertThat(imageRepairer.hasTransparentPixels(imageData, "png")).isFalse()
}

@Test
fun testHasTransparentPixels_fullyTransparentImage_returnsTrue() {
val imageData = createPngImageData(BufferedImage.TYPE_INT_ARGB) { graphics ->
graphics.color = Color(0, 0, 0, 0)
graphics.fillRect(0, 0, 2, 2)
}

assertThat(imageRepairer.hasTransparentPixels(imageData, "png")).isTrue()
}

@Test
fun testHasTransparentPixels_partiallyTransparentPixel_returnsTrue() {
val imageData = createPngImageData(BufferedImage.TYPE_INT_ARGB) { graphics ->
graphics.color = Color(255, 0, 0, 128)
graphics.fillRect(0, 0, 2, 2)
}

assertThat(imageRepairer.hasTransparentPixels(imageData, "png")).isTrue()
}

@Test
fun testHasTransparentPixels_imageWithoutAlphaChannel_returnsFalse() {
val imageData = createPngImageData(BufferedImage.TYPE_INT_RGB) { graphics ->
graphics.color = Color.RED
graphics.fillRect(0, 0, 2, 2)
}

assertThat(imageRepairer.hasTransparentPixels(imageData, "png")).isFalse()
}

@Test
fun testHasTransparentPixels_svgExtension_returnsFalse() {
val imageData = "<svg></svg>".toByteArray()

assertThat(imageRepairer.hasTransparentPixels(imageData, "svg")).isFalse()
}

@Test
fun testHasTransparentPixels_svgExtensionUpperCase_returnsFalse() {
val imageData = "<svg></svg>".toByteArray()

assertThat(imageRepairer.hasTransparentPixels(imageData, "SVG")).isFalse()
}

@Test
fun testHasTransparentPixels_mixedOpaqueAndTransparent_returnsTrue() {
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
image.setRGB(0, 0, Color(255, 0, 0, 255).rgb)
image.setRGB(1, 0, Color(0, 255, 0, 255).rgb)
image.setRGB(0, 1, Color(0, 0, 255, 100).rgb)
image.setRGB(1, 1, Color(255, 255, 0, 255).rgb)
val imageData = ByteArrayOutputStream().also {
ImageIO.write(image, "png", it)
}.toByteArray()

assertThat(imageRepairer.hasTransparentPixels(imageData, "png")).isTrue()
}

private fun createPngImageData(
imageType: Int,
draw: (java.awt.Graphics2D) -> Unit
): ByteArray {
val image = BufferedImage(2, 2, imageType)
val graphics = image.createGraphics()
draw(graphics)
graphics.dispose()
return ByteArrayOutputStream().also {
ImageIO.write(image, "png", it)
}.toByteArray()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
Tests corresponding to structure compatibility checking scripts.
"""

load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test")

kt_jvm_test(
name = "StructureCompatibilityCheckerTest",
srcs = ["StructureCompatibilityCheckerTest.kt"],
deps = [
"//scripts/src/java/org/oppia/android/scripts/gae/compat",
"//scripts/src/java/org/oppia/android/scripts/gae/proto:localization_tracker",
"//third_party:com_google_truth_truth",
"//third_party:org_jetbrains_kotlin_kotlin-test-junit",
],
)
Loading
Loading