From c5e861d71c14c90419676049ffd63d7e8586fc23 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Tue, 26 May 2026 19:38:40 +0300 Subject: [PATCH] feat: `` and `` support (#2131) ## Summary * **What is the goal of this PR?** Support for `` and `` 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 Co-authored-by: Julia --- lib/EpdFont/EpdFontFamily.h | 15 +- lib/Epub/Epub/BookMetadataCache.cpp | 2 +- lib/Epub/Epub/blocks/TextBlock.cpp | 20 +- lib/Epub/Epub/css/CssParser.cpp | 26 +- lib/Epub/Epub/css/CssParser.h | 2 +- lib/Epub/Epub/css/CssStyle.h | 20 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 43 +++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 4 + lib/GfxRenderer/GfxRenderer.cpp | 97 +++++++- scripts/generate_supsub_test_epub.py | 232 ++++++++++++++++++ test/epubs/test_supsub.epub | Bin 0 -> 6445 bytes 11 files changed, 443 insertions(+), 18 deletions(-) create mode 100755 scripts/generate_supsub_test_epub.py create mode 100644 test/epubs/test_supsub.epub diff --git a/lib/EpdFont/EpdFontFamily.h b/lib/EpdFont/EpdFontFamily.h index b08540d8..2712bdbc 100644 --- a/lib/EpdFont/EpdFontFamily.h +++ b/lib/EpdFont/EpdFontFamily.h @@ -3,7 +3,20 @@ class EpdFontFamily { public: - enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 }; + // Bitmask of text style flags carried per-word through layout and serialized in page cache. + // Bits 0-1 select the font variant (BOLD/ITALIC); bits 2-5 are decoration/positioning overlays + // applied at render time without changing the underlying font. getFont() ignores all bits + // above bit 1 so decorations compose freely with bold/italic (e.g. BOLD | UNDERLINE | SUP). + enum Style : uint8_t { + REGULAR = 0, + BOLD = 1, + ITALIC = 2, + BOLD_ITALIC = 3, + UNDERLINE = 4, // drawn as a line below baseline by TextBlock::render() + STRIKETHROUGH = 8, // drawn as a line through midline by TextBlock::render() + SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender + SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender + }; explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr, const EpdFont* boldItalic = nullptr) diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 66d4e8d4..d2eab4f8 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -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"; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 0f132d52..0735b3c2 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -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({static_cast(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; diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 38be4bcf..b4a9f4f1 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -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(style.display)); + file.write(static_cast(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(&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(displayVal); + // Read verticalAlign value + uint8_t verticalAlignVal; + if (file.read(&verticalAlignVal, 1) != 1) { + rulesBySelector_.clear(); + return false; + } + style.verticalAlign = static_cast(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; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 004a232d..77795e6d 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -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; diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 7b129eaf..9af9fa6c 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -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(); } }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 072dc0c6..62a5409d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -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(fontStyle | EpdFontFamily::UNDERLINE); } + if (effectiveSup) { + fontStyle = static_cast(fontStyle | EpdFontFamily::SUP); + } else if (effectiveSub) { + fontStyle = static_cast(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(); } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 989184ea..f11c6256 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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 inlineStyleStack; std::vector 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; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index d6859cc3..0a0b2b03 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -131,7 +131,81 @@ enum class TextRotation { None, Rotated90CW }; // Shared glyph rendering logic for normal and rotated text. // Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. -template +// Render a glyph at 50% scale. Used for SUP/SUB style bits. +// +// Each destination pixel represents a 2x2 source block. Drawing when that block +// contains ink preserves thin strokes that nearest-neighbor sampling can skip. +// +// The advance width is also halved in drawText() so layout reserves exactly the right +// horizontal space for the scaled glyph. +static void renderCharScaled(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode, + const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY, + const bool pixelState, const EpdFontFamily::Style style) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); + if (!glyph) return; + + const EpdFontData* fontData = fontFamily.getData(style); + const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph); + if (!bitmap) return; + + const int srcW = glyph->width; + const int srcH = glyph->height; + const int dstW = (srcW + 1) / 2; // ceil so odd-width glyphs aren't clipped + const int dstH = (srcH + 1) / 2; + // Scale the glyph bearing by the same factor so the scaled glyph sits at the correct + // pixel offset from the (already-shifted) cursor position. + const int baseX = cursorX + glyph->left / 2; + const int baseY = cursorY - glyph->top / 2; + + if (fontData->is2Bit) { + // 2-bit packed format: 4 pixels per byte, MSB first, 2 bits per pixel. + // raw value: 0=white, 1=light-gray, 2=dark-gray, 3=black. + for (int dstY = 0; dstY < dstH; dstY++) { + const int srcY = dstY * 2; + for (int dstX = 0; dstX < dstW; dstX++) { + const int srcX = dstX * 2; + uint8_t coverage = 0; + uint8_t maxRaw = 0; + for (int sampleY = 0; sampleY < 2 && srcY + sampleY < srcH; sampleY++) { + for (int sampleX = 0; sampleX < 2 && srcX + sampleX < srcW; sampleX++) { + const int pos = (srcY + sampleY) * srcW + srcX + sampleX; + const uint8_t byte = bitmap[pos >> 2]; + const uint8_t raw = (byte >> ((3 - (pos & 3)) * 2)) & 0x3; + coverage += raw; + if (raw > maxRaw) maxRaw = raw; + } + } + if (maxRaw >= 2 || coverage >= 2) { + renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState); + } + } + } + } else { + // 1-bit packed format: 8 pixels per byte, MSB first. + for (int dstY = 0; dstY < dstH; dstY++) { + const int srcY = dstY * 2; + for (int dstX = 0; dstX < dstW; dstX++) { + const int srcX = dstX * 2; + bool hasInk = false; + for (int sampleY = 0; sampleY < 2 && srcY + sampleY < srcH; sampleY++) { + for (int sampleX = 0; sampleX < 2 && srcX + sampleX < srcW; sampleX++) { + const int pos = (srcY + sampleY) * srcW + srcX + sampleX; + const uint8_t byte = bitmap[pos >> 3]; + const uint8_t bit = 7 - (pos & 7); + if ((byte >> bit) & 1) { + hasInk = true; + } + } + } + if (hasInk) { + renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState); + } + } + } + } +} + +template static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode, const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY, const bool pixelState, const EpdFontFamily::Style style) { @@ -352,7 +426,19 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha lastBaseTop = glyph ? glyph->top : 0; prevAdvanceFP = glyph ? glyph->advanceX : 0; // 12.4 fixed-point - renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); + const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0; + if (isSupSub) { + // Halve the advance so the cursor advances by the same amount the scaled glyph + // actually occupies, keeping spacing correct without needing a separate smaller font. + prevAdvanceFP = (prevAdvanceFP + 1) / 2; + } + + if (isSupSub) { + // yPos already carries the vertical offset applied by TextBlock::render(). + renderCharScaled(*this, renderMode, font, cp, lastBaseX, yPos, black, style); + } else { + renderCharImpl(*this, renderMode, font, cp, lastBaseX, yPos, black, style); + } prevCp = cp; } } @@ -1298,9 +1384,11 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami auto sdIt = sdCardFonts_.find(fontId); if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { int32_t widthFP = 0; + const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0; const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style); while (uint32_t cp = utf8NextCodepoint(reinterpret_cast(&text))) { - widthFP += sdIt->second->getAdvance(cp, styleIdx); + int32_t advFP = sdIt->second->getAdvance(cp, styleIdx); + widthFP += isSupSub ? (advFP + 1) / 2 : advFP; } return fp4::toPixel(widthFP); } @@ -1331,6 +1419,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami const EpdGlyph* glyph = font.getGlyph(cp, style); prevAdvanceFP = glyph ? glyph->advanceX : 0; + if ((style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { + prevAdvanceFP = (prevAdvanceFP + 1) / 2; + } prevCp = cp; } widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance diff --git a/scripts/generate_supsub_test_epub.py b/scripts/generate_supsub_test_epub.py new file mode 100755 index 00000000..b1e38ff4 --- /dev/null +++ b/scripts/generate_supsub_test_epub.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +Generate test EPUB for subscript and superscript rendering verification. +""" + +import zipfile +from pathlib import Path + +OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +def create_epub(filename, title, chapters): + """Create an EPUB file with given chapters.""" + with zipfile.ZipFile(filename, 'w', zipfile.ZIP_DEFLATED) as epub: + # mimetype (uncompressed, first file) + epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED) + + # META-INF/container.xml + epub.writestr('META-INF/container.xml', ''' + + + + +''') + + # Build spine and manifest + manifest_items = [] + spine_items = [] + for i, (chapter_title, content) in enumerate(chapters): + chapter_id = f'chapter{i}' + manifest_items.append(f'') + spine_items.append(f'') + epub.writestr(f'OEBPS/{chapter_id}.xhtml', content) + + # Write stylesheet + epub.writestr('OEBPS/style.css', '''.super { + vertical-align: super; +} +.sub { + vertical-align: sub; +} +''') + manifest_items.append('') + + # content.opf + epub.writestr('OEBPS/content.opf', f''' + + + {title} + CrossPoint Test Generator + en + test-supsub-001 + + + {''.join(manifest_items)} + + + + {''.join(spine_items)} + +''') + + # toc.ncx + nav_points = [] + for i, (chapter_title, _) in enumerate(chapters): + nav_points.append(f''' + + {chapter_title} + + ''') + + epub.writestr('OEBPS/toc.ncx', f''' + + + + + {title} + + {''.join(nav_points)} + +''') + +def make_chapter(title, content): + """Create XHTML chapter content.""" + return f''' + + + + {title} + + + +

{title}

+ {content} + +''' + +if __name__ == '__main__': + print("Creating subscript/superscript test EPUB...") + + chapters = [ + ("Introduction", make_chapter("Subscript and Superscript Tests", """ +

This EPUB tests subscript and superscript rendering.

+

Features tested:

+
    +
  • Basic superscript (exponents, footnotes)
  • +
  • Basic subscript (chemical formulas, sequences)
  • +
  • Mixed sup and sub in same paragraph
  • +
  • Ordinal numbers
  • +
  • Nested with bold and italic
  • +
  • Long runs of sup/sub text
  • +
+""")), + + ("Basic Superscript", make_chapter("Basic Superscript", """ +

Mathematical Formulas

+

E = mc2 is Einstein's mass-energy equivalence.

+

The area of a circle is πr2.

+

210 = 1024.

+

xn + yn = zn

+ +

Footnote References

+

This is a sentence with a footnote1 reference.

+

Multiple footnotes2 can appear3 in one paragraph.

+""")), + + ("Basic Subscript", make_chapter("Basic Subscript", """ +

Chemical Formulas

+

Water is H2O.

+

Carbon dioxide is CO2.

+

Glucose: C6H12O6.

+

Sulfuric acid: H2SO4.

+ +

Mathematical Sequences

+

The sequence a1, a2, a3, ..., an.

+

Matrix element Aij where i is row and j is column.

+""")), + + ("Mixed Sup and Sub", make_chapter("Mixed Superscript and Subscript", """ +

Chemistry and Physics

+

The pH of water is 7, meaning [H3O+] = 10-7 mol/L.

+

Speed of light: c = 2.998 × 108 m/s.

+

Avogadro's number: 6.022 × 1023 mol-1.

+ +

Complex Formulas

+

Isotope notation: 235U92 (uranium-235).

+

Electron configuration: 1s2 2s2 2p6.

+""")), + + ("Ordinals and Dates", make_chapter("Ordinal Numbers", """ +

Ordinal Suffixes

+

On the 1st of January, the 2nd quarter begins on the 3rd month.

+

The 4th through 20th days of the month.

+

The 21st, 22nd, 23rd, and 24th hours.

+ +

Historical Dates

+

The 19th century saw rapid industrialization.

+

World War II ended on May 8th, 1945.

+""")), + + ("Nested Styles", make_chapter("Nested with Bold and Italic", """ +

Bold Superscript

+

Bold superscript: x2 + y2 = r2.

+

Bold base with superscript: E = mc2.

+ +

Italic Subscript

+

Italic subscript: Hn represents the n-th harmonic.

+

Italic base with subscript: a1, a2, a3.

+ +

Combined Styles

+

Bold text with H2O and E = mc2 inside.

+

Italic text with CO2 and xn inside.

+""")), + + ("Long Runs", make_chapter("Long Superscript and Subscript Runs", """ +

Extended Superscript

+

This wordhas a rather long superscript attached to it continuing normally.

+

Polynomial: x10 + x9 + x8 + x7 + x6 + x5 + x4 + x3 + x2 + x + 1.

+ +

Extended Subscript

+

This wordhas a rather long subscript attached to it continuing normally.

+

Sequence: a1, a2, a3, a4, a5, a6, a7, a8, a9, a10.

+ +

Alternating

+

Mixed: x2y1 + x3y2 + x4y3 = 0.

+""")), + + ("Edge Cases", make_chapter("Edge Cases and Stress Tests", """ +

Empty and Whitespace

+

Empty superscript: xy should show xy.

+

Whitespace: x y should show x y.

+ +

Nested Sup/Sub (Not Standard)

+

Nested attempt: xyz (may not render correctly).

+ +

Unicode in Sup/Sub

+

Greek letters: α2 + β2 = γ2.

+

Symbols: H2O → H+ + OH.

+ +

Line Breaking

+

Long text with superscript at the end of a line to test wrapping behavior when the superscript might need to wrap to the next line1 and continue here.

+""")), + + ("CSS Style Tests", make_chapter("CSS Style Tests", """ +

CSS Classes

+

This tests superscript via CSS class (.super): E = mc2.

+

This tests subscript via CSS class (.sub): Water is H2O.

+ +

CSS Inline Styles

+

This tests superscript via inline style: E = mc2.

+

This tests subscript via inline style: Water is H2O.

+ +

Comparison Side-by-Side

+

Tag `sup`: E = mc2

+

Class `super`: E = mc2

+

Inline `super`: E = mc2

+
+

Tag `sub`: H2O

+

Class `sub`: H2O

+

Inline `sub`: H2O

+ +

Mixed and Nested in CSS

+

CSS Class mixed: [H3O+] = 10-7 mol/L.

+

CSS Inline mixed: [H3O+] = 10-7 mol/L.

+

Bold text with class H2O and E = mc2 inside.

+

Italic text with inline H2O and E = mc2 inside.

+""")), + ] + + output_file = OUTPUT_DIR / 'test_supsub.epub' + create_epub(output_file, 'Subscript and Superscript Tests', chapters) + print(f"Created: {output_file}") diff --git a/test/epubs/test_supsub.epub b/test/epubs/test_supsub.epub new file mode 100644 index 0000000000000000000000000000000000000000..d3a987823c42a44effbe2c3bc52fad2f8483e929 GIT binary patch literal 6445 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 VRM^x&K){22{9wENIO2cq{sSg^>Vg0O literal 0 HcmV?d00001