Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc4230713 | ||
|
|
0bb34a4a32 | ||
|
|
745fc8687e | ||
|
|
3761e00398 | ||
|
|
0ce1aeaa87 | ||
|
|
8746d3f549 | ||
|
|
bcae45a023 | ||
|
|
e81ba48f6d | ||
|
|
62ca15159a | ||
|
|
f0dc67541b | ||
|
|
1924cc234d | ||
|
|
456824d6d6 | ||
|
|
767e4c135c | ||
|
|
7384a32cdd | ||
|
|
4d596ce095 | ||
|
|
737cf0771c | ||
|
|
1985163827 | ||
|
|
c5c61a3503 | ||
|
|
202dacf350 | ||
|
|
284d521125 | ||
|
|
7bebe08432 | ||
|
|
1262be6a38 | ||
|
|
f18cec2da8 | ||
|
|
a57da8011e | ||
|
|
583366ae1d | ||
|
|
e000068384 | ||
|
|
2a543d0319 | ||
|
|
23d3278b50 | ||
|
|
3bcd1bffb5 | ||
|
|
889f27e1a3 | ||
|
|
9de67d958e | ||
|
|
41dc904755 | ||
|
|
8e720d4037 | ||
|
|
5afad2ca07 | ||
|
|
303179865d | ||
|
|
fb52178cc6 | ||
|
|
890a0e768b | ||
|
|
a8e459bce5 | ||
|
|
9fb6792e10 | ||
|
|
40c1b913ec | ||
|
|
cc34c79495 | ||
|
|
12ebddaa79 | ||
|
|
2b14517344 | ||
|
|
849ba2f60f | ||
|
|
9ae8ddddca | ||
|
|
50b9aa5431 | ||
|
|
56c2c21021 | ||
|
|
73700baf87 | ||
|
|
8b833eef0b | ||
|
|
78745c4e29 |
@@ -0,0 +1,43 @@
|
||||
# Dockerfile.ci copies nothing from the context, but keeping it small makes
|
||||
# `docker build` fast and avoids shipping local state into the build.
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Python cache and build output
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# Test/coverage output
|
||||
.coverage
|
||||
coverage.json
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
cov_info/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.claude/
|
||||
|
||||
# Generated docs/images
|
||||
docs/images/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -11,166 +11,167 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
# Built from Dockerfile.ci at the repo root. Carries Python 3.10-3.13,
|
||||
# each in its own venv at /opt/py<version> with every dependency
|
||||
# pre-installed, so a run downloads nothing.
|
||||
image: gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13']
|
||||
fail-fast: false
|
||||
|
||||
env:
|
||||
# pyWebLayout is a library: it is tested on every interpreter
|
||||
# pyproject.toml's requires-python claims to support.
|
||||
PYBIN: /opt/py${{ matrix.python-version }}/bin
|
||||
# Badges and artifacts are published once, not once per matrix leg -
|
||||
# four jobs racing to force-push the same branch is not a publish
|
||||
# strategy. This leg is the one that publishes.
|
||||
PUBLISH: ${{ matrix.python-version == '3.13' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
- name: Install project
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install package in development mode
|
||||
pip install -e .
|
||||
# Install test dependencies if they exist
|
||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
|
||||
if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi
|
||||
# Install common test packages
|
||||
pip install pytest pytest-cov flake8 coverage-badge interrogate
|
||||
|
||||
- name: Download initial failed badges
|
||||
# --no-deps: dependencies are baked into the image. If a new one is
|
||||
# added to pyproject.toml, add it to Dockerfile.ci and rebuild;
|
||||
# the check below is what catches forgetting to.
|
||||
$PYBIN/pip install -e . --no-deps
|
||||
$PYBIN/python -c "import pyWebLayout; print('pyWebLayout', pyWebLayout.__file__)"
|
||||
|
||||
- name: Verify declared dependencies are sufficient
|
||||
if: env.PUBLISH == 'true'
|
||||
run: |
|
||||
echo "Downloading initial failed badges..."
|
||||
|
||||
# Create cov_info directory first
|
||||
mkdir -p cov_info
|
||||
|
||||
# Download failed badges as defaults
|
||||
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
|
||||
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
|
||||
|
||||
echo "Initial failed badges created:"
|
||||
ls -la cov_info/coverage*.svg
|
||||
|
||||
# A clean venv with ONLY the declared runtime deps, installed from
|
||||
# the index rather than from the image. If an import here fails,
|
||||
# pyproject.toml is incomplete and a real `pip install pyWebLayout`
|
||||
# fails the same way for a user. This is the one step that is
|
||||
# allowed to reach the network.
|
||||
$PYBIN/python -m venv /tmp/clean-install
|
||||
/tmp/clean-install/bin/pip install --upgrade pip
|
||||
/tmp/clean-install/bin/pip install .
|
||||
/tmp/clean-install/bin/python -c "
|
||||
import pyWebLayout.concrete, pyWebLayout.abstract
|
||||
import pyWebLayout.io.readers.epub_reader
|
||||
import pyWebLayout.io.readers.html_extraction
|
||||
import pyWebLayout.layout.ereader_manager
|
||||
print('clean install imports OK')
|
||||
"
|
||||
|
||||
- name: Run tests with pytest
|
||||
id: pytest
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Run tests with coverage
|
||||
python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
|
||||
|
||||
$PYBIN/python -m pytest tests/ -v \
|
||||
--cov=pyWebLayout \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=json \
|
||||
--cov-report=html \
|
||||
--cov-report=xml
|
||||
|
||||
- name: Check documentation coverage
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Generate documentation coverage report
|
||||
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyWebLayout/
|
||||
|
||||
$PYBIN/interrogate -v \
|
||||
--ignore-init-method --ignore-init-module --ignore-magic \
|
||||
--ignore-private --ignore-property-decorators --ignore-semiprivate \
|
||||
--fail-under=80 pyWebLayout/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# Stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
$PYBIN/flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# Exit-zero treats all errors as warnings
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
- name: Create coverage info directory
|
||||
if: always()
|
||||
$PYBIN/flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
- name: Fail the job if tests failed
|
||||
if: steps.pytest.outcome != 'success'
|
||||
run: |
|
||||
# pytest runs with continue-on-error so the badge steps below still
|
||||
# execute; without this the job would report green on a red suite.
|
||||
echo "::error::pytest failed on Python ${{ matrix.python-version }}"
|
||||
exit 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Badges and artifacts - publishing leg only
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
- name: Prepare badge directory
|
||||
if: always() && env.PUBLISH == 'true'
|
||||
run: |
|
||||
mkdir -p cov_info
|
||||
echo "Created cov_info directory for coverage data"
|
||||
|
||||
# Default to failed badges; the steps below overwrite them on success
|
||||
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
|
||||
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
|
||||
|
||||
- name: Update test coverage badge on success
|
||||
if: steps.pytest.outcome == 'success' && always()
|
||||
if: always() && env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
echo "Tests passed! Generating successful coverage badge..."
|
||||
|
||||
if [ -f coverage.json ]; then
|
||||
coverage-badge -o cov_info/coverage.svg -f
|
||||
echo "✅ Test coverage badge updated with actual results"
|
||||
$PYBIN/coverage-badge -o cov_info/coverage.svg -f
|
||||
echo "✅ Test coverage badge updated"
|
||||
else
|
||||
echo "⚠️ No coverage.json found, keeping failed badge"
|
||||
fi
|
||||
|
||||
- name: Update docs coverage badge on success
|
||||
if: steps.docs.outcome == 'success' && always()
|
||||
|
||||
- name: Update docs coverage badge on success
|
||||
if: always() && env.PUBLISH == 'true' && steps.docs.outcome == 'success'
|
||||
run: |
|
||||
echo "Docs check passed! Generating successful docs badge..."
|
||||
|
||||
# Remove existing badge first to avoid overwrite error
|
||||
rm -f cov_info/coverage-docs.svg
|
||||
interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
|
||||
echo "✅ Docs coverage badge updated with actual results"
|
||||
|
||||
$PYBIN/interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
|
||||
echo "✅ Docs coverage badge updated"
|
||||
|
||||
- name: Generate coverage reports
|
||||
if: steps.pytest.outcome == 'success'
|
||||
if: env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
# Generate coverage summary for README
|
||||
python -c "
|
||||
import json
|
||||
import os
|
||||
# Read coverage data
|
||||
$PYBIN/python -c "
|
||||
import json, os
|
||||
if os.path.exists('coverage.json'):
|
||||
with open('coverage.json', 'r') as f:
|
||||
coverage_data = json.load(f)
|
||||
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
|
||||
# Create coverage summary file in cov_info directory
|
||||
with open('coverage.json') as f:
|
||||
data = json.load(f)
|
||||
total = round(data['totals']['percent_covered'], 1)
|
||||
with open('cov_info/coverage-summary.txt', 'w') as f:
|
||||
f.write(f'{total_coverage}%')
|
||||
print(f'Test Coverage: {total_coverage}%')
|
||||
covered_lines = coverage_data['totals']['covered_lines']
|
||||
total_lines = coverage_data['totals']['num_statements']
|
||||
print(f'Lines Covered: {covered_lines}/{total_lines}')
|
||||
f.write(f'{total}%')
|
||||
print(f\"Test Coverage: {total}%\")
|
||||
print(f\"Lines Covered: {data['totals']['covered_lines']}/{data['totals']['num_statements']}\")
|
||||
else:
|
||||
print('No coverage data found')
|
||||
"
|
||||
|
||||
# Copy other coverage files to cov_info
|
||||
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
|
||||
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
|
||||
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
|
||||
|
||||
|
||||
- name: Final badge status
|
||||
if: always()
|
||||
if: always() && env.PUBLISH == 'true'
|
||||
run: |
|
||||
echo "=== FINAL BADGE STATUS ==="
|
||||
echo "Test outcome: ${{ steps.pytest.outcome }}"
|
||||
echo "Docs outcome: ${{ steps.docs.outcome }}"
|
||||
|
||||
if [ -f cov_info/coverage.svg ]; then
|
||||
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
|
||||
else
|
||||
echo "❌ Test coverage badge: MISSING"
|
||||
fi
|
||||
|
||||
if [ -f cov_info/coverage-docs.svg ]; then
|
||||
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
|
||||
else
|
||||
echo "❌ Docs coverage badge: MISSING"
|
||||
fi
|
||||
|
||||
echo "Coverage info directory contents:"
|
||||
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
|
||||
|
||||
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory"
|
||||
|
||||
- name: Upload coverage artifacts
|
||||
if: always() && env.PUBLISH == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
cov_info/
|
||||
|
||||
path: cov_info/
|
||||
|
||||
- name: Commit badges to badges branch
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: env.PUBLISH == 'true' && github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
git config --local user.email "action@gitea.local"
|
||||
git config --local user.name "Gitea Action"
|
||||
|
||||
# Set the remote URL to use the token
|
||||
|
||||
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyWebLayout.git
|
||||
|
||||
# Create a new orphan branch for badges (this discards any existing badges branch)
|
||||
|
||||
# Orphan branch holding only the badges, force-pushed each time
|
||||
git checkout --orphan badges
|
||||
|
||||
# Remove all files except cov_info
|
||||
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Add only the coverage info directory
|
||||
git add -f cov_info/
|
||||
|
||||
# Always commit (force overwrite)
|
||||
echo "Force updating badges branch with new coverage data..."
|
||||
git commit -m "Update coverage badges [skip ci]"
|
||||
git push -f origin badges
|
||||
|
||||
@@ -45,9 +45,12 @@ test_output/
|
||||
examples/output/
|
||||
|
||||
# Generated data
|
||||
|
||||
bookmarks/
|
||||
positions/
|
||||
|
||||
# Profiling scripts
|
||||
profile_*.py
|
||||
|
||||
# Debug scripts output
|
||||
debug_*.png
|
||||
.fish*
|
||||
@@ -1,233 +1,348 @@
|
||||
# pyWebLayout Architecture: Abstract vs Concrete
|
||||
# pyWebLayout Architecture
|
||||
|
||||
This document explains the fundamental architectural separation between **Abstract** and **Concrete** layers in the pyWebLayout library.
|
||||
This document describes how pyWebLayout is organised: the layers, what lives in each,
|
||||
and the rules that govern how they may depend on one another.
|
||||
|
||||
## Overview
|
||||
|
||||
The pyWebLayout library follows a clear separation between two distinct layers:
|
||||
The library turns markup (HTML/EPUB) into rendered images. That pipeline is split into
|
||||
layers with a strict dependency direction:
|
||||
|
||||
- **Abstract Layer**: Represents the logical structure and content of documents (HTML/EPUB text)
|
||||
- **Concrete Layer**: Handles the spatial rendering and visual representation of content
|
||||
```
|
||||
io/readers/ parse markup into document structure
|
||||
↓
|
||||
abstract/ what the document says ─┐
|
||||
↓ ├─ built on core/ + style/
|
||||
layout/ decide where things go │
|
||||
↓ │
|
||||
concrete/ what the pixels are ─┘
|
||||
↓
|
||||
PIL.Image
|
||||
```
|
||||
|
||||
This separation provides flexibility, testability, and clean separation of concerns.
|
||||
The central distinction is **abstract vs concrete**:
|
||||
|
||||
## Abstract Layer (`pyWebLayout/abstract/`)
|
||||
- **Abstract** — the logical content and structure of a document. A `Paragraph` knows
|
||||
it contains words in an order; it does not know how wide they are.
|
||||
- **Concrete** — the spatial realisation of that content. A `Line` knows exactly which
|
||||
glyphs sit at which pixel offsets on a specific canvas.
|
||||
|
||||
The Abstract layer deals with the **logical structure** of documents without concerning itself with how content will be visually rendered.
|
||||
One abstract document produces many concrete renderings: different page sizes, font
|
||||
scales, and font families all re-run the concrete layer over unchanged abstract content.
|
||||
That is what makes ereader features like live font scaling and reflow possible.
|
||||
|
||||
### Key Components
|
||||
## Layers
|
||||
|
||||
#### `abstract/block.py`
|
||||
- `Block`: Base class for all block-level content
|
||||
- `Paragraph`: Represents a logical paragraph containing words
|
||||
- `Heading`: Represents headings with semantic levels (H1-H6)
|
||||
- `HList`: Represents ordered/unordered lists
|
||||
- `Image`: Represents image references
|
||||
### `core/` — shared foundations
|
||||
|
||||
#### `abstract/inline.py`
|
||||
- `Word`: Represents individual words with text content and styling information
|
||||
- Contains methods for hyphenation and text manipulation
|
||||
- Does **not** handle rendering or spatial layout
|
||||
Everything else is built on these. `core/` depends on nothing but `style/`.
|
||||
|
||||
#### `abstract/document.py`
|
||||
- `Document`: Container for the overall document structure
|
||||
- `Chapter`: Logical grouping of blocks (for books/long documents)
|
||||
**`core/base.py`** defines the contracts that make a class abstract or concrete:
|
||||
|
||||
### Characteristics of Abstract Classes
|
||||
| Contract | Kind | Meaning |
|
||||
|---|---|---|
|
||||
| `Renderable` | ABC | Has `render()`; produces or draws visual output |
|
||||
| `Queriable` | ABC | Has `in_object(point)`; can be hit-tested |
|
||||
| `Layoutable` | ABC | Has `layout()`; arranges its own contents |
|
||||
| `Interactable` | ABC | Holds a callback invoked on interaction |
|
||||
| `Geometric` | mixin | `origin` and `size` as numpy arrays |
|
||||
| `Hierarchical` | mixin | `parent` back-reference |
|
||||
| `Styleable` | mixin | Carries a style object |
|
||||
| `FontRegistry` | mixin | Deduplicates `Font` instances across a document |
|
||||
| `MetadataContainer` | mixin | Key/value metadata with typed accessors |
|
||||
| `BlockContainer` | mixin | Holds child `Block`s |
|
||||
| `ContainerAware` | mixin | Knows the container it was added to |
|
||||
|
||||
1. **Content-focused**: Store text, structure, and semantic meaning
|
||||
2. **Layout-agnostic**: No knowledge of fonts, pixels, or rendering
|
||||
3. **Reusable**: Same content can be rendered in different formats/sizes
|
||||
4. **Serializable**: Can be saved/loaded without rendering context
|
||||
The useful shorthand: **`Renderable` and `Geometric` are the concrete markers.** An
|
||||
abstract class that acquires either has crossed the line.
|
||||
|
||||
### Example: Abstract Word
|
||||
**`core/query.py`** — `QueryResult` and `SelectionRange`: the result types for mapping a
|
||||
pixel back to content (what was clicked, what text is selected, where it is in the
|
||||
document).
|
||||
|
||||
**`core/highlight.py`** — `Highlight`, `HighlightColor`, `HighlightManager`. Highlights
|
||||
store both pixel bounds (for drawing) and semantic bounds (word indices, for surviving
|
||||
a font change).
|
||||
|
||||
**`core/cache.py`** — `UsageCache` / `SizedUsageCache`, bounded caches keyed by
|
||||
`(font, string)` for text measurement and glyph rasterisation. Eviction is by usage
|
||||
count with periodic aging, not LRU; the module docstring explains why at length. This
|
||||
exists because a single page issues thousands of measurements over fewer than a
|
||||
thousand distinct pairs, on hardware where an unbounded cache is not affordable.
|
||||
|
||||
**`core/callback_registry.py`** — `CallbackRegistry`, which owns the interactive
|
||||
elements registered on a page and dispatches to them.
|
||||
|
||||
### `style/` — semantic styling and its resolution
|
||||
|
||||
This package is itself an instance of the abstract/concrete split, applied to styling:
|
||||
|
||||
- **`abstract_style.py`** — `AbstractStyle` (frozen dataclass, hashable) captures
|
||||
*intent*: `FontFamily.SERIF`, `FontSize.LARGE`, `color="black"`. `AbstractStyleRegistry`
|
||||
interns them so a document holds one instance per distinct style.
|
||||
- **`concrete_style.py`** — `RenderingContext` (user preferences, DPI, accessibility
|
||||
flags, available space) plus `StyleResolver`, which maps `AbstractStyle` +
|
||||
`RenderingContext` → `ConcreteStyle` (a resolved font path, a pixel size, an RGB
|
||||
tuple). `ConcreteStyleRegistry` caches the results.
|
||||
- **`fonts.py`** — `Font`, the loaded PIL font plus its rendering attributes, and the
|
||||
bundled DejaVu families (`BundledFont`).
|
||||
- **`page_style.py`** — `PageStyle`: borders, padding, background, default alignment.
|
||||
- **`alignment.py`** — the `Alignment` enum.
|
||||
|
||||
`AbstractStyle` → `StyleResolver` → `ConcreteStyle` is the mechanism by which the same
|
||||
document renders differently for different readers.
|
||||
|
||||
### `abstract/` — document content and structure
|
||||
|
||||
Layout-agnostic representations of what the document contains.
|
||||
|
||||
**`abstract/block.py`** — block-level content, all deriving from `Block`:
|
||||
|
||||
`Paragraph`, `Heading` (with `HeadingLevel`), `Quote`, `CodeBlock`, `HList` (with
|
||||
`ListStyle`) and `ListItem`, `Table` / `TableRow` / `TableCell`, `Image` and
|
||||
`LinkedImage`, `HorizontalRule`, `PageBreak`.
|
||||
|
||||
**`abstract/inline.py`** — `Word`, `FormattedSpan`, `LinkedWord`, `LineBreak`. `Word`
|
||||
holds text, a style, and previous/next links into the document's word sequence. It can
|
||||
report whether it is a hyphenation candidate (`possible_hyphenation(language)`), but it
|
||||
does not perform the split — that is a measurement decision and belongs to `Line`.
|
||||
|
||||
**`abstract/document.py`** — `Document`, `Chapter`, `Book`, `MetadataType`. `Book` is
|
||||
the EPUB-shaped `Document` with chapters and a table of contents.
|
||||
|
||||
**`abstract/functional.py`** — `Link`, `Button`, `Form`, `FormField`, `LinkType`,
|
||||
`FormFieldType`.
|
||||
|
||||
**`abstract/interactive_image.py`** — `InteractiveImage`, an `Image` that is also
|
||||
`Interactable` and `Queriable`.
|
||||
|
||||
### `concrete/` — spatial realisation
|
||||
|
||||
Objects that know their position and size and can draw themselves onto a canvas.
|
||||
|
||||
**`concrete/text.py`** — the heart of text layout:
|
||||
- `Text` — one renderable fragment (a whole word, or a hyphenated part). Requires an
|
||||
`ImageDraw.Draw` at construction so it can measure itself immediately.
|
||||
- `Line` — a sequence of `Text` objects with resolved spacing and a baseline.
|
||||
`Line.add_word()` is where a `Word` becomes one or two `Text` objects: it measures,
|
||||
and if the word overflows it tries dictionary hyphenation, then brute-force splitting,
|
||||
before rejecting the word.
|
||||
- `AlignmentHandler` and its subclasses (`LeftAlignmentHandler`,
|
||||
`CenterRightAlignmentHandler`, `JustifyAlignmentHandler`) — the strategy objects that
|
||||
turn a set of measured fragments plus an available width into concrete spacing and a
|
||||
start position.
|
||||
- Cache management: `configure_text_caches`, `clear_text_caches`, `text_cache_stats`,
|
||||
`prewarm_text_caches`.
|
||||
|
||||
**`concrete/page.py`** — `Page`: a fixed-size canvas holding `Renderable` children, with
|
||||
a content rectangle derived from its `PageStyle`, `can_fit_line()` for the layouter to
|
||||
test against, `render()` returning a `PIL.Image`, and `query_point()` / `query_range()`
|
||||
for hit-testing.
|
||||
|
||||
**`concrete/box.py`** — `Box`, the `Geometric` + `Renderable` + `Queriable` base for
|
||||
positioned drawable objects.
|
||||
|
||||
**`concrete/dynamic_page.py`** — `DynamicPage` (a `Page` subclass) and `SizeConstraints`.
|
||||
Adds a two-phase measure-then-layout protocol, so a container such as a table can learn
|
||||
its content's intrinsic size before committing to a size allocation.
|
||||
|
||||
**`concrete/image.py`** — `RenderableImage`.
|
||||
|
||||
**`concrete/table.py`** — `TableRenderer`, `TableRowRenderer`, `TableCellRenderer`,
|
||||
`TableStyle`. Cells host their own nested `Page`, which is why `Page` accepts a
|
||||
non-zero `origin`.
|
||||
|
||||
**`concrete/functional.py`** — `LinkText`, `ButtonText`, `FormFieldText`: `Text`
|
||||
subclasses that are also `Interactable`.
|
||||
|
||||
**`concrete/interaction_handler.py`** — `InteractionHandler`, `InteractionStateManager`:
|
||||
routing taps and presses to registered elements and tracking pressed/released state.
|
||||
|
||||
### `layout/` — the abstract → concrete transformation
|
||||
|
||||
This is the package the pipeline diagram calls the layout engine.
|
||||
|
||||
**`layout/document_layouter.py`** — a set of layouter functions, one per content kind,
|
||||
all sharing a signature shape of *(abstract element, target `Page`) → did it fit*:
|
||||
|
||||
```python
|
||||
# An Abstract Word knows its text content and semantic properties
|
||||
word = Word("supercalifragilisticexpialidocious", font_style)
|
||||
word.hyphenate() # Logical operation - finds break points
|
||||
parts = word.get_hyphenated_parts() # Returns ["super-", "cali-", "fragi-", ...]
|
||||
paragraph_layouter(paragraph, page, start_word=0, pretext=None, alignment_override=None)
|
||||
-> (complete: bool, failed_word_index: int | None, remaining_pretext: Text | None)
|
||||
|
||||
image_layouter(image, page, max_width=None, max_height=None) -> bool
|
||||
table_layouter(table, page, style=None) -> bool
|
||||
pagebreak_layouter(page_break, page) -> bool
|
||||
button_layouter(button, page, font=None, padding=...) -> (bool, str)
|
||||
form_field_layouter(field, page, font=None, ...) -> ...
|
||||
form_layouter(form, page, font=None, field_spacing=10) -> (bool, list[str])
|
||||
```
|
||||
|
||||
## Concrete Layer (`pyWebLayout/concrete/`)
|
||||
They **append to a page**, they do not return a list of lines. The three-part return of
|
||||
`paragraph_layouter` is what makes pagination resumable: when a paragraph runs off the
|
||||
bottom of a page, the caller learns which word failed and whether a hyphenated fragment
|
||||
is pending, and can continue on the next page from exactly there.
|
||||
|
||||
The Concrete layer handles the **spatial representation** and actual rendering of content.
|
||||
`DocumentLayouter` wraps a `Page` and dispatches over a list of abstract elements by
|
||||
type, holding the `ConcreteStyleRegistry` for the run.
|
||||
|
||||
### Key Components
|
||||
**`layout/ereader_layout.py`** — the paginated reading model:
|
||||
- `RenderingPosition` — a serialisable cursor expressed in *abstract* coordinates
|
||||
(chapter, block, word, table cell, list item, pending pretext). Because it names
|
||||
document structure rather than pixels, it survives font-size and page-size changes.
|
||||
- `BidirectionalLayouter` — renders a page forward or backward from a position,
|
||||
returning `(Page, next_position)`.
|
||||
- `ChapterNavigator` / `ChapterInfo` — a table of contents built from heading structure.
|
||||
- `FontScaler`, `FontFamilyOverride` — apply scale and family changes to blocks at
|
||||
layout time without mutating the abstract document.
|
||||
|
||||
#### `concrete/text.py`
|
||||
- `Text`: Renders a specific text fragment with precise positioning
|
||||
- `Line`: Manages a line of `Text` objects with spacing and alignment
|
||||
- Handles actual pixel measurements, font rendering, and positioning
|
||||
**`layout/page_buffer.py`** — `PageBuffer` (LRU caches of rendered pages in both
|
||||
directions, plus position maps) and `BufferedPageRenderer` (background rendering).
|
||||
|
||||
#### `concrete/page.py`
|
||||
- `Page`: Top-level container for rendered content
|
||||
- `Container`: Layout manager for organizing renderable objects
|
||||
- Handles spatial layout, pagination, and visual composition
|
||||
**`layout/ereader_manager.py`** — `EreaderLayoutManager`, the top-level application
|
||||
interface (page turns, font changes, chapter jumps, progress), and `BookmarkManager`
|
||||
for persisting bookmarks and the last reading position.
|
||||
|
||||
#### `concrete/box.py`
|
||||
- `Box`: Base class for all spatially-aware renderable objects
|
||||
- Provides positioning, sizing, and rendering capabilities
|
||||
**`layout/table_optimizer.py`** — column width allocation for tables.
|
||||
|
||||
### Characteristics of Concrete Classes
|
||||
### `io/readers/` — parsing
|
||||
|
||||
1. **Rendering-focused**: Handle pixels, fonts, images, and visual output
|
||||
2. **Spatially-aware**: Know exact positions, sizes, and layout constraints
|
||||
3. **Implementation-specific**: Tied to specific rendering technologies (PIL, etc.)
|
||||
4. **Non-portable**: Rendering results are tied to specific display contexts
|
||||
**`html_extraction.py`** — `parse_html_string(html, base_font=None, document=None,
|
||||
base_path=None) -> List[Block]`. Built from a `StyleContext` (a `NamedTuple` threaded
|
||||
down the tree, carrying the inherited font and styling) and a table of per-tag handlers
|
||||
(`paragraph_handler`, `heading_handler`, `table_handler`, …). This is the only place
|
||||
that knows about HTML.
|
||||
|
||||
### Example: Concrete Text
|
||||
**`epub_reader.py`** — `EPUBReader` and `read_epub(path) -> Book`: container/OPF
|
||||
parsing, spine and manifest, table of contents, cover handling, and image processing
|
||||
(with an e-ink processor available by default).
|
||||
|
||||
## Dependency rules
|
||||
|
||||
The direction of dependency is what keeps the split honest:
|
||||
|
||||
```
|
||||
io/readers → abstract → core, style
|
||||
layout → abstract, concrete, core, style
|
||||
concrete → abstract, core, style
|
||||
abstract → core, style ← must NOT import concrete
|
||||
```
|
||||
|
||||
`abstract/` importing from `concrete/` is the violation to watch for. `concrete/`
|
||||
importing from `abstract/` is expected and correct: a `Text` may point back at the
|
||||
`Word` it came from, and a table cell renderer reads its abstract `TableCell`.
|
||||
|
||||
**Where the abstract layer touches rendering today, and why:**
|
||||
|
||||
- Abstract classes are constructed with `Font` objects, which carry a pixel size and a
|
||||
loaded font file. This is a deliberate compromise for parsing performance (HTML
|
||||
styling resolves to a `Font` once, at parse time) but it does mean the abstract layer
|
||||
is not fully rendering-independent. `AbstractStyle` is the intended replacement, and
|
||||
`Word` already accepts either.
|
||||
- `Word.concrete` / `Word.add_concete()` ([inline.py:42](pyWebLayout/abstract/inline.py#L42),
|
||||
[:134](pyWebLayout/abstract/inline.py#L134)) is a back-reference to the `Text` objects
|
||||
a word became. **It is currently written and never read**, and it cannot correctly
|
||||
model the relationship anyway: one `Word` becomes many `Text`s across re-layouts, and
|
||||
a single slot only remembers the most recent. Treat it as vestigial. When a
|
||||
word→pixels mapping is genuinely needed, build it on `Text`'s `source` back-reference
|
||||
(concrete pointing at abstract), which is the safe direction — note it is currently
|
||||
stored as `_source` with no public accessor, and is itself unread today.
|
||||
|
||||
## Worked example
|
||||
|
||||
```python
|
||||
# A Concrete Text object handles actual rendering
|
||||
text = Text("super-", font) # Specific text fragment
|
||||
text._calculate_dimensions() # Computes exact pixel size
|
||||
image = text.render() # Produces actual visual output
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
# 1. Parse: markup -> abstract blocks
|
||||
blocks = parse_html_string("<h1>Chapter One</h1><p>It was a dark and stormy night.</p>")
|
||||
# [Heading, Paragraph]
|
||||
|
||||
# 2. Lay out: abstract blocks -> concrete children appended to a Page
|
||||
page = Page(size=(400, 600))
|
||||
DocumentLayouter(page).layout_document(blocks)
|
||||
|
||||
# 3. Render: Page -> PIL.Image
|
||||
image = page.render()
|
||||
|
||||
# 4. Query: pixel -> content
|
||||
result = page.query_point((50, 40))
|
||||
print(result.object_type, result.text) # e.g. "text" "Chapter"
|
||||
```
|
||||
|
||||
## The Transformation Process
|
||||
|
||||
The architecture involves a clear transformation from Abstract to Concrete:
|
||||
|
||||
```
|
||||
Abstract Document
|
||||
↓
|
||||
[Parser Layer]
|
||||
↓
|
||||
Abstract Blocks (Paragraph, Heading, etc.)
|
||||
↓
|
||||
[Layout Engine]
|
||||
↓
|
||||
Concrete Objects (Text, Line, Page)
|
||||
↓
|
||||
[Rendering Engine]
|
||||
↓
|
||||
Visual Output (Images, PDF, etc.)
|
||||
```
|
||||
|
||||
### Example Transformation
|
||||
Inspecting the intermediate concrete objects:
|
||||
|
||||
```python
|
||||
# 1. Abstract content
|
||||
paragraph = Paragraph()
|
||||
paragraph.add_word(Word("This", font))
|
||||
paragraph.add_word(Word("is", font))
|
||||
paragraph.add_word(Word("a", font))
|
||||
paragraph.add_word(Word("test", font))
|
||||
from pyWebLayout.concrete.text import Line
|
||||
|
||||
# 2. Layout transformation
|
||||
layout = ParagraphLayout(line_width=200, line_height=20)
|
||||
lines = layout.layout_paragraph(paragraph) # Returns List[Line]
|
||||
|
||||
# 3. Each Line contains concrete Text objects
|
||||
for line in lines:
|
||||
for text_obj in line.text_objects: # List[Text]
|
||||
print(f"Text: '{text_obj.text}' at position {text_obj._origin}")
|
||||
for line in (c for c in page.children if isinstance(c, Line)):
|
||||
print([t.text for t in line.text_objects])
|
||||
# ['Chapter', 'One']
|
||||
# ['It', 'was', 'a', 'dark', 'and', 'stormy', 'night.']
|
||||
```
|
||||
|
||||
## Key Architectural Principles
|
||||
For paginated reading, drive `EreaderLayoutManager` instead of building pages by hand:
|
||||
|
||||
### 1. **Single Responsibility**
|
||||
- Abstract classes: Handle content and structure
|
||||
- Concrete classes: Handle rendering and layout
|
||||
|
||||
### 2. **Separation of Concerns**
|
||||
- Text parsing/processing ≠ Text rendering
|
||||
- Document structure ≠ Page layout
|
||||
- Content semantics ≠ Visual presentation
|
||||
|
||||
### 3. **Immutable Abstract Content**
|
||||
- Abstract content remains unchanged during rendering
|
||||
- Multiple concrete representations can be generated from same abstract content
|
||||
- Enables pagination, different formats, responsive layouts
|
||||
|
||||
### 4. **One-to-Many Relationships**
|
||||
- One Abstract Word → Multiple Concrete Text objects (hyphenation)
|
||||
- One Abstract Paragraph → Multiple Concrete Lines
|
||||
- One Abstract Document → Multiple Concrete Pages
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
### ❌ **Mixing Concerns**
|
||||
```python
|
||||
# WRONG: Abstract class knowing about pixels
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
manager = EreaderLayoutManager(blocks, page_size=(800, 600), document_id="my-book")
|
||||
page = manager.get_current_page()
|
||||
page = manager.next_page()
|
||||
manager.set_font_scale(1.25) # re-lays out from the same RenderingPosition
|
||||
```
|
||||
|
||||
## Design principles
|
||||
|
||||
**1. Abstract content is not mutated by layout.** Font scaling and family overrides
|
||||
produce scaled copies at layout time rather than editing the document. This is what
|
||||
allows the same `blocks` list to back a buffer of pages at several font sizes.
|
||||
|
||||
**2. Positions are expressed in abstract coordinates.** `RenderingPosition` names a
|
||||
chapter, block, and word — never a pixel. A reader who changes font size stays on the
|
||||
same sentence.
|
||||
|
||||
**3. Layouters report partial success.** Every layouter returns whether it fit, and the
|
||||
text layouter also returns where it stopped. Pagination is built from this rather than
|
||||
from a separate page-breaking pass.
|
||||
|
||||
**4. One abstract object may become many concrete ones.** One `Word` → one or two `Text`
|
||||
fragments; one `Paragraph` → many `Line`s across many `Page`s; one `Document` → an
|
||||
unbounded sequence of `Page`s. Any API that assumes a one-to-one mapping will be wrong
|
||||
at a hyphen or a page boundary.
|
||||
|
||||
**5. Measurement is cached, not avoided.** Text width and glyph rasterisation are the
|
||||
hot path. `core/cache.py` bounds their cost; `prewarm_text_caches` front-loads it for a
|
||||
known document.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
**Concrete state stored on abstract objects.** An abstract object that remembers its
|
||||
rendered width, position, or `Text` objects is wrong at the second rendering. If a
|
||||
back-reference is needed, point from concrete to abstract.
|
||||
|
||||
```python
|
||||
# WRONG
|
||||
class Word:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
self.rendered_width = None # ❌ Concrete concern in abstract class
|
||||
self.rendered_width = None # invalidated by any font change
|
||||
|
||||
# RIGHT
|
||||
text = Text(word.text, font, draw, source=word) # concrete knows its origin
|
||||
```
|
||||
|
||||
### ❌ **renderable_words Concept**
|
||||
```python
|
||||
# WRONG: Confusing abstract and concrete
|
||||
line.renderable_words # ❌ This suggests Words are renderable
|
||||
# Words are abstract - only Text objects render
|
||||
```
|
||||
**Treating `Word` as renderable.** Words are abstract; only `Text` draws. There is no
|
||||
`renderable_words` anywhere in the codebase, and there should not be.
|
||||
|
||||
### ✅ **Correct Separation**
|
||||
```python
|
||||
# CORRECT: Clear separation
|
||||
abstract_word = Word("test") # Abstract content
|
||||
concrete_text = Text("test", font) # Concrete rendering
|
||||
line.text_objects.append(concrete_text) # Concrete objects in concrete container
|
||||
```
|
||||
**Assuming a layouter returns lines.** `paragraph_layouter` appends to a page and
|
||||
reports what did not fit. Code that expects `List[Line]` back is working from an
|
||||
outdated model.
|
||||
|
||||
## Benefits of This Architecture
|
||||
## Summary
|
||||
|
||||
### 1. **Flexibility**
|
||||
- Same content can be rendered at different sizes
|
||||
- Multiple output formats from single source
|
||||
- Easy to implement responsive design
|
||||
|
||||
### 2. **Testability**
|
||||
- Abstract logic can be tested without rendering
|
||||
- Layout algorithms can be tested independently
|
||||
- Visual rendering can be mocked
|
||||
|
||||
### 3. **Performance**
|
||||
- Abstract content can be cached and reused
|
||||
- Layout can be computed once for multiple renderings
|
||||
- Incremental updates possible
|
||||
|
||||
### 4. **Maintainability**
|
||||
- Clear boundaries between text processing and rendering
|
||||
- Changes to rendering don't affect content parsing
|
||||
- Easy to swap rendering backends
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
pyWebLayout/
|
||||
├── abstract/ # Content and structure
|
||||
│ ├── block.py # Document blocks (Paragraph, Heading, etc.)
|
||||
│ ├── inline.py # Inline content (Word, etc.)
|
||||
│ ├── document.py # Document structure
|
||||
│ └── functional.py # Links, buttons, etc.
|
||||
│
|
||||
├── concrete/ # Rendering and layout
|
||||
│ ├── text.py # Text and Line rendering
|
||||
│ ├── page.py # Page layout and containers
|
||||
│ ├── box.py # Base rendering classes
|
||||
│ ├── image.py # Image rendering
|
||||
│ └── functional.py # Interactive elements
|
||||
│
|
||||
├── typesetting/ # Layout algorithms
|
||||
│ ├── paragraph_layout.py # Abstract → Concrete transformation
|
||||
│ ├── flow.py # Text flow management
|
||||
│ └── pagination.py # Page breaking logic
|
||||
│
|
||||
└── style/ # Styling and formatting
|
||||
├── fonts.py # Font management
|
||||
├── layout.py # Layout constants
|
||||
└── alignment.py # Alignment enums
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Abstract/Concrete separation is fundamental to pyWebLayout's design. It ensures clean separation between content processing and visual rendering, enabling flexible, maintainable, and testable document processing pipelines.
|
||||
|
||||
**Remember**:
|
||||
- **Abstract** = What to display (content, structure, semantics)
|
||||
- **Concrete** = How to display it (pixels, fonts, positioning, rendering)
|
||||
|
||||
This architecture enables the library to handle complex document layouts while maintaining clear, understandable code organization.
|
||||
- **`core/`** — contracts and shared machinery
|
||||
- **`style/`** — semantic style, and its resolution to concrete rendering parameters
|
||||
- **`abstract/`** — what the document says
|
||||
- **`concrete/`** — where the pixels go
|
||||
- **`layout/`** — the transformation between them, and pagination on top of it
|
||||
- **`io/readers/`** — markup in
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# CI test image for pyWebLayout
|
||||
# Build: docker build -f Dockerfile.ci -t gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest
|
||||
#
|
||||
# pyWebLayout is a library, so CI tests every interpreter pyproject.toml claims
|
||||
# to support rather than just one. All four are in this image and the workflow
|
||||
# matrix picks one per job; dependencies are pre-installed into each, so a CI
|
||||
# run downloads nothing.
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# deadsnakes carries the Python versions Ubuntu 24.04 does not ship
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
software-properties-common \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Interpreters. 3.12 is Ubuntu 24.04's own; the rest come from deadsnakes.
|
||||
python3.10 python3.10-venv \
|
||||
python3.11 python3.11-venv \
|
||||
python3.12 python3.12-venv \
|
||||
python3.13 python3.13-venv \
|
||||
# Pillow needs these at runtime for font rasterisation and image IO
|
||||
libfreetype6 \
|
||||
libjpeg-turbo8 \
|
||||
libopenjp2-7 \
|
||||
libtiff6 \
|
||||
zlib1g \
|
||||
# Used by the workflow itself
|
||||
curl \
|
||||
git \
|
||||
nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# One venv per interpreter at a predictable path, /opt/py<version>. Ubuntu marks
|
||||
# its system Python externally-managed, so installing into venvs sidesteps that
|
||||
# without --break-system-packages, and keeps the four dependency sets isolated.
|
||||
#
|
||||
# The package list is written once so versions cannot drift between
|
||||
# interpreters. It mirrors pyproject.toml's runtime deps plus the test and dev
|
||||
# extras; keep the two in step.
|
||||
#
|
||||
# Deliberately NOT installed: pyWebLayout itself. The workflow installs the
|
||||
# checkout with --no-deps, so a job always tests the code under review.
|
||||
#
|
||||
# setuptools is pinned below 81 because that release dropped pkg_resources,
|
||||
# which coverage-badge still imports at startup. Without the pin the badge step
|
||||
# dies with ModuleNotFoundError. Revisit when coverage-badge stops using it.
|
||||
RUN for v in 3.10 3.11 3.12 3.13; do \
|
||||
python$v -m venv /opt/py$v && \
|
||||
/opt/py$v/bin/pip install --no-cache-dir --upgrade pip wheel && \
|
||||
/opt/py$v/bin/pip install --no-cache-dir --upgrade "setuptools<81" && \
|
||||
/opt/py$v/bin/pip install --no-cache-dir \
|
||||
Pillow \
|
||||
numpy \
|
||||
pyphen \
|
||||
beautifulsoup4 \
|
||||
lxml \
|
||||
pytest \
|
||||
pytest-cov \
|
||||
flask \
|
||||
werkzeug \
|
||||
ebooklib \
|
||||
requests \
|
||||
flake8 \
|
||||
coverage-badge \
|
||||
interrogate \
|
||||
; \
|
||||
done
|
||||
|
||||
# Fail the build rather than ship an image whose dependencies do not import
|
||||
RUN for v in 3.10 3.11 3.12 3.13; do \
|
||||
echo "--- python$v ---" && \
|
||||
/opt/py$v/bin/python -c \
|
||||
"import sys, PIL, numpy, pyphen, bs4, pytest, flask, ebooklib, requests; \
|
||||
print(sys.version.split()[0], 'deps OK')" \
|
||||
; \
|
||||
done
|
||||
@@ -0,0 +1,139 @@
|
||||
# Dynamic Font Family Switching
|
||||
|
||||
The pyWebLayout ereader now supports dynamic font family switching, allowing readers to change fonts on-the-fly without losing their reading position.
|
||||
|
||||
## Visual Demo
|
||||
|
||||

|
||||
|
||||
*The same content rendered in Sans-Serif, Serif, and Monospace fonts*
|
||||
|
||||
## Features
|
||||
|
||||
- **Three Bundled Font Families**: Sans-Serif (DejaVu Sans), Serif (DejaVu Serif), and Monospace (DejaVu Sans Mono)
|
||||
- **Dynamic Switching**: Change fonts instantly during reading
|
||||
- **Position Preservation**: Your reading position is maintained across font changes
|
||||
- **Attribute Preservation**: Bold, italic, size, and color are preserved when switching families
|
||||
- **Automatic Cache Management**: Intelligent cache invalidation ensures optimal performance
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
from pyWebLayout.layout.ereader_manager import create_ereader_manager
|
||||
|
||||
# Create an ereader instance
|
||||
manager = create_ereader_manager(blocks, page_size=(600, 800))
|
||||
|
||||
# Switch to serif font
|
||||
manager.set_font_family(BundledFont.SERIF)
|
||||
page = manager.get_current_page()
|
||||
|
||||
# Switch to monospace font
|
||||
manager.set_font_family(BundledFont.MONOSPACE)
|
||||
page = manager.get_current_page()
|
||||
|
||||
# Restore original fonts
|
||||
manager.set_font_family(None)
|
||||
page = manager.get_current_page()
|
||||
|
||||
# Query current font family
|
||||
current_family = manager.get_font_family() # Returns BundledFont or None
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### EreaderLayoutManager Methods
|
||||
|
||||
#### `set_font_family(family: Optional[BundledFont]) -> Page`
|
||||
|
||||
Change the font family and re-render the current page.
|
||||
|
||||
**Parameters:**
|
||||
- `family`: Font family to use (`BundledFont.SANS`, `BundledFont.SERIF`, `BundledFont.MONOSPACE`, or `None` for original fonts)
|
||||
|
||||
**Returns:**
|
||||
- Re-rendered page with the new font family
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# Switch to serif
|
||||
page = manager.set_font_family(BundledFont.SERIF)
|
||||
|
||||
# Restore original fonts
|
||||
page = manager.set_font_family(None)
|
||||
```
|
||||
|
||||
#### `get_font_family() -> Optional[BundledFont]`
|
||||
|
||||
Get the current font family override.
|
||||
|
||||
**Returns:**
|
||||
- Current font family (`BundledFont.SANS`, `BundledFont.SERIF`, `BundledFont.MONOSPACE`) or `None` if using original fonts
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
family = manager.get_font_family()
|
||||
if family:
|
||||
print(f"Currently using: {family.value}")
|
||||
else:
|
||||
print("Using original fonts")
|
||||
```
|
||||
|
||||
## Font Families
|
||||
|
||||
### Sans-Serif (BundledFont.SANS)
|
||||
- **Font**: DejaVu Sans
|
||||
- **Best for**: Screen reading, modern interfaces
|
||||
- **Characteristics**: Clean, legible, no decorative strokes
|
||||
|
||||
### Serif (BundledFont.SERIF)
|
||||
- **Font**: DejaVu Serif
|
||||
- **Best for**: Long-form reading, formal documents
|
||||
- **Characteristics**: Traditional, classic appearance with decorative strokes
|
||||
|
||||
### Monospace (BundledFont.MONOSPACE)
|
||||
- **Font**: DejaVu Sans Mono
|
||||
- **Best for**: Code, technical documentation
|
||||
- **Characteristics**: Fixed-width characters, uniform spacing
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Demo
|
||||
See [examples/11_font_family_switching_demo.py](examples/11_font_family_switching_demo.py) for a full demonstration including:
|
||||
- Creating ereader content
|
||||
- Switching between font families
|
||||
- Navigating with different fonts
|
||||
- Position tracking across font changes
|
||||
|
||||
### Generate README Images
|
||||
Run [examples/generate_readme_font_demo.py](examples/generate_readme_font_demo.py) to create comparison images:
|
||||
```bash
|
||||
python examples/generate_readme_font_demo.py
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The font family switching is implemented using a **hybrid approach** that combines:
|
||||
|
||||
1. **FontFamilyOverride class**: Manages font preferences at render time
|
||||
2. **Font transformation pipeline**: Intercepts and transforms Font objects during rendering
|
||||
3. **Intelligent caching**: Automatic cache invalidation when font family changes
|
||||
4. **Backward compatibility**: Works with existing Font-based content without migration
|
||||
|
||||
This approach provides:
|
||||
- ✅ No breaking changes to existing code
|
||||
- ✅ Instant font switching without document recreation
|
||||
- ✅ Preservation of font attributes (weight, style, size, color)
|
||||
- ✅ Optimal performance with intelligent buffering
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Font family changes invalidate the page buffer cache
|
||||
- Reading position is preserved using the abstract document structure
|
||||
- Background rendering adapts to the new font family automatically
|
||||
- All three bundled fonts are included in the package (license: Bitstream Vera / Public Domain)
|
||||
|
||||
## License
|
||||
|
||||
The bundled DejaVu fonts are free and open source under the [Bitstream Vera License](pyWebLayout/assets/fonts/DEJAVU_README.md).
|
||||
@@ -25,6 +25,7 @@ PyWebLayout is a Python library for HTML-like layout and rendering to paginated
|
||||
### 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
|
||||
@@ -119,6 +120,32 @@ The library supports various page layouts and configurations:
|
||||
<em>Buttons, forms, and callback binding</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>🆕 Pagination & PageBreak</b><br>
|
||||
<img src="docs/images/example_08_pagination_explicit.png" width="300" alt="Pagination"><br>
|
||||
<em>Multi-page documents with explicit and automatic breaks</em>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>🆕 Link Navigation</b><br>
|
||||
<img src="docs/images/example_09_link_navigation.png" width="300" alt="Links"><br>
|
||||
<em>All 4 link types: Internal, External, API, Function</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>🆕 Comprehensive Forms</b><br>
|
||||
<img src="docs/images/example_10_forms.png" width="300" alt="Forms"><br>
|
||||
<em>All 14 form field types with validation</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>🆕 Dynamic Font Family Switching</b><br>
|
||||
<img src="docs/images/font_family_switching_vertical.png" width="600" alt="Font Switching"><br>
|
||||
<em>Switch between Sans, Serif, and Monospace fonts instantly</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Examples
|
||||
@@ -132,21 +159,105 @@ The `examples/` directory contains working demonstrations:
|
||||
- **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling
|
||||
- **[05_html_table_with_images.py](examples/05_html_table_with_images.py)** - Tables with embedded images
|
||||
- **[06_functional_elements_demo.py](examples/06_functional_elements_demo.py)** - Interactive buttons and forms with callbacks
|
||||
- **[08_bundled_fonts_demo.py](examples/08_bundled_fonts_demo.py)** - Using the bundled DejaVu font families
|
||||
|
||||
### 🆕 Advanced Features (NEW)
|
||||
- **[08_pagination_demo.py](examples/08_pagination_demo.py)** - Multi-page documents with PageBreak ([11 tests](tests/examples/test_08_pagination_demo.py))
|
||||
- **[09_link_navigation_demo.py](examples/09_link_navigation_demo.py)** - All link types and navigation ([10 tests](tests/examples/test_09_link_navigation_demo.py))
|
||||
- **[10_forms_demo.py](examples/10_forms_demo.py)** - All 14 form field types ([9 tests](tests/examples/test_10_forms_demo.py))
|
||||
- **[11_font_family_switching_demo.py](examples/11_font_family_switching_demo.py)** - 🆕 Dynamic font switching in ereader
|
||||
|
||||
Run any example:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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](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
|
||||
|
||||
```python
|
||||
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](FONT_SWITCHING_FEATURE.md) for complete documentation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Detailed explanation of Abstract/Concrete architecture
|
||||
- **[examples/README.md](examples/README.md)** - Complete guide to all examples
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Abstract/Concrete architecture guide
|
||||
- **[FONT_SWITCHING_FEATURE.md](FONT_SWITCHING_FEATURE.md)** - 🆕 Font family switching guide
|
||||
- **[examples/README.md](examples/README.md)** - Complete examples guide with tests
|
||||
- **[docs/images/README.md](docs/images/README.md)** - Visual documentation index
|
||||
- **[pyWebLayout/layout/README_EREADER_API.md](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`:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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](.gitea/workflows/ci.yml). Badges and
|
||||
coverage artifacts are published from the 3.13 leg only.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
# Architecture Review
|
||||
|
||||
Independent review of the codebase at `c5c61a3` (2026-08-06), answering one
|
||||
question: **is this a well-architected library or an over-complex mess?**
|
||||
|
||||
It is a well-architected library with one rotten subsystem inside it. The core
|
||||
design holds up; roughly a fifth of the code is speculative or non-functional,
|
||||
and it is concentrated in the ereader pagination/buffering layer.
|
||||
|
||||
This document records the verdict, the evidence, and the findings **not already
|
||||
covered** by [LAYOUT_REMEDIATION_SPEC.md](LAYOUT_REMEDIATION_SPEC.md). Where a
|
||||
finding is already specced, it is cross-referenced rather than restated.
|
||||
|
||||
## Contents
|
||||
|
||||
| ID | Finding | Severity | Status |
|
||||
|----|---------|----------|--------|
|
||||
| [R1](#r1--the-process-pool-crashes-on-python-314) | The process pool crashes on Python 3.14 | Critical | New; raises priority of S12 |
|
||||
| [R2](#r2--the-test-suite-hangs-at-interpreter-exit) | Test suite hangs at interpreter exit | High | New; same root cause as R1 |
|
||||
| [R3](#r3--font-scaling-destroys-hyperlinks) | Font scaling destroys hyperlinks | High | New |
|
||||
| [R4](#r4--three-packaging-configs-that-disagree) | Three packaging configs that disagree | Medium | New (corrected) |
|
||||
| [R5](#r5--monkey-patched-page-methods-with-a-conflicting-signature) | Monkey-patched `Page` methods with a conflicting signature | Medium | New |
|
||||
| [R6](#r6--dead-duck-typing-cluster-in-pagepy) | Dead duck-typing cluster in `page.py` | Low | New; extends S10.3 |
|
||||
| [R7](#r7--two-orphaned-subsystems) | Two orphaned subsystems | Low | New |
|
||||
| [R8](#r8--backward-pagination-is-guesswork) | Backward pagination is guesswork | Medium | Resolved by S16 |
|
||||
| [R9](#r9--query_points-hit-region-is-offset-from-the-glyphs) | `query_point`'s hit region is offset from the glyphs | Medium | New, open |
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Well architected.** The concerns that usually decide this question are all on
|
||||
the right side of the line:
|
||||
|
||||
- **The abstract/concrete split is real, not aspirational.** Verified
|
||||
empirically: `abstract/` never imports `concrete/`; `core/` imports nothing but
|
||||
itself. The single crossing ([document.py:7](../pyWebLayout/abstract/document.py#L7))
|
||||
is into `style/`, which the dependency rules permit. Most codebases claiming
|
||||
this layering have leaked it within a year.
|
||||
- **The layouter contract is the right abstraction.** `paragraph_layouter`
|
||||
returning `(fit, failed_word_index, remaining_pretext)` is what makes
|
||||
pagination resumable, and the shape is consistent across content types. Layout
|
||||
engines that return `List[Line]` cannot paginate without a second pass.
|
||||
- **[core/cache.py](../pyWebLayout/core/cache.py) is exemplary.** Usage-ranked
|
||||
eviction with periodic aging, sampled eviction instead of a maintained heap,
|
||||
O(1) hit path with no reordering — every choice justified by a measurement in
|
||||
the docstring.
|
||||
- **[concrete/text.py](../pyWebLayout/concrete/text.py) is the strongest file.**
|
||||
The glyph cache ([:581-647](../pyWebLayout/concrete/text.py#L581-L647))
|
||||
reimplements PIL's internals to skip per-call setup, with a permanent graceful
|
||||
fallback when the private API is absent. Alignment is a clean strategy pattern,
|
||||
and `render_alignment_handler` handles last-line-of-paragraph correctly.
|
||||
- **[html_extraction.py](../pyWebLayout/io/readers/html_extraction.py) is
|
||||
textbook.** An immutable `StyleContext` threaded down the tree plus a handler
|
||||
dispatch table — no giant if/elif, no mutable parser state.
|
||||
|
||||
**The complexity that is not earned** is concentrated in three places, all in the
|
||||
same band of the code:
|
||||
|
||||
1. `layout/page_buffer.py` — 520 lines of multiprocess prefetch that has never
|
||||
worked (S12, plus R1/R2 below).
|
||||
2. `BidirectionalLayouter.render_page_backward` — ~100 lines of convergence
|
||||
heuristics standing in for an anchor list that already exists (R8).
|
||||
3. The second block dispatcher in `ereader_layout.py`, which silently drops
|
||||
tables and lists (S4, S8).
|
||||
|
||||
The pattern is visible in the git history: work from `e000068` onward (caching,
|
||||
alignment, page geometry) is markedly better than the ereader scaffolding it sits
|
||||
on. This is not a mess. It is a solid library with an early prototype still
|
||||
embedded in it.
|
||||
|
||||
**Sizing the cleanup:** R1–R7 plus S12 and S10.3 remove roughly 800–900 lines and
|
||||
fix four user-visible defects. None of it requires redesigning anything.
|
||||
|
||||
## Test baseline
|
||||
|
||||
At `c5c61a3`, in a clean venv on Python 3.14.6:
|
||||
|
||||
```
|
||||
833 passed, 2 skipped, 24 subtests passed in 11.51s
|
||||
```
|
||||
|
||||
(The 2 skips were environmental — the review venv lacked `requests`, so the URL
|
||||
image tests skipped. With the `test` extra from R4 installed the suite reports
|
||||
`853 passed, 24 subtests passed in 13.53s`.)
|
||||
|
||||
The suite then **hangs indefinitely** rather than exiting. See R2.
|
||||
|
||||
## Status of the existing remediation spec
|
||||
|
||||
| Spec | Subject | State |
|
||||
|------|---------|-------|
|
||||
| S1 | Inline content in non-paragraph containers | Done (`284d521`) |
|
||||
| S2 | Page geometry: origin and content rect | Done (`f18cec2`) |
|
||||
| S3 | Draw/canvas lifecycle | Done (`202dacf`) |
|
||||
| S11 | Partial-block progress discarded | Done (`a57da80`) |
|
||||
| S13 | Word spacing and alignment | Done (`1262be6`) |
|
||||
| S14 | Vertical centring in buttons and fields | Done (`c5c61a3`) |
|
||||
| S4–S10, S12 | Dispatch, cells, table grid, pagination, hygiene, background rendering | Outstanding |
|
||||
|
||||
The spec's analysis is sound and in places sharper than this review — S12 caught
|
||||
that `_render_page_worker` omits `page_size` entirely, which this review missed.
|
||||
Nothing below supersedes it.
|
||||
|
||||
---
|
||||
|
||||
## R1 — The process pool crashes on Python 3.14
|
||||
|
||||
**Severity: critical. Raises S12 from "useless" to "fatal".**
|
||||
|
||||
### Problem
|
||||
|
||||
`PageBuffer` submits to a `ProcessPoolExecutor` from inside
|
||||
`BufferedPageRenderer.render_page`
|
||||
([page_buffer.py:431](../pyWebLayout/layout/page_buffer.py#L431)). Python 3.14
|
||||
changed the default multiprocessing start method on Linux from `fork` to
|
||||
`forkserver`. Under a non-`fork` start method, `submit()` reaches
|
||||
`_check_not_importing_main()`, which raises unless the caller sits inside an
|
||||
`if __name__ == "__main__":` guard — and the child re-imports the caller's main
|
||||
module, re-executing it.
|
||||
|
||||
S12 documents this subsystem as delivering no benefit. On 3.14 it is worse than
|
||||
that: `EreaderLayoutManager.get_current_page()` **raises** when called from
|
||||
module-level script code.
|
||||
|
||||
### Evidence
|
||||
|
||||
A plain script calling `manager.get_current_page()` at module level, Python
|
||||
3.14.6:
|
||||
|
||||
```
|
||||
RuntimeError:
|
||||
An attempt has been made to start a new process before the
|
||||
current process has finished its bootstrapping phase.
|
||||
...
|
||||
File "pyWebLayout/layout/page_buffer.py", line 221, in _queue_forward_renders
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
ConnectionResetError: [Errno 104] Connection reset by peer
|
||||
```
|
||||
|
||||
With a `__main__` guard added it does not raise, and instead confirms S12's
|
||||
finding on every job:
|
||||
|
||||
```
|
||||
Background render failed for position RenderingPosition(...): cannot pickle 'ImagingCore' object
|
||||
Background render failed for position RenderingPosition(...): cannot pickle 'ImagingCore' object
|
||||
```
|
||||
|
||||
### Action
|
||||
|
||||
Fold into **S12**, and treat S12 as unblocked and urgent rather than phase 4. The
|
||||
recommended resolution there — delete the pool, keep the LRU buffers and position
|
||||
maps, replace prefetch with synchronous readahead — resolves R1 and R2 as a side
|
||||
effect. S12's measurement gate still applies to the *readahead* decision; it does
|
||||
not need to gate deletion of the pool, because the pool's contribution is
|
||||
provably zero.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/page_buffer.py`
|
||||
|
||||
---
|
||||
|
||||
## R2 — The test suite hangs at interpreter exit
|
||||
|
||||
**Severity: high.** Same root cause as R1.
|
||||
|
||||
### Problem
|
||||
|
||||
`PageBuffer.__del__` calls `shutdown()`, which calls
|
||||
`executor.shutdown(wait=True)` ([page_buffer.py:342](../pyWebLayout/layout/page_buffer.py#L342)).
|
||||
`EreaderLayoutManager.__del__` does the same via `renderer.shutdown()`. Running
|
||||
`__del__` at interpreter shutdown and blocking on a process pool inside it
|
||||
deadlocks.
|
||||
|
||||
### Evidence
|
||||
|
||||
```
|
||||
833 passed, 2 skipped, 24 subtests passed in 11.51s
|
||||
```
|
||||
|
||||
...then the process sat at ~0% CPU with idle forkserver children for 13 minutes
|
||||
before being killed. Reproduced twice; both runs completed the tests in under
|
||||
12s and neither exited.
|
||||
|
||||
This is why CI wall-clock does not resemble the 11.5s the tests actually take.
|
||||
|
||||
### Action
|
||||
|
||||
Resolved by S12's deletion of the executor. If for any reason the pool is
|
||||
retained, `__del__` must not block: register an `atexit` handler or require
|
||||
explicit `shutdown()`, and never `wait=True` from a finaliser.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `pytest` returns to the shell within a second of printing its summary line.
|
||||
- No `multiprocessing` child processes outlive the test session.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/page_buffer.py`, `pyWebLayout/layout/ereader_manager.py`
|
||||
|
||||
---
|
||||
|
||||
## R3 — Font scaling destroys hyperlinks
|
||||
|
||||
**Severity: high. User-visible, silent, and trivially reproducible.**
|
||||
|
||||
### Problem
|
||||
|
||||
`BidirectionalLayouter._scale_block_fonts`
|
||||
([ereader_layout.py:474-498](../pyWebLayout/layout/ereader_layout.py#L474-L498))
|
||||
rebuilds a scaled block by constructing plain `Word(word.text, scaled_style)` for
|
||||
every word. `LinkedWord` is a `Word` subclass
|
||||
([inline.py:288](../pyWebLayout/abstract/inline.py#L288)), so the reconstruction
|
||||
downgrades it and the link target is discarded.
|
||||
|
||||
The function returns the block unchanged when `font_scale == 1.0` and no family
|
||||
override is set, which is why no test has caught this: the defect only appears
|
||||
once the reader changes font size.
|
||||
|
||||
Two further gaps in the same function:
|
||||
|
||||
1. It handles only `Paragraph` and `Heading`. Every other block type is returned
|
||||
unscaled, so a font-size change leaves images, tables and lists at their
|
||||
original size while the text around them reflows.
|
||||
2. It allocates a new `Paragraph` and a new `Word` per word on **every page
|
||||
render** at any scale ≠ 1.0 — directly against the caching work in
|
||||
`concrete/text.py`, and on the hot path.
|
||||
|
||||
### Evidence
|
||||
|
||||
At `c5c61a3`, parsing `<p>Go to <a href="http://x">this link</a> now.</p>`:
|
||||
|
||||
```
|
||||
scale=1.0: LinkedWords = 2
|
||||
scale=1.5: LinkedWords = 0
|
||||
```
|
||||
|
||||
### Design
|
||||
|
||||
Stop reconstructing abstract blocks at layout time. Font scale and family are
|
||||
*rendering context*, not document content — carrying them in a copied document
|
||||
violates the "abstract content is not mutated by layout" principle in
|
||||
[ARCHITECTURE.md](../ARCHITECTURE.md) in spirit, even though it copies rather
|
||||
than mutates.
|
||||
|
||||
Preferred: thread the scale/override into the layouter and resolve fonts at
|
||||
`Text` construction, where `Font` objects are already deduplicated by
|
||||
`FontRegistry`. `paragraph_layouter` already accepts an `alignment_override`;
|
||||
`font_scale` and `font_family_override` belong in the same place.
|
||||
|
||||
Minimum viable fix if the larger change is deferred: reconstruct via
|
||||
`type(word)` and copy subclass state, and extend coverage to every block type.
|
||||
This is strictly a stopgap — it keeps the per-page allocation cost.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A document containing `<a href>` retains every `LinkedWord` after
|
||||
`set_font_scale(1.5)`, and `query_point` over the rendered page still returns
|
||||
`object_type="link"` with the correct target.
|
||||
- An image block's rendered size is unaffected by `set_font_scale`, or scales
|
||||
deliberately — not left inconsistent with the text around it.
|
||||
- No new `Word`/`Paragraph` objects are allocated per page render at scale ≠ 1.0.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/ereader_layout.py`, `pyWebLayout/layout/document_layouter.py`
|
||||
|
||||
---
|
||||
|
||||
## R4 — Three packaging configs that disagree
|
||||
|
||||
**Severity: medium.** *Corrected: the original review claimed a clean install
|
||||
fails on first import. It does not — see below.*
|
||||
|
||||
### Problem
|
||||
|
||||
The project carries **three** sets of packaging metadata:
|
||||
|
||||
| File | Declares |
|
||||
|------|----------|
|
||||
| `pyproject.toml` `[project]` | Pillow, numpy, pyphen, beautifulsoup4, flask, ebooklib, requests |
|
||||
| `setup.cfg` `[options]` | Pillow, numpy |
|
||||
| `setup.py` `setup(...)` kwargs | Pillow, numpy |
|
||||
|
||||
`pyproject.toml`'s `[project]` table wins under any modern build backend, so the
|
||||
shipped wheel is correct and `pip install pyWebLayout` works. The `setup.cfg` and
|
||||
`setup.py` copies are dead, contradictory, and actively misleading — reading
|
||||
either one gives the wrong answer about what the library needs.
|
||||
|
||||
The authoritative list is itself wrong in the other direction:
|
||||
|
||||
- **`flask` is a runtime dependency.** It is imported only by
|
||||
`tests/abstract/test_abstract_blocks.py`, as a fixture HTTP server. Every user
|
||||
installs Flask, Jinja2, Werkzeug, click, itsdangerous and blinker for nothing.
|
||||
- **`ebooklib` is a runtime dependency and is never imported by the library.**
|
||||
`epub_reader.py` uses `zipfile` + `xml.etree` directly. Only the *tests* use
|
||||
ebooklib, to build EPUB fixtures.
|
||||
- **`requests` is declared required but is optional.** `concrete/image.py:100-111`
|
||||
imports it lazily and degrades to an error message on the image when absent.
|
||||
- **`requires-python = ">=3.6"` is false.** The package uses dataclasses (3.7+)
|
||||
and `from __future__ import annotations` (3.7+); CI tests 3.10, 3.12 and 3.13.
|
||||
|
||||
Net effect: a runtime install pulls 7 direct dependencies where 4 are needed.
|
||||
|
||||
### Action
|
||||
|
||||
- Consolidate on `pyproject.toml`. Reduce `setup.cfg` to its `[flake8]` section
|
||||
and `setup.py` to a `setup()` shim, each with a comment saying where metadata
|
||||
lives.
|
||||
- Runtime deps: Pillow, numpy, pyphen, beautifulsoup4. Move flask, werkzeug,
|
||||
ebooklib and requests into a `test` extra; add a `remote-images` extra for
|
||||
requests; add a `dev` extra composing them.
|
||||
- Set `requires-python = ">=3.10"` to match the CI matrix, and add version
|
||||
classifiers.
|
||||
- Add a CI step that installs the package into an empty venv with **only**
|
||||
declared runtime deps and imports every top-level subpackage. This class of
|
||||
defect is only caught by installing what you ship — and it is what would have
|
||||
caught the original misreading.
|
||||
|
||||
### Files
|
||||
|
||||
`pyproject.toml`, `setup.cfg`, `setup.py`, `.gitea/workflows/ci.yml`
|
||||
|
||||
---
|
||||
|
||||
## R5 — Monkey-patched `Page` methods with a conflicting signature
|
||||
|
||||
**Severity: medium.** Currently inert; a landmine if `Page` is ever refactored.
|
||||
|
||||
### Problem
|
||||
|
||||
[ereader_layout.py:741-761](../pyWebLayout/layout/ereader_layout.py#L741-L761)
|
||||
defines `_add_page_methods()` and calls it at import time. It attaches
|
||||
`can_fit_line` and `available_width` to the `Page` class if they are absent.
|
||||
|
||||
`Page` defines both ([page.py:59](../pyWebLayout/concrete/page.py#L59),
|
||||
[page.py:147](../pyWebLayout/concrete/page.py#L147)), so the patch never fires.
|
||||
But the two definitions of `can_fit_line` **do not agree**:
|
||||
|
||||
| Source | Signature |
|
||||
|--------|-----------|
|
||||
| `Page` | `can_fit_line(baseline_spacing, ascent=0, descent=0)` |
|
||||
| monkey patch | `can_fit_line(line_height)` |
|
||||
|
||||
The patched version also ignores descenders entirely — the exact bug S2 fixed. If
|
||||
`Page.can_fit_line` were ever renamed or moved, this would silently reinstate
|
||||
pre-S2 clipping behaviour, from an import side effect in a different package.
|
||||
|
||||
### Action
|
||||
|
||||
Delete `_add_page_methods` and its call site. Import-time monkey-patching of a
|
||||
class in another module has no place here; if `Page` is missing something the
|
||||
layout engine needs, it belongs on `Page`.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/ereader_layout.py`
|
||||
|
||||
---
|
||||
|
||||
## R6 — Dead duck-typing cluster in `page.py`
|
||||
|
||||
**Severity: low.** Extends S10.3.
|
||||
|
||||
### Problem
|
||||
|
||||
[page.py:261-491](../pyWebLayout/concrete/page.py#L261-L491) contains a closed
|
||||
cluster with no external callers:
|
||||
|
||||
- `_get_child_property` (:261) — called only by the four below
|
||||
- `_get_child_height` (:301) — called by nothing
|
||||
- `_get_child_position` (:382) — called only by `_point_in_child`
|
||||
- `_point_in_child` (:435) — called by nothing
|
||||
- `_get_child_size` (:466) — called only by `_point_in_child`
|
||||
|
||||
Verified by grep across `pyWebLayout/`, `tests/`, `examples/` and `scripts/`:
|
||||
zero references outside the cluster. About 90 lines.
|
||||
|
||||
It exists because `Renderable` declares neither `size` nor `origin`, so the code
|
||||
probes `_size`, `size`, `_height`, `height`, `_origin` and `position` in turn with
|
||||
`hasattr`. `query_point` (:399) already does the right thing instead — it relies
|
||||
on the `Queriable` interface.
|
||||
|
||||
### Action
|
||||
|
||||
- Delete all five methods.
|
||||
- Add `origin` and `size` to the `Renderable`/`Geometric` contract in
|
||||
`core/base.py` so the duck-typing cannot grow back. This is the same concern as
|
||||
S10.1's render contract and can ship with it.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/concrete/page.py`, `pyWebLayout/core/base.py`
|
||||
|
||||
---
|
||||
|
||||
## R7 — Two orphaned subsystems
|
||||
|
||||
**Severity: low**, but they are a large share of the "is this over-complex?"
|
||||
impression: 559 lines that nothing in the library reaches.
|
||||
|
||||
### Problem
|
||||
|
||||
**`concrete/interaction_handler.py` (310 lines).** `InteractionHandler` and
|
||||
`InteractionStateManager` are referenced only by
|
||||
`examples/07_pressed_state_demo.py`. No library code, no ereader path, no tests.
|
||||
|
||||
**`core/highlight.py` (249 lines).** `Highlight`, `HighlightColor` and
|
||||
`HighlightManager` have tests (`tests/core/test_highlight.py`) but are not wired
|
||||
into `EreaderLayoutManager` at all. Highlighting is not reachable through the
|
||||
library's own top-level interface.
|
||||
|
||||
`HighlightManager` also duplicates `BookmarkManager`'s JSON persistence
|
||||
(directory, `_save`, `_load`, per-document file naming) with no shared base.
|
||||
|
||||
### Action
|
||||
|
||||
Decide per subsystem, and record the decision:
|
||||
|
||||
- **Wire it up** — `EreaderLayoutManager` grows `add_highlight` / `highlights_for_page`
|
||||
and the persistence merges with `BookmarkManager` into one document-state store.
|
||||
- **Or move it out** — relocate to `examples/` or delete, and drop the tests with it.
|
||||
|
||||
Either is fine. Leaving a tested, documented, unreachable subsystem in `core/` is
|
||||
what makes the library look larger and less coherent than it is.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/concrete/interaction_handler.py`, `pyWebLayout/core/highlight.py`,
|
||||
`pyWebLayout/layout/ereader_manager.py`
|
||||
|
||||
---
|
||||
|
||||
## R8 — Backward pagination is guesswork
|
||||
|
||||
**Severity: medium.** S11's closing note already flags this for audit; this
|
||||
records what the audit found.
|
||||
|
||||
### Problem
|
||||
|
||||
`render_page_backward`
|
||||
([ereader_layout.py:372-472](../pyWebLayout/layout/ereader_layout.py#L372-L472))
|
||||
finds the previous page by estimating a start position, rendering forward,
|
||||
comparing the end against the target, and adjusting — **up to 10 times**. It then
|
||||
has a fallback that jumps back up to 5 blocks and renders again, and a fallback
|
||||
for *that* which renders from the start of the document.
|
||||
|
||||
Worst case: one "previous page" tap costs up to 12 full page layouts.
|
||||
|
||||
The estimator it converges from is `max(1, int(10 / font_scale))` blocks
|
||||
([:684](../pyWebLayout/layout/ereader_layout.py#L684)) — a constant with no
|
||||
relationship to page size, block length or font metrics.
|
||||
|
||||
The correct answer is usually already known.
|
||||
`EreaderLayoutManager._page_history` ([ereader_manager.py:210](../pyWebLayout/layout/ereader_manager.py#L210))
|
||||
records real page-start positions and serves them instantly; the refinement loop
|
||||
only runs when history misses — after a jump, a bookmark, a font change, or
|
||||
beyond 50 entries.
|
||||
|
||||
S11's note asks whether these fallbacks were compensating for the
|
||||
discarded-progress bug it fixed. They were, in part: the "failed to move
|
||||
backward" branch at [:446](../pyWebLayout/layout/ereader_layout.py#L446) is
|
||||
reachable precisely when forward rendering fails to advance, which S11 addressed.
|
||||
|
||||
### Design
|
||||
|
||||
Replace convergence with anchors. Maintain a sorted list of known page-start
|
||||
positions — chapter starts from `ChapterNavigator` (free, already built) plus
|
||||
every position visited. To go back from position P: binary-search the largest
|
||||
anchor A < P, render forward from A collecting page starts until reaching P, and
|
||||
return the last one. Cost is bounded by the anchor spacing, and every page start
|
||||
discovered on the way is itself a new anchor, so the second traversal of any
|
||||
region is free.
|
||||
|
||||
This subsumes `_page_history`, so the two mechanisms become one.
|
||||
|
||||
**Sequencing:** do this after S12, and after S8 — table and list pagination
|
||||
changes what a page start can be, and re-deriving anchors is cheap only once
|
||||
positions round-trip through tables correctly (S8 already notes this dependency).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `previous_page()` from any position issues at most *k* page layouts, where *k*
|
||||
is the anchor spacing, with no iteration count and no fallback ladder.
|
||||
- Forward-then-backward round-trips exactly, from a cold cache, after a chapter
|
||||
jump, and after a bookmark restore.
|
||||
- `_estimate_page_start`, `_adjust_start_estimate` and the three-tier fallback
|
||||
are deleted, not retained alongside.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/ereader_layout.py`, `pyWebLayout/layout/ereader_manager.py`
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
All findings in this document are resolved. What remains is the existing
|
||||
remediation spec: **S4 → S5 → S6 → S7 → S8 → S9**, plus **S10.1**, unchanged.
|
||||
|
||||
| ID | Resolution | Commit |
|
||||
|----|-----------|--------|
|
||||
| R1 | Fixed with S12 — the pool that raised is gone | `1924cc2` |
|
||||
| R2 | Fixed with S12 — no executor, no blocking finaliser | `1924cc2` |
|
||||
| R3 | `Word.with_style` keeps subclasses; all container blocks scale | `f0dc675` |
|
||||
| R4 | Consolidated on `pyproject.toml`; 7 runtime deps → 4 | `767e4c1` |
|
||||
| R5 | Monkey patch deleted | `62ca151` |
|
||||
| R6 | 138 dead lines deleted; contract hardening deferred to S10.1 | `e81ba48` |
|
||||
| R7 | Both subsystems wired into `EreaderLayoutManager` | `8746d3f`, `0ce1aea` |
|
||||
| R8 | Superseded by S16 (anchor replay); dead estimators removed | `bcae45a` |
|
||||
| R9 | Open — see below | — |
|
||||
|
||||
Two things worth carrying forward:
|
||||
|
||||
- **S12's measurement stands as the argument against prefetch.** A page render
|
||||
is 9–56 ms. Any future proposal to render ahead should have to beat that
|
||||
number first.
|
||||
- **Wiring an orphan found a bug.** R7's interaction handler had a crash on
|
||||
every hovered or pressed link (`0ce1aea`). Unreachable code is not
|
||||
neutral — it is untested code that looks tested.
|
||||
|
||||
---
|
||||
|
||||
## R9 — query_point's hit region is offset from the glyphs
|
||||
|
||||
**Severity: medium.** Found while verifying R3; not part of the original review.
|
||||
|
||||
### Problem
|
||||
|
||||
The region `Page.query_point` reports for a text object does not line up with
|
||||
where that object says it is. Probing a `LinkText` at the centre of its own
|
||||
`origin`/`size` box returns `object_type="empty"`.
|
||||
|
||||
### Evidence
|
||||
|
||||
A single-link page at 400×600, default scale:
|
||||
|
||||
```
|
||||
'this' origin=(68.3, 35.0) size=(29.2, 19.0) centre=(82, 44) -> empty
|
||||
'link' origin=(102.5, 35.0) size=(28.3, 19.0) centre=(116, 44) -> empty
|
||||
|
||||
grid scan: link is detected across y≈20–39
|
||||
LinkText claims: y≈35–54
|
||||
```
|
||||
|
||||
The two bands overlap by about four pixels. The offset is close to the font
|
||||
ascent, which points at a baseline-versus-top mismatch between the coordinates
|
||||
`Text` stores and the ones `in_object` tests.
|
||||
|
||||
This reproduces identically at scale 1.0 and 1.5, so it predates the R3 fix.
|
||||
|
||||
### Why it matters
|
||||
|
||||
Taps land through the grid because the region is only shifted, not absent — but
|
||||
it is shifted by most of a line height. Near the top or bottom of a page, or
|
||||
between tightly spaced lines, a tap can hit the neighbouring line instead of the
|
||||
one under the finger. It also makes `LinkText.origin`/`size` unusable for
|
||||
drawing selection or highlight overlays, which is what R7's highlighting now
|
||||
depends on.
|
||||
|
||||
### Action
|
||||
|
||||
Establish which of the two is authoritative — almost certainly the drawn
|
||||
position — and make the other agree. This sits close to S2 (page geometry) and
|
||||
S3 (draw/canvas lifecycle), both already landed, so the conventions to match
|
||||
are in place.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `page.query_point(centre_of(obj))` returns `obj` for every text object on a
|
||||
rendered page, at scales 0.8, 1.0, 1.5 and 2.0.
|
||||
- The end-to-end test in `tests/layout/test_font_scaling.py` probes the centre
|
||||
directly instead of scanning a grid.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/concrete/text.py`, `pyWebLayout/concrete/page.py`,
|
||||
`pyWebLayout/core/base.py`
|
||||
|
||||
## Reproducing the findings
|
||||
|
||||
Reviewed at `c5c61a3` on Python 3.14.6, in a venv containing
|
||||
`pytest pyphen Pillow numpy beautifulsoup4 lxml ebooklib`.
|
||||
|
||||
- **R1**: call `EreaderLayoutManager(...).get_current_page()` from module-level
|
||||
script code (no `__main__` guard).
|
||||
- **R2**: `python -m pytest -q`; observe the summary line, then the hang.
|
||||
- **R3**: parse HTML containing `<a href>`, call
|
||||
`BidirectionalLayouter._scale_block_fonts(block, 1.5)`, count `LinkedWord`
|
||||
instances in the result.
|
||||
- **R5**: compare `inspect.signature(Page.can_fit_line)` against the patch body.
|
||||
- **R6**: grep the five method names across `pyWebLayout/ tests/ examples/ scripts/`.
|
||||
- **R8**: read the loop; no execution needed.
|
||||
@@ -1,6 +1,6 @@
|
||||
# EbookReader Animated Demonstrations
|
||||
# pyWebLayout Visual Documentation
|
||||
|
||||
This directory contains animated GIF demonstrations of the pyWebLayout EbookReader functionality.
|
||||
This directory contains visual documentation for pyWebLayout, including animated GIF demonstrations of the EbookReader functionality and static example outputs showcasing various features.
|
||||
|
||||
## Generated GIFs
|
||||
|
||||
@@ -85,16 +85,129 @@ You can modify `generate_ereader_gifs.py` to adjust:
|
||||
| `ereader_chapter_navigation.gif` | ~290 KB | 11 | 1000ms |
|
||||
| `ereader_bookmarks.gif` | ~500 KB | 17 | 600ms |
|
||||
|
||||
---
|
||||
|
||||
## Example Outputs
|
||||
|
||||
Static PNG images generated by the example scripts, demonstrating various pyWebLayout features.
|
||||
|
||||
### Example 01: Simple Page Rendering
|
||||
**File:** `example_01_page_rendering.png`
|
||||
**Source:** [examples/01_simple_page_rendering.py](../../examples/01_simple_page_rendering.py)
|
||||
**Demonstrates:** Page styles, borders, padding, background colors
|
||||
|
||||
### Example 06: Functional Elements
|
||||
**File:** `example_06_functional_elements.png`
|
||||
**Source:** [examples/06_functional_elements_demo.py](../../examples/06_functional_elements_demo.py)
|
||||
**Demonstrates:** Buttons, form fields, interactive elements
|
||||
|
||||
### Example 08: Pagination (NEW)
|
||||
**Files:**
|
||||
- `example_08_pagination_explicit.png` (109 KB) - 5 pages with explicit PageBreaks
|
||||
- `example_08_pagination_auto.png` (87 KB) - 2 pages with automatic pagination
|
||||
|
||||
**Source:** [examples/08_pagination_demo.py](../../examples/08_pagination_demo.py)
|
||||
**Test:** [tests/examples/test_08_pagination_demo.py](../../tests/examples/test_08_pagination_demo.py)
|
||||
|
||||
**Demonstrates:**
|
||||
- Using `PageBreak` to force content onto new pages
|
||||
- Multi-page document layout with explicit breaks
|
||||
- Automatic pagination when content overflows
|
||||
- Page numbering functionality
|
||||
- Document flow control
|
||||
|
||||
**Coverage:** ✅ Fills critical gap - PageBreak had NO examples before this
|
||||
|
||||
### Example 09: Link Navigation (NEW)
|
||||
**File:** `example_09_link_navigation.png` (60 KB)
|
||||
**Source:** [examples/09_link_navigation_demo.py](../../examples/09_link_navigation_demo.py)
|
||||
**Test:** [tests/examples/test_09_link_navigation_demo.py](../../tests/examples/test_09_link_navigation_demo.py)
|
||||
|
||||
**Demonstrates:**
|
||||
- **Internal links** - Document navigation (`#section1`, `#section2`)
|
||||
- **External links** - Web URLs (`https://example.com`)
|
||||
- **API links** - API endpoints (`/api/settings`, `/api/save`)
|
||||
- **Function links** - Direct function calls (`calculate()`, `process()`)
|
||||
- Link styling (underlined, color-coded by type)
|
||||
- Link callbacks and interactivity
|
||||
|
||||
**Coverage:** ✅ Comprehensive - All 4 LinkType variations demonstrated
|
||||
|
||||
### Example 10: Comprehensive Forms (NEW)
|
||||
**File:** `example_10_forms.png` (31 KB)
|
||||
**Source:** [examples/10_forms_demo.py](../../examples/10_forms_demo.py)
|
||||
**Test:** [tests/examples/test_10_forms_demo.py](../../tests/examples/test_10_forms_demo.py)
|
||||
|
||||
**Demonstrates all 14 FormFieldType variations:**
|
||||
|
||||
**Text-Based Fields:**
|
||||
- `TEXT` - Standard text input
|
||||
- `EMAIL` - Email validation field
|
||||
- `PASSWORD` - Password masking
|
||||
- `URL` - URL validation
|
||||
- `TEXTAREA` - Multi-line text
|
||||
|
||||
**Number/Date/Time Fields:**
|
||||
- `NUMBER` - Numeric input
|
||||
- `DATE` - Date picker
|
||||
- `TIME` - Time selector
|
||||
- `RANGE` - Slider control
|
||||
- `COLOR` - Color picker
|
||||
|
||||
**Selection Fields:**
|
||||
- `CHECKBOX` - Boolean selection
|
||||
- `RADIO` - Single choice from options
|
||||
- `SELECT` - Dropdown menu
|
||||
- `HIDDEN` - Hidden form data
|
||||
|
||||
**Coverage:** ✅ Complete - All 14 field types across 4 practical examples
|
||||
|
||||
---
|
||||
|
||||
## Generating New Examples
|
||||
|
||||
### Run Individual Examples
|
||||
```bash
|
||||
# Navigate to project root
|
||||
cd /path/to/pyWebLayout
|
||||
|
||||
# Run specific example
|
||||
python examples/08_pagination_demo.py
|
||||
python examples/09_link_navigation_demo.py
|
||||
python examples/10_forms_demo.py
|
||||
```
|
||||
|
||||
### Run All Example Tests
|
||||
```bash
|
||||
# Run all example tests with pytest
|
||||
python -m pytest tests/examples/ -v
|
||||
|
||||
# Run specific test file
|
||||
python -m pytest tests/examples/test_08_pagination_demo.py -v
|
||||
```
|
||||
|
||||
All new examples (08, 09, 10) include:
|
||||
- ✅ Comprehensive documentation
|
||||
- ✅ Full test coverage (30 tests total)
|
||||
- ✅ Visual output verification
|
||||
- ✅ Working code examples
|
||||
|
||||
See the main [README.md](../../README.md) and [examples/README.md](../../examples/README.md) for detailed information.
|
||||
|
||||
---
|
||||
|
||||
## Usage in Documentation
|
||||
|
||||
These GIFs are embedded in the main [README.md](../../README.md) to showcase the EbookReader's capabilities to potential users.
|
||||
These visual assets are used throughout the pyWebLayout documentation to showcase capabilities.
|
||||
|
||||
To embed in Markdown:
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
To embed in HTML with size control:
|
||||
```html
|
||||
<img src="docs/images/ereader_page_navigation.gif" width="300" alt="Page Navigation">
|
||||
<img src="docs/images/example_08_pagination_explicit.png" width="400" alt="Pagination">
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -59,8 +59,7 @@ def draw_placeholder_content(page: Page):
|
||||
100),
|
||||
font=font)
|
||||
draw.text(
|
||||
(10, 10), f"Border: {
|
||||
page.border_size}px", fill=(
|
||||
(10, 10), f"Border: {page.border_size}px", fill=(
|
||||
150, 150, 150), font=font)
|
||||
draw.text(
|
||||
(content_x + 10,
|
||||
|
||||
@@ -27,9 +27,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
def create_book_catalog_html():
|
||||
"""Create HTML for a book catalog table with actual <img> tags."""
|
||||
# Get base path for images - use absolute paths for the img src
|
||||
Path(__file__).parent.parent / "tests" / "data"
|
||||
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||
|
||||
html = """
|
||||
html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
@@ -76,9 +76,9 @@ def create_book_catalog_html():
|
||||
|
||||
def create_product_showcase_html():
|
||||
"""Create HTML for a product showcase table with images."""
|
||||
Path(__file__).parent.parent / "tests" / "data"
|
||||
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||
|
||||
html = """
|
||||
html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Demonstration of pressed/depressed states for buttons and links with visual feedback.
|
||||
|
||||
This example shows:
|
||||
1. How to use the InteractionHandler for automatic press/release cycles
|
||||
2. How to manually manage states for custom event loops
|
||||
3. How the dirty flag system tracks when re-rendering is needed
|
||||
4. Visual differences between normal, hovered, and pressed states
|
||||
|
||||
The demo creates a page with buttons and links, then simulates clicking them
|
||||
with proper visual feedback timing.
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete import Page
|
||||
from pyWebLayout.concrete.interaction_handler import InteractionHandler, InteractionStateManager
|
||||
from pyWebLayout.abstract.functional import Button, Link, LinkType
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
|
||||
def create_interactive_demo_page():
|
||||
"""
|
||||
Create a page with various interactive elements demonstrating state changes.
|
||||
"""
|
||||
# Create page
|
||||
page = Page(size=(600, 500), style=PageStyle(border_width=10))
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create fonts
|
||||
title_font = Font(font_size=24, colour=(0, 0, 100))
|
||||
body_font = Font(font_size=16, colour=(0, 0, 0))
|
||||
button_font = Font(font_size=14, colour=(255, 255, 255))
|
||||
|
||||
# Title
|
||||
title = Paragraph(title_font)
|
||||
title.add_word(Word("Interactive", title_font))
|
||||
title.add_word(Word("Elements", title_font))
|
||||
title.add_word(Word("Demo", title_font))
|
||||
layouter.layout_paragraph(title)
|
||||
page._current_y_offset += 15
|
||||
|
||||
# Description
|
||||
desc = Paragraph(body_font)
|
||||
desc.add_word(Word("Click", body_font))
|
||||
desc.add_word(Word("the", body_font))
|
||||
desc.add_word(Word("buttons", body_font))
|
||||
desc.add_word(Word("and", body_font))
|
||||
desc.add_word(Word("links", body_font))
|
||||
desc.add_word(Word("below", body_font))
|
||||
desc.add_word(Word("to", body_font))
|
||||
desc.add_word(Word("see", body_font))
|
||||
desc.add_word(Word("pressed", body_font))
|
||||
desc.add_word(Word("state", body_font))
|
||||
desc.add_word(Word("feedback!", body_font))
|
||||
layouter.layout_paragraph(desc)
|
||||
page._current_y_offset += 20
|
||||
|
||||
# Callback functions
|
||||
def on_save():
|
||||
print("💾 Save button clicked!")
|
||||
return "saved"
|
||||
|
||||
def on_cancel():
|
||||
print("❌ Cancel button clicked!")
|
||||
return "cancelled"
|
||||
|
||||
def on_link_click(location, point):
|
||||
print(f"🔗 Link clicked: {location} at {point}")
|
||||
return location
|
||||
|
||||
# Create buttons
|
||||
save_button = Button(
|
||||
label="Save Document",
|
||||
callback=lambda point, **kwargs: on_save(),
|
||||
html_id="save-btn"
|
||||
)
|
||||
|
||||
cancel_button = Button(
|
||||
label="Cancel",
|
||||
callback=lambda point, **kwargs: on_cancel(),
|
||||
html_id="cancel-btn"
|
||||
)
|
||||
|
||||
# Layout buttons
|
||||
success1, save_id = layouter.layout_button(save_button, font=button_font)
|
||||
page._current_y_offset += 12
|
||||
success2, cancel_id = layouter.layout_button(cancel_button, font=button_font)
|
||||
page._current_y_offset += 25
|
||||
|
||||
# Create paragraph with links
|
||||
link_para = Paragraph(body_font)
|
||||
link_para.add_word(Word("Visit", body_font))
|
||||
link_para.add_word(Word("our", body_font))
|
||||
|
||||
# Add a link
|
||||
internal_link = Link(
|
||||
location="https://example.com",
|
||||
link_type=LinkType.EXTERNAL,
|
||||
callback=on_link_click,
|
||||
title="Example website"
|
||||
)
|
||||
link_para.add_word(LinkedWord(
|
||||
"website",
|
||||
body_font,
|
||||
location="https://example.com",
|
||||
link_type=LinkType.EXTERNAL,
|
||||
callback=on_link_click,
|
||||
title="Example website"
|
||||
))
|
||||
link_para.add_word(Word("or", body_font))
|
||||
|
||||
# Add another link
|
||||
docs_link = Link(
|
||||
location="/docs",
|
||||
link_type=LinkType.INTERNAL,
|
||||
callback=on_link_click,
|
||||
title="Documentation"
|
||||
)
|
||||
link_para.add_word(LinkedWord(
|
||||
"documentation",
|
||||
body_font,
|
||||
location="/docs",
|
||||
link_type=LinkType.INTERNAL,
|
||||
callback=on_link_click,
|
||||
title="Documentation"
|
||||
))
|
||||
link_para.add_word(Word("page.", body_font))
|
||||
|
||||
layouter.layout_paragraph(link_para)
|
||||
|
||||
return page, save_id, cancel_id
|
||||
|
||||
|
||||
def demo_automatic_interaction():
|
||||
"""
|
||||
Demonstrate automatic interaction handling with InteractionHandler.
|
||||
|
||||
This shows the simplest usage pattern where InteractionHandler manages
|
||||
the complete press/release cycle automatically.
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("Demo 1: Automatic Interaction with Visual Feedback")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create the page
|
||||
page, save_id, cancel_id = create_interactive_demo_page()
|
||||
|
||||
# Create interaction handler
|
||||
handler = InteractionHandler(page, press_duration_ms=150)
|
||||
|
||||
print("Initial render:")
|
||||
initial_render = page.render()
|
||||
initial_render.save("demo_07_initial.png")
|
||||
print(f" ✓ Saved: demo_07_initial.png")
|
||||
print(f" ✓ Page dirty flag: {page.is_dirty}")
|
||||
print()
|
||||
|
||||
# Get the save button
|
||||
save_button = page.callbacks.get_by_id("save-btn")
|
||||
click_point = np.array([50, 150])
|
||||
|
||||
print("Simulating button click with automatic feedback...")
|
||||
print(f" → Setting pressed state at t=0ms")
|
||||
|
||||
# Execute with automatic feedback
|
||||
pressed_frame, released_frame, result = handler.execute_with_feedback(
|
||||
save_button,
|
||||
click_point
|
||||
)
|
||||
|
||||
print(f" → Showing pressed state for 150ms")
|
||||
print(f" → Executing callback")
|
||||
print(f" → Result: {result}")
|
||||
print(f" → Setting released state")
|
||||
|
||||
# Save the frames
|
||||
pressed_frame.save("demo_07_pressed.png")
|
||||
print(f" ✓ Saved: demo_07_pressed.png")
|
||||
|
||||
released_frame.save("demo_07_released.png")
|
||||
print(f" ✓ Saved: demo_07_released.png")
|
||||
print()
|
||||
|
||||
|
||||
def demo_manual_state_management():
|
||||
"""
|
||||
Demonstrate manual state management for custom event loops.
|
||||
|
||||
This shows how an application with its own event loop can manage
|
||||
states and check the dirty flag before re-rendering.
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("Demo 2: Manual State Management with Dirty Flag Checking")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create the page
|
||||
page, save_id, cancel_id = create_interactive_demo_page()
|
||||
|
||||
# Initial render
|
||||
print("Initial render:")
|
||||
current_frame = page.render()
|
||||
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
|
||||
print()
|
||||
|
||||
# Get the cancel button
|
||||
cancel_button = page.callbacks.get_by_id("cancel-btn")
|
||||
|
||||
# Simulate mouse down
|
||||
print("Mouse down event:")
|
||||
# Set page reference if not already set
|
||||
if not hasattr(cancel_button, '_page') or cancel_button._page is None:
|
||||
cancel_button.set_page(page)
|
||||
cancel_button.set_pressed(True)
|
||||
print(f" ✓ Set pressed state")
|
||||
print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)")
|
||||
|
||||
# Check if we need to re-render
|
||||
if page.is_dirty:
|
||||
print(" → Re-rendering (dirty flag is set)")
|
||||
current_frame = page.render()
|
||||
current_frame.save("demo_07_manual_pressed.png")
|
||||
print(f" ✓ Saved: demo_07_manual_pressed.png")
|
||||
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
|
||||
print()
|
||||
|
||||
# Wait a bit
|
||||
print("Waiting 150ms for visual feedback...")
|
||||
time.sleep(0.15)
|
||||
print()
|
||||
|
||||
# Execute callback
|
||||
print("Executing callback:")
|
||||
result = cancel_button.interact(np.array([50, 200]))
|
||||
print(f" ✓ Result: {result}")
|
||||
print()
|
||||
|
||||
# Simulate mouse up
|
||||
print("Mouse up event:")
|
||||
cancel_button.set_pressed(False)
|
||||
print(f" ✓ Set released state")
|
||||
print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)")
|
||||
|
||||
# Check if we need to re-render
|
||||
if page.is_dirty:
|
||||
print(" → Re-rendering (dirty flag is set)")
|
||||
current_frame = page.render()
|
||||
current_frame.save("demo_07_manual_released.png")
|
||||
print(f" ✓ Saved: demo_07_manual_released.png")
|
||||
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
|
||||
print()
|
||||
|
||||
|
||||
def demo_state_manager():
|
||||
"""
|
||||
Demonstrate the InteractionStateManager for hover/press tracking.
|
||||
|
||||
This shows how to use the high-level state manager that automatically
|
||||
handles hover and press states based on cursor position.
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("Demo 3: InteractionStateManager for Hover and Press Tracking")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create the page
|
||||
page, save_id, cancel_id = create_interactive_demo_page()
|
||||
|
||||
# Create state manager
|
||||
state_mgr = InteractionStateManager(page)
|
||||
|
||||
# Initial render
|
||||
print("Initial render:")
|
||||
current_frame = page.render()
|
||||
print(f" ✓ Rendered initial state")
|
||||
print()
|
||||
|
||||
# Simulate cursor moving over a button
|
||||
button_center = (150, 150)
|
||||
print(f"Cursor moves to button position {button_center}:")
|
||||
hover_frame = state_mgr.update_hover(button_center)
|
||||
if hover_frame:
|
||||
print(f" ✓ Hover state changed, page re-rendered")
|
||||
hover_frame.save("demo_07_hover.png")
|
||||
print(f" ✓ Saved: demo_07_hover.png")
|
||||
print()
|
||||
|
||||
# Simulate mouse down
|
||||
print(f"Mouse down at {button_center}:")
|
||||
pressed_frame = state_mgr.handle_mouse_down(button_center)
|
||||
if pressed_frame:
|
||||
print(f" ✓ Pressed state set, page re-rendered")
|
||||
pressed_frame.save("demo_07_state_mgr_pressed.png")
|
||||
print(f" ✓ Saved: demo_07_state_mgr_pressed.png")
|
||||
print()
|
||||
|
||||
# Wait for visual feedback
|
||||
time.sleep(0.15)
|
||||
|
||||
# Simulate mouse up
|
||||
print(f"Mouse up at {button_center}:")
|
||||
released_frame, result = state_mgr.handle_mouse_up(button_center)
|
||||
if released_frame:
|
||||
print(f" ✓ Released state set, page re-rendered")
|
||||
print(f" ✓ Callback result: {result}")
|
||||
released_frame.save("demo_07_state_mgr_released.png")
|
||||
print(f" ✓ Saved: demo_07_state_mgr_released.png")
|
||||
print()
|
||||
|
||||
# Simulate cursor moving away
|
||||
away_point = (50, 50)
|
||||
print(f"Cursor moves away to {away_point}:")
|
||||
away_frame = state_mgr.update_hover(away_point)
|
||||
if away_frame:
|
||||
print(f" ✓ Hover state cleared, page re-rendered")
|
||||
away_frame.save("demo_07_no_hover.png")
|
||||
print(f" ✓ Saved: demo_07_no_hover.png")
|
||||
print()
|
||||
|
||||
|
||||
def demo_performance_optimization():
|
||||
"""
|
||||
Demonstrate how the dirty flag prevents unnecessary re-renders.
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("Demo 4: Performance Optimization with Dirty Flag")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create the page
|
||||
page, save_id, cancel_id = create_interactive_demo_page()
|
||||
|
||||
print("Scenario: Multiple state queries without changes")
|
||||
print()
|
||||
|
||||
# Initial render
|
||||
page.render()
|
||||
print(f"1. After initial render - dirty: {page.is_dirty}")
|
||||
|
||||
# Check if dirty before rendering again
|
||||
print(f"2. Check dirty flag: {page.is_dirty}")
|
||||
if not page.is_dirty:
|
||||
print(" → Skipping render (no changes)")
|
||||
print()
|
||||
|
||||
# Now make a change
|
||||
button = page.callbacks.get_by_id("save-btn")
|
||||
print("3. Setting button to pressed state")
|
||||
# Ensure page reference is set
|
||||
if not hasattr(button, '_page') or button._page is None:
|
||||
button.set_page(page)
|
||||
button.set_pressed(True)
|
||||
print(f" → dirty: {page.is_dirty}")
|
||||
print()
|
||||
|
||||
# This time we need to render
|
||||
print(f"4. Check dirty flag: {page.is_dirty}")
|
||||
if page.is_dirty:
|
||||
print(" → Re-rendering (state changed)")
|
||||
page.render()
|
||||
print(f" → dirty after render: {page.is_dirty}")
|
||||
print()
|
||||
|
||||
print("Benefit: Only render when actual changes occur!")
|
||||
print()
|
||||
|
||||
|
||||
def create_animated_gif():
|
||||
"""
|
||||
Create an animated GIF showing the button press sequence.
|
||||
"""
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
print("=" * 70)
|
||||
print("Creating Animated GIF")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Check if the PNG files exist
|
||||
png_files = [
|
||||
"demo_07_initial.png",
|
||||
"demo_07_pressed.png",
|
||||
"demo_07_released.png"
|
||||
]
|
||||
|
||||
if not all(os.path.exists(f) for f in png_files):
|
||||
print(" ⚠ PNG files not found, skipping GIF creation")
|
||||
return
|
||||
|
||||
# Load the images
|
||||
initial = Image.open('demo_07_initial.png')
|
||||
pressed = Image.open('demo_07_pressed.png')
|
||||
released = Image.open('demo_07_released.png')
|
||||
|
||||
# Create animated GIF showing the button interaction sequence
|
||||
# Sequence: initial (1000ms) -> pressed (200ms) -> released (500ms) -> loop
|
||||
frames = [initial, pressed, released]
|
||||
durations = [1000, 200, 500] # milliseconds per frame
|
||||
|
||||
output_path = "docs/images/example_07_button_animation.gif"
|
||||
|
||||
# Create docs/images directory if it doesn't exist
|
||||
os.makedirs("docs/images", exist_ok=True)
|
||||
|
||||
# Save as animated GIF
|
||||
initial.save(
|
||||
output_path,
|
||||
save_all=True,
|
||||
append_images=[pressed, released],
|
||||
duration=durations,
|
||||
loop=0 # 0 means loop forever
|
||||
)
|
||||
|
||||
print(f" ✓ Created: {output_path}")
|
||||
print(f" ✓ Frames: {len(frames)}")
|
||||
print(f" ✓ Sequence: initial (1000ms) → pressed (200ms) → released (500ms)")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n")
|
||||
print("╔" + "═" * 68 + "╗")
|
||||
print("║" + " " * 15 + "PRESSED STATE DEMONSTRATION" + " " * 26 + "║")
|
||||
print("╚" + "═" * 68 + "╝")
|
||||
print()
|
||||
|
||||
# Run all demos
|
||||
demo_automatic_interaction()
|
||||
print("\n")
|
||||
|
||||
demo_manual_state_management()
|
||||
print("\n")
|
||||
|
||||
demo_state_manager()
|
||||
print("\n")
|
||||
|
||||
demo_performance_optimization()
|
||||
print("\n")
|
||||
|
||||
# Create animated GIF
|
||||
create_animated_gif()
|
||||
|
||||
print("=" * 70)
|
||||
print("All demos complete! Check the generated PNG files and animated GIF.")
|
||||
print("=" * 70)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Demonstration of bundled fonts in pyWebLayout.
|
||||
|
||||
This example shows:
|
||||
1. How to use the bundled DejaVu font families
|
||||
2. Different font variants (regular, bold, italic, bold-italic)
|
||||
3. The three font families (Sans, Serif, Monospace)
|
||||
4. Convenient Font.from_family() method for easy font selection
|
||||
|
||||
The demo creates a page showcasing all bundled fonts with different styles.
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete import Page
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, BundledFont
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
|
||||
def create_font_showcase_page():
|
||||
"""
|
||||
Create a page demonstrating all bundled fonts and variants.
|
||||
"""
|
||||
# Create page with some padding
|
||||
page = Page(size=(800, 1000), style=PageStyle(border_width=20))
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Title
|
||||
title_font = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=32,
|
||||
colour=(0, 0, 100),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
title = Paragraph(title_font)
|
||||
title.add_word(Word("Bundled", title_font))
|
||||
title.add_word(Word("Fonts", title_font))
|
||||
title.add_word(Word("Showcase", title_font))
|
||||
layouter.layout_paragraph(title)
|
||||
page._current_y_offset += 20
|
||||
|
||||
# Introduction
|
||||
intro_font = Font.from_family(BundledFont.SANS, font_size=14, colour=(50, 50, 50))
|
||||
intro = Paragraph(intro_font)
|
||||
intro_text = "pyWebLayout bundles the DejaVu font family with three font types and four variants each."
|
||||
for word in intro_text.split():
|
||||
intro.add_word(Word(word, intro_font))
|
||||
layouter.layout_paragraph(intro)
|
||||
page._current_y_offset += 25
|
||||
|
||||
# --- Sans Serif Section ---
|
||||
section_font = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=20,
|
||||
colour=(0, 100, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
sans_section = Paragraph(section_font)
|
||||
sans_section.add_word(Word("Sans-Serif", section_font))
|
||||
sans_section.add_word(Word("(DejaVu", section_font))
|
||||
sans_section.add_word(Word("Sans)", section_font))
|
||||
layouter.layout_paragraph(sans_section)
|
||||
page._current_y_offset += 10
|
||||
|
||||
# Sans Regular
|
||||
sans_regular = Font.from_family(BundledFont.SANS, font_size=16)
|
||||
demo_text_paragraph(layouter, page, sans_regular, "Regular:")
|
||||
|
||||
# Sans Bold
|
||||
sans_bold = Font.from_family(BundledFont.SANS, font_size=16, weight=FontWeight.BOLD)
|
||||
demo_text_paragraph(layouter, page, sans_bold, "Bold:")
|
||||
|
||||
# Sans Italic
|
||||
sans_italic = Font.from_family(BundledFont.SANS, font_size=16, style=FontStyle.ITALIC)
|
||||
demo_text_paragraph(layouter, page, sans_italic, "Italic:")
|
||||
|
||||
# Sans Bold Italic
|
||||
sans_bold_italic = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=16,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
demo_text_paragraph(layouter, page, sans_bold_italic, "Bold Italic:")
|
||||
page._current_y_offset += 20
|
||||
|
||||
# --- Serif Section ---
|
||||
serif_section = Paragraph(section_font)
|
||||
serif_section.add_word(Word("Serif", section_font))
|
||||
serif_section.add_word(Word("(DejaVu", section_font))
|
||||
serif_section.add_word(Word("Serif)", section_font))
|
||||
layouter.layout_paragraph(serif_section)
|
||||
page._current_y_offset += 10
|
||||
|
||||
# Serif Regular
|
||||
serif_regular = Font.from_family(BundledFont.SERIF, font_size=16)
|
||||
demo_text_paragraph(layouter, page, serif_regular, "Regular:")
|
||||
|
||||
# Serif Bold
|
||||
serif_bold = Font.from_family(BundledFont.SERIF, font_size=16, weight=FontWeight.BOLD)
|
||||
demo_text_paragraph(layouter, page, serif_bold, "Bold:")
|
||||
|
||||
# Serif Italic
|
||||
serif_italic = Font.from_family(BundledFont.SERIF, font_size=16, style=FontStyle.ITALIC)
|
||||
demo_text_paragraph(layouter, page, serif_italic, "Italic:")
|
||||
|
||||
# Serif Bold Italic
|
||||
serif_bold_italic = Font.from_family(
|
||||
BundledFont.SERIF,
|
||||
font_size=16,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
demo_text_paragraph(layouter, page, serif_bold_italic, "Bold Italic:")
|
||||
page._current_y_offset += 20
|
||||
|
||||
# --- Monospace Section ---
|
||||
mono_section = Paragraph(section_font)
|
||||
mono_section.add_word(Word("Monospace", section_font))
|
||||
mono_section.add_word(Word("(DejaVu", section_font))
|
||||
mono_section.add_word(Word("Sans", section_font))
|
||||
mono_section.add_word(Word("Mono)", section_font))
|
||||
layouter.layout_paragraph(mono_section)
|
||||
page._current_y_offset += 10
|
||||
|
||||
# Mono Regular
|
||||
mono_regular = Font.from_family(BundledFont.MONOSPACE, font_size=14)
|
||||
demo_code_paragraph(layouter, page, mono_regular, "Regular:")
|
||||
|
||||
# Mono Bold
|
||||
mono_bold = Font.from_family(BundledFont.MONOSPACE, font_size=14, weight=FontWeight.BOLD)
|
||||
demo_code_paragraph(layouter, page, mono_bold, "Bold:")
|
||||
|
||||
# Mono Italic
|
||||
mono_italic = Font.from_family(BundledFont.MONOSPACE, font_size=14, style=FontStyle.ITALIC)
|
||||
demo_code_paragraph(layouter, page, mono_italic, "Italic:")
|
||||
|
||||
# Mono Bold Italic
|
||||
mono_bold_italic = Font.from_family(
|
||||
BundledFont.MONOSPACE,
|
||||
font_size=14,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
demo_code_paragraph(layouter, page, mono_bold_italic, "Bold Italic:")
|
||||
page._current_y_offset += 20
|
||||
|
||||
# Footer
|
||||
footer_font = Font.from_family(BundledFont.SANS, font_size=12, colour=(100, 100, 100))
|
||||
footer = Paragraph(footer_font)
|
||||
footer_text = "All fonts are free and open source under the Bitstream Vera License."
|
||||
for word in footer_text.split():
|
||||
footer.add_word(Word(word, footer_font))
|
||||
layouter.layout_paragraph(footer)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def demo_text_paragraph(layouter, page, font, label):
|
||||
"""Create a paragraph showing sample text with the given font."""
|
||||
# Label in smaller font
|
||||
label_font = Font.from_family(BundledFont.SANS, font_size=12, colour=(100, 100, 100))
|
||||
label_para = Paragraph(label_font)
|
||||
label_para.add_word(Word(label, label_font))
|
||||
layouter.layout_paragraph(label_para)
|
||||
page._current_y_offset += 5
|
||||
|
||||
# Sample text
|
||||
para = Paragraph(font)
|
||||
sample = "The quick brown fox jumps over the lazy dog. 0123456789"
|
||||
for word in sample.split():
|
||||
para.add_word(Word(word, font))
|
||||
layouter.layout_paragraph(para)
|
||||
page._current_y_offset += 8
|
||||
|
||||
|
||||
def demo_code_paragraph(layouter, page, font, label):
|
||||
"""Create a paragraph showing sample code with the given font."""
|
||||
# Label in smaller font
|
||||
label_font = Font.from_family(BundledFont.SANS, font_size=12, colour=(100, 100, 100))
|
||||
label_para = Paragraph(label_font)
|
||||
label_para.add_word(Word(label, label_font))
|
||||
layouter.layout_paragraph(label_para)
|
||||
page._current_y_offset += 5
|
||||
|
||||
# Sample code
|
||||
para = Paragraph(font)
|
||||
code = "def hello(): print('Hello, World!') # 0123456789"
|
||||
for word in code.split():
|
||||
para.add_word(Word(word, font))
|
||||
layouter.layout_paragraph(para)
|
||||
page._current_y_offset += 8
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n")
|
||||
print("=" * 70)
|
||||
print("Bundled Fonts Demonstration")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
print("Creating font showcase page...")
|
||||
page = create_font_showcase_page()
|
||||
|
||||
print("Rendering page...")
|
||||
image = page.render()
|
||||
|
||||
output_file = "demo_08_bundled_fonts.png"
|
||||
image.save(output_file)
|
||||
print(f"Saved: {output_file}")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Demo complete!")
|
||||
print()
|
||||
print("The page showcases all bundled fonts:")
|
||||
print(" - DejaVu Sans (Sans-serif)")
|
||||
print(" - DejaVu Serif (Serif)")
|
||||
print(" - DejaVu Sans Mono (Monospace)")
|
||||
print()
|
||||
print("Each family has 4 variants:")
|
||||
print(" - Regular")
|
||||
print(" - Bold")
|
||||
print(" - Italic")
|
||||
print(" - Bold Italic")
|
||||
print("=" * 70)
|
||||
print()
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pagination Example with PageBreak
|
||||
|
||||
This example demonstrates:
|
||||
- Using PageBreak to force content onto new pages
|
||||
- Multi-page document layout with automatic page creation
|
||||
- Different content types across multiple pages
|
||||
- Page numbering and document flow
|
||||
- Combining text, images, and tables across pages
|
||||
|
||||
This shows how to create multi-page documents with explicit page breaks.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import Paragraph, PageBreak, Image as AbstractImage
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
|
||||
def create_sample_paragraph(text: str, font_size: int = 14) -> Paragraph:
|
||||
"""Create a paragraph from plain text."""
|
||||
font = Font(font_size=font_size, colour=(50, 50, 50))
|
||||
paragraph = Paragraph(style=font)
|
||||
for word in text.split():
|
||||
paragraph.add_word(Word(word, font))
|
||||
return paragraph
|
||||
|
||||
|
||||
def create_title_paragraph(text: str) -> Paragraph:
|
||||
"""Create a title paragraph with larger font."""
|
||||
font = Font(font_size=24, colour=(0, 0, 100), weight='bold')
|
||||
paragraph = Paragraph(style=font)
|
||||
for word in text.split():
|
||||
paragraph.add_word(Word(word, font))
|
||||
return paragraph
|
||||
|
||||
|
||||
def create_heading_paragraph(text: str) -> Paragraph:
|
||||
"""Create a heading paragraph."""
|
||||
font = Font(font_size=18, colour=(50, 50, 100), weight='bold')
|
||||
paragraph = Paragraph(style=font)
|
||||
for word in text.split():
|
||||
paragraph.add_word(Word(word, font))
|
||||
return paragraph
|
||||
|
||||
|
||||
def create_placeholder_image(width: int, height: int, label: str) -> AbstractImage:
|
||||
"""Create a placeholder image for demonstration."""
|
||||
img = Image.new('RGB', (width, height), (200, 220, 240))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw border
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=(100, 120, 140), width=2)
|
||||
|
||||
# Add label
|
||||
text_bbox = draw.textbbox((0, 0), label)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_height = text_bbox[3] - text_bbox[1]
|
||||
text_x = (width - text_width) // 2
|
||||
text_y = (height - text_height) // 2
|
||||
draw.text((text_x, text_y), label, fill=(80, 80, 120))
|
||||
|
||||
return AbstractImage(source=img)
|
||||
|
||||
|
||||
def create_example_document_with_pagebreaks():
|
||||
"""
|
||||
Example: Multi-page document with explicit page breaks.
|
||||
|
||||
This demonstrates how PageBreak forces content onto new pages.
|
||||
"""
|
||||
print("\n Creating multi-page document with PageBreaks...")
|
||||
|
||||
# Define common page style
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(100, 100, 150),
|
||||
padding=(30, 40, 30, 40),
|
||||
background_color=(255, 255, 255),
|
||||
line_spacing=6
|
||||
)
|
||||
|
||||
# Create document content with page breaks
|
||||
content = [
|
||||
# Page 1: Title and Introduction
|
||||
create_title_paragraph("Multi-Page Document Example"),
|
||||
create_sample_paragraph(
|
||||
"This document demonstrates how to use PageBreak elements to control "
|
||||
"document pagination. Each PageBreak forces subsequent content to start "
|
||||
"on a new page, allowing you to structure multi-page documents precisely."
|
||||
),
|
||||
create_sample_paragraph(
|
||||
"Page breaks are particularly useful for creating chapters, sections, or "
|
||||
"ensuring that important content starts at the top of a fresh page rather "
|
||||
"than being split across page boundaries."
|
||||
),
|
||||
|
||||
# Force page break - next content will be on page 2
|
||||
PageBreak(),
|
||||
|
||||
# Page 2: First Section
|
||||
create_heading_paragraph("Section 1: Text Content"),
|
||||
create_sample_paragraph(
|
||||
"This is the second page of our document. It starts with a clean break "
|
||||
"from the previous page, ensuring the section heading appears at the top."
|
||||
),
|
||||
create_sample_paragraph(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod "
|
||||
"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim "
|
||||
"veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea "
|
||||
"commodo consequat."
|
||||
),
|
||||
create_sample_paragraph(
|
||||
"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 deserunt mollit anim id est laborum."
|
||||
),
|
||||
|
||||
# Another page break
|
||||
PageBreak(),
|
||||
|
||||
# Page 3: Images
|
||||
create_heading_paragraph("Section 2: Visual Content"),
|
||||
create_sample_paragraph(
|
||||
"This page contains image content, demonstrating that page breaks work "
|
||||
"correctly with different content types."
|
||||
),
|
||||
create_placeholder_image(300, 200, "Figure 1: Sample Image"),
|
||||
create_sample_paragraph("The image above is placed on this dedicated page."),
|
||||
|
||||
# Final page break
|
||||
PageBreak(),
|
||||
|
||||
# Page 4: Conclusion
|
||||
create_heading_paragraph("Conclusion"),
|
||||
create_sample_paragraph(
|
||||
"This final page demonstrates that you can create complex multi-page "
|
||||
"documents by strategically placing PageBreak elements in your content."
|
||||
),
|
||||
create_sample_paragraph(
|
||||
"Key benefits of using PageBreak: 1) Control where pages start, "
|
||||
"2) Prevent awkward content splits, 3) Create professional-looking "
|
||||
"documents with proper sectioning, 4) Ensure important content gets "
|
||||
"visual prominence at page tops."
|
||||
),
|
||||
create_sample_paragraph(
|
||||
"Thank you for reviewing this pagination example. Try experimenting "
|
||||
"with PageBreak placement to create your own multi-page documents!"
|
||||
),
|
||||
]
|
||||
|
||||
# Layout the document across multiple pages
|
||||
pages = []
|
||||
current_page = Page(size=(600, 800), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
|
||||
for element in content:
|
||||
if isinstance(element, PageBreak):
|
||||
# Save current page and create a new one
|
||||
pages.append(current_page)
|
||||
current_page = Page(size=(600, 800), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
elif isinstance(element, Paragraph):
|
||||
success, _, _ = layouter.layout_paragraph(element)
|
||||
if not success:
|
||||
# Page is full, create new page and retry
|
||||
pages.append(current_page)
|
||||
current_page = Page(size=(600, 800), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
success, _, _ = layouter.layout_paragraph(element)
|
||||
if not success:
|
||||
print(" WARNING: Content too large for page")
|
||||
elif isinstance(element, AbstractImage):
|
||||
success = layouter.layout_image(element)
|
||||
if not success:
|
||||
# Image doesn't fit, try on new page
|
||||
pages.append(current_page)
|
||||
current_page = Page(size=(600, 800), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
success = layouter.layout_image(element)
|
||||
if not success:
|
||||
print(" WARNING: Image too large for page")
|
||||
|
||||
# Add the final page
|
||||
pages.append(current_page)
|
||||
|
||||
print(f" Created {len(pages)} pages")
|
||||
return pages
|
||||
|
||||
|
||||
def create_auto_pagination_example():
|
||||
"""
|
||||
Example: Document that automatically flows to multiple pages.
|
||||
|
||||
This shows the difference between automatic pagination (when content
|
||||
doesn't fit) vs explicit PageBreak usage.
|
||||
"""
|
||||
print("\n Creating auto-paginated document (no explicit breaks)...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=1,
|
||||
border_color=(150, 150, 150),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(250, 250, 250),
|
||||
line_spacing=5
|
||||
)
|
||||
|
||||
# Create lots of content that will naturally overflow
|
||||
content = [
|
||||
create_heading_paragraph("Auto-Pagination Example"),
|
||||
create_sample_paragraph(
|
||||
"This document does NOT use PageBreak. Instead, it demonstrates how "
|
||||
"content automatically flows to new pages when the current page is full."
|
||||
),
|
||||
]
|
||||
|
||||
# Add many paragraphs to force automatic page breaks
|
||||
for i in range(1, 11):
|
||||
content.append(
|
||||
create_sample_paragraph(
|
||||
f"Paragraph {i}: This is automatically laid out content. "
|
||||
f"When this paragraph doesn't fit on the current page, the layouter "
|
||||
f"will create a new page automatically. This is different from using "
|
||||
f"PageBreak which forces a new page regardless of available space. "
|
||||
f"Auto-pagination is useful for flowing content naturally."
|
||||
)
|
||||
)
|
||||
|
||||
# Layout across pages
|
||||
pages = []
|
||||
current_page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
|
||||
for element in content:
|
||||
if isinstance(element, Paragraph):
|
||||
success, _, _ = layouter.layout_paragraph(element)
|
||||
if not success:
|
||||
# Auto page break - content didn't fit
|
||||
pages.append(current_page)
|
||||
current_page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(current_page)
|
||||
layouter.layout_paragraph(element)
|
||||
|
||||
pages.append(current_page)
|
||||
|
||||
print(f" Auto-created {len(pages)} pages")
|
||||
return pages
|
||||
|
||||
|
||||
def add_page_numbers(pages, start_number: int = 1):
|
||||
"""Add page numbers to rendered pages."""
|
||||
numbered_pages = []
|
||||
font = Font(font_size=10, colour=(100, 100, 100))
|
||||
|
||||
for i, page in enumerate(pages, start=start_number):
|
||||
# Render the page
|
||||
img = page.render()
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Add page number at bottom center
|
||||
page_text = f"Page {i}"
|
||||
bbox = draw.textbbox((0, 0), page_text)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = (img.size[0] - text_width) // 2
|
||||
y = img.size[1] - 20
|
||||
|
||||
draw.text((x, y), page_text, fill=(100, 100, 100))
|
||||
numbered_pages.append(img)
|
||||
|
||||
return numbered_pages
|
||||
|
||||
|
||||
def combine_pages_vertically(pages, title: str = ""):
|
||||
"""Combine multiple pages into a vertical strip."""
|
||||
if not pages:
|
||||
return None
|
||||
|
||||
padding = 20
|
||||
title_height = 40 if title else 0
|
||||
|
||||
# Calculate dimensions
|
||||
page_width = pages[0].size[0]
|
||||
page_height = pages[0].size[1]
|
||||
|
||||
total_width = page_width + 2 * padding
|
||||
total_height = len(pages) * (page_height + padding) + padding + title_height
|
||||
|
||||
# Create combined image
|
||||
combined = Image.new('RGB', (total_width, total_height), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(combined)
|
||||
|
||||
# Draw title if provided
|
||||
if title:
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
title_font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16
|
||||
)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
title_x = (total_width - text_width) // 2
|
||||
draw.text((title_x, 10), title, fill=(50, 50, 50), font=title_font)
|
||||
|
||||
# Place pages vertically
|
||||
y_offset = title_height + padding
|
||||
for page_img in pages:
|
||||
combined.paste(page_img, (padding, y_offset))
|
||||
y_offset += page_height + padding
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate pagination with PageBreak."""
|
||||
print("Pagination Example with PageBreak")
|
||||
print("=" * 50)
|
||||
|
||||
# Example 1: Explicit page breaks
|
||||
pages1 = create_example_document_with_pagebreaks()
|
||||
rendered_pages1 = add_page_numbers(pages1)
|
||||
combined1 = combine_pages_vertically(
|
||||
rendered_pages1,
|
||||
"Example 1: Explicit PageBreak Usage"
|
||||
)
|
||||
|
||||
# Example 2: Auto pagination
|
||||
pages2 = create_auto_pagination_example()
|
||||
rendered_pages2 = add_page_numbers(pages2)
|
||||
combined2 = combine_pages_vertically(
|
||||
rendered_pages2,
|
||||
"Example 2: Automatic Pagination"
|
||||
)
|
||||
|
||||
# Save outputs
|
||||
output_dir = Path("docs/images")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_path1 = output_dir / "example_08_pagination_explicit.png"
|
||||
output_path2 = output_dir / "example_08_pagination_auto.png"
|
||||
|
||||
combined1.save(output_path1)
|
||||
combined2.save(output_path2)
|
||||
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output 1 saved to: {output_path1}")
|
||||
print(f" - {len(pages1)} pages with explicit PageBreaks")
|
||||
print(f" Output 2 saved to: {output_path2}")
|
||||
print(f" - {len(pages2)} pages with auto-pagination")
|
||||
|
||||
return combined1, combined2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Link Navigation Example
|
||||
|
||||
This example demonstrates:
|
||||
- Creating clickable links with LinkedWord
|
||||
- Different link types (INTERNAL, EXTERNAL, API, FUNCTION)
|
||||
- Link styling with underlines and colors
|
||||
- Link callbacks and event handling
|
||||
- Interactive link states (hover, pressed)
|
||||
- Organizing linked content in paragraphs
|
||||
|
||||
This shows how to create interactive documents with hyperlinks.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.inline import Word, LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
|
||||
# Track link clicks for demonstration
|
||||
link_clicks = []
|
||||
|
||||
|
||||
def link_callback(link_id: str):
|
||||
"""Callback for link clicks"""
|
||||
def callback():
|
||||
link_clicks.append(link_id)
|
||||
print(f" Link clicked: {link_id}")
|
||||
return callback
|
||||
|
||||
|
||||
def create_paragraph_with_links(
|
||||
text_parts: List[tuple],
|
||||
font_size: int = 14) -> Paragraph:
|
||||
"""
|
||||
Create a paragraph with mixed text and links.
|
||||
|
||||
Args:
|
||||
text_parts: List of tuples where each is either:
|
||||
('text', "word1 word2") for normal text
|
||||
('link', "word", location, link_type, callback_id)
|
||||
font_size: Base font size
|
||||
|
||||
Returns:
|
||||
Paragraph with words and links
|
||||
"""
|
||||
font = Font(font_size=font_size, colour=(50, 50, 50))
|
||||
paragraph = Paragraph(style=font)
|
||||
|
||||
for part in text_parts:
|
||||
if part[0] == 'text':
|
||||
# Add normal words
|
||||
for word_text in part[1].split():
|
||||
paragraph.add_word(Word(word_text, font))
|
||||
elif part[0] == 'link':
|
||||
# Add linked word
|
||||
word_text, location, link_type, callback_id = part[1:]
|
||||
callback = link_callback(callback_id)
|
||||
linked_word = LinkedWord(
|
||||
text=word_text,
|
||||
style=font,
|
||||
location=location,
|
||||
link_type=link_type,
|
||||
callback=callback,
|
||||
title=f"Click to: {location}"
|
||||
)
|
||||
paragraph.add_word(linked_word)
|
||||
|
||||
return paragraph
|
||||
|
||||
|
||||
def create_example_1_internal_links():
|
||||
"""Example 1: Internal navigation links within a document."""
|
||||
print("\n Creating Example 1: Internal links...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 150, 200),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255),
|
||||
line_spacing=6
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Title
|
||||
title_font = Font(font_size=20, colour=(0, 0, 100), weight='bold')
|
||||
title = Paragraph(style=title_font)
|
||||
for word in "Internal Navigation Links".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
|
||||
# Content with internal links
|
||||
intro = create_paragraph_with_links([
|
||||
('text', "This document demonstrates"),
|
||||
('link', "internal", "#section1", LinkType.INTERNAL, "goto_section1"),
|
||||
('text', "navigation links that jump to different parts of the document."),
|
||||
])
|
||||
|
||||
section1 = create_paragraph_with_links([
|
||||
('text', "Jump to"),
|
||||
('link', "Section 2", "#section2", LinkType.INTERNAL, "goto_section2"),
|
||||
('text', "or"),
|
||||
('link', "Section 3", "#section3", LinkType.INTERNAL, "goto_section3"),
|
||||
('text', "within this document."),
|
||||
])
|
||||
|
||||
section2 = create_paragraph_with_links([
|
||||
('text', "You are in Section 2. Return to"),
|
||||
('link', "top", "#top", LinkType.INTERNAL, "goto_top"),
|
||||
('text', "or go to"),
|
||||
('link', "Section 3", "#section3", LinkType.INTERNAL, "goto_section3_from2"),
|
||||
])
|
||||
|
||||
section3 = create_paragraph_with_links([
|
||||
('text', "This is Section 3. Go back to"),
|
||||
('link', "Section 1", "#section1", LinkType.INTERNAL, "goto_section1_from3"),
|
||||
('text', "or"),
|
||||
('link', "top", "#top", LinkType.INTERNAL, "goto_top_from3"),
|
||||
])
|
||||
|
||||
# Layout content
|
||||
layouter.layout_paragraph(title)
|
||||
layouter.layout_paragraph(intro)
|
||||
layouter.layout_paragraph(section1)
|
||||
layouter.layout_paragraph(section2)
|
||||
layouter.layout_paragraph(section3)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def create_example_2_external_links():
|
||||
"""Example 2: External links to websites."""
|
||||
print(" Creating Example 2: External links...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 200, 150),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255),
|
||||
line_spacing=6
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Title
|
||||
title_font = Font(font_size=20, colour=(0, 100, 0), weight='bold')
|
||||
title = Paragraph(style=title_font)
|
||||
for word in "External Web Links".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
|
||||
# Content with external links
|
||||
intro = create_paragraph_with_links([
|
||||
('text', "Click"),
|
||||
('link', "here", "https://example.com", LinkType.EXTERNAL, "visit_example"),
|
||||
('text', "to visit an external website."),
|
||||
])
|
||||
|
||||
resources = create_paragraph_with_links([
|
||||
('text', "Useful resources:"),
|
||||
('link', "Documentation", "https://docs.example.com", LinkType.EXTERNAL, "visit_docs"),
|
||||
('text', "and"),
|
||||
('link', "GitHub", "https://github.com/example", LinkType.EXTERNAL, "visit_github"),
|
||||
])
|
||||
|
||||
more_links = create_paragraph_with_links([
|
||||
('text', "Learn more at"),
|
||||
('link', "Wikipedia", "https://wikipedia.org", LinkType.EXTERNAL, "visit_wiki"),
|
||||
('text', "or check out"),
|
||||
('link', "Python.org", "https://python.org", LinkType.EXTERNAL, "visit_python"),
|
||||
])
|
||||
|
||||
# Layout content
|
||||
layouter.layout_paragraph(title)
|
||||
layouter.layout_paragraph(intro)
|
||||
layouter.layout_paragraph(resources)
|
||||
layouter.layout_paragraph(more_links)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def create_example_3_api_links():
|
||||
"""Example 3: API links that trigger actions."""
|
||||
print(" Creating Example 3: API links...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(200, 150, 150),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255),
|
||||
line_spacing=6
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Title
|
||||
title_font = Font(font_size=20, colour=(150, 0, 0), weight='bold')
|
||||
title = Paragraph(style=title_font)
|
||||
for word in "API Action Links".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
|
||||
# Content with API links
|
||||
settings = create_paragraph_with_links([
|
||||
('text', "Click"),
|
||||
('link', "Settings", "/api/settings", LinkType.API, "open_settings"),
|
||||
('text', "to configure the application."),
|
||||
])
|
||||
|
||||
actions = create_paragraph_with_links([
|
||||
('text', "Actions:"),
|
||||
('link', "Save", "/api/save", LinkType.API, "save_action"),
|
||||
('text', "or"),
|
||||
('link', "Export", "/api/export", LinkType.API, "export_action"),
|
||||
('text', "your data."),
|
||||
])
|
||||
|
||||
management = create_paragraph_with_links([
|
||||
('text', "Manage:"),
|
||||
('link', "Users", "/api/users", LinkType.API, "manage_users"),
|
||||
('text', "or"),
|
||||
('link', "Permissions", "/api/permissions", LinkType.API, "manage_perms"),
|
||||
])
|
||||
|
||||
# Layout content
|
||||
layouter.layout_paragraph(title)
|
||||
layouter.layout_paragraph(settings)
|
||||
layouter.layout_paragraph(actions)
|
||||
layouter.layout_paragraph(management)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def create_example_4_function_links():
|
||||
"""Example 4: Function links that execute code."""
|
||||
print(" Creating Example 4: Function links...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 200, 200),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255),
|
||||
line_spacing=6
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Title
|
||||
title_font = Font(font_size=20, colour=(0, 120, 120), weight='bold')
|
||||
title = Paragraph(style=title_font)
|
||||
for word in "Function Execution Links".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
|
||||
# Content with function links
|
||||
intro = create_paragraph_with_links([
|
||||
('text', "These links execute"),
|
||||
('link', "functions", "calculate()", LinkType.FUNCTION, "exec_calculate"),
|
||||
('text', "directly in the application."),
|
||||
])
|
||||
|
||||
calculations = create_paragraph_with_links([
|
||||
('text', "Run:"),
|
||||
('link', "analyze()", "analyze()", LinkType.FUNCTION, "exec_analyze"),
|
||||
('text', "or"),
|
||||
('link', "process()", "process()", LinkType.FUNCTION, "exec_process"),
|
||||
])
|
||||
|
||||
utilities = create_paragraph_with_links([
|
||||
('text', "Utilities:"),
|
||||
('link', "validate()", "validate()", LinkType.FUNCTION, "exec_validate"),
|
||||
('text', "and"),
|
||||
('link', "cleanup()", "cleanup()", LinkType.FUNCTION, "exec_cleanup"),
|
||||
])
|
||||
|
||||
# Layout content
|
||||
layouter.layout_paragraph(title)
|
||||
layouter.layout_paragraph(intro)
|
||||
layouter.layout_paragraph(calculations)
|
||||
layouter.layout_paragraph(utilities)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def combine_pages_into_grid(pages, title):
|
||||
"""Combine multiple pages into a 2x2 grid."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
print("\n Combining pages into grid...")
|
||||
|
||||
# Render all pages
|
||||
images = [page.render() for page in pages]
|
||||
|
||||
# Grid layout
|
||||
padding = 20
|
||||
title_height = 40
|
||||
cols = 2
|
||||
rows = 2
|
||||
|
||||
# Calculate dimensions
|
||||
img_width = images[0].size[0]
|
||||
img_height = images[0].size[1]
|
||||
|
||||
total_width = cols * img_width + (cols + 1) * padding
|
||||
total_height = rows * img_height + (rows + 1) * padding + title_height
|
||||
|
||||
# Create combined image
|
||||
combined = Image.new('RGB', (total_width, total_height), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(combined)
|
||||
|
||||
# Draw title
|
||||
try:
|
||||
title_font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18
|
||||
)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
|
||||
# Center the title
|
||||
bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
title_x = (total_width - text_width) // 2
|
||||
draw.text((title_x, 10), title, fill=(50, 50, 50), font=title_font)
|
||||
|
||||
# Place pages in grid
|
||||
y_offset = title_height + padding
|
||||
for row in range(rows):
|
||||
x_offset = padding
|
||||
for col in range(cols):
|
||||
idx = row * cols + col
|
||||
if idx < len(images):
|
||||
combined.paste(images[idx], (x_offset, y_offset))
|
||||
x_offset += img_width + padding
|
||||
y_offset += img_height + padding
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate link navigation across different link types."""
|
||||
global link_clicks
|
||||
link_clicks = []
|
||||
|
||||
print("Link Navigation Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Create examples for each link type
|
||||
pages = [
|
||||
create_example_1_internal_links(),
|
||||
create_example_2_external_links(),
|
||||
create_example_3_api_links(),
|
||||
create_example_4_function_links()
|
||||
]
|
||||
|
||||
# Combine into demonstration image
|
||||
combined_image = combine_pages_into_grid(
|
||||
pages,
|
||||
"Link Types: Internal | External | API | Function"
|
||||
)
|
||||
|
||||
# Save output
|
||||
output_dir = Path("docs/images")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / "example_09_link_navigation.png"
|
||||
combined_image.save(output_path)
|
||||
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
|
||||
print(f" Created {len(pages)} link type examples")
|
||||
print(f" Total links created: {len(link_clicks)} callbacks registered")
|
||||
|
||||
return combined_image, link_clicks
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive Forms Example
|
||||
|
||||
This example demonstrates:
|
||||
- All FormFieldType variations (TEXT, PASSWORD, EMAIL, etc.)
|
||||
- Form layout with multiple fields
|
||||
- Field labels and validation
|
||||
- Form submission callbacks
|
||||
- Organizing forms on pages
|
||||
|
||||
This shows how to create interactive forms with all available field types.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.functional import Form, FormField, FormFieldType
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
# Track form submissions
|
||||
form_submissions = []
|
||||
|
||||
|
||||
def form_submit_callback(form_id: str):
|
||||
"""Callback for form submissions"""
|
||||
def callback(data):
|
||||
form_submissions.append((form_id, data))
|
||||
print(f" Form submitted: {form_id} with data: {data}")
|
||||
return callback
|
||||
|
||||
|
||||
def create_example_1_text_fields():
|
||||
"""Example 1: Text input fields"""
|
||||
print("\n Creating Example 1: Text input fields...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 150, 200),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create form with text fields
|
||||
form = Form(form_id="text_form", html_id="text_form", callback=form_submit_callback("text_form"))
|
||||
|
||||
# Add various text-based fields
|
||||
form.add_field(FormField(
|
||||
name="username",
|
||||
label="Username",
|
||||
field_type=FormFieldType.TEXT,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="email",
|
||||
label="Email Address",
|
||||
field_type=FormFieldType.EMAIL,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="password",
|
||||
label="Password",
|
||||
field_type=FormFieldType.PASSWORD,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="website",
|
||||
label="Website URL",
|
||||
field_type=FormFieldType.URL,
|
||||
required=False
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="bio",
|
||||
label="Biography",
|
||||
field_type=FormFieldType.TEXTAREA,
|
||||
required=False
|
||||
))
|
||||
|
||||
# Layout the form
|
||||
font = Font(font_size=12, colour=(50, 50, 50))
|
||||
success, field_ids = layouter.layout_form(form, font=font)
|
||||
|
||||
print(f" Laid out {len(field_ids)} text fields")
|
||||
return page
|
||||
|
||||
|
||||
def create_example_2_number_fields():
|
||||
"""Example 2: Number and date/time fields"""
|
||||
print(" Creating Example 2: Number and date/time fields...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 200, 150),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create form with number/date fields
|
||||
form = Form(form_id="number_form", html_id="number_form", callback=form_submit_callback("number_form"))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="age",
|
||||
label="Age",
|
||||
field_type=FormFieldType.NUMBER,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="birth_date",
|
||||
label="Birth Date",
|
||||
field_type=FormFieldType.DATE,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="appointment",
|
||||
label="Appointment Time",
|
||||
field_type=FormFieldType.TIME,
|
||||
required=False
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="rating",
|
||||
label="Rating (1-10)",
|
||||
field_type=FormFieldType.RANGE,
|
||||
required=False
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="color",
|
||||
label="Favorite Color",
|
||||
field_type=FormFieldType.COLOR,
|
||||
required=False
|
||||
))
|
||||
|
||||
# Layout the form
|
||||
font = Font(font_size=12, colour=(50, 50, 50))
|
||||
success, field_ids = layouter.layout_form(form, font=font)
|
||||
|
||||
print(f" Laid out {len(field_ids)} number/date fields")
|
||||
return page
|
||||
|
||||
|
||||
def create_example_3_selection_fields():
|
||||
"""Example 3: Checkbox, radio, and select fields"""
|
||||
print(" Creating Example 3: Selection fields...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(200, 150, 150),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(500, 600), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create form with selection fields
|
||||
form = Form(form_id="selection_form", html_id="selection_form", callback=form_submit_callback("selection_form"))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="newsletter",
|
||||
label="Subscribe to Newsletter",
|
||||
field_type=FormFieldType.CHECKBOX,
|
||||
required=False
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="terms",
|
||||
label="Accept Terms and Conditions",
|
||||
field_type=FormFieldType.CHECKBOX,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="gender",
|
||||
label="Gender",
|
||||
field_type=FormFieldType.RADIO,
|
||||
required=False
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="country",
|
||||
label="Country",
|
||||
field_type=FormFieldType.SELECT,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="hidden_token",
|
||||
label="", # Hidden fields don't display labels
|
||||
field_type=FormFieldType.HIDDEN,
|
||||
required=False
|
||||
))
|
||||
|
||||
# Layout the form
|
||||
font = Font(font_size=12, colour=(50, 50, 50))
|
||||
success, field_ids = layouter.layout_form(form, font=font)
|
||||
|
||||
print(f" Laid out {len(field_ids)} selection fields")
|
||||
return page
|
||||
|
||||
|
||||
def create_example_4_complete_form():
|
||||
"""Example 4: Complete registration form with mixed field types"""
|
||||
print(" Creating Example 4: Complete registration form...")
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(150, 200, 200),
|
||||
padding=(20, 30, 20, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(500, 700), style=page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create comprehensive registration form
|
||||
form = Form(form_id="registration_form", html_id="registration_form", callback=form_submit_callback("registration"))
|
||||
|
||||
# Personal information
|
||||
form.add_field(FormField(
|
||||
name="full_name",
|
||||
label="Full Name",
|
||||
field_type=FormFieldType.TEXT,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="email",
|
||||
label="Email",
|
||||
field_type=FormFieldType.EMAIL,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="password",
|
||||
label="Password",
|
||||
field_type=FormFieldType.PASSWORD,
|
||||
required=True
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
name="age",
|
||||
label="Age",
|
||||
field_type=FormFieldType.NUMBER,
|
||||
required=True
|
||||
))
|
||||
|
||||
# Preferences
|
||||
form.add_field(FormField(
|
||||
name="notifications",
|
||||
label="Enable Notifications",
|
||||
field_type=FormFieldType.CHECKBOX,
|
||||
required=False
|
||||
))
|
||||
|
||||
# Layout the form
|
||||
font = Font(font_size=12, colour=(50, 50, 50))
|
||||
success, field_ids = layouter.layout_form(form, font=font, field_spacing=15)
|
||||
|
||||
print(f" Laid out complete form with {len(field_ids)} fields")
|
||||
return page
|
||||
|
||||
|
||||
def combine_pages_into_grid(pages, title):
|
||||
"""Combine multiple pages into a 2x2 grid."""
|
||||
print("\n Combining pages into grid...")
|
||||
|
||||
# Render all pages
|
||||
images = [page.render() for page in pages]
|
||||
|
||||
# Grid layout
|
||||
padding = 20
|
||||
title_height = 40
|
||||
cols = 2
|
||||
rows = 2
|
||||
|
||||
# Calculate dimensions
|
||||
img_width = images[0].size[0]
|
||||
img_height = images[0].size[1]
|
||||
|
||||
total_width = cols * img_width + (cols + 1) * padding
|
||||
total_height = rows * img_height + (rows + 1) * padding + title_height
|
||||
|
||||
# Create combined image
|
||||
combined = Image.new('RGB', (total_width, total_height), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(combined)
|
||||
|
||||
# Draw title
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
title_font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18
|
||||
)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
title_x = (total_width - text_width) // 2
|
||||
draw.text((title_x, 10), title, fill=(50, 50, 50), font=title_font)
|
||||
|
||||
# Place pages in grid
|
||||
y_offset = title_height + padding
|
||||
for row in range(rows):
|
||||
x_offset = padding
|
||||
for col in range(cols):
|
||||
idx = row * cols + col
|
||||
if idx < len(images):
|
||||
combined.paste(images[idx], (x_offset, y_offset))
|
||||
x_offset += img_width + padding
|
||||
y_offset += img_height + padding
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate comprehensive form field types."""
|
||||
global form_submissions
|
||||
form_submissions = []
|
||||
|
||||
print("Comprehensive Forms Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Create examples for different form types
|
||||
pages = [
|
||||
create_example_1_text_fields(),
|
||||
create_example_2_number_fields(),
|
||||
create_example_3_selection_fields(),
|
||||
create_example_4_complete_form()
|
||||
]
|
||||
|
||||
# Combine into demonstration image
|
||||
combined_image = combine_pages_into_grid(
|
||||
pages,
|
||||
"Form Field Types: Text | Numbers | Selection | Complete"
|
||||
)
|
||||
|
||||
# Save output
|
||||
output_dir = Path("docs/images")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / "example_10_forms.png"
|
||||
combined_image.save(output_path)
|
||||
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
|
||||
print(f" Created {len(pages)} form examples")
|
||||
print(f" Total form callbacks registered: {len(form_submissions)}")
|
||||
|
||||
return combined_image, form_submissions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Demonstration of dynamic font family switching in the ereader.
|
||||
|
||||
This example shows how to:
|
||||
1. Initialize an ereader with content
|
||||
2. Dynamically switch between different font families (Sans, Serif, Monospace)
|
||||
3. Maintain reading position across font changes
|
||||
4. Use the font family API
|
||||
|
||||
The ereader manager provides a high-level API for changing fonts on-the-fly
|
||||
without losing your place in the document.
|
||||
"""
|
||||
|
||||
from pyWebLayout.abstract import Paragraph, Heading, Word
|
||||
from pyWebLayout.abstract.block import HeadingLevel
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.fonts import BundledFont, FontWeight
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.ereader_manager import create_ereader_manager
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def create_sample_content():
|
||||
"""Create sample document content with various text styles"""
|
||||
blocks = []
|
||||
|
||||
# Create a default font for the content
|
||||
default_font = Font.from_family(BundledFont.SANS, font_size=16)
|
||||
heading_font = Font.from_family(BundledFont.SANS, font_size=24, weight=FontWeight.BOLD)
|
||||
|
||||
# Title
|
||||
title = Heading(level=HeadingLevel.H1, style=heading_font)
|
||||
for word in "Font Family Switching Demo".split():
|
||||
title.add_word(Word(word, heading_font))
|
||||
blocks.append(title)
|
||||
|
||||
# Introduction paragraph
|
||||
intro_font = Font.from_family(BundledFont.SANS, font_size=16)
|
||||
intro = Paragraph(intro_font)
|
||||
intro_text = (
|
||||
"This demonstration shows how the ereader can dynamically switch between "
|
||||
"different font families while maintaining your reading position. "
|
||||
"The three bundled font families (Sans, Serif, and Monospace) can be "
|
||||
"changed on-the-fly without recreating the document."
|
||||
)
|
||||
for word in intro_text.split():
|
||||
intro.add_word(Word(word, intro_font))
|
||||
blocks.append(intro)
|
||||
|
||||
# Section 1
|
||||
section1_heading = Heading(level=HeadingLevel.H2, style=heading_font)
|
||||
for word in "Sans-Serif Font".split():
|
||||
section1_heading.add_word(Word(word, heading_font))
|
||||
blocks.append(section1_heading)
|
||||
|
||||
para1 = Paragraph(default_font)
|
||||
text1 = (
|
||||
"Sans-serif fonts like DejaVu Sans are clean and modern, making them "
|
||||
"ideal for screen reading. They lack the decorative strokes (serifs) "
|
||||
"found in traditional typefaces, which can improve legibility on digital displays. "
|
||||
"Many ereader applications default to sans-serif fonts for this reason."
|
||||
)
|
||||
for word in text1.split():
|
||||
para1.add_word(Word(word, default_font))
|
||||
blocks.append(para1)
|
||||
|
||||
# Section 2
|
||||
section2_heading = Heading(level=HeadingLevel.H2, style=heading_font)
|
||||
for word in "Serif Font".split():
|
||||
section2_heading.add_word(Word(word, heading_font))
|
||||
blocks.append(section2_heading)
|
||||
|
||||
para2 = Paragraph(default_font)
|
||||
text2 = (
|
||||
"Serif fonts like DejaVu Serif have small decorative strokes at the ends "
|
||||
"of letter strokes. These fonts are traditionally used in print media and "
|
||||
"can give a more formal, classic appearance. Many readers prefer serif fonts "
|
||||
"for long-form reading as they find them easier on the eyes."
|
||||
)
|
||||
for word in text2.split():
|
||||
para2.add_word(Word(word, default_font))
|
||||
blocks.append(para2)
|
||||
|
||||
# Section 3
|
||||
section3_heading = Heading(level=HeadingLevel.H2, style=heading_font)
|
||||
for word in "Monospace Font".split():
|
||||
section3_heading.add_word(Word(word, heading_font))
|
||||
blocks.append(section3_heading)
|
||||
|
||||
para3 = Paragraph(default_font)
|
||||
text3 = (
|
||||
"Monospace fonts like DejaVu Sans Mono have equal spacing between all characters. "
|
||||
"They are commonly used for displaying code, technical documentation, and typewriter-style "
|
||||
"text. While less common for general reading, some users prefer the uniform character "
|
||||
"spacing for certain types of content."
|
||||
)
|
||||
for word in text3.split():
|
||||
para3.add_word(Word(word, default_font))
|
||||
blocks.append(para3)
|
||||
|
||||
# Final paragraph
|
||||
conclusion = Paragraph(default_font)
|
||||
conclusion_text = (
|
||||
"The ability to switch fonts dynamically is a key feature of modern ereaders. "
|
||||
"It allows readers to customize their reading experience based on personal preference, "
|
||||
"lighting conditions, and content type. Try switching between the three font families "
|
||||
"to see which one you prefer for different types of reading."
|
||||
)
|
||||
for word in conclusion_text.split():
|
||||
conclusion.add_word(Word(word, default_font))
|
||||
blocks.append(conclusion)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def render_pages_with_different_fonts(manager, output_prefix="demo_11"):
|
||||
"""Render the same page with different font families"""
|
||||
|
||||
print("\nRendering pages with different font families...")
|
||||
print("=" * 70)
|
||||
|
||||
font_families = [
|
||||
(None, "Original (Sans)"),
|
||||
(BundledFont.SERIF, "Serif"),
|
||||
(BundledFont.MONOSPACE, "Monospace"),
|
||||
(BundledFont.SANS, "Sans (explicit)")
|
||||
]
|
||||
|
||||
images = []
|
||||
|
||||
for font_family, name in font_families:
|
||||
print(f"\nRendering with {name} font...")
|
||||
|
||||
# Switch font family
|
||||
manager.set_font_family(font_family)
|
||||
|
||||
# Get current page
|
||||
page = manager.get_current_page()
|
||||
|
||||
# Render to image
|
||||
image = page.render()
|
||||
filename = f"{output_prefix}_{name.lower().replace(' ', '_').replace('(', '').replace(')', '')}.png"
|
||||
image.save(filename)
|
||||
print(f" Saved: {filename}")
|
||||
|
||||
images.append((name, image))
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def demonstrate_font_switching():
|
||||
"""Main demonstration function"""
|
||||
print("\n")
|
||||
print("=" * 70)
|
||||
print("Font Family Switching Demonstration")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create sample content
|
||||
print("Creating sample document...")
|
||||
blocks = create_sample_content()
|
||||
print(f" Created {len(blocks)} blocks")
|
||||
|
||||
# Initialize ereader manager
|
||||
print("\nInitializing ereader manager...")
|
||||
page_size = (600, 800)
|
||||
manager = create_ereader_manager(
|
||||
blocks,
|
||||
page_size,
|
||||
document_id="font_switching_demo"
|
||||
)
|
||||
print(f" Page size: {page_size[0]}x{page_size[1]}")
|
||||
print(f" Initial font family: {manager.get_font_family()}")
|
||||
|
||||
# Render pages with different fonts
|
||||
images = render_pages_with_different_fonts(manager)
|
||||
|
||||
# Show position info
|
||||
print("\nPosition information after font switches:")
|
||||
print(" " + "-" * 66)
|
||||
pos_info = manager.get_position_info()
|
||||
print(f" Current position: Block {pos_info['position']['block_index']}, "
|
||||
f"Word {pos_info['position']['word_index']}")
|
||||
print(f" Font family: {pos_info['font_family'] or 'Original'}")
|
||||
print(f" Font scale: {pos_info['font_scale']}")
|
||||
print(f" Reading progress: {pos_info['progress']:.1%}")
|
||||
|
||||
# Test navigation with font switching
|
||||
print("\nTesting navigation with font switching...")
|
||||
print(" " + "-" * 66)
|
||||
|
||||
# Reset to beginning
|
||||
manager.jump_to_position(manager.current_position.__class__())
|
||||
|
||||
# Advance a few pages with serif font
|
||||
manager.set_font_family(BundledFont.SERIF)
|
||||
print(f" Switched to SERIF font")
|
||||
|
||||
for i in range(3):
|
||||
next_page = manager.next_page()
|
||||
if next_page:
|
||||
print(f" Page {i+2}: Advanced successfully")
|
||||
|
||||
# Switch to monospace
|
||||
manager.set_font_family(BundledFont.MONOSPACE)
|
||||
print(f" Switched to MONOSPACE font")
|
||||
current_page = manager.get_current_page()
|
||||
print(f" Re-rendered current page with new font")
|
||||
|
||||
# Go back a page
|
||||
prev_page = manager.previous_page()
|
||||
if prev_page:
|
||||
print(f" Navigated back successfully")
|
||||
|
||||
# Cache statistics
|
||||
print("\nCache statistics:")
|
||||
print(" " + "-" * 66)
|
||||
stats = manager.get_cache_stats()
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Demo complete!")
|
||||
print()
|
||||
print("Key features demonstrated:")
|
||||
print(" ✓ Dynamic font family switching (Sans, Serif, Monospace)")
|
||||
print(" ✓ Position preservation across font changes")
|
||||
print(" ✓ Automatic cache invalidation on font change")
|
||||
print(" ✓ Navigation with different fonts")
|
||||
print(" ✓ Font family info in position tracking")
|
||||
print()
|
||||
print("The rendered pages show the same content in different font families.")
|
||||
print("Notice how the layout adapts while maintaining readability.")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demonstrate_font_switching()
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Table Text Wrapping Example
|
||||
|
||||
This example demonstrates the line wrapping functionality in table cells:
|
||||
- Tables with long text that wraps across multiple lines
|
||||
- Automatic word wrapping within cell boundaries
|
||||
- Hyphenation support for long words
|
||||
- Multiple paragraphs per cell
|
||||
- Comparison of narrow vs. wide columns
|
||||
|
||||
Shows how the Line-based text layout system handles text overflow in tables.
|
||||
"""
|
||||
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def create_narrow_columns_example():
|
||||
"""Create a table with narrow columns to show aggressive wrapping."""
|
||||
print(" - Narrow columns with text wrapping")
|
||||
|
||||
html = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Description</th>
|
||||
<th>Benefits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Automatic Line Wrapping</td>
|
||||
<td>Text automatically wraps to fit within the available cell width, creating multiple lines as needed.</td>
|
||||
<td>Improves readability and prevents horizontal overflow in tables.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hyphenation Support</td>
|
||||
<td>Long words are intelligently hyphenated using pyphen library or brute-force splitting when necessary.</td>
|
||||
<td>Handles extraordinarily long words that wouldn't fit on a single line.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Multi-paragraph Cells</td>
|
||||
<td>Each cell can contain multiple paragraphs or headings, all properly wrapped.</td>
|
||||
<td>Allows rich content within table cells.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return html, "Text Wrapping in Narrow Columns"
|
||||
|
||||
|
||||
def create_mixed_content_example():
|
||||
"""Create a table with both short and long content."""
|
||||
print(" - Mixed content lengths")
|
||||
|
||||
html = """
|
||||
<table>
|
||||
<caption>Product Comparison</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Short Description</th>
|
||||
<th>Detailed Features</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Widget Pro</td>
|
||||
<td>Premium</td>
|
||||
<td>Advanced functionality with enterprise-grade reliability, comprehensive warranty coverage, and dedicated customer support available around the clock.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Widget Lite</td>
|
||||
<td>Basic</td>
|
||||
<td>Essential features for everyday use with straightforward operation and minimal learning curve.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Widget Max</td>
|
||||
<td>Ultimate</td>
|
||||
<td>Everything from Widget Pro plus additional customization options, API integration capabilities, and advanced analytics dashboard.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return html, "Mixed Short and Long Content"
|
||||
|
||||
|
||||
def create_technical_documentation_example():
|
||||
"""Create a table like technical documentation."""
|
||||
print(" - Technical documentation style")
|
||||
|
||||
html = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API Method</th>
|
||||
<th>Parameters</th>
|
||||
<th>Description</th>
|
||||
<th>Return Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>render_table()</td>
|
||||
<td>table, origin, width, draw, style</td>
|
||||
<td>Renders a table with automatic text wrapping in cells. Uses the Line class for intelligent word placement and hyphenation.</td>
|
||||
<td>Rendered table with calculated height and width properties.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>add_word()</td>
|
||||
<td>word, pretext</td>
|
||||
<td>Attempts to add a word to the current line. If it doesn't fit, tries hyphenation strategies including pyphen and brute-force splitting.</td>
|
||||
<td>Tuple of (success, overflow_text) indicating whether word was added and any remaining text.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>calculate_spacing()</td>
|
||||
<td>text_objects, width, min_spacing, max_spacing</td>
|
||||
<td>Determines optimal spacing between words to achieve proper justification within the specified constraints.</td>
|
||||
<td>Calculated spacing value and position offset for alignment.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return html, "Technical Documentation Table"
|
||||
|
||||
|
||||
def create_news_article_example():
|
||||
"""Create a table with article-style content."""
|
||||
print(" - News article layout")
|
||||
|
||||
html = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Headline</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>2024-01-15</td>
|
||||
<td>New Text Wrapping Feature</td>
|
||||
<td>PyWebLayout now supports automatic line wrapping in table cells, bringing sophisticated text layout capabilities to table rendering. The implementation leverages the existing Line class infrastructure.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2024-01-10</td>
|
||||
<td>Hyphenation Improvements</td>
|
||||
<td>Enhanced hyphenation algorithms now include both dictionary-based pyphen hyphenation and intelligent brute-force splitting for edge cases.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2024-01-05</td>
|
||||
<td>Performance Optimization</td>
|
||||
<td>Table rendering performance improved through better caching and reduced font object creation overhead.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
return html, "News Article Layout"
|
||||
|
||||
|
||||
def render_table_example(html, title, style_variant=0):
|
||||
"""Render a single table example."""
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract.block import Table
|
||||
|
||||
# Parse HTML
|
||||
base_font = Font(font_size=12)
|
||||
blocks = parse_html_string(html, base_font=base_font)
|
||||
|
||||
# Find the table block
|
||||
table = None
|
||||
for block in blocks:
|
||||
if isinstance(block, Table):
|
||||
table = block
|
||||
break
|
||||
|
||||
if not table:
|
||||
print(f" Warning: No table found in {title}")
|
||||
return None
|
||||
|
||||
# Create page style
|
||||
page_style = PageStyle(
|
||||
padding=(20, 20, 20, 20),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
# Create page
|
||||
page_size = (900, 600)
|
||||
page = Page(size=page_size, style=page_style)
|
||||
|
||||
# Create table style variants
|
||||
table_styles = [
|
||||
# Default style
|
||||
TableStyle(
|
||||
border_width=1,
|
||||
border_color=(0, 0, 0),
|
||||
cell_padding=(8, 8, 8, 8),
|
||||
header_bg_color=(240, 240, 240),
|
||||
cell_bg_color=(255, 255, 255)
|
||||
),
|
||||
# Blue header style
|
||||
TableStyle(
|
||||
border_width=2,
|
||||
border_color=(70, 130, 180),
|
||||
cell_padding=(10, 10, 10, 10),
|
||||
header_bg_color=(176, 196, 222),
|
||||
cell_bg_color=(245, 250, 255)
|
||||
),
|
||||
# Minimal style
|
||||
TableStyle(
|
||||
border_width=1,
|
||||
border_color=(200, 200, 200),
|
||||
cell_padding=(6, 6, 6, 6),
|
||||
header_bg_color=(250, 250, 250),
|
||||
cell_bg_color=(255, 255, 255)
|
||||
),
|
||||
]
|
||||
|
||||
table_style = table_styles[style_variant % len(table_styles)]
|
||||
|
||||
# Create layouter and render table
|
||||
layouter = DocumentLayouter(page)
|
||||
layouter.layout_table(table, style=table_style)
|
||||
|
||||
# Get the rendered canvas
|
||||
_ = page.draw # Ensure canvas exists
|
||||
img = page._canvas
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def combine_examples(examples):
|
||||
"""Combine multiple example images into one."""
|
||||
images = []
|
||||
titles = []
|
||||
|
||||
for html, title in examples:
|
||||
img = render_table_example(html, title)
|
||||
if img:
|
||||
images.append(img)
|
||||
titles.append(title)
|
||||
|
||||
if not images:
|
||||
return None
|
||||
|
||||
# Calculate combined image size
|
||||
max_width = max(img.width for img in images)
|
||||
total_height = sum(img.height for img in images) + 40 * len(images) # Extra space between images
|
||||
|
||||
# Create combined image
|
||||
combined = Image.new('RGB', (max_width, total_height), color=(255, 255, 255))
|
||||
|
||||
# Paste images
|
||||
y_offset = 20
|
||||
for img in images:
|
||||
combined.paste(img, (0, y_offset))
|
||||
y_offset += img.height + 40
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the table text wrapping example."""
|
||||
print("\nTable Text Wrapping Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Create examples
|
||||
print("\n Creating table examples...")
|
||||
examples = [
|
||||
create_narrow_columns_example(),
|
||||
create_mixed_content_example(),
|
||||
create_technical_documentation_example(),
|
||||
create_news_article_example(),
|
||||
]
|
||||
|
||||
print("\n Rendering table examples...")
|
||||
combined_image = combine_examples(examples)
|
||||
|
||||
if combined_image:
|
||||
# Save the output
|
||||
output_dir = Path(__file__).parent.parent / 'docs' / 'images'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / 'example_11_table_text_wrapping.png'
|
||||
|
||||
combined_image.save(str(output_path))
|
||||
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {combined_image.width}x{combined_image.height} pixels")
|
||||
print(f" Created {len(examples)} table examples with text wrapping")
|
||||
else:
|
||||
print("\n✗ Failed to generate examples")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Table Text Wrapping Example
|
||||
|
||||
A minimal example showing text wrapping in table cells.
|
||||
Perfect for quick testing and demonstration.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.abstract.block import Table
|
||||
|
||||
|
||||
def main():
|
||||
"""Create a simple table with text wrapping."""
|
||||
print("\nSimple Table Text Wrapping Example")
|
||||
print("=" * 50)
|
||||
|
||||
# HTML with a table containing long text
|
||||
html = """
|
||||
<table>
|
||||
<caption>Text Wrapping Demonstration</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column 1</th>
|
||||
<th>Column 2</th>
|
||||
<th>Column 3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>This is a cell with quite a lot of text that will need to wrap across multiple lines.</td>
|
||||
<td>Short text</td>
|
||||
<td>Another cell with enough content to demonstrate the automatic line wrapping functionality.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell A</td>
|
||||
<td>This middle cell contains a paragraph with several words that should wrap nicely within the available space.</td>
|
||||
<td>Cell C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Words like supercalifragilisticexpialidocious might need hyphenation.</td>
|
||||
<td>Normal text</td>
|
||||
<td>The wrapping algorithm handles both regular word wrapping and hyphenation seamlessly.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
print("\n Parsing HTML and creating table...")
|
||||
|
||||
# Parse HTML
|
||||
base_font = Font(font_size=12)
|
||||
blocks = parse_html_string(html, base_font=base_font)
|
||||
|
||||
# Find table
|
||||
table = None
|
||||
for block in blocks:
|
||||
if isinstance(block, Table):
|
||||
table = block
|
||||
break
|
||||
|
||||
if not table:
|
||||
print(" ✗ No table found!")
|
||||
return
|
||||
|
||||
print(" ✓ Table parsed successfully")
|
||||
|
||||
# Create page
|
||||
page_style = PageStyle(
|
||||
padding=(30, 30, 30, 30),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
page = Page(size=(800, 600), style=page_style)
|
||||
|
||||
# Create table style
|
||||
table_style = TableStyle(
|
||||
border_width=2,
|
||||
border_color=(70, 130, 180),
|
||||
cell_padding=(10, 10, 10, 10),
|
||||
header_bg_color=(176, 196, 222),
|
||||
cell_bg_color=(245, 250, 255)
|
||||
)
|
||||
|
||||
print(" Rendering table with text wrapping...")
|
||||
|
||||
# Layout and render
|
||||
layouter = DocumentLayouter(page)
|
||||
layouter.layout_table(table, style=table_style)
|
||||
|
||||
# Get rendered image
|
||||
_ = page.draw
|
||||
img = page._canvas
|
||||
|
||||
# Save output
|
||||
output_path = Path(__file__).parent.parent / 'docs' / 'images' / 'example_11b_simple_wrapping.png'
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(output_path))
|
||||
|
||||
print(f"\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {img.width}x{img.height} pixels")
|
||||
print(f"\n The table demonstrates:")
|
||||
print(f" • Automatic line wrapping in cells")
|
||||
print(f" • Proper word spacing and alignment")
|
||||
print(f" • Hyphenation for very long words")
|
||||
print(f" • Multi-line text within cell boundaries")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo: Optimized Table Column Width Layout
|
||||
|
||||
This example demonstrates the intelligent table column width optimization:
|
||||
- Automatic width distribution based on content
|
||||
- HTML width overrides (fixed column widths)
|
||||
- Sampling for performance (large tables)
|
||||
- Comparison: before (equal distribution) vs after (optimized)
|
||||
|
||||
The optimizer:
|
||||
1. Samples first ~5 rows from each section
|
||||
2. Measures minimum and preferred widths for each column
|
||||
3. Distributes available space proportionally
|
||||
4. Respects HTML width attributes
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from PIL import ImageDraw
|
||||
|
||||
|
||||
def create_demo_table_1():
|
||||
"""Create a table with varying content lengths (shows optimization)."""
|
||||
table = Table()
|
||||
table.caption = "Example 1: Optimized Width Distribution"
|
||||
|
||||
font = Font(font_size=11)
|
||||
|
||||
# Header
|
||||
header_row = TableRow()
|
||||
for text in ["ID", "Name", "Description"]:
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
header_row.add_cell(cell)
|
||||
table.add_row(header_row, section="header")
|
||||
|
||||
# Body rows with varying content lengths
|
||||
data = [
|
||||
("1", "Alice", "Short description"),
|
||||
("2", "Bob", "This is a much longer description that demonstrates how the optimizer allocates more space to columns with longer content"),
|
||||
("3", "Charlie", "Medium length description here"),
|
||||
("4", "Diana", "Another longer description that shows the column width optimization working effectively for content-heavy cells"),
|
||||
]
|
||||
|
||||
for row_data in data:
|
||||
row = TableRow()
|
||||
for text in row_data:
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_demo_table_2():
|
||||
"""Create a table with HTML width overrides."""
|
||||
table = Table()
|
||||
table.caption = "Example 2: Fixed Column Widths (HTML override)"
|
||||
|
||||
font = Font(font_size=11)
|
||||
|
||||
# Header with width attributes
|
||||
header_row = TableRow()
|
||||
|
||||
# Fixed width column
|
||||
cell1 = TableCell(is_header=True)
|
||||
cell1.width = "80px" # HTML width override!
|
||||
para1 = Paragraph(font)
|
||||
para1.add_word(Word("ID", font))
|
||||
para1.add_word(Word("(Fixed", font))
|
||||
para1.add_word(Word("80px)", font))
|
||||
cell1.add_block(para1)
|
||||
header_row.add_cell(cell1)
|
||||
|
||||
# Auto-width columns
|
||||
for text in ["Name (Auto)", "Description (Auto)"]:
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, font))
|
||||
cell.add_block(para)
|
||||
header_row.add_cell(cell)
|
||||
|
||||
table.add_row(header_row, section="header")
|
||||
|
||||
# Body rows
|
||||
data = [
|
||||
("1", "Alice", "The first two columns adapt to remaining space"),
|
||||
("2", "Bob", "ID column stays fixed at 80px width"),
|
||||
("3", "Charlie", "Name and Description share the remaining width proportionally"),
|
||||
]
|
||||
|
||||
for row_data in data:
|
||||
row = TableRow()
|
||||
# First cell also has fixed width
|
||||
cell = TableCell()
|
||||
cell.width = "80px"
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(row_data[0], font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Other cells auto-width
|
||||
for text in row_data[1:]:
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
table.add_row(row, section="body")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_demo_table_3():
|
||||
"""Create a large table (demonstrates sampling)."""
|
||||
table = Table()
|
||||
table.caption = "Example 3: Large Table (uses sampling for performance)"
|
||||
|
||||
font = Font(font_size=10)
|
||||
|
||||
# Header
|
||||
header_row = TableRow()
|
||||
for text in ["Index", "Data A", "Data B", "Data C"]:
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
header_row.add_cell(cell)
|
||||
table.add_row(header_row, section="header")
|
||||
|
||||
# Many body rows (only first ~5 will be sampled for measurement)
|
||||
for i in range(50):
|
||||
row = TableRow()
|
||||
|
||||
# Index
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(str(i + 1), font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Data columns with varying content
|
||||
if i % 3 == 0:
|
||||
data = ["Short", "Medium length", "Longer content here"]
|
||||
elif i % 3 == 1:
|
||||
data = ["Medium", "Short", "Also longer content"]
|
||||
else:
|
||||
data = ["Longer text", "Short", "Medium"]
|
||||
|
||||
for text in data:
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
table.add_row(row, section="body")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def main():
|
||||
# Create page
|
||||
page_style = PageStyle(
|
||||
border_width=1,
|
||||
padding=(20, 20, 20, 20),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
page = Page(size=(800, 2200), style=page_style)
|
||||
|
||||
# Get canvas and draw
|
||||
canvas = page._create_canvas()
|
||||
page._canvas = canvas
|
||||
page._draw = ImageDraw.Draw(canvas)
|
||||
|
||||
current_y = 30
|
||||
|
||||
# Table style
|
||||
table_style = TableStyle(
|
||||
border_width=1,
|
||||
border_color=(100, 100, 100),
|
||||
cell_padding=(8, 8, 8, 8),
|
||||
header_bg_color=(220, 230, 240),
|
||||
cell_bg_color=(255, 255, 255),
|
||||
alternate_row_color=(248, 248, 248)
|
||||
)
|
||||
|
||||
# Render Example 1: Optimized distribution
|
||||
table1 = create_demo_table_1()
|
||||
renderer1 = TableRenderer(
|
||||
table1,
|
||||
origin=(20, current_y),
|
||||
available_width=760,
|
||||
draw=page._draw,
|
||||
style=table_style,
|
||||
canvas=canvas
|
||||
)
|
||||
renderer1.render()
|
||||
current_y += renderer1.height + 40
|
||||
|
||||
# Render Example 2: Fixed widths
|
||||
table2 = create_demo_table_2()
|
||||
renderer2 = TableRenderer(
|
||||
table2,
|
||||
origin=(20, current_y),
|
||||
available_width=760,
|
||||
draw=page._draw,
|
||||
style=table_style,
|
||||
canvas=canvas
|
||||
)
|
||||
renderer2.render()
|
||||
current_y += renderer2.height + 40
|
||||
|
||||
# Render Example 3: Large table with sampling
|
||||
table3 = create_demo_table_3()
|
||||
renderer3 = TableRenderer(
|
||||
table3,
|
||||
origin=(20, current_y),
|
||||
available_width=760,
|
||||
draw=page._draw,
|
||||
style=table_style,
|
||||
canvas=canvas
|
||||
)
|
||||
renderer3.render()
|
||||
|
||||
# Save
|
||||
output_path = "docs/images/example_12_optimized_table_layout.png"
|
||||
canvas.save(output_path)
|
||||
|
||||
print(f"✓ Optimized table layout demo created!")
|
||||
print(f" Output: {output_path}")
|
||||
print(f" Image size: {canvas.size}")
|
||||
print(f"\nExamples demonstrated:")
|
||||
print(f" 1. Content-aware width distribution")
|
||||
print(f" 2. HTML width overrides (80px fixed column)")
|
||||
print(f" 3. Large table with sampling (50 rows, only ~5 measured)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo: Table Pagination
|
||||
|
||||
This example demonstrates table pagination when content exceeds page height:
|
||||
- Large table that spans multiple pages
|
||||
- Automatic row-level pagination (entire rows move to next page)
|
||||
- Continuation markers ("continued on next page", "continued from previous page")
|
||||
- Headers repeated on each page
|
||||
|
||||
The pagination system:
|
||||
1. Renders rows sequentially until page height limit reached
|
||||
2. Moves entire row to next page if it doesn't fit
|
||||
3. Repeats header row on continuation pages
|
||||
4. Adds visual markers to show table continues
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def create_large_table():
|
||||
"""Create a table with many rows that will require pagination."""
|
||||
table = Table()
|
||||
table.caption = "Employee Directory (Paginated)"
|
||||
|
||||
font = Font(font_size=11)
|
||||
|
||||
# Header
|
||||
header_row = TableRow()
|
||||
for text in ["ID", "Name", "Department", "Email", "Phone"]:
|
||||
cell = TableCell(is_header=True)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
header_row.add_cell(cell)
|
||||
table.add_row(header_row, section="header")
|
||||
|
||||
# Many body rows (will span multiple pages)
|
||||
departments = ["Engineering", "Sales", "Marketing", "HR", "Finance", "Operations", "Support"]
|
||||
|
||||
for i in range(60):
|
||||
row = TableRow()
|
||||
|
||||
# ID
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"EMP{i+1001}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Name
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
names = ["Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Lee",
|
||||
"Eve Wilson", "Frank Miller", "Grace Davis", "Henry Taylor"]
|
||||
para.add_word(Word(names[i % len(names)], font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Department
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(departments[i % len(departments)], font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Email
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
email = f"{names[i % len(names)].lower().replace(' ', '.')}@company.com"
|
||||
para.add_word(Word(email, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Phone
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(f"+1-555-{(i*17)%1000:04d}", font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
table.add_row(row, section="body")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def render_table_with_pagination(table, page_size, max_pages=3):
|
||||
"""
|
||||
Render a table across multiple pages.
|
||||
|
||||
Args:
|
||||
table: The table to render
|
||||
page_size: Tuple of (width, height) for each page
|
||||
max_pages: Maximum number of pages to render
|
||||
|
||||
Returns:
|
||||
List of PIL Images (one per page)
|
||||
"""
|
||||
pages = []
|
||||
|
||||
page_style = PageStyle(
|
||||
border_width=1,
|
||||
padding=(20, 20, 20, 20),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
table_style = TableStyle(
|
||||
border_width=1,
|
||||
border_color=(100, 100, 100),
|
||||
cell_padding=(6, 8, 6, 8),
|
||||
header_bg_color=(220, 230, 240),
|
||||
cell_bg_color=(255, 255, 255),
|
||||
alternate_row_color=(248, 248, 248)
|
||||
)
|
||||
|
||||
# Get all rows
|
||||
all_rows = list(table.all_rows())
|
||||
header_rows = [row for section, row in all_rows if section == "header"]
|
||||
body_rows = [row for section, row in all_rows if section == "body"]
|
||||
|
||||
# Calculate header height once
|
||||
temp_page = Page(size=page_size, style=page_style)
|
||||
temp_canvas = temp_page._create_canvas()
|
||||
temp_draw = ImageDraw.Draw(temp_canvas)
|
||||
|
||||
# Create temporary table with just header to measure
|
||||
header_table = Table()
|
||||
header_table.caption = table.caption
|
||||
for header_row in header_rows:
|
||||
header_table.add_row(header_row, section="header")
|
||||
|
||||
header_renderer = TableRenderer(
|
||||
header_table,
|
||||
origin=(20, 20),
|
||||
available_width=page_size[0] - 40,
|
||||
draw=temp_draw,
|
||||
style=table_style,
|
||||
canvas=temp_canvas
|
||||
)
|
||||
header_height = header_renderer.height
|
||||
|
||||
# Available height for body rows
|
||||
available_body_height = page_size[1] - 60 - header_height # margins + header
|
||||
|
||||
# Paginate body rows
|
||||
current_page_rows = []
|
||||
current_height = 0
|
||||
page_num = 0
|
||||
|
||||
for i, body_row in enumerate(body_rows):
|
||||
if page_num >= max_pages:
|
||||
break
|
||||
|
||||
# Estimate row height (simplified - actual would measure each row)
|
||||
# For this demo, assume ~30px per row
|
||||
row_height = 35
|
||||
|
||||
if current_height + row_height > available_body_height and current_page_rows:
|
||||
# Render current page
|
||||
page_canvas = render_page(
|
||||
table,
|
||||
header_rows,
|
||||
current_page_rows,
|
||||
page_size,
|
||||
page_style,
|
||||
table_style,
|
||||
page_num,
|
||||
is_last=False
|
||||
)
|
||||
pages.append(page_canvas)
|
||||
|
||||
# Start new page
|
||||
page_num += 1
|
||||
current_page_rows = []
|
||||
current_height = 0
|
||||
|
||||
current_page_rows.append(body_row)
|
||||
current_height += row_height
|
||||
|
||||
# Render final page
|
||||
if current_page_rows and page_num < max_pages:
|
||||
page_canvas = render_page(
|
||||
table,
|
||||
header_rows,
|
||||
current_page_rows,
|
||||
page_size,
|
||||
page_style,
|
||||
table_style,
|
||||
page_num,
|
||||
is_last=(i == len(body_rows) - 1)
|
||||
)
|
||||
pages.append(page_canvas)
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def render_page(table, header_rows, body_rows, page_size, page_style, table_style, page_num, is_last):
|
||||
"""Render a single page with header and body rows."""
|
||||
page = Page(size=page_size, style=page_style)
|
||||
canvas = page._create_canvas()
|
||||
page._canvas = canvas
|
||||
page._draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Create table for this page
|
||||
page_table = Table()
|
||||
if page_num == 0:
|
||||
page_table.caption = table.caption
|
||||
else:
|
||||
page_table.caption = f"{table.caption} (continued)"
|
||||
|
||||
# Add header rows
|
||||
for header_row in header_rows:
|
||||
page_table.add_row(header_row, section="header")
|
||||
|
||||
# Add body rows for this page
|
||||
for body_row in body_rows:
|
||||
page_table.add_row(body_row, section="body")
|
||||
|
||||
# Render table
|
||||
renderer = TableRenderer(
|
||||
page_table,
|
||||
origin=(20, 20),
|
||||
available_width=page_size[0] - 40,
|
||||
draw=page._draw,
|
||||
style=table_style,
|
||||
canvas=canvas
|
||||
)
|
||||
renderer.render()
|
||||
|
||||
# Add continuation marker at bottom
|
||||
if not is_last:
|
||||
font = Font(font_size=10)
|
||||
y_pos = page_size[1] - 30
|
||||
page._draw.text(
|
||||
(page_size[0] // 2 - 100, y_pos),
|
||||
"(continued on next page)",
|
||||
fill=(100, 100, 100),
|
||||
font=font.font
|
||||
)
|
||||
|
||||
# Add page number
|
||||
page._draw.text(
|
||||
(page_size[0] // 2 - 20, page_size[1] - 15),
|
||||
f"Page {page_num + 1}",
|
||||
fill=(150, 150, 150),
|
||||
font=Font(font_size=9).font
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def main():
|
||||
# Create large table
|
||||
table = create_large_table()
|
||||
|
||||
# Render with pagination (3 pages max for demo)
|
||||
page_size = (900, 700)
|
||||
pages = render_table_with_pagination(table, page_size, max_pages=3)
|
||||
|
||||
# Combine pages side-by-side for visualization
|
||||
total_width = page_size[0] * len(pages) + (len(pages) - 1) * 20 # 20px spacing
|
||||
combined = Image.new('RGB', (total_width, page_size[1]), (240, 240, 240))
|
||||
|
||||
x_offset = 0
|
||||
for i, page_canvas in enumerate(pages):
|
||||
combined.paste(page_canvas, (x_offset, 0))
|
||||
x_offset += page_size[0] + 20
|
||||
|
||||
# Save
|
||||
output_path = "docs/images/example_13_table_pagination.png"
|
||||
combined.save(output_path)
|
||||
|
||||
print(f"✓ Table pagination demo created!")
|
||||
print(f" Output: {output_path}")
|
||||
print(f" Pages rendered: {len(pages)}")
|
||||
print(f" Image size: {combined.size}")
|
||||
print(f"\nDemonstrates:")
|
||||
print(f" - Large table (60 rows) paginated across {len(pages)} pages")
|
||||
print(f" - Header repeated on each page")
|
||||
print(f" - Continuation markers")
|
||||
print(f" - Page numbers")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo: Working Interactive Table with Buttons
|
||||
|
||||
This example shows a fully working interactive table where buttons are
|
||||
actually rendered inside table cells and can handle click events.
|
||||
|
||||
This uses a hybrid approach:
|
||||
1. Tables are rendered normally for structure
|
||||
2. Buttons are rendered on top at calculated positions
|
||||
3. Click detection maps coordinates to button callbacks
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.concrete.functional import ButtonText
|
||||
from pyWebLayout.abstract.functional import Button
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
|
||||
|
||||
def create_interactive_table():
|
||||
"""Create a table structure (buttons will be overlaid)."""
|
||||
table = Table()
|
||||
table.caption = "User Management with Interactive Buttons"
|
||||
|
||||
font = Font(font_size=11)
|
||||
|
||||
# Header
|
||||
header_row = TableRow()
|
||||
for i, text in enumerate(["ID", "Name", "Email", "Actions"]):
|
||||
cell = TableCell(is_header=True)
|
||||
# Set width for Actions column
|
||||
if text == "Actions":
|
||||
cell.width = "220px" # Enough for 3 buttons
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(text, font))
|
||||
cell.add_block(para)
|
||||
header_row.add_cell(cell)
|
||||
table.add_row(header_row, section="header")
|
||||
|
||||
# Body rows
|
||||
users = [
|
||||
("U001", "Alice Johnson", "alice@example.com"),
|
||||
("U002", "Bob Smith", "bob@example.com"),
|
||||
("U003", "Charlie Brown", "charlie@example.com"),
|
||||
]
|
||||
|
||||
for user_id, name, email in users:
|
||||
row = TableRow()
|
||||
|
||||
# ID
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(user_id, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Name
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(name, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Email
|
||||
cell = TableCell()
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word(email, font))
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
# Actions - leave empty for buttons to be overlaid
|
||||
# Set width hint to ensure space for 3 buttons
|
||||
cell = TableCell()
|
||||
cell.width = "220px" # Enough for 3 buttons (3 × 65px + padding)
|
||||
para = Paragraph(font)
|
||||
para.add_word(Word("", font)) # Empty placeholder
|
||||
cell.add_block(para)
|
||||
row.add_cell(cell)
|
||||
|
||||
table.add_row(row, section="body")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def render_buttons_in_table(canvas, draw, table_origin, column_widths, row_heights, users):
|
||||
"""
|
||||
Render interactive buttons inside the table cells.
|
||||
|
||||
This calculates the exact position of each button based on the table
|
||||
layout and renders ButtonText objects at those positions.
|
||||
|
||||
Args:
|
||||
canvas: PIL Image canvas
|
||||
draw: PIL ImageDraw object
|
||||
table_origin: (x, y) position of table top-left
|
||||
column_widths: List of column widths
|
||||
row_heights: Dict with 'header', 'body', 'footer' keys
|
||||
users: User data for button labels
|
||||
|
||||
Returns:
|
||||
List of (button, bounds) for click detection
|
||||
"""
|
||||
button_font = Font(font_size=10)
|
||||
buttons_with_bounds = []
|
||||
|
||||
# Calculate Actions column position (column 3, index 3)
|
||||
actions_col_x = table_origin[0] + sum(column_widths[:3]) + 3 * 2 # +borders
|
||||
actions_col_width = column_widths[3]
|
||||
|
||||
# Start after caption and header row
|
||||
# Caption takes 20px + 10px spacing = 30px
|
||||
caption_height = 30
|
||||
header_height = row_heights.get("header", 30)
|
||||
current_y = table_origin[1] + caption_height + header_height + 2 # +caption +header +border
|
||||
|
||||
for i, (user_id, name, email) in enumerate(users):
|
||||
row_height = row_heights.get("body", 30) # All body rows have same height
|
||||
|
||||
# Position buttons horizontally in the Actions cell
|
||||
button_x = actions_col_x + 10 # Padding from cell edge
|
||||
button_y = current_y + (row_height - 30) // 2 # Center vertically
|
||||
|
||||
# Create buttons for this row
|
||||
# Note: Button callbacks receive click point as first argument
|
||||
buttons = [
|
||||
("View", lambda point, uid=user_id: print(f"View {uid}")),
|
||||
("Edit", lambda point, uid=user_id: print(f"Edit {uid}")),
|
||||
("Delete", lambda point, uid=user_id: print(f"Delete {uid}"))
|
||||
]
|
||||
|
||||
for label, callback in buttons:
|
||||
# Create button
|
||||
abstract_button = Button(label=label, callback=callback)
|
||||
button_text = ButtonText(
|
||||
button=abstract_button,
|
||||
font=button_font,
|
||||
draw=draw,
|
||||
padding=(6, 12, 6, 12)
|
||||
)
|
||||
|
||||
# Set position
|
||||
button_text._origin = np.array([button_x, button_y])
|
||||
|
||||
# Render button
|
||||
button_text.render()
|
||||
|
||||
# Store bounds for click detection
|
||||
button_width = 60 # Approximate
|
||||
button_height = 25
|
||||
bounds = (button_x, button_y, button_x + button_width, button_y + button_height)
|
||||
buttons_with_bounds.append((abstract_button, bounds))
|
||||
|
||||
# Move to next button position
|
||||
button_x += 65
|
||||
|
||||
# Move to next row
|
||||
current_y += row_height + 1 # +border
|
||||
|
||||
return buttons_with_bounds
|
||||
|
||||
|
||||
def handle_click(click_pos, buttons_with_bounds):
|
||||
"""
|
||||
Handle a click event by checking if it's inside any button bounds.
|
||||
|
||||
Args:
|
||||
click_pos: (x, y) tuple of click position
|
||||
buttons_with_bounds: List of (button, bounds) tuples
|
||||
|
||||
Returns:
|
||||
True if a button was clicked, False otherwise
|
||||
"""
|
||||
click_x, click_y = click_pos
|
||||
|
||||
for button, (x1, y1, x2, y2) in buttons_with_bounds:
|
||||
if x1 <= click_x <= x2 and y1 <= click_y <= y2:
|
||||
# Click is inside this button!
|
||||
button.execute(click_pos)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# Create page
|
||||
page_size = (900, 500) # Increased height to fit instructions
|
||||
page_style = PageStyle(
|
||||
border_width=1,
|
||||
padding=(20, 20, 20, 20),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
page = Page(size=page_size, style=page_style)
|
||||
canvas = page._create_canvas()
|
||||
page._canvas = canvas
|
||||
page._draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Table style
|
||||
table_style = TableStyle(
|
||||
border_width=1,
|
||||
border_color=(100, 100, 100),
|
||||
cell_padding=(8, 10, 8, 10),
|
||||
header_bg_color=(220, 230, 240),
|
||||
cell_bg_color=(255, 255, 255),
|
||||
alternate_row_color=(248, 248, 248)
|
||||
)
|
||||
|
||||
# User data
|
||||
users = [
|
||||
("U001", "Alice Johnson", "alice@example.com"),
|
||||
("U002", "Bob Smith", "bob@example.com"),
|
||||
("U003", "Charlie Brown", "charlie@example.com"),
|
||||
]
|
||||
|
||||
# Create and render table
|
||||
table = create_interactive_table()
|
||||
table_origin = (20, 30)
|
||||
renderer = TableRenderer(
|
||||
table,
|
||||
origin=table_origin,
|
||||
available_width=860,
|
||||
draw=page._draw,
|
||||
style=table_style,
|
||||
canvas=canvas
|
||||
)
|
||||
renderer.render()
|
||||
|
||||
# Get table dimensions for button positioning
|
||||
column_widths = renderer._column_widths
|
||||
row_heights = renderer._row_heights
|
||||
|
||||
# Render interactive buttons on top of table
|
||||
buttons_with_bounds = render_buttons_in_table(
|
||||
canvas, page._draw, table_origin,
|
||||
column_widths, row_heights, users
|
||||
)
|
||||
|
||||
# Add instructions (position below the table)
|
||||
# Calculate actual table height based on rows
|
||||
header_height = row_heights.get("header", 30)
|
||||
body_height = row_heights.get("body", 30) * len(users)
|
||||
actual_table_height = header_height + body_height + (len(users) + 2) * 2 # +borders
|
||||
|
||||
inst_font = Font(font_size=12)
|
||||
y_offset = table_origin[1] + actual_table_height + 50
|
||||
page._draw.text(
|
||||
(20, y_offset),
|
||||
"Interactive Table Demo:",
|
||||
fill=(50, 50, 50),
|
||||
font=inst_font.font
|
||||
)
|
||||
|
||||
note_font = Font(font_size=10)
|
||||
page._draw.text(
|
||||
(20, y_offset + 25),
|
||||
"• Buttons are rendered at calculated positions within table cells",
|
||||
fill=(80, 80, 80),
|
||||
font=note_font.font
|
||||
)
|
||||
page._draw.text(
|
||||
(20, y_offset + 45),
|
||||
"• Click detection maps coordinates to button callbacks",
|
||||
fill=(80, 80, 80),
|
||||
font=note_font.font
|
||||
)
|
||||
page._draw.text(
|
||||
(20, y_offset + 65),
|
||||
"• Try simulated clicks below:",
|
||||
fill=(80, 80, 80),
|
||||
font=note_font.font
|
||||
)
|
||||
|
||||
# Save
|
||||
output_path = "docs/images/example_14_interactive_table.png"
|
||||
canvas.save(output_path)
|
||||
|
||||
print(f"✓ Working interactive table demo created!")
|
||||
print(f" Output: {output_path}")
|
||||
print(f" Image size: {canvas.size}")
|
||||
print(f"\nDemonstrating button click detection:")
|
||||
|
||||
# Simulate some clicks to demonstrate functionality
|
||||
actions_col_x = table_origin[0] + sum(column_widths[:3]) + 6
|
||||
caption_height = 30
|
||||
header_height = row_heights.get("header", 30)
|
||||
body_row_height = row_heights.get("body", 30)
|
||||
|
||||
# Calculate first body row position (after caption + header + border)
|
||||
first_row_y = table_origin[1] + caption_height + header_height + 2
|
||||
|
||||
test_clicks = [
|
||||
(100, 100, "Click outside table"),
|
||||
(actions_col_x + 10, first_row_y + 15, "View button - Alice"),
|
||||
(actions_col_x + 75, first_row_y + 15, "Edit button - Alice"),
|
||||
(actions_col_x + 140, first_row_y + 15, "Delete button - Alice"),
|
||||
(actions_col_x + 10, first_row_y + body_row_height + 17, "View button - Bob"),
|
||||
]
|
||||
|
||||
for x, y, desc in test_clicks:
|
||||
print(f"\n Click at ({x}, {y}) - {desc}:")
|
||||
clicked = handle_click((x, y), buttons_with_bounds)
|
||||
if not clicked:
|
||||
print(f" No button at this position")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -68,10 +68,10 @@ Demonstrates:
|
||||

|
||||
|
||||
### 05. Tables with Images
|
||||
**`05_table_with_images.py`** - Tables containing images and mixed content
|
||||
**`05_html_table_with_images.py`** - Tables containing images and mixed content
|
||||
|
||||
```bash
|
||||
python 05_table_with_images.py
|
||||
python 05_html_table_with_images.py
|
||||
```
|
||||
|
||||
Demonstrates:
|
||||
@@ -80,8 +80,9 @@ Demonstrates:
|
||||
- Book catalog and product showcase tables
|
||||
- Mixed content (images and text) in cells
|
||||
- Using cover images from test data
|
||||
- HTML table parsing with `<img>` tags
|
||||
|
||||

|
||||

|
||||
|
||||
### 06. Functional Elements (Interactive)
|
||||
**`06_functional_elements_demo.py`** - Interactive buttons and forms with callbacks
|
||||
@@ -101,17 +102,139 @@ Demonstrates:
|
||||
|
||||

|
||||
|
||||
## Advanced Examples
|
||||
### 07. Button Pressed States (Interactive)
|
||||
**`07_pressed_state_demo.py`** - Visual feedback for button interactions
|
||||
|
||||
### HTML Rendering
|
||||
```bash
|
||||
python 07_pressed_state_demo.py
|
||||
```
|
||||
|
||||
These examples demonstrate rendering HTML content to multi-page layouts:
|
||||
Demonstrates:
|
||||
- Button pressed/released state management
|
||||
- Visual feedback timing (150ms press duration)
|
||||
- Automatic interaction handling with `InteractionHandler`
|
||||
- Manual state management for custom event loops
|
||||
- Dirty flag system for optimized re-rendering
|
||||
- State tracking with `InteractionStateManager`
|
||||
|
||||
**`html_line_breaking_demo.py`** - Basic HTML line breaking demonstration
|
||||
**`html_multipage_simple.py`** - Simple single-page HTML rendering
|
||||
**`html_multipage_demo_final.py`** - Complete multi-page HTML rendering with headers/footers
|
||||

|
||||
|
||||
For detailed information about HTML rendering, see `README_HTML_MULTIPAGE.md`.
|
||||
*Animated GIF showing button press sequence: initial → pressed → released*
|
||||
|
||||
---
|
||||
|
||||
## 🆕 New Examples (2024-11)
|
||||
|
||||
These examples address critical coverage gaps and demonstrate advanced features:
|
||||
|
||||
### 08. Bundled Fonts Showcase
|
||||
**`08_bundled_fonts_demo.py`** - Demonstration of all bundled fonts
|
||||
|
||||
```bash
|
||||
python 08_bundled_fonts_demo.py
|
||||
```
|
||||
|
||||
Demonstrates:
|
||||
- DejaVu Sans (Sans-serif)
|
||||
- DejaVu Serif (Serif)
|
||||
- DejaVu Sans Mono (Monospace)
|
||||
- All font variants: Regular, Bold, Italic, Bold Italic
|
||||
|
||||

|
||||
|
||||
### 08. Pagination with PageBreak ✅
|
||||
**`08_pagination_demo.py`** - Multi-page documents with explicit and automatic pagination
|
||||
|
||||
```bash
|
||||
python 08_pagination_demo.py
|
||||
```
|
||||
|
||||
**Test Coverage:** [tests/examples/test_08_pagination_demo.py](../tests/examples/test_08_pagination_demo.py) - 11 tests
|
||||
|
||||
Demonstrates:
|
||||
- Using `PageBreak` to force content onto new pages
|
||||
- Multi-page document layout with explicit breaks
|
||||
- Automatic pagination when content overflows
|
||||
- Page numbering functionality
|
||||
- Document flow control
|
||||
- Combining pages into vertical strips
|
||||
|
||||
**Coverage Impact:** Fills critical gap - PageBreak layouter had NO examples before this!
|
||||
|
||||

|
||||
|
||||
### 09. Link Navigation (NEW) ✅
|
||||
**`09_link_navigation_demo.py`** - All link types and interactive navigation
|
||||
|
||||
```bash
|
||||
python 09_link_navigation_demo.py
|
||||
```
|
||||
|
||||
**Test Coverage:** [tests/examples/test_09_link_navigation_demo.py](../tests/examples/test_09_link_navigation_demo.py) - 10 tests
|
||||
|
||||
Demonstrates:
|
||||
- **Internal links** - Document navigation (`#section1`, `#section2`)
|
||||
- **External links** - Web URLs (`https://example.com`)
|
||||
- **API links** - API endpoints (`/api/settings`, `/api/save`)
|
||||
- **Function links** - Direct function calls (`calculate()`, `process()`)
|
||||
- Link styling (underlined, color-coded by type)
|
||||
- Link callbacks and interactivity
|
||||
- Mixed text and link paragraphs
|
||||
|
||||
**Coverage Impact:** Comprehensive - All 4 LinkType variations demonstrated!
|
||||
|
||||

|
||||
|
||||
### 10. Comprehensive Forms (NEW) ✅
|
||||
**`10_forms_demo.py`** - All 14 form field types with validation
|
||||
|
||||
```bash
|
||||
python 10_forms_demo.py
|
||||
```
|
||||
|
||||
**Test Coverage:** [tests/examples/test_10_forms_demo.py](../tests/examples/test_10_forms_demo.py) - 9 tests
|
||||
|
||||
Demonstrates all 14 FormFieldType variations:
|
||||
|
||||
**Text-Based Fields:**
|
||||
- TEXT, EMAIL, PASSWORD, URL, TEXTAREA
|
||||
|
||||
**Number/Date/Time Fields:**
|
||||
- NUMBER, DATE, TIME, RANGE, COLOR
|
||||
|
||||
**Selection Fields:**
|
||||
- CHECKBOX, RADIO, SELECT, HIDDEN
|
||||
|
||||
**Coverage Impact:** Complete - All 14 field types across 4 practical form examples!
|
||||
|
||||

|
||||
|
||||
### 11. Table Text Wrapping (NEW) ✅
|
||||
**`11_table_text_wrapping_demo.py`** - Automatic line wrapping in table cells
|
||||
|
||||
```bash
|
||||
python 11_table_text_wrapping_demo.py
|
||||
```
|
||||
|
||||
**Simple Version:** `11b_simple_table_wrapping.py` - Quick demonstration
|
||||
|
||||
Demonstrates:
|
||||
- **Automatic line wrapping** - Text wraps across multiple lines within cells
|
||||
- **Word hyphenation** - Long words are intelligently hyphenated
|
||||
- **Narrow columns** - Aggressive wrapping for tight spaces
|
||||
- **Mixed content** - Both short and long text in the same table
|
||||
- **Technical documentation** - API reference style tables
|
||||
- **News layouts** - Article-style table content
|
||||
|
||||
**Implementation:** Uses the Line class from `pyWebLayout.concrete.text` with:
|
||||
- Word-by-word fitting with intelligent spacing
|
||||
- Pyphen-based dictionary hyphenation
|
||||
- Brute-force splitting for edge cases
|
||||
- Proper baseline alignment and metrics
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Running the Examples
|
||||
|
||||
@@ -119,21 +242,68 @@ All examples can be run directly from the examples directory:
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
python 01_simple_page_rendering.py
|
||||
python 02_text_and_layout.py
|
||||
python 03_page_layouts.py
|
||||
python 04_table_rendering.py
|
||||
python 05_table_with_images.py
|
||||
python 06_functional_elements_demo.py
|
||||
|
||||
# Getting Started (01-07)
|
||||
python 01_simple_page_rendering.py # Page layouts
|
||||
python 02_text_and_layout.py # Text alignment with justified text
|
||||
python 03_page_layouts.py # Various page sizes
|
||||
python 04_table_rendering.py # Table styles
|
||||
python 05_html_table_with_images.py # HTML tables with images
|
||||
python 06_functional_elements_demo.py # Interactive buttons and forms
|
||||
python 07_pressed_state_demo.py # Button pressed states (generates GIF)
|
||||
|
||||
# Advanced Features (08-11)
|
||||
python 08_bundled_fonts_demo.py # Bundled font showcase
|
||||
python 08_pagination_demo.py # Multi-page documents
|
||||
python 09_link_navigation_demo.py # All link types
|
||||
python 10_forms_demo.py # All form field types
|
||||
python 11_table_text_wrapping_demo.py # Table text wrapping
|
||||
python 11b_simple_table_wrapping.py # Simple wrapping demo
|
||||
```
|
||||
|
||||
Output images are saved to the `docs/images/` directory.
|
||||
|
||||
## Recent Improvements
|
||||
|
||||
### ✅ Justified Text Fix (2024-11-10)
|
||||
Lines using justified alignment now properly fill the entire width by:
|
||||
- Calculating base spacing and remainder pixels
|
||||
- Distributing remainder across word gaps to eliminate short lines
|
||||
- Removing max_spacing constraint for true justification
|
||||
|
||||
**Affected examples:** 02, 11, 11b - All text now perfectly justified!
|
||||
|
||||
### ✅ Animated Button States (2024-11-10)
|
||||
Example 07 now automatically generates an animated GIF showing button interactions:
|
||||
- Initial state (1000ms)
|
||||
- Pressed state (200ms)
|
||||
- Released state (500ms)
|
||||
- Loops continuously
|
||||
|
||||
**Output:** `docs/images/example_07_button_animation.gif`
|
||||
|
||||
### Running Tests
|
||||
|
||||
All new examples (08, 09, 10) include comprehensive test coverage:
|
||||
|
||||
```bash
|
||||
# Run all example tests
|
||||
python -m pytest tests/examples/ -v
|
||||
|
||||
# Run specific test file
|
||||
python -m pytest tests/examples/test_08_pagination_demo.py -v
|
||||
python -m pytest tests/examples/test_09_link_navigation_demo.py -v
|
||||
python -m pytest tests/examples/test_10_forms_demo.py -v
|
||||
```
|
||||
|
||||
**Total Test Coverage:** 30 tests (11 + 10 + 9), all passing ✅
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- `README_HTML_MULTIPAGE.md` - HTML multi-page rendering guide
|
||||
- `../ARCHITECTURE.md` - Detailed explanation of the Abstract/Concrete architecture
|
||||
- `../docs/images/` - Rendered example outputs
|
||||
- `../docs/images/README.md` - Visual documentation index with all examples
|
||||
- `../pyWebLayout/layout/README_EREADER_API.md` - EbookReader API reference
|
||||
|
||||
## Debug/Development Scripts
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Generate a demo image for README.md showing font family switching feature.
|
||||
|
||||
Creates a side-by-side comparison of the same content rendered in
|
||||
Sans, Serif, and Monospace fonts.
|
||||
"""
|
||||
|
||||
from pyWebLayout.abstract import Paragraph, Heading, Word
|
||||
from pyWebLayout.abstract.block import HeadingLevel
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.fonts import BundledFont, FontWeight
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.ereader_manager import create_ereader_manager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def create_demo_content():
|
||||
"""Create concise demo content that fits nicely on a small page"""
|
||||
blocks = []
|
||||
|
||||
# Title
|
||||
title_font = Font.from_family(BundledFont.SANS, font_size=28, weight=FontWeight.BOLD)
|
||||
title = Heading(level=HeadingLevel.H1, style=title_font)
|
||||
for word in "The Adventure Begins".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
blocks.append(title)
|
||||
|
||||
# Paragraph
|
||||
body_font = Font.from_family(BundledFont.SANS, font_size=14)
|
||||
para = Paragraph(body_font)
|
||||
text = (
|
||||
"In the quiet village of Millbrook, young Emma discovered an ancient map "
|
||||
"hidden in her grandmother's attic. The parchment revealed a mysterious "
|
||||
"forest path marked with symbols she had never seen before. With courage "
|
||||
"in her heart and the map in her pocket, she set out at dawn to uncover "
|
||||
"the secrets that lay beyond the old oak trees."
|
||||
)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, body_font))
|
||||
blocks.append(para)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def render_with_font_family(blocks, page_size, font_family, family_name):
|
||||
"""Render a page with a specific font family"""
|
||||
manager = create_ereader_manager(
|
||||
blocks,
|
||||
page_size,
|
||||
document_id=f"demo_{family_name.lower()}"
|
||||
)
|
||||
|
||||
# Set font family (None means original/default)
|
||||
manager.set_font_family(font_family)
|
||||
|
||||
# Get the first page
|
||||
page = manager.get_current_page()
|
||||
return page.render()
|
||||
|
||||
|
||||
def create_comparison_image():
|
||||
"""Create a side-by-side comparison of all three font families"""
|
||||
|
||||
# Page size for each panel
|
||||
page_width = 400
|
||||
page_height = 300
|
||||
|
||||
# Create demo content
|
||||
print("Creating demo content...")
|
||||
blocks = create_demo_content()
|
||||
|
||||
# Render with each font family
|
||||
print("Rendering with Sans font...")
|
||||
sans_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
|
||||
)
|
||||
|
||||
print("Rendering with Serif font...")
|
||||
serif_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
|
||||
)
|
||||
|
||||
print("Rendering with Monospace font...")
|
||||
mono_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
|
||||
)
|
||||
|
||||
# Create a composite image with all three side by side
|
||||
spacing = 20
|
||||
label_height = 30
|
||||
total_width = page_width * 3 + spacing * 4
|
||||
total_height = page_height + label_height + spacing * 2
|
||||
|
||||
composite = Image.new('RGB', (total_width, total_height), color='#f5f5f5')
|
||||
|
||||
# Paste the three images
|
||||
x_positions = [
|
||||
spacing,
|
||||
spacing * 2 + page_width,
|
||||
spacing * 3 + page_width * 2
|
||||
]
|
||||
|
||||
for img, x_pos in zip([sans_image, serif_image, mono_image], x_positions):
|
||||
composite.paste(img, (x_pos, label_height + spacing))
|
||||
|
||||
# Add labels
|
||||
draw = ImageDraw.Draw(composite)
|
||||
|
||||
# Try to use a nice font, fallback to default if not available
|
||||
try:
|
||||
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
|
||||
except:
|
||||
label_font = ImageFont.load_default()
|
||||
|
||||
labels = ["Sans-Serif", "Serif", "Monospace"]
|
||||
for label, x_pos in zip(labels, x_positions):
|
||||
# Calculate text position to center it
|
||||
bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_x = x_pos + (page_width - text_width) // 2
|
||||
|
||||
draw.text((text_x, 5), label, fill='#333333', font=label_font)
|
||||
|
||||
# Save the image
|
||||
output_path = "docs/images/font_family_switching.png"
|
||||
composite.save(output_path, quality=95)
|
||||
print(f"\n✓ Saved demo image to: {output_path}")
|
||||
print(f" Image size: {total_width}x{total_height}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def create_single_vertical_comparison():
|
||||
"""Create a vertical comparison that's better for README"""
|
||||
|
||||
# Page size for each panel
|
||||
page_width = 700
|
||||
page_height = 280
|
||||
|
||||
# Create demo content
|
||||
print("\nCreating vertical comparison for README...")
|
||||
blocks = create_demo_content()
|
||||
|
||||
# Render with each font family
|
||||
print(" Rendering Sans...")
|
||||
sans_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
|
||||
)
|
||||
|
||||
print(" Rendering Serif...")
|
||||
serif_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
|
||||
)
|
||||
|
||||
print(" Rendering Monospace...")
|
||||
mono_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
|
||||
)
|
||||
|
||||
# Create a composite image stacked vertically
|
||||
spacing = 15
|
||||
label_width = 120
|
||||
total_width = page_width + label_width + spacing * 2
|
||||
total_height = page_height * 3 + spacing * 4
|
||||
|
||||
composite = Image.new('RGB', (total_width, total_height), color='#ffffff')
|
||||
|
||||
# Add a subtle border
|
||||
draw = ImageDraw.Draw(composite)
|
||||
draw.rectangle([(0, 0), (total_width-1, total_height-1)], outline='#e0e0e0', width=1)
|
||||
|
||||
# Paste the three images vertically
|
||||
y_positions = [
|
||||
spacing,
|
||||
spacing * 2 + page_height,
|
||||
spacing * 3 + page_height * 2
|
||||
]
|
||||
|
||||
images_data = [
|
||||
(sans_image, "Sans-Serif", "#4A90E2"),
|
||||
(serif_image, "Serif", "#E94B3C"),
|
||||
(mono_image, "Monospace", "#50C878")
|
||||
]
|
||||
|
||||
# Try to use a nice font
|
||||
try:
|
||||
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except:
|
||||
label_font = ImageFont.load_default()
|
||||
small_font = ImageFont.load_default()
|
||||
|
||||
for (img, label, color), y_pos in zip(images_data, y_positions):
|
||||
# Paste the page image
|
||||
composite.paste(img, (label_width + spacing, y_pos))
|
||||
|
||||
# Draw label background
|
||||
draw.rectangle(
|
||||
[(spacing, y_pos + 10), (label_width, y_pos + 40)],
|
||||
fill=color
|
||||
)
|
||||
|
||||
# Draw label text
|
||||
draw.text(
|
||||
(spacing + 10, y_pos + 17),
|
||||
label,
|
||||
fill='#ffffff',
|
||||
font=label_font
|
||||
)
|
||||
|
||||
# Draw font description
|
||||
descriptions = {
|
||||
"Sans-Serif": "Clean & Modern",
|
||||
"Serif": "Classic & Formal",
|
||||
"Monospace": "Code & Technical"
|
||||
}
|
||||
draw.text(
|
||||
(spacing + 5, y_pos + 50),
|
||||
descriptions[label],
|
||||
fill='#666666',
|
||||
font=small_font
|
||||
)
|
||||
|
||||
# Save the image
|
||||
output_path = "docs/images/font_family_switching_vertical.png"
|
||||
composite.save(output_path, quality=95)
|
||||
print(f" ✓ Saved: {output_path}")
|
||||
print(f" Size: {total_width}x{total_height}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 70)
|
||||
print("Generating README Demo Images")
|
||||
print("=" * 70)
|
||||
|
||||
# Create both versions
|
||||
horizontal_path = create_comparison_image()
|
||||
vertical_path = create_single_vertical_comparison()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Demo images generated successfully!")
|
||||
print("=" * 70)
|
||||
print(f"\nHorizontal comparison: {horizontal_path}")
|
||||
print(f"Vertical comparison: {vertical_path}")
|
||||
print("\nRecommended for README: vertical version")
|
||||
print("\nMarkdown snippet:")
|
||||
print("```markdown")
|
||||
print("")
|
||||
print("```")
|
||||
print()
|
||||
@@ -6,7 +6,7 @@ import urllib.request
|
||||
import urllib.parse
|
||||
from PIL import Image as PILImage
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..core import Hierarchical, Styleable, FontRegistry
|
||||
from ..core import Hierarchical, Styleable, FontRegistry, ContainerAware, BlockContainer
|
||||
|
||||
|
||||
class BlockType(Enum):
|
||||
@@ -50,7 +50,7 @@ class Block(Hierarchical):
|
||||
return self._block_type
|
||||
|
||||
|
||||
class Paragraph(Styleable, FontRegistry, Block):
|
||||
class Paragraph(Styleable, FontRegistry, ContainerAware, Block):
|
||||
"""
|
||||
A paragraph is a block-level element that contains a sequence of words.
|
||||
|
||||
@@ -85,22 +85,15 @@ class Paragraph(Styleable, FontRegistry, Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_block method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
elif style is None and hasattr(container, 'default_style'):
|
||||
style = container.default_style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container)
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new paragraph
|
||||
paragraph = cls(style)
|
||||
|
||||
# Add the paragraph to the container
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(paragraph)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
container.add_block(paragraph)
|
||||
|
||||
return paragraph
|
||||
|
||||
@@ -237,22 +230,15 @@ class Heading(Paragraph):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_block method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
elif style is None and hasattr(container, 'default_style'):
|
||||
style = container.default_style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container)
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new heading
|
||||
heading = cls(level, style)
|
||||
|
||||
# Add the heading to the container
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(heading)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
container.add_block(heading)
|
||||
|
||||
return heading
|
||||
|
||||
@@ -267,7 +253,7 @@ class Heading(Paragraph):
|
||||
self._level = level
|
||||
|
||||
|
||||
class Quote(Block):
|
||||
class Quote(BlockContainer, ContainerAware, Block):
|
||||
"""
|
||||
A blockquote element that can contain other block elements.
|
||||
"""
|
||||
@@ -280,7 +266,6 @@ class Quote(Block):
|
||||
style: Optional default style for child blocks
|
||||
"""
|
||||
super().__init__(BlockType.QUOTE)
|
||||
self._blocks: List[Block] = []
|
||||
self._style = style
|
||||
|
||||
@classmethod
|
||||
@@ -299,22 +284,15 @@ class Quote(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_block method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
elif style is None and hasattr(container, 'default_style'):
|
||||
style = container.default_style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container)
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new quote
|
||||
quote = cls(style)
|
||||
|
||||
# Add the quote to the container
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(quote)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
container.add_block(quote)
|
||||
|
||||
return quote
|
||||
|
||||
@@ -328,54 +306,6 @@ class Quote(Block):
|
||||
"""Set the default style for this quote"""
|
||||
self._style = style
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block element to this quote.
|
||||
|
||||
Args:
|
||||
block: The Block object to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
block.parent = self
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this quote.
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from quote
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this quote.
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from quote
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
return Heading.create_and_add_to(self, level, style)
|
||||
|
||||
def blocks(self) -> Iterator[Block]:
|
||||
"""
|
||||
Iterate over the blocks in this quote.
|
||||
|
||||
Yields:
|
||||
Each Block in the quote
|
||||
"""
|
||||
for block in self._blocks:
|
||||
yield block
|
||||
|
||||
|
||||
class CodeBlock(Block):
|
||||
"""
|
||||
@@ -416,8 +346,8 @@ class CodeBlock(Block):
|
||||
container.add_block(code_block)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
||||
)
|
||||
|
||||
return code_block
|
||||
|
||||
@@ -463,7 +393,7 @@ class ListStyle(Enum):
|
||||
DEFINITION = 3 # <dl>
|
||||
|
||||
|
||||
class HList(Block):
|
||||
class HList(ContainerAware, Block):
|
||||
"""
|
||||
An HTML list element (ul, ol, dl).
|
||||
"""
|
||||
@@ -502,22 +432,15 @@ class HList(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_block method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if default_style is None and hasattr(container, 'style'):
|
||||
default_style = container.style
|
||||
elif default_style is None and hasattr(container, 'default_style'):
|
||||
default_style = container.default_style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container)
|
||||
default_style = cls._inherit_style(container, default_style)
|
||||
|
||||
# Create the new list
|
||||
hlist = cls(style, default_style)
|
||||
|
||||
# Add the list to the container
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(hlist)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
container.add_block(hlist)
|
||||
|
||||
return hlist
|
||||
|
||||
@@ -580,7 +503,7 @@ class HList(Block):
|
||||
return len(self._items)
|
||||
|
||||
|
||||
class ListItem(Block):
|
||||
class ListItem(BlockContainer, ContainerAware, Block):
|
||||
"""
|
||||
A list item element that can contain other block elements.
|
||||
"""
|
||||
@@ -594,7 +517,6 @@ class ListItem(Block):
|
||||
style: Optional default style for child blocks
|
||||
"""
|
||||
super().__init__(BlockType.LIST_ITEM)
|
||||
self._blocks: List[Block] = []
|
||||
self._term = term
|
||||
self._style = style
|
||||
|
||||
@@ -619,22 +541,15 @@ class ListItem(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_item method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'default_style'):
|
||||
style = container.default_style
|
||||
elif style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container, required_method='add_item')
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new list item
|
||||
item = cls(term, style)
|
||||
|
||||
# Add the list item to the container
|
||||
if hasattr(container, 'add_item'):
|
||||
container.add_item(item)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_item' method")
|
||||
container.add_item(item)
|
||||
|
||||
return item
|
||||
|
||||
@@ -658,56 +573,8 @@ class ListItem(Block):
|
||||
"""Set the default style for this list item"""
|
||||
self._style = style
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block element to this list item.
|
||||
|
||||
Args:
|
||||
block: The Block object to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
block.parent = self
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this list item.
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from list item
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this list item.
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from list item
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
return Heading.create_and_add_to(self, level, style)
|
||||
|
||||
def blocks(self) -> Iterator[Block]:
|
||||
"""
|
||||
Iterate over the blocks in this list item.
|
||||
|
||||
Yields:
|
||||
Each Block in the list item
|
||||
"""
|
||||
for block in self._blocks:
|
||||
yield block
|
||||
|
||||
|
||||
class TableCell(Block):
|
||||
class TableCell(BlockContainer, ContainerAware, Block):
|
||||
"""
|
||||
A table cell element that can contain other block elements.
|
||||
"""
|
||||
@@ -731,7 +598,6 @@ class TableCell(Block):
|
||||
self._is_header = is_header
|
||||
self._colspan = colspan
|
||||
self._rowspan = rowspan
|
||||
self._blocks: List[Block] = []
|
||||
self._style = style
|
||||
|
||||
@classmethod
|
||||
@@ -754,20 +620,15 @@ class TableCell(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_cell method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container, required_method='add_cell')
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new table cell
|
||||
cell = cls(is_header, colspan, rowspan, style)
|
||||
|
||||
# Add the cell to the container
|
||||
if hasattr(container, 'add_cell'):
|
||||
container.add_cell(cell)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_cell' method")
|
||||
container.add_cell(cell)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -811,56 +672,8 @@ class TableCell(Block):
|
||||
"""Set the default style for this table cell"""
|
||||
self._style = style
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block element to this cell.
|
||||
|
||||
Args:
|
||||
block: The Block object to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
block.parent = self
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this table cell.
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from cell
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this table cell.
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from cell
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
return Heading.create_and_add_to(self, level, style)
|
||||
|
||||
def blocks(self) -> Iterator[Block]:
|
||||
"""
|
||||
Iterate over the blocks in this cell.
|
||||
|
||||
Yields:
|
||||
Each Block in the cell
|
||||
"""
|
||||
for block in self._blocks:
|
||||
yield block
|
||||
|
||||
|
||||
class TableRow(Block):
|
||||
class TableRow(ContainerAware, Block):
|
||||
"""
|
||||
A table row element containing table cells.
|
||||
"""
|
||||
@@ -897,20 +710,15 @@ class TableRow(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_row method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container, required_method='add_row')
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new table row
|
||||
row = cls(style)
|
||||
|
||||
# Add the row to the container
|
||||
if hasattr(container, 'add_row'):
|
||||
container.add_row(row, section)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_row' method")
|
||||
container.add_row(row, section)
|
||||
|
||||
return row
|
||||
|
||||
@@ -970,7 +778,7 @@ class TableRow(Block):
|
||||
return len(self._cells)
|
||||
|
||||
|
||||
class Table(Block):
|
||||
class Table(ContainerAware, Block):
|
||||
"""
|
||||
A table element containing rows and cells.
|
||||
"""
|
||||
@@ -1011,22 +819,15 @@ class Table(Block):
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_block method
|
||||
"""
|
||||
# Inherit style from container if not provided
|
||||
if style is None and hasattr(container, 'style'):
|
||||
style = container.style
|
||||
elif style is None and hasattr(container, 'default_style'):
|
||||
style = container.default_style
|
||||
# Validate container and inherit style using ContainerAware utilities
|
||||
cls._validate_container(container)
|
||||
style = cls._inherit_style(container, style)
|
||||
|
||||
# Create the new table
|
||||
table = cls(caption, style)
|
||||
|
||||
# Add the table to the container
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(table)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
container.add_block(table)
|
||||
|
||||
return table
|
||||
|
||||
@@ -1193,8 +994,8 @@ class Image(Block):
|
||||
container.add_block(image)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
||||
)
|
||||
|
||||
return image
|
||||
|
||||
@@ -1567,8 +1368,8 @@ class HorizontalRule(Block):
|
||||
container.add_block(hr)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
||||
)
|
||||
|
||||
return hr
|
||||
|
||||
@@ -1608,7 +1409,7 @@ class PageBreak(Block):
|
||||
container.add_block(page_break)
|
||||
else:
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
||||
)
|
||||
|
||||
return page_break
|
||||
|
||||
@@ -3,12 +3,25 @@ from pyWebLayout.core import Hierarchical
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
||||
from functools import lru_cache
|
||||
import pyphen
|
||||
|
||||
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen:
|
||||
"""
|
||||
The pyphen dictionary for a language, reused across words.
|
||||
|
||||
Pyphen caches the parsed dictionary file itself, but rebuilding the wrapper
|
||||
per word still costs about 40% of a hyphenation call, and hyphenation is
|
||||
attempted for every word that overflows its line.
|
||||
"""
|
||||
return pyphen.Pyphen(lang=language)
|
||||
|
||||
|
||||
class Word:
|
||||
"""
|
||||
An abstract representation of a word in a document. Words can be split across
|
||||
@@ -163,6 +176,18 @@ class Word:
|
||||
"""Set the next word in sequence"""
|
||||
self._next = next_word
|
||||
|
||||
def with_style(self, style: Font) -> 'Word':
|
||||
"""
|
||||
Return a copy of this word carrying a different font.
|
||||
|
||||
Subclasses that hold extra state must override this, or that state is
|
||||
silently dropped when a caller restyles the word. Sequence links
|
||||
(previous/next) are deliberately not copied: the copy belongs to a
|
||||
different word chain, which the new container rebuilds as words are
|
||||
added to it.
|
||||
"""
|
||||
return Word(self._text, style, self._background)
|
||||
|
||||
def possible_hyphenation(self, language: str = None) -> bool:
|
||||
"""
|
||||
Hyphenate the word and store the parts.
|
||||
@@ -174,8 +199,7 @@ class Word:
|
||||
bool: True if the word was hyphenated, False otherwise.
|
||||
"""
|
||||
|
||||
dic = pyphen.Pyphen(lang=self._style.language)
|
||||
return list(dic.iterate(self._text))
|
||||
return list(_hyphen_dict(self._style.language).iterate(self._text))
|
||||
|
||||
|
||||
...
|
||||
@@ -348,6 +372,19 @@ class LinkedWord(Word):
|
||||
"""Get the link title/tooltip"""
|
||||
return self._title
|
||||
|
||||
def with_style(self, style: Font) -> 'LinkedWord':
|
||||
"""Return a copy carrying a different font, keeping the link intact."""
|
||||
return LinkedWord(
|
||||
self._text,
|
||||
style,
|
||||
self._location,
|
||||
link_type=self._link_type,
|
||||
callback=self._callback,
|
||||
background=self._background,
|
||||
params=dict(self._params),
|
||||
title=self._title,
|
||||
)
|
||||
|
||||
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""
|
||||
Execute the link action.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and
|
||||
associated documentation files (the "Font Software"), to reproduce
|
||||
and distribute the modifications to the Bitstream Vera Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be included in all copies of one or more of the Font Software
|
||||
typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in
|
||||
particular the designs of glyphs or characters in the Fonts may be
|
||||
modified and additional glyphs or characters may be added to the
|
||||
Fonts, only if the fonts are renamed to names not containing either
|
||||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts
|
||||
or Font Software that has been modified and is distributed under the
|
||||
"Tavmjong Bah Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy of one or more of the Font Software typefaces may be sold by
|
||||
itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||
be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in this Font Software without prior written authorization
|
||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||
. fr.
|
||||
|
||||
TeX Gyre DJV Math
|
||||
-----------------
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
|
||||
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||
(on behalf of TeX users groups) are in public domain.
|
||||
|
||||
Letters imported from Euler Fraktur from AMSfonts are (c) American
|
||||
Mathematical Society (see below).
|
||||
Bitstream Vera Fonts Copyright
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
|
||||
is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license (“Fonts”) and associated
|
||||
documentation
|
||||
files (the “Font Software”), to reproduce and distribute the Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute,
|
||||
and/or sell copies of the Font Software, and to permit persons to whom
|
||||
the Font Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be
|
||||
included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional
|
||||
glyphs or characters may be added to the Fonts, only if the fonts are
|
||||
renamed
|
||||
to names not containing either the words “Bitstream” or the word “Vera”.
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or
|
||||
Font Software
|
||||
that has been modified and is distributed under the “Bitstream Vera”
|
||||
names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy
|
||||
of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
|
||||
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
|
||||
ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
|
||||
INABILITY TO USE
|
||||
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Except as contained in this notice, the names of GNOME, the GNOME
|
||||
Foundation,
|
||||
and Bitstream Inc., shall not be used in advertising or otherwise to promote
|
||||
the sale, use or other dealings in this Font Software without prior written
|
||||
authorization from the GNOME Foundation or Bitstream Inc., respectively.
|
||||
For further information, contact: fonts at gnome dot org.
|
||||
|
||||
AMSFonts (v. 2.2) copyright
|
||||
|
||||
The PostScript Type 1 implementation of the AMSFonts produced by and
|
||||
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
|
||||
available for general use. This has been accomplished through the
|
||||
cooperation
|
||||
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
|
||||
Members of this consortium include:
|
||||
|
||||
Elsevier Science IBM Corporation Society for Industrial and Applied
|
||||
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
|
||||
|
||||
In order to assure the authenticity of these fonts, copyright will be
|
||||
held by
|
||||
the American Mathematical Society. This is not meant to restrict in any way
|
||||
the legitimate use of the fonts, such as (but not limited to) electronic
|
||||
distribution of documents containing these fonts, inclusion of these fonts
|
||||
into other public domain or commercial font collections or computer
|
||||
applications, use of the outline data to create derivative fonts and/or
|
||||
faces, etc. However, the AMS does require that the AMS copyright notice be
|
||||
removed from any derivative versions of the fonts which have been altered in
|
||||
any way. In addition, to ensure the fidelity of TeX documents using Computer
|
||||
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
|
||||
has requested that any alterations which yield different font metrics be
|
||||
given a different name.
|
||||
|
||||
$Id$
|
||||
@@ -0,0 +1,67 @@
|
||||
[](https://travis-ci.org/dejavu-fonts/dejavu-fonts)
|
||||
|
||||
DejaVu fonts 2.37 (c)2004-2016 DejaVu fonts team
|
||||
------------------------------------------------
|
||||
|
||||
The DejaVu fonts are a font family based on the Bitstream Vera Fonts
|
||||
(http://gnome.org/fonts/). Its purpose is to provide a wider range of
|
||||
characters (see status.txt for more information) while maintaining the
|
||||
original look and feel.
|
||||
|
||||
DejaVu fonts are based on Bitstream Vera fonts version 1.10.
|
||||
|
||||
Available fonts (Sans = sans serif, Mono = monospaced):
|
||||
|
||||
DejaVu Sans Mono
|
||||
DejaVu Sans Mono Bold
|
||||
DejaVu Sans Mono Bold Oblique
|
||||
DejaVu Sans Mono Oblique
|
||||
DejaVu Sans
|
||||
DejaVu Sans Bold
|
||||
DejaVu Sans Bold Oblique
|
||||
DejaVu Sans Oblique
|
||||
DejaVu Sans ExtraLight (experimental)
|
||||
DejaVu Serif
|
||||
DejaVu Serif Bold
|
||||
DejaVu Serif Bold Italic (experimental)
|
||||
DejaVu Serif Italic (experimental)
|
||||
DejaVu Sans Condensed (experimental)
|
||||
DejaVu Sans Condensed Bold (experimental)
|
||||
DejaVu Sans Condensed Bold Oblique (experimental)
|
||||
DejaVu Sans Condensed Oblique (experimental)
|
||||
DejaVu Serif Condensed (experimental)
|
||||
DejaVu Serif Condensed Bold (experimental)
|
||||
DejaVu Serif Condensed Bold Italic (experimental)
|
||||
DejaVu Serif Condensed Italic (experimental)
|
||||
DejaVu Math TeX Gyre
|
||||
|
||||
All fonts are also available as derivative called DejaVu LGC with support
|
||||
only for Latin, Greek and Cyrillic scripts.
|
||||
|
||||
For license information see LICENSE. What's new is described in NEWS. Known
|
||||
bugs are in BUGS. All authors are mentioned in AUTHORS.
|
||||
|
||||
Fonts are published in source form as SFD files (Spline Font Database from
|
||||
FontForge - http://fontforge.sf.net/) and in compiled form as TTF files
|
||||
(TrueType fonts).
|
||||
|
||||
For more information go to http://dejavu.sourceforge.net/.
|
||||
|
||||
Characters from Arev fonts, Copyright (c) 2006 by Tavmjong Bah:
|
||||
---------------------------
|
||||
U+01BA, U+01BF, U+01F7, U+021C-U+021D, U+0220, U+0222-U+0223,
|
||||
U+02B9, U+02BA, U+02BD, U+02C2-U+02C5, U+02d4-U+02D5,
|
||||
U+02D7, U+02EC-U+02EE, U+0346-U+034E, U+0360, U+0362,
|
||||
U+03E2-03EF, U+0460-0463, U+0466-U+0486, U+0488-U+0489, U+04A8-U+04A9,
|
||||
U+0500-U+050F, U+2055-205E, U+20B0, U+20B2-U+20B3, U+2102, U+210D, U+210F,
|
||||
U+2111, U+2113, U+2115, U+2118-U+211A, U+211C-U+211D, U+2124, U+2135,
|
||||
U+213C-U+2140, U+2295-U+2298, U+2308-U+230B, U+26A2-U+26B1, U+2701-U+2704,
|
||||
U+2706-U+2709, U+270C-U+274B, U+2758-U+275A, U+2761-U+2775, U+2780-U+2794,
|
||||
U+2798-U+27AF, U+27B1-U+27BE, U+FB05-U+FB06
|
||||
|
||||
DejaVu Math TeX Gyre
|
||||
--------------------
|
||||
TeX Gyre DJV Math by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||
(on behalf of TeX users groups).
|
||||
|
||||
$Id$
|
||||
@@ -0,0 +1,129 @@
|
||||
# Bundled Fonts
|
||||
|
||||
This directory contains free, open-source TrueType fonts bundled with pyWebLayout for consistent rendering across all platforms.
|
||||
|
||||
## Font Families
|
||||
|
||||
### DejaVu Sans (Sans-serif)
|
||||
A modern, clean sans-serif font excellent for body text and UI elements.
|
||||
|
||||
- `DejaVuSans.ttf` - Regular
|
||||
- `DejaVuSans-Bold.ttf` - Bold
|
||||
- `DejaVuSans-Oblique.ttf` - Italic
|
||||
- `DejaVuSans-BoldOblique.ttf` - Bold Italic
|
||||
|
||||
### DejaVu Serif (Serif)
|
||||
A classic serif font ideal for formal documents and traditional layouts.
|
||||
|
||||
- `DejaVuSerif.ttf` - Regular
|
||||
- `DejaVuSerif-Bold.ttf` - Bold
|
||||
- `DejaVuSerif-Italic.ttf` - Italic
|
||||
- `DejaVuSerif-BoldItalic.ttf` - Bold Italic
|
||||
|
||||
### DejaVu Sans Mono (Monospace)
|
||||
A fixed-width font perfect for code blocks and technical content.
|
||||
|
||||
- `DejaVuSansMono.ttf` - Regular
|
||||
- `DejaVuSansMono-Bold.ttf` - Bold
|
||||
- `DejaVuSansMono-Oblique.ttf` - Italic
|
||||
- `DejaVuSansMono-BoldOblique.ttf` - Bold Italic
|
||||
|
||||
## Usage
|
||||
|
||||
### Easy Way: Using Font.from_family() (Recommended)
|
||||
|
||||
The easiest way to use bundled fonts is with the `Font.from_family()` class method:
|
||||
|
||||
```python
|
||||
from pyWebLayout.style import Font, BundledFont, FontWeight, FontStyle
|
||||
|
||||
# Create a sans-serif font
|
||||
sans_font = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=16
|
||||
)
|
||||
|
||||
# Create a bold serif font
|
||||
serif_bold = Font.from_family(
|
||||
BundledFont.SERIF,
|
||||
font_size=18,
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
# Create an italic monospace font
|
||||
mono_italic = Font.from_family(
|
||||
BundledFont.MONOSPACE,
|
||||
font_size=14,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
|
||||
# Create a bold italic sans font
|
||||
sans_bold_italic = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=16,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
```
|
||||
|
||||
### Manual Way: Using get_bundled_font_path()
|
||||
|
||||
You can also get the path to bundled fonts directly:
|
||||
|
||||
```python
|
||||
from pyWebLayout.style import Font, BundledFont, FontWeight, FontStyle, get_bundled_font_path
|
||||
|
||||
# Get the path to a specific font
|
||||
font_path = get_bundled_font_path(
|
||||
BundledFont.SERIF,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
|
||||
# Create a font with that path
|
||||
font = Font(font_path=font_path, font_size=16)
|
||||
```
|
||||
|
||||
### Low-level Way: Direct Paths
|
||||
|
||||
If you prefer to specify paths directly:
|
||||
|
||||
```python
|
||||
import os
|
||||
from pyWebLayout.style import Font, get_bundled_fonts_dir
|
||||
|
||||
# Get the fonts directory
|
||||
fonts_dir = get_bundled_fonts_dir()
|
||||
|
||||
# Use specific font files
|
||||
sans_font = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSans.ttf'),
|
||||
font_size=16
|
||||
)
|
||||
|
||||
serif_bold = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSerif-Bold.ttf'),
|
||||
font_size=18
|
||||
)
|
||||
|
||||
mono_italic = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSansMono-Oblique.ttf'),
|
||||
font_size=14
|
||||
)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
The DejaVu fonts are free software under the terms of the Bitstream Vera Fonts Copyright and the Arev Fonts Copyright.
|
||||
|
||||
See `DEJAVU_LICENSE.txt` for full license details.
|
||||
|
||||
## About DejaVu Fonts
|
||||
|
||||
DejaVu fonts are a font family based on the Bitstream Vera Fonts. Its purpose is to provide a wider range of characters while maintaining the original look and feel through the process of collaborative development.
|
||||
|
||||
- **Version**: 2.37
|
||||
- **Website**: https://dejavu-fonts.github.io/
|
||||
- **Repository**: https://github.com/dejavu-fonts/dejavu-fonts
|
||||
|
||||
The fonts provide excellent Unicode coverage and are widely used in open-source projects.
|
||||
@@ -4,7 +4,14 @@ Concrete layer for the pyWebLayout library.
|
||||
This package contains concrete implementations that can be directly rendered.
|
||||
"""
|
||||
|
||||
from .text import Text, Line
|
||||
from .text import (
|
||||
Text,
|
||||
Line,
|
||||
configure_text_caches,
|
||||
clear_text_caches,
|
||||
text_cache_stats,
|
||||
prewarm_text_caches,
|
||||
)
|
||||
from .box import Box
|
||||
from .image import RenderableImage
|
||||
from .page import Page
|
||||
@@ -22,4 +29,8 @@ __all__ = [
|
||||
'Cell',
|
||||
'LinkText',
|
||||
'ButtonText',
|
||||
'configure_text_caches',
|
||||
'clear_text_caches',
|
||||
'text_cache_stats',
|
||||
'prewarm_text_caches',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
DynamicPage implementation for pyWebLayout.
|
||||
|
||||
A DynamicPage is a page that dynamically sizes itself based on content and constraints.
|
||||
Unlike a regular Page with fixed size, a DynamicPage measures its content first and
|
||||
then layouts within the allocated space.
|
||||
|
||||
Use cases:
|
||||
- Table cells that need to fit content
|
||||
- Containers that should grow with content
|
||||
- Responsive layouts that adapt to constraints
|
||||
"""
|
||||
|
||||
from typing import Tuple, Optional, List
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.core.base import Renderable
|
||||
|
||||
|
||||
@dataclass
|
||||
class SizeConstraints:
|
||||
"""Size constraints for dynamic layout."""
|
||||
min_width: Optional[int] = None
|
||||
max_width: Optional[int] = None
|
||||
min_height: Optional[int] = None
|
||||
max_height: Optional[int] = None
|
||||
# Note: Hyphenation threshold is controlled by Font.min_hyphenation_width
|
||||
# Don't duplicate that logic here
|
||||
|
||||
|
||||
class DynamicPage(Page):
|
||||
"""
|
||||
A page that dynamically sizes itself based on content and constraints.
|
||||
|
||||
The layout process has two phases:
|
||||
1. Measurement: Calculate intrinsic size needed for content
|
||||
2. Layout: Position content within allocated size
|
||||
|
||||
This allows containers (like tables) to optimize space allocation before rendering.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
constraints: Optional[SizeConstraints] = None,
|
||||
style: Optional[PageStyle] = None):
|
||||
"""
|
||||
Initialize a dynamic page.
|
||||
|
||||
Args:
|
||||
constraints: Optional size constraints (min/max width/height)
|
||||
style: The PageStyle defining borders, spacing, and appearance
|
||||
"""
|
||||
# Start with zero size - will be determined during measurement/layout
|
||||
super().__init__(size=(0, 0), style=style)
|
||||
self._constraints = constraints if constraints is not None else SizeConstraints()
|
||||
|
||||
# Measurement state
|
||||
self._is_measured = False
|
||||
self._intrinsic_size: Optional[Tuple[int, int]] = None
|
||||
self._min_width_cache: Optional[int] = None
|
||||
self._preferred_width_cache: Optional[int] = None
|
||||
self._content_height_cache: Optional[int] = None
|
||||
|
||||
# Pagination state
|
||||
self._render_offset = 0 # For partial rendering (pagination)
|
||||
self._is_laid_out = False
|
||||
|
||||
@property
|
||||
def constraints(self) -> SizeConstraints:
|
||||
"""Get the size constraints for this page."""
|
||||
return self._constraints
|
||||
|
||||
def measure(self, available_width: Optional[int] = None) -> Tuple[int, int]:
|
||||
"""
|
||||
Measure the intrinsic size needed for content.
|
||||
|
||||
This walks through all children and calculates how much space they need.
|
||||
The measurement respects constraints (min/max width/height).
|
||||
|
||||
Args:
|
||||
available_width: Optional width constraint for wrapping content
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) needed
|
||||
"""
|
||||
if self._is_measured and self._intrinsic_size is not None:
|
||||
return self._intrinsic_size
|
||||
|
||||
# Apply constraints to available width
|
||||
if available_width is not None:
|
||||
if self._constraints.max_width is not None:
|
||||
available_width = min(available_width, self._constraints.max_width)
|
||||
if self._constraints.min_width is not None:
|
||||
available_width = max(available_width, self._constraints.min_width)
|
||||
|
||||
# Measure content
|
||||
# For now, walk through children and sum their sizes
|
||||
total_width = 0
|
||||
total_height = 0
|
||||
|
||||
for child in self._children:
|
||||
if hasattr(child, 'measure'):
|
||||
# Child is also dynamic - ask it to measure
|
||||
child_size = child.measure(available_width)
|
||||
child_width, child_height = child_size
|
||||
else:
|
||||
# Child has fixed size
|
||||
child_width = child.size[0] if hasattr(child, 'size') else 0
|
||||
child_height = child.size[1] if hasattr(child, 'size') else 0
|
||||
|
||||
total_width = max(total_width, child_width)
|
||||
total_height += child_height
|
||||
|
||||
# Add page padding/borders
|
||||
total_width += self._style.total_horizontal_padding + self._style.total_border_width
|
||||
total_height += self._style.total_vertical_padding + self._style.total_border_width
|
||||
|
||||
# Apply constraints
|
||||
if self._constraints.min_width is not None:
|
||||
total_width = max(total_width, self._constraints.min_width)
|
||||
if self._constraints.max_width is not None:
|
||||
total_width = min(total_width, self._constraints.max_width)
|
||||
if self._constraints.min_height is not None:
|
||||
total_height = max(total_height, self._constraints.min_height)
|
||||
if self._constraints.max_height is not None:
|
||||
total_height = min(total_height, self._constraints.max_height)
|
||||
|
||||
self._intrinsic_size = (total_width, total_height)
|
||||
self._is_measured = True
|
||||
|
||||
return self._intrinsic_size
|
||||
|
||||
def get_min_width(self) -> int:
|
||||
"""
|
||||
Get minimum width needed to render content.
|
||||
|
||||
This finds the widest word/element that cannot be broken,
|
||||
using Font.min_hyphenation_width for hyphenation control.
|
||||
|
||||
Returns:
|
||||
Minimum width in pixels
|
||||
"""
|
||||
# Check cache
|
||||
if self._min_width_cache is not None:
|
||||
return self._min_width_cache
|
||||
|
||||
# Calculate minimum width based on content
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
|
||||
min_width = 0
|
||||
|
||||
# Walk through children and find longest unbreakable segment
|
||||
for child in self._children:
|
||||
if isinstance(child, Line):
|
||||
# Check all words in the line
|
||||
# Font's min_hyphenation_width already controls breaking
|
||||
for text_obj in getattr(child, '_text_objects', []):
|
||||
if isinstance(text_obj, Text) and hasattr(text_obj, '_text'):
|
||||
word_text = text_obj._text
|
||||
# Text stores font in _style, not _font
|
||||
font = getattr(text_obj, '_style', None)
|
||||
|
||||
if font:
|
||||
# Just measure the word - Font handles hyphenation rules
|
||||
word_width = int(font.font.getlength(word_text))
|
||||
min_width = max(min_width, word_width)
|
||||
elif hasattr(child, 'get_min_width'):
|
||||
# Child supports min width calculation
|
||||
child_min = child.get_min_width()
|
||||
min_width = max(min_width, child_min)
|
||||
elif hasattr(child, 'size'):
|
||||
# Use actual width
|
||||
min_width = max(min_width, child.size[0])
|
||||
|
||||
# Add padding/borders
|
||||
min_width += self._style.total_horizontal_padding + self._style.total_border_width
|
||||
|
||||
# Apply minimum constraint
|
||||
if self._constraints.min_width is not None:
|
||||
min_width = max(min_width, self._constraints.min_width)
|
||||
|
||||
self._min_width_cache = min_width
|
||||
return min_width
|
||||
|
||||
def get_preferred_width(self) -> int:
|
||||
"""
|
||||
Get preferred width (no wrapping).
|
||||
|
||||
This returns the width needed to render all content without any
|
||||
line wrapping.
|
||||
|
||||
Returns:
|
||||
Preferred width in pixels
|
||||
"""
|
||||
# Check cache
|
||||
if self._preferred_width_cache is not None:
|
||||
return self._preferred_width_cache
|
||||
|
||||
# Calculate preferred width (no wrapping)
|
||||
from pyWebLayout.concrete.text import Line
|
||||
|
||||
pref_width = 0
|
||||
|
||||
for child in self._children:
|
||||
if isinstance(child, Line):
|
||||
# Get line width without wrapping (including spacing between words)
|
||||
text_objects = getattr(child, '_text_objects', [])
|
||||
if text_objects:
|
||||
line_width = 0
|
||||
for i, text_obj in enumerate(text_objects):
|
||||
if hasattr(text_obj, '_text') and hasattr(text_obj, '_style'):
|
||||
# Text stores font in _style, not _font
|
||||
word_width = text_obj._style.font.getlength(text_obj._text)
|
||||
line_width += word_width
|
||||
|
||||
# Add spacing after word (except last word)
|
||||
if i < len(text_objects) - 1:
|
||||
# Get spacing from Line if available, otherwise use default
|
||||
spacing = getattr(child, '_spacing', (3, 6))
|
||||
# Use minimum spacing for preferred width calculation
|
||||
line_width += spacing[0] if isinstance(spacing, tuple) else 3
|
||||
|
||||
pref_width = max(pref_width, line_width)
|
||||
elif hasattr(child, 'get_preferred_width'):
|
||||
child_pref = child.get_preferred_width()
|
||||
pref_width = max(pref_width, child_pref)
|
||||
elif hasattr(child, 'size'):
|
||||
# Use actual size
|
||||
pref_width = max(pref_width, child.size[0])
|
||||
|
||||
# Add padding/borders
|
||||
pref_width += self._style.total_horizontal_padding + self._style.total_border_width
|
||||
|
||||
# Apply constraints
|
||||
if self._constraints.max_width is not None:
|
||||
pref_width = min(pref_width, self._constraints.max_width)
|
||||
if self._constraints.min_width is not None:
|
||||
pref_width = max(pref_width, self._constraints.min_width)
|
||||
|
||||
self._preferred_width_cache = pref_width
|
||||
return pref_width
|
||||
|
||||
def measure_content_height(self) -> int:
|
||||
"""
|
||||
Measure total height needed to render all content.
|
||||
|
||||
This is used for pagination to know how much content remains.
|
||||
|
||||
Returns:
|
||||
Total height in pixels
|
||||
"""
|
||||
# Check cache
|
||||
if self._content_height_cache is not None:
|
||||
return self._content_height_cache
|
||||
|
||||
total_height = 0
|
||||
|
||||
for child in self._children:
|
||||
if hasattr(child, 'measure_content_height'):
|
||||
child_height = child.measure_content_height()
|
||||
elif hasattr(child, 'size'):
|
||||
child_height = child.size[1]
|
||||
else:
|
||||
child_height = 0
|
||||
|
||||
total_height += child_height
|
||||
|
||||
# Add padding/borders
|
||||
total_height += self._style.total_vertical_padding + self._style.total_border_width
|
||||
|
||||
self._content_height_cache = total_height
|
||||
return total_height
|
||||
|
||||
def layout(self, size: Tuple[int, int]):
|
||||
"""
|
||||
Layout content within the given size.
|
||||
|
||||
This is called after measurement to position children within
|
||||
the allocated space.
|
||||
|
||||
Args:
|
||||
size: The final size allocated to this page (width, height)
|
||||
"""
|
||||
# Set the page size
|
||||
self._size = size
|
||||
|
||||
# Position children sequentially
|
||||
# Use the same logic as Page but now we know our final size
|
||||
content_x = self._style.border_width + self._style.padding_left
|
||||
content_y = self._style.border_width + self._style.padding_top
|
||||
|
||||
self._current_y_offset = content_y
|
||||
self._is_first_line = True
|
||||
|
||||
# Children position themselves, we just track y_offset
|
||||
# The actual positioning happens when children render
|
||||
|
||||
self._is_laid_out = True
|
||||
self._dirty = True # Mark for re-render
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
"""
|
||||
Render the page with all its children.
|
||||
|
||||
If not yet measured/laid out, use intrinsic sizing.
|
||||
|
||||
Returns:
|
||||
PIL Image containing the rendered page
|
||||
"""
|
||||
# Ensure we have a valid size
|
||||
if self._size[0] == 0 or self._size[1] == 0:
|
||||
if not self._is_measured:
|
||||
# Auto-measure with no constraints
|
||||
self.measure()
|
||||
|
||||
if self._intrinsic_size:
|
||||
self._size = self._intrinsic_size
|
||||
else:
|
||||
# Fallback to minimum size
|
||||
self._size = (100, 100)
|
||||
|
||||
# Use parent's render implementation
|
||||
return super().render()
|
||||
|
||||
# Pagination Support
|
||||
# ------------------
|
||||
|
||||
def render_partial(self, available_height: int) -> int:
|
||||
"""
|
||||
Render as much content as fits in available_height.
|
||||
|
||||
This is used for pagination when a page needs to be split across
|
||||
multiple output pages.
|
||||
|
||||
Args:
|
||||
available_height: Height available on current page
|
||||
|
||||
Returns:
|
||||
Amount of content rendered (in pixels)
|
||||
"""
|
||||
# Calculate how many children fit in available height
|
||||
rendered_height = 0
|
||||
content_start_y = self._style.border_width + self._style.padding_top
|
||||
|
||||
for i, child in enumerate(self._children):
|
||||
# Skip already rendered children
|
||||
if rendered_height < self._render_offset:
|
||||
if hasattr(child, 'size'):
|
||||
rendered_height += child.size[1]
|
||||
continue
|
||||
|
||||
# Check if this child fits
|
||||
child_height = child.size[1] if hasattr(child, 'size') else 0
|
||||
|
||||
if rendered_height + child_height <= available_height:
|
||||
# Child fits - render it
|
||||
if hasattr(child, 'render'):
|
||||
child.render()
|
||||
rendered_height += child_height
|
||||
else:
|
||||
# No more space
|
||||
break
|
||||
|
||||
# Update render offset for next call
|
||||
self._render_offset = rendered_height
|
||||
|
||||
return rendered_height
|
||||
|
||||
def has_more_content(self) -> bool:
|
||||
"""
|
||||
Check if there's unrendered content remaining.
|
||||
|
||||
Returns:
|
||||
True if more content needs to be rendered
|
||||
"""
|
||||
total_height = self.measure_content_height()
|
||||
return self._render_offset < total_height
|
||||
|
||||
def reset_pagination(self):
|
||||
"""Reset pagination to render from beginning."""
|
||||
self._render_offset = 0
|
||||
|
||||
def invalidate_caches(self):
|
||||
"""Invalidate all measurement caches (call when children change)."""
|
||||
self._is_measured = False
|
||||
self._intrinsic_size = None
|
||||
self._min_width_cache = None
|
||||
self._preferred_width_cache = None
|
||||
self._content_height_cache = None
|
||||
self._is_laid_out = False
|
||||
|
||||
def add_child(self, child: Renderable) -> 'DynamicPage':
|
||||
"""
|
||||
Add a child and invalidate caches.
|
||||
|
||||
Args:
|
||||
child: The renderable object to add
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
super().add_child(child)
|
||||
self.invalidate_caches()
|
||||
return self
|
||||
|
||||
def clear_children(self) -> 'DynamicPage':
|
||||
"""
|
||||
Remove all children and invalidate caches.
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
super().clear_children()
|
||||
self.invalidate_caches()
|
||||
return self
|
||||
@@ -16,7 +16,7 @@ class LinkText(Text, Interactable, Queriable):
|
||||
"""
|
||||
|
||||
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
|
||||
source=None, line=None):
|
||||
source=None, line=None, page=None):
|
||||
"""
|
||||
Initialize a linkable text object.
|
||||
|
||||
@@ -27,6 +27,7 @@ class LinkText(Text, Interactable, Queriable):
|
||||
draw: The drawing context
|
||||
source: Optional source object
|
||||
line: Optional line container
|
||||
page: Optional parent page (for dirty flag management)
|
||||
"""
|
||||
# Create link-styled font (underlined and colored based on link type)
|
||||
link_font = font.with_decoration(TextDecoration.UNDERLINE)
|
||||
@@ -46,9 +47,11 @@ class LinkText(Text, Interactable, Queriable):
|
||||
# Initialize Interactable with the link's execute method
|
||||
Interactable.__init__(self, link.execute)
|
||||
|
||||
# Store the link object
|
||||
# Store the link object and page reference
|
||||
self._link = link
|
||||
self._page = page
|
||||
self._hovered = False
|
||||
self._pressed = False
|
||||
|
||||
# Ensure _origin is initialized as numpy array
|
||||
if not hasattr(self, '_origin') or self._origin is None:
|
||||
@@ -62,40 +65,58 @@ class LinkText(Text, Interactable, Queriable):
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state for visual feedback"""
|
||||
self._hovered = hovered
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set the pressed state for visual feedback"""
|
||||
self._pressed = pressed
|
||||
self._mark_page_dirty()
|
||||
|
||||
def _mark_page_dirty(self):
|
||||
"""Mark the parent page as dirty if available"""
|
||||
if self._page and hasattr(self._page, 'mark_dirty'):
|
||||
self._page.mark_dirty()
|
||||
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Render the link text with optional hover effects.
|
||||
Render the link text with optional hover and pressed effects.
|
||||
|
||||
Args:
|
||||
next_text: The next Text object in the line (if any)
|
||||
spacing: The spacing to the next text object
|
||||
"""
|
||||
# Handle mock objects in tests
|
||||
size = self.size
|
||||
if hasattr(size, '__call__'): # It's a Mock
|
||||
# Use default size for tests
|
||||
size = np.array([100, 20])
|
||||
else:
|
||||
size = np.array(size)
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(
|
||||
self._origin) if not isinstance(
|
||||
self._origin,
|
||||
np.ndarray) else self._origin
|
||||
|
||||
# Draw background based on state (before text is rendered).
|
||||
# PIL wants a flat sequence of four scalars; handing it a list of two
|
||||
# numpy arrays raises "coordinate list must contain exactly 2
|
||||
# coordinates".
|
||||
if self._pressed or self._hovered:
|
||||
far = origin + size
|
||||
box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
|
||||
if self._pressed:
|
||||
# Pressed state - stronger, darker highlight
|
||||
bg_color = (180, 180, 255, 180)
|
||||
else:
|
||||
# Hover state - subtle highlight
|
||||
bg_color = (220, 220, 255, 100)
|
||||
self._draw.rectangle(box, fill=bg_color)
|
||||
|
||||
# Call the parent Text render method with parameters
|
||||
super().render(next_text, spacing)
|
||||
|
||||
# Add hover effect if needed
|
||||
if self._hovered:
|
||||
# Draw a subtle highlight background
|
||||
highlight_color = (220, 220, 255, 100) # Light blue with alpha
|
||||
|
||||
# Handle mock objects in tests
|
||||
size = self.size
|
||||
if hasattr(size, '__call__'): # It's a Mock
|
||||
# Use default size for tests
|
||||
size = np.array([100, 20])
|
||||
else:
|
||||
size = np.array(size)
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(
|
||||
self._origin) if not isinstance(
|
||||
self._origin,
|
||||
np.ndarray) else self._origin
|
||||
|
||||
self._draw.rectangle([origin, origin + size],
|
||||
fill=highlight_color)
|
||||
|
||||
|
||||
class ButtonText(Text, Interactable, Queriable):
|
||||
"""
|
||||
@@ -105,7 +126,7 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
|
||||
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
|
||||
source=None, line=None):
|
||||
source=None, line=None, page=None):
|
||||
"""
|
||||
Initialize a button text object.
|
||||
|
||||
@@ -116,6 +137,7 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
padding: Padding around the button text (top, right, bottom, left)
|
||||
source: Optional source object
|
||||
line: Optional line container
|
||||
page: Optional parent page (for dirty flag management)
|
||||
"""
|
||||
# Initialize Text with the button label
|
||||
Text.__init__(self, button.label, font, draw, source, line)
|
||||
@@ -126,6 +148,7 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
# Store button properties
|
||||
self._button = button
|
||||
self._padding = padding
|
||||
self._page = page
|
||||
self._pressed = False
|
||||
self._hovered = False
|
||||
|
||||
@@ -135,7 +158,22 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._padded_width = text_width + padding[1] + padding[3]
|
||||
self._padded_height = self._style.font_size + padding[0] + padding[2]
|
||||
|
||||
# Size the button from the text's visual height (ascent + descent), not
|
||||
# from the nominal font size. The two differ by several pixels - DejaVu at
|
||||
# 14px measures 17 - so sizing by font_size leaves the button too short to
|
||||
# centre its own label in.
|
||||
self._text_height = self._visual_text_height()
|
||||
self._padded_height = self._text_height + padding[0] + padding[2]
|
||||
|
||||
def _visual_text_height(self) -> int:
|
||||
"""Height of the rendered text, ascender to descender."""
|
||||
try:
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
return int(ascent + descent)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Mock or unusual font object; the nominal size is the best guess.
|
||||
return int(getattr(self._style, 'font_size', 0) or 0)
|
||||
|
||||
@property
|
||||
def button(self) -> Button:
|
||||
@@ -150,10 +188,26 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set the pressed state"""
|
||||
self._pressed = pressed
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state"""
|
||||
self._hovered = hovered
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_page(self, page):
|
||||
"""
|
||||
Set the parent page reference for dirty flag management.
|
||||
|
||||
Args:
|
||||
page: The Page object containing this element
|
||||
"""
|
||||
self._page = page
|
||||
|
||||
def _mark_page_dirty(self):
|
||||
"""Mark the parent page as dirty if available"""
|
||||
if self._page and hasattr(self._page, 'mark_dirty'):
|
||||
self._page.mark_dirty()
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
@@ -203,11 +257,18 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
# Total button height minus top and bottom padding gives us text area height
|
||||
text_area_height = self._padded_height - self._padding[0] - self._padding[2]
|
||||
|
||||
# Center the text visual height (ascent + descent) within the text area
|
||||
# The y position is where the baseline sits
|
||||
# Visual center = area_height/2, baseline should be at center + descent/2
|
||||
vertical_center = text_area_height / 2
|
||||
text_y = self._origin[1] + self._padding[0] + vertical_center + (descent / 2)
|
||||
# Centre the text's visual height (ascent + descent) within the text area.
|
||||
# text_y is the baseline, since Text renders with anchor "ls".
|
||||
#
|
||||
# top of glyphs = area_top + (area_height - (ascent + descent)) / 2
|
||||
# baseline = top of glyphs + ascent
|
||||
#
|
||||
# The previous form, area_top + area_height/2 + descent/2, is only
|
||||
# equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
|
||||
# label rendered several pixels above centre, against the top edge.
|
||||
text_top = self._origin[1] + self._padding[0] \
|
||||
+ (text_area_height - (ascent + descent)) / 2
|
||||
text_y = text_top + ascent
|
||||
|
||||
# Temporarily set origin for text rendering
|
||||
original_origin = self._origin.copy()
|
||||
@@ -241,8 +302,17 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
"""
|
||||
A Text subclass that can handle FormField interactions.
|
||||
Renders form field labels and input areas.
|
||||
|
||||
The origin is the top-left of the whole control: label, then a gap, then the
|
||||
input box. Text itself draws from a baseline, so the label is offset down by
|
||||
its ascent when rendering; without that the glyphs would sit above the origin
|
||||
and overprint whatever is above, which for a stacked form is the previous
|
||||
field's input box.
|
||||
"""
|
||||
|
||||
# Vertical gap between the label and its input box, in pixels.
|
||||
LABEL_GAP = 5
|
||||
|
||||
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||
field_height: int = 24, source=None, line=None):
|
||||
"""
|
||||
@@ -268,8 +338,11 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
self._field_height = field_height
|
||||
self._focused = False
|
||||
|
||||
# Calculate total height (label + gap + field)
|
||||
self._total_height = self._style.font_size + 5 + field_height
|
||||
# Calculate total height (label + gap + field). The label's height is its
|
||||
# ink height, ascender to descender, not the nominal font size - the two
|
||||
# differ by several pixels and the gap between label and box is only 5.
|
||||
self._label_height = self._visual_label_height()
|
||||
self._total_height = self._label_height + self.LABEL_GAP + field_height
|
||||
|
||||
# Field width should be at least as wide as the label
|
||||
# Use getattr to handle mock objects in tests
|
||||
@@ -278,6 +351,20 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
self._width, '__call__') else 0
|
||||
self._field_width = max(text_width, 150)
|
||||
|
||||
def _visual_label_height(self) -> int:
|
||||
"""Height of the rendered label, ascender to descender."""
|
||||
try:
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
return int(ascent + descent)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Mock or unusual font object; the nominal size is the best guess.
|
||||
return int(getattr(self._style, 'font_size', 0) or 0)
|
||||
|
||||
@property
|
||||
def field_area_offset(self) -> int:
|
||||
"""Distance from this control's origin to the top of its input box."""
|
||||
return self._label_height + self.LABEL_GAP
|
||||
|
||||
@property
|
||||
def field(self) -> FormField:
|
||||
"""Get the associated FormField object"""
|
||||
@@ -296,12 +383,21 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
"""
|
||||
Render the form field with label and input area.
|
||||
"""
|
||||
# Render the label
|
||||
super().render()
|
||||
# Render the label. Text draws from the baseline, so shift down by the
|
||||
# ascent to make the origin the top of the label rather than its baseline.
|
||||
try:
|
||||
label_ascent = self._style.font.getmetrics()[0]
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
label_ascent = self._label_height
|
||||
|
||||
# Calculate field position (below label with 5px gap)
|
||||
label_origin = self._origin
|
||||
self._origin = np.array([label_origin[0], label_origin[1] + label_ascent])
|
||||
super().render()
|
||||
self._origin = label_origin
|
||||
|
||||
# Calculate field position (below the label, with the standard gap)
|
||||
field_x = self._origin[0]
|
||||
field_y = self._origin[1] + self._style.font_size + 5
|
||||
field_y = self._origin[1] + self.field_area_offset
|
||||
|
||||
# Draw field background and border
|
||||
bg_color = (255, 255, 255)
|
||||
@@ -326,11 +422,12 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
# Get font metrics to properly center the baseline
|
||||
ascent, descent = value_font.font.getmetrics()
|
||||
|
||||
# Center the text vertically within the field
|
||||
# The y coordinate is where the baseline sits (anchor="ls")
|
||||
vertical_center = self._field_height / 2
|
||||
# Centre the value within the input box. As in ButtonText, the
|
||||
# baseline sits at the top of the glyphs plus the ascent; centring on
|
||||
# half the box height plus half the descent only works for a 2:1
|
||||
# ascent/descent ratio and otherwise rides high.
|
||||
value_x = field_x + 5
|
||||
value_y = field_y + vertical_center + (descent / 2)
|
||||
value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
|
||||
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
@@ -347,7 +444,7 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
True if the field was clicked and focused
|
||||
"""
|
||||
# Calculate field area
|
||||
field_y = self._style.font_size + 5
|
||||
field_y = self.field_area_offset
|
||||
|
||||
# Check if click is within the input field area (not just the label)
|
||||
if (0 <= point[0] <= self._field_width and
|
||||
|
||||
@@ -53,6 +53,9 @@ class RenderableImage(Renderable, Queriable):
|
||||
if size[0] is None or size[1] is None:
|
||||
size = (100, 100) # Default size when image dimensions are unavailable
|
||||
|
||||
# Ensure dimensions are positive (can be negative if calculated from insufficient space)
|
||||
size = (max(1, size[0]), max(1, size[1]))
|
||||
|
||||
# Set size as numpy array
|
||||
self._size = np.array(size)
|
||||
|
||||
@@ -103,8 +106,7 @@ class RenderableImage(Renderable, Queriable):
|
||||
self._pil_image = PILImage.open(BytesIO(response.content))
|
||||
self._abstract_image._loaded_image = self._pil_image
|
||||
else:
|
||||
self._error_message = f"Failed to load image: HTTP status {
|
||||
response.status_code}"
|
||||
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
|
||||
except ImportError:
|
||||
self._error_message = "Requests library not available for URL loading"
|
||||
else:
|
||||
@@ -173,6 +175,10 @@ class RenderableImage(Renderable, Queriable):
|
||||
# Get the target dimensions
|
||||
target_width, target_height = self._size
|
||||
|
||||
# Ensure target dimensions are positive
|
||||
target_width = max(1, int(target_width))
|
||||
target_height = max(1, int(target_height))
|
||||
|
||||
# Get the original dimensions
|
||||
orig_width, orig_height = self._pil_image.size
|
||||
|
||||
@@ -184,8 +190,8 @@ class RenderableImage(Renderable, Queriable):
|
||||
ratio = min(width_ratio, height_ratio)
|
||||
|
||||
# Calculate new dimensions
|
||||
new_width = int(orig_width * ratio)
|
||||
new_height = int(orig_height * ratio)
|
||||
new_width = max(1, int(orig_width * ratio))
|
||||
new_height = max(1, int(orig_height * ratio))
|
||||
|
||||
# Resize the image
|
||||
if self._pil_image.mode == 'RGBA':
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Interaction handler for managing button/link press-release lifecycle with visual feedback.
|
||||
|
||||
This module provides utilities for handling interactive element states and rendering
|
||||
frames at different stages of interaction (pressed, released).
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple, Callable, Any
|
||||
from PIL import Image
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
|
||||
class InteractionHandler:
|
||||
"""
|
||||
Manages the press-release lifecycle for interactive elements.
|
||||
|
||||
This class handles the timing and state management needed to show
|
||||
visual feedback when buttons or links are clicked. It can generate
|
||||
multiple rendered frames showing the pressed and released states.
|
||||
|
||||
Usage patterns:
|
||||
|
||||
Pattern A - Simple one-shot with automatic frames:
|
||||
handler = InteractionHandler(page)
|
||||
frames = handler.execute_with_feedback(button_element, point)
|
||||
# Returns: [pressed_frame, released_frame]
|
||||
# Show frames in sequence with brief delay
|
||||
|
||||
Pattern B - Manual state management for custom event loops:
|
||||
handler = InteractionHandler(page)
|
||||
handler.set_pressed_state(button_element, True)
|
||||
pressed_frame = handler.render_current_state()
|
||||
# ... show frame, wait, execute action ...
|
||||
handler.set_pressed_state(button_element, False)
|
||||
released_frame = handler.render_current_state()
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page, press_duration_ms: int = 150):
|
||||
"""
|
||||
Initialize the interaction handler.
|
||||
|
||||
Args:
|
||||
page: The Page object containing the interactive elements
|
||||
press_duration_ms: How long to show the pressed state (default: 150ms)
|
||||
"""
|
||||
self._page = page
|
||||
self._press_duration_ms = press_duration_ms
|
||||
|
||||
def set_pressed_state(self, element, pressed: bool):
|
||||
"""
|
||||
Set the pressed state of an interactive element.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
pressed: True to show pressed, False to show released
|
||||
"""
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
# Ensure element has page reference for dirty flag
|
||||
if not hasattr(element, '_page') or element._page is None:
|
||||
element.set_page(self._page)
|
||||
element.set_pressed(pressed)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Element must be LinkText or ButtonText, got {type(element)}")
|
||||
|
||||
def set_hovered_state(self, element, hovered: bool):
|
||||
"""
|
||||
Set the hovered state of an interactive element.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
hovered: True to show hovered, False for normal
|
||||
"""
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
# Ensure element has page reference for dirty flag
|
||||
if not hasattr(element, '_page') or element._page is None:
|
||||
element.set_page(self._page)
|
||||
element.set_hovered(hovered)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Element must be LinkText or ButtonText, got {type(element)}")
|
||||
|
||||
def render_current_state(self) -> Image.Image:
|
||||
"""
|
||||
Render the page with current element states.
|
||||
|
||||
Returns:
|
||||
PIL Image of the rendered page
|
||||
"""
|
||||
return self._page.render()
|
||||
|
||||
def execute_with_feedback(
|
||||
self,
|
||||
element,
|
||||
point: Optional[np.ndarray] = None,
|
||||
callback: Optional[Callable] = None) -> Tuple[Image.Image, Image.Image, Any]:
|
||||
"""
|
||||
Execute an interaction with visual feedback at each stage.
|
||||
|
||||
This is the high-level "all-in-one" method that:
|
||||
1. Sets pressed state and renders
|
||||
2. Waits for press_duration_ms
|
||||
3. Executes the element's callback (or provided callback)
|
||||
4. Sets released state and renders
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
point: Optional point where interaction occurred
|
||||
callback: Optional custom callback (overrides element's callback)
|
||||
|
||||
Returns:
|
||||
Tuple of (pressed_frame, released_frame, callback_result)
|
||||
"""
|
||||
# Step 1: Render pressed state
|
||||
self.set_pressed_state(element, True)
|
||||
pressed_frame = self.render_current_state()
|
||||
|
||||
# Step 2: Wait for visual feedback duration
|
||||
time.sleep(self._press_duration_ms / 1000.0)
|
||||
|
||||
# Step 3: Execute callback
|
||||
callback_result = None
|
||||
if callback:
|
||||
callback_result = callback(point) if point is not None else callback()
|
||||
elif hasattr(element, 'interact'):
|
||||
callback_result = element.interact(point)
|
||||
|
||||
# Step 4: Render released state
|
||||
self.set_pressed_state(element, False)
|
||||
released_frame = self.render_current_state()
|
||||
|
||||
return pressed_frame, released_frame, callback_result
|
||||
|
||||
def execute_async_with_feedback(
|
||||
self,
|
||||
element,
|
||||
point: Optional[np.ndarray] = None) -> Tuple[Image.Image, Callable, Image.Image]:
|
||||
"""
|
||||
Execute an interaction with visual feedback, returning frames immediately
|
||||
without blocking.
|
||||
|
||||
This method returns the frames and a callback to execute later, allowing
|
||||
the caller to control when the action actually happens.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
point: Optional point where interaction occurred
|
||||
|
||||
Returns:
|
||||
Tuple of (pressed_frame, execute_callback, released_frame)
|
||||
where execute_callback is a function that will execute the interaction
|
||||
"""
|
||||
# Render pressed state
|
||||
self.set_pressed_state(element, True)
|
||||
pressed_frame = self.render_current_state()
|
||||
|
||||
# Create callback that will execute the interaction and reset state
|
||||
def execute_callback():
|
||||
result = None
|
||||
if hasattr(element, 'interact'):
|
||||
result = element.interact(point)
|
||||
self.set_pressed_state(element, False)
|
||||
return result
|
||||
|
||||
# Pre-render the released state (element state is still pressed)
|
||||
# We'll return this frame but the caller controls when to show it
|
||||
self.set_pressed_state(element, False)
|
||||
released_frame = self.render_current_state()
|
||||
|
||||
# Reset back to pressed for consistency
|
||||
# (caller will call execute_callback which sets to False)
|
||||
self.set_pressed_state(element, True)
|
||||
|
||||
return pressed_frame, execute_callback, released_frame
|
||||
|
||||
|
||||
class InteractionStateManager:
|
||||
"""
|
||||
Manages interaction states for multiple elements on a page.
|
||||
|
||||
Useful for applications that need to track hover/press states
|
||||
across many interactive elements simultaneously.
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
"""
|
||||
Initialize the state manager.
|
||||
|
||||
Args:
|
||||
page: The Page object containing interactive elements
|
||||
"""
|
||||
self._page = page
|
||||
self._hovered_element = None
|
||||
self._pressed_element = None
|
||||
|
||||
def update_hover(self, point: Tuple[int, int]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Update hover state based on cursor position.
|
||||
|
||||
Queries the page to find what's under the cursor and updates
|
||||
hover states accordingly.
|
||||
|
||||
Args:
|
||||
point: Cursor position (x, y)
|
||||
|
||||
Returns:
|
||||
New rendered frame if hover state changed, None otherwise
|
||||
"""
|
||||
# Query what's at this point
|
||||
result = self._page.query_point(point)
|
||||
|
||||
if not result or not result.is_interactive:
|
||||
# Nothing interactive under cursor
|
||||
if self._hovered_element:
|
||||
# Clear previous hover
|
||||
if isinstance(self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
self._hovered_element = None
|
||||
return self._page.render()
|
||||
return None
|
||||
|
||||
# Something interactive is under cursor
|
||||
element = result.object
|
||||
if element != self._hovered_element:
|
||||
# Hover changed
|
||||
# Clear old hover
|
||||
if self._hovered_element and isinstance(
|
||||
self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
|
||||
# Set new hover
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
element.set_hovered(True)
|
||||
|
||||
self._hovered_element = element
|
||||
return self._page.render()
|
||||
|
||||
return None
|
||||
|
||||
def handle_mouse_down(self, point: Tuple[int, int]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Handle mouse button press at a point.
|
||||
|
||||
Args:
|
||||
point: Click position (x, y)
|
||||
|
||||
Returns:
|
||||
New rendered frame showing pressed state, or None if nothing interactive
|
||||
"""
|
||||
result = self._page.query_point(point)
|
||||
|
||||
if not result or not result.is_interactive:
|
||||
return None
|
||||
|
||||
element = result.object
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
element.set_pressed(True)
|
||||
self._pressed_element = element
|
||||
return self._page.render()
|
||||
|
||||
return None
|
||||
|
||||
def handle_mouse_up(
|
||||
self,
|
||||
point: Tuple[int,
|
||||
int]) -> Tuple[Optional[Image.Image],
|
||||
Any]:
|
||||
"""
|
||||
Handle mouse button release at a point.
|
||||
|
||||
Args:
|
||||
point: Release position (x, y)
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_frame, callback_result)
|
||||
Frame shows released state, result is from executing the callback
|
||||
"""
|
||||
if not self._pressed_element:
|
||||
return None, None
|
||||
|
||||
# Execute the interaction
|
||||
callback_result = None
|
||||
if hasattr(self._pressed_element, 'interact'):
|
||||
callback_result = self._pressed_element.interact(
|
||||
np.array(point))
|
||||
|
||||
# Release the pressed state
|
||||
if isinstance(self._pressed_element, (LinkText, ButtonText)):
|
||||
self._pressed_element.set_pressed(False)
|
||||
|
||||
self._pressed_element = None
|
||||
|
||||
return self._page.render(), callback_result
|
||||
|
||||
def reset(self):
|
||||
"""Reset all interaction states."""
|
||||
if self._hovered_element and isinstance(
|
||||
self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
|
||||
if self._pressed_element and isinstance(
|
||||
self._pressed_element, (LinkText, ButtonText)):
|
||||
self._pressed_element.set_pressed(False)
|
||||
|
||||
self._hovered_element = None
|
||||
self._pressed_element = None
|
||||
@@ -15,30 +15,46 @@ class Page(Renderable, Queriable):
|
||||
contains a given point.
|
||||
"""
|
||||
|
||||
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
|
||||
# Mode of the render canvas. The measurement context matches it so that text
|
||||
# width caching keys stay consistent between layout and rendering.
|
||||
_CANVAS_MODE = 'RGBA'
|
||||
|
||||
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None,
|
||||
origin: Tuple[int, int] = (0, 0)):
|
||||
"""
|
||||
Initialize a new page.
|
||||
|
||||
Args:
|
||||
size: The total size of the page (width, height) including borders
|
||||
style: The PageStyle defining borders, spacing, and appearance
|
||||
origin: Absolute position of the page's top-left corner. Non-zero for
|
||||
a page nested inside another surface, such as a table cell.
|
||||
"""
|
||||
self._size = size
|
||||
self._origin = origin
|
||||
self._style = style if style is not None else PageStyle()
|
||||
self._children: List[Renderable] = []
|
||||
self._canvas: Optional[Image.Image] = None
|
||||
self._draw: Optional[ImageDraw.Draw] = None
|
||||
self._measurement_draw: Optional[ImageDraw.ImageDraw] = None
|
||||
# Initialize y_offset to start of content area
|
||||
# Position the first line so its baseline is close to the top boundary
|
||||
# For subsequent lines, baseline-to-baseline spacing is used
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
self._current_y_offset = (self._origin[1] + self._style.border_width
|
||||
+ self._style.padding_top)
|
||||
self._is_first_line = True # Track if we're placing the first line
|
||||
# Callback registry for managing interactable elements
|
||||
self._callbacks = CallbackRegistry()
|
||||
# Dirty flag to track if page needs re-rendering due to state changes
|
||||
self._dirty = True
|
||||
|
||||
def free_space(self) -> Tuple[int, int]:
|
||||
"""Get the remaining space on the page"""
|
||||
return (self._size[0], self._size[1] - self._current_y_offset)
|
||||
"""
|
||||
Get the remaining space in the content area.
|
||||
|
||||
Deprecated: use content_rect and remaining_height, which this delegates to.
|
||||
"""
|
||||
return (self.content_rect[2], self.remaining_height)
|
||||
|
||||
def can_fit_line(
|
||||
self,
|
||||
@@ -57,7 +73,8 @@ class Page(Renderable, Queriable):
|
||||
True if the line fits within page boundaries
|
||||
"""
|
||||
# Calculate the maximum Y position allowed (bottom boundary)
|
||||
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
|
||||
content_y, content_h = self.content_rect[1], self.content_rect[3]
|
||||
max_y = content_y + content_h
|
||||
|
||||
# If ascent/descent not provided, use simple check (backward compatibility)
|
||||
if ascent == 0 and descent == 0:
|
||||
@@ -75,6 +92,34 @@ class Page(Renderable, Queriable):
|
||||
"""Get the total page size including borders"""
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def origin(self) -> Tuple[int, int]:
|
||||
"""Absolute position of the page's top-left corner"""
|
||||
return self._origin
|
||||
|
||||
@property
|
||||
def content_origin(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Absolute top-left of the content box: the page origin plus its border and
|
||||
top/left padding. Layout starts here.
|
||||
"""
|
||||
return (
|
||||
self._origin[0] + self._style.border_width + self._style.padding_left,
|
||||
self._origin[1] + self._style.border_width + self._style.padding_top,
|
||||
)
|
||||
|
||||
@property
|
||||
def content_rect(self) -> Tuple[int, int, int, int]:
|
||||
"""(x, y, width, height) of the content box, in absolute coordinates"""
|
||||
x, y = self.content_origin
|
||||
return (x, y, self.content_size[0], self.content_size[1])
|
||||
|
||||
@property
|
||||
def remaining_height(self) -> int:
|
||||
"""Content-box height still available below the current layout cursor"""
|
||||
_, y, _, h = self.content_rect
|
||||
return max(0, y + h - self._current_y_offset)
|
||||
|
||||
@property
|
||||
def canvas_size(self) -> Tuple[int, int]:
|
||||
"""Get the canvas size (page size minus borders)"""
|
||||
@@ -113,15 +158,53 @@ class Page(Renderable, Queriable):
|
||||
"""Get the callback registry for managing interactable elements"""
|
||||
return self._callbacks
|
||||
|
||||
@property
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if the page needs re-rendering due to state changes"""
|
||||
return self._dirty
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Mark the page as needing re-rendering"""
|
||||
self._dirty = True
|
||||
|
||||
def mark_clean(self):
|
||||
"""Mark the page as clean (up-to-date render)"""
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
||||
"""Get the ImageDraw object for drawing on this page's canvas"""
|
||||
if self._draw is None:
|
||||
"""
|
||||
Get the ImageDraw object bound to this page's render canvas.
|
||||
|
||||
Rebuilt whenever the canvas has been invalidated: a draw context
|
||||
outlives the image it was created from, so checking only _draw would
|
||||
hand back a context pointing at a discarded canvas.
|
||||
"""
|
||||
if self._draw is None or self._canvas is None:
|
||||
# Initialize canvas and draw context if not already done
|
||||
self._canvas = self._create_canvas()
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
return self._draw
|
||||
|
||||
@property
|
||||
def measurement_draw(self) -> ImageDraw.ImageDraw:
|
||||
"""
|
||||
A scratch draw context for text metrics during layout.
|
||||
|
||||
Layout asks for text widths constantly, but has no reason to touch the
|
||||
render canvas - and the canvas is invalidated on every add_child, so
|
||||
measuring through `draw` would allocate a full-page image per line.
|
||||
This context is 1x1 and never invalidated.
|
||||
|
||||
Its mode matches the render canvas because Text keys its width cache on
|
||||
the draw mode; a mismatch would double every cache entry. Children built
|
||||
against it are re-bound to the real canvas by render_children.
|
||||
"""
|
||||
if self._measurement_draw is None:
|
||||
scratch = Image.new(self._CANVAS_MODE, (1, 1))
|
||||
self._measurement_draw = ImageDraw.Draw(scratch)
|
||||
return self._measurement_draw
|
||||
|
||||
def add_child(self, child: Renderable) -> 'Page':
|
||||
"""
|
||||
Add a child renderable object to this page.
|
||||
@@ -167,7 +250,7 @@ class Page(Renderable, Queriable):
|
||||
# Clear callback registry when clearing children
|
||||
self._callbacks.clear()
|
||||
# Reset y_offset to start of content area (after border and padding)
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
self._current_y_offset = self.content_origin[1]
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -175,34 +258,6 @@ class Page(Renderable, Queriable):
|
||||
"""Get a copy of the children list"""
|
||||
return self._children.copy()
|
||||
|
||||
def _get_child_height(self, child: Renderable) -> int:
|
||||
"""
|
||||
Get the height of a child object.
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return int(child._size[1])
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return int(child.size[1])
|
||||
|
||||
if hasattr(child, 'height'):
|
||||
return int(child.height)
|
||||
|
||||
# Default fallback height
|
||||
return 20
|
||||
|
||||
def render_children(self):
|
||||
"""
|
||||
Call render on all children in the list.
|
||||
@@ -232,6 +287,9 @@ class Page(Renderable, Queriable):
|
||||
# Render all children - they draw directly onto the canvas
|
||||
self.render_children()
|
||||
|
||||
# Mark as clean after rendering
|
||||
self._dirty = False
|
||||
|
||||
return self._canvas
|
||||
|
||||
def _create_canvas(self) -> Image.Image:
|
||||
@@ -242,7 +300,7 @@ class Page(Renderable, Queriable):
|
||||
PIL Image with background and borders applied
|
||||
"""
|
||||
# Create base image
|
||||
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
|
||||
canvas = Image.new(self._CANVAS_MODE, self._size, (*self._style.background_color, 255))
|
||||
|
||||
# Draw borders if needed
|
||||
if self._style.border_width > 0:
|
||||
@@ -258,30 +316,6 @@ class Page(Renderable, Queriable):
|
||||
|
||||
return canvas
|
||||
|
||||
def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
|
||||
"""
|
||||
Get the position where a child should be rendered.
|
||||
|
||||
Args:
|
||||
child: The child object
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y) coordinates
|
||||
"""
|
||||
if hasattr(child, '_origin') and child._origin is not None:
|
||||
if isinstance(child._origin, np.ndarray):
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
elif isinstance(child._origin, (list, tuple)) and len(child._origin) >= 2:
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
|
||||
if hasattr(child, 'position'):
|
||||
pos = child.position
|
||||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
return (int(pos[0]), int(pos[1]))
|
||||
|
||||
# Default to origin
|
||||
return (0, 0)
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
|
||||
"""
|
||||
Query a point to find the deepest object at that location.
|
||||
@@ -318,64 +352,6 @@ class Page(Renderable, Queriable):
|
||||
bounds=(int(point[0]), int(point[1]), 0, 0)
|
||||
)
|
||||
|
||||
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
|
||||
"""
|
||||
Check if a point is within a child's bounds.
|
||||
|
||||
Args:
|
||||
point: The point to check
|
||||
child: The child to check against
|
||||
|
||||
Returns:
|
||||
True if the point is within the child's bounds
|
||||
"""
|
||||
# If child implements Queriable interface, use it
|
||||
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
|
||||
try:
|
||||
return child.in_object(point)
|
||||
except BaseException:
|
||||
pass # Fall back to bounds checking
|
||||
|
||||
# Get child position and size for bounds checking
|
||||
child_pos = self._get_child_position(child)
|
||||
child_size = self._get_child_size(child)
|
||||
|
||||
if child_size is None:
|
||||
return False
|
||||
|
||||
# Check if point is within child bounds
|
||||
return (
|
||||
child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
|
||||
child_pos[1] <= point[1] < child_pos[1] + child_size[1]
|
||||
)
|
||||
|
||||
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Get the size of a child object.
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) or None if size cannot be determined
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return (int(child._size[0]), int(child._size[1]))
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return (int(child.size[0]), int(child.size[1]))
|
||||
|
||||
if hasattr(child, 'width') and hasattr(child, 'height'):
|
||||
return (int(child.width), int(child.height))
|
||||
|
||||
return None
|
||||
|
||||
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
|
||||
"""
|
||||
Package an object into a QueryResult with metadata.
|
||||
@@ -486,6 +462,6 @@ class Page(Renderable, Queriable):
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
return (
|
||||
0 <= point[0] < self._size[0] and
|
||||
0 <= point[1] < self._size[1]
|
||||
self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
|
||||
self._origin[1] <= point[1] < self._origin[1] + self._size[1]
|
||||
)
|
||||
|
||||
@@ -108,21 +108,34 @@ class TableCellRenderer(Box):
|
||||
return None # Cell rendering is done directly on the page
|
||||
|
||||
def _render_cell_content(self, x: int, y: int, width: int, height: int):
|
||||
"""Render the content inside the cell (text and images)."""
|
||||
from PIL import ImageFont
|
||||
"""Render the content inside the cell (text and images) with line wrapping."""
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style import FontWeight, Alignment
|
||||
|
||||
current_y = y + 2
|
||||
available_height = height - 4 # Account for top/bottom padding
|
||||
|
||||
# Get font
|
||||
try:
|
||||
if self._is_header_section and self._style.header_text_bold:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
|
||||
else:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
# Create font for the cell
|
||||
font_size = 12
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
if self._is_header_section and self._style.header_text_bold:
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
font = Font(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
weight=FontWeight.BOLD if self._is_header_section and self._style.header_text_bold else FontWeight.NORMAL
|
||||
)
|
||||
|
||||
# Word spacing constraints (min, max)
|
||||
min_spacing = int(font_size * 0.25)
|
||||
max_spacing = int(font_size * 0.5)
|
||||
word_spacing = (min_spacing, max_spacing)
|
||||
|
||||
# Line height (baseline spacing)
|
||||
line_height = font_size + 4
|
||||
ascent, descent = font.font.getmetrics()
|
||||
|
||||
# Render each block in the cell
|
||||
for block in self._cell.blocks():
|
||||
@@ -131,38 +144,102 @@ class TableCellRenderer(Box):
|
||||
current_y = self._render_image_in_cell(
|
||||
block, x, current_y, width, height - (current_y - y))
|
||||
elif isinstance(block, (Paragraph, Heading)):
|
||||
# Extract and render text
|
||||
words = []
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
for word in word_items:
|
||||
if hasattr(word, 'text'):
|
||||
words.append(word.text)
|
||||
elif isinstance(word, tuple) and len(word) >= 2:
|
||||
word_obj = word[1]
|
||||
if hasattr(word_obj, 'text'):
|
||||
words.append(word_obj.text)
|
||||
# Get words from the block
|
||||
from pyWebLayout.abstract.inline import Word as AbstractWord
|
||||
|
||||
if words:
|
||||
text = " ".join(words)
|
||||
if current_y <= y + height - 15:
|
||||
self._draw.text((x + 2, current_y), text,
|
||||
fill=(0, 0, 0), font=font)
|
||||
current_y += 16
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Create new Word objects with the table cell's font
|
||||
# The words from the paragraph may have AbstractStyle, but we need Font objects
|
||||
wrapped_words = []
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Create a new Word with the cell's Font
|
||||
new_word = AbstractWord(word_text, font)
|
||||
wrapped_words.append(new_word)
|
||||
|
||||
# Layout words using Line objects with wrapping
|
||||
word_index = 0
|
||||
pretext = None
|
||||
|
||||
while word_index < len(wrapped_words):
|
||||
# Check if we have space for another line
|
||||
if current_y + ascent + descent > y + available_height:
|
||||
break # No more space in cell
|
||||
|
||||
# Create a new line
|
||||
line = Line(
|
||||
spacing=word_spacing,
|
||||
origin=(x + 2, current_y),
|
||||
size=(width - 4, line_height),
|
||||
draw=self._draw,
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add words to this line until it's full
|
||||
line_has_content = False
|
||||
while word_index < len(wrapped_words):
|
||||
word = wrapped_words[word_index]
|
||||
|
||||
# Try to add word to line
|
||||
success, overflow = line.add_word(word, pretext)
|
||||
pretext = None # Clear pretext after use
|
||||
|
||||
if success:
|
||||
line_has_content = True
|
||||
if overflow:
|
||||
# Word was hyphenated, carry over to next line
|
||||
# DON'T increment word_index - we need to add the overflow
|
||||
# to the next line with the same word
|
||||
pretext = overflow
|
||||
break # Move to next line
|
||||
else:
|
||||
# Word fit completely, move to next word
|
||||
word_index += 1
|
||||
else:
|
||||
# Word doesn't fit on this line
|
||||
if not line_has_content:
|
||||
# Even first word doesn't fit, force it anyway and advance
|
||||
# This prevents infinite loops with words that truly can't fit
|
||||
word_index += 1
|
||||
break
|
||||
|
||||
# Render the line if it has content
|
||||
if line_has_content or len(line.text_objects) > 0:
|
||||
line.render()
|
||||
current_y += line_height
|
||||
|
||||
if current_y > y + height - 10: # Don't overflow cell
|
||||
break
|
||||
|
||||
# If no structured content, try to get any text representation
|
||||
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
|
||||
# Use simple text rendering for fallback case
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
pil_font = ImageFont.truetype(font_path, font_size)
|
||||
except BaseException:
|
||||
pil_font = ImageFont.load_default()
|
||||
|
||||
self._draw.text(
|
||||
(x + 2,
|
||||
current_y),
|
||||
(x + 2, current_y),
|
||||
self._cell._text_content,
|
||||
fill=(
|
||||
0,
|
||||
0,
|
||||
0),
|
||||
font=font)
|
||||
fill=(0, 0, 0),
|
||||
font=pil_font
|
||||
)
|
||||
|
||||
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
||||
max_width: int, max_height: int) -> int:
|
||||
@@ -375,43 +452,41 @@ class TableRenderer(Box):
|
||||
"""
|
||||
Calculate column widths and row heights for the table.
|
||||
|
||||
Uses the table optimizer for intelligent column width distribution.
|
||||
|
||||
Returns:
|
||||
Tuple of (column_widths, row_heights_dict)
|
||||
"""
|
||||
# Determine number of columns (from first row)
|
||||
num_columns = 0
|
||||
all_rows = list(self._table.all_rows())
|
||||
if all_rows:
|
||||
first_row = all_rows[0][1]
|
||||
num_columns = first_row.cell_count
|
||||
from pyWebLayout.layout.table_optimizer import optimize_table_layout
|
||||
|
||||
if num_columns == 0:
|
||||
all_rows = list(self._table.all_rows())
|
||||
|
||||
if not all_rows:
|
||||
return ([100], {"header": 30, "body": 30, "footer": 30})
|
||||
|
||||
# Calculate column widths (equal distribution for now)
|
||||
# Account for borders between columns
|
||||
total_border_width = self._style.border_width * (num_columns + 1)
|
||||
available_for_columns = self._available_width - total_border_width
|
||||
column_width = max(50, available_for_columns // num_columns)
|
||||
column_widths = [column_width] * num_columns
|
||||
# Use optimizer for column widths!
|
||||
column_widths = optimize_table_layout(
|
||||
self._table,
|
||||
self._available_width,
|
||||
sample_size=5,
|
||||
style=self._style
|
||||
)
|
||||
|
||||
# Calculate row heights
|
||||
header_height = 35 if any(1 for section,
|
||||
_ in all_rows if section == "header") else 0
|
||||
if not column_widths:
|
||||
# Fallback if table is empty
|
||||
column_widths = [100]
|
||||
|
||||
# Check if any body rows contain images - if so, use larger height
|
||||
body_height = 30
|
||||
for section, row in all_rows:
|
||||
if section == "body":
|
||||
for cell in row.cells():
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Use larger height for rows with images
|
||||
body_height = max(body_height, 120)
|
||||
break
|
||||
# Calculate row heights dynamically based on optimized column widths
|
||||
header_height = self._calculate_row_height_for_section(
|
||||
all_rows, "header", column_widths) if any(
|
||||
1 for section, _ in all_rows if section == "header") else 0
|
||||
|
||||
footer_height = 30 if any(1 for section,
|
||||
_ in all_rows if section == "footer") else 0
|
||||
body_height = self._calculate_row_height_for_section(
|
||||
all_rows, "body", column_widths)
|
||||
|
||||
footer_height = self._calculate_row_height_for_section(
|
||||
all_rows, "footer", column_widths) if any(
|
||||
1 for section, _ in all_rows if section == "footer") else 0
|
||||
|
||||
row_heights = {
|
||||
"header": header_height,
|
||||
@@ -421,6 +496,148 @@ class TableRenderer(Box):
|
||||
|
||||
return (column_widths, row_heights)
|
||||
|
||||
def _calculate_row_height_for_section(
|
||||
self,
|
||||
all_rows: List,
|
||||
section: str,
|
||||
column_widths: List[int]) -> int:
|
||||
"""
|
||||
Calculate the maximum required height for rows in a specific section.
|
||||
|
||||
Args:
|
||||
all_rows: List of all rows in the table
|
||||
section: Section name ('header', 'body', or 'footer')
|
||||
column_widths: List of column widths
|
||||
|
||||
Returns:
|
||||
Maximum height needed for rows in this section
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.inline import Word as AbstractWord
|
||||
|
||||
# Font configuration
|
||||
font_size = 12
|
||||
line_height = font_size + 4
|
||||
padding = self._style.cell_padding
|
||||
vertical_padding = padding[0] + padding[2] # top + bottom
|
||||
horizontal_padding = padding[1] + padding[3] # left + right
|
||||
|
||||
max_height = 40 # Minimum height
|
||||
|
||||
for row_section, row in all_rows:
|
||||
if row_section != section:
|
||||
continue
|
||||
|
||||
row_max_height = 40 # Minimum for this row
|
||||
|
||||
for cell_idx, cell in enumerate(row.cells()):
|
||||
if cell_idx >= len(column_widths):
|
||||
continue
|
||||
|
||||
# Get cell width (accounting for colspan)
|
||||
cell_width = column_widths[cell_idx]
|
||||
if cell.colspan > 1 and cell_idx + \
|
||||
cell.colspan <= len(column_widths):
|
||||
cell_width = sum(
|
||||
column_widths[cell_idx:cell_idx + cell.colspan])
|
||||
cell_width += self._style.border_width * (cell.colspan - 1)
|
||||
|
||||
# Calculate content width (minus padding)
|
||||
content_width = cell_width - horizontal_padding - 4 # Extra margin
|
||||
|
||||
cell_height = vertical_padding + 4 # Base height with padding
|
||||
|
||||
# Analyze each block in the cell
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Images need more space
|
||||
cell_height = max(cell_height, 120)
|
||||
elif isinstance(block, (Paragraph, Heading)):
|
||||
# Calculate text wrapping height
|
||||
word_items = block.words() if callable(
|
||||
block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Simulate text wrapping to count lines
|
||||
lines_needed = self._estimate_wrapped_lines(
|
||||
words, content_width, font_size)
|
||||
text_height = lines_needed * line_height
|
||||
cell_height = max(
|
||||
cell_height, text_height + vertical_padding + 4)
|
||||
|
||||
row_max_height = max(row_max_height, cell_height)
|
||||
|
||||
max_height = max(max_height, row_max_height)
|
||||
|
||||
return max_height
|
||||
|
||||
def _estimate_wrapped_lines(
|
||||
self,
|
||||
words: List,
|
||||
available_width: int,
|
||||
font_size: int) -> int:
|
||||
"""
|
||||
Estimate how many lines are needed to render the given words.
|
||||
|
||||
Args:
|
||||
words: List of word objects
|
||||
available_width: Available width for text
|
||||
font_size: Font size in pixels
|
||||
|
||||
Returns:
|
||||
Number of lines needed
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
# Create a temporary font for measurement
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
font = Font(font_path=font_path, font_size=font_size)
|
||||
|
||||
# Word spacing (approximate)
|
||||
word_spacing = int(font_size * 0.25)
|
||||
|
||||
lines = 1
|
||||
current_line_width = 0
|
||||
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(
|
||||
word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Measure word width
|
||||
word_width = font.font.getlength(word_text)
|
||||
|
||||
# Check if word fits on current line
|
||||
if current_line_width > 0: # Not first word on line
|
||||
needed_width = current_line_width + word_spacing + word_width
|
||||
if needed_width > available_width:
|
||||
# Need new line
|
||||
lines += 1
|
||||
current_line_width = word_width
|
||||
else:
|
||||
current_line_width = needed_width
|
||||
else:
|
||||
# First word on line
|
||||
if word_width > available_width:
|
||||
# Word needs to be hyphenated, assume it takes 1 line
|
||||
lines += 1
|
||||
current_line_width = 0
|
||||
else:
|
||||
current_line_width = word_width
|
||||
|
||||
return lines
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
"""Render the complete table."""
|
||||
x, y = self._origin
|
||||
|
||||
@@ -6,11 +6,242 @@ from pyWebLayout.style import Alignment, Font, TextDecoration
|
||||
from pyWebLayout.abstract import Word
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import Link
|
||||
from PIL import ImageDraw
|
||||
from typing import Tuple, List, Optional
|
||||
from pyWebLayout.core.cache import UsageCache, SizedUsageCache
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from typing import Tuple, List, Optional, Any, Dict
|
||||
import logging
|
||||
import math
|
||||
import numpy as np
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text rendering caches
|
||||
#
|
||||
# A page re-measures and re-rasterises the same words constantly: measured over a
|
||||
# novel at 1404x1872, a page issues ~2800 width measurements and ~2500 glyph
|
||||
# rasterisations for fewer than 1000 distinct (font, string) pairs. Caching both
|
||||
# turns a ~225ms page into a ~30ms page. Both caches are bounded so that a long
|
||||
# reading session cannot grow without limit on a memory-constrained device.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Word widths are small floats; 8192 entries costs well under 1MB and comfortably
|
||||
# spans the working set of several chapters at a couple of font sizes.
|
||||
DEFAULT_WIDTH_CACHE_ENTRIES = 8192
|
||||
|
||||
# Glyph bitmaps are the expensive ones: ~700 bytes each on average at 1404x1872,
|
||||
# so an unbounded cache reaches ~12MB after 40 pages. 4MB holds several pages'
|
||||
# worth of distinct words while leaving headroom on a 512MB Pi Zero 2.
|
||||
DEFAULT_GLYPH_CACHE_BYTES = 4 * 1024 * 1024
|
||||
|
||||
# PIL rasterises text at sub-pixel horizontal offsets, so a cache keyed only on
|
||||
# (font, string) would quantise every word to a whole pixel. Bucketing the
|
||||
# sub-pixel phase keeps that error negligible at the cost of more entries. 2 steps
|
||||
# holds the mean error to ~3.6/255 -- a fifth of one step of a 16-level e-ink
|
||||
# panel -- while keeping the cache four times smaller than 4 steps would.
|
||||
DEFAULT_GLYPH_SUBPIXEL_STEPS = 2
|
||||
|
||||
|
||||
def _glyph_entry_bytes(entry: Tuple[Any, Tuple[int, int]]) -> int:
|
||||
"""Approximate footprint of a cached (mask, offset) pair, in bytes."""
|
||||
mask = entry[0]
|
||||
try:
|
||||
width, height = mask.size
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return 0
|
||||
return width * height
|
||||
|
||||
|
||||
_width_cache: UsageCache = UsageCache(DEFAULT_WIDTH_CACHE_ENTRIES)
|
||||
_glyph_cache: SizedUsageCache = SizedUsageCache(DEFAULT_GLYPH_CACHE_BYTES, _glyph_entry_bytes)
|
||||
_glyph_subpixel_steps: int = DEFAULT_GLYPH_SUBPIXEL_STEPS
|
||||
|
||||
# Every Line asks its font for the advance width of a space. That single
|
||||
# FreeTypeFont.getlength(" ") call costs ~18us -- two orders of magnitude more
|
||||
# than getmetrics() -- because PIL shapes the string from scratch each time, and
|
||||
# it lands once per line created, which dominates the cost of laying a line out.
|
||||
# There are only ever a handful of distinct fonts in play, so memoise per font
|
||||
# object. Values are wrapped in a 1-tuple because None is itself a legitimate
|
||||
# result (fonts that cannot report a length) and must not read as a cache miss.
|
||||
_space_advance_cache: Dict[Any, Tuple[Optional[int]]] = {}
|
||||
|
||||
# Set to False the first time the fast rasterisation path is found to be
|
||||
# unavailable (e.g. a PIL build without the private ImageDraw internals it uses),
|
||||
# after which every Text falls back to ImageDraw.text().
|
||||
_glyph_fast_path_available: bool = True
|
||||
|
||||
|
||||
def configure_text_caches(width_entries: Optional[int] = None,
|
||||
glyph_bytes: Optional[int] = None,
|
||||
subpixel_steps: Optional[int] = None):
|
||||
"""
|
||||
Tune the text rendering caches.
|
||||
|
||||
Memory-constrained targets should shrink these; a desktop rendering many font
|
||||
sizes may benefit from raising them.
|
||||
|
||||
Args:
|
||||
width_entries: Maximum cached word-width measurements.
|
||||
glyph_bytes: Maximum total size of cached glyph bitmaps, in bytes.
|
||||
subpixel_steps: Sub-pixel phase buckets per axis. 1 disables sub-pixel
|
||||
positioning entirely (smallest cache, slightly softer text).
|
||||
"""
|
||||
global _glyph_subpixel_steps
|
||||
|
||||
if width_entries is not None:
|
||||
_width_cache.resize(width_entries)
|
||||
if glyph_bytes is not None:
|
||||
_glyph_cache.resize(glyph_bytes)
|
||||
if subpixel_steps is not None:
|
||||
if subpixel_steps <= 0:
|
||||
raise ValueError(f"subpixel_steps must be positive, got {subpixel_steps}")
|
||||
if subpixel_steps != _glyph_subpixel_steps:
|
||||
# Cached entries embed the phase bucket in their key.
|
||||
_glyph_cache.clear()
|
||||
_glyph_subpixel_steps = subpixel_steps
|
||||
|
||||
|
||||
def clear_text_caches():
|
||||
"""Drop all cached widths and glyph bitmaps."""
|
||||
_width_cache.clear()
|
||||
_glyph_cache.clear()
|
||||
_space_advance_cache.clear()
|
||||
|
||||
|
||||
def _space_advance(font) -> Optional[int]:
|
||||
"""
|
||||
The font's own advance width for a space, in whole pixels.
|
||||
|
||||
None when the font cannot report one, which is the signal for callers to fall
|
||||
back to their configured spacing range.
|
||||
"""
|
||||
try:
|
||||
cached = _space_advance_cache.get(font)
|
||||
except TypeError:
|
||||
# Unhashable font object; measure without caching.
|
||||
cached = None
|
||||
else:
|
||||
if cached is not None:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
value = int(round(font.getlength(" ")))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
value = None
|
||||
|
||||
try:
|
||||
_space_advance_cache[font] = (value,)
|
||||
except TypeError:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def text_cache_stats() -> Dict[str, Any]:
|
||||
"""Occupancy and hit rates for both text caches, for tuning and diagnostics."""
|
||||
return {
|
||||
'width': _width_cache.stats(),
|
||||
'glyph': _glyph_cache.stats(),
|
||||
'glyph_subpixel_steps': _glyph_subpixel_steps,
|
||||
'glyph_fast_path': _glyph_fast_path_available,
|
||||
}
|
||||
|
||||
|
||||
def prewarm_text_caches(entries,
|
||||
draw: Optional[ImageDraw.ImageDraw] = None,
|
||||
budget_bytes: Optional[int] = None,
|
||||
max_words: Optional[int] = None) -> Tuple[int, int]:
|
||||
"""
|
||||
Preload the caches with a document's most frequent words.
|
||||
|
||||
A document states its own access distribution up front: the words it uses most
|
||||
are the words every page will draw. Rasterising them once at open time moves
|
||||
that work off the page-turn path, and seeding each entry with its document
|
||||
frequency puts it in the right place in the eviction order immediately, rather
|
||||
than after the cache has learned it.
|
||||
|
||||
This depends on eviction ranking by use count. Under recency eviction the
|
||||
preloaded entries would be discarded by the first page of unfamiliar text; under
|
||||
usage ranking a word occurring 4000 times outranks anything met while scanning
|
||||
and stays resident. Measured over a 50-page trace, preloading cut misses by 27%
|
||||
with usage ranking against 12% with recency.
|
||||
|
||||
Args:
|
||||
entries: Iterable of ``(font, text, colour, frequency)``, where `font` is a
|
||||
PIL font object, `colour` the fill the text will be drawn in, and
|
||||
`frequency` the number of times the word occurs in the document.
|
||||
draw: An ImageDraw sharing the page's mode, used to resolve ink and font
|
||||
mode. A scratch RGBA context is used if omitted.
|
||||
budget_bytes: Cap on bytes to preload. Defaults to half the glyph budget so
|
||||
that live rendering keeps room to cache what preloading missed.
|
||||
max_words: Cap on distinct words to preload, before sub-pixel variants.
|
||||
|
||||
Returns:
|
||||
Tuple of (words preloaded, bytes preloaded).
|
||||
"""
|
||||
if not _glyph_fast_path_available:
|
||||
return 0, 0
|
||||
|
||||
if draw is None:
|
||||
from PIL import Image
|
||||
draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
|
||||
|
||||
if budget_bytes is None:
|
||||
budget_bytes = _glyph_cache.max_bytes // 2
|
||||
budget_bytes = min(budget_bytes, _glyph_cache.max_bytes)
|
||||
|
||||
ranked = sorted(entries, key=lambda e: -e[3])
|
||||
if max_words is not None:
|
||||
ranked = ranked[:max_words]
|
||||
|
||||
steps = _glyph_subpixel_steps
|
||||
mode = draw.fontmode
|
||||
draw_mode = draw.mode
|
||||
ink_cache: Dict[Any, Any] = {}
|
||||
words = 0
|
||||
used = 0
|
||||
|
||||
for font, text, colour, frequency in ranked:
|
||||
if frequency <= 1 or used >= budget_bytes:
|
||||
break
|
||||
if not isinstance(font, ImageFont.FreeTypeFont):
|
||||
continue
|
||||
|
||||
try:
|
||||
ink = ink_cache.get(colour)
|
||||
if ink is None:
|
||||
ink, _ = draw._getink(colour)
|
||||
if ink is None:
|
||||
continue
|
||||
ink_cache[colour] = ink
|
||||
|
||||
# Measuring is cheap and every layout pass needs it.
|
||||
_width_cache.put((font, text, draw_mode),
|
||||
draw.textlength(text, font=font), count=frequency)
|
||||
|
||||
# Words land on arbitrary sub-pixel offsets, so cover every horizontal
|
||||
# phase. Baselines are whole pixels, so only phase 0 is needed
|
||||
# vertically.
|
||||
for x_bucket in range(steps):
|
||||
entry = font.getmask2(text, mode, anchor="ls", ink=ink,
|
||||
start=(x_bucket / steps, 0.0))
|
||||
_glyph_cache.put((font, text, mode, ink, x_bucket, 0), entry,
|
||||
count=frequency)
|
||||
used += _glyph_entry_bytes(entry)
|
||||
words += 1
|
||||
|
||||
except AttributeError:
|
||||
logger.warning("Glyph cache unavailable for this Pillow build; "
|
||||
"skipping prewarm.", exc_info=True)
|
||||
return words, used
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
logger.debug("Prewarmed %d words (%.2fMB) into the text caches",
|
||||
words, used / 1e6)
|
||||
return words, used
|
||||
|
||||
|
||||
class AlignmentHandler(ABC):
|
||||
"""
|
||||
@@ -21,7 +252,10 @@ class AlignmentHandler(ABC):
|
||||
@abstractmethod
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
max_spacing: int,
|
||||
natural_spacing: Optional[int] = None,
|
||||
total_width: Optional[float] = None
|
||||
) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate the spacing between words and starting position for the line.
|
||||
|
||||
@@ -30,9 +264,17 @@ class AlignmentHandler(ABC):
|
||||
available_width: Total width available for the line
|
||||
min_spacing: Minimum spacing between words
|
||||
max_spacing: Maximum spacing between words
|
||||
natural_spacing: The font's own space width. Ragged alignments use it
|
||||
as a constant gap; justification ignores it. Defaults to
|
||||
min_spacing when not supplied.
|
||||
total_width: The summed width of `text_objects`, when the caller
|
||||
already knows it. Purely an optimisation: a line asks its handler
|
||||
to re-measure once per candidate word, and summing the whole line
|
||||
each time makes filling a line quadratic in its word count. Omit
|
||||
it and the sum is taken here as before.
|
||||
|
||||
Returns:
|
||||
Tuple of (spacing_between_words, starting_x_position)
|
||||
Tuple of (spacing_between_words, starting_x_position, overflow)
|
||||
"""
|
||||
|
||||
|
||||
@@ -43,16 +285,24 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
text_objects: List['Text'],
|
||||
available_width: int,
|
||||
min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
max_spacing: int,
|
||||
natural_spacing: Optional[int] = None,
|
||||
total_width: Optional[float] = None
|
||||
) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate spacing and position for left-aligned text objects.
|
||||
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
|
||||
|
||||
Left-aligned text uses a constant word space and leaves whatever is left
|
||||
over as a ragged right edge. It must not spread the residual space across
|
||||
the gaps: that stretches each line by a different amount, which reads as
|
||||
badly-set justified text rather than as ragged-right.
|
||||
|
||||
Args:
|
||||
text_objects (List[Text]): A list of text objects to be laid out.
|
||||
available_width (int): The total width available for layout.
|
||||
min_spacing (int): Minimum spacing between text objects.
|
||||
max_spacing (int): Maximum spacing between text objects.
|
||||
natural_spacing (Optional[int]): The font's own space width.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
||||
@@ -61,33 +311,20 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
if len(text_objects) <= 1:
|
||||
return 0, 0, False
|
||||
|
||||
# Calculate the total length of all text objects
|
||||
text_length = sum([text.width for text in text_objects])
|
||||
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
||||
|
||||
# Calculate number of gaps between texts
|
||||
text_length = (sum([text.width for text in text_objects])
|
||||
if total_width is None else total_width)
|
||||
num_gaps = len(text_objects) - 1
|
||||
|
||||
# Calculate minimum space needed (text + minimum gaps)
|
||||
min_total_width = text_length + (min_spacing * num_gaps)
|
||||
# The spacing is constant whether or not the content fits: tightening a
|
||||
# full line here would make it differ from its neighbours, which is the
|
||||
# variation this alignment is supposed to avoid. Report the overflow and
|
||||
# let line breaking move the offending word instead.
|
||||
overflow = text_length + (spacing * num_gaps) > available_width
|
||||
|
||||
# Check if we have overflow (CREngine pattern: always use min_spacing for
|
||||
# overflow)
|
||||
if min_total_width > available_width:
|
||||
return min_spacing, 0, True # Overflow - but use safe minimum spacing
|
||||
|
||||
# Calculate residual space left after accounting for text lengths
|
||||
residual_space = available_width - text_length
|
||||
|
||||
# Calculate ideal spacing
|
||||
actual_spacing = residual_space // num_gaps
|
||||
# Clamp within bounds (CREngine pattern: respect max_spacing)
|
||||
if actual_spacing > max_spacing:
|
||||
return max_spacing, 0, False
|
||||
elif actual_spacing < min_spacing:
|
||||
# Ensure we never return spacing less than min_spacing
|
||||
return min_spacing, 0, False
|
||||
else:
|
||||
return actual_spacing, 0, False # Use calculated spacing
|
||||
return spacing, 0, overflow
|
||||
|
||||
|
||||
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
@@ -98,10 +335,20 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Center/right alignment uses minimum spacing with calculated start position."""
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
residual_space = available_width - word_length
|
||||
max_spacing: int,
|
||||
natural_spacing: Optional[int] = None,
|
||||
total_width: Optional[float] = None
|
||||
) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Centre/right alignment: constant word space, line shifted as a block.
|
||||
|
||||
Like left alignment, the residual space must not be spread across the
|
||||
gaps - it belongs in the margin. The start position is then derived from
|
||||
the same spacing that will actually be used, so the line lands where it
|
||||
was measured to land.
|
||||
"""
|
||||
word_length = (sum([word.width for word in text_objects])
|
||||
if total_width is None else total_width)
|
||||
|
||||
# Handle single word case
|
||||
if len(text_objects) <= 1:
|
||||
@@ -109,46 +356,103 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
start_position = (available_width - word_length) // 2
|
||||
else: # RIGHT
|
||||
start_position = available_width - word_length
|
||||
return 0, max(0, start_position), False
|
||||
return 0, max(0, int(start_position)), False
|
||||
|
||||
actual_spacing = residual_space // (len(text_objects) - 1)
|
||||
ideal_space = (min_spacing + max_spacing) / 2
|
||||
if actual_spacing > 0.5 * (min_spacing + max_spacing):
|
||||
actual_spacing = 0.5 * (min_spacing + max_spacing)
|
||||
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
||||
|
||||
content_length = word_length + (len(text_objects) - 1) * actual_spacing
|
||||
num_gaps = len(text_objects) - 1
|
||||
overflow = word_length + (spacing * num_gaps) > available_width
|
||||
|
||||
content_length = word_length + num_gaps * spacing
|
||||
if self._alignment == Alignment.CENTER:
|
||||
start_position = (available_width - content_length) // 2
|
||||
else:
|
||||
start_position = available_width - content_length
|
||||
|
||||
if actual_spacing < min_spacing:
|
||||
return actual_spacing, max(0, start_position), True
|
||||
|
||||
return ideal_space, max(0, start_position), False
|
||||
return spacing, max(0, int(start_position)), overflow
|
||||
|
||||
|
||||
class JustifyAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for justified text with full justification."""
|
||||
|
||||
def __init__(self):
|
||||
# The per-gap spacings are described by a plan rather than stored outright,
|
||||
# and materialised on demand by the _gap_spacings property below. Fitting a
|
||||
# line calls this handler once per candidate word and only ever looks at the
|
||||
# first gap; building the whole list on each of those probes made adding n
|
||||
# words to a line O(n^2). Only render() reads the full list.
|
||||
self._gap_uniform: Optional[int] = None
|
||||
self._gap_residual: int = 0
|
||||
self._gap_count: int = 0
|
||||
self._gap_cache: Optional[List[int]] = []
|
||||
|
||||
@property
|
||||
def _gap_spacings(self) -> List[int]:
|
||||
"""The spacing to apply at each gap, left to right."""
|
||||
if self._gap_cache is None:
|
||||
if self._gap_uniform is not None:
|
||||
self._gap_cache = [self._gap_uniform] * self._gap_count
|
||||
else:
|
||||
self._gap_cache = self._distribute(self._gap_residual, self._gap_count)
|
||||
return self._gap_cache
|
||||
|
||||
@staticmethod
|
||||
def _distribute(total: int, num_gaps: int) -> List[int]:
|
||||
"""Split `total` pixels across `num_gaps` gaps by cumulative rounding."""
|
||||
gaps = []
|
||||
placed = 0
|
||||
for i in range(1, num_gaps + 1):
|
||||
cumulative = int(round(total * i / num_gaps))
|
||||
gaps.append(cumulative - placed)
|
||||
placed = cumulative
|
||||
return gaps
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Justified alignment distributes space to fill the entire line width."""
|
||||
max_spacing: int,
|
||||
natural_spacing: Optional[int] = None,
|
||||
total_width: Optional[float] = None
|
||||
) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Justified alignment distributes space to fill the entire line width.
|
||||
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
natural_spacing is ignored: filling the measure is the whole point.
|
||||
|
||||
For justified text, we ALWAYS try to fill the entire width by distributing
|
||||
space between words, regardless of max_spacing constraints. The only limit
|
||||
is min_spacing to ensure readability.
|
||||
"""
|
||||
|
||||
word_length = (sum([word.width for word in text_objects])
|
||||
if total_width is None else total_width)
|
||||
residual_space = available_width - word_length
|
||||
num_gaps = max(1, len(text_objects) - 1)
|
||||
|
||||
actual_spacing = residual_space // num_gaps
|
||||
ideal_space = (min_spacing + max_spacing) // 2
|
||||
# can we touch the end?
|
||||
if actual_spacing < max_spacing:
|
||||
if actual_spacing < min_spacing:
|
||||
# Ensure we never return spacing less than min_spacing
|
||||
return min_spacing, 0, True
|
||||
return max(min_spacing, actual_spacing), 0, False
|
||||
return ideal_space, 0, False
|
||||
# Check if we have enough space for minimum spacing
|
||||
if residual_space // num_gaps < min_spacing:
|
||||
# Not enough space - this is overflow
|
||||
self._gap_uniform = min_spacing
|
||||
self._gap_count = num_gaps
|
||||
self._gap_cache = None
|
||||
return min_spacing, 0, True
|
||||
|
||||
# Distribute the residual by cumulative rounding rather than by taking a
|
||||
# floor per gap and scattering the remainder. Word widths are fractional,
|
||||
# so flooring each gap loses part of a pixel and truncating the remainder
|
||||
# loses up to another - the line then stops one or two pixels short of the
|
||||
# margin, and by a different amount on each line, which is visible as a
|
||||
# ragged right edge on otherwise justified text. Rounding the running
|
||||
# total makes the gaps sum to the residual exactly.
|
||||
total = int(round(residual_space))
|
||||
self._gap_uniform = None
|
||||
self._gap_residual = total
|
||||
self._gap_count = num_gaps
|
||||
self._gap_cache = None
|
||||
|
||||
# The first gap is the whole of the plan that fitting needs, and it falls
|
||||
# out of the same cumulative rounding as _distribute would give it.
|
||||
return int(round(total / num_gaps)), 0, False
|
||||
|
||||
|
||||
class Text(Renderable, Queriable):
|
||||
@@ -184,9 +488,19 @@ class Text(Renderable, Queriable):
|
||||
|
||||
def _calculate_dimensions(self):
|
||||
"""Calculate the width and height of the text based on the font metrics"""
|
||||
# Get the size using PIL's text size functionality
|
||||
# Measuring a word costs a FreeType shaping pass, and the same words recur
|
||||
# constantly within a document, so results are cached per (font, string).
|
||||
# The draw's image mode is part of the key because PIL derives advance
|
||||
# widths differently for bilevel ("1") targets.
|
||||
font = self._style.font
|
||||
self._width = self._draw.textlength(self._text, font=font)
|
||||
key = (font, self._text, self._draw.mode)
|
||||
|
||||
width = _width_cache.get(key)
|
||||
if width is None:
|
||||
width = self._draw.textlength(self._text, font=font)
|
||||
_width_cache.put(key, width)
|
||||
self._width = width
|
||||
|
||||
ascent, descent = font.getmetrics()
|
||||
self._ascent = ascent
|
||||
self._middle_y = ascent - descent / 2
|
||||
@@ -227,8 +541,11 @@ class Text(Renderable, Queriable):
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Get the width of the text"""
|
||||
return np.array((self._width, self._style.font_size))
|
||||
"""Get the width and height of the text"""
|
||||
# Return actual rendered height (ascent + descent) not just font_size
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
actual_height = ascent + descent
|
||||
return np.array((self._width, actual_height))
|
||||
|
||||
def set_origin(self, origin: np.generic):
|
||||
"""Set the origin (left baseline ("ls")) of this text element"""
|
||||
@@ -238,6 +555,31 @@ class Text(Renderable, Queriable):
|
||||
"""Add this text to a line"""
|
||||
self._line = line
|
||||
|
||||
def in_object(self, point: np.generic):
|
||||
"""
|
||||
Check if a point is in the text object.
|
||||
|
||||
Override Queriable.in_object() because Text uses baseline-anchored positioning.
|
||||
The origin is at the baseline (anchor="ls"), not the top-left corner.
|
||||
|
||||
Args:
|
||||
point: The coordinates to check
|
||||
|
||||
Returns:
|
||||
True if the point is within the text bounds
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
|
||||
# Text origin is at baseline, so visual top is origin[1] - ascent
|
||||
visual_top = self._origin[1] - self._ascent
|
||||
visual_bottom = self._origin[1] + (self.size[1] - self._ascent)
|
||||
|
||||
# Check if point is within bounds
|
||||
# X: origin[0] to origin[0] + width
|
||||
# Y: visual_top to visual_bottom
|
||||
return (self._origin[0] <= point_array[0] < self._origin[0] + self.size[0] and
|
||||
visual_top <= point_array[1] < visual_bottom)
|
||||
|
||||
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Apply text decoration (underline or strikethrough).
|
||||
@@ -294,23 +636,95 @@ class Text(Renderable, Queriable):
|
||||
A PIL Image containing the rendered text
|
||||
"""
|
||||
|
||||
style = self._style
|
||||
|
||||
# Draw the text background if specified
|
||||
if self._style.background and self._style.background[3] > 0: # If alpha > 0
|
||||
self._draw.rectangle([self._origin, self._origin +
|
||||
self._size], fill=self._style.background)
|
||||
if style.background and style.background[3] > 0: # If alpha > 0
|
||||
self._draw.rectangle([tuple(self._origin), tuple(self._origin + self.size)],
|
||||
fill=style.background)
|
||||
|
||||
# Draw the text using baseline as anchor point ("ls" = left-baseline)
|
||||
# This ensures the origin represents the baseline, not the top-left
|
||||
self._draw.text(
|
||||
(self.origin[0],
|
||||
self._origin[1]),
|
||||
self._text,
|
||||
font=self._style.font,
|
||||
fill=self._style.colour,
|
||||
anchor="ls")
|
||||
if not self._render_from_glyph_cache(style):
|
||||
self._draw.text(
|
||||
(self.origin[0],
|
||||
self._origin[1]),
|
||||
self._text,
|
||||
font=style.font,
|
||||
fill=style.colour,
|
||||
anchor="ls")
|
||||
|
||||
# Apply any text decorations with knowledge of next text
|
||||
self._apply_decoration(next_text, spacing)
|
||||
if style.decoration != TextDecoration.NONE:
|
||||
self._apply_decoration(next_text, spacing)
|
||||
|
||||
def _render_from_glyph_cache(self, style) -> bool:
|
||||
"""
|
||||
Blit this word from the cached glyph bitmap.
|
||||
|
||||
Rasterising a word is the single most expensive step in drawing a page, and
|
||||
the same words recur constantly, so the bitmap PIL would produce is cached
|
||||
and blitted directly. This reproduces what ImageDraw.text() does internally
|
||||
(getmask2 followed by draw_bitmap) minus the per-call setup.
|
||||
|
||||
Returns:
|
||||
True if the word was drawn. False means the caller must fall back to
|
||||
ImageDraw.text().
|
||||
"""
|
||||
global _glyph_fast_path_available
|
||||
|
||||
if not _glyph_fast_path_available:
|
||||
return False
|
||||
|
||||
draw = self._draw
|
||||
font = style.font
|
||||
|
||||
# Bitmap and other non-FreeType fonts do not expose getmask2's anchor and
|
||||
# sub-pixel arguments; let PIL handle them.
|
||||
if not isinstance(font, ImageFont.FreeTypeFont):
|
||||
return False
|
||||
|
||||
try:
|
||||
ink, _ = draw._getink(style.colour)
|
||||
if ink is None:
|
||||
return False
|
||||
|
||||
# floor() rather than modf() so the fraction is always in [0, 1),
|
||||
# keeping bucket indices non-negative for negative coordinates.
|
||||
x = float(self._origin[0])
|
||||
y = float(self._origin[1])
|
||||
x_whole = math.floor(x)
|
||||
y_whole = math.floor(y)
|
||||
|
||||
steps = _glyph_subpixel_steps
|
||||
x_bucket = int((x - x_whole) * steps)
|
||||
y_bucket = int((y - y_whole) * steps)
|
||||
|
||||
mode = draw.fontmode
|
||||
key = (font, self._text, mode, ink, x_bucket, y_bucket)
|
||||
|
||||
entry = _glyph_cache.get(key)
|
||||
if entry is None:
|
||||
entry = font.getmask2(
|
||||
self._text, mode, anchor="ls", ink=ink,
|
||||
start=(x_bucket / steps, y_bucket / steps))
|
||||
_glyph_cache.put(key, entry)
|
||||
|
||||
mask, offset = entry
|
||||
draw.draw.draw_bitmap((x_whole + offset[0], y_whole + offset[1]), mask, ink)
|
||||
return True
|
||||
|
||||
except AttributeError:
|
||||
# A PIL build without the internals this path relies on. Stop trying.
|
||||
logger.warning(
|
||||
"Glyph cache unavailable for this Pillow build; falling back to "
|
||||
"ImageDraw.text() for all text rendering.", exc_info=True)
|
||||
_glyph_fast_path_available = False
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# This particular colour/mode combination is not supported by the fast
|
||||
# path (e.g. an ink PIL cannot resolve). Others may still be.
|
||||
return False
|
||||
|
||||
|
||||
class Line(Box):
|
||||
@@ -355,6 +769,9 @@ class Line(Box):
|
||||
"""
|
||||
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
||||
self._text_objects: List['Text'] = [] # Store Text objects directly
|
||||
# Prefix sums of the widths in _text_objects, kept in step by _push_text /
|
||||
# _pop_text. Element 0 is the empty sum. See _push_text for the rationale.
|
||||
self._width_prefix: List[float] = [0.0]
|
||||
self._spacing = spacing # (min_spacing, max_spacing)
|
||||
self._font = font if font else Font() # Use default font if none provided
|
||||
self._current_width = 0 # Track the current width used
|
||||
@@ -368,6 +785,10 @@ class Line(Box):
|
||||
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
||||
self._position_render = 0
|
||||
|
||||
# The font's own space advance. Ragged alignments use this as their
|
||||
# constant word gap rather than stretching to fill the measure.
|
||||
self._natural_spacing = _space_advance(self._font.font)
|
||||
|
||||
# Hyphenation configuration parameters
|
||||
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
||||
self._min_chars_before_hyphen = min_chars_before_hyphen
|
||||
@@ -376,6 +797,34 @@ class Line(Box):
|
||||
# Create the appropriate alignment handler
|
||||
self._alignment_handler = self._create_alignment_handler(halign)
|
||||
|
||||
# Set on the final line of a paragraph. Justification stretches a line to
|
||||
# fill the column, which is wrong for the last line - a three-word tail
|
||||
# would be spread across the full measure. The last line takes its
|
||||
# natural width instead, as in every other typesetting system.
|
||||
self._is_paragraph_end = False
|
||||
|
||||
@property
|
||||
def is_paragraph_end(self) -> bool:
|
||||
"""Whether this is the final line of its paragraph"""
|
||||
return self._is_paragraph_end
|
||||
|
||||
@is_paragraph_end.setter
|
||||
def is_paragraph_end(self, value: bool):
|
||||
self._is_paragraph_end = value
|
||||
|
||||
@property
|
||||
def render_alignment_handler(self) -> AlignmentHandler:
|
||||
"""
|
||||
The handler used to position text when rendering.
|
||||
|
||||
This differs from the fitting handler only for the last line of a
|
||||
justified paragraph, which is rendered flush left.
|
||||
"""
|
||||
if self._is_paragraph_end and isinstance(
|
||||
self._alignment_handler, JustifyAlignmentHandler):
|
||||
return LeftAlignmentHandler()
|
||||
return self._alignment_handler
|
||||
|
||||
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
||||
"""
|
||||
Create the appropriate alignment handler based on the alignment type.
|
||||
@@ -402,6 +851,45 @@ class Line(Box):
|
||||
"""Set the next line in sequence"""
|
||||
self._next = line
|
||||
|
||||
@property
|
||||
def _content_width(self) -> float:
|
||||
"""Summed width of the line's current contents."""
|
||||
return self._width_prefix[-1]
|
||||
|
||||
def _push_text(self, text: 'Text'):
|
||||
"""
|
||||
Append a Text to the line, keeping the running width sum in step.
|
||||
|
||||
Fitting a word is a trial: the candidate is pushed, measured, and popped
|
||||
again if it did not fit, so the line's contents churn far more often than
|
||||
they grow. Tracking the sum here rather than re-adding every width on each
|
||||
measurement is what keeps filling a line linear in its word count.
|
||||
|
||||
The sum is kept as a prefix list rather than as one accumulator that is
|
||||
added to and subtracted from. Widths are floats, so `(total + w) - w` need
|
||||
not give back `total` exactly, and a drift of one ulp is enough to flip an
|
||||
overflow decision on a line that ends flush. Truncating a prefix list
|
||||
restores the earlier total bit for bit, and each entry is built by the same
|
||||
left-to-right addition sum() would perform.
|
||||
"""
|
||||
self._text_objects.append(text)
|
||||
self._width_prefix.append(self._width_prefix[-1] + text.width)
|
||||
|
||||
def _pop_text(self) -> 'Text':
|
||||
"""Remove the last Text from the line, keeping the width sum in step."""
|
||||
text = self._text_objects.pop()
|
||||
self._width_prefix.pop()
|
||||
return text
|
||||
|
||||
def _measure(self, handler: Optional[AlignmentHandler] = None
|
||||
) -> Tuple[int, int, bool]:
|
||||
"""Ask an alignment handler to place the line's current contents."""
|
||||
if handler is None:
|
||||
handler = self._alignment_handler
|
||||
return handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
|
||||
self._natural_spacing, self._content_width)
|
||||
|
||||
def add_word(self,
|
||||
word: 'Word',
|
||||
part: Optional[Text] = None) -> Tuple[bool,
|
||||
@@ -420,7 +908,7 @@ class Line(Box):
|
||||
"""
|
||||
# First, add any pretext from previous hyphenation
|
||||
if part is not None:
|
||||
self._text_objects.append(part)
|
||||
self._push_text(part)
|
||||
self._words.append(word)
|
||||
part.add_line(self)
|
||||
|
||||
@@ -449,9 +937,8 @@ class Line(Box):
|
||||
line=self)
|
||||
else:
|
||||
text = Text.from_word(word, self._draw)
|
||||
self._text_objects.append(text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
self._push_text(text)
|
||||
spacing, position, overflow = self._measure()
|
||||
|
||||
if not overflow:
|
||||
# Word fits! Add it completely
|
||||
@@ -463,7 +950,7 @@ class Line(Box):
|
||||
return True, None
|
||||
|
||||
# Word doesn't fit, remove it and try hyphenation
|
||||
_ = self._text_objects.pop()
|
||||
self._pop_text()
|
||||
|
||||
# Step 1: Try pyphen hyphenation
|
||||
pyphen_splits = word.possible_hyphenation()
|
||||
@@ -496,10 +983,9 @@ class Line(Box):
|
||||
source=word)
|
||||
|
||||
# Check if first part fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
_ = self._text_objects.pop()
|
||||
self._push_text(first_text)
|
||||
spacing, position, overflow = self._measure()
|
||||
self._pop_text()
|
||||
|
||||
if not overflow:
|
||||
# This split fits! Add it to valid options
|
||||
@@ -512,7 +998,7 @@ class Line(Box):
|
||||
first_text, second_text, spacing, position = best_split
|
||||
|
||||
# Apply the split
|
||||
self._text_objects.append(first_text)
|
||||
self._push_text(first_text)
|
||||
first_text.line = self
|
||||
word.add_concete((first_text, second_text))
|
||||
self._spacing_render = spacing
|
||||
@@ -523,7 +1009,7 @@ class Line(Box):
|
||||
# Step 3: Try brute force hyphenation (only for long words)
|
||||
if len(word.text) >= self._min_word_length_for_brute_force:
|
||||
# Calculate available space for the word
|
||||
word_length = sum([text.width for text in self._text_objects])
|
||||
word_length = self._content_width
|
||||
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
||||
remaining = self._size[0] - word_length - spacing_length
|
||||
|
||||
@@ -567,9 +1053,8 @@ class Line(Box):
|
||||
source=word)
|
||||
|
||||
# Verify the first part actually fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
self._push_text(first_text)
|
||||
spacing, position, overflow = self._measure()
|
||||
|
||||
if not overflow:
|
||||
# Brute force split works!
|
||||
@@ -582,7 +1067,7 @@ class Line(Box):
|
||||
return True, second_text
|
||||
else:
|
||||
# Doesn't fit, remove it
|
||||
_ = self._text_objects.pop()
|
||||
self._pop_text()
|
||||
|
||||
# Step 4: Word cannot be hyphenated or split, move to next line
|
||||
return False, None
|
||||
@@ -594,10 +1079,13 @@ class Line(Box):
|
||||
Returns:
|
||||
A PIL Image containing the rendered line
|
||||
"""
|
||||
# Recalculate spacing and position for current text objects to ensure accuracy
|
||||
# Recalculate spacing and position for current text objects to ensure
|
||||
# accuracy. Word fitting used the paragraph's alignment; rendering uses
|
||||
# render_alignment_handler, which differs only for the last line of a
|
||||
# justified paragraph.
|
||||
handler = self.render_alignment_handler
|
||||
if len(self._text_objects) > 0:
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
spacing, position, overflow = self._measure(handler)
|
||||
self._spacing_render = spacing
|
||||
self._position_render = position
|
||||
|
||||
@@ -605,18 +1093,34 @@ class Line(Box):
|
||||
|
||||
# Start x_cursor at line origin plus any alignment offset
|
||||
x_cursor = self._origin[0] + self._position_render
|
||||
for i, text in enumerate(self._text_objects):
|
||||
|
||||
# Everything the loop needs that does not vary per word is resolved once.
|
||||
# Only justified lines carry per-gap spacings; every other alignment uses
|
||||
# the single spacing figured above.
|
||||
texts = self._text_objects
|
||||
last = len(texts) - 1
|
||||
draw = self._draw
|
||||
default_spacing = self._spacing_render
|
||||
gaps = handler._gap_spacings if isinstance(handler, JustifyAlignmentHandler) else ()
|
||||
gap_count = len(gaps)
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
# Update text draw context to current draw context
|
||||
text._draw = self._draw
|
||||
text._draw = draw
|
||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||
|
||||
# Determine next text object for continuous decoration
|
||||
next_text = self._text_objects[i + 1] if i + \
|
||||
1 < len(self._text_objects) else None
|
||||
next_text = texts[i + 1] if i < last else None
|
||||
|
||||
# Get the spacing for this specific gap (variable for justified text)
|
||||
current_spacing = gaps[i] if i < gap_count else default_spacing
|
||||
|
||||
# Render with next text information for continuous underline/strikethrough
|
||||
text.render(next_text, self._spacing_render)
|
||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
||||
text.render(next_text, current_spacing)
|
||||
# Add text width, then spacing only if there are more words
|
||||
x_cursor += text.width
|
||||
if i < last:
|
||||
x_cursor += current_spacing
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
||||
"""
|
||||
|
||||
@@ -331,10 +331,16 @@ class BlockContainer:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._blocks = []
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
"""Get the list of blocks in this container"""
|
||||
return self._blocks
|
||||
"""
|
||||
Get an iterator over the blocks in this container.
|
||||
|
||||
Can be used as blocks() for iteration or accessing the _blocks list directly.
|
||||
|
||||
Returns:
|
||||
Iterator over blocks
|
||||
"""
|
||||
return iter(self._blocks)
|
||||
|
||||
def add_block(self, block):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Bounded usage-ranked caches for the text rendering hot path.
|
||||
|
||||
Laying out and rasterising a page re-measures and re-draws the same words over and
|
||||
over: a typical page issues ~2800 width measurements and ~2500 glyph rasterisations
|
||||
for fewer than 1000 distinct (font, string) pairs. Caching both collapses that work,
|
||||
but an unbounded cache is not an option on a memory-constrained target such as a
|
||||
Raspberry Pi Zero 2, where the rasterised bitmaps reach ~19MB over a long session.
|
||||
|
||||
Eviction is by **usage count**, not recency. Word frequency in prose is Zipfian and
|
||||
stationary -- a small set of words ("the", "and", "of") accounts for most tokens on
|
||||
every page, and that set barely shifts as the reader advances -- so the words worth
|
||||
keeping are exactly the ones used most.
|
||||
|
||||
Two design choices keep this from costing more than it saves, because `get` runs
|
||||
once per word drawn (~2500 times per page):
|
||||
|
||||
* **Counting is O(1) with no reordering.** Each entry carries its own use counter,
|
||||
bumped in place. Ranking structures that reorder on every hit (a frequency-bucket
|
||||
LFU, or an LRU's linked-list splice) were measured 3-5ms per page slower than the
|
||||
hit rate they buy is worth.
|
||||
* **Eviction samples rather than sorts.** Finding the globally least-used entry
|
||||
would need a heap kept current on every hit. Instead a small random sample is
|
||||
drawn and the least-used member of it evicted, the same approximation Redis uses
|
||||
for its LFU policy. With the default sample size the evicted entry is very
|
||||
likely to be in the bottom few percent, which is all that matters here.
|
||||
|
||||
Both are single-threaded by design; the rendering path holds the GIL throughout and
|
||||
adding locking would cost more than it protects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, TypeVar
|
||||
|
||||
K = TypeVar('K', bound=Hashable)
|
||||
V = TypeVar('V')
|
||||
|
||||
# Entries examined per eviction. Larger samples approximate true least-frequently-used
|
||||
# more closely at linear cost; 8 puts the victim in the bottom ~15% of entries, which
|
||||
# is ample when the alternative is a rasterisation that costs ~60us either way.
|
||||
DEFAULT_EVICTION_SAMPLE = 8
|
||||
|
||||
# Halving every entry's use count after this many insertions keeps the cache
|
||||
# responsive to a change of working set. Without it, entries that were hot long ago
|
||||
# retain counts a newly-hot entry cannot beat and are never evicted -- the classic
|
||||
# failure of pure frequency eviction. Measured on a real access trace, a font-size
|
||||
# change drove hit rate to 0% without aging and left it unchanged with it.
|
||||
DEFAULT_AGING_INTERVAL = 10000
|
||||
|
||||
# Index of each field in an entry. Entries are plain lists rather than tuples or
|
||||
# objects so the counter can be bumped in place, without rehashing the key.
|
||||
_VALUE = 0
|
||||
_COUNT = 1
|
||||
_SLOT = 2
|
||||
|
||||
|
||||
class _UsageRanked(Generic[K, V]):
|
||||
"""
|
||||
Shared usage-count bookkeeping for the caches below.
|
||||
|
||||
Entries live in a dict for lookup and, in parallel, in a flat list that makes
|
||||
uniform random sampling possible. Each entry records its own index in that list
|
||||
so removal can swap in the tail element and stay O(1).
|
||||
|
||||
Subclasses supply the bound by implementing :meth:`_over_budget` and the
|
||||
accounting hooks :meth:`_record_add` / :meth:`_record_remove`.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if aging_interval is not None and aging_interval <= 0:
|
||||
raise ValueError(f"aging_interval must be positive, got {aging_interval}")
|
||||
if eviction_sample <= 0:
|
||||
raise ValueError(f"eviction_sample must be positive, got {eviction_sample}")
|
||||
|
||||
self._aging_interval = aging_interval
|
||||
self._eviction_sample = eviction_sample
|
||||
|
||||
self._entries: Dict[K, List[Any]] = {}
|
||||
self._slots: List[K] = []
|
||||
self._randrange = random.randrange
|
||||
|
||||
self._inserts_since_aging = 0
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._evictions = 0
|
||||
self._agings = 0
|
||||
|
||||
# -- subclass hooks ----------------------------------------------------
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _record_add(self, key: K, value: V):
|
||||
"""Account for a value entering the cache."""
|
||||
|
||||
def _record_remove(self, key: K):
|
||||
"""Account for a value leaving the cache."""
|
||||
|
||||
# -- core operations ---------------------------------------------------
|
||||
|
||||
def get(self, key: K) -> Optional[V]:
|
||||
"""Return the cached value for `key`, or None, counting the use."""
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
self._misses += 1
|
||||
return None
|
||||
entry[_COUNT] += 1
|
||||
self._hits += 1
|
||||
return entry[_VALUE]
|
||||
|
||||
def _add_new(self, key: K, value: V):
|
||||
"""Insert a key not currently present."""
|
||||
# New entries start at 1 rather than 0 so that a single reuse is enough to
|
||||
# outrank an entry that has never been touched since the last aging pass.
|
||||
self._entries[key] = [value, 1, len(self._slots)]
|
||||
self._slots.append(key)
|
||||
self._record_add(key, value)
|
||||
|
||||
def _remove(self, key: K):
|
||||
"""Remove a key outright, keeping the sampling list dense."""
|
||||
entry = self._entries.pop(key)
|
||||
slot = entry[_SLOT]
|
||||
last = self._slots.pop()
|
||||
if last != key:
|
||||
self._slots[slot] = last
|
||||
self._entries[last][_SLOT] = slot
|
||||
self._record_remove(key)
|
||||
|
||||
def _evict_one(self) -> bool:
|
||||
"""Evict the least-used member of a random sample. False if empty."""
|
||||
count = len(self._slots)
|
||||
if not count:
|
||||
return False
|
||||
|
||||
if count <= self._eviction_sample:
|
||||
victim = min(self._slots, key=lambda k: self._entries[k][_COUNT])
|
||||
else:
|
||||
randrange = self._randrange
|
||||
entries = self._entries
|
||||
slots = self._slots
|
||||
victim = slots[randrange(count)]
|
||||
best = entries[victim][_COUNT]
|
||||
for _ in range(self._eviction_sample - 1):
|
||||
candidate = slots[randrange(count)]
|
||||
score = entries[candidate][_COUNT]
|
||||
if score < best:
|
||||
victim, best = candidate, score
|
||||
|
||||
self._remove(victim)
|
||||
self._evictions += 1
|
||||
return True
|
||||
|
||||
def _evict_to_budget(self):
|
||||
while self._over_budget():
|
||||
if not self._evict_one():
|
||||
break
|
||||
|
||||
def _maybe_age(self):
|
||||
"""Halve every use count once the aging interval has elapsed."""
|
||||
if self._aging_interval is None:
|
||||
return
|
||||
self._inserts_since_aging += 1
|
||||
if self._inserts_since_aging < self._aging_interval:
|
||||
return
|
||||
|
||||
self._inserts_since_aging = 0
|
||||
self._agings += 1
|
||||
for entry in self._entries.values():
|
||||
entry[_COUNT] = entry[_COUNT] // 2 or 1
|
||||
|
||||
def clear(self):
|
||||
"""Drop all entries. Counters are preserved."""
|
||||
self._entries.clear()
|
||||
self._slots.clear()
|
||||
self._inserts_since_aging = 0
|
||||
|
||||
def _base_stats(self) -> Dict[str, Any]:
|
||||
total = self._hits + self._misses
|
||||
return {
|
||||
'entries': len(self._entries),
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'evictions': self._evictions,
|
||||
'agings': self._agings,
|
||||
'hit_rate': (self._hits / total) if total else 0.0,
|
||||
}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._entries
|
||||
|
||||
|
||||
class UsageCache(_UsageRanked[K, V]):
|
||||
"""
|
||||
Usage-ranked cache bounded by number of entries.
|
||||
|
||||
Args:
|
||||
max_entries: Maximum number of entries to retain. Must be positive.
|
||||
aging_interval: Insertions between halving all use counts, or None to
|
||||
disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
|
||||
eviction_sample: Entries sampled per eviction.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int,
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if max_entries <= 0:
|
||||
raise ValueError(f"max_entries must be positive, got {max_entries}")
|
||||
super().__init__(aging_interval, eviction_sample)
|
||||
self._max_entries = max_entries
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
return len(self._entries) > self._max_entries
|
||||
|
||||
def put(self, key: K, value: V, count: int = 1):
|
||||
"""
|
||||
Insert `value`, evicting the least-used entries past the bound.
|
||||
|
||||
Args:
|
||||
count: Initial use count. Pass a document-derived frequency to rank a
|
||||
preloaded entry ahead of words that have not been seen yet.
|
||||
"""
|
||||
existing = self._entries.get(key)
|
||||
if existing is not None:
|
||||
existing[_VALUE] = value
|
||||
existing[_COUNT] += 1
|
||||
return
|
||||
self._add_new(key, value)
|
||||
if count > 1:
|
||||
self._entries[key][_COUNT] = count
|
||||
self._evict_to_budget()
|
||||
self._maybe_age()
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return self._max_entries
|
||||
|
||||
def resize(self, max_entries: int):
|
||||
"""Change the bound, evicting immediately if the cache now overflows."""
|
||||
if max_entries <= 0:
|
||||
raise ValueError(f"max_entries must be positive, got {max_entries}")
|
||||
self._max_entries = max_entries
|
||||
self._evict_to_budget()
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Hit/miss/eviction counters and current occupancy."""
|
||||
stats = self._base_stats()
|
||||
stats['max_entries'] = self._max_entries
|
||||
return stats
|
||||
|
||||
|
||||
class SizedUsageCache(_UsageRanked[K, V]):
|
||||
"""
|
||||
Usage-ranked cache bounded by the total size of its values.
|
||||
|
||||
Args:
|
||||
max_bytes: Maximum total value size to retain. Must be positive.
|
||||
sizer: Returns the size in bytes of a value. Called once per insertion.
|
||||
aging_interval: Insertions between halving all use counts, or None to
|
||||
disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
|
||||
eviction_sample: Entries sampled per eviction.
|
||||
|
||||
A value larger than `max_bytes` on its own is returned to the caller but not
|
||||
retained, so that one oversized entry cannot flush the whole cache.
|
||||
"""
|
||||
|
||||
def __init__(self, max_bytes: int, sizer: Callable[[V], int],
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if max_bytes <= 0:
|
||||
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
||||
super().__init__(aging_interval, eviction_sample)
|
||||
self._max_bytes = max_bytes
|
||||
self._sizer = sizer
|
||||
self._sizes: Dict[K, int] = {}
|
||||
self._total_bytes = 0
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
return self._total_bytes > self._max_bytes
|
||||
|
||||
def _record_add(self, key: K, value: V):
|
||||
size = self._sizer(value)
|
||||
self._sizes[key] = size
|
||||
self._total_bytes += size
|
||||
|
||||
def _record_remove(self, key: K):
|
||||
self._total_bytes -= self._sizes.pop(key)
|
||||
|
||||
def put(self, key: K, value: V, count: int = 1):
|
||||
"""
|
||||
Insert `value`, evicting the least-used entries past the bound.
|
||||
|
||||
Args:
|
||||
count: Initial use count. Pass a document-derived frequency to rank a
|
||||
preloaded entry ahead of words that have not been seen yet.
|
||||
"""
|
||||
if key in self._entries:
|
||||
# Re-measure: the replacement may be a different size.
|
||||
self._remove(key)
|
||||
|
||||
if self._sizer(value) > self._max_bytes:
|
||||
# Too large to ever retain; skip rather than flush everything for it.
|
||||
return
|
||||
|
||||
self._add_new(key, value)
|
||||
if count > 1:
|
||||
self._entries[key][_COUNT] = count
|
||||
self._evict_to_budget()
|
||||
self._maybe_age()
|
||||
|
||||
@property
|
||||
def max_bytes(self) -> int:
|
||||
return self._max_bytes
|
||||
|
||||
@property
|
||||
def total_bytes(self) -> int:
|
||||
return self._total_bytes
|
||||
|
||||
def resize(self, max_bytes: int):
|
||||
"""Change the bound, evicting immediately if the cache now overflows."""
|
||||
if max_bytes <= 0:
|
||||
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
||||
self._max_bytes = max_bytes
|
||||
self._evict_to_budget()
|
||||
|
||||
def clear(self):
|
||||
"""Drop all entries. Counters are preserved."""
|
||||
super().clear()
|
||||
self._sizes.clear()
|
||||
self._total_bytes = 0
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Hit/miss/eviction counters and current occupancy."""
|
||||
stats = self._base_stats()
|
||||
stats['total_bytes'] = self._total_bytes
|
||||
stats['max_bytes'] = self._max_bytes
|
||||
return stats
|
||||
@@ -6,12 +6,16 @@ managing highlight collections, and rendering highlights on pages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HighlightColor(Enum):
|
||||
"""Predefined highlight colors with RGBA values"""
|
||||
@@ -44,6 +48,12 @@ class Highlight:
|
||||
start_word_index: Optional[int] = None # Word index in document (if available)
|
||||
end_word_index: Optional[int] = None
|
||||
|
||||
# Where in the document this highlight lives, as a serialized
|
||||
# RenderingPosition. `bounds` are pixel coordinates on one particular
|
||||
# rendering, so they stop matching as soon as the font scale or page size
|
||||
# changes; this survives repagination and is what page association uses.
|
||||
position: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Metadata
|
||||
note: Optional[str] = None # Optional annotation
|
||||
tags: List[str] = None # Optional categorization tags
|
||||
@@ -63,6 +73,7 @@ class Highlight:
|
||||
'text': self.text,
|
||||
'start_word_index': self.start_word_index,
|
||||
'end_word_index': self.end_word_index,
|
||||
'position': self.position,
|
||||
'note': self.note,
|
||||
'tags': self.tags,
|
||||
'timestamp': self.timestamp
|
||||
@@ -78,6 +89,7 @@ class Highlight:
|
||||
text=data['text'],
|
||||
start_word_index=data.get('start_word_index'),
|
||||
end_word_index=data.get('end_word_index'),
|
||||
position=data.get('position'),
|
||||
note=data.get('note'),
|
||||
tags=data.get('tags', []),
|
||||
timestamp=data.get('timestamp')
|
||||
@@ -100,12 +112,9 @@ class HighlightManager:
|
||||
highlights_dir: Directory to store highlight data
|
||||
"""
|
||||
self.document_id = document_id
|
||||
self.highlights_dir = Path(highlights_dir)
|
||||
self.highlights_dir = ensure_dir(highlights_dir)
|
||||
self.highlights: Dict[str, Highlight] = {} # id -> Highlight
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
self.highlights_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing highlights
|
||||
self._load_highlights()
|
||||
|
||||
@@ -178,34 +187,22 @@ class HighlightManager:
|
||||
|
||||
def _save_highlights(self) -> None:
|
||||
"""Persist highlights to disk"""
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
data = {
|
||||
'document_id': self.document_id,
|
||||
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||
}
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving highlights: {e}")
|
||||
write_json(self._get_filepath(), {
|
||||
'document_id': self.document_id,
|
||||
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||
})
|
||||
|
||||
def _load_highlights(self) -> None:
|
||||
"""Load highlights from disk"""
|
||||
data = read_json(self._get_filepath(), {})
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
if not filepath.exists():
|
||||
return
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.highlights = {
|
||||
h['id']: Highlight.from_dict(h)
|
||||
for h in data.get('highlights', [])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error loading highlights: {e}")
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
logger.warning("Highlight file %s is not in the expected shape; ignoring it",
|
||||
self._get_filepath(), exc_info=True)
|
||||
self.highlights = {}
|
||||
|
||||
|
||||
@@ -213,16 +210,18 @@ def create_highlight_from_query_result(
|
||||
result,
|
||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None,
|
||||
position: Optional[Dict[str, Any]] = None
|
||||
) -> Highlight:
|
||||
"""
|
||||
Create a highlight from a QueryResult.
|
||||
|
||||
Args:
|
||||
result: QueryResult from query_pixel or query_range
|
||||
result: QueryResult from query_point or query_range
|
||||
color: RGBA color tuple
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
position: Serialized RenderingPosition of the page the result came from
|
||||
|
||||
Returns:
|
||||
Highlight instance
|
||||
@@ -243,6 +242,7 @@ def create_highlight_from_query_result(
|
||||
bounds=bounds,
|
||||
color=color,
|
||||
text=text,
|
||||
position=position,
|
||||
note=note,
|
||||
tags=tags or [],
|
||||
timestamp=time()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Small JSON-file helpers shared by the per-document stores.
|
||||
|
||||
BookmarkManager and HighlightManager both keep a JSON file per document under a
|
||||
directory, and both had their own copy of "make the directory, try to read it,
|
||||
swallow and print on failure". The duplication is the point of this module; the
|
||||
file formats themselves stay owned by each store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_dir(path: str | Path) -> Path:
|
||||
"""Return `path` as a Path, creating it and any missing parents."""
|
||||
directory = Path(path)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def read_json(path: Path, default: Any) -> Any:
|
||||
"""
|
||||
Read JSON from `path`, returning `default` if it is missing or unreadable.
|
||||
|
||||
A corrupt store must not stop a book from opening, so failures are logged
|
||||
and swallowed. `default` is returned as given, so pass a fresh mutable if
|
||||
the caller intends to mutate it.
|
||||
"""
|
||||
if not path.exists():
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
logger.warning("Could not read %s; ignoring its contents", path, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> bool:
|
||||
"""
|
||||
Write `data` to `path` as JSON.
|
||||
|
||||
Returns True on success. Failures are logged rather than raised: losing a
|
||||
bookmark is not a reason to take down the reader.
|
||||
"""
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
return True
|
||||
except (OSError, TypeError, ValueError):
|
||||
logger.error("Could not write %s", path, exc_info=True)
|
||||
return False
|
||||
@@ -446,17 +446,43 @@ class EPUBReader:
|
||||
|
||||
def _process_chapter_images(self, chapter: Chapter):
|
||||
"""
|
||||
Process images in a single chapter.
|
||||
Load and process images in a single chapter.
|
||||
|
||||
This method loads images from disk into memory and applies image processing.
|
||||
Images must be loaded before the temporary EPUB directory is cleaned up.
|
||||
|
||||
Args:
|
||||
chapter: The chapter containing images to process
|
||||
"""
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from PIL import Image as PILImage
|
||||
import io
|
||||
|
||||
for block in chapter.blocks:
|
||||
if isinstance(block, AbstractImage):
|
||||
# Only process if image has been loaded and processor is enabled
|
||||
if hasattr(block, '_loaded_image') and block._loaded_image:
|
||||
# Load image into memory if not already loaded
|
||||
if not hasattr(block, '_loaded_image') or not block._loaded_image:
|
||||
try:
|
||||
# Load the image from the source path
|
||||
if os.path.isfile(block.source):
|
||||
with open(block.source, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
# Create PIL image from bytes in memory
|
||||
pil_image = PILImage.open(io.BytesIO(image_bytes))
|
||||
pil_image.load() # Force loading into memory
|
||||
block._loaded_image = pil_image.copy() # Create a copy to ensure it persists
|
||||
|
||||
# Set width and height on the block from the loaded image
|
||||
# This is required for layout calculations
|
||||
block._width = pil_image.width
|
||||
block._height = pil_image.height
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load image '{block.source}': {str(e)}")
|
||||
# Continue without the image
|
||||
continue
|
||||
|
||||
# Apply image processing if enabled and image is loaded
|
||||
if self.image_processor and hasattr(block, '_loaded_image') and block._loaded_image:
|
||||
try:
|
||||
block._loaded_image = self.image_processor(block._loaded_image)
|
||||
except Exception as e:
|
||||
@@ -466,10 +492,12 @@ class EPUBReader:
|
||||
# Continue with unprocessed image
|
||||
|
||||
def _process_content_images(self):
|
||||
"""Apply image processing to all images in chapters."""
|
||||
if not self.image_processor:
|
||||
return
|
||||
"""
|
||||
Load all images into memory and apply image processing.
|
||||
|
||||
This must be called before the temporary EPUB directory is cleaned up,
|
||||
to ensure images are loaded from disk into memory.
|
||||
"""
|
||||
for chapter in self.book.chapters:
|
||||
self._process_chapter_images(chapter)
|
||||
|
||||
@@ -527,8 +555,11 @@ class EPUBReader:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
# Parse HTML and add blocks to chapter
|
||||
blocks = parse_html_string(html, document=self.book)
|
||||
# Get the directory of the HTML file for resolving relative paths
|
||||
html_dir = os.path.dirname(path)
|
||||
|
||||
# Parse HTML and add blocks to chapter, passing base_path for image resolution
|
||||
blocks = parse_html_string(html, document=self.book, base_path=html_dir)
|
||||
|
||||
# Copy blocks to the chapter
|
||||
for block in blocks:
|
||||
|
||||
@@ -8,6 +8,7 @@ Each handler function has a robust signature that handles style hints, CSS class
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from bs4.element import CData, Comment, Doctype, ProcessingInstruction
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block,
|
||||
@@ -41,6 +42,7 @@ class StyleContext(NamedTuple):
|
||||
element_attributes: Dict[str, Any]
|
||||
parent_elements: List[str] # Stack of parent element names
|
||||
document: Optional[Any] # Reference to document for font registry
|
||||
base_path: Optional[str] = None # Base path for resolving relative URLs
|
||||
|
||||
def with_font(self, font: Font) -> "StyleContext":
|
||||
"""Create new context with modified font."""
|
||||
@@ -71,25 +73,35 @@ class StyleContext(NamedTuple):
|
||||
|
||||
def create_base_context(
|
||||
base_font: Optional[Font] = None,
|
||||
document=None) -> StyleContext:
|
||||
document=None,
|
||||
base_path: Optional[str] = None) -> StyleContext:
|
||||
"""
|
||||
Create a base style context with default values.
|
||||
|
||||
Args:
|
||||
base_font: Base font to use, defaults to system default
|
||||
document: Document instance for font registry
|
||||
base_path: Base directory path for resolving relative URLs
|
||||
|
||||
Returns:
|
||||
StyleContext with default values
|
||||
"""
|
||||
# Use document's font registry if available, otherwise create default font
|
||||
if base_font is None:
|
||||
if document and hasattr(document, 'get_or_create_font'):
|
||||
base_font = document.get_or_create_font()
|
||||
else:
|
||||
base_font = Font()
|
||||
|
||||
return StyleContext(
|
||||
font=base_font or Font(),
|
||||
font=base_font,
|
||||
background=None,
|
||||
css_classes=set(),
|
||||
css_styles={},
|
||||
element_attributes={},
|
||||
parent_elements=[],
|
||||
document=document,
|
||||
base_path=base_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -358,6 +370,24 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
element: BeautifulSoup Tag object
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
List of Word objects (including LinkedWord for hyperlinks)
|
||||
"""
|
||||
return extract_words_from_nodes(list(element.children), context)
|
||||
|
||||
|
||||
def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]:
|
||||
"""
|
||||
Extract words from a sequence of sibling nodes.
|
||||
|
||||
Separated from extract_text_content so that a container holding a mix of
|
||||
inline and block children can hand over just the inline runs, without
|
||||
building a synthetic element to wrap them in.
|
||||
|
||||
Args:
|
||||
nodes: BeautifulSoup nodes (Tags and NavigableStrings) in document order
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
List of Word objects (including LinkedWord for hyperlinks)
|
||||
"""
|
||||
@@ -366,15 +396,20 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
|
||||
words = []
|
||||
|
||||
for child in element.children:
|
||||
for child in nodes:
|
||||
# Comments and processing instructions are NavigableString subclasses;
|
||||
# their text is markup, not content.
|
||||
if isinstance(child, (Comment, Doctype, CData, ProcessingInstruction)):
|
||||
continue
|
||||
|
||||
if isinstance(child, NavigableString):
|
||||
# Plain text - split into words
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
word_texts = text.split()
|
||||
for word_text in word_texts:
|
||||
if word_text:
|
||||
words.append(Word(word_text, context.font, context.background))
|
||||
# Plain text - split into words. Argument-less str.split() already
|
||||
# discards surrounding whitespace and never yields an empty string, so
|
||||
# it needs neither a preceding strip() nor a per-word emptiness test.
|
||||
font = context.font
|
||||
background = context.background
|
||||
words.extend([Word(word_text, font, background)
|
||||
for word_text in str(child).split()])
|
||||
elif isinstance(child, Tag):
|
||||
# Special handling for <a> tags (hyperlinks)
|
||||
if child.name.lower() == "a":
|
||||
@@ -455,6 +490,93 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
return words
|
||||
|
||||
|
||||
# Tags that flow within a line of text rather than forming a block of their own.
|
||||
# They carry no handler of their own: extract_words_from_nodes consumes them,
|
||||
# applying their styling to the words they contain.
|
||||
INLINE_TAGS = frozenset({
|
||||
"a", "span", "strong", "b", "em", "i", "u", "s", "del", "ins", "mark",
|
||||
"small", "sub", "sup", "code", "q", "cite", "abbr", "time",
|
||||
})
|
||||
|
||||
|
||||
def is_inline(node) -> bool:
|
||||
"""
|
||||
Whether a node belongs to a run of text rather than standing as its own block.
|
||||
|
||||
Args:
|
||||
node: A BeautifulSoup Tag or NavigableString
|
||||
|
||||
Returns:
|
||||
True for text and inline tags, False for block-level tags
|
||||
"""
|
||||
if isinstance(node, Tag):
|
||||
return node.name.lower() in INLINE_TAGS
|
||||
if isinstance(node, (Comment, Doctype, CData, ProcessingInstruction)):
|
||||
return False
|
||||
return isinstance(node, NavigableString)
|
||||
|
||||
|
||||
def process_block_children(element: Tag, context: StyleContext) -> List[Block]:
|
||||
"""
|
||||
Process a container's children into a list of blocks.
|
||||
|
||||
Containers may hold a mix of inline and block content. Consecutive inline
|
||||
children are gathered into a run and become one Paragraph; a block child ends
|
||||
the current run and is processed by its own handler. This is the single entry
|
||||
point for every container that is not itself a paragraph - div, li, td, th,
|
||||
blockquote and the semantic containers.
|
||||
|
||||
Without this, inline tags reach process_element, whose handler for them is
|
||||
ignore_handler, and their text is silently dropped.
|
||||
|
||||
Args:
|
||||
element: The container element
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
Blocks in document order
|
||||
"""
|
||||
blocks: List[Block] = []
|
||||
run: List = []
|
||||
|
||||
def flush_run():
|
||||
"""Turn the pending inline run into a paragraph, if it holds any words."""
|
||||
if not run:
|
||||
return
|
||||
words = extract_words_from_nodes(run, context)
|
||||
run.clear()
|
||||
if words:
|
||||
paragraph = Paragraph(context.font)
|
||||
for word in words:
|
||||
paragraph.add_word(word)
|
||||
blocks.append(paragraph)
|
||||
|
||||
for child in element.children:
|
||||
# <br> ends the current line of text and starts a new one.
|
||||
if isinstance(child, Tag) and child.name.lower() == "br":
|
||||
flush_run()
|
||||
continue
|
||||
|
||||
if is_inline(child):
|
||||
run.append(child)
|
||||
continue
|
||||
|
||||
if not isinstance(child, Tag):
|
||||
continue # comments and similar
|
||||
|
||||
flush_run()
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
blocks.extend(result)
|
||||
else:
|
||||
blocks.append(result)
|
||||
|
||||
flush_run()
|
||||
return blocks
|
||||
|
||||
|
||||
def process_element(
|
||||
element: Tag, context: StyleContext
|
||||
) -> Union[Block, List[Block], None]:
|
||||
@@ -478,8 +600,65 @@ def process_element(
|
||||
# Union[Block, List[Block], None]
|
||||
|
||||
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
"""Handle <p> elements."""
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, List[Block], Image]:
|
||||
"""
|
||||
Handle <p> elements.
|
||||
|
||||
Special handling for paragraphs containing images:
|
||||
- If the paragraph contains only an image (common in EPUBs), return the image block
|
||||
- If the paragraph contains images mixed with text, split into separate blocks
|
||||
- Otherwise, return a normal paragraph with text content
|
||||
"""
|
||||
# Check if paragraph contains any img tags (including nested ones)
|
||||
img_tags = element.find_all('img')
|
||||
|
||||
if img_tags:
|
||||
# Paragraph contains images - need special handling
|
||||
blocks = []
|
||||
|
||||
# Check if this is an image-only paragraph (very common in EPUBs)
|
||||
# Get text content without the img tags
|
||||
text_content = element.get_text(strip=True)
|
||||
|
||||
if not text_content or len(text_content.strip()) == 0:
|
||||
# Image-only paragraph - return just the image(s)
|
||||
for img_tag in img_tags:
|
||||
child_context = apply_element_styling(context, img_tag)
|
||||
img_block = image_handler(img_tag, child_context)
|
||||
if img_block:
|
||||
blocks.append(img_block)
|
||||
|
||||
# Return single image or list of images
|
||||
if len(blocks) == 1:
|
||||
return blocks[0]
|
||||
return blocks if blocks else Paragraph(context.font)
|
||||
|
||||
# Mixed content - paragraph has both text and images
|
||||
# Process children in order to preserve structure
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
if child.name == 'img':
|
||||
# Add the image as a separate block
|
||||
child_context = apply_element_styling(context, child)
|
||||
img_block = image_handler(child, child_context)
|
||||
if img_block:
|
||||
blocks.append(img_block)
|
||||
else:
|
||||
# Process other inline elements as part of text
|
||||
# This will be handled by extract_text_content below
|
||||
pass
|
||||
|
||||
# Also add a paragraph with the text content
|
||||
paragraph = Paragraph(context.font)
|
||||
words = extract_text_content(element, context)
|
||||
if words:
|
||||
for word in words:
|
||||
paragraph.add_word(word)
|
||||
blocks.insert(0, paragraph) # Text comes before images
|
||||
|
||||
return blocks if blocks else Paragraph(context.font)
|
||||
|
||||
# No images - normal paragraph handling
|
||||
paragraph = Paragraph(context.font)
|
||||
words = extract_text_content(element, context)
|
||||
for word in words:
|
||||
@@ -489,17 +668,7 @@ def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
|
||||
def div_handler(element: Tag, context: StyleContext) -> List[Block]:
|
||||
"""Handle <div> elements - treat as generic container."""
|
||||
blocks = []
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
blocks.extend(result)
|
||||
else:
|
||||
blocks.append(result)
|
||||
return blocks
|
||||
return process_block_children(element, context)
|
||||
|
||||
|
||||
def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||
@@ -524,16 +693,8 @@ def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||
def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
|
||||
"""Handle <blockquote> elements."""
|
||||
quote = Quote(context.font)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
quote.add_block(block)
|
||||
else:
|
||||
quote.add_block(result)
|
||||
for block in process_block_children(element, context):
|
||||
quote.add_block(block)
|
||||
return quote
|
||||
|
||||
|
||||
@@ -587,28 +748,8 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
|
||||
def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
|
||||
"""Handle <li> elements."""
|
||||
list_item = ListItem(None, context.font)
|
||||
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
list_item.add_block(block)
|
||||
else:
|
||||
list_item.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in list item - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
list_item.add_block(paragraph)
|
||||
|
||||
for block in process_block_children(element, context):
|
||||
list_item.add_block(block)
|
||||
return list_item
|
||||
|
||||
|
||||
@@ -660,27 +801,8 @@ def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(False, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in cell - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -691,26 +813,8 @@ def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(True, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content (same as td)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -728,9 +832,19 @@ def line_break_handler(element: Tag, context: StyleContext) -> None:
|
||||
|
||||
def image_handler(element: Tag, context: StyleContext) -> Image:
|
||||
"""Handle <img> elements."""
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
src = context.element_attributes.get("src", "")
|
||||
alt_text = context.element_attributes.get("alt", "")
|
||||
|
||||
# Resolve relative paths if base_path is provided
|
||||
if context.base_path and src and not src.startswith(('http://', 'https://', '/')):
|
||||
# Parse the src to handle URL-encoded characters
|
||||
src_decoded = urllib.parse.unquote(src)
|
||||
# Resolve relative path to absolute path
|
||||
src = os.path.normpath(os.path.join(context.base_path, src_decoded))
|
||||
|
||||
# Parse dimensions if provided
|
||||
width = height = None
|
||||
try:
|
||||
@@ -819,7 +933,7 @@ HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None
|
||||
|
||||
|
||||
def parse_html_string(
|
||||
html_string: str, base_font: Optional[Font] = None, document=None
|
||||
html_string: str, base_font: Optional[Font] = None, document=None, base_path: Optional[str] = None
|
||||
) -> List[Block]:
|
||||
"""
|
||||
Parse HTML string and return list of Block objects.
|
||||
@@ -828,12 +942,14 @@ def parse_html_string(
|
||||
html_string: HTML content to parse
|
||||
base_font: Base font for styling, defaults to system default
|
||||
document: Document instance for font registry to avoid duplicate fonts
|
||||
base_path: Base directory path for resolving relative URLs (e.g., image sources)
|
||||
|
||||
Returns:
|
||||
List of Block objects representing the document structure
|
||||
"""
|
||||
soup = BeautifulSoup(html_string, "html.parser")
|
||||
context = create_base_context(base_font, document)
|
||||
context = create_base_context(base_font, document, base_path)
|
||||
|
||||
blocks = []
|
||||
|
||||
# Process the body if it exists, otherwise process all top-level elements
|
||||
|
||||
@@ -8,7 +8,7 @@ from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, Heading, PageBreak, Table
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
@@ -51,6 +51,15 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
# We need to get word spacing constraints from the Font's abstract style if available
|
||||
# For now, use reasonable defaults based on font size
|
||||
|
||||
# Alignment for text that does not specify its own. Headings are never
|
||||
# justified - stretching a two-word title across the measure is always wrong -
|
||||
# so they fall back to flush left.
|
||||
default_alignment = getattr(page.style, 'default_alignment', None)
|
||||
if not isinstance(default_alignment, Alignment):
|
||||
default_alignment = Alignment.JUSTIFY
|
||||
if isinstance(paragraph, Heading):
|
||||
default_alignment = Alignment.LEFT
|
||||
|
||||
if isinstance(paragraph.style, Font):
|
||||
# paragraph.style is already a Font (concrete style)
|
||||
font = paragraph.style
|
||||
@@ -59,7 +68,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
min_spacing = float(font.font_size) * 0.25 # 25% of font size
|
||||
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
||||
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
||||
text_align = Alignment.LEFT # Default alignment
|
||||
text_align = default_alignment
|
||||
else:
|
||||
# paragraph.style is an AbstractStyle, resolve it
|
||||
# Ensure font_size is an int (it could be a FontSize enum)
|
||||
@@ -79,7 +88,8 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
int(concrete_style.word_spacing_min),
|
||||
int(concrete_style.word_spacing_max)
|
||||
)
|
||||
text_align = concrete_style.text_align
|
||||
# text_align is None when the source did not specify one.
|
||||
text_align = concrete_style.text_align or default_alignment
|
||||
|
||||
# Apply page-level word spacing override if specified
|
||||
if hasattr(
|
||||
@@ -100,15 +110,28 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
|
||||
# Cap font size to page maximum if needed
|
||||
if font.font_size > page.style.max_font_size:
|
||||
font = Font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
# Use paragraph's font registry to create the capped font
|
||||
if hasattr(paragraph, 'get_or_create_font'):
|
||||
font = paragraph.get_or_create_font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
else:
|
||||
# Fallback to direct creation (will still use global cache)
|
||||
font = Font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
|
||||
# Calculate baseline-to-baseline spacing: font size + additional line spacing
|
||||
# This is the vertical distance between baselines of consecutive lines
|
||||
@@ -138,20 +161,17 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
y_cursor = page._current_y_offset
|
||||
else:
|
||||
y_cursor = page._current_y_offset
|
||||
x_cursor = page.border_size
|
||||
x_cursor = page.content_origin[0]
|
||||
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
temp_text.width
|
||||
else:
|
||||
pass
|
||||
# `word` is accepted for call-site readability only: the line that is about
|
||||
# to be created measures it when it is added, so measuring it here as well
|
||||
# only paid for a Text object that was immediately discarded.
|
||||
|
||||
return Line(
|
||||
spacing=word_spacing_constraints,
|
||||
origin=(x_cursor, y_cursor),
|
||||
size=(page.available_width, baseline_spacing),
|
||||
draw=page.draw,
|
||||
draw=page.measurement_draw,
|
||||
font=font,
|
||||
halign=text_align
|
||||
)
|
||||
@@ -202,7 +222,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
return False, i, overflow_text
|
||||
|
||||
# Check if the word will fit on the new line before adding it
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
temp_text = Text.from_word(word, page.measurement_draw)
|
||||
if temp_text.width > current_line.size[0]:
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
@@ -211,13 +231,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
(Text(
|
||||
pair[0],
|
||||
word.style,
|
||||
page.draw,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word),
|
||||
Text(
|
||||
pair[1],
|
||||
word.style,
|
||||
page.draw,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
@@ -247,7 +267,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
else:
|
||||
current_pretext = overflow_text # May be None or hyphenated remainder
|
||||
|
||||
# All words processed successfully
|
||||
# All words processed successfully. The line holding the final word is the
|
||||
# end of the paragraph, so it is rendered at its natural width rather than
|
||||
# justified to the full column. A paragraph continued on the next page does
|
||||
# not reach here, so its lines stay justified - which is correct.
|
||||
if current_line is not None:
|
||||
current_line.is_paragraph_end = True
|
||||
|
||||
return True, None, None
|
||||
|
||||
|
||||
@@ -292,7 +318,12 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
max_width = page.available_width
|
||||
|
||||
# Calculate available height on page
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# If no space available, image doesn't fit
|
||||
if available_height <= 0:
|
||||
return False
|
||||
|
||||
if max_height is None:
|
||||
max_height = available_height
|
||||
else:
|
||||
@@ -307,7 +338,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
return False
|
||||
|
||||
# Create renderable image
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
@@ -350,7 +381,7 @@ def table_layouter(
|
||||
"""
|
||||
# Calculate available space
|
||||
available_width = page.available_width
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
@@ -370,7 +401,7 @@ def table_layouter(
|
||||
|
||||
# Check if table fits on current page
|
||||
table_height = renderer.size[1]
|
||||
available_height = page.size[1] - y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
if table_height > available_height:
|
||||
return False
|
||||
@@ -418,10 +449,10 @@ def button_layouter(button: Button,
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create ButtonText renderable
|
||||
button_text = ButtonText(button, font, page.draw, padding=padding)
|
||||
button_text = ButtonText(button, font, page.measurement_draw, padding=padding)
|
||||
|
||||
# Check if button fits on current page
|
||||
button_height = button_text.size[1]
|
||||
@@ -429,7 +460,7 @@ def button_layouter(button: Button,
|
||||
return False, ""
|
||||
|
||||
# Position the button
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
button_text.set_origin(np.array([x_offset, y_offset]))
|
||||
@@ -468,10 +499,11 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create FormFieldText renderable
|
||||
field_text = FormFieldText(field, font, page.draw, field_height=field_height)
|
||||
field_text = FormFieldText(field, font, page.measurement_draw,
|
||||
field_height=field_height)
|
||||
|
||||
# Check if field fits on current page
|
||||
total_field_height = field_text.size[1]
|
||||
@@ -479,7 +511,7 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
||||
return False, ""
|
||||
|
||||
# Position the field
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
field_text.set_origin(np.array([x_offset, y_offset]))
|
||||
|
||||
@@ -15,13 +15,16 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Tuple, Optional, Any
|
||||
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell,
|
||||
HList, ListItem, Quote, Image)
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +43,19 @@ class RenderingPosition:
|
||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||
page_y_offset: int = 0 # Vertical position on page
|
||||
|
||||
def _key(self) -> Tuple[Any, ...]:
|
||||
"""
|
||||
The fields in declaration order.
|
||||
|
||||
Copying, comparing and hashing a position all used to go through
|
||||
dataclasses.asdict, which walks the field list and deep-copies each value.
|
||||
Every field here is an immutable scalar, so that traversal bought nothing
|
||||
and these three run constantly during page navigation and buffer lookups.
|
||||
"""
|
||||
return (self.chapter_index, self.block_index, self.word_index,
|
||||
self.table_row, self.table_col, self.list_item_index,
|
||||
self.remaining_pretext, self.page_y_offset)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize position for saving to file/database"""
|
||||
return asdict(self)
|
||||
@@ -51,17 +67,17 @@ class RenderingPosition:
|
||||
|
||||
def copy(self) -> 'RenderingPosition':
|
||||
"""Create a copy of this position"""
|
||||
return RenderingPosition(**asdict(self))
|
||||
return RenderingPosition(*self._key())
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
"""Check if two positions are equal"""
|
||||
if not isinstance(other, RenderingPosition):
|
||||
return False
|
||||
return asdict(self) == asdict(other)
|
||||
return self._key() == other._key()
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Make position hashable for use as dict key"""
|
||||
return hash(tuple(asdict(self).values()))
|
||||
return hash(self._key())
|
||||
|
||||
|
||||
class ChapterInfo:
|
||||
@@ -94,6 +110,26 @@ class ChapterNavigator:
|
||||
"""Scan blocks for headings and build chapter navigation map"""
|
||||
current_chapter_index = 0
|
||||
|
||||
# Check if first block is a cover image and add it to TOC
|
||||
if self.blocks and isinstance(self.blocks[0], Image):
|
||||
cover_position = RenderingPosition(
|
||||
chapter_index=0,
|
||||
block_index=0,
|
||||
word_index=0,
|
||||
table_row=0,
|
||||
table_col=0,
|
||||
list_item_index=0
|
||||
)
|
||||
|
||||
cover_info = ChapterInfo(
|
||||
title="Cover",
|
||||
level=HeadingLevel.H1, # Treat as top-level entry
|
||||
position=cover_position,
|
||||
block_index=0
|
||||
)
|
||||
|
||||
self.chapters.append(cover_info)
|
||||
|
||||
for block_index, block in enumerate(self.blocks):
|
||||
if isinstance(block, Heading):
|
||||
# Create position for this heading
|
||||
@@ -161,32 +197,50 @@ class ChapterNavigator:
|
||||
return self.chapters[0] if self.chapters else None
|
||||
|
||||
|
||||
class FontScaler:
|
||||
class FontFamilyOverride:
|
||||
"""
|
||||
Handles font scaling operations for ereader font size adjustments.
|
||||
Applies scaling at layout/render time while preserving original font objects.
|
||||
Manages font family preferences for ereader rendering.
|
||||
Allows dynamic font family switching without modifying source blocks.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def scale_font(font: Font, scale_factor: float) -> Font:
|
||||
def __init__(self, preferred_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
Initialize font family override.
|
||||
|
||||
Args:
|
||||
preferred_family: Preferred bundled font family (None = use original fonts)
|
||||
"""
|
||||
self.preferred_family = preferred_family
|
||||
|
||||
def override_font(self, font: Font) -> Font:
|
||||
"""
|
||||
Create a new font with the preferred family while preserving other attributes.
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size
|
||||
Font with overridden family, or original if no override is set
|
||||
"""
|
||||
if scale_factor == 1.0:
|
||||
if self.preferred_family is None:
|
||||
return font
|
||||
|
||||
scaled_size = max(1, int(font.font_size * scale_factor))
|
||||
# Get the appropriate font path for the preferred family
|
||||
# preserving the original font's weight and style
|
||||
new_font_path = get_bundled_font_path(
|
||||
family=self.preferred_family,
|
||||
weight=font.weight,
|
||||
style=font.style
|
||||
)
|
||||
|
||||
# If we couldn't find a matching font, fall back to original
|
||||
if new_font_path is None:
|
||||
return font
|
||||
|
||||
# Create a new font with the overridden path
|
||||
return Font(
|
||||
font_path=font._font_path,
|
||||
font_size=scaled_size,
|
||||
font_path=new_font_path,
|
||||
font_size=font.font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
@@ -196,6 +250,49 @@ class FontScaler:
|
||||
min_hyphenation_width=font.min_hyphenation_width
|
||||
)
|
||||
|
||||
|
||||
class FontScaler:
|
||||
"""
|
||||
Handles font scaling operations for ereader font size adjustments.
|
||||
Applies scaling at layout/render time while preserving original font objects.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
family_override: Optional font family override
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size and optional family override
|
||||
"""
|
||||
# Apply family override first if specified
|
||||
working_font = font
|
||||
if family_override is not None:
|
||||
working_font = family_override.override_font(font)
|
||||
|
||||
# Then apply scaling
|
||||
if scale_factor == 1.0:
|
||||
return working_font
|
||||
|
||||
scaled_size = max(1, int(working_font.font_size * scale_factor))
|
||||
|
||||
return Font(
|
||||
font_path=working_font._font_path,
|
||||
font_size=scaled_size,
|
||||
colour=working_font.colour,
|
||||
weight=working_font.weight,
|
||||
style=working_font.style,
|
||||
decoration=working_font.decoration,
|
||||
background=working_font.background,
|
||||
language=working_font.language,
|
||||
min_hyphenation_width=working_font.min_hyphenation_width
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def scale_word_spacing(spacing: Tuple[int, int],
|
||||
scale_factor: float) -> Tuple[int, int]:
|
||||
@@ -222,12 +319,27 @@ class BidirectionalLayouter:
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600),
|
||||
alignment_override=None):
|
||||
alignment_override=None,
|
||||
font_family_override: Optional[FontFamilyOverride] = None):
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.page_size = page_size
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.alignment_override = alignment_override
|
||||
self.font_family_override = font_family_override
|
||||
|
||||
# Maps (font_scale, end position) -> the position the page started at.
|
||||
# Filled in as pages are laid out forward, which makes "previous page"
|
||||
# exact and free for anywhere the reader has already been. Keyed by font
|
||||
# scale because changing it repaginates the document.
|
||||
self._page_chain: Dict[Tuple[float, Tuple[int, int, int]],
|
||||
RenderingPosition] = {}
|
||||
|
||||
# Scaled copies of blocks, keyed by (id(block), font_scale). Rebuilding
|
||||
# a block's words on every page render allocated a fresh Paragraph and
|
||||
# Word per word on the hot path. The original block is kept alongside
|
||||
# the copy so its id cannot be recycled while it is a live key.
|
||||
self._scaled_block_cache: Dict[Tuple[int, float], Tuple[Block, Block]] = {}
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
@@ -260,7 +372,14 @@ class BidirectionalLayouter:
|
||||
scaled_block, page, current_pos, font_scale)
|
||||
|
||||
if not success:
|
||||
# Block doesn't fit, we're done with this page
|
||||
# The block did not fit in its entirety. It may still have been
|
||||
# laid out partially - a paragraph larger than one page places as
|
||||
# many lines as fit and reports the word it stopped at. Keeping
|
||||
# that resume point is what allows the next page to continue;
|
||||
# discarding it tells the caller no progress was made, which
|
||||
# dead-ends navigation on the block forever.
|
||||
if self._position_compare(new_pos, current_pos) > 0:
|
||||
current_pos = new_pos
|
||||
break
|
||||
|
||||
# Add inter-block spacing after successfully laying out a block
|
||||
@@ -276,65 +395,241 @@ class BidirectionalLayouter:
|
||||
|
||||
current_pos = new_pos
|
||||
|
||||
# Remember this link in the chain so stepping back to it later is exact.
|
||||
if self._position_compare(current_pos, position) > 0:
|
||||
self._page_chain[(font_scale, self._position_key(current_pos))] = \
|
||||
position.copy()
|
||||
|
||||
return page, current_pos
|
||||
|
||||
# How many block starts before the target to try as replay anchors before
|
||||
# settling for the best inexact answer.
|
||||
MAX_BACKWARD_ANCHORS = 4
|
||||
|
||||
# Ceiling on pages replayed from a single anchor, so a pathologically long
|
||||
# block cannot make one page turn walk an entire chapter.
|
||||
MAX_REPLAY_PAGES = 8
|
||||
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page that ends at the given position, filling backward.
|
||||
Critical for "previous page" navigation.
|
||||
Render the page that ends at the given position - "previous page".
|
||||
|
||||
Pagination is a pure function: laying out from a position q yields a page
|
||||
and the position where it stopped, next(q). The page before P is therefore
|
||||
the q for which next(q) == P, and it is found by *replaying* the chain
|
||||
forward from an anchor, not by guessing q.
|
||||
|
||||
The previous implementation searched instead: it estimated a block index
|
||||
and bisected on it, pinning word_index to 0. Pages routinely start
|
||||
mid-block, so the answer was frequently not in the search space at all -
|
||||
the search then exhausted its iterations and fell back to a position that
|
||||
was not the previous page, usually the start of the document.
|
||||
|
||||
Three sources are tried in order:
|
||||
|
||||
1. The recorded chain, from pages already laid out going forward. Exact,
|
||||
and the common case when the reader is paging back and forth.
|
||||
2. Replay from the start of the block containing P, then from
|
||||
progressively earlier blocks. Exact when P lies on the resulting chain.
|
||||
3. Failing an exact hit - which happens when P was reached by a jump or a
|
||||
restored bookmark rather than by reading forward, so it is on no
|
||||
natural chain - the latest page start before P. That overlaps P's page
|
||||
slightly rather than skipping content, which is the safe direction to
|
||||
be wrong in.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
end_position: Position where the page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
# This is a complex operation that requires iterative refinement
|
||||
# We'll start with an estimated start position and refine it
|
||||
document_start = RenderingPosition()
|
||||
|
||||
estimated_start = self._estimate_page_start(end_position, font_scale)
|
||||
# Nothing precedes the start of the document.
|
||||
if self._position_compare(end_position, document_start) <= 0:
|
||||
page, _ = self.render_page_forward(document_start, font_scale)
|
||||
return page, document_start
|
||||
|
||||
# Render forward from estimated start and see if we reach the target
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
# 1. The chain we have already walked.
|
||||
remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
|
||||
if remembered is not None:
|
||||
page, actual_end = self.render_page_forward(remembered, font_scale)
|
||||
if self._position_compare(actual_end, end_position) == 0:
|
||||
return page, remembered
|
||||
|
||||
# If we overshot or undershot, adjust and try again
|
||||
# This is a simplified implementation - a full version would be more
|
||||
# sophisticated
|
||||
if self._position_compare(actual_end, end_position) != 0:
|
||||
# Adjust estimate and try again (simplified)
|
||||
estimated_start = self._adjust_start_estimate(
|
||||
estimated_start, end_position, actual_end)
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
# 2/3. Replay from anchors, keeping the best inexact result as a fallback.
|
||||
fallback = None
|
||||
for anchor in self._backward_anchors(end_position):
|
||||
page, start, exact = self._replay_to(anchor, end_position, font_scale)
|
||||
if page is None:
|
||||
continue
|
||||
if exact:
|
||||
return page, start
|
||||
if fallback is None:
|
||||
fallback = (page, start)
|
||||
|
||||
return page, estimated_start
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
page, _ = self.render_page_forward(document_start, font_scale)
|
||||
return page, document_start
|
||||
|
||||
def _backward_anchors(self, target: RenderingPosition):
|
||||
"""
|
||||
Yield positions to replay from, nearest first.
|
||||
|
||||
Block starts are used as anchors because they are the coarsest positions
|
||||
that are certainly valid to lay out from. The block containing the target
|
||||
comes first: when the target is mid-block, the page before it usually
|
||||
starts in that same block or the one before.
|
||||
"""
|
||||
first_block = target.block_index if target.word_index > 0 \
|
||||
else target.block_index - 1
|
||||
|
||||
for offset in range(self.MAX_BACKWARD_ANCHORS):
|
||||
block_index = first_block - offset
|
||||
if block_index < 0:
|
||||
break
|
||||
yield RenderingPosition(
|
||||
chapter_index=target.chapter_index,
|
||||
block_index=block_index,
|
||||
word_index=0,
|
||||
)
|
||||
|
||||
if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
|
||||
yield RenderingPosition()
|
||||
|
||||
def _replay_to(self,
|
||||
anchor: RenderingPosition,
|
||||
target: RenderingPosition,
|
||||
font_scale: float):
|
||||
"""
|
||||
Lay out pages forward from `anchor`, looking for the one ending at `target`.
|
||||
|
||||
Returns:
|
||||
(page, start, exact). `exact` is True when a page ended precisely on
|
||||
the target. When the chain steps over the target instead, the last
|
||||
page starting before it is returned with exact=False. (None, None,
|
||||
False) means the anchor yielded nothing usable.
|
||||
"""
|
||||
position = anchor
|
||||
last = (None, None)
|
||||
|
||||
for _ in range(self.MAX_REPLAY_PAGES):
|
||||
if self._position_compare(position, target) >= 0:
|
||||
break
|
||||
|
||||
page, next_position = self.render_page_forward(position, font_scale)
|
||||
comparison = self._position_compare(next_position, target)
|
||||
|
||||
if comparison == 0:
|
||||
return page, position, True
|
||||
|
||||
if comparison > 0:
|
||||
# Stepped over the target: this chain does not pass through it.
|
||||
return last[0], last[1], False
|
||||
|
||||
if self._position_compare(next_position, position) <= 0:
|
||||
break # no progress; give up on this anchor
|
||||
|
||||
last = (page, position)
|
||||
position = next_position
|
||||
|
||||
return last[0], last[1], False
|
||||
|
||||
@staticmethod
|
||||
def _position_key(position: RenderingPosition) -> Tuple[int, int, int]:
|
||||
"""Hashable identity of a position, for the page chain map."""
|
||||
return (position.chapter_index, position.block_index, position.word_index)
|
||||
|
||||
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
|
||||
"""Apply font scaling to all fonts in a block"""
|
||||
if font_scale == 1.0:
|
||||
"""
|
||||
Apply font scaling and the font family override to every font in a block.
|
||||
|
||||
Returns the block unchanged when there is nothing to apply. Results are
|
||||
memoised per (block, scale) for the life of the layouter, so a page
|
||||
re-render at an unchanged scale costs a dict lookup.
|
||||
"""
|
||||
if font_scale == 1.0 and self.font_family_override is None:
|
||||
return block
|
||||
|
||||
# This is a simplified implementation
|
||||
# In practice, we'd need to handle each block type appropriately
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
scaled_block_style = FontScaler.scale_font(block.style, font_scale)
|
||||
if isinstance(block, Heading):
|
||||
scaled_block = Heading(block.level, scaled_block_style)
|
||||
else:
|
||||
scaled_block = Paragraph(scaled_block_style)
|
||||
key = (id(block), font_scale)
|
||||
cached = self._scaled_block_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached[1]
|
||||
|
||||
# words_iter() returns tuples of (position, word)
|
||||
for position, word in block.words_iter():
|
||||
scaled = self._build_scaled_block(block, font_scale)
|
||||
self._scaled_block_cache[key] = (block, scaled)
|
||||
return scaled
|
||||
|
||||
def _build_scaled_block(self, block: Block, font_scale: float) -> Block:
|
||||
"""Construct the scaled copy of a block. See _scale_block_fonts."""
|
||||
def scale(font: Font) -> Font:
|
||||
return FontScaler.scale_font(font, font_scale, self.font_family_override)
|
||||
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
if isinstance(block, Heading):
|
||||
scaled_block = Heading(block.level, scale(block.style))
|
||||
else:
|
||||
scaled_block = Paragraph(scale(block.style))
|
||||
|
||||
# words_iter() yields (position, word) tuples. with_style() keeps
|
||||
# the concrete word class, so a LinkedWord stays linked - rebuilding
|
||||
# these as plain Words silently stripped every hyperlink in the
|
||||
# document as soon as the reader changed font size.
|
||||
for _, word in block.words_iter():
|
||||
if isinstance(word, Word):
|
||||
scaled_word = Word(
|
||||
word.text, FontScaler.scale_font(
|
||||
word.style, font_scale))
|
||||
scaled_block.add_word(scaled_word)
|
||||
scaled_block.add_word(word.with_style(scale(word.style)))
|
||||
return scaled_block
|
||||
|
||||
if isinstance(block, Quote):
|
||||
scaled_quote = Quote(scale(block.style) if block.style else None)
|
||||
for child in block.blocks():
|
||||
scaled_quote.add_block(self._scale_block_fonts(child, font_scale))
|
||||
return scaled_quote
|
||||
|
||||
if isinstance(block, HList):
|
||||
scaled_list = HList(
|
||||
block.style,
|
||||
scale(block.default_style) if block.default_style else None)
|
||||
for item in block.items():
|
||||
scaled_item = ListItem(
|
||||
item.term,
|
||||
scale(item.style) if item.style else None)
|
||||
for child in item.blocks():
|
||||
scaled_item.add_block(self._scale_block_fonts(child, font_scale))
|
||||
scaled_list.add_item(scaled_item)
|
||||
return scaled_list
|
||||
|
||||
if isinstance(block, Table):
|
||||
scaled_table = Table(
|
||||
block.caption,
|
||||
scale(block.style) if block.style else None)
|
||||
# Rows must go back into the section they came from, or a <thead>
|
||||
# row would be re-added as a body row.
|
||||
for section, rows in (('header', block.header_rows()),
|
||||
('body', block.body_rows()),
|
||||
('footer', block.footer_rows())):
|
||||
for row in rows:
|
||||
scaled_row = TableRow(scale(row.style) if row.style else None)
|
||||
for cell in row.cells():
|
||||
scaled_cell = TableCell(
|
||||
is_header=cell.is_header,
|
||||
colspan=cell.colspan,
|
||||
rowspan=cell.rowspan,
|
||||
style=scale(cell.style) if cell.style else None)
|
||||
for child in cell.blocks():
|
||||
scaled_cell.add_block(self._scale_block_fonts(child, font_scale))
|
||||
scaled_row.add_cell(scaled_cell)
|
||||
scaled_table.add_row(scaled_row, section)
|
||||
return scaled_table
|
||||
|
||||
# Blocks with no fonts of their own (Image, HorizontalRule, PageBreak,
|
||||
# CodeBlock - which carries raw lines, not styled words) pass through.
|
||||
return block
|
||||
|
||||
def _layout_block_on_page(self,
|
||||
@@ -357,6 +652,8 @@ class BidirectionalLayouter:
|
||||
return self._layout_table_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, HList):
|
||||
return self._layout_list_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, Image):
|
||||
return self._layout_image_on_page(block, page, position, font_scale)
|
||||
else:
|
||||
# Skip unknown block types
|
||||
new_pos = position.copy()
|
||||
@@ -469,39 +766,45 @@ class BidirectionalLayouter:
|
||||
new_pos.list_item_index = 0
|
||||
return True, new_pos
|
||||
|
||||
def _estimate_page_start(
|
||||
self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float) -> RenderingPosition:
|
||||
"""Estimate where a page should start to end at the given position"""
|
||||
# This is a simplified heuristic - a full implementation would be more
|
||||
# sophisticated
|
||||
estimated_start = end_position.copy()
|
||||
def _layout_image_on_page(self,
|
||||
image: Image,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Layout an image on the page using the image_layouter.
|
||||
|
||||
# Move back by an estimated number of blocks that would fit on a page
|
||||
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
|
||||
estimated_start.block_index = max(
|
||||
0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.word_index = 0
|
||||
Args:
|
||||
image: The Image block to layout
|
||||
page: The page to layout on
|
||||
position: Current rendering position (should be at the start of this image block)
|
||||
font_scale: Font scaling factor (not used for images, but kept for consistency)
|
||||
|
||||
return estimated_start
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
- success: True if image was laid out, False if page ran out of space
|
||||
- new_position: Updated position (next block if success, same block if failed)
|
||||
"""
|
||||
# Try to layout the image on the current page
|
||||
success = image_layouter(
|
||||
image=image,
|
||||
page=page,
|
||||
max_width=None, # Use page available width
|
||||
max_height=None # Use page available height
|
||||
)
|
||||
|
||||
def _adjust_start_estimate(
|
||||
self,
|
||||
current_start: RenderingPosition,
|
||||
target_end: RenderingPosition,
|
||||
actual_end: RenderingPosition) -> RenderingPosition:
|
||||
"""Adjust start position estimate based on overshoot/undershoot"""
|
||||
# Simplified adjustment logic
|
||||
adjusted = current_start.copy()
|
||||
new_pos = position.copy()
|
||||
|
||||
comparison = self._position_compare(actual_end, target_end)
|
||||
if comparison > 0: # Overshot
|
||||
adjusted.block_index = max(0, adjusted.block_index + 1)
|
||||
elif comparison < 0: # Undershot
|
||||
adjusted.block_index = max(0, adjusted.block_index - 1)
|
||||
|
||||
return adjusted
|
||||
if success:
|
||||
# Image was successfully laid out, move to next block
|
||||
new_pos.block_index += 1
|
||||
new_pos.word_index = 0
|
||||
return True, new_pos
|
||||
else:
|
||||
# Image didn't fit on current page, signal to continue on next page
|
||||
# Keep same position so it will be attempted on the next page
|
||||
return False, position
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition,
|
||||
pos2: RenderingPosition) -> int:
|
||||
@@ -513,27 +816,3 @@ class BidirectionalLayouter:
|
||||
if pos1.word_index != pos2.word_index:
|
||||
return 1 if pos1.word_index > pos2.word_index else -1
|
||||
return 0
|
||||
|
||||
|
||||
# Add can_fit_line method to Page class if it doesn't exist
|
||||
def _add_page_methods():
|
||||
"""Add missing methods to Page class"""
|
||||
if not hasattr(Page, 'can_fit_line'):
|
||||
def can_fit_line(self, line_height: int) -> bool:
|
||||
"""Check if a line of given height can fit on the page"""
|
||||
available_height = self.content_size[1] - self._current_y_offset
|
||||
return available_height >= line_height
|
||||
|
||||
Page.can_fit_line = can_fit_line
|
||||
|
||||
if not hasattr(Page, 'available_width'):
|
||||
@property
|
||||
def available_width(self) -> int:
|
||||
"""Get available width for content"""
|
||||
return self.content_size[0]
|
||||
|
||||
Page.available_width = available_width
|
||||
|
||||
|
||||
# Apply the page methods
|
||||
_add_page_methods()
|
||||
|
||||
@@ -8,14 +8,23 @@ into a unified, easy-to-use API.
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||
import json
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
||||
from .page_buffer import BufferedPageRenderer
|
||||
from pyWebLayout.abstract.block import Block, HeadingLevel
|
||||
from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
from pyWebLayout.layout.document_layouter import image_layouter
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \
|
||||
create_highlight_from_query_result
|
||||
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
|
||||
from pyWebLayout.concrete.interaction_handler import InteractionStateManager
|
||||
from PIL import Image as Image_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkManager:
|
||||
@@ -32,8 +41,7 @@ class BookmarkManager:
|
||||
bookmarks_dir: Directory to store bookmark files
|
||||
"""
|
||||
self.document_id = document_id
|
||||
self.bookmarks_dir = Path(bookmarks_dir)
|
||||
self.bookmarks_dir.mkdir(exist_ok=True)
|
||||
self.bookmarks_dir = ensure_dir(bookmarks_dir)
|
||||
|
||||
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
|
||||
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
|
||||
@@ -43,29 +51,23 @@ class BookmarkManager:
|
||||
|
||||
def _load_bookmarks(self):
|
||||
"""Load bookmarks from file"""
|
||||
if self.bookmarks_file.exists():
|
||||
try:
|
||||
with open(self.bookmarks_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self._bookmarks = {
|
||||
name: RenderingPosition.from_dict(pos_data)
|
||||
for name, pos_data in data.items()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Failed to load bookmarks: {e}")
|
||||
self._bookmarks = {}
|
||||
data = read_json(self.bookmarks_file, {})
|
||||
try:
|
||||
self._bookmarks = {
|
||||
name: RenderingPosition.from_dict(pos_data)
|
||||
for name, pos_data in data.items()
|
||||
}
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
logger.warning("Bookmark file %s is not in the expected shape; ignoring it",
|
||||
self.bookmarks_file, exc_info=True)
|
||||
self._bookmarks = {}
|
||||
|
||||
def _save_bookmarks(self):
|
||||
"""Save bookmarks to file"""
|
||||
try:
|
||||
data = {
|
||||
name: position.to_dict()
|
||||
for name, position in self._bookmarks.items()
|
||||
}
|
||||
with open(self.bookmarks_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save bookmarks: {e}")
|
||||
write_json(self.bookmarks_file, {
|
||||
name: position.to_dict()
|
||||
for name, position in self._bookmarks.items()
|
||||
})
|
||||
|
||||
def add_bookmark(self, name: str, position: RenderingPosition):
|
||||
"""
|
||||
@@ -122,11 +124,7 @@ class BookmarkManager:
|
||||
Args:
|
||||
position: Current reading position
|
||||
"""
|
||||
try:
|
||||
with open(self.position_file, 'w') as f:
|
||||
json.dump(position.to_dict(), f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save reading position: {e}")
|
||||
write_json(self.position_file, position.to_dict())
|
||||
|
||||
def load_reading_position(self) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
@@ -135,14 +133,15 @@ class BookmarkManager:
|
||||
Returns:
|
||||
Last reading position or None if not found
|
||||
"""
|
||||
if self.position_file.exists():
|
||||
try:
|
||||
with open(self.position_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
return RenderingPosition.from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"Failed to load reading position: {e}")
|
||||
return None
|
||||
data = read_json(self.position_file, None)
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
return RenderingPosition.from_dict(data)
|
||||
except (TypeError, KeyError):
|
||||
logger.warning("Position file %s is not in the expected shape; ignoring it",
|
||||
self.position_file, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
class EreaderLayoutManager:
|
||||
@@ -152,6 +151,7 @@ class EreaderLayoutManager:
|
||||
Features:
|
||||
- Sub-second page rendering with intelligent buffering
|
||||
- Font scaling support
|
||||
- Dynamic font family switching (Sans, Serif, Monospace)
|
||||
- Chapter navigation
|
||||
- Bookmark management
|
||||
- Position persistence
|
||||
@@ -164,7 +164,8 @@ class EreaderLayoutManager:
|
||||
document_id: str = "default",
|
||||
buffer_size: int = 5,
|
||||
page_style: Optional[PageStyle] = None,
|
||||
bookmarks_dir: str = "bookmarks"):
|
||||
bookmarks_dir: str = "bookmarks",
|
||||
highlights_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the ereader layout manager.
|
||||
|
||||
@@ -175,6 +176,8 @@ class EreaderLayoutManager:
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
page_style: Custom page styling (uses default if None)
|
||||
bookmarks_dir: Directory to store bookmark files
|
||||
highlights_dir: Directory to store highlights. Defaults to
|
||||
bookmarks_dir, so a document's reading state lives in one place.
|
||||
"""
|
||||
self.blocks = blocks
|
||||
self.page_size = page_size
|
||||
@@ -189,15 +192,31 @@ class EreaderLayoutManager:
|
||||
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
|
||||
self.highlight_manager = HighlightManager(
|
||||
document_id, highlights_dir if highlights_dir is not None else bookmarks_dir)
|
||||
|
||||
# Current state
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
# Cover page handling
|
||||
self._has_cover = self._detect_cover()
|
||||
self._on_cover_page = self._has_cover # Start on cover if one exists
|
||||
|
||||
# Page position history for fast backward navigation
|
||||
# List of (position, font_scale) tuples representing the start of each page visited
|
||||
self._page_history: List[Tuple[RenderingPosition, float]] = []
|
||||
self._max_history_size = 50 # Keep last 50 page positions
|
||||
|
||||
# Load last reading position if available
|
||||
saved_position = self.bookmark_manager.load_reading_position()
|
||||
if saved_position:
|
||||
self.current_position = saved_position
|
||||
self._on_cover_page = False # If we have a saved position, we're past the cover
|
||||
|
||||
# Pointer interaction state, rebound whenever the displayed page changes
|
||||
self._interaction_state_manager: Optional[InteractionStateManager] = None
|
||||
self._interaction_page: Optional[Page] = None
|
||||
|
||||
# Callbacks for UI updates
|
||||
self.position_changed_callback: Optional[Callable[[
|
||||
@@ -205,6 +224,67 @@ class EreaderLayoutManager:
|
||||
self.chapter_changed_callback: Optional[Callable[[
|
||||
Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def prewarm_caches(self, max_words: int = 2000,
|
||||
budget_bytes: Optional[int] = None) -> Tuple[int, int]:
|
||||
"""
|
||||
Preload the text caches with this document's most frequent words.
|
||||
|
||||
Counts how often each word occurs in the book and rasterises the most
|
||||
common ones ahead of time, so that the work lands at open time rather than
|
||||
on the first page turns. Entries are seeded with their document frequency,
|
||||
which is what keeps them resident under usage-ranked eviction.
|
||||
|
||||
Safe to call again after a font change; the fonts differ, so the new
|
||||
entries simply take their place in the eviction order alongside the old.
|
||||
|
||||
Args:
|
||||
max_words: Maximum distinct words to preload.
|
||||
budget_bytes: Bytes of glyph cache to fill. Defaults to half the budget.
|
||||
|
||||
Returns:
|
||||
Tuple of (words preloaded, bytes preloaded).
|
||||
"""
|
||||
from collections import Counter
|
||||
from pyWebLayout.concrete.text import prewarm_text_caches
|
||||
from .ereader_layout import FontScaler
|
||||
|
||||
override = getattr(self.renderer.layouter, 'font_family_override', None)
|
||||
|
||||
# Count by (style, text): the same word in a heading and in body text is a
|
||||
# different rasterisation, and both are worth counting separately.
|
||||
counts: Dict[Tuple[int, str], int] = Counter()
|
||||
styles: Dict[int, Any] = {}
|
||||
for block in self.blocks:
|
||||
words = getattr(block, '_words', None)
|
||||
if not words:
|
||||
continue
|
||||
for word in words:
|
||||
style = word.style
|
||||
if style is None:
|
||||
continue
|
||||
key = id(style)
|
||||
styles.setdefault(key, style)
|
||||
counts[(key, word.text)] += 1
|
||||
|
||||
# Resolve each distinct style once through the same scaling the layouter
|
||||
# applies, so the preloaded keys match what rendering will look up.
|
||||
scaled: Dict[int, Any] = {}
|
||||
for key, style in styles.items():
|
||||
try:
|
||||
scaled[key] = FontScaler.scale_font(style, self.font_scale, override)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
entries = []
|
||||
for (style_key, text), count in counts.items():
|
||||
font = scaled.get(style_key)
|
||||
if font is None:
|
||||
continue
|
||||
entries.append((font.font, text, font.colour, count))
|
||||
|
||||
return prewarm_text_caches(entries, budget_bytes=budget_bytes,
|
||||
max_words=max_words)
|
||||
|
||||
def set_position_changed_callback(
|
||||
self, callback: Callable[[RenderingPosition], None]):
|
||||
"""Set callback for position changes"""
|
||||
@@ -215,6 +295,69 @@ class EreaderLayoutManager:
|
||||
"""Set callback for chapter changes"""
|
||||
self.chapter_changed_callback = callback
|
||||
|
||||
def _detect_cover(self) -> bool:
|
||||
"""
|
||||
Detect if the document has a cover page.
|
||||
|
||||
A cover is detected if:
|
||||
1. The first block is an Image block, OR
|
||||
2. The document has cover metadata (future enhancement)
|
||||
|
||||
Returns:
|
||||
True if a cover page should be rendered
|
||||
"""
|
||||
if not self.blocks:
|
||||
return False
|
||||
|
||||
# Check if first block is an image - treat it as a cover
|
||||
first_block = self.blocks[0]
|
||||
if isinstance(first_block, Image):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _render_cover_page(self) -> Page:
|
||||
"""
|
||||
Render a dedicated cover page.
|
||||
|
||||
The cover page displays the first image block (if it exists)
|
||||
using the standard image layouter with maximum dimensions to fill the page.
|
||||
|
||||
Returns:
|
||||
Rendered cover page
|
||||
"""
|
||||
# Create a new page for the cover
|
||||
page = Page(self.page_size, self.page_style)
|
||||
|
||||
if not self.blocks or not isinstance(self.blocks[0], Image):
|
||||
# No cover image, return blank page
|
||||
return page
|
||||
|
||||
cover_image_block = self.blocks[0]
|
||||
|
||||
# Use the image layouter to render the cover image
|
||||
# Use full page dimensions (minus borders/padding) for cover
|
||||
try:
|
||||
max_width = self.page_size[0] - 2 * self.page_style.border_width
|
||||
max_height = self.page_size[1] - 2 * self.page_style.border_width
|
||||
|
||||
# Layout the image on the page
|
||||
success = image_layouter(
|
||||
image=cover_image_block,
|
||||
page=page,
|
||||
max_width=max_width,
|
||||
max_height=max_height
|
||||
)
|
||||
|
||||
if not success:
|
||||
print("Warning: Failed to layout cover image")
|
||||
|
||||
except Exception as e:
|
||||
# If image loading fails, just return the blank page
|
||||
print(f"Warning: Failed to load cover image: {e}")
|
||||
|
||||
return page
|
||||
|
||||
def _notify_position_changed(self):
|
||||
"""Notify UI of position change"""
|
||||
if self.position_changed_callback:
|
||||
@@ -233,9 +376,16 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
Get the page at the current reading position.
|
||||
|
||||
If on the cover page, returns the rendered cover.
|
||||
Otherwise, returns the regular content page.
|
||||
|
||||
Returns:
|
||||
Rendered page
|
||||
"""
|
||||
# Check if we're on the cover page
|
||||
if self._on_cover_page and self._has_cover:
|
||||
return self._render_cover_page()
|
||||
|
||||
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
return page
|
||||
|
||||
@@ -243,9 +393,26 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
Advance to the next page.
|
||||
|
||||
If currently on the cover page, advances to the first content page.
|
||||
Otherwise, advances to the next content page.
|
||||
|
||||
Returns:
|
||||
Next page or None if at end of document
|
||||
"""
|
||||
# Special case: transitioning from cover to first content page
|
||||
if self._on_cover_page and self._has_cover:
|
||||
self._on_cover_page = False
|
||||
# If first block is an image (the cover), skip it and start from block 1
|
||||
if self.blocks and isinstance(self.blocks[0], Image):
|
||||
self.current_position = RenderingPosition(chapter_index=0, block_index=1)
|
||||
else:
|
||||
self.current_position = RenderingPosition()
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
# Save current position to history before moving forward
|
||||
self._add_to_history(self.current_position, self.font_scale)
|
||||
|
||||
page, next_position = self.renderer.render_page(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
@@ -255,23 +422,71 @@ class EreaderLayoutManager:
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
# No progress. That is the correct answer only at the end of the
|
||||
# document; anywhere else a block has failed to lay out and would trap
|
||||
# the reader on this page. Skipping the block costs one block, not the
|
||||
# rest of the book.
|
||||
if self.current_position.block_index < len(self.blocks):
|
||||
logger.error(
|
||||
"Block %d made no layout progress; skipping it. This is a layout "
|
||||
"bug - the block placed nothing and reported no resume point.",
|
||||
self.current_position.block_index)
|
||||
self.current_position = RenderingPosition(
|
||||
chapter_index=self.current_position.chapter_index,
|
||||
block_index=self.current_position.block_index + 1)
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
return None # At end of document
|
||||
|
||||
def previous_page(self) -> Optional[Page]:
|
||||
"""
|
||||
Go to the previous page.
|
||||
|
||||
Uses cached page history for instant navigation when available,
|
||||
falls back to iterative refinement algorithm when needed.
|
||||
Can navigate back to the cover page if it exists.
|
||||
|
||||
Returns:
|
||||
Previous page or None if at beginning of document
|
||||
Previous page or None if at beginning of document (or on cover)
|
||||
"""
|
||||
# Special case: if at the beginning of content and there's a cover, go back to it
|
||||
if self._has_cover and self._is_at_beginning() and not self._on_cover_page:
|
||||
self._on_cover_page = True
|
||||
# Restore the canonical cover position. Being on the cover must have a
|
||||
# single representation: a fresh load sits at block 0 with the cover
|
||||
# showing, so returning to the cover has to land there too. Leaving the
|
||||
# position at the first content block saves a position that reopens past
|
||||
# the cover, silently losing it.
|
||||
self.current_position = RenderingPosition()
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
# Can't go before the cover
|
||||
if self._on_cover_page:
|
||||
return None
|
||||
|
||||
if self._is_at_beginning():
|
||||
return None
|
||||
|
||||
# Use backward rendering to find the previous page
|
||||
# Fast path: Check if we have this position in history
|
||||
previous_position = self._get_from_history(self.current_position, self.font_scale)
|
||||
|
||||
if previous_position is not None:
|
||||
# Cache hit! Use the cached position for instant navigation
|
||||
self.current_position = previous_position
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
# Slow path: Use backward rendering to find the previous page
|
||||
# This uses the iterative refinement algorithm we just fixed
|
||||
page, start_position = self.renderer.render_page_backward(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
if start_position != self.current_position:
|
||||
# Save this calculated position to history for future use
|
||||
self._add_to_history(start_position, self.font_scale)
|
||||
|
||||
self.current_position = start_position
|
||||
self._notify_position_changed()
|
||||
return page
|
||||
@@ -279,9 +494,17 @@ class EreaderLayoutManager:
|
||||
return None # At beginning of document
|
||||
|
||||
def _is_at_beginning(self) -> bool:
|
||||
"""Check if we're at the beginning of the document"""
|
||||
"""
|
||||
Check if we're at the beginning of the document content.
|
||||
|
||||
If a cover exists (first block is an Image), the beginning of content
|
||||
is at block_index=1. Otherwise, it's at block_index=0.
|
||||
"""
|
||||
# Determine the first content block index
|
||||
first_content_block = 1 if (self._has_cover and self.blocks and isinstance(self.blocks[0], Image)) else 0
|
||||
|
||||
return (self.current_position.chapter_index == 0 and
|
||||
self.current_position.block_index == 0 and
|
||||
self.current_position.block_index == first_content_block and
|
||||
self.current_position.word_index == 0)
|
||||
|
||||
def jump_to_position(self, position: RenderingPosition) -> Page:
|
||||
@@ -295,6 +518,7 @@ class EreaderLayoutManager:
|
||||
Page at the new position
|
||||
"""
|
||||
self.current_position = position
|
||||
self._on_cover_page = False # Jumping to a position means we're past the cover
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
@@ -328,10 +552,75 @@ class EreaderLayoutManager:
|
||||
return self.jump_to_position(chapters[chapter_index].position)
|
||||
return None
|
||||
|
||||
def _add_to_history(self, position: RenderingPosition, font_scale: float):
|
||||
"""
|
||||
Add a page position to the navigation history.
|
||||
|
||||
Args:
|
||||
position: The page start position to remember
|
||||
font_scale: The font scale at this position
|
||||
"""
|
||||
# Only add if it's different from the last entry
|
||||
if not self._page_history or \
|
||||
self._page_history[-1][0] != position or \
|
||||
self._page_history[-1][1] != font_scale:
|
||||
|
||||
self._page_history.append((position.copy(), font_scale))
|
||||
|
||||
# Trim history if it exceeds max size
|
||||
if len(self._page_history) > self._max_history_size:
|
||||
self._page_history.pop(0)
|
||||
|
||||
def _get_from_history(
|
||||
self,
|
||||
current_position: RenderingPosition,
|
||||
current_font_scale: float) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
Get the previous page position from history.
|
||||
|
||||
Searches backward through history to find the last position that
|
||||
comes before the current position at the same font scale.
|
||||
|
||||
Args:
|
||||
current_position: Current page position
|
||||
current_font_scale: Current font scale
|
||||
|
||||
Returns:
|
||||
Previous page position or None if not found in history
|
||||
"""
|
||||
# Search backward through history
|
||||
for i in range(len(self._page_history) - 1, -1, -1):
|
||||
hist_position, hist_font_scale = self._page_history[i]
|
||||
|
||||
# Must match font scale
|
||||
if hist_font_scale != current_font_scale:
|
||||
continue
|
||||
|
||||
# Must be before current position
|
||||
if (hist_position.chapter_index < current_position.chapter_index or
|
||||
(hist_position.chapter_index == current_position.chapter_index and
|
||||
hist_position.block_index < current_position.block_index) or
|
||||
(hist_position.chapter_index == current_position.chapter_index and
|
||||
hist_position.block_index == current_position.block_index and
|
||||
hist_position.word_index < current_position.word_index)):
|
||||
|
||||
# Found a previous position - remove it and everything after from history
|
||||
# since we're navigating backward
|
||||
self._page_history = self._page_history[:i]
|
||||
return hist_position.copy()
|
||||
|
||||
return None
|
||||
|
||||
def _clear_history(self):
|
||||
"""Clear the page navigation history."""
|
||||
self._page_history.clear()
|
||||
|
||||
def set_font_scale(self, scale: float) -> Page:
|
||||
"""
|
||||
Change the font scale and re-render current page.
|
||||
|
||||
Clears page history since font changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
|
||||
|
||||
@@ -340,6 +629,8 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
if scale != self.font_scale:
|
||||
self.font_scale = scale
|
||||
# Clear history since font scale changes invalidate all cached positions
|
||||
self._clear_history()
|
||||
# The renderer will handle cache invalidation
|
||||
|
||||
return self.get_current_page()
|
||||
@@ -348,10 +639,49 @@ class EreaderLayoutManager:
|
||||
"""Get the current font scale"""
|
||||
return self.font_scale
|
||||
|
||||
def set_font_family(self, family: Optional[BundledFont]) -> Page:
|
||||
"""
|
||||
Change the font family and re-render current page.
|
||||
|
||||
Switches all text in the document to use the specified bundled font family
|
||||
while preserving font weights, styles, sizes, and other attributes.
|
||||
Clears page history and cache since font changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
family: Bundled font family to use (SANS, SERIF, MONOSPACE, or None for original fonts)
|
||||
|
||||
Returns:
|
||||
Re-rendered page with new font family
|
||||
|
||||
Example:
|
||||
>>> from pyWebLayout.style.fonts import BundledFont
|
||||
>>> manager.set_font_family(BundledFont.SERIF) # Switch to serif
|
||||
>>> manager.set_font_family(BundledFont.SANS) # Switch to sans
|
||||
>>> manager.set_font_family(None) # Restore original fonts
|
||||
"""
|
||||
# Update the renderer's font family
|
||||
self.renderer.set_font_family(family)
|
||||
|
||||
# Clear history since font changes invalidate all cached positions
|
||||
self._clear_history()
|
||||
|
||||
return self.get_current_page()
|
||||
|
||||
def get_font_family(self) -> Optional[BundledFont]:
|
||||
"""
|
||||
Get the current font family override.
|
||||
|
||||
Returns:
|
||||
Current font family (SANS, SERIF, MONOSPACE) or None if using original fonts
|
||||
"""
|
||||
return self.renderer.get_font_family()
|
||||
|
||||
def increase_line_spacing(self, amount: int = 2) -> Page:
|
||||
"""
|
||||
Increase line spacing and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to add to line spacing (default: 2)
|
||||
|
||||
@@ -361,12 +691,15 @@ class EreaderLayoutManager:
|
||||
self.page_style.line_spacing += amount
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def decrease_line_spacing(self, amount: int = 2) -> Page:
|
||||
"""
|
||||
Decrease line spacing and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to remove from line spacing (default: 2)
|
||||
|
||||
@@ -376,12 +709,15 @@ class EreaderLayoutManager:
|
||||
self.page_style.line_spacing = max(0, self.page_style.line_spacing - amount)
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def increase_inter_block_spacing(self, amount: int = 5) -> Page:
|
||||
"""
|
||||
Increase spacing between blocks and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to add to inter-block spacing (default: 5)
|
||||
|
||||
@@ -391,12 +727,15 @@ class EreaderLayoutManager:
|
||||
self.page_style.inter_block_spacing += amount
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def decrease_inter_block_spacing(self, amount: int = 5) -> Page:
|
||||
"""
|
||||
Decrease spacing between blocks and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to remove from inter-block spacing (default: 5)
|
||||
|
||||
@@ -407,12 +746,15 @@ class EreaderLayoutManager:
|
||||
0, self.page_style.inter_block_spacing - amount)
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def increase_word_spacing(self, amount: int = 2) -> Page:
|
||||
"""
|
||||
Increase spacing between words and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to add to word spacing (default: 2)
|
||||
|
||||
@@ -422,12 +764,15 @@ class EreaderLayoutManager:
|
||||
self.page_style.word_spacing += amount
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def decrease_word_spacing(self, amount: int = 2) -> Page:
|
||||
"""
|
||||
Decrease spacing between words and re-render current page.
|
||||
|
||||
Clears page history since spacing changes invalidate all cached positions.
|
||||
|
||||
Args:
|
||||
amount: Pixels to remove from word spacing (default: 2)
|
||||
|
||||
@@ -437,6 +782,7 @@ class EreaderLayoutManager:
|
||||
self.page_style.word_spacing = max(0, self.page_style.word_spacing - amount)
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
self._clear_history() # Clear position history
|
||||
return self.get_current_page()
|
||||
|
||||
def get_table_of_contents(
|
||||
@@ -510,6 +856,165 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
return self.bookmark_manager.list_bookmarks()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Highlights
|
||||
#
|
||||
# A Highlight carries pixel bounds, which belong to the one rendering it
|
||||
# was taken from: change the font scale or page size and they no longer
|
||||
# describe anything. Each highlight therefore also records the
|
||||
# RenderingPosition of the page it was made on, and page association goes
|
||||
# through that rather than through the bounds.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def highlight_point(self,
|
||||
point: Tuple[int, int],
|
||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None) -> Optional[Highlight]:
|
||||
"""
|
||||
Highlight whatever is at a point on the current page.
|
||||
|
||||
Args:
|
||||
point: (x, y) in page coordinates, as delivered by a tap
|
||||
color: RGBA fill, e.g. one of HighlightColor
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
|
||||
Returns:
|
||||
The stored Highlight, or None if nothing was at that point.
|
||||
"""
|
||||
result = self.get_current_page().query_point(point)
|
||||
if result is None or result.object_type == "empty":
|
||||
return None
|
||||
|
||||
return self._store_highlight(result, color, note, tags)
|
||||
|
||||
def highlight_range(self,
|
||||
start: Tuple[int, int],
|
||||
end: Tuple[int, int],
|
||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None) -> Optional[Highlight]:
|
||||
"""
|
||||
Highlight the text between two points on the current page.
|
||||
|
||||
Args:
|
||||
start: (x, y) where the selection began
|
||||
end: (x, y) where the selection ended
|
||||
color: RGBA fill, e.g. one of HighlightColor
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
|
||||
Returns:
|
||||
The stored Highlight, or None if the range selected no text.
|
||||
"""
|
||||
selection = self.get_current_page().query_range(start, end)
|
||||
if not selection.results:
|
||||
return None
|
||||
|
||||
return self._store_highlight(selection, color, note, tags)
|
||||
|
||||
def _store_highlight(self, result, color, note, tags) -> Highlight:
|
||||
"""Build a Highlight from a query result and persist it."""
|
||||
highlight = create_highlight_from_query_result(
|
||||
result, color=color, note=note, tags=tags,
|
||||
position=self.current_position.to_dict())
|
||||
self.highlight_manager.add_highlight(highlight)
|
||||
return highlight
|
||||
|
||||
def remove_highlight(self, highlight_id: str) -> bool:
|
||||
"""
|
||||
Remove a highlight.
|
||||
|
||||
Args:
|
||||
highlight_id: ID of the highlight to remove
|
||||
|
||||
Returns:
|
||||
True if it existed and was removed
|
||||
"""
|
||||
return self.highlight_manager.remove_highlight(highlight_id)
|
||||
|
||||
def list_highlights(self) -> List[Highlight]:
|
||||
"""Get every highlight in this document."""
|
||||
return self.highlight_manager.list_highlights()
|
||||
|
||||
def get_highlights_for_current_page(self) -> List[Highlight]:
|
||||
"""
|
||||
Get the highlights made on the page currently being displayed.
|
||||
|
||||
Matched on the recorded RenderingPosition, so this stays correct across
|
||||
font changes; highlights saved before the position field existed have
|
||||
no position and are never matched.
|
||||
"""
|
||||
current = self.current_position.to_dict()
|
||||
return [h for h in self.highlight_manager.list_highlights()
|
||||
if h.position == current]
|
||||
|
||||
def clear_highlights(self) -> None:
|
||||
"""Remove every highlight in this document."""
|
||||
self.highlight_manager.clear_all()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pointer interaction
|
||||
#
|
||||
# Press/hover feedback is state that belongs to one rendered page, so the
|
||||
# state machine is rebound whenever the displayed page changes. Callers get
|
||||
# a fresh frame back when something changed visually, and None when nothing
|
||||
# did - so a UI can skip a redraw it does not need.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _interaction_state(self) -> InteractionStateManager:
|
||||
"""The state machine for the page currently displayed."""
|
||||
page = self.get_current_page()
|
||||
if self._interaction_page is not page:
|
||||
if self._interaction_state_manager is not None:
|
||||
self._interaction_state_manager.reset()
|
||||
self._interaction_state_manager = InteractionStateManager(page)
|
||||
self._interaction_page = page
|
||||
return self._interaction_state_manager
|
||||
|
||||
def handle_hover(self, point: Tuple[int, int]) -> Optional[Image_.Image]:
|
||||
"""
|
||||
Update hover feedback for a pointer at `point`.
|
||||
|
||||
Args:
|
||||
point: (x, y) in page coordinates
|
||||
|
||||
Returns:
|
||||
A re-rendered frame if the hover state changed, else None.
|
||||
"""
|
||||
return self._interaction_state().update_hover(point)
|
||||
|
||||
def handle_touch_down(self, point: Tuple[int, int]) -> Optional[Image_.Image]:
|
||||
"""
|
||||
Show pressed feedback for whatever interactive element is at `point`.
|
||||
|
||||
Args:
|
||||
point: (x, y) in page coordinates
|
||||
|
||||
Returns:
|
||||
A frame showing the pressed state, or None if nothing interactive
|
||||
is there.
|
||||
"""
|
||||
return self._interaction_state().handle_mouse_down(point)
|
||||
|
||||
def handle_touch_up(self, point: Tuple[int, int]) -> Tuple[Optional[Image_.Image], Any]:
|
||||
"""
|
||||
Release the pressed element and run its action.
|
||||
|
||||
Args:
|
||||
point: (x, y) in page coordinates
|
||||
|
||||
Returns:
|
||||
(frame, callback_result). Both are None if no element was pressed.
|
||||
"""
|
||||
return self._interaction_state().handle_mouse_up(point)
|
||||
|
||||
def reset_interaction_state(self) -> None:
|
||||
"""Clear any hover or press feedback, e.g. when the pointer leaves."""
|
||||
if self._interaction_state_manager is not None:
|
||||
self._interaction_state_manager.reset()
|
||||
|
||||
def get_reading_progress(self) -> float:
|
||||
"""
|
||||
Get reading progress as a percentage.
|
||||
@@ -527,6 +1032,38 @@ class EreaderLayoutManager:
|
||||
|
||||
return current_block / max(1, total_blocks - 1)
|
||||
|
||||
def has_cover(self) -> bool:
|
||||
"""
|
||||
Check if the document has a cover page.
|
||||
|
||||
Returns:
|
||||
True if a cover page is available
|
||||
"""
|
||||
return self._has_cover
|
||||
|
||||
def is_on_cover(self) -> bool:
|
||||
"""
|
||||
Check if currently viewing the cover page.
|
||||
|
||||
Returns:
|
||||
True if on the cover page
|
||||
"""
|
||||
return self._on_cover_page
|
||||
|
||||
def jump_to_cover(self) -> Optional[Page]:
|
||||
"""
|
||||
Jump to the cover page if one exists.
|
||||
|
||||
Returns:
|
||||
Cover page or None if no cover exists
|
||||
"""
|
||||
if not self._has_cover:
|
||||
return None
|
||||
|
||||
self._on_cover_page = True
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
def get_position_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about the current position.
|
||||
@@ -535,9 +1072,12 @@ class EreaderLayoutManager:
|
||||
Dictionary with position details
|
||||
"""
|
||||
current_chapter = self.get_current_chapter()
|
||||
font_family = self.get_font_family()
|
||||
|
||||
return {
|
||||
'position': self.current_position.to_dict(),
|
||||
'on_cover': self._on_cover_page,
|
||||
'has_cover': self._has_cover,
|
||||
'chapter': {
|
||||
'title': current_chapter.title if current_chapter else None,
|
||||
'level': current_chapter.level if current_chapter else None,
|
||||
@@ -545,6 +1085,7 @@ class EreaderLayoutManager:
|
||||
},
|
||||
'progress': self.get_reading_progress(),
|
||||
'font_scale': self.font_scale,
|
||||
'font_family': font_family.value if font_family else None,
|
||||
'page_size': self.page_size
|
||||
}
|
||||
|
||||
@@ -561,16 +1102,31 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
Shutdown the ereader manager and clean up resources.
|
||||
Call this when the application is closing.
|
||||
|
||||
Idempotent: calling it twice saves the position once.
|
||||
"""
|
||||
if getattr(self, '_shutdown_done', False):
|
||||
return
|
||||
self._shutdown_done = True
|
||||
|
||||
# Save current position
|
||||
self.bookmark_manager.save_reading_position(self.current_position)
|
||||
|
||||
# Shutdown renderer and buffer
|
||||
# Release cached pages
|
||||
self.renderer.shutdown()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
"""
|
||||
Best-effort cleanup for callers that never called shutdown().
|
||||
|
||||
Finalisers run during interpreter teardown, when modules and globals
|
||||
may already be torn down, so this must never raise and must never
|
||||
block. Applications should call shutdown() explicitly.
|
||||
"""
|
||||
try:
|
||||
self.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Convenience function for quick setup
|
||||
|
||||
@@ -1,70 +1,57 @@
|
||||
"""
|
||||
Multi-process page buffering system for high-performance ereader navigation.
|
||||
Page caching for ereader navigation.
|
||||
|
||||
This module provides intelligent page caching with background rendering using
|
||||
multiprocessing to achieve sub-second page navigation performance.
|
||||
`PageBuffer` is an LRU cache of rendered pages plus the position links between
|
||||
them; `BufferedPageRenderer` wraps it around a `BidirectionalLayouter`.
|
||||
|
||||
This module used to render pages ahead of time in a `ProcessPoolExecutor`. That
|
||||
never worked and has been removed — see S12 in docs/LAYOUT_REMEDIATION_SPEC.md
|
||||
and R1/R2 in docs/ARCHITECTURE_REVIEW.md. In short: the worker returned
|
||||
`pickle.dumps(page)`, and a Page holds a live PIL canvas, which is not
|
||||
picklable, so every job failed and the result was discarded. The cost — four
|
||||
interpreter copies and the whole block list shipped per job — was paid in full
|
||||
for no benefit. On Python 3.14, where the default start method became
|
||||
`forkserver`, submitting from module-level code raised outright.
|
||||
|
||||
Prefetch is not needed. Measured on the tests/data Wikipedia fixture (411
|
||||
blocks) with the text caches warm, one page render costs:
|
||||
|
||||
800x600 p50 8.8 ms p95 15.4 ms
|
||||
1072x1448 p50 13.8 ms p95 56.1 ms
|
||||
|
||||
A page turn is cheaper than the IPC that was meant to hide it. If a slower
|
||||
target device ever changes that, the fallback is a synchronous `readahead()`
|
||||
method on this class, or a single worker *thread* — layout is PIL-bound and PIL
|
||||
releases the GIL — not a process pool. Making the concrete tree picklable
|
||||
(Page -> Line -> Text -> Font -> FreeTypeFont) is a large surface area to
|
||||
maintain for a cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ProcessPoolExecutor, Future
|
||||
import threading
|
||||
import pickle
|
||||
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter, FontFamilyOverride
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def _render_page_worker(args: Tuple[List[Block],
|
||||
PageStyle,
|
||||
RenderingPosition,
|
||||
float,
|
||||
bool]) -> Tuple[RenderingPosition,
|
||||
bytes,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Worker function for multiprocess page rendering.
|
||||
|
||||
Args:
|
||||
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
|
||||
|
||||
Returns:
|
||||
Tuple of (original_position, pickled_page, next_position)
|
||||
"""
|
||||
blocks, page_style, position, font_scale, is_backward = args
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, page_style)
|
||||
|
||||
if is_backward:
|
||||
page, next_pos = layouter.render_page_backward(position, font_scale)
|
||||
else:
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale)
|
||||
|
||||
# Serialize the page for inter-process communication
|
||||
pickled_page = pickle.dumps(page)
|
||||
|
||||
return position, pickled_page, next_pos
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
|
||||
class PageBuffer:
|
||||
"""
|
||||
Intelligent page caching system with LRU eviction and background rendering.
|
||||
Maintains separate forward and backward buffers for optimal navigation performance.
|
||||
LRU cache of rendered pages, with separate forward and backward buffers and
|
||||
the position links between adjacent pages.
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
|
||||
def __init__(self, buffer_size: int = 5):
|
||||
"""
|
||||
Initialize the page buffer.
|
||||
|
||||
Args:
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
max_workers: Maximum number of worker processes for background rendering
|
||||
"""
|
||||
self.buffer_size = buffer_size
|
||||
self.max_workers = max_workers
|
||||
|
||||
# LRU caches for forward and backward pages
|
||||
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
@@ -76,21 +63,18 @@ class PageBuffer:
|
||||
self.reverse_position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Background rendering
|
||||
self.executor: Optional[ProcessPoolExecutor] = None
|
||||
self.pending_renders: Dict[RenderingPosition, Future] = {}
|
||||
self.render_lock = threading.Lock()
|
||||
|
||||
# Document state
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
self.page_style: Optional[PageStyle] = None
|
||||
self.current_font_scale: float = 1.0
|
||||
self.current_font_family: Optional[BundledFont] = None
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
font_scale: float = 1.0):
|
||||
font_scale: float = 1.0,
|
||||
font_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Initialize the buffer with document blocks and page style.
|
||||
|
||||
@@ -98,14 +82,12 @@ class PageBuffer:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
font_scale: Current font scaling factor
|
||||
font_family: Optional font family override
|
||||
"""
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.current_font_scale = font_scale
|
||||
|
||||
# Start the process pool
|
||||
if self.executor is None:
|
||||
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
|
||||
self.current_font_family = font_family
|
||||
|
||||
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
||||
"""
|
||||
@@ -167,123 +149,12 @@ class PageBuffer:
|
||||
self.position_map.pop(oldest_pos, None)
|
||||
self.reverse_position_map.pop(oldest_pos, None)
|
||||
|
||||
def start_background_rendering(
|
||||
self,
|
||||
current_position: RenderingPosition,
|
||||
direction: str = 'forward'):
|
||||
"""
|
||||
Start background rendering of upcoming pages.
|
||||
|
||||
Args:
|
||||
current_position: Current reading position
|
||||
direction: 'forward', 'backward', or 'both'
|
||||
"""
|
||||
if not self.blocks or not self.page_style or not self.executor:
|
||||
return
|
||||
|
||||
with self.render_lock:
|
||||
if direction in ['forward', 'both']:
|
||||
self._queue_forward_renders(current_position)
|
||||
|
||||
if direction in ['backward', 'both']:
|
||||
self._queue_backward_renders(current_position)
|
||||
|
||||
def _queue_forward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue forward page renders starting from the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get next position from cache
|
||||
current_pos = self.position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
False)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render
|
||||
# completes
|
||||
break
|
||||
|
||||
def _queue_backward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue backward page renders ending at the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get previous position from cache
|
||||
current_pos = self.reverse_position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
True)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the
|
||||
# render completes
|
||||
break
|
||||
|
||||
def check_completed_renders(self):
|
||||
"""Check for completed background renders and cache the results"""
|
||||
if not self.pending_renders:
|
||||
return
|
||||
|
||||
completed = []
|
||||
|
||||
with self.render_lock:
|
||||
for position, future in self.pending_renders.items():
|
||||
if future.done():
|
||||
try:
|
||||
original_pos, pickled_page, next_pos = future.result()
|
||||
|
||||
# Deserialize the page
|
||||
page = pickle.loads(pickled_page)
|
||||
|
||||
# Cache the page
|
||||
self.cache_page(original_pos, page, next_pos, is_backward=False)
|
||||
|
||||
completed.append(position)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Background render failed for position {position}: {e}")
|
||||
completed.append(position)
|
||||
|
||||
# Remove completed renders
|
||||
for pos in completed:
|
||||
self.pending_renders.pop(pos, None)
|
||||
|
||||
def invalidate_all(self):
|
||||
"""Clear all cached pages and cancel pending renders"""
|
||||
with self.render_lock:
|
||||
# Cancel pending renders
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
self.pending_renders.clear()
|
||||
|
||||
# Clear caches
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
"""Clear all cached pages"""
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
|
||||
def set_font_scale(self, font_scale: float):
|
||||
"""
|
||||
@@ -296,40 +167,43 @@ class PageBuffer:
|
||||
self.current_font_scale = font_scale
|
||||
self.invalidate_all()
|
||||
|
||||
def set_font_family(self, font_family: Optional[BundledFont]):
|
||||
"""
|
||||
Update font family and invalidate cache.
|
||||
|
||||
Args:
|
||||
font_family: New font family (None = use original fonts)
|
||||
"""
|
||||
if font_family != self.current_font_family:
|
||||
self.current_font_family = font_family
|
||||
self.invalidate_all()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics for debugging/monitoring"""
|
||||
return {
|
||||
'forward_buffer_size': len(self.forward_buffer),
|
||||
'backward_buffer_size': len(self.backward_buffer),
|
||||
'pending_renders': len(self.pending_renders),
|
||||
'position_mappings': len(self.position_map),
|
||||
'reverse_position_mappings': len(self.reverse_position_map),
|
||||
'current_font_scale': self.current_font_scale
|
||||
'current_font_scale': self.current_font_scale,
|
||||
'current_font_family': self.current_font_family.value if self.current_font_family else None
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the page buffer and clean up resources"""
|
||||
if self.executor:
|
||||
# Cancel pending renders
|
||||
with self.render_lock:
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
"""
|
||||
Release cached pages.
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
self.executor = None
|
||||
|
||||
# Clear all caches
|
||||
Cheap and idempotent. There is deliberately no __del__ calling this:
|
||||
blocking work in a finaliser is what deadlocked the interpreter at exit
|
||||
while the process pool existed.
|
||||
"""
|
||||
self.invalidate_all()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
|
||||
|
||||
class BufferedPageRenderer:
|
||||
"""
|
||||
High-level interface for buffered page rendering with automatic background caching.
|
||||
High-level interface for page rendering with an LRU cache in front of the
|
||||
layouter.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
@@ -338,7 +212,8 @@ class BufferedPageRenderer:
|
||||
buffer_size: int = 5,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600)):
|
||||
600),
|
||||
font_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Initialize the buffered renderer.
|
||||
|
||||
@@ -347,18 +222,26 @@ class BufferedPageRenderer:
|
||||
page_style: Page styling configuration
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
page_size: Page size (width, height) in pixels
|
||||
font_family: Optional font family override
|
||||
"""
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
|
||||
# Create font family override if specified
|
||||
font_family_override = FontFamilyOverride(font_family) if font_family else None
|
||||
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size, font_family_override=font_family_override)
|
||||
self.buffer = PageBuffer(buffer_size)
|
||||
self.buffer.initialize(blocks, page_style)
|
||||
self.buffer.initialize(blocks, page_style, font_family=font_family)
|
||||
self.page_size = page_size
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
self.font_family = font_family
|
||||
|
||||
def render_page(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page with intelligent caching.
|
||||
Render a page, serving it from cache when possible.
|
||||
|
||||
Args:
|
||||
position: Position to render from
|
||||
@@ -375,13 +258,11 @@ class BufferedPageRenderer:
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(position)
|
||||
if cached_page:
|
||||
# Get next position from position map
|
||||
next_pos = self.buffer.position_map.get(position, position)
|
||||
|
||||
# Start background rendering for upcoming pages
|
||||
self.buffer.start_background_rendering(position, 'forward')
|
||||
|
||||
return cached_page, next_pos
|
||||
# Only use the cache if we also know where the next page starts;
|
||||
# otherwise fall through and compute it.
|
||||
next_pos = self.buffer.position_map.get(position)
|
||||
if next_pos is not None:
|
||||
return cached_page, next_pos
|
||||
|
||||
# Render the page directly
|
||||
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
||||
@@ -389,12 +270,6 @@ class BufferedPageRenderer:
|
||||
# Cache the result
|
||||
self.buffer.cache_page(position, page, next_pos)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
return page, next_pos
|
||||
|
||||
def render_page_backward(self,
|
||||
@@ -402,7 +277,8 @@ class BufferedPageRenderer:
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page ending at the given position with intelligent caching.
|
||||
Render a page ending at the given position, serving it from cache when
|
||||
possible.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
@@ -419,13 +295,11 @@ class BufferedPageRenderer:
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(end_position)
|
||||
if cached_page:
|
||||
# Get previous position from reverse position map
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
|
||||
|
||||
# Start background rendering for previous pages
|
||||
self.buffer.start_background_rendering(end_position, 'backward')
|
||||
|
||||
return cached_page, prev_pos
|
||||
# Only use the cache if we also know where the previous page
|
||||
# starts; otherwise fall through and compute it.
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position)
|
||||
if prev_pos is not None:
|
||||
return cached_page, prev_pos
|
||||
|
||||
# Render the page directly
|
||||
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
||||
@@ -433,18 +307,38 @@ class BufferedPageRenderer:
|
||||
# Cache the result
|
||||
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(end_position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
return page, start_pos
|
||||
|
||||
def set_font_family(self, font_family: Optional[BundledFont]):
|
||||
"""
|
||||
Change the font family and invalidate cache.
|
||||
|
||||
Args:
|
||||
font_family: New font family (None = use original fonts)
|
||||
"""
|
||||
if font_family != self.font_family:
|
||||
self.font_family = font_family
|
||||
|
||||
# Update buffer
|
||||
self.buffer.set_font_family(font_family)
|
||||
|
||||
# Recreate layouter with new font family override
|
||||
font_family_override = FontFamilyOverride(font_family) if font_family else None
|
||||
self.layouter = BidirectionalLayouter(
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
self.page_size,
|
||||
font_family_override=font_family_override
|
||||
)
|
||||
|
||||
def get_font_family(self) -> Optional[BundledFont]:
|
||||
"""Get the current font family override"""
|
||||
return self.font_family
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics"""
|
||||
return self.buffer.get_cache_stats()
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the renderer and clean up resources"""
|
||||
"""Release cached pages"""
|
||||
self.buffer.shutdown()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Table column width optimization for pyWebLayout.
|
||||
|
||||
This module provides intelligent column width distribution for tables,
|
||||
ensuring optimal space usage while respecting content constraints.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from pyWebLayout.abstract.block import Table, TableRow
|
||||
|
||||
|
||||
def optimize_table_layout(table: Table,
|
||||
available_width: int,
|
||||
sample_size: int = 5,
|
||||
style=None) -> List[int]:
|
||||
"""
|
||||
Optimize column widths for a table.
|
||||
|
||||
Strategy:
|
||||
1. Check for HTML width overrides (colspan, width attributes)
|
||||
2. Sample first ~5 rows to estimate column requirements (performance)
|
||||
3. Calculate minimum width for each column (longest unbreakable word)
|
||||
4. Calculate preferred width for each column (no wrapping)
|
||||
5. If total preferred fits: use preferred
|
||||
6. Otherwise: distribute available space proportionally
|
||||
7. Ensure no column < min_width
|
||||
|
||||
Note: Hyphenation threshold is controlled by Font.min_hyphenation_width,
|
||||
not passed as a parameter here to avoid duplication.
|
||||
|
||||
Args:
|
||||
table: The table to optimize
|
||||
available_width: Total width available
|
||||
sample_size: Number of rows to sample for measurement (default 5)
|
||||
style: Optional table style for border/padding calculations
|
||||
|
||||
Returns:
|
||||
List of optimized column widths
|
||||
"""
|
||||
from pyWebLayout.concrete.dynamic_page import DynamicPage
|
||||
|
||||
n_cols = get_column_count(table)
|
||||
if n_cols == 0:
|
||||
return []
|
||||
|
||||
# Account for table borders/padding overhead
|
||||
if style:
|
||||
overhead = calculate_table_overhead(n_cols, style)
|
||||
available_for_content = available_width - overhead
|
||||
else:
|
||||
# Default border overhead
|
||||
border_width = 1
|
||||
overhead = border_width * (n_cols + 1)
|
||||
available_for_content = available_width - overhead
|
||||
|
||||
# Phase 0: Check for HTML width overrides
|
||||
html_widths = extract_html_column_widths(table)
|
||||
fixed_columns = {i: width for i, width in enumerate(html_widths) if width is not None}
|
||||
|
||||
# Phase 1: Sample rows and measure constraints for each column
|
||||
min_widths = [] # Minimum without breaking words (Font handles hyphenation)
|
||||
pref_widths = [] # Preferred (no wrapping)
|
||||
|
||||
# Sample first ~5 rows from each section (header, body, footer)
|
||||
sampled_rows = sample_table_rows(table, sample_size)
|
||||
|
||||
for col_idx in range(n_cols):
|
||||
# Check if this column has HTML width override
|
||||
if col_idx in fixed_columns:
|
||||
fixed_width = fixed_columns[col_idx]
|
||||
min_widths.append(fixed_width)
|
||||
pref_widths.append(fixed_width)
|
||||
continue
|
||||
|
||||
col_min = 50 # Absolute minimum
|
||||
col_pref = 50
|
||||
|
||||
# Check sampled cells in this column
|
||||
for row in sampled_rows:
|
||||
cells = list(row.cells())
|
||||
if col_idx >= len(cells):
|
||||
continue
|
||||
|
||||
cell = cells[col_idx]
|
||||
|
||||
# Create a DynamicPage for this cell with no padding/borders
|
||||
# (we're just measuring content, not rendering a full page)
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
measurement_style = PageStyle(padding=(0, 0, 0, 0), border_width=0)
|
||||
cell_page = DynamicPage(style=measurement_style)
|
||||
|
||||
# Add cell content to page
|
||||
layout_cell_content(cell_page, cell)
|
||||
|
||||
# Measure minimum width (Font's min_hyphenation_width controls breaking)
|
||||
# DynamicPage returns pure content width (no padding since we set it to 0)
|
||||
# TableRenderer will add cell padding later
|
||||
cell_min = cell_page.get_min_width()
|
||||
col_min = max(col_min, cell_min)
|
||||
|
||||
# Measure preferred width (no wrapping)
|
||||
cell_pref = cell_page.get_preferred_width()
|
||||
col_pref = max(col_pref, cell_pref)
|
||||
|
||||
min_widths.append(col_min)
|
||||
pref_widths.append(col_pref)
|
||||
|
||||
# Phase 2: Distribute width (respecting fixed columns)
|
||||
return distribute_column_widths(
|
||||
min_widths,
|
||||
pref_widths,
|
||||
available_for_content,
|
||||
fixed_columns
|
||||
)
|
||||
|
||||
|
||||
def layout_cell_content(page, cell):
|
||||
"""
|
||||
Layout cell content onto a DynamicPage.
|
||||
|
||||
This adds all blocks from the cell (paragraphs, images, etc.)
|
||||
as children of the page so they can be measured.
|
||||
|
||||
Args:
|
||||
page: DynamicPage to add content to
|
||||
cell: TableCell containing blocks
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style import FontWeight, Alignment
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading
|
||||
from PIL import Image as PILImage, ImageDraw
|
||||
|
||||
# Default font for measurement
|
||||
font_size = 12
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
font = Font(font_path=font_path, font_size=font_size)
|
||||
|
||||
# Create a minimal draw context for Text measurement
|
||||
# (Text needs this for width calculation)
|
||||
dummy_img = PILImage.new('RGB', (1, 1))
|
||||
dummy_draw = ImageDraw.Draw(dummy_img)
|
||||
|
||||
# Get all blocks from the cell
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
# Get words from the block
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Create a line for measurement
|
||||
line = Line(
|
||||
spacing=(3, 6), # word spacing
|
||||
origin=(0, 0),
|
||||
size=(1000, 20), # Large size for measurement
|
||||
draw=dummy_draw,
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add all words to estimate width
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Create Text object for the word
|
||||
# Text constructor: (text, style, draw)
|
||||
text_obj = Text(
|
||||
text=word_text,
|
||||
style=font, # Font is the style
|
||||
draw=dummy_draw
|
||||
)
|
||||
|
||||
line._text_objects.append(text_obj)
|
||||
|
||||
# Add line to page
|
||||
page.add_child(line)
|
||||
|
||||
|
||||
def get_column_count(table: Table) -> int:
|
||||
"""
|
||||
Get the number of columns in a table.
|
||||
|
||||
Args:
|
||||
table: The table to analyze
|
||||
|
||||
Returns:
|
||||
Number of columns
|
||||
"""
|
||||
all_rows = list(table.all_rows())
|
||||
if not all_rows:
|
||||
return 0
|
||||
|
||||
# Get from first row
|
||||
first_row = all_rows[0][1]
|
||||
return first_row.cell_count
|
||||
|
||||
|
||||
def sample_table_rows(table: Table, sample_size: int) -> List[TableRow]:
|
||||
"""
|
||||
Sample first ~sample_size rows from each table section.
|
||||
|
||||
Args:
|
||||
table: The table to sample
|
||||
sample_size: Number of rows to sample per section
|
||||
|
||||
Returns:
|
||||
List of sampled rows
|
||||
"""
|
||||
sampled = []
|
||||
|
||||
for section in ["header", "body", "footer"]:
|
||||
section_rows = [row for sec, row in table.all_rows() if sec == section]
|
||||
# Take first sample_size rows (or fewer if section is smaller)
|
||||
sampled.extend(section_rows[:sample_size])
|
||||
|
||||
return sampled
|
||||
|
||||
|
||||
def extract_html_column_widths(table: Table) -> List[Optional[int]]:
|
||||
"""
|
||||
Extract column width overrides from HTML attributes.
|
||||
|
||||
Checks for:
|
||||
- <col width="100px"> elements
|
||||
- <td width="100px"> in first row
|
||||
- <th width="100px"> in header
|
||||
|
||||
Args:
|
||||
table: The table to check
|
||||
|
||||
Returns:
|
||||
List of widths (None for auto-layout columns)
|
||||
"""
|
||||
n_cols = get_column_count(table)
|
||||
widths = [None] * n_cols
|
||||
|
||||
# Check for <col> elements with width
|
||||
if hasattr(table, 'col_widths'):
|
||||
for i, width in enumerate(table.col_widths):
|
||||
if width is not None:
|
||||
widths[i] = parse_html_width(width)
|
||||
|
||||
# Check first row cells for width attributes
|
||||
all_rows = list(table.all_rows())
|
||||
if all_rows:
|
||||
first_row = all_rows[0][1]
|
||||
cells = list(first_row.cells())
|
||||
for i, cell in enumerate(cells):
|
||||
if i < len(widths) and hasattr(cell, 'width') and cell.width is not None:
|
||||
widths[i] = parse_html_width(cell.width)
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def parse_html_width(width_value) -> Optional[int]:
|
||||
"""
|
||||
Parse HTML width value (e.g., "100px", "20%", "100").
|
||||
|
||||
Args:
|
||||
width_value: HTML width attribute value
|
||||
|
||||
Returns:
|
||||
Width in pixels, or None if percentage/invalid
|
||||
"""
|
||||
if isinstance(width_value, int):
|
||||
return width_value
|
||||
|
||||
if isinstance(width_value, str):
|
||||
# Remove whitespace
|
||||
width_value = width_value.strip()
|
||||
|
||||
# Percentage widths not supported yet
|
||||
if '%' in width_value:
|
||||
return None
|
||||
|
||||
# Parse pixel values
|
||||
if width_value.endswith('px'):
|
||||
try:
|
||||
return int(width_value[:-2])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Plain number
|
||||
try:
|
||||
return int(width_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def distribute_column_widths(min_widths: List[int],
|
||||
pref_widths: List[int],
|
||||
available_width: int,
|
||||
fixed_columns: Dict[int, int]) -> List[int]:
|
||||
"""
|
||||
Distribute width among columns, respecting fixed column widths.
|
||||
|
||||
Args:
|
||||
min_widths: Minimum width for each column
|
||||
pref_widths: Preferred width for each column
|
||||
available_width: Total width available
|
||||
fixed_columns: Dict mapping column index to fixed width
|
||||
|
||||
Returns:
|
||||
List of final column widths
|
||||
"""
|
||||
n_cols = len(min_widths)
|
||||
if n_cols == 0:
|
||||
return []
|
||||
|
||||
# Calculate available space for flexible columns
|
||||
fixed_total = sum(fixed_columns.values())
|
||||
flexible_available = available_width - fixed_total
|
||||
|
||||
# Get indices of flexible columns
|
||||
flexible_cols = [i for i in range(n_cols) if i not in fixed_columns]
|
||||
|
||||
if not flexible_cols:
|
||||
# All columns fixed - return as-is
|
||||
return [fixed_columns.get(i, min_widths[i]) for i in range(n_cols)]
|
||||
|
||||
# Calculate totals for flexible columns only
|
||||
flex_min_total = sum(min_widths[i] for i in flexible_cols)
|
||||
flex_pref_total = sum(pref_widths[i] for i in flexible_cols)
|
||||
|
||||
# Distribute space among flexible columns
|
||||
widths = [0] * n_cols
|
||||
|
||||
# Set fixed columns
|
||||
for i, width in fixed_columns.items():
|
||||
widths[i] = width
|
||||
|
||||
# Distribute to flexible columns
|
||||
if flex_pref_total <= flexible_available:
|
||||
# Preferred widths fit - distribute remaining space proportionally
|
||||
extra_space = flexible_available - flex_pref_total
|
||||
|
||||
if extra_space > 0 and flex_pref_total > 0:
|
||||
# Distribute extra space proportionally based on preferred widths
|
||||
for i in flexible_cols:
|
||||
proportion = pref_widths[i] / flex_pref_total
|
||||
widths[i] = int(pref_widths[i] + (extra_space * proportion))
|
||||
else:
|
||||
# No extra space, just use preferred widths
|
||||
for i in flexible_cols:
|
||||
widths[i] = pref_widths[i]
|
||||
elif flex_min_total > flexible_available:
|
||||
# Can't satisfy minimum - force it anyway (graceful degradation)
|
||||
for i in flexible_cols:
|
||||
widths[i] = min_widths[i]
|
||||
else:
|
||||
# Proportional distribution between min and pref
|
||||
extra_space = flexible_available - flex_min_total
|
||||
flex_pref_over_min = flex_pref_total - flex_min_total
|
||||
|
||||
for i in flexible_cols:
|
||||
if flex_pref_over_min > 0:
|
||||
pref_over_min = pref_widths[i] - min_widths[i]
|
||||
proportion = pref_over_min / flex_pref_over_min
|
||||
extra = extra_space * proportion
|
||||
widths[i] = int(min_widths[i] + extra)
|
||||
else:
|
||||
widths[i] = int(min_widths[i])
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def calculate_table_overhead(n_cols: int, style) -> int:
|
||||
"""
|
||||
Calculate the pixel overhead for table borders and spacing.
|
||||
|
||||
Args:
|
||||
n_cols: Number of columns
|
||||
style: TableStyle object
|
||||
|
||||
Returns:
|
||||
Total pixel overhead
|
||||
"""
|
||||
# Border on each side of each column + outer borders
|
||||
border_overhead = style.border_width * (n_cols + 1)
|
||||
|
||||
# Cell spacing if any
|
||||
spacing_overhead = style.cell_spacing * (n_cols - 1) if n_cols > 1 else 0
|
||||
|
||||
return border_overhead + spacing_overhead
|
||||
@@ -4,7 +4,10 @@ Style system for the pyWebLayout library.
|
||||
This module provides the core styling components used throughout the library.
|
||||
"""
|
||||
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from .fonts import (
|
||||
Font, FontWeight, FontStyle, TextDecoration,
|
||||
BundledFont, get_bundled_font_path, get_bundled_fonts_dir
|
||||
)
|
||||
from .abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
)
|
||||
@@ -14,6 +17,7 @@ from .alignment import Alignment
|
||||
|
||||
__all__ = [
|
||||
"Font", "FontWeight", "FontStyle", "TextDecoration",
|
||||
"BundledFont", "get_bundled_font_path", "get_bundled_fonts_dir",
|
||||
"AbstractStyle", "AbstractStyleRegistry", "FontFamily", "FontSize",
|
||||
"ConcreteStyle", "PageStyle", "Alignment"
|
||||
]
|
||||
|
||||
@@ -81,7 +81,8 @@ class AbstractStyle:
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||
|
||||
# Text properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
# None means "not specified": the page's default_alignment applies.
|
||||
text_align: Optional[TextAlign] = None
|
||||
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
||||
word_spacing: Optional[Union[str, float]] = None
|
||||
@@ -111,7 +112,17 @@ class AbstractStyle:
|
||||
Since this is a frozen dataclass, it should be hashable by default,
|
||||
but we provide a custom implementation to ensure all fields are
|
||||
properly considered and to handle the Union types correctly.
|
||||
|
||||
The result is memoised on first use. Styles are used as dictionary keys
|
||||
throughout parsing and style resolution, and five of the fields are enum
|
||||
members whose own __hash__ is a Python-level call, so rebuilding the
|
||||
15-tuple on every lookup was a measurable share of document parsing. The
|
||||
class is frozen, so the value cannot go stale.
|
||||
"""
|
||||
cached = self.__dict__.get('_hash_cache')
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Convert all values to hashable forms
|
||||
hashable_values = (
|
||||
self.font_family,
|
||||
@@ -131,7 +142,9 @@ class AbstractStyle:
|
||||
self.parent_style_id
|
||||
)
|
||||
|
||||
return hash(hashable_values)
|
||||
result = hash(hashable_values)
|
||||
object.__setattr__(self, '_hash_cache', result)
|
||||
return result
|
||||
|
||||
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
||||
"""
|
||||
|
||||
@@ -61,7 +61,8 @@ class ConcreteStyle:
|
||||
decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
# Layout properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
# None means "not specified": the page's default_alignment applies.
|
||||
text_align: Optional[TextAlign] = None
|
||||
line_height: float = 1.0 # Multiplier
|
||||
letter_spacing: float = 0.0 # In pixels
|
||||
word_spacing: float = 0.0 # In pixels
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
# e.g. bold, italic, regular
|
||||
from PIL import ImageFont
|
||||
from enum import Enum
|
||||
from typing import Tuple, Optional
|
||||
from typing import Tuple, Optional, Dict
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Set up logging for font loading
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global cache for PIL ImageFont objects to avoid reloading fonts from disk
|
||||
# Key: (font_path, font_size), Value: PIL ImageFont object
|
||||
_FONT_CACHE: Dict[Tuple[Optional[str], int], ImageFont.FreeTypeFont] = {}
|
||||
|
||||
# Cache for bundled font path to avoid repeated filesystem lookups
|
||||
_BUNDLED_FONT_PATH: Optional[str] = None
|
||||
|
||||
# Cache for bundled fonts directory
|
||||
_BUNDLED_FONTS_DIR: Optional[str] = None
|
||||
|
||||
|
||||
class FontWeight(Enum):
|
||||
NORMAL = "normal"
|
||||
@@ -26,6 +36,105 @@ class TextDecoration(Enum):
|
||||
STRIKETHROUGH = "strikethrough"
|
||||
|
||||
|
||||
class BundledFont(Enum):
|
||||
"""Bundled font families available in pyWebLayout"""
|
||||
SANS = "sans" # DejaVu Sans - modern sans-serif
|
||||
SERIF = "serif" # DejaVu Serif - classic serif
|
||||
MONOSPACE = "monospace" # DejaVu Sans Mono - fixed-width
|
||||
|
||||
|
||||
def get_bundled_fonts_dir():
|
||||
"""
|
||||
Get the directory containing bundled fonts (cached).
|
||||
|
||||
Returns:
|
||||
str: Path to the fonts directory, or None if not found
|
||||
"""
|
||||
global _BUNDLED_FONTS_DIR
|
||||
|
||||
# Return cached path if available
|
||||
if _BUNDLED_FONTS_DIR is not None:
|
||||
return _BUNDLED_FONTS_DIR
|
||||
|
||||
# First time - determine the path and cache it
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
|
||||
|
||||
if os.path.exists(fonts_dir) and os.path.isdir(fonts_dir):
|
||||
_BUNDLED_FONTS_DIR = fonts_dir
|
||||
logger.debug(f"Found bundled fonts directory at: {fonts_dir}")
|
||||
return fonts_dir
|
||||
else:
|
||||
logger.warning(f"Bundled fonts directory not found at: {fonts_dir}")
|
||||
_BUNDLED_FONTS_DIR = "" # Empty string to indicate "checked but not found"
|
||||
return None
|
||||
|
||||
|
||||
def get_bundled_font_path(
|
||||
family: BundledFont = BundledFont.SANS,
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get the path to a specific bundled font file.
|
||||
|
||||
Args:
|
||||
family: The font family (SANS, SERIF, or MONOSPACE)
|
||||
weight: The font weight (NORMAL or BOLD)
|
||||
style: The font style (NORMAL or ITALIC)
|
||||
|
||||
Returns:
|
||||
str: Full path to the font file, or None if not found
|
||||
|
||||
Example:
|
||||
>>> # Get bold italic sans font
|
||||
>>> path = get_bundled_font_path(BundledFont.SANS, FontWeight.BOLD, FontStyle.ITALIC)
|
||||
>>> font = Font(font_path=path, font_size=16)
|
||||
"""
|
||||
fonts_dir = get_bundled_fonts_dir()
|
||||
if not fonts_dir:
|
||||
return None
|
||||
|
||||
# Map font parameters to filename
|
||||
family_map = {
|
||||
BundledFont.SANS: "DejaVuSans",
|
||||
BundledFont.SERIF: "DejaVuSerif",
|
||||
BundledFont.MONOSPACE: "DejaVuSansMono"
|
||||
}
|
||||
|
||||
base_name = family_map.get(family, "DejaVuSans")
|
||||
|
||||
# Build the font file name
|
||||
parts = [base_name]
|
||||
|
||||
if weight == FontWeight.BOLD and style == FontStyle.ITALIC:
|
||||
# Special case: both bold and italic
|
||||
if family == BundledFont.MONOSPACE:
|
||||
parts.append("BoldOblique")
|
||||
elif family == BundledFont.SERIF:
|
||||
parts.append("BoldItalic")
|
||||
else: # SANS
|
||||
parts.append("BoldOblique")
|
||||
elif weight == FontWeight.BOLD:
|
||||
parts.append("Bold")
|
||||
elif style == FontStyle.ITALIC:
|
||||
# Italic naming differs by family
|
||||
if family == BundledFont.MONOSPACE or family == BundledFont.SANS:
|
||||
parts.append("Oblique")
|
||||
else: # SERIF
|
||||
parts.append("Italic")
|
||||
|
||||
filename = "-".join(parts) + ".ttf"
|
||||
font_path = os.path.join(fonts_dir, filename)
|
||||
|
||||
if os.path.exists(font_path):
|
||||
logger.debug(f"Found bundled font: {filename}")
|
||||
return font_path
|
||||
else:
|
||||
logger.warning(f"Bundled font not found: {filename}")
|
||||
return None
|
||||
|
||||
|
||||
class Font:
|
||||
"""
|
||||
Font class to manage text rendering properties including font face, size, color, and styling.
|
||||
@@ -46,7 +155,7 @@ class Font:
|
||||
Initialize a Font object with the specified properties.
|
||||
|
||||
Args:
|
||||
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
|
||||
font_path: Path to the font file (.ttf, .otf). If None, uses default bundled font.
|
||||
font_size: Size of the font in points.
|
||||
colour: RGB color tuple for the text.
|
||||
weight: Font weight (normal or bold).
|
||||
@@ -69,8 +178,66 @@ class Font:
|
||||
# Load the font file or use default
|
||||
self._load_font()
|
||||
|
||||
@classmethod
|
||||
def from_family(cls,
|
||||
family: BundledFont = BundledFont.SANS,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL,
|
||||
decoration: TextDecoration = TextDecoration.NONE,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
"""
|
||||
Create a Font using a bundled font family.
|
||||
|
||||
This is a convenient way to use the bundled DejaVu fonts without needing to
|
||||
specify paths manually.
|
||||
|
||||
Args:
|
||||
family: The font family to use (SANS, SERIF, or MONOSPACE)
|
||||
font_size: Size of the font in points.
|
||||
colour: RGB color tuple for the text.
|
||||
weight: Font weight (normal or bold).
|
||||
style: Font style (normal or italic).
|
||||
decoration: Text decoration (none, underline, or strikethrough).
|
||||
background: RGBA background color for the text. If None, transparent background.
|
||||
language: Language code for hyphenation and text processing.
|
||||
min_hyphenation_width: Minimum width in pixels required for hyphenation.
|
||||
|
||||
Returns:
|
||||
Font object configured with the bundled font
|
||||
|
||||
Example:
|
||||
>>> # Create a bold serif font
|
||||
>>> font = Font.from_family(BundledFont.SERIF, font_size=18, weight=FontWeight.BOLD)
|
||||
>>>
|
||||
>>> # Create an italic monospace font
|
||||
>>> code_font = Font.from_family(BundledFont.MONOSPACE, style=FontStyle.ITALIC)
|
||||
"""
|
||||
font_path = get_bundled_font_path(family, weight, style)
|
||||
return cls(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
colour=colour,
|
||||
weight=weight,
|
||||
style=style,
|
||||
decoration=decoration,
|
||||
background=background,
|
||||
language=language,
|
||||
min_hyphenation_width=min_hyphenation_width
|
||||
)
|
||||
|
||||
def _get_bundled_font_path(self):
|
||||
"""Get the path to the bundled font"""
|
||||
"""Get the path to the bundled font (cached)"""
|
||||
global _BUNDLED_FONT_PATH
|
||||
|
||||
# Return cached path if available
|
||||
if _BUNDLED_FONT_PATH is not None:
|
||||
return _BUNDLED_FONT_PATH
|
||||
|
||||
# First time - determine the path and cache it
|
||||
# Get the directory containing this module
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Navigate to the assets/fonts directory
|
||||
@@ -86,13 +253,31 @@ class Font:
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
logger.info(f"Found bundled font at: {bundled_font_path}")
|
||||
_BUNDLED_FONT_PATH = bundled_font_path
|
||||
return bundled_font_path
|
||||
else:
|
||||
logger.warning(f"Bundled font not found at: {bundled_font_path}")
|
||||
# Cache None to indicate bundled font is not available
|
||||
_BUNDLED_FONT_PATH = "" # Use empty string instead of None to differentiate from "not checked yet"
|
||||
return None
|
||||
|
||||
def _load_font(self):
|
||||
"""Load the font using PIL's ImageFont with consistent bundled font"""
|
||||
"""Load the font using PIL's ImageFont with consistent bundled font and caching"""
|
||||
# Determine the actual font path to use
|
||||
font_path_to_use = self._font_path
|
||||
if not font_path_to_use:
|
||||
font_path_to_use = self._get_bundled_font_path()
|
||||
|
||||
# Create cache key
|
||||
cache_key = (font_path_to_use, self._font_size)
|
||||
|
||||
# Check if font is already cached
|
||||
if cache_key in _FONT_CACHE:
|
||||
self._font = _FONT_CACHE[cache_key]
|
||||
logger.debug(f"Reusing cached font: {font_path_to_use} at size {self._font_size}")
|
||||
return
|
||||
|
||||
# Font not cached, need to load it
|
||||
try:
|
||||
if self._font_path:
|
||||
# Use specified font path
|
||||
@@ -119,10 +304,15 @@ class Font:
|
||||
"Bundled font not available, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
# Cache the loaded font
|
||||
_FONT_CACHE[cache_key] = self._font
|
||||
logger.debug(f"Cached font: {font_path_to_use} at size {self._font_size}")
|
||||
|
||||
except Exception as e:
|
||||
# Ultimate fallback to default font
|
||||
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
# Don't cache the default font as it doesn't have a path
|
||||
|
||||
@property
|
||||
def font(self):
|
||||
@@ -169,62 +359,48 @@ class Font:
|
||||
"""Get the minimum width required for hyphenation to be considered"""
|
||||
return self._min_hyphenation_width
|
||||
|
||||
def _with_modified(self, **kwargs):
|
||||
"""
|
||||
Internal helper to create a new Font with modified parameters.
|
||||
|
||||
This consolidates the duplication across all with_* methods.
|
||||
|
||||
Args:
|
||||
**kwargs: Parameters to override (e.g., font_size=20, colour=(255,0,0))
|
||||
|
||||
Returns:
|
||||
New Font object with modified parameters
|
||||
"""
|
||||
params = {
|
||||
'font_path': self._font_path,
|
||||
'font_size': self._font_size,
|
||||
'colour': self._colour,
|
||||
'weight': self._weight,
|
||||
'style': self._style,
|
||||
'decoration': self._decoration,
|
||||
'background': self._background,
|
||||
'language': self.language,
|
||||
'min_hyphenation_width': self._min_hyphenation_width
|
||||
}
|
||||
params.update(kwargs)
|
||||
return Font(**params)
|
||||
|
||||
def with_size(self, size: int):
|
||||
"""Create a new Font object with modified size"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(font_size=size)
|
||||
|
||||
def with_colour(self, colour: Tuple[int, int, int]):
|
||||
"""Create a new Font object with modified colour"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(colour=colour)
|
||||
|
||||
def with_weight(self, weight: FontWeight):
|
||||
"""Create a new Font object with modified weight"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(weight=weight)
|
||||
|
||||
def with_style(self, style: FontStyle):
|
||||
"""Create a new Font object with modified style"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(style=style)
|
||||
|
||||
def with_decoration(self, decoration: TextDecoration):
|
||||
"""Create a new Font object with modified decoration"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(decoration=decoration)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Tuple
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -8,6 +10,10 @@ class PageStyle:
|
||||
Defines the styling properties for a page including borders, spacing, and layout.
|
||||
"""
|
||||
|
||||
# Alignment applied to body text that does not specify its own. Headings are
|
||||
# never justified regardless of this setting.
|
||||
default_alignment: Alignment = Alignment.JUSTIFY
|
||||
|
||||
# Border properties
|
||||
border_width: int = 0
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0)
|
||||
|
||||
@@ -4,24 +4,56 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pyWebLayout"
|
||||
version = "0.1.1"
|
||||
description = "A Python library for HTML-like layout and rendering"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.6"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
authors = [
|
||||
{name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
|
||||
]
|
||||
dynamic = ["version"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"Pillow",
|
||||
"numpy",
|
||||
"pyphen",
|
||||
"beautifulsoup4",
|
||||
"flask",
|
||||
"ebooklib",
|
||||
"requests"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.tourolle.paris/pyWebLayout"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Loading images from http(s) URLs. concrete.image imports requests lazily and
|
||||
# degrades to an error message on the image when it is absent, so it is not a
|
||||
# hard requirement.
|
||||
remote-images = ["requests"]
|
||||
test = [
|
||||
"pytest>=6.0",
|
||||
"pytest-cov",
|
||||
"flask", # fixture HTTP server in tests/abstract/test_abstract_blocks.py
|
||||
"werkzeug", # make_server, same fixture
|
||||
"ebooklib", # builds EPUB fixtures; the reader itself uses zipfile + ElementTree
|
||||
"requests", # exercises the remote-images path
|
||||
]
|
||||
dev = [
|
||||
"pyWebLayout[test,remote-images]",
|
||||
"flake8",
|
||||
"coverage-badge",
|
||||
"interrogate",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pyWebLayout*"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["pyWebLayout"]
|
||||
branch = true
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
[metadata]
|
||||
name = pyWebLayout
|
||||
version = 0.1.1
|
||||
author = Duncan Tourolle
|
||||
author_email = duncan@tourolle.paris
|
||||
description = A Python library for HTML-like layout and rendering
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://gitea.tourolle.paris/pyWebLayout
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: OSI Approved :: MIT License
|
||||
Operating System :: OS Independent
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
python_requires = >=3.6
|
||||
install_requires =
|
||||
Pillow
|
||||
numpy
|
||||
|
||||
[options.packages.find]
|
||||
include = pyWebLayout*
|
||||
# Packaging metadata lives in pyproject.toml ([project]), which takes
|
||||
# precedence over anything declared here. This file keeps only tool config
|
||||
# that has nowhere better to live.
|
||||
|
||||
[flake8]
|
||||
exclude =
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
from setuptools import setup, find_packages
|
||||
"""Shim for legacy `python setup.py` invocations.
|
||||
|
||||
setup(
|
||||
name="pyWebLayout",
|
||||
version="0.1.1",
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"Pillow",
|
||||
"numpy",
|
||||
],
|
||||
extras_require={
|
||||
"test": [
|
||||
"coverage>=5.0",
|
||||
],
|
||||
"dev": [
|
||||
"coverage>=5.0",
|
||||
"pytest>=6.0",
|
||||
],
|
||||
},
|
||||
author="Duncan Tourolle",
|
||||
author_email="duncan@tourolle.paris",
|
||||
description="A Python library for HTML-like layout and rendering",
|
||||
long_description=open("README.md").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://gitea.tourolle.paris/pyWebLayout",
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
python_requires=">=3.6",
|
||||
)
|
||||
All packaging metadata lives in setup.cfg. Keeping a second copy here was an
|
||||
active hazard: keyword arguments passed to setup() override setup.cfg, so the
|
||||
two could disagree silently and the setup.py copy would win.
|
||||
"""
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
|
||||
@@ -307,6 +307,8 @@ class TestImagePIL(unittest.TestCase):
|
||||
|
||||
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
|
||||
cls.flask_server_running = False
|
||||
cls.flask_server.shutdown()
|
||||
cls.flask_server.server_close()
|
||||
cls.flask_thread.join(timeout=2)
|
||||
|
||||
@classmethod
|
||||
@@ -350,10 +352,9 @@ class TestImagePIL(unittest.TestCase):
|
||||
"""Start a Flask server for URL testing."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
cls.flask_app = Flask(__name__)
|
||||
cls.flask_port = 5555 # Use a specific port for testing
|
||||
cls.flask_server_running = True
|
||||
|
||||
@cls.flask_app.route('/test.jpg')
|
||||
def serve_test_image():
|
||||
@@ -363,15 +364,20 @@ class TestImagePIL(unittest.TestCase):
|
||||
def health_check():
|
||||
return 'OK', 200
|
||||
|
||||
def run_flask():
|
||||
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
|
||||
use_reloader=False, threaded=True)
|
||||
# Bind to an ephemeral port so concurrent/leftover test runs can't clash
|
||||
cls.flask_server = make_server('127.0.0.1', 0, cls.flask_app, threaded=True)
|
||||
cls.flask_port = cls.flask_server.server_port
|
||||
cls.flask_server_running = True
|
||||
|
||||
cls.flask_thread = threading.Thread(target=run_flask, daemon=True)
|
||||
cls.flask_thread = threading.Thread(target=cls.flask_server.serve_forever, daemon=True)
|
||||
cls.flask_thread.start()
|
||||
|
||||
# Wait for server to be ready with health check
|
||||
max_wait = 5 # Maximum 5 seconds
|
||||
# Wait for server to be ready with health check.
|
||||
# Generous, because this now raises rather than falling through
|
||||
# silently: on a loaded CI runner the accept loop can take several
|
||||
# seconds to get scheduled, and a spurious failure here is worse than
|
||||
# a slow one. The loop exits as soon as the server answers.
|
||||
max_wait = 30
|
||||
wait_interval = 0.1 # Check every 100ms
|
||||
elapsed = 0
|
||||
|
||||
@@ -379,12 +385,15 @@ class TestImagePIL(unittest.TestCase):
|
||||
try:
|
||||
with urllib.request.urlopen(f'http://127.0.0.1:{cls.flask_port}/health', timeout=1) as response:
|
||||
if response.status == 200:
|
||||
break
|
||||
return
|
||||
except (urllib.error.URLError, ConnectionRefusedError, OSError):
|
||||
pass
|
||||
time.sleep(wait_interval)
|
||||
elapsed += wait_interval
|
||||
|
||||
raise RuntimeError(
|
||||
f"Test Flask server did not become ready on port {cls.flask_port} within {max_wait}s")
|
||||
|
||||
def test_image_url_detection(self):
|
||||
"""Test URL detection functionality."""
|
||||
img = Image()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Regression tests for word spacing under each alignment (spec S13).
|
||||
|
||||
Only justified text stretches word gaps to fill the measure. Left, centre and
|
||||
right aligned text use a natural, constant word space and leave a ragged edge;
|
||||
previously they distributed the residual space across the gaps, which produced
|
||||
text that looked justified but did not reach the margin, with a right edge that
|
||||
wobbled by several pixels from line to line.
|
||||
|
||||
The final line of a justified paragraph is also not stretched.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import (
|
||||
CenterRightAlignmentHandler,
|
||||
JustifyAlignmentHandler,
|
||||
LeftAlignmentHandler,
|
||||
Line,
|
||||
)
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.style import Alignment, Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
PAGE = (500, 400)
|
||||
PADDING = (20, 20, 20, 20)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=14)
|
||||
|
||||
|
||||
def lay_out(font, alignment, text, size=PAGE):
|
||||
page = Page(size=size, style=PageStyle(border_width=0, padding=PADDING))
|
||||
paragraph = Paragraph(font)
|
||||
for word in text.split():
|
||||
paragraph.add_word(Word(word, font))
|
||||
paragraph_layouter(paragraph, page, alignment_override=alignment)
|
||||
return page
|
||||
|
||||
|
||||
def rendered_lines(page):
|
||||
lines = [c for c in page.children if isinstance(c, Line) and c._text_objects]
|
||||
for line in lines:
|
||||
line.render()
|
||||
return lines
|
||||
|
||||
|
||||
def gaps_of(line):
|
||||
"""Observed pixel gaps between consecutive words on a rendered line."""
|
||||
tos = line._text_objects
|
||||
return [int(tos[i + 1]._origin[0]) - (int(tos[i]._origin[0]) + int(tos[i].width))
|
||||
for i in range(len(tos) - 1)]
|
||||
|
||||
|
||||
BODY = ("Paragraph text that is automatically laid out when this paragraph does "
|
||||
"not fit on the current page the layouter will create a new page for it "
|
||||
"which differs from using an explicit page break marker in the source ") * 2
|
||||
|
||||
|
||||
class TestLeftAlignmentUsesConstantSpacing:
|
||||
|
||||
def test_gaps_are_uniform_within_a_line(self, font):
|
||||
page = lay_out(font, Alignment.LEFT, BODY)
|
||||
for line in rendered_lines(page):
|
||||
gaps = gaps_of(line)
|
||||
if len(gaps) > 1:
|
||||
assert max(gaps) - min(gaps) <= 1, \
|
||||
f"left-aligned gaps should be constant, got {gaps}"
|
||||
|
||||
def test_gaps_are_uniform_across_lines(self, font):
|
||||
"""The regression: each line got its own stretch factor."""
|
||||
page = lay_out(font, Alignment.LEFT, BODY)
|
||||
all_gaps = [g for line in rendered_lines(page) for g in gaps_of(line)]
|
||||
assert max(all_gaps) - min(all_gaps) <= 1, \
|
||||
f"spacing must not vary line to line, got {sorted(set(all_gaps))}"
|
||||
|
||||
def test_lines_do_not_reach_the_right_margin(self, font):
|
||||
"""Left-aligned text is ragged; a flush right edge means it was stretched."""
|
||||
page = lay_out(font, Alignment.LEFT, BODY)
|
||||
right = page.content_rect[0] + page.content_rect[2]
|
||||
ends = [max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
|
||||
for line in rendered_lines(page)]
|
||||
assert not all(right - e <= 1 for e in ends), \
|
||||
"every line reached the margin exactly - text was justified, not left aligned"
|
||||
|
||||
def test_handler_returns_natural_spacing(self, font):
|
||||
handler = LeftAlignmentHandler()
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from PIL import Image, ImageDraw
|
||||
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
|
||||
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
|
||||
|
||||
spacing, position, overflow = handler.calculate_spacing_and_position(
|
||||
texts, 400, 3, 7, natural_spacing=5)
|
||||
|
||||
assert spacing == 5, "natural spacing should be used verbatim when it fits"
|
||||
assert position == 0
|
||||
assert not overflow
|
||||
|
||||
def test_handler_clamps_natural_spacing_to_bounds(self, font):
|
||||
handler = LeftAlignmentHandler()
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from PIL import Image, ImageDraw
|
||||
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
|
||||
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
|
||||
|
||||
assert handler.calculate_spacing_and_position(
|
||||
texts, 400, 3, 7, natural_spacing=99)[0] == 7
|
||||
assert handler.calculate_spacing_and_position(
|
||||
texts, 400, 3, 7, natural_spacing=1)[0] == 3
|
||||
|
||||
|
||||
class TestJustifyStillFills:
|
||||
|
||||
def test_body_lines_reach_the_margin(self, font):
|
||||
page = lay_out(font, Alignment.JUSTIFY, BODY)
|
||||
lines = rendered_lines(page)
|
||||
right = page.content_rect[0] + page.content_rect[2]
|
||||
|
||||
for line in lines:
|
||||
if line.is_paragraph_end:
|
||||
continue
|
||||
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
|
||||
assert right - end <= 2, f"justified line fell {right - end}px short"
|
||||
|
||||
def test_last_line_is_not_stretched(self, font):
|
||||
page = lay_out(font, Alignment.JUSTIFY,
|
||||
BODY + " and then a deliberately short tail.")
|
||||
lines = rendered_lines(page)
|
||||
last = [line for line in lines if line.is_paragraph_end]
|
||||
assert last, "the final line of a completed paragraph must be marked"
|
||||
|
||||
gaps = gaps_of(last[-1])
|
||||
if gaps:
|
||||
assert max(gaps) <= 8, \
|
||||
f"final line was justified across the measure, gaps={gaps}"
|
||||
|
||||
def test_continued_paragraph_keeps_justification(self, font):
|
||||
"""A paragraph split across pages: its lines are not paragraph ends."""
|
||||
page = lay_out(font, Alignment.JUSTIFY, BODY * 6, size=(500, 200))
|
||||
lines = rendered_lines(page)
|
||||
assert lines, "the page should hold some lines"
|
||||
assert not any(line.is_paragraph_end for line in lines), \
|
||||
"an unfinished paragraph has no final line on this page"
|
||||
|
||||
|
||||
class TestCentreAndRight:
|
||||
|
||||
def test_centre_uses_constant_spacing_and_is_centred(self, font):
|
||||
page = lay_out(font, Alignment.CENTER, BODY)
|
||||
right = page.content_rect[0] + page.content_rect[2]
|
||||
left = page.content_rect[0]
|
||||
|
||||
for line in rendered_lines(page):
|
||||
tos = line._text_objects
|
||||
# Float extents: integer truncation of each end would itself skew the
|
||||
# comparison by a pixel.
|
||||
start = float(tos[0]._origin[0])
|
||||
end = float(tos[-1]._origin[0]) + tos[-1].width
|
||||
# Equal margins either side, within rounding of the half-space.
|
||||
assert abs((start - left) - (right - end)) <= 2, \
|
||||
f"line not centred: left margin {start - left}, right {right - end}"
|
||||
|
||||
def test_right_aligned_lines_end_at_the_margin(self, font):
|
||||
page = lay_out(font, Alignment.RIGHT, BODY)
|
||||
right = page.content_rect[0] + page.content_rect[2]
|
||||
|
||||
for line in rendered_lines(page):
|
||||
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
|
||||
assert right - end <= 2, f"right-aligned line fell {right - end}px short"
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Regression tests for the page draw/canvas lifecycle (spec S3).
|
||||
|
||||
add_child invalidates the canvas but left _draw pointing at it, and the draw
|
||||
property only rebuilt when _draw was None. Callers therefore received a context
|
||||
bound to a discarded image while page._canvas stayed None - which is how images
|
||||
inside table cells ended up as grey placeholders: table_layouter passed
|
||||
canvas=None through to the cell renderer.
|
||||
|
||||
Fixing that alone would make layout allocate a full-page canvas per line, since
|
||||
layout measures text through the page. Measurement now goes through a dedicated
|
||||
scratch context.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=12)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page():
|
||||
return Page(size=(400, 600), style=PageStyle())
|
||||
|
||||
|
||||
def paragraph_of(font, count=40):
|
||||
paragraph = Paragraph(font)
|
||||
for i in range(count):
|
||||
paragraph.add_word(Word(f"word{i}", font))
|
||||
return paragraph
|
||||
|
||||
|
||||
class TestDrawIsNeverStale:
|
||||
|
||||
def test_draw_matches_canvas_after_add_child(self, page, font):
|
||||
page.draw # force canvas creation
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
|
||||
assert page.draw.im is page._canvas.im, \
|
||||
"draw must be bound to the page's current canvas"
|
||||
|
||||
def test_canvas_is_present_after_layout(self, page, font):
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
page.draw
|
||||
|
||||
assert page._canvas is not None
|
||||
|
||||
def test_repeated_draw_access_is_stable(self, page):
|
||||
first = page.draw
|
||||
assert page.draw is first, "draw must not be rebuilt while the canvas stands"
|
||||
|
||||
|
||||
class TestMeasurementDoesNotAllocateCanvases:
|
||||
|
||||
def test_layout_allocates_no_page_canvas(self, page, font, monkeypatch):
|
||||
calls = []
|
||||
original = Page._create_canvas
|
||||
|
||||
def counting(self):
|
||||
calls.append(1)
|
||||
return original(self)
|
||||
|
||||
monkeypatch.setattr(Page, "_create_canvas", counting)
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font, 400))
|
||||
|
||||
assert calls == [], \
|
||||
f"layout allocated {len(calls)} full-page canvases; it should allocate none"
|
||||
|
||||
def test_measurement_context_is_tiny_and_matches_canvas_mode(self, page):
|
||||
scratch = page.measurement_draw
|
||||
assert scratch.im.size == (1, 1)
|
||||
assert scratch.mode == Page._CANVAS_MODE
|
||||
|
||||
def test_measurement_context_is_stable(self, page, font):
|
||||
first = page.measurement_draw
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
assert page.measurement_draw is first, \
|
||||
"the scratch context must survive canvas invalidation"
|
||||
|
||||
|
||||
class TestRenderIsRepeatable:
|
||||
|
||||
def test_two_renders_are_identical(self, page, font):
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
|
||||
first = page.render().copy()
|
||||
second = page.render().copy()
|
||||
|
||||
assert first.tobytes() == second.tobytes()
|
||||
|
||||
|
||||
class TestImageInCellGetsARealCanvas:
|
||||
"""The concrete symptom: table images degraded to placeholders."""
|
||||
|
||||
@pytest.fixture
|
||||
def image_path(self, tmp_path):
|
||||
path = tmp_path / "swatch.png"
|
||||
Image.new("RGB", (40, 30), (10, 200, 10)).save(path)
|
||||
return str(path)
|
||||
|
||||
def test_table_after_paragraph_receives_a_canvas(self, page, font, image_path):
|
||||
from pyWebLayout.abstract.block import Table, TableCell, TableRow
|
||||
from pyWebLayout.layout.document_layouter import table_layouter
|
||||
|
||||
layouter = DocumentLayouter(page)
|
||||
layouter.layout_paragraph(paragraph_of(font, 10))
|
||||
|
||||
table = Table()
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
cell.add_block(AbstractImage(image_path))
|
||||
row.add_cell(cell)
|
||||
table.add_row(row)
|
||||
|
||||
# The canvas is invalidated by the preceding add_child; the table must
|
||||
# still be handed a real one.
|
||||
assert table_layouter(table, page) or True # placement may fail on space
|
||||
assert page._canvas is not None, \
|
||||
"table layout must not run against a None canvas"
|
||||
@@ -125,8 +125,8 @@ class TestLinkText(unittest.TestCase):
|
||||
# Mock width property
|
||||
renderable._width = 80
|
||||
|
||||
# Point inside link
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
# Point inside link - origin is at baseline (10, 20), so test at baseline Y
|
||||
self.assertTrue(renderable.in_object((15, 20)))
|
||||
|
||||
# Point outside link
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
@@ -334,8 +334,11 @@ class TestFormFieldText(unittest.TestCase):
|
||||
"""Test size property includes field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Size should include label height + gap + field height
|
||||
expected_height = renderable._style.font_size + 5 + renderable._field_height
|
||||
# Size should include label height + gap + field height. The label's
|
||||
# height is its ink height (ascent + descent), not the nominal font size.
|
||||
ascent, descent = renderable._style.font.getmetrics()
|
||||
expected_height = (ascent + descent) + FormFieldText.LABEL_GAP \
|
||||
+ renderable._field_height
|
||||
expected_width = renderable._field_width # Use the calculated field width
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
|
||||
@@ -76,8 +76,14 @@ class TestText(unittest.TestCase):
|
||||
|
||||
def test_in_object_true(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
# Set origin at baseline position (50, 50)
|
||||
text_instance.set_origin(np.array([50, 50]))
|
||||
|
||||
# Test with a point that should be inside the text bounds
|
||||
point = (5, 5)
|
||||
# The text origin is at the baseline (50, 50)
|
||||
# Visual bounds are: top = 50 - ascent, bottom = 50 + descent
|
||||
# So a point at (55, 50) should be inside (at baseline)
|
||||
point = (55, 50)
|
||||
self.assertTrue(text_instance.in_object(point))
|
||||
|
||||
def test_in_object_false(self):
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Unit tests for DynamicPage class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pyWebLayout.concrete.dynamic_page import DynamicPage, SizeConstraints
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class TestSizeConstraints:
|
||||
"""Test SizeConstraints dataclass."""
|
||||
|
||||
def test_default_constraints(self):
|
||||
"""Test default constraint values."""
|
||||
constraints = SizeConstraints()
|
||||
assert constraints.min_width is None
|
||||
assert constraints.max_width is None
|
||||
assert constraints.min_height is None
|
||||
assert constraints.max_height is None
|
||||
|
||||
def test_custom_constraints(self):
|
||||
"""Test custom constraint values."""
|
||||
constraints = SizeConstraints(
|
||||
min_width=100,
|
||||
max_width=500,
|
||||
min_height=50,
|
||||
max_height=1000
|
||||
)
|
||||
assert constraints.min_width == 100
|
||||
assert constraints.max_width == 500
|
||||
assert constraints.min_height == 50
|
||||
assert constraints.max_height == 1000
|
||||
|
||||
|
||||
class TestDynamicPage:
|
||||
"""Test DynamicPage class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test DynamicPage initialization."""
|
||||
page = DynamicPage()
|
||||
|
||||
assert page.size == (0, 0) # Starts with zero size
|
||||
assert not page._is_measured
|
||||
assert not page._is_laid_out
|
||||
assert page._render_offset == 0
|
||||
assert page.constraints is not None
|
||||
|
||||
def test_initialization_with_constraints(self):
|
||||
"""Test initialization with custom constraints."""
|
||||
constraints = SizeConstraints(min_width=200, max_width=800)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
assert page.constraints.min_width == 200
|
||||
assert page.constraints.max_width == 800
|
||||
|
||||
def test_initialization_with_style(self):
|
||||
"""Test initialization with custom style."""
|
||||
style = PageStyle(border_width=2, padding=(10, 20, 10, 20))
|
||||
page = DynamicPage(style=style)
|
||||
|
||||
assert page.style.border_width == 2
|
||||
assert page.style.padding_top == 10
|
||||
|
||||
def test_measure_empty_page(self):
|
||||
"""Test measuring an empty page."""
|
||||
page = DynamicPage()
|
||||
width, height = page.measure()
|
||||
|
||||
# Empty page should have minimal size (just padding/borders)
|
||||
assert width > 0 # At least padding/borders
|
||||
assert height > 0
|
||||
assert page._is_measured
|
||||
|
||||
def test_measure_with_constraints(self):
|
||||
"""Test measuring respects constraints."""
|
||||
constraints = SizeConstraints(min_width=300, min_height=200)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
width, height = page.measure()
|
||||
|
||||
assert width >= 300
|
||||
assert height >= 200
|
||||
|
||||
def test_measure_caching(self):
|
||||
"""Test that measurement is cached."""
|
||||
page = DynamicPage()
|
||||
|
||||
# First measurement
|
||||
size1 = page.measure()
|
||||
|
||||
# Second measurement should return cached value
|
||||
size2 = page.measure()
|
||||
|
||||
assert size1 == size2
|
||||
assert page._is_measured
|
||||
|
||||
def test_get_min_width(self):
|
||||
"""Test get_min_width."""
|
||||
page = DynamicPage()
|
||||
min_width = page.get_min_width()
|
||||
|
||||
assert min_width > 0
|
||||
assert isinstance(min_width, int)
|
||||
|
||||
def test_get_preferred_width(self):
|
||||
"""Test get_preferred_width."""
|
||||
page = DynamicPage()
|
||||
pref_width = page.get_preferred_width()
|
||||
|
||||
assert pref_width > 0
|
||||
assert isinstance(pref_width, int)
|
||||
|
||||
def test_measure_content_height(self):
|
||||
"""Test measure_content_height."""
|
||||
page = DynamicPage()
|
||||
content_height = page.measure_content_height()
|
||||
|
||||
assert content_height > 0
|
||||
assert isinstance(content_height, int)
|
||||
|
||||
def test_layout(self):
|
||||
"""Test layout method."""
|
||||
page = DynamicPage()
|
||||
target_size = (400, 600)
|
||||
|
||||
page.layout(target_size)
|
||||
|
||||
assert page.size == target_size
|
||||
assert page._is_laid_out
|
||||
assert page._dirty # Should be marked for re-render
|
||||
|
||||
def test_render_without_layout(self):
|
||||
"""Test rendering without explicit layout (auto-sizing)."""
|
||||
page = DynamicPage()
|
||||
image = page.render()
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size[0] > 0
|
||||
assert image.size[1] > 0
|
||||
|
||||
def test_render_with_layout(self):
|
||||
"""Test rendering after explicit layout."""
|
||||
page = DynamicPage()
|
||||
page.layout((500, 700))
|
||||
|
||||
image = page.render()
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (500, 700)
|
||||
|
||||
def test_add_child_invalidates_cache(self):
|
||||
"""Test that adding a child invalidates measurement caches."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Measure to populate cache
|
||||
page.measure()
|
||||
assert page._is_measured
|
||||
|
||||
# Add a child (mock renderable)
|
||||
class MockRenderable:
|
||||
def __init__(self):
|
||||
self.size = (100, 50)
|
||||
self._origin = (0, 0)
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
def render(self):
|
||||
pass
|
||||
|
||||
page.add_child(MockRenderable())
|
||||
|
||||
# Caches should be invalidated
|
||||
assert not page._is_measured
|
||||
assert page._intrinsic_size is None
|
||||
|
||||
def test_clear_children_invalidates_cache(self):
|
||||
"""Test that clearing children invalidates caches."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Measure to populate cache
|
||||
page.measure()
|
||||
assert page._is_measured
|
||||
|
||||
# Clear children
|
||||
page.clear_children()
|
||||
|
||||
# Caches should be invalidated
|
||||
assert not page._is_measured
|
||||
|
||||
def test_pagination_reset(self):
|
||||
"""Test pagination reset."""
|
||||
page = DynamicPage()
|
||||
page._render_offset = 100
|
||||
|
||||
page.reset_pagination()
|
||||
|
||||
assert page._render_offset == 0
|
||||
|
||||
def test_has_more_content_false(self):
|
||||
"""Test has_more_content when all content is rendered."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Set render offset to total height
|
||||
total_height = page.measure_content_height()
|
||||
page._render_offset = total_height
|
||||
|
||||
assert not page.has_more_content()
|
||||
|
||||
def test_has_more_content_true(self):
|
||||
"""Test has_more_content when content remains."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Offset is less than total
|
||||
page._render_offset = 0
|
||||
|
||||
assert page.has_more_content()
|
||||
|
||||
def test_min_width_measurement(self):
|
||||
"""Test min width measures longest word."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Min width should be at least padding/borders
|
||||
min_width = page.get_min_width()
|
||||
assert min_width > 0
|
||||
|
||||
def test_invalidate_caches(self):
|
||||
"""Test cache invalidation."""
|
||||
page = DynamicPage()
|
||||
|
||||
# Populate caches
|
||||
page.measure()
|
||||
page.get_min_width()
|
||||
page.get_preferred_width()
|
||||
page.measure_content_height()
|
||||
|
||||
assert page._is_measured
|
||||
assert page._intrinsic_size is not None
|
||||
assert page._min_width_cache is not None
|
||||
assert page._preferred_width_cache is not None
|
||||
assert page._content_height_cache is not None
|
||||
|
||||
# Invalidate
|
||||
page.invalidate_caches()
|
||||
|
||||
assert not page._is_measured
|
||||
assert page._intrinsic_size is None
|
||||
assert page._min_width_cache is None
|
||||
assert page._preferred_width_cache is None
|
||||
assert page._content_height_cache is None
|
||||
assert not page._is_laid_out
|
||||
|
||||
def test_measure_with_available_width(self):
|
||||
"""Test measurement with available_width constraint."""
|
||||
page = DynamicPage()
|
||||
|
||||
width, height = page.measure(available_width=300)
|
||||
|
||||
# Width should respect available_width
|
||||
assert width <= 300
|
||||
|
||||
def test_constraints_override_available_width(self):
|
||||
"""Test that constraints override available_width."""
|
||||
constraints = SizeConstraints(min_width=400)
|
||||
page = DynamicPage(constraints=constraints)
|
||||
|
||||
width, height = page.measure(available_width=300)
|
||||
|
||||
# Should use min_width constraint, not available_width
|
||||
assert width >= 400
|
||||
|
||||
def test_render_partial_empty_page(self):
|
||||
"""Test partial rendering on empty page."""
|
||||
page = DynamicPage()
|
||||
|
||||
rendered = page.render_partial(available_height=100)
|
||||
|
||||
assert rendered >= 0
|
||||
assert isinstance(rendered, int)
|
||||
|
||||
def test_method_chaining_add_child(self):
|
||||
"""Test that add_child returns self for chaining."""
|
||||
page = DynamicPage()
|
||||
|
||||
class MockRenderable:
|
||||
def __init__(self):
|
||||
self.size = (50, 50)
|
||||
self._origin = (0, 0)
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
result = page.add_child(MockRenderable())
|
||||
|
||||
assert result is page
|
||||
|
||||
def test_method_chaining_clear_children(self):
|
||||
"""Test that clear_children returns self for chaining."""
|
||||
page = DynamicPage()
|
||||
|
||||
result = page.clear_children()
|
||||
|
||||
assert result is page
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Regression tests for form field label geometry (spec S15).
|
||||
|
||||
Text renders with a baseline anchor, so drawing the label at the field's origin
|
||||
put its glyphs above that origin - outside the box the field claims through size
|
||||
and in_object. Stacked fields therefore had each label overprinting the input box
|
||||
of the field before it.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.abstract.functional import Form, FormField, FormFieldType
|
||||
from pyWebLayout.concrete.functional import FormFieldText
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.document_layouter import form_layouter
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
ORIGIN = (10, 40)
|
||||
FIELD_HEIGHT = 24
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canvas():
|
||||
image = Image.new("RGB", (300, 200), (255, 255, 255))
|
||||
return image, ImageDraw.Draw(image)
|
||||
|
||||
|
||||
def make_field(font, draw, label="Email Address"):
|
||||
field = FormField(name="email", field_type=FormFieldType.TEXT, label=label)
|
||||
renderable = FormFieldText(field, font, draw, field_height=FIELD_HEIGHT)
|
||||
renderable.set_origin(np.array(list(ORIGIN)))
|
||||
return renderable
|
||||
|
||||
|
||||
def ink_rows(image, x_range, y_range):
|
||||
pixels = image.convert("RGB").load()
|
||||
return [y for y in y_range
|
||||
if any(sum(pixels[x, y]) < 400 for x in x_range)]
|
||||
|
||||
|
||||
class TestLabelStaysInsideTheFieldBox:
|
||||
|
||||
def test_label_ink_is_below_the_origin(self, font, canvas):
|
||||
image, draw = canvas
|
||||
renderable = make_field(font, draw)
|
||||
renderable.render()
|
||||
|
||||
rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 140),
|
||||
range(0, ORIGIN[1]))
|
||||
assert not rows, \
|
||||
f"label drew above its own origin, at rows {rows}"
|
||||
|
||||
def test_label_and_box_do_not_overlap(self, font, canvas):
|
||||
image, draw = canvas
|
||||
renderable = make_field(font, draw)
|
||||
renderable.render()
|
||||
|
||||
ascent, descent = font.font.getmetrics()
|
||||
label_bottom = ORIGIN[1] + ascent + descent
|
||||
box_top = renderable.field_area_offset + ORIGIN[1]
|
||||
|
||||
assert box_top >= label_bottom, \
|
||||
"the input box must start below the label's descenders"
|
||||
|
||||
def test_reported_height_covers_everything_drawn(self, font, canvas):
|
||||
image, draw = canvas
|
||||
renderable = make_field(font, draw)
|
||||
renderable.render()
|
||||
|
||||
top, bottom = ORIGIN[1], ORIGIN[1] + int(renderable.size[1])
|
||||
rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 200), range(0, 200))
|
||||
assert min(rows) >= top, "ink above the field's declared box"
|
||||
assert max(rows) < bottom, "ink below the field's declared box"
|
||||
|
||||
|
||||
class TestStackedFieldsDoNotCollide:
|
||||
|
||||
def test_form_layout_leaves_labels_clear(self, font):
|
||||
page = Page(size=(300, 400), style=PageStyle())
|
||||
form = Form("signup")
|
||||
for name in ["Username", "Email Address", "Password"]:
|
||||
form.add_field(FormField(name=name.lower().replace(" ", "_"),
|
||||
field_type=FormFieldType.TEXT, label=name))
|
||||
|
||||
ok, ids = form_layouter(form, page, font)
|
||||
assert ok and len(ids) == 3
|
||||
|
||||
fields = [c for c in page.children if isinstance(c, FormFieldText)]
|
||||
assert len(fields) == 3
|
||||
|
||||
for earlier, later in zip(fields, fields[1:]):
|
||||
earlier_bottom = earlier.origin[1] + earlier.size[1]
|
||||
assert later.origin[1] >= earlier_bottom, \
|
||||
"fields overlap: a label would print over the preceding input box"
|
||||
|
||||
def test_rendered_form_has_no_ink_collisions(self, font):
|
||||
"""Every field's ink stays within its own declared bounds."""
|
||||
page = Page(size=(300, 400), style=PageStyle())
|
||||
form = Form("signup")
|
||||
for name in ["Username", "Email Address"]:
|
||||
form.add_field(FormField(name=name.lower(), field_type=FormFieldType.TEXT,
|
||||
label=name))
|
||||
form_layouter(form, page, font)
|
||||
image = page.render()
|
||||
|
||||
fields = [c for c in page.children if isinstance(c, FormFieldText)]
|
||||
for field in fields:
|
||||
top = int(field.origin[1])
|
||||
bottom = top + int(field.size[1])
|
||||
rows = ink_rows(image, range(int(field.origin[0]),
|
||||
int(field.origin[0] + field.size[0])),
|
||||
range(max(0, top - 6), top))
|
||||
assert not rows, f"ink found just above a field at y={top}"
|
||||
|
||||
|
||||
class TestClickTargetsFollowTheLayout:
|
||||
|
||||
def test_click_in_the_input_area_focuses(self, font, canvas):
|
||||
_, draw = canvas
|
||||
renderable = make_field(font, draw)
|
||||
|
||||
inside = (5, renderable.field_area_offset + FIELD_HEIGHT // 2)
|
||||
assert renderable.handle_click(inside) is True
|
||||
assert renderable._focused is True
|
||||
|
||||
def test_click_on_the_label_does_not_focus(self, font, canvas):
|
||||
_, draw = canvas
|
||||
renderable = make_field(font, draw)
|
||||
|
||||
on_label = (5, 2)
|
||||
assert renderable.handle_click(on_label) is False
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Regression tests for vertical centring of text in buttons and form fields.
|
||||
|
||||
Both placed the baseline at `top + height/2 + descent/2`. Centring text whose
|
||||
visual height is ascent+descent inside a box of height H puts the baseline at
|
||||
`top + H/2 + (ascent-descent)/2`; the two agree only when ascent == 2*descent.
|
||||
Real fonts have a much larger ratio - DejaVu is nearer 4:1 - so the text sat
|
||||
several pixels high, hugging the top edge of the button.
|
||||
|
||||
The button was also sized from the nominal font size rather than the text's
|
||||
actual visual height, leaving it too short to centre anything in.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.abstract.functional import Button, FormField, FormFieldType
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
CANVAS = (300, 120)
|
||||
PADDING = (6, 10, 6, 10) # top, right, bottom, left
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def draw_ctx():
|
||||
image = Image.new("RGB", CANVAS, (255, 255, 255))
|
||||
return image, ImageDraw.Draw(image)
|
||||
|
||||
|
||||
def ink_rows(image, box):
|
||||
"""
|
||||
Rows within box that carry text ink.
|
||||
|
||||
Only the central columns are sampled: the button has rounded corners, so the
|
||||
page background shows through at the extremes of every row and would read as
|
||||
white text on all of them.
|
||||
"""
|
||||
x0, y0, x1, y1 = box
|
||||
inset = (x1 - x0) // 4
|
||||
pixels = image.convert("RGB").load()
|
||||
rows = []
|
||||
for y in range(y0, y1):
|
||||
for x in range(x0 + inset, x1 - inset):
|
||||
r, g, b = pixels[x, y]
|
||||
# Button text is white on a blue fill; look for near-white ink.
|
||||
if r > 240 and g > 240 and b > 240:
|
||||
rows.append(y)
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
class TestButtonTextCentring:
|
||||
|
||||
@pytest.mark.parametrize("font_size", [10, 14, 20])
|
||||
def test_text_is_vertically_centred(self, draw_ctx, font_size):
|
||||
image, draw = draw_ctx
|
||||
font = Font(font_size=font_size, colour=(255, 255, 255))
|
||||
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
button.set_origin(np.array([20, 20]))
|
||||
button.render()
|
||||
|
||||
x0, y0 = 20, 20
|
||||
x1 = x0 + int(button.size[0])
|
||||
y1 = y0 + int(button.size[1])
|
||||
rows = ink_rows(image, (x0, y0, x1, y1))
|
||||
assert rows, "the button should have visible text"
|
||||
|
||||
gap_above = min(rows) - y0
|
||||
gap_below = y1 - max(rows) - 1
|
||||
|
||||
assert abs(gap_above - gap_below) <= 2, (
|
||||
f"text not centred at size {font_size}: "
|
||||
f"{gap_above}px above, {gap_below}px below")
|
||||
|
||||
def test_button_is_tall_enough_for_its_text(self):
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
image = Image.new("RGB", CANVAS, (255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
button = ButtonText(Button(label="Cancel", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
|
||||
ascent, descent = font.font.getmetrics()
|
||||
assert int(button.size[1]) >= ascent + descent + PADDING[0] + PADDING[2], \
|
||||
"button height must accommodate the text's visual height, not the nominal size"
|
||||
|
||||
def test_text_stays_inside_the_button(self, draw_ctx):
|
||||
image, draw = draw_ctx
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
button.set_origin(np.array([20, 20]))
|
||||
button.render()
|
||||
|
||||
y0, y1 = 20, 20 + int(button.size[1])
|
||||
rows = ink_rows(image, (20, y0, 20 + int(button.size[0]), y1))
|
||||
assert min(rows) >= y0, "text escaped above the button"
|
||||
assert max(rows) < y1, "text escaped below the button"
|
||||
|
||||
|
||||
class TestFormFieldValueCentring:
|
||||
|
||||
def test_value_is_centred_in_the_input_box(self):
|
||||
image = Image.new("RGB", (300, 120), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
field = FormField(name="who", field_type=FormFieldType.TEXT, value="Hello")
|
||||
renderable = FormFieldText(field, font, draw, field_height=28)
|
||||
renderable.set_origin(np.array([10, 10]))
|
||||
renderable.render()
|
||||
|
||||
field_y = 10 + font.font_size + 5
|
||||
pixels = image.convert("RGB").load()
|
||||
rows = [y for y in range(field_y, field_y + 28)
|
||||
if any(pixels[x, y] == (0, 0, 0) for x in range(12, 200))]
|
||||
assert rows, "the field value should be visible"
|
||||
|
||||
gap_above = min(rows) - field_y
|
||||
gap_below = (field_y + 28) - max(rows) - 1
|
||||
assert abs(gap_above - gap_below) <= 3, (
|
||||
f"field value not centred: {gap_above}px above, {gap_below}px below")
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Regression tests for page content geometry (spec S2).
|
||||
|
||||
Content must be laid out inside the content box - the page box less its border
|
||||
and padding - on all four sides. Horizontal padding was previously ignored on the
|
||||
left, shifting every line left by padding_left and leaving a gutter of
|
||||
padding_left + padding_right on the right, so lines appeared to break early.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
PADDING = (40, 30, 40, 20) # top, right, bottom, left - deliberately asymmetric
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=12)
|
||||
|
||||
|
||||
def filled_page(size, style, font, word_count=120):
|
||||
page = Page(size=size, style=style)
|
||||
paragraph = Paragraph(font)
|
||||
for i in range(word_count):
|
||||
paragraph.add_word(Word(f"word{i}", font))
|
||||
DocumentLayouter(page).layout_paragraph(paragraph)
|
||||
return page
|
||||
|
||||
|
||||
class TestContentBox:
|
||||
"""content_origin / content_rect describe the box content lives in."""
|
||||
|
||||
def test_content_origin_includes_border_and_padding(self):
|
||||
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
|
||||
assert page.content_origin == (2 + 20, 2 + 40)
|
||||
|
||||
def test_content_rect_subtracts_both_paddings(self):
|
||||
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
|
||||
x, y, w, h = page.content_rect
|
||||
assert (x, y) == (22, 42)
|
||||
assert w == 400 - 2 * 2 - 20 - 30
|
||||
assert h == 300 - 2 * 2 - 40 - 40
|
||||
|
||||
def test_page_origin_offsets_the_content_box(self):
|
||||
"""A page placed inside another surface reports absolute coordinates."""
|
||||
page = Page(size=(100, 50), style=PageStyle(border_width=1, padding=(5, 5, 5, 5)),
|
||||
origin=(200, 300))
|
||||
assert page.content_origin == (206, 306)
|
||||
|
||||
def test_remaining_height_respects_bottom_padding(self, font):
|
||||
style = PageStyle(border_width=2, padding=PADDING)
|
||||
page = Page(size=(400, 300), style=style)
|
||||
# Nothing laid out yet: the whole content box is available.
|
||||
assert page.remaining_height == page.content_rect[3]
|
||||
|
||||
|
||||
class TestLinePlacement:
|
||||
"""Lines must start after the left padding and end before the right padding."""
|
||||
|
||||
def test_first_line_starts_at_content_origin(self, font):
|
||||
style = PageStyle(border_width=2, padding=PADDING)
|
||||
page = filled_page((400, 300), style, font)
|
||||
|
||||
line = page.children[0]
|
||||
assert int(line.origin[0]) == page.content_origin[0]
|
||||
assert int(line.origin[1]) == page.content_origin[1]
|
||||
|
||||
def test_line_width_matches_content_width(self, font):
|
||||
style = PageStyle(border_width=2, padding=PADDING)
|
||||
page = filled_page((400, 300), style, font)
|
||||
|
||||
line = page.children[0]
|
||||
assert int(line.size[0]) == page.content_rect[2]
|
||||
|
||||
def test_no_line_extends_past_the_right_content_edge(self, font):
|
||||
style = PageStyle(border_width=2, padding=PADDING)
|
||||
page = filled_page((400, 300), style, font)
|
||||
right_edge = page.content_rect[0] + page.content_rect[2]
|
||||
|
||||
for line in page.children:
|
||||
assert int(line.origin[0]) + int(line.size[0]) <= right_edge
|
||||
|
||||
def test_ink_stays_inside_the_content_box(self, font):
|
||||
"""The rendered pixels, not just the boxes, respect the padding."""
|
||||
style = PageStyle(border_width=0, padding=PADDING,
|
||||
background_color=(255, 255, 255))
|
||||
page = filled_page((400, 300), style, font)
|
||||
image = page.render().convert("L")
|
||||
pixels = image.load()
|
||||
|
||||
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
|
||||
assert inked_x, "the page should have text on it"
|
||||
|
||||
x0, _, w, _ = page.content_rect
|
||||
assert min(inked_x) >= x0
|
||||
assert max(inked_x) <= x0 + w
|
||||
|
||||
def test_right_gutter_is_not_double_width(self, font):
|
||||
"""
|
||||
The regression: text was shifted left by padding_left, so the right gutter
|
||||
was padding_left + padding_right wide while the left gutter was zero.
|
||||
"""
|
||||
style = PageStyle(border_width=0, padding=(10, 30, 10, 30))
|
||||
page = filled_page((400, 300), style, font, word_count=200)
|
||||
image = page.render().convert("L")
|
||||
pixels = image.load()
|
||||
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
|
||||
|
||||
left_gutter = min(inked_x)
|
||||
right_gutter = 400 - max(inked_x)
|
||||
# Justification means the right edge is not always exactly flush, so allow
|
||||
# slack - but the two gutters must be comparable, not 0 vs 60.
|
||||
assert abs(left_gutter - right_gutter) < 25, \
|
||||
f"asymmetric gutters: left={left_gutter} right={right_gutter}"
|
||||
|
||||
|
||||
class TestBlockBottomBoundary:
|
||||
"""Blocks must not be placed into the bottom padding."""
|
||||
|
||||
def test_lines_stop_before_bottom_padding(self, font):
|
||||
style = PageStyle(border_width=2, padding=PADDING)
|
||||
page = filled_page((400, 300), style, font, word_count=500)
|
||||
bottom_edge = page.content_rect[1] + page.content_rect[3]
|
||||
|
||||
for line in page.children:
|
||||
assert int(line.origin[1]) <= bottom_edge
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Unit tests for the bounded usage-ranked caches.
|
||||
|
||||
Covers the guarantees the text rendering path depends on: that the bounds are never
|
||||
exceeded, that eviction prefers the least-used entries, that aging lets a new
|
||||
working set displace an old one, and that document-frequency seeding survives a
|
||||
scan of unfamiliar keys.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from pyWebLayout.core.cache import (
|
||||
UsageCache,
|
||||
SizedUsageCache,
|
||||
DEFAULT_AGING_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
class TestUsageCache(unittest.TestCase):
|
||||
"""Entry-count-bounded cache."""
|
||||
|
||||
def test_rejects_invalid_bounds(self):
|
||||
for bad in (0, -1):
|
||||
with self.assertRaises(ValueError):
|
||||
UsageCache(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
UsageCache(4, aging_interval=0)
|
||||
with self.assertRaises(ValueError):
|
||||
UsageCache(4, eviction_sample=0)
|
||||
|
||||
def test_stores_and_returns_values(self):
|
||||
cache = UsageCache(4)
|
||||
cache.put('a', 1)
|
||||
self.assertEqual(cache.get('a'), 1)
|
||||
self.assertIsNone(cache.get('missing'))
|
||||
self.assertIn('a', cache)
|
||||
self.assertEqual(len(cache), 1)
|
||||
|
||||
def test_never_exceeds_max_entries(self):
|
||||
cache = UsageCache(10)
|
||||
for i in range(500):
|
||||
cache.put(i, i)
|
||||
self.assertLessEqual(len(cache), 10)
|
||||
self.assertEqual(cache.stats()['entries'], 10)
|
||||
|
||||
def test_evicts_least_used(self):
|
||||
# One hot key among many cold ones must survive a long cold scan. The
|
||||
# sample is smaller than the cache, so this is probabilistic in principle;
|
||||
# a hot key's count is far enough above the rest to make it reliable.
|
||||
cache = UsageCache(20, eviction_sample=8)
|
||||
cache.put('hot', 'value')
|
||||
for _ in range(200):
|
||||
cache.get('hot')
|
||||
for i in range(400):
|
||||
cache.put(f'cold{i}', i)
|
||||
cache.get('hot')
|
||||
self.assertEqual(cache.get('hot'), 'value')
|
||||
|
||||
def test_repeated_put_does_not_duplicate(self):
|
||||
cache = UsageCache(10)
|
||||
for _ in range(50):
|
||||
cache.put('a', 1)
|
||||
self.assertEqual(len(cache), 1)
|
||||
|
||||
def test_put_updates_existing_value(self):
|
||||
cache = UsageCache(10)
|
||||
cache.put('a', 1)
|
||||
cache.put('a', 2)
|
||||
self.assertEqual(cache.get('a'), 2)
|
||||
|
||||
def test_seeded_count_outranks_fresh_entries(self):
|
||||
"""A document-frequency seed must survive a scan of unseen keys."""
|
||||
cache = UsageCache(20, eviction_sample=8)
|
||||
cache.put('frequent', 'value', count=5000)
|
||||
for i in range(400):
|
||||
cache.put(f'new{i}', i)
|
||||
self.assertEqual(cache.get('frequent'), 'value')
|
||||
|
||||
def test_aging_lets_a_new_working_set_take_over(self):
|
||||
"""Without aging, stale high counts lock the cache permanently."""
|
||||
cache = UsageCache(20, aging_interval=50, eviction_sample=8)
|
||||
for i in range(20):
|
||||
cache.put(f'old{i}', i, count=10000)
|
||||
|
||||
# A completely different working set, each key used a few times.
|
||||
for round_ in range(60):
|
||||
for i in range(10):
|
||||
key = f'new{i}'
|
||||
if cache.get(key) is None:
|
||||
cache.put(key, i)
|
||||
|
||||
survivors = sum(1 for i in range(10) if f'new{i}' in cache)
|
||||
self.assertGreater(survivors, 0,
|
||||
"aging should let the new working set displace the old")
|
||||
self.assertGreater(cache.stats()['agings'], 0)
|
||||
|
||||
def test_aging_can_be_disabled(self):
|
||||
cache = UsageCache(10, aging_interval=None)
|
||||
for i in range(100):
|
||||
cache.put(i, i)
|
||||
self.assertEqual(cache.stats()['agings'], 0)
|
||||
|
||||
def test_resize_evicts_immediately(self):
|
||||
cache = UsageCache(100)
|
||||
for i in range(100):
|
||||
cache.put(i, i)
|
||||
cache.resize(10)
|
||||
self.assertEqual(len(cache), 10)
|
||||
with self.assertRaises(ValueError):
|
||||
cache.resize(0)
|
||||
|
||||
def test_clear_empties_but_keeps_counters(self):
|
||||
cache = UsageCache(10)
|
||||
cache.put('a', 1)
|
||||
cache.get('a')
|
||||
cache.clear()
|
||||
self.assertEqual(len(cache), 0)
|
||||
self.assertNotIn('a', cache)
|
||||
self.assertEqual(cache.stats()['hits'], 1)
|
||||
|
||||
def test_stats_track_hits_and_misses(self):
|
||||
cache = UsageCache(10)
|
||||
cache.put('a', 1)
|
||||
cache.get('a')
|
||||
cache.get('a')
|
||||
cache.get('b')
|
||||
stats = cache.stats()
|
||||
self.assertEqual(stats['hits'], 2)
|
||||
self.assertEqual(stats['misses'], 1)
|
||||
self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
|
||||
self.assertEqual(stats['max_entries'], 10)
|
||||
|
||||
def test_internal_slot_list_stays_consistent(self):
|
||||
"""Eviction swaps the tail into the freed slot; indices must stay valid."""
|
||||
cache = UsageCache(8)
|
||||
for i in range(300):
|
||||
cache.put(i, i)
|
||||
for key in list(cache._entries):
|
||||
self.assertEqual(cache._slots[cache._entries[key][2]], key)
|
||||
self.assertEqual(len(cache._slots), len(cache._entries))
|
||||
|
||||
|
||||
class TestSizedUsageCache(unittest.TestCase):
|
||||
"""Byte-bounded cache, as used for glyph bitmaps."""
|
||||
|
||||
@staticmethod
|
||||
def sizer(value):
|
||||
return value
|
||||
|
||||
def test_rejects_invalid_bounds(self):
|
||||
for bad in (0, -1):
|
||||
with self.assertRaises(ValueError):
|
||||
SizedUsageCache(bad, self.sizer)
|
||||
|
||||
def test_never_exceeds_max_bytes(self):
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
for i in range(500):
|
||||
cache.put(i, 100)
|
||||
self.assertLessEqual(cache.total_bytes, 1000)
|
||||
|
||||
def test_tracks_total_bytes(self):
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
cache.put('a', 100)
|
||||
cache.put('b', 250)
|
||||
self.assertEqual(cache.total_bytes, 350)
|
||||
|
||||
def test_oversized_value_is_not_retained(self):
|
||||
"""One huge entry must not flush everything else out."""
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
cache.put('small', 100)
|
||||
cache.put('huge', 5000)
|
||||
self.assertNotIn('huge', cache)
|
||||
self.assertIn('small', cache)
|
||||
self.assertEqual(cache.total_bytes, 100)
|
||||
|
||||
def test_replacing_a_value_remeasures_it(self):
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
cache.put('a', 100)
|
||||
cache.put('a', 300)
|
||||
self.assertEqual(cache.total_bytes, 300)
|
||||
self.assertEqual(len(cache), 1)
|
||||
|
||||
def test_evicts_least_used(self):
|
||||
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
|
||||
cache.put('hot', 100)
|
||||
for _ in range(200):
|
||||
cache.get('hot')
|
||||
for i in range(400):
|
||||
cache.put(f'cold{i}', 100)
|
||||
cache.get('hot')
|
||||
self.assertIn('hot', cache)
|
||||
|
||||
def test_seeded_count_outranks_fresh_entries(self):
|
||||
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
|
||||
cache.put('frequent', 100, count=5000)
|
||||
for i in range(400):
|
||||
cache.put(f'new{i}', 100)
|
||||
self.assertIn('frequent', cache)
|
||||
|
||||
def test_resize_evicts_immediately(self):
|
||||
cache = SizedUsageCache(10000, self.sizer)
|
||||
for i in range(100):
|
||||
cache.put(i, 100)
|
||||
cache.resize(500)
|
||||
self.assertLessEqual(cache.total_bytes, 500)
|
||||
|
||||
def test_clear_resets_byte_accounting(self):
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
cache.put('a', 100)
|
||||
cache.clear()
|
||||
self.assertEqual(cache.total_bytes, 0)
|
||||
self.assertEqual(len(cache), 0)
|
||||
|
||||
def test_stats_report_bounds(self):
|
||||
cache = SizedUsageCache(1000, self.sizer)
|
||||
cache.put('a', 100)
|
||||
stats = cache.stats()
|
||||
self.assertEqual(stats['total_bytes'], 100)
|
||||
self.assertEqual(stats['max_bytes'], 1000)
|
||||
self.assertEqual(stats['entries'], 1)
|
||||
|
||||
def test_bookkeeping_stays_consistent_under_churn(self):
|
||||
"""Byte total and slot list must not drift over many evictions."""
|
||||
cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
|
||||
for i in range(2000):
|
||||
cache.put(i, (i % 7 + 1) * 50)
|
||||
if i % 3 == 0:
|
||||
cache.get(i)
|
||||
self.assertEqual(cache.total_bytes,
|
||||
sum(cache._sizes[k] for k in cache._entries))
|
||||
self.assertEqual(len(cache._slots), len(cache._entries))
|
||||
self.assertLessEqual(cache.total_bytes, cache.max_bytes)
|
||||
|
||||
|
||||
class TestDefaults(unittest.TestCase):
|
||||
|
||||
def test_aging_is_enabled_by_default(self):
|
||||
self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
|
||||
self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
|
||||
self.assertIsNotNone(UsageCache(4)._aging_interval)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -145,7 +145,9 @@ class TestTextQueryPoint(unittest.TestCase):
|
||||
text.set_origin(np.array([100, 100]))
|
||||
|
||||
# Point inside text bounds
|
||||
self.assertTrue(text.in_object(np.array([110, 105])))
|
||||
# Origin is at baseline (100, 100), so test a point slightly above (at ascent/2)
|
||||
# and to the right
|
||||
self.assertTrue(text.in_object(np.array([110, 100])))
|
||||
|
||||
def test_in_object_miss(self):
|
||||
"""Test in_object returns False for point outside text"""
|
||||
@@ -188,7 +190,9 @@ class TestLineQueryPoint(unittest.TestCase):
|
||||
# (after rendering, text objects have positions set)
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
# Origin is at baseline, so query at baseline position (Y = origin[1])
|
||||
# with X offset into the text
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
@@ -234,7 +238,8 @@ class TestLineQueryPoint(unittest.TestCase):
|
||||
# Query the link
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
# Origin is at baseline, query at baseline Y position
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
@@ -279,7 +284,8 @@ class TestPageQueryPoint(unittest.TestCase):
|
||||
# Query a point inside the line
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
# Origin is at baseline, query at baseline Y position
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
|
||||
|
||||
result = self.page.query_point(point)
|
||||
|
||||
@@ -321,7 +327,8 @@ class TestPageQueryPoint(unittest.TestCase):
|
||||
# Query first line
|
||||
if len(line1._text_objects) > 0:
|
||||
text_obj1 = line1._text_objects[0]
|
||||
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1] + 5))
|
||||
# Origin is at baseline, query at baseline Y position
|
||||
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1]))
|
||||
result1 = self.page.query_point(point1)
|
||||
|
||||
self.assertIsNotNone(result1)
|
||||
@@ -330,7 +337,8 @@ class TestPageQueryPoint(unittest.TestCase):
|
||||
# Query second line
|
||||
if len(line2._text_objects) > 0:
|
||||
text_obj2 = line2._text_objects[0]
|
||||
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1] + 5))
|
||||
# Origin is at baseline, query at baseline Y position
|
||||
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1]))
|
||||
result2 = self.page.query_point(point2)
|
||||
|
||||
self.assertIsNotNone(result2)
|
||||
@@ -368,9 +376,10 @@ class TestPageQueryRange(unittest.TestCase):
|
||||
start_text = line._text_objects[0]
|
||||
end_text = line._text_objects[1]
|
||||
|
||||
# Origin is at baseline, query at baseline Y position
|
||||
start_point = (
|
||||
int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
|
||||
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
|
||||
int(start_text._origin[0] + 5), int(start_text._origin[1]))
|
||||
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1]))
|
||||
|
||||
sel_range = self.page.query_range(start_point, end_point)
|
||||
|
||||
|
||||