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 |
@@ -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,169 +11,167 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.12', '3.13']
|
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Install project
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
# --no-deps: dependencies are baked into the image. If a new one is
|
||||||
# Install package in development mode
|
# added to pyproject.toml, add it to Dockerfile.ci and rebuild;
|
||||||
pip install -e .
|
# the check below is what catches forgetting to.
|
||||||
# Install test dependencies if they exist
|
$PYBIN/pip install -e . --no-deps
|
||||||
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
|
$PYBIN/python -c "import pyWebLayout; print('pyWebLayout', pyWebLayout.__file__)"
|
||||||
if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi
|
|
||||||
# Install common test packages
|
- name: Verify declared dependencies are sufficient
|
||||||
pip install pytest pytest-cov flake8 coverage-badge interrogate
|
if: env.PUBLISH == 'true'
|
||||||
|
|
||||||
- name: Download initial failed badges
|
|
||||||
run: |
|
run: |
|
||||||
echo "Downloading initial failed badges..."
|
# A clean venv with ONLY the declared runtime deps, installed from
|
||||||
|
# the index rather than from the image. If an import here fails,
|
||||||
# Create cov_info directory first
|
# pyproject.toml is incomplete and a real `pip install pyWebLayout`
|
||||||
mkdir -p cov_info
|
# fails the same way for a user. This is the one step that is
|
||||||
|
# allowed to reach the network.
|
||||||
# Download failed badges as defaults
|
$PYBIN/python -m venv /tmp/clean-install
|
||||||
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
|
/tmp/clean-install/bin/pip install --upgrade pip
|
||||||
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
|
/tmp/clean-install/bin/pip install .
|
||||||
|
/tmp/clean-install/bin/python -c "
|
||||||
echo "Initial failed badges created:"
|
import pyWebLayout.concrete, pyWebLayout.abstract
|
||||||
ls -la cov_info/coverage*.svg
|
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
|
- name: Run tests with pytest
|
||||||
id: pytest
|
id: pytest
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Run tests with coverage
|
$PYBIN/python -m pytest tests/ -v \
|
||||||
python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
|
--cov=pyWebLayout \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=json \
|
||||||
|
--cov-report=html \
|
||||||
|
--cov-report=xml
|
||||||
|
|
||||||
- name: Check documentation coverage
|
- name: Check documentation coverage
|
||||||
id: docs
|
id: docs
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
# Generate documentation coverage report
|
$PYBIN/interrogate -v \
|
||||||
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyWebLayout/
|
--ignore-init-method --ignore-init-module --ignore-magic \
|
||||||
|
--ignore-private --ignore-property-decorators --ignore-semiprivate \
|
||||||
|
--fail-under=80 pyWebLayout/
|
||||||
|
|
||||||
- name: Lint with flake8
|
- name: Lint with flake8
|
||||||
run: |
|
run: |
|
||||||
# Stop the build if there are Python syntax errors or undefined names
|
# 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
|
# Exit-zero treats all errors as warnings
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
$PYBIN/flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
|
||||||
- name: Create coverage info directory
|
- name: Fail the job if tests failed
|
||||||
if: always()
|
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: |
|
run: |
|
||||||
mkdir -p cov_info
|
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
|
- name: Update test coverage badge on success
|
||||||
if: steps.pytest.outcome == 'success' && always()
|
if: always() && env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
echo "Tests passed! Generating successful coverage badge..."
|
|
||||||
|
|
||||||
if [ -f coverage.json ]; then
|
if [ -f coverage.json ]; then
|
||||||
coverage-badge -o cov_info/coverage.svg -f
|
$PYBIN/coverage-badge -o cov_info/coverage.svg -f
|
||||||
echo "✅ Test coverage badge updated with actual results"
|
echo "✅ Test coverage badge updated"
|
||||||
else
|
else
|
||||||
echo "⚠️ No coverage.json found, keeping failed badge"
|
echo "⚠️ No coverage.json found, keeping failed badge"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Update docs coverage badge on success
|
- name: Update docs coverage badge on success
|
||||||
if: steps.docs.outcome == 'success' && always()
|
if: always() && env.PUBLISH == 'true' && steps.docs.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
echo "Docs check passed! Generating successful docs badge..."
|
|
||||||
|
|
||||||
# Remove existing badge first to avoid overwrite error
|
|
||||||
rm -f cov_info/coverage-docs.svg
|
rm -f cov_info/coverage-docs.svg
|
||||||
interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
|
$PYBIN/interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
|
||||||
echo "✅ Docs coverage badge updated with actual results"
|
echo "✅ Docs coverage badge updated"
|
||||||
|
|
||||||
- name: Generate coverage reports
|
- name: Generate coverage reports
|
||||||
if: steps.pytest.outcome == 'success'
|
if: env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
# Generate coverage summary for README
|
$PYBIN/python -c "
|
||||||
python -c "
|
import json, os
|
||||||
import json
|
|
||||||
import os
|
|
||||||
# Read coverage data
|
|
||||||
if os.path.exists('coverage.json'):
|
if os.path.exists('coverage.json'):
|
||||||
with open('coverage.json', 'r') as f:
|
with open('coverage.json') as f:
|
||||||
coverage_data = json.load(f)
|
data = json.load(f)
|
||||||
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
|
total = round(data['totals']['percent_covered'], 1)
|
||||||
# Create coverage summary file in cov_info directory
|
|
||||||
with open('cov_info/coverage-summary.txt', 'w') as f:
|
with open('cov_info/coverage-summary.txt', 'w') as f:
|
||||||
f.write(f'{total_coverage}%')
|
f.write(f'{total}%')
|
||||||
print(f'Test Coverage: {total_coverage}%')
|
print(f\"Test Coverage: {total}%\")
|
||||||
covered_lines = coverage_data['totals']['covered_lines']
|
print(f\"Lines Covered: {data['totals']['covered_lines']}/{data['totals']['num_statements']}\")
|
||||||
total_lines = coverage_data['totals']['num_statements']
|
|
||||||
print(f'Lines Covered: {covered_lines}/{total_lines}')
|
|
||||||
else:
|
else:
|
||||||
print('No coverage data found')
|
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.json ]; then cp coverage.json cov_info/; fi
|
||||||
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
|
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
|
||||||
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
|
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
|
||||||
|
|
||||||
- name: Final badge status
|
- name: Final badge status
|
||||||
if: always()
|
if: always() && env.PUBLISH == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "=== FINAL BADGE STATUS ==="
|
echo "=== FINAL BADGE STATUS ==="
|
||||||
echo "Test outcome: ${{ steps.pytest.outcome }}"
|
echo "Test outcome: ${{ steps.pytest.outcome }}"
|
||||||
echo "Docs outcome: ${{ steps.docs.outcome }}"
|
echo "Docs outcome: ${{ steps.docs.outcome }}"
|
||||||
|
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory"
|
||||||
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"
|
|
||||||
|
|
||||||
- name: Upload coverage artifacts
|
- name: Upload coverage artifacts
|
||||||
|
if: always() && env.PUBLISH == 'true'
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-reports
|
name: coverage-reports
|
||||||
path: |
|
path: cov_info/
|
||||||
cov_info/
|
|
||||||
|
|
||||||
- name: Commit badges to badges branch
|
- name: Commit badges to badges branch
|
||||||
if: github.ref == 'refs/heads/master'
|
if: env.PUBLISH == 'true' && github.ref == 'refs/heads/master'
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@gitea.local"
|
git config --local user.email "action@gitea.local"
|
||||||
git config --local user.name "Gitea Action"
|
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
|
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
|
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
|
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/
|
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 commit -m "Update coverage badges [skip ci]"
|
||||||
git push -f origin badges
|
git push -f origin badges
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ test_output/
|
|||||||
examples/output/
|
examples/output/
|
||||||
|
|
||||||
# Generated data
|
# Generated data
|
||||||
|
bookmarks/
|
||||||
positions/
|
positions/
|
||||||
|
|
||||||
|
# Profiling scripts
|
||||||
|
profile_*.py
|
||||||
|
|
||||||
# Debug scripts output
|
# Debug scripts output
|
||||||
debug_*.png
|
debug_*.png
|
||||||
.fish*
|
.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
|
## 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`
|
### `core/` — shared foundations
|
||||||
- `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
|
|
||||||
|
|
||||||
#### `abstract/inline.py`
|
Everything else is built on these. `core/` depends on nothing but `style/`.
|
||||||
- `Word`: Represents individual words with text content and styling information
|
|
||||||
- Contains methods for hyphenation and text manipulation
|
|
||||||
- Does **not** handle rendering or spatial layout
|
|
||||||
|
|
||||||
#### `abstract/document.py`
|
**`core/base.py`** defines the contracts that make a class abstract or concrete:
|
||||||
- `Document`: Container for the overall document structure
|
|
||||||
- `Chapter`: Logical grouping of blocks (for books/long documents)
|
|
||||||
|
|
||||||
### 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
|
The useful shorthand: **`Renderable` and `Geometric` are the concrete markers.** An
|
||||||
2. **Layout-agnostic**: No knowledge of fonts, pixels, or rendering
|
abstract class that acquires either has crossed the line.
|
||||||
3. **Reusable**: Same content can be rendered in different formats/sizes
|
|
||||||
4. **Serializable**: Can be saved/loaded without rendering context
|
|
||||||
|
|
||||||
### 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
|
```python
|
||||||
# An Abstract Word knows its text content and semantic properties
|
paragraph_layouter(paragraph, page, start_word=0, pretext=None, alignment_override=None)
|
||||||
word = Word("supercalifragilisticexpialidocious", font_style)
|
-> (complete: bool, failed_word_index: int | None, remaining_pretext: Text | None)
|
||||||
word.hyphenate() # Logical operation - finds break points
|
|
||||||
parts = word.get_hyphenated_parts() # Returns ["super-", "cali-", "fragi-", ...]
|
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`
|
**`layout/page_buffer.py`** — `PageBuffer` (LRU caches of rendered pages in both
|
||||||
- `Text`: Renders a specific text fragment with precise positioning
|
directions, plus position maps) and `BufferedPageRenderer` (background rendering).
|
||||||
- `Line`: Manages a line of `Text` objects with spacing and alignment
|
|
||||||
- Handles actual pixel measurements, font rendering, and positioning
|
|
||||||
|
|
||||||
#### `concrete/page.py`
|
**`layout/ereader_manager.py`** — `EreaderLayoutManager`, the top-level application
|
||||||
- `Page`: Top-level container for rendered content
|
interface (page turns, font changes, chapter jumps, progress), and `BookmarkManager`
|
||||||
- `Container`: Layout manager for organizing renderable objects
|
for persisting bookmarks and the last reading position.
|
||||||
- Handles spatial layout, pagination, and visual composition
|
|
||||||
|
|
||||||
#### `concrete/box.py`
|
**`layout/table_optimizer.py`** — column width allocation for tables.
|
||||||
- `Box`: Base class for all spatially-aware renderable objects
|
|
||||||
- Provides positioning, sizing, and rendering capabilities
|
|
||||||
|
|
||||||
### Characteristics of Concrete Classes
|
### `io/readers/` — parsing
|
||||||
|
|
||||||
1. **Rendering-focused**: Handle pixels, fonts, images, and visual output
|
**`html_extraction.py`** — `parse_html_string(html, base_font=None, document=None,
|
||||||
2. **Spatially-aware**: Know exact positions, sizes, and layout constraints
|
base_path=None) -> List[Block]`. Built from a `StyleContext` (a `NamedTuple` threaded
|
||||||
3. **Implementation-specific**: Tied to specific rendering technologies (PIL, etc.)
|
down the tree, carrying the inherited font and styling) and a table of per-tag handlers
|
||||||
4. **Non-portable**: Rendering results are tied to specific display contexts
|
(`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
|
```python
|
||||||
# A Concrete Text object handles actual rendering
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
text = Text("super-", font) # Specific text fragment
|
from pyWebLayout.concrete.page import Page
|
||||||
text._calculate_dimensions() # Computes exact pixel size
|
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||||
image = text.render() # Produces actual visual output
|
|
||||||
|
# 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
|
Inspecting the intermediate concrete objects:
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 1. Abstract content
|
from pyWebLayout.concrete.text import Line
|
||||||
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))
|
|
||||||
|
|
||||||
# 2. Layout transformation
|
for line in (c for c in page.children if isinstance(c, Line)):
|
||||||
layout = ParagraphLayout(line_width=200, line_height=20)
|
print([t.text for t in line.text_objects])
|
||||||
lines = layout.layout_paragraph(paragraph) # Returns List[Line]
|
# ['Chapter', 'One']
|
||||||
|
# ['It', 'was', 'a', 'dark', 'and', 'stormy', 'night.']
|
||||||
# 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}")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```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:
|
class Word:
|
||||||
def __init__(self, text):
|
def __init__(self, text):
|
||||||
self.text = text
|
self.rendered_width = None # invalidated by any font change
|
||||||
self.rendered_width = None # ❌ Concrete concern in abstract class
|
|
||||||
|
# RIGHT
|
||||||
|
text = Text(word.text, font, draw, source=word) # concrete knows its origin
|
||||||
```
|
```
|
||||||
|
|
||||||
### ❌ **renderable_words Concept**
|
**Treating `Word` as renderable.** Words are abstract; only `Text` draws. There is no
|
||||||
```python
|
`renderable_words` anywhere in the codebase, and there should not be.
|
||||||
# WRONG: Confusing abstract and concrete
|
|
||||||
line.renderable_words # ❌ This suggests Words are renderable
|
|
||||||
# Words are abstract - only Text objects render
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ **Correct Separation**
|
**Assuming a layouter returns lines.** `paragraph_layouter` appends to a page and
|
||||||
```python
|
reports what did not fit. Code that expects `List[Line]` back is working from an
|
||||||
# CORRECT: Clear separation
|
outdated model.
|
||||||
abstract_word = Word("test") # Abstract content
|
|
||||||
concrete_text = Text("test", font) # Concrete rendering
|
|
||||||
line.text_objects.append(concrete_text) # Concrete objects in concrete container
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits of This Architecture
|
## Summary
|
||||||
|
|
||||||
### 1. **Flexibility**
|
- **`core/`** — contracts and shared machinery
|
||||||
- Same content can be rendered at different sizes
|
- **`style/`** — semantic style, and its resolution to concrete rendering parameters
|
||||||
- Multiple output formats from single source
|
- **`abstract/`** — what the document says
|
||||||
- Easy to implement responsive design
|
- **`concrete/`** — where the pixels go
|
||||||
|
- **`layout/`** — the transformation between them, and pagination on top of it
|
||||||
### 2. **Testability**
|
- **`io/readers/`** — markup in
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -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
|
### Text and HTML Support
|
||||||
- 📝 **HTML Parsing** - Parse HTML content into structured document blocks
|
- 📝 **HTML Parsing** - Parse HTML content into structured document blocks
|
||||||
- 🔤 **Font Support** - Multiple font sizes, weights, and styles
|
- 🔤 **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
|
- ↔️ **Text Alignment** - Left, center, right, and justified text
|
||||||
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
|
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
|
||||||
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
|
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
|
||||||
@@ -138,6 +139,13 @@ The library supports various page layouts and configurations:
|
|||||||
<em>All 14 form field types with validation</em>
|
<em>All 14 form field types with validation</em>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -151,11 +159,13 @@ The `examples/` directory contains working demonstrations:
|
|||||||
- **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling
|
- **[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
|
- **[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
|
- **[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)
|
### 🆕 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))
|
- **[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))
|
- **[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))
|
- **[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:
|
Run any example:
|
||||||
```bash
|
```bash
|
||||||
@@ -176,14 +186,78 @@ python -m pytest tests/examples/ -v # 30 tests, all passing ✅
|
|||||||
|
|
||||||
See **[examples/README.md](examples/README.md)** for detailed documentation.
|
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
|
## Documentation
|
||||||
|
|
||||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Abstract/Concrete architecture guide
|
- **[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
|
- **[examples/README.md](examples/README.md)** - Complete examples guide with tests
|
||||||
- **[docs/images/README.md](docs/images/README.md)** - Visual documentation index
|
- **[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
|
- **[pyWebLayout/layout/README_EREADER_API.md](pyWebLayout/layout/README_EREADER_API.md)** - EbookReader API reference
|
||||||
- **API Reference** - See docstrings in source code
|
- **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
|
## License
|
||||||
|
|
||||||
MIT 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.
|
||||||
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 117 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 |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 31 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 |
@@ -371,6 +371,59 @@ def demo_performance_optimization():
|
|||||||
print()
|
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__":
|
if __name__ == "__main__":
|
||||||
print("\n")
|
print("\n")
|
||||||
print("╔" + "═" * 68 + "╗")
|
print("╔" + "═" * 68 + "╗")
|
||||||
@@ -391,6 +444,9 @@ if __name__ == "__main__":
|
|||||||
demo_performance_optimization()
|
demo_performance_optimization()
|
||||||
print("\n")
|
print("\n")
|
||||||
|
|
||||||
|
# Create animated GIF
|
||||||
|
create_animated_gif()
|
||||||
|
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print("All demos complete! Check the generated PNG files.")
|
print("All demos complete! Check the generated PNG files and animated GIF.")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|||||||
@@ -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. 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
|
```bash
|
||||||
python 05_table_with_images.py
|
python 05_html_table_with_images.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Demonstrates:
|
Demonstrates:
|
||||||
@@ -80,8 +80,9 @@ Demonstrates:
|
|||||||
- Book catalog and product showcase tables
|
- Book catalog and product showcase tables
|
||||||
- Mixed content (images and text) in cells
|
- Mixed content (images and text) in cells
|
||||||
- Using cover images from test data
|
- Using cover images from test data
|
||||||
|
- HTML table parsing with `<img>` tags
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
|
### 07. Button Pressed States (Interactive)
|
||||||
|
**`07_pressed_state_demo.py`** - Visual feedback for button interactions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python 07_pressed_state_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Animated GIF showing button press sequence: initial → pressed → released*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🆕 New Examples (2024-11)
|
## 🆕 New Examples (2024-11)
|
||||||
|
|
||||||
These examples address critical coverage gaps and demonstrate advanced features:
|
These examples address critical coverage gaps and demonstrate advanced features:
|
||||||
|
|
||||||
### 08. Pagination with PageBreak (NEW) ✅
|
### 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
|
**`08_pagination_demo.py`** - Multi-page documents with explicit and automatic pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -174,20 +209,33 @@ Demonstrates all 14 FormFieldType variations:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Advanced Examples
|
|
||||||
|
|
||||||
### HTML Rendering
|
|
||||||
|
|
||||||
These examples demonstrate rendering HTML content to multi-page layouts:
|
|
||||||
|
|
||||||
**`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`.
|
|
||||||
|
|
||||||
## Running the Examples
|
## Running the Examples
|
||||||
|
|
||||||
All examples can be run directly from the examples directory:
|
All examples can be run directly from the examples directory:
|
||||||
@@ -195,22 +243,45 @@ All examples can be run directly from the examples directory:
|
|||||||
```bash
|
```bash
|
||||||
cd examples
|
cd examples
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started (01-07)
|
||||||
python 01_simple_page_rendering.py
|
python 01_simple_page_rendering.py # Page layouts
|
||||||
python 02_text_and_layout.py
|
python 02_text_and_layout.py # Text alignment with justified text
|
||||||
python 03_page_layouts.py
|
python 03_page_layouts.py # Various page sizes
|
||||||
python 04_table_rendering.py
|
python 04_table_rendering.py # Table styles
|
||||||
python 05_table_with_images.py
|
python 05_html_table_with_images.py # HTML tables with images
|
||||||
python 06_functional_elements_demo.py
|
python 06_functional_elements_demo.py # Interactive buttons and forms
|
||||||
|
python 07_pressed_state_demo.py # Button pressed states (generates GIF)
|
||||||
|
|
||||||
# NEW: Advanced Features
|
# Advanced Features (08-11)
|
||||||
python 08_pagination_demo.py # Multi-page documents
|
python 08_bundled_fonts_demo.py # Bundled font showcase
|
||||||
python 09_link_navigation_demo.py # All link types
|
python 08_pagination_demo.py # Multi-page documents
|
||||||
python 10_forms_demo.py # All form field types
|
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.
|
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
|
### Running Tests
|
||||||
|
|
||||||
All new examples (08, 09, 10) include comprehensive test coverage:
|
All new examples (08, 09, 10) include comprehensive test coverage:
|
||||||
|
|||||||
@@ -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
|
import urllib.parse
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
from .inline import Word, FormattedSpan
|
from .inline import Word, FormattedSpan
|
||||||
from ..core import Hierarchical, Styleable, FontRegistry
|
from ..core import Hierarchical, Styleable, FontRegistry, ContainerAware, BlockContainer
|
||||||
|
|
||||||
|
|
||||||
class BlockType(Enum):
|
class BlockType(Enum):
|
||||||
@@ -50,7 +50,7 @@ class Block(Hierarchical):
|
|||||||
return self._block_type
|
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.
|
A paragraph is a block-level element that contains a sequence of words.
|
||||||
|
|
||||||
@@ -85,22 +85,15 @@ class Paragraph(Styleable, FontRegistry, Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_block method
|
AttributeError: If the container doesn't have the required add_block method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container)
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
elif style is None and hasattr(container, 'default_style'):
|
|
||||||
style = container.default_style
|
|
||||||
|
|
||||||
# Create the new paragraph
|
# Create the new paragraph
|
||||||
paragraph = cls(style)
|
paragraph = cls(style)
|
||||||
|
|
||||||
# Add the paragraph to the container
|
# Add the paragraph to the container
|
||||||
if hasattr(container, 'add_block'):
|
container.add_block(paragraph)
|
||||||
container.add_block(paragraph)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return paragraph
|
return paragraph
|
||||||
|
|
||||||
@@ -237,22 +230,15 @@ class Heading(Paragraph):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_block method
|
AttributeError: If the container doesn't have the required add_block method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container)
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
elif style is None and hasattr(container, 'default_style'):
|
|
||||||
style = container.default_style
|
|
||||||
|
|
||||||
# Create the new heading
|
# Create the new heading
|
||||||
heading = cls(level, style)
|
heading = cls(level, style)
|
||||||
|
|
||||||
# Add the heading to the container
|
# Add the heading to the container
|
||||||
if hasattr(container, 'add_block'):
|
container.add_block(heading)
|
||||||
container.add_block(heading)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return heading
|
return heading
|
||||||
|
|
||||||
@@ -267,7 +253,7 @@ class Heading(Paragraph):
|
|||||||
self._level = level
|
self._level = level
|
||||||
|
|
||||||
|
|
||||||
class Quote(Block):
|
class Quote(BlockContainer, ContainerAware, Block):
|
||||||
"""
|
"""
|
||||||
A blockquote element that can contain other block elements.
|
A blockquote element that can contain other block elements.
|
||||||
"""
|
"""
|
||||||
@@ -280,7 +266,6 @@ class Quote(Block):
|
|||||||
style: Optional default style for child blocks
|
style: Optional default style for child blocks
|
||||||
"""
|
"""
|
||||||
super().__init__(BlockType.QUOTE)
|
super().__init__(BlockType.QUOTE)
|
||||||
self._blocks: List[Block] = []
|
|
||||||
self._style = style
|
self._style = style
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -299,22 +284,15 @@ class Quote(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_block method
|
AttributeError: If the container doesn't have the required add_block method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container)
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
elif style is None and hasattr(container, 'default_style'):
|
|
||||||
style = container.default_style
|
|
||||||
|
|
||||||
# Create the new quote
|
# Create the new quote
|
||||||
quote = cls(style)
|
quote = cls(style)
|
||||||
|
|
||||||
# Add the quote to the container
|
# Add the quote to the container
|
||||||
if hasattr(container, 'add_block'):
|
container.add_block(quote)
|
||||||
container.add_block(quote)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
@@ -328,54 +306,6 @@ class Quote(Block):
|
|||||||
"""Set the default style for this quote"""
|
"""Set the default style for this quote"""
|
||||||
self._style = style
|
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):
|
class CodeBlock(Block):
|
||||||
"""
|
"""
|
||||||
@@ -463,7 +393,7 @@ class ListStyle(Enum):
|
|||||||
DEFINITION = 3 # <dl>
|
DEFINITION = 3 # <dl>
|
||||||
|
|
||||||
|
|
||||||
class HList(Block):
|
class HList(ContainerAware, Block):
|
||||||
"""
|
"""
|
||||||
An HTML list element (ul, ol, dl).
|
An HTML list element (ul, ol, dl).
|
||||||
"""
|
"""
|
||||||
@@ -502,22 +432,15 @@ class HList(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_block method
|
AttributeError: If the container doesn't have the required add_block method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if default_style is None and hasattr(container, 'style'):
|
cls._validate_container(container)
|
||||||
default_style = container.style
|
default_style = cls._inherit_style(container, default_style)
|
||||||
elif default_style is None and hasattr(container, 'default_style'):
|
|
||||||
default_style = container.default_style
|
|
||||||
|
|
||||||
# Create the new list
|
# Create the new list
|
||||||
hlist = cls(style, default_style)
|
hlist = cls(style, default_style)
|
||||||
|
|
||||||
# Add the list to the container
|
# Add the list to the container
|
||||||
if hasattr(container, 'add_block'):
|
container.add_block(hlist)
|
||||||
container.add_block(hlist)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return hlist
|
return hlist
|
||||||
|
|
||||||
@@ -580,7 +503,7 @@ class HList(Block):
|
|||||||
return len(self._items)
|
return len(self._items)
|
||||||
|
|
||||||
|
|
||||||
class ListItem(Block):
|
class ListItem(BlockContainer, ContainerAware, Block):
|
||||||
"""
|
"""
|
||||||
A list item element that can contain other block elements.
|
A list item element that can contain other block elements.
|
||||||
"""
|
"""
|
||||||
@@ -594,7 +517,6 @@ class ListItem(Block):
|
|||||||
style: Optional default style for child blocks
|
style: Optional default style for child blocks
|
||||||
"""
|
"""
|
||||||
super().__init__(BlockType.LIST_ITEM)
|
super().__init__(BlockType.LIST_ITEM)
|
||||||
self._blocks: List[Block] = []
|
|
||||||
self._term = term
|
self._term = term
|
||||||
self._style = style
|
self._style = style
|
||||||
|
|
||||||
@@ -619,22 +541,15 @@ class ListItem(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_item method
|
AttributeError: If the container doesn't have the required add_item method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'default_style'):
|
cls._validate_container(container, required_method='add_item')
|
||||||
style = container.default_style
|
style = cls._inherit_style(container, style)
|
||||||
elif style is None and hasattr(container, 'style'):
|
|
||||||
style = container.style
|
|
||||||
|
|
||||||
# Create the new list item
|
# Create the new list item
|
||||||
item = cls(term, style)
|
item = cls(term, style)
|
||||||
|
|
||||||
# Add the list item to the container
|
# Add the list item to the container
|
||||||
if hasattr(container, 'add_item'):
|
container.add_item(item)
|
||||||
container.add_item(item)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_item' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@@ -658,56 +573,8 @@ class ListItem(Block):
|
|||||||
"""Set the default style for this list item"""
|
"""Set the default style for this list item"""
|
||||||
self._style = style
|
self._style = style
|
||||||
|
|
||||||
def add_block(self, block: Block):
|
|
||||||
"""
|
|
||||||
Add a block element to this list item.
|
|
||||||
|
|
||||||
Args:
|
class TableCell(BlockContainer, ContainerAware, Block):
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
A table cell element that can contain other block elements.
|
A table cell element that can contain other block elements.
|
||||||
"""
|
"""
|
||||||
@@ -731,7 +598,6 @@ class TableCell(Block):
|
|||||||
self._is_header = is_header
|
self._is_header = is_header
|
||||||
self._colspan = colspan
|
self._colspan = colspan
|
||||||
self._rowspan = rowspan
|
self._rowspan = rowspan
|
||||||
self._blocks: List[Block] = []
|
|
||||||
self._style = style
|
self._style = style
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -754,20 +620,15 @@ class TableCell(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_cell method
|
AttributeError: If the container doesn't have the required add_cell method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container, required_method='add_cell')
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
|
|
||||||
# Create the new table cell
|
# Create the new table cell
|
||||||
cell = cls(is_header, colspan, rowspan, style)
|
cell = cls(is_header, colspan, rowspan, style)
|
||||||
|
|
||||||
# Add the cell to the container
|
# Add the cell to the container
|
||||||
if hasattr(container, 'add_cell'):
|
container.add_cell(cell)
|
||||||
container.add_cell(cell)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_cell' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
@@ -811,56 +672,8 @@ class TableCell(Block):
|
|||||||
"""Set the default style for this table cell"""
|
"""Set the default style for this table cell"""
|
||||||
self._style = style
|
self._style = style
|
||||||
|
|
||||||
def add_block(self, block: Block):
|
|
||||||
"""
|
|
||||||
Add a block element to this cell.
|
|
||||||
|
|
||||||
Args:
|
class TableRow(ContainerAware, Block):
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
A table row element containing table cells.
|
A table row element containing table cells.
|
||||||
"""
|
"""
|
||||||
@@ -897,20 +710,15 @@ class TableRow(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_row method
|
AttributeError: If the container doesn't have the required add_row method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container, required_method='add_row')
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
|
|
||||||
# Create the new table row
|
# Create the new table row
|
||||||
row = cls(style)
|
row = cls(style)
|
||||||
|
|
||||||
# Add the row to the container
|
# Add the row to the container
|
||||||
if hasattr(container, 'add_row'):
|
container.add_row(row, section)
|
||||||
container.add_row(row, section)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_row' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@@ -970,7 +778,7 @@ class TableRow(Block):
|
|||||||
return len(self._cells)
|
return len(self._cells)
|
||||||
|
|
||||||
|
|
||||||
class Table(Block):
|
class Table(ContainerAware, Block):
|
||||||
"""
|
"""
|
||||||
A table element containing rows and cells.
|
A table element containing rows and cells.
|
||||||
"""
|
"""
|
||||||
@@ -1011,22 +819,15 @@ class Table(Block):
|
|||||||
Raises:
|
Raises:
|
||||||
AttributeError: If the container doesn't have the required add_block method
|
AttributeError: If the container doesn't have the required add_block method
|
||||||
"""
|
"""
|
||||||
# Inherit style from container if not provided
|
# Validate container and inherit style using ContainerAware utilities
|
||||||
if style is None and hasattr(container, 'style'):
|
cls._validate_container(container)
|
||||||
style = container.style
|
style = cls._inherit_style(container, style)
|
||||||
elif style is None and hasattr(container, 'default_style'):
|
|
||||||
style = container.default_style
|
|
||||||
|
|
||||||
# Create the new table
|
# Create the new table
|
||||||
table = cls(caption, style)
|
table = cls(caption, style)
|
||||||
|
|
||||||
# Add the table to the container
|
# Add the table to the container
|
||||||
if hasattr(container, 'add_block'):
|
container.add_block(table)
|
||||||
container.add_block(table)
|
|
||||||
else:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Container {type(container).__name__} must have an 'add_block' method"
|
|
||||||
)
|
|
||||||
|
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,25 @@ from pyWebLayout.core import Hierarchical
|
|||||||
from pyWebLayout.style import Font
|
from pyWebLayout.style import Font
|
||||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||||
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
||||||
|
from functools import lru_cache
|
||||||
import pyphen
|
import pyphen
|
||||||
|
|
||||||
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
||||||
from pyWebLayout.abstract.functional import LinkType
|
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:
|
class Word:
|
||||||
"""
|
"""
|
||||||
An abstract representation of a word in a document. Words can be split across
|
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"""
|
"""Set the next word in sequence"""
|
||||||
self._next = next_word
|
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:
|
def possible_hyphenation(self, language: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Hyphenate the word and store the parts.
|
Hyphenate the word and store the parts.
|
||||||
@@ -174,8 +199,7 @@ class Word:
|
|||||||
bool: True if the word was hyphenated, False otherwise.
|
bool: True if the word was hyphenated, False otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dic = pyphen.Pyphen(lang=self._style.language)
|
return list(_hyphen_dict(self._style.language).iterate(self._text))
|
||||||
return list(dic.iterate(self._text))
|
|
||||||
|
|
||||||
|
|
||||||
...
|
...
|
||||||
@@ -348,6 +372,19 @@ class LinkedWord(Word):
|
|||||||
"""Get the link title/tooltip"""
|
"""Get the link title/tooltip"""
|
||||||
return self._title
|
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:
|
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
|
||||||
"""
|
"""
|
||||||
Execute the link action.
|
Execute the link action.
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ Concrete layer for the pyWebLayout library.
|
|||||||
This package contains concrete implementations that can be directly rendered.
|
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 .box import Box
|
||||||
from .image import RenderableImage
|
from .image import RenderableImage
|
||||||
from .page import Page
|
from .page import Page
|
||||||
@@ -22,4 +29,8 @@ __all__ = [
|
|||||||
'Cell',
|
'Cell',
|
||||||
'LinkText',
|
'LinkText',
|
||||||
'ButtonText',
|
'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
|
||||||
@@ -99,15 +99,20 @@ class LinkText(Text, Interactable, Queriable):
|
|||||||
self._origin,
|
self._origin,
|
||||||
np.ndarray) else self._origin
|
np.ndarray) else self._origin
|
||||||
|
|
||||||
# Draw background based on state (before text is rendered)
|
# Draw background based on state (before text is rendered).
|
||||||
if self._pressed:
|
# PIL wants a flat sequence of four scalars; handing it a list of two
|
||||||
# Pressed state - stronger, darker highlight
|
# numpy arrays raises "coordinate list must contain exactly 2
|
||||||
bg_color = (180, 180, 255, 180) # Stronger blue with more opacity
|
# coordinates".
|
||||||
self._draw.rectangle([origin, origin + size], fill=bg_color)
|
if self._pressed or self._hovered:
|
||||||
elif self._hovered:
|
far = origin + size
|
||||||
# Hover state - subtle highlight
|
box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
|
||||||
bg_color = (220, 220, 255, 100) # Light blue with alpha
|
if self._pressed:
|
||||||
self._draw.rectangle([origin, origin + size], fill=bg_color)
|
# 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
|
# Call the parent Text render method with parameters
|
||||||
super().render(next_text, spacing)
|
super().render(next_text, spacing)
|
||||||
@@ -153,7 +158,22 @@ class ButtonText(Text, Interactable, Queriable):
|
|||||||
self, '_width', 0) if not hasattr(
|
self, '_width', 0) if not hasattr(
|
||||||
self._width, '__call__') else 0
|
self._width, '__call__') else 0
|
||||||
self._padded_width = text_width + padding[1] + padding[3]
|
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
|
@property
|
||||||
def button(self) -> Button:
|
def button(self) -> Button:
|
||||||
@@ -237,11 +257,18 @@ class ButtonText(Text, Interactable, Queriable):
|
|||||||
# Total button height minus top and bottom padding gives us text area height
|
# 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]
|
text_area_height = self._padded_height - self._padding[0] - self._padding[2]
|
||||||
|
|
||||||
# Center the text visual height (ascent + descent) within the text area
|
# Centre the text's visual height (ascent + descent) within the text area.
|
||||||
# The y position is where the baseline sits
|
# text_y is the baseline, since Text renders with anchor "ls".
|
||||||
# Visual center = area_height/2, baseline should be at center + descent/2
|
#
|
||||||
vertical_center = text_area_height / 2
|
# top of glyphs = area_top + (area_height - (ascent + descent)) / 2
|
||||||
text_y = self._origin[1] + self._padding[0] + vertical_center + (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
|
# Temporarily set origin for text rendering
|
||||||
original_origin = self._origin.copy()
|
original_origin = self._origin.copy()
|
||||||
@@ -275,8 +302,17 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
"""
|
"""
|
||||||
A Text subclass that can handle FormField interactions.
|
A Text subclass that can handle FormField interactions.
|
||||||
Renders form field labels and input areas.
|
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,
|
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||||
field_height: int = 24, source=None, line=None):
|
field_height: int = 24, source=None, line=None):
|
||||||
"""
|
"""
|
||||||
@@ -302,8 +338,11 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
self._field_height = field_height
|
self._field_height = field_height
|
||||||
self._focused = False
|
self._focused = False
|
||||||
|
|
||||||
# Calculate total height (label + gap + field)
|
# Calculate total height (label + gap + field). The label's height is its
|
||||||
self._total_height = self._style.font_size + 5 + field_height
|
# 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
|
# Field width should be at least as wide as the label
|
||||||
# Use getattr to handle mock objects in tests
|
# Use getattr to handle mock objects in tests
|
||||||
@@ -312,6 +351,20 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
self._width, '__call__') else 0
|
self._width, '__call__') else 0
|
||||||
self._field_width = max(text_width, 150)
|
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
|
@property
|
||||||
def field(self) -> FormField:
|
def field(self) -> FormField:
|
||||||
"""Get the associated FormField object"""
|
"""Get the associated FormField object"""
|
||||||
@@ -330,12 +383,21 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
"""
|
"""
|
||||||
Render the form field with label and input area.
|
Render the form field with label and input area.
|
||||||
"""
|
"""
|
||||||
# Render the label
|
# Render the label. Text draws from the baseline, so shift down by the
|
||||||
super().render()
|
# 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_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
|
# Draw field background and border
|
||||||
bg_color = (255, 255, 255)
|
bg_color = (255, 255, 255)
|
||||||
@@ -360,11 +422,12 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
# Get font metrics to properly center the baseline
|
# Get font metrics to properly center the baseline
|
||||||
ascent, descent = value_font.font.getmetrics()
|
ascent, descent = value_font.font.getmetrics()
|
||||||
|
|
||||||
# Center the text vertically within the field
|
# Centre the value within the input box. As in ButtonText, the
|
||||||
# The y coordinate is where the baseline sits (anchor="ls")
|
# baseline sits at the top of the glyphs plus the ascent; centring on
|
||||||
vertical_center = self._field_height / 2
|
# 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_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
|
# Draw the value text
|
||||||
self._draw.text((value_x, value_y), value_text,
|
self._draw.text((value_x, value_y), value_text,
|
||||||
@@ -381,7 +444,7 @@ class FormFieldText(Text, Interactable, Queriable):
|
|||||||
True if the field was clicked and focused
|
True if the field was clicked and focused
|
||||||
"""
|
"""
|
||||||
# Calculate field area
|
# 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)
|
# Check if click is within the input field area (not just the label)
|
||||||
if (0 <= point[0] <= self._field_width and
|
if (0 <= point[0] <= self._field_width and
|
||||||
|
|||||||
@@ -15,23 +15,33 @@ class Page(Renderable, Queriable):
|
|||||||
contains a given point.
|
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.
|
Initialize a new page.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
size: The total size of the page (width, height) including borders
|
size: The total size of the page (width, height) including borders
|
||||||
style: The PageStyle defining borders, spacing, and appearance
|
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._size = size
|
||||||
|
self._origin = origin
|
||||||
self._style = style if style is not None else PageStyle()
|
self._style = style if style is not None else PageStyle()
|
||||||
self._children: List[Renderable] = []
|
self._children: List[Renderable] = []
|
||||||
self._canvas: Optional[Image.Image] = None
|
self._canvas: Optional[Image.Image] = None
|
||||||
self._draw: Optional[ImageDraw.Draw] = None
|
self._draw: Optional[ImageDraw.Draw] = None
|
||||||
|
self._measurement_draw: Optional[ImageDraw.ImageDraw] = None
|
||||||
# Initialize y_offset to start of content area
|
# Initialize y_offset to start of content area
|
||||||
# Position the first line so its baseline is close to the top boundary
|
# Position the first line so its baseline is close to the top boundary
|
||||||
# For subsequent lines, baseline-to-baseline spacing is used
|
# 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
|
self._is_first_line = True # Track if we're placing the first line
|
||||||
# Callback registry for managing interactable elements
|
# Callback registry for managing interactable elements
|
||||||
self._callbacks = CallbackRegistry()
|
self._callbacks = CallbackRegistry()
|
||||||
@@ -39,8 +49,12 @@ class Page(Renderable, Queriable):
|
|||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
def free_space(self) -> Tuple[int, int]:
|
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(
|
def can_fit_line(
|
||||||
self,
|
self,
|
||||||
@@ -59,7 +73,8 @@ class Page(Renderable, Queriable):
|
|||||||
True if the line fits within page boundaries
|
True if the line fits within page boundaries
|
||||||
"""
|
"""
|
||||||
# Calculate the maximum Y position allowed (bottom boundary)
|
# 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/descent not provided, use simple check (backward compatibility)
|
||||||
if ascent == 0 and descent == 0:
|
if ascent == 0 and descent == 0:
|
||||||
@@ -77,6 +92,34 @@ class Page(Renderable, Queriable):
|
|||||||
"""Get the total page size including borders"""
|
"""Get the total page size including borders"""
|
||||||
return self._size
|
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
|
@property
|
||||||
def canvas_size(self) -> Tuple[int, int]:
|
def canvas_size(self) -> Tuple[int, int]:
|
||||||
"""Get the canvas size (page size minus borders)"""
|
"""Get the canvas size (page size minus borders)"""
|
||||||
@@ -130,13 +173,38 @@ class Page(Renderable, Queriable):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
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
|
# Initialize canvas and draw context if not already done
|
||||||
self._canvas = self._create_canvas()
|
self._canvas = self._create_canvas()
|
||||||
self._draw = ImageDraw.Draw(self._canvas)
|
self._draw = ImageDraw.Draw(self._canvas)
|
||||||
return self._draw
|
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':
|
def add_child(self, child: Renderable) -> 'Page':
|
||||||
"""
|
"""
|
||||||
Add a child renderable object to this page.
|
Add a child renderable object to this page.
|
||||||
@@ -182,7 +250,7 @@ class Page(Renderable, Queriable):
|
|||||||
# Clear callback registry when clearing children
|
# Clear callback registry when clearing children
|
||||||
self._callbacks.clear()
|
self._callbacks.clear()
|
||||||
# Reset y_offset to start of content area (after border and padding)
|
# 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
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -190,34 +258,6 @@ class Page(Renderable, Queriable):
|
|||||||
"""Get a copy of the children list"""
|
"""Get a copy of the children list"""
|
||||||
return self._children.copy()
|
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):
|
def render_children(self):
|
||||||
"""
|
"""
|
||||||
Call render on all children in the list.
|
Call render on all children in the list.
|
||||||
@@ -260,7 +300,7 @@ class Page(Renderable, Queriable):
|
|||||||
PIL Image with background and borders applied
|
PIL Image with background and borders applied
|
||||||
"""
|
"""
|
||||||
# Create base image
|
# 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
|
# Draw borders if needed
|
||||||
if self._style.border_width > 0:
|
if self._style.border_width > 0:
|
||||||
@@ -276,30 +316,6 @@ class Page(Renderable, Queriable):
|
|||||||
|
|
||||||
return canvas
|
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]:
|
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
|
||||||
"""
|
"""
|
||||||
Query a point to find the deepest object at that location.
|
Query a point to find the deepest object at that location.
|
||||||
@@ -336,64 +352,6 @@ class Page(Renderable, Queriable):
|
|||||||
bounds=(int(point[0]), int(point[1]), 0, 0)
|
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:
|
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
|
||||||
"""
|
"""
|
||||||
Package an object into a QueryResult with metadata.
|
Package an object into a QueryResult with metadata.
|
||||||
@@ -504,6 +462,6 @@ class Page(Renderable, Queriable):
|
|||||||
True if the point is within the page bounds
|
True if the point is within the page bounds
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
0 <= point[0] < self._size[0] and
|
self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
|
||||||
0 <= point[1] < self._size[1]
|
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
|
return None # Cell rendering is done directly on the page
|
||||||
|
|
||||||
def _render_cell_content(self, x: int, y: int, width: int, height: int):
|
def _render_cell_content(self, x: int, y: int, width: int, height: int):
|
||||||
"""Render the content inside the cell (text and images)."""
|
"""Render the content inside the cell (text and images) with line wrapping."""
|
||||||
from PIL import ImageFont
|
from pyWebLayout.concrete.text import Line, Text
|
||||||
|
from pyWebLayout.style.fonts import Font
|
||||||
|
from pyWebLayout.style import FontWeight, Alignment
|
||||||
|
|
||||||
current_y = y + 2
|
current_y = y + 2
|
||||||
|
available_height = height - 4 # Account for top/bottom padding
|
||||||
|
|
||||||
# Get font
|
# Create font for the cell
|
||||||
try:
|
font_size = 12
|
||||||
if self._is_header_section and self._style.header_text_bold:
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||||
font = ImageFont.truetype(
|
if self._is_header_section and self._style.header_text_bold:
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
else:
|
|
||||||
font = ImageFont.truetype(
|
font = Font(
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
|
font_path=font_path,
|
||||||
except BaseException:
|
font_size=font_size,
|
||||||
font = ImageFont.load_default()
|
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
|
# Render each block in the cell
|
||||||
for block in self._cell.blocks():
|
for block in self._cell.blocks():
|
||||||
@@ -131,38 +144,102 @@ class TableCellRenderer(Box):
|
|||||||
current_y = self._render_image_in_cell(
|
current_y = self._render_image_in_cell(
|
||||||
block, x, current_y, width, height - (current_y - y))
|
block, x, current_y, width, height - (current_y - y))
|
||||||
elif isinstance(block, (Paragraph, Heading)):
|
elif isinstance(block, (Paragraph, Heading)):
|
||||||
# Extract and render text
|
# Get words from the block
|
||||||
words = []
|
from pyWebLayout.abstract.inline import Word as AbstractWord
|
||||||
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)
|
|
||||||
|
|
||||||
if words:
|
word_items = block.words() if callable(block.words) else block.words
|
||||||
text = " ".join(words)
|
words = list(word_items)
|
||||||
if current_y <= y + height - 15:
|
|
||||||
self._draw.text((x + 2, current_y), text,
|
if not words:
|
||||||
fill=(0, 0, 0), font=font)
|
continue
|
||||||
current_y += 16
|
|
||||||
|
# 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
|
if current_y > y + height - 10: # Don't overflow cell
|
||||||
break
|
break
|
||||||
|
|
||||||
# If no structured content, try to get any text representation
|
# If no structured content, try to get any text representation
|
||||||
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
|
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(
|
self._draw.text(
|
||||||
(x + 2,
|
(x + 2, current_y),
|
||||||
current_y),
|
|
||||||
self._cell._text_content,
|
self._cell._text_content,
|
||||||
fill=(
|
fill=(0, 0, 0),
|
||||||
0,
|
font=pil_font
|
||||||
0,
|
)
|
||||||
0),
|
|
||||||
font=font)
|
|
||||||
|
|
||||||
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
||||||
max_width: int, max_height: int) -> int:
|
max_width: int, max_height: int) -> int:
|
||||||
@@ -375,43 +452,41 @@ class TableRenderer(Box):
|
|||||||
"""
|
"""
|
||||||
Calculate column widths and row heights for the table.
|
Calculate column widths and row heights for the table.
|
||||||
|
|
||||||
|
Uses the table optimizer for intelligent column width distribution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (column_widths, row_heights_dict)
|
Tuple of (column_widths, row_heights_dict)
|
||||||
"""
|
"""
|
||||||
# Determine number of columns (from first row)
|
from pyWebLayout.layout.table_optimizer import optimize_table_layout
|
||||||
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
|
|
||||||
|
|
||||||
if num_columns == 0:
|
all_rows = list(self._table.all_rows())
|
||||||
|
|
||||||
|
if not all_rows:
|
||||||
return ([100], {"header": 30, "body": 30, "footer": 30})
|
return ([100], {"header": 30, "body": 30, "footer": 30})
|
||||||
|
|
||||||
# Calculate column widths (equal distribution for now)
|
# Use optimizer for column widths!
|
||||||
# Account for borders between columns
|
column_widths = optimize_table_layout(
|
||||||
total_border_width = self._style.border_width * (num_columns + 1)
|
self._table,
|
||||||
available_for_columns = self._available_width - total_border_width
|
self._available_width,
|
||||||
column_width = max(50, available_for_columns // num_columns)
|
sample_size=5,
|
||||||
column_widths = [column_width] * num_columns
|
style=self._style
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate row heights
|
if not column_widths:
|
||||||
header_height = 35 if any(1 for section,
|
# Fallback if table is empty
|
||||||
_ in all_rows if section == "header") else 0
|
column_widths = [100]
|
||||||
|
|
||||||
# Check if any body rows contain images - if so, use larger height
|
# Calculate row heights dynamically based on optimized column widths
|
||||||
body_height = 30
|
header_height = self._calculate_row_height_for_section(
|
||||||
for section, row in all_rows:
|
all_rows, "header", column_widths) if any(
|
||||||
if section == "body":
|
1 for section, _ in all_rows if section == "header") else 0
|
||||||
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
|
|
||||||
|
|
||||||
footer_height = 30 if any(1 for section,
|
body_height = self._calculate_row_height_for_section(
|
||||||
_ in all_rows if section == "footer") else 0
|
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 = {
|
row_heights = {
|
||||||
"header": header_height,
|
"header": header_height,
|
||||||
@@ -421,6 +496,148 @@ class TableRenderer(Box):
|
|||||||
|
|
||||||
return (column_widths, row_heights)
|
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:
|
def render(self) -> Image.Image:
|
||||||
"""Render the complete table."""
|
"""Render the complete table."""
|
||||||
x, y = self._origin
|
x, y = self._origin
|
||||||
|
|||||||
@@ -6,11 +6,242 @@ from pyWebLayout.style import Alignment, Font, TextDecoration
|
|||||||
from pyWebLayout.abstract import Word
|
from pyWebLayout.abstract import Word
|
||||||
from pyWebLayout.abstract.inline import LinkedWord
|
from pyWebLayout.abstract.inline import LinkedWord
|
||||||
from pyWebLayout.abstract.functional import Link
|
from pyWebLayout.abstract.functional import Link
|
||||||
from PIL import ImageDraw
|
from pyWebLayout.core.cache import UsageCache, SizedUsageCache
|
||||||
from typing import Tuple, List, Optional
|
from PIL import ImageDraw, ImageFont
|
||||||
|
from typing import Tuple, List, Optional, Any, Dict
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from abc import ABC, abstractmethod
|
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):
|
class AlignmentHandler(ABC):
|
||||||
"""
|
"""
|
||||||
@@ -21,7 +252,10 @@ class AlignmentHandler(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
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.
|
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
|
available_width: Total width available for the line
|
||||||
min_spacing: Minimum spacing between words
|
min_spacing: Minimum spacing between words
|
||||||
max_spacing: Maximum 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:
|
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'],
|
text_objects: List['Text'],
|
||||||
available_width: int,
|
available_width: int,
|
||||||
min_spacing: 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.
|
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:
|
Args:
|
||||||
text_objects (List[Text]): A list of text objects to be laid out.
|
text_objects (List[Text]): A list of text objects to be laid out.
|
||||||
available_width (int): The total width available for layout.
|
available_width (int): The total width available for layout.
|
||||||
min_spacing (int): Minimum spacing between text objects.
|
min_spacing (int): Minimum spacing between text objects.
|
||||||
max_spacing (int): Maximum spacing between text objects.
|
max_spacing (int): Maximum spacing between text objects.
|
||||||
|
natural_spacing (Optional[int]): The font's own space width.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
||||||
@@ -61,33 +311,20 @@ class LeftAlignmentHandler(AlignmentHandler):
|
|||||||
if len(text_objects) <= 1:
|
if len(text_objects) <= 1:
|
||||||
return 0, 0, False
|
return 0, 0, False
|
||||||
|
|
||||||
# Calculate the total length of all text objects
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||||
text_length = sum([text.width for text in text_objects])
|
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
|
num_gaps = len(text_objects) - 1
|
||||||
|
|
||||||
# Calculate minimum space needed (text + minimum gaps)
|
# The spacing is constant whether or not the content fits: tightening a
|
||||||
min_total_width = text_length + (min_spacing * num_gaps)
|
# 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
|
return spacing, 0, overflow
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
class CenterRightAlignmentHandler(AlignmentHandler):
|
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||||
@@ -98,10 +335,20 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
|
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int) -> Tuple[int, int, bool]:
|
max_spacing: int,
|
||||||
"""Center/right alignment uses minimum spacing with calculated start position."""
|
natural_spacing: Optional[int] = None,
|
||||||
word_length = sum([word.width for word in text_objects])
|
total_width: Optional[float] = None
|
||||||
residual_space = available_width - word_length
|
) -> 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
|
# Handle single word case
|
||||||
if len(text_objects) <= 1:
|
if len(text_objects) <= 1:
|
||||||
@@ -109,46 +356,103 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
start_position = (available_width - word_length) // 2
|
start_position = (available_width - word_length) // 2
|
||||||
else: # RIGHT
|
else: # RIGHT
|
||||||
start_position = available_width - word_length
|
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)
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||||
ideal_space = (min_spacing + max_spacing) / 2
|
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
||||||
if actual_spacing > 0.5 * (min_spacing + max_spacing):
|
|
||||||
actual_spacing = 0.5 * (min_spacing + max_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:
|
if self._alignment == Alignment.CENTER:
|
||||||
start_position = (available_width - content_length) // 2
|
start_position = (available_width - content_length) // 2
|
||||||
else:
|
else:
|
||||||
start_position = available_width - content_length
|
start_position = available_width - content_length
|
||||||
|
|
||||||
if actual_spacing < min_spacing:
|
return spacing, max(0, int(start_position)), overflow
|
||||||
return actual_spacing, max(0, start_position), True
|
|
||||||
|
|
||||||
return ideal_space, max(0, start_position), False
|
|
||||||
|
|
||||||
|
|
||||||
class JustifyAlignmentHandler(AlignmentHandler):
|
class JustifyAlignmentHandler(AlignmentHandler):
|
||||||
"""Handler for justified text with full justification."""
|
"""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'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int) -> Tuple[int, int, bool]:
|
max_spacing: int,
|
||||||
"""Justified alignment distributes space to fill the entire line width."""
|
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
|
residual_space = available_width - word_length
|
||||||
num_gaps = max(1, len(text_objects) - 1)
|
num_gaps = max(1, len(text_objects) - 1)
|
||||||
|
|
||||||
actual_spacing = residual_space // num_gaps
|
# Check if we have enough space for minimum spacing
|
||||||
ideal_space = (min_spacing + max_spacing) // 2
|
if residual_space // num_gaps < min_spacing:
|
||||||
# can we touch the end?
|
# Not enough space - this is overflow
|
||||||
if actual_spacing < max_spacing:
|
self._gap_uniform = min_spacing
|
||||||
if actual_spacing < min_spacing:
|
self._gap_count = num_gaps
|
||||||
# Ensure we never return spacing less than min_spacing
|
self._gap_cache = None
|
||||||
return min_spacing, 0, True
|
return min_spacing, 0, True
|
||||||
return max(min_spacing, actual_spacing), 0, False
|
|
||||||
return ideal_space, 0, False
|
# 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):
|
class Text(Renderable, Queriable):
|
||||||
@@ -184,9 +488,19 @@ class Text(Renderable, Queriable):
|
|||||||
|
|
||||||
def _calculate_dimensions(self):
|
def _calculate_dimensions(self):
|
||||||
"""Calculate the width and height of the text based on the font metrics"""
|
"""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
|
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()
|
ascent, descent = font.getmetrics()
|
||||||
self._ascent = ascent
|
self._ascent = ascent
|
||||||
self._middle_y = ascent - descent / 2
|
self._middle_y = ascent - descent / 2
|
||||||
@@ -322,23 +636,95 @@ class Text(Renderable, Queriable):
|
|||||||
A PIL Image containing the rendered text
|
A PIL Image containing the rendered text
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
style = self._style
|
||||||
|
|
||||||
# Draw the text background if specified
|
# Draw the text background if specified
|
||||||
if self._style.background and self._style.background[3] > 0: # If alpha > 0
|
if style.background and style.background[3] > 0: # If alpha > 0
|
||||||
self._draw.rectangle([self._origin, self._origin +
|
self._draw.rectangle([tuple(self._origin), tuple(self._origin + self.size)],
|
||||||
self._size], fill=self._style.background)
|
fill=style.background)
|
||||||
|
|
||||||
# Draw the text using baseline as anchor point ("ls" = left-baseline)
|
# Draw the text using baseline as anchor point ("ls" = left-baseline)
|
||||||
# This ensures the origin represents the baseline, not the top-left
|
# This ensures the origin represents the baseline, not the top-left
|
||||||
self._draw.text(
|
if not self._render_from_glyph_cache(style):
|
||||||
(self.origin[0],
|
self._draw.text(
|
||||||
self._origin[1]),
|
(self.origin[0],
|
||||||
self._text,
|
self._origin[1]),
|
||||||
font=self._style.font,
|
self._text,
|
||||||
fill=self._style.colour,
|
font=style.font,
|
||||||
anchor="ls")
|
fill=style.colour,
|
||||||
|
anchor="ls")
|
||||||
|
|
||||||
# Apply any text decorations with knowledge of next text
|
# 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):
|
class Line(Box):
|
||||||
@@ -383,6 +769,9 @@ class Line(Box):
|
|||||||
"""
|
"""
|
||||||
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
||||||
self._text_objects: List['Text'] = [] # Store Text objects directly
|
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._spacing = spacing # (min_spacing, max_spacing)
|
||||||
self._font = font if font else Font() # Use default font if none provided
|
self._font = font if font else Font() # Use default font if none provided
|
||||||
self._current_width = 0 # Track the current width used
|
self._current_width = 0 # Track the current width used
|
||||||
@@ -396,6 +785,10 @@ class Line(Box):
|
|||||||
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
||||||
self._position_render = 0
|
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
|
# Hyphenation configuration parameters
|
||||||
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
||||||
self._min_chars_before_hyphen = min_chars_before_hyphen
|
self._min_chars_before_hyphen = min_chars_before_hyphen
|
||||||
@@ -404,6 +797,34 @@ class Line(Box):
|
|||||||
# Create the appropriate alignment handler
|
# Create the appropriate alignment handler
|
||||||
self._alignment_handler = self._create_alignment_handler(halign)
|
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:
|
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
||||||
"""
|
"""
|
||||||
Create the appropriate alignment handler based on the alignment type.
|
Create the appropriate alignment handler based on the alignment type.
|
||||||
@@ -430,6 +851,45 @@ class Line(Box):
|
|||||||
"""Set the next line in sequence"""
|
"""Set the next line in sequence"""
|
||||||
self._next = line
|
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,
|
def add_word(self,
|
||||||
word: 'Word',
|
word: 'Word',
|
||||||
part: Optional[Text] = None) -> Tuple[bool,
|
part: Optional[Text] = None) -> Tuple[bool,
|
||||||
@@ -448,7 +908,7 @@ class Line(Box):
|
|||||||
"""
|
"""
|
||||||
# First, add any pretext from previous hyphenation
|
# First, add any pretext from previous hyphenation
|
||||||
if part is not None:
|
if part is not None:
|
||||||
self._text_objects.append(part)
|
self._push_text(part)
|
||||||
self._words.append(word)
|
self._words.append(word)
|
||||||
part.add_line(self)
|
part.add_line(self)
|
||||||
|
|
||||||
@@ -477,9 +937,8 @@ class Line(Box):
|
|||||||
line=self)
|
line=self)
|
||||||
else:
|
else:
|
||||||
text = Text.from_word(word, self._draw)
|
text = Text.from_word(word, self._draw)
|
||||||
self._text_objects.append(text)
|
self._push_text(text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# Word fits! Add it completely
|
# Word fits! Add it completely
|
||||||
@@ -491,7 +950,7 @@ class Line(Box):
|
|||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
# Word doesn't fit, remove it and try hyphenation
|
# Word doesn't fit, remove it and try hyphenation
|
||||||
_ = self._text_objects.pop()
|
self._pop_text()
|
||||||
|
|
||||||
# Step 1: Try pyphen hyphenation
|
# Step 1: Try pyphen hyphenation
|
||||||
pyphen_splits = word.possible_hyphenation()
|
pyphen_splits = word.possible_hyphenation()
|
||||||
@@ -524,10 +983,9 @@ class Line(Box):
|
|||||||
source=word)
|
source=word)
|
||||||
|
|
||||||
# Check if first part fits
|
# Check if first part fits
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
self._pop_text()
|
||||||
_ = self._text_objects.pop()
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# This split fits! Add it to valid options
|
# This split fits! Add it to valid options
|
||||||
@@ -540,7 +998,7 @@ class Line(Box):
|
|||||||
first_text, second_text, spacing, position = best_split
|
first_text, second_text, spacing, position = best_split
|
||||||
|
|
||||||
# Apply the split
|
# Apply the split
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
first_text.line = self
|
first_text.line = self
|
||||||
word.add_concete((first_text, second_text))
|
word.add_concete((first_text, second_text))
|
||||||
self._spacing_render = spacing
|
self._spacing_render = spacing
|
||||||
@@ -551,7 +1009,7 @@ class Line(Box):
|
|||||||
# Step 3: Try brute force hyphenation (only for long words)
|
# Step 3: Try brute force hyphenation (only for long words)
|
||||||
if len(word.text) >= self._min_word_length_for_brute_force:
|
if len(word.text) >= self._min_word_length_for_brute_force:
|
||||||
# Calculate available space for the word
|
# 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)
|
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
||||||
remaining = self._size[0] - word_length - spacing_length
|
remaining = self._size[0] - word_length - spacing_length
|
||||||
|
|
||||||
@@ -595,9 +1053,8 @@ class Line(Box):
|
|||||||
source=word)
|
source=word)
|
||||||
|
|
||||||
# Verify the first part actually fits
|
# Verify the first part actually fits
|
||||||
self._text_objects.append(first_text)
|
self._push_text(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure()
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
# Brute force split works!
|
# Brute force split works!
|
||||||
@@ -610,7 +1067,7 @@ class Line(Box):
|
|||||||
return True, second_text
|
return True, second_text
|
||||||
else:
|
else:
|
||||||
# Doesn't fit, remove it
|
# Doesn't fit, remove it
|
||||||
_ = self._text_objects.pop()
|
self._pop_text()
|
||||||
|
|
||||||
# Step 4: Word cannot be hyphenated or split, move to next line
|
# Step 4: Word cannot be hyphenated or split, move to next line
|
||||||
return False, None
|
return False, None
|
||||||
@@ -622,10 +1079,13 @@ class Line(Box):
|
|||||||
Returns:
|
Returns:
|
||||||
A PIL Image containing the rendered line
|
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:
|
if len(self._text_objects) > 0:
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = self._measure(handler)
|
||||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
|
||||||
self._spacing_render = spacing
|
self._spacing_render = spacing
|
||||||
self._position_render = position
|
self._position_render = position
|
||||||
|
|
||||||
@@ -633,18 +1093,34 @@ class Line(Box):
|
|||||||
|
|
||||||
# Start x_cursor at line origin plus any alignment offset
|
# Start x_cursor at line origin plus any alignment offset
|
||||||
x_cursor = self._origin[0] + self._position_render
|
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
|
# Update text draw context to current draw context
|
||||||
text._draw = self._draw
|
text._draw = draw
|
||||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||||
|
|
||||||
# Determine next text object for continuous decoration
|
# Determine next text object for continuous decoration
|
||||||
next_text = self._text_objects[i + 1] if i + \
|
next_text = texts[i + 1] if i < last else None
|
||||||
1 < len(self._text_objects) 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
|
# Render with next text information for continuous underline/strikethrough
|
||||||
text.render(next_text, self._spacing_render)
|
text.render(next_text, current_spacing)
|
||||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
# 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']:
|
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -331,10 +331,16 @@ class BlockContainer:
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._blocks = []
|
self._blocks = []
|
||||||
|
|
||||||
@property
|
|
||||||
def blocks(self):
|
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):
|
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
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Tuple, Optional, Dict, Any
|
from typing import List, Tuple, Optional, Dict, Any
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HighlightColor(Enum):
|
class HighlightColor(Enum):
|
||||||
"""Predefined highlight colors with RGBA values"""
|
"""Predefined highlight colors with RGBA values"""
|
||||||
@@ -44,6 +48,12 @@ class Highlight:
|
|||||||
start_word_index: Optional[int] = None # Word index in document (if available)
|
start_word_index: Optional[int] = None # Word index in document (if available)
|
||||||
end_word_index: Optional[int] = None
|
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
|
# Metadata
|
||||||
note: Optional[str] = None # Optional annotation
|
note: Optional[str] = None # Optional annotation
|
||||||
tags: List[str] = None # Optional categorization tags
|
tags: List[str] = None # Optional categorization tags
|
||||||
@@ -63,6 +73,7 @@ class Highlight:
|
|||||||
'text': self.text,
|
'text': self.text,
|
||||||
'start_word_index': self.start_word_index,
|
'start_word_index': self.start_word_index,
|
||||||
'end_word_index': self.end_word_index,
|
'end_word_index': self.end_word_index,
|
||||||
|
'position': self.position,
|
||||||
'note': self.note,
|
'note': self.note,
|
||||||
'tags': self.tags,
|
'tags': self.tags,
|
||||||
'timestamp': self.timestamp
|
'timestamp': self.timestamp
|
||||||
@@ -78,6 +89,7 @@ class Highlight:
|
|||||||
text=data['text'],
|
text=data['text'],
|
||||||
start_word_index=data.get('start_word_index'),
|
start_word_index=data.get('start_word_index'),
|
||||||
end_word_index=data.get('end_word_index'),
|
end_word_index=data.get('end_word_index'),
|
||||||
|
position=data.get('position'),
|
||||||
note=data.get('note'),
|
note=data.get('note'),
|
||||||
tags=data.get('tags', []),
|
tags=data.get('tags', []),
|
||||||
timestamp=data.get('timestamp')
|
timestamp=data.get('timestamp')
|
||||||
@@ -100,12 +112,9 @@ class HighlightManager:
|
|||||||
highlights_dir: Directory to store highlight data
|
highlights_dir: Directory to store highlight data
|
||||||
"""
|
"""
|
||||||
self.document_id = document_id
|
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
|
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
|
# Load existing highlights
|
||||||
self._load_highlights()
|
self._load_highlights()
|
||||||
|
|
||||||
@@ -178,34 +187,22 @@ class HighlightManager:
|
|||||||
|
|
||||||
def _save_highlights(self) -> None:
|
def _save_highlights(self) -> None:
|
||||||
"""Persist highlights to disk"""
|
"""Persist highlights to disk"""
|
||||||
try:
|
write_json(self._get_filepath(), {
|
||||||
filepath = self._get_filepath()
|
'document_id': self.document_id,
|
||||||
data = {
|
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||||
'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}")
|
|
||||||
|
|
||||||
def _load_highlights(self) -> None:
|
def _load_highlights(self) -> None:
|
||||||
"""Load highlights from disk"""
|
"""Load highlights from disk"""
|
||||||
|
data = read_json(self._get_filepath(), {})
|
||||||
try:
|
try:
|
||||||
filepath = self._get_filepath()
|
|
||||||
if not filepath.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
self.highlights = {
|
self.highlights = {
|
||||||
h['id']: Highlight.from_dict(h)
|
h['id']: Highlight.from_dict(h)
|
||||||
for h in data.get('highlights', [])
|
for h in data.get('highlights', [])
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except (AttributeError, TypeError, KeyError):
|
||||||
print(f"Error loading highlights: {e}")
|
logger.warning("Highlight file %s is not in the expected shape; ignoring it",
|
||||||
|
self._get_filepath(), exc_info=True)
|
||||||
self.highlights = {}
|
self.highlights = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -213,16 +210,18 @@ def create_highlight_from_query_result(
|
|||||||
result,
|
result,
|
||||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||||
note: Optional[str] = None,
|
note: Optional[str] = None,
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None,
|
||||||
|
position: Optional[Dict[str, Any]] = None
|
||||||
) -> Highlight:
|
) -> Highlight:
|
||||||
"""
|
"""
|
||||||
Create a highlight from a QueryResult.
|
Create a highlight from a QueryResult.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result: QueryResult from query_pixel or query_range
|
result: QueryResult from query_point or query_range
|
||||||
color: RGBA color tuple
|
color: RGBA color tuple
|
||||||
note: Optional annotation
|
note: Optional annotation
|
||||||
tags: Optional categorization tags
|
tags: Optional categorization tags
|
||||||
|
position: Serialized RenderingPosition of the page the result came from
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Highlight instance
|
Highlight instance
|
||||||
@@ -243,6 +242,7 @@ def create_highlight_from_query_result(
|
|||||||
bounds=bounds,
|
bounds=bounds,
|
||||||
color=color,
|
color=color,
|
||||||
text=text,
|
text=text,
|
||||||
|
position=position,
|
||||||
note=note,
|
note=note,
|
||||||
tags=tags or [],
|
tags=tags or [],
|
||||||
timestamp=time()
|
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
|
||||||
@@ -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 typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||||
|
from bs4.element import CData, Comment, Doctype, ProcessingInstruction
|
||||||
from pyWebLayout.abstract.inline import Word
|
from pyWebLayout.abstract.inline import Word
|
||||||
from pyWebLayout.abstract.block import (
|
from pyWebLayout.abstract.block import (
|
||||||
Block,
|
Block,
|
||||||
@@ -369,6 +370,24 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
|||||||
element: BeautifulSoup Tag object
|
element: BeautifulSoup Tag object
|
||||||
context: Current style context
|
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:
|
Returns:
|
||||||
List of Word objects (including LinkedWord for hyperlinks)
|
List of Word objects (including LinkedWord for hyperlinks)
|
||||||
"""
|
"""
|
||||||
@@ -377,15 +396,20 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
|||||||
|
|
||||||
words = []
|
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):
|
if isinstance(child, NavigableString):
|
||||||
# Plain text - split into words
|
# Plain text - split into words. Argument-less str.split() already
|
||||||
text = str(child).strip()
|
# discards surrounding whitespace and never yields an empty string, so
|
||||||
if text:
|
# it needs neither a preceding strip() nor a per-word emptiness test.
|
||||||
word_texts = text.split()
|
font = context.font
|
||||||
for word_text in word_texts:
|
background = context.background
|
||||||
if word_text:
|
words.extend([Word(word_text, font, background)
|
||||||
words.append(Word(word_text, context.font, context.background))
|
for word_text in str(child).split()])
|
||||||
elif isinstance(child, Tag):
|
elif isinstance(child, Tag):
|
||||||
# Special handling for <a> tags (hyperlinks)
|
# Special handling for <a> tags (hyperlinks)
|
||||||
if child.name.lower() == "a":
|
if child.name.lower() == "a":
|
||||||
@@ -466,6 +490,93 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
|||||||
return words
|
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(
|
def process_element(
|
||||||
element: Tag, context: StyleContext
|
element: Tag, context: StyleContext
|
||||||
) -> Union[Block, List[Block], None]:
|
) -> Union[Block, List[Block], None]:
|
||||||
@@ -557,17 +668,7 @@ def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, L
|
|||||||
|
|
||||||
def div_handler(element: Tag, context: StyleContext) -> List[Block]:
|
def div_handler(element: Tag, context: StyleContext) -> List[Block]:
|
||||||
"""Handle <div> elements - treat as generic container."""
|
"""Handle <div> elements - treat as generic container."""
|
||||||
blocks = []
|
return process_block_children(element, context)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||||
@@ -592,16 +693,8 @@ def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
|||||||
def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
|
def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
|
||||||
"""Handle <blockquote> elements."""
|
"""Handle <blockquote> elements."""
|
||||||
quote = Quote(context.font)
|
quote = Quote(context.font)
|
||||||
for child in element.children:
|
for block in process_block_children(element, context):
|
||||||
if isinstance(child, Tag):
|
quote.add_block(block)
|
||||||
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)
|
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
@@ -655,28 +748,8 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
|
|||||||
def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
|
def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
|
||||||
"""Handle <li> elements."""
|
"""Handle <li> elements."""
|
||||||
list_item = ListItem(None, context.font)
|
list_item = ListItem(None, context.font)
|
||||||
|
for block in process_block_children(element, context):
|
||||||
for child in element.children:
|
list_item.add_block(block)
|
||||||
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)
|
|
||||||
|
|
||||||
return list_item
|
return list_item
|
||||||
|
|
||||||
|
|
||||||
@@ -728,27 +801,8 @@ def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
|||||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||||
cell = TableCell(False, colspan, rowspan, context.font)
|
cell = TableCell(False, colspan, rowspan, context.font)
|
||||||
|
|
||||||
# Process cell content
|
for block in process_block_children(element, context):
|
||||||
for child in element.children:
|
cell.add_block(block)
|
||||||
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)
|
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
@@ -759,26 +813,8 @@ def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
|||||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||||
cell = TableCell(True, colspan, rowspan, context.font)
|
cell = TableCell(True, colspan, rowspan, context.font)
|
||||||
|
|
||||||
# Process cell content (same as td)
|
for block in process_block_children(element, context):
|
||||||
for child in element.children:
|
cell.add_block(block)
|
||||||
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)
|
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pyWebLayout.concrete.image import RenderableImage
|
|||||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||||
from pyWebLayout.abstract import Paragraph, Word
|
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.abstract.functional import Button, Form, FormField
|
||||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||||
from pyWebLayout.style import Font, Alignment
|
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
|
# We need to get word spacing constraints from the Font's abstract style if available
|
||||||
# For now, use reasonable defaults based on font size
|
# 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):
|
if isinstance(paragraph.style, Font):
|
||||||
# paragraph.style is already a Font (concrete style)
|
# paragraph.style is already a Font (concrete style)
|
||||||
font = paragraph.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
|
min_spacing = float(font.font_size) * 0.25 # 25% of font size
|
||||||
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
||||||
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
||||||
text_align = Alignment.LEFT # Default alignment
|
text_align = default_alignment
|
||||||
else:
|
else:
|
||||||
# paragraph.style is an AbstractStyle, resolve it
|
# paragraph.style is an AbstractStyle, resolve it
|
||||||
# Ensure font_size is an int (it could be a FontSize enum)
|
# 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_min),
|
||||||
int(concrete_style.word_spacing_max)
|
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
|
# Apply page-level word spacing override if specified
|
||||||
if hasattr(
|
if hasattr(
|
||||||
@@ -151,20 +161,17 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
y_cursor = page._current_y_offset
|
y_cursor = page._current_y_offset
|
||||||
else:
|
else:
|
||||||
y_cursor = page._current_y_offset
|
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
|
# `word` is accepted for call-site readability only: the line that is about
|
||||||
if word:
|
# to be created measures it when it is added, so measuring it here as well
|
||||||
temp_text = Text.from_word(word, page.draw)
|
# only paid for a Text object that was immediately discarded.
|
||||||
temp_text.width
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return Line(
|
return Line(
|
||||||
spacing=word_spacing_constraints,
|
spacing=word_spacing_constraints,
|
||||||
origin=(x_cursor, y_cursor),
|
origin=(x_cursor, y_cursor),
|
||||||
size=(page.available_width, baseline_spacing),
|
size=(page.available_width, baseline_spacing),
|
||||||
draw=page.draw,
|
draw=page.measurement_draw,
|
||||||
font=font,
|
font=font,
|
||||||
halign=text_align
|
halign=text_align
|
||||||
)
|
)
|
||||||
@@ -215,7 +222,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
return False, i, overflow_text
|
return False, i, overflow_text
|
||||||
|
|
||||||
# Check if the word will fit on the new line before adding it
|
# 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]:
|
if temp_text.width > current_line.size[0]:
|
||||||
# Word is too wide for the line, we need to hyphenate it
|
# Word is too wide for the line, we need to hyphenate it
|
||||||
if len(word.text) >= 6:
|
if len(word.text) >= 6:
|
||||||
@@ -224,13 +231,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
(Text(
|
(Text(
|
||||||
pair[0],
|
pair[0],
|
||||||
word.style,
|
word.style,
|
||||||
page.draw,
|
page.measurement_draw,
|
||||||
line=current_line,
|
line=current_line,
|
||||||
source=word),
|
source=word),
|
||||||
Text(
|
Text(
|
||||||
pair[1],
|
pair[1],
|
||||||
word.style,
|
word.style,
|
||||||
page.draw,
|
page.measurement_draw,
|
||||||
line=current_line,
|
line=current_line,
|
||||||
source=word)) for pair in word.possible_hyphenation()]
|
source=word)) for pair in word.possible_hyphenation()]
|
||||||
if len(splits) > 0:
|
if len(splits) > 0:
|
||||||
@@ -260,7 +267,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
else:
|
else:
|
||||||
current_pretext = overflow_text # May be None or hyphenated remainder
|
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
|
return True, None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +318,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
|||||||
max_width = page.available_width
|
max_width = page.available_width
|
||||||
|
|
||||||
# Calculate available height on page
|
# 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 no space available, image doesn't fit
|
||||||
if available_height <= 0:
|
if available_height <= 0:
|
||||||
@@ -325,7 +338,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Create renderable image
|
# Create renderable image
|
||||||
x_offset = page.border_size
|
x_offset = page.content_origin[0]
|
||||||
y_offset = page._current_y_offset
|
y_offset = page._current_y_offset
|
||||||
|
|
||||||
# Access page.draw to ensure canvas is initialized
|
# Access page.draw to ensure canvas is initialized
|
||||||
@@ -368,7 +381,7 @@ def table_layouter(
|
|||||||
"""
|
"""
|
||||||
# Calculate available space
|
# Calculate available space
|
||||||
available_width = page.available_width
|
available_width = page.available_width
|
||||||
x_offset = page.border_size
|
x_offset = page.content_origin[0]
|
||||||
y_offset = page._current_y_offset
|
y_offset = page._current_y_offset
|
||||||
|
|
||||||
# Access page.draw to ensure canvas is initialized
|
# Access page.draw to ensure canvas is initialized
|
||||||
@@ -388,7 +401,7 @@ def table_layouter(
|
|||||||
|
|
||||||
# Check if table fits on current page
|
# Check if table fits on current page
|
||||||
table_height = renderer.size[1]
|
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:
|
if table_height > available_height:
|
||||||
return False
|
return False
|
||||||
@@ -436,10 +449,10 @@ def button_layouter(button: Button,
|
|||||||
font = Font(font_size=14, colour=(255, 255, 255))
|
font = Font(font_size=14, colour=(255, 255, 255))
|
||||||
|
|
||||||
# Calculate available space
|
# Calculate available space
|
||||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
available_height = page.remaining_height
|
||||||
|
|
||||||
# Create ButtonText renderable
|
# 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
|
# Check if button fits on current page
|
||||||
button_height = button_text.size[1]
|
button_height = button_text.size[1]
|
||||||
@@ -447,7 +460,7 @@ def button_layouter(button: Button,
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
# Position the button
|
# Position the button
|
||||||
x_offset = page.border_size
|
x_offset = page.content_origin[0]
|
||||||
y_offset = page._current_y_offset
|
y_offset = page._current_y_offset
|
||||||
|
|
||||||
button_text.set_origin(np.array([x_offset, y_offset]))
|
button_text.set_origin(np.array([x_offset, y_offset]))
|
||||||
@@ -486,10 +499,11 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
|||||||
font = Font(font_size=12, colour=(0, 0, 0))
|
font = Font(font_size=12, colour=(0, 0, 0))
|
||||||
|
|
||||||
# Calculate available space
|
# Calculate available space
|
||||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
available_height = page.remaining_height
|
||||||
|
|
||||||
# Create FormFieldText renderable
|
# 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
|
# Check if field fits on current page
|
||||||
total_field_height = field_text.size[1]
|
total_field_height = field_text.size[1]
|
||||||
@@ -497,7 +511,7 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
# Position the field
|
# Position the field
|
||||||
x_offset = page.border_size
|
x_offset = page.content_origin[0]
|
||||||
y_offset = page._current_y_offset
|
y_offset = page._current_y_offset
|
||||||
|
|
||||||
field_text.set_origin(np.array([x_offset, y_offset]))
|
field_text.set_origin(np.array([x_offset, y_offset]))
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import List, Dict, Tuple, Optional, Any
|
from typing import List, Dict, Tuple, Optional, Any
|
||||||
|
|
||||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList, Image
|
from pyWebLayout.abstract.block import (
|
||||||
|
Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell,
|
||||||
|
HList, ListItem, Quote, Image)
|
||||||
from pyWebLayout.abstract.inline import Word
|
from pyWebLayout.abstract.inline import Word
|
||||||
from pyWebLayout.concrete.page import Page
|
from pyWebLayout.concrete.page import Page
|
||||||
from pyWebLayout.concrete.text import Text
|
from pyWebLayout.concrete.text import Text
|
||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
from pyWebLayout.style import Font
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
|
||||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
|
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +43,19 @@ class RenderingPosition:
|
|||||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||||
page_y_offset: int = 0 # Vertical position on page
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Serialize position for saving to file/database"""
|
"""Serialize position for saving to file/database"""
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@@ -51,17 +67,17 @@ class RenderingPosition:
|
|||||||
|
|
||||||
def copy(self) -> 'RenderingPosition':
|
def copy(self) -> 'RenderingPosition':
|
||||||
"""Create a copy of this position"""
|
"""Create a copy of this position"""
|
||||||
return RenderingPosition(**asdict(self))
|
return RenderingPosition(*self._key())
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
def __eq__(self, other) -> bool:
|
||||||
"""Check if two positions are equal"""
|
"""Check if two positions are equal"""
|
||||||
if not isinstance(other, RenderingPosition):
|
if not isinstance(other, RenderingPosition):
|
||||||
return False
|
return False
|
||||||
return asdict(self) == asdict(other)
|
return self._key() == other._key()
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
"""Make position hashable for use as dict key"""
|
"""Make position hashable for use as dict key"""
|
||||||
return hash(tuple(asdict(self).values()))
|
return hash(self._key())
|
||||||
|
|
||||||
|
|
||||||
class ChapterInfo:
|
class ChapterInfo:
|
||||||
@@ -181,32 +197,50 @@ class ChapterNavigator:
|
|||||||
return self.chapters[0] if self.chapters else None
|
return self.chapters[0] if self.chapters else None
|
||||||
|
|
||||||
|
|
||||||
class FontScaler:
|
class FontFamilyOverride:
|
||||||
"""
|
"""
|
||||||
Handles font scaling operations for ereader font size adjustments.
|
Manages font family preferences for ereader rendering.
|
||||||
Applies scaling at layout/render time while preserving original font objects.
|
Allows dynamic font family switching without modifying source blocks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, preferred_family: Optional[BundledFont] = None):
|
||||||
def scale_font(font: Font, scale_factor: float) -> Font:
|
|
||||||
"""
|
"""
|
||||||
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:
|
Args:
|
||||||
font: Original font object
|
font: Original font object
|
||||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
|
||||||
|
|
||||||
Returns:
|
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
|
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(
|
return Font(
|
||||||
font_path=font._font_path,
|
font_path=new_font_path,
|
||||||
font_size=scaled_size,
|
font_size=font.font_size,
|
||||||
colour=font.colour,
|
colour=font.colour,
|
||||||
weight=font.weight,
|
weight=font.weight,
|
||||||
style=font.style,
|
style=font.style,
|
||||||
@@ -216,6 +250,49 @@ class FontScaler:
|
|||||||
min_hyphenation_width=font.min_hyphenation_width
|
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
|
@staticmethod
|
||||||
def scale_word_spacing(spacing: Tuple[int, int],
|
def scale_word_spacing(spacing: Tuple[int, int],
|
||||||
scale_factor: float) -> Tuple[int, int]:
|
scale_factor: float) -> Tuple[int, int]:
|
||||||
@@ -242,12 +319,27 @@ class BidirectionalLayouter:
|
|||||||
page_size: Tuple[int,
|
page_size: Tuple[int,
|
||||||
int] = (800,
|
int] = (800,
|
||||||
600),
|
600),
|
||||||
alignment_override=None):
|
alignment_override=None,
|
||||||
|
font_family_override: Optional[FontFamilyOverride] = None):
|
||||||
self.blocks = blocks
|
self.blocks = blocks
|
||||||
self.page_style = page_style
|
self.page_style = page_style
|
||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
self.chapter_navigator = ChapterNavigator(blocks)
|
self.chapter_navigator = ChapterNavigator(blocks)
|
||||||
self.alignment_override = alignment_override
|
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,
|
def render_page_forward(self, position: RenderingPosition,
|
||||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||||
@@ -280,7 +372,14 @@ class BidirectionalLayouter:
|
|||||||
scaled_block, page, current_pos, font_scale)
|
scaled_block, page, current_pos, font_scale)
|
||||||
|
|
||||||
if not success:
|
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
|
break
|
||||||
|
|
||||||
# Add inter-block spacing after successfully laying out a block
|
# Add inter-block spacing after successfully laying out a block
|
||||||
@@ -296,133 +395,241 @@ class BidirectionalLayouter:
|
|||||||
|
|
||||||
current_pos = new_pos
|
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
|
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,
|
def render_page_backward(self,
|
||||||
end_position: RenderingPosition,
|
end_position: RenderingPosition,
|
||||||
font_scale: float = 1.0) -> Tuple[Page,
|
font_scale: float = 1.0) -> Tuple[Page,
|
||||||
RenderingPosition]:
|
RenderingPosition]:
|
||||||
"""
|
"""
|
||||||
Render a page that ends at the given position, filling backward.
|
Render the page that ends at the given position - "previous page".
|
||||||
Critical for "previous page" navigation.
|
|
||||||
|
|
||||||
Uses iterative refinement to find the correct start position that
|
Pagination is a pure function: laying out from a position q yields a page
|
||||||
results in a page ending at (or very close to) the target position.
|
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:
|
Args:
|
||||||
end_position: Position where page should end
|
end_position: Position where the page should end
|
||||||
font_scale: Font scaling factor
|
font_scale: Font scaling factor
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (rendered_page, start_position)
|
Tuple of (rendered_page, start_position)
|
||||||
"""
|
"""
|
||||||
# Handle edge case: already at beginning
|
document_start = RenderingPosition()
|
||||||
if end_position.block_index == 0 and end_position.word_index == 0:
|
|
||||||
return self.render_page_forward(end_position, font_scale)
|
|
||||||
|
|
||||||
# Start with initial estimate
|
# Nothing precedes the start of the document.
|
||||||
estimated_start = self._estimate_page_start(end_position, font_scale)
|
if self._position_compare(end_position, document_start) <= 0:
|
||||||
|
page, _ = self.render_page_forward(document_start, font_scale)
|
||||||
|
return page, document_start
|
||||||
|
|
||||||
# Iterative refinement: keep adjusting until we converge or hit max iterations
|
# 1. The chain we have already walked.
|
||||||
max_iterations = 10
|
remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
|
||||||
best_page = None
|
if remembered is not None:
|
||||||
best_start = estimated_start
|
page, actual_end = self.render_page_forward(remembered, font_scale)
|
||||||
best_distance = float('inf')
|
if self._position_compare(actual_end, end_position) == 0:
|
||||||
|
return page, remembered
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
# 2/3. Replay from anchors, keeping the best inexact result as a fallback.
|
||||||
# Render forward from current estimate
|
fallback = None
|
||||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
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)
|
||||||
|
|
||||||
# Calculate how far we are from target
|
if fallback is not None:
|
||||||
comparison = self._position_compare(actual_end, end_position)
|
return fallback
|
||||||
|
|
||||||
# Perfect match or close enough (within same block)
|
page, _ = self.render_page_forward(document_start, font_scale)
|
||||||
# BUT: ensure we actually moved backward (estimated_start < end_position)
|
return page, document_start
|
||||||
if comparison == 0:
|
|
||||||
# Check if we actually found a valid previous page
|
|
||||||
if self._position_compare(estimated_start, end_position) < 0:
|
|
||||||
return page, estimated_start
|
|
||||||
# If estimated_start >= end_position, we haven't moved backward
|
|
||||||
# Continue iterating to find a better position
|
|
||||||
elif iteration == 0:
|
|
||||||
# On first iteration, if we can't find a previous position,
|
|
||||||
# we're likely at or near the beginning
|
|
||||||
break
|
|
||||||
|
|
||||||
# Track best result so far
|
def _backward_anchors(self, target: RenderingPosition):
|
||||||
distance = abs(actual_end.block_index - end_position.block_index)
|
"""
|
||||||
if distance < best_distance:
|
Yield positions to replay from, nearest first.
|
||||||
best_distance = distance
|
|
||||||
best_page = page
|
|
||||||
best_start = estimated_start.copy()
|
|
||||||
|
|
||||||
# Adjust estimate for next iteration
|
Block starts are used as anchors because they are the coarsest positions
|
||||||
estimated_start = self._adjust_start_estimate(
|
that are certainly valid to lay out from. The block containing the target
|
||||||
estimated_start, end_position, actual_end)
|
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
|
||||||
|
|
||||||
# Safety: don't go before document start
|
for offset in range(self.MAX_BACKWARD_ANCHORS):
|
||||||
if estimated_start.block_index < 0:
|
block_index = first_block - offset
|
||||||
estimated_start.block_index = 0
|
if block_index < 0:
|
||||||
estimated_start.word_index = 0
|
break
|
||||||
|
yield RenderingPosition(
|
||||||
# If we exhausted iterations, return best result found
|
chapter_index=target.chapter_index,
|
||||||
# BUT: ensure we didn't return the same position (no backward progress)
|
block_index=block_index,
|
||||||
final_page = best_page if best_page else page
|
word_index=0,
|
||||||
final_start = best_start
|
|
||||||
|
|
||||||
# Safety check: if final_start >= end_position, we failed to move backward
|
|
||||||
# This can happen at the beginning of the document or when estimation failed
|
|
||||||
if self._position_compare(final_start, end_position) >= 0:
|
|
||||||
# Can't go further back - check if we're at the absolute beginning
|
|
||||||
if end_position.block_index == 0 and end_position.word_index == 0:
|
|
||||||
# Already at beginning, return as-is
|
|
||||||
return final_page, final_start
|
|
||||||
|
|
||||||
# Fallback strategy: try a more aggressive backward jump
|
|
||||||
# Start from several blocks before the current position
|
|
||||||
blocks_to_jump = max(1, min(5, end_position.block_index))
|
|
||||||
fallback_pos = RenderingPosition(
|
|
||||||
chapter_index=end_position.chapter_index,
|
|
||||||
block_index=max(0, end_position.block_index - blocks_to_jump),
|
|
||||||
word_index=0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Render forward from the fallback position
|
if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
|
||||||
fallback_page, fallback_end = self.render_page_forward(fallback_pos, font_scale)
|
yield RenderingPosition()
|
||||||
|
|
||||||
# Verify the fallback actually moved us backward
|
def _replay_to(self,
|
||||||
if self._position_compare(fallback_pos, end_position) < 0:
|
anchor: RenderingPosition,
|
||||||
return fallback_page, fallback_pos
|
target: RenderingPosition,
|
||||||
|
font_scale: float):
|
||||||
|
"""
|
||||||
|
Lay out pages forward from `anchor`, looking for the one ending at `target`.
|
||||||
|
|
||||||
# If even the fallback didn't work, we're likely at the beginning
|
Returns:
|
||||||
# Return a page starting from the beginning
|
(page, start, exact). `exact` is True when a page ended precisely on
|
||||||
return self.render_page_forward(RenderingPosition(), font_scale)
|
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)
|
||||||
|
|
||||||
return final_page, final_start
|
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:
|
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
|
return block
|
||||||
|
|
||||||
# This is a simplified implementation
|
key = (id(block), font_scale)
|
||||||
# In practice, we'd need to handle each block type appropriately
|
cached = self._scaled_block_cache.get(key)
|
||||||
if isinstance(block, (Paragraph, Heading)):
|
if cached is not None:
|
||||||
scaled_block_style = FontScaler.scale_font(block.style, font_scale)
|
return cached[1]
|
||||||
if isinstance(block, Heading):
|
|
||||||
scaled_block = Heading(block.level, scaled_block_style)
|
|
||||||
else:
|
|
||||||
scaled_block = Paragraph(scaled_block_style)
|
|
||||||
|
|
||||||
# words_iter() returns tuples of (position, word)
|
scaled = self._build_scaled_block(block, font_scale)
|
||||||
for position, word in block.words_iter():
|
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):
|
if isinstance(word, Word):
|
||||||
scaled_word = Word(
|
scaled_block.add_word(word.with_style(scale(word.style)))
|
||||||
word.text, FontScaler.scale_font(
|
|
||||||
word.style, font_scale))
|
|
||||||
scaled_block.add_word(scaled_word)
|
|
||||||
return scaled_block
|
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
|
return block
|
||||||
|
|
||||||
def _layout_block_on_page(self,
|
def _layout_block_on_page(self,
|
||||||
@@ -599,60 +806,6 @@ class BidirectionalLayouter:
|
|||||||
# Keep same position so it will be attempted on the next page
|
# Keep same position so it will be attempted on the next page
|
||||||
return False, position
|
return False, position
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
return estimated_start
|
|
||||||
|
|
||||||
def _adjust_start_estimate(
|
|
||||||
self,
|
|
||||||
current_start: RenderingPosition,
|
|
||||||
target_end: RenderingPosition,
|
|
||||||
actual_end: RenderingPosition) -> RenderingPosition:
|
|
||||||
"""
|
|
||||||
Adjust start position estimate based on overshoot/undershoot.
|
|
||||||
Uses proportional adjustment to converge faster.
|
|
||||||
"""
|
|
||||||
adjusted = current_start.copy()
|
|
||||||
|
|
||||||
# Calculate the difference between actual and target end positions
|
|
||||||
block_diff = actual_end.block_index - target_end.block_index
|
|
||||||
|
|
||||||
comparison = self._position_compare(actual_end, target_end)
|
|
||||||
|
|
||||||
if comparison < 0: # Undershot - rendered to block X but need to reach block Y where X < Y
|
|
||||||
# We didn't render far enough forward
|
|
||||||
# Need to start at a LATER block (higher index) so the page includes more content
|
|
||||||
adjustment = max(1, abs(block_diff) // 2)
|
|
||||||
new_index = adjusted.block_index + adjustment
|
|
||||||
# Clamp to valid range
|
|
||||||
if len(self.blocks) > 0:
|
|
||||||
adjusted.block_index = min(len(self.blocks) - 1, max(0, new_index))
|
|
||||||
else:
|
|
||||||
adjusted.block_index = max(0, new_index)
|
|
||||||
elif comparison > 0: # Overshot - rendered past the target
|
|
||||||
# We rendered too far forward
|
|
||||||
# Need to start at an EARLIER block (lower index) so the page doesn't go as far
|
|
||||||
adjustment = max(1, abs(block_diff) // 2)
|
|
||||||
adjusted.block_index = max(0, adjusted.block_index - adjustment)
|
|
||||||
|
|
||||||
# Reset word index when adjusting blocks
|
|
||||||
adjusted.word_index = 0
|
|
||||||
|
|
||||||
return adjusted
|
|
||||||
|
|
||||||
def _position_compare(self, pos1: RenderingPosition,
|
def _position_compare(self, pos1: RenderingPosition,
|
||||||
pos2: RenderingPosition) -> int:
|
pos2: RenderingPosition) -> int:
|
||||||
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
||||||
@@ -663,27 +816,3 @@ class BidirectionalLayouter:
|
|||||||
if pos1.word_index != pos2.word_index:
|
if pos1.word_index != pos2.word_index:
|
||||||
return 1 if pos1.word_index > pos2.word_index else -1
|
return 1 if pos1.word_index > pos2.word_index else -1
|
||||||
return 0
|
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,8 +8,7 @@ into a unified, easy-to-use API.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||||
import json
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
||||||
from .page_buffer import BufferedPageRenderer
|
from .page_buffer import BufferedPageRenderer
|
||||||
@@ -17,7 +16,15 @@ from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType
|
|||||||
from pyWebLayout.concrete.page import Page
|
from pyWebLayout.concrete.page import Page
|
||||||
from pyWebLayout.concrete.image import RenderableImage
|
from pyWebLayout.concrete.image import RenderableImage
|
||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
from pyWebLayout.style.fonts import BundledFont
|
||||||
from pyWebLayout.layout.document_layouter import image_layouter
|
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:
|
class BookmarkManager:
|
||||||
@@ -34,8 +41,7 @@ class BookmarkManager:
|
|||||||
bookmarks_dir: Directory to store bookmark files
|
bookmarks_dir: Directory to store bookmark files
|
||||||
"""
|
"""
|
||||||
self.document_id = document_id
|
self.document_id = document_id
|
||||||
self.bookmarks_dir = Path(bookmarks_dir)
|
self.bookmarks_dir = ensure_dir(bookmarks_dir)
|
||||||
self.bookmarks_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
|
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
|
||||||
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
|
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
|
||||||
@@ -45,29 +51,23 @@ class BookmarkManager:
|
|||||||
|
|
||||||
def _load_bookmarks(self):
|
def _load_bookmarks(self):
|
||||||
"""Load bookmarks from file"""
|
"""Load bookmarks from file"""
|
||||||
if self.bookmarks_file.exists():
|
data = read_json(self.bookmarks_file, {})
|
||||||
try:
|
try:
|
||||||
with open(self.bookmarks_file, 'r') as f:
|
self._bookmarks = {
|
||||||
data = json.load(f)
|
name: RenderingPosition.from_dict(pos_data)
|
||||||
self._bookmarks = {
|
for name, pos_data in data.items()
|
||||||
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",
|
||||||
except Exception as e:
|
self.bookmarks_file, exc_info=True)
|
||||||
print(f"Failed to load bookmarks: {e}")
|
self._bookmarks = {}
|
||||||
self._bookmarks = {}
|
|
||||||
|
|
||||||
def _save_bookmarks(self):
|
def _save_bookmarks(self):
|
||||||
"""Save bookmarks to file"""
|
"""Save bookmarks to file"""
|
||||||
try:
|
write_json(self.bookmarks_file, {
|
||||||
data = {
|
name: position.to_dict()
|
||||||
name: position.to_dict()
|
for name, position in self._bookmarks.items()
|
||||||
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}")
|
|
||||||
|
|
||||||
def add_bookmark(self, name: str, position: RenderingPosition):
|
def add_bookmark(self, name: str, position: RenderingPosition):
|
||||||
"""
|
"""
|
||||||
@@ -124,11 +124,7 @@ class BookmarkManager:
|
|||||||
Args:
|
Args:
|
||||||
position: Current reading position
|
position: Current reading position
|
||||||
"""
|
"""
|
||||||
try:
|
write_json(self.position_file, position.to_dict())
|
||||||
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}")
|
|
||||||
|
|
||||||
def load_reading_position(self) -> Optional[RenderingPosition]:
|
def load_reading_position(self) -> Optional[RenderingPosition]:
|
||||||
"""
|
"""
|
||||||
@@ -137,14 +133,15 @@ class BookmarkManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Last reading position or None if not found
|
Last reading position or None if not found
|
||||||
"""
|
"""
|
||||||
if self.position_file.exists():
|
data = read_json(self.position_file, None)
|
||||||
try:
|
if data is None:
|
||||||
with open(self.position_file, 'r') as f:
|
return None
|
||||||
data = json.load(f)
|
try:
|
||||||
return RenderingPosition.from_dict(data)
|
return RenderingPosition.from_dict(data)
|
||||||
except Exception as e:
|
except (TypeError, KeyError):
|
||||||
print(f"Failed to load reading position: {e}")
|
logger.warning("Position file %s is not in the expected shape; ignoring it",
|
||||||
return None
|
self.position_file, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class EreaderLayoutManager:
|
class EreaderLayoutManager:
|
||||||
@@ -154,6 +151,7 @@ class EreaderLayoutManager:
|
|||||||
Features:
|
Features:
|
||||||
- Sub-second page rendering with intelligent buffering
|
- Sub-second page rendering with intelligent buffering
|
||||||
- Font scaling support
|
- Font scaling support
|
||||||
|
- Dynamic font family switching (Sans, Serif, Monospace)
|
||||||
- Chapter navigation
|
- Chapter navigation
|
||||||
- Bookmark management
|
- Bookmark management
|
||||||
- Position persistence
|
- Position persistence
|
||||||
@@ -166,7 +164,8 @@ class EreaderLayoutManager:
|
|||||||
document_id: str = "default",
|
document_id: str = "default",
|
||||||
buffer_size: int = 5,
|
buffer_size: int = 5,
|
||||||
page_style: Optional[PageStyle] = None,
|
page_style: Optional[PageStyle] = None,
|
||||||
bookmarks_dir: str = "bookmarks"):
|
bookmarks_dir: str = "bookmarks",
|
||||||
|
highlights_dir: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the ereader layout manager.
|
Initialize the ereader layout manager.
|
||||||
|
|
||||||
@@ -177,6 +176,8 @@ class EreaderLayoutManager:
|
|||||||
buffer_size: Number of pages to cache in each direction
|
buffer_size: Number of pages to cache in each direction
|
||||||
page_style: Custom page styling (uses default if None)
|
page_style: Custom page styling (uses default if None)
|
||||||
bookmarks_dir: Directory to store bookmark files
|
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.blocks = blocks
|
||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
@@ -191,6 +192,8 @@ class EreaderLayoutManager:
|
|||||||
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
|
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
|
||||||
self.chapter_navigator = ChapterNavigator(blocks)
|
self.chapter_navigator = ChapterNavigator(blocks)
|
||||||
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
|
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
|
# Current state
|
||||||
self.current_position = RenderingPosition()
|
self.current_position = RenderingPosition()
|
||||||
@@ -211,12 +214,77 @@ class EreaderLayoutManager:
|
|||||||
self.current_position = saved_position
|
self.current_position = saved_position
|
||||||
self._on_cover_page = False # If we have a saved position, we're past the cover
|
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
|
# Callbacks for UI updates
|
||||||
self.position_changed_callback: Optional[Callable[[
|
self.position_changed_callback: Optional[Callable[[
|
||||||
RenderingPosition], None]] = None
|
RenderingPosition], None]] = None
|
||||||
self.chapter_changed_callback: Optional[Callable[[
|
self.chapter_changed_callback: Optional[Callable[[
|
||||||
Optional[ChapterInfo]], None]] = None
|
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(
|
def set_position_changed_callback(
|
||||||
self, callback: Callable[[RenderingPosition], None]):
|
self, callback: Callable[[RenderingPosition], None]):
|
||||||
"""Set callback for position changes"""
|
"""Set callback for position changes"""
|
||||||
@@ -354,6 +422,21 @@ class EreaderLayoutManager:
|
|||||||
self._notify_position_changed()
|
self._notify_position_changed()
|
||||||
return self.get_current_page()
|
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
|
return None # At end of document
|
||||||
|
|
||||||
def previous_page(self) -> Optional[Page]:
|
def previous_page(self) -> Optional[Page]:
|
||||||
@@ -370,6 +453,12 @@ class EreaderLayoutManager:
|
|||||||
# Special case: if at the beginning of content and there's a cover, go back to it
|
# 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:
|
if self._has_cover and self._is_at_beginning() and not self._on_cover_page:
|
||||||
self._on_cover_page = True
|
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()
|
self._notify_position_changed()
|
||||||
return self.get_current_page()
|
return self.get_current_page()
|
||||||
|
|
||||||
@@ -550,6 +639,43 @@ class EreaderLayoutManager:
|
|||||||
"""Get the current font scale"""
|
"""Get the current font scale"""
|
||||||
return self.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:
|
def increase_line_spacing(self, amount: int = 2) -> Page:
|
||||||
"""
|
"""
|
||||||
Increase line spacing and re-render current page.
|
Increase line spacing and re-render current page.
|
||||||
@@ -730,6 +856,165 @@ class EreaderLayoutManager:
|
|||||||
"""
|
"""
|
||||||
return self.bookmark_manager.list_bookmarks()
|
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:
|
def get_reading_progress(self) -> float:
|
||||||
"""
|
"""
|
||||||
Get reading progress as a percentage.
|
Get reading progress as a percentage.
|
||||||
@@ -787,6 +1072,7 @@ class EreaderLayoutManager:
|
|||||||
Dictionary with position details
|
Dictionary with position details
|
||||||
"""
|
"""
|
||||||
current_chapter = self.get_current_chapter()
|
current_chapter = self.get_current_chapter()
|
||||||
|
font_family = self.get_font_family()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'position': self.current_position.to_dict(),
|
'position': self.current_position.to_dict(),
|
||||||
@@ -799,6 +1085,7 @@ class EreaderLayoutManager:
|
|||||||
},
|
},
|
||||||
'progress': self.get_reading_progress(),
|
'progress': self.get_reading_progress(),
|
||||||
'font_scale': self.font_scale,
|
'font_scale': self.font_scale,
|
||||||
|
'font_family': font_family.value if font_family else None,
|
||||||
'page_size': self.page_size
|
'page_size': self.page_size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,16 +1102,31 @@ class EreaderLayoutManager:
|
|||||||
"""
|
"""
|
||||||
Shutdown the ereader manager and clean up resources.
|
Shutdown the ereader manager and clean up resources.
|
||||||
Call this when the application is closing.
|
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
|
# Save current position
|
||||||
self.bookmark_manager.save_reading_position(self.current_position)
|
self.bookmark_manager.save_reading_position(self.current_position)
|
||||||
|
|
||||||
# Shutdown renderer and buffer
|
# Release cached pages
|
||||||
self.renderer.shutdown()
|
self.renderer.shutdown()
|
||||||
|
|
||||||
def __del__(self):
|
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
|
# 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
|
`PageBuffer` is an LRU cache of rendered pages plus the position links between
|
||||||
multiprocessing to achieve sub-second page navigation performance.
|
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 __future__ import annotations
|
||||||
from typing import Dict, Optional, List, Tuple, Any
|
from typing import Dict, Optional, List, Tuple, Any
|
||||||
from collections import OrderedDict
|
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.concrete.page import Page
|
||||||
from pyWebLayout.abstract.block import Block
|
from pyWebLayout.abstract.block import Block
|
||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
from pyWebLayout.style.fonts import BundledFont
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class PageBuffer:
|
class PageBuffer:
|
||||||
"""
|
"""
|
||||||
Intelligent page caching system with LRU eviction and background rendering.
|
LRU cache of rendered pages, with separate forward and backward buffers and
|
||||||
Maintains separate forward and backward buffers for optimal navigation performance.
|
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.
|
Initialize the page buffer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
buffer_size: Number of pages to cache in each direction
|
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.buffer_size = buffer_size
|
||||||
self.max_workers = max_workers
|
|
||||||
|
|
||||||
# LRU caches for forward and backward pages
|
# LRU caches for forward and backward pages
|
||||||
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||||
@@ -76,21 +63,18 @@ class PageBuffer:
|
|||||||
self.reverse_position_map: Dict[RenderingPosition,
|
self.reverse_position_map: Dict[RenderingPosition,
|
||||||
RenderingPosition] = {} # current -> previous
|
RenderingPosition] = {} # current -> previous
|
||||||
|
|
||||||
# Background rendering
|
|
||||||
self.executor: Optional[ProcessPoolExecutor] = None
|
|
||||||
self.pending_renders: Dict[RenderingPosition, Future] = {}
|
|
||||||
self.render_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Document state
|
# Document state
|
||||||
self.blocks: Optional[List[Block]] = None
|
self.blocks: Optional[List[Block]] = None
|
||||||
self.page_style: Optional[PageStyle] = None
|
self.page_style: Optional[PageStyle] = None
|
||||||
self.current_font_scale: float = 1.0
|
self.current_font_scale: float = 1.0
|
||||||
|
self.current_font_family: Optional[BundledFont] = None
|
||||||
|
|
||||||
def initialize(
|
def initialize(
|
||||||
self,
|
self,
|
||||||
blocks: List[Block],
|
blocks: List[Block],
|
||||||
page_style: PageStyle,
|
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.
|
Initialize the buffer with document blocks and page style.
|
||||||
|
|
||||||
@@ -98,14 +82,12 @@ class PageBuffer:
|
|||||||
blocks: Document blocks to render
|
blocks: Document blocks to render
|
||||||
page_style: Page styling configuration
|
page_style: Page styling configuration
|
||||||
font_scale: Current font scaling factor
|
font_scale: Current font scaling factor
|
||||||
|
font_family: Optional font family override
|
||||||
"""
|
"""
|
||||||
self.blocks = blocks
|
self.blocks = blocks
|
||||||
self.page_style = page_style
|
self.page_style = page_style
|
||||||
self.current_font_scale = font_scale
|
self.current_font_scale = font_scale
|
||||||
|
self.current_font_family = font_family
|
||||||
# Start the process pool
|
|
||||||
if self.executor is None:
|
|
||||||
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
|
|
||||||
|
|
||||||
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
||||||
"""
|
"""
|
||||||
@@ -167,123 +149,12 @@ class PageBuffer:
|
|||||||
self.position_map.pop(oldest_pos, None)
|
self.position_map.pop(oldest_pos, None)
|
||||||
self.reverse_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):
|
def invalidate_all(self):
|
||||||
"""Clear all cached pages and cancel pending renders"""
|
"""Clear all cached pages"""
|
||||||
with self.render_lock:
|
self.forward_buffer.clear()
|
||||||
# Cancel pending renders
|
self.backward_buffer.clear()
|
||||||
for future in self.pending_renders.values():
|
self.position_map.clear()
|
||||||
future.cancel()
|
self.reverse_position_map.clear()
|
||||||
self.pending_renders.clear()
|
|
||||||
|
|
||||||
# Clear caches
|
|
||||||
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):
|
def set_font_scale(self, font_scale: float):
|
||||||
"""
|
"""
|
||||||
@@ -296,40 +167,43 @@ class PageBuffer:
|
|||||||
self.current_font_scale = font_scale
|
self.current_font_scale = font_scale
|
||||||
self.invalidate_all()
|
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]:
|
def get_cache_stats(self) -> Dict[str, Any]:
|
||||||
"""Get cache statistics for debugging/monitoring"""
|
"""Get cache statistics for debugging/monitoring"""
|
||||||
return {
|
return {
|
||||||
'forward_buffer_size': len(self.forward_buffer),
|
'forward_buffer_size': len(self.forward_buffer),
|
||||||
'backward_buffer_size': len(self.backward_buffer),
|
'backward_buffer_size': len(self.backward_buffer),
|
||||||
'pending_renders': len(self.pending_renders),
|
|
||||||
'position_mappings': len(self.position_map),
|
'position_mappings': len(self.position_map),
|
||||||
'reverse_position_mappings': len(self.reverse_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):
|
def shutdown(self):
|
||||||
"""Shutdown the page buffer and clean up resources"""
|
"""
|
||||||
if self.executor:
|
Release cached pages.
|
||||||
# Cancel pending renders
|
|
||||||
with self.render_lock:
|
|
||||||
for future in self.pending_renders.values():
|
|
||||||
future.cancel()
|
|
||||||
|
|
||||||
# Shutdown executor
|
Cheap and idempotent. There is deliberately no __del__ calling this:
|
||||||
self.executor.shutdown(wait=True)
|
blocking work in a finaliser is what deadlocked the interpreter at exit
|
||||||
self.executor = None
|
while the process pool existed.
|
||||||
|
"""
|
||||||
# Clear all caches
|
|
||||||
self.invalidate_all()
|
self.invalidate_all()
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
"""Cleanup on destruction"""
|
|
||||||
self.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
class BufferedPageRenderer:
|
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,
|
def __init__(self,
|
||||||
@@ -338,7 +212,8 @@ class BufferedPageRenderer:
|
|||||||
buffer_size: int = 5,
|
buffer_size: int = 5,
|
||||||
page_size: Tuple[int,
|
page_size: Tuple[int,
|
||||||
int] = (800,
|
int] = (800,
|
||||||
600)):
|
600),
|
||||||
|
font_family: Optional[BundledFont] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the buffered renderer.
|
Initialize the buffered renderer.
|
||||||
|
|
||||||
@@ -347,18 +222,26 @@ class BufferedPageRenderer:
|
|||||||
page_style: Page styling configuration
|
page_style: Page styling configuration
|
||||||
buffer_size: Number of pages to cache in each direction
|
buffer_size: Number of pages to cache in each direction
|
||||||
page_size: Page size (width, height) in pixels
|
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 = 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.current_position = RenderingPosition()
|
||||||
self.font_scale = 1.0
|
self.font_scale = 1.0
|
||||||
|
self.font_family = font_family
|
||||||
|
|
||||||
def render_page(self, position: RenderingPosition,
|
def render_page(self, position: RenderingPosition,
|
||||||
font_scale: float = 1.0) -> Tuple[Page, 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:
|
Args:
|
||||||
position: Position to render from
|
position: Position to render from
|
||||||
@@ -375,32 +258,18 @@ class BufferedPageRenderer:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
cached_page = self.buffer.get_page(position)
|
cached_page = self.buffer.get_page(position)
|
||||||
if cached_page:
|
if cached_page:
|
||||||
# Get next position from position map
|
# 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)
|
next_pos = self.buffer.position_map.get(position)
|
||||||
|
|
||||||
# Only use cache if we have the forward position mapping
|
|
||||||
# Otherwise, we need to compute it
|
|
||||||
if next_pos is not None:
|
if next_pos is not None:
|
||||||
# Start background rendering for upcoming pages
|
|
||||||
self.buffer.start_background_rendering(position, 'forward')
|
|
||||||
|
|
||||||
return cached_page, next_pos
|
return cached_page, next_pos
|
||||||
|
|
||||||
# Cache hit for the page, but we don't have the forward position
|
|
||||||
# Fall through to compute it below
|
|
||||||
|
|
||||||
# Render the page directly
|
# Render the page directly
|
||||||
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
self.buffer.cache_page(position, page, next_pos)
|
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
|
return page, next_pos
|
||||||
|
|
||||||
def render_page_backward(self,
|
def render_page_backward(self,
|
||||||
@@ -408,7 +277,8 @@ class BufferedPageRenderer:
|
|||||||
font_scale: float = 1.0) -> Tuple[Page,
|
font_scale: float = 1.0) -> Tuple[Page,
|
||||||
RenderingPosition]:
|
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:
|
Args:
|
||||||
end_position: Position where page should end
|
end_position: Position where page should end
|
||||||
@@ -425,38 +295,50 @@ class BufferedPageRenderer:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
cached_page = self.buffer.get_page(end_position)
|
cached_page = self.buffer.get_page(end_position)
|
||||||
if cached_page:
|
if cached_page:
|
||||||
# Get previous position from reverse position map
|
# 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)
|
prev_pos = self.buffer.reverse_position_map.get(end_position)
|
||||||
|
|
||||||
# Only use cache if we have the reverse position mapping
|
|
||||||
# Otherwise, we need to compute it
|
|
||||||
if prev_pos is not None:
|
if prev_pos is not None:
|
||||||
# Start background rendering for previous pages
|
|
||||||
self.buffer.start_background_rendering(end_position, 'backward')
|
|
||||||
|
|
||||||
return cached_page, prev_pos
|
return cached_page, prev_pos
|
||||||
|
|
||||||
# Cache hit for the page, but we don't have the reverse position
|
|
||||||
# Fall through to compute it below
|
|
||||||
|
|
||||||
# Render the page directly
|
# Render the page directly
|
||||||
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
|
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
|
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]:
|
def get_cache_stats(self) -> Dict[str, Any]:
|
||||||
"""Get cache statistics"""
|
"""Get cache statistics"""
|
||||||
return self.buffer.get_cache_stats()
|
return self.buffer.get_cache_stats()
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""Shutdown the renderer and clean up resources"""
|
"""Release cached pages"""
|
||||||
self.buffer.shutdown()
|
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
|
||||||
@@ -81,7 +81,8 @@ class AbstractStyle:
|
|||||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||||
|
|
||||||
# Text properties
|
# 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.
|
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||||
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
||||||
word_spacing: Optional[Union[str, float]] = None
|
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,
|
Since this is a frozen dataclass, it should be hashable by default,
|
||||||
but we provide a custom implementation to ensure all fields are
|
but we provide a custom implementation to ensure all fields are
|
||||||
properly considered and to handle the Union types correctly.
|
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
|
# Convert all values to hashable forms
|
||||||
hashable_values = (
|
hashable_values = (
|
||||||
self.font_family,
|
self.font_family,
|
||||||
@@ -131,7 +142,9 @@ class AbstractStyle:
|
|||||||
self.parent_style_id
|
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':
|
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ class ConcreteStyle:
|
|||||||
decoration: TextDecoration = TextDecoration.NONE
|
decoration: TextDecoration = TextDecoration.NONE
|
||||||
|
|
||||||
# Layout properties
|
# 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
|
line_height: float = 1.0 # Multiplier
|
||||||
letter_spacing: float = 0.0 # In pixels
|
letter_spacing: float = 0.0 # In pixels
|
||||||
word_spacing: float = 0.0 # In pixels
|
word_spacing: float = 0.0 # In pixels
|
||||||
|
|||||||
@@ -359,62 +359,48 @@ class Font:
|
|||||||
"""Get the minimum width required for hyphenation to be considered"""
|
"""Get the minimum width required for hyphenation to be considered"""
|
||||||
return self._min_hyphenation_width
|
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):
|
def with_size(self, size: int):
|
||||||
"""Create a new Font object with modified size"""
|
"""Create a new Font object with modified size"""
|
||||||
return Font(
|
return self._with_modified(font_size=size)
|
||||||
self._font_path,
|
|
||||||
size,
|
|
||||||
self._colour,
|
|
||||||
self._weight,
|
|
||||||
self._style,
|
|
||||||
self._decoration,
|
|
||||||
self._background
|
|
||||||
)
|
|
||||||
|
|
||||||
def with_colour(self, colour: Tuple[int, int, int]):
|
def with_colour(self, colour: Tuple[int, int, int]):
|
||||||
"""Create a new Font object with modified colour"""
|
"""Create a new Font object with modified colour"""
|
||||||
return Font(
|
return self._with_modified(colour=colour)
|
||||||
self._font_path,
|
|
||||||
self._font_size,
|
|
||||||
colour,
|
|
||||||
self._weight,
|
|
||||||
self._style,
|
|
||||||
self._decoration,
|
|
||||||
self._background
|
|
||||||
)
|
|
||||||
|
|
||||||
def with_weight(self, weight: FontWeight):
|
def with_weight(self, weight: FontWeight):
|
||||||
"""Create a new Font object with modified weight"""
|
"""Create a new Font object with modified weight"""
|
||||||
return Font(
|
return self._with_modified(weight=weight)
|
||||||
self._font_path,
|
|
||||||
self._font_size,
|
|
||||||
self._colour,
|
|
||||||
weight,
|
|
||||||
self._style,
|
|
||||||
self._decoration,
|
|
||||||
self._background
|
|
||||||
)
|
|
||||||
|
|
||||||
def with_style(self, style: FontStyle):
|
def with_style(self, style: FontStyle):
|
||||||
"""Create a new Font object with modified style"""
|
"""Create a new Font object with modified style"""
|
||||||
return Font(
|
return self._with_modified(style=style)
|
||||||
self._font_path,
|
|
||||||
self._font_size,
|
|
||||||
self._colour,
|
|
||||||
self._weight,
|
|
||||||
style,
|
|
||||||
self._decoration,
|
|
||||||
self._background
|
|
||||||
)
|
|
||||||
|
|
||||||
def with_decoration(self, decoration: TextDecoration):
|
def with_decoration(self, decoration: TextDecoration):
|
||||||
"""Create a new Font object with modified decoration"""
|
"""Create a new Font object with modified decoration"""
|
||||||
return Font(
|
return self._with_modified(decoration=decoration)
|
||||||
self._font_path,
|
|
||||||
self._font_size,
|
|
||||||
self._colour,
|
|
||||||
self._weight,
|
|
||||||
self._style,
|
|
||||||
decoration,
|
|
||||||
self._background
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from pyWebLayout.style.alignment import Alignment
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -8,6 +10,10 @@ class PageStyle:
|
|||||||
Defines the styling properties for a page including borders, spacing, and layout.
|
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 properties
|
||||||
border_width: int = 0
|
border_width: int = 0
|
||||||
border_color: Tuple[int, int, int] = (0, 0, 0)
|
border_color: Tuple[int, int, int] = (0, 0, 0)
|
||||||
|
|||||||
@@ -4,24 +4,56 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyWebLayout"
|
name = "pyWebLayout"
|
||||||
|
version = "0.1.1"
|
||||||
description = "A Python library for HTML-like layout and rendering"
|
description = "A Python library for HTML-like layout and rendering"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.6"
|
requires-python = ">=3.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
|
{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 = [
|
dependencies = [
|
||||||
"Pillow",
|
"Pillow",
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyphen",
|
"pyphen",
|
||||||
"beautifulsoup4",
|
"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]
|
[tool.coverage.run]
|
||||||
source = ["pyWebLayout"]
|
source = ["pyWebLayout"]
|
||||||
branch = true
|
branch = true
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
[metadata]
|
# Packaging metadata lives in pyproject.toml ([project]), which takes
|
||||||
name = pyWebLayout
|
# precedence over anything declared here. This file keeps only tool config
|
||||||
version = 0.1.1
|
# that has nowhere better to live.
|
||||||
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*
|
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
exclude =
|
exclude =
|
||||||
|
|||||||
@@ -1,32 +1,10 @@
|
|||||||
from setuptools import setup, find_packages
|
"""Shim for legacy `python setup.py` invocations.
|
||||||
|
|
||||||
setup(
|
All packaging metadata lives in setup.cfg. Keeping a second copy here was an
|
||||||
name="pyWebLayout",
|
active hazard: keyword arguments passed to setup() override setup.cfg, so the
|
||||||
version="0.1.1",
|
two could disagree silently and the setup.py copy would win.
|
||||||
packages=find_packages(),
|
"""
|
||||||
install_requires=[
|
|
||||||
"Pillow",
|
from setuptools import setup
|
||||||
"numpy",
|
|
||||||
],
|
setup()
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -307,6 +307,8 @@ class TestImagePIL(unittest.TestCase):
|
|||||||
|
|
||||||
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
|
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
|
||||||
cls.flask_server_running = False
|
cls.flask_server_running = False
|
||||||
|
cls.flask_server.shutdown()
|
||||||
|
cls.flask_server.server_close()
|
||||||
cls.flask_thread.join(timeout=2)
|
cls.flask_thread.join(timeout=2)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -350,10 +352,9 @@ class TestImagePIL(unittest.TestCase):
|
|||||||
"""Start a Flask server for URL testing."""
|
"""Start a Flask server for URL testing."""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
cls.flask_app = Flask(__name__)
|
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')
|
@cls.flask_app.route('/test.jpg')
|
||||||
def serve_test_image():
|
def serve_test_image():
|
||||||
@@ -363,15 +364,20 @@ class TestImagePIL(unittest.TestCase):
|
|||||||
def health_check():
|
def health_check():
|
||||||
return 'OK', 200
|
return 'OK', 200
|
||||||
|
|
||||||
def run_flask():
|
# Bind to an ephemeral port so concurrent/leftover test runs can't clash
|
||||||
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
|
cls.flask_server = make_server('127.0.0.1', 0, cls.flask_app, threaded=True)
|
||||||
use_reloader=False, 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()
|
cls.flask_thread.start()
|
||||||
|
|
||||||
# Wait for server to be ready with health check
|
# Wait for server to be ready with health check.
|
||||||
max_wait = 5 # Maximum 5 seconds
|
# 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
|
wait_interval = 0.1 # Check every 100ms
|
||||||
elapsed = 0
|
elapsed = 0
|
||||||
|
|
||||||
@@ -379,12 +385,15 @@ class TestImagePIL(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f'http://127.0.0.1:{cls.flask_port}/health', timeout=1) as response:
|
with urllib.request.urlopen(f'http://127.0.0.1:{cls.flask_port}/health', timeout=1) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
break
|
return
|
||||||
except (urllib.error.URLError, ConnectionRefusedError, OSError):
|
except (urllib.error.URLError, ConnectionRefusedError, OSError):
|
||||||
pass
|
pass
|
||||||
time.sleep(wait_interval)
|
time.sleep(wait_interval)
|
||||||
elapsed += 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):
|
def test_image_url_detection(self):
|
||||||
"""Test URL detection functionality."""
|
"""Test URL detection functionality."""
|
||||||
img = Image()
|
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"
|
||||||
@@ -334,8 +334,11 @@ class TestFormFieldText(unittest.TestCase):
|
|||||||
"""Test size property includes field area"""
|
"""Test size property includes field area"""
|
||||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||||
|
|
||||||
# Size should include label height + gap + field height
|
# Size should include label height + gap + field height. The label's
|
||||||
expected_height = renderable._style.font_size + 5 + renderable._field_height
|
# 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
|
expected_width = renderable._field_width # Use the calculated field width
|
||||||
|
|
||||||
np.testing.assert_array_equal(
|
np.testing.assert_array_equal(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Regression tests for inline content inside block containers (spec S1).
|
||||||
|
|
||||||
|
Inline tags are registered to ignore_handler because they are meant to be
|
||||||
|
consumed by extract_text_content. Only <p> and <h1>-<h6> ever called it, so
|
||||||
|
every other container - div, li, td, th, blockquote - iterated its children as
|
||||||
|
blocks, and inline tags returned None. Their text was silently discarded, and
|
||||||
|
bare text nodes each became a separate paragraph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.abstract.block import (
|
||||||
|
HList,
|
||||||
|
Paragraph,
|
||||||
|
Quote,
|
||||||
|
Table,
|
||||||
|
)
|
||||||
|
from pyWebLayout.abstract.inline import LinkedWord, Word
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
|
||||||
|
|
||||||
|
def words_of(block):
|
||||||
|
return [w.text for w in getattr(block, 'words', [])]
|
||||||
|
|
||||||
|
|
||||||
|
def all_words(blocks):
|
||||||
|
out = []
|
||||||
|
for block in blocks:
|
||||||
|
out.extend(words_of(block))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cell_blocks(table):
|
||||||
|
for _, row in table.all_rows():
|
||||||
|
for cell in row.cells():
|
||||||
|
yield list(cell.blocks())
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED = ["hello", "world", "again"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestInlineContentIsKept:
|
||||||
|
"""The same markup must survive in every container."""
|
||||||
|
|
||||||
|
def test_paragraph_control(self):
|
||||||
|
"""<p> already worked - this is the reference behaviour."""
|
||||||
|
blocks = parse_html_string("<p>hello <b>world</b> again</p>")
|
||||||
|
assert all_words(blocks) == EXPECTED
|
||||||
|
|
||||||
|
def test_div(self):
|
||||||
|
blocks = parse_html_string("<div>hello <b>world</b> again</div>")
|
||||||
|
assert all_words(blocks) == EXPECTED
|
||||||
|
|
||||||
|
def test_list_item(self):
|
||||||
|
blocks = parse_html_string("<ul><li>hello <b>world</b> again</li></ul>")
|
||||||
|
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||||
|
item = list(hlist.items())[0]
|
||||||
|
assert all_words(item.blocks()) == EXPECTED
|
||||||
|
|
||||||
|
def test_table_cell(self):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
"<table><tr><td>hello <b>world</b> again</td></tr></table>")
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||||
|
|
||||||
|
def test_table_header_cell(self):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
"<table><tr><th>hello <b>world</b> again</th></tr></table>")
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||||
|
|
||||||
|
def test_blockquote(self):
|
||||||
|
blocks = parse_html_string("<blockquote>hello <b>world</b> again</blockquote>")
|
||||||
|
quote = next(b for b in blocks if isinstance(b, Quote))
|
||||||
|
assert all_words(quote.blocks()) == EXPECTED
|
||||||
|
|
||||||
|
|
||||||
|
class TestInlineRunsCoalesce:
|
||||||
|
"""A run of inline content is one paragraph, not one per text node."""
|
||||||
|
|
||||||
|
def test_div_yields_a_single_paragraph(self):
|
||||||
|
blocks = parse_html_string("<div>a <b>b</b> c</div>")
|
||||||
|
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||||
|
assert len(paragraphs) == 1, f"expected one paragraph, got {len(blocks)} blocks"
|
||||||
|
assert words_of(paragraphs[0]) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_cell_yields_a_single_paragraph(self):
|
||||||
|
blocks = parse_html_string("<table><tr><td>a <b>b</b> c</td></tr></table>")
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
cell = next(cell_blocks(table))
|
||||||
|
assert len(cell) == 1
|
||||||
|
assert words_of(cell[0]) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_block_child_splits_the_run(self):
|
||||||
|
"""Inline runs either side of a block child stay separate, in order."""
|
||||||
|
blocks = parse_html_string(
|
||||||
|
"<table><tr><td>before<p>middle</p>after</td></tr></table>")
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
cell = next(cell_blocks(table))
|
||||||
|
assert [words_of(b) for b in cell] == [["before"], ["middle"], ["after"]]
|
||||||
|
|
||||||
|
def test_line_break_splits_the_run(self):
|
||||||
|
blocks = parse_html_string("<div>first<br>second</div>")
|
||||||
|
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||||
|
assert [words_of(p) for p in paragraphs] == [["first"], ["second"]]
|
||||||
|
|
||||||
|
def test_whitespace_between_blocks_makes_no_paragraph(self):
|
||||||
|
blocks = parse_html_string("<div>\n <p>one</p>\n <p>two</p>\n</div>")
|
||||||
|
assert [words_of(b) for b in blocks] == [["one"], ["two"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinksSurvive:
|
||||||
|
"""<a href> must produce LinkedWord wherever it appears."""
|
||||||
|
|
||||||
|
def test_link_in_cell(self):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
'<table><tr><td><a href="http://x">link</a> text</td></tr></table>')
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
cell = next(cell_blocks(table))
|
||||||
|
found = [w for b in cell for w in getattr(b, 'words', [])]
|
||||||
|
|
||||||
|
assert [w.text for w in found] == ["link", "text"]
|
||||||
|
linked = [w for w in found if isinstance(w, LinkedWord)]
|
||||||
|
assert len(linked) == 1
|
||||||
|
assert linked[0].location == "http://x"
|
||||||
|
|
||||||
|
def test_link_in_div(self):
|
||||||
|
blocks = parse_html_string('<div>see <a href="#s2">Section 2</a> now</div>')
|
||||||
|
found = [w for b in blocks for w in getattr(b, 'words', [])]
|
||||||
|
assert [w.text for w in found] == ["see", "Section", "2", "now"]
|
||||||
|
assert all(isinstance(w, LinkedWord) for w in found[1:3])
|
||||||
|
|
||||||
|
def test_link_in_list_item(self):
|
||||||
|
blocks = parse_html_string('<ul><li><a href="u">click</a> here</li></ul>')
|
||||||
|
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||||
|
item = hlist._items[0]
|
||||||
|
found = [w for b in item.blocks() for w in getattr(b, 'words', [])]
|
||||||
|
assert [w.text for w in found] == ["click", "here"]
|
||||||
|
assert isinstance(found[0], LinkedWord)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNestedContainers:
|
||||||
|
|
||||||
|
def test_div_in_div(self):
|
||||||
|
blocks = parse_html_string("<div>outer <div>inner</div> tail</div>")
|
||||||
|
assert [words_of(b) for b in blocks] == [["outer"], ["inner"], ["tail"]]
|
||||||
|
|
||||||
|
def test_block_children_still_pass_through(self):
|
||||||
|
blocks = parse_html_string("<div><h1>Title</h1><p>Body</p></div>")
|
||||||
|
assert len(blocks) == 2
|
||||||
|
assert words_of(blocks[0]) == ["Title"]
|
||||||
|
assert words_of(blocks[1]) == ["Body"]
|
||||||
|
|
||||||
|
def test_cell_containing_a_list(self):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
"<table><tr><td>intro<ul><li>item</li></ul></td></tr></table>")
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
cell = next(cell_blocks(table))
|
||||||
|
assert isinstance(cell[0], Paragraph)
|
||||||
|
assert words_of(cell[0]) == ["intro"]
|
||||||
|
assert isinstance(cell[1], HList)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComments:
|
||||||
|
|
||||||
|
def test_comment_text_is_not_content(self):
|
||||||
|
blocks = parse_html_string("<div>real<!-- hidden note -->text</div>")
|
||||||
|
assert all_words(blocks) == ["real", "text"]
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Regression tests for backward page navigation (spec S16).
|
||||||
|
|
||||||
|
The previous page of P is the position q for which laying out forward from q ends
|
||||||
|
exactly at P. The old implementation searched for q by guessing a block index and
|
||||||
|
bisecting, with word_index pinned to 0 - so a page starting mid-paragraph was not
|
||||||
|
in the search space at all. It exhausted its ten iterations and fell back to a
|
||||||
|
position that was not the previous page, typically the start of the document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.abstract.block import Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_SIZE = (800, 600)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def font():
|
||||||
|
return Font(font_size=16)
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(font, count, tag):
|
||||||
|
block = Paragraph(font)
|
||||||
|
for i in range(count):
|
||||||
|
block.add_word(Word(f"{tag}{i}", font))
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def long_document(font):
|
||||||
|
"""Short paragraphs around one that spans several pages."""
|
||||||
|
return [
|
||||||
|
paragraph(font, 60, "a"),
|
||||||
|
paragraph(font, 80, "b"),
|
||||||
|
paragraph(font, 1200, "long"),
|
||||||
|
paragraph(font, 70, "c"),
|
||||||
|
paragraph(font, 90, "d"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def block_document(font):
|
||||||
|
"""Many small blocks, so every page starts on a block boundary."""
|
||||||
|
return [paragraph(font, 40, f"p{i}") for i in range(40)]
|
||||||
|
|
||||||
|
|
||||||
|
def forward_chain(layouter, limit=30):
|
||||||
|
"""The page start positions a reader would visit going forward."""
|
||||||
|
starts = []
|
||||||
|
pos = RenderingPosition()
|
||||||
|
for _ in range(limit):
|
||||||
|
starts.append(pos)
|
||||||
|
_, nxt = layouter.render_page_forward(pos, 1.0)
|
||||||
|
if nxt.block_index >= len(layouter.blocks):
|
||||||
|
break
|
||||||
|
if (nxt.block_index, nxt.word_index) == (pos.block_index, pos.word_index):
|
||||||
|
pytest.fail("forward pagination made no progress")
|
||||||
|
pos = nxt
|
||||||
|
return starts
|
||||||
|
|
||||||
|
|
||||||
|
def key(position):
|
||||||
|
return (position.chapter_index, position.block_index, position.word_index)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardMatchesForward:
|
||||||
|
"""The defining invariant: forward from the answer lands exactly on P."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("document", ["long_document", "block_document"])
|
||||||
|
def test_previous_page_is_the_forward_predecessor(self, document, request):
|
||||||
|
blocks = request.getfixturevalue(document)
|
||||||
|
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
|
||||||
|
starts = forward_chain(layouter)
|
||||||
|
assert len(starts) > 2, "need a few pages to test against"
|
||||||
|
|
||||||
|
for i in range(1, len(starts)):
|
||||||
|
_, got = layouter.render_page_backward(starts[i], 1.0)
|
||||||
|
assert key(got) == key(starts[i - 1]), (
|
||||||
|
f"page {i}: expected to land on page {i - 1} "
|
||||||
|
f"{key(starts[i - 1])}, got {key(got)}")
|
||||||
|
|
||||||
|
def test_result_lays_out_to_the_target(self, long_document):
|
||||||
|
"""Independent of the recorded chain: replaying the answer must reach P."""
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
starts = forward_chain(layouter)
|
||||||
|
|
||||||
|
for target in starts[1:]:
|
||||||
|
_, start = layouter.render_page_backward(target, 1.0)
|
||||||
|
_, end = layouter.render_page_forward(start, 1.0)
|
||||||
|
assert key(end) == key(target), (
|
||||||
|
f"a page starting at {key(start)} ends at {key(end)}, "
|
||||||
|
f"not at the requested {key(target)}")
|
||||||
|
|
||||||
|
def test_mid_paragraph_targets_are_reachable(self, long_document):
|
||||||
|
"""The specific regression: starts inside a block, not on its boundary."""
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
starts = forward_chain(layouter)
|
||||||
|
mid = [s for s in starts if s.word_index > 0]
|
||||||
|
assert mid, "this document should paginate mid-paragraph"
|
||||||
|
|
||||||
|
for target in mid:
|
||||||
|
_, got = layouter.render_page_backward(target, 1.0)
|
||||||
|
assert key(got) != (0, 0, 0) or key(target) == key(starts[1]), \
|
||||||
|
"backward navigation fell back to the document start"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundTrip:
|
||||||
|
|
||||||
|
def test_forward_then_back_returns_to_the_same_place(self, long_document):
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
pos = RenderingPosition()
|
||||||
|
|
||||||
|
for _ in range(4):
|
||||||
|
_, nxt = layouter.render_page_forward(pos, 1.0)
|
||||||
|
_, back = layouter.render_page_backward(nxt, 1.0)
|
||||||
|
assert key(back) == key(pos), \
|
||||||
|
f"round trip drifted: {key(pos)} -> {key(nxt)} -> {key(back)}"
|
||||||
|
pos = nxt
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdges:
|
||||||
|
|
||||||
|
def test_at_document_start_stays_there(self, long_document):
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
_, got = layouter.render_page_backward(RenderingPosition(), 1.0)
|
||||||
|
assert key(got) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_second_page_goes_back_to_the_first(self, long_document):
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
_, second = layouter.render_page_forward(RenderingPosition(), 1.0)
|
||||||
|
_, got = layouter.render_page_backward(second, 1.0)
|
||||||
|
assert key(got) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_empty_document_is_safe(self):
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
|
||||||
|
page, got = layouter.render_page_backward(RenderingPosition(), 1.0)
|
||||||
|
assert page is not None
|
||||||
|
assert key(got) == (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCost:
|
||||||
|
|
||||||
|
def test_backward_is_not_wildly_more_expensive_than_forward(self, long_document):
|
||||||
|
"""The old path burned ten full layouts per call and still got it wrong."""
|
||||||
|
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||||
|
starts = forward_chain(layouter)
|
||||||
|
|
||||||
|
calls = {"n": 0}
|
||||||
|
original = BidirectionalLayouter.render_page_forward
|
||||||
|
|
||||||
|
def counting(self, position, font_scale=1.0):
|
||||||
|
calls["n"] += 1
|
||||||
|
return original(self, position, font_scale)
|
||||||
|
|
||||||
|
BidirectionalLayouter.render_page_forward = counting
|
||||||
|
try:
|
||||||
|
worst = 0
|
||||||
|
for target in starts[1:]:
|
||||||
|
calls["n"] = 0
|
||||||
|
layouter.render_page_backward(target, 1.0)
|
||||||
|
worst = max(worst, calls["n"])
|
||||||
|
finally:
|
||||||
|
BidirectionalLayouter.render_page_forward = original
|
||||||
|
|
||||||
|
assert worst <= 10, f"backward navigation cost {worst} forward layouts"
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Tests for the highlight API on EreaderLayoutManager (R7).
|
||||||
|
|
||||||
|
core/highlight.py was fully implemented and tested but unreachable: the manager
|
||||||
|
had no highlight API, so highlighting could not be used through the library's
|
||||||
|
own interface. These tests cover the wiring, not the dataclass - that is
|
||||||
|
tests/core/test_highlight.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.core.highlight import Highlight, HighlightColor
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(tmp_path):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
"<p>" + " ".join(f"word{i}" for i in range(300)) + "</p>")
|
||||||
|
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||||
|
document_id="highlights",
|
||||||
|
bookmarks_dir=str(tmp_path))
|
||||||
|
yield manager
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def text_points(page, limit=None):
|
||||||
|
"""Points on the rendered page that land on a text object."""
|
||||||
|
found = []
|
||||||
|
for y in range(0, 120, 2):
|
||||||
|
for x in range(0, 400, 2):
|
||||||
|
result = page.query_point((x, y))
|
||||||
|
if result is not None and result.object_type == "text" and result.text:
|
||||||
|
found.append((x, y))
|
||||||
|
if limit and len(found) >= limit:
|
||||||
|
return found
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def point_on_text(manager):
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
return text_points(page, limit=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightPoint:
|
||||||
|
def test_highlighting_a_word_returns_a_stored_highlight(self, manager, point_on_text):
|
||||||
|
highlight = manager.highlight_point(point_on_text)
|
||||||
|
|
||||||
|
assert isinstance(highlight, Highlight)
|
||||||
|
assert highlight.text
|
||||||
|
assert manager.list_highlights() == [highlight]
|
||||||
|
|
||||||
|
def test_colour_note_and_tags_are_kept(self, manager, point_on_text):
|
||||||
|
highlight = manager.highlight_point(
|
||||||
|
point_on_text, color=HighlightColor.GREEN.value,
|
||||||
|
note="a note", tags=["review"])
|
||||||
|
|
||||||
|
assert highlight.color == HighlightColor.GREEN.value
|
||||||
|
assert highlight.note == "a note"
|
||||||
|
assert highlight.tags == ["review"]
|
||||||
|
|
||||||
|
def test_highlighting_empty_space_returns_none(self, manager):
|
||||||
|
manager.get_current_page().render()
|
||||||
|
|
||||||
|
assert manager.highlight_point((399, 599)) is None
|
||||||
|
assert manager.list_highlights() == []
|
||||||
|
|
||||||
|
def test_the_originating_position_is_recorded(self, manager, point_on_text):
|
||||||
|
highlight = manager.highlight_point(point_on_text)
|
||||||
|
|
||||||
|
assert highlight.position == manager.current_position.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightRange:
|
||||||
|
def test_a_selection_spans_multiple_words(self, manager):
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
points = text_points(page)
|
||||||
|
|
||||||
|
highlight = manager.highlight_range(points[0], points[-1])
|
||||||
|
|
||||||
|
assert highlight is not None
|
||||||
|
assert len(highlight.text.split()) > 1
|
||||||
|
assert len(highlight.bounds) > 1
|
||||||
|
|
||||||
|
def test_a_selection_hitting_no_text_returns_none(self, manager):
|
||||||
|
manager.get_current_page().render()
|
||||||
|
|
||||||
|
assert manager.highlight_range((398, 596), (399, 599)) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightsAreScopedToTheirPage:
|
||||||
|
def test_current_page_highlights_do_not_leak_across_pages(self, manager, point_on_text):
|
||||||
|
manager.highlight_point(point_on_text)
|
||||||
|
assert len(manager.get_highlights_for_current_page()) == 1
|
||||||
|
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
assert manager.get_highlights_for_current_page() == []
|
||||||
|
assert len(manager.list_highlights()) == 1, "still in the document, just not here"
|
||||||
|
|
||||||
|
def test_returning_to_the_page_finds_it_again(self, manager, point_on_text):
|
||||||
|
highlight = manager.highlight_point(point_on_text)
|
||||||
|
manager.next_page()
|
||||||
|
manager.previous_page()
|
||||||
|
|
||||||
|
assert manager.get_highlights_for_current_page() == [highlight]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
def test_highlights_survive_a_restart(self, manager, point_on_text, tmp_path):
|
||||||
|
highlight = manager.highlight_point(point_on_text, note="kept")
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
reopened = EreaderLayoutManager(
|
||||||
|
manager.blocks, page_size=(400, 600), document_id="highlights",
|
||||||
|
bookmarks_dir=str(tmp_path))
|
||||||
|
try:
|
||||||
|
restored = reopened.list_highlights()
|
||||||
|
assert len(restored) == 1
|
||||||
|
assert restored[0].id == highlight.id
|
||||||
|
assert restored[0].note == "kept"
|
||||||
|
assert restored[0].position == highlight.position
|
||||||
|
finally:
|
||||||
|
reopened.shutdown()
|
||||||
|
|
||||||
|
def test_highlights_share_the_bookmarks_directory_by_default(self, manager,
|
||||||
|
point_on_text, tmp_path):
|
||||||
|
manager.highlight_point(point_on_text)
|
||||||
|
|
||||||
|
assert (tmp_path / "highlights_highlights.json").exists()
|
||||||
|
|
||||||
|
def test_removing_a_highlight_persists(self, manager, point_on_text, tmp_path):
|
||||||
|
highlight = manager.highlight_point(point_on_text)
|
||||||
|
|
||||||
|
assert manager.remove_highlight(highlight.id) is True
|
||||||
|
assert manager.remove_highlight(highlight.id) is False
|
||||||
|
|
||||||
|
reopened = EreaderLayoutManager(
|
||||||
|
manager.blocks, page_size=(400, 600), document_id="highlights",
|
||||||
|
bookmarks_dir=str(tmp_path))
|
||||||
|
try:
|
||||||
|
assert reopened.list_highlights() == []
|
||||||
|
finally:
|
||||||
|
reopened.shutdown()
|
||||||
|
|
||||||
|
def test_clear_removes_everything(self, manager, point_on_text):
|
||||||
|
manager.highlight_point(point_on_text)
|
||||||
|
|
||||||
|
manager.clear_highlights()
|
||||||
|
|
||||||
|
assert manager.list_highlights() == []
|
||||||
|
|
||||||
|
def test_a_corrupt_store_does_not_stop_the_book_opening(self, tmp_path):
|
||||||
|
(tmp_path / "broken_highlights.json").write_text("{not json")
|
||||||
|
blocks = parse_html_string("<p>hello world</p>")
|
||||||
|
|
||||||
|
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||||
|
document_id="broken",
|
||||||
|
bookmarks_dir=str(tmp_path))
|
||||||
|
try:
|
||||||
|
assert manager.list_highlights() == []
|
||||||
|
assert manager.get_current_page() is not None
|
||||||
|
finally:
|
||||||
|
manager.shutdown()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Tests for pointer interaction on EreaderLayoutManager (R7).
|
||||||
|
|
||||||
|
concrete/interaction_handler.py was 310 lines reachable only from
|
||||||
|
examples/07_pressed_state_demo.py - no library code, no tests. These cover the
|
||||||
|
wiring; the press/hover state on the elements themselves lives in
|
||||||
|
tests/concrete/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(tmp_path):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
'<p>Tap <a href="action:go">this link</a> please.</p>'
|
||||||
|
'<p>' + " ".join(f"w{i}" for i in range(400)) + '</p>')
|
||||||
|
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||||
|
document_id="interaction",
|
||||||
|
bookmarks_dir=str(tmp_path))
|
||||||
|
yield manager
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def link_point(manager):
|
||||||
|
"""A page coordinate that lands on the interactive link."""
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
for y in range(0, 120, 2):
|
||||||
|
for x in range(0, 400, 2):
|
||||||
|
result = page.query_point((x, y))
|
||||||
|
if result is not None and result.is_interactive:
|
||||||
|
return (x, y)
|
||||||
|
pytest.fail("fixture document rendered no interactive element")
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_POINT = (399, 599)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHover:
|
||||||
|
def test_hovering_an_element_produces_a_frame(self, manager, link_point):
|
||||||
|
assert isinstance(manager.handle_hover(link_point), Image.Image)
|
||||||
|
|
||||||
|
def test_hovering_the_same_element_again_reports_no_change(self, manager, link_point):
|
||||||
|
manager.handle_hover(link_point)
|
||||||
|
|
||||||
|
assert manager.handle_hover(link_point) is None, \
|
||||||
|
"an unchanged hover should not force the caller to redraw"
|
||||||
|
|
||||||
|
def test_moving_off_the_element_clears_the_hover(self, manager, link_point):
|
||||||
|
manager.handle_hover(link_point)
|
||||||
|
|
||||||
|
assert isinstance(manager.handle_hover(EMPTY_POINT), Image.Image)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPress:
|
||||||
|
def test_pressing_an_element_produces_a_frame(self, manager, link_point):
|
||||||
|
assert isinstance(manager.handle_touch_down(link_point), Image.Image)
|
||||||
|
|
||||||
|
def test_pressing_empty_space_does_nothing(self, manager):
|
||||||
|
manager.get_current_page().render()
|
||||||
|
|
||||||
|
assert manager.handle_touch_down(EMPTY_POINT) is None
|
||||||
|
|
||||||
|
def test_release_runs_the_link_action(self, manager, link_point):
|
||||||
|
manager.handle_touch_down(link_point)
|
||||||
|
|
||||||
|
frame, result = manager.handle_touch_up(link_point)
|
||||||
|
|
||||||
|
assert isinstance(frame, Image.Image)
|
||||||
|
assert result == "action:go"
|
||||||
|
|
||||||
|
def test_release_without_a_press_is_a_no_op(self, manager):
|
||||||
|
manager.get_current_page().render()
|
||||||
|
|
||||||
|
assert manager.handle_touch_up(EMPTY_POINT) == (None, None)
|
||||||
|
|
||||||
|
def test_a_full_press_release_cycle_leaves_no_state(self, manager, link_point):
|
||||||
|
manager.handle_touch_down(link_point)
|
||||||
|
manager.handle_touch_up(link_point)
|
||||||
|
|
||||||
|
assert manager.handle_touch_up(link_point) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateFollowsTheDisplayedPage:
|
||||||
|
def test_navigating_rebinds_the_state_machine(self, manager, link_point):
|
||||||
|
before = manager._interaction_state()
|
||||||
|
|
||||||
|
manager.next_page()
|
||||||
|
|
||||||
|
assert manager._interaction_state() is not before, \
|
||||||
|
"press state belongs to one rendered page"
|
||||||
|
|
||||||
|
def test_state_survives_repeated_access_on_one_page(self, manager, link_point):
|
||||||
|
assert manager._interaction_state() is manager._interaction_state()
|
||||||
|
|
||||||
|
def test_reset_is_safe_before_any_interaction(self, manager):
|
||||||
|
manager.reset_interaction_state() # must not raise
|
||||||
|
|
||||||
|
def test_reset_clears_a_pending_press(self, manager, link_point):
|
||||||
|
manager.handle_touch_down(link_point)
|
||||||
|
|
||||||
|
manager.reset_interaction_state()
|
||||||
|
|
||||||
|
assert manager.handle_touch_up(link_point) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPressedRenderingRegression:
|
||||||
|
"""
|
||||||
|
LinkText.render passed [origin, origin + size] - two numpy arrays - to
|
||||||
|
PIL's draw.rectangle, which needs a flat four-scalar box. Rendering any
|
||||||
|
hovered or pressed link raised TypeError. Nothing caught it because the
|
||||||
|
only caller was an example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_rendering_a_hovered_link_does_not_raise(self, manager, link_point):
|
||||||
|
manager.handle_hover(link_point)
|
||||||
|
|
||||||
|
assert isinstance(manager.get_current_page().render(), Image.Image)
|
||||||
|
|
||||||
|
def test_rendering_a_pressed_link_does_not_raise(self, manager, link_point):
|
||||||
|
manager.handle_touch_down(link_point)
|
||||||
|
|
||||||
|
assert isinstance(manager.get_current_page().render(), Image.Image)
|
||||||
@@ -570,30 +570,6 @@ class TestBidirectionalLayouter:
|
|||||||
# Should return same block
|
# Should return same block
|
||||||
assert scaled == paragraph
|
assert scaled == paragraph
|
||||||
|
|
||||||
def test_estimate_page_start(self):
|
|
||||||
"""Test estimation of page start position."""
|
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
|
||||||
|
|
||||||
end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0)
|
|
||||||
|
|
||||||
estimated = layouter._estimate_page_start(end_pos, 1.0)
|
|
||||||
|
|
||||||
# Should estimate some blocks before the end position
|
|
||||||
assert estimated.block_index < end_pos.block_index
|
|
||||||
assert estimated.block_index >= 0
|
|
||||||
|
|
||||||
def test_estimate_page_start_with_font_scale(self):
|
|
||||||
"""Test that font scale affects page start estimation."""
|
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
|
||||||
|
|
||||||
end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0)
|
|
||||||
|
|
||||||
est_normal = layouter._estimate_page_start(end_pos, 1.0)
|
|
||||||
est_large = layouter._estimate_page_start(end_pos, 2.0)
|
|
||||||
|
|
||||||
# Larger font should estimate fewer blocks
|
|
||||||
assert est_large.block_index >= est_normal.block_index
|
|
||||||
|
|
||||||
def test_scale_block_fonts_paragraph(self, sample_font):
|
def test_scale_block_fonts_paragraph(self, sample_font):
|
||||||
"""Test scaling fonts in a paragraph block."""
|
"""Test scaling fonts in a paragraph block."""
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
@@ -784,50 +760,6 @@ class TestBidirectionalLayouter:
|
|||||||
# Start position should be before or at end position
|
# Start position should be before or at end position
|
||||||
assert start_pos.block_index <= end_position.block_index
|
assert start_pos.block_index <= end_position.block_index
|
||||||
|
|
||||||
def test_adjust_start_estimate_overshot(self):
|
|
||||||
"""Test adjustment when forward render overshoots target."""
|
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
|
||||||
|
|
||||||
current_start = RenderingPosition(block_index=5)
|
|
||||||
target_end = RenderingPosition(block_index=10)
|
|
||||||
actual_end = RenderingPosition(block_index=12) # Overshot (went too far)
|
|
||||||
|
|
||||||
adjusted = layouter._adjust_start_estimate(
|
|
||||||
current_start, target_end, actual_end)
|
|
||||||
|
|
||||||
# Overshot means we rendered too far forward
|
|
||||||
# So we need to start EARLIER (decrease block_index) to not go as far
|
|
||||||
assert adjusted.block_index < current_start.block_index
|
|
||||||
|
|
||||||
def test_adjust_start_estimate_undershot(self):
|
|
||||||
"""Test adjustment when forward render undershoots target."""
|
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
|
||||||
|
|
||||||
current_start = RenderingPosition(block_index=5)
|
|
||||||
target_end = RenderingPosition(block_index=10)
|
|
||||||
actual_end = RenderingPosition(block_index=8) # Undershot (didn't go far enough)
|
|
||||||
|
|
||||||
adjusted = layouter._adjust_start_estimate(
|
|
||||||
current_start, target_end, actual_end)
|
|
||||||
|
|
||||||
# Undershot means we didn't render far enough forward
|
|
||||||
# So we need to start LATER (increase block_index) to include more content
|
|
||||||
assert adjusted.block_index > current_start.block_index
|
|
||||||
|
|
||||||
def test_adjust_start_estimate_exact(self):
|
|
||||||
"""Test adjustment when forward render hits target exactly."""
|
|
||||||
layouter = BidirectionalLayouter([], PageStyle())
|
|
||||||
|
|
||||||
current_start = RenderingPosition(block_index=5)
|
|
||||||
target_end = RenderingPosition(block_index=10)
|
|
||||||
actual_end = RenderingPosition(block_index=10) # Exact
|
|
||||||
|
|
||||||
adjusted = layouter._adjust_start_estimate(
|
|
||||||
current_start, target_end, actual_end)
|
|
||||||
|
|
||||||
# Should return same or similar position
|
|
||||||
assert adjusted.block_index >= 0
|
|
||||||
|
|
||||||
def test_layout_paragraph_on_page_with_pretext(
|
def test_layout_paragraph_on_page_with_pretext(
|
||||||
self, sample_font, sample_page_style):
|
self, sample_font, sample_page_style):
|
||||||
"""Test paragraph layout with pretext (hyphenated word continuation)."""
|
"""Test paragraph layout with pretext (hyphenated word continuation)."""
|
||||||
@@ -899,5 +831,43 @@ class TestBidirectionalLayouter:
|
|||||||
assert next_pos == position # No progress possible
|
assert next_pos == position # No progress possible
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoPageMonkeyPatching:
|
||||||
|
"""
|
||||||
|
R5: importing this module used to run _add_page_methods(), which attached
|
||||||
|
can_fit_line/available_width to Page if they were absent. They are not
|
||||||
|
absent, so it never fired - but its can_fit_line took (line_height) and
|
||||||
|
ignored descenders, while Page's takes (baseline_spacing, ascent, descent).
|
||||||
|
Had Page's ever been renamed, the import would have silently reinstated the
|
||||||
|
pre-S2 clipping bug from another package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_module_does_not_patch_page(self):
|
||||||
|
import pyWebLayout.layout.ereader_layout as ereader_layout
|
||||||
|
|
||||||
|
assert not hasattr(ereader_layout, '_add_page_methods')
|
||||||
|
|
||||||
|
def test_page_owns_its_geometry_methods(self):
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
|
||||||
|
assert 'can_fit_line' in vars(Page)
|
||||||
|
assert 'available_width' in vars(Page)
|
||||||
|
|
||||||
|
def test_can_fit_line_still_accounts_for_descenders(self, sample_page_style):
|
||||||
|
"""
|
||||||
|
The patched version took a single line_height and had no way to express
|
||||||
|
descent, so a descender hanging past the content box counted as fitting.
|
||||||
|
"""
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
|
||||||
|
page = Page(size=(200, 100), style=sample_page_style)
|
||||||
|
content_y, content_h = page.content_rect[1], page.content_rect[3]
|
||||||
|
available = content_y + content_h - page._current_y_offset
|
||||||
|
|
||||||
|
assert page.can_fit_line(0, ascent=available, descent=0)
|
||||||
|
assert not page.can_fit_line(0, ascent=available, descent=1), \
|
||||||
|
"a descender past the content box must not be reported as fitting"
|
||||||
|
assert page.can_fit_line(0, ascent=available - 1, descent=1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Tests for font scaling in the ereader layout path (R3).
|
||||||
|
|
||||||
|
_scale_block_fonts rebuilds a block with scaled fonts. It used to construct a
|
||||||
|
plain Word for every word, which downgraded LinkedWord and silently discarded
|
||||||
|
every hyperlink in the document as soon as the reader changed font size. It
|
||||||
|
also handled only Paragraph and Heading, so quotes, lists and tables kept their
|
||||||
|
original size while the text around them reflowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.abstract.block import Paragraph, Heading, Quote, HList, Table
|
||||||
|
from pyWebLayout.abstract.inline import LinkedWord, Word
|
||||||
|
from pyWebLayout.concrete.functional import LinkText
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """
|
||||||
|
<p>Go to <a href="http://example.com" title="Tooltip">this link</a> now.</p>
|
||||||
|
<blockquote><p>Quoted <a href="http://q.example">qlink</a> text.</p></blockquote>
|
||||||
|
<ul><li>item <a href="http://l.example">llink</a> one</li></ul>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>head <a href="http://h.example">hlink</a></th></tr></thead>
|
||||||
|
<tbody><tr><td>cell <a href="http://c.example">clink</a></td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_links(block, out=None):
|
||||||
|
"""Every LinkedWord reachable in a block, at any nesting depth."""
|
||||||
|
out = [] if out is None else out
|
||||||
|
if isinstance(block, Paragraph): # covers Heading
|
||||||
|
for _, word in block.words_iter():
|
||||||
|
if isinstance(word, LinkedWord):
|
||||||
|
out.append(word)
|
||||||
|
elif isinstance(block, Quote):
|
||||||
|
for child in block.blocks():
|
||||||
|
collect_links(child, out)
|
||||||
|
elif isinstance(block, HList):
|
||||||
|
for item in block.items():
|
||||||
|
for child in item.blocks():
|
||||||
|
collect_links(child, out)
|
||||||
|
elif isinstance(block, Table):
|
||||||
|
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
|
||||||
|
for row in rows:
|
||||||
|
for cell in row.cells():
|
||||||
|
for child in cell.blocks():
|
||||||
|
collect_links(child, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sizes(block, out=None):
|
||||||
|
"""Every font size reachable in a block, at any nesting depth."""
|
||||||
|
out = [] if out is None else out
|
||||||
|
if isinstance(block, Paragraph):
|
||||||
|
for _, word in block.words_iter():
|
||||||
|
out.append(word.style.font_size)
|
||||||
|
elif isinstance(block, Quote):
|
||||||
|
for child in block.blocks():
|
||||||
|
collect_sizes(child, out)
|
||||||
|
elif isinstance(block, HList):
|
||||||
|
for item in block.items():
|
||||||
|
for child in item.blocks():
|
||||||
|
collect_sizes(child, out)
|
||||||
|
elif isinstance(block, Table):
|
||||||
|
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
|
||||||
|
for row in rows:
|
||||||
|
for cell in row.cells():
|
||||||
|
for child in cell.blocks():
|
||||||
|
collect_sizes(child, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def blocks():
|
||||||
|
return parse_html_string(HTML)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def layouter(blocks):
|
||||||
|
return BidirectionalLayouter(blocks, PageStyle(), (400, 600))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Word.with_style
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestWithStyle:
|
||||||
|
def test_word_keeps_its_text_and_takes_the_new_font(self):
|
||||||
|
word = Word("hello", Font(font_size=16))
|
||||||
|
|
||||||
|
copy = word.with_style(Font(font_size=24))
|
||||||
|
|
||||||
|
assert copy.text == "hello"
|
||||||
|
assert copy.style.font_size == 24
|
||||||
|
assert word.style.font_size == 16, "the original must not be mutated"
|
||||||
|
|
||||||
|
def test_linked_word_stays_linked(self):
|
||||||
|
word = LinkedWord("hello", Font(font_size=16), "http://example.com",
|
||||||
|
params={"a": "1"}, title="Tooltip")
|
||||||
|
|
||||||
|
copy = word.with_style(Font(font_size=24))
|
||||||
|
|
||||||
|
assert isinstance(copy, LinkedWord)
|
||||||
|
assert copy.location == "http://example.com"
|
||||||
|
assert copy.link_type == word.link_type
|
||||||
|
assert copy.params == {"a": "1"}
|
||||||
|
assert copy.link_title == "Tooltip"
|
||||||
|
assert copy.style.font_size == 24
|
||||||
|
|
||||||
|
def test_linked_word_params_are_copied_not_shared(self):
|
||||||
|
word = LinkedWord("hello", Font(), "http://example.com", params={"a": "1"})
|
||||||
|
|
||||||
|
copy = word.with_style(Font(font_size=24))
|
||||||
|
copy.params["b"] = "2"
|
||||||
|
|
||||||
|
assert "b" not in word.params
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# _scale_block_fonts
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestScaleBlockFonts:
|
||||||
|
def test_links_survive_scaling_in_every_container(self, blocks, layouter):
|
||||||
|
before = sum(len(collect_links(b)) for b in blocks)
|
||||||
|
after = sum(len(collect_links(layouter._scale_block_fonts(b, 1.5)))
|
||||||
|
for b in blocks)
|
||||||
|
|
||||||
|
assert before == 6, "fixture should contain 6 linked words"
|
||||||
|
assert after == before, "scaling must not discard hyperlinks"
|
||||||
|
|
||||||
|
def test_link_targets_are_preserved_exactly(self, blocks, layouter):
|
||||||
|
scaled = [layouter._scale_block_fonts(b, 1.5) for b in blocks]
|
||||||
|
targets = sorted(w.location for b in scaled for w in collect_links(b))
|
||||||
|
|
||||||
|
assert targets == sorted([
|
||||||
|
"http://example.com", "http://example.com",
|
||||||
|
"http://q.example", "http://l.example",
|
||||||
|
"http://h.example", "http://c.example",
|
||||||
|
])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index,kind", [(0, "paragraph"), (1, "quote"),
|
||||||
|
(2, "list"), (3, "table")])
|
||||||
|
def test_every_container_type_actually_scales(self, blocks, layouter, index, kind):
|
||||||
|
original = collect_sizes(blocks[index])
|
||||||
|
scaled = collect_sizes(layouter._scale_block_fonts(blocks[index], 2.0))
|
||||||
|
|
||||||
|
assert original, f"fixture {kind} should contain sized words"
|
||||||
|
assert scaled == [s * 2 for s in original], f"{kind} did not scale"
|
||||||
|
|
||||||
|
def test_table_rows_stay_in_their_section(self, blocks, layouter):
|
||||||
|
table = next(b for b in blocks if isinstance(b, Table))
|
||||||
|
|
||||||
|
scaled = layouter._scale_block_fonts(table, 1.5)
|
||||||
|
|
||||||
|
assert len(list(scaled.header_rows())) == len(list(table.header_rows()))
|
||||||
|
assert len(list(scaled.body_rows())) == len(list(table.body_rows()))
|
||||||
|
|
||||||
|
def test_unscaled_blocks_are_returned_unchanged(self, blocks, layouter):
|
||||||
|
assert layouter._scale_block_fonts(blocks[0], 1.0) is blocks[0]
|
||||||
|
|
||||||
|
def test_heading_level_is_preserved(self, layouter):
|
||||||
|
heading = parse_html_string("<h3>Title here</h3>")[0]
|
||||||
|
|
||||||
|
scaled = layouter._scale_block_fonts(heading, 1.5)
|
||||||
|
|
||||||
|
assert isinstance(scaled, Heading)
|
||||||
|
assert scaled.level == heading.level
|
||||||
|
|
||||||
|
def test_result_is_memoised(self, blocks, layouter):
|
||||||
|
"""Rebuilding a block per page render allocated on the hot path."""
|
||||||
|
first = layouter._scale_block_fonts(blocks[0], 1.5)
|
||||||
|
second = layouter._scale_block_fonts(blocks[0], 1.5)
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
|
||||||
|
def test_different_scales_are_cached_separately(self, blocks, layouter):
|
||||||
|
assert (layouter._scale_block_fonts(blocks[0], 1.5)
|
||||||
|
is not layouter._scale_block_fonts(blocks[0], 2.0))
|
||||||
|
|
||||||
|
def test_originals_are_never_mutated(self, blocks, layouter):
|
||||||
|
before = [collect_sizes(b) for b in blocks]
|
||||||
|
|
||||||
|
for b in blocks:
|
||||||
|
layouter._scale_block_fonts(b, 3.0)
|
||||||
|
|
||||||
|
assert [collect_sizes(b) for b in blocks] == before
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# End to end
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def rendered_link_texts(page):
|
||||||
|
"""Every LinkText on a rendered page. They live inside Line objects."""
|
||||||
|
found = []
|
||||||
|
for child in page._children:
|
||||||
|
for text_obj in getattr(child, '_text_objects', []):
|
||||||
|
if isinstance(text_obj, LinkText):
|
||||||
|
found.append(text_obj)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinksRemainClickableAfterFontChange:
|
||||||
|
"""
|
||||||
|
The user-visible symptom of R3: increase the font size and links stop
|
||||||
|
responding to taps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(self):
|
||||||
|
blocks = parse_html_string(
|
||||||
|
'<p>Go to <a href="http://example.com">this link</a> now.</p>')
|
||||||
|
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||||
|
bookmarks_dir=tempfile.mkdtemp())
|
||||||
|
yield manager
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
def test_links_render_at_default_scale(self, manager):
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
|
||||||
|
assert [t.link.location for t in rendered_link_texts(page)] == \
|
||||||
|
["http://example.com", "http://example.com"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("scale", [0.8, 1.5, 2.0])
|
||||||
|
def test_links_survive_a_font_size_change(self, manager, scale):
|
||||||
|
manager.set_font_scale(scale)
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
|
||||||
|
locations = {t.link.location for t in rendered_link_texts(page)}
|
||||||
|
assert locations == {"http://example.com"}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("scale", [1.0, 1.5])
|
||||||
|
def test_the_link_is_reachable_by_tapping(self, manager, scale):
|
||||||
|
"""
|
||||||
|
Scanned rather than probed at the LinkText's own centre: the hit region
|
||||||
|
query_point reports is offset from LinkText.origin by roughly the
|
||||||
|
ascent. That misalignment predates this fix and is tracked separately
|
||||||
|
as R9 - it reproduces identically at scale 1.0.
|
||||||
|
"""
|
||||||
|
manager.set_font_scale(scale)
|
||||||
|
page = manager.get_current_page()
|
||||||
|
page.render()
|
||||||
|
|
||||||
|
targets = set()
|
||||||
|
for y in range(0, 120, 2):
|
||||||
|
for x in range(0, 400, 2):
|
||||||
|
result = page.query_point((x, y))
|
||||||
|
if result is not None and result.object_type == "link":
|
||||||
|
targets.add(result.link_target)
|
||||||
|
|
||||||
|
assert targets == {"http://example.com"}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Tests for the page caching layer.
|
||||||
|
|
||||||
|
Covers PageBuffer's LRU behaviour and BufferedPageRenderer's cache hits, plus
|
||||||
|
regressions for S12/R1/R2: the module must not start worker processes and must
|
||||||
|
not do blocking work in a finaliser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.layout.page_buffer import PageBuffer, BufferedPageRenderer
|
||||||
|
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||||
|
from pyWebLayout.abstract.block import Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_blocks():
|
||||||
|
"""A document long enough to paginate over several pages."""
|
||||||
|
font = Font()
|
||||||
|
blocks = []
|
||||||
|
for p in range(6):
|
||||||
|
para = Paragraph(style=font)
|
||||||
|
for w in range(120):
|
||||||
|
para.add_word(Word(f"p{p}w{w}", font))
|
||||||
|
blocks.append(para)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def renderer(sample_blocks):
|
||||||
|
return BufferedPageRenderer(sample_blocks, PageStyle(), buffer_size=3, page_size=(800, 600))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PageBuffer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPageBuffer:
|
||||||
|
def test_get_page_misses_when_empty(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
assert buf.get_page(RenderingPosition()) is None
|
||||||
|
|
||||||
|
def test_cache_page_round_trips(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
pos, nxt = RenderingPosition(block_index=0), RenderingPosition(block_index=1)
|
||||||
|
sentinel = object()
|
||||||
|
|
||||||
|
buf.cache_page(pos, sentinel, nxt)
|
||||||
|
|
||||||
|
assert buf.get_page(pos) is sentinel
|
||||||
|
assert buf.position_map[pos] == nxt
|
||||||
|
|
||||||
|
def test_lru_evicts_oldest_and_cleans_position_map(self):
|
||||||
|
buf = PageBuffer(buffer_size=2)
|
||||||
|
positions = [RenderingPosition(block_index=i) for i in range(4)]
|
||||||
|
for i, pos in enumerate(positions):
|
||||||
|
buf.cache_page(pos, object(), RenderingPosition(block_index=i + 1))
|
||||||
|
|
||||||
|
assert buf.get_page(positions[0]) is None, "oldest should have been evicted"
|
||||||
|
assert positions[0] not in buf.position_map, "position map must not leak evicted entries"
|
||||||
|
assert buf.get_page(positions[-1]) is not None
|
||||||
|
|
||||||
|
def test_get_page_refreshes_lru_order(self):
|
||||||
|
buf = PageBuffer(buffer_size=2)
|
||||||
|
a, b, c = (RenderingPosition(block_index=i) for i in range(3))
|
||||||
|
buf.cache_page(a, object())
|
||||||
|
buf.cache_page(b, object())
|
||||||
|
|
||||||
|
buf.get_page(a) # a becomes most recently used
|
||||||
|
buf.cache_page(c, object())
|
||||||
|
|
||||||
|
assert buf.get_page(a) is not None, "recently used entry should survive"
|
||||||
|
assert buf.get_page(b) is None, "least recently used entry should be evicted"
|
||||||
|
|
||||||
|
def test_backward_pages_land_in_the_backward_buffer(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
start, end = RenderingPosition(block_index=1), RenderingPosition(block_index=2)
|
||||||
|
|
||||||
|
buf.cache_page(start, object(), end, is_backward=True)
|
||||||
|
|
||||||
|
assert start in buf.backward_buffer
|
||||||
|
assert start not in buf.forward_buffer
|
||||||
|
assert buf.reverse_position_map[end] == start
|
||||||
|
|
||||||
|
def test_font_scale_change_invalidates(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
pos = RenderingPosition()
|
||||||
|
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
|
||||||
|
|
||||||
|
buf.set_font_scale(1.5)
|
||||||
|
|
||||||
|
assert buf.get_page(pos) is None
|
||||||
|
|
||||||
|
def test_same_font_scale_keeps_cache(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
pos = RenderingPosition()
|
||||||
|
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
|
||||||
|
|
||||||
|
buf.set_font_scale(1.0)
|
||||||
|
|
||||||
|
assert buf.get_page(pos) is not None
|
||||||
|
|
||||||
|
def test_shutdown_is_idempotent(self):
|
||||||
|
buf = PageBuffer(buffer_size=3)
|
||||||
|
buf.cache_page(RenderingPosition(), object())
|
||||||
|
|
||||||
|
buf.shutdown()
|
||||||
|
buf.shutdown()
|
||||||
|
|
||||||
|
assert buf.get_cache_stats()['forward_buffer_size'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BufferedPageRenderer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestBufferedPageRenderer:
|
||||||
|
def test_render_page_returns_a_page_and_advances(self, renderer):
|
||||||
|
page, next_pos = renderer.render_page(RenderingPosition(), 1.0)
|
||||||
|
|
||||||
|
assert page is not None
|
||||||
|
assert next_pos != RenderingPosition()
|
||||||
|
|
||||||
|
def test_second_render_of_same_position_is_served_from_cache(self, renderer):
|
||||||
|
pos = RenderingPosition()
|
||||||
|
first, first_next = renderer.render_page(pos, 1.0)
|
||||||
|
second, second_next = renderer.render_page(pos, 1.0)
|
||||||
|
|
||||||
|
assert second is first, "identical page object means it came from the cache"
|
||||||
|
assert second_next == first_next
|
||||||
|
|
||||||
|
def test_font_scale_change_forces_a_re_render(self, renderer):
|
||||||
|
pos = RenderingPosition()
|
||||||
|
first, _ = renderer.render_page(pos, 1.0)
|
||||||
|
scaled, _ = renderer.render_page(pos, 1.5)
|
||||||
|
|
||||||
|
assert scaled is not first
|
||||||
|
|
||||||
|
def test_backward_render_round_trips_to_the_original_position(self, renderer):
|
||||||
|
start = RenderingPosition()
|
||||||
|
_, second_page_pos = renderer.render_page(start, 1.0)
|
||||||
|
|
||||||
|
_, back_to = renderer.render_page_backward(second_page_pos, 1.0)
|
||||||
|
|
||||||
|
assert back_to == start
|
||||||
|
|
||||||
|
def test_shutdown_clears_the_cache(self, renderer):
|
||||||
|
renderer.render_page(RenderingPosition(), 1.0)
|
||||||
|
renderer.shutdown()
|
||||||
|
|
||||||
|
assert renderer.get_cache_stats()['forward_buffer_size'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# S12 / R1 / R2 regressions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestNoBackgroundProcesses:
|
||||||
|
"""
|
||||||
|
The process pool that used to live here never produced a usable page (a Page
|
||||||
|
holds a live PIL canvas and cannot be pickled), and on Python 3.14's
|
||||||
|
forkserver default it raised when driven from module-level code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_module_declares_no_process_pool(self):
|
||||||
|
import pyWebLayout.layout.page_buffer as page_buffer
|
||||||
|
|
||||||
|
source = page_buffer.__file__
|
||||||
|
assert not hasattr(page_buffer, '_render_page_worker')
|
||||||
|
assert not hasattr(PageBuffer(), 'executor')
|
||||||
|
with open(source, encoding='utf-8') as fh:
|
||||||
|
body = fh.read().split('"""', 2)[-1] # skip the module docstring
|
||||||
|
assert 'ProcessPoolExecutor' not in body
|
||||||
|
assert 'pickle' not in body
|
||||||
|
|
||||||
|
def test_page_buffer_has_no_finaliser(self):
|
||||||
|
"""
|
||||||
|
PageBuffer.__del__ called executor.shutdown(wait=True), which deadlocked
|
||||||
|
the interpreter at exit. Cleanup must be explicit.
|
||||||
|
"""
|
||||||
|
assert '__del__' not in vars(PageBuffer)
|
||||||
|
|
||||||
|
def test_navigation_works_without_a_main_guard(self, tmp_path):
|
||||||
|
"""
|
||||||
|
R1: EreaderLayoutManager raised RuntimeError when used from module-level
|
||||||
|
script code, because submitting to a ProcessPoolExecutor under a
|
||||||
|
non-fork start method requires an `if __name__ == "__main__"` guard.
|
||||||
|
"""
|
||||||
|
script = textwrap.dedent(f"""
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
|
||||||
|
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(2000)) + "</p>")
|
||||||
|
m = EreaderLayoutManager(blocks, page_size=(800, 600),
|
||||||
|
bookmarks_dir={str(tmp_path)!r})
|
||||||
|
m.get_current_page()
|
||||||
|
m.next_page()
|
||||||
|
m.previous_page()
|
||||||
|
m.shutdown()
|
||||||
|
print("OK")
|
||||||
|
""")
|
||||||
|
result = subprocess.run([sys.executable, "-c", script],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "OK" in result.stdout
|
||||||
|
|
||||||
|
def test_interpreter_exits_without_explicit_shutdown(self, tmp_path):
|
||||||
|
"""
|
||||||
|
R2: a manager left to be finalised at exit must not hang. The timeout is
|
||||||
|
the assertion.
|
||||||
|
"""
|
||||||
|
script = textwrap.dedent(f"""
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||||
|
|
||||||
|
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(500)) + "</p>")
|
||||||
|
m = EreaderLayoutManager(blocks, page_size=(800, 600),
|
||||||
|
bookmarks_dir={str(tmp_path)!r})
|
||||||
|
m.get_current_page()
|
||||||
|
# deliberately no shutdown() - rely on interpreter teardown
|
||||||
|
""")
|
||||||
|
result = subprocess.run([sys.executable, "-c", script],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Regression tests for blocks that span more than one page.
|
||||||
|
|
||||||
|
A block larger than a single page is laid out partially, and the layouter reports
|
||||||
|
where it stopped. If that resume point is discarded, the reader is told it made no
|
||||||
|
progress and navigation dead-ends on that block (spec S11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyWebLayout.abstract.block import Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_SIZE = (800, 600)
|
||||||
|
|
||||||
|
|
||||||
|
def make_paragraph(word_count, font):
|
||||||
|
"""A paragraph of distinct words, so we can verify none are lost or repeated."""
|
||||||
|
paragraph = Paragraph(font)
|
||||||
|
for i in range(word_count):
|
||||||
|
paragraph.add_word(Word(f"word{i}", font))
|
||||||
|
return paragraph
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def font():
|
||||||
|
return Font(font_size=16)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def huge_paragraph(font):
|
||||||
|
"""A paragraph far larger than one page - the shape that dead-ended."""
|
||||||
|
return make_paragraph(2877, font)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageSpanningParagraph:
|
||||||
|
"""A single paragraph larger than one page must paginate, not dead-end."""
|
||||||
|
|
||||||
|
def test_layouter_reports_where_it_stopped(self, huge_paragraph):
|
||||||
|
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||||
|
page = Page(size=PAGE_SIZE, style=PageStyle())
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_block_on_page(
|
||||||
|
huge_paragraph, page, RenderingPosition(), 1.0)
|
||||||
|
|
||||||
|
assert not success, "a 2877-word paragraph cannot fit on one page"
|
||||||
|
assert new_pos.word_index > 0, "the resume point must be reported"
|
||||||
|
|
||||||
|
def test_first_page_advances(self, huge_paragraph):
|
||||||
|
"""The regression: next position equalled the start position."""
|
||||||
|
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||||
|
start = RenderingPosition()
|
||||||
|
|
||||||
|
page, next_pos = layouter.render_page_forward(start, 1.0)
|
||||||
|
|
||||||
|
assert len(page.children) > 0, "content was placed on the page"
|
||||||
|
assert (next_pos.block_index, next_pos.word_index) > \
|
||||||
|
(start.block_index, start.word_index), \
|
||||||
|
"a page with content on it must advance the position"
|
||||||
|
|
||||||
|
def test_paginates_to_completion(self, huge_paragraph):
|
||||||
|
"""Every page advances, and the document terminates."""
|
||||||
|
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||||
|
pos = RenderingPosition()
|
||||||
|
positions = [(pos.block_index, pos.word_index)]
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
page, next_pos = layouter.render_page_forward(pos, 1.0)
|
||||||
|
key = (next_pos.block_index, next_pos.word_index)
|
||||||
|
|
||||||
|
if next_pos.block_index >= 1:
|
||||||
|
break # ran off the end of the (single-block) document
|
||||||
|
|
||||||
|
assert key > positions[-1], f"no progress at page {len(positions)}"
|
||||||
|
positions.append(key)
|
||||||
|
pos = next_pos
|
||||||
|
else:
|
||||||
|
pytest.fail("pagination did not terminate")
|
||||||
|
|
||||||
|
assert len(positions) > 5, "a 2877-word paragraph spans several pages"
|
||||||
|
|
||||||
|
def test_no_words_lost_or_repeated(self, huge_paragraph):
|
||||||
|
"""Word coverage across pages is exactly the paragraph, in order."""
|
||||||
|
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||||
|
pos = RenderingPosition()
|
||||||
|
boundaries = [0]
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
_, next_pos = layouter.render_page_forward(pos, 1.0)
|
||||||
|
if next_pos.block_index >= 1:
|
||||||
|
break
|
||||||
|
boundaries.append(next_pos.word_index)
|
||||||
|
pos = next_pos
|
||||||
|
|
||||||
|
assert boundaries == sorted(boundaries), "word indices must not go backward"
|
||||||
|
assert len(boundaries) == len(set(boundaries)), "a page must not be re-rendered"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonSpanningBlocksUnaffected:
|
||||||
|
"""The fix must not change behaviour for blocks that fit."""
|
||||||
|
|
||||||
|
def test_small_paragraphs_still_advance_by_block(self, font):
|
||||||
|
blocks = [make_paragraph(20, font) for _ in range(3)]
|
||||||
|
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
|
||||||
|
|
||||||
|
_, next_pos = layouter.render_page_forward(RenderingPosition(), 1.0)
|
||||||
|
|
||||||
|
assert next_pos.block_index == 3, "all three short paragraphs fit on one page"
|
||||||
|
assert next_pos.word_index == 0
|
||||||
|
|
||||||
|
def test_empty_document_terminates(self):
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
|
||||||
|
start = RenderingPosition()
|
||||||
|
|
||||||
|
page, next_pos = layouter.render_page_forward(start, 1.0)
|
||||||
|
|
||||||
|
assert next_pos.block_index == start.block_index
|
||||||
|
assert len(page.children) == 0
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for table column width optimization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyWebLayout.layout.table_optimizer import (
|
||||||
|
optimize_table_layout,
|
||||||
|
sample_table_rows,
|
||||||
|
extract_html_column_widths,
|
||||||
|
parse_html_width,
|
||||||
|
distribute_column_widths,
|
||||||
|
get_column_count,
|
||||||
|
calculate_table_overhead
|
||||||
|
)
|
||||||
|
from pyWebLayout.abstract.block import Table
|
||||||
|
from pyWebLayout.concrete.table import TableStyle
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseHtmlWidth:
|
||||||
|
"""Test HTML width parsing."""
|
||||||
|
|
||||||
|
def test_parse_int(self):
|
||||||
|
assert parse_html_width(100) == 100
|
||||||
|
|
||||||
|
def test_parse_px_string(self):
|
||||||
|
assert parse_html_width("150px") == 150
|
||||||
|
|
||||||
|
def test_parse_plain_number_string(self):
|
||||||
|
assert parse_html_width("200") == 200
|
||||||
|
|
||||||
|
def test_parse_percentage_returns_none(self):
|
||||||
|
assert parse_html_width("50%") is None
|
||||||
|
|
||||||
|
def test_parse_invalid_string(self):
|
||||||
|
assert parse_html_width("invalid") is None
|
||||||
|
|
||||||
|
def test_parse_with_whitespace(self):
|
||||||
|
assert parse_html_width(" 120px ") == 120
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistributeColumnWidths:
|
||||||
|
"""Test column width distribution."""
|
||||||
|
|
||||||
|
def test_distribute_with_no_fixed_columns(self):
|
||||||
|
min_widths = [50, 60, 70]
|
||||||
|
pref_widths = [100, 120, 140]
|
||||||
|
available = 360
|
||||||
|
fixed = {}
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
# Should use preferred widths (they fit)
|
||||||
|
assert result == [100, 120, 140]
|
||||||
|
|
||||||
|
def test_distribute_when_preferred_fits(self):
|
||||||
|
min_widths = [50, 50]
|
||||||
|
pref_widths = [100, 100]
|
||||||
|
available = 250
|
||||||
|
fixed = {}
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
# Preferred widths fit, extra 50px distributed proportionally (25px each)
|
||||||
|
assert result == [125, 125]
|
||||||
|
|
||||||
|
def test_distribute_when_must_use_minimum(self):
|
||||||
|
min_widths = [100, 100]
|
||||||
|
pref_widths = [200, 200]
|
||||||
|
available = 150
|
||||||
|
fixed = {}
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
# Can't even fit minimum, but force it anyway
|
||||||
|
assert result == [100, 100]
|
||||||
|
|
||||||
|
def test_distribute_proportional(self):
|
||||||
|
min_widths = [50, 50]
|
||||||
|
pref_widths = [200, 100]
|
||||||
|
available = 200 # Between min and pref totals
|
||||||
|
fixed = {}
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
# Should distribute proportionally
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] + result[1] == 200
|
||||||
|
# First column should get more (higher pref)
|
||||||
|
assert result[0] > result[1]
|
||||||
|
|
||||||
|
def test_distribute_with_fixed_columns(self):
|
||||||
|
min_widths = [50, 50, 50]
|
||||||
|
pref_widths = [100, 100, 100]
|
||||||
|
available = 300
|
||||||
|
fixed = {1: 80} # Second column fixed at 80
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
# Second column should be 80
|
||||||
|
assert result[1] == 80
|
||||||
|
# Other columns share remaining space
|
||||||
|
assert result[0] + result[2] == 220
|
||||||
|
|
||||||
|
def test_distribute_all_fixed(self):
|
||||||
|
min_widths = [50, 50]
|
||||||
|
pref_widths = [100, 100]
|
||||||
|
available = 300
|
||||||
|
fixed = {0: 120, 1: 150}
|
||||||
|
|
||||||
|
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
|
||||||
|
|
||||||
|
assert result == [120, 150]
|
||||||
|
|
||||||
|
def test_distribute_empty(self):
|
||||||
|
result = distribute_column_widths([], [], 100, {})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetColumnCount:
|
||||||
|
"""Test column counting."""
|
||||||
|
|
||||||
|
def test_empty_table(self):
|
||||||
|
table = Table()
|
||||||
|
assert get_column_count(table) == 0
|
||||||
|
|
||||||
|
def test_table_with_header(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
row = TableRow()
|
||||||
|
for text in ["A", "B", "C"]:
|
||||||
|
cell = TableCell(is_header=True)
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(text, font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
|
||||||
|
table.add_row(row, section="header")
|
||||||
|
|
||||||
|
assert get_column_count(table) == 3
|
||||||
|
|
||||||
|
def test_table_with_body(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
row = TableRow()
|
||||||
|
for text in ["1", "2"]:
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(text, font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
assert get_column_count(table) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleTableRows:
|
||||||
|
"""Test row sampling."""
|
||||||
|
|
||||||
|
def test_sample_small_table(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
for text in ["1", "2"]:
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(text, font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
sampled = sample_table_rows(table, sample_size=5)
|
||||||
|
|
||||||
|
# Should get all rows (only 2)
|
||||||
|
assert len(sampled) == 2
|
||||||
|
|
||||||
|
def test_sample_large_table(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
for i in range(20):
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(str(i), font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
sampled = sample_table_rows(table, sample_size=5)
|
||||||
|
|
||||||
|
# Should get only 5 body rows
|
||||||
|
assert len(sampled) == 5
|
||||||
|
|
||||||
|
def test_sample_with_header_body_footer(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
# 3 header rows
|
||||||
|
for i in range(3):
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell(is_header=True)
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(f"H{i}", font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="header")
|
||||||
|
|
||||||
|
# 10 body rows
|
||||||
|
for i in range(10):
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(f"B{i}", font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
# 2 footer rows
|
||||||
|
for i in range(2):
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(f"F{i}", font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="footer")
|
||||||
|
|
||||||
|
sampled = sample_table_rows(table, sample_size=2)
|
||||||
|
|
||||||
|
# Should get 2 from each section = 6 total
|
||||||
|
assert len(sampled) == 6
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractHtmlColumnWidths:
|
||||||
|
"""Test HTML width extraction."""
|
||||||
|
|
||||||
|
def test_no_widths(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
row = TableRow()
|
||||||
|
for text in ["A", "B"]:
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(text, font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
widths = extract_html_column_widths(table)
|
||||||
|
|
||||||
|
assert widths == [None, None]
|
||||||
|
|
||||||
|
def test_cell_width_attributes(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
row = TableRow()
|
||||||
|
|
||||||
|
cell1 = TableCell()
|
||||||
|
cell1.width = "100px"
|
||||||
|
para1 = Paragraph(font)
|
||||||
|
para1.add_word(Word("A", font))
|
||||||
|
cell1.add_block(para1)
|
||||||
|
row.add_cell(cell1)
|
||||||
|
|
||||||
|
cell2 = TableCell()
|
||||||
|
cell2.width = "150"
|
||||||
|
para2 = Paragraph(font)
|
||||||
|
para2.add_word(Word("B", font))
|
||||||
|
cell2.add_block(para2)
|
||||||
|
row.add_cell(cell2)
|
||||||
|
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
widths = extract_html_column_widths(table)
|
||||||
|
|
||||||
|
assert widths == [100, 150]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateTableOverhead:
|
||||||
|
"""Test table overhead calculation."""
|
||||||
|
|
||||||
|
def test_basic_overhead(self):
|
||||||
|
style = TableStyle(border_width=1, cell_spacing=0)
|
||||||
|
overhead = calculate_table_overhead(3, style)
|
||||||
|
|
||||||
|
# 3 columns = 4 borders (n+1)
|
||||||
|
assert overhead == 4
|
||||||
|
|
||||||
|
def test_with_cell_spacing(self):
|
||||||
|
style = TableStyle(border_width=1, cell_spacing=5)
|
||||||
|
overhead = calculate_table_overhead(3, style)
|
||||||
|
|
||||||
|
# 4 borders + 2 spacings (n-1)
|
||||||
|
assert overhead == 4 + 10
|
||||||
|
|
||||||
|
def test_thicker_borders(self):
|
||||||
|
style = TableStyle(border_width=3, cell_spacing=0)
|
||||||
|
overhead = calculate_table_overhead(2, style)
|
||||||
|
|
||||||
|
# 2 columns = 3 borders * 3px
|
||||||
|
assert overhead == 9
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptimizeTableLayout:
|
||||||
|
"""Test full table optimization."""
|
||||||
|
|
||||||
|
def test_optimize_simple_table(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
row = TableRow()
|
||||||
|
for text in ["Short", "A bit longer text"]:
|
||||||
|
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")
|
||||||
|
|
||||||
|
style = TableStyle()
|
||||||
|
widths = optimize_table_layout(table, available_width=400, style=style)
|
||||||
|
|
||||||
|
# Should return 2 column widths
|
||||||
|
assert len(widths) == 2
|
||||||
|
# Second column should be wider
|
||||||
|
assert widths[1] > widths[0]
|
||||||
|
|
||||||
|
def test_optimize_empty_table(self):
|
||||||
|
table = Table()
|
||||||
|
|
||||||
|
widths = optimize_table_layout(table, available_width=400)
|
||||||
|
|
||||||
|
assert widths == []
|
||||||
|
|
||||||
|
def test_optimize_respects_sample_size(self):
|
||||||
|
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
table = Table()
|
||||||
|
font = Font(font_size=12)
|
||||||
|
|
||||||
|
# Create 20 rows but only first 5 should be sampled
|
||||||
|
for i in range(20):
|
||||||
|
row = TableRow()
|
||||||
|
cell = TableCell()
|
||||||
|
para = Paragraph(font)
|
||||||
|
para.add_word(Word(f"Data {i}", font))
|
||||||
|
cell.add_block(para)
|
||||||
|
row.add_cell(cell)
|
||||||
|
table.add_row(row, section="body")
|
||||||
|
|
||||||
|
widths = optimize_table_layout(table, available_width=400, sample_size=5)
|
||||||
|
|
||||||
|
# Should return width for 1 column
|
||||||
|
assert len(widths) == 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v'])
|
||||||
@@ -24,6 +24,12 @@ class TestDocumentLayouter:
|
|||||||
self.mock_page.border_size = 20
|
self.mock_page.border_size = 20
|
||||||
self.mock_page._current_y_offset = 50
|
self.mock_page._current_y_offset = 50
|
||||||
self.mock_page.available_width = 400
|
self.mock_page.available_width = 400
|
||||||
|
# Content geometry: a 440x600 page with a 20px border and no padding, so
|
||||||
|
# the content box starts at (20, 20) and is 400 wide.
|
||||||
|
self.mock_page.size = (440, 600)
|
||||||
|
self.mock_page.content_origin = (20, 20)
|
||||||
|
self.mock_page.content_rect = (20, 20, 400, 560)
|
||||||
|
self.mock_page.remaining_height = 530 # 20 + 560 - 50
|
||||||
self.mock_page.draw = Mock()
|
self.mock_page.draw = Mock()
|
||||||
self.mock_page.can_fit_line = Mock(return_value=True)
|
self.mock_page.can_fit_line = Mock(return_value=True)
|
||||||
self.mock_page.add_child = Mock()
|
self.mock_page.add_child = Mock()
|
||||||
@@ -603,6 +609,10 @@ class TestTableLayouter:
|
|||||||
self.mock_page._current_y_offset = 50
|
self.mock_page._current_y_offset = 50
|
||||||
self.mock_page.available_width = 600
|
self.mock_page.available_width = 600
|
||||||
self.mock_page.size = (800, 1000)
|
self.mock_page.size = (800, 1000)
|
||||||
|
# Content geometry: 800x1000 page, 20px border, no padding.
|
||||||
|
self.mock_page.content_origin = (20, 20)
|
||||||
|
self.mock_page.content_rect = (20, 20, 600, 960)
|
||||||
|
self.mock_page.remaining_height = 930 # 20 + 960 - 50
|
||||||
|
|
||||||
# Create mock draw and canvas
|
# Create mock draw and canvas
|
||||||
self.mock_draw = Mock()
|
self.mock_draw = Mock()
|
||||||
|
|||||||