diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 368a4c60..93b5cf5b 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -20,7 +20,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"}; @@ -582,6 +582,8 @@ 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) { + self->preUntilDepth = std::min(self->preUntilDepth, self->depth); } } } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { @@ -709,6 +711,15 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char for (int i = 0; i < len; i++) { if (isWhitespace(s[i])) { + // Inside
: treat newline as a hard line break
+ if (s[i] == '\n' && self->preUntilDepth < self->depth) {
+ if (self->partWordBufferIndex > 0) {
+ self->flushPartWordBuffer();
+ }
+ 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();
@@ -943,6 +954,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) {
diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
index 1cc0ea39..be52826f 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;
// 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'