diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 5b2fa183..467cb676 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -22,7 +22,7 @@ constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t PARSE_BUFFER_SIZE = 1024; -const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; +const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote", "pre"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); const char* BOLD_TAGS[] = {"b", "strong"}; @@ -639,6 +639,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (strcmp(name, "li") == 0) { self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + } else if (strcmp(name, "pre") == 0) { + // Record depth so characterData can treat \n as a hard line break inside
.
+        // depth has not been incremented yet here; it will be after startElement returns.
+        self->preUntilDepth = std::min(self->preUntilDepth, self->depth);
       }
     }
   } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
@@ -766,6 +770,21 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
 
   for (int i = 0; i < len; i++) {
     if (isWhitespace(s[i])) {
+      // Inside 
: treat \n as a hard line break.
+      if (s[i] == '\n' && self->preUntilDepth < self->depth) {
+        if (self->partWordBufferIndex > 0) {
+          self->flushPartWordBuffer();
+        }
+        // Blank line: the current block is empty, but we still need to emit a visible
+        // empty line.  Add a single space so the block is non-empty and makePages()
+        // will produce a line of the correct height instead of reusing the empty block.
+        if (self->currentTextBlock->isEmpty()) {
+          self->currentTextBlock->addWord(" ", EpdFontFamily::REGULAR);
+        }
+        self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
+        self->nextWordContinues = false;
+        continue;
+      }
       // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
       if (self->partWordBufferIndex > 0) {
         self->flushPartWordBuffer();
@@ -1000,6 +1019,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
     self->underlineUntilDepth = INT_MAX;
   }
 
+  // Leaving pre tag
+  if (self->preUntilDepth == self->depth) {
+    self->preUntilDepth = INT_MAX;
+  }
+
   // Pop from inline style stack if we pushed an entry at this depth
   // This handles all inline elements: b, i, u, span, etc.
   if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
@@ -1223,8 +1247,11 @@ void ChapterHtmlSlimParser::makePages() {
     currentPageNextY += blockStyle.paddingBottom;
   }
 
-  // Extra paragraph spacing if enabled (default behavior)
-  if (extraParagraphSpacing) {
+  // Extra paragraph spacing if enabled (default behavior).
+  // Suppressed between lines within a 
 block so code/preformatted text is not
+  // double-spaced; the last line of the block is flushed after 
is closed and + // preUntilDepth has already been reset, so it still receives normal paragraph spacing. + if (extraParagraphSpacing && preUntilDepth == INT_MAX) { currentPageNextY += lineHeight / 2; } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 9eacfb19..8231d9a5 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -32,6 +32,7 @@ class ChapterHtmlSlimParser { int boldUntilDepth = INT_MAX; int italicUntilDepth = INT_MAX; int underlineUntilDepth = 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
   char partWordBuffer[MAX_WORD_SIZE + 1] = {};
diff --git a/scripts/generate_test_epub.py b/scripts/generate_test_epub.py
index e585420c..d8a7f4db 100644
--- a/scripts/generate_test_epub.py
+++ b/scripts/generate_test_epub.py
@@ -1,13 +1,10 @@
 #!/usr/bin/env python3
 """
-Generate test EPUBs for image rendering verification.
+Generate test EPUBs for rendering verification.
 
-Creates EPUBs with annotated JPEG and PNG images to verify:
-- Grayscale rendering (4 levels)
-- Image scaling
-- Image centering
-- Cache performance
-- Page serialization
+Creates EPUBs to verify:
+- Image: Grayscale rendering (4 levels), scaling, centering, cache performance
+- Text: pre element line breaks, blank lines, nested code element
 """
 
 import os
@@ -24,15 +21,35 @@ OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
 SCREEN_WIDTH = 480
 SCREEN_HEIGHT = 800
 
+
 def get_font(size=20):
     """Get a font, falling back to default if needed."""
-    try:
-        return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
-    except:
+    import sys
+
+    candidates = []
+    if sys.platform == "win32":
+        windir = os.environ.get("WINDIR", "C:\\Windows")
+        candidates = [
+            os.path.join(windir, "Fonts", "arial.ttf"),
+            os.path.join(windir, "Fonts", "calibri.ttf"),
+        ]
+    elif sys.platform == "darwin":
+        candidates = [
+            "/System/Library/Fonts/Helvetica.ttc",
+            "/Library/Fonts/Arial.ttf",
+        ]
+    else:
+        candidates = [
+            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+            "/usr/share/fonts/TTF/DejaVuSans.ttf",
+        ]
+    for path in candidates:
         try:
-            return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
+            return ImageFont.truetype(path, size)
         except:
-            return ImageFont.load_default()
+            continue
+    return ImageFont.load_default()
+
 
 def draw_text_centered(draw, y, text, font, fill=0):
     """Draw centered text at given y position."""
@@ -41,6 +58,7 @@ def draw_text_centered(draw, y, text, font, fill=0):
     x = (draw.im.size[0] - text_width) // 2
     draw.text((x, y), text, font=font, fill=fill)
 
+
 def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
     """Draw text with word wrapping."""
     words = text.split()
@@ -48,29 +66,30 @@ def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
     current_line = []
 
     for word in words:
-        test_line = ' '.join(current_line + [word])
+        test_line = " ".join(current_line + [word])
         bbox = draw.textbbox((0, 0), test_line, font=font)
         if bbox[2] - bbox[0] <= max_width:
             current_line.append(word)
         else:
             if current_line:
-                lines.append(' '.join(current_line))
+                lines.append(" ".join(current_line))
             current_line = [word]
     if current_line:
-        lines.append(' '.join(current_line))
+        lines.append(" ".join(current_line))
 
-    line_height = font.size + 4 if hasattr(font, 'size') else 20
+    line_height = font.size + 4 if hasattr(font, "size") else 20
     for i, line in enumerate(lines):
         draw.text((x, y + i * line_height), line, font=font, fill=fill)
 
     return len(lines) * line_height
 
+
 def create_grayscale_test_image(filename, is_png=True):
     """
     Create image with 4 grayscale squares to verify 4-level rendering.
     """
     width, height = 400, 600
-    img = Image.new('L', (width, height), 255)
+    img = Image.new("L", (width, height), 255)
     draw = ImageDraw.Draw(img)
     font = get_font(16)
     font_small = get_font(14)
@@ -99,13 +118,18 @@ def create_grayscale_test_image(filename, is_png=True):
         x = (width - square_size) // 2
 
         # Draw square with border
-        draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
+        draw.rectangle([x - 2, y - 2, x + square_size + 2, y + square_size + 2], fill=0)
         draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
 
         # Label below square
         bbox = draw.textbbox((0, 0), label, font=font_small)
         label_width = bbox[2] - bbox[0]
-        draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
+        draw.text(
+            ((width - label_width) // 2, y + square_size + 5),
+            label,
+            font=font_small,
+            fill=0,
+        )
 
     # Instructions at bottom (well below the last square)
     y = height - 70
@@ -115,27 +139,33 @@ def create_grayscale_test_image(filename, is_png=True):
 
     # Save
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_centering_test_image(filename, is_png=True):
     """
     Create image with border markers to verify centering.
     """
     width, height = 350, 400
-    img = Image.new('L', (width, height), 255)
+    img = Image.new("L", (width, height), 255)
     draw = ImageDraw.Draw(img)
     font = get_font(16)
     font_small = get_font(14)
 
     # Draw border
-    draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
+    draw.rectangle([0, 0, width - 1, height - 1], outline=0, width=3)
 
     # Corner markers
     marker_size = 20
-    for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
-        draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
+    for x, y in [
+        (0, 0),
+        (width - marker_size, 0),
+        (0, height - marker_size),
+        (width - marker_size, height - marker_size),
+    ]:
+        draw.rectangle([x, y, x + marker_size, y + marker_size], fill=0)
 
     # Center cross
     cx, cy = width // 2, height // 2
@@ -152,19 +182,26 @@ def create_centering_test_image(filename, is_png=True):
 
     y = 150
     draw_text_centered(draw, y, "Check:", font_small, fill=0)
-    draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 25, "- Equal margins left & right", font_small, fill=64
+    )
     draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
-    draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 65, "- Border is complete rectangle", font_small, fill=64
+    )
 
     # Pass/fail
     y = height - 80
-    draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
+    draw_text_centered(
+        draw, y, "PASS: Centered, all corners visible", font_small, fill=0
+    )
     draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_scaling_test_image(filename, is_png=True):
     """
@@ -172,26 +209,38 @@ def create_scaling_test_image(filename, is_png=True):
     """
     # Make image larger than screen but within decoder limits (max 2048x1536)
     width, height = 1200, 1500
-    img = Image.new('L', (width, height), 240)
+    img = Image.new("L", (width, height), 240)
     draw = ImageDraw.Draw(img)
     font = get_font(48)
     font_medium = get_font(32)
     font_small = get_font(24)
 
     # Border
-    draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
-    draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
+    draw.rectangle([0, 0, width - 1, height - 1], outline=0, width=8)
+    draw.rectangle([20, 20, width - 21, height - 21], outline=128, width=4)
 
     # Title
     draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
-    draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
+    draw_text_centered(
+        draw,
+        130,
+        f"Original: {width}x{height} (larger than screen)",
+        font_medium,
+        fill=64,
+    )
 
     # Grid pattern to verify scaling quality
     grid_start_y = 220
     grid_size = 400
     cell_size = 50
 
-    draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
+    draw_text_centered(
+        draw,
+        grid_start_y - 40,
+        "Grid pattern (check for artifacts):",
+        font_small,
+        fill=0,
+    )
 
     grid_x = (width - grid_size) // 2
     for row in range(grid_size // cell_size):
@@ -205,7 +254,9 @@ def create_scaling_test_image(filename, is_png=True):
 
     # Size indicator bars
     y = grid_start_y + grid_size + 60
-    draw_text_centered(draw, y, "Width markers (should fit on screen):", font_small, fill=0)
+    draw_text_centered(
+        draw, y, "Width markers (should fit on screen):", font_small, fill=0
+    )
 
     bar_y = y + 40
     # Full width bar
@@ -233,13 +284,18 @@ def create_scaling_test_image(filename, is_png=True):
         draw_text_centered(draw, y + i * 35, text, font_small, fill=64)
 
     y = height - 100
-    draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
-    draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
+    draw_text_centered(
+        draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0
+    )
+    draw_text_centered(
+        draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64
+    )
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_wide_scaling_test_image(filename, is_png=True):
     """
@@ -247,19 +303,25 @@ def create_wide_scaling_test_image(filename, is_png=True):
     that can trigger cache dimension mismatches due to floating-point rounding.
     """
     width, height = 1807, 736
-    img = Image.new('L', (width, height), 240)
+    img = Image.new("L", (width, height), 240)
     draw = ImageDraw.Draw(img)
     font = get_font(48)
     font_medium = get_font(32)
     font_small = get_font(24)
 
     # Border
-    draw.rectangle([0, 0, width-1, height-1], outline=0, width=6)
-    draw.rectangle([15, 15, width-16, height-16], outline=128, width=3)
+    draw.rectangle([0, 0, width - 1, height - 1], outline=0, width=6)
+    draw.rectangle([15, 15, width - 16, height - 16], outline=128, width=3)
 
     # Title
     draw_text_centered(draw, 40, "WIDE SCALING TEST", font, fill=0)
-    draw_text_centered(draw, 100, f"Original: {width}x{height} (tests rounding edge case)", font_medium, fill=64)
+    draw_text_centered(
+        draw,
+        100,
+        f"Original: {width}x{height} (tests rounding edge case)",
+        font_medium,
+        fill=64,
+    )
 
     # Grid pattern to verify scaling quality
     grid_start_x = 100
@@ -268,7 +330,12 @@ def create_wide_scaling_test_image(filename, is_png=True):
     grid_height = 300
     cell_size = 50
 
-    draw.text((grid_start_x, grid_start_y - 35), "Grid pattern (check for artifacts):", font=font_small, fill=0)
+    draw.text(
+        (grid_start_x, grid_start_y - 35),
+        "Grid pattern (check for artifacts):",
+        font=font_small,
+        fill=0,
+    )
 
     for row in range(grid_height // cell_size):
         for col in range(grid_width // cell_size):
@@ -300,27 +367,32 @@ def create_wide_scaling_test_image(filename, is_png=True):
 
     # Pass/fail at bottom
     y = height - 80
-    draw_text_centered(draw, y, "PASS: Single decode, cached correctly", font_small, fill=0)
-    draw_text_centered(draw, y + 30, "FAIL: Cache mismatch, multiple decodes", font_small, fill=64)
+    draw_text_centered(
+        draw, y, "PASS: Single decode, cached correctly", font_small, fill=0
+    )
+    draw_text_centered(
+        draw, y + 30, "FAIL: Cache mismatch, multiple decodes", font_small, fill=64
+    )
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_cache_test_image(filename, page_num, is_png=True):
     """
     Create image for cache performance testing.
     """
     width, height = 400, 300
-    img = Image.new('L', (width, height), 255)
+    img = Image.new("L", (width, height), 255)
     draw = ImageDraw.Draw(img)
     font = get_font(18)
     font_small = get_font(14)
     font_large = get_font(36)
 
     # Border
-    draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
+    draw.rectangle([0, 0, width - 1, height - 1], outline=0, width=2)
 
     # Page number prominent
     draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
@@ -329,29 +401,36 @@ def create_cache_test_image(filename, page_num, is_png=True):
     # Instructions
     y = 140
     draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
-    draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 25, "Second load should be faster", font_small, fill=64
+    )
 
     y = 220
     draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
-    draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64
+    )
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_gradient_test_image(filename, is_png=True):
     """
     Create horizontal gradient to test grayscale banding.
     """
     width, height = 400, 500
-    img = Image.new('L', (width, height), 255)
+    img = Image.new("L", (width, height), 255)
     draw = ImageDraw.Draw(img)
     font = get_font(16)
     font_small = get_font(14)
 
     draw_text_centered(draw, 10, "GRADIENT TEST", font, fill=0)
-    draw_text_centered(draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64)
+    draw_text_centered(
+        draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64
+    )
 
     # Horizontal gradient
     gradient_y = 70
@@ -361,7 +440,11 @@ def create_gradient_test_image(filename, is_png=True):
         draw.line([(x, gradient_y), (x, gradient_y + gradient_height)], fill=gray)
 
     # Border around gradient
-    draw.rectangle([0, gradient_y-1, width-1, gradient_y + gradient_height + 1], outline=0, width=1)
+    draw.rectangle(
+        [0, gradient_y - 1, width - 1, gradient_y + gradient_height + 1],
+        outline=0,
+        width=1,
+    )
 
     # Labels
     y = gradient_y + gradient_height + 10
@@ -370,7 +453,9 @@ def create_gradient_test_image(filename, is_png=True):
 
     # 4-step gradient (what it should look like)
     y = 220
-    draw_text_centered(draw, y, "Expected result (4 distinct bands):", font_small, fill=0)
+    draw_text_centered(
+        draw, y, "Expected result (4 distinct bands):", font_small, fill=0
+    )
 
     band_y = y + 25
     band_height = 60
@@ -378,7 +463,9 @@ def create_gradient_test_image(filename, is_png=True):
     for i, gray in enumerate([0, 85, 170, 255]):
         x = i * band_width
         draw.rectangle([x, band_y, x + band_width, band_y + band_height], fill=gray)
-    draw.rectangle([0, band_y-1, width-1, band_y + band_height + 1], outline=0, width=1)
+    draw.rectangle(
+        [0, band_y - 1, width - 1, band_y + band_height + 1], outline=0, width=1
+    )
 
     # Vertical gradient
     y = 340
@@ -389,31 +476,36 @@ def create_gradient_test_image(filename, is_png=True):
     for row in range(vgrad_height):
         gray = int(255 * row / vgrad_height)
         draw.line([(50, vgrad_y + row), (width - 50, vgrad_y + row)], fill=gray)
-    draw.rectangle([49, vgrad_y-1, width-49, vgrad_y + vgrad_height + 1], outline=0, width=1)
+    draw.rectangle(
+        [49, vgrad_y - 1, width - 49, vgrad_y + vgrad_height + 1], outline=0, width=1
+    )
 
     # Pass/fail
     y = height - 50
     draw_text_centered(draw, y, "PASS: Clear 4-band quantization", font_small, fill=0)
-    draw_text_centered(draw, y + 20, "FAIL: Binary/noisy dithering", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 20, "FAIL: Binary/noisy dithering", font_small, fill=64
+    )
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_format_test_image(filename, format_name, is_png=True):
     """
     Create simple image to verify format support.
     """
     width, height = 350, 250
-    img = Image.new('L', (width, height), 255)
+    img = Image.new("L", (width, height), 255)
     draw = ImageDraw.Draw(img)
     font = get_font(20)
     font_large = get_font(36)
     font_small = get_font(14)
 
     # Border
-    draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
+    draw.rectangle([0, 0, width - 1, height - 1], outline=0, width=3)
 
     # Format name
     draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
@@ -422,15 +514,20 @@ def create_format_test_image(filename, format_name, is_png=True):
     # Checkmark area
     y = 140
     draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
-    draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
+    draw_text_centered(
+        draw, y + 20, f"{format_name} decoding works!", font_small, fill=64
+    )
 
     y = height - 40
-    draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
+    draw_text_centered(
+        draw, y, f"PASS: {format_name} image visible", font_small, fill=0
+    )
 
     if is_png:
-        img.save(filename, 'PNG')
+        img.save(filename, "PNG")
     else:
-        img.save(filename, 'JPEG', quality=95)
+        img.save(filename, "JPEG", quality=95)
+
 
 def create_epub(epub_path, title, chapters):
     """
@@ -439,18 +536,20 @@ def create_epub(epub_path, title, chapters):
     chapters: list of (chapter_title, html_content, images)
               images: list of (image_filename, image_data)
     """
-    with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
+    with zipfile.ZipFile(epub_path, "w", zipfile.ZIP_DEFLATED) as epub:
         # mimetype (must be first, uncompressed)
-        epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
+        epub.writestr(
+            "mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED
+        )
 
         # Container
-        container_xml = '''
+        container_xml = """
 
   
     
   
-'''
-        epub.writestr('META-INF/container.xml', container_xml)
+"""
+        epub.writestr("META-INF/container.xml", container_xml)
 
         # Collect all images and chapters
         manifest_items = []
@@ -458,22 +557,28 @@ def create_epub(epub_path, title, chapters):
 
         # Add chapters and images
         for i, (chapter_title, html_content, images) in enumerate(chapters):
-            chapter_id = f'chapter{i+1}'
-            chapter_file = f'chapter{i+1}.xhtml'
+            chapter_id = f"chapter{i + 1}"
+            chapter_file = f"chapter{i + 1}.xhtml"
 
             # Add images for this chapter
             for img_filename, img_data in images:
-                media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg'
-                manifest_items.append(f'    ')
-                epub.writestr(f'OEBPS/images/{img_filename}', img_data)
+                media_type = (
+                    "image/png" if img_filename.endswith(".png") else "image/jpeg"
+                )
+                manifest_items.append(
+                    f'    '
+                )
+                epub.writestr(f"OEBPS/images/{img_filename}", img_data)
 
             # Add chapter
-            manifest_items.append(f'    ')
+            manifest_items.append(
+                f'    '
+            )
             spine_items.append(f'    ')
-            epub.writestr(f'OEBPS/{chapter_file}', html_content)
+            epub.writestr(f"OEBPS/{chapter_file}", html_content)
 
         # content.opf
-        content_opf = f'''
+        content_opf = f"""
 
   
     test-epub-{title.lower().replace(" ", "-")}
@@ -487,13 +592,17 @@ def create_epub(epub_path, title, chapters):
   
 {chr(10).join(spine_items)}
   
-'''
-        epub.writestr('OEBPS/content.opf', content_opf)
+"""
+        epub.writestr("OEBPS/content.opf", content_opf)
 
         # Navigation document
-        nav_items = '\n'.join([f'      
  • {chapters[i][0]}
  • ' - for i in range(len(chapters))]) - nav_xhtml = f''' + nav_items = "\n".join( + [ + f'
  • {chapters[i][0]}
  • ' + for i in range(len(chapters)) + ] + ) + nav_xhtml = f""" Navigation @@ -505,12 +614,13 @@ def create_epub(epub_path, title, chapters): -''' - epub.writestr('OEBPS/nav.xhtml', nav_xhtml) +""" + epub.writestr("OEBPS/nav.xhtml", nav_xhtml) + def make_chapter(title, body_content): """Create XHTML chapter content.""" - return f''' + return f""" {title} @@ -518,13 +628,15 @@ def make_chapter(title, body_content):

    {title}

    {body_content} -''' +""" + def main(): OUTPUT_DIR.mkdir(exist_ok=True) # Temp directory for images import tempfile + with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) @@ -534,32 +646,36 @@ def main(): images = {} # JPEG tests - create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False) - create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False) - create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False) - create_wide_scaling_test_image(tmpdir / 'wide_scaling_test.jpg', is_png=False) - create_gradient_test_image(tmpdir / 'gradient_test.jpg', is_png=False) - create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False) - create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False) - create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False) + create_grayscale_test_image(tmpdir / "grayscale_test.jpg", is_png=False) + create_centering_test_image(tmpdir / "centering_test.jpg", is_png=False) + create_scaling_test_image(tmpdir / "scaling_test.jpg", is_png=False) + create_wide_scaling_test_image(tmpdir / "wide_scaling_test.jpg", is_png=False) + create_gradient_test_image(tmpdir / "gradient_test.jpg", is_png=False) + create_format_test_image(tmpdir / "jpeg_format.jpg", "JPEG", is_png=False) + create_cache_test_image(tmpdir / "cache_test_1.jpg", 1, is_png=False) + create_cache_test_image(tmpdir / "cache_test_2.jpg", 2, is_png=False) # PNG tests - create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True) - create_centering_test_image(tmpdir / 'centering_test.png', is_png=True) - create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True) - create_wide_scaling_test_image(tmpdir / 'wide_scaling_test.png', is_png=True) - create_gradient_test_image(tmpdir / 'gradient_test.png', is_png=True) - create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True) - create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True) - create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True) + create_grayscale_test_image(tmpdir / "grayscale_test.png", is_png=True) + create_centering_test_image(tmpdir / "centering_test.png", is_png=True) + create_scaling_test_image(tmpdir / "scaling_test.png", is_png=True) + create_wide_scaling_test_image(tmpdir / "wide_scaling_test.png", is_png=True) + create_gradient_test_image(tmpdir / "gradient_test.png", is_png=True) + create_format_test_image(tmpdir / "png_format.png", "PNG", is_png=True) + create_cache_test_image(tmpdir / "cache_test_1.png", 1, is_png=True) + create_cache_test_image(tmpdir / "cache_test_2.png", 2, is_png=True) # Read all images - for img_file in tmpdir.glob('*.*'): + for img_file in tmpdir.glob("*.*"): images[img_file.name] = img_file.read_bytes() print("Creating JPEG test EPUB...") jpeg_chapters = [ - ("Introduction", make_chapter("JPEG Image Tests", """ + ( + "Introduction", + make_chapter( + "JPEG Image Tests", + """

    This EPUB tests JPEG image rendering.

    Navigate through chapters to verify each test case.

    Test Plan:

    @@ -569,45 +685,108 @@ def main():
  • Large image scaling
  • Cache performance
  • -"""), []), - ("1. JPEG Format", make_chapter("JPEG Format Test", """ +""", + ), + [], + ), + ( + "1. JPEG Format", + make_chapter( + "JPEG Format Test", + """

    Basic JPEG decoding test.

    JPEG format test

    If the image above is visible, JPEG decoding works.

    -"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), - ("2. Grayscale", make_chapter("Grayscale Test", """ +""", + ), + [("jpeg_format.jpg", images["jpeg_format.jpg"])], + ), + ( + "2. Grayscale", + make_chapter( + "Grayscale Test", + """

    Verify 4 distinct gray levels are visible.

    Grayscale test -"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]), - ("3. Gradient", make_chapter("Gradient Test", """ +""", + ), + [("grayscale_test.jpg", images["grayscale_test.jpg"])], + ), + ( + "3. Gradient", + make_chapter( + "Gradient Test", + """

    Verify gradient quantizes to 4 bands.

    Gradient test -"""), [('gradient_test.jpg', images['gradient_test.jpg'])]), - ("4. Centering", make_chapter("Centering Test", """ +""", + ), + [("gradient_test.jpg", images["gradient_test.jpg"])], + ), + ( + "4. Centering", + make_chapter( + "Centering Test", + """

    Verify image is centered horizontally.

    Centering test -"""), [('centering_test.jpg', images['centering_test.jpg'])]), - ("5. Scaling", make_chapter("Scaling Test", """ +""", + ), + [("centering_test.jpg", images["centering_test.jpg"])], + ), + ( + "5. Scaling", + make_chapter( + "Scaling Test", + """

    This image is 1200x1500 pixels - larger than the screen.

    It should be scaled down to fit.

    Scaling test -"""), [('scaling_test.jpg', images['scaling_test.jpg'])]), - ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ +""", + ), + [("scaling_test.jpg", images["scaling_test.jpg"])], + ), + ( + "6. Wide Scaling", + make_chapter( + "Wide Scaling Test", + """

    This image is 1807x736 pixels - a wide landscape format.

    Tests scaling with dimensions that can cause cache mismatches.

    Wide scaling test -"""), [('wide_scaling_test.jpg', images['wide_scaling_test.jpg'])]), - ("7. Cache Test A", make_chapter("Cache Test - Page A", """ +""", + ), + [("wide_scaling_test.jpg", images["wide_scaling_test.jpg"])], + ), + ( + "7. Cache Test A", + make_chapter( + "Cache Test - Page A", + """

    First cache test page. Note the load time.

    Cache test 1

    Navigate to next page, then come back.

    -"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]), - ("8. Cache Test B", make_chapter("Cache Test - Page B", """ +""", + ), + [("cache_test_1.jpg", images["cache_test_1.jpg"])], + ), + ( + "8. Cache Test B", + make_chapter( + "Cache Test - Page B", + """

    Second cache test page.

    Cache test 2

    Navigate back to Page A - it should load faster from cache.

    -"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]), - ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", """ +""", + ), + [("cache_test_2.jpg", images["cache_test_2.jpg"])], + ), + ( + "9. Alignment Bleed", + make_chapter( + "Image Centering Bleed Test", + """

    Tests that image centering does not bleed into following text blocks (issue #1026).

    Set Paragraph Alignment to Justify and Embedded Style to OFF before testing.

    All paragraphs below the images should be justified, not centered.

    @@ -617,14 +796,23 @@ def main():

    FIRST PARAGRAPH after image. This paragraph follows an empty heading and an image-only paragraph. With the bug present, this text appears centered instead of justified because the empty heading's default Center alignment bleeds through the chain of empty text blocks. Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor.

    SECOND PARAGRAPH in the same div. This paragraph should always be justified because the first paragraph's text block was flushed. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia.

    -"""), []), # centering_test.jpg already included by chapter 4 +""", + ), + [], + ), # centering_test.jpg already included by chapter 4 ] - create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters) + create_epub( + OUTPUT_DIR / "test_jpeg_images.epub", "JPEG Image Tests", jpeg_chapters + ) print("Creating PNG test EPUB...") png_chapters = [ - ("Introduction", make_chapter("PNG Image Tests", """ + ( + "Introduction", + make_chapter( + "PNG Image Tests", + """

    This EPUB tests PNG image rendering.

    Navigate through chapters to verify each test case.

    Test Plan:

    @@ -634,45 +822,108 @@ def main():
  • Image centering
  • Large image scaling
  • -"""), []), - ("1. PNG Format", make_chapter("PNG Format Test", """ +""", + ), + [], + ), + ( + "1. PNG Format", + make_chapter( + "PNG Format Test", + """

    Basic PNG decoding test.

    PNG format test

    If the image above is visible and no crash occurred, PNG decoding works.

    -"""), [('png_format.png', images['png_format.png'])]), - ("2. Grayscale", make_chapter("Grayscale Test", """ +""", + ), + [("png_format.png", images["png_format.png"])], + ), + ( + "2. Grayscale", + make_chapter( + "Grayscale Test", + """

    Verify 4 distinct gray levels are visible.

    Grayscale test -"""), [('grayscale_test.png', images['grayscale_test.png'])]), - ("3. Gradient", make_chapter("Gradient Test", """ +""", + ), + [("grayscale_test.png", images["grayscale_test.png"])], + ), + ( + "3. Gradient", + make_chapter( + "Gradient Test", + """

    Verify gradient quantizes to 4 bands.

    Gradient test -"""), [('gradient_test.png', images['gradient_test.png'])]), - ("4. Centering", make_chapter("Centering Test", """ +""", + ), + [("gradient_test.png", images["gradient_test.png"])], + ), + ( + "4. Centering", + make_chapter( + "Centering Test", + """

    Verify image is centered horizontally.

    Centering test -"""), [('centering_test.png', images['centering_test.png'])]), - ("5. Scaling", make_chapter("Scaling Test", """ +""", + ), + [("centering_test.png", images["centering_test.png"])], + ), + ( + "5. Scaling", + make_chapter( + "Scaling Test", + """

    This image is 1200x1500 pixels - larger than the screen.

    It should be scaled down to fit.

    Scaling test -"""), [('scaling_test.png', images['scaling_test.png'])]), - ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ +""", + ), + [("scaling_test.png", images["scaling_test.png"])], + ), + ( + "6. Wide Scaling", + make_chapter( + "Wide Scaling Test", + """

    This image is 1807x736 pixels - a wide landscape format.

    Tests scaling with dimensions that can cause cache mismatches.

    Wide scaling test -"""), [('wide_scaling_test.png', images['wide_scaling_test.png'])]), - ("7. Cache Test A", make_chapter("Cache Test - Page A", """ +""", + ), + [("wide_scaling_test.png", images["wide_scaling_test.png"])], + ), + ( + "7. Cache Test A", + make_chapter( + "Cache Test - Page A", + """

    First cache test page. Note the load time.

    Cache test 1

    Navigate to next page, then come back.

    -"""), [('cache_test_1.png', images['cache_test_1.png'])]), - ("8. Cache Test B", make_chapter("Cache Test - Page B", """ +""", + ), + [("cache_test_1.png", images["cache_test_1.png"])], + ), + ( + "8. Cache Test B", + make_chapter( + "Cache Test - Page B", + """

    Second cache test page.

    Cache test 2

    Navigate back to Page A - it should load faster from cache.

    -"""), [('cache_test_2.png', images['cache_test_2.png'])]), - ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", """ +""", + ), + [("cache_test_2.png", images["cache_test_2.png"])], + ), + ( + "9. Alignment Bleed", + make_chapter( + "Image Centering Bleed Test", + """

    Tests that image centering does not bleed into following text blocks (issue #1026).

    Set Paragraph Alignment to Justify and Embedded Style to OFF before testing.

    All paragraphs below the images should be justified, not centered.

    @@ -682,41 +933,151 @@ def main():

    FIRST PARAGRAPH after image. This paragraph follows an empty heading and an image-only paragraph. With the bug present, this text appears centered instead of justified because the empty heading's default Center alignment bleeds through the chain of empty text blocks. Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor.

    SECOND PARAGRAPH in the same div. This paragraph should always be justified because the first paragraph's text block was flushed. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia.

    -"""), []), # centering_test.png already included by chapter 4 +""", + ), + [], + ), # centering_test.png already included by chapter 4 ] - create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters) + create_epub( + OUTPUT_DIR / "test_png_images.epub", "PNG Image Tests", png_chapters + ) print("Creating mixed format test EPUB...") mixed_chapters = [ - ("Introduction", make_chapter("Mixed Image Format Tests", """ + ( + "Introduction", + make_chapter( + "Mixed Image Format Tests", + """

    This EPUB contains both JPEG and PNG images.

    Tests format detection and mixed rendering.

    -"""), []), - ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """ +""", + ), + [], + ), + ( + "1. JPEG Image", + make_chapter( + "JPEG in Mixed EPUB", + """

    This is a JPEG image:

    JPEG -"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), - ("2. PNG Image", make_chapter("PNG in Mixed EPUB", """ +""", + ), + [("jpeg_format.jpg", images["jpeg_format.jpg"])], + ), + ( + "2. PNG Image", + make_chapter( + "PNG in Mixed EPUB", + """

    This is a PNG image:

    PNG -"""), [('png_format.png', images['png_format.png'])]), - ("3. Both Formats", make_chapter("Both Formats on One Page", """ +""", + ), + [("png_format.png", images["png_format.png"])], + ), + ( + "3. Both Formats", + make_chapter( + "Both Formats on One Page", + """

    JPEG image:

    JPEG grayscale

    PNG image:

    PNG grayscale

    Both should render with proper grayscale.

    -"""), [('grayscale_test.jpg', images['grayscale_test.jpg']), - ('grayscale_test.png', images['grayscale_test.png'])]), +""", + ), + [ + ("grayscale_test.jpg", images["grayscale_test.jpg"]), + ("grayscale_test.png", images["grayscale_test.png"]), + ], + ), ] - create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters) + create_epub( + OUTPUT_DIR / "test_mixed_images.epub", "Mixed Format Tests", mixed_chapters + ) + + print("Creating text rendering test EPUB...") + text_chapters = [ + ( + "Introduction", + make_chapter( + "Text Rendering Tests", + """ +

    This EPUB tests text rendering edge cases.

    +

    Test Plan:

    +
      +
    • pre element: intrinsic line breaks preserved
    • +
    • pre element: leading/trailing blank lines
    • +
    • pre with inline code element
    • +
    +""", + ), + [], + ), + ( + "1. pre Line Breaks", + make_chapter( + "pre: Intrinsic Line Breaks", + """ +

    The block below is a single <pre> element. Each source line must appear on its own line:

    +
    Line one
    +Line two
    +Line three
    +

    Normal paragraph after the pre block. Text resumes as a regular wrapped paragraph.

    +""", + ), + [], + ), + ( + "2. pre Blank Lines", + make_chapter( + "pre: Blank Lines", + """ +

    The pre block below contains intentional blank lines between code lines:

    +
    First line
    +
    +Third line (blank line above)
    +
    +Fifth line (blank line above)
    +

    Each blank line in the source should produce an empty line in the output.

    +""", + ), + [], + ), + ( + "3. pre with code", + make_chapter( + "pre with Nested code Element", + """ +

    Common EPUB pattern: <pre><code>...</code></pre>. Line breaks must still be preserved:

    +
    function greet(name) {
    +    return "Hello, " + name;
    +}
    +
    +greet("World");
    +

    Normal paragraph after pre/code block.

    +""", + ), + [], + ), + ] + + create_epub( + OUTPUT_DIR / "test_text_rendering.epub", + "Text Rendering Tests", + text_chapters, + ) print(f"\nTest EPUBs created in: {OUTPUT_DIR}") print("Files:") - for f in OUTPUT_DIR.glob('*.epub'): + for f in OUTPUT_DIR.glob("*.epub"): print(f" - {f.name}") -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/test/epubs/test_text_rendering.epub b/test/epubs/test_text_rendering.epub new file mode 100644 index 00000000..2067f55c Binary files /dev/null and b/test/epubs/test_text_rendering.epub differ