fix: truncating chapter titles using UTF-8 safe function (#599)

## Summary

* Truncating chapter titles using utf8 safe functions (Cyrillic titles
were split mid codepoint)
* refactoring of lib/Utf8

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_
This commit is contained in:
Arthur Tazhitdinov 2026-02-01 16:23:48 +05:00 committed by Luke Stein
parent cc2f125809
commit 4bb8a869e7
8 changed files with 45 additions and 40 deletions

View File

@ -415,13 +415,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style);
while (itemWidth > maxWidth && item.length() > 8) {
item.replace(item.length() - 5, 5, "...");
itemWidth = getTextWidth(fontId, item.c_str(), style);
const char* ellipsis = "...";
int textWidth = getTextWidth(fontId, item.c_str(), style);
if (textWidth <= maxWidth) {
// Text fits, return as is
return item;
}
return item;
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
utf8RemoveLastChar(item);
}
return item.empty() ? ellipsis : item + ellipsis;
}
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation

View File

@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
return cp;
}
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}

View File

@ -1,7 +1,11 @@
#pragma once
#include <cstdint>
#include <string>
#define REPLACEMENT_GLYPH 0xFFFD
uint32_t utf8NextCodepoint(const unsigned char** string);
// Remove the last UTF-8 codepoint from a std::string and return the new size.
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);

View File

@ -4,6 +4,7 @@
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <Utf8.h>
#include <Xtc.h>
#include <cstring>
@ -366,7 +367,7 @@ void HomeActivity::render() {
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back());
utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
@ -375,7 +376,7 @@ void HomeActivity::render() {
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i);
utf8RemoveLastChar(i);
// Check if we have room for ellipsis
std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
@ -428,7 +429,7 @@ void HomeActivity::render() {
if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
utf8RemoveLastChar(trimmedAuthor);
}
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
@ -462,14 +463,14 @@ void HomeActivity::render() {
// Trim author if too long (UTF-8 safe)
bool wasTrimmed = false;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true;
}
if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
utf8RemoveLastChar(trimmedAuthor);
}
trimmedAuthor.append("...");
}

View File

@ -584,8 +584,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
titleMarginLeftAdjusted = titleMarginLeft;
}
while (titleWidth > availableTitleSpace && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
if (titleWidth > availableTitleSpace) {
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
}

View File

@ -546,8 +546,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
std::string title = txt->getTitle();
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
if (titleWidth > availableTextWidth) {
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}

View File

@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension);
}
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
// Walk back to find the start of the last UTF-8 character
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}
} // namespace StringUtils

View File

@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension);
// UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, size_t numChars);
} // namespace StringUtils