Skip to content

Commit 7d80926

Browse files
authored
Merge pull request #379 from rosuH/codex/s3a-image-space-textsize
[codex] Implement image-space text sizing
2 parents 6df5864 + 0115f18 commit 7d80926

3 files changed

Lines changed: 187 additions & 14 deletions

File tree

app/src/main/java/me/rosuh/easywatermark/render/WatermarkRenderer.kt

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ import kotlin.math.max
3838
*/
3939
object WatermarkRenderer {
4040

41+
/**
42+
* S3a image-space sizing reference width (px). `textSize` is interpreted as image-space:
43+
*
44+
* ```
45+
* textPx = textSize * imageWidth / REF_WIDTH
46+
* ```
47+
*
48+
* where `imageWidth` is the width of the bitmap the watermark cell tiles over (the displayed
49+
* drawable in preview, the full source image at export — both already carried by
50+
* `ImageInfo.width`). This makes the watermark a constant fraction (`textSize / REF_WIDTH`) of the
51+
* image on every device and in both preview and export, replacing the old, device-dependent,
52+
* un-persisted preview-matrix scale (`1/MSCALE_X`).
53+
*
54+
* **Why 1000:** it is the canonical reference image width already used by every existing
55+
* golden/gate in this repo (`WatermarkExportGoldenTest`, `WatermarkCellGoldenTest`,
56+
* `WatermarkCellInstrumentedGoldenTest`, `WatermarkCellParityGateTest` all use
57+
* `ImageInfo.width = 1000`). At that reference width the formula reproduces the legacy unscaled
58+
* paint size exactly (`textSize * 1000 / 1000 == textSize`), so `textSize` keeps its historical
59+
* meaning at the reference and scales proportionally elsewhere. 1000 is also representative of a
60+
* typical editor preview-canvas width on the dominant ~1080px-wide phone class (canvas = screen −
61+
* padding ≈ 1000), so the typical user's export size is approximately preserved — the bounded
62+
* one-time shift accepted under D3 Option A (see ACSP ref-width-decision.md). A precise
63+
* production-preview-width recalibration on the authority device is a later optional refinement.
64+
*/
65+
const val REF_WIDTH: Float = 1000f
66+
4167
/**
4268
* Build the text watermark cell + REPEAT/CLAMP [BitmapShader]. Verbatim extraction of the former
4369
* `WaterMarkImageView.buildTextBitmapShader` (which now delegates here). Uses legacy
@@ -133,11 +159,11 @@ object WatermarkRenderer {
133159
* nearest-neighbor `Bitmap.createScaledBitmap(..., false)` behavior.
134160
*/
135161
suspend fun buildIconShader(
136-
imageInfo: ImageInfo,
162+
@Suppress("UNUSED_PARAMETER") imageInfo: ImageInfo, // S3a: icon no longer reads scaleX; kept for API compatibility
137163
srcBitmap: Bitmap,
138164
config: WaterMark,
139165
textPaint: Paint,
140-
scale: Boolean,
166+
@Suppress("UNUSED_PARAMETER") scale: Boolean, // S3a: view-scale coupling removed; param kept for source/API compatibility
141167
coroutineContext: CoroutineContext,
142168
): WaterMarkShader? = withContext(coroutineContext) {
143169
if (srcBitmap.isRecycled) {
@@ -153,12 +179,10 @@ object WatermarkRenderer {
153179
val maxSize = WatermarkGeometry.diagonal(rawHeight, rawWidth)
154180
val finalWidth = WatermarkGeometry.horizontalGap(maxSize, config.hGap)
155181
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
182+
// S3a: `textSize` is the icon scale ratio (textSize/14 ⇒ 14 = 1×), preserved from legacy.
183+
// The old preview-matrix `imageInfo.scaleX` factor (applied only at export, `scale=true`) is
184+
// REMOVED so preview and export size icons identically and independent of view scale (D2c).
185+
val scaleRatio = config.textSize / 14f
162186

163187
val targetBitmap = Bitmap.createBitmap(
164188
(finalWidth * scaleRatio).toInt(),

app/src/main/java/me/rosuh/easywatermark/utils/ktx/PainKtx.kt

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,29 @@ import android.graphics.Typeface
66
import android.text.TextPaint
77
import me.rosuh.easywatermark.data.model.ImageInfo
88
import me.rosuh.easywatermark.data.model.WaterMark
9+
import me.rosuh.easywatermark.render.WatermarkRenderer
910

1011
/**
11-
* 因为预览和实际图像之间存在缩放,所以在预览时要除去缩放比。而在保存时,就不需要了
12-
* Because there is a zoom between the preview and the actual image, the zoom ratio should be removed when previewing
13-
* When saving, it’s not needed
12+
* S3a image-space sizing: the text paint size is `textSize * imageInfo.width / REF_WIDTH`, i.e.
13+
* `textSize` is a fraction (`textSize / REF_WIDTH`) of the target image's width, resolved against the
14+
* bitmap the cell tiles over (`ImageInfo.width` = the displayed drawable in preview, the full source
15+
* image at export). Preview and export now use the SAME formula, so the watermark is the same fraction
16+
* of the image regardless of device/preview-view size.
17+
*
18+
* Replaces the old, device-dependent behavior (`textSize` raw in preview; `textSize * scaleX`, where
19+
* `scaleX = 1/MSCALE_X` from the preview matrix, at export). [isScale] is retained for call-site/source
20+
* compatibility but **no longer affects sizing** — both modes are image-space. Persisted `textSize`
21+
* values are unchanged (D3 Option A, no DataStore migration).
1422
* @author hi@rosuh.me
15-
* @date 2020/9/8
23+
* @date 2020/9/8 · S3a image-space re-spec 2026-06-14
1624
*/
1725
fun Paint.applyConfig(
1826
imageInfo: ImageInfo,
1927
config: WaterMark?,
20-
isScale: Boolean = true
28+
@Suppress("UNUSED_PARAMETER") isScale: Boolean = true
2129
): Paint {
2230
val size = config?.textSize ?: 14f
23-
textSize = if (isScale) size else size * imageInfo.scaleX
31+
textSize = size * imageInfo.width / WatermarkRenderer.REF_WIDTH
2432
color = config?.textColor ?: Color.RED
2533
alpha = config?.alpha ?: 128
2634
style = config?.textStyle?.obtainSysStyle() ?: Paint.Style.FILL
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package me.rosuh.easywatermark.render
2+
3+
import android.app.Application
4+
import android.graphics.Bitmap
5+
import android.graphics.Color
6+
import android.graphics.Paint
7+
import android.net.Uri
8+
import android.text.TextPaint
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.runBlocking
11+
import me.rosuh.easywatermark.data.model.ImageInfo
12+
import me.rosuh.easywatermark.data.model.WaterMark
13+
import me.rosuh.easywatermark.ui.widget.utils.WaterMarkShader
14+
import me.rosuh.easywatermark.utils.ktx.applyConfig
15+
import org.junit.Assert.assertEquals
16+
import org.junit.Assert.assertTrue
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.annotation.Config
21+
import org.robolectric.annotation.GraphicsMode
22+
23+
/**
24+
* S3a — image-space `textSize` behavior. `textPx = textSize * imageInfo.width / REF_WIDTH`
25+
* (`WatermarkRenderer.REF_WIDTH = 1000`). These tests pin the NEW behavior and **fail on the old
26+
* view-scale-dependent behavior**:
27+
* - old text: `textSize` raw (preview) / `textSize * imageInfo.scaleX` (export) — depended on the
28+
* preview matrix and ignored image width;
29+
* - old icon: `(scale ? imageInfo.scaleX : 1) * textSize/14` — depended on view scale at export.
30+
*
31+
* The new behavior is independent of `imageInfo.scaleX` and of the `isScale`/`scale` flags, and scales
32+
* linearly with `imageInfo.width`. At the reference width (1000, used by every existing golden) the
33+
* text paint size equals the legacy unscaled size, so existing goldens are unchanged.
34+
*/
35+
@RunWith(RobolectricTestRunner::class)
36+
@Config(sdk = [34], application = Application::class)
37+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
38+
class WatermarkImageSpaceSizingTest {
39+
40+
private fun config(size: Float = 24f) = WaterMark.default.copy(
41+
text = "GOLDEN", degree = 0f, hGap = 0, vGap = 0,
42+
textSize = size, textColor = Color.WHITE, iconUri = Uri.EMPTY,
43+
)
44+
45+
private fun imageInfo(width: Int, scaleX: Float = 1f) =
46+
ImageInfo.empty().apply { this.width = width; this.height = width; this.scaleX = scaleX }
47+
48+
// ---- direct paint sizing (the precise old-vs-new pin) --------------------------------------
49+
50+
@Test
51+
fun text_paint_size_equals_legacy_at_reference_width() {
52+
// At REF_WIDTH=1000 the image-space size reproduces the legacy unscaled paint size exactly.
53+
val p = TextPaint().applyConfig(imageInfo(width = 1000), config(size = 24f), isScale = false)
54+
assertEquals(24f, p.textSize, 0.001f)
55+
}
56+
57+
@Test
58+
fun text_paint_size_scales_linearly_with_image_width() {
59+
// 2x image width -> 2x text px. (OLD isScale=true ignored width -> would stay 24f -> fails.)
60+
val p1000 = TextPaint().applyConfig(imageInfo(1000), config(24f), isScale = true)
61+
val p2000 = TextPaint().applyConfig(imageInfo(2000), config(24f), isScale = true)
62+
val p500 = TextPaint().applyConfig(imageInfo(500), config(24f), isScale = true)
63+
assertEquals(24f, p1000.textSize, 0.001f)
64+
assertEquals(48f, p2000.textSize, 0.001f)
65+
assertEquals(12f, p500.textSize, 0.001f)
66+
}
67+
68+
@Test
69+
fun text_paint_size_independent_of_view_scaleX() {
70+
// OLD export path multiplied by imageInfo.scaleX; new path must ignore it.
71+
val a = TextPaint().applyConfig(imageInfo(1000, scaleX = 1f), config(24f), isScale = false)
72+
val b = TextPaint().applyConfig(imageInfo(1000, scaleX = 4f), config(24f), isScale = false)
73+
assertEquals(a.textSize, b.textSize, 0.001f)
74+
assertEquals(24f, b.textSize, 0.001f) // not 24*4
75+
}
76+
77+
@Test
78+
fun text_paint_size_independent_of_isScale_flag() {
79+
// Preview (isScale=true) and export (isScale=false) now use the SAME formula.
80+
val preview = TextPaint().applyConfig(imageInfo(2000, scaleX = 3f), config(24f), isScale = true)
81+
val export = TextPaint().applyConfig(imageInfo(2000, scaleX = 3f), config(24f), isScale = false)
82+
assertEquals(preview.textSize, export.textSize, 0.001f)
83+
assertEquals(48f, export.textSize, 0.001f) // 24 * 2000/1000
84+
}
85+
86+
// ---- rendered text cell end-to-end --------------------------------------------------------
87+
88+
private fun textCell(width: Int, scaleX: Float, size: Float = 24f): WaterMarkShader {
89+
val info = imageInfo(width, scaleX)
90+
val paint = TextPaint().applyConfig(info, config(size), isScale = false)
91+
return runBlocking {
92+
WatermarkRenderer.buildTextShader(info, config(size), paint, Dispatchers.Unconfined)
93+
}!!
94+
}
95+
96+
@Test
97+
fun text_cell_grows_with_image_width() {
98+
val c1000 = textCell(width = 1000, scaleX = 1f)
99+
val c2000 = textCell(width = 2000, scaleX = 1f)
100+
// ~2x paint size -> ~2x cell (allow rounding slack from .toInt()/StaticLayout).
101+
assertTrue("2000px-image cell must be larger", c2000.width > c1000.width && c2000.height > c1000.height)
102+
val ratio = c2000.width.toFloat() / c1000.width
103+
assertTrue("cell width ratio ~2x (was $ratio)", ratio in 1.8f..2.2f)
104+
}
105+
106+
@Test
107+
fun text_cell_independent_of_view_scaleX() {
108+
val a = textCell(width = 1000, scaleX = 1f)
109+
val b = textCell(width = 1000, scaleX = 5f)
110+
assertEquals("cell width must not depend on scaleX", a.width, b.width)
111+
assertEquals("cell height must not depend on scaleX", a.height, b.height)
112+
}
113+
114+
// ---- icon cell ----------------------------------------------------------------------------
115+
116+
private fun iconCell(scale: Boolean, scaleX: Float, size: Float = 14f): WaterMarkShader {
117+
val info = imageInfo(1000, scaleX)
118+
val src = Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
119+
return runBlocking {
120+
WatermarkRenderer.buildIconShader(info, src, config(size), Paint(), scale, Dispatchers.Unconfined)
121+
}!!
122+
}
123+
124+
@Test
125+
fun icon_cell_independent_of_view_scale_and_scaleX() {
126+
// OLD: scale=true multiplied by imageInfo.scaleX -> bigger at export. NEW: textSize/14 always.
127+
val previewLike = iconCell(scale = false, scaleX = 1f)
128+
val exportLike = iconCell(scale = true, scaleX = 4f)
129+
assertEquals("icon cell must not depend on scale flag/scaleX", previewLike.width, exportLike.width)
130+
assertEquals("icon cell must not depend on scale flag/scaleX", previewLike.height, exportLike.height)
131+
}
132+
133+
@Test
134+
fun icon_cell_preserves_textsize_over_14_ratio() {
135+
// textSize 28 -> 2x of textSize 14 (ratio semantics preserved).
136+
val x1 = iconCell(scale = false, scaleX = 1f, size = 14f)
137+
val x2 = iconCell(scale = false, scaleX = 1f, size = 28f)
138+
val ratio = x2.width.toFloat() / x1.width
139+
assertTrue("icon ratio ~2x (was $ratio)", ratio in 1.8f..2.2f)
140+
}
141+
}

0 commit comments

Comments
 (0)