All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.in.procyk.compose.qrcode.QrCodePainter.kt Maven / Gradle / Ivy

Go to download

Helper functions and extensions when working with compose-multiplatform projects

There is a newer version: 1.6.1.0
Show newest version
/**
 * Based on QRose by Alexander Zhirkevich from [Github](https://github.com/alexzhirkevich/qrose)
 *
 * MIT License
 *
 * Copyright (c) 2023 Alexander Zhirkevich
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package `in`.procyk.compose.qrcode

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.IntSize
import `in`.procyk.compose.qrcode.options.*
import `in`.procyk.compose.qrcode.options.dsl.QrOptionsBuilderScope
import kotlin.math.ceil
import kotlin.math.roundToInt

/**
 * Create and remember QR code painter
 *
 * @param data QR code payload
 * @param keys keys of the [options] block. QR code will be re-generated when any key is changed.
 * @param options [QrOptions] builder block
 * */
@Composable
fun rememberQrCodePainter(
    data : String,
    vararg keys : Any?,
    options : QrOptionsBuilderScope.() -> Unit
) : QrCodePainter = rememberQrCodePainter(
    data = data,
    options = remember(keys) { QrOptions(options) }
)

/**
 * Create and remember QR code painter
 *
 * @param data QR code payload
 * @param options QR code styling options
 * */
@Composable
fun rememberQrCodePainter(
    data : String,
    options : QrOptions
) : QrCodePainter = remember(data, options) {
    QrCodePainter(data, options)
}

@Composable
fun rememberQrCodePainter(
    data : String,
    shapes: QrShapes = QrShapes(),
    colors : QrColors = QrColors(),
    logo : QrLogo = QrLogo(),
    errorCorrectionLevel: QrErrorCorrectionLevel = QrErrorCorrectionLevel.Auto,
    fourEyed : Boolean = false,
) : QrCodePainter = rememberQrCodePainter(
    data = data,
    options = remember(shapes, colors, logo, errorCorrectionLevel, fourEyed) {
        QrOptions(
            shapes = shapes,
            colors = colors,
            logo = logo,
            errorCorrectionLevel = errorCorrectionLevel,
            fourEyed = fourEyed
        )
    }
)

/**
 * Encodes [data] payload and renders it into the compose [Painter] using styling [options]
 * */
@Immutable
class QrCodePainter(
    val data : String,
    val options: QrOptions = QrOptions(),
) : Painter() {

    private val initialMatrixSize : Int

    private val actualCodeMatrix = options.shapes.code.run {

        val initialMatrix = QRCode(
            data = data,
            errorCorrectionLevel =
            if (options.errorCorrectionLevel == QrErrorCorrectionLevel.Auto)
                options.errorCorrectionLevel.fit(options).lvl
            else options.errorCorrectionLevel.lvl
        ).encode()

        initialMatrixSize = initialMatrix.size

        initialMatrix.transform()
    }

    private var codeMatrix = actualCodeMatrix

    override val intrinsicSize: Size = Size(
        codeMatrix.size.toFloat() * 10f,
        codeMatrix.size.toFloat() * 10f
    )

    private val shapeIncrease = (codeMatrix.size - initialMatrixSize)/2

    private val balls = mutableListOf(
        2 + shapeIncrease to 2 + shapeIncrease,
        2 + shapeIncrease to initialMatrixSize - 5 + shapeIncrease,
        initialMatrixSize - 5 + shapeIncrease to 2 + shapeIncrease
    ).apply {
        if (options.fourEyed)
            this += initialMatrixSize - 5 + shapeIncrease to initialMatrixSize - 5 + shapeIncrease
    }.toList()

    private val frames = mutableListOf(
        shapeIncrease to shapeIncrease,
        shapeIncrease to initialMatrixSize - 7 + shapeIncrease,
        initialMatrixSize - 7 + shapeIncrease to shapeIncrease
    ).apply {
        if (options.fourEyed) {
            this += initialMatrixSize - 7 + shapeIncrease to initialMatrixSize - 7 + shapeIncrease
        }
    }.toList()

    private val shouldSeparateDarkPixels
        get() = options.colors.dark.mode == QrBrushMode.Separate

    private val shouldSeparateLightPixels
        get() = options.colors.light.mode == QrBrushMode.Separate

    private val shouldSeparateFrames
        get() = options.colors.frame.isSpecified || shouldSeparateDarkPixels

    private val shouldSeparateBalls
        get() = options.colors.ball.isSpecified || shouldSeparateDarkPixels

    private var colorFilter: ColorFilter? = null
    private var alpha: Float = 1f

    private val cacheDrawScope = DrawCache()
    private var cachedSize: Size? = null

    override fun toString(): String {
        return "QrCodePainter(data = $data)"
    }

    override fun hashCode(): Int {
        return data.hashCode() * 31 + options.hashCode()
    }

    override fun applyAlpha(alpha: Float): Boolean {
        this.alpha = alpha
        return true
    }

    override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
        this.colorFilter = colorFilter
        return true
    }

    private val DrawScope.logoSize
        get() = size * options.logo.size

    private val DrawScope.logoPaddingSize
        get() = logoSize.width * (1 + options.logo.padding.size)

    private val DrawScope.pixelSize : Float
        get() = minOf(size.width, size.height) / codeMatrix.size

    private val drawBlock: DrawScope.() -> Unit = { draw() }

    private fun DrawScope.draw() {

        val pixelSize = pixelSize

        prepareLogo(pixelSize)

        val (dark, light) = createMainElements(pixelSize)

        if (shouldSeparateDarkPixels || shouldSeparateLightPixels) {
            drawSeparatePixels(pixelSize)
        }

        if (!shouldSeparateLightPixels) {
            drawPath(
                path = light,
                brush = options.colors.light
                    .brush(pixelSize * codeMatrix.size, Neighbors.Empty),
            )
        }

        if (!shouldSeparateDarkPixels) {
            drawPath(
                path = dark,
                brush = options.colors.dark
                    .brush(pixelSize * codeMatrix.size, Neighbors.Empty),
            )
        }

        if (shouldSeparateFrames) {
            drawFrames(pixelSize)
        }

        if (shouldSeparateBalls) {
            drawBalls(pixelSize)
        }

        drawLogo()
    }



    override fun DrawScope.onDraw() {

        if (cachedSize != size) {

            codeMatrix = actualCodeMatrix.copy()

            cacheDrawScope.drawCachedImage(
                size = IntSize(ceil(size.width).toInt(), ceil(size.height).toInt()),
                density = this,
                layoutDirection = layoutDirection,
                block = drawBlock
            )
        }
        cacheDrawScope.drawInto(
            target = this,
            alpha = alpha,
            colorFilter = colorFilter
        )
    }


    private fun DrawScope.drawSeparatePixels(
        pixelSize: Float,
    ){
        val darkPaint = darkPaintFactory(pixelSize)
        val lightPaint = lightPaintFactory(pixelSize)

        val darkPixelPath = darkPixelPathFactory(pixelSize)
        val lightPixelPath = lightPixelPathFactory(pixelSize)

        repeat(codeMatrix.size) { i ->
            repeat(codeMatrix.size) inner@{ j ->
                if (isInsideFrameOrBall(i, j))
                    return@inner

                translate(
                    left = i * pixelSize,
                    top = j * pixelSize
                ) {
                    if (shouldSeparateDarkPixels && codeMatrix[i, j] == QrCodeMatrix.PixelType.DarkPixel) {
                        val n = codeMatrix.neighbors(i, j)
                        drawPath(
                            path = darkPixelPath.next(n),
                            brush = darkPaint.next(n),
                        )
                    }
                    if (shouldSeparateLightPixels && codeMatrix[i, j] == QrCodeMatrix.PixelType.LightPixel) {
                        val n = codeMatrix.neighbors(i, j)

                        drawPath(
                            path = lightPixelPath.next(n),
                            brush = lightPaint.next(n),
                        )
                    }
                }
            }
        }
    }


    private fun DrawScope.prepareLogo(pixelSize: Float) {

        val ps = logoPaddingSize

        if (options.logo.padding is QrLogoPadding.Natural) {
            val logoPath = options.logo.shape.newPath(
                size = ps,
                neighbors = Neighbors.Empty
            ).apply {
                translate(
                    Offset(
                        (size.width - ps) / 2f,
                        (size.height - ps) / 2f,
                    )
                )
            }

            val darkPathF = darkPixelPathFactory(pixelSize)
            val lightPathF = lightPixelPathFactory(pixelSize)

            val logoPixels = (codeMatrix.size *
                    options.logo.size.coerceIn(0f, 1f) *
                    (1 + options.logo.padding.size.coerceIn(0f, 1f))).roundToInt() + 1

            val xRange =
                (codeMatrix.size - logoPixels) / 2 until (codeMatrix.size + logoPixels) / 2
            val yRange =
                (codeMatrix.size - logoPixels) / 2 until (codeMatrix.size + logoPixels) / 2

            for (x in xRange) {
                for (y in yRange) {
                    val neighbors = codeMatrix.neighbors(x, y)
                    val offset = Offset(x * pixelSize, y * pixelSize)

                    val darkPath = darkPathF.next(neighbors).apply {
                        translate(offset)
                    }
                    val lightPath = lightPathF.next(neighbors).apply {
                        translate(offset)
                    }

                    if (
                        codeMatrix[x, y] == QrCodeMatrix.PixelType.DarkPixel &&
                        logoPath.intersects(darkPath) ||
                        codeMatrix[x, y] == QrCodeMatrix.PixelType.LightPixel &&
                        logoPath.intersects(lightPath)
                    ) {
                        codeMatrix[x, y] = QrCodeMatrix.PixelType.Logo
                    }
                }
            }
        }
    }

    private fun DrawScope.drawLogo() {

        val ps = logoPaddingSize

        if (options.logo.padding is QrLogoPadding.Accurate){
            val path = options.logo.shape.newPath(
                size = ps,
                neighbors = Neighbors.Empty
            )

            translate(
                left = center.x - ps / 2,
                top = center.y - ps / 2
            ) {
                drawPath(path, Color.Black, blendMode = BlendMode.Clear)
            }
        }

        options.logo.painter?.let {
            it.run {
                translate(
                    left = center.x - logoSize.width / 2,
                    top = center.y - logoSize.height / 2
                ) {
                    draw(logoSize, alpha, colorFilter)
                }
            }
        }
    }


    private fun DrawScope.drawBalls(
       pixelSize: Float
    ) {
        val brush by ballBrushFactory(pixelSize)
        val path by ballShapeFactory(pixelSize)

        balls.forEach {

            translate(
                it.first * pixelSize,
                it.second * pixelSize
            ) {
                drawPath(
                    path = path,
                    brush = brush,
                )
            }
        }
    }


    private fun DrawScope.drawFrames(
        pixelSize: Float
    ) {
        val ballBrush by frameBrushFactory(pixelSize)
        val ballPath by frameShapeFactory(pixelSize)

        frames.forEach {

            translate(
                it.first * pixelSize,
                it.second * pixelSize
            ) {
                drawPath(
                    path = ballPath,
                    brush = ballBrush,
                )
            }
        }
    }

    private fun createMainElements(
        pixelSize: Float
    ): Pair {

        val darkPath = Path().apply {
            fillType = PathFillType.EvenOdd
        }
        val lightPath = Path().apply {
            fillType = PathFillType.EvenOdd
        }

        val rotatedFramePath by frameShapeFactory(pixelSize)
        val rotatedBallPath by ballShapeFactory(pixelSize)

        val darkPixelPathFactory = darkPixelPathFactory(pixelSize)
        val lightPixelPathFactory = lightPixelPathFactory(pixelSize)

        for (x in 0 until codeMatrix.size) {
            for (y in 0 until codeMatrix.size) {

                val neighbors = codeMatrix.neighbors(x, y)

                when {
                    !shouldSeparateFrames && isFrameStart(x, y) ->
                        darkPath
                            .addPath(
                                path = rotatedFramePath,
                                offset = Offset(x * pixelSize, y * pixelSize)
                            )


                    !shouldSeparateBalls && isBallStart(x, y) ->
                        darkPath
                            .addPath(
                                path = rotatedBallPath,
                                offset = Offset(x * pixelSize, y * pixelSize)
                            )

                    isInsideFrameOrBall(x, y) -> Unit

                    !shouldSeparateDarkPixels && codeMatrix[x, y] == QrCodeMatrix.PixelType.DarkPixel ->
                        darkPath
                            .addPath(
                                path = darkPixelPathFactory.next(neighbors),
                                offset = Offset(x * pixelSize, y * pixelSize)
                            )

                    !shouldSeparateLightPixels && codeMatrix[x, y] == QrCodeMatrix.PixelType.LightPixel ->
                        lightPath
                            .addPath(
                                path = lightPixelPathFactory.next(neighbors),
                                offset = Offset(x * pixelSize, y * pixelSize)
                            )

                }
            }
        }
        return darkPath to lightPath
    }


    private fun isFrameStart(x: Int, y: Int) =
        x - shapeIncrease == 0 && y - shapeIncrease == 0 ||
                x - shapeIncrease == 0 && y - shapeIncrease == initialMatrixSize - 7 ||
                x - shapeIncrease == initialMatrixSize - 7 && y - shapeIncrease == 0 ||
                options.fourEyed && x - shapeIncrease == initialMatrixSize - 7 && y - shapeIncrease == initialMatrixSize - 7

    private fun isBallStart(x: Int, y: Int) =
        x - shapeIncrease == 2 && y - shapeIncrease ==initialMatrixSize - 5 ||
                x - shapeIncrease == initialMatrixSize - 5 && y - shapeIncrease == 2 ||
                x - shapeIncrease == 2 && y - shapeIncrease == 2 ||
                options.fourEyed && x - shapeIncrease == initialMatrixSize - 5 && y - shapeIncrease == initialMatrixSize - 5

    private fun isInsideFrameOrBall(x: Int, y: Int): Boolean {
        return x - shapeIncrease in -1..7 && y - shapeIncrease in -1..7 ||
                x - shapeIncrease in -1..7 && y - shapeIncrease in initialMatrixSize - 8 until initialMatrixSize + 1 ||
                x - shapeIncrease in initialMatrixSize - 8 until initialMatrixSize + 1 && y - shapeIncrease in -1..7 ||
                options.fourEyed && x - shapeIncrease in initialMatrixSize - 8 until initialMatrixSize + 1 && y - shapeIncrease in initialMatrixSize - 8 until initialMatrixSize + 1
    }

    private fun darkPaintFactory(pixelSize: Float) =
        pixelBrushFactory(
            brush = options.colors.dark,
            separate = shouldSeparateDarkPixels,
            pixelSize = pixelSize
        )

    private fun lightPaintFactory(pixelSize: Float) =
        pixelBrushFactory(
            brush = options.colors.light,
            separate = shouldSeparateLightPixels,
            pixelSize = pixelSize
        )

    private fun ballBrushFactory(pixelSize: Float) =
        eyeBrushFactory(brush = options.colors.ball, pixelSize = pixelSize)

    private fun frameBrushFactory(pixelSize: Float) =
        eyeBrushFactory(brush = options.colors.frame, pixelSize = pixelSize)


    private fun ballShapeFactory(pixelSize: Float): Lazy =
        rotatedPathFactory(
            shape = options.shapes.ball,
            shapeSize = pixelSize * BALL_SIZE
        )

    private fun frameShapeFactory(pixelSize: Float): Lazy =
        rotatedPathFactory(
            shape = options.shapes.frame,
            shapeSize = pixelSize * FRAME_SIZE
        )

    private fun darkPixelPathFactory(pixelSize: Float) =
        pixelPathFactory(
            shape = options.shapes.darkPixel,
            pixelSize = pixelSize
        )

    private fun lightPixelPathFactory(pixelSize: Float) =
        pixelPathFactory(
            shape = options.shapes.lightPixel,
            pixelSize = pixelSize
        )

    private fun pixelPathFactory(
        shape : QrShapeModifier,
        pixelSize: Float
    ) : NeighborsBasedFactory {
        val path = Path()

        return NeighborsBasedFactory {
            path.rewind()
            path.apply {
                shape.run {
                    path(pixelSize, it)
                }
            }
            path
        }
    }

    private fun rotatedPathFactory(
        shape: QrShapeModifier,
        shapeSize: Float,
    ): Lazy {

        var number = 0

        val path = Path()

        val factory = NeighborsBasedFactory {
            path.apply {
                rewind()
                fillType = PathFillType.EvenOdd
                shape.run { path(shapeSize, it) }
            }
        }

        return Recreating {
            factory.next(Neighbors.forEyeWithNumber(number, options.fourEyed)).apply {
                if (options.shapes.centralSymmetry) {
                    val angle = when (number) {
                        0 -> 0f
                        1 -> -90f
                        2 -> 90f
                        else -> 180f
                    }
                    rotate(angle, Offset(shapeSize/2, shapeSize/2))
                }
            }.also {
                number = (number + 1) % if (options.fourEyed) 4 else 3
            }
        }
    }


    private fun eyeBrushFactory(
        brush: QrBrush,
        pixelSize: Float
    ): Lazy {
        val b = brush
            .takeIf { it.isSpecified }
            ?: QrBrush.Default

        var number = 0

        val factory = {
            b.brush(
                size = pixelSize,
                neighbors = Neighbors.forEyeWithNumber(number, options.fourEyed)
            ).also {
                number = (number + 1) % if (options.fourEyed) 4 else 3
            }
        }

        return Recreating(factory)
    }

    private fun pixelBrushFactory(
        brush: QrBrush,
        separate: Boolean,
        pixelSize: Float,
    ): NeighborsBasedFactory {

        val size = if (separate)
            pixelSize
        else codeMatrix.size * pixelSize

        val joinBrush by lazy { brush.brush(size, Neighbors.Empty) }

        return NeighborsBasedFactory {
            if (separate)
                brush.brush(size, it)
            else joinBrush
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false

        other as QrCodePainter

        if (data != other.data) return false
        if (options != other.options) return false

        return true
    }
}

private const val BALL_SIZE = 3
private const val FRAME_SIZE = 7


private fun Path.rotate(degree: Float, pivot : Offset) {
    translate(-pivot)
    transform(Matrix().apply { rotateZ(degree) })
    translate(pivot)
}

private fun Path.intersects(other : Path) =
    Path.combine(
        operation = PathOperation.Intersect,
        path1 = this,
        path2 = other
    ).isEmpty.not()

private class Recreating(
    private val factory : () -> T
) : Lazy {
    override val value: T
        get() = factory()

    override fun isInitialized(): Boolean = true
}

private fun Neighbors.Companion.forEyeWithNumber(number : Int, fourthEyeEnabled : Boolean) : Neighbors {
    return when (number) {
        0 -> Neighbors(bottom = true, right = true, bottomRight = fourthEyeEnabled)
        1 -> Neighbors(bottom = fourthEyeEnabled, left = true, bottomLeft = true)
        2 -> Neighbors(top = true, topRight = true, right = fourthEyeEnabled)
        3 -> Neighbors(top = true, left = true, topLeft = true)

        else -> throw IllegalStateException("Incorrect eye number: $number")
    }
}

private fun interface NeighborsBasedFactory {
    fun next(neighbors: Neighbors) : T
}


private fun QrErrorCorrectionLevel.fit(
    options: QrOptions
) : QrErrorCorrectionLevel {

    val logoSize = options.logo.size*
            (1 + options.logo.padding.size) //*
//            options.shapes.code.shapeSizeIncrease

    val hasLogo = options.logo.padding != QrLogoPadding.Empty

    return if (this == QrErrorCorrectionLevel.Auto)
        when {
            !hasLogo -> QrErrorCorrectionLevel.Low
            logoSize > .3 -> QrErrorCorrectionLevel.High
            logoSize in .2 .. .3 && lvl < ErrorCorrectionLevel.Q ->
                QrErrorCorrectionLevel.MediumHigh
            logoSize > .05f && lvl < ErrorCorrectionLevel.M ->
                QrErrorCorrectionLevel.Medium
            else -> this
        } else this
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy