Layout was dominated by work that was either repeated per word or thrown
away. Rendered output is unchanged: every page hashed byte-for-byte
identical across 3 page sizes, 2 font scales, 2 font families, and 4
alignments x 4 column widths chosen to force heavy hyphenation.
layout, 600x800, 40 pages ~200ms -> ~124ms
layout, 1404x1872, 11 pages ~168ms -> ~111ms
Measured, not guessed. The reflex fix - swapping list comprehensions for
generators - measures slower here (602ns vs 425ns for the width sum), so
those are left alone.
- Line asked its font for the advance width of a space on every
construction. FreeTypeFont.getlength(" ") costs ~18us, two orders of
magnitude more than getmetrics(), and it landed once per line. Memoise
per font object.
- Line.add_word was quadratic in the words on a line. Each candidate word
re-summed every width and rebuilt the whole per-gap spacing list, when
fitting only ever reads the first gap. Gap spacings are now a plan
materialised on demand (only render() reads the list), and widths come
from a prefix sum. The prefix list, rather than one accumulator, is what
keeps this exact: widths are floats, (total + w) - w need not give back
total, and one ulp flips an overflow decision on a line that ends flush.
- RenderingPosition.copy/__eq__/__hash__ all went through
dataclasses.asdict, a deep recursive walk, over 8 immutable scalars.
- paragraph_layouter built a Text per line purely to discard it.
- AbstractStyle.__hash__ rebuilt a 15-tuple containing 5 enums on every
dict lookup; memoised on the frozen instance (1037ns -> 160ns).
- The pyphen dictionary wrapper was rebuilt for every word that overflowed
its line, and word extraction stripped before splitting and tested each
split result for emptiness, neither of which str.split() needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PyWebLayout
Project Status
📋 Note: Badges show results from the commit referenced in the URLs. Red "error" badges indicate build failures for that specific step.
Description
PyWebLayout is a Python library for HTML-like layout and rendering to paginated images. It provides a flexible page rendering system with support for borders, padding, text layout, and HTML parsing.
Key Features
Page Rendering System
- 📄 Flexible Page Layouts - Create pages with customizable sizes, borders, and padding
- 🎨 Styling System - Control backgrounds, border colors, and spacing
- 📐 Multiple Layouts - Support for portrait, landscape, and square pages
- 🖼️ Image Output - Render pages to PIL Images (PNG, JPEG, etc.)
Text and HTML Support
- 📝 HTML Parsing - Parse HTML content into structured document blocks
- 🔤 Font Support - Multiple font sizes, weights, and styles
- 🎨 Dynamic Font Families - Switch between Sans, Serif, and Monospace fonts on-the-fly
- ↔️ Text Alignment - Left, center, right, and justified text
- 📖 Rich Content - Headings, paragraphs, bold, italic, and more
- 📊 Table Rendering - Full HTML table support with headers, borders, and styling
- 🔘 Interactive Elements - Buttons, forms, and links with callback support
Architecture
- Abstract/Concrete Separation - Clean separation between content structure and rendering
- Extensible Design - Easy to extend with custom renderables
- Type-safe - Comprehensive type hints throughout the codebase
Installation
pip install pyWebLayout
Quick Start
Basic Page Rendering
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
# Create a styled page
page_style = PageStyle(
border_width=2,
border_color=(200, 200, 200),
padding=(30, 30, 30, 30), # top, right, bottom, left
background_color=(255, 255, 255)
)
page = Page(size=(600, 800), style=page_style)
# Render to image
image = page.render()
image.save("my_page.png")
HTML Content Parsing
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.style import Font
# Parse HTML to structured blocks
html = """
<h1>Document Title</h1>
<p>First paragraph with <b>bold</b> text.</p>
<p>Second paragraph with more content.</p>
"""
base_font = Font(font_size=14)
blocks = parse_html_string(html, base_font=base_font)
# blocks is a list of structured content (Paragraph, Heading, etc.)
Visual Examples
The library supports various page layouts and configurations:
Examples
The examples/ directory contains working demonstrations:
Getting Started
- 01_simple_page_rendering.py - Introduction to the Page system
- 02_text_and_layout.py - HTML parsing and text rendering
- 03_page_layouts.py - Different page configurations
- 04_table_rendering.py - HTML table rendering with styling
- 05_html_table_with_images.py - Tables with embedded images
- 06_functional_elements_demo.py - Interactive buttons and forms with callbacks
- 08_bundled_fonts_demo.py - Using the bundled DejaVu font families
🆕 Advanced Features (NEW)
- 08_pagination_demo.py - Multi-page documents with PageBreak (11 tests)
- 09_link_navigation_demo.py - All link types and navigation (10 tests)
- 10_forms_demo.py - All 14 form field types (9 tests)
- 11_font_family_switching_demo.py - 🆕 Dynamic font switching in ereader
Run any example:
cd examples
python 01_simple_page_rendering.py
python 08_pagination_demo.py # NEW: Multi-page documents
All new examples include comprehensive test coverage! Run tests with:
python -m pytest tests/examples/ -v # 30 tests, all passing ✅
Coverage Impact: The new examples fill critical documentation gaps:
- PageBreak: 0% → 100% (had NO examples before)
- LinkText: 14% → 100% (all 4 link types demonstrated)
- FormFields: 14% → 100% (all 14 field types demonstrated)
See examples/README.md for detailed documentation.
Font Family Switching (NEW ✨)
PyWebLayout now supports dynamic font family switching in the ereader, allowing readers to change fonts on-the-fly without losing their reading position!
Quick Example
from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.ereader_manager import create_ereader_manager
# Create an ereader
manager = create_ereader_manager(blocks, page_size=(600, 800))
# Switch to serif font
manager.set_font_family(BundledFont.SERIF)
# Switch to monospace font
manager.set_font_family(BundledFont.MONOSPACE)
# Restore original fonts
manager.set_font_family(None)
# Query current font
current = manager.get_font_family()
Features
- 3 Bundled Fonts: Sans, Serif, and Monospace (DejaVu font family)
- Instant Switching: Change fonts without recreating the document
- Position Preservation: Reading position maintained across font changes
- Attribute Preservation: Bold, italic, size, and color are preserved
- Smart Caching: Automatic cache invalidation for optimal performance
Learn more: See FONT_SWITCHING_FEATURE.md for complete documentation.
Documentation
- ARCHITECTURE.md - Abstract/Concrete architecture guide
- FONT_SWITCHING_FEATURE.md - 🆕 Font family switching guide
- examples/README.md - Complete examples guide with tests
- docs/images/README.md - Visual documentation index
- pyWebLayout/layout/README_EREADER_API.md - EbookReader API reference
- API Reference - See docstrings in source code
Continuous integration
CI runs in a prebuilt container image rather than installing dependencies per
job. The image carries Python 3.10, 3.11, 3.12 and 3.13, each in its own venv at
/opt/py<version> with every dependency installed, so a run downloads nothing
and the test matrix covers the whole range pyproject.toml claims to support.
Rebuild and push the image whenever Dockerfile.ci changes — most often
because a dependency was added to pyproject.toml:
docker build -f Dockerfile.ci -t gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest .
docker push gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest
To reproduce a CI job locally:
docker run --rm -v "$PWD:/src:ro" gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest bash -c '
mkdir -p /work && cp -a /src/. /work/ && cd /work && rm -rf venv .git
/opt/py3.13/bin/pip install -e . --no-deps -q
/opt/py3.13/bin/python -m pytest tests/ -q'
The workflow is .gitea/workflows/ci.yml. Badges and coverage artifacts are published from the 3.13 leg only.
License
MIT License
Author
Duncan Tourolle - duncan@tourolle.paris








