Skip to content

Commit 62db7b2

Browse files
authored
Merge pull request #380 from rosuH/codex/s3b-textmeasurer-cjk-baseline
S3b: Adopt TextMeasurer for watermark measurement
2 parents 7d80926 + eac461f commit 62db7b2

14 files changed

Lines changed: 172 additions & 92 deletions

app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellInstrumentedGoldenTest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.net.Uri
88
import android.text.TextPaint
99
import android.util.Log
1010
import androidx.test.ext.junit.runners.AndroidJUnit4
11+
import androidx.test.platform.app.InstrumentationRegistry
1112
import kotlinx.coroutines.Dispatchers
1213
import kotlinx.coroutines.runBlocking
1314
import me.rosuh.easywatermark.data.model.ImageInfo
@@ -37,7 +38,11 @@ class WatermarkCellInstrumentedGoldenTest {
3738
val imageInfo = ImageInfo.empty().apply { width = 1000; height = 1000 }
3839
val paint = TextPaint().applyConfig(imageInfo, config, isScale = false)
3940
val shader = runBlocking {
40-
WaterMarkImageView.buildTextBitmapShader(imageInfo, config, paint, Dispatchers.Unconfined)
41+
WaterMarkImageView.buildTextBitmapShader(
42+
imageInfo, config, paint,
43+
androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext),
44+
Dispatchers.Unconfined,
45+
)
4146
}!!
4247
val w = shader.width.coerceAtLeast(1)
4348
val h = shader.height.coerceAtLeast(1)

app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellParityGateTest.kt

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,64 +17,73 @@ import org.junit.runner.RunWith
1717
import kotlin.math.max
1818

1919
/**
20-
* C2b **candidate signed-baseline** measurement gate. NOT product code; not wired into
21-
* `WaterMarkImageView`/`MainViewModel`.
20+
* C2b **accepted signed-baseline** measurement gate (S3b / D1). The `TextMeasurer` measurement seam
21+
* [WatermarkTextMeasurer] + [TextMeasureEnv] is now wired into the product renderer
22+
* ([me.rosuh.easywatermark.render.WatermarkRenderer.buildTextShader]) for text-cell measurement
23+
* (drawing stays legacy `StaticLayout`); this gate pins the same seam the product uses. Signed-baseline
24+
* behavior: non-CJK strict legacy==seam (width AND height); CJK exact width + exact signed delta +
25+
* exact signed absolute baseline (no tolerance widening; every row logged `PARITYGATE|…`).
2226
*
23-
* The candidate `TextMeasurer` measurement now lives behind the extracted seam
24-
* [WatermarkTextMeasurer] + [TextMeasureEnv] (app module `render/`, ACSP 20260614-002242) instead of
25-
* inline here — the gate exercises the same seam the future C2b renderer will use. Signed-baseline
26-
* behavior is unchanged: non-CJK strict legacy==seam (width AND height); CJK exact width + exact signed
27-
* delta + exact signed absolute baseline (no tolerance widening; every row logged `PARITYGATE|…`).
27+
* ACCEPTED (S3b) — the CJK baselines encode "Option 1" (Compose CJK line-height accepted as the
28+
* renderer baseline), promoted from candidate per the accepted D1 sign-off (ADR-0014/ADR-0004).
2829
*
29-
* CANDIDATE STATUS — the CJK baselines encode "Option 1" (accept Compose CJK line-height as the C2b
30-
* future-renderer baseline), pending coordinator/product sign-off (ADR-0014 candidate note). No product
31-
* renderer/export is wired.
30+
* PLATFORM-PINNED (ADR-0010): the CJK absolute baselines below are a same-platform device baseline. Under
31+
* the updated device policy (2026-06-14: any available adb target is acceptable, do not require a
32+
* specific serial) they were re-pinned to the **acceptance target used for S3b verification:
33+
* emulator-5554 / AVD Pixel_9_Pro_XL / Android 16 / API 36** (model `sdk_gphone64_arm64`). Robolectric
34+
* is not a CJK dimension oracle; this gate is instrumented/device-only. Non-CJK rows assert parity and
35+
* delta only, because their absolute glyph advances are device-font dependent under the any-device
36+
* policy. Re-baseline the CJK absolute rows on the pinned CI device if the fleet changes.
3237
*
33-
* PLATFORM-PINNED (ADR-0010): the absolute baselines below are a same-platform device baseline (Samsung
34-
* SM-S906E / Android 16 / API 36 CJK font). Robolectric is not a CJK dimension oracle; this gate is
35-
* instrumented/device-only. The CJK *absolute* assertions are device-pinned and only pass on that
36-
* baseline device — the legacy==seam *parity* + signed *delta* assertions are device-independent.
37-
* Re-baseline on the pinned CI device if the device fleet changes.
38+
* Device note: CJK *heights* matched the earlier SM-S906E baseline exactly (18/35/70/140); only the
39+
* CJK glyph *widths* differed by a few px (wider Noto font) — recorded here, not treated as a blocker
40+
* (updated policy).
3841
*/
3942
@RunWith(AndroidJUnit4::class)
4043
class WatermarkCellParityGateTest {
4144

4245
private val tag = "PARITYGATE"
4346

4447
/**
45-
* One gate row. `seamW`/`seamH` = the signed absolute baseline (API-36 device); `dH` = the signed
46-
* legacy→seam height delta (0 for non-CJK). Width delta is always 0 (asserted).
48+
* One gate row. `seamW`/`seamH` = the CJK signed absolute baseline (API-36 device); `dH` = the
49+
* signed legacy→seam height delta (0 for non-CJK). Width delta is always 0 (asserted).
4750
*/
4851
private data class Baseline(
4952
val label: String, val text: String, val typeface: Int, val size: Float,
50-
val seamW: Int, val seamH: Int, val dH: Int,
53+
val dH: Int, val seamW: Int? = null, val seamH: Int? = null,
5154
)
5255

5356
private data class Measured(val b: Baseline, val lw: Int, val lh: Int, val sw: Int, val sh: Int)
5457

55-
// Non-CJK anchors — signed baseline == legacy (delta 0). Must stay exact across sizes.
58+
// Non-CJK anchors — signed baseline == legacy (delta 0). The product contract is parity, not
59+
// device-pinned absolute glyph advances, under the any-device policy.
5660
private val nonCjk = listOf(
57-
Baseline("GOLDEN@24", "GOLDEN", TextTypeface.Normal.serializeKey(), 24f, 93, 33, 0),
58-
Baseline("MULTILINE@24", "DO NOT\nREDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, 161, 61, 0),
59-
Baseline("EMOJI@24", "👋 DO NOT REDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, 288, 33, 0),
60-
Baseline("GOLDEN_BOLD@24", "GOLDEN", TextTypeface.Bold.serializeKey(), 24f, 93, 33, 0),
61-
Baseline("GOLDEN_ITALIC@24", "GOLDEN", TextTypeface.Italic.serializeKey(), 24f, 90, 33, 0),
62-
Baseline("GOLDEN@12", "GOLDEN", TextTypeface.Normal.serializeKey(), 12f, 46, 17, 0),
63-
Baseline("GOLDEN@48", "GOLDEN", TextTypeface.Normal.serializeKey(), 48f, 185, 65, 0),
61+
Baseline("GOLDEN@24", "GOLDEN", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
62+
Baseline("MULTILINE@24", "DO NOT\nREDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
63+
Baseline("EMOJI@24", "👋 DO NOT REDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
64+
Baseline("GOLDEN_BOLD@24", "GOLDEN", TextTypeface.Bold.serializeKey(), 24f, dH = 0),
65+
Baseline("GOLDEN_ITALIC@24", "GOLDEN", TextTypeface.Italic.serializeKey(), 24f, dH = 0),
66+
Baseline("GOLDEN@12", "GOLDEN", TextTypeface.Normal.serializeKey(), 12f, dH = 0),
67+
Baseline("GOLDEN@48", "GOLDEN", TextTypeface.Normal.serializeKey(), 48f, dH = 0),
6468
)
6569

66-
// CJK — signed baseline accepts the Compose line-height growth (height only; width exact). Pinned to
67-
// SM-S906E / API 36. Delta scales with text size AND line count (single-line +1/+2/+5; 2-line +4/+9/+18).
70+
// CJK — signed baseline accepts the Compose line-height growth (height only; width exact vs legacy).
71+
// Absolutes re-pinned to emulator-5554 / API 36. CJK HEIGHTS match the earlier SM-S906E baseline
72+
// exactly (18/35/70/140); only CJK glyph WIDTHS are a few px wider (Noto). Signed ΔH unchanged
73+
// (device-independent: single-line +1/+2/+5; 2-line +4/+9/+18).
6874
private val cjk = listOf(
69-
Baseline("CJK_MIX@12", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 12f, 105, 18, 1),
70-
Baseline("CJK_SHORT@12", "水印", TextTypeface.Normal.serializeKey(), 12f, 22, 18, 1),
71-
Baseline("CJK_MULTILINE@12", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 12f, 44, 35, 4),
72-
Baseline("CJK_MIX@24", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 24f, 212, 35, 2),
73-
Baseline("CJK_SHORT@24", "水印", TextTypeface.Normal.serializeKey(), 24f, 46, 35, 2),
74-
Baseline("CJK_MULTILINE@24", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 24f, 92, 70, 9),
75-
Baseline("CJK_MIX@48", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 48f, 423, 70, 5),
76-
Baseline("CJK_SHORT@48", "水印", TextTypeface.Normal.serializeKey(), 48f, 92, 70, 5),
77-
Baseline("CJK_MULTILINE@48", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 48f, 184, 140, 18),
75+
Baseline("CJK_MIX@12", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 12f, dH = 1, seamW = 109, seamH = 18),
76+
Baseline("CJK_SHORT@12", "水印", TextTypeface.Normal.serializeKey(), 12f, dH = 1, seamW = 24, seamH = 18),
77+
Baseline("CJK_MULTILINE@12", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 12f, dH = 4, seamW = 48, seamH = 35),
78+
Baseline("CJK_MIX@24", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 24f, dH = 2, seamW = 216, seamH = 35),
79+
Baseline("CJK_SHORT@24", "水印", TextTypeface.Normal.serializeKey(), 24f, dH = 2, seamW = 48, seamH = 35),
80+
Baseline("CJK_MULTILINE@24", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 24f, dH = 9, seamW = 96, seamH = 70),
81+
Baseline("CJK_MIX@48", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 48f, dH = 5, seamW = 431, seamH = 70),
82+
Baseline("CJK_SHORT@48", "水印", TextTypeface.Normal.serializeKey(), 48f, dH = 5, seamW = 96, seamH = 70),
83+
Baseline(
84+
"CJK_MULTILINE@48", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(),
85+
48f, dH = 18, seamW = 192, seamH = 140,
86+
),
7887
)
7988

8089
private fun paintFor(b: Baseline): TextPaint {
@@ -86,7 +95,7 @@ class WatermarkCellParityGateTest {
8695
return TextPaint().applyConfig(imageInfo, config, isScale = false)
8796
}
8897

89-
/** Byte-identical to buildTextBitmapShader's measurement. */
98+
/** Legacy StaticLayout measurement used as the parity reference for product output. */
9099
private fun legacyMeasure(paint: TextPaint, text: String): Pair<Int, Int> {
91100
var maxLineWidth = 0
92101
text.split("\n").forEach {
@@ -98,7 +107,7 @@ class WatermarkCellParityGateTest {
98107
return sl.width to sl.height
99108
}
100109

101-
/** Candidate seam — delegates to the extracted [WatermarkTextMeasurer]/[TextMeasureEnv]. */
110+
/** Product seam — delegates to the extracted [WatermarkTextMeasurer]/[TextMeasureEnv]. */
102111
private fun seamMeasure(paint: TextPaint, text: String): Pair<Int, Int> {
103112
val env = androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext)
104113
val size = WatermarkTextMeasurer.measure(env, text, paint.toWatermarkTextStyle())
@@ -109,35 +118,44 @@ class WatermarkCellParityGateTest {
109118
val paint = paintFor(b)
110119
val (lw, lh) = legacyMeasure(paint, b.text)
111120
val (sw, sh) = seamMeasure(paint, b.text)
112-
Log.i(tag, "PARITYGATE|${b.label}|legacy=${lw}x${lh}|seam=${sw}x${sh}|d=(${sw - lw},${sh - lh})|signedBaseline=${b.seamW}x${b.seamH}|signedDH=${b.dH}")
121+
val signedBaseline = if (b.seamW != null && b.seamH != null) "${b.seamW}x${b.seamH}" else "n/a"
122+
Log.i(
123+
tag,
124+
"PARITYGATE|${b.label}|legacy=${lw}x${lh}|seam=${sw}x${sh}|d=(${sw - lw},${sh - lh})|" +
125+
"signedBaseline=$signedBaseline|signedDH=${b.dH}",
126+
)
113127
return Measured(b, lw, lh, sw, sh)
114128
}
115129

116-
private fun assertRow(m: Measured) {
130+
private fun assertRow(m: Measured, assertAbsoluteBaseline: Boolean) {
117131
val b = m.b
118132
// (1) width parity is ALWAYS exact — width never drifts (device-independent).
119133
assertEquals("width parity [${b.label}]", m.lw, m.sw)
120134
// (2) signed legacy→seam height delta is exactly the recorded value (device-independent).
121135
assertEquals("signed height delta [${b.label}]", b.dH, m.sh - m.lh)
122-
// (3) signed absolute baseline (API-36 device) — no tolerance (device-pinned).
123-
assertEquals("signed baseline width [${b.label}]", b.seamW, m.sw)
124-
assertEquals("signed baseline height [${b.label}]", b.seamH, m.sh)
136+
if (assertAbsoluteBaseline) {
137+
val seamW = requireNotNull(b.seamW) { "missing signed baseline width [${b.label}]" }
138+
val seamH = requireNotNull(b.seamH) { "missing signed baseline height [${b.label}]" }
139+
// (3) signed absolute baseline (API-36 device) — no tolerance (device-pinned).
140+
assertEquals("signed baseline width [${b.label}]", seamW, m.sw)
141+
assertEquals("signed baseline height [${b.label}]", seamH, m.sh)
142+
}
125143
}
126144

127145
/** Non-CJK: seam == legacy exactly (signed delta 0) across sizes. */
128146
@Test
129147
fun nonCjk_cell_dimensions_match_legacy_exactly() {
130148
val rows = nonCjk.map { measureAndLog(it) } // log ALL rows before asserting
131-
rows.forEach { assertRow(it) }
149+
rows.forEach { assertRow(it, assertAbsoluteBaseline = false) }
132150
}
133151

134152
/**
135-
* CJK: width exact; height equals the explicitly signed baseline/delta (candidate — Compose
136-
* line-height accepted as the C2b future-renderer baseline, pending sign-off). Green, not hidden.
153+
* CJK: width exact; height equals the explicitly signed baseline/delta. Compose line-height is the
154+
* accepted S3b/D1 renderer baseline. Green, not hidden.
137155
*/
138156
@Test
139157
fun cjk_cell_dimensions_match_signed_baseline() {
140158
val rows = cjk.map { measureAndLog(it) } // log ALL rows before asserting
141-
rows.forEach { assertRow(it) }
159+
rows.forEach { assertRow(it, assertAbsoluteBaseline = true) }
142160
}
143161
}

app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkExportInstrumentedGoldenTest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import android.net.Uri
1010
import android.text.TextPaint
1111
import android.util.Log
1212
import androidx.test.ext.junit.runners.AndroidJUnit4
13+
import androidx.test.platform.app.InstrumentationRegistry
1314
import kotlinx.coroutines.Dispatchers
1415
import kotlinx.coroutines.runBlocking
1516
import me.rosuh.easywatermark.data.model.ImageInfo
@@ -77,7 +78,11 @@ class WatermarkExportInstrumentedGoldenTest {
7778
val shader = runBlocking {
7879
if (spec.text != null) {
7980
val paint = TextPaint().applyConfig(imageInfo, config, isScale = false)
80-
WaterMarkImageView.buildTextBitmapShader(imageInfo, config, paint, Dispatchers.Unconfined)
81+
WaterMarkImageView.buildTextBitmapShader(
82+
imageInfo, config, paint,
83+
androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext),
84+
Dispatchers.Unconfined,
85+
)
8186
} else {
8287
val src = Bitmap.createBitmap(spec.iconW, spec.iconH, Bitmap.Config.ARGB_8888).apply {
8388
eraseColor(Color.WHITE)

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@ import androidx.compose.ui.unit.sp
1414

1515
/**
1616
* C2b text-measurement seam — **Android glue half** (ACSP 20260614-002242, boundary-polished
17-
* 20260614-080727). NOT product code: `internal`, **not called by any product path**. Builds the
18-
* platform-neutral [TextMeasureEnv] / drives [WatermarkTextMeasurer] (both in `TextMeasureEnv.kt`) from
19-
* Android `Context` / `TextPaint`.
17+
* 20260614-080727; adopted as **product measurement** in S3b/D1). Builds the platform-neutral
18+
* [TextMeasureEnv] / drives [WatermarkTextMeasurer] (both in `TextMeasureEnv.kt`) from Android
19+
* `Context` / `TextPaint`. Now called by the product text path
20+
* ([me.rosuh.easywatermark.render.WatermarkRenderer.buildTextShader]) via the preview/export call
21+
* sites (`WaterMarkImageView`, `MainViewModel.generateImage`); drawing stays legacy `StaticLayout`.
2022
*
2123
* These two declarations are the parts that MUST stay Android-side (they touch `Context` and the legacy
2224
* `TextPaint`); the neutral seam in `TextMeasureEnv.kt` stays free of them so it can move to
2325
* `:shared/commonMain` unchanged. Behavior is identical to the pre-split file (no logic change).
2426
*/
2527

2628
/**
27-
* Android bootstrap: build a [TextMeasureEnv] where a [Context] already exists (the future renderer gets
28-
* this from DI, NOT through `buildTextBitmapShader`/`generateImage`). `Density(1f)` ⇒ image-space
29-
* (`1.sp == 1px`), the C2b "image-space sizing" direction, decoupled from any view/window density.
29+
* Android bootstrap: build a [TextMeasureEnv] at the preview/export boundary where a [Context] already
30+
* exists. `Density(1f)` ⇒ image-space (`1.sp == 1px`), the C2b "image-space sizing" direction,
31+
* decoupled from any view/window density.
3032
*/
3133
internal fun androidTextMeasureEnv(context: Context): TextMeasureEnv =
3234
TextMeasureEnv(

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,30 @@ import androidx.compose.ui.unit.LayoutDirection
1010

1111
/**
1212
* C2b text-measurement seam — **platform-neutral half** (ACSP 20260614-002242, boundary-polished
13-
* 20260614-080727). NOT product code: `internal` and **not called by any product path**
14-
* (`WaterMarkImageView`/`MainViewModel`/`EditorScreen` untouched). Today only the instrumented gate
15-
* `WatermarkCellParityGateTest` references it (via the Android glue in `AndroidTextMeasureEnv.kt`).
13+
* 20260614-080727; adopted as **product measurement** in S3b/D1). Used by the renderer text path
14+
* ([WatermarkRenderer.buildTextShader]) for the watermark text-cell box; drawing stays legacy
15+
* `StaticLayout`. The instrumented gate `WatermarkCellParityGateTest` pins it against the signed CJK
16+
* baseline (non-CJK exact; CJK width exact + signed height delta).
1617
*
1718
* This file contains **only platform-neutral, commonMain-ready** declarations — no Android `Context` or
1819
* `TextPaint`, no `createFontFamilyResolver`/`sp`. It constructs `TextMeasurer` directly (not
1920
* `@Composable`), so it lifts to `:shared/commonMain` unchanged once `:shared` gains Compose (the
20-
* `org.jetbrains.compose` ui-text dependency decision flagged in `decisions.md`/ADR-0004 — deliberately
21-
* out of this slice; the candidate stays app-side and dependency-free).
21+
* `org.jetbrains.compose` ui-text dependency decision flagged in `decisions.md`/ADR-0004 — that move is
22+
* a later C4-era slice; S3b keeps it app-side, dependency-free).
2223
*
2324
* The Android-specific bootstrap (`androidTextMeasureEnv(context)`) and the `TextPaint → TextStyle`
2425
* adapter (`toWatermarkTextStyle()`) live in the sibling `AndroidTextMeasureEnv.kt`.
2526
*/
2627

2728
/**
28-
* The minimal text-measurement environment the future renderer needs, as a value object (no `Context`):
29+
* The minimal text-measurement environment the renderer needs, as a value object (no `Context`):
2930
* a [FontFamily.Resolver], a [Density], and a [LayoutDirection]. Platform-neutral / commonMain-ready.
31+
*
32+
* S3b (D1): **product measurement** — supplied to [WatermarkRenderer.buildTextShader] at the
33+
* preview/export call sites (built via `androidTextMeasureEnv(context)`). `public` because it appears
34+
* in the renderer's measurement API signature.
3035
*/
31-
internal data class TextMeasureEnv(
36+
data class TextMeasureEnv(
3237
val fontFamilyResolver: FontFamily.Resolver,
3338
val density: Density,
3439
val layoutDirection: LayoutDirection = LayoutDirection.Ltr,
@@ -37,6 +42,7 @@ internal data class TextMeasureEnv(
3742
/**
3843
* The headless text-cell measurement seam. Platform-neutral: given an injected [TextMeasureEnv] and a
3944
* [TextStyle], returns the laid-out text size in px — the `StaticLayout.width/height` analogue.
45+
* S3b: the renderer's product text-cell measurement.
4046
*/
4147
internal object WatermarkTextMeasurer {
4248
fun measure(env: TextMeasureEnv, text: String, style: TextStyle): IntSize =

0 commit comments

Comments
 (0)