Skip to content

Commit d823685

Browse files
authored
Fix #4170: Adding LatexImageSpan for vertical alignment of locally rendered and cached LaTeX (#5647)
<!-- READ ME FIRST: Please fill in the explanation section below and check off every point from the Essential Checklist! --> ## Explanation <!-- - Explain what your PR does. If this PR fixes an existing bug, please include - "Fixes #bugnum:" in the explanation so that GitHub can auto-close the issue - when this PR is merged. --> Fix #4170 The PR implements `LatexImageSpan`, which calculates the appropriate ascent, descent, and alignment offsets based on the font metrics and drawable height. This ensures that LaTeX equations appear properly aligned. ## Before ![before](https://github.com/user-attachments/assets/dce66cab-2790-43da-a6b8-3facd6154b8e) ## After ![after](https://github.com/user-attachments/assets/33da46e4-e666-409f-8553-0e5c97297051) ## Essential Checklist <!-- Please tick the relevant boxes by putting an "x" in them. --> - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).
1 parent f98c410 commit d823685

File tree

2 files changed

+218
-2
lines changed

2 files changed

+218
-2
lines changed

utility/src/main/java/org/oppia/android/util/parser/html/MathTagHandler.kt

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package org.oppia.android.util.parser.html
22

33
import android.app.Application
44
import android.content.res.AssetManager
5+
import android.graphics.Canvas
6+
import android.graphics.Paint
7+
import android.graphics.drawable.Drawable
58
import android.text.Editable
69
import android.text.Spannable
710
import android.text.style.ImageSpan
@@ -60,12 +63,13 @@ class MathTagHandler(
6063
}
6164
is MathContent.MathAsLatex -> {
6265
if (cacheLatexRendering) {
63-
ImageSpan(
66+
LatexImageSpan(
6467
imageRetriever.loadMathDrawable(
6568
content.rawLatex,
6669
lineHeight,
6770
type = if (useInlineRendering) INLINE_TEXT_IMAGE else BLOCK_IMAGE
68-
)
71+
),
72+
useInlineRendering
6973
)
7074
} else {
7175
MathExpressionSpan(
@@ -144,3 +148,87 @@ class MathTagHandler(
144148
return mathVal?.let { "Math content $it" } ?: ""
145149
}
146150
}
151+
152+
/** An [ImageSpan] that vertically centers a LaTeX drawable within the surrounding text. */
153+
private class LatexImageSpan(
154+
imageDrawable: Drawable?,
155+
private val isInlineMode: Boolean
156+
) : ImageSpan(imageDrawable ?: createEmptyDrawable()) {
157+
158+
companion object {
159+
private const val INLINE_VERTICAL_SHIFT_RATIO = 0.9f
160+
161+
private fun createEmptyDrawable(): Drawable {
162+
return object : Drawable() {
163+
override fun draw(canvas: Canvas) {}
164+
override fun setAlpha(alpha: Int) {}
165+
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
166+
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSPARENT
167+
168+
init {
169+
setBounds(0, 0, 1, 1)
170+
}
171+
}
172+
}
173+
}
174+
175+
override fun getSize(
176+
paint: Paint,
177+
text: CharSequence,
178+
start: Int,
179+
end: Int,
180+
fontMetrics: Paint.FontMetricsInt?
181+
): Int {
182+
val drawableBounds = drawable.bounds
183+
val imageHeight = drawableBounds.height()
184+
val textMetrics = paint.fontMetricsInt
185+
val textHeight = textMetrics.descent - textMetrics.ascent
186+
187+
fontMetrics?.let { metrics ->
188+
if (isInlineMode) {
189+
val verticalShift = (imageHeight - textHeight) / 2 +
190+
(textMetrics.descent * INLINE_VERTICAL_SHIFT_RATIO).toInt()
191+
metrics.ascent = textMetrics.ascent - verticalShift
192+
metrics.top = metrics.ascent
193+
metrics.descent = textMetrics.descent + verticalShift
194+
metrics.bottom = metrics.descent
195+
} else {
196+
val totalHeight = (imageHeight * 1.2).toInt()
197+
metrics.ascent = -totalHeight / 2
198+
metrics.top = metrics.ascent
199+
metrics.descent = totalHeight / 2
200+
metrics.bottom = metrics.descent
201+
}
202+
}
203+
return drawableBounds.right
204+
}
205+
206+
override fun draw(
207+
canvas: Canvas,
208+
text: CharSequence,
209+
start: Int,
210+
end: Int,
211+
x: Float,
212+
lineTop: Int,
213+
baseline: Int,
214+
lineBottom: Int,
215+
paint: Paint
216+
) {
217+
canvas.save()
218+
219+
val imageHeight = drawable.bounds.height()
220+
val yOffset = if (isInlineMode) {
221+
val metrics = paint.fontMetricsInt
222+
val ascent = metrics.ascent.toFloat()
223+
val descent = metrics.descent.toFloat()
224+
val expectedCenterY = baseline.toFloat() + (ascent + descent) / 2f
225+
expectedCenterY - (imageHeight / 2f)
226+
} else {
227+
lineTop.toFloat() + (lineBottom - lineTop - imageHeight) / 2f
228+
}
229+
230+
canvas.translate(x, yOffset)
231+
drawable.draw(canvas)
232+
canvas.restore()
233+
}
234+
}

utility/src/test/java/org/oppia/android/util/parser/html/MathTagHandlerTest.kt

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package org.oppia.android.util.parser.html
22

33
import android.app.Application
44
import android.content.Context
5+
import android.graphics.Canvas
56
import android.graphics.Color
7+
import android.graphics.Paint
68
import android.text.Html
79
import android.text.Spannable
810
import android.text.style.ImageSpan
@@ -21,6 +23,8 @@ import org.junit.runner.RunWith
2123
import org.mockito.ArgumentCaptor
2224
import org.mockito.Captor
2325
import org.mockito.Mock
26+
import org.mockito.Mockito.eq
27+
import org.mockito.Mockito.mock
2428
import org.mockito.Mockito.times
2529
import org.mockito.Mockito.verify
2630
import org.mockito.Mockito.verifyNoMoreInteractions
@@ -39,6 +43,7 @@ import org.robolectric.annotation.Config
3943
import org.robolectric.annotation.LooperMode
4044
import javax.inject.Inject
4145
import javax.inject.Singleton
46+
import kotlin.math.abs
4247
import kotlin.reflect.KClass
4348

4449
private const val MATH_MARKUP_1 =
@@ -107,6 +112,129 @@ class MathTagHandlerTest {
107112
}
108113

109114
// TODO(#3085): Introduce test for verifying that the error log scenario is logged correctly.
115+
@Test
116+
fun testParseHtml_withMathMarkup_cachingOn_imageSpanHasCorrectMetrics() {
117+
118+
val parsedHtml = CustomHtmlContentHandler.fromHtml(
119+
html = MATH_WITHOUT_FILENAME_MARKUP,
120+
imageRetriever = mockImageRetriever,
121+
customTagHandlers = tagHandlersWithCachedMathSupport
122+
)
123+
val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
124+
assertThat(imageSpans).hasLength(1)
125+
126+
val paint = Paint()
127+
paint.textSize = 20f
128+
val originalMetrics = Paint.FontMetricsInt()
129+
paint.getFontMetricsInt(originalMetrics)
130+
131+
val spanMetrics = Paint.FontMetricsInt()
132+
imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, spanMetrics)
133+
134+
// The span's center should align with the text's center
135+
val originalCenter = (originalMetrics.descent + originalMetrics.ascent) / 2
136+
val spanCenter = (spanMetrics.descent + spanMetrics.ascent) / 2
137+
assertThat(abs(originalCenter - spanCenter)).isLessThan(2)
138+
}
139+
140+
@Test
141+
fun testParseHtml_withMathMarkup_cachingOn_drawsAtCorrectVerticalPosition() {
142+
143+
val parsedHtml = CustomHtmlContentHandler.fromHtml(
144+
html = MATH_WITHOUT_FILENAME_MARKUP,
145+
imageRetriever = mockImageRetriever,
146+
customTagHandlers = tagHandlersWithCachedMathSupport
147+
)
148+
149+
val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
150+
assertThat(imageSpans).hasLength(1)
151+
152+
val mockCanvas = mock(Canvas::class.java)
153+
val paint = Paint()
154+
paint.textSize = 20f
155+
156+
val metrics = paint.fontMetricsInt
157+
val y = 100
158+
159+
imageSpans[0].draw(
160+
mockCanvas,
161+
parsedHtml,
162+
0,
163+
parsedHtml.length,
164+
0f,
165+
0,
166+
y,
167+
200,
168+
paint
169+
)
170+
171+
val textHeight = (metrics.descent - metrics.ascent).toFloat()
172+
val textMidline = y.toFloat() - (textHeight / 2f)
173+
val verticalShift = metrics.descent * 0.9f
174+
val drawable = imageSpans[0].drawable
175+
val expectedTranslation = textMidline + verticalShift - (drawable.bounds.height() / 2f)
176+
177+
// The translation should position the drawable centered around the text baseline
178+
verify(mockCanvas).save()
179+
verify(mockCanvas).translate(
180+
eq(0f),
181+
capture(floatCaptor)
182+
)
183+
assertThat(floatCaptor.value).isWithin(1f).of(expectedTranslation)
184+
verify(mockCanvas).restore()
185+
}
186+
187+
@Test
188+
fun testParseHtml_withMathMarkup_cachingOn_maintainsConsistentHeight() {
189+
190+
val parsedHtml = CustomHtmlContentHandler.fromHtml(
191+
html = MATH_WITHOUT_FILENAME_MARKUP,
192+
imageRetriever = mockImageRetriever,
193+
customTagHandlers = tagHandlersWithCachedMathSupport
194+
)
195+
196+
val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
197+
assertThat(imageSpans).hasLength(1)
198+
199+
val paint = Paint()
200+
paint.textSize = 20f
201+
202+
val metrics1 = Paint.FontMetricsInt()
203+
val metrics2 = Paint.FontMetricsInt()
204+
205+
val size1 = imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics1)
206+
val size2 = imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics2)
207+
208+
assertThat(size1).isEqualTo(size2)
209+
assertThat(metrics1.ascent).isEqualTo(metrics2.ascent)
210+
assertThat(metrics1.descent).isEqualTo(metrics2.descent)
211+
assertThat(metrics1.top).isEqualTo(metrics2.top)
212+
assertThat(metrics1.bottom).isEqualTo(metrics2.bottom)
213+
}
214+
215+
@Test
216+
fun testParseHtml_withMathMarkup_cachingOn_respectsLineHeight() {
217+
218+
val parsedHtml = CustomHtmlContentHandler.fromHtml(
219+
html = MATH_WITHOUT_FILENAME_MARKUP,
220+
imageRetriever = mockImageRetriever,
221+
customTagHandlers = tagHandlersWithCachedMathSupport
222+
)
223+
224+
val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
225+
assertThat(imageSpans).hasLength(1)
226+
227+
val paint = Paint()
228+
paint.textSize = 20f
229+
230+
val metrics = Paint.FontMetricsInt()
231+
imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics)
232+
233+
// Verify that the total height does not exceed the line height
234+
val totalHeight = metrics.bottom - metrics.top
235+
val lineHeight = paint.textSize * 1.2f
236+
assertThat(totalHeight.toFloat()).isLessThan(lineHeight)
237+
}
110238

111239
@Test
112240
fun testParseHtml_emptyString_doesNotIncludeImageSpan() {

0 commit comments

Comments
 (0)