commit 0ab8d422c584e5bbf731b0408c194394b38c082f Author: Craig Raw Date: Thu Jan 5 11:53:23 2023 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5052f82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +.gradle +*iml +build +*.properties +out +*.log +build-*.sh +.DS_Store \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..d624615 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Toucan + +### Java implementation of LifeHash + +Toucan is a Java implementation of the [LifeHash](https://github.com/BlockchainCommons/bc-lifehash) hash visualization algorithm. +It is a direct port of the reference C++/C implementation by Wolf McNally. +Toucan requires a minimum of Java 17. + +## Setup + +Toucan is hosted in Maven Central and can be added as a dependency with the following: + +``` +implementation('com.sparrowwallet:toucan:0.9.0') +``` + +## Usage + +A LifeHash is represented by the `LifeHash.Image` class, which can be created as follows: + +```java +import com.sparrowwallet.toucan.*; +import java.awt.image.BufferedImage; + +public class Main { + public static void main(String[] args) { + LifeHash.Image lifeHashImage = LifeHash.makeFromUTF8("Hello World", LifeHashVersion.VERSION2, 1, false); + BufferedImage awtImage = LifeHash.getBufferedImage(lifeHashImage); + } +} +``` + +## Testing + +Toucan has a small testsuite ported from the C++ implementation. The tests can be run with: + +``` +./gradlew test +``` + +## License + +Toucan is licensed under the Apache 2 software license. + +## Dependencies + +No dependencies. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2d8d471 --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'java-library' + id 'signing' + id 'maven-publish' + id('io.github.gradle-nexus.publish-plugin') version '1.1.0' +} + +group 'com.sparrowwallet' +version '0.9.0' + +sourceCompatibility = 17 + +repositories { + mavenCentral() +} + +dependencies { + testImplementation group: 'junit', name: 'junit', version: '4.13.1' +} + +java { + withJavadocJar() + withSourcesJar() +} + +nexusPublishing { + repositories { + sonatype() + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from(components.java) + pom { + name = 'toucan' + description = 'Java implementation of Lifehash' + url = 'https://github.com/sparrowwallet/toucan' + + scm { + url = 'https://github.com/sparrowwallet/toucan' + connection = 'scm:git://github.com/sparrowwallet/toucan.git' + developerConnection = 'scm:git://github.com:sparrowwallet/toucan.git' + } + + licenses { + license { + name = 'The Apache Software License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + + developers { + developer { + id = 'craigraw' + name = 'Craig Raw' + email = 'mail@sparrowwallet.com' + } + } + } + } + } +} + +signing { + sign publishing.publications.mavenJava +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..41d9927 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9962039 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'toucan' + diff --git a/src/main/java/com/sparrowwallet/toucan/LifeHash.java b/src/main/java/com/sparrowwallet/toucan/LifeHash.java new file mode 100644 index 0000000..f41adb7 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/LifeHash.java @@ -0,0 +1,244 @@ +package com.sparrowwallet.toucan; + +import com.sparrowwallet.toucan.impl.*; + +import java.awt.image.BufferedImage; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static com.sparrowwallet.toucan.impl.Gradients.selectGradient; +import static com.sparrowwallet.toucan.impl.Pattern.selectPattern; +import static com.sparrowwallet.toucan.impl.Utils.clamped; +import static com.sparrowwallet.toucan.impl.Utils.lerpFrom; + +public class LifeHash { + /** + * Creates a LifeHash.Image object from the provided String + * + * @param s the String to extract UTF-8 bytes from as input + * @param version the version of LifeHash to use + * @param moduleSize the size of the LifeHash + * @param hasAlpha whether transparency information should be included + * @return an object representing the LifeHash + */ + public static Image makeFromUTF8(String s, LifeHashVersion version, int moduleSize, boolean hasAlpha) { + return makeFromData(s.getBytes(StandardCharsets.UTF_8), version, moduleSize, hasAlpha); + } + + /** + * Creates a LifeHash.Image object from the provided bytes + * + * @param data the bytes to use as input + * @param version the version of LifeHash to use + * @param moduleSize the size of the LifeHash + * @param hasAlpha whether transparency information should be included + * @return an object representing the LifeHash + */ + public static Image makeFromData(byte[] data, LifeHashVersion version, int moduleSize, boolean hasAlpha) { + byte[] digest = Sha256Hash.hash(data); + return makeFromDigest(digest, version, moduleSize, hasAlpha); + } + + /** + * Creates a LifeHash.Image object from the provided SHA256 hash + * + * @param digest a 32 byte array representing a SHA256 hash to use as input + * @param version the version of LifeHash to use + * @param moduleSize the size of the LifeHash + * @param hasAlpha whether transparency information should be included + * @return an object representing the LifeHash + */ + public static Image makeFromDigest(byte[] digest, LifeHashVersion version, int moduleSize, boolean hasAlpha) { + if(digest.length != 32) { + throw new IllegalArgumentException("Digest must be 32 bytes."); + } + + int length; + int maxGenerations; + + switch(version) { + case VERSION1, VERSION2 -> { + length = 16; + maxGenerations = 150; + } + case DETAILED, FIDUCIAL, GRAYSCALE_FIDUCIAL -> { + length = 32; + maxGenerations = 300; + } + default -> throw new IllegalArgumentException("Invalid version."); + } + + Size size = new Size(length, length); + + // These get reused from generation to generation by swapping them. + CellGrid currentCellGrid = new CellGrid(size); + CellGrid nextCellGrid = new CellGrid(size); + ChangeGrid currentChangeGrid = new ChangeGrid(size); + ChangeGrid nextChangeGrid = new ChangeGrid(size); + + Set historySet = new HashSet<>(); + List history = new ArrayList<>(); + + switch(version) { + case VERSION1 -> nextCellGrid.setData(digest); + case VERSION2 -> + // Ensure that .version2 in no way resembles .version1 + nextCellGrid.setData(Sha256Hash.hash(digest)); + case DETAILED, FIDUCIAL, GRAYSCALE_FIDUCIAL -> { + byte[] digest1 = digest; + // Ensure that grayscale fiducials in no way resemble the regular color fiducials + if(version == LifeHashVersion.GRAYSCALE_FIDUCIAL) { + digest1 = Sha256Hash.hash(digest1); + } + byte[] digest2 = Sha256Hash.hash(digest1); + byte[] digest3 = Sha256Hash.hash(digest2); + byte[] digest4 = Sha256Hash.hash(digest3); + byte[] digestFinal = new byte[digest1.length*4]; + System.arraycopy(digest1, 0, digestFinal, 0, digest1.length); + System.arraycopy(digest2, 0, digestFinal, digest1.length, digest2.length); + System.arraycopy(digest3, 0, digestFinal, digest1.length * 2, digest3.length); + System.arraycopy(digest4, 0, digestFinal, digest1.length * 3, digest4.length); + nextCellGrid.setData(digestFinal); + } + } + + nextChangeGrid.setAll(true); + + while (history.size() < maxGenerations) { + CellGrid tempCellGrid = currentCellGrid; + currentCellGrid = nextCellGrid; + nextCellGrid = tempCellGrid; + + ChangeGrid tempChangeGrid = currentChangeGrid; + currentChangeGrid = nextChangeGrid; + nextChangeGrid = tempChangeGrid; + + byte[] data = currentCellGrid.getData(); + Sha256Hash hash = Sha256Hash.of(data); + if (historySet.contains(hash)) { + break; + } + historySet.add(hash); + history.add(data); + + currentCellGrid.nextGeneration(currentChangeGrid, nextCellGrid, nextChangeGrid); + } + + FracGrid fracGrid = new FracGrid(size); + for (int i = 0; i < history.size(); i++) { + currentCellGrid.setData(history.get(i)); + double frac = clamped(lerpFrom(0, history.size(), i + 1)); + fracGrid.overlay(currentCellGrid, frac); + } + + // Normalizing the frac_grid to the range 0..1 was a step left out of .version1 + // In some cases it can cause the full range of the gradient to go unused. + // This fixes the problem for the other versions, while remaining compatible + // with .version1. + if (version != LifeHashVersion.VERSION1) { + double minValue = Double.MAX_VALUE; + double maxValue = Double.MIN_VALUE; + + for (Point point : fracGrid.getPoints()) { + double value = fracGrid.getValue(point); + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + } + + for (Point point : fracGrid.getPoints()) { + double currentValue = fracGrid.getValue(point); + double value = lerpFrom(minValue, maxValue, currentValue); + fracGrid.setValue(value, point); + } + } + + + BitEnumerator entropy = new BitEnumerator(digest); + + switch(version) { + case DETAILED -> + // Throw away a bit of entropy to ensure we generate different colors and patterns from .version1 + entropy.next(); + case VERSION2 -> + // Throw away two bits of entropy to ensure we generate different colors and patterns from .version1 or .detailed. + entropy.nextUint2(); + default -> { + } + } + + ColorFunc gradient = selectGradient(entropy, version); + Pattern pattern = selectPattern(entropy, version); + ColorGrid color_grid = new ColorGrid(fracGrid, gradient, pattern); + + return makeImage(color_grid.size.width(), color_grid.size.height(), color_grid.colors(), moduleSize, hasAlpha); + } + + static Image makeImage(int width, int height, List floatColors, int moduleSize, boolean hasAlpha) { + if (moduleSize == 0) { + throw new IllegalArgumentException("Invalid module size."); + } + + int scaledWidth = width * moduleSize; + int scaledHeight = height * moduleSize; + int resultComponents = hasAlpha ? 4 : 3; + int scaledCapacity = scaledWidth * scaledHeight * resultComponents; + + List resultColors = new ArrayList<>(scaledCapacity); + for(int i = 0; i < scaledCapacity; i++) { + resultColors.add((byte)0); + } + + for (int targetY = 0; targetY < scaledWidth; targetY++) { + for (int targetX = 0; targetX < scaledHeight; targetX++) { + int sourceX = targetX / moduleSize; + int sourceY = targetY / moduleSize; + int sourceOffset = (sourceY * width + sourceX) * 3; + + int targetOffset = (targetY * scaledWidth + targetX) * resultComponents; + + resultColors.set(targetOffset, (byte)(clamped(floatColors.get(sourceOffset)) * 255)); + resultColors.set(targetOffset + 1, (byte)(clamped(floatColors.get(sourceOffset + 1)) * 255)); + resultColors.set(targetOffset + 2, (byte)(clamped(floatColors.get(sourceOffset + 2)) * 255)); + if (hasAlpha) { + resultColors.set(targetOffset + 3, (byte)255); + } + } + } + + return new Image(scaledWidth, scaledHeight, resultColors, hasAlpha); + } + + /** + * Creates a java.awt.image.BufferedImage from the LifeHash image + * + * @param image the LifeHash.Image to use + * @return a renderable image + */ + public static BufferedImage getBufferedImage(Image image) { + BufferedImage bufferedImage = new BufferedImage(image.width, image.height, image.hasAlpha ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + int offset = (y * image.width + x) * (image.hasAlpha ? 4 : 3); + int r = image.colors.get(offset) & 0xFF; + int g = image.colors.get(offset + 1) & 0xFF; + int b = image.colors.get(offset + 2) & 0xFF; + int color; + if(image.hasAlpha) { + int a = image.colors.get(offset + 3) & 0xFF; + color = (a << 24) | (r << 16) | (g << 8) | b; + } else { + color = (r << 16) | (g << 8) | b; + } + bufferedImage.setRGB(x, y, color); + } + } + + return bufferedImage; + } + + public record Image(int width, int height, List colors, boolean hasAlpha) { + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/LifeHashVersion.java b/src/main/java/com/sparrowwallet/toucan/LifeHashVersion.java new file mode 100644 index 0000000..92a1892 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/LifeHashVersion.java @@ -0,0 +1,9 @@ +package com.sparrowwallet.toucan; + +public enum LifeHashVersion { + VERSION1, // DEPRECATED. Uses HSB gamut. Not CMYK-friendly. Has some minor gradient bugs. + VERSION2, // CMYK-friendly gamut. Recommended for most purposes. + DETAILED, // Double resolution. CMYK-friendly gamut gamut. + FIDUCIAL, // Optimized for generating machine-vision fiducials. High-contrast. CMYK-friendly gamut. + GRAYSCALE_FIDUCIAL // Optimized for generating machine-vision fiducials. High-contrast. +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/BitAggregator.java b/src/main/java/com/sparrowwallet/toucan/impl/BitAggregator.java new file mode 100644 index 0000000..04cb2f3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/BitAggregator.java @@ -0,0 +1,35 @@ +package com.sparrowwallet.toucan.impl; + +import java.util.ArrayList; +import java.util.List; + +public class BitAggregator { + private final List data; + private int bitMask; + + public BitAggregator() { + data = new ArrayList<>(); + bitMask = 0; + } + + public void append(boolean bit) { + if (bitMask == 0) { + bitMask = 0x80; + data.add((byte) 0); + } + + if (bit) { + data.set(data.size() - 1, (byte) (data.get(data.size() - 1) | bitMask)); + } + + bitMask >>= 1; + } + + public byte[] getData() { + byte[] result = new byte[data.size()]; + for (int i = 0; i < data.size(); i++) { + result[i] = data.get(i); + } + return result; + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/BitEnumerator.java b/src/main/java/com/sparrowwallet/toucan/impl/BitEnumerator.java new file mode 100644 index 0000000..182a8d1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/BitEnumerator.java @@ -0,0 +1,63 @@ +package com.sparrowwallet.toucan.impl; + +public class BitEnumerator { + private final byte[] data; + private int index; + private int mask; + + public BitEnumerator(byte[] data) { + this.data = data; + this.index = 0; + this.mask = 0x80; + } + + public boolean hasNext() { + return mask != 0 || index != (data.length - 1); + } + + public boolean next() { + if (!hasNext()) { + throw new IllegalStateException("BitEnumerator underflow."); + } + + if (mask == 0) { + mask = 0x80; + index += 1; + } + + boolean b = (data[index] & mask) != 0; + mask >>= 1; + return b; + } + + public int nextConfigurable(int bitMask, int bits) { + int value = 0; + for (int i = 0; i < bits; i++) { + if (next()) { + value |= bitMask; + } + bitMask >>= 1; + } + return value; + } + + public int nextUint2() { + int bitMask = 0x02; + return nextConfigurable(bitMask, 2); + } + + public int nextUint8() { + int bitMask = 0x80; + return nextConfigurable(bitMask, 8); + } + + public int nextUint16() { + int bitMask = 0x8000; + return nextConfigurable(bitMask, 16); + } + + public double nextFrac() { + return (double) nextUint16() / 65535.0; + } +} + diff --git a/src/main/java/com/sparrowwallet/toucan/impl/CellGrid.java b/src/main/java/com/sparrowwallet/toucan/impl/CellGrid.java new file mode 100644 index 0000000..afc89ad --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/CellGrid.java @@ -0,0 +1,95 @@ +package com.sparrowwallet.toucan.impl; + +public class CellGrid extends Grid { + private final BitAggregator data; + + public CellGrid(Size size) { + super(size); + data = new BitAggregator(); + } + + public byte[] getData() { + BitAggregator aggregator = new BitAggregator(); + + for (Point point : getPoints()) { + aggregator.append(getValue(point)); + } + + return aggregator.getData(); + } + + public void setData(byte[] data) { + assert capacity == data.length * 8; + + BitEnumerator e = new BitEnumerator(data); + int i = 0; + + while (e.hasNext()) { + boolean value = e.next(); + storage.set(i, value); + i += 1; + } + } + + static boolean isAliveInNextGeneration(boolean currentAlive, int neighborsCount) { + if (currentAlive) { + return neighborsCount == 2 || neighborsCount == 3; + } else { + return neighborsCount == 3; + } + } + + private int countNeighbors(Point point) { + int total = 0; + + for (PointPair neighborhood : getNeighborhood(point)) { + Point pointO = neighborhood.o(); + Point pointP = neighborhood.p(); + if (pointO.equals(Point.ZERO)) { + continue; + } + + if (getValue(pointP)) { + total += 1; + } + } + + return total; + } + + public void nextGeneration(ChangeGrid currentChangeGrid, CellGrid nextCellGrid, ChangeGrid nextChangeGrid) { + nextCellGrid.setAll(false); + nextChangeGrid.setAll(false); + + for (Point p : getPoints()) { + boolean currentAlive = getValue(p); + if (currentChangeGrid.getValue(p)) { + int neighborsCount = countNeighbors(p); + boolean nextAlive = isAliveInNextGeneration(currentAlive, neighborsCount); + if (nextAlive) { + nextCellGrid.setValue(true, p); + } + if (currentAlive != nextAlive) { + nextChangeGrid.setChanged(p); + } + } else { + nextCellGrid.setValue(currentAlive, p); + } + } + } + + @Override + protected Color colorForValue(Boolean colorValue) { + if (colorValue) { + return Colors.WHITE; + } else { + return Colors.BLACK; + } + } + + @Override + protected Boolean getDefault() { + return Boolean.FALSE; + } +} + diff --git a/src/main/java/com/sparrowwallet/toucan/impl/ChangeGrid.java b/src/main/java/com/sparrowwallet/toucan/impl/ChangeGrid.java new file mode 100644 index 0000000..829f39f --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/ChangeGrid.java @@ -0,0 +1,23 @@ +package com.sparrowwallet.toucan.impl; + +public class ChangeGrid extends Grid { + public ChangeGrid(Size size) { + super(size); + } + + public void setChanged(Point point) { + for(PointPair neighborhood : getNeighborhood(point)) { + setValue(true, neighborhood.p()); + } + } + + @Override + protected Color colorForValue(Boolean value) { + return value ? Colors.RED : Colors.BLUE; + } + + @Override + protected Boolean getDefault() { + return Boolean.FALSE; + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Color.java b/src/main/java/com/sparrowwallet/toucan/impl/Color.java new file mode 100644 index 0000000..692f39e --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Color.java @@ -0,0 +1,48 @@ +package com.sparrowwallet.toucan.impl; + +import static com.sparrowwallet.toucan.impl.Utils.clamped; + +public class Color { + final double r; + final double g; + final double b; + + public Color(double r, double g, double b) { + this.r = r; + this.g = g; + this.b = b; + } + + static Color fromUint8Values(int r, int g, int b) { + return new Color((float) r / 255, (float) g / 255, (float) b / 255); + } + + Color lighten(double t) { + return this.lerpTo(Colors.WHITE, t); + } + + Color darken(double t) { + return this.lerpTo(Colors.BLACK, t); + } + + double luminance() { + return Math.sqrt(Math.pow(0.299 * this.r, 2) + Math.pow(0.587 * this.g, 2) + Math.pow(0.114 * this.b, 2)); + } + + Color burn(double t) { + double f = Math.max(1.0f - t, 1.0e-7f); + return new Color( + Math.min(1.0f - (1.0f - this.r) / f, 1.0f), + Math.min(1.0f - (1.0f - this.g) / f, 1.0f), + Math.min(1.0f - (1.0f - this.b) / f, 1.0f) + ); + } + + Color lerpTo(Color other, double t) { + double f = clamped(t); + double red = clamped(this.r * (1 - f) + other.r * f); + double green = clamped(this.g * (1 - f) + other.g * f); + double blue = clamped(this.b * (1 - f) + other.b * f); + return new Color(red, green, blue); + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/ColorFunc.java b/src/main/java/com/sparrowwallet/toucan/impl/ColorFunc.java new file mode 100644 index 0000000..2b6464e --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/ColorFunc.java @@ -0,0 +1,58 @@ +package com.sparrowwallet.toucan.impl; + +import java.util.List; + +import static com.sparrowwallet.toucan.impl.Utils.modulo; + +public abstract class ColorFunc { + public static ColorFunc reverse(ColorFunc c) { + return new ColorFunc() { + @Override + Color apply(double value) { + return c.apply(1 - value); + } + }; + } + + public static ColorFunc blend(Color color1, Color color2) { + return new ColorFunc() { + @Override + Color apply(double value) { + return color1.lerpTo(color2, value); + } + }; + } + + public static ColorFunc blend(List colors) { + int count = colors.size(); + switch(count) { + case 0: + return blend(Colors.BLACK, Colors.BLACK); + case 1: + return blend(colors.get(0), colors.get(0)); + case 2: + return blend(colors.get(0), colors.get(1)); + default: + return new ColorFunc() { + @Override + Color apply(double value) { + if (value >= 1) { + return colors.get(count - 1); + } else if (value <= 0) { + return colors.get(0); + } else { + int segments = count - 1; + double s = value * segments; + int segment = (int) s; + double segmentFrac = modulo(s, 1); + Color c1 = colors.get(segment); + Color c2 = colors.get(segment + 1); + return c1.lerpTo(c2, segmentFrac); + } + } + }; + } + } + + abstract Color apply(double value); +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/ColorGrid.java b/src/main/java/com/sparrowwallet/toucan/impl/ColorGrid.java new file mode 100644 index 0000000..1f077bc --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/ColorGrid.java @@ -0,0 +1,85 @@ +package com.sparrowwallet.toucan.impl; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ColorGrid extends Grid { + static List snowflakeTransforms = List.of( + new Transform(false, false, false), + new Transform(false, true, false), + new Transform(false, false, true), + new Transform(false, true, true) + ); + + static List pinwheelTransforms = List.of( + new Transform(false, false, false), + new Transform(true, true, false), + new Transform(true, false, true), + new Transform(false, true, true) + ); + + static List fiducialTransforms = List.of( + new Transform(false, false, false) + ); + + static Map> transformsMap = Map.of( + Pattern.SNOWFLAKE, snowflakeTransforms, + Pattern.PINWHEEL, pinwheelTransforms, + Pattern.FIDUCIAL, fiducialTransforms + ); + + public ColorGrid(FracGrid fracGrid, ColorFunc gradient, Pattern pattern) { + super(targetSize(fracGrid.getSize(), pattern)); + + List transforms = transformsMap.getOrDefault(pattern, new ArrayList<>()); + + for(Point point : fracGrid.getPoints()) { + double value = fracGrid.getValue(point); + Color someColor = gradient.apply(value); + draw(point, someColor, transforms); + } + } + + @Override + protected Color colorForValue(Color color) { + return color; + } + + @Override + protected Color getDefault() { + return Colors.BLACK; + } + + private static Size targetSize(Size inSize, Pattern pattern) { + int multiplier = (pattern == Pattern.FIDUCIAL) ? 1 : 2; + return new Size(inSize.width() * multiplier, inSize.height() * multiplier); + } + + private Point transformPoint(Point point, Transform transform) { + int x = point.x(); + int y = point.y(); + if (transform.transpose) { + int temp = x; + x = y; + y = temp; + } + if (transform.reflectX) { + x = maxX - x; + } + if (transform.reflectY) { + y = maxY - y; + } + return new Point(x, y); + } + + private void draw(Point p, Color color, List transforms) { + for (Transform t : transforms) { + Point p2 = transformPoint(p, t); + setValue(color, p2); + } + } + + static record Transform(boolean transpose, boolean reflectX, boolean reflectY) { + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Colors.java b/src/main/java/com/sparrowwallet/toucan/impl/Colors.java new file mode 100644 index 0000000..8675a75 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Colors.java @@ -0,0 +1,12 @@ +package com.sparrowwallet.toucan.impl; + +public class Colors { + public static final Color WHITE = new Color(1, 1, 1); + public static final Color BLACK = new Color(0, 0, 0); + public static final Color RED = new Color(1, 0, 0); + public static final Color GREEN = new Color(0, 1, 0); + public static final Color BLUE = new Color(0, 0, 1); + public static final Color CYAN = new Color(0, 1, 1); + public static final Color MAGENTA = new Color(1, 0, 1); + public static final Color YELLOW = new Color(1, 1, 0); +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/FracGrid.java b/src/main/java/com/sparrowwallet/toucan/impl/FracGrid.java new file mode 100644 index 0000000..17251cc --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/FracGrid.java @@ -0,0 +1,25 @@ +package com.sparrowwallet.toucan.impl; + +public class FracGrid extends Grid { + public FracGrid(Size size) { + super(size); + } + + public void overlay(CellGrid cellGrid, double frac) { + for(Point point : getPoints()) { + if(cellGrid.getValue(point)) { + setValue(frac, point); + } + } + } + + @Override + protected Color colorForValue(Double value) { + return Colors.BLACK.lerpTo(Colors.WHITE, value); + } + + @Override + protected Double getDefault() { + return 0d; + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Gradients.java b/src/main/java/com/sparrowwallet/toucan/impl/Gradients.java new file mode 100644 index 0000000..08dea85 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Gradients.java @@ -0,0 +1,315 @@ +package com.sparrowwallet.toucan.impl; + +import com.sparrowwallet.toucan.LifeHashVersion; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static com.sparrowwallet.toucan.impl.Utils.lerp; +import static com.sparrowwallet.toucan.impl.Utils.modulo; + +public class Gradients { + static ColorFunc grayscale = ColorFunc.blend(Colors.BLACK, Colors.WHITE); + + static ColorFunc selectGrayscale(BitEnumerator entropy) { + return entropy.next() ? grayscale : ColorFunc.reverse(grayscale); + } + + static ColorFunc makeHue = new ColorFunc() { + @Override + Color apply(double value) { + return new HSBColor(value).color(); + } + }; + + static ColorFunc spectrum = ColorFunc.blend(List.of( + Color.fromUint8Values(0, 168, 222), + Color.fromUint8Values(51, 51, 145), + Color.fromUint8Values(233, 19, 136), + Color.fromUint8Values(235, 45, 46), + Color.fromUint8Values(253, 233, 43), + Color.fromUint8Values(0, 158, 84), + Color.fromUint8Values(0, 168, 222) + )); + + static ColorFunc spectrumCmykSafe = ColorFunc.blend(List.of( + Color.fromUint8Values(0, 168, 222), + Color.fromUint8Values(41, 60, 130), + Color.fromUint8Values(210, 59, 130), + Color.fromUint8Values(217, 63, 53), + Color.fromUint8Values(244, 228, 81), + Color.fromUint8Values(0, 158, 84), + Color.fromUint8Values(0, 168, 222) + )); + + static Color adjustForLuminance(Color color, Color contrastColor) { + double lum = color.luminance(); + double contrastLum = contrastColor.luminance(); + double threshold = 0.6; + double offset = Math.abs(lum - contrastLum); + if (offset > threshold) { + return color; + } + double boost = 0.7; + double t = lerp(0, threshold, boost, 0, offset); + if (contrastLum > lum) { + // darken this color + return color.darken(t).burn(t * 0.6); + } else { + // lighten this color + return color.lighten(t).burn(t * 0.6); + } + } + + static ColorFunc monochromatic(BitEnumerator entropy, ColorFunc hueGenerator) { + double hue = entropy.nextFrac(); + boolean isTint = entropy.next(); + boolean isReversed = entropy.next(); + double keyAdvance = entropy.nextFrac() * 0.3 + 0.05; + double neutralAdvance = entropy.nextFrac() * 0.3 + 0.05; + + Color keyColor = hueGenerator.apply(hue); + + double contrastBrightness; + if (isTint) { + contrastBrightness = 1; + keyColor = keyColor.darken(0.5); + } else { + contrastBrightness = 0; + } + Color neutralColor = grayscale.apply(contrastBrightness); + + Color keyColor2 = keyColor.lerpTo(neutralColor, keyAdvance); + Color neutralColor2 = neutralColor.lerpTo(keyColor, neutralAdvance); + + ColorFunc gradient = ColorFunc.blend(keyColor2, neutralColor2); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc monochromaticFiducial(BitEnumerator entropy) { + double hue = entropy.nextFrac(); + boolean isReversed = entropy.next(); + boolean isTint = entropy.next(); + + Color contrastColor = isTint ? Colors.WHITE : Colors.BLACK; + Color keyColor = adjustForLuminance(spectrumCmykSafe.apply(hue), contrastColor); + + ColorFunc gradient = ColorFunc.blend(List.of(keyColor, contrastColor, keyColor)); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc complementary(BitEnumerator entropy, ColorFunc hueGenerator) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = modulo(spectrum1 + 0.5, 1); + double lighterAdvance = entropy.nextFrac() * 0.3; + double darkerAdvance = entropy.nextFrac() * 0.3; + boolean isReversed = entropy.next(); + + Color color1 = hueGenerator.apply(spectrum1); + Color color2 = hueGenerator.apply(spectrum2); + + double luma1 = color1.luminance(); + double luma2 = color2.luminance(); + + Color darkerColor; + Color lighterColor; + if (luma1 > luma2) { + darkerColor = color2; + lighterColor = color1; + } else { + darkerColor = color1; + lighterColor = color2; + } + + Color adjustedLighterColor = lighterColor.lighten(lighterAdvance); + Color adjustedDarkerColor = darkerColor.darken(darkerAdvance); + + ColorFunc gradient = ColorFunc.blend(adjustedDarkerColor, adjustedLighterColor); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc complementaryFiducial(BitEnumerator entropy) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = modulo((spectrum1 + 0.5), 1); + boolean is_tint = entropy.next(); + boolean is_reversed = entropy.next(); + boolean neutral_color_bias = entropy.next(); + + Color neutral_color = is_tint ? Colors.WHITE : Colors.BLACK; + Color color1 = spectrumCmykSafe.apply(spectrum1); + Color color2 = spectrumCmykSafe.apply(spectrum2); + + Color biased_neutral_color = neutral_color.lerpTo(neutral_color_bias ? color1 : color2, 0.2).burn(0.1); + ColorFunc gradient = ColorFunc.blend(List.of( + adjustForLuminance(color1, biased_neutral_color), + biased_neutral_color, + adjustForLuminance(color2, biased_neutral_color) + )); + return is_reversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc triadic(BitEnumerator entropy, ColorFunc hueGenerator) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = modulo(spectrum1 + 1.0 / 3, 1); + double spectrum3 = modulo((spectrum1 + 2.0 / 3), 1); + double lighter_advance = entropy.nextFrac() * 0.3; + double darker_advance = entropy.nextFrac() * 0.3; + boolean is_reversed = entropy.next(); + + Color color1 = hueGenerator.apply(spectrum1); + Color color2 = hueGenerator.apply(spectrum2); + Color color3 = hueGenerator.apply(spectrum3); + List colors = new ArrayList<>(); + colors.add(color1); + colors.add(color2); + colors.add(color3); + colors.sort(Comparator.comparingDouble(Color::luminance)); + + Color darker_color = colors.get(0); + Color middle_color = colors.get(1); + Color lighter_color = colors.get(2); + + Color adjusted_lighter_color = lighter_color.lighten(lighter_advance); + Color adjusted_darker_color = darker_color.darken(darker_advance); + + ColorFunc gradient = ColorFunc.blend(List.of(adjusted_lighter_color, middle_color, adjusted_darker_color)); + return is_reversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc triadicFiducial(BitEnumerator entropy) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = (spectrum1 + 1.0 / 3) % 1; + double spectrum3 = (spectrum1 + 2.0 / 3) % 1; + boolean isTint = entropy.next(); + int neutralInsertIndex = entropy.nextUint8() % 2 + 1; + boolean isReversed = entropy.next(); + + Color neutralColor = isTint ? Colors.WHITE : Colors.BLACK; + + List colors = new ArrayList<>(List.of(spectrumCmykSafe.apply(spectrum1), spectrumCmykSafe.apply(spectrum2), spectrumCmykSafe.apply(spectrum3))); + switch(neutralInsertIndex) { + case 1 -> { + colors.set(0, adjustForLuminance(colors.get(0), neutralColor)); + colors.set(1, adjustForLuminance(colors.get(1), neutralColor)); + colors.set(2, adjustForLuminance(colors.get(2), colors.get(1))); + } + case 2 -> { + colors.set(1, adjustForLuminance(colors.get(1), neutralColor)); + colors.set(2, adjustForLuminance(colors.get(2), neutralColor)); + colors.set(0, adjustForLuminance(colors.get(0), colors.get(1))); + } + default -> throw new IllegalArgumentException("Internal error."); + } + + colors.add(neutralInsertIndex, neutralColor); + + ColorFunc gradient = ColorFunc.blend(colors); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc analogous(BitEnumerator entropy, ColorFunc hueGenerator) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = modulo(spectrum1 + 1.0 / 12, 1); + double spectrum3 = modulo(spectrum1 + 2.0 / 12, 1); + double spectrum4 = modulo(spectrum1 + 3.0 / 12, 1); + double advance = entropy.nextFrac() * 0.5 + 0.2; + boolean isReversed = entropy.next(); + + Color color1 = hueGenerator.apply(spectrum1); + Color color2 = hueGenerator.apply(spectrum2); + Color color3 = hueGenerator.apply(spectrum3); + Color color4 = hueGenerator.apply(spectrum4); + + Color darkestColor; + Color darkColor; + Color lightColor; + Color lightestColor; + + if (color1.luminance() < color4.luminance()) { + darkestColor = color1; + darkColor = color2; + lightColor = color3; + lightestColor = color4; + } else { + darkestColor = color4; + darkColor = color3; + lightColor = color2; + lightestColor = color1; + } + + Color adjustedDarkestColor = darkestColor.darken(advance); + Color adjustedDarkColor = darkColor.darken(advance / 2); + Color adjustedLightColor = lightColor.lighten(advance / 2); + Color adjustedLightestColor = lightestColor.lighten(advance); + + ColorFunc gradient = ColorFunc.blend(List.of(adjustedDarkestColor, adjustedDarkColor, adjustedLightColor, adjustedLightestColor)); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + static ColorFunc analogousFiducial(BitEnumerator entropy) { + double spectrum1 = entropy.nextFrac(); + double spectrum2 = modulo(spectrum1 + 1.0 / 10, 1); + double spectrum3 = modulo(spectrum1 + 2.0 / 10, 1); + boolean isTint = entropy.next(); + int neutralInsertIndex = entropy.nextUint8() % 2 + 1; + boolean isReversed = entropy.next(); + + Color neutralColor = isTint ? Colors.WHITE : Colors.BLACK; + + List colors = new ArrayList<>(List.of(spectrumCmykSafe.apply(spectrum1), spectrumCmykSafe.apply(spectrum2), spectrumCmykSafe.apply(spectrum3))); + switch(neutralInsertIndex) { + case 1 -> { + colors.set(0, adjustForLuminance(colors.get(0), neutralColor)); + colors.set(1, adjustForLuminance(colors.get(1), neutralColor)); + colors.set(2, adjustForLuminance(colors.get(2), colors.get(1))); + } + case 2 -> { + colors.set(1, adjustForLuminance(colors.get(1), neutralColor)); + colors.set(2, adjustForLuminance(colors.get(2), neutralColor)); + colors.set(0, adjustForLuminance(colors.get(0), colors.get(1))); + } + default -> throw new IllegalStateException("Internal error"); + } + colors.add(neutralInsertIndex, neutralColor); + + ColorFunc gradient = ColorFunc.blend(colors); + return isReversed ? ColorFunc.reverse(gradient) : gradient; + } + + public static ColorFunc selectGradient(BitEnumerator entropy, LifeHashVersion version) { + if(version == LifeHashVersion.GRAYSCALE_FIDUCIAL) { + return selectGrayscale(entropy); + } + + int value = entropy.nextUint2(); + + return switch(value) { + case 0 -> switch(version) { + case VERSION1 -> monochromatic(entropy, makeHue); + case VERSION2, DETAILED -> monochromatic(entropy, spectrumCmykSafe); + case FIDUCIAL -> monochromaticFiducial(entropy); + case GRAYSCALE_FIDUCIAL -> grayscale; + }; + case 1 -> switch(version) { + case VERSION1 -> complementary(entropy, spectrum); + case VERSION2, DETAILED -> complementary(entropy, spectrumCmykSafe); + case FIDUCIAL -> complementaryFiducial(entropy); + case GRAYSCALE_FIDUCIAL -> grayscale; + }; + case 2 -> switch(version) { + case VERSION1 -> triadic(entropy, spectrum); + case VERSION2, DETAILED -> triadic(entropy, spectrumCmykSafe); + case FIDUCIAL -> triadicFiducial(entropy); + case GRAYSCALE_FIDUCIAL -> grayscale; + }; + case 3 -> switch(version) { + case VERSION1 -> analogous(entropy, spectrum); + case VERSION2, DETAILED -> analogous(entropy, spectrumCmykSafe); + case FIDUCIAL -> analogousFiducial(entropy); + case GRAYSCALE_FIDUCIAL -> grayscale; + }; + default -> grayscale; + }; + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Grid.java b/src/main/java/com/sparrowwallet/toucan/impl/Grid.java new file mode 100644 index 0000000..7629774 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Grid.java @@ -0,0 +1,105 @@ +package com.sparrowwallet.toucan.impl; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +public abstract class Grid { + // public + public final Size size; + + // private + protected final int capacity; + protected final int maxX; + protected final int maxY; + + protected final List storage; + + private Supplier supplier; + + public Grid(Size size) { + this.size = size; + this.capacity = size.width() * size.height(); + this.storage = new ArrayList<>(this.capacity); + for(int i = 0; i < this.capacity; i++) { + this.storage.add(getDefault()); + } + this.maxX = size.width() - 1; + this.maxY = size.height() - 1; + } + + protected abstract Color colorForValue(T colorValue); + + protected abstract T getDefault(); + + private int offset(Point point) { + return point.y() * this.size.width() + point.x(); + } + + static int circularIndex(int index, int modulus) { + return (index + modulus) % modulus; + } + + public void setAll(T value) { + for(int i = 0; i < this.capacity; i++) { + this.storage.set(i, value); + } + } + + public void setValue(T value, Point point) { + int offset = offset(point); + this.storage.set(offset, value); + } + + public T getValue(Point point) { + return this.storage.get(offset(point)); + } + + public List getPoints() { + List points = new ArrayList<>(); + for(int y = 0; y <= this.maxY; y++) { + for(int x = 0; x <= this.maxX; x++) { + points.add(new Point(x, y)); + } + } + return points; + } + + public List getNeighborhood(Point point) { + List pointPairs = new ArrayList<>(); + for(int oy = -1; oy <= 1; oy++) { + for(int ox = -1; ox <= 1; ox++) { + Point o = new Point(ox, oy); + int px = circularIndex(ox + point.x(), this.size.width()); + int py = circularIndex(oy + point.y(), this.size.height()); + Point p = new Point(px, py); + pointPairs.add(new PointPair(o, p)); + } + } + return pointPairs; + } + + public List colors() { + List result = new ArrayList<>(); + + for(int idx = 0; idx < this.storage.size(); idx++) { + T colorValue = this.storage.get(idx); + Color color = this.colorForValue(colorValue); + + result.add(color.r); + result.add(color.g); + result.add(color.b); + } + + return result; + } + + public Size getSize() { + return size; + } + + protected void setChanged(Point p) { + + } +} + diff --git a/src/main/java/com/sparrowwallet/toucan/impl/HSBColor.java b/src/main/java/com/sparrowwallet/toucan/impl/HSBColor.java new file mode 100644 index 0000000..3b648d9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/HSBColor.java @@ -0,0 +1,109 @@ +package com.sparrowwallet.toucan.impl; + +import static com.sparrowwallet.toucan.impl.Utils.clamped; +import static com.sparrowwallet.toucan.impl.Utils.modulo; + +public class HSBColor { + public double hue; + public double saturation; + public double brightness; + + public HSBColor(double hue) { + this.hue = hue; + this.saturation = 1; + this.brightness = 1; + } + + public HSBColor(Color color) { + double r = color.r; + double g = color.g; + double b = color.b; + + double maxValue = Math.max(r, Math.max(g, b)); + double minValue = Math.min(r, Math.min(g, b)); + + double brightness = maxValue; + + double d = maxValue - minValue; + double saturation = maxValue == 0 ? 0 : d / maxValue; + + double hue; + if (maxValue == minValue) { + hue = 0; // achromatic + } else { + if (maxValue == r) { + hue = ((g - b) / d + (g < b ? 6 : 0)) / 6; + } else if (maxValue == g) { + hue = ((b - r) / d + 2) / 6; + } else if (maxValue == b) { + hue = ((r - g) / d + 4) / 6; + } else { + throw new IllegalArgumentException("Internal error."); + } + } + this.hue = hue; + this.saturation = saturation; + this.brightness = brightness; + } + + public Color color() { + double v = clamped(brightness); + double s = clamped(saturation); + double red; + double green; + double blue; + + if(s <= 0) { + red = v; + green = v; + blue = v; + } else { + double h = modulo(hue, 1); + if(h < 0) { + h += 1; + } + h *= 6; + int i = (int) Math.floor(h); + double f = h - i; + double p = v * (1 - s); + double q = v * (1 - s * f); + double t = v * (1 - s * (1 - f)); + switch(i) { + case 0: + red = v; + green = t; + blue = p; + break; + case 1: + red = q; + green = v; + blue = p; + break; + case 2: + red = p; + green = v; + blue = t; + break; + case 3: + red = p; + green = q; + blue = v; + break; + case 4: + red = t; + green = p; + blue = v; + break; + case 5: + red = v; + green = p; + blue = q; + break; + default: + throw new IllegalArgumentException(); + } + } + + return new Color(red, green, blue); + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Pattern.java b/src/main/java/com/sparrowwallet/toucan/impl/Pattern.java new file mode 100644 index 0000000..e7db2f9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Pattern.java @@ -0,0 +1,21 @@ +package com.sparrowwallet.toucan.impl; + +import com.sparrowwallet.toucan.LifeHashVersion; + +public enum Pattern { + SNOWFLAKE, // Mirror around central axes. + PINWHEEL, // Rotate around center. + FIDUCIAL; // Identity. + + public static Pattern selectPattern(BitEnumerator entropy, LifeHashVersion version) { + if (version == LifeHashVersion.FIDUCIAL || version == LifeHashVersion.GRAYSCALE_FIDUCIAL) { + return FIDUCIAL; + } else { + if (entropy.next()) { + return SNOWFLAKE; + } else { + return PINWHEEL; + } + } + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Point.java b/src/main/java/com/sparrowwallet/toucan/impl/Point.java new file mode 100644 index 0000000..ccfdddd --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Point.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.toucan.impl; + +public record Point(int x, int y) { + public static Point ZERO = new Point(0, 0); +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/PointPair.java b/src/main/java/com/sparrowwallet/toucan/impl/PointPair.java new file mode 100644 index 0000000..338a2de --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/PointPair.java @@ -0,0 +1,4 @@ +package com.sparrowwallet.toucan.impl; + +public record PointPair(Point o, Point p) { +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Sha256Hash.java b/src/main/java/com/sparrowwallet/toucan/impl/Sha256Hash.java new file mode 100644 index 0000000..e0330a4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Sha256Hash.java @@ -0,0 +1,259 @@ +/* + * Copyright 2011 Google Inc. + * Copyright 2014 Andreas Schildbach + * + * 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 com.sparrowwallet.toucan.impl; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class Sha256Hash implements Comparable { + public static final int LENGTH = 32; // bytes + public static final Sha256Hash ZERO_HASH = wrap(new byte[LENGTH]); + + private final byte[] bytes; + + private Sha256Hash(byte[] rawHashBytes) { + if(rawHashBytes.length != LENGTH) { + throw new IllegalArgumentException(); + } + + this.bytes = rawHashBytes; + } + + /** + * Creates a new instance that wraps the given hash value. + * + * @param rawHashBytes the raw hash bytes to wrap + * @return a new instance + * @throws IllegalArgumentException if the given array length is not exactly 32 + */ + public static Sha256Hash wrap(byte[] rawHashBytes) { + return new Sha256Hash(rawHashBytes); + } + + /** + * Creates a new instance that wraps the given hash value (represented as a hex string). + * + * @param hexString a hash value represented as a hex string + * @return a new instance + * @throws IllegalArgumentException if the given string is not a valid + * hex string, or if it does not represent exactly 32 bytes + */ + public static Sha256Hash wrap(String hexString) { + return wrap(Utils.hexToBytes(hexString)); + } + + /** + * Creates a new instance that wraps the given hash value, but with byte order reversed. + * + * @param rawHashBytes the raw hash bytes to wrap + * @return a new instance + * @throws IllegalArgumentException if the given array length is not exactly 32 + */ + public static Sha256Hash wrapReversed(byte[] rawHashBytes) { + return wrap(Utils.reverseBytes(rawHashBytes)); + } + + /** + * Creates a new instance containing the calculated (one-time) hash of the given bytes. + * + * @param contents the bytes on which the hash value is calculated + * @return a new instance containing the calculated (one-time) hash + */ + public static Sha256Hash of(byte[] contents) { + return wrap(hash(contents)); + } + + /** + * Creates a new instance containing the hash of the calculated hash of the given bytes. + * + * @param contents the bytes on which the hash value is calculated + * @return a new instance containing the calculated (two-time) hash + */ + public static Sha256Hash twiceOf(byte[] contents) { + return wrap(hashTwice(contents)); + } + + /** + * Creates a new instance containing the hash of the calculated hash of the given bytes. + * + * @param content1 first bytes on which the hash value is calculated + * @param content2 second bytes on which the hash value is calculated + * @return a new instance containing the calculated (two-time) hash + */ + public static Sha256Hash twiceOf(byte[] content1, byte[] content2) { + return wrap(hashTwice(content1, content2)); + } + + /** + * Returns a new SHA-256 MessageDigest instance. + * + * This is a convenience method which wraps the checked + * exception that can never occur with a RuntimeException. + * + * @return a new SHA-256 MessageDigest instance + */ + public static MessageDigest newDigest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); // Can't happen. + } + } + + /** + * Calculates the SHA-256 hash of the given bytes. + * + * @param input the bytes to hash + * @return the hash (in big-endian order) + */ + public static byte[] hash(byte[] input) { + return hash(input, 0, input.length); + } + + /** + * Calculates the SHA-256 hash of the given byte range. + * + * @param input the array containing the bytes to hash + * @param offset the offset within the array of the bytes to hash + * @param length the number of bytes to hash + * @return the hash (in big-endian order) + */ + public static byte[] hash(byte[] input, int offset, int length) { + MessageDigest digest = newDigest(); + digest.update(input, offset, length); + return digest.digest(); + } + + /** + * Calculates the SHA-256 hash of the given bytes, + * and then hashes the resulting hash again. + * + * @param input the bytes to hash + * @return the double-hash (in big-endian order) + */ + public static byte[] hashTwice(byte[] input) { + return hashTwice(input, 0, input.length); + } + + /** + * Calculates the hash of hash on the given chunks of bytes. This is equivalent to concatenating the two + * chunks and then passing the result to {@link #hashTwice(byte[])}. + */ + public static byte[] hashTwice(byte[] input1, byte[] input2) { + MessageDigest digest = newDigest(); + digest.update(input1); + digest.update(input2); + return digest.digest(digest.digest()); + } + + /** + * Calculates the SHA-256 hash of the given byte range, + * and then hashes the resulting hash again. + * + * @param input the array containing the bytes to hash + * @param offset the offset within the array of the bytes to hash + * @param length the number of bytes to hash + * @return the double-hash (in big-endian order) + */ + public static byte[] hashTwice(byte[] input, int offset, int length) { + MessageDigest digest = newDigest(); + digest.update(input, offset, length); + return digest.digest(digest.digest()); + } + + /** + * Calculates the hash of hash on the given byte ranges. This is equivalent to + * concatenating the two ranges and then passing the result to {@link #hashTwice(byte[])}. + */ + public static byte[] hashTwice(byte[] input1, int offset1, int length1, + byte[] input2, int offset2, int length2) { + MessageDigest digest = newDigest(); + digest.update(input1, offset1, length1); + digest.update(input2, offset2, length2); + return digest.digest(digest.digest()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + return Arrays.equals(bytes, ((Sha256Hash)o).bytes); + } + + /** + * Returns the last four bytes of the wrapped hash. This should be unique enough to be a suitable hash code even for + * blocks, where the goal is to try and get the first bytes to be zeros (i.e. the value as a big integer lower + * than the target value). + */ + @Override + public int hashCode() { + // Use the last 4 bytes, not the first 4 which are often zeros in Bitcoin. + return fromBytes(bytes[LENGTH - 4], bytes[LENGTH - 3], bytes[LENGTH - 2], bytes[LENGTH - 1]); + } + + /** + * Returns the {@code int} value whose byte representation is the given 4 bytes, in big-endian + * order; equivalent to {@code Ints.fromByteArray(new byte[] {b1, b2, b3, b4})}. + * + * @since 7.0 + */ + public static int fromBytes(byte b1, byte b2, byte b3, byte b4) { + return b1 << 24 | (b2 & 0xFF) << 16 | (b3 & 0xFF) << 8 | (b4 & 0xFF); + } + + @Override + public String toString() { + return Utils.bytesToHex(bytes); + } + + /** + * Returns the bytes interpreted as a positive integer. + */ + public BigInteger toBigInteger() { + return new BigInteger(1, bytes); + } + + /** + * Returns the internal byte array, without defensively copying. Therefore do NOT modify the returned array. + */ + public byte[] getBytes() { + return bytes; + } + + /** + * Returns a reversed copy of the internal byte array. + */ + public byte[] getReversedBytes() { + return Utils.reverseBytes(bytes); + } + + @Override + public int compareTo(final Sha256Hash other) { + for (int i = LENGTH - 1; i >= 0; i--) { + final int thisByte = this.bytes[i] & 0xff; + final int otherByte = other.bytes[i] & 0xff; + if (thisByte > otherByte) + return 1; + if (thisByte < otherByte) + return -1; + } + return 0; + } +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Size.java b/src/main/java/com/sparrowwallet/toucan/impl/Size.java new file mode 100644 index 0000000..b6445d9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Size.java @@ -0,0 +1,4 @@ +package com.sparrowwallet.toucan.impl; + +public record Size(int width, int height) { +} diff --git a/src/main/java/com/sparrowwallet/toucan/impl/Utils.java b/src/main/java/com/sparrowwallet/toucan/impl/Utils.java new file mode 100644 index 0000000..5d61f9f --- /dev/null +++ b/src/main/java/com/sparrowwallet/toucan/impl/Utils.java @@ -0,0 +1,85 @@ +package com.sparrowwallet.toucan.impl; + +public class Utils { + public static double lerpTo(double toA, double toB, double t) { + return t * (toB - toA) + toA; + } + + public static double lerpFrom(double fromA, double fromB, double t) { + return (fromA - t) / (fromA - fromB); + } + + public static double lerp(double fromA, double fromB, double toC, double toD, double t) { + return lerpTo(toC, toD, lerpFrom(fromA, fromB, t)); + } + + // Return the minimum of `a`, `b`, and `c`. + public static double min(double a, double b, double c) { + return Math.min(Math.min(a, b), c); + } + + public static double max(double a, double b, double c) { + return Math.max(Math.max(a, b), c); + } + + public static double clamped(double n) { + return Math.max(Math.min(n, 1), 0); + } + + public static double modulo(double dividend, double divisor) { + return dividend % divisor; //Math.IEEEremainder(Math.IEEEremainder(dividend, divisor) + divisor, divisor); + } + + private final static char[] hexArray = "0123456789abcdef".toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for ( int j = 0; j < bytes.length; j++ ) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } + + public static byte[] hexToBytes(final String data) { + return decodeHex(data.toCharArray()); + } + + public static byte[] decodeHex(final char[] data) { + + final int len = data.length; + + if ((len & 0x01) != 0) { + throw new IllegalArgumentException("Odd number of characters."); + } + + final byte[] out = new byte[len >> 1]; + + // two characters form the hex value. + for (int i = 0, j = 0; j < len; i++) { + int f = toDigit(data[j], j) << 4; + j++; + f = f | toDigit(data[j], j); + j++; + out[i] = (byte) (f & 0xFF); + } + + return out; + } + + protected static int toDigit(final char ch, final int index) { + final int digit = Character.digit(ch, 16); + if (digit == -1) { + throw new IllegalArgumentException("Illegal hexadecimal character " + ch + " at index " + index); + } + return digit; + } + + public static byte[] reverseBytes(byte[] bytes) { + byte[] buf = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) + buf[i] = bytes[bytes.length - 1 - i]; + return buf; + } +} \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..0cb7c54 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,4 @@ +module com.sparrowwallet.toucan { + requires java.desktop; + exports com.sparrowwallet.toucan; +} \ No newline at end of file diff --git a/src/test/java/com/sparrowwallet/toucan/LifeHashTest.java b/src/test/java/com/sparrowwallet/toucan/LifeHashTest.java new file mode 100644 index 0000000..280c297 --- /dev/null +++ b/src/test/java/com/sparrowwallet/toucan/LifeHashTest.java @@ -0,0 +1,28 @@ +package com.sparrowwallet.toucan; + +import org.junit.Assert; +import org.junit.Test; + +public class LifeHashTest { + @Test + public void testHello() { + LifeHash.Image image = LifeHash.makeFromUTF8("Hello", LifeHashVersion.VERSION2, 1, false); + Assert.assertEquals(32, image.width()); + Assert.assertEquals(32, image.height()); + byte[] expected = new byte[] { -110, 126, -126, -78, 104, 92, -74, 101, 87, -54, 88, 64, -57, 89, 66, -59, 90, 69, -74, 101, 87, -76, 102, 89, -97, 117, 114, -46, 82, 54 }; + for(int i = 0; i < expected.length; i++) { + Assert.assertEquals(expected[i], image.colors().get(i).byteValue()); + } + } + + @Test + public void testHelloAlpha() { + LifeHash.Image image = LifeHash.makeFromUTF8("Hello", LifeHashVersion.VERSION2, 1, true); + Assert.assertEquals(32, image.width()); + Assert.assertEquals(32, image.height()); + byte[] expected = new byte[] { -110, 126, -126, -1, -78, 104, 92, -1, -74, 101, 87, -1, -54, 88, 64, -1, -57, 89, 66, -1, -59, 90, 69, -1, -74, 101, 87, -1, -76, 102, 89, -1, -97, 117, 114, -1, -46, 82, 54, -1 }; + for(int i = 0; i < expected.length; i++) { + Assert.assertEquals(expected[i], image.colors().get(i).byteValue()); + } + } +}