diff --git a/src/main/java/net/kemitix/text/fit/BoxFitter.java b/src/main/java/net/kemitix/text/fit/BoxFitter.java new file mode 100644 index 0000000..d719608 --- /dev/null +++ b/src/main/java/net/kemitix/text/fit/BoxFitter.java @@ -0,0 +1,14 @@ +package net.kemitix.text.fit; + +import java.awt.*; +import java.awt.geom.Rectangle2D; +import java.util.function.Function; + +public interface BoxFitter { + int fit( + String text, + Function fontFactory, + Graphics2D graphics2D, + Rectangle2D box + ); +} diff --git a/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java b/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java new file mode 100644 index 0000000..e9b540a --- /dev/null +++ b/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java @@ -0,0 +1,105 @@ +package net.kemitix.text.fit; + +import lombok.RequiredArgsConstructor; + +import java.awt.*; +import java.awt.font.FontRenderContext; +import java.awt.geom.Rectangle2D; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Fit the text to a box by finding the best font size and word wrapping to fill + * the space using a binary search. + */ +@RequiredArgsConstructor +class BoxFitterImpl implements BoxFitter { + + private final WordWrapper wordWrapper; + + @Override + public int fit( + String text, + Function fontFactory, + Graphics2D graphics2D, + Rectangle2D box + ) { + int fit = fitMinMax(0, (int) box.getHeight(), + new FitEnvironment(text, fontFactory, graphics2D, box)); + if (fit <= 2) { + throw new IllegalArgumentException("The text is too long to fit"); + } + return fit; + } + + private Integer fitMinMax( + int min, + int max, + FitEnvironment e + ) { + int mid = (max + min) / 2; + if (mid == min){ + return mid; + } + Font font = e.getFont(mid); + List lines = wrapLines(font, e); + List lineSizes = + lineSizes(font, lines, e.fontRenderContext()); + if (sumLineHeights(lineSizes) > e.boxHeight() || + maxLineWidth(lineSizes) > e.boxWidth()) { + return fitMinMax(min, mid, e); + } + return fitMinMax(mid, max, e); + } + + private List wrapLines( + Font font, + FitEnvironment e + ) { + return wordWrapper.wrap(e.text, font, e.graphics2D, e.boxWidth()); + } + + private List lineSizes( + Font font, + List lines, + FontRenderContext renderContext + ) { + return lines.stream() + .map(line -> font.getStringBounds(line.strip(), renderContext)) + .collect(Collectors.toList()); + } + + private int sumLineHeights(List lineSizes) { + return lineSizes.stream().map(Rectangle2D::getHeight) + .mapToInt(Double::intValue).sum(); + } + + private int maxLineWidth(List lineSizes) { + return lineSizes.stream().map(Rectangle2D::getWidth) + .mapToInt(Double::intValue).max().orElse(0); + } + + @RequiredArgsConstructor + private static class FitEnvironment { + private final String text; + private final Function fontFactory; + private final Graphics2D graphics2D; + private final Rectangle2D box; + + public Font getFont(int size) { + return fontFactory.apply(size); + } + public int boxWidth() { + return (int) box.getWidth(); + } + + public int boxHeight() { + return (int) box.getHeight(); + } + + public FontRenderContext fontRenderContext() { + return graphics2D.getFontRenderContext(); + } + } +} diff --git a/src/main/java/net/kemitix/text/fit/TextFit.java b/src/main/java/net/kemitix/text/fit/TextFit.java index 3ad6509..d122b09 100644 --- a/src/main/java/net/kemitix/text/fit/TextFit.java +++ b/src/main/java/net/kemitix/text/fit/TextFit.java @@ -4,4 +4,7 @@ public interface TextFit { static WordWrapper wrapper() { return new TextLineWrapImpl(); } + static BoxFitter fitter() { + return new BoxFitterImpl(wrapper()); + } } diff --git a/src/test/java/net/kemitix/text/fit/BoxFitterTest.java b/src/test/java/net/kemitix/text/fit/BoxFitterTest.java new file mode 100644 index 0000000..a93a69c --- /dev/null +++ b/src/test/java/net/kemitix/text/fit/BoxFitterTest.java @@ -0,0 +1,112 @@ +package net.kemitix.text.fit; + +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.*; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Collections; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class BoxFitterTest + implements WithAssertions { + + private final BoxFitter boxFitter = TextFit.fitter(); + private final int imageSize = 300; + private final int fontSize = 20; + private final Graphics2D graphics2D = graphics(imageSize, imageSize); + private final Font font; + private Function fontFactory; + private Rectangle2D box = new Rectangle(imageSize, imageSize); + + public BoxFitterTest() throws URISyntaxException, IOException, FontFormatException { + URL resource = this.getClass().getResource("alice/Alice-Regular.ttf"); + font = Font.createFont(Font.TRUETYPE_FONT, new File(resource.toURI())) + .deriveFont(Font.PLAIN, fontSize); + fontFactory = size -> font.deriveFont(Font.PLAIN, size); + } + + @Test + @DisplayName("Fit single words") + public void fitSingleWord() { + Map wordMap = Map.of( + ".", 263, + "a", 263, + "Word", 121, + "longer", 104, + "extralongword", 44 + ); + wordMap.forEach((word, expectedSize) -> + assertThat(invoke(word)) + .as(word) + .isEqualTo(expectedSize)); + } + + @Test + @DisplayName("Fit two words") + public void fitTwoWords() { + Map wordMap = Map.of( + ". .", 263, + "a a", 208, + "Another Word", 81, + longStringGenerator(1), 100, + longStringGenerator(2), 36, + longStringGenerator(3), 27, + longStringGenerator(4), 22, + longStringGenerator(5), 20, + longStringGenerator(100), 4, + longStringGenerator(196), 3 + ); + wordMap.forEach((word, expectedSize) -> + assertThat(invoke(word)) + .as(word) + .isEqualTo(expectedSize)); + } + + @Test + @DisplayName("Text too long to fit throws and exception") + // too long to fit means it would need to be rendered at a font size of <2 + public void tooLongThrows() { + String longText = longStringGenerator(197); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> invoke(longText)); + } + + @Test + @DisplayName("Long text can be fitted down to a font size of 3") + public void veryLongFits() { + String longText = longStringGenerator(196); + assertThatCode(() -> invoke(longText)) + .doesNotThrowAnyException(); + } + + private int invoke(String longText) { + return boxFitter.fit(longText, fontFactory, graphics2D, box); + } + + private String longStringGenerator(int cycles) { + String text = "This is a long piece of text that should result in an " + + "attempt to render it at a font size on less than 2."; + return "cycles: " + cycles + IntStream.range(0, cycles) + .mapToObj(x -> "\n").collect(Collectors.joining(text)); + } + + private Graphics2D graphics(int width, int height) { + return image(width, height) + .createGraphics(); + } + + private BufferedImage image(int width, int height) { + return new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + } +} diff --git a/src/test/java/net/kemitix/text/fit/TextLineWrapTest.java b/src/test/java/net/kemitix/text/fit/TextLineWrapTest.java index 7266db8..48885b7 100644 --- a/src/test/java/net/kemitix/text/fit/TextLineWrapTest.java +++ b/src/test/java/net/kemitix/text/fit/TextLineWrapTest.java @@ -18,7 +18,7 @@ public class TextLineWrapTest private final WordWrapper textLineWrap = TextFit.wrapper(); private final int imageSize = 300; private final int fontSize = 20; - private final Graphics2D graphics100x100 = graphics(imageSize, imageSize); + private final Graphics2D graphics2D = graphics(imageSize, imageSize); private final Font font; public TextLineWrapTest() @@ -31,7 +31,7 @@ public class TextLineWrapTest } private List invoke(String in) { - return textLineWrap.wrap(in, font, graphics100x100, imageSize); + return textLineWrap.wrap(in, font, graphics2D, imageSize); } @Test