BoxFitter: use box list API internally and improve font size selection

This commit is contained in:
Paul Campbell 2020-05-22 22:49:43 +01:00
parent 57625a1ea9
commit e9b3ba39e1
2 changed files with 89 additions and 56 deletions

View file

@ -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();

View file

@ -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));
} }
} }