diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index 583939518..ef9b41802 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -858,7 +858,7 @@ private static List wrapParagraph(List logicalLines, // Over-wide on a fresh (or already empty) line: break it at soft seams, // char-splitting as a last resort. double availableWidth = availableWidthForPrefix(maxWidth, currentPrefix, style, measurement); - List chunks = breakLongToken(strippedToken, style, availableWidth, measurement); + List chunks = TokenBreaking.breakLongToken(strippedToken, style, availableWidth, measurement); if (chunks.isEmpty()) { continue; } @@ -987,7 +987,7 @@ private static List wrapInlineParagraph(List runs, // coalesced fill inside the column. Interior fragments under-fill by // at most that padding — cosmetic, and only on an over-wide chip. double reserve = chip ? textToken.leadPad() + textToken.trailPad() : 0.0; - List pieces = breakLongToken( + List pieces = TokenBreaking.breakLongToken( textToken.text(), textToken.textStyle(), Math.max(1.0, maxWidth - currentWidth - reserve), @@ -1117,7 +1117,7 @@ private static List wrapMarkdownParagraph(List logicalLin } } - List chunks = breakLongToken( + List chunks = TokenBreaking.breakLongToken( sanitizedToken.text(), sanitizedToken.textStyle(), Math.max(1.0, maxWidth - currentWidth), @@ -1631,132 +1631,6 @@ private static double lineWidth(List bodies, return width; } - /** - * Characters after which a long, otherwise-unbreakable token may wrap. These - * are the joiners inside package coordinates, fully-qualified class names and - * URLs ({@code org.junit.jupiter:junit-jupiter:5.10.2}, {@code a/b/c}), so an - * over-wide code token breaks at a readable seam instead of mid-identifier. - */ - private static final String SOFT_BREAK_AFTER_CHARS = ".:/-"; - - /** - * Breaks a single over-wide token into line-pieces that each fit - * {@code maxWidth}. Prefers breaking at {@link #SOFT_BREAK_AFTER_CHARS soft - * seams} (after {@code . : / -}) so coordinates and URLs split naturally, and - * falls back to character-level {@link #splitLongToken splitting} for any lone - * segment still too wide (e.g. a long dot-less identifier). The returned pieces - * have the same shape the wrap loops already consume from {@code splitLongToken} - * — for a token with no soft seams the two are identical. - */ - private static List breakLongToken(String token, - TextStyle style, - double maxWidth, - TextMeasurementSystem measurement) { - if (token == null || token.isEmpty()) { - return List.of(); - } - List segments = softBreakSegments(token); - List pieces = new ArrayList<>(); - StringBuilder current = new StringBuilder(); - double currentWidth = 0.0; - for (String segment : segments) { - double segmentWidth = measurement.textWidth(style, segment); - if (current.length() > 0 && currentWidth + segmentWidth > maxWidth + EPS) { - pieces.add(current.toString()); - current.setLength(0); - currentWidth = 0.0; - } - if (current.length() == 0 && segmentWidth > maxWidth + EPS) { - // A single seam-delimited segment is itself too wide: char-split it. - // Every char-chunk but the last is a finished piece; the tail seeds - // the current piece so a following short segment can still join it. - List chars = splitLongToken(segment, style, maxWidth, measurement); - for (int index = 0; index < chars.size() - 1; index++) { - pieces.add(chars.get(index)); - } - String tail = chars.get(chars.size() - 1); - current.append(tail); - currentWidth = measurement.textWidth(style, tail); - } else { - current.append(segment); - currentWidth += segmentWidth; - } - } - if (current.length() > 0) { - pieces.add(current.toString()); - } - return List.copyOf(pieces); - } - - /** - * Splits a token after each {@link #SOFT_BREAK_AFTER_CHARS soft-break - * character}, keeping the delimiter with the preceding segment - * ({@code org.junit.jupiter:} then {@code junit-jupiter:} then {@code 5.10.2}). - * A token with no such characters yields a single segment (itself). - */ - private static List softBreakSegments(String token) { - List segments = new ArrayList<>(); - int start = 0; - for (int index = 0; index < token.length(); index++) { - if (SOFT_BREAK_AFTER_CHARS.indexOf(token.charAt(index)) >= 0) { - segments.add(token.substring(start, index + 1)); - start = index + 1; - } - } - if (start < token.length()) { - segments.add(token.substring(start)); - } - return segments; - } - - private static List splitLongToken(String token, - TextStyle style, - double maxWidth, - TextMeasurementSystem measurement) { - if (token == null || token.isEmpty()) { - return List.of(); - } - - List pieces = new ArrayList<>(); - String remaining = token; - while (!remaining.isEmpty()) { - int splitIndex = fitCharacters(remaining, style, maxWidth, measurement); - if (splitIndex <= 0 || splitIndex >= remaining.length()) { - pieces.add(remaining); - break; - } - pieces.add(remaining.substring(0, splitIndex)); - remaining = remaining.substring(splitIndex).stripLeading(); - } - return List.copyOf(pieces); - } - - private static int fitCharacters(String text, - TextStyle style, - double maxWidth, - TextMeasurementSystem measurement) { - // Largest prefix length whose width fits. The fit predicate - // width(substring(0,n)) <= maxWidth is monotonic in n (each added char - // contributes a non-negative glyph advance), so the fitting lengths form - // a prefix [1..lastFitting] and a binary search finds the SAME boundary - // as the old linear scan — but in O(log n) width calls instead of - // measuring every growing prefix (which was O(n) calls and O(n^2) - // measured characters for a long unbreakable token). - int lastFitting = 0; - int low = 1; - int high = text.length(); - while (low <= high) { - int mid = (low + high) >>> 1; - if (measurement.textWidth(style, text.substring(0, mid)) <= maxWidth + EPS) { - lastFitting = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - return lastFitting == 0 ? Math.min(1, text.length()) : lastFitting; - } - private static String trimTrailingSpaces(String value) { int end = value.length(); while (end > 0 && Character.isWhitespace(value.charAt(end - 1))) { diff --git a/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java new file mode 100644 index 000000000..136cb0a65 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java @@ -0,0 +1,166 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; + +import java.util.ArrayList; +import java.util.List; + +import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; + +/** + * Breaks a single over-wide, otherwise-unbreakable token into line-pieces that + * each fit a width budget. Prefers wrapping at readable soft seams + * ({@code . : / -}) so package coordinates, fully-qualified class names and URLs + * split naturally, and falls back to character-level splitting for a lone + * segment that is still too wide. + * + *

Extracted from {@link TextFlowSupport} (which enters only through + * {@link #breakLongToken}) so the pure breaking logic is unit-testable in + * isolation and does not grow the wrap-loop file. Package-private intentionally + * — engine surface, not public API.

+ * + * @author Artem Demchyshyn + */ +final class TokenBreaking { + + /** + * Characters after which a long, otherwise-unbreakable token may wrap. These + * are the joiners inside package coordinates, fully-qualified class names and + * URLs ({@code org.junit.jupiter:junit-jupiter:5.10.2}, {@code a/b/c}), so an + * over-wide code token breaks at a readable seam instead of mid-identifier. + */ + private static final String SOFT_BREAK_AFTER_CHARS = ".:/-"; + + private TokenBreaking() { + } + + /** + * Breaks a single over-wide token into line-pieces that each fit + * {@code maxWidth}. Prefers breaking at {@link #SOFT_BREAK_AFTER_CHARS soft + * seams} (after {@code . : / -}) so coordinates and URLs split naturally, and + * falls back to character-level {@link #splitLongToken splitting} for any lone + * segment still too wide (e.g. a long dot-less identifier). The returned pieces + * have the same shape the wrap loops consume — for a token with no soft seams + * the result is identical to a bare {@link #splitLongToken}. + */ + static List breakLongToken(String token, + TextStyle style, + double maxWidth, + TextMeasurementSystem measurement) { + if (token == null || token.isEmpty()) { + return List.of(); + } + List segments = softBreakSegments(token); + List pieces = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + double currentWidth = 0.0; + for (String segment : segments) { + double segmentWidth = measurement.textWidth(style, segment); + if (current.length() > 0 && currentWidth + segmentWidth > maxWidth + EPS) { + pieces.add(current.toString()); + current.setLength(0); + currentWidth = 0.0; + } + if (current.length() == 0 && segmentWidth > maxWidth + EPS) { + // A single seam-delimited segment is itself too wide: char-split it. + // Every char-chunk but the last is a finished piece; the tail seeds + // the current piece so a following short segment can still join it. + List chars = splitLongToken(segment, style, maxWidth, measurement); + for (int index = 0; index < chars.size() - 1; index++) { + pieces.add(chars.get(index)); + } + String tail = chars.get(chars.size() - 1); + current.append(tail); + currentWidth = measurement.textWidth(style, tail); + } else { + current.append(segment); + currentWidth += segmentWidth; + } + } + if (current.length() > 0) { + pieces.add(current.toString()); + } + return List.copyOf(pieces); + } + + /** + * Splits a token after each {@link #SOFT_BREAK_AFTER_CHARS soft-break + * character}, keeping the delimiter with the preceding segment + * ({@code org.junit.jupiter:} then {@code junit-jupiter:} then {@code 5.10.2}). + * A token with no such characters yields a single segment (itself). + */ + static List softBreakSegments(String token) { + if (token == null || token.isEmpty()) { + return List.of(); + } + List segments = new ArrayList<>(); + int start = 0; + for (int index = 0; index < token.length(); index++) { + if (SOFT_BREAK_AFTER_CHARS.indexOf(token.charAt(index)) >= 0) { + segments.add(token.substring(start, index + 1)); + start = index + 1; + } + } + if (start < token.length()) { + segments.add(token.substring(start)); + } + return segments; + } + + /** + * Character-level fallback: splits a token into the longest prefixes that each + * fit {@code maxWidth}, guaranteeing at least one character per piece. + */ + static List splitLongToken(String token, + TextStyle style, + double maxWidth, + TextMeasurementSystem measurement) { + if (token == null || token.isEmpty()) { + return List.of(); + } + + List pieces = new ArrayList<>(); + String remaining = token; + while (!remaining.isEmpty()) { + int splitIndex = fitCharacters(remaining, style, maxWidth, measurement); + if (splitIndex <= 0 || splitIndex >= remaining.length()) { + pieces.add(remaining); + break; + } + pieces.add(remaining.substring(0, splitIndex)); + remaining = remaining.substring(splitIndex).stripLeading(); + } + return List.copyOf(pieces); + } + + /** + * Largest prefix length of {@code text} whose width fits {@code maxWidth}, + * or 1 (the guaranteed minimum) when not even the first character fits. + */ + static int fitCharacters(String text, + TextStyle style, + double maxWidth, + TextMeasurementSystem measurement) { + // Largest prefix length whose width fits. The fit predicate + // width(substring(0,n)) <= maxWidth is monotonic in n (each added char + // contributes a non-negative glyph advance), so the fitting lengths form + // a prefix [1..lastFitting] and a binary search finds the SAME boundary + // as the old linear scan — but in O(log n) width calls instead of + // measuring every growing prefix (which was O(n) calls and O(n^2) + // measured characters for a long unbreakable token). + int lastFitting = 0; + int low = 1; + int high = text.length(); + while (low <= high) { + int mid = (low + high) >>> 1; + if (measurement.textWidth(style, text.substring(0, mid)) <= maxWidth + EPS) { + lastFitting = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return lastFitting == 0 ? Math.min(1, text.length()) : lastFitting; + } +} diff --git a/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java new file mode 100644 index 000000000..e7001f7d1 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java @@ -0,0 +1,136 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.components.geometry.ContentSize; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Direct unit coverage for {@link TokenBreaking} — soft-seam segmentation, greedy + * packing, and the character-level fallback — driven by a deterministic + * fixed-width measurement so the assertions do not depend on real font metrics. + */ +class TokenBreakingTest { + + private static final TextStyle STYLE = TextStyle.DEFAULT_STYLE; + /** Each character is exactly 1.0 wide, so a token's width equals its length. */ + private static final TextMeasurementSystem UNIT = new FixedWidthMeasurement(1.0); + + // ── softBreakSegments (pure, no measurement) ──────────────────────────── + + @Test + void softBreakSegmentsSplitsAfterEachSeamKeepingTheDelimiter() { + assertThat(TokenBreaking.softBreakSegments("org.junit.jupiter:junit-jupiter:5.10.2")) + .containsExactly("org.", "junit.", "jupiter:", "junit-", "jupiter:", "5.", "10.", "2"); + } + + @Test + void softBreakSegmentsWithNoSeamReturnsTheWholeToken() { + assertThat(TokenBreaking.softBreakSegments("abcdefgh")).containsExactly("abcdefgh"); + } + + @Test + void softBreakSegmentsHandlesLeadingAndTrailingDelimiters() { + assertThat(TokenBreaking.softBreakSegments("/a/b")).containsExactly("/", "a/", "b"); + assertThat(TokenBreaking.softBreakSegments("...")).containsExactly(".", ".", "."); + // The concatenation of the segments always reconstructs the input. + assertThat(String.join("", TokenBreaking.softBreakSegments("a.b:c/d-e"))).isEqualTo("a.b:c/d-e"); + } + + @Test + void softBreakSegmentsOnNullOrEmptyIsEmpty() { + assertThat(TokenBreaking.softBreakSegments(null)).isEmpty(); + assertThat(TokenBreaking.softBreakSegments("")).isEmpty(); + } + + // ── fitCharacters ─────────────────────────────────────────────────────── + + @Test + void fitCharactersReturnsTheLongestFittingPrefix() { + assertThat(TokenBreaking.fitCharacters("abcdefgh", STYLE, 3.0, UNIT)).isEqualTo(3); + } + + @Test + void fitCharactersReturnsAtLeastOneCharBelowGlyphWidth() { + assertThat(TokenBreaking.fitCharacters("abc", STYLE, 0.4, UNIT)).isEqualTo(1); + } + + // ── splitLongToken (char-level) ───────────────────────────────────────── + + @Test + void splitLongTokenBreaksIntoFittingChunks() { + assertThat(TokenBreaking.splitLongToken("abcdefgh", STYLE, 3.0, UNIT)) + .containsExactly("abc", "def", "gh"); + } + + // ── breakLongToken (seam-aware, char-split fallback) ──────────────────── + + @Test + void breakLongTokenWithoutSeamsMatchesSplitLongToken() { + List viaBreak = TokenBreaking.breakLongToken("abcdefgh", STYLE, 3.0, UNIT); + List viaSplit = TokenBreaking.splitLongToken("abcdefgh", STYLE, 3.0, UNIT); + assertThat(viaBreak).isEqualTo(viaSplit).containsExactly("abc", "def", "gh"); + } + + @Test + void breakLongTokenReturnsTheWholeTokenWhenItAlreadyFits() { + // A no-seam token that fits the budget is returned as a single piece. + assertThat(TokenBreaking.breakLongToken("abc", STYLE, 10.0, UNIT)).containsExactly("abc"); + } + + @Test + void breakLongTokenPacksSegmentsAtSeams() { + // "org." + "junit." fill width 10 exactly; "jupiter" starts a new piece. + assertThat(TokenBreaking.breakLongToken("org.junit.jupiter", STYLE, 10.0, UNIT)) + .containsExactly("org.junit.", "jupiter"); + } + + @Test + void breakLongTokenCharSplitsAnOverWideSegment() { + // "verylongsegment." (16) exceeds width 5, so it char-splits; the tail seeds + // the piece that the following short "x" segment then joins. + assertThat(TokenBreaking.breakLongToken("verylongsegment.x", STYLE, 5.0, UNIT)) + .containsExactly("veryl", "ongse", "gment", ".x"); + } + + @Test + void breakLongTokenEveryPieceFitsAndReconstructsTheInput() { + for (String token : List.of( + "org.junit.jupiter:junit-jupiter:5.10.2", + "https://repo1.maven.org/maven2/", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) { + for (double width : new double[] {1.0, 4.0, 7.0, 12.0}) { + List pieces = TokenBreaking.breakLongToken(token, STYLE, width, UNIT); + assertThat(String.join("", pieces)) + .as("pieces reconstruct the token at width %s", width).isEqualTo(token); + assertThat(pieces).allSatisfy(piece -> + assertThat((double) piece.length()) + .as("each piece fits width %s", width) + .isLessThanOrEqualTo(width + 1e-6)); + } + } + } + + @Test + void breakLongTokenEmptyOrNullReturnsEmpty() { + assertThat(TokenBreaking.breakLongToken("", STYLE, 5.0, UNIT)).isEmpty(); + assertThat(TokenBreaking.breakLongToken(null, STYLE, 5.0, UNIT)).isEmpty(); + } + + /** Deterministic measurement: width == character count * unit; metrics are inert. */ + private record FixedWidthMeasurement(double unit) implements TextMeasurementSystem { + @Override + public ContentSize measure(TextStyle style, String text) { + return new ContentSize(text.length() * unit, unit); + } + + @Override + public LineMetrics lineMetrics(TextStyle style) { + return new LineMetrics(unit, 0.0, 0.0); + } + } +}