$wordCounts Word tokens by lowercase form
+ *
+ * @return void
+ */
+ private static function echoParseWarning(array $tokens, array $wordCounts): void
+ {
+ $characters = 0;
+ foreach ($tokens as $t) {
+ $characters += \mb_strlen($t->text, 'UTF-8');
+ }
+
+ $verdict = ParseCoverage::assess(array_sum($wordCounts), $characters);
+ if (!ParseCoverage::isWarning($verdict)) {
+ return;
+ }
+
+ $headline = $verdict === ParseCoverage::NO_WORDS
+ ? __('text.parse_warning.no_words')
+ : __('text.parse_warning.almost_no_words');
+
+ echo ''
+ . \htmlspecialchars($headline, ENT_QUOTES, 'UTF-8')
+ . ' '
+ . \htmlspecialchars(__('text.parse_warning.check_language'), ENT_QUOTES, 'UTF-8')
+ . '
';
+ }
+
/**
* Echo the multi-word statistics JSON for the check-text preview.
*
diff --git a/src/frontend/js/modules/text/api/texts_api.ts b/src/frontend/js/modules/text/api/texts_api.ts
index 62bed08fd..591c84d5b 100644
--- a/src/frontend/js/modules/text/api/texts_api.ts
+++ b/src/frontend/js/modules/text/api/texts_api.ts
@@ -38,6 +38,21 @@ export interface TextReadingConfig {
annTextSize: number;
// Reader layout settings
readerWidth: number;
+ /**
+ * Set when the text parsed into (almost) no words, which happens when the
+ * language's word characters do not match its script. Null when fine.
+ */
+ parseWarning: ParseWarning | null;
+}
+
+/**
+ * Why a text came out with nothing the reader can click, and where to fix it.
+ */
+export interface ParseWarning {
+ headline: string;
+ detail: string;
+ linkLabel: string;
+ linkHref: string;
}
/**
diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts
index b697de258..f714dc04f 100644
--- a/src/frontend/js/modules/text/components/text_reader.ts
+++ b/src/frontend/js/modules/text/components/text_reader.ts
@@ -10,7 +10,8 @@
import Alpine from 'alpinejs';
import type { WordStoreState } from '@modules/vocabulary/stores/word_store';
-import { renderText, updateWordStatusInDOM, type RenderSettings } from '../pages/reading/text_renderer';
+import { renderText, updateWordStatusInDOM, renderParseWarning, type RenderSettings }
+ from '../pages/reading/text_renderer';
import { setupMultiWordSelection } from '../pages/reading/text_multiword_selection';
import { TextsApi } from '@modules/text/api/texts_api';
import { SettingsApi } from '@modules/admin/api/settings_api';
@@ -168,7 +169,7 @@ export function textReaderData(): TextReaderData {
const settings = this.getRenderSettings();
const html = renderText(this.store.words, settings);
- container.innerHTML = html;
+ container.innerHTML = renderParseWarning(this.store.parseWarning) + html;
// Apply RTL styling if needed
if (this.store.rightToLeft) {
diff --git a/src/frontend/js/modules/text/pages/reading/text_renderer.ts b/src/frontend/js/modules/text/pages/reading/text_renderer.ts
index 4976cd258..dde9b86b4 100644
--- a/src/frontend/js/modules/text/pages/reading/text_renderer.ts
+++ b/src/frontend/js/modules/text/pages/reading/text_renderer.ts
@@ -9,7 +9,7 @@
*/
import type { WordData } from '@modules/vocabulary/stores/word_store';
-import type { MultiWordRef } from '@modules/text/api/texts_api';
+import type { MultiWordRef, ParseWarning } from '@modules/text/api/texts_api';
import { parseInlineMarkdown } from '@shared/utils/inline_markdown';
/**
@@ -271,6 +271,41 @@ function escapeAttr(text: string): string {
* Words and adjacent punctuation are wrapped together to prevent line breaks.
* Multi-word expressions are wrapped in mw-group spans with connected underlines.
*/
+/**
+ * Banner shown above a text that parsed into (almost) nothing.
+ *
+ * Such a text still displays every character, so without this it reads as an
+ * ordinary text that has inexplicably stopped responding to clicks.
+ *
+ * Built through the DOM rather than by concatenation: escapeHtml() leaves
+ * quotes alone, which is fine for text but would let an attribute value break
+ * out of the attribute it sits in.
+ *
+ * @param warning What the server found wrong, or null when the parse was fine
+ * @returns HTML for the banner, or an empty string
+ */
+export function renderParseWarning(warning: ParseWarning | null): string {
+ if (!warning) return '';
+
+ const box = document.createElement('div');
+ box.className = 'notification is-warning is-light';
+
+ const message = document.createElement('p');
+ const headline = document.createElement('strong');
+ headline.textContent = warning.headline;
+ message.append(headline, ` ${warning.detail}`);
+
+ const linkLine = document.createElement('p');
+ const link = document.createElement('a');
+ // setAttribute, not .href, so the path stays relative in the markup
+ link.setAttribute('href', warning.linkHref);
+ link.textContent = warning.linkLabel;
+ linkLine.append(link);
+
+ box.append(message, linkLine);
+ return box.outerHTML;
+}
+
export function renderText(words: WordData[], settings: RenderSettings): string {
if (words.length === 0) return '';
diff --git a/src/frontend/js/modules/vocabulary/stores/word_store.ts b/src/frontend/js/modules/vocabulary/stores/word_store.ts
index 1814c84e0..c4dc340c7 100644
--- a/src/frontend/js/modules/vocabulary/stores/word_store.ts
+++ b/src/frontend/js/modules/vocabulary/stores/word_store.ts
@@ -10,7 +10,7 @@
import Alpine from 'alpinejs';
import { TermsApi } from '@modules/vocabulary/api/terms_api';
-import { TextsApi, type TextWord, type TextReadingConfig, type DictLinks, type MultiWordRef } from '@modules/text/api/texts_api';
+import { TextsApi, type TextWord, type TextReadingConfig, type DictLinks, type MultiWordRef, type ParseWarning } from '@modules/text/api/texts_api';
import { injectTextStyles, generateParagraphStyles } from '@modules/text/pages/reading/text_styles';
import { renderText, updateWordStatusInDOM, updateWordTranslationInDOM, type RenderSettings } from '@modules/text/pages/reading/text_renderer';
@@ -57,6 +57,7 @@ export interface WordStoreState {
rightToLeft: boolean;
textSize: number;
removeSpaces: boolean;
+ parseWarning: ParseWarning | null;
dictLinks: DictLinks;
// Annotation/display settings
@@ -126,6 +127,7 @@ function createWordStore(): WordStoreState {
rightToLeft: false,
textSize: 100,
removeSpaces: false,
+ parseWarning: null,
dictLinks: {
dict1: '',
dict2: '',
@@ -231,6 +233,7 @@ function createWordStore(): WordStoreState {
this.rightToLeft = config.rightToLeft;
this.textSize = config.textSize;
this.removeSpaces = config.removeSpaces ?? false;
+ this.parseWarning = config.parseWarning ?? null;
this.dictLinks = config.dictLinks;
// Annotation/display settings
diff --git a/tests/backend/Modules/Text/Domain/ParseCoverageTest.php b/tests/backend/Modules/Text/Domain/ParseCoverageTest.php
new file mode 100644
index 000000000..dc7555810
--- /dev/null
+++ b/tests/backend/Modules/Text/Domain/ParseCoverageTest.php
@@ -0,0 +1,91 @@
+
+ */
+
+declare(strict_types=1);
+
+namespace Lwt\Tests\Modules\Text\Domain;
+
+use Lwt\Modules\Text\Domain\ParseCoverage;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * When a parse counts as having produced nothing learnable (#278).
+ */
+#[CoversClass(ParseCoverage::class)]
+class ParseCoverageTest extends TestCase
+{
+ public function testNoWordsAtAllIsTheReportedCase(): void
+ {
+ // The reported bug: a Chinese text on a Latin language parses into
+ // a readable page where nothing can be clicked
+ $this->assertSame(ParseCoverage::NO_WORDS, ParseCoverage::assess(0, 420));
+ }
+
+ public function testAFewStrayTokensInALongTextStillCounts(): void
+ {
+ // A digit or a Latin fragment inside a non-Latin text matches, the
+ // rest does not; a plain zero test would miss this
+ $this->assertSame(ParseCoverage::ALMOST_NO_WORDS, ParseCoverage::assess(3, 400));
+ }
+
+ /**
+ * @param int $words Words the parse produced
+ * @param int $characters Characters in the text
+ */
+ #[DataProvider('healthyParses')]
+ public function testARealLanguageIsNeverWarnedAbout(int $words, int $characters): void
+ {
+ $this->assertSame(ParseCoverage::OK, ParseCoverage::assess($words, $characters));
+ }
+
+ /**
+ * Word-to-character ratios that real languages actually produce.
+ *
+ * @return array
+ */
+ public static function healthyParses(): array
+ {
+ return [
+ 'English prose, ~1 word per 6 characters' => [70, 420],
+ 'German compounds, ~1 per 12' => [35, 420],
+ 'character-split Chinese, ~1 per 1' => [400, 420],
+ 'jieba-segmented Chinese, ~1 per 2' => [200, 420],
+ 'a sparse but plausible text, 1 per 20' => [21, 420],
+ ];
+ }
+
+ public function testAShortTextIsNotJudgedOnDensity(): void
+ {
+ // One word in a title or a caption is not a broken parse
+ $this->assertSame(ParseCoverage::OK, ParseCoverage::assess(1, 199));
+ }
+
+ public function testAShortTextWithNoWordsIsStillReported(): void
+ {
+ $this->assertSame(ParseCoverage::NO_WORDS, ParseCoverage::assess(0, 20));
+ }
+
+ public function testAnEmptyTextIsNotAParsingProblem(): void
+ {
+ $this->assertSame(ParseCoverage::OK, ParseCoverage::assess(0, 0));
+ $this->assertSame(ParseCoverage::OK, ParseCoverage::assess(0, -1));
+ }
+
+ public function testOnlyTheOkVerdictIsSilent(): void
+ {
+ $this->assertFalse(ParseCoverage::isWarning(ParseCoverage::OK));
+ $this->assertTrue(ParseCoverage::isWarning(ParseCoverage::NO_WORDS));
+ $this->assertTrue(ParseCoverage::isWarning(ParseCoverage::ALMOST_NO_WORDS));
+ }
+}
diff --git a/tests/frontend/reading/parse_warning.test.ts b/tests/frontend/reading/parse_warning.test.ts
new file mode 100644
index 000000000..7e17a59e3
--- /dev/null
+++ b/tests/frontend/reading/parse_warning.test.ts
@@ -0,0 +1,46 @@
+/**
+ * Tests for the banner shown when a text parses into (almost) nothing (#278).
+ */
+import { describe, it, expect } from 'vitest';
+import { renderParseWarning } from '../../../src/frontend/js/modules/text/pages/reading/text_renderer';
+
+const warning = {
+ headline: 'None of this text could be turned into words.',
+ detail: "The language's Word Characters setting does not match this text.",
+ linkLabel: 'Check the language settings',
+ linkHref: '/languages/7/edit'
+};
+
+describe('renderParseWarning', () => {
+ it('renders nothing when the parse was fine', () => {
+ expect(renderParseWarning(null)).toBe('');
+ });
+
+ it('shows what went wrong and where to fix it', () => {
+ const html = renderParseWarning(warning);
+
+ expect(html).toContain('notification is-warning');
+ expect(html).toContain('None of this text could be turned into words.');
+ expect(html).toContain('does not match this text');
+ expect(html).toContain('href="/languages/7/edit"');
+ expect(html).toContain('Check the language settings');
+ });
+
+ it('escapes the server-supplied text', () => {
+ const html = renderParseWarning({
+ ...warning,
+ headline: '',
+ linkHref: '/languages/1/edit"onmouseover="alert(1)'
+ });
+
+ expect(html).not.toContain('