feat: <sup> and <sub> support (#2131)

## Summary

* **What is the goal of this PR?** Support for `<sup>` and `<sub>` tags.

## Additional Context

This isn't my work, but @jpirnay 's (missing you here, man!). I migrated
his work from
https://github.com/jpirnay/crosspoint-reader/commit/bcd8c32cf26447ccc792cfedd2fdbfce4fee5210
with some micro-optimizations.

Screenshots: 

[Subscript-and-Superscript-Tests_ch2_p1_10pct_55632.bmp](https://github.com/user-attachments/files/28196936/Subscript-and-Superscript-Tests_ch2_p1_10pct_55632.bmp)

[Subscript-and-Superscript-Tests_ch3_p1_21pct_77473.bmp](https://github.com/user-attachments/files/28196937/Subscript-and-Superscript-Tests_ch3_p1_21pct_77473.bmp)


---

### 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? _**< YES >**_

---------

Co-authored-by: jpirnay <jens@pirnay.com>
Co-authored-by: Julia <julia@uxj.io>
This commit is contained in:
Uri Tauber
2026-05-26 12:38:40 -04:00
committed by GitHub
co-authored by jpirnay Julia
parent 34e923d722
commit c5e861d71c
11 changed files with 443 additions and 18 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 5;
constexpr uint8_t BOOK_CACHE_VERSION = 9;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
+16 -4
View File
@@ -18,11 +18,23 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return;
}
const int ascender = renderer.getFontAscenderSize(fontId);
for (size_t i = 0; i < words.size(); i++) {
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0;
// SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside
// drawText, so these offsets are chosen relative to the full-size ascender:
// SUP: raise by 40% of ascender — sits clearly above the cap-height
// SUB: lower by 25% of ascender — descends below baseline without clashing with ascenders below
int wordY = y;
if ((currentStyle & EpdFontFamily::SUP) != 0) {
wordY -= ascender * 2 / 5;
} else if ((currentStyle & EpdFontFamily::SUB) != 0) {
wordY += ascender / 4;
}
if (boundary > 0) {
// Focus split: draw bold prefix, then the regular suffix at a pre-computed x offset.
// The bold prefix is bounded to 9 codepoints by the clamp on targetBoldChars in
@@ -36,18 +48,18 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
memcpy(boldBuf, words[i].c_str(), boldLen);
boldBuf[boldLen] = '\0';
renderer.drawText(fontId, wordX, y, boldBuf, true, boldStyle);
renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle);
const int suffixX = wordX + wordFocusSuffixX[i];
renderer.drawText(fontId, suffixX, y, words[i].c_str() + boldLen, true, currentStyle);
renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle);
} else {
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle);
}
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = words[i];
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
const int underlineY = wordY + ascender + 2;
int startX = wordX;
int underlineWidth = fullWordWidth;
+23 -3
View File
@@ -344,6 +344,15 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
const std::string_view displayValue = stripTrailingImportant(propValueBuf);
style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block;
style.defined.display = 1;
} else if (propNameBuf == "vertical-align") {
const std::string v = normalized(propValueBuf);
if (v == "super") {
style.verticalAlign = CssVerticalAlign::Super;
style.defined.verticalAlign = 1;
} else if (v == "sub") {
style.verticalAlign = CssVerticalAlign::Sub;
style.defined.verticalAlign = 1;
}
}
}
@@ -720,9 +729,10 @@ bool CssParser::saveToCache() const {
writeLength(style.imageHeight);
writeLength(style.imageWidth);
file.write(static_cast<uint8_t>(style.display));
file.write(static_cast<uint8_t>(style.verticalAlign));
// Write defined flags as uint16_t
uint16_t definedBits = 0;
uint32_t definedBits = 0;
if (style.defined.textAlign) definedBits |= 1 << 0;
if (style.defined.fontStyle) definedBits |= 1 << 1;
if (style.defined.fontWeight) definedBits |= 1 << 2;
@@ -739,6 +749,7 @@ bool CssParser::saveToCache() const {
if (style.defined.imageHeight) definedBits |= 1 << 13;
if (style.defined.imageWidth) definedBits |= 1 << 14;
if (style.defined.display) definedBits |= 1 << 15;
if (style.defined.verticalAlign) definedBits |= 1 << 16;
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
}
@@ -789,7 +800,7 @@ bool CssParser::loadFromCache() {
constexpr size_t CSS_LENGTH_FIELD_COUNT = 11;
constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t);
constexpr size_t CSS_FIXED_STYLE_BYTES =
4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t);
5 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint32_t);
// Read each rule
for (uint16_t i = 0; i < ruleCount; ++i) {
@@ -880,8 +891,16 @@ bool CssParser::loadFromCache() {
}
style.display = static_cast<CssDisplay>(displayVal);
// Read verticalAlign value
uint8_t verticalAlignVal;
if (file.read(&verticalAlignVal, 1) != 1) {
rulesBySelector_.clear();
return false;
}
style.verticalAlign = static_cast<CssVerticalAlign>(verticalAlignVal);
// Read defined flags
uint16_t definedBits = 0;
uint32_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear();
return false;
@@ -902,6 +921,7 @@ bool CssParser::loadFromCache() {
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
style.defined.display = (definedBits & 1 << 15) != 0;
style.defined.verticalAlign = (definedBits & 1 << 16) != 0;
rulesBySelector_[selector] = style;
}
+1 -1
View File
@@ -31,7 +31,7 @@
class CssParser {
public:
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 4;
static constexpr uint8_t CSS_CACHE_VERSION = 5;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
~CssParser() = default;
+16 -4
View File
@@ -57,6 +57,9 @@ enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
// Display options - only None and Block are relevant for e-ink rendering
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
// Vertical alignment options for inline elements (e.g. superscript/subscript)
enum class CssVerticalAlign : uint8_t { Baseline = 0, Super = 1, Sub = 2 };
// Bitmask for tracking which properties have been explicitly set
struct CssPropertyFlags {
uint16_t textAlign : 1;
@@ -75,6 +78,7 @@ struct CssPropertyFlags {
uint16_t imageHeight : 1;
uint16_t imageWidth : 1;
uint16_t display : 1;
uint16_t verticalAlign : 1;
CssPropertyFlags()
: textAlign(0),
@@ -92,19 +96,20 @@ struct CssPropertyFlags {
paddingRight(0),
imageHeight(0),
imageWidth(0),
display(0) {}
display(0),
verticalAlign(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
imageWidth || display;
imageWidth || display || verticalAlign;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
imageHeight = imageWidth = display = 0;
imageHeight = imageWidth = display = verticalAlign = 0;
}
};
@@ -128,7 +133,8 @@ struct CssStyle {
CssLength paddingRight; // Padding right
CssLength imageHeight; // Height for img (e.g. 2em) width derived from aspect ratio when only height set
CssLength imageWidth; // Width for img when both or only width set
CssDisplay display = CssDisplay::Block; // display property (Block or None)
CssDisplay display = CssDisplay::Block; // display property (Block or None)
CssVerticalAlign verticalAlign = CssVerticalAlign::Baseline; // vertical-align (super/sub positioning)
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -199,6 +205,10 @@ struct CssStyle {
display = base.display;
defined.display = 1;
}
if (base.hasVerticalAlign()) {
verticalAlign = base.verticalAlign;
defined.verticalAlign = 1;
}
}
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -217,6 +227,7 @@ struct CssStyle {
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
[[nodiscard]] bool hasDisplay() const { return defined.display; }
[[nodiscard]] bool hasVerticalAlign() const { return defined.verticalAlign; }
void reset() {
textAlign = CssTextAlign::Left;
@@ -228,6 +239,7 @@ struct CssStyle {
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
imageHeight = imageWidth = CssLength{};
display = CssDisplay::Block;
verticalAlign = CssVerticalAlign::Baseline;
defined.clearAll();
}
};
@@ -74,6 +74,8 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
effectiveUnderline =
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
effectiveSup = false;
effectiveSub = false;
// Apply inline style stack in order
for (const auto& entry : inlineStyleStack) {
@@ -86,6 +88,14 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
if (entry.hasUnderline) {
effectiveUnderline = entry.underline;
}
if (entry.hasSup) {
effectiveSup = entry.sup;
if (entry.sup) effectiveSub = false;
}
if (entry.hasSub) {
effectiveSub = entry.sub;
if (entry.sub) effectiveSup = false;
}
}
}
@@ -107,6 +117,11 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (isUnderline) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
}
if (effectiveSup) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
} else if (effectiveSub) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUB);
}
// flush the buffer
partWordBuffer[partWordBufferIndex] = '\0';
@@ -786,9 +801,26 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "sup") == 0 || strcmp(name, "sub") == 0) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
StyleStackEntry entry;
entry.depth = self->depth;
if (strcmp(name, "sup") == 0) {
entry.hasSup = true;
entry.sup = true;
} else {
entry.hasSub = true;
entry.sub = true;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
// Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration() ||
cssStyle.hasVerticalAlign()) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
@@ -808,6 +840,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
if (cssStyle.hasVerticalAlign()) {
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
entry.hasSup = true;
entry.sup = true;
} else if (cssStyle.verticalAlign == CssVerticalAlign::Sub) {
entry.hasSub = true;
entry.sub = true;
}
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
}
@@ -61,6 +61,8 @@ class ChapterHtmlSlimParser {
bool hasBold = false, bold = false;
bool hasItalic = false, italic = false;
bool hasUnderline = false, underline = false;
bool hasSup = false, sup = false;
bool hasSub = false, sub = false;
};
std::vector<StyleStackEntry> inlineStyleStack;
std::vector<BlockStyle> blockStyleStack; // accumulated block styles from open ancestor elements
@@ -68,6 +70,8 @@ class ChapterHtmlSlimParser {
bool effectiveBold = false;
bool effectiveItalic = false;
bool effectiveUnderline = false;
bool effectiveSup = false;
bool effectiveSub = false;
int tableDepth = 0;
int tableRowIndex = 0;
int tableColIndex = 0;