Merge pull request #222 from jpirnay/feat-supsub

feat: Proper support for sup / sub
This commit is contained in:
jpirnay
2026-05-15 21:00:25 +02:00
committed by GitHub
7 changed files with 177 additions and 4 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, STRIKETHROUGH = 8 };
// 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
@@ -11,7 +11,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 8;
constexpr uint8_t BOOK_CACHE_VERSION = 9;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
+12 -1
View File
@@ -12,10 +12,21 @@ 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];
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
// 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;
}
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle);
const std::string& w = words[i];
const bool hasEmSpacePrefix = w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 &&
@@ -205,6 +205,8 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
effectiveStrikethrough =
currentCssStyle.hasTextDecoration() && (static_cast<uint8_t>(currentCssStyle.textDecoration) &
static_cast<uint8_t>(CssTextDecoration::LineThrough)) != 0;
effectiveSup = false;
effectiveSub = false;
// Apply inline style stack in order
for (const auto& entry : inlineStyleStack) {
@@ -220,6 +222,14 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
if (entry.hasStrikethrough) {
effectiveStrikethrough = entry.strikethrough;
}
if (entry.hasSup) {
effectiveSup = entry.sup;
if (entry.sup) effectiveSub = false;
}
if (entry.hasSub) {
effectiveSub = entry.sub;
if (entry.sub) effectiveSup = false;
}
}
}
@@ -280,6 +290,11 @@ bool ChapterHtmlSlimParser::flushPartWordBuffer() {
if (isStrikethrough) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::STRIKETHROUGH);
}
if (effectiveSup) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
} else if (effectiveSub) {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUB);
}
// flush the buffer — route to table cell text when inside a <td>/<th>
partWordBuffer[partWordBufferIndex] = '\0';
@@ -1185,6 +1200,22 @@ 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) {
if (!self->flushPartWordBuffer()) return;
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()) {
@@ -66,6 +66,8 @@ class ChapterHtmlSlimParser final : public Print {
bool hasItalic = false, italic = false;
bool hasUnderline = false, underline = false;
bool hasStrikethrough = false, strikethrough = false;
bool hasSup = false, sup = false;
bool hasSub = false, sub = false;
};
std::vector<StyleStackEntry> inlineStyleStack;
CssStyle currentCssStyle;
@@ -73,6 +75,8 @@ class ChapterHtmlSlimParser final : public Print {
bool effectiveItalic = false;
bool effectiveUnderline = false;
bool effectiveStrikethrough = false;
bool effectiveSup = false;
bool effectiveSub = false;
// Buffered table model — populated while inside <table>, emitted on </table>
struct BufferedTableCell {
std::unique_ptr<ParsedText> text;
+77
View File
@@ -842,6 +842,68 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
}
// Render a glyph at 50% scale via nearest-neighbor sampling. Used for SUP/SUB style bits.
//
// Nearest-neighbor is chosen deliberately: at 50% every source pixel maps cleanly to one
// destination pixel (srcX = dstX*2, srcY = dstY*2), so there is no blending and no new
// gray levels are introduced — important for 1-bit BW rendering.
//
// For 2-bit (anti-aliased) fonts only raw values >= 2 (dark-gray and black) are drawn.
// Dropping the light-gray level keeps small glyphs crisp rather than muddy.
//
// 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;
const int pos = srcY * srcW + srcX;
const uint8_t byte = bitmap[pos >> 2];
const uint8_t raw = (byte >> ((3 - (pos & 3)) * 2)) & 0x3;
if (raw >= 2) { // threshold: skip light-gray, draw dark-gray and black
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;
const int pos = srcY * srcW + srcX;
const uint8_t byte = bitmap[pos >> 3];
const uint8_t bit = 7 - (pos & 7);
if ((byte >> bit) & 1) {
renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState);
}
}
}
}
}
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
// efficient as possible.
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
@@ -960,9 +1022,21 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
lastBaseAdvanceFP = glyph->advanceX;
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.
lastBaseAdvanceFP = (lastBaseAdvanceFP + 1) / 2;
}
prevAdvanceFP = lastBaseAdvanceFP;
if (isSupSub) {
// yPos already carries the vertical offset applied by TextBlock::render().
renderCharScaled(*this, renderModeSnapshot, font, cp, lastBaseX, yPos, black, style);
} else {
renderCharImpl<TextRotation::None>(*this, renderModeSnapshot, font, cp, lastBaseX, yPos, black, style);
}
prevCp = cp;
}
}
@@ -2017,6 +2091,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
continue;
}
prevAdvanceFP = glyph->advanceX;
if ((style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
prevAdvanceFP = (prevAdvanceFP + 1) / 2;
}
prevCp = cp;
}
widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance
+37
View File
@@ -1015,6 +1015,7 @@ def main():
<li>pre element: leading/trailing blank lines</li>
<li>pre with inline code element</li>
<li>horizontal rules between paragraphs</li>
<li>superscript and subscript rendering</li>
</ul>
""",
),
@@ -1085,6 +1086,42 @@ greet("World");</code></pre>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<hr/>
<p>End of horizontal rule tests.</p>
""",
),
[],
),
(
"5. Superscript and Subscript",
make_chapter(
"Superscript and Subscript",
"""
<h2>Basic superscript</h2>
<p>E = mc<sup>2</sup> is Einstein's mass-energy equivalence.</p>
<p>The area of a circle is &#960;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>Basic subscript</h2>
<p>Water is H<sub>2</sub>O.</p>
<p>Carbon dioxide is CO<sub>2</sub>.</p>
<p>The sequence a<sub>1</sub>, a<sub>2</sub>, a<sub>3</sub>, ..., a<sub>n</sub>.</p>
<p>Glucose: C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>.</p>
<h2>Mixed sup and sub</h2>
<p>The pH of water is 7, meaning [H<sub>3</sub>O<sup>+</sup>] = 10<sup>-7</sup> mol/L.</p>
<p>Footnote reference<sup>1</sup> and another<sup>2</sup> in the same sentence.</p>
<h2>Ordinals</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>
<h2>Nested with bold and italic</h2>
<p>Speed of light: c = 2.998 &#215; 10<sup>8</sup> m/s.</p>
<p>Avogadro: 6.022 &#215; 10<sup>23</sup> mol<sup>-1</sup>.</p>
<p>Bold superscript: x<sup><b>2</b></sup> and italic subscript: H<sub><i>n</i></sub>.</p>
<h2>Long runs</h2>
<p>This word<sup>has a rather long superscript attached to it</sup> continuing normally.</p>
<p>This word<sub>has a rather long subscript attached to it</sub> continuing normally.</p>
""",
),
[],