Add BoxFitter
This commit is contained in:
parent
3804cafabe
commit
44aed308f8
5 changed files with 236 additions and 2 deletions
14
src/main/java/net/kemitix/text/fit/BoxFitter.java
Normal file
14
src/main/java/net/kemitix/text/fit/BoxFitter.java
Normal file
|
@ -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<Integer, Font> fontFactory,
|
||||
Graphics2D graphics2D,
|
||||
Rectangle2D box
|
||||
);
|
||||
}
|
105
src/main/java/net/kemitix/text/fit/BoxFitterImpl.java
Normal file
105
src/main/java/net/kemitix/text/fit/BoxFitterImpl.java
Normal file
|
@ -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<Integer, Font> 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<String> lines = wrapLines(font, e);
|
||||
List<Rectangle2D> 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<String> wrapLines(
|
||||
Font font,
|
||||
FitEnvironment e
|
||||
) {
|
||||
return wordWrapper.wrap(e.text, font, e.graphics2D, e.boxWidth());
|
||||
}
|
||||
|
||||
private List<Rectangle2D> lineSizes(
|
||||
Font font,
|
||||
List<String> lines,
|
||||
FontRenderContext renderContext
|
||||
) {
|
||||
return lines.stream()
|
||||
.map(line -> font.getStringBounds(line.strip(), renderContext))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private int sumLineHeights(List<Rectangle2D> lineSizes) {
|
||||
return lineSizes.stream().map(Rectangle2D::getHeight)
|
||||
.mapToInt(Double::intValue).sum();
|
||||
}
|
||||
|
||||
private int maxLineWidth(List<Rectangle2D> 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<Integer, Font> 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,4 +4,7 @@ public interface TextFit {
|
|||
static WordWrapper wrapper() {
|
||||
return new TextLineWrapImpl();
|
||||
}
|
||||
static BoxFitter fitter() {
|
||||
return new BoxFitterImpl(wrapper());
|
||||
}
|
||||
}
|
||||
|
|
112
src/test/java/net/kemitix/text/fit/BoxFitterTest.java
Normal file
112
src/test/java/net/kemitix/text/fit/BoxFitterTest.java
Normal file
|
@ -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<Integer, Font> 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<String, Integer> 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<String, Integer> 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);
|
||||
}
|
||||
}
|
|
@ -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<String> invoke(String in) {
|
||||
return textLineWrap.wrap(in, font, graphics100x100, imageSize);
|
||||
return textLineWrap.wrap(in, font, graphics2D, imageSize);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Reference in a new issue