diff options
11 files changed, 682 insertions, 6 deletions
diff --git a/core/java/android/content/res/CompatibilityInfo.java b/core/java/android/content/res/CompatibilityInfo.java index 6ce22422643e..ce6e1c7c676f 100644 --- a/core/java/android/content/res/CompatibilityInfo.java +++ b/core/java/android/content/res/CompatibilityInfo.java @@ -563,6 +563,9 @@ public class CompatibilityInfo implements Parcelable { if (applyToSize) { inoutDm.widthPixels = (int) (inoutDm.widthPixels * invertedRatio + 0.5f); inoutDm.heightPixels = (int) (inoutDm.heightPixels * invertedRatio + 0.5f); + + float fontScale = inoutDm.scaledDensity / inoutDm.density; + inoutDm.fontScaleConverter = FontScaleConverterFactory.forScale(fontScale); } } diff --git a/core/java/android/content/res/FontScaleConverter.java b/core/java/android/content/res/FontScaleConverter.java new file mode 100644 index 000000000000..c7fdb16682e3 --- /dev/null +++ b/core/java/android/content/res/FontScaleConverter.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 android.content.res; + +import android.annotation.NonNull; +import android.util.MathUtils; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.Arrays; + +/** + * A lookup table for non-linear font scaling. Converts font sizes given in "sp" dimensions to a + * "dp" dimension according to a non-linear curve. + * + * <p>This is meant to improve readability at larger font scales: larger fonts will scale up more + * slowly than smaller fonts, so we don't get ridiculously huge fonts that don't fit on the screen. + * + * <p>The thinking here is that large fonts are already big enough to read, but we still want to + * scale them slightly to preserve the visual hierarchy when compared to smaller fonts. + * + * @hide + */ +public class FontScaleConverter { + /** + * How close the given SP should be to a canonical SP in the array before they are considered + * the same for lookup purposes. + */ + private static final float THRESHOLD_FOR_MATCHING_SP = 0.02f; + + @VisibleForTesting + final float[] mFromSpValues; + @VisibleForTesting + final float[] mToDpValues; + + /** + * Creates a lookup table for the given conversions. + * + * <p>Any "sp" value not in the lookup table will be derived via linear interpolation. + * + * <p>The arrays must be sorted ascending and monotonically increasing. + * + * @param fromSp array of dimensions in SP + * @param toDp array of dimensions in DP that correspond to an SP value in fromSp + * + * @throws IllegalArgumentException if the array lengths don't match or are empty + * @hide + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public FontScaleConverter(@NonNull float[] fromSp, @NonNull float[] toDp) { + if (fromSp.length != toDp.length || fromSp.length == 0) { + throw new IllegalArgumentException("Array lengths must match and be nonzero"); + } + + mFromSpValues = fromSp; + mToDpValues = toDp; + } + + /** + * Convert a dimension in "sp" to "dp" using the lookup table. + * + * @hide + */ + public float convertSpToDp(float sp) { + final float spPositive = Math.abs(sp); + // TODO(b/247861374): find a match at a higher index? + final int spRounded = Math.round(spPositive); + final float sign = Math.signum(sp); + final int index = Arrays.binarySearch(mFromSpValues, spRounded); + if (index >= 0 && Math.abs(spRounded - spPositive) < THRESHOLD_FOR_MATCHING_SP) { + // exact match, return the matching dp + return sign * mToDpValues[index]; + } else { + // must be a value in between index and index + 1: interpolate. + final int lowerIndex = -(index + 1) - 1; + + final float startSp; + final float endSp; + final float startDp; + final float endDp; + + if (lowerIndex >= mFromSpValues.length - 1) { + // It's past our lookup table. Determine the last elements' scaling factor and use. + startSp = mFromSpValues[mFromSpValues.length - 1]; + startDp = mToDpValues[mFromSpValues.length - 1]; + + if (startSp == 0) return 0; + + final float scalingFactor = startDp / startSp; + return sp * scalingFactor; + } else if (lowerIndex == -1) { + // It's smaller than the smallest value in our table. Interpolate from 0. + startSp = 0; + startDp = 0; + endSp = mFromSpValues[0]; + endDp = mToDpValues[0]; + } else { + startSp = mFromSpValues[lowerIndex]; + endSp = mFromSpValues[lowerIndex + 1]; + startDp = mToDpValues[lowerIndex]; + endDp = mToDpValues[lowerIndex + 1]; + } + + return sign * MathUtils.constrainedMap(startDp, endDp, startSp, endSp, spPositive); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof FontScaleConverter)) return false; + FontScaleConverter that = (FontScaleConverter) o; + return Arrays.equals(mFromSpValues, that.mFromSpValues) + && Arrays.equals(mToDpValues, that.mToDpValues); + } + + @Override + public int hashCode() { + int result = Arrays.hashCode(mFromSpValues); + result = 31 * result + Arrays.hashCode(mToDpValues); + return result; + } + + @Override + public String toString() { + return "FontScaleConverter{" + + "fromSpValues=" + + Arrays.toString(mFromSpValues) + + ", toDpValues=" + + Arrays.toString(mToDpValues) + + '}'; + } +} diff --git a/core/java/android/content/res/FontScaleConverterFactory.java b/core/java/android/content/res/FontScaleConverterFactory.java new file mode 100644 index 000000000000..c77a372f1939 --- /dev/null +++ b/core/java/android/content/res/FontScaleConverterFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 android.content.res; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.util.SparseArray; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Stores lookup tables for creating {@link FontScaleConverter}s at various scales. + * + * @hide + */ +public class FontScaleConverterFactory { + private static final float SCALE_KEY_MULTIPLIER = 100f; + + @VisibleForTesting + static final SparseArray<FontScaleConverter> LOOKUP_TABLES = new SparseArray<>(); + + static { + // These were generated by frameworks/base/tools/fonts/font-scaling-array-generator.js and + // manually tweaked for optimum readability. + put( + /* scaleKey= */ 1.15f, + new FontScaleConverter( + /* fromSp= */ + new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100}, + /* toDp= */ + new float[] { 9.2f, 11.5f, 13.8f, 16.1f, 20.7f, 23f, 27.6f, 34.5f, 115}) + ); + + put( + /* scaleKey= */ 1.3f, + new FontScaleConverter( + /* fromSp= */ + new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100}, + /* toDp= */ + new float[] {10.4f, 13f, 15.6f, 18.2f, 23.4f, 26f, 31.2f, 39f, 130}) + ); + + put( + /* scaleKey= */ 1.5f, + new FontScaleConverter( + /* fromSp= */ + new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100}, + /* toDp= */ + new float[] { 12f, 15f, 18f, 21f, 27f, 30f, 36f, 45f, 150}) + ); + + put( + /* scaleKey= */ 1.8f, + new FontScaleConverter( + /* fromSp= */ + new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100}, + /* toDp= */ + new float[] {14.4f, 18f, 21.6f, 25.2f, 32.4f, 36f, 43.2f, 54f, 180}) + ); + + put( + /* scaleKey= */ 2f, + new FontScaleConverter( + /* fromSp= */ + new float[] { 8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100}, + /* toDp= */ + new float[] { 16f, 20f, 24f, 28f, 36f, 40f, 48f, 60f, 200}) + ); + + } + + private FontScaleConverterFactory() {} + + /** + * Finds a matching FontScaleConverter for the given fontScale factor. + * + * @param fontScale the scale factor, usually from {@link Configuration#fontScale}. + * + * @return a converter for the given scale, or null if non-linear scaling should not be used. + * + * @hide + */ + @Nullable + public static FontScaleConverter forScale(float fontScale) { + if (fontScale <= 1) { + // We don't need non-linear curves for shrinking text or for 100%. + // Also, fontScale==0 should not have a curve either + return null; + } + + FontScaleConverter lookupTable = get(fontScale); + // TODO(b/247861716): interpolate between two tables when null + + return lookupTable; + } + + private static void put(float scaleKey, @NonNull FontScaleConverter fontScaleConverter) { + LOOKUP_TABLES.put((int) (scaleKey * SCALE_KEY_MULTIPLIER), fontScaleConverter); + } + + @Nullable + private static FontScaleConverter get(float scaleKey) { + return LOOKUP_TABLES.get((int) (scaleKey * SCALE_KEY_MULTIPLIER)); + } +} diff --git a/core/java/android/content/res/ResourcesImpl.java b/core/java/android/content/res/ResourcesImpl.java index ff072916292b..50809ac13f4d 100644 --- a/core/java/android/content/res/ResourcesImpl.java +++ b/core/java/android/content/res/ResourcesImpl.java @@ -430,6 +430,8 @@ public class ResourcesImpl { // Protect against an unset fontScale. mMetrics.scaledDensity = mMetrics.density * (mConfiguration.fontScale != 0 ? mConfiguration.fontScale : 1.0f); + mMetrics.fontScaleConverter = + FontScaleConverterFactory.forScale(mConfiguration.fontScale); final int width, height; if (mMetrics.widthPixels >= mMetrics.heightPixels) { diff --git a/core/java/android/util/DisplayMetrics.java b/core/java/android/util/DisplayMetrics.java index 0a3e6b1cff38..9ce7c93c799c 100755 --- a/core/java/android/util/DisplayMetrics.java +++ b/core/java/android/util/DisplayMetrics.java @@ -18,6 +18,7 @@ package android.util; import android.annotation.Nullable; import android.compat.annotation.UnsupportedAppUsage; +import android.content.res.FontScaleConverter; import android.os.SystemProperties; /** @@ -265,6 +266,15 @@ public class DisplayMetrics { * increments at runtime based on a user preference for the font size. */ public float scaledDensity; + + /** + * If non-null, this will be used to calculate font sizes instead of {@link #scaledDensity}. + * + * @hide + */ + @Nullable + public FontScaleConverter fontScaleConverter; + /** * The exact physical pixels per inch of the screen in the X dimension. */ @@ -342,6 +352,7 @@ public class DisplayMetrics { noncompatScaledDensity = o.noncompatScaledDensity; noncompatXdpi = o.noncompatXdpi; noncompatYdpi = o.noncompatYdpi; + fontScaleConverter = o.fontScaleConverter; } public void setToDefaults() { @@ -359,6 +370,7 @@ public class DisplayMetrics { noncompatScaledDensity = scaledDensity; noncompatXdpi = xdpi; noncompatYdpi = ydpi; + fontScaleConverter = null; } @Override diff --git a/core/java/android/util/TypedValue.java b/core/java/android/util/TypedValue.java index 19de396c4a4a..de204a60a59c 100644 --- a/core/java/android/util/TypedValue.java +++ b/core/java/android/util/TypedValue.java @@ -408,7 +408,14 @@ public class TypedValue { case COMPLEX_UNIT_DIP: return value * metrics.density; case COMPLEX_UNIT_SP: - return value * metrics.scaledDensity; + if (metrics.fontScaleConverter != null) { + return applyDimension( + COMPLEX_UNIT_DIP, + metrics.fontScaleConverter.convertSpToDp(value), + metrics); + } else { + return value * metrics.scaledDensity; + } case COMPLEX_UNIT_PT: return value * metrics.xdpi * (1.0f/72); case COMPLEX_UNIT_IN: diff --git a/core/tests/coretests/Android.bp b/core/tests/coretests/Android.bp index a767f834c765..cffc702b42f7 100644 --- a/core/tests/coretests/Android.bp +++ b/core/tests/coretests/Android.bp @@ -43,6 +43,7 @@ android_test { "mockwebserver", "guava", "androidx.core_core", + "androidx.core_core-ktx", "androidx.test.espresso.core", "androidx.test.ext.junit", "androidx.test.runner", diff --git a/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt b/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt new file mode 100644 index 000000000000..cfca0375bb96 --- /dev/null +++ b/core/tests/coretests/src/android/content/res/FontScaleConverterFactoryTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 android.content.res + +import androidx.core.util.forEach +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FontScaleConverterFactoryTest { + + @Test + fun scale200IsTwiceAtSmallSizes() { + val table = FontScaleConverterFactory.forScale(2F)!! + assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(2f) + assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(16f) + assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(20f) + assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(10f) + assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f) + } + + @SmallTest + fun missingLookupTableReturnsNull() { + assertThat(FontScaleConverterFactory.forScale(3F)).isNull() + } + + @SmallTest + fun missingLookupTable105ReturnsNull() { + assertThat(FontScaleConverterFactory.forScale(1.05F)).isNull() + } + + @SmallTest + fun missingLookupTableNegativeReturnsNull() { + assertThat(FontScaleConverterFactory.forScale(-1F)).isNull() + } + + @SmallTest + fun unnecessaryFontScalesReturnsNull() { + assertThat(FontScaleConverterFactory.forScale(0F)).isNull() + assertThat(FontScaleConverterFactory.forScale(1F)).isNull() + assertThat(FontScaleConverterFactory.forScale(0.85F)).isNull() + } + + @SmallTest + fun tablesMatchAndAreMonotonicallyIncreasing() { + FontScaleConverterFactory.LOOKUP_TABLES.forEach { _, lookupTable -> + assertThat(lookupTable.mToDpValues).hasLength(lookupTable.mFromSpValues.size) + assertThat(lookupTable.mToDpValues).isNotEmpty() + + assertThat(lookupTable.mFromSpValues.asList()).isInStrictOrder() + assertThat(lookupTable.mToDpValues.asList()).isInStrictOrder() + + assertThat(lookupTable.mFromSpValues.asList()).containsNoDuplicates() + assertThat(lookupTable.mToDpValues.asList()).containsNoDuplicates() + } + } + + companion object { + private const val CONVERSION_TOLERANCE = 0.05f + } +} diff --git a/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt b/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt new file mode 100644 index 000000000000..e405c55a53e3 --- /dev/null +++ b/core/tests/coretests/src/android/content/res/FontScaleConverterTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 android.content.res + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FontScaleConverterTest { + + @Test + fun straightInterpolation() { + val table = createTable(8f to 8f, 10f to 10f, 20f to 20f) + assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1f) + assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(8f) + assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(10f) + assertThat(table.convertSpToDp(30F)).isWithin(CONVERSION_TOLERANCE).of(30f) + assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(20f) + assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(5f) + assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f) + } + + @Test + fun interpolate200Percent() { + val table = createTable(8f to 16f, 10f to 20f, 30f to 60f) + assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(2f) + assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(16f) + assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(20f) + assertThat(table.convertSpToDp(30F)).isWithin(CONVERSION_TOLERANCE).of(60f) + assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(40f) + assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(10f) + assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f) + } + + @Test + fun interpolate150Percent() { + val table = createTable(2f to 3f, 10f to 15f, 20f to 30f, 100f to 150f) + assertThat(table.convertSpToDp(2F)).isWithin(CONVERSION_TOLERANCE).of(3f) + assertThat(table.convertSpToDp(1F)).isWithin(CONVERSION_TOLERANCE).of(1.5f) + assertThat(table.convertSpToDp(8F)).isWithin(CONVERSION_TOLERANCE).of(12f) + assertThat(table.convertSpToDp(10F)).isWithin(CONVERSION_TOLERANCE).of(15f) + assertThat(table.convertSpToDp(20F)).isWithin(CONVERSION_TOLERANCE).of(30f) + assertThat(table.convertSpToDp(50F)).isWithin(CONVERSION_TOLERANCE).of(75f) + assertThat(table.convertSpToDp(5F)).isWithin(CONVERSION_TOLERANCE).of(7.5f) + assertThat(table.convertSpToDp(0F)).isWithin(CONVERSION_TOLERANCE).of(0f) + } + + @Test + fun pastEndsUsesLastScalingFactor() { + val table = createTable(8f to 16f, 10f to 20f, 30f to 60f) + assertThat(table.convertSpToDp(100F)).isWithin(CONVERSION_TOLERANCE).of(200f) + assertThat(table.convertSpToDp(31F)).isWithin(CONVERSION_TOLERANCE).of(62f) + assertThat(table.convertSpToDp(1000F)).isWithin(CONVERSION_TOLERANCE).of(2000f) + assertThat(table.convertSpToDp(2000F)).isWithin(CONVERSION_TOLERANCE).of(4000f) + assertThat(table.convertSpToDp(10000F)).isWithin(CONVERSION_TOLERANCE).of(20000f) + } + + @Test + fun negativeSpIsNegativeDp() { + val table = createTable(8f to 16f, 10f to 20f, 30f to 60f) + assertThat(table.convertSpToDp(-1F)).isWithin(CONVERSION_TOLERANCE).of(-2f) + assertThat(table.convertSpToDp(-8F)).isWithin(CONVERSION_TOLERANCE).of(-16f) + assertThat(table.convertSpToDp(-10F)).isWithin(CONVERSION_TOLERANCE).of(-20f) + assertThat(table.convertSpToDp(-30F)).isWithin(CONVERSION_TOLERANCE).of(-60f) + assertThat(table.convertSpToDp(-20F)).isWithin(CONVERSION_TOLERANCE).of(-40f) + assertThat(table.convertSpToDp(-5F)).isWithin(CONVERSION_TOLERANCE).of(-10f) + assertThat(table.convertSpToDp(-0F)).isWithin(CONVERSION_TOLERANCE).of(0f) + } + + private fun createTable(vararg pairs: Pair<Float, Float>) = + FontScaleConverter( + pairs.map { it.first }.toFloatArray(), + pairs.map { it.second }.toFloatArray() + ) + + companion object { + private const val CONVERSION_TOLERANCE = 0.05f + } +} diff --git a/core/tests/coretests/src/android/util/TypedValueTest.kt b/core/tests/coretests/src/android/util/TypedValueTest.kt index 7a05d970de33..7d98a7d1faa1 100644 --- a/core/tests/coretests/src/android/util/TypedValueTest.kt +++ b/core/tests/coretests/src/android/util/TypedValueTest.kt @@ -16,16 +16,17 @@ package android.util +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.filters.SmallTest -import androidx.test.runner.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock -import kotlin.math.abs -import kotlin.math.min -import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) class TypedValueTest { @@ -152,4 +153,19 @@ class TypedValueTest { val widthPx = TypedValue.complexToDimensionPixelSize(widthDimen, metrics) assertEquals(widthFloat.roundToInt(), widthPx) } -}
\ No newline at end of file + + @SmallTest + @Test + fun testNonLinearFontScaling_nullLookupFallsBackToScaledDensity() { + val metrics: DisplayMetrics = mock(DisplayMetrics::class.java) + val fontScale = 2f + metrics.density = 1f + metrics.scaledDensity = fontScale * metrics.density + metrics.fontScaleConverter = null + + assertThat(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 10f, metrics)) + .isEqualTo(20f) + assertThat(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 50f, metrics)) + .isEqualTo(100f) + } +} diff --git a/tools/fonts/font-scaling-array-generator.js b/tools/fonts/font-scaling-array-generator.js new file mode 100644 index 000000000000..9754697bfb51 --- /dev/null +++ b/tools/fonts/font-scaling-array-generator.js @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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. + */ + +/** + Generates arrays for non-linear font scaling, to be pasted into + frameworks/base/core/java/android/content/res/FontScaleConverterFactory.java + + To use: + `node font-scaling-array-generator.js` + or just open a browser, open DevTools, and paste into the Console. +*/ + +/** + * Modify this to match your packages/apps/Settings/res/arrays.xml#entryvalues_font_size + * array so that all possible scales are generated. + */ +const scales = [1.15, 1.30, 1.5, 1.8, 2]; + +const commonSpSizes = [8, 10, 12, 14, 18, 20, 24, 30, 100]; + +/** + * Enum for GENERATION_STYLE which determines how to generate the arrays. + */ +const GenerationStyle = { + /** + * Interpolates between hand-tweaked curves. This is the best option and + * shouldn't require any additional tweaking. + */ + CUSTOM_TWEAKED: 'CUSTOM_TWEAKED', + + /** + * Uses a curve equation that is mostly correct, but will need manual tweaking + * at some scales. + */ + CURVE: 'CURVE', + + /** + * Uses straight linear multiplication. Good starting point for manual + * tweaking. + */ + LINEAR: 'LINEAR' +} + +/** + * Determines how arrays are generated. Must be one of the GenerationStyle + * values. + */ +const GENERATION_STYLE = GenerationStyle.CUSTOM_TWEAKED; + +// These are hand-tweaked curves from which we will derive the other +// interstitial curves using linear interpolation, in the case of using +// GenerationStyle.CUSTOM_TWEAKED. +const interpolationTargets = { + 1.0: commonSpSizes, + 1.5: [12, 15, 18, 22, 24, 26, 28, 30, 100], + 2.0: [16, 20, 24, 26, 30, 34, 36, 38, 100] +}; + +/** + * Interpolate a value with specified extrema, to a new value between new + * extrema. + * + * @param value the current value + * @param inputMin minimum the input value can reach + * @param inputMax maximum the input value can reach + * @param outputMin minimum the output value can reach + * @param outputMax maximum the output value can reach + */ +function map(value, inputMin, inputMax, outputMin, outputMax) { + return outputMin + (outputMax - outputMin) * ((value - inputMin) / (inputMax - inputMin)); +} + +/*** + * Interpolate between values a and b. + */ +function lerp(a, b, fraction) { + return (a * (1.0 - fraction)) + (b * fraction); +} + +function generateRatios(scale) { + // Find the best two arrays to interpolate between. + let startTarget, endTarget; + let startTargetScale, endTargetScale; + const targetScales = Object.keys(interpolationTargets).sort(); + for (let i = 0; i < targetScales.length - 1; i++) { + const targetScaleKey = targetScales[i]; + const targetScale = parseFloat(targetScaleKey, 10); + const startTargetScaleKey = targetScaleKey; + const endTargetScaleKey = targetScales[i + 1]; + + if (scale < parseFloat(startTargetScaleKey, 10)) { + break; + } + + startTargetScale = parseFloat(startTargetScaleKey, 10); + endTargetScale = parseFloat(endTargetScaleKey, 10); + startTarget = interpolationTargets[startTargetScaleKey]; + endTarget = interpolationTargets[endTargetScaleKey]; + } + const interpolationProgress = map(scale, startTargetScale, endTargetScale, 0, 1); + + return commonSpSizes.map((sp, i) => { + const originalSizeDp = sp; + let newSizeDp; + switch (GENERATION_STYLE) { + case GenerationStyle.CUSTOM_TWEAKED: + newSizeDp = lerp(startTarget[i], endTarget[i], interpolationProgress); + break; + case GenerationStyle.CURVE: { + let coeff1; + let coeff2; + if (scale < 1) { + // \left(1.22^{-\left(x+5\right)}+0.5\right)\cdot x + coeff1 = -5; + coeff2 = scale; + } else { + // (1.22^{-\left(x-10\right)}+1\right)\cdot x + coeff1 = map(scale, 1, 2, 2, 8); + coeff2 = 1; + } + newSizeDp = ((Math.pow(1.22, (-(originalSizeDp - coeff1))) + coeff2) * originalSizeDp); + break; + } + case GenerationStyle.LINEAR: + newSizeDp = originalSizeDp * scale; + break; + default: + throw new Error('Invalid GENERATION_STYLE'); + } + return { + fromSp: sp, + toDp: newSizeDp + } + }); +} + +const scaleArrays = + scales + .map(scale => { + const scaleString = (scale * 100).toFixed(0); + return { + scale, + name: `font_size_original_sp_to_scaled_dp_${scaleString}_percent` + } + }) + .map(scaleArray => { + const items = generateRatios(scaleArray.scale); + + return { + ...scaleArray, + items + } + }); + +function formatDigit(d) { + const twoSignificantDigits = Math.round(d * 100) / 100; + return String(twoSignificantDigits).padStart(4, ' '); +} + +console.log( + '' + + scaleArrays.reduce( + (previousScaleArray, currentScaleArray) => { + const itemsFromSp = currentScaleArray.items.map(d => d.fromSp) + .map(formatDigit) + .join('f, '); + const itemsToDp = currentScaleArray.items.map(d => d.toDp) + .map(formatDigit) + .join('f, '); + + return previousScaleArray + ` + put( + /* scaleKey= */ ${currentScaleArray.scale}f, + new FontScaleConverter( + /* fromSp= */ + new float[] {${itemsFromSp}}, + /* toDp= */ + new float[] {${itemsToDp}}) + ); + `; + }, + '')); |