BoxFitter: use box list API internally and improve font size selection
This commit is contained in:
parent
57625a1ea9
commit
e9b3ba39e1
2 changed files with 89 additions and 56 deletions
|
@ -5,9 +5,12 @@ import lombok.RequiredArgsConstructor;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.font.FontRenderContext;
|
import java.awt.font.FontRenderContext;
|
||||||
import java.awt.geom.Rectangle2D;
|
import java.awt.geom.Rectangle2D;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fit the text to a box by finding the best font size and word wrapping to fill
|
* Fit the text to a box by finding the best font size and word wrapping to fill
|
||||||
|
@ -16,6 +19,7 @@ import java.util.stream.Collectors;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
class BoxFitterImpl implements BoxFitter {
|
class BoxFitterImpl implements BoxFitter {
|
||||||
|
|
||||||
|
public static final int MAX_FONT_SIZE = 10_000;
|
||||||
private final WordWrapper wordWrapper;
|
private final WordWrapper wordWrapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -25,12 +29,8 @@ class BoxFitterImpl implements BoxFitter {
|
||||||
Graphics2D graphics2D,
|
Graphics2D graphics2D,
|
||||||
Rectangle2D box
|
Rectangle2D box
|
||||||
) {
|
) {
|
||||||
int fit = fitMinMax(0, (int) box.getHeight(),
|
return fit(text, fontFactory, graphics2D,
|
||||||
new FitEnvironment(text, fontFactory, graphics2D, box));
|
Collections.singletonList(box));
|
||||||
if (fit <= 2) {
|
|
||||||
throw new IllegalArgumentException("The text is too long to fit");
|
|
||||||
}
|
|
||||||
return fit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -38,9 +38,15 @@ class BoxFitterImpl implements BoxFitter {
|
||||||
String text,
|
String text,
|
||||||
Function<Integer, Font> fontFactory,
|
Function<Integer, Font> fontFactory,
|
||||||
Graphics2D graphics2D,
|
Graphics2D graphics2D,
|
||||||
List<Rectangle2D> box
|
List<Rectangle2D> boxes
|
||||||
) {
|
) {
|
||||||
return fit(text, fontFactory, graphics2D, box.get(0));
|
int fit = fitMinMax(0, MAX_FONT_SIZE,
|
||||||
|
new FitEnvironment(text, fontFactory, graphics2D,
|
||||||
|
boxes));
|
||||||
|
if (fit <= 2) {
|
||||||
|
throw new IllegalArgumentException("The text is too long to fit");
|
||||||
|
}
|
||||||
|
return fit;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Integer fitMinMax(
|
private Integer fitMinMax(
|
||||||
|
@ -49,29 +55,39 @@ class BoxFitterImpl implements BoxFitter {
|
||||||
FitEnvironment e
|
FitEnvironment e
|
||||||
) {
|
) {
|
||||||
int mid = (max + min) / 2;
|
int mid = (max + min) / 2;
|
||||||
if (mid == min){
|
if (mid == min && mid + 1 == max) {
|
||||||
return mid;
|
return mid;
|
||||||
}
|
}
|
||||||
Font font = e.getFont(mid);
|
Font font = e.getFont(mid);
|
||||||
try {
|
try {
|
||||||
List<String> lines = wrapLines(font, e);
|
List<List<Rectangle2D>> linesPerBox = wrapLines(font, e);
|
||||||
List<Rectangle2D> lineSizes =
|
if (tooManyLines(linesPerBox, e.boxes)) {
|
||||||
lineSizes(font, lines, e.fontRenderContext());
|
|
||||||
if (sumLineHeights(lineSizes) > e.boxHeight() ||
|
|
||||||
maxLineWidth(lineSizes) > e.boxWidth()) {
|
|
||||||
return fitMinMax(min, mid, e);
|
return fitMinMax(min, mid, e);
|
||||||
}
|
}
|
||||||
} catch (WordTooLong err) {
|
} catch (WordTooLong | NotEnoughSpace err) {
|
||||||
return fitMinMax(min, mid, e);
|
return fitMinMax(min, mid, e);
|
||||||
}
|
}
|
||||||
return fitMinMax(mid, max, e);
|
return fitMinMax(mid, max, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> wrapLines(
|
private boolean tooManyLines(
|
||||||
|
List<List<Rectangle2D>> linesPerBox,
|
||||||
|
List<Rectangle2D> boxes
|
||||||
|
) {
|
||||||
|
return StreamZipper.zip(linesPerBox, boxes,
|
||||||
|
(stringBounds, box) ->
|
||||||
|
sumLineHeights(stringBounds) > box.getHeight()
|
||||||
|
).anyMatch(b -> b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<Rectangle2D>> wrapLines(
|
||||||
Font font,
|
Font font,
|
||||||
FitEnvironment e
|
FitEnvironment e
|
||||||
) {
|
) {
|
||||||
return wordWrapper.wrap(e.text, font, e.graphics2D, e.boxWidth());
|
return wordWrapper.wrap(e.text, font, e.graphics2D, e.boxes)
|
||||||
|
.stream()
|
||||||
|
.map(l -> lineSizes(font, l, e.fontRenderContext()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Rectangle2D> lineSizes(
|
private List<Rectangle2D> lineSizes(
|
||||||
|
@ -85,13 +101,10 @@ class BoxFitterImpl implements BoxFitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int sumLineHeights(List<Rectangle2D> lineSizes) {
|
private int sumLineHeights(List<Rectangle2D> lineSizes) {
|
||||||
return lineSizes.stream().map(Rectangle2D::getHeight)
|
return lineSizes.stream()
|
||||||
.mapToInt(Double::intValue).sum();
|
.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
|
@RequiredArgsConstructor
|
||||||
|
@ -99,18 +112,11 @@ class BoxFitterImpl implements BoxFitter {
|
||||||
private final String text;
|
private final String text;
|
||||||
private final Function<Integer, Font> fontFactory;
|
private final Function<Integer, Font> fontFactory;
|
||||||
private final Graphics2D graphics2D;
|
private final Graphics2D graphics2D;
|
||||||
private final Rectangle2D box;
|
private final List<Rectangle2D> boxes;
|
||||||
|
|
||||||
public Font getFont(int size) {
|
public Font getFont(int size) {
|
||||||
return fontFactory.apply(size);
|
return fontFactory.apply(size);
|
||||||
}
|
}
|
||||||
public int boxWidth() {
|
|
||||||
return (int) box.getWidth();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int boxHeight() {
|
|
||||||
return (int) box.getHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
public FontRenderContext fontRenderContext() {
|
public FontRenderContext fontRenderContext() {
|
||||||
return graphics2D.getFontRenderContext();
|
return graphics2D.getFontRenderContext();
|
||||||
|
|
|
@ -13,6 +13,7 @@ import java.io.IOException;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
@ -25,6 +26,9 @@ public class BoxFitterTest
|
||||||
private final BoxFitter boxFitter = TextFit.fitter();
|
private final BoxFitter boxFitter = TextFit.fitter();
|
||||||
private final Font font;
|
private final Font font;
|
||||||
private Function<Integer, Font> fontFactory;
|
private Function<Integer, Font> fontFactory;
|
||||||
|
private final int imageSize = 300;
|
||||||
|
private final Graphics2D graphics2D = graphics(imageSize, imageSize);
|
||||||
|
private Rectangle2D box = new Rectangle(imageSize, imageSize);
|
||||||
|
|
||||||
public BoxFitterTest() throws URISyntaxException, IOException, FontFormatException {
|
public BoxFitterTest() throws URISyntaxException, IOException, FontFormatException {
|
||||||
URL resource = this.getClass().getResource("alice/Alice-Regular.ttf");
|
URL resource = this.getClass().getResource("alice/Alice-Regular.ttf");
|
||||||
|
@ -42,10 +46,6 @@ public class BoxFitterTest
|
||||||
@DisplayName("Single Box API")
|
@DisplayName("Single Box API")
|
||||||
public class SingleBoxAPI implements FitTests {
|
public class SingleBoxAPI implements FitTests {
|
||||||
|
|
||||||
private final int imageSize = 300;
|
|
||||||
private final Graphics2D graphics2D = graphics(imageSize, imageSize);
|
|
||||||
private Rectangle2D box = new Rectangle(imageSize, imageSize);
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int fit(String longText) {
|
public int fit(String longText) {
|
||||||
return boxFitter.fit(longText, fontFactory, graphics2D, box);
|
return boxFitter.fit(longText, fontFactory, graphics2D, box);
|
||||||
|
@ -110,38 +110,65 @@ public class BoxFitterTest
|
||||||
@DisplayName("List of Boxes API")
|
@DisplayName("List of Boxes API")
|
||||||
public class BoxListAPI {
|
public class BoxListAPI {
|
||||||
|
|
||||||
private final int imageSize = 300;
|
|
||||||
private final Graphics2D graphics2D = graphics(imageSize, imageSize);
|
|
||||||
private Rectangle2D box = new Rectangle(imageSize, imageSize);
|
|
||||||
private List<Rectangle2D> boxes = Arrays.asList(box, box);
|
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("Single Box")
|
@DisplayName("Single Box")
|
||||||
// different API, but should have same behaviour as using single box API
|
// different API, but should have same behaviour as using single box API
|
||||||
public class SingleBox extends SingleBoxAPI {
|
public class SingleBox implements FitTests {
|
||||||
|
|
||||||
@Override
|
private final List<Rectangle2D> boxes = Collections.singletonList(box);
|
||||||
public int fit(String longText) {
|
|
||||||
return boxFitter.fit(longText, fontFactory, graphics2D, boxes);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("Two Boxes")
|
|
||||||
public class TwoBoxes implements FitTests{
|
|
||||||
@Override
|
@Override
|
||||||
public int fit(String longText) {
|
public int fit(String longText) {
|
||||||
return boxFitter.fit(longText, fontFactory, graphics2D, boxes);
|
return boxFitter.fit(longText, fontFactory, graphics2D, boxes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Text too long to fit single box - fits into two")
|
@DisplayName("Fit various lengths")
|
||||||
|
public void fitVariousLengths() {
|
||||||
|
Map<String, Integer> wordMap = Map.of(
|
||||||
|
". .", 263,
|
||||||
|
"a a", 208,
|
||||||
|
"Another Word", 76,
|
||||||
|
longStringGenerator(1), 93,
|
||||||
|
longStringGenerator(2), 36,
|
||||||
|
longStringGenerator(3), 27,
|
||||||
|
longStringGenerator(4), 22,
|
||||||
|
longStringGenerator(5), 20,
|
||||||
|
longStringGenerator(100), 4,
|
||||||
|
longStringGenerator(196), 3
|
||||||
|
);
|
||||||
|
wordMap.forEach((word, expectedSize) ->
|
||||||
|
assertThat(fit(word))
|
||||||
|
.as(word)
|
||||||
|
.isEqualTo(expectedSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Excess text for one box - throws")
|
||||||
|
public void tooMuchForOneBox() {
|
||||||
|
assertThatExceptionOfType(NotEnoughSpace.class)
|
||||||
|
.isThrownBy(() ->
|
||||||
|
fit(longStringGenerator(197)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Two Boxes")
|
||||||
|
public class TwoBoxes implements FitTests{
|
||||||
|
|
||||||
|
private final List<Rectangle2D> boxes = Arrays.asList(box, box);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int fit(String longText) {
|
||||||
|
return boxFitter.fit(longText, fontFactory, graphics2D, boxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Excess text for one box - fits into two")
|
||||||
public void tooLongThrows() {
|
public void tooLongThrows() {
|
||||||
String longText = longStringGenerator(197);
|
assertThatCode(() ->
|
||||||
//TODO: should overflow into second box
|
fit(longStringGenerator(197)))
|
||||||
assertThatExceptionOfType(IllegalArgumentException.class)
|
.doesNotThrowAnyException();
|
||||||
.isThrownBy(() -> fit(longText));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue