potential fix for chronicles

This commit is contained in:
Stefan Hardegger
2026-08-21 15:24:38 +02:00
parent 23034e6762
commit dc86673429
3 changed files with 246 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import org.hibernate.annotations.UpdateTimestamp;
import org.jsoup.Jsoup;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.storycove.util.TextSanitizer;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -171,8 +172,8 @@ public class Story {
}
public void setContentHtml(String contentHtml) {
this.contentHtml = contentHtml;
this.setContentPlain(Jsoup.parse(contentHtml).text());
this.contentHtml = TextSanitizer.sanitize(contentHtml);
this.setContentPlain(Jsoup.parse(this.contentHtml).text());
updateWordCount();
}

View File

@@ -0,0 +1,105 @@
package com.storycove.util;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.TextNode;
/**
* Normalizes the text nodes of story HTML so that invisible Unicode cruft (commonly left behind by
* EPUB/Calibre conversions) can never reach persisted story content. Only text node content is touched;
* tag structure and attributes pass through unchanged.
*/
public final class TextSanitizer {
private static final int MIN_COLLAPSIBLE_RUN = 3;
private TextSanitizer() {
}
/**
* Strips Unicode "Cf" (format) characters and collapses runs of 3+ whitespace-like characters
* down to a single space, within text nodes only.
*/
public static String sanitize(String html) {
if (html == null || html.trim().isEmpty()) {
return html;
}
Document doc = Jsoup.parseBodyFragment(html);
doc.outputSettings().prettyPrint(false);
doc.body().traverse((node, depth) -> {
// Skip text nodes that are pure structural whitespace (indentation/formatting
// between tags, e.g. " ", "\n "): isBlank() only matches ' ', '\t', '\n', '\r',
// '\f', so any node containing real prose, Cf chars, or NBSP spam still gets processed.
if (node instanceof TextNode textNode && !textNode.isBlank()) {
String original = textNode.getWholeText();
String normalized = normalizeText(original);
if (!normalized.equals(original)) {
textNode.text(normalized);
}
}
});
return doc.body().html();
}
static String normalizeText(String text) {
String withoutFormatChars = stripFormatCharacters(text);
return collapseLongWhitespaceRuns(withoutFormatChars);
}
private static String stripFormatCharacters(String text) {
StringBuilder result = new StringBuilder(text.length());
int i = 0;
while (i < text.length()) {
int codePoint = text.codePointAt(i);
int charCount = Character.charCount(codePoint);
if (Character.getType(codePoint) != Character.FORMAT) {
result.appendCodePoint(codePoint);
}
i += charCount;
}
return result.toString();
}
private static String collapseLongWhitespaceRuns(String text) {
StringBuilder result = new StringBuilder(text.length());
int len = text.length();
int i = 0;
while (i < len) {
int codePoint = text.codePointAt(i);
if (isSpaceLike(codePoint)) {
int runStart = i;
int runLength = 0;
int j = i;
while (j < len) {
int cp = text.codePointAt(j);
if (!isSpaceLike(cp)) {
break;
}
runLength++;
j += Character.charCount(cp);
}
if (runLength >= MIN_COLLAPSIBLE_RUN) {
result.append(' ');
} else {
result.append(text, runStart, j);
}
i = j;
} else {
result.appendCodePoint(codePoint);
i += Character.charCount(codePoint);
}
}
return result.toString();
}
/**
* Mirrors Python's str.isspace(): Unicode space separators (Zs/Zl/Zp, which includes NBSP)
* plus the ASCII/control whitespace characters (tab, newline, etc.).
*/
private static boolean isSpaceLike(int codePoint) {
return Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint);
}
}

View File

@@ -0,0 +1,138 @@
package com.storycove.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("TextSanitizer Tests")
class TextSanitizerTest {
private static final String ZWSP = ""; // zero width space (Cf)
private static final String WORD_JOINER = ""; // Cf
private static final String BOM = ""; // zero width no-break space / BOM (Cf)
private static final String NBSP = " "; // non-breaking space (Zs, not Cf)
@Test
@DisplayName("Should strip a large run of zero-width spaces embedded mid-sentence")
void shouldStripLargeRunOfZeroWidthSpacesMidSentence() {
String garbageRun = ZWSP.repeat(5000);
String html = "<p>The hero drew" + garbageRun + "his sword and charged.</p>";
String sanitized = TextSanitizer.sanitize(html);
assertFalse(sanitized.contains(ZWSP), "Zero-width spaces should be fully stripped");
assertEquals("<p>The hero drewhis sword and charged.</p>", sanitized);
}
@Test
@DisplayName("Should strip mixed Cf characters (word joiner, BOM) anywhere in text")
void shouldStripMixedFormatCharacters() {
String html = "<p>Hello" + BOM + " " + WORD_JOINER + "world.</p>";
String sanitized = TextSanitizer.sanitize(html);
assertFalse(sanitized.contains(BOM));
assertFalse(sanitized.contains(WORD_JOINER));
assertEquals("<p>Hello world.</p>", sanitized);
}
@Test
@DisplayName("Should collapse a long run of regular spaces used as a formatting hack")
void shouldCollapseLongRunOfRegularSpaces() {
String html = "<p>He paused" + " ".repeat(200) + "then spoke.</p>";
String sanitized = TextSanitizer.sanitize(html);
assertEquals("<p>He paused then spoke.</p>", sanitized);
}
@Test
@DisplayName("Should collapse a long run of NBSP characters down to a single space")
void shouldCollapseLongRunOfNbsp() {
String html = "<p>Wait" + NBSP.repeat(500) + "for it.</p>";
String sanitized = TextSanitizer.sanitize(html);
assertEquals("<p>Wait for it.</p>", sanitized);
}
@Test
@DisplayName("Should collapse a mixed run of tabs, NBSP, and regular spaces")
void shouldCollapseMixedWhitespaceRun() {
String html = "<p>Stop" + " \t" + NBSP + " \t" + NBSP + "go.</p>";
String sanitized = TextSanitizer.sanitize(html);
assertEquals("<p>Stop go.</p>", sanitized);
}
@Test
@DisplayName("Should leave a single space untouched")
void shouldLeaveSingleSpaceUntouched() {
String html = "<p>One two three.</p>";
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should leave a run of exactly two whitespace characters untouched")
void shouldLeaveTwoWhitespaceCharsUntouched() {
String html = "<p>One two.</p>"; // two regular spaces
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should not mangle accented / non-ASCII characters")
void shouldPreserveAccentedCharacters() {
String html = "<p>Café, naïve, über, 日本語, 😀 stays intact.</p>";
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should preserve paragraph structure expressed via a double newline")
void shouldPreserveDoubleNewlineParagraphBreaks() {
String html = "<p>First paragraph.\n\nSecond paragraph.</p>";
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should preserve paragraph structure expressed via <p> tags")
void shouldPreserveParagraphTags() {
String html = "<p>First paragraph.</p><p>Second paragraph.</p>";
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should not alter whitespace used purely for HTML indentation between tags")
void shouldNotCollapseInterTagIndentation() {
// The "\n " nodes between <div>/<p> tags are pure structural indentation, not prose,
// and must be left completely untouched even though they are 3+ char whitespace runs.
String html = "<div>\n <p>Once upon a time.</p>\n <p>The end.</p>\n</div>";
String sanitized = TextSanitizer.sanitize(html);
assertEquals(html, sanitized);
}
@Test
@DisplayName("Control case: realistic story content is unaffected end-to-end")
void controlCaseRealisticStoryIsUnaffected() {
String html = "<p>The old café on the corner of Rue de Bellévue had seen better days, "
+ "but Anaïs still loved it.</p>"
+ "<p>\"We'll meet at dawn,\" she whispered, \"just like we always do.\"</p>";
assertEquals(html, TextSanitizer.sanitize(html));
}
@Test
@DisplayName("Should handle null and blank input gracefully")
void shouldHandleNullAndBlankInput() {
assertNull(TextSanitizer.sanitize(null));
assertEquals("", TextSanitizer.sanitize(""));
}
}