From c1d0e0c53afad26d8c3a8d0bf9aa683b4834bc46 Mon Sep 17 00:00:00 2001 From: Romain Guy Date: Thu, 1 Dec 2022 17:19:53 -0800 Subject: [PATCH] Remove Bitmap.toPath[s] and Path.divide We now rely on the APIs provided by the pathway library. --- README.md | 19 +- build.gradle | 4 +- combo-breaker-demo/build.gradle | 2 + .../combobreaker/demo/ComboBreakerActivity.kt | 4 +- combo-breaker/build.gradle | 2 - .../romainguy/text/combobreaker/Contours.kt | 269 ------------------ .../romainguy/text/combobreaker/Geometry.kt | 64 ----- .../dev/romainguy/text/combobreaker/Image.kt | 184 ------------ gradle.properties | 2 +- 9 files changed, 16 insertions(+), 534 deletions(-) delete mode 100644 combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Contours.kt delete mode 100644 combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Image.kt diff --git a/README.md b/README.md index dda0557..7470006 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,6 @@ flow the text content around its children. - Arbitrary shapes (any `Path`) - Justification - Hyphenation -- Generate shapes from images -- Path division (extract multiple contours as a list of paths) - Compatible with API 29+ ## Design Systems @@ -106,8 +104,9 @@ TextFlow( } ``` -Using the extension `Bitmap.toPath` provided by this library, a shape can be extracted from a -bitmap and used as the flow shape for the desired child: +The non-rectangular `Path` shape is created using the extension `Bitmap.toPath` from the +[pathway](https://github.com/romainguy/pathway) library. Using that API, a shape can be extracted +from a bitmap and used as the flow shape for the desired child: ![Flow around non-rectangular shapes](art/screenshot_arbitrary_shapes.png) @@ -117,9 +116,9 @@ the example below, both justification and hyphenation are enabled: ![Justification and hyphenation](art/screenshot_styles_and_justification.png) You can also specify multiple shapes for any given element by using the `flowShapes` modifiers -instead of `flowShape`. `flowShapes` accepts/returns list of paths instead of a single path. You -can easily extract a list of paths from a `Bitmap` by using `Bitmap.toPaths()` instead of -`Bitmap.toPath()`. For instance: +instead of `flowShape`. `flowShapes` accepts/returns list of paths instead of a single path. +For instance, with [pathway](https://github.com/romainguy/pathway) you can easily extract a list of +paths from a `Bitmap` by using `Bitmap.toPaths()` instead of `Bitmap.toPath()`. ```kotlin val heartsShapes = heartsBitmap.toPaths().map { it.asComposePath() } @@ -139,7 +138,7 @@ TextFlow( } ``` -The division operation create many shapes around which the text can flow: +This creates many shapes around which the text can flow: ![Multiple shapes per element](art/screenshot_shapes.png) @@ -153,10 +152,10 @@ repositories { dependencies { // Use this library and BasicTextFlow() if you don't want a dependency on material3 - implementation 'dev.romainguy:combo-breaker:0.7.0' + implementation 'dev.romainguy:combo-breaker:0.8.0' // Use this library and TextFlow() if you use material3 - implementation 'dev.romainguy:combo-breaker-material3:0.7.0' + implementation 'dev.romainguy:combo-breaker-material3:0.8.0' } ``` diff --git a/build.gradle b/build.gradle index 447b04e..406b4de 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id 'com.android.application' version '8.0.0-alpha08' apply false - id 'com.android.library' version '8.0.0-alpha08' apply false + id 'com.android.application' version '8.0.0-alpha09' apply false + id 'com.android.library' version '8.0.0-alpha09' apply false id 'org.jetbrains.kotlin.android' version '1.7.20' apply false } diff --git a/combo-breaker-demo/build.gradle b/combo-breaker-demo/build.gradle index 6ae0183..7c8c29a 100644 --- a/combo-breaker-demo/build.gradle +++ b/combo-breaker-demo/build.gradle @@ -61,5 +61,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' implementation 'androidx.activity:activity-compose:1.6.1' + implementation 'dev.romainguy:pathway:0.9.0' + implementation project(path: ':combo-breaker-material3') } diff --git a/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt b/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt index 01e3f22..57cf6db 100644 --- a/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt +++ b/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt @@ -66,8 +66,8 @@ import dev.romainguy.text.combobreaker.TextFlowHyphenation import dev.romainguy.text.combobreaker.TextFlowJustification import dev.romainguy.text.combobreaker.demo.ui.theme.ComboBreakerTheme import dev.romainguy.text.combobreaker.material3.TextFlow -import dev.romainguy.text.combobreaker.toPath -import dev.romainguy.text.combobreaker.toPaths +import dev.romainguy.graphics.path.toPath +import dev.romainguy.graphics.path.toPaths class ComboBreakerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/combo-breaker/build.gradle b/combo-breaker/build.gradle index 98c51ac..5acfb20 100644 --- a/combo-breaker/build.gradle +++ b/combo-breaker/build.gradle @@ -41,8 +41,6 @@ dependencies { implementation platform("androidx.compose:compose-bom:$compose_bom") implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.material3:material3' - - implementation 'dev.romainguy:pathway:0.8.0' } apply plugin: 'com.vanniktech.maven.publish' diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Contours.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Contours.kt deleted file mode 100644 index baf7399..0000000 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Contours.kt +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright (C) 2022 Romain Guy - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package dev.romainguy.text.combobreaker - -import android.graphics.Path -import kotlin.math.cos -import kotlin.math.sqrt - -@Suppress("NOTHING_TO_INLINE") -private inline fun invlength(x: Float, y: Float) = 1.0f / sqrt(x * x + y * y) - -@Suppress("NOTHING_TO_INLINE") -private inline fun dot(x0: Float, y0: Float, x1: Float, y1: Float) = x0 * x1 + y0 * y1 - -private const val ContourStorageGrowth = 2.0f -private const val ContourDefaultStorage = 32 - -/** - * A contour is a series of line segments. It can be seen as a simplified version of a Path. - * We use a custom data structure instead of a Path to avoid JNI transitions and the use of - * an extra dependency like [pathway](https://github.com/romainguy/pathway) required to iterate - * over the content of a Path. - * - * The first point in a contour should be treated as a move command in a Path, and - * subsequent points should be line commands (`lineTo`). - * - * @param x The x coordinate of the first point in the contour. - * @param y The y coordinate of the first point in the contour. - * @param capacity The default capacity as a number of points. - */ -internal class Contour(x: Float, y: Float, capacity: Int = ContourDefaultStorage) { - /** - * The points making this contour. The size of the array is guaranteed to be greater or - * equal to [count] * 2. Each point is made of 2 floats in the array, respectively x and y. - */ - private var points = FloatArray(capacity * 2) // 2 floats per point - - /** - * Numbers of points in this contour. - */ - private var count = 1 - - init { - points[0] = x - points[1] = y - } - - /** - * Builds a new contour with 2 points (1 line). - */ - constructor(x0: Float, y0: Float, x1: Float, y1: Float): this(x0, y0) { - points[2] = x1 - points[3] = y1 - count++ - } - - /** - * Returns true if this contour starts with the specified point. - */ - fun startsWith(x: Float, y: Float) = points[0] == x && points[1] == y - - /** - * Returns true if this contour ends with the specified point. - */ - fun endsWith(x: Float, y: Float): Boolean { - val i = (count - 1) * 2 - return points[i] == x && points[i + 1] == y - } - - /** - * Inserts the specified point at the beginning of the contour. - */ - fun prepend(x: Float, y: Float) { - val index = ensureCapacity(count + 1) * 2 - points.copyInto(points, 2, 0, index) - points[0] = x - points[1] = y - } - - /** - * Inserts the specified point at the end of the contour. - */ - fun append(x: Float, y: Float) { - val index = ensureCapacity(count + 1) * 2 - points[index] = x - points[index + 1] = y - } - - /** - * Adds all the point of the specified contour into this contour. - */ - fun add(contour: Contour) { - val index = ensureCapacity(count + contour.count) * 2 - contour.points.copyInto(points, index, 0, contour.count * 2) - } - - /** - * Returns a new contour as a simplified representation of this contour. The simplification - * step is based on the specified [tolerance], expressed as the minimum angle in degrees - * allowed between two segments in the contour. - */ - fun simplify(tolerance: Float): Contour { - val simplified = Contour(points[0], points[1], points[2], points[3]) - val minTolerance = -cos(Math.toRadians(tolerance.toDouble())).toFloat() - - for (i in 2 until count) { - var index = i * 2 - val x = points[index] - val y = points[index + 1] - - index = (simplified.count - 2) * 2 - var x0 = simplified.points[index] - var y0 = simplified.points[index + 1] - - index += 2 - var x1 = simplified.points[index] - var y1 = simplified.points[index + 1] - - // Quick checks for horizontal/vertical cases - if (x0 == x && x1 == x) { - simplified.points[index + 1] = y - } else if (y0 == y && y1 == y) { - simplified.points[index] = x - } else { - x0 -= x1 - y0 -= y1 - val l0 = invlength(x0, y0) - - x1 = x - x1 - y1 = y - y1 - val l1 = invlength(x1, y1) - - val cosAngle = dot(x0 * l0, y0 * l0, x1 * l1, y1 * l1) - if (cosAngle > minTolerance) { - simplified.append(x, y) - } else { - simplified.points[index] = x - simplified.points[index + 1] = y - } - } - } - - // TODO: We should check the angle between the first and last point when the contour is - // a closed contour - - return simplified - } - - private fun ensureCapacity(newCount: Int): Int { - if (newCount * 2 > points.size) { - val newPoints = FloatArray((newCount * 2.0f * ContourStorageGrowth).toInt()) - points.copyInto(newPoints, 0, 0, count * 2) - points = newPoints - } - val oldCount = count - count = newCount - return oldCount - } - - /** - * Converts this contour to a [Path], adding to the existing [path] is specified, - * otherwise creates a new one and adds to that new [Path]. - */ - fun toPath(path: Path = Path()): Path { - val p = points - path.moveTo(p[0], p[1]) - - for (j in 1 until count) { - val index = j * 2 - path.lineTo(p[index], p[index + 1]) - } - - return path - } -} - -/** - * A [ContourSet] is a list of contours to which new segments can be added. When a new segment - * is added, either a new [Contour] is created in the list, or the segment is added to existing - * contours, which can lead to the fusion of pairs of contours. - */ -internal class ContourSet { - private val contours = mutableListOf() - - /** - * Number of contours in this set. - */ - val size: Int - get() = contours.size - - /** - * Returns the contour at the specified index. - */ - operator fun get(index: Int) = contours[index] - - /** - * Iterates over all the contours in the set. - */ - operator fun iterator() = contours.iterator() - - /** - * Finds the index of the contour that starts with the specified point. - */ - private fun startIndexOf(x: Float, y: Float): Int { - val size = contours.size - for (i in 0 until size) { - if (contours[i].startsWith(x, y)) return i - } - return -1 - } - - /** - * Finds the index of the contour that ends with the specified point. - */ - private fun endIndexOf(x: Float, y: Float): Int { - val size = contours.size - for (i in 0 until size) { - if (contours[i].endsWith(x, y)) return i - } - return -1 - } - - /** - * Adds a segment defined by the specified coordinates to the contour set. - * This operation can create a new [Contour] or merge existing contours. - */ - fun addLine(x0: Float, y0: Float, x1: Float, y1: Float) { - // Find the contour this new line would come from - val from = endIndexOf(x0, y0) - // Find the contour this new line would connect to - val to = startIndexOf(x1, y1) - if (from >= 0 && to >= 0) { - if (from != to) { - // Join the two contours - contours[from].add(contours[to]) - contours.removeAt(to) - } else { - // Loop the contour by appending its first point - contours[from].append(x1, y1) - } - } else if (from >= 0) { - // We're coming from an existing contour, append x1/y1 - contours[from].append(x1, y1) - } else if (to >= 0) { - // We're going to an existing contour head, prepend x0/y0 - contours[to].prepend(x0, y0) - } else { - // No contour, let's start a new one - // TODO: We often create a Contour here to join it right after in the next call - // to addLine(). We could defer the creation until the next addition to - // avoid those merges - contours.add(Contour(x0, y0, x1, y1)) - } - } -} diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt index acf3bbc..d3457c9 100644 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt +++ b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt @@ -21,73 +21,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidPath -import dev.romainguy.graphics.path.PathSegment.Type.* -import dev.romainguy.graphics.path.iterator import kotlin.math.max import kotlin.math.min -/** - * Divides this path into a list of paths. Each contour inside this path is returned as a separate - * [Path]. For instance the following code snippet creates two rectangular contours: - * - * ``` - * val p = Path() - * p.addRect(…) - * p.addRect(…) - * val paths = p.divide() - * ``` - * The list returned by calling `p.divide()` will contain two `Path` instances, each representing - * one of the two rectangles. - * - * @param paths An optional mutable list of [Path] that will hold the result of the division. - * - * @return A list of [Path] representing all the contours in this path. The returned list is either - * a newly allocated list if the [paths] parameter was left unspecified, or the [paths] parameter. - */ -fun Path.divide(paths: MutableList = mutableListOf()): List { - var path = Path() - - var first = true - - val iterator = asAndroidPath().iterator() - val points = FloatArray(8) - - while (iterator.hasNext()) { - when (iterator.next(points)) { - Move -> { - if (!first) { - paths.add(path) - path = Path() - } - first = false - path.moveTo(points[0], points[1]) - } - Line -> path.lineTo(points[2], points[3]) - Quadratic -> path.quadraticBezierTo( - points[2], - points[3], - points[4], - points[5] - ) - Conic -> continue // We convert conics to quadratics - Cubic -> path.cubicTo( - points[2], - points[3], - points[4], - points[5], - points[6], - points[7] - ) - Close -> path.close() - Done -> continue // Won't happen inside this loop - } - } - - if (!first) paths.add(path) - - return paths -} - internal class PathSegment(val x0: Float, val y0: Float, val x1: Float, val y1: Float) /** diff --git a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Image.kt b/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Image.kt deleted file mode 100644 index d9f52d5..0000000 --- a/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Image.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright (C) 2022 Romain Guy - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package dev.romainguy.text.combobreaker - -import android.graphics.Bitmap -import android.graphics.Path -import kotlin.math.max -import kotlin.math.min - -/** - * Extract the contours of this [Bitmap] as a [Path]. The contours are traced by following opaque - * pixels, as defined by [alphaThreshold]. Any pixel with an alpha channel value greater than the - * specified [alphaThreshold] is considered opaque. - * - * After the contours are built, a simplification pass is performed to reduce the complexity of the - * paths. The [minAngle] parameter defines the minimum angle between two segments in the contour - * before they are collapsed. For instance, passing a [minAngle] of 45 means that the final path - * will not contain adjacent segments with an angle greater than 45 degrees. - * - * @param alphaThreshold Maximum alpha channel a pixel might have before being considered opaque. - * This value is between 0.0 and 1.0. - * @param minAngle Minimum angle between two segments in the contour before they are collapsed - * to simplify the final geometry. - * - * @return A [Path] containing all the contours detected in this [Bitmap], separated by `moveTo` - * commands inside the path. - */ -fun Bitmap.toPath( - alphaThreshold: Float = 0.0f, - minAngle: Float = 15.0f, -): Path { - if (!hasAlpha()) { - return Path().apply { - addRect(0.0f, 0.0f, width.toFloat(), height.toFloat(), Path.Direction.CCW) - } - } - - val contours = toContourSet(alphaThreshold) - - val path = Path() - val size = contours.size - for (i in 0 until size) { - val contour = if (minAngle < 1.0f) contours[i] else contours[i].simplify(minAngle) - contour.toPath(path) - } - - return path -} - -/** - * Extract the contours of this [Bitmap] as a list of [Path]. The contours are traced by following - * opaque pixels, as defined by [alphaThreshold]. Any pixel with an alpha channel value greater - * than the specified [alphaThreshold] is considered opaque. - * - * After the contours are built, a simplification pass is performed to reduce the complexity of the - * paths. The [minAngle] parameter defines the minimum angle between two segments in the contour - * before they are collapsed. For instance, passing a [minAngle] of 45 means that the final path - * will not contain adjacent segments with an angle greater than 45 degrees. - * - * @param alphaThreshold Maximum alpha channel a pixel might have before being considered opaque. - * This value is between 0.0 and 1.0. - * @param minAngle Minimum angle between two segments in the contour before they are collapsed - * to simplify the final geometry. - * - * @return A list of [Path] containing all the contours detected in this [Bitmap] as separate - * paths. - */ -fun Bitmap.toPaths( - alphaThreshold: Float = 0.0f, - minAngle: Float = 15.0f, -): List { - if (!hasAlpha()) { - return listOf( - Path().apply { - addRect(0.0f, 0.0f, width.toFloat(), height.toFloat(), Path.Direction.CCW) - } - ) - } - - val contours = toContourSet(alphaThreshold) - val paths = mutableListOf() - - val size = contours.size - for (i in 0 until size) { - val path = Path() - val contour = if (minAngle < 1.0f) contours[i] else contours[i].simplify(minAngle) - contour.toPath(path) - paths += path - } - - return paths -} - -private fun Bitmap.toContourSet(alphaThreshold: Float): ContourSet { - val w = width - val h = height - - val pixels = IntArray(w * h) - getPixels(pixels, 0, w, 0, 0, w, h) - - // Increase threshold by 1 to make the branchless function sample() work - val threshold = (alphaThreshold * 255.0f + 1).toInt().coerceIn(0, 255) - - val contours = ContourSet() - - val xmax = (w - 1).toFloat() - val ymax = (h - 1).toFloat() - - // Pretend we have a guard band to handle opaque pixels at the edges - for (y in -1 until h) { - val y0 = max(0.0f, y.toFloat()) - val yM = (y + 0.5f).clamp(0.0f, ymax) - val y1 = min(ymax, y + 1.0f) - - for (x in -1 until w) { - val x0 = max(0.0f, x.toFloat()) - val xM = (x + 0.5f).clamp(0.0f, xmax) - val x1 = min(xmax, x + 1.0f) - - val a = pixels.sample(w, h, x, y, threshold) - val b = pixels.sample(w, h, x + 1, y, threshold) - val c = pixels.sample(w, h, x, y + 1, threshold) - val d = pixels.sample(w, h, x + 1, y + 1, threshold) - - when (quadKey(a, b, c, d)) { - // 0x0 -> fully transparent, skip - 0x1 -> contours.addLine(x0, yM, xM, y0) - 0x2 -> contours.addLine(xM, y0, x1, yM) - 0x3 -> contours.addLine(x0, yM, x1, yM) - 0x4 -> contours.addLine(xM, y1, x0, yM) - 0x5 -> contours.addLine(xM, y1, xM, y0) - 0x6 -> { - contours.addLine(xM, y0, x1, yM) - contours.addLine(x0, y1, x0, yM) - } - 0x7 -> contours.addLine(xM, y1, x1, yM) - 0x8 -> contours.addLine(x1, yM, xM, y1) - 0x9 -> { - contours.addLine(x0, yM, xM, y0) - contours.addLine(x1, yM, xM, y1) - } - 0xA -> contours.addLine(xM, y0, xM, y1) - 0xB -> contours.addLine(x0, yM, xM, y1) - 0xC -> contours.addLine(x1, yM, x0, yM) - 0xD -> contours.addLine(x1, yM, xM, y0) - 0xE -> contours.addLine(xM, y0, x0, yM) - // 0xF -> fully opaque, skip - } - } - } - - return contours -} - -@Suppress("NOTHING_TO_INLINE") -private inline fun IntArray.sample(w: Int, h: Int, x: Int, y: Int, threshold: Int) = - if (x < 0 || x >= w || y < 0 || y >= h) - 0 - else - (((this[y * w + x] ushr 24) - threshold) ushr 31) xor 1 - -@Suppress("NOTHING_TO_INLINE") -private inline fun quadKey(a: Int, b: Int, c: Int, d: Int) = - a or (b shl 1) or (c shl 2) or (d shl 3) - -fun Float.clamp(minimumValue: Float, maximumValue: Float): Float { - if (this < minimumValue) return minimumValue - if (this > maximumValue) return maximumValue - return this -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 72bcafe..f164221 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=dev.romainguy -VERSION_NAME=0.7.0 +VERSION_NAME=0.8.0 SONATYPE_HOST=S01 RELEASE_SIGNING_ENABLED=true