From e9b3ba39e1c39c957001de0b26c22b081452f7e7 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Fri, 22 May 2020 22:49:43 +0100 Subject: [PATCH] BoxFitter: use box list API internally and improve font size selection --- .../net/kemitix/text/fit/BoxFitterImpl.java | 70 +++++++++-------- .../net/kemitix/text/fit/BoxFitterTest.java | 75 +++++++++++++------ 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java b/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java index 854550c..2606b8d 100644 --- a/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java +++ b/src/main/java/net/kemitix/text/fit/BoxFitterImpl.java @@ -5,9 +5,12 @@ import lombok.RequiredArgsConstructor; import java.awt.*; import java.awt.font.FontRenderContext; import java.awt.geom.Rectangle2D; +import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; 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 @@ -16,6 +19,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor class BoxFitterImpl implements BoxFitter { + public static final int MAX_FONT_SIZE = 10_000; private final WordWrapper wordWrapper; @Override @@ -25,12 +29,8 @@ class BoxFitterImpl implements BoxFitter { 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; + return fit(text, fontFactory, graphics2D, + Collections.singletonList(box)); } @Override @@ -38,9 +38,15 @@ class BoxFitterImpl implements BoxFitter { String text, Function fontFactory, Graphics2D graphics2D, - List box + List 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( @@ -49,29 +55,39 @@ class BoxFitterImpl implements BoxFitter { FitEnvironment e ) { int mid = (max + min) / 2; - if (mid == min){ + if (mid == min && mid + 1 == max) { return mid; } Font font = e.getFont(mid); try { - List lines = wrapLines(font, e); - List lineSizes = - lineSizes(font, lines, e.fontRenderContext()); - if (sumLineHeights(lineSizes) > e.boxHeight() || - maxLineWidth(lineSizes) > e.boxWidth()) { + List> linesPerBox = wrapLines(font, e); + if (tooManyLines(linesPerBox, e.boxes)) { return fitMinMax(min, mid, e); } - } catch (WordTooLong err) { + } catch (WordTooLong | NotEnoughSpace err) { return fitMinMax(min, mid, e); } return fitMinMax(mid, max, e); } - private List wrapLines( + private boolean tooManyLines( + List> linesPerBox, + List boxes + ) { + return StreamZipper.zip(linesPerBox, boxes, + (stringBounds, box) -> + sumLineHeights(stringBounds) > box.getHeight() + ).anyMatch(b -> b); + } + + private List> wrapLines( Font font, 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 lineSizes( @@ -85,13 +101,10 @@ class BoxFitterImpl implements BoxFitter { } 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); + return lineSizes.stream() + .map(Rectangle2D::getHeight) + .mapToInt(Double::intValue) + .sum(); } @RequiredArgsConstructor @@ -99,18 +112,11 @@ class BoxFitterImpl implements BoxFitter { private final String text; private final Function fontFactory; private final Graphics2D graphics2D; - private final Rectangle2D box; + private final List boxes; 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/test/java/net/kemitix/text/fit/BoxFitterTest.java b/src/test/java/net/kemitix/text/fit/BoxFitterTest.java index b209b12..5f39e9a 100644 --- a/src/test/java/net/kemitix/text/fit/BoxFitterTest.java +++ b/src/test/java/net/kemitix/text/fit/BoxFitterTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -25,6 +26,9 @@ public class BoxFitterTest private final BoxFitter boxFitter = TextFit.fitter(); private final Font font; private Function 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 { URL resource = this.getClass().getResource("alice/Alice-Regular.ttf"); @@ -42,10 +46,6 @@ public class BoxFitterTest @DisplayName("Single Box API") 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 public int fit(String longText) { return boxFitter.fit(longText, fontFactory, graphics2D, box); @@ -110,38 +110,65 @@ public class BoxFitterTest @DisplayName("List of Boxes API") public class BoxListAPI { - private final int imageSize = 300; - private final Graphics2D graphics2D = graphics(imageSize, imageSize); - private Rectangle2D box = new Rectangle(imageSize, imageSize); - private List boxes = Arrays.asList(box, box); - @Nested @DisplayName("Single Box") // different API, but should have same behaviour as using single box API - public class SingleBox extends SingleBoxAPI { + public class SingleBox implements FitTests { - @Override - public int fit(String longText) { - return boxFitter.fit(longText, fontFactory, graphics2D, boxes); - } + private final List boxes = Collections.singletonList(box); - } - - @Nested - @DisplayName("Two Boxes") - public class TwoBoxes implements FitTests{ @Override public int fit(String longText) { return boxFitter.fit(longText, fontFactory, graphics2D, boxes); } @Test - @DisplayName("Text too long to fit single box - fits into two") + @DisplayName("Fit various lengths") + public void fitVariousLengths() { + Map 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 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() { - String longText = longStringGenerator(197); - //TODO: should overflow into second box - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> fit(longText)); + assertThatCode(() -> + fit(longStringGenerator(197))) + .doesNotThrowAnyException(); } }