@@ -6,6 +6,7 @@ import android.graphics.Color
66import android.graphics.Paint
77import android.graphics.RectF
88import android.graphics.Typeface
9+ import android.os.Build
910import android.text.Layout
1011import android.text.StaticLayout
1112import android.text.TextPaint
@@ -41,14 +42,14 @@ class OverlayView @JvmOverloads constructor(
4142 null
4243 }
4344 } ? : Typeface .DEFAULT
44-
45+
4546 val style = when {
4647 settings.overlayFontBold && settings.overlayFontItalic -> Typeface .BOLD_ITALIC
4748 settings.overlayFontBold -> Typeface .BOLD
4849 settings.overlayFontItalic -> Typeface .ITALIC
4950 else -> Typeface .NORMAL
5051 }
51-
52+
5253 typeface = Typeface .create(baseTypeface, style)
5354 }
5455
@@ -86,17 +87,17 @@ class OverlayView @JvmOverloads constructor(
8687
8788 fun updateStreamingDeadline (incrementalTextLength : Int ) {
8889 if (incrementalTextLength <= 0 ) return
89-
90+
9091 val currentTime = System .currentTimeMillis()
9192 streamingDeadline = max(streamingDeadline, currentTime) + (incrementalTextLength.toLong() * settings.durationPerWordMs)
9293 }
9394
9495 override fun onDraw (canvas : Canvas ) {
9596 super .onDraw(canvas)
96-
97+
9798 // Get the current view's absolute position offset on the screen
9899 getLocationOnScreen(locationOnScreen)
99-
100+
100101 val screenWidth = resources.displayMetrics.widthPixels
101102 val screenHeight = resources.displayMetrics.heightPixels
102103
@@ -126,13 +127,15 @@ class OverlayView @JvmOverloads constructor(
126127 } else {
127128 drawHorizontalBlock(canvas, block, text, localLeft, localTop)
128129 }
130+ }
129131
130- if (settings.showBoxes) {
132+ if (settings.showBoxes) {
133+ textBlocks.forEach { block ->
131134 debugRect.set(
132- bounds.left.toFloat() - locationOnScreen[0 ],
133- bounds.top.toFloat() - locationOnScreen[1 ],
134- bounds.right.toFloat() - locationOnScreen[0 ],
135- bounds.bottom.toFloat() - locationOnScreen[1 ]
135+ block. bounds.left.toFloat() - locationOnScreen[0 ],
136+ block. bounds.top.toFloat() - locationOnScreen[1 ],
137+ block. bounds.right.toFloat() - locationOnScreen[0 ],
138+ block. bounds.bottom.toFloat() - locationOnScreen[1 ]
136139 )
137140 canvas.drawRect(debugRect, boxPaint)
138141 }
@@ -171,31 +174,60 @@ class OverlayView @JvmOverloads constructor(
171174 bgPaint.color = Color .argb(alpha, Color .red(it), Color .green(it), Color .blue(it))
172175 }
173176 }
174-
175- // Prioritize original line height for font size
176- val originalLineHeight = block.firstLineBounds.height().toFloat()
177- textPaint.textSize = originalLineHeight.coerceIn(12f , 120f )
178-
179- var layout = createStaticLayout(text, targetWidth.toInt())
180-
181- // Scaling strategy: fill the original area as much as possible while maintaining layout consistency
182- if (layout.height > targetHeight * 1.1f ) {
183- while (layout.height > targetHeight * 1.1f && textPaint.textSize > 12f ) {
184- textPaint.textSize - = 0.5f
185- layout = createStaticLayout(text, targetWidth.toInt())
177+
178+ // 1. Set font size based on median box height
179+ val medianBoxHeight = block.lines.map { it.bounds.height().toFloat() }.median()
180+ textPaint.textSize = medianBoxHeight.coerceIn(12f , 120f )
181+
182+ // Get font metrics for precise height calculation
183+ var fm = textPaint.fontMetrics
184+ var fontHeight = fm.descent - fm.ascent
185+
186+ // 2. Calculate line spacing multiplier precisely to match original line distance
187+ // Use fontHeight as denominator to eliminate downward drift
188+ val lineSpacingMulti = if (block.lines.size > 1 ) {
189+ val dist = block.lines.zipWithNext { a, b -> (b.bounds.top - a.bounds.top).toFloat() }
190+ .filter { it > 0 }
191+ if (dist.isNotEmpty()) (dist.median() / fontHeight).coerceIn(0.5f , 3.0f ) else 1.0f
192+ } else 1.0f
193+
194+ fun buildLayout () = StaticLayout .Builder .obtain(text, 0 , text.length, textPaint, max(10 , targetWidth.toInt()))
195+ .setAlignment(Layout .Alignment .ALIGN_NORMAL )
196+ .setLineSpacing(0f , lineSpacingMulti)
197+ .setIncludePad(false )
198+ .apply {
199+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .P ) {
200+ setUseLineSpacingFromFallbacks(false )
201+ }
186202 }
187- } else if (layout.lineCount == 1 && layout.height < targetHeight * 0.8f ) {
188- // If only one line and there's plenty of height, scale up to fill width or height
189- while (layout.height < targetHeight * 0.9f && getMaxLineWidth(layout) < targetWidth * 0.9f && textPaint.textSize < originalLineHeight * 1.2f ) {
190- textPaint.textSize + = 0.5f
191- layout = createStaticLayout(text, targetWidth.toInt())
203+ .build()
204+
205+ var layout = buildLayout()
206+
207+ // 3. Scaling strategy: shrink text if it wraps more than expected or exceeds target height
208+ val expectedLineCount = max(1 , block.lines.size)
209+ val shouldShrink = {
210+ layout.lineCount > expectedLineCount || (expectedLineCount > 1 && layout.height > targetHeight * 1.05f )
211+ }
212+
213+ if (shouldShrink()) {
214+ while (shouldShrink() && textPaint.textSize > 12f ) {
215+ textPaint.textSize - = 0.5f
216+ layout = buildLayout()
192217 }
218+ // Update metrics after final size is decided
219+ fm = textPaint.fontMetrics
220+ fontHeight = fm.descent - fm.ascent
193221 }
194222
223+
195224 val actualTextWidth = getMaxLineWidth(layout)
196225 val finalWidth = max(targetWidth, actualTextWidth)
197226 val finalHeight = max(targetHeight, layout.height.toFloat())
198227
228+ // 4. Calculate vertical offset to center the font in the box height
229+ val verticalOffset = (medianBoxHeight - fontHeight) / 2f
230+
199231 val drawRect = RectF (left, top, left + finalWidth, top + finalHeight)
200232
201233 // Check if the drawing area exceeds the current view bounds (screen bounds)
@@ -206,7 +238,8 @@ class OverlayView @JvmOverloads constructor(
206238 // Use a rectangular box for a "textbox" feel and better coverage of original text
207239 canvas.drawRect(drawRect, bgPaint)
208240
209- canvas.withTranslation(drawRect.left, drawRect.top) {
241+ // Apply translation with vertical centering offset
242+ canvas.withTranslation(drawRect.left, drawRect.top + verticalOffset) {
210243 layout.draw(this )
211244 }
212245
@@ -230,17 +263,25 @@ class OverlayView @JvmOverloads constructor(
230263 bgPaint.color = Color .argb(alpha, Color .red(it), Color .green(it), Color .blue(it))
231264 }
232265 }
233-
234- val originalColWidth = block.firstLineBounds.width().toFloat()
235- var bestTextSize = originalColWidth.coerceIn(12f , 120f )
236-
266+
267+ // Prioritize median column width for font size
268+ val medianColWidth = block.lines.map { it.bounds.width().toFloat() }.median()
269+ var bestTextSize = medianColWidth.coerceIn(12f , 120f )
270+
271+ // Calculate column spacing multiplier from original lines
272+ val colSpacingMulti = if (block.lines.size > 1 ) {
273+ val dist = block.lines.zipWithNext { a, b -> kotlin.math.abs(b.bounds.left - a.bounds.left).toFloat() }
274+ .filter { it > 0 }
275+ if (dist.isNotEmpty()) (dist.median() / medianColWidth).coerceIn(0.8f , 2.5f ) else 1.2f
276+ } else 1.2f
277+
237278 fun layoutVertical (size : Float ): List <List <String >> {
238279 textPaint.textSize = size
239280 val paragraphs = text.split(" \n " )
240281 val allCols = mutableListOf<List <String >>()
241282 val charHeight = size * 1.1f
242283 val maxCharsPerCol = (targetHeight / charHeight).toInt().coerceAtLeast(1 )
243-
284+
244285 paragraphs.forEach { para ->
245286 if (para.isEmpty()) {
246287 allCols.add(emptyList())
@@ -257,17 +298,17 @@ class OverlayView @JvmOverloads constructor(
257298 }
258299
259300 var columnGroups = layoutVertical(bestTextSize)
260- val colWidth = bestTextSize * 1.2f
301+ val colWidth = bestTextSize * colSpacingMulti
261302
262303 // Adjust vertical layout to match the original width
263304 if (columnGroups.size * colWidth > targetWidth * 1.1f ) {
264- while (columnGroups.size * (bestTextSize * 1.2f ) > targetWidth * 1.1f && bestTextSize > 12f ) {
305+ while (columnGroups.size * (bestTextSize * colSpacingMulti ) > targetWidth * 1.1f && bestTextSize > 12f ) {
265306 bestTextSize - = 0.5f
266307 columnGroups = layoutVertical(bestTextSize)
267308 }
268309 }
269310
270- val finalColWidth = bestTextSize * 1.2f
311+ val finalColWidth = bestTextSize * colSpacingMulti
271312 val totalColsWidth = columnGroups.size * finalColWidth
272313 val finalWidth = max(targetWidth, totalColsWidth)
273314
@@ -280,14 +321,19 @@ class OverlayView @JvmOverloads constructor(
280321
281322 canvas.drawRect(drawRect, bgPaint)
282323
324+ val vFm = textPaint.fontMetrics
325+ val vFontHeight = vFm.descent - vFm.ascent
326+ val vOffset = (bestTextSize - vFontHeight) / 2f
327+
283328 columnGroups.forEachIndexed { colIdx, chars ->
284329 // Vertical text is usually right-to-left
285330 val colX = drawRect.right - (colIdx + 1 ) * finalColWidth
286- var currentY = drawRect.top
331+ var currentY = drawRect.top + vOffset
287332
288333 chars.forEach { charStr ->
289334 val charW = textPaint.measureText(charStr)
290- canvas.drawText(charStr, colX + (finalColWidth - charW) / 2 , currentY + bestTextSize, textPaint)
335+ // Draw text using ascent for precise baseline alignment
336+ canvas.drawText(charStr, colX + (finalColWidth - charW) / 2 , currentY - vFm.ascent, textPaint)
291337 currentY + = bestTextSize * 1.1f
292338 }
293339 }
@@ -306,12 +352,14 @@ class OverlayView @JvmOverloads constructor(
306352 return maxW
307353 }
308354
309- private fun createStaticLayout (text : String , width : Int ): StaticLayout {
310- return StaticLayout .Builder .obtain(text, 0 , text.length, textPaint, max(10 , width))
311- .setAlignment(Layout .Alignment .ALIGN_NORMAL )
312- .setLineSpacing(0f , 1.0f )
313- .setIncludePad(false )
314- .build()
355+ private fun List<Float>.median (): Float {
356+ if (isEmpty()) throw NoSuchElementException (" Cannot calculate median of an empty list" )
357+ val sorted = sorted()
358+ return if (size % 2 == 0 ) {
359+ (sorted[size / 2 - 1 ] + sorted[size / 2 ]) / 2f
360+ } else {
361+ sorted[size / 2 ]
362+ }
315363 }
316364
317365 private fun calculateDelay (): Long {
@@ -320,7 +368,7 @@ class OverlayView @JvmOverloads constructor(
320368 }
321369
322370 val currentTime = System .currentTimeMillis()
323-
371+
324372 // Explicit check: whether in "streaming translation" mode that requires cumulative budget
325373 val isStreamingFlow = settings.enableStreaming && ! settings.ocrOnly
326374
0 commit comments