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:
co-authored by
jpirnay
Julia
parent
34e923d722
commit
c5e861d71c
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,6 +134,7 @@ struct CssStyle {
|
||||
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)
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
Executable
+232
@@ -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.
Reference in New Issue
Block a user