Skip to content

Commit 6df5864

Browse files
authored
Merge pull request #378 from rosuH/codex/s2a-watermark-renderer-extraction
refactor: extract Android watermark renderer seam
2 parents 934cd15 + 04fd6bb commit 6df5864

4 files changed

Lines changed: 460 additions & 208 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package me.rosuh.easywatermark.render
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.BitmapShader
5+
import android.graphics.Canvas
6+
import android.graphics.Color
7+
import android.graphics.Paint
8+
import android.graphics.Shader
9+
import android.text.Layout
10+
import android.text.StaticLayout
11+
import android.text.TextPaint
12+
import androidx.core.graphics.withSave
13+
import kotlinx.coroutines.withContext
14+
import me.rosuh.easywatermark.data.model.ImageInfo
15+
import me.rosuh.easywatermark.data.model.WaterMark
16+
import me.rosuh.easywatermark.ui.widget.utils.WaterMarkShader
17+
import kotlin.coroutines.CoroutineContext
18+
import kotlin.math.max
19+
20+
/**
21+
* Android-only watermark renderer seam (CMP plan S2a). Centralizes the CURRENT preview/export
22+
* watermark logic that was duplicated across [me.rosuh.easywatermark.ui.widget.WaterMarkImageView]
23+
* (preview `onDraw` + companion cell builders) and
24+
* [me.rosuh.easywatermark.ui.MainViewModel] `generateImage` (export):
25+
*
26+
* - [buildTextShader] / [buildIconShader] — build one watermark cell + its tiling [BitmapShader]
27+
* (legacy Android `StaticLayout` / scaled-bitmap path; cell sizing via commonMain
28+
* [WatermarkGeometry]).
29+
* - [compose] — draw the cell shader over a target canvas: REPEAT tiles a region, CLAMP paints one
30+
* decal at a fractional offset. Preview and export now call the SAME helper.
31+
*
32+
* This is an EXTRACTION-ONLY slice: the bodies below are moved verbatim from the old call sites, so
33+
* rendered pixels are unchanged (guarded by the S0 strict export golden + the S2a composition
34+
* equivalence test). It is deliberately **Android-only** (it touches `android.graphics.*`,
35+
* `android.text.StaticLayout`, `TextPaint`) and therefore lives in `:app`, NOT `shared/commonMain` —
36+
* the platform-neutral renderer is a later, explicitly-approved migration slice. Likewise it keeps
37+
* the legacy text path and does NOT adopt `TextMeasurer`/`TextMeasureEnv` in this slice.
38+
*/
39+
object WatermarkRenderer {
40+
41+
/**
42+
* Build the text watermark cell + REPEAT/CLAMP [BitmapShader]. Verbatim extraction of the former
43+
* `WaterMarkImageView.buildTextBitmapShader` (which now delegates here). Uses legacy
44+
* `StaticLayout` + the supplied [textPaint] (configured via `TextPaint.applyConfig`).
45+
*/
46+
suspend fun buildTextShader(
47+
imageInfo: ImageInfo,
48+
config: WaterMark,
49+
textPaint: TextPaint,
50+
coroutineContext: CoroutineContext,
51+
): WaterMarkShader? = withContext(coroutineContext) {
52+
if (config.text.isBlank()) {
53+
return@withContext null
54+
}
55+
val showDebugRect = config.enableBounds
56+
var maxLineWidth = 0
57+
val tileMode = config.obtainTileMode()
58+
// calculate the max width of all lines
59+
config.text.split("\n").forEach {
60+
val startIndex = config.text.indexOf(it).coerceAtLeast(0)
61+
val lineWidth = textPaint.measureText(
62+
config.text,
63+
startIndex,
64+
(startIndex + it.length).coerceAtMost(config.text.length)
65+
).toInt()
66+
maxLineWidth = max(maxLineWidth, lineWidth)
67+
}
68+
69+
val staticLayout =
70+
StaticLayout.Builder.obtain(
71+
config.text,
72+
0,
73+
config.text.length,
74+
textPaint,
75+
maxLineWidth
76+
)
77+
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
78+
.build()
79+
80+
val textWidth = staticLayout.width.toFloat().coerceAtLeast(1f)
81+
val textHeight = staticLayout.height.toFloat().coerceAtLeast(1f)
82+
83+
// C2a: delegate cell sizing to the shared commonMain engine core (behavior-identical
84+
// formulas; pinned by WatermarkCellGoldenTest). Verified rendering parity on-device.
85+
val fixWidth = WatermarkGeometry.rotatedCellWidth(textWidth, textHeight, config.degree)
86+
val fixHeight = WatermarkGeometry.rotatedCellHeight(textWidth, textHeight, config.degree)
87+
val finalWidth = WatermarkGeometry.horizontalGap(fixWidth.toInt(), config.hGap)
88+
val finalHeight = WatermarkGeometry.verticalGap(fixHeight.toInt(), config.vGap)
89+
val bitmap = Bitmap.createBitmap(finalWidth, finalHeight, Bitmap.Config.ARGB_8888)
90+
val canvas = Canvas(bitmap)
91+
if (showDebugRect) {
92+
val tmpPaint = Paint().apply {
93+
color = Color.RED
94+
strokeWidth = 1f
95+
style = Paint.Style.STROKE
96+
}
97+
canvas.drawRect(0f, 0f, finalWidth.toFloat(), finalHeight.toFloat(), tmpPaint)
98+
canvas.save()
99+
}
100+
// rotate by user input
101+
canvas.rotate(
102+
config.degree,
103+
(finalWidth / 2).toFloat(),
104+
(finalHeight / 2).toFloat()
105+
)
106+
// draw text
107+
canvas.withSave {
108+
this.translate(
109+
((finalWidth) / 2).toFloat(),
110+
((finalHeight - staticLayout.getLineBottom(0) - staticLayout.getLineTop(0)) / 2).toFloat()
111+
)
112+
staticLayout.draw(canvas)
113+
}
114+
115+
if (showDebugRect) {
116+
canvas.restore()
117+
}
118+
val bitmapShader = BitmapShader(
119+
bitmap,
120+
tileMode,
121+
tileMode
122+
)
123+
return@withContext WaterMarkShader(
124+
bitmapShader,
125+
bitmap.width,
126+
bitmap.height
127+
)
128+
}
129+
130+
/**
131+
* Build the icon watermark cell + REPEAT/CLAMP [BitmapShader]. Verbatim extraction of the former
132+
* `WaterMarkImageView.buildIconBitmapShader` (which now delegates here). Preserves the legacy
133+
* nearest-neighbor `Bitmap.createScaledBitmap(..., false)` behavior.
134+
*/
135+
suspend fun buildIconShader(
136+
imageInfo: ImageInfo,
137+
srcBitmap: Bitmap,
138+
config: WaterMark,
139+
textPaint: Paint,
140+
scale: Boolean,
141+
coroutineContext: CoroutineContext,
142+
): WaterMarkShader? = withContext(coroutineContext) {
143+
if (srcBitmap.isRecycled) {
144+
return@withContext null
145+
}
146+
val tileMode = config.obtainTileMode()
147+
val showDebugRect = config.enableBounds
148+
val rawWidth = srcBitmap.width.toFloat().coerceAtLeast(1f)
149+
val rawHeight = srcBitmap.height.toFloat().coerceAtLeast(1f)
150+
151+
// C2a: icon-cell sizing via the shared commonMain engine core (equivalence pinned by
152+
// WatermarkCellGoldenTest.iconCell_dimensions_match_geometry).
153+
val maxSize = WatermarkGeometry.diagonal(rawHeight, rawWidth)
154+
val finalWidth = WatermarkGeometry.horizontalGap(maxSize, config.hGap)
155+
val finalHeight = WatermarkGeometry.verticalGap(maxSize, config.vGap)
156+
// textSize represents scale ratio of icon.
157+
val scaleRatio = if (scale) {
158+
imageInfo.scaleX
159+
} else {
160+
1f
161+
} * config.textSize / 14f
162+
163+
val targetBitmap = Bitmap.createBitmap(
164+
(finalWidth * scaleRatio).toInt(),
165+
(finalHeight * scaleRatio).toInt(),
166+
Bitmap.Config.ARGB_8888
167+
)
168+
169+
val canvas = Canvas(targetBitmap)
170+
171+
val scaleBitmap = Bitmap.createScaledBitmap(
172+
srcBitmap,
173+
(rawWidth * scaleRatio).toInt(), (rawHeight * scaleRatio).toInt(),
174+
false
175+
)
176+
177+
if (showDebugRect) {
178+
val tmpPaint = Paint().apply {
179+
color = Color.RED
180+
strokeWidth = 1f
181+
style = Paint.Style.STROKE
182+
}
183+
canvas.drawRect(0f, 0f, finalWidth * scaleRatio, finalHeight * scaleRatio, tmpPaint)
184+
canvas.save()
185+
}
186+
canvas.rotate(
187+
config.degree,
188+
(finalWidth * scaleRatio / 2),
189+
(finalHeight * scaleRatio / 2)
190+
)
191+
192+
canvas.drawBitmap(
193+
scaleBitmap,
194+
(finalWidth * scaleRatio - scaleBitmap.width) / 2.toFloat(),
195+
(finalHeight * scaleRatio - scaleBitmap.height) / 2.toFloat(),
196+
textPaint
197+
)
198+
if (showDebugRect) {
199+
canvas.restore()
200+
}
201+
val bitmapShader = BitmapShader(
202+
targetBitmap,
203+
tileMode,
204+
tileMode
205+
)
206+
return@withContext WaterMarkShader(
207+
bitmapShader,
208+
targetBitmap.width,
209+
targetBitmap.height
210+
)
211+
}
212+
213+
/**
214+
* Draw the watermark cell [shader] over [canvas] — the composition step shared by preview
215+
* (`onDraw`) and export (`generateImage`). Verbatim unification of the two former branches:
216+
*
217+
* - REPEAT: `translate(left, top)` then fill `[regionWidth] x [regionHeight]` (the whole
218+
* image/drawable region is tiled).
219+
* - CLAMP : `translate(left + offsetX*regionWidth, top + offsetY*regionHeight)` then draw ONE
220+
* cell-sized decal (`shader.width x shader.height`).
221+
*
222+
* Preview passes the drawable bounds (`left=drawableBounds.left, top=drawableBounds.top,
223+
* region=drawableBounds.width()/height()`); export passes `left=0, top=0, region=bitmap size`
224+
* (its REPEAT branch had no translate — `translate(0,0)` is a no-op, so behavior is identical).
225+
* The null-shader edge cases (CLAMP → 0x0 rect; REPEAT → paint fills the region) are preserved.
226+
*/
227+
fun compose(
228+
canvas: Canvas,
229+
shader: WaterMarkShader?,
230+
tileMode: Shader.TileMode,
231+
paint: Paint,
232+
left: Float,
233+
top: Float,
234+
regionWidth: Float,
235+
regionHeight: Float,
236+
offsetX: Float,
237+
offsetY: Float,
238+
) {
239+
paint.shader = shader?.bitmapShader
240+
canvas.withSave {
241+
if (tileMode == Shader.TileMode.CLAMP) {
242+
translate(left + offsetX * regionWidth, top + offsetY * regionHeight)
243+
drawRect(
244+
0f,
245+
0f,
246+
(shader?.width ?: 0).toFloat(),
247+
(shader?.height ?: 0).toFloat(),
248+
paint
249+
)
250+
} else {
251+
translate(left, top)
252+
drawRect(0f, 0f, regionWidth, regionHeight, paint)
253+
}
254+
}
255+
}
256+
}

app/src/main/java/me/rosuh/easywatermark/ui/MainViewModel.kt

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import android.graphics.Bitmap
1111
import android.graphics.Canvas
1212
import android.graphics.Matrix
1313
import android.graphics.Paint
14-
import android.graphics.Shader
1514
import android.net.Uri
1615
import android.os.Build
1716
import android.os.Environment
@@ -54,6 +53,7 @@ import me.rosuh.easywatermark.data.model.ViewInfo
5453
import me.rosuh.easywatermark.data.model.WaterMark
5554
import me.rosuh.easywatermark.data.model.WatermarkTileMode
5655
import me.rosuh.easywatermark.data.model.entity.Template
56+
import me.rosuh.easywatermark.render.WatermarkRenderer
5757
import me.rosuh.easywatermark.data.repo.MemorySettingRepo
5858
import me.rosuh.easywatermark.data.repo.TemplateRepository
5959
import me.rosuh.easywatermark.data.repo.UserConfigRepository
@@ -327,9 +327,11 @@ class MainViewModel (
327327
imageInfo.scaleY = 1 / matrixValues[Matrix.MSCALE_X]
328328
val bitmapPaint = TextPaint().applyConfig(imageInfo, tmpConfig, isScale = false)
329329
val layoutPaint = Paint()
330+
// S2a: build the cell shader through the Android renderer seam (same seam the preview
331+
// path uses via WaterMarkImageView's companion wrappers).
330332
val shader = when (waterMark.value?.markMode) {
331333
WaterMarkRepository.MarkMode.Text -> {
332-
WaterMarkImageView.buildTextBitmapShader(
334+
WatermarkRenderer.buildTextShader(
333335
imageInfo,
334336
waterMark.value!!,
335337
bitmapPaint,
@@ -352,7 +354,7 @@ class MainViewModel (
352354
)
353355
}
354356
val iconBitmap = iconBitmapRect.data!!.bitmap
355-
WaterMarkImageView.buildIconBitmapShader(
357+
WatermarkRenderer.buildIconShader(
356358
imageInfo,
357359
iconBitmap,
358360
tmpConfig,
@@ -369,29 +371,21 @@ class MainViewModel (
369371
)
370372
}
371373

372-
layoutPaint.shader = shader?.bitmapShader
373-
374-
if (tmpConfig.obtainTileMode() == Shader.TileMode.CLAMP) {
375-
canvas.translate(
376-
0 + imageInfo.offsetX * mutableBitmap.width,
377-
0 + imageInfo.offsetY * mutableBitmap.height
378-
)
379-
canvas.drawRect(
380-
0f,
381-
0f,
382-
(shader?.width ?: 0).toFloat(),
383-
(shader?.height ?: 0).toFloat(),
384-
layoutPaint
385-
)
386-
} else {
387-
canvas.drawRect(
388-
0f,
389-
0f,
390-
mutableBitmap.width.toFloat(),
391-
mutableBitmap.height.toFloat(),
392-
layoutPaint
393-
)
394-
}
374+
// S2a: composition delegated to the shared renderer seam (same helper as preview).
375+
// Export composites at the bitmap origin (left/top = 0, region = full bitmap); the old
376+
// REPEAT branch had no canvas translate, which `compose` reproduces as translate(0,0).
377+
WatermarkRenderer.compose(
378+
canvas = canvas,
379+
shader = shader,
380+
tileMode = tmpConfig.obtainTileMode(),
381+
paint = layoutPaint,
382+
left = 0f,
383+
top = 0f,
384+
regionWidth = mutableBitmap.width.toFloat(),
385+
regionHeight = mutableBitmap.height.toFloat(),
386+
offsetX = imageInfo.offsetX,
387+
offsetY = imageInfo.offsetY,
388+
)
395389

396390
return@withContext if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
397391
val imageCollection =

0 commit comments

Comments
 (0)