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 00000000..d3a98782 Binary files /dev/null and b/test/epubs/test_supsub.epub differ