|
| 1 | +package it.bosler.numeracy.gallery |
| 2 | + |
| 3 | +import androidx.compose.foundation.layout.Box |
| 4 | +import androidx.compose.foundation.layout.fillMaxSize |
| 5 | +import androidx.compose.material3.MaterialTheme |
| 6 | +import androidx.compose.material3.Surface |
| 7 | +import androidx.compose.runtime.Composable |
| 8 | +import androidx.compose.ui.ImageComposeScene |
| 9 | +import androidx.compose.ui.Modifier |
| 10 | +import androidx.compose.ui.unit.Density |
| 11 | +import it.bosler.numeracy.ui.theme.NumeracyTheme |
| 12 | +import org.jetbrains.skia.Image |
| 13 | +import org.jetbrains.skia.Rect |
| 14 | +import org.jetbrains.skia.SamplingMode |
| 15 | +import org.jetbrains.skia.Surface as SkiaSurface |
| 16 | +import java.io.File |
| 17 | + |
| 18 | +/** |
| 19 | + * Draws a composable to a PNG with no display, so every screen of the app can be looked at as a |
| 20 | + * picture that came from the code the app runs. Runs on the JVM target. |
| 21 | + */ |
| 22 | +fun renderToPng( |
| 23 | + name: String, |
| 24 | + widthDp: Int, |
| 25 | + heightDp: Int, |
| 26 | + dark: Boolean, |
| 27 | + outDir: File, |
| 28 | + // Pixels per dp. A phone render is read at its own size and wants two; a wide one is only ever |
| 29 | + // looked at beside the phone, at half a window, and at two it costs four times the pixels for |
| 30 | + // no more to read. |
| 31 | + scale: Float, |
| 32 | + content: @Composable () -> Unit, |
| 33 | +) { |
| 34 | + val density = Density(scale) |
| 35 | + val scene = ImageComposeScene( |
| 36 | + width = (widthDp * density.density).toInt(), |
| 37 | + height = (heightDp * density.density).toInt(), |
| 38 | + density = density, |
| 39 | + ) { |
| 40 | + NumeracyTheme(darkTheme = dark) { |
| 41 | + Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) { |
| 42 | + Box(Modifier.fillMaxSize()) { content() } |
| 43 | + } |
| 44 | + } |
| 45 | + } |
| 46 | + try { |
| 47 | + // A screen answers a tap with an animation and loads its photos off the composition, so the |
| 48 | + // first frame is a screen mid-blink: colours not yet arrived at, cards still grey. Frames |
| 49 | + // are drawn until those have settled, and the last one is the picture. |
| 50 | + var last: Image? = null |
| 51 | + var time = 0L |
| 52 | + repeat(SETTLING_FRAMES) { |
| 53 | + last = scene.render(time) |
| 54 | + time += FRAME_NANOS |
| 55 | + Thread.sleep(2) |
| 56 | + } |
| 57 | + val image = last!! |
| 58 | + outDir.mkdirs() |
| 59 | + File(outDir, "$name.png").writeBytes(image.encodeToData()!!.bytes) |
| 60 | + // The book asks for a small copy where it shows a picture small, and a card is the top of a |
| 61 | + // screen at a size that can be read. Without them every thumbnail on the index is a |
| 62 | + // quarter-megabyte photograph of a phone. |
| 63 | + writePart(image, File(outDir, "small"), name, SMALL_SCALE, 1f) |
| 64 | + writePart(image, File(outDir, "card"), name, CARD_SCALE, CARD_TOP) |
| 65 | + } finally { |
| 66 | + scene.close() |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +/** A copy of a render: [scale] of its size, and the top [top] of its height. */ |
| 71 | +private fun writePart(image: Image, dir: File, name: String, scale: Float, top: Float) { |
| 72 | + val width = (image.width * scale).toInt().coerceAtLeast(1) |
| 73 | + val height = (image.height * top * scale).toInt().coerceAtLeast(1) |
| 74 | + val surface = SkiaSurface.makeRasterN32Premul(width, height) |
| 75 | + try { |
| 76 | + surface.canvas.drawImageRect( |
| 77 | + image, |
| 78 | + Rect.makeWH(image.width.toFloat(), image.height * top), |
| 79 | + Rect.makeWH(width.toFloat(), height.toFloat()), |
| 80 | + SamplingMode.LINEAR, |
| 81 | + null, |
| 82 | + true, |
| 83 | + ) |
| 84 | + dir.mkdirs() |
| 85 | + File(dir, "$name.png").writeBytes(surface.makeImageSnapshot().encodeToData()!!.bytes) |
| 86 | + } finally { |
| 87 | + surface.close() |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +private const val SMALL_SCALE = 0.5f |
| 92 | +private const val CARD_SCALE = 0.55f |
| 93 | +private const val CARD_TOP = 0.45f |
| 94 | + |
| 95 | +private const val FRAME_NANOS = 16_000_000L |
| 96 | +private val SETTLING_FRAMES = (System.getProperty("gallery.frames") ?: "45").toInt() |
0 commit comments