diff --git a/src/Modules/Text/Application/Services/TextPrintService.php b/src/Modules/Text/Application/Services/TextPrintService.php
index dfc8a6bcf..a53913f83 100644
--- a/src/Modules/Text/Application/Services/TextPrintService.php
+++ b/src/Modules/Text/Application/Services/TextPrintService.php
@@ -462,7 +462,10 @@ public function getTextItemsForApi(int $textId): array
/**
* Get annotated text items formatted for API response.
*
- * Parses the stored annotation string into structured data.
+ * Parses the stored annotation string into structured data. Romanizations
+ * are resolved for the whole text in one query rather than per term — the
+ * annotated view renders every word at once, so a lookup per item would be
+ * hundreds of round trips on a normal text.
*
* @param int $textId Text ID
*
@@ -486,13 +489,24 @@ public function getAnnotationForApi(int $textId): ?array
if ($items === false) {
return null;
}
- $parsed = [];
+ $rows = [];
foreach ($items as $item) {
$vals = preg_split('/[\t]/u', $item);
- if ($vals === false) {
+ // A row needs at least an order and a term. Splitting the stored
+ // annotation on newlines yields a blank entry for any trailing or
+ // repeated newline, and those would otherwise come back as words
+ // with empty text and render as empty elements.
+ if ($vals === false || count($vals) < 2) {
continue;
}
+ $rows[] = $vals;
+ }
+
+ $romanizations = $this->romanizationsForRows($rows);
+ $parsed = [];
+
+ foreach ($rows as $vals) {
$order = isset($vals[0]) ? (int) $vals[0] : -1;
$text = $vals[1] ?? '';
$wordId = (isset($vals[2]) && ctype_digit($vals[2])) ? (int) $vals[2] : null;
@@ -509,6 +523,7 @@ public function getAnnotationForApi(int $textId): ?array
'text' => $text,
'wordId' => $wordId,
'translation' => $translation,
+ 'romanization' => $wordId === null ? '' : ($romanizations[$wordId] ?? ''),
'isWord' => $order > -1
];
}
@@ -516,6 +531,38 @@ public function getAnnotationForApi(int $textId): ?array
return $parsed;
}
+ /**
+ * Look up the romanization of every word referenced by the parsed rows.
+ *
+ * @param array> $rows Tab-split annotation rows
+ *
+ * @return array Romanization keyed by word ID
+ */
+ private function romanizationsForRows(array $rows): array
+ {
+ $wordIds = [];
+ foreach ($rows as $vals) {
+ if (isset($vals[2]) && ctype_digit($vals[2])) {
+ $wordIds[(int) $vals[2]] = true;
+ }
+ }
+ if ($wordIds === []) {
+ return [];
+ }
+
+ $records = QueryBuilder::table('words')
+ ->select(['WoID', 'WoRomanization'])
+ ->whereIn('WoID', array_keys($wordIds))
+ ->getPrepared();
+
+ $byId = [];
+ foreach ($records as $record) {
+ $byId[(int) $record['WoID']] = (string) ($record['WoRomanization'] ?? '');
+ }
+
+ return $byId;
+ }
+
// ===========================
// VIEW DATA PREPARATION
// ===========================
diff --git a/src/Modules/Text/Http/TextReadController.php b/src/Modules/Text/Http/TextReadController.php
index d700643a4..35d13060e 100644
--- a/src/Modules/Text/Http/TextReadController.php
+++ b/src/Modules/Text/Http/TextReadController.php
@@ -147,13 +147,10 @@ public function display(?int $text = null): ?RedirectResponse
return $this->redirect('/text/edit');
}
- $annotatedText = $this->displayService->getAnnotatedText($textId);
- if (strlen($annotatedText) <= 0) {
- return $this->redirect('/text/edit');
- }
-
- $settings = $this->displayService->getTextDisplaySettings($textId);
- if ($settings === null) {
+ // Still checked here: with no annotation there is nothing for the
+ // client to render, and sending the user to an empty shell is worse
+ // than putting them back on the text list.
+ if (strlen($this->displayService->getAnnotatedText($textId)) <= 0) {
return $this->redirect('/text/edit');
}
@@ -165,9 +162,10 @@ public function display(?int $text = null): ?RedirectResponse
$title = $headerData['title'];
$audio = $headerData['audio'];
$sourceUri = $headerData['sourceUri'];
- $textSize = $settings['textSize'];
- $rtlScript = $settings['rtlScript'];
+ // Prev/next depends on the request's language filter, query and tag
+ // selection, which live in the session rather than behind an endpoint,
+ // so this chrome stays server-rendered.
$textLinks = (new TextNavigationService())->getPreviousAndNextTextLinks(
$textId,
'display_impr_text.php?text=',
@@ -178,12 +176,10 @@ public function display(?int $text = null): ?RedirectResponse
$mediaPlayerHtml = (new \Lwt\Modules\Admin\Application\Services\MediaService())
->getMediaPlayerHtml($audio);
- $annotations = $this->displayService->parseAnnotations($annotatedText);
-
$this->displayService->saveCurrentText($textId);
PageLayoutHelper::renderPageStartNobody('Display');
- include self::MODULE_VIEWS . '/display_main.php';
+ include self::MODULE_VIEWS . '/display_alpine.php';
PageLayoutHelper::renderPageEnd();
return null;
diff --git a/src/Modules/Text/Views/display_alpine.php b/src/Modules/Text/Views/display_alpine.php
new file mode 100644
index 000000000..3d5526629
--- /dev/null
+++ b/src/Modules/Text/Views/display_alpine.php
@@ -0,0 +1,140 @@
+
+ * @license Unlicense
+ * @link https://hugofara.github.io/lwt/developer/api
+ * @since 3.6.1
+ *
+ * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file
+ *
+ * @var int $textId
+ * @var string $title
+ * @var string|null $sourceUri
+ * @var string $textLinks
+ * @var string $mediaPlayerHtml
+ */
+
+namespace Lwt\Views\Text;
+
+use Lwt\Shared\UI\Helpers\ConfigIsland;
+use Lwt\Shared\UI\Helpers\IconHelper;
+
+// Type-safe variable extraction from controller context
+assert(is_int($textId));
+/**
+ * @var string $titleTyped
+*/
+$titleTyped = $title;
+/**
+ * @var string|null $sourceUriTyped
+*/
+$sourceUriTyped = $sourceUri;
+/**
+ * @var string $textLinksTyped
+*/
+$textLinksTyped = $textLinks;
+assert(is_string($mediaPlayerHtml));
+
+?>
+
+
+
+
+
+
+ __('text.display.toggle_text_on'),
+ 'alt' => __('text.display.toggle_text_on'),
+ 'class' => 'click'
+ ]);
+ ?>
+
+
+ __('text.display.toggle_text_off'),
+ 'alt' => __('text.display.toggle_text_off'),
+ 'class' => 'click'
+ ]);
+ ?>
+
+
+ __('text.display.toggle_annotation_on'),
+ 'alt' => __('text.display.toggle_annotation_on'),
+ 'class' => 'click'
+ ]);
+ ?>
+
+
+ __('text.display.toggle_annotation_off'),
+ 'alt' => __('text.display.toggle_annotation_off'),
+ 'class' => 'click'
+ ]);
+ ?>
+
+
+
+ ';
+ $textSourceLabel = __('text.display.text_source');
+ echo IconHelper::render('link', ['title' => $textSourceLabel, 'alt' => $textSourceLabel]);
+ echo '';
+ }
+ echo $textLinksTyped;
+ ?>
+
+
+
+ $closeLabel, 'alt' => $closeLabel, 'class' => 'click']
+ );
+ ?>
+
+
+
+
+
+
+
+
= __e('text.read.loading') ?>
+
+
+
+
+
+
+ $textId]); ?>
diff --git a/src/Modules/Text/Views/display_header.php b/src/Modules/Text/Views/display_header.php
deleted file mode 100644
index 4ae860550..000000000
--- a/src/Modules/Text/Views/display_header.php
+++ /dev/null
@@ -1,121 +0,0 @@
-
- * @license Unlicense
- * @link https://hugofara.github.io/lwt/developer/api
- * @since 3.0.0
- *
- * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file
- *
- * @var string $title
- * @var int $textId
- * @var string|null $sourceUri
- * @var string $textLinks
- * @var string $mediaPlayerHtml
- */
-
-namespace Lwt\Views\Text;
-
-// Variables injected from text_display_header.php:
-// $title, $audio, $sourceUri, $textLinks
-
-use Lwt\Shared\UI\Helpers\IconHelper;
-
-// Type-safe variable extraction from controller context
-/**
- * @var string $titleTyped
-*/
-$titleTyped = $title;
-/**
- * @var string|null $sourceUriTyped
-*/
-$sourceUriTyped = $sourceUri;
-/**
- * @var string $textLinksTyped
-*/
-$textLinksTyped = $textLinks;
-/**
- * @var string $mediaPlayerHtml
-*/
-assert(is_string($mediaPlayerHtml));
-?>
-
-
-
-
- __('text.display.toggle_text_on'),
- 'alt' => __('text.display.toggle_text_on'),
- 'class' => 'click'
- ]);
- ?>
-
-
- __('text.display.toggle_text_off'),
- 'alt' => __('text.display.toggle_text_off'),
- 'class' => 'click'
- ]);
- ?>
-
-
- __('text.display.toggle_annotation_on'),
- 'alt' => __('text.display.toggle_annotation_on'),
- 'class' => 'click'
- ]);
- ?>
-
-
- __('text.display.toggle_annotation_off'),
- 'alt' => __('text.display.toggle_annotation_off'),
- 'class' => 'click'
- ]);
- ?>
-
-
-
- ';
- $textSourceLabel = __('text.display.text_source');
- echo IconHelper::render('link', ['title' => $textSourceLabel, 'alt' => $textSourceLabel]);
- echo '';
- }
- echo $textLinksTyped;
- ?>
-
-
-
- $closeLabel, 'alt' => $closeLabel, 'class' => 'click']
- );
- ?>
-
-
-
-
diff --git a/src/Modules/Text/Views/display_main.php b/src/Modules/Text/Views/display_main.php
deleted file mode 100644
index 596a4d938..000000000
--- a/src/Modules/Text/Views/display_main.php
+++ /dev/null
@@ -1,41 +0,0 @@
-
- * @license Unlicense
- * @link https://hugofara.github.io/lwt/developer/api
- * @since 3.0.0
- *
- * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file
- */
-
-declare(strict_types=1);
-
-namespace Lwt\Views\Text;
-
-?>
-
diff --git a/src/Modules/Text/Views/display_text.php b/src/Modules/Text/Views/display_text.php
deleted file mode 100644
index 93834494e..000000000
--- a/src/Modules/Text/Views/display_text.php
+++ /dev/null
@@ -1,80 +0,0 @@
-
- * @license Unlicense
- * @link https://hugofara.github.io/lwt/developer/api
- * @since 3.0.0
- *
- * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file
- *
- * @var array $annotations
- * @var int $textSize
- * @var bool $rtlScript
- */
-
-namespace Lwt\Views\Text;
-
-// Type-safe variable extraction from controller context
-assert(is_array($annotations));
-/**
- * @var array
-*/
-$annotationsTyped = $annotations;
-/**
- * @var int
-*/
-$textSizeTyped = $textSize;
-/**
- * @var bool
-*/
-$rtlScriptTyped = $rtlScript;
-?>
->
-
- -1) {
- // Regular word with annotation
- $romTitle = ($item['rom'] ?? '') === ''
- ? ''
- : ' title="' . \htmlspecialchars($item['rom'] ?? '', ENT_QUOTES, 'UTF-8') . '"';
- $textEscaped = \htmlspecialchars($item['text'] ?? '', ENT_QUOTES, 'UTF-8');
- $transEscaped = \htmlspecialchars($item['trans'] ?? '', ENT_QUOTES, 'UTF-8');
- echo '
-
- ' .
- $textEscaped .
- '
-
-
- ';
- } else {
- // Punctuation or paragraph marker
- echo str_replace(
- "¶",
- '
-
',
- " " . \htmlspecialchars($item['text'] ?? '', ENT_QUOTES, 'UTF-8')
- );
- }
-}
-?>
-
-
diff --git a/src/frontend/js/modules/text/api/texts_api.ts b/src/frontend/js/modules/text/api/texts_api.ts
index 2b27cf6d5..1953270e7 100644
--- a/src/frontend/js/modules/text/api/texts_api.ts
+++ b/src/frontend/js/modules/text/api/texts_api.ts
@@ -292,6 +292,8 @@ export interface AnnotationItem {
text: string;
wordId: number | null;
translation: string;
+ /** Reading of the term, shown as a tooltip; empty when the word has none. */
+ romanization: string;
isWord: boolean;
}
diff --git a/src/frontend/js/modules/text/index.ts b/src/frontend/js/modules/text/index.ts
index cfbb0a746..1921585c0 100644
--- a/src/frontend/js/modules/text/index.ts
+++ b/src/frontend/js/modules/text/index.ts
@@ -43,4 +43,5 @@ import './pages/file_import';
import './pages/text_check_display';
import './pages/text_check_form';
import './pages/text_print_app';
+import './pages/text_display_app';
import './pages/text_suggestions';
diff --git a/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts b/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts
index c01d24535..0fa5aeae6 100644
--- a/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts
+++ b/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts
@@ -1,7 +1,7 @@
/**
* Annotation Toggle - Show/hide translations and annotations in text display.
*
- * Extracted from Views/Text/display_header.php
+ * Drives the toggle buttons in the annotated display and print views.
*
* @license unlicense
* @since 3.0.0
diff --git a/src/frontend/js/modules/text/pages/text_display_app.ts b/src/frontend/js/modules/text/pages/text_display_app.ts
new file mode 100644
index 000000000..13778e1b1
--- /dev/null
+++ b/src/frontend/js/modules/text/pages/text_display_app.ts
@@ -0,0 +1,174 @@
+/**
+ * Text Display App - renders the annotated ("improved") text view.
+ *
+ * Replaces the server-rendered display_text.php: the page now ships a shell
+ * and fetches the annotation from /api/v1/texts/{id}/annotation.
+ *
+ * The markup produced here is deliberately identical to what PHP emitted —
+ * `.anntermruby` for the term and `.anntransruby2` for its translation, inside
+ * a — because annotation_toggle.ts drives the show/hide buttons by
+ * querying those classes at click time.
+ *
+ * @license Unlicense
+ * @since 3.6.1
+ */
+
+import Alpine from 'alpinejs';
+import { readPageConfig } from '@shared/utils/page_config';
+import { escapeHtml } from '@shared/utils/html_utils';
+import { TextsApi, type AnnotationItem } from '@modules/text/api/texts_api';
+
+/**
+ * Paragraph marker in the stored annotation.
+ */
+const PARAGRAPH_MARKER = '¶';
+
+/**
+ * Page configuration emitted by display_alpine.php.
+ */
+interface TextDisplayConfig {
+ textId: number;
+}
+
+/**
+ * Alpine component state for the annotated display page.
+ */
+export interface TextDisplayData {
+ loading: boolean;
+ error: string;
+ textId: number;
+ init(): Promise;
+ isReady(): boolean;
+ render(items: AnnotationItem[], textSize: number, rtlScript: boolean): void;
+}
+
+/**
+ * Build the opening tag of a paragraph at the configured text size.
+ *
+ * @param textSize Percentage font size from the language settings
+ * @param leading line-height for this paragraph
+ *
+ * @returns The opening tag
+ */
+function paragraphTag(textSize: number, leading: string): string {
+ return `
`;
+}
+
+/**
+ * Render one annotated term as ruby markup.
+ *
+ * @param item Annotation item for a word
+ *
+ * @returns HTML for the term and its translation
+ */
+function renderTerm(item: AnnotationItem): string {
+ const rom = item.romanization ?? '';
+ const romTitle = rom === '' ? '' : ` title="${escapeHtml(rom)}"`;
+ return (
+ ' ' +
+ '' +
+ `` +
+ escapeHtml(item.text) +
+ '' +
+ '' +
+ '' +
+ ' '
+ );
+}
+
+/**
+ * Render a non-word item: punctuation, whitespace, or a paragraph break.
+ *
+ * A paragraph marker closes the current
and opens the next one, which is
+ * how the stored annotation encodes line structure.
+ *
+ * @param item Annotation item that is not a word
+ * @param textSize Percentage font size from the language settings
+ *
+ * @returns HTML for the item
+ */
+function renderNonWord(item: AnnotationItem, textSize: number): string {
+ const escaped = ' ' + escapeHtml(item.text);
+ return escaped.split(PARAGRAPH_MARKER).join(
+ '
' + paragraphTag(textSize, '1.3')
+ );
+}
+
+/**
+ * Create the annotated display component.
+ *
+ * @returns Alpine component data
+ */
+export function textDisplayData(): TextDisplayData {
+ const config = readPageConfig('text-display-config', {
+ textId: 0
+ });
+
+ return {
+ loading: true,
+ error: '',
+ textId: config.textId,
+
+ async init(): Promise {
+ if (this.textId === 0) {
+ this.loading = false;
+ this.error = 'No text selected.';
+ return;
+ }
+
+ const response = await TextsApi.getAnnotation(this.textId);
+ const items = response.data?.items;
+
+ if (!response.data || !items) {
+ this.loading = false;
+ this.error = response.error || 'Could not load the annotated text.';
+ return;
+ }
+
+ this.render(
+ items,
+ response.data.config.textSize,
+ response.data.config.rtlScript
+ );
+ this.loading = false;
+ },
+
+ isReady(): boolean {
+ return !this.loading && this.error === '';
+ },
+
+ render(items: AnnotationItem[], textSize: number, rtlScript: boolean): void {
+ const container = document.getElementById('print');
+ if (!container) {
+ return;
+ }
+
+ if (rtlScript) {
+ container.setAttribute('dir', 'rtl');
+ } else {
+ container.removeAttribute('dir');
+ }
+
+ const parts = [paragraphTag(textSize, '1.35')];
+ for (const item of items) {
+ parts.push(
+ item.isWord ? renderTerm(item) : renderNonWord(item, textSize)
+ );
+ }
+ parts.push('
');
+
+ container.innerHTML = parts.join('');
+ }
+ };
+}
+
+/**
+ * Register the component with Alpine.
+ */
+export function initTextDisplayAlpine(): void {
+ Alpine.data('textDisplay', textDisplayData);
+}
+
+initTextDisplayAlpine();
diff --git a/tests/backend/Services/TextPrintServiceTest.php b/tests/backend/Services/TextPrintServiceTest.php
index 397f5fcba..2dfccfb9b 100644
--- a/tests/backend/Services/TextPrintServiceTest.php
+++ b/tests/backend/Services/TextPrintServiceTest.php
@@ -485,6 +485,150 @@ public function testPrepareAnnotatedPrintDataReturnsNullForNonExistentText(): vo
$this->assertNull($data);
}
+ // ===== getAnnotationForApi() tests =====
+
+ /**
+ * Link a word to the test text so the annotation rebuild picks it up.
+ *
+ * getAnnotationForApi() regenerates the annotation from word_occurrences
+ * rather than trusting the stored string, so a fixture that only writes
+ * TxAnnotatedText produces items with no word behind them.
+ *
+ * @param string $term Surface form
+ * @param string $romanization Reading to store on the word
+ *
+ * @return array{0: int, 1: int} Word ID and sentence ID, for cleanup
+ */
+ private function linkWordToTestText(string $term, string $romanization): array
+ {
+ Connection::query(
+ "INSERT INTO sentences (SeLgID, SeTxID, SeOrder, SeFirstPos, SeText) VALUES ("
+ . self::$testLangId . ", " . self::$testTextId . ", 1, 1, '" . $term . ".')"
+ );
+ $sentenceId = (int) Connection::fetchValue("SELECT LAST_INSERT_ID() AS value");
+
+ Connection::query(
+ "INSERT INTO words (WoLgID, WoText, WoTextLC, WoStatus, WoTranslation, "
+ . "WoRomanization, WoSentence, WoStatusChanged) VALUES ("
+ . self::$testLangId . ", '" . $term . "', '" . $term . "', 1, 'meaning', '"
+ . $romanization . "', '', NOW())"
+ );
+ $wordId = (int) Connection::fetchValue("SELECT LAST_INSERT_ID() AS value");
+
+ Connection::query(
+ "INSERT INTO word_occurrences "
+ . "(Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text) VALUES ("
+ . $wordId . ", " . self::$testLangId . ", " . self::$testTextId . ", "
+ . $sentenceId . ", 1, 1, '" . $term . "')"
+ );
+ Connection::query(
+ "INSERT INTO word_occurrences "
+ . "(Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text) VALUES ("
+ . "NULL, " . self::$testLangId . ", " . self::$testTextId . ", "
+ . $sentenceId . ", 2, 0, '.')"
+ );
+
+ return [$wordId, $sentenceId];
+ }
+
+ /**
+ * Remove the fixture rows created by linkWordToTestText().
+ *
+ * @param int $wordId Word to drop
+ * @param int $sentenceId Sentence to drop
+ *
+ * @return void
+ */
+ private function unlinkWordFromTestText(int $wordId, int $sentenceId): void
+ {
+ Connection::query("DELETE FROM word_occurrences WHERE Ti2SeID = " . $sentenceId);
+ Connection::query("DELETE FROM words WHERE WoID = " . $wordId);
+ Connection::query("DELETE FROM sentences WHERE SeID = " . $sentenceId);
+ }
+
+ public function testGetAnnotationForApiCarriesRomanization(): void
+ {
+ if (!self::$dbConnected) {
+ $this->markTestSkipped('Database connection required');
+ }
+
+ [$wordId, $sentenceId] = $this->linkWordToTestText('annrom', 'ro-man');
+
+ try {
+ $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId);
+
+ $this->assertIsArray($items);
+ $this->assertNotEmpty($items);
+
+ $words = array_values(array_filter($items, fn(array $i): bool => $i['isWord']));
+ $this->assertNotEmpty($words, 'Expected the linked word in the annotation');
+ $this->assertSame($wordId, $words[0]['wordId']);
+ $this->assertSame('ro-man', $words[0]['romanization']);
+
+ // Items with no word behind them carry an empty reading, never null,
+ // so the client can render them without a presence check.
+ $nonWords = array_values(array_filter($items, fn(array $i): bool => !$i['isWord']));
+ $this->assertNotEmpty($nonWords);
+ $this->assertSame('', $nonWords[0]['romanization']);
+ } finally {
+ $this->unlinkWordFromTestText($wordId, $sentenceId);
+ }
+ }
+
+ public function testGetAnnotationForApiReturnsEmptyRomanizationWhenWordHasNone(): void
+ {
+ if (!self::$dbConnected) {
+ $this->markTestSkipped('Database connection required');
+ }
+
+ [$wordId, $sentenceId] = $this->linkWordToTestText('annnorom', '');
+
+ try {
+ $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId);
+
+ $this->assertIsArray($items);
+ $words = array_values(array_filter($items, fn(array $i): bool => $i['isWord']));
+ $this->assertNotEmpty($words);
+ $this->assertSame('', $words[0]['romanization']);
+ } finally {
+ $this->unlinkWordFromTestText($wordId, $sentenceId);
+ }
+ }
+
+ public function testGetAnnotationForApiSkipsBlankAnnotationRows(): void
+ {
+ if (!self::$dbConnected) {
+ $this->markTestSkipped('Database connection required');
+ }
+
+ [$wordId, $sentenceId] = $this->linkWordToTestText('annblank', '');
+
+ try {
+ $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId);
+
+ $this->assertIsArray($items);
+ // A trailing or repeated newline in the stored annotation splits to a
+ // blank row. Reported as a word it would render an empty , which
+ // is what the server-rendered display view used to filter out.
+ foreach ($items as $item) {
+ if ($item['isWord']) {
+ $this->assertNotSame('', $item['text'], 'Blank row reported as a word');
+ }
+ }
+ } finally {
+ $this->unlinkWordFromTestText($wordId, $sentenceId);
+ }
+ }
+
+ public function testGetAnnotationForApiReturnsNullWithoutAnnotation(): void
+ {
+ if (!self::$dbConnected) {
+ $this->markTestSkipped('Database connection required');
+ }
+
+ $this->assertNull((new TextPrintService())->getAnnotationForApi(999999));
+ }
+
// ===== Constants tests =====
public function testAnnotationConstants(): void
diff --git a/tests/frontend/texts/text_display.test.ts b/tests/frontend/texts/text_display.test.ts
new file mode 100644
index 000000000..34b67f6f8
--- /dev/null
+++ b/tests/frontend/texts/text_display.test.ts
@@ -0,0 +1,191 @@
+/**
+ * Tests for text_display_app.ts — the annotated ("improved") text view.
+ *
+ * The markup asserted here is the contract the server-rendered
+ * display_text.php used to produce: annotation_toggle.ts finds terms and
+ * translations by the .anntermruby / .anntransruby2 classes at click time, so
+ * a rename here silently breaks the show/hide buttons.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+const getAnnotation = vi.fn();
+
+vi.mock('alpinejs', () => ({
+ default: { data: vi.fn() }
+}));
+
+vi.mock('../../../src/frontend/js/modules/text/api/texts_api', () => ({
+ TextsApi: {
+ get getAnnotation() {
+ return getAnnotation;
+ }
+ }
+}));
+
+import { textDisplayData } from '../../../src/frontend/js/modules/text/pages/text_display_app';
+
+/** Build an annotation item with sane defaults. */
+function item(overrides: Record = {}) {
+ return {
+ order: 0,
+ text: 'Hallo',
+ wordId: 1,
+ translation: 'hello',
+ romanization: '',
+ isWord: true,
+ ...overrides
+ };
+}
+
+/** Respond to getAnnotation with these items and config. */
+function respond(items: unknown[], config: Record = {}) {
+ getAnnotation.mockResolvedValue({
+ data: {
+ items,
+ config: {
+ textId: 7,
+ title: 'T',
+ sourceUri: '',
+ audioUri: '',
+ langId: 1,
+ textSize: 150,
+ rtlScript: false,
+ hasAnnotation: true,
+ ttsClass: null,
+ ...config
+ }
+ }
+ });
+}
+
+/** Install the page shell the view renders, with the config island. */
+function setupDom(textId: number = 7): void {
+ document.body.innerHTML =
+ '' +
+ ``;
+}
+
+describe('textDisplayData', () => {
+ beforeEach(() => {
+ getAnnotation.mockReset();
+ setupDom();
+ });
+
+ it('reads the text id from the config island', () => {
+ setupDom(42);
+ expect(textDisplayData().textId).toBe(42);
+ });
+
+ it('renders a word as ruby with the toggle classes intact', async () => {
+ respond([item({ text: 'Hallo', translation: 'hello' })]);
+ const app = textDisplayData();
+
+ await app.init();
+
+ const html = document.getElementById('print')!.innerHTML;
+ expect(html).toContain('anntermruby');
+ expect(html).toContain('anntransruby2');
+ expect(html).toContain('Hallo');
+ expect(html).toContain('hello');
+ expect(app.loading).toBe(false);
+ expect(app.error).toBe('');
+ });
+
+ it('puts the romanization in a title attribute, and omits it when empty', async () => {
+ respond([item({ romanization: 'ha-lo' })]);
+ await textDisplayData().init();
+ expect(document.getElementById('print')!.innerHTML).toContain('title="ha-lo"');
+
+ setupDom();
+ respond([item({ romanization: '' })]);
+ await textDisplayData().init();
+ expect(document.getElementById('print')!.innerHTML).not.toContain('title=');
+ });
+
+ it('escapes markup in terms, translations and romanizations', async () => {
+ respond([
+ item({
+ text: 'x',
+ translation: '"quoted" & y',
+ romanization: '