diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt index f5653a632f..c54fcb2915 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt @@ -84,17 +84,20 @@ data class HighlightQuote( val trail = trimTrail(context.substring(endExclusive)) val quote = context.substring(start, endExclusive) - // Nothing to trim on either side: the original context is short enough to show whole. - if (!lead.trimmed && !trail.trimmed) { - return HighlightQuote(context, start until endExclusive) - } - val prefix = if (lead.trimmed) "$ELLIPSIS " else "" val suffix = if (trail.trimmed) " $ELLIPSIS" else "" - val text = prefix + lead.text + quote + trail.text + suffix - val markStart = prefix.length + lead.text.length - return HighlightQuote(text, markStart until (markStart + quote.length)) + // Drop any blank lines sitting at the very edges of the context — with the far text + // trimmed (or even when it isn't) they would otherwise render as empty space above or + // below the quote. Only the outer edges are touched; the whitespace framing the quote + // itself is preserved. + val raw = prefix + lead.text + quote + trail.text + suffix + val leadingBlank = raw.length - raw.trimStart().length + val text = raw.trim() + + val markStart = (prefix.length + lead.text.length - leadingBlank).coerceAtLeast(0) + val markEnd = (markStart + quote.length).coerceAtMost(text.length) + return HighlightQuote(text, markStart until markEnd) } private class Side( @@ -138,9 +141,13 @@ data class HighlightQuote( highlight: String, prefix: String?, ): Int? { + // The common case — no prefix to disambiguate with — needs only the first match, so + // don't scan the whole context enumerating every occurrence. + if (prefix.isNullOrBlank()) return context.indexOf(highlight).takeIf { it >= 0 } + val occurrences = occurrencesOf(context, highlight) if (occurrences.isEmpty()) return null - if (occurrences.size == 1 || prefix.isNullOrBlank()) return occurrences.first() + if (occurrences.size == 1) return occurrences.first() val tail = prefix.trimEnd() return occurrences.firstOrNull { context.substring(0, it).trimEnd().endsWith(tail) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt index 8f2858d989..076d7d9c48 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt @@ -114,6 +114,17 @@ class HighlightQuoteTest { assertTrue(quote.text.length < context.length / 2, "expected windowing, got ${quote.text.length} of ${context.length}") } + @Test + fun dropsBlankLinesAtTheEdgesOfAShortContext() { + // A context whose paragraph boundaries left blank lines at its very start and end would + // otherwise render as empty space above and below the quote. + val quote = HighlightQuote.of("Forward Secrecy", "\n\nForward Secrecy is nice.\n\n") + + assertEquals("Forward Secrecy is nice.", quote.text) + assertEquals(0 until 15, quote.marked) + assertEquals("Forward Secrecy", quote.text.substring(quote.marked!!)) + } + @Test fun trimmingSnapsToWholeWordsSoNoWordIsCutInHalf() { val lead = "alpha bravo charlie delta echo foxtrot ".repeat(20) // long, space-separated