From b83482e732f21041599c9989b68e6c43097664af Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 29 Apr 2026 09:08:07 +0200 Subject: [PATCH] Support strikethrough --- lib/EpdFont/EpdFontFamily.h | 2 +- lib/Epub/Epub/blocks/TextBlock.cpp | 36 ++++---- lib/Epub/Epub/css/CssParser.cpp | 10 ++- lib/Epub/Epub/css/CssStyle.h | 2 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 84 +++++++++++++++---- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 3 + lib/Md/MdParser.cpp | 32 +++++-- test/README | 5 ++ test/epub_css/CssParserTest.cpp | 62 ++++++++++++++ test/md_parser/MdParserTest.cpp | 10 +++ test/run_epub_css_test.sh | 40 +++++++++ 11 files changed, 242 insertions(+), 44 deletions(-) create mode 100644 test/epub_css/CssParserTest.cpp create mode 100644 test/run_epub_css_test.sh diff --git a/lib/EpdFont/EpdFontFamily.h b/lib/EpdFont/EpdFontFamily.h index b08540d8..51b6e68b 100644 --- a/lib/EpdFont/EpdFontFamily.h +++ b/lib/EpdFont/EpdFontFamily.h @@ -3,7 +3,7 @@ class EpdFontFamily { public: - enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 }; + enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4, STRIKETHROUGH = 8 }; explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr, const EpdFont* boldItalic = nullptr) diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index e3e35d42..750d7b0e 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -17,26 +17,28 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int const EpdFontFamily::Style currentStyle = wordStyles[i]; renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle); + const std::string& w = words[i]; + const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle); + int startX = wordX; + int lineWidth = fullWordWidth; + const bool hasEmSpacePrefix = w.size() >= 3 && static_cast(w[0]) == 0xE2 && + static_cast(w[1]) == 0x80 && static_cast(w[2]) == 0x83; + if (hasEmSpacePrefix) { + const char* visiblePtr = w.c_str() + 3; + const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); + const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); + startX = wordX + prefixWidth; + lineWidth = visibleWidth; + } + 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; + renderer.drawLine(startX, underlineY, startX + lineWidth, underlineY, true); + } - int startX = wordX; - int underlineWidth = fullWordWidth; - - // if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visiblePtr = w.c_str() + 3; - const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); - const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle); - startX = wordX + prefixWidth; - underlineWidth = visibleWidth; - } - - renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true); + if ((currentStyle & EpdFontFamily::STRIKETHROUGH) != 0) { + const int strikeY = y + renderer.getFontAscenderSize(fontId) / 2 + 1; + renderer.drawLine(startX, strikeY, startX + lineWidth, strikeY, true); } } } diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index d2e679c3..33d2841c 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -205,10 +205,12 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) { const std::string v = normalized(val); // text-decoration can have multiple space-separated values - if (v.find("underline") != std::string::npos) { - return CssTextDecoration::Underline; - } - return CssTextDecoration::None; + bool underline = v.find("underline") != std::string::npos; + bool lineThrough = v.find("line-through") != std::string::npos; + uint8_t result = 0; + if (underline) result |= static_cast(CssTextDecoration::Underline); + if (lineThrough) result |= static_cast(CssTextDecoration::LineThrough); + return static_cast(result); } CssLength CssParser::interpretLength(const std::string& val) { diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 7b129eaf..18357f10 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -52,7 +52,7 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 }; enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 }; // Text decoration options -enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 }; +enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2, UnderlineLineThrough = 3 }; // Display options - only None and Block are relevant for e-ink rendering enum class CssDisplay : uint8_t { Block = 0, None = 1 }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index d51936e6..fbcd39d4 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -43,6 +43,9 @@ constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* UNDERLINE_TAGS[] = {"u", "ins"}; constexpr int NUM_UNDERLINE_TAGS = sizeof(UNDERLINE_TAGS) / sizeof(UNDERLINE_TAGS[0]); +const char* STRIKETHROUGH_TAGS[] = {"s", "del", "strike"}; +constexpr int NUM_STRIKETHROUGH_TAGS = sizeof(STRIKETHROUGH_TAGS) / sizeof(STRIKETHROUGH_TAGS[0]); + const char* IMAGE_TAGS[] = {"img"}; constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); @@ -135,8 +138,11 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold; effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic; - effectiveUnderline = - currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline; + effectiveUnderline = currentCssStyle.hasTextDecoration() && (static_cast(currentCssStyle.textDecoration) & + static_cast(CssTextDecoration::Underline)) != 0; + effectiveStrikethrough = + currentCssStyle.hasTextDecoration() && (static_cast(currentCssStyle.textDecoration) & + static_cast(CssTextDecoration::LineThrough)) != 0; // Apply inline style stack in order for (const auto& entry : inlineStyleStack) { @@ -149,6 +155,9 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { if (entry.hasUnderline) { effectiveUnderline = entry.underline; } + if (entry.hasStrikethrough) { + effectiveStrikethrough = entry.strikethrough; + } } } @@ -158,6 +167,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { const bool isBold = boldUntilDepth < depth || effectiveBold; const bool isItalic = italicUntilDepth < depth || effectiveItalic; const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline; + const bool isStrikethrough = strikethroughUntilDepth < depth || effectiveStrikethrough; // Combine style flags using bitwise OR EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; @@ -170,6 +180,9 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { if (isUnderline) { fontStyle = static_cast(fontStyle | EpdFontFamily::UNDERLINE); } + if (isStrikethrough) { + fontStyle = static_cast(fontStyle | EpdFontFamily::STRIKETHROUGH); + } // flush the buffer partWordBuffer[partWordBufferIndex] = '\0'; @@ -857,18 +870,30 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->preUntilDepth = std::min(self->preUntilDepth, self->depth); } } - } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { + } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || + matches(name, STRIKETHROUGH_TAGS, NUM_STRIKETHROUGH_TAGS)) { // Flush buffer before style change so preceding text gets current style if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); self->nextWordContinues = true; } - self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); - // Push inline style entry for underline tag + if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { + self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); + } + if (matches(name, STRIKETHROUGH_TAGS, NUM_STRIKETHROUGH_TAGS)) { + self->strikethroughUntilDepth = std::min(self->strikethroughUntilDepth, self->depth); + } + // Push inline style entry for underline/strikethrough tag StyleStackEntry entry; entry.depth = self->depth; // Track depth for matching pop - entry.hasUnderline = true; - entry.underline = true; + if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { + entry.hasUnderline = true; + entry.underline = true; + } + if (matches(name, STRIKETHROUGH_TAGS, NUM_STRIKETHROUGH_TAGS)) { + entry.hasStrikethrough = true; + entry.strikethrough = true; + } if (cssStyle.hasFontWeight()) { entry.hasBold = true; entry.bold = cssStyle.fontWeight == CssFontWeight::Bold; @@ -896,8 +921,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; } if (cssStyle.hasTextDecoration()) { - entry.hasUnderline = true; - entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; + const uint8_t dec = static_cast(cssStyle.textDecoration); + if (dec & static_cast(CssTextDecoration::Underline)) { + entry.hasUnderline = true; + entry.underline = true; + } + if (dec & static_cast(CssTextDecoration::LineThrough)) { + entry.hasStrikethrough = true; + entry.strikethrough = true; + } } self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); @@ -918,8 +950,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.bold = cssStyle.fontWeight == CssFontWeight::Bold; } if (cssStyle.hasTextDecoration()) { - entry.hasUnderline = true; - entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; + const uint8_t dec = static_cast(cssStyle.textDecoration); + if (dec & static_cast(CssTextDecoration::Underline)) { + entry.hasUnderline = true; + entry.underline = true; + } + if (dec & static_cast(CssTextDecoration::LineThrough)) { + entry.hasStrikethrough = true; + entry.strikethrough = true; + } } self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); @@ -942,8 +981,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* entry.italic = cssStyle.fontStyle == CssFontStyle::Italic; } if (cssStyle.hasTextDecoration()) { - entry.hasUnderline = true; - entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline; + const uint8_t dec = static_cast(cssStyle.textDecoration); + if (dec & static_cast(CssTextDecoration::Underline)) { + entry.hasUnderline = true; + entry.underline = true; + } + if (dec & static_cast(CssTextDecoration::LineThrough)) { + entry.hasStrikethrough = true; + entry.strikethrough = true; + } } self->inlineStyleStack.push_back(entry); self->updateEffectiveInlineStyle(); @@ -1188,8 +1234,10 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n const bool willClearBold = self->boldUntilDepth == self->depth - 1; const bool willClearItalic = self->italicUntilDepth == self->depth - 1; const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1; + const bool willClearStrikethrough = self->strikethroughUntilDepth == self->depth - 1; - const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline; + const bool styleWillChange = + willPopStyleStack || willClearBold || willClearItalic || willClearUnderline || willClearStrikethrough; const bool headerOrBlockTag = isHeaderOrBlock(name); const bool tableStructuralTag = isTableStructuralTag(name); @@ -1208,7 +1256,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n !headerOrBlockTag && !tableStructuralTag && !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1; const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || - matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || tableStructuralTag || + matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || + matches(name, STRIKETHROUGH_TAGS, NUM_STRIKETHROUGH_TAGS) || tableStructuralTag || matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; if (shouldFlush) { @@ -1282,6 +1331,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n self->underlineUntilDepth = INT_MAX; } + // Leaving strikethrough tag + if (self->strikethroughUntilDepth == self->depth) { + self->strikethroughUntilDepth = INT_MAX; + } + // Leaving pre tag if (self->preUntilDepth == self->depth) { self->preUntilDepth = INT_MAX; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 1898a0b8..875ecfcc 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -34,6 +34,7 @@ class ChapterHtmlSlimParser final : public Print { int boldUntilDepth = INT_MAX; int italicUntilDepth = INT_MAX; int underlineUntilDepth = INT_MAX; + int strikethroughUntilDepth = INT_MAX; int preUntilDepth = INT_MAX; // set when inside a
 element; enables \n → line-break handling
   // buffer for building up words from characters, will auto break if longer than this
   // leave one char at end for null pointer
@@ -63,12 +64,14 @@ class ChapterHtmlSlimParser final : public Print {
     bool hasBold = false, bold = false;
     bool hasItalic = false, italic = false;
     bool hasUnderline = false, underline = false;
+    bool hasStrikethrough = false, strikethrough = false;
   };
   std::vector inlineStyleStack;
   CssStyle currentCssStyle;
   bool effectiveBold = false;
   bool effectiveItalic = false;
   bool effectiveUnderline = false;
+  bool effectiveStrikethrough = false;
   int tableDepth = 0;
   int tableRowIndex = 0;
   int tableColIndex = 0;
diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp
index ea4bb3c7..2ee7a598 100644
--- a/lib/Md/MdParser.cpp
+++ b/lib/Md/MdParser.cpp
@@ -4,11 +4,22 @@
 
 namespace MdParser {
 
-static EpdFontFamily::Style combineFlags(bool bold, bool italic) {
-  if (bold && italic) return EpdFontFamily::BOLD_ITALIC;
-  if (bold) return EpdFontFamily::BOLD;
-  if (italic) return EpdFontFamily::ITALIC;
-  return EpdFontFamily::REGULAR;
+static EpdFontFamily::Style combineFlags(bool bold, bool italic, bool strike) {
+  EpdFontFamily::Style result = EpdFontFamily::REGULAR;
+  if (bold)
+    result =
+        static_cast(static_cast(result) | static_cast(EpdFontFamily::BOLD));
+  if (italic)
+    result =
+        static_cast(static_cast(result) | static_cast(EpdFontFamily::ITALIC));
+  if (bold && italic)
+    result = static_cast(
+        static_cast(EpdFontFamily::BOLD_ITALIC) |
+        (static_cast(result) & static_cast(EpdFontFamily::STRIKETHROUGH)));
+  if (strike)
+    result = static_cast(static_cast(result) |
+                                               static_cast(EpdFontFamily::STRIKETHROUGH));
+  return result;
 }
 
 static constexpr int TAB_WIDTH = 4;
@@ -63,11 +74,12 @@ std::vector parseInline(const std::string& text) {
   std::string current;
   bool bold = false;
   bool italic = false;
+  bool strike = false;
   size_t i = 0;
 
   auto emitSpan = [&]() {
     if (!current.empty()) {
-      spans.push_back({std::move(current), combineFlags(bold, italic)});
+      spans.push_back({std::move(current), combineFlags(bold, italic, strike)});
       current.clear();
     }
   };
@@ -111,6 +123,14 @@ std::vector parseInline(const std::string& text) {
       continue;
     }
 
+    // ~~ — toggle strikethrough
+    if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') {
+      emitSpan();
+      strike = !strike;
+      i += 2;
+      continue;
+    }
+
     // ** or __ — toggle bold
     if ((c == '*' || c == '_') && i + 1 < text.size() && text[i + 1] == c) {
       if (c == '_' && !isUnderscoreEmphasis(text, i, 2)) {
diff --git a/test/README b/test/README
index 9b1e87bc..4fafee1d 100644
--- a/test/README
+++ b/test/README
@@ -9,3 +9,8 @@ in the development cycle.
 
 More information about PlatformIO Unit Testing:
 - https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
+
+Example test runners in this repository:
+- test/run_url_utils_test.sh
+- test/run_opds_parser_test.sh
+- test/run_epub_css_test.sh
diff --git a/test/epub_css/CssParserTest.cpp b/test/epub_css/CssParserTest.cpp
new file mode 100644
index 00000000..ce91c30f
--- /dev/null
+++ b/test/epub_css/CssParserTest.cpp
@@ -0,0 +1,62 @@
+#include 
+#include 
+
+#include "../../lib/Epub/Epub/css/CssParser.h"
+
+static int testsPassed = 0;
+static int testsFailed = 0;
+
+#define ASSERT_EQ(a, b)                                                         \
+  do {                                                                          \
+    if ((a) != (b)) {                                                           \
+      fprintf(stderr, "  FAIL: %s:%d: %s != %s\n", __FILE__, __LINE__, #a, #b); \
+      testsFailed++;                                                            \
+      return;                                                                   \
+    }                                                                           \
+  } while (0)
+
+#define ASSERT_TRUE(cond)                                                       \
+  do {                                                                         \
+    if (!(cond)) {                                                             \
+      fprintf(stderr, "  FAIL: %s:%d: %s is false\n", __FILE__, __LINE__, #cond); \
+      testsFailed++;                                                           \
+      return;                                                                  \
+    }                                                                          \
+  } while (0)
+
+#define PASS() testsPassed++
+
+void testInlineLineThrough() {
+  printf("testInlineLineThrough...\n");
+  const CssStyle style = CssParser::parseInlineStyle("text-decoration: line-through");
+  ASSERT_TRUE(style.hasTextDecoration());
+  ASSERT_EQ(style.textDecoration, CssTextDecoration::LineThrough);
+  PASS();
+}
+
+void testInlineUnderlineLineThrough() {
+  printf("testInlineUnderlineLineThrough...\n");
+  const CssStyle style = CssParser::parseInlineStyle("text-decoration: underline line-through");
+  ASSERT_TRUE(style.hasTextDecoration());
+  ASSERT_EQ(style.textDecoration, CssTextDecoration::UnderlineLineThrough);
+  PASS();
+}
+
+void testInlineTextDecorationNormalization() {
+  printf("testInlineTextDecorationNormalization...\n");
+  const CssStyle style = CssParser::parseInlineStyle("TEXT-DECORATION : LINE-THROUGH ;");
+  ASSERT_TRUE(style.hasTextDecoration());
+  ASSERT_EQ(style.textDecoration, CssTextDecoration::LineThrough);
+  PASS();
+}
+
+int main() {
+  printf("=== EPUB CSS Parser Tests ===\n\n");
+
+  testInlineLineThrough();
+  testInlineUnderlineLineThrough();
+  testInlineTextDecorationNormalization();
+
+  printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed);
+  return testsFailed > 0 ? 1 : 0;
+}
diff --git a/test/md_parser/MdParserTest.cpp b/test/md_parser/MdParserTest.cpp
index d1bc4ff7..2ab3fb2f 100644
--- a/test/md_parser/MdParserTest.cpp
+++ b/test/md_parser/MdParserTest.cpp
@@ -69,6 +69,15 @@ void testAsteriskEmphasisStillWorks() {
   PASS();
 }
 
+void testStrikethroughWorks() {
+  printf("testStrikethroughWorks...\n");
+  auto spans = MdParser::parseInline("foo ~~bar~~ baz");
+  ASSERT_EQ(flattenText(spans), "foo bar baz");
+  ASSERT_EQ(spans.size(), 3);
+  ASSERT_EQ(spans[1].style, EpdFontFamily::STRIKETHROUGH);
+  PASS();
+}
+
 void testUnderscoreBoldWorks() {
   printf("testUnderscoreBoldWorks...\n");
   auto spans = MdParser::parseInline("foo __bar__ baz");
@@ -105,6 +114,7 @@ int main() {
   testUnderscoreWithinExpressionRemainsLiteral();
   testUnderscoreEmphasisStillWorks();
   testAsteriskEmphasisStillWorks();
+  testStrikethroughWorks();
   testUnderscoreBoldWorks();
   testNestedUnorderedListIndentLevel();
   testNestedOrderedListIndentLevel();
diff --git a/test/run_epub_css_test.sh b/test/run_epub_css_test.sh
new file mode 100644
index 00000000..e9c6c023
--- /dev/null
+++ b/test/run_epub_css_test.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BUILD_DIR="$ROOT_DIR/build/epub_css"
+BINARY="$BUILD_DIR/CssParserTest"
+PLATFORMIO_DIR="${PLATFORMIO_CORE_DIR:-$HOME/.platformio}"
+ARDUINO_FRAMEWORK_DIR="$PLATFORMIO_DIR/packages/framework-arduinoespressif32"
+
+mkdir -p "$BUILD_DIR"
+
+SOURCES=(
+  "$ROOT_DIR/test/epub_css/CssParserTest.cpp"
+  "$ROOT_DIR/lib/Epub/Epub/css/CssParser.cpp"
+  "$ROOT_DIR/lib/hal/HalStorage.cpp"
+  "$ROOT_DIR/lib/Logging/Logging.cpp"
+)
+
+CXXFLAGS=(
+  -std=c++20
+  -O2
+  -Wall
+  -Wextra
+  -pedantic
+  -fno-exceptions
+  -DARDUINO_USB_MODE=1
+  -DARDUINO_USB_CDC_ON_BOOT=1
+  -DDESTRUCTOR_CLOSES_FILE=1
+  -I"$ROOT_DIR/test/shims"
+  -I"$ROOT_DIR"
+  -I"$ROOT_DIR/lib"
+  -I"$ROOT_DIR/lib/hal"
+  -I"$ROOT_DIR/lib/Logging"
+  -I"$ARDUINO_FRAMEWORK_DIR/cores/esp32"
+  -I"$ARDUINO_FRAMEWORK_DIR/variants/esp32c3"
+)
+
+c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY"
+
+"$BINARY" "$@"