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
+14 -1
View File
@@ -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)
+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;
+94 -3
View File
@@ -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 <TextRotation rotation>
// 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 <TextRotation rotation = TextRotation::None>
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<TextRotation::None>(*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<TextRotation::None>(*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<const uint8_t**>(&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
+232
View File
@@ -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', '''<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>''')
# 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'<item id="{chapter_id}" href="{chapter_id}.xhtml" media-type="application/xhtml+xml"/>')
spine_items.append(f'<itemref idref="{chapter_id}"/>')
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('<item id="style" href="style.css" media-type="text/css"/>')
# content.opf
epub.writestr('OEBPS/content.opf', f'''<?xml version="1.0"?>
<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{title}</dc:title>
<dc:creator>CrossPoint Test Generator</dc:creator>
<dc:language>en</dc:language>
<dc:identifier id="bookid">test-supsub-001</dc:identifier>
</metadata>
<manifest>
{''.join(manifest_items)}
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
{''.join(spine_items)}
</spine>
</package>''')
# toc.ncx
nav_points = []
for i, (chapter_title, _) in enumerate(chapters):
nav_points.append(f'''
<navPoint id="navPoint-{i+1}" playOrder="{i+1}">
<navLabel><text>{chapter_title}</text></navLabel>
<content src="chapter{i}.xhtml"/>
</navPoint>''')
epub.writestr('OEBPS/toc.ncx', f'''<?xml version="1.0"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="test-supsub-001"/>
</head>
<docTitle><text>{title}</text></docTitle>
<navMap>
{''.join(nav_points)}
</navMap>
</ncx>''')
def make_chapter(title, content):
"""Create XHTML chapter content."""
return f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{title}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body>
<h1>{title}</h1>
{content}
</body>
</html>'''
if __name__ == '__main__':
print("Creating subscript/superscript test EPUB...")
chapters = [
("Introduction", make_chapter("Subscript and Superscript Tests", """
<p>This EPUB tests subscript and superscript rendering.</p>
<p>Features tested:</p>
<ul>
<li>Basic superscript (exponents, footnotes)</li>
<li>Basic subscript (chemical formulas, sequences)</li>
<li>Mixed sup and sub in same paragraph</li>
<li>Ordinal numbers</li>
<li>Nested with bold and italic</li>
<li>Long runs of sup/sub text</li>
</ul>
""")),
("Basic Superscript", make_chapter("Basic Superscript", """
<h2>Mathematical Formulas</h2>
<p>E = mc<sup>2</sup> is Einstein's mass-energy equivalence.</p>
<p>The area of a circle is πr<sup>2</sup>.</p>
<p>2<sup>10</sup> = 1024.</p>
<p>x<sup>n</sup> + y<sup>n</sup> = z<sup>n</sup></p>
<h2>Footnote References</h2>
<p>This is a sentence with a footnote<sup>1</sup> reference.</p>
<p>Multiple footnotes<sup>2</sup> can appear<sup>3</sup> in one paragraph.</p>
""")),
("Basic Subscript", make_chapter("Basic Subscript", """
<h2>Chemical Formulas</h2>
<p>Water is H<sub>2</sub>O.</p>
<p>Carbon dioxide is CO<sub>2</sub>.</p>
<p>Glucose: C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>.</p>
<p>Sulfuric acid: H<sub>2</sub>SO<sub>4</sub>.</p>
<h2>Mathematical Sequences</h2>
<p>The sequence a<sub>1</sub>, a<sub>2</sub>, a<sub>3</sub>, ..., a<sub>n</sub>.</p>
<p>Matrix element A<sub>ij</sub> where i is row and j is column.</p>
""")),
("Mixed Sup and Sub", make_chapter("Mixed Superscript and Subscript", """
<h2>Chemistry and Physics</h2>
<p>The pH of water is 7, meaning [H<sub>3</sub>O<sup>+</sup>] = 10<sup>-7</sup> mol/L.</p>
<p>Speed of light: c = 2.998 × 10<sup>8</sup> m/s.</p>
<p>Avogadro's number: 6.022 × 10<sup>23</sup> mol<sup>-1</sup>.</p>
<h2>Complex Formulas</h2>
<p>Isotope notation: <sup>235</sup>U<sub>92</sub> (uranium-235).</p>
<p>Electron configuration: 1s<sup>2</sup> 2s<sup>2</sup> 2p<sup>6</sup>.</p>
""")),
("Ordinals and Dates", make_chapter("Ordinal Numbers", """
<h2>Ordinal Suffixes</h2>
<p>On the 1<sup>st</sup> of January, the 2<sup>nd</sup> quarter begins on the 3<sup>rd</sup> month.</p>
<p>The 4<sup>th</sup> through 20<sup>th</sup> days of the month.</p>
<p>The 21<sup>st</sup>, 22<sup>nd</sup>, 23<sup>rd</sup>, and 24<sup>th</sup> hours.</p>
<h2>Historical Dates</h2>
<p>The 19<sup>th</sup> century saw rapid industrialization.</p>
<p>World War II ended on May 8<sup>th</sup>, 1945.</p>
""")),
("Nested Styles", make_chapter("Nested with Bold and Italic", """
<h2>Bold Superscript</h2>
<p>Bold superscript: x<sup><b>2</b></sup> + y<sup><b>2</b></sup> = r<sup><b>2</b></sup>.</p>
<p>Bold base with superscript: <b>E = mc<sup>2</sup></b>.</p>
<h2>Italic Subscript</h2>
<p>Italic subscript: H<sub><i>n</i></sub> represents the n-th harmonic.</p>
<p>Italic base with subscript: <i>a<sub>1</sub>, a<sub>2</sub>, a<sub>3</sub></i>.</p>
<h2>Combined Styles</h2>
<p><b>Bold text with H<sub>2</sub>O and E = mc<sup>2</sup> inside.</b></p>
<p><i>Italic text with CO<sub>2</sub> and x<sup>n</sup> inside.</i></p>
""")),
("Long Runs", make_chapter("Long Superscript and Subscript Runs", """
<h2>Extended Superscript</h2>
<p>This word<sup>has a rather long superscript attached to it</sup> continuing normally.</p>
<p>Polynomial: x<sup>10</sup> + x<sup>9</sup> + x<sup>8</sup> + x<sup>7</sup> + x<sup>6</sup> + x<sup>5</sup> + x<sup>4</sup> + x<sup>3</sup> + x<sup>2</sup> + x + 1.</p>
<h2>Extended Subscript</h2>
<p>This word<sub>has a rather long subscript attached to it</sub> continuing normally.</p>
<p>Sequence: a<sub>1</sub>, a<sub>2</sub>, a<sub>3</sub>, a<sub>4</sub>, a<sub>5</sub>, a<sub>6</sub>, a<sub>7</sub>, a<sub>8</sub>, a<sub>9</sub>, a<sub>10</sub>.</p>
<h2>Alternating</h2>
<p>Mixed: x<sup>2</sup>y<sub>1</sub> + x<sup>3</sup>y<sub>2</sub> + x<sup>4</sup>y<sub>3</sub> = 0.</p>
""")),
("Edge Cases", make_chapter("Edge Cases and Stress Tests", """
<h2>Empty and Whitespace</h2>
<p>Empty superscript: x<sup></sup>y should show xy.</p>
<p>Whitespace: x<sup> </sup>y should show x y.</p>
<h2>Nested Sup/Sub (Not Standard)</h2>
<p>Nested attempt: x<sup>y<sup>z</sup></sup> (may not render correctly).</p>
<h2>Unicode in Sup/Sub</h2>
<p>Greek letters: α<sup>2</sup> + β<sup>2</sup> = γ<sup>2</sup>.</p>
<p>Symbols: H<sub>2</sub>O → H<sup>+</sup> + OH<sup></sup>.</p>
<h2>Line Breaking</h2>
<p>Long text with superscript at the end of a line to test wrapping behavior when the superscript might need to wrap to the next line<sup>1</sup> and continue here.</p>
""")),
("CSS Style Tests", make_chapter("CSS Style Tests", """
<h2>CSS Classes</h2>
<p>This tests superscript via CSS class (.super): E = mc<span class="super">2</span>.</p>
<p>This tests subscript via CSS class (.sub): Water is H<span class="sub">2</span>O.</p>
<h2>CSS Inline Styles</h2>
<p>This tests superscript via inline style: E = mc<span style="vertical-align: super;">2</span>.</p>
<p>This tests subscript via inline style: Water is H<span style="vertical-align: sub;">2</span>O.</p>
<h2>Comparison Side-by-Side</h2>
<p>Tag `sup`: E = mc<sup>2</sup></p>
<p>Class `super`: E = mc<span class="super">2</span></p>
<p>Inline `super`: E = mc<span style="vertical-align: super;">2</span></p>
<br/>
<p>Tag `sub`: H<sub>2</sub>O</p>
<p>Class `sub`: H<span class="sub">2</span>O</p>
<p>Inline `sub`: H<span style="vertical-align: sub;">2</span>O</p>
<h2>Mixed and Nested in CSS</h2>
<p>CSS Class mixed: [H<span class="sub">3</span>O<span class="super">+</span>] = 10<span class="super">-7</span> mol/L.</p>
<p>CSS Inline mixed: [H<span style="vertical-align: sub;">3</span>O<span style="vertical-align: super;">+</span>] = 10<span style="vertical-align: super;">-7</span> mol/L.</p>
<p><b>Bold text with class H<span class="sub">2</span>O and E = mc<span class="super">2</span> inside.</b></p>
<p><i>Italic text with inline H<span style="vertical-align: sub;">2</span>O and E = mc<span style="vertical-align: super;">2</span> inside.</i></p>
""")),
]
output_file = OUTPUT_DIR / 'test_supsub.epub'
create_epub(output_file, 'Subscript and Superscript Tests', chapters)
print(f"Created: {output_file}")
Binary file not shown.