From d8bf461f2f5baa321bf603dba7e94180997a9852 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 00:01:13 -0700 Subject: [PATCH] fix(library): cut display titles at 80 UTF-16 units, never inside a pair The server counted code points while the Library's JavaScript counts UTF-16 units, so a first line of emoji kept 80 emoji on cards but showed 40 in the Library. Both now cut at 80 UTF-16 units and drop a character that would straddle the limit instead of leaving half a surrogate pair. --- database/library.py | 23 +++++++++++++++++++---- tests/js/workspace-library-title.test.js | 15 +++++++++++++++ tests/test_library.py | 13 +++++++++++++ web/js/workspace/library.mjs | 9 ++++++++- 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 tests/js/workspace-library-title.test.js diff --git a/database/library.py b/database/library.py index a54ffd2..ce562df 100644 --- a/database/library.py +++ b/database/library.py @@ -22,14 +22,29 @@ _JS_TRIM = "".join(map(chr, _TRIM_CODES)) DISPLAY_TITLE_LIMIT = 80 +def _utf16_units(text: str) -> int: + """JavaScript ``length``: characters beyond the BMP count as two units.""" + return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF) + + +def _utf16_prefix(text: str, units: int) -> str: + """The longest prefix within ``units`` UTF-16 units, never splitting a pair.""" + used = 0 + for index, ch in enumerate(text): + used += 2 if ord(ch) > 0xFFFF else 1 + if used > units: + return text[:index] + return text + + def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]: """Return ``(title, source)``: a stored title, else the text's first line. ``source`` is ``"stored"`` for the first non-blank stored title (blank as in JavaScript's trim, like the Library view), ``"text"`` for the first line, and None when both are empty. The first line is trimmed and, past - DISPLAY_TITLE_LIMIT characters, cut with an ellipsis like the Library's - ``title()``. + DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's + ``libraryTitle()`` (never inside a surrogate pair). """ for title in stored_titles: if isinstance(title, str) and title.strip(_JS_TRIM): @@ -37,8 +52,8 @@ def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]: if not isinstance(text, str): return None, None first = text.split("\n")[0].strip(_JS_TRIM) - if len(first) > DISPLAY_TITLE_LIMIT: - first = first[:DISPLAY_TITLE_LIMIT] + "\u2026" + if _utf16_units(first) > DISPLAY_TITLE_LIMIT: + first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026" return (first, "text") if first else (None, None) diff --git a/tests/js/workspace-library-title.test.js b/tests/js/workspace-library-title.test.js new file mode 100644 index 0000000..e8cadf2 --- /dev/null +++ b/tests/js/workspace-library-title.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({})); + +test('library title counts UTF-16 units and never splits a surrogate pair', () => { + assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported'); + assert.equal(libraryTitle(' short line \nsecond'), 'short line'); + assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80)); + assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`); + // 80 emoji are 160 UTF-16 units: 40 fit. + assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`); + // 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole. + assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`); +}); diff --git a/tests/test_library.py b/tests/test_library.py index 96fbf54..088706c 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1693,3 +1693,16 @@ class TestLibrary(unittest.TestCase): ) with self.assertRaisesRegex(ValueError, "does not belong to this positive"): self.db.list_library_images(positive, pair_id=foreign, archive="all") + + +class DisplayTitleUnits(unittest.TestCase): + """The first-line title is cut like the Library's JavaScript title().""" + + def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self): + from database.library import display_title + + self.assertEqual(display_title((), " short line \nsecond"), "short line") + self.assertEqual(display_title((), "a" * 80), "a" * 80) + self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…") + self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…") + self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…") diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 389df2c..fc62309 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -28,7 +28,14 @@ function el(tag,text,className) { const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; } function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} -function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;} +// Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches). +export function libraryTitle(text) { + const first=text.split('\n')[0].trim(); + if (first.length<=80) return first; + const cut=first.slice(0,80); + return `${/[\uD800-\uDBFF]$/.test(cut)?cut.slice(0,-1):cut}…`; +} +const title=libraryTitle; /** The full stored title; the first-line fallback only when it is blank. */ function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);} function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}