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

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

|
||||

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

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

|
||||

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

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

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

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

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

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

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

|
||||
|
||||
---
|
||||
|
||||
## Running the Examples
|
||||
|
||||
@@ -119,21 +242,68 @@ All examples can be run directly from the examples directory:
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
python 01_simple_page_rendering.py
|
||||
python 02_text_and_layout.py
|
||||
python 03_page_layouts.py
|
||||
python 04_table_rendering.py
|
||||
python 05_table_with_images.py
|
||||
python 06_functional_elements_demo.py
|
||||
|
||||
# Getting Started (01-07)
|
||||
python 01_simple_page_rendering.py # Page layouts
|
||||
python 02_text_and_layout.py # Text alignment with justified text
|
||||
python 03_page_layouts.py # Various page sizes
|
||||
python 04_table_rendering.py # Table styles
|
||||
python 05_html_table_with_images.py # HTML tables with images
|
||||
python 06_functional_elements_demo.py # Interactive buttons and forms
|
||||
python 07_pressed_state_demo.py # Button pressed states (generates GIF)
|
||||
|
||||
# Advanced Features (08-11)
|
||||
python 08_bundled_fonts_demo.py # Bundled font showcase
|
||||
python 08_pagination_demo.py # Multi-page documents
|
||||
python 09_link_navigation_demo.py # All link types
|
||||
python 10_forms_demo.py # All form field types
|
||||
python 11_table_text_wrapping_demo.py # Table text wrapping
|
||||
python 11b_simple_table_wrapping.py # Simple wrapping demo
|
||||
```
|
||||
|
||||
Output images are saved to the `docs/images/` directory.
|
||||
|
||||
## Recent Improvements
|
||||
|
||||
### ✅ Justified Text Fix (2024-11-10)
|
||||
Lines using justified alignment now properly fill the entire width by:
|
||||
- Calculating base spacing and remainder pixels
|
||||
- Distributing remainder across word gaps to eliminate short lines
|
||||
- Removing max_spacing constraint for true justification
|
||||
|
||||
**Affected examples:** 02, 11, 11b - All text now perfectly justified!
|
||||
|
||||
### ✅ Animated Button States (2024-11-10)
|
||||
Example 07 now automatically generates an animated GIF showing button interactions:
|
||||
- Initial state (1000ms)
|
||||
- Pressed state (200ms)
|
||||
- Released state (500ms)
|
||||
- Loops continuously
|
||||
|
||||
**Output:** `docs/images/example_07_button_animation.gif`
|
||||
|
||||
### Running Tests
|
||||
|
||||
All new examples (08, 09, 10) include comprehensive test coverage:
|
||||
|
||||
```bash
|
||||
# Run all example tests
|
||||
python -m pytest tests/examples/ -v
|
||||
|
||||
# Run specific test file
|
||||
python -m pytest tests/examples/test_08_pagination_demo.py -v
|
||||
python -m pytest tests/examples/test_09_link_navigation_demo.py -v
|
||||
python -m pytest tests/examples/test_10_forms_demo.py -v
|
||||
```
|
||||
|
||||
**Total Test Coverage:** 30 tests (11 + 10 + 9), all passing ✅
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- `README_HTML_MULTIPAGE.md` - HTML multi-page rendering guide
|
||||
- `../ARCHITECTURE.md` - Detailed explanation of the Abstract/Concrete architecture
|
||||
- `../docs/images/` - Rendered example outputs
|
||||
- `../docs/images/README.md` - Visual documentation index with all examples
|
||||
- `../pyWebLayout/layout/README_EREADER_API.md` - EbookReader API reference
|
||||
|
||||
## Debug/Development Scripts
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Generate a demo image for README.md showing font family switching feature.
|
||||
|
||||
Creates a side-by-side comparison of the same content rendered in
|
||||
Sans, Serif, and Monospace fonts.
|
||||
"""
|
||||
|
||||
from pyWebLayout.abstract import Paragraph, Heading, Word
|
||||
from pyWebLayout.abstract.block import HeadingLevel
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.fonts import BundledFont, FontWeight
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.ereader_manager import create_ereader_manager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def create_demo_content():
|
||||
"""Create concise demo content that fits nicely on a small page"""
|
||||
blocks = []
|
||||
|
||||
# Title
|
||||
title_font = Font.from_family(BundledFont.SANS, font_size=28, weight=FontWeight.BOLD)
|
||||
title = Heading(level=HeadingLevel.H1, style=title_font)
|
||||
for word in "The Adventure Begins".split():
|
||||
title.add_word(Word(word, title_font))
|
||||
blocks.append(title)
|
||||
|
||||
# Paragraph
|
||||
body_font = Font.from_family(BundledFont.SANS, font_size=14)
|
||||
para = Paragraph(body_font)
|
||||
text = (
|
||||
"In the quiet village of Millbrook, young Emma discovered an ancient map "
|
||||
"hidden in her grandmother's attic. The parchment revealed a mysterious "
|
||||
"forest path marked with symbols she had never seen before. With courage "
|
||||
"in her heart and the map in her pocket, she set out at dawn to uncover "
|
||||
"the secrets that lay beyond the old oak trees."
|
||||
)
|
||||
for word in text.split():
|
||||
para.add_word(Word(word, body_font))
|
||||
blocks.append(para)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def render_with_font_family(blocks, page_size, font_family, family_name):
|
||||
"""Render a page with a specific font family"""
|
||||
manager = create_ereader_manager(
|
||||
blocks,
|
||||
page_size,
|
||||
document_id=f"demo_{family_name.lower()}"
|
||||
)
|
||||
|
||||
# Set font family (None means original/default)
|
||||
manager.set_font_family(font_family)
|
||||
|
||||
# Get the first page
|
||||
page = manager.get_current_page()
|
||||
return page.render()
|
||||
|
||||
|
||||
def create_comparison_image():
|
||||
"""Create a side-by-side comparison of all three font families"""
|
||||
|
||||
# Page size for each panel
|
||||
page_width = 400
|
||||
page_height = 300
|
||||
|
||||
# Create demo content
|
||||
print("Creating demo content...")
|
||||
blocks = create_demo_content()
|
||||
|
||||
# Render with each font family
|
||||
print("Rendering with Sans font...")
|
||||
sans_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
|
||||
)
|
||||
|
||||
print("Rendering with Serif font...")
|
||||
serif_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
|
||||
)
|
||||
|
||||
print("Rendering with Monospace font...")
|
||||
mono_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
|
||||
)
|
||||
|
||||
# Create a composite image with all three side by side
|
||||
spacing = 20
|
||||
label_height = 30
|
||||
total_width = page_width * 3 + spacing * 4
|
||||
total_height = page_height + label_height + spacing * 2
|
||||
|
||||
composite = Image.new('RGB', (total_width, total_height), color='#f5f5f5')
|
||||
|
||||
# Paste the three images
|
||||
x_positions = [
|
||||
spacing,
|
||||
spacing * 2 + page_width,
|
||||
spacing * 3 + page_width * 2
|
||||
]
|
||||
|
||||
for img, x_pos in zip([sans_image, serif_image, mono_image], x_positions):
|
||||
composite.paste(img, (x_pos, label_height + spacing))
|
||||
|
||||
# Add labels
|
||||
draw = ImageDraw.Draw(composite)
|
||||
|
||||
# Try to use a nice font, fallback to default if not available
|
||||
try:
|
||||
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
|
||||
except:
|
||||
label_font = ImageFont.load_default()
|
||||
|
||||
labels = ["Sans-Serif", "Serif", "Monospace"]
|
||||
for label, x_pos in zip(labels, x_positions):
|
||||
# Calculate text position to center it
|
||||
bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_x = x_pos + (page_width - text_width) // 2
|
||||
|
||||
draw.text((text_x, 5), label, fill='#333333', font=label_font)
|
||||
|
||||
# Save the image
|
||||
output_path = "docs/images/font_family_switching.png"
|
||||
composite.save(output_path, quality=95)
|
||||
print(f"\n✓ Saved demo image to: {output_path}")
|
||||
print(f" Image size: {total_width}x{total_height}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def create_single_vertical_comparison():
|
||||
"""Create a vertical comparison that's better for README"""
|
||||
|
||||
# Page size for each panel
|
||||
page_width = 700
|
||||
page_height = 280
|
||||
|
||||
# Create demo content
|
||||
print("\nCreating vertical comparison for README...")
|
||||
blocks = create_demo_content()
|
||||
|
||||
# Render with each font family
|
||||
print(" Rendering Sans...")
|
||||
sans_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
|
||||
)
|
||||
|
||||
print(" Rendering Serif...")
|
||||
serif_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
|
||||
)
|
||||
|
||||
print(" Rendering Monospace...")
|
||||
mono_image = render_with_font_family(
|
||||
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
|
||||
)
|
||||
|
||||
# Create a composite image stacked vertically
|
||||
spacing = 15
|
||||
label_width = 120
|
||||
total_width = page_width + label_width + spacing * 2
|
||||
total_height = page_height * 3 + spacing * 4
|
||||
|
||||
composite = Image.new('RGB', (total_width, total_height), color='#ffffff')
|
||||
|
||||
# Add a subtle border
|
||||
draw = ImageDraw.Draw(composite)
|
||||
draw.rectangle([(0, 0), (total_width-1, total_height-1)], outline='#e0e0e0', width=1)
|
||||
|
||||
# Paste the three images vertically
|
||||
y_positions = [
|
||||
spacing,
|
||||
spacing * 2 + page_height,
|
||||
spacing * 3 + page_height * 2
|
||||
]
|
||||
|
||||
images_data = [
|
||||
(sans_image, "Sans-Serif", "#4A90E2"),
|
||||
(serif_image, "Serif", "#E94B3C"),
|
||||
(mono_image, "Monospace", "#50C878")
|
||||
]
|
||||
|
||||
# Try to use a nice font
|
||||
try:
|
||||
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
|
||||
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except:
|
||||
label_font = ImageFont.load_default()
|
||||
small_font = ImageFont.load_default()
|
||||
|
||||
for (img, label, color), y_pos in zip(images_data, y_positions):
|
||||
# Paste the page image
|
||||
composite.paste(img, (label_width + spacing, y_pos))
|
||||
|
||||
# Draw label background
|
||||
draw.rectangle(
|
||||
[(spacing, y_pos + 10), (label_width, y_pos + 40)],
|
||||
fill=color
|
||||
)
|
||||
|
||||
# Draw label text
|
||||
draw.text(
|
||||
(spacing + 10, y_pos + 17),
|
||||
label,
|
||||
fill='#ffffff',
|
||||
font=label_font
|
||||
)
|
||||
|
||||
# Draw font description
|
||||
descriptions = {
|
||||
"Sans-Serif": "Clean & Modern",
|
||||
"Serif": "Classic & Formal",
|
||||
"Monospace": "Code & Technical"
|
||||
}
|
||||
draw.text(
|
||||
(spacing + 5, y_pos + 50),
|
||||
descriptions[label],
|
||||
fill='#666666',
|
||||
font=small_font
|
||||
)
|
||||
|
||||
# Save the image
|
||||
output_path = "docs/images/font_family_switching_vertical.png"
|
||||
composite.save(output_path, quality=95)
|
||||
print(f" ✓ Saved: {output_path}")
|
||||
print(f" Size: {total_width}x{total_height}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 70)
|
||||
print("Generating README Demo Images")
|
||||
print("=" * 70)
|
||||
|
||||
# Create both versions
|
||||
horizontal_path = create_comparison_image()
|
||||
vertical_path = create_single_vertical_comparison()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Demo images generated successfully!")
|
||||
print("=" * 70)
|
||||
print(f"\nHorizontal comparison: {horizontal_path}")
|
||||
print(f"Vertical comparison: {vertical_path}")
|
||||
print("\nRecommended for README: vertical version")
|
||||
print("\nMarkdown snippet:")
|
||||
print("```markdown")
|
||||
print("")
|
||||
print("```")
|
||||
print()
|
||||
@@ -8,22 +8,15 @@ supports pagination for ebook-like content with the ability to pause,
|
||||
save state, and resume rendering.
|
||||
"""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
__version__ = '0.1.1'
|
||||
|
||||
# Core abstractions
|
||||
from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
|
||||
|
||||
# Style components
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
# Abstract document model
|
||||
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
|
||||
|
||||
# Concrete implementations
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.concrete.text import Line
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
# Abstract components
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
|
||||
from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
|
||||
from .block import HorizontalRule, Image
|
||||
from .interactive_image import InteractiveImage
|
||||
from .inline import Word, FormattedSpan, LineBreak
|
||||
from .document import Document, MetadataType, Chapter, Book
|
||||
from .functional import Link, LinkType, Button, Form, FormField, FormFieldType
|
||||
"""
|
||||
Abstract layer for the pyWebLayout library.
|
||||
|
||||
This package contains abstract representations of document elements that are
|
||||
independent of rendering specifics.
|
||||
"""
|
||||
|
||||
from .inline import Word, FormattedSpan
|
||||
from .block import Paragraph, Heading, Image, HeadingLevel
|
||||
from .document import Document
|
||||
from .functional import LinkType
|
||||
|
||||
__all__ = [
|
||||
'Word',
|
||||
'FormattedSpan',
|
||||
'Paragraph',
|
||||
'Heading',
|
||||
'Image',
|
||||
'HeadingLevel',
|
||||
'Document',
|
||||
'LinkType',
|
||||
]
|
||||
|
||||
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Union, Any
|
||||
from enum import Enum
|
||||
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
|
||||
from .functional import Link, Button, Form
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
@@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
language: str = "en-US",
|
||||
default_style=None):
|
||||
"""
|
||||
Initialize a new document.
|
||||
|
||||
@@ -49,13 +51,13 @@ class Document(FontRegistry, MetadataContainer):
|
||||
self._resources: Dict[str, Any] = {} # External resources like images
|
||||
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
|
||||
self._scripts: List[str] = [] # JavaScript code
|
||||
|
||||
|
||||
# Style management with new abstract/concrete system
|
||||
self._abstract_style_registry = AbstractStyleRegistry()
|
||||
self._rendering_context = RenderingContext(default_language=language)
|
||||
self._style_resolver = StyleResolver(self._rendering_context)
|
||||
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
|
||||
|
||||
|
||||
# Set default style
|
||||
if default_style is None:
|
||||
# Create a default abstract style
|
||||
@@ -68,45 +70,46 @@ class Document(FontRegistry, MetadataContainer):
|
||||
color=default_style.colour,
|
||||
language=default_style.language
|
||||
)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(
|
||||
default_style)
|
||||
self._default_style = default_style
|
||||
|
||||
# Set basic metadata
|
||||
if title:
|
||||
self.set_metadata(MetadataType.TITLE, title)
|
||||
self.set_metadata(MetadataType.LANGUAGE, language)
|
||||
|
||||
|
||||
@property
|
||||
def blocks(self) -> List[Block]:
|
||||
"""Get the top-level blocks in this document"""
|
||||
return self._blocks
|
||||
|
||||
|
||||
@property
|
||||
def default_style(self):
|
||||
"""Get the default style for this document"""
|
||||
return self._default_style
|
||||
|
||||
|
||||
@default_style.setter
|
||||
def default_style(self, style):
|
||||
"""Set the default style for this document"""
|
||||
self._default_style = style
|
||||
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block to this document.
|
||||
|
||||
|
||||
Args:
|
||||
block: The block to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this document.
|
||||
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
@@ -115,15 +118,18 @@ class Document(FontRegistry, MetadataContainer):
|
||||
paragraph = Paragraph(style)
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this document.
|
||||
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
@@ -132,16 +138,20 @@ class Document(FontRegistry, MetadataContainer):
|
||||
heading = Heading(level, style)
|
||||
self.add_block(heading)
|
||||
return heading
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
|
||||
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> 'Chapter':
|
||||
"""
|
||||
Create a new chapter with inherited style.
|
||||
|
||||
|
||||
Args:
|
||||
title: The chapter title
|
||||
level: The chapter level
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Chapter object
|
||||
"""
|
||||
@@ -154,148 +164,148 @@ class Document(FontRegistry, MetadataContainer):
|
||||
def add_anchor(self, name: str, target: Block):
|
||||
"""
|
||||
Add a named anchor to this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The anchor name
|
||||
target: The target block
|
||||
"""
|
||||
self._anchors[name] = target
|
||||
|
||||
|
||||
def get_anchor(self, name: str) -> Optional[Block]:
|
||||
"""
|
||||
Get a named anchor from this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The anchor name
|
||||
|
||||
|
||||
Returns:
|
||||
The target block, or None if not found
|
||||
"""
|
||||
return self._anchors.get(name)
|
||||
|
||||
|
||||
def add_resource(self, name: str, resource: Any):
|
||||
"""
|
||||
Add a resource to this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The resource name
|
||||
resource: The resource data
|
||||
"""
|
||||
self._resources[name] = resource
|
||||
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a resource from this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The resource name
|
||||
|
||||
|
||||
Returns:
|
||||
The resource data, or None if not found
|
||||
"""
|
||||
return self._resources.get(name)
|
||||
|
||||
|
||||
def add_stylesheet(self, stylesheet: Dict[str, Any]):
|
||||
"""
|
||||
Add a stylesheet to this document.
|
||||
|
||||
|
||||
Args:
|
||||
stylesheet: The stylesheet data
|
||||
"""
|
||||
self._stylesheets.append(stylesheet)
|
||||
|
||||
|
||||
def add_script(self, script: str):
|
||||
"""
|
||||
Add a script to this document.
|
||||
|
||||
|
||||
Args:
|
||||
script: The script code
|
||||
"""
|
||||
self._scripts.append(script)
|
||||
|
||||
|
||||
def get_title(self) -> Optional[str]:
|
||||
"""
|
||||
Get the document title.
|
||||
|
||||
|
||||
Returns:
|
||||
The document title, or None if not set
|
||||
"""
|
||||
return self.get_metadata(MetadataType.TITLE)
|
||||
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the document title.
|
||||
|
||||
|
||||
Args:
|
||||
title: The document title
|
||||
"""
|
||||
self.set_metadata(MetadataType.TITLE, title)
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""
|
||||
Get the document title as a property.
|
||||
|
||||
|
||||
Returns:
|
||||
The document title, or None if not set
|
||||
"""
|
||||
return self.get_title()
|
||||
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str):
|
||||
"""
|
||||
Set the document title as a property.
|
||||
|
||||
|
||||
Args:
|
||||
title: The document title
|
||||
"""
|
||||
self.set_title(title)
|
||||
|
||||
|
||||
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
|
||||
"""
|
||||
Find all blocks of a specific type.
|
||||
|
||||
|
||||
Args:
|
||||
block_type: The type of blocks to find
|
||||
|
||||
|
||||
Returns:
|
||||
A list of matching blocks
|
||||
"""
|
||||
result = []
|
||||
|
||||
|
||||
def _find_recursive(blocks: List[Block]):
|
||||
for block in blocks:
|
||||
if block.block_type == block_type:
|
||||
result.append(block)
|
||||
|
||||
|
||||
# Check for child blocks based on block type
|
||||
if hasattr(block, '_blocks'):
|
||||
_find_recursive(block._blocks)
|
||||
elif hasattr(block, '_items') and isinstance(block._items, list):
|
||||
_find_recursive(block._items)
|
||||
|
||||
|
||||
_find_recursive(self._blocks)
|
||||
return result
|
||||
|
||||
|
||||
def find_headings(self) -> List[Heading]:
|
||||
"""
|
||||
Find all headings in the document.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of heading blocks
|
||||
"""
|
||||
blocks = self.find_blocks_by_type(BlockType.HEADING)
|
||||
return [block for block in blocks if isinstance(block, Heading)]
|
||||
|
||||
|
||||
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
|
||||
"""
|
||||
Generate a table of contents from headings.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of tuples containing (level, title, heading_block)
|
||||
"""
|
||||
headings = self.find_headings()
|
||||
|
||||
|
||||
toc = []
|
||||
for heading in headings:
|
||||
# Extract text from the heading
|
||||
@@ -303,26 +313,26 @@ class Document(FontRegistry, MetadataContainer):
|
||||
for _, word in heading.words_iter():
|
||||
title += word.text + " "
|
||||
title = title.strip()
|
||||
|
||||
|
||||
# Add to TOC
|
||||
level = heading.level.value # Get numeric value from HeadingLevel enum
|
||||
toc.append((level, title, heading))
|
||||
|
||||
|
||||
return toc
|
||||
|
||||
def get_or_create_style(self,
|
||||
font_family: FontFamily = FontFamily.SERIF,
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
font_style: FontStyle = FontStyle.NORMAL,
|
||||
text_decoration: TextDecoration = TextDecoration.NONE,
|
||||
color: Union[str, Tuple[int, int, int]] = "black",
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
|
||||
language: str = "en-US",
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def get_or_create_style(self,
|
||||
font_family: FontFamily = FontFamily.SERIF,
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
font_style: FontStyle = FontStyle.NORMAL,
|
||||
text_decoration: TextDecoration = TextDecoration.NONE,
|
||||
color: Union[str, Tuple[int, int, int]] = "black",
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
|
||||
language: str = "en-US",
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get or create an abstract style with the specified properties.
|
||||
|
||||
|
||||
Args:
|
||||
font_family: Semantic font family
|
||||
font_size: Font size (semantic or numeric)
|
||||
@@ -333,7 +343,7 @@ class Document(FontRegistry, MetadataContainer):
|
||||
background_color: Background color
|
||||
language: Language code
|
||||
**kwargs: Additional style properties
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (style_id, AbstractStyle)
|
||||
"""
|
||||
@@ -348,34 +358,34 @@ class Document(FontRegistry, MetadataContainer):
|
||||
language=language,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
return self._abstract_style_registry.get_or_create_style(abstract_style)
|
||||
|
||||
|
||||
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
|
||||
"""
|
||||
Get a Font object for an AbstractStyle (for rendering).
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: The abstract style to get a font for
|
||||
|
||||
|
||||
Returns:
|
||||
Font object ready for rendering
|
||||
"""
|
||||
return self._concrete_style_registry.get_font(abstract_style)
|
||||
|
||||
|
||||
def update_rendering_context(self, **kwargs):
|
||||
"""
|
||||
Update the rendering context (user preferences, device settings, etc.).
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
|
||||
"""
|
||||
self._style_resolver.update_context(**kwargs)
|
||||
|
||||
|
||||
def get_style_registry(self) -> AbstractStyleRegistry:
|
||||
"""Get the abstract style registry for this document."""
|
||||
return self._abstract_style_registry
|
||||
|
||||
|
||||
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
|
||||
"""Get the concrete style registry for this document."""
|
||||
return self._concrete_style_registry
|
||||
@@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None,
|
||||
parent=None):
|
||||
"""
|
||||
Initialize a new chapter.
|
||||
|
||||
@@ -408,53 +423,53 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
self._blocks: List[Block] = []
|
||||
self._style = style
|
||||
self._parent = parent
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""Get the chapter title"""
|
||||
return self._title
|
||||
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str):
|
||||
"""Set the chapter title"""
|
||||
self._title = title
|
||||
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
"""Get the chapter level"""
|
||||
return self._level
|
||||
|
||||
|
||||
@property
|
||||
def blocks(self) -> List[Block]:
|
||||
"""Get the blocks in this chapter"""
|
||||
return self._blocks
|
||||
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
"""Get the default style for this chapter"""
|
||||
return self._style
|
||||
|
||||
|
||||
@style.setter
|
||||
def style(self, style):
|
||||
"""Set the default style for this chapter"""
|
||||
self._style = style
|
||||
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
block: The block to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from chapter
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
@@ -463,15 +478,18 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
paragraph = Paragraph(style)
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from chapter
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
@@ -490,12 +508,12 @@ class Book(Document):
|
||||
Abstract representation of an ebook.
|
||||
A book is a document that contains chapters.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
|
||||
|
||||
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
|
||||
language: str = "en-US", default_style=None):
|
||||
"""
|
||||
Initialize a new book.
|
||||
|
||||
|
||||
Args:
|
||||
title: The book title
|
||||
author: The book author
|
||||
@@ -504,33 +522,37 @@ class Book(Document):
|
||||
"""
|
||||
super().__init__(title, language, default_style)
|
||||
self._chapters: List[Chapter] = []
|
||||
|
||||
|
||||
if author:
|
||||
self.set_metadata(MetadataType.AUTHOR, author)
|
||||
|
||||
|
||||
@property
|
||||
def chapters(self) -> List[Chapter]:
|
||||
"""Get the chapters in this book"""
|
||||
return self._chapters
|
||||
|
||||
|
||||
def add_chapter(self, chapter: Chapter):
|
||||
"""
|
||||
Add a chapter to this book.
|
||||
|
||||
|
||||
Args:
|
||||
chapter: The chapter to add
|
||||
"""
|
||||
self._chapters.append(chapter)
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
|
||||
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> Chapter:
|
||||
"""
|
||||
Create and add a new chapter with inherited style.
|
||||
|
||||
|
||||
Args:
|
||||
title: The chapter title
|
||||
level: The chapter level
|
||||
style: Optional style override. If None, inherits from book
|
||||
|
||||
|
||||
Returns:
|
||||
The new chapter
|
||||
"""
|
||||
@@ -539,29 +561,29 @@ class Book(Document):
|
||||
chapter = Chapter(title, level, style)
|
||||
self.add_chapter(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def get_author(self) -> Optional[str]:
|
||||
"""
|
||||
Get the book author.
|
||||
|
||||
|
||||
Returns:
|
||||
The book author, or None if not set
|
||||
"""
|
||||
return self.get_metadata(MetadataType.AUTHOR)
|
||||
|
||||
|
||||
def set_author(self, author: str):
|
||||
"""
|
||||
Set the book author.
|
||||
|
||||
|
||||
Args:
|
||||
author: The book author
|
||||
"""
|
||||
self.set_metadata(MetadataType.AUTHOR, author)
|
||||
|
||||
|
||||
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
|
||||
"""
|
||||
Generate a table of contents from chapters.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of tuples containing (level, title, chapter)
|
||||
"""
|
||||
@@ -569,5 +591,5 @@ class Book(Document):
|
||||
for chapter in self._chapters:
|
||||
if chapter.title:
|
||||
toc.append((chapter.level, chapter.title, chapter))
|
||||
|
||||
|
||||
return toc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Any, Optional, Union, List, Tuple
|
||||
from typing import Callable, Dict, Any, Optional, List, Tuple
|
||||
from pyWebLayout.core.base import Interactable
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class Link(Interactable):
|
||||
Links can be used for navigation within a document, to external resources,
|
||||
or to trigger API calls for functionality like settings management.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
location: str,
|
||||
link_type: LinkType = LinkType.INTERNAL,
|
||||
@@ -43,22 +43,22 @@ class Link(Interactable):
|
||||
self._params = params or {}
|
||||
self._title = title
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Get the target location of this link"""
|
||||
return self._location
|
||||
|
||||
|
||||
@property
|
||||
def link_type(self) -> LinkType:
|
||||
"""Get the type of this link"""
|
||||
return self._link_type
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the parameters for this link"""
|
||||
return self._params
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""Get the title/tooltip for this link"""
|
||||
@@ -95,7 +95,7 @@ class Button(Interactable):
|
||||
A button that can be clicked to execute an action.
|
||||
Buttons are similar to function links but are rendered differently.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
label: str,
|
||||
callback: Callable,
|
||||
@@ -117,27 +117,27 @@ class Button(Interactable):
|
||||
self._params = params or {}
|
||||
self._enabled = enabled
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Get the button label"""
|
||||
return self._label
|
||||
|
||||
|
||||
@label.setter
|
||||
def label(self, label: str):
|
||||
"""Set the button label"""
|
||||
self._label = label
|
||||
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if the button is enabled"""
|
||||
return self._enabled
|
||||
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, enabled: bool):
|
||||
"""Enable or disable the button"""
|
||||
self._enabled = enabled
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the button parameters"""
|
||||
@@ -168,7 +168,7 @@ class Form(Interactable):
|
||||
A form that can contain input fields and be submitted.
|
||||
Forms can be used for user input and settings configuration.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
form_id: str,
|
||||
action: Optional[str] = None,
|
||||
@@ -188,12 +188,12 @@ class Form(Interactable):
|
||||
self._action = action
|
||||
self._fields: Dict[str, FormField] = {}
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def form_id(self) -> str:
|
||||
"""Get the form ID"""
|
||||
return self._form_id
|
||||
|
||||
|
||||
@property
|
||||
def action(self) -> Optional[str]:
|
||||
"""Get the form action"""
|
||||
@@ -207,46 +207,46 @@ class Form(Interactable):
|
||||
def add_field(self, field: FormField):
|
||||
"""
|
||||
Add a field to this form.
|
||||
|
||||
|
||||
Args:
|
||||
field: The FormField to add
|
||||
"""
|
||||
self._fields[field.name] = field
|
||||
field.form = self
|
||||
|
||||
|
||||
def get_field(self, name: str) -> Optional[FormField]:
|
||||
"""
|
||||
Get a field by name.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of the field to get
|
||||
|
||||
|
||||
Returns:
|
||||
The FormField with the specified name, or None if not found
|
||||
"""
|
||||
return self._fields.get(name)
|
||||
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the current values of all fields in this form.
|
||||
|
||||
|
||||
Returns:
|
||||
A dictionary mapping field names to their current values
|
||||
"""
|
||||
return {name: field.value for name, field in self._fields.items()}
|
||||
|
||||
|
||||
def execute(self) -> Any:
|
||||
"""
|
||||
Submit the form, executing the callback with the form values.
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the callback function, or the form values if no callback is provided.
|
||||
"""
|
||||
values = self.get_values()
|
||||
|
||||
|
||||
if self._callback:
|
||||
return self._callback(self._form_id, values)
|
||||
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@@ -272,8 +272,8 @@ class FormField:
|
||||
"""
|
||||
A field in a form that can accept user input.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
field_type: FormFieldType,
|
||||
label: Optional[str] = None,
|
||||
@@ -282,7 +282,7 @@ class FormField:
|
||||
options: Optional[List[Tuple[str, str]]] = None):
|
||||
"""
|
||||
Initialize a form field.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of this field
|
||||
field_type: The type of this field
|
||||
@@ -298,47 +298,47 @@ class FormField:
|
||||
self._required = required
|
||||
self._options = options or []
|
||||
self._form: Optional[Form] = None
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the field name"""
|
||||
return self._name
|
||||
|
||||
|
||||
@property
|
||||
def field_type(self) -> FormFieldType:
|
||||
"""Get the field type"""
|
||||
return self._field_type
|
||||
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Get the field label"""
|
||||
return self._label
|
||||
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
"""Get the current field value"""
|
||||
return self._value
|
||||
|
||||
|
||||
@value.setter
|
||||
def value(self, value: Any):
|
||||
"""Set the field value"""
|
||||
self._value = value
|
||||
|
||||
|
||||
@property
|
||||
def required(self) -> bool:
|
||||
"""Check if the field is required"""
|
||||
return self._required
|
||||
|
||||
|
||||
@property
|
||||
def options(self) -> List[Tuple[str, str]]:
|
||||
"""Get the field options"""
|
||||
return self._options
|
||||
|
||||
|
||||
@property
|
||||
def form(self) -> Optional[Form]:
|
||||
"""Get the form containing this field"""
|
||||
return self._form
|
||||
|
||||
|
||||
@form.setter
|
||||
def form(self, form: Form):
|
||||
"""Set the form containing this field"""
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
from __future__ import annotations
|
||||
from pyWebLayout.core.base import Queriable
|
||||
from pyWebLayout.core import Hierarchical
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from typing import Tuple, Union, List, Optional, Dict, Any, Callable
|
||||
from functools import lru_cache
|
||||
import pyphen
|
||||
|
||||
# Import LinkType for type hints (imported at module level to avoid F821 linting error)
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _hyphen_dict(language: Optional[str]) -> pyphen.Pyphen:
|
||||
"""
|
||||
The pyphen dictionary for a language, reused across words.
|
||||
|
||||
Pyphen caches the parsed dictionary file itself, but rebuilding the wrapper
|
||||
per word still costs about 40% of a hyphenation call, and hyphenation is
|
||||
attempted for every word that overflows its line.
|
||||
"""
|
||||
return pyphen.Pyphen(lang=language)
|
||||
|
||||
|
||||
class Word:
|
||||
"""
|
||||
An abstract representation of a word in a document. Words can be split across
|
||||
lines or pages during rendering. This class manages the logical representation
|
||||
of a word without any rendering specifics.
|
||||
|
||||
|
||||
Now uses AbstractStyle objects for memory efficiency and proper style management.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
style: Union[Font,
|
||||
AbstractStyle],
|
||||
background=None,
|
||||
previous: Union['Word',
|
||||
None] = None):
|
||||
"""
|
||||
Initialize a new Word.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
style: AbstractStyle object or Font object (for backward compatibility)
|
||||
@@ -40,25 +57,25 @@ class Word:
|
||||
previous.add_next(self)
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
||||
background=None) -> 'Word':
|
||||
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
||||
background=None) -> 'Word':
|
||||
"""
|
||||
Create a new Word and add it to a container, inheriting style and language
|
||||
from the container if not explicitly provided.
|
||||
|
||||
|
||||
This method provides a convenient way to create words that automatically
|
||||
inherit styling from their container (Paragraph, FormattedSpan, etc.)
|
||||
without copying string values - using object references instead.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
container: The container to add the word to (must have add_word method and style property)
|
||||
style: Optional Font style override. If None, inherits from container
|
||||
background: Optional background color override. If None, inherits from container
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Word object
|
||||
|
||||
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_word method or style property
|
||||
"""
|
||||
@@ -67,12 +84,13 @@ class Word:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
background = container.background
|
||||
|
||||
|
||||
# Determine the previous word for proper linking
|
||||
previous = None
|
||||
if hasattr(container, '_words') and container._words:
|
||||
@@ -86,21 +104,21 @@ class Word:
|
||||
previous = word
|
||||
except (StopIteration, TypeError):
|
||||
previous = None
|
||||
|
||||
|
||||
# Create the new word
|
||||
word = cls(text, style, background, previous)
|
||||
|
||||
|
||||
# Link the previous word to this new one
|
||||
if previous:
|
||||
previous.add_next(word)
|
||||
|
||||
|
||||
# Add the word to the container
|
||||
if hasattr(container, 'add_word'):
|
||||
# Check if add_word expects a Word object or text string
|
||||
import inspect
|
||||
sig = inspect.signature(container.add_word)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
|
||||
if len(params) > 0:
|
||||
# Peek at the parameter name to guess the expected type
|
||||
param_name = params[0]
|
||||
@@ -110,7 +128,8 @@ class Word:
|
||||
else:
|
||||
# Might expect text string (like FormattedSpan.add_word)
|
||||
# In this case, we can't use the container's add_word as it would create
|
||||
# a duplicate Word. We need to add directly to the container's word list.
|
||||
# a duplicate Word. We need to add directly to the container's word
|
||||
# list.
|
||||
if hasattr(container, '_words'):
|
||||
container._words.append(word)
|
||||
else:
|
||||
@@ -120,72 +139,82 @@ class Word:
|
||||
# No parameters, shouldn't happen with add_word methods
|
||||
container.add_word(word)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have an 'add_word' method")
|
||||
|
||||
return word
|
||||
|
||||
|
||||
def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
|
||||
|
||||
def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
|
||||
self.concrete = text
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get the text content of the word"""
|
||||
return self._text
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> Font:
|
||||
"""Get the font style of the word"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color of the word"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def previous(self) -> Union['Word', None]:
|
||||
"""Get the previous word in sequence"""
|
||||
return self._previous
|
||||
|
||||
|
||||
@property
|
||||
def next(self) -> Union['Word', None]:
|
||||
"""Get the next word in sequence"""
|
||||
return self._next
|
||||
|
||||
|
||||
def add_next(self, next_word: 'Word'):
|
||||
"""Set the next word in sequence"""
|
||||
self._next = next_word
|
||||
|
||||
|
||||
|
||||
def with_style(self, style: Font) -> 'Word':
|
||||
"""
|
||||
Return a copy of this word carrying a different font.
|
||||
|
||||
Subclasses that hold extra state must override this, or that state is
|
||||
silently dropped when a caller restyles the word. Sequence links
|
||||
(previous/next) are deliberately not copied: the copy belongs to a
|
||||
different word chain, which the new container rebuilds as words are
|
||||
added to it.
|
||||
"""
|
||||
return Word(self._text, style, self._background)
|
||||
|
||||
def possible_hyphenation(self, language: str = None) -> bool:
|
||||
"""
|
||||
Hyphenate the word and store the parts.
|
||||
|
||||
|
||||
Args:
|
||||
language: Language code for hyphenation. If None, uses the style's language.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the word was hyphenated, False otherwise.
|
||||
"""
|
||||
|
||||
dic = pyphen.Pyphen(lang=self._style.language)
|
||||
return list(dic.iterate(self._text))
|
||||
...
|
||||
return list(_hyphen_dict(self._style.language).iterate(self._text))
|
||||
|
||||
|
||||
...
|
||||
|
||||
|
||||
class FormattedSpan:
|
||||
"""
|
||||
A run of words with consistent formatting.
|
||||
This represents a sequence of words that share the same style attributes.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, style: Font, background=None):
|
||||
"""
|
||||
Initialize a new formatted span.
|
||||
|
||||
|
||||
Args:
|
||||
style: Font style information for all words in this span
|
||||
background: Optional background color override
|
||||
@@ -193,21 +222,25 @@ class FormattedSpan:
|
||||
self._style = style
|
||||
self._background = background if background else style.background
|
||||
self._words: List[Word] = []
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
style: Optional[Font] = None,
|
||||
background=None) -> 'FormattedSpan':
|
||||
"""
|
||||
Create a new FormattedSpan and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
|
||||
|
||||
Args:
|
||||
container: The container to add the span to (must have add_span method and style property)
|
||||
style: Optional Font style override. If None, inherits from container
|
||||
background: Optional background color override
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created FormattedSpan object
|
||||
|
||||
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_span method or style property
|
||||
"""
|
||||
@@ -216,72 +249,74 @@ class FormattedSpan:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
background = container.background
|
||||
|
||||
|
||||
# Create the new span
|
||||
span = cls(style, background)
|
||||
|
||||
|
||||
# Add the span to the container
|
||||
if hasattr(container, 'add_span'):
|
||||
container.add_span(span)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have an 'add_span' method")
|
||||
|
||||
return span
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> Font:
|
||||
"""Get the font style of this span"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color of this span"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def words(self) -> List[Word]:
|
||||
"""Get the list of words in this span"""
|
||||
return self._words
|
||||
|
||||
|
||||
def add_word(self, text: str) -> Word:
|
||||
"""
|
||||
Create and add a new word to this span.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Word object
|
||||
"""
|
||||
# Get the previous word if any
|
||||
previous = self._words[-1] if self._words else None
|
||||
|
||||
|
||||
# Create the new word
|
||||
word = Word(text, self._style, self._background, previous)
|
||||
|
||||
|
||||
# Link the previous word to this new one
|
||||
if previous:
|
||||
previous.add_next(word)
|
||||
|
||||
|
||||
# Add the word to our list
|
||||
self._words.append(word)
|
||||
|
||||
|
||||
return word
|
||||
|
||||
|
||||
class LinkedWord(Word):
|
||||
"""
|
||||
A Word that is also a Link - combines text content with hyperlink functionality.
|
||||
|
||||
|
||||
When a word is part of a hyperlink, it becomes clickable and can trigger
|
||||
navigation or callbacks. Multiple words can share the same link destination.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
|
||||
location: str, link_type: Optional['LinkType'] = None,
|
||||
callback: Optional[Callable] = None,
|
||||
@@ -290,7 +325,7 @@ class LinkedWord(Word):
|
||||
title: Optional[str] = None):
|
||||
"""
|
||||
Initialize a linked word.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
style: The font style
|
||||
@@ -304,46 +339,59 @@ class LinkedWord(Word):
|
||||
"""
|
||||
# Initialize Word first
|
||||
super().__init__(text, style, background, previous)
|
||||
|
||||
|
||||
# Store link properties
|
||||
self._location = location
|
||||
self._link_type = link_type or LinkType.EXTERNAL
|
||||
self._callback = callback
|
||||
self._params = params or {}
|
||||
self._title = title
|
||||
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Get the link target location"""
|
||||
return self._location
|
||||
|
||||
|
||||
@property
|
||||
def link_type(self):
|
||||
"""Get the type of link"""
|
||||
return self._link_type
|
||||
|
||||
|
||||
@property
|
||||
def link_callback(self) -> Optional[Callable]:
|
||||
"""Get the link callback (distinct from word callback)"""
|
||||
return self._callback
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the link parameters"""
|
||||
return self._params
|
||||
|
||||
|
||||
@property
|
||||
def link_title(self) -> Optional[str]:
|
||||
"""Get the link title/tooltip"""
|
||||
return self._title
|
||||
|
||||
|
||||
def with_style(self, style: Font) -> 'LinkedWord':
|
||||
"""Return a copy carrying a different font, keeping the link intact."""
|
||||
return LinkedWord(
|
||||
self._text,
|
||||
style,
|
||||
self._location,
|
||||
link_type=self._link_type,
|
||||
callback=self._callback,
|
||||
background=self._background,
|
||||
params=dict(self._params),
|
||||
title=self._title,
|
||||
)
|
||||
|
||||
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""
|
||||
Execute the link action.
|
||||
|
||||
|
||||
Args:
|
||||
context: Optional context dict (e.g., {'text': word.text})
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the link execution
|
||||
"""
|
||||
@@ -351,7 +399,7 @@ class LinkedWord(Word):
|
||||
full_context = {**self._params, 'text': self._text}
|
||||
if context:
|
||||
full_context.update(context)
|
||||
|
||||
|
||||
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
|
||||
return self._callback(self._location, **full_context)
|
||||
else:
|
||||
@@ -379,21 +427,21 @@ class LineBreak(Hierarchical):
|
||||
def block_type(self):
|
||||
"""Get the block type for this line break"""
|
||||
return self._block_type
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container) -> 'LineBreak':
|
||||
"""
|
||||
Create a new LineBreak and add it to a container.
|
||||
|
||||
|
||||
Args:
|
||||
container: The container to add the line break to
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created LineBreak object
|
||||
"""
|
||||
# Create the new line break
|
||||
line_break = cls()
|
||||
|
||||
|
||||
# Add the line break to the container if it has an appropriate method
|
||||
if hasattr(container, 'add_line_break'):
|
||||
container.add_line_break(line_break)
|
||||
@@ -405,5 +453,5 @@ class LineBreak(Hierarchical):
|
||||
else:
|
||||
# Set parent relationship manually
|
||||
line_break.parent = container
|
||||
|
||||
|
||||
return line_break
|
||||
|
||||
@@ -9,7 +9,7 @@ proper bounding box detection.
|
||||
from typing import Optional, Callable, Tuple
|
||||
import numpy as np
|
||||
|
||||
from .block import Image, BlockType
|
||||
from .block import Image
|
||||
from ..core.base import Interactable, Queriable
|
||||
|
||||
|
||||
@@ -54,7 +54,12 @@ class InteractiveImage(Image, Interactable, Queriable):
|
||||
callback: Function to call when image is tapped (receives point coordinates)
|
||||
"""
|
||||
# Initialize Image
|
||||
Image.__init__(self, source=source, alt_text=alt_text, width=width, height=height)
|
||||
Image.__init__(
|
||||
self,
|
||||
source=source,
|
||||
alt_text=alt_text,
|
||||
width=width,
|
||||
height=height)
|
||||
|
||||
# Initialize Interactable
|
||||
Interactable.__init__(self, callback=callback)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and
|
||||
associated documentation files (the "Font Software"), to reproduce
|
||||
and distribute the modifications to the Bitstream Vera Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be included in all copies of one or more of the Font Software
|
||||
typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in
|
||||
particular the designs of glyphs or characters in the Fonts may be
|
||||
modified and additional glyphs or characters may be added to the
|
||||
Fonts, only if the fonts are renamed to names not containing either
|
||||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts
|
||||
or Font Software that has been modified and is distributed under the
|
||||
"Tavmjong Bah Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy of one or more of the Font Software typefaces may be sold by
|
||||
itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||
be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in this Font Software without prior written authorization
|
||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||
. fr.
|
||||
|
||||
TeX Gyre DJV Math
|
||||
-----------------
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
|
||||
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||
(on behalf of TeX users groups) are in public domain.
|
||||
|
||||
Letters imported from Euler Fraktur from AMSfonts are (c) American
|
||||
Mathematical Society (see below).
|
||||
Bitstream Vera Fonts Copyright
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
|
||||
is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license (“Fonts”) and associated
|
||||
documentation
|
||||
files (the “Font Software”), to reproduce and distribute the Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute,
|
||||
and/or sell copies of the Font Software, and to permit persons to whom
|
||||
the Font Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be
|
||||
included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional
|
||||
glyphs or characters may be added to the Fonts, only if the fonts are
|
||||
renamed
|
||||
to names not containing either the words “Bitstream” or the word “Vera”.
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or
|
||||
Font Software
|
||||
that has been modified and is distributed under the “Bitstream Vera”
|
||||
names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy
|
||||
of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
|
||||
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
|
||||
ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
|
||||
INABILITY TO USE
|
||||
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Except as contained in this notice, the names of GNOME, the GNOME
|
||||
Foundation,
|
||||
and Bitstream Inc., shall not be used in advertising or otherwise to promote
|
||||
the sale, use or other dealings in this Font Software without prior written
|
||||
authorization from the GNOME Foundation or Bitstream Inc., respectively.
|
||||
For further information, contact: fonts at gnome dot org.
|
||||
|
||||
AMSFonts (v. 2.2) copyright
|
||||
|
||||
The PostScript Type 1 implementation of the AMSFonts produced by and
|
||||
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
|
||||
available for general use. This has been accomplished through the
|
||||
cooperation
|
||||
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
|
||||
Members of this consortium include:
|
||||
|
||||
Elsevier Science IBM Corporation Society for Industrial and Applied
|
||||
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
|
||||
|
||||
In order to assure the authenticity of these fonts, copyright will be
|
||||
held by
|
||||
the American Mathematical Society. This is not meant to restrict in any way
|
||||
the legitimate use of the fonts, such as (but not limited to) electronic
|
||||
distribution of documents containing these fonts, inclusion of these fonts
|
||||
into other public domain or commercial font collections or computer
|
||||
applications, use of the outline data to create derivative fonts and/or
|
||||
faces, etc. However, the AMS does require that the AMS copyright notice be
|
||||
removed from any derivative versions of the fonts which have been altered in
|
||||
any way. In addition, to ensure the fidelity of TeX documents using Computer
|
||||
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
|
||||
has requested that any alterations which yield different font metrics be
|
||||
given a different name.
|
||||
|
||||
$Id$
|
||||
@@ -0,0 +1,67 @@
|
||||
[](https://travis-ci.org/dejavu-fonts/dejavu-fonts)
|
||||
|
||||
DejaVu fonts 2.37 (c)2004-2016 DejaVu fonts team
|
||||
------------------------------------------------
|
||||
|
||||
The DejaVu fonts are a font family based on the Bitstream Vera Fonts
|
||||
(http://gnome.org/fonts/). Its purpose is to provide a wider range of
|
||||
characters (see status.txt for more information) while maintaining the
|
||||
original look and feel.
|
||||
|
||||
DejaVu fonts are based on Bitstream Vera fonts version 1.10.
|
||||
|
||||
Available fonts (Sans = sans serif, Mono = monospaced):
|
||||
|
||||
DejaVu Sans Mono
|
||||
DejaVu Sans Mono Bold
|
||||
DejaVu Sans Mono Bold Oblique
|
||||
DejaVu Sans Mono Oblique
|
||||
DejaVu Sans
|
||||
DejaVu Sans Bold
|
||||
DejaVu Sans Bold Oblique
|
||||
DejaVu Sans Oblique
|
||||
DejaVu Sans ExtraLight (experimental)
|
||||
DejaVu Serif
|
||||
DejaVu Serif Bold
|
||||
DejaVu Serif Bold Italic (experimental)
|
||||
DejaVu Serif Italic (experimental)
|
||||
DejaVu Sans Condensed (experimental)
|
||||
DejaVu Sans Condensed Bold (experimental)
|
||||
DejaVu Sans Condensed Bold Oblique (experimental)
|
||||
DejaVu Sans Condensed Oblique (experimental)
|
||||
DejaVu Serif Condensed (experimental)
|
||||
DejaVu Serif Condensed Bold (experimental)
|
||||
DejaVu Serif Condensed Bold Italic (experimental)
|
||||
DejaVu Serif Condensed Italic (experimental)
|
||||
DejaVu Math TeX Gyre
|
||||
|
||||
All fonts are also available as derivative called DejaVu LGC with support
|
||||
only for Latin, Greek and Cyrillic scripts.
|
||||
|
||||
For license information see LICENSE. What's new is described in NEWS. Known
|
||||
bugs are in BUGS. All authors are mentioned in AUTHORS.
|
||||
|
||||
Fonts are published in source form as SFD files (Spline Font Database from
|
||||
FontForge - http://fontforge.sf.net/) and in compiled form as TTF files
|
||||
(TrueType fonts).
|
||||
|
||||
For more information go to http://dejavu.sourceforge.net/.
|
||||
|
||||
Characters from Arev fonts, Copyright (c) 2006 by Tavmjong Bah:
|
||||
---------------------------
|
||||
U+01BA, U+01BF, U+01F7, U+021C-U+021D, U+0220, U+0222-U+0223,
|
||||
U+02B9, U+02BA, U+02BD, U+02C2-U+02C5, U+02d4-U+02D5,
|
||||
U+02D7, U+02EC-U+02EE, U+0346-U+034E, U+0360, U+0362,
|
||||
U+03E2-03EF, U+0460-0463, U+0466-U+0486, U+0488-U+0489, U+04A8-U+04A9,
|
||||
U+0500-U+050F, U+2055-205E, U+20B0, U+20B2-U+20B3, U+2102, U+210D, U+210F,
|
||||
U+2111, U+2113, U+2115, U+2118-U+211A, U+211C-U+211D, U+2124, U+2135,
|
||||
U+213C-U+2140, U+2295-U+2298, U+2308-U+230B, U+26A2-U+26B1, U+2701-U+2704,
|
||||
U+2706-U+2709, U+270C-U+274B, U+2758-U+275A, U+2761-U+2775, U+2780-U+2794,
|
||||
U+2798-U+27AF, U+27B1-U+27BE, U+FB05-U+FB06
|
||||
|
||||
DejaVu Math TeX Gyre
|
||||
--------------------
|
||||
TeX Gyre DJV Math by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||
(on behalf of TeX users groups).
|
||||
|
||||
$Id$
|
||||
@@ -0,0 +1,129 @@
|
||||
# Bundled Fonts
|
||||
|
||||
This directory contains free, open-source TrueType fonts bundled with pyWebLayout for consistent rendering across all platforms.
|
||||
|
||||
## Font Families
|
||||
|
||||
### DejaVu Sans (Sans-serif)
|
||||
A modern, clean sans-serif font excellent for body text and UI elements.
|
||||
|
||||
- `DejaVuSans.ttf` - Regular
|
||||
- `DejaVuSans-Bold.ttf` - Bold
|
||||
- `DejaVuSans-Oblique.ttf` - Italic
|
||||
- `DejaVuSans-BoldOblique.ttf` - Bold Italic
|
||||
|
||||
### DejaVu Serif (Serif)
|
||||
A classic serif font ideal for formal documents and traditional layouts.
|
||||
|
||||
- `DejaVuSerif.ttf` - Regular
|
||||
- `DejaVuSerif-Bold.ttf` - Bold
|
||||
- `DejaVuSerif-Italic.ttf` - Italic
|
||||
- `DejaVuSerif-BoldItalic.ttf` - Bold Italic
|
||||
|
||||
### DejaVu Sans Mono (Monospace)
|
||||
A fixed-width font perfect for code blocks and technical content.
|
||||
|
||||
- `DejaVuSansMono.ttf` - Regular
|
||||
- `DejaVuSansMono-Bold.ttf` - Bold
|
||||
- `DejaVuSansMono-Oblique.ttf` - Italic
|
||||
- `DejaVuSansMono-BoldOblique.ttf` - Bold Italic
|
||||
|
||||
## Usage
|
||||
|
||||
### Easy Way: Using Font.from_family() (Recommended)
|
||||
|
||||
The easiest way to use bundled fonts is with the `Font.from_family()` class method:
|
||||
|
||||
```python
|
||||
from pyWebLayout.style import Font, BundledFont, FontWeight, FontStyle
|
||||
|
||||
# Create a sans-serif font
|
||||
sans_font = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=16
|
||||
)
|
||||
|
||||
# Create a bold serif font
|
||||
serif_bold = Font.from_family(
|
||||
BundledFont.SERIF,
|
||||
font_size=18,
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
# Create an italic monospace font
|
||||
mono_italic = Font.from_family(
|
||||
BundledFont.MONOSPACE,
|
||||
font_size=14,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
|
||||
# Create a bold italic sans font
|
||||
sans_bold_italic = Font.from_family(
|
||||
BundledFont.SANS,
|
||||
font_size=16,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
```
|
||||
|
||||
### Manual Way: Using get_bundled_font_path()
|
||||
|
||||
You can also get the path to bundled fonts directly:
|
||||
|
||||
```python
|
||||
from pyWebLayout.style import Font, BundledFont, FontWeight, FontStyle, get_bundled_font_path
|
||||
|
||||
# Get the path to a specific font
|
||||
font_path = get_bundled_font_path(
|
||||
BundledFont.SERIF,
|
||||
weight=FontWeight.BOLD,
|
||||
style=FontStyle.ITALIC
|
||||
)
|
||||
|
||||
# Create a font with that path
|
||||
font = Font(font_path=font_path, font_size=16)
|
||||
```
|
||||
|
||||
### Low-level Way: Direct Paths
|
||||
|
||||
If you prefer to specify paths directly:
|
||||
|
||||
```python
|
||||
import os
|
||||
from pyWebLayout.style import Font, get_bundled_fonts_dir
|
||||
|
||||
# Get the fonts directory
|
||||
fonts_dir = get_bundled_fonts_dir()
|
||||
|
||||
# Use specific font files
|
||||
sans_font = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSans.ttf'),
|
||||
font_size=16
|
||||
)
|
||||
|
||||
serif_bold = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSerif-Bold.ttf'),
|
||||
font_size=18
|
||||
)
|
||||
|
||||
mono_italic = Font(
|
||||
font_path=os.path.join(fonts_dir, 'DejaVuSansMono-Oblique.ttf'),
|
||||
font_size=14
|
||||
)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
The DejaVu fonts are free software under the terms of the Bitstream Vera Fonts Copyright and the Arev Fonts Copyright.
|
||||
|
||||
See `DEJAVU_LICENSE.txt` for full license details.
|
||||
|
||||
## About DejaVu Fonts
|
||||
|
||||
DejaVu fonts are a font family based on the Bitstream Vera Fonts. Its purpose is to provide a wider range of characters while maintaining the original look and feel through the process of collaborative development.
|
||||
|
||||
- **Version**: 2.37
|
||||
- **Website**: https://dejavu-fonts.github.io/
|
||||
- **Repository**: https://github.com/dejavu-fonts/dejavu-fonts
|
||||
|
||||
The fonts provide excellent Unicode coverage and are widely used in open-source projects.
|
||||
@@ -1,6 +1,36 @@
|
||||
"""
|
||||
Concrete layer for the pyWebLayout library.
|
||||
|
||||
This package contains concrete implementations that can be directly rendered.
|
||||
"""
|
||||
|
||||
from .text import (
|
||||
Text,
|
||||
Line,
|
||||
configure_text_caches,
|
||||
clear_text_caches,
|
||||
text_cache_stats,
|
||||
prewarm_text_caches,
|
||||
)
|
||||
from .box import Box
|
||||
from .page import Page
|
||||
from .text import Text, Line
|
||||
from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text
|
||||
from .image import RenderableImage
|
||||
from .table import TableRenderer, TableRowRenderer, TableCellRenderer, TableStyle
|
||||
from .page import Page
|
||||
from pyWebLayout.abstract.block import Table, TableRow as Row, TableCell as Cell
|
||||
from .functional import LinkText, ButtonText
|
||||
|
||||
__all__ = [
|
||||
'Text',
|
||||
'Line',
|
||||
'Box',
|
||||
'RenderableImage',
|
||||
'Page',
|
||||
'Table',
|
||||
'Row',
|
||||
'Cell',
|
||||
'LinkText',
|
||||
'ButtonText',
|
||||
'configure_text_caches',
|
||||
'clear_text_caches',
|
||||
'text_cache_stats',
|
||||
'prewarm_text_caches',
|
||||
]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, Union, List, Optional, Dict
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core import Geometric
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class Box(Geometric, Renderable, Queriable):
|
||||
"""
|
||||
A box with geometric properties (origin and size).
|
||||
@@ -14,12 +14,20 @@ class Box(Geometric, Renderable, Queriable):
|
||||
Uses Geometric mixin for origin and size management.
|
||||
"""
|
||||
|
||||
def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
|
||||
def __init__(
|
||||
self,
|
||||
origin,
|
||||
size,
|
||||
callback=None,
|
||||
sheet: Image = None,
|
||||
mode: bool = None,
|
||||
halign=Alignment.CENTER,
|
||||
valign=Alignment.CENTER):
|
||||
super().__init__(origin=origin, size=size)
|
||||
self._end = self._origin + self._size
|
||||
self._end = self._origin + self._size
|
||||
self._callback = callback
|
||||
self._sheet : Image = sheet
|
||||
if self._sheet == None:
|
||||
self._sheet: Image = sheet
|
||||
if self._sheet is None:
|
||||
self._mode = mode
|
||||
else:
|
||||
self._mode = sheet.mode
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Dict, Any, Tuple, List, Union
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Interactable, Queriable
|
||||
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
|
||||
from pyWebLayout.style import Font, TextDecoration
|
||||
from .text import Text
|
||||
|
||||
@@ -14,12 +14,12 @@ class LinkText(Text, Interactable, Queriable):
|
||||
A Text subclass that can handle Link interactions.
|
||||
Combines text rendering with clickable link functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
|
||||
source=None, line=None):
|
||||
|
||||
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
|
||||
source=None, line=None, page=None):
|
||||
"""
|
||||
Initialize a linkable text object.
|
||||
|
||||
|
||||
Args:
|
||||
link: The abstract Link object to handle interactions
|
||||
text: The text content to render
|
||||
@@ -27,72 +27,95 @@ class LinkText(Text, Interactable, Queriable):
|
||||
draw: The drawing context
|
||||
source: Optional source object
|
||||
line: Optional line container
|
||||
page: Optional parent page (for dirty flag management)
|
||||
"""
|
||||
# Create link-styled font (underlined and colored based on link type)
|
||||
link_font = font.with_decoration(TextDecoration.UNDERLINE)
|
||||
if link.link_type == LinkType.INTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
|
||||
elif link.link_type == LinkType.EXTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
|
||||
link_font = link_font.with_colour(
|
||||
(0, 0, 180)) # Darker blue for external links
|
||||
elif link.link_type == LinkType.API:
|
||||
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
|
||||
elif link.link_type == LinkType.FUNCTION:
|
||||
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
|
||||
|
||||
|
||||
# Initialize Text with the styled font
|
||||
Text.__init__(self, text, link_font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable with the link's execute method
|
||||
Interactable.__init__(self, link.execute)
|
||||
|
||||
# Store the link object
|
||||
|
||||
# Store the link object and page reference
|
||||
self._link = link
|
||||
self._page = page
|
||||
self._hovered = False
|
||||
|
||||
self._pressed = False
|
||||
|
||||
# Ensure _origin is initialized as numpy array
|
||||
if not hasattr(self, '_origin') or self._origin is None:
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
|
||||
@property
|
||||
def link(self) -> Link:
|
||||
"""Get the associated Link object"""
|
||||
return self._link
|
||||
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state for visual feedback"""
|
||||
self._hovered = hovered
|
||||
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set the pressed state for visual feedback"""
|
||||
self._pressed = pressed
|
||||
self._mark_page_dirty()
|
||||
|
||||
def _mark_page_dirty(self):
|
||||
"""Mark the parent page as dirty if available"""
|
||||
if self._page and hasattr(self._page, 'mark_dirty'):
|
||||
self._page.mark_dirty()
|
||||
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Render the link text with optional hover effects.
|
||||
Render the link text with optional hover and pressed effects.
|
||||
|
||||
Args:
|
||||
next_text: The next Text object in the line (if any)
|
||||
spacing: The spacing to the next text object
|
||||
"""
|
||||
# Handle mock objects in tests
|
||||
size = self.size
|
||||
if hasattr(size, '__call__'): # It's a Mock
|
||||
# Use default size for tests
|
||||
size = np.array([100, 20])
|
||||
else:
|
||||
size = np.array(size)
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(
|
||||
self._origin) if not isinstance(
|
||||
self._origin,
|
||||
np.ndarray) else self._origin
|
||||
|
||||
# Draw background based on state (before text is rendered).
|
||||
# PIL wants a flat sequence of four scalars; handing it a list of two
|
||||
# numpy arrays raises "coordinate list must contain exactly 2
|
||||
# coordinates".
|
||||
if self._pressed or self._hovered:
|
||||
far = origin + size
|
||||
box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
|
||||
if self._pressed:
|
||||
# Pressed state - stronger, darker highlight
|
||||
bg_color = (180, 180, 255, 180)
|
||||
else:
|
||||
# Hover state - subtle highlight
|
||||
bg_color = (220, 220, 255, 100)
|
||||
self._draw.rectangle(box, fill=bg_color)
|
||||
|
||||
# Call the parent Text render method with parameters
|
||||
super().render(next_text, spacing)
|
||||
|
||||
# Add hover effect if needed
|
||||
if self._hovered:
|
||||
# Draw a subtle highlight background
|
||||
highlight_color = (220, 220, 255, 100) # Light blue with alpha
|
||||
|
||||
# Handle mock objects in tests
|
||||
size = self.size
|
||||
if hasattr(size, '__call__'): # It's a Mock
|
||||
# Use default size for tests
|
||||
size = np.array([100, 20])
|
||||
else:
|
||||
size = np.array(size)
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
|
||||
|
||||
self._draw.rectangle([origin, origin + size],
|
||||
fill=highlight_color)
|
||||
|
||||
|
||||
|
||||
class ButtonText(Text, Interactable, Queriable):
|
||||
@@ -100,13 +123,13 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
A Text subclass that can handle Button interactions.
|
||||
Renders text as a clickable button with visual states.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
|
||||
source=None, line=None):
|
||||
source=None, line=None, page=None):
|
||||
"""
|
||||
Initialize a button text object.
|
||||
|
||||
|
||||
Args:
|
||||
button: The abstract Button object to handle interactions
|
||||
font: The base font style
|
||||
@@ -114,43 +137,77 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
padding: Padding around the button text (top, right, bottom, left)
|
||||
source: Optional source object
|
||||
line: Optional line container
|
||||
page: Optional parent page (for dirty flag management)
|
||||
"""
|
||||
# Initialize Text with the button label
|
||||
Text.__init__(self, button.label, font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable with the button's execute method
|
||||
Interactable.__init__(self, button.execute)
|
||||
|
||||
|
||||
# Store button properties
|
||||
self._button = button
|
||||
self._padding = padding
|
||||
self._page = page
|
||||
self._pressed = False
|
||||
self._hovered = False
|
||||
|
||||
|
||||
# Recalculate dimensions to include padding
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._padded_width = text_width + padding[1] + padding[3]
|
||||
self._padded_height = self._style.font_size + padding[0] + padding[2]
|
||||
|
||||
|
||||
# Size the button from the text's visual height (ascent + descent), not
|
||||
# from the nominal font size. The two differ by several pixels - DejaVu at
|
||||
# 14px measures 17 - so sizing by font_size leaves the button too short to
|
||||
# centre its own label in.
|
||||
self._text_height = self._visual_text_height()
|
||||
self._padded_height = self._text_height + padding[0] + padding[2]
|
||||
|
||||
def _visual_text_height(self) -> int:
|
||||
"""Height of the rendered text, ascender to descender."""
|
||||
try:
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
return int(ascent + descent)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Mock or unusual font object; the nominal size is the best guess.
|
||||
return int(getattr(self._style, 'font_size', 0) or 0)
|
||||
|
||||
@property
|
||||
def button(self) -> Button:
|
||||
"""Get the associated Button object"""
|
||||
return self._button
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the padded size of the button"""
|
||||
return np.array([self._padded_width, self._padded_height])
|
||||
|
||||
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set the pressed state"""
|
||||
self._pressed = pressed
|
||||
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state"""
|
||||
self._hovered = hovered
|
||||
|
||||
self._mark_page_dirty()
|
||||
|
||||
def set_page(self, page):
|
||||
"""
|
||||
Set the parent page reference for dirty flag management.
|
||||
|
||||
Args:
|
||||
page: The Page object containing this element
|
||||
"""
|
||||
self._page = page
|
||||
|
||||
def _mark_page_dirty(self):
|
||||
"""Mark the parent page as dirty if available"""
|
||||
if self._page and hasattr(self._page, 'mark_dirty'):
|
||||
self._page.mark_dirty()
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
@@ -177,7 +234,7 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
bg_color = (100, 150, 200)
|
||||
border_color = (70, 120, 170)
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
|
||||
# Draw button background with rounded corners
|
||||
# rounded_rectangle expects [x0, y0, x1, y1] format
|
||||
button_rect = [
|
||||
@@ -187,8 +244,8 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
int(self._origin[1] + self.size[1])
|
||||
]
|
||||
self._draw.rounded_rectangle(button_rect, fill=bg_color,
|
||||
outline=border_color, width=1, radius=4)
|
||||
|
||||
outline=border_color, width=1, radius=4)
|
||||
|
||||
# Update text color and render text centered within padding
|
||||
self._style = self._style.with_colour(text_color)
|
||||
text_x = self._origin[0] + self._padding[3] # left padding
|
||||
@@ -200,37 +257,44 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
# Total button height minus top and bottom padding gives us text area height
|
||||
text_area_height = self._padded_height - self._padding[0] - self._padding[2]
|
||||
|
||||
# Center the text visual height (ascent + descent) within the text area
|
||||
# The y position is where the baseline sits
|
||||
# Visual center = area_height/2, baseline should be at center + descent/2
|
||||
vertical_center = text_area_height / 2
|
||||
text_y = self._origin[1] + self._padding[0] + vertical_center + (descent / 2)
|
||||
# Centre the text's visual height (ascent + descent) within the text area.
|
||||
# text_y is the baseline, since Text renders with anchor "ls".
|
||||
#
|
||||
# top of glyphs = area_top + (area_height - (ascent + descent)) / 2
|
||||
# baseline = top of glyphs + ascent
|
||||
#
|
||||
# The previous form, area_top + area_height/2 + descent/2, is only
|
||||
# equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
|
||||
# label rendered several pixels above centre, against the top edge.
|
||||
text_top = self._origin[1] + self._padding[0] \
|
||||
+ (text_area_height - (ascent + descent)) / 2
|
||||
text_y = text_top + ascent
|
||||
|
||||
# Temporarily set origin for text rendering
|
||||
original_origin = self._origin.copy()
|
||||
self._origin = np.array([text_x, text_y])
|
||||
|
||||
|
||||
# Call parent render method for the text
|
||||
super().render()
|
||||
|
||||
|
||||
# Restore original origin
|
||||
self._origin = original_origin
|
||||
|
||||
|
||||
def in_object(self, point) -> bool:
|
||||
"""
|
||||
Check if a point is within this button.
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the button bounds (including padding)
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the padded button boundaries
|
||||
return (0 <= relative_point[0] < self._padded_width and
|
||||
return (0 <= relative_point[0] < self._padded_width and
|
||||
0 <= relative_point[1] < self._padded_height)
|
||||
|
||||
|
||||
@@ -238,13 +302,22 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
"""
|
||||
A Text subclass that can handle FormField interactions.
|
||||
Renders form field labels and input areas.
|
||||
|
||||
The origin is the top-left of the whole control: label, then a gap, then the
|
||||
input box. Text itself draws from a baseline, so the label is offset down by
|
||||
its ascent when rendering; without that the glyphs would sit above the origin
|
||||
and overprint whatever is above, which for a stacked form is the previous
|
||||
field's input box.
|
||||
"""
|
||||
|
||||
|
||||
# Vertical gap between the label and its input box, in pixels.
|
||||
LABEL_GAP = 5
|
||||
|
||||
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||
field_height: int = 24, source=None, line=None):
|
||||
"""
|
||||
Initialize a form field text object.
|
||||
|
||||
|
||||
Args:
|
||||
field: The abstract FormField object to handle interactions
|
||||
font: The base font style for the label
|
||||
@@ -255,132 +328,162 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
"""
|
||||
# Initialize Text with the field label
|
||||
Text.__init__(self, field.label, font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable - form fields don't have direct callbacks
|
||||
# but can notify of focus/value changes
|
||||
Interactable.__init__(self, None)
|
||||
|
||||
|
||||
# Store field properties
|
||||
self._field = field
|
||||
self._field_height = field_height
|
||||
self._focused = False
|
||||
|
||||
# Calculate total height (label + gap + field)
|
||||
self._total_height = self._style.font_size + 5 + field_height
|
||||
|
||||
|
||||
# Calculate total height (label + gap + field). The label's height is its
|
||||
# ink height, ascender to descender, not the nominal font size - the two
|
||||
# differ by several pixels and the gap between label and box is only 5.
|
||||
self._label_height = self._visual_label_height()
|
||||
self._total_height = self._label_height + self.LABEL_GAP + field_height
|
||||
|
||||
# Field width should be at least as wide as the label
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._field_width = max(text_width, 150)
|
||||
|
||||
|
||||
def _visual_label_height(self) -> int:
|
||||
"""Height of the rendered label, ascender to descender."""
|
||||
try:
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
return int(ascent + descent)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Mock or unusual font object; the nominal size is the best guess.
|
||||
return int(getattr(self._style, 'font_size', 0) or 0)
|
||||
|
||||
@property
|
||||
def field_area_offset(self) -> int:
|
||||
"""Distance from this control's origin to the top of its input box."""
|
||||
return self._label_height + self.LABEL_GAP
|
||||
|
||||
@property
|
||||
def field(self) -> FormField:
|
||||
"""Get the associated FormField object"""
|
||||
return self._field
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the total size including label and field"""
|
||||
return np.array([self._field_width, self._total_height])
|
||||
|
||||
|
||||
def set_focused(self, focused: bool):
|
||||
"""Set the focus state"""
|
||||
self._focused = focused
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
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
|
||||
# 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
|
||||
|
||||
label_origin = self._origin
|
||||
self._origin = np.array([label_origin[0], label_origin[1] + label_ascent])
|
||||
super().render()
|
||||
|
||||
# Calculate field position (below label with 5px gap)
|
||||
self._origin = label_origin
|
||||
|
||||
# Calculate field position (below the label, with the standard gap)
|
||||
field_x = self._origin[0]
|
||||
field_y = self._origin[1] + self._style.font_size + 5
|
||||
|
||||
field_y = self._origin[1] + self.field_area_offset
|
||||
|
||||
# Draw field background and border
|
||||
bg_color = (255, 255, 255)
|
||||
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
|
||||
|
||||
field_rect = [(field_x, field_y),
|
||||
(field_x + self._field_width, field_y + self._field_height)]
|
||||
|
||||
field_rect = [(field_x, field_y),
|
||||
(field_x + self._field_width, field_y + self._field_height)]
|
||||
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
|
||||
|
||||
|
||||
# Render field value if present
|
||||
if self._field.value is not None:
|
||||
value_text = str(self._field.value)
|
||||
|
||||
|
||||
# For password fields, mask the text
|
||||
if self._field.field_type == FormFieldType.PASSWORD:
|
||||
value_text = "•" * len(value_text)
|
||||
|
||||
|
||||
# Create a temporary Text object for the value
|
||||
value_font = self._style.with_colour((0, 0, 0))
|
||||
|
||||
|
||||
# Position value text within field (with some padding)
|
||||
# Get font metrics to properly center the baseline
|
||||
ascent, descent = value_font.font.getmetrics()
|
||||
|
||||
# Center the text vertically within the field
|
||||
# The y coordinate is where the baseline sits (anchor="ls")
|
||||
vertical_center = self._field_height / 2
|
||||
# Centre the value within the input box. As in ButtonText, the
|
||||
# baseline sits at the top of the glyphs plus the ascent; centring on
|
||||
# half the box height plus half the descent only works for a 2:1
|
||||
# ascent/descent ratio and otherwise rides high.
|
||||
value_x = field_x + 5
|
||||
value_y = field_y + vertical_center + (descent / 2)
|
||||
|
||||
value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
|
||||
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
def handle_click(self, point) -> bool:
|
||||
"""
|
||||
Handle clicks on the form field.
|
||||
|
||||
|
||||
Args:
|
||||
point: The click coordinates relative to this field
|
||||
|
||||
|
||||
Returns:
|
||||
True if the field was clicked and focused
|
||||
"""
|
||||
# Calculate field area
|
||||
field_y = self._style.font_size + 5
|
||||
|
||||
field_y = self.field_area_offset
|
||||
|
||||
# Check if click is within the input field area (not just the label)
|
||||
if (0 <= point[0] <= self._field_width and
|
||||
field_y <= point[1] <= field_y + self._field_height):
|
||||
field_y <= point[1] <= field_y + self._field_height):
|
||||
self.set_focused(True)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def in_object(self, point) -> bool:
|
||||
"""
|
||||
Check if a point is within this form field (including label and input area).
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the field bounds
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the total field area
|
||||
return (0 <= relative_point[0] < self._field_width and
|
||||
return (0 <= relative_point[0] < self._field_width and
|
||||
0 <= relative_point[1] < self._total_height)
|
||||
|
||||
|
||||
# Factory functions for creating functional text objects
|
||||
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
|
||||
def create_link_text(link: Link, text: str, font: Font,
|
||||
draw: ImageDraw.Draw) -> LinkText:
|
||||
"""
|
||||
Factory function to create a LinkText object.
|
||||
|
||||
|
||||
Args:
|
||||
link: The Link object to associate with the text
|
||||
text: The text content to display
|
||||
font: The base font style
|
||||
draw: The drawing context
|
||||
|
||||
|
||||
Returns:
|
||||
A LinkText object ready for rendering and interaction
|
||||
"""
|
||||
@@ -388,16 +491,16 @@ def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) ->
|
||||
|
||||
|
||||
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
|
||||
"""
|
||||
Factory function to create a ButtonText object.
|
||||
|
||||
|
||||
Args:
|
||||
button: The Button object to associate with the text
|
||||
font: The base font style
|
||||
draw: The drawing context
|
||||
padding: Padding around the button text
|
||||
|
||||
|
||||
Returns:
|
||||
A ButtonText object ready for rendering and interaction
|
||||
"""
|
||||
@@ -405,16 +508,16 @@ def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
|
||||
|
||||
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||
field_height: int = 24) -> FormFieldText:
|
||||
field_height: int = 24) -> FormFieldText:
|
||||
"""
|
||||
Factory function to create a FormFieldText object.
|
||||
|
||||
|
||||
Args:
|
||||
field: The FormField object to associate with the text
|
||||
font: The base font style for the label
|
||||
draw: The drawing context
|
||||
field_height: Height of the input field area
|
||||
|
||||
|
||||
Returns:
|
||||
A FormFieldText object ready for rendering and interaction
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import os
|
||||
from typing import Optional, Tuple, Union, Dict, Any
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from .box import Box
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
@@ -12,14 +11,14 @@ class RenderableImage(Renderable, Queriable):
|
||||
"""
|
||||
A concrete implementation for rendering Image objects.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
|
||||
max_width: Optional[int] = None, max_height: Optional[int] = None,
|
||||
origin=None, size=None, callback=None, sheet=None, mode=None,
|
||||
halign=Alignment.CENTER, valign=Alignment.CENTER):
|
||||
"""
|
||||
Initialize a renderable image.
|
||||
|
||||
|
||||
Args:
|
||||
image: The abstract Image object to render
|
||||
draw: The PIL ImageDraw object to draw on
|
||||
@@ -40,52 +39,57 @@ class RenderableImage(Renderable, Queriable):
|
||||
self._error_message = None
|
||||
self._halign = halign
|
||||
self._valign = valign
|
||||
|
||||
|
||||
# Set origin as numpy array
|
||||
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
|
||||
|
||||
|
||||
# Try to load the image
|
||||
self._load_image()
|
||||
|
||||
|
||||
# Calculate the size if not provided
|
||||
if size is None:
|
||||
size = image.calculate_scaled_dimensions(max_width, max_height)
|
||||
# Ensure we have valid dimensions, fallback to defaults if None
|
||||
if size[0] is None or size[1] is None:
|
||||
size = (100, 100) # Default size when image dimensions are unavailable
|
||||
|
||||
|
||||
# Ensure dimensions are positive (can be negative if calculated from insufficient space)
|
||||
size = (max(1, size[0]), max(1, size[1]))
|
||||
|
||||
# Set size as numpy array
|
||||
self._size = np.array(size)
|
||||
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin of the image"""
|
||||
return self._origin
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the size of the image"""
|
||||
return self._size
|
||||
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Get the width of the image"""
|
||||
return self._size[0]
|
||||
|
||||
|
||||
def set_origin(self, origin: np.ndarray):
|
||||
"""Set the origin of this image element"""
|
||||
self._origin = origin
|
||||
|
||||
|
||||
def _load_image(self):
|
||||
"""Load the image from the source path"""
|
||||
try:
|
||||
# Check if the image has already been loaded into memory
|
||||
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
if hasattr(
|
||||
self._abstract_image,
|
||||
'_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
self._pil_image = self._abstract_image._loaded_image
|
||||
return
|
||||
|
||||
|
||||
source = self._abstract_image.source
|
||||
|
||||
|
||||
# Handle different types of sources
|
||||
if os.path.isfile(source):
|
||||
# Local file
|
||||
@@ -96,7 +100,7 @@ class RenderableImage(Renderable, Queriable):
|
||||
try:
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
response = requests.get(source, stream=True)
|
||||
if response.status_code == 200:
|
||||
self._pil_image = PILImage.open(BytesIO(response.content))
|
||||
@@ -107,11 +111,11 @@ class RenderableImage(Renderable, Queriable):
|
||||
self._error_message = "Requests library not available for URL loading"
|
||||
else:
|
||||
self._error_message = f"Unable to load image from source: {source}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self._error_message = f"Error loading image: {str(e)}"
|
||||
self._abstract_image._error = self._error_message
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the image directly into the canvas using the provided draw object.
|
||||
@@ -119,11 +123,11 @@ class RenderableImage(Renderable, Queriable):
|
||||
if self._pil_image:
|
||||
# Resize the image to fit the box while maintaining aspect ratio
|
||||
resized_image = self._resize_image()
|
||||
|
||||
|
||||
# Calculate position based on alignment
|
||||
img_width, img_height = resized_image.size
|
||||
box_width, box_height = self._size
|
||||
|
||||
|
||||
# Horizontal alignment
|
||||
if self._halign == Alignment.LEFT:
|
||||
x_offset = 0
|
||||
@@ -131,7 +135,7 @@ class RenderableImage(Renderable, Queriable):
|
||||
x_offset = box_width - img_width
|
||||
else: # CENTER is default
|
||||
x_offset = (box_width - img_width) // 2
|
||||
|
||||
|
||||
# Vertical alignment
|
||||
if self._valign == Alignment.TOP:
|
||||
y_offset = 0
|
||||
@@ -139,55 +143,66 @@ class RenderableImage(Renderable, Queriable):
|
||||
y_offset = box_height - img_height
|
||||
else: # CENTER is default
|
||||
y_offset = (box_height - img_height) // 2
|
||||
|
||||
|
||||
# Calculate final position on canvas
|
||||
final_x = int(self._origin[0] + x_offset)
|
||||
final_y = int(self._origin[1] + y_offset)
|
||||
|
||||
|
||||
# Get the underlying image from the draw object to paste onto
|
||||
|
||||
|
||||
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
|
||||
|
||||
self._canvas.paste(
|
||||
resized_image,
|
||||
(final_x,
|
||||
final_y,
|
||||
final_x +
|
||||
img_width,
|
||||
final_y +
|
||||
img_height))
|
||||
else:
|
||||
# Draw error placeholder
|
||||
self._draw_error_placeholder()
|
||||
|
||||
|
||||
def _resize_image(self) -> PILImage.Image:
|
||||
"""
|
||||
Resize the image to fit within the box while maintaining aspect ratio.
|
||||
|
||||
|
||||
Returns:
|
||||
A resized PIL Image
|
||||
"""
|
||||
if not self._pil_image:
|
||||
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
|
||||
|
||||
|
||||
# Get the target dimensions
|
||||
target_width, target_height = self._size
|
||||
|
||||
|
||||
# Ensure target dimensions are positive
|
||||
target_width = max(1, int(target_width))
|
||||
target_height = max(1, int(target_height))
|
||||
|
||||
# Get the original dimensions
|
||||
orig_width, orig_height = self._pil_image.size
|
||||
|
||||
|
||||
# Calculate the scaling factor to maintain aspect ratio
|
||||
width_ratio = target_width / orig_width
|
||||
height_ratio = target_height / orig_height
|
||||
|
||||
|
||||
# Use the smaller ratio to ensure the image fits within the box
|
||||
ratio = min(width_ratio, height_ratio)
|
||||
|
||||
|
||||
# Calculate new dimensions
|
||||
new_width = int(orig_width * ratio)
|
||||
new_height = int(orig_height * ratio)
|
||||
|
||||
new_width = max(1, int(orig_width * ratio))
|
||||
new_height = max(1, int(orig_height * ratio))
|
||||
|
||||
# Resize the image
|
||||
if self._pil_image.mode == 'RGBA':
|
||||
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
|
||||
else:
|
||||
# Convert to RGBA if needed
|
||||
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
|
||||
|
||||
resized = self._pil_image.convert('RGBA').resize(
|
||||
(new_width, new_height), PILImage.LANCZOS)
|
||||
|
||||
return resized
|
||||
|
||||
|
||||
def _draw_error_placeholder(self):
|
||||
"""
|
||||
Draw a placeholder for when the image can't be loaded.
|
||||
@@ -197,68 +212,69 @@ class RenderableImage(Renderable, Queriable):
|
||||
y1 = int(self._origin[1])
|
||||
x2 = int(self._origin[0] + self._size[0])
|
||||
y2 = int(self._origin[1] + self._size[1])
|
||||
|
||||
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
# Draw a gray box with a border
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
|
||||
240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
|
||||
# Draw an X across the box
|
||||
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
|
||||
self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
|
||||
|
||||
|
||||
# Add error text if available
|
||||
if self._error_message:
|
||||
try:
|
||||
# Try to use a basic font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
|
||||
# Draw the error message, wrapped to fit
|
||||
error_text = "Error: " + self._error_message
|
||||
|
||||
|
||||
# Simple text wrapping - split by words and add lines
|
||||
words = error_text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
|
||||
for word in words:
|
||||
test_line = current_line + " " + word if current_line else word
|
||||
text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
|
||||
|
||||
if text_width <= self._size[0] - 20: # 10px padding on each side
|
||||
current_line = test_line
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
|
||||
# Draw each line
|
||||
y_pos = y1 + 10
|
||||
for line in lines:
|
||||
text_bbox = self._draw.textbbox((0, 0), line, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_height = text_bbox[3] - text_bbox[1]
|
||||
|
||||
|
||||
# Center the text horizontally
|
||||
x_pos = x1 + (self._size[0] - text_width) // 2
|
||||
|
||||
|
||||
# Draw the text
|
||||
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
|
||||
|
||||
|
||||
# Move to the next line
|
||||
y_pos += text_height + 2
|
||||
|
||||
|
||||
except Exception:
|
||||
# If text rendering fails, just draw a generic error indicator
|
||||
pass
|
||||
|
||||
|
||||
def in_object(self, point):
|
||||
"""Check if a point is within this image"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the image boundaries
|
||||
return (0 <= relative_point[0] < self._size[0] and
|
||||
return (0 <= relative_point[0] < self._size[0] and
|
||||
0 <= relative_point[1] < self._size[1])
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Interaction handler for managing button/link press-release lifecycle with visual feedback.
|
||||
|
||||
This module provides utilities for handling interactive element states and rendering
|
||||
frames at different stages of interaction (pressed, released).
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple, Callable, Any
|
||||
from PIL import Image
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
|
||||
class InteractionHandler:
|
||||
"""
|
||||
Manages the press-release lifecycle for interactive elements.
|
||||
|
||||
This class handles the timing and state management needed to show
|
||||
visual feedback when buttons or links are clicked. It can generate
|
||||
multiple rendered frames showing the pressed and released states.
|
||||
|
||||
Usage patterns:
|
||||
|
||||
Pattern A - Simple one-shot with automatic frames:
|
||||
handler = InteractionHandler(page)
|
||||
frames = handler.execute_with_feedback(button_element, point)
|
||||
# Returns: [pressed_frame, released_frame]
|
||||
# Show frames in sequence with brief delay
|
||||
|
||||
Pattern B - Manual state management for custom event loops:
|
||||
handler = InteractionHandler(page)
|
||||
handler.set_pressed_state(button_element, True)
|
||||
pressed_frame = handler.render_current_state()
|
||||
# ... show frame, wait, execute action ...
|
||||
handler.set_pressed_state(button_element, False)
|
||||
released_frame = handler.render_current_state()
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page, press_duration_ms: int = 150):
|
||||
"""
|
||||
Initialize the interaction handler.
|
||||
|
||||
Args:
|
||||
page: The Page object containing the interactive elements
|
||||
press_duration_ms: How long to show the pressed state (default: 150ms)
|
||||
"""
|
||||
self._page = page
|
||||
self._press_duration_ms = press_duration_ms
|
||||
|
||||
def set_pressed_state(self, element, pressed: bool):
|
||||
"""
|
||||
Set the pressed state of an interactive element.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
pressed: True to show pressed, False to show released
|
||||
"""
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
# Ensure element has page reference for dirty flag
|
||||
if not hasattr(element, '_page') or element._page is None:
|
||||
element.set_page(self._page)
|
||||
element.set_pressed(pressed)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Element must be LinkText or ButtonText, got {type(element)}")
|
||||
|
||||
def set_hovered_state(self, element, hovered: bool):
|
||||
"""
|
||||
Set the hovered state of an interactive element.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
hovered: True to show hovered, False for normal
|
||||
"""
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
# Ensure element has page reference for dirty flag
|
||||
if not hasattr(element, '_page') or element._page is None:
|
||||
element.set_page(self._page)
|
||||
element.set_hovered(hovered)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Element must be LinkText or ButtonText, got {type(element)}")
|
||||
|
||||
def render_current_state(self) -> Image.Image:
|
||||
"""
|
||||
Render the page with current element states.
|
||||
|
||||
Returns:
|
||||
PIL Image of the rendered page
|
||||
"""
|
||||
return self._page.render()
|
||||
|
||||
def execute_with_feedback(
|
||||
self,
|
||||
element,
|
||||
point: Optional[np.ndarray] = None,
|
||||
callback: Optional[Callable] = None) -> Tuple[Image.Image, Image.Image, Any]:
|
||||
"""
|
||||
Execute an interaction with visual feedback at each stage.
|
||||
|
||||
This is the high-level "all-in-one" method that:
|
||||
1. Sets pressed state and renders
|
||||
2. Waits for press_duration_ms
|
||||
3. Executes the element's callback (or provided callback)
|
||||
4. Sets released state and renders
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
point: Optional point where interaction occurred
|
||||
callback: Optional custom callback (overrides element's callback)
|
||||
|
||||
Returns:
|
||||
Tuple of (pressed_frame, released_frame, callback_result)
|
||||
"""
|
||||
# Step 1: Render pressed state
|
||||
self.set_pressed_state(element, True)
|
||||
pressed_frame = self.render_current_state()
|
||||
|
||||
# Step 2: Wait for visual feedback duration
|
||||
time.sleep(self._press_duration_ms / 1000.0)
|
||||
|
||||
# Step 3: Execute callback
|
||||
callback_result = None
|
||||
if callback:
|
||||
callback_result = callback(point) if point is not None else callback()
|
||||
elif hasattr(element, 'interact'):
|
||||
callback_result = element.interact(point)
|
||||
|
||||
# Step 4: Render released state
|
||||
self.set_pressed_state(element, False)
|
||||
released_frame = self.render_current_state()
|
||||
|
||||
return pressed_frame, released_frame, callback_result
|
||||
|
||||
def execute_async_with_feedback(
|
||||
self,
|
||||
element,
|
||||
point: Optional[np.ndarray] = None) -> Tuple[Image.Image, Callable, Image.Image]:
|
||||
"""
|
||||
Execute an interaction with visual feedback, returning frames immediately
|
||||
without blocking.
|
||||
|
||||
This method returns the frames and a callback to execute later, allowing
|
||||
the caller to control when the action actually happens.
|
||||
|
||||
Args:
|
||||
element: A LinkText or ButtonText object
|
||||
point: Optional point where interaction occurred
|
||||
|
||||
Returns:
|
||||
Tuple of (pressed_frame, execute_callback, released_frame)
|
||||
where execute_callback is a function that will execute the interaction
|
||||
"""
|
||||
# Render pressed state
|
||||
self.set_pressed_state(element, True)
|
||||
pressed_frame = self.render_current_state()
|
||||
|
||||
# Create callback that will execute the interaction and reset state
|
||||
def execute_callback():
|
||||
result = None
|
||||
if hasattr(element, 'interact'):
|
||||
result = element.interact(point)
|
||||
self.set_pressed_state(element, False)
|
||||
return result
|
||||
|
||||
# Pre-render the released state (element state is still pressed)
|
||||
# We'll return this frame but the caller controls when to show it
|
||||
self.set_pressed_state(element, False)
|
||||
released_frame = self.render_current_state()
|
||||
|
||||
# Reset back to pressed for consistency
|
||||
# (caller will call execute_callback which sets to False)
|
||||
self.set_pressed_state(element, True)
|
||||
|
||||
return pressed_frame, execute_callback, released_frame
|
||||
|
||||
|
||||
class InteractionStateManager:
|
||||
"""
|
||||
Manages interaction states for multiple elements on a page.
|
||||
|
||||
Useful for applications that need to track hover/press states
|
||||
across many interactive elements simultaneously.
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
"""
|
||||
Initialize the state manager.
|
||||
|
||||
Args:
|
||||
page: The Page object containing interactive elements
|
||||
"""
|
||||
self._page = page
|
||||
self._hovered_element = None
|
||||
self._pressed_element = None
|
||||
|
||||
def update_hover(self, point: Tuple[int, int]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Update hover state based on cursor position.
|
||||
|
||||
Queries the page to find what's under the cursor and updates
|
||||
hover states accordingly.
|
||||
|
||||
Args:
|
||||
point: Cursor position (x, y)
|
||||
|
||||
Returns:
|
||||
New rendered frame if hover state changed, None otherwise
|
||||
"""
|
||||
# Query what's at this point
|
||||
result = self._page.query_point(point)
|
||||
|
||||
if not result or not result.is_interactive:
|
||||
# Nothing interactive under cursor
|
||||
if self._hovered_element:
|
||||
# Clear previous hover
|
||||
if isinstance(self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
self._hovered_element = None
|
||||
return self._page.render()
|
||||
return None
|
||||
|
||||
# Something interactive is under cursor
|
||||
element = result.object
|
||||
if element != self._hovered_element:
|
||||
# Hover changed
|
||||
# Clear old hover
|
||||
if self._hovered_element and isinstance(
|
||||
self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
|
||||
# Set new hover
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
element.set_hovered(True)
|
||||
|
||||
self._hovered_element = element
|
||||
return self._page.render()
|
||||
|
||||
return None
|
||||
|
||||
def handle_mouse_down(self, point: Tuple[int, int]) -> Optional[Image.Image]:
|
||||
"""
|
||||
Handle mouse button press at a point.
|
||||
|
||||
Args:
|
||||
point: Click position (x, y)
|
||||
|
||||
Returns:
|
||||
New rendered frame showing pressed state, or None if nothing interactive
|
||||
"""
|
||||
result = self._page.query_point(point)
|
||||
|
||||
if not result or not result.is_interactive:
|
||||
return None
|
||||
|
||||
element = result.object
|
||||
if isinstance(element, (LinkText, ButtonText)):
|
||||
element.set_pressed(True)
|
||||
self._pressed_element = element
|
||||
return self._page.render()
|
||||
|
||||
return None
|
||||
|
||||
def handle_mouse_up(
|
||||
self,
|
||||
point: Tuple[int,
|
||||
int]) -> Tuple[Optional[Image.Image],
|
||||
Any]:
|
||||
"""
|
||||
Handle mouse button release at a point.
|
||||
|
||||
Args:
|
||||
point: Release position (x, y)
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_frame, callback_result)
|
||||
Frame shows released state, result is from executing the callback
|
||||
"""
|
||||
if not self._pressed_element:
|
||||
return None, None
|
||||
|
||||
# Execute the interaction
|
||||
callback_result = None
|
||||
if hasattr(self._pressed_element, 'interact'):
|
||||
callback_result = self._pressed_element.interact(
|
||||
np.array(point))
|
||||
|
||||
# Release the pressed state
|
||||
if isinstance(self._pressed_element, (LinkText, ButtonText)):
|
||||
self._pressed_element.set_pressed(False)
|
||||
|
||||
self._pressed_element = None
|
||||
|
||||
return self._page.render(), callback_result
|
||||
|
||||
def reset(self):
|
||||
"""Reset all interaction states."""
|
||||
if self._hovered_element and isinstance(
|
||||
self._hovered_element, (LinkText, ButtonText)):
|
||||
self._hovered_element.set_hovered(False)
|
||||
|
||||
if self._pressed_element and isinstance(
|
||||
self._pressed_element, (LinkText, ButtonText)):
|
||||
self._pressed_element.set_pressed(False)
|
||||
|
||||
self._hovered_element = None
|
||||
self._pressed_element = None
|
||||
@@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
|
||||
|
||||
class Page(Renderable, Queriable):
|
||||
"""
|
||||
@@ -16,54 +15,75 @@ class Page(Renderable, Queriable):
|
||||
contains a given point.
|
||||
"""
|
||||
|
||||
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
|
||||
# Mode of the render canvas. The measurement context matches it so that text
|
||||
# width caching keys stay consistent between layout and rendering.
|
||||
_CANVAS_MODE = 'RGBA'
|
||||
|
||||
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None,
|
||||
origin: Tuple[int, int] = (0, 0)):
|
||||
"""
|
||||
Initialize a new page.
|
||||
|
||||
Args:
|
||||
size: The total size of the page (width, height) including borders
|
||||
style: The PageStyle defining borders, spacing, and appearance
|
||||
origin: Absolute position of the page's top-left corner. Non-zero for
|
||||
a page nested inside another surface, such as a table cell.
|
||||
"""
|
||||
self._size = size
|
||||
self._origin = origin
|
||||
self._style = style if style is not None else PageStyle()
|
||||
self._children: List[Renderable] = []
|
||||
self._canvas: Optional[Image.Image] = None
|
||||
self._draw: Optional[ImageDraw.Draw] = None
|
||||
self._measurement_draw: Optional[ImageDraw.ImageDraw] = None
|
||||
# Initialize y_offset to start of content area
|
||||
# Position the first line so its baseline is close to the top boundary
|
||||
# For subsequent lines, baseline-to-baseline spacing is used
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
self._current_y_offset = (self._origin[1] + self._style.border_width
|
||||
+ self._style.padding_top)
|
||||
self._is_first_line = True # Track if we're placing the first line
|
||||
# Callback registry for managing interactable elements
|
||||
self._callbacks = CallbackRegistry()
|
||||
# Dirty flag to track if page needs re-rendering due to state changes
|
||||
self._dirty = True
|
||||
|
||||
def free_space(self) -> Tuple[int, int]:
|
||||
"""Get the remaining space on the page"""
|
||||
return (self._size[0], self._size[1] - self._current_y_offset)
|
||||
"""
|
||||
Get the remaining space in the content area.
|
||||
|
||||
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
|
||||
Deprecated: use content_rect and remaining_height, which this delegates to.
|
||||
"""
|
||||
return (self.content_rect[2], self.remaining_height)
|
||||
|
||||
def can_fit_line(
|
||||
self,
|
||||
baseline_spacing: int,
|
||||
ascent: int = 0,
|
||||
descent: int = 0) -> bool:
|
||||
"""
|
||||
Check if a line with the given metrics can fit on the page.
|
||||
|
||||
|
||||
Args:
|
||||
baseline_spacing: Distance from current position to next baseline
|
||||
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
|
||||
descent: Font descent (height below baseline), defaults to 0 for backward compat
|
||||
|
||||
|
||||
Returns:
|
||||
True if the line fits within page boundaries
|
||||
"""
|
||||
# Calculate the maximum Y position allowed (bottom boundary)
|
||||
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
|
||||
|
||||
content_y, content_h = self.content_rect[1], self.content_rect[3]
|
||||
max_y = content_y + content_h
|
||||
|
||||
# If ascent/descent not provided, use simple check (backward compatibility)
|
||||
if ascent == 0 and descent == 0:
|
||||
return (self._current_y_offset + baseline_spacing) <= max_y
|
||||
|
||||
|
||||
# Calculate where the bottom of the text would be
|
||||
# Text bottom = current_y_offset + ascent + descent
|
||||
text_bottom = self._current_y_offset + ascent + descent
|
||||
|
||||
|
||||
# Check if text bottom would exceed the boundary
|
||||
return text_bottom <= max_y
|
||||
|
||||
@@ -72,6 +92,34 @@ class Page(Renderable, Queriable):
|
||||
"""Get the total page size including borders"""
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def origin(self) -> Tuple[int, int]:
|
||||
"""Absolute position of the page's top-left corner"""
|
||||
return self._origin
|
||||
|
||||
@property
|
||||
def content_origin(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Absolute top-left of the content box: the page origin plus its border and
|
||||
top/left padding. Layout starts here.
|
||||
"""
|
||||
return (
|
||||
self._origin[0] + self._style.border_width + self._style.padding_left,
|
||||
self._origin[1] + self._style.border_width + self._style.padding_top,
|
||||
)
|
||||
|
||||
@property
|
||||
def content_rect(self) -> Tuple[int, int, int, int]:
|
||||
"""(x, y, width, height) of the content box, in absolute coordinates"""
|
||||
x, y = self.content_origin
|
||||
return (x, y, self.content_size[0], self.content_size[1])
|
||||
|
||||
@property
|
||||
def remaining_height(self) -> int:
|
||||
"""Content-box height still available below the current layout cursor"""
|
||||
_, y, _, h = self.content_rect
|
||||
return max(0, y + h - self._current_y_offset)
|
||||
|
||||
@property
|
||||
def canvas_size(self) -> Tuple[int, int]:
|
||||
"""Get the canvas size (page size minus borders)"""
|
||||
@@ -110,15 +158,53 @@ class Page(Renderable, Queriable):
|
||||
"""Get the callback registry for managing interactable elements"""
|
||||
return self._callbacks
|
||||
|
||||
@property
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if the page needs re-rendering due to state changes"""
|
||||
return self._dirty
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Mark the page as needing re-rendering"""
|
||||
self._dirty = True
|
||||
|
||||
def mark_clean(self):
|
||||
"""Mark the page as clean (up-to-date render)"""
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
||||
"""Get the ImageDraw object for drawing on this page's canvas"""
|
||||
if self._draw is None:
|
||||
"""
|
||||
Get the ImageDraw object bound to this page's render canvas.
|
||||
|
||||
Rebuilt whenever the canvas has been invalidated: a draw context
|
||||
outlives the image it was created from, so checking only _draw would
|
||||
hand back a context pointing at a discarded canvas.
|
||||
"""
|
||||
if self._draw is None or self._canvas is None:
|
||||
# Initialize canvas and draw context if not already done
|
||||
self._canvas = self._create_canvas()
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
return self._draw
|
||||
|
||||
@property
|
||||
def measurement_draw(self) -> ImageDraw.ImageDraw:
|
||||
"""
|
||||
A scratch draw context for text metrics during layout.
|
||||
|
||||
Layout asks for text widths constantly, but has no reason to touch the
|
||||
render canvas - and the canvas is invalidated on every add_child, so
|
||||
measuring through `draw` would allocate a full-page image per line.
|
||||
This context is 1x1 and never invalidated.
|
||||
|
||||
Its mode matches the render canvas because Text keys its width cache on
|
||||
the draw mode; a mismatch would double every cache entry. Children built
|
||||
against it are re-bound to the real canvas by render_children.
|
||||
"""
|
||||
if self._measurement_draw is None:
|
||||
scratch = Image.new(self._CANVAS_MODE, (1, 1))
|
||||
self._measurement_draw = ImageDraw.Draw(scratch)
|
||||
return self._measurement_draw
|
||||
|
||||
def add_child(self, child: Renderable) -> 'Page':
|
||||
"""
|
||||
Add a child renderable object to this page.
|
||||
@@ -164,7 +250,7 @@ class Page(Renderable, Queriable):
|
||||
# Clear callback registry when clearing children
|
||||
self._callbacks.clear()
|
||||
# Reset y_offset to start of content area (after border and padding)
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
self._current_y_offset = self.content_origin[1]
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -172,30 +258,6 @@ class Page(Renderable, Queriable):
|
||||
"""Get a copy of the children list"""
|
||||
return self._children.copy()
|
||||
|
||||
def _get_child_height(self, child: Renderable) -> int:
|
||||
"""
|
||||
Get the height of a child object.
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
return int(child._size[1])
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
return int(child.size[1])
|
||||
|
||||
if hasattr(child, 'height'):
|
||||
return int(child.height)
|
||||
|
||||
# Default fallback height
|
||||
return 20
|
||||
|
||||
def render_children(self):
|
||||
"""
|
||||
Call render on all children in the list.
|
||||
@@ -225,6 +287,9 @@ class Page(Renderable, Queriable):
|
||||
# Render all children - they draw directly onto the canvas
|
||||
self.render_children()
|
||||
|
||||
# Mark as clean after rendering
|
||||
self._dirty = False
|
||||
|
||||
return self._canvas
|
||||
|
||||
def _create_canvas(self) -> Image.Image:
|
||||
@@ -235,7 +300,7 @@ class Page(Renderable, Queriable):
|
||||
PIL Image with background and borders applied
|
||||
"""
|
||||
# Create base image
|
||||
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
|
||||
canvas = Image.new(self._CANVAS_MODE, self._size, (*self._style.background_color, 255))
|
||||
|
||||
# Draw borders if needed
|
||||
if self._style.border_width > 0:
|
||||
@@ -251,30 +316,6 @@ class Page(Renderable, Queriable):
|
||||
|
||||
return canvas
|
||||
|
||||
def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
|
||||
"""
|
||||
Get the position where a child should be rendered.
|
||||
|
||||
Args:
|
||||
child: The child object
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y) coordinates
|
||||
"""
|
||||
if hasattr(child, '_origin') and child._origin is not None:
|
||||
if isinstance(child._origin, np.ndarray):
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
elif isinstance(child._origin, (list, tuple)) and len(child._origin) >= 2:
|
||||
return (int(child._origin[0]), int(child._origin[1]))
|
||||
|
||||
if hasattr(child, 'position'):
|
||||
pos = child.position
|
||||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
return (int(pos[0]), int(pos[1]))
|
||||
|
||||
# Default to origin
|
||||
return (0, 0)
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional[QueryResult]:
|
||||
"""
|
||||
Query a point to find the deepest object at that location.
|
||||
@@ -311,60 +352,6 @@ class Page(Renderable, Queriable):
|
||||
bounds=(int(point[0]), int(point[1]), 0, 0)
|
||||
)
|
||||
|
||||
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
|
||||
"""
|
||||
Check if a point is within a child's bounds.
|
||||
|
||||
Args:
|
||||
point: The point to check
|
||||
child: The child to check against
|
||||
|
||||
Returns:
|
||||
True if the point is within the child's bounds
|
||||
"""
|
||||
# If child implements Queriable interface, use it
|
||||
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
|
||||
try:
|
||||
return child.in_object(point)
|
||||
except:
|
||||
pass # Fall back to bounds checking
|
||||
|
||||
# Get child position and size for bounds checking
|
||||
child_pos = self._get_child_position(child)
|
||||
child_size = self._get_child_size(child)
|
||||
|
||||
if child_size is None:
|
||||
return False
|
||||
|
||||
# Check if point is within child bounds
|
||||
return (
|
||||
child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
|
||||
child_pos[1] <= point[1] < child_pos[1] + child_size[1]
|
||||
)
|
||||
|
||||
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Get the size of a child object.
|
||||
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) or None if size cannot be determined
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
return (int(child._size[0]), int(child._size[1]))
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
return (int(child.size[0]), int(child.size[1]))
|
||||
|
||||
if hasattr(child, 'width') and hasattr(child, 'height'):
|
||||
return (int(child.width), int(child.height))
|
||||
|
||||
return None
|
||||
|
||||
def _make_query_result(self, obj, point: Tuple[int, int]) -> QueryResult:
|
||||
"""
|
||||
Package an object into a QueryResult with metadata.
|
||||
@@ -422,7 +409,8 @@ class Page(Renderable, Queriable):
|
||||
bounds=bounds
|
||||
)
|
||||
|
||||
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
|
||||
def query_range(self, start: Tuple[int, int],
|
||||
end: Tuple[int, int]) -> SelectionRange:
|
||||
"""
|
||||
Query all text objects between two points (for text selection).
|
||||
Uses Queriable.in_object() to determine which objects are in range.
|
||||
@@ -474,6 +462,6 @@ class Page(Renderable, Queriable):
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
return (
|
||||
0 <= point[0] < self._size[0] and
|
||||
0 <= point[1] < self._size[1]
|
||||
self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
|
||||
self._origin[1] <= point[1] < self._origin[1] + self._size[1]
|
||||
)
|
||||
|
||||
@@ -9,15 +9,13 @@ This module provides the concrete rendering classes for tables, including:
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.base import Renderable
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
|
||||
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -49,8 +47,15 @@ class TableCellRenderer(Box):
|
||||
Supports paragraphs, headings, images, and links within cells.
|
||||
"""
|
||||
|
||||
def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int],
|
||||
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
|
||||
def __init__(self,
|
||||
cell: TableCell,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
size: Tuple[int,
|
||||
int],
|
||||
draw: ImageDraw.Draw,
|
||||
style: TableStyle,
|
||||
is_header_section: bool = False,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table cell renderer.
|
||||
@@ -103,52 +108,141 @@ class TableCellRenderer(Box):
|
||||
return None # Cell rendering is done directly on the page
|
||||
|
||||
def _render_cell_content(self, x: int, y: int, width: int, height: int):
|
||||
"""Render the content inside the cell (text and images)."""
|
||||
from PIL import ImageFont
|
||||
"""Render the content inside the cell (text and images) with line wrapping."""
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style import FontWeight, Alignment
|
||||
|
||||
current_y = y + 2
|
||||
available_height = height - 4 # Account for top/bottom padding
|
||||
|
||||
# Get font
|
||||
try:
|
||||
if self._is_header_section and self._style.header_text_bold:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
|
||||
else:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
# Create font for the cell
|
||||
font_size = 12
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
if self._is_header_section and self._style.header_text_bold:
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
font = Font(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
weight=FontWeight.BOLD if self._is_header_section and self._style.header_text_bold else FontWeight.NORMAL
|
||||
)
|
||||
|
||||
# Word spacing constraints (min, max)
|
||||
min_spacing = int(font_size * 0.25)
|
||||
max_spacing = int(font_size * 0.5)
|
||||
word_spacing = (min_spacing, max_spacing)
|
||||
|
||||
# Line height (baseline spacing)
|
||||
line_height = font_size + 4
|
||||
ascent, descent = font.font.getmetrics()
|
||||
|
||||
# Render each block in the cell
|
||||
for block in self._cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Render image
|
||||
current_y = self._render_image_in_cell(block, x, current_y, width, height - (current_y - y))
|
||||
current_y = self._render_image_in_cell(
|
||||
block, x, current_y, width, height - (current_y - y))
|
||||
elif isinstance(block, (Paragraph, Heading)):
|
||||
# Extract and render text
|
||||
words = []
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
for word in word_items:
|
||||
if hasattr(word, 'text'):
|
||||
words.append(word.text)
|
||||
elif isinstance(word, tuple) and len(word) >= 2:
|
||||
word_obj = word[1]
|
||||
if hasattr(word_obj, 'text'):
|
||||
words.append(word_obj.text)
|
||||
# Get words from the block
|
||||
from pyWebLayout.abstract.inline import Word as AbstractWord
|
||||
|
||||
if words:
|
||||
text = " ".join(words)
|
||||
if current_y <= y + height - 15:
|
||||
self._draw.text((x + 2, current_y), text, fill=(0, 0, 0), font=font)
|
||||
current_y += 16
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Create new Word objects with the table cell's font
|
||||
# The words from the paragraph may have AbstractStyle, but we need Font objects
|
||||
wrapped_words = []
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Create a new Word with the cell's Font
|
||||
new_word = AbstractWord(word_text, font)
|
||||
wrapped_words.append(new_word)
|
||||
|
||||
# Layout words using Line objects with wrapping
|
||||
word_index = 0
|
||||
pretext = None
|
||||
|
||||
while word_index < len(wrapped_words):
|
||||
# Check if we have space for another line
|
||||
if current_y + ascent + descent > y + available_height:
|
||||
break # No more space in cell
|
||||
|
||||
# Create a new line
|
||||
line = Line(
|
||||
spacing=word_spacing,
|
||||
origin=(x + 2, current_y),
|
||||
size=(width - 4, line_height),
|
||||
draw=self._draw,
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add words to this line until it's full
|
||||
line_has_content = False
|
||||
while word_index < len(wrapped_words):
|
||||
word = wrapped_words[word_index]
|
||||
|
||||
# Try to add word to line
|
||||
success, overflow = line.add_word(word, pretext)
|
||||
pretext = None # Clear pretext after use
|
||||
|
||||
if success:
|
||||
line_has_content = True
|
||||
if overflow:
|
||||
# Word was hyphenated, carry over to next line
|
||||
# DON'T increment word_index - we need to add the overflow
|
||||
# to the next line with the same word
|
||||
pretext = overflow
|
||||
break # Move to next line
|
||||
else:
|
||||
# Word fit completely, move to next word
|
||||
word_index += 1
|
||||
else:
|
||||
# Word doesn't fit on this line
|
||||
if not line_has_content:
|
||||
# Even first word doesn't fit, force it anyway and advance
|
||||
# This prevents infinite loops with words that truly can't fit
|
||||
word_index += 1
|
||||
break
|
||||
|
||||
# Render the line if it has content
|
||||
if line_has_content or len(line.text_objects) > 0:
|
||||
line.render()
|
||||
current_y += line_height
|
||||
|
||||
if current_y > y + height - 10: # Don't overflow cell
|
||||
break
|
||||
|
||||
# If no structured content, try to get any text representation
|
||||
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
|
||||
self._draw.text((x + 2, current_y), self._cell._text_content, fill=(0, 0, 0), font=font)
|
||||
# Use simple text rendering for fallback case
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
pil_font = ImageFont.truetype(font_path, font_size)
|
||||
except BaseException:
|
||||
pil_font = ImageFont.load_default()
|
||||
|
||||
self._draw.text(
|
||||
(x + 2, current_y),
|
||||
self._cell._text_content,
|
||||
fill=(0, 0, 0),
|
||||
font=pil_font
|
||||
)
|
||||
|
||||
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
||||
max_width: int, max_height: int) -> int:
|
||||
max_width: int, max_height: int) -> int:
|
||||
"""
|
||||
Render an image block inside a table cell.
|
||||
|
||||
@@ -181,7 +275,8 @@ class TableCellRenderer(Box):
|
||||
# Use more of the cell space for images
|
||||
img_width, img_height = img.size
|
||||
scale_w = max_width / img_width if img_width > max_width else 1
|
||||
scale_h = (max_height - 10) / img_height if img_height > (max_height - 10) else 1
|
||||
scale_h = (max_height - 10) / \
|
||||
img_height if img_height > (max_height - 10) else 1
|
||||
scale = min(scale_w, scale_h, 1.0) # Don't upscale
|
||||
|
||||
new_width = int(img_width * scale)
|
||||
@@ -210,8 +305,9 @@ class TableCellRenderer(Box):
|
||||
# Draw image indicator text
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
|
||||
except:
|
||||
small_font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
|
||||
except BaseException:
|
||||
small_font = ImageFont.load_default()
|
||||
|
||||
text = f"[Image: {new_width}x{new_height}]"
|
||||
@@ -219,7 +315,9 @@ class TableCellRenderer(Box):
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_x = img_x + (new_width - text_width) // 2
|
||||
text_y = y + (new_height - 12) // 2
|
||||
self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
|
||||
self._draw.text(
|
||||
(text_x, text_y), text, fill=(
|
||||
100, 100, 100), font=small_font)
|
||||
|
||||
# Set bounds on InteractiveImage objects for tap detection
|
||||
if isinstance(image_block, InteractiveImage):
|
||||
@@ -230,7 +328,7 @@ class TableCellRenderer(Box):
|
||||
|
||||
return y + new_height + 5 # Add some spacing after image
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# If image loading fails, just return current position
|
||||
return y + 20
|
||||
|
||||
@@ -240,9 +338,15 @@ class TableRowRenderer(Box):
|
||||
Renders a single table row containing multiple cells.
|
||||
"""
|
||||
|
||||
def __init__(self, row: TableRow, origin: Tuple[int, int],
|
||||
column_widths: List[int], row_height: int,
|
||||
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
|
||||
def __init__(self,
|
||||
row: TableRow,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
column_widths: List[int],
|
||||
row_height: int,
|
||||
draw: ImageDraw.Draw,
|
||||
style: TableStyle,
|
||||
is_header_section: bool = False,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table row renderer.
|
||||
@@ -309,9 +413,14 @@ class TableRenderer(Box):
|
||||
Handles layout calculation, row/cell placement, and overall table structure.
|
||||
"""
|
||||
|
||||
def __init__(self, table: Table, origin: Tuple[int, int],
|
||||
available_width: int, draw: ImageDraw.Draw,
|
||||
style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None):
|
||||
def __init__(self,
|
||||
table: Table,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
available_width: int,
|
||||
draw: ImageDraw.Draw,
|
||||
style: Optional[TableStyle] = None,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table renderer.
|
||||
|
||||
@@ -331,8 +440,10 @@ class TableRenderer(Box):
|
||||
|
||||
# Calculate table dimensions
|
||||
self._column_widths, self._row_heights = self._calculate_dimensions()
|
||||
total_width = sum(self._column_widths) + self._style.border_width * (len(self._column_widths) + 1)
|
||||
total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1)
|
||||
total_width = sum(self._column_widths) + \
|
||||
self._style.border_width * (len(self._column_widths) + 1)
|
||||
total_height = sum(self._row_heights.values()) + \
|
||||
self._style.border_width * (len(self._row_heights) + 1)
|
||||
|
||||
super().__init__(origin, (total_width, total_height))
|
||||
self._row_renderers: List[TableRowRenderer] = []
|
||||
@@ -341,41 +452,41 @@ class TableRenderer(Box):
|
||||
"""
|
||||
Calculate column widths and row heights for the table.
|
||||
|
||||
Uses the table optimizer for intelligent column width distribution.
|
||||
|
||||
Returns:
|
||||
Tuple of (column_widths, row_heights_dict)
|
||||
"""
|
||||
# Determine number of columns (from first row)
|
||||
num_columns = 0
|
||||
all_rows = list(self._table.all_rows())
|
||||
if all_rows:
|
||||
first_row = all_rows[0][1]
|
||||
num_columns = first_row.cell_count
|
||||
from pyWebLayout.layout.table_optimizer import optimize_table_layout
|
||||
|
||||
if num_columns == 0:
|
||||
all_rows = list(self._table.all_rows())
|
||||
|
||||
if not all_rows:
|
||||
return ([100], {"header": 30, "body": 30, "footer": 30})
|
||||
|
||||
# Calculate column widths (equal distribution for now)
|
||||
# Account for borders between columns
|
||||
total_border_width = self._style.border_width * (num_columns + 1)
|
||||
available_for_columns = self._available_width - total_border_width
|
||||
column_width = max(50, available_for_columns // num_columns)
|
||||
column_widths = [column_width] * num_columns
|
||||
# Use optimizer for column widths!
|
||||
column_widths = optimize_table_layout(
|
||||
self._table,
|
||||
self._available_width,
|
||||
sample_size=5,
|
||||
style=self._style
|
||||
)
|
||||
|
||||
# Calculate row heights
|
||||
header_height = 35 if any(1 for section, _ in all_rows if section == "header") else 0
|
||||
if not column_widths:
|
||||
# Fallback if table is empty
|
||||
column_widths = [100]
|
||||
|
||||
# Check if any body rows contain images - if so, use larger height
|
||||
body_height = 30
|
||||
for section, row in all_rows:
|
||||
if section == "body":
|
||||
for cell in row.cells():
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Use larger height for rows with images
|
||||
body_height = max(body_height, 120)
|
||||
break
|
||||
# Calculate row heights dynamically based on optimized column widths
|
||||
header_height = self._calculate_row_height_for_section(
|
||||
all_rows, "header", column_widths) if any(
|
||||
1 for section, _ in all_rows if section == "header") else 0
|
||||
|
||||
footer_height = 30 if any(1 for section, _ in all_rows if section == "footer") else 0
|
||||
body_height = self._calculate_row_height_for_section(
|
||||
all_rows, "body", column_widths)
|
||||
|
||||
footer_height = self._calculate_row_height_for_section(
|
||||
all_rows, "footer", column_widths) if any(
|
||||
1 for section, _ in all_rows if section == "footer") else 0
|
||||
|
||||
row_heights = {
|
||||
"header": header_height,
|
||||
@@ -385,6 +496,148 @@ class TableRenderer(Box):
|
||||
|
||||
return (column_widths, row_heights)
|
||||
|
||||
def _calculate_row_height_for_section(
|
||||
self,
|
||||
all_rows: List,
|
||||
section: str,
|
||||
column_widths: List[int]) -> int:
|
||||
"""
|
||||
Calculate the maximum required height for rows in a specific section.
|
||||
|
||||
Args:
|
||||
all_rows: List of all rows in the table
|
||||
section: Section name ('header', 'body', or 'footer')
|
||||
column_widths: List of column widths
|
||||
|
||||
Returns:
|
||||
Maximum height needed for rows in this section
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.inline import Word as AbstractWord
|
||||
|
||||
# Font configuration
|
||||
font_size = 12
|
||||
line_height = font_size + 4
|
||||
padding = self._style.cell_padding
|
||||
vertical_padding = padding[0] + padding[2] # top + bottom
|
||||
horizontal_padding = padding[1] + padding[3] # left + right
|
||||
|
||||
max_height = 40 # Minimum height
|
||||
|
||||
for row_section, row in all_rows:
|
||||
if row_section != section:
|
||||
continue
|
||||
|
||||
row_max_height = 40 # Minimum for this row
|
||||
|
||||
for cell_idx, cell in enumerate(row.cells()):
|
||||
if cell_idx >= len(column_widths):
|
||||
continue
|
||||
|
||||
# Get cell width (accounting for colspan)
|
||||
cell_width = column_widths[cell_idx]
|
||||
if cell.colspan > 1 and cell_idx + \
|
||||
cell.colspan <= len(column_widths):
|
||||
cell_width = sum(
|
||||
column_widths[cell_idx:cell_idx + cell.colspan])
|
||||
cell_width += self._style.border_width * (cell.colspan - 1)
|
||||
|
||||
# Calculate content width (minus padding)
|
||||
content_width = cell_width - horizontal_padding - 4 # Extra margin
|
||||
|
||||
cell_height = vertical_padding + 4 # Base height with padding
|
||||
|
||||
# Analyze each block in the cell
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Images need more space
|
||||
cell_height = max(cell_height, 120)
|
||||
elif isinstance(block, (Paragraph, Heading)):
|
||||
# Calculate text wrapping height
|
||||
word_items = block.words() if callable(
|
||||
block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Simulate text wrapping to count lines
|
||||
lines_needed = self._estimate_wrapped_lines(
|
||||
words, content_width, font_size)
|
||||
text_height = lines_needed * line_height
|
||||
cell_height = max(
|
||||
cell_height, text_height + vertical_padding + 4)
|
||||
|
||||
row_max_height = max(row_max_height, cell_height)
|
||||
|
||||
max_height = max(max_height, row_max_height)
|
||||
|
||||
return max_height
|
||||
|
||||
def _estimate_wrapped_lines(
|
||||
self,
|
||||
words: List,
|
||||
available_width: int,
|
||||
font_size: int) -> int:
|
||||
"""
|
||||
Estimate how many lines are needed to render the given words.
|
||||
|
||||
Args:
|
||||
words: List of word objects
|
||||
available_width: Available width for text
|
||||
font_size: Font size in pixels
|
||||
|
||||
Returns:
|
||||
Number of lines needed
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
# Create a temporary font for measurement
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
font = Font(font_path=font_path, font_size=font_size)
|
||||
|
||||
# Word spacing (approximate)
|
||||
word_spacing = int(font_size * 0.25)
|
||||
|
||||
lines = 1
|
||||
current_line_width = 0
|
||||
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(
|
||||
word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Measure word width
|
||||
word_width = font.font.getlength(word_text)
|
||||
|
||||
# Check if word fits on current line
|
||||
if current_line_width > 0: # Not first word on line
|
||||
needed_width = current_line_width + word_spacing + word_width
|
||||
if needed_width > available_width:
|
||||
# Need new line
|
||||
lines += 1
|
||||
current_line_width = word_width
|
||||
else:
|
||||
current_line_width = needed_width
|
||||
else:
|
||||
# First word on line
|
||||
if word_width > available_width:
|
||||
# Word needs to be hyphenated, assume it takes 1 line
|
||||
lines += 1
|
||||
current_line_width = 0
|
||||
else:
|
||||
current_line_width = word_width
|
||||
|
||||
return lines
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
"""Render the complete table."""
|
||||
x, y = self._origin
|
||||
@@ -428,8 +681,9 @@ class TableRenderer(Box):
|
||||
from PIL import ImageFont
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
|
||||
except:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Center the caption
|
||||
|
||||
@@ -5,8 +5,30 @@ This package contains the core abstractions and base classes that form the found
|
||||
of the pyWebLayout rendering system.
|
||||
"""
|
||||
|
||||
from pyWebLayout.core.base import (
|
||||
Renderable, Interactable, Layoutable, Queriable,
|
||||
Hierarchical, Geometric, Styleable, FontRegistry,
|
||||
MetadataContainer, BlockContainer, ContainerAware
|
||||
from .base import (
|
||||
Renderable,
|
||||
Interactable,
|
||||
Layoutable,
|
||||
Queriable,
|
||||
Hierarchical,
|
||||
Geometric,
|
||||
Styleable,
|
||||
FontRegistry,
|
||||
MetadataContainer,
|
||||
BlockContainer,
|
||||
ContainerAware,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Renderable',
|
||||
'Interactable',
|
||||
'Layoutable',
|
||||
'Queriable',
|
||||
'Hierarchical',
|
||||
'Geometric',
|
||||
'Styleable',
|
||||
'FontRegistry',
|
||||
'MetadataContainer',
|
||||
'BlockContainer',
|
||||
'ContainerAware',
|
||||
]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from abc import ABC
|
||||
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
@@ -14,57 +12,62 @@ class Renderable(ABC):
|
||||
Abstract base class for any object that can be rendered to an image.
|
||||
All renderable objects must implement the render method.
|
||||
"""
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the object to an image.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL.Image: The rendered image
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
|
||||
class Interactable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be interacted with.
|
||||
Interactable objects must have a callback that is executed when interacted with.
|
||||
"""
|
||||
|
||||
def __init__(self, callback=None):
|
||||
"""
|
||||
Initialize an interactable object.
|
||||
|
||||
|
||||
Args:
|
||||
callback: The function to call when this object is interacted with
|
||||
"""
|
||||
self._callback = callback
|
||||
|
||||
|
||||
def interact(self, point: np.generic):
|
||||
"""
|
||||
Handle interaction at the given point.
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates of the interaction
|
||||
|
||||
|
||||
Returns:
|
||||
The result of calling the callback function with the point
|
||||
"""
|
||||
if self._callback is None:
|
||||
return None
|
||||
return self._callback(point)
|
||||
|
||||
|
||||
|
||||
class Layoutable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be laid out.
|
||||
Layoutable objects must implement the layout method which arranges their contents.
|
||||
"""
|
||||
|
||||
def layout(self):
|
||||
"""
|
||||
Layout the object's contents.
|
||||
This method should be called before rendering to properly arrange the object's contents.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Queriable(ABC):
|
||||
|
||||
@@ -181,15 +184,15 @@ class FontRegistry:
|
||||
self._fonts: Dict[str, 'Font'] = {}
|
||||
|
||||
def get_or_create_font(self,
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: 'FontWeight' = None,
|
||||
style: 'FontStyle' = None,
|
||||
decoration: 'TextDecoration' = None,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: 'FontWeight' = None,
|
||||
style: 'FontStyle' = None,
|
||||
decoration: 'TextDecoration' = None,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
"""
|
||||
Get or create a font with the specified properties.
|
||||
|
||||
@@ -222,7 +225,11 @@ class FontRegistry:
|
||||
decoration = TextDecoration.NONE
|
||||
|
||||
# If we have a parent with font management, delegate to parent
|
||||
if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'):
|
||||
if hasattr(
|
||||
self,
|
||||
'_parent') and self._parent and hasattr(
|
||||
self._parent,
|
||||
'get_or_create_font'):
|
||||
return self._parent.get_or_create_font(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
@@ -324,10 +331,16 @@ class BlockContainer:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._blocks = []
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
"""Get the list of blocks in this container"""
|
||||
return self._blocks
|
||||
"""
|
||||
Get an iterator over the blocks in this container.
|
||||
|
||||
Can be used as blocks() for iteration or accessing the _blocks list directly.
|
||||
|
||||
Returns:
|
||||
Iterator over blocks
|
||||
"""
|
||||
return iter(self._blocks)
|
||||
|
||||
def add_block(self, block):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Bounded usage-ranked caches for the text rendering hot path.
|
||||
|
||||
Laying out and rasterising a page re-measures and re-draws the same words over and
|
||||
over: a typical page issues ~2800 width measurements and ~2500 glyph rasterisations
|
||||
for fewer than 1000 distinct (font, string) pairs. Caching both collapses that work,
|
||||
but an unbounded cache is not an option on a memory-constrained target such as a
|
||||
Raspberry Pi Zero 2, where the rasterised bitmaps reach ~19MB over a long session.
|
||||
|
||||
Eviction is by **usage count**, not recency. Word frequency in prose is Zipfian and
|
||||
stationary -- a small set of words ("the", "and", "of") accounts for most tokens on
|
||||
every page, and that set barely shifts as the reader advances -- so the words worth
|
||||
keeping are exactly the ones used most.
|
||||
|
||||
Two design choices keep this from costing more than it saves, because `get` runs
|
||||
once per word drawn (~2500 times per page):
|
||||
|
||||
* **Counting is O(1) with no reordering.** Each entry carries its own use counter,
|
||||
bumped in place. Ranking structures that reorder on every hit (a frequency-bucket
|
||||
LFU, or an LRU's linked-list splice) were measured 3-5ms per page slower than the
|
||||
hit rate they buy is worth.
|
||||
* **Eviction samples rather than sorts.** Finding the globally least-used entry
|
||||
would need a heap kept current on every hit. Instead a small random sample is
|
||||
drawn and the least-used member of it evicted, the same approximation Redis uses
|
||||
for its LFU policy. With the default sample size the evicted entry is very
|
||||
likely to be in the bottom few percent, which is all that matters here.
|
||||
|
||||
Both are single-threaded by design; the rendering path holds the GIL throughout and
|
||||
adding locking would cost more than it protects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, TypeVar
|
||||
|
||||
K = TypeVar('K', bound=Hashable)
|
||||
V = TypeVar('V')
|
||||
|
||||
# Entries examined per eviction. Larger samples approximate true least-frequently-used
|
||||
# more closely at linear cost; 8 puts the victim in the bottom ~15% of entries, which
|
||||
# is ample when the alternative is a rasterisation that costs ~60us either way.
|
||||
DEFAULT_EVICTION_SAMPLE = 8
|
||||
|
||||
# Halving every entry's use count after this many insertions keeps the cache
|
||||
# responsive to a change of working set. Without it, entries that were hot long ago
|
||||
# retain counts a newly-hot entry cannot beat and are never evicted -- the classic
|
||||
# failure of pure frequency eviction. Measured on a real access trace, a font-size
|
||||
# change drove hit rate to 0% without aging and left it unchanged with it.
|
||||
DEFAULT_AGING_INTERVAL = 10000
|
||||
|
||||
# Index of each field in an entry. Entries are plain lists rather than tuples or
|
||||
# objects so the counter can be bumped in place, without rehashing the key.
|
||||
_VALUE = 0
|
||||
_COUNT = 1
|
||||
_SLOT = 2
|
||||
|
||||
|
||||
class _UsageRanked(Generic[K, V]):
|
||||
"""
|
||||
Shared usage-count bookkeeping for the caches below.
|
||||
|
||||
Entries live in a dict for lookup and, in parallel, in a flat list that makes
|
||||
uniform random sampling possible. Each entry records its own index in that list
|
||||
so removal can swap in the tail element and stay O(1).
|
||||
|
||||
Subclasses supply the bound by implementing :meth:`_over_budget` and the
|
||||
accounting hooks :meth:`_record_add` / :meth:`_record_remove`.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if aging_interval is not None and aging_interval <= 0:
|
||||
raise ValueError(f"aging_interval must be positive, got {aging_interval}")
|
||||
if eviction_sample <= 0:
|
||||
raise ValueError(f"eviction_sample must be positive, got {eviction_sample}")
|
||||
|
||||
self._aging_interval = aging_interval
|
||||
self._eviction_sample = eviction_sample
|
||||
|
||||
self._entries: Dict[K, List[Any]] = {}
|
||||
self._slots: List[K] = []
|
||||
self._randrange = random.randrange
|
||||
|
||||
self._inserts_since_aging = 0
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._evictions = 0
|
||||
self._agings = 0
|
||||
|
||||
# -- subclass hooks ----------------------------------------------------
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _record_add(self, key: K, value: V):
|
||||
"""Account for a value entering the cache."""
|
||||
|
||||
def _record_remove(self, key: K):
|
||||
"""Account for a value leaving the cache."""
|
||||
|
||||
# -- core operations ---------------------------------------------------
|
||||
|
||||
def get(self, key: K) -> Optional[V]:
|
||||
"""Return the cached value for `key`, or None, counting the use."""
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
self._misses += 1
|
||||
return None
|
||||
entry[_COUNT] += 1
|
||||
self._hits += 1
|
||||
return entry[_VALUE]
|
||||
|
||||
def _add_new(self, key: K, value: V):
|
||||
"""Insert a key not currently present."""
|
||||
# New entries start at 1 rather than 0 so that a single reuse is enough to
|
||||
# outrank an entry that has never been touched since the last aging pass.
|
||||
self._entries[key] = [value, 1, len(self._slots)]
|
||||
self._slots.append(key)
|
||||
self._record_add(key, value)
|
||||
|
||||
def _remove(self, key: K):
|
||||
"""Remove a key outright, keeping the sampling list dense."""
|
||||
entry = self._entries.pop(key)
|
||||
slot = entry[_SLOT]
|
||||
last = self._slots.pop()
|
||||
if last != key:
|
||||
self._slots[slot] = last
|
||||
self._entries[last][_SLOT] = slot
|
||||
self._record_remove(key)
|
||||
|
||||
def _evict_one(self) -> bool:
|
||||
"""Evict the least-used member of a random sample. False if empty."""
|
||||
count = len(self._slots)
|
||||
if not count:
|
||||
return False
|
||||
|
||||
if count <= self._eviction_sample:
|
||||
victim = min(self._slots, key=lambda k: self._entries[k][_COUNT])
|
||||
else:
|
||||
randrange = self._randrange
|
||||
entries = self._entries
|
||||
slots = self._slots
|
||||
victim = slots[randrange(count)]
|
||||
best = entries[victim][_COUNT]
|
||||
for _ in range(self._eviction_sample - 1):
|
||||
candidate = slots[randrange(count)]
|
||||
score = entries[candidate][_COUNT]
|
||||
if score < best:
|
||||
victim, best = candidate, score
|
||||
|
||||
self._remove(victim)
|
||||
self._evictions += 1
|
||||
return True
|
||||
|
||||
def _evict_to_budget(self):
|
||||
while self._over_budget():
|
||||
if not self._evict_one():
|
||||
break
|
||||
|
||||
def _maybe_age(self):
|
||||
"""Halve every use count once the aging interval has elapsed."""
|
||||
if self._aging_interval is None:
|
||||
return
|
||||
self._inserts_since_aging += 1
|
||||
if self._inserts_since_aging < self._aging_interval:
|
||||
return
|
||||
|
||||
self._inserts_since_aging = 0
|
||||
self._agings += 1
|
||||
for entry in self._entries.values():
|
||||
entry[_COUNT] = entry[_COUNT] // 2 or 1
|
||||
|
||||
def clear(self):
|
||||
"""Drop all entries. Counters are preserved."""
|
||||
self._entries.clear()
|
||||
self._slots.clear()
|
||||
self._inserts_since_aging = 0
|
||||
|
||||
def _base_stats(self) -> Dict[str, Any]:
|
||||
total = self._hits + self._misses
|
||||
return {
|
||||
'entries': len(self._entries),
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'evictions': self._evictions,
|
||||
'agings': self._agings,
|
||||
'hit_rate': (self._hits / total) if total else 0.0,
|
||||
}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._entries
|
||||
|
||||
|
||||
class UsageCache(_UsageRanked[K, V]):
|
||||
"""
|
||||
Usage-ranked cache bounded by number of entries.
|
||||
|
||||
Args:
|
||||
max_entries: Maximum number of entries to retain. Must be positive.
|
||||
aging_interval: Insertions between halving all use counts, or None to
|
||||
disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
|
||||
eviction_sample: Entries sampled per eviction.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int,
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if max_entries <= 0:
|
||||
raise ValueError(f"max_entries must be positive, got {max_entries}")
|
||||
super().__init__(aging_interval, eviction_sample)
|
||||
self._max_entries = max_entries
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
return len(self._entries) > self._max_entries
|
||||
|
||||
def put(self, key: K, value: V, count: int = 1):
|
||||
"""
|
||||
Insert `value`, evicting the least-used entries past the bound.
|
||||
|
||||
Args:
|
||||
count: Initial use count. Pass a document-derived frequency to rank a
|
||||
preloaded entry ahead of words that have not been seen yet.
|
||||
"""
|
||||
existing = self._entries.get(key)
|
||||
if existing is not None:
|
||||
existing[_VALUE] = value
|
||||
existing[_COUNT] += 1
|
||||
return
|
||||
self._add_new(key, value)
|
||||
if count > 1:
|
||||
self._entries[key][_COUNT] = count
|
||||
self._evict_to_budget()
|
||||
self._maybe_age()
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return self._max_entries
|
||||
|
||||
def resize(self, max_entries: int):
|
||||
"""Change the bound, evicting immediately if the cache now overflows."""
|
||||
if max_entries <= 0:
|
||||
raise ValueError(f"max_entries must be positive, got {max_entries}")
|
||||
self._max_entries = max_entries
|
||||
self._evict_to_budget()
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Hit/miss/eviction counters and current occupancy."""
|
||||
stats = self._base_stats()
|
||||
stats['max_entries'] = self._max_entries
|
||||
return stats
|
||||
|
||||
|
||||
class SizedUsageCache(_UsageRanked[K, V]):
|
||||
"""
|
||||
Usage-ranked cache bounded by the total size of its values.
|
||||
|
||||
Args:
|
||||
max_bytes: Maximum total value size to retain. Must be positive.
|
||||
sizer: Returns the size in bytes of a value. Called once per insertion.
|
||||
aging_interval: Insertions between halving all use counts, or None to
|
||||
disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
|
||||
eviction_sample: Entries sampled per eviction.
|
||||
|
||||
A value larger than `max_bytes` on its own is returned to the caller but not
|
||||
retained, so that one oversized entry cannot flush the whole cache.
|
||||
"""
|
||||
|
||||
def __init__(self, max_bytes: int, sizer: Callable[[V], int],
|
||||
aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
|
||||
eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
|
||||
if max_bytes <= 0:
|
||||
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
||||
super().__init__(aging_interval, eviction_sample)
|
||||
self._max_bytes = max_bytes
|
||||
self._sizer = sizer
|
||||
self._sizes: Dict[K, int] = {}
|
||||
self._total_bytes = 0
|
||||
|
||||
def _over_budget(self) -> bool:
|
||||
return self._total_bytes > self._max_bytes
|
||||
|
||||
def _record_add(self, key: K, value: V):
|
||||
size = self._sizer(value)
|
||||
self._sizes[key] = size
|
||||
self._total_bytes += size
|
||||
|
||||
def _record_remove(self, key: K):
|
||||
self._total_bytes -= self._sizes.pop(key)
|
||||
|
||||
def put(self, key: K, value: V, count: int = 1):
|
||||
"""
|
||||
Insert `value`, evicting the least-used entries past the bound.
|
||||
|
||||
Args:
|
||||
count: Initial use count. Pass a document-derived frequency to rank a
|
||||
preloaded entry ahead of words that have not been seen yet.
|
||||
"""
|
||||
if key in self._entries:
|
||||
# Re-measure: the replacement may be a different size.
|
||||
self._remove(key)
|
||||
|
||||
if self._sizer(value) > self._max_bytes:
|
||||
# Too large to ever retain; skip rather than flush everything for it.
|
||||
return
|
||||
|
||||
self._add_new(key, value)
|
||||
if count > 1:
|
||||
self._entries[key][_COUNT] = count
|
||||
self._evict_to_budget()
|
||||
self._maybe_age()
|
||||
|
||||
@property
|
||||
def max_bytes(self) -> int:
|
||||
return self._max_bytes
|
||||
|
||||
@property
|
||||
def total_bytes(self) -> int:
|
||||
return self._total_bytes
|
||||
|
||||
def resize(self, max_bytes: int):
|
||||
"""Change the bound, evicting immediately if the cache now overflows."""
|
||||
if max_bytes <= 0:
|
||||
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
||||
self._max_bytes = max_bytes
|
||||
self._evict_to_budget()
|
||||
|
||||
def clear(self):
|
||||
"""Drop all entries. Counters are preserved."""
|
||||
super().clear()
|
||||
self._sizes.clear()
|
||||
self._total_bytes = 0
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Hit/miss/eviction counters and current occupancy."""
|
||||
stats = self._base_stats()
|
||||
stats['total_bytes'] = self._total_bytes
|
||||
stats['max_bytes'] = self._max_bytes
|
||||
return stats
|
||||
@@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
|
||||
- Type-based batch operations
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from pyWebLayout.core.base import Interactable
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class CallbackRegistry:
|
||||
"""Initialize an empty callback registry."""
|
||||
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
|
||||
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
|
||||
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
|
||||
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
|
||||
self._auto_counter: int = 0
|
||||
|
||||
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:
|
||||
|
||||
@@ -6,12 +6,16 @@ managing highlight collections, and rendering highlights on pages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HighlightColor(Enum):
|
||||
"""Predefined highlight colors with RGBA values"""
|
||||
@@ -44,6 +48,12 @@ class Highlight:
|
||||
start_word_index: Optional[int] = None # Word index in document (if available)
|
||||
end_word_index: Optional[int] = None
|
||||
|
||||
# Where in the document this highlight lives, as a serialized
|
||||
# RenderingPosition. `bounds` are pixel coordinates on one particular
|
||||
# rendering, so they stop matching as soon as the font scale or page size
|
||||
# changes; this survives repagination and is what page association uses.
|
||||
position: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Metadata
|
||||
note: Optional[str] = None # Optional annotation
|
||||
tags: List[str] = None # Optional categorization tags
|
||||
@@ -63,6 +73,7 @@ class Highlight:
|
||||
'text': self.text,
|
||||
'start_word_index': self.start_word_index,
|
||||
'end_word_index': self.end_word_index,
|
||||
'position': self.position,
|
||||
'note': self.note,
|
||||
'tags': self.tags,
|
||||
'timestamp': self.timestamp
|
||||
@@ -78,6 +89,7 @@ class Highlight:
|
||||
text=data['text'],
|
||||
start_word_index=data.get('start_word_index'),
|
||||
end_word_index=data.get('end_word_index'),
|
||||
position=data.get('position'),
|
||||
note=data.get('note'),
|
||||
tags=data.get('tags', []),
|
||||
timestamp=data.get('timestamp')
|
||||
@@ -100,12 +112,9 @@ class HighlightManager:
|
||||
highlights_dir: Directory to store highlight data
|
||||
"""
|
||||
self.document_id = document_id
|
||||
self.highlights_dir = Path(highlights_dir)
|
||||
self.highlights_dir = ensure_dir(highlights_dir)
|
||||
self.highlights: Dict[str, Highlight] = {} # id -> Highlight
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
self.highlights_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing highlights
|
||||
self._load_highlights()
|
||||
|
||||
@@ -148,7 +157,8 @@ class HighlightManager:
|
||||
self.highlights.clear()
|
||||
self._save_highlights()
|
||||
|
||||
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
def get_highlights_for_page(
|
||||
self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
"""
|
||||
Get highlights that appear on a specific page.
|
||||
|
||||
@@ -165,7 +175,7 @@ class HighlightManager:
|
||||
# Check if any highlight bounds overlap with page
|
||||
for hx, hy, hw, hh in highlight.bounds:
|
||||
if (hx < page_x + page_w and hx + hw > page_x and
|
||||
hy < page_y + page_h and hy + hh > page_y):
|
||||
hy < page_y + page_h and hy + hh > page_y):
|
||||
page_highlights.append(highlight)
|
||||
break
|
||||
|
||||
@@ -177,34 +187,22 @@ class HighlightManager:
|
||||
|
||||
def _save_highlights(self) -> None:
|
||||
"""Persist highlights to disk"""
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
data = {
|
||||
'document_id': self.document_id,
|
||||
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||
}
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving highlights: {e}")
|
||||
write_json(self._get_filepath(), {
|
||||
'document_id': self.document_id,
|
||||
'highlights': [h.to_dict() for h in self.highlights.values()]
|
||||
})
|
||||
|
||||
def _load_highlights(self) -> None:
|
||||
"""Load highlights from disk"""
|
||||
data = read_json(self._get_filepath(), {})
|
||||
try:
|
||||
filepath = self._get_filepath()
|
||||
if not filepath.exists():
|
||||
return
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.highlights = {
|
||||
h['id']: Highlight.from_dict(h)
|
||||
for h in data.get('highlights', [])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error loading highlights: {e}")
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
logger.warning("Highlight file %s is not in the expected shape; ignoring it",
|
||||
self._get_filepath(), exc_info=True)
|
||||
self.highlights = {}
|
||||
|
||||
|
||||
@@ -212,16 +210,18 @@ def create_highlight_from_query_result(
|
||||
result,
|
||||
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
|
||||
note: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None,
|
||||
position: Optional[Dict[str, Any]] = None
|
||||
) -> Highlight:
|
||||
"""
|
||||
Create a highlight from a QueryResult.
|
||||
|
||||
Args:
|
||||
result: QueryResult from query_pixel or query_range
|
||||
result: QueryResult from query_point or query_range
|
||||
color: RGBA color tuple
|
||||
note: Optional annotation
|
||||
tags: Optional categorization tags
|
||||
position: Serialized RenderingPosition of the page the result came from
|
||||
|
||||
Returns:
|
||||
Highlight instance
|
||||
@@ -242,6 +242,7 @@ def create_highlight_from_query_result(
|
||||
bounds=bounds,
|
||||
color=color,
|
||||
text=text,
|
||||
position=position,
|
||||
note=note,
|
||||
tags=tags or [],
|
||||
timestamp=time()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Small JSON-file helpers shared by the per-document stores.
|
||||
|
||||
BookmarkManager and HighlightManager both keep a JSON file per document under a
|
||||
directory, and both had their own copy of "make the directory, try to read it,
|
||||
swallow and print on failure". The duplication is the point of this module; the
|
||||
file formats themselves stay owned by each store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_dir(path: str | Path) -> Path:
|
||||
"""Return `path` as a Path, creating it and any missing parents."""
|
||||
directory = Path(path)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def read_json(path: Path, default: Any) -> Any:
|
||||
"""
|
||||
Read JSON from `path`, returning `default` if it is missing or unreadable.
|
||||
|
||||
A corrupt store must not stop a book from opening, so failures are logged
|
||||
and swallowed. `default` is returned as given, so pass a fresh mutable if
|
||||
the caller intends to mutate it.
|
||||
"""
|
||||
if not path.exists():
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
logger.warning("Could not read %s; ignoring its contents", path, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> bool:
|
||||
"""
|
||||
Write `data` to `path` as JSON.
|
||||
|
||||
Returns True on success. Failures are logged rather than raised: losing a
|
||||
bookmark is not a reason to take down the reader.
|
||||
"""
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
return True
|
||||
except (OSError, TypeError, ValueError):
|
||||
logger.error("Could not write %s", path, exc_info=True)
|
||||
return False
|
||||
@@ -9,7 +9,6 @@ and text selection.
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple, List, Any, TYPE_CHECKING
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.base import Queriable
|
||||
|
||||
@@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
|
||||
"""
|
||||
|
||||
# Readers
|
||||
from pyWebLayout.io.readers.epub_reader import EPUBReader
|
||||
|
||||
@@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
from typing import Dict, List, Optional, Any, Tuple, Callable
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import urllib.parse
|
||||
from PIL import Image as PILImage, ImageOps
|
||||
|
||||
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
|
||||
from pyWebLayout.abstract.document import Book, Chapter, MetadataType
|
||||
from pyWebLayout.abstract.block import PageBreak
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
|
||||
@@ -33,38 +32,39 @@ def default_eink_processor(img: PILImage.Image) -> PILImage.Image:
|
||||
"""
|
||||
Process image for 4-bit e-ink display using PIL only.
|
||||
Applies histogram equalization and 4-bit quantization.
|
||||
|
||||
|
||||
Args:
|
||||
img: PIL Image to process
|
||||
|
||||
|
||||
Returns:
|
||||
Processed PIL Image in L mode (grayscale) with 4-bit quantization
|
||||
"""
|
||||
# Convert to grayscale if needed
|
||||
if img.mode != 'L':
|
||||
img = img.convert('L')
|
||||
|
||||
|
||||
# Apply histogram equalization for contrast enhancement
|
||||
img = ImageOps.equalize(img)
|
||||
|
||||
|
||||
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
|
||||
img = img.point(lambda x: (x // 16) * 17)
|
||||
|
||||
|
||||
return img
|
||||
|
||||
|
||||
class EPUBReader:
|
||||
"""
|
||||
Reader for EPUB documents.
|
||||
|
||||
|
||||
This class extracts content from EPUB files and converts it to
|
||||
pyWebLayout's abstract document model.
|
||||
"""
|
||||
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[
|
||||
PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
"""
|
||||
Initialize an EPUB reader.
|
||||
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
image_processor: Optional function to process images for display optimization.
|
||||
@@ -82,11 +82,11 @@ class EPUBReader:
|
||||
self.spine = []
|
||||
self.manifest = {}
|
||||
self.cover_id = None # ID of the cover image in manifest
|
||||
|
||||
|
||||
def read(self) -> Book:
|
||||
"""
|
||||
Read the EPUB file and convert it to a Book.
|
||||
|
||||
|
||||
Returns:
|
||||
Book: The parsed book
|
||||
"""
|
||||
@@ -100,45 +100,47 @@ class EPUBReader:
|
||||
|
||||
# Add chapters to the book
|
||||
self._add_chapters()
|
||||
|
||||
|
||||
# Process images for e-ink display optimization
|
||||
self._process_content_images()
|
||||
|
||||
|
||||
return self.book
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
if self.temp_dir:
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _extract_epub(self):
|
||||
"""Extract the EPUB file to a temporary directory."""
|
||||
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(self.temp_dir)
|
||||
|
||||
|
||||
# Find the content directory (typically OEBPS or OPS)
|
||||
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
|
||||
if os.path.exists(container_path):
|
||||
tree = ET.parse(container_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Get the path to the package document (content.opf)
|
||||
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
for rootfile in root.findall(
|
||||
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
full_path = rootfile.get('full-path')
|
||||
if full_path:
|
||||
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
|
||||
self.content_dir = os.path.dirname(
|
||||
os.path.join(self.temp_dir, full_path))
|
||||
return
|
||||
|
||||
|
||||
# Fallback: look for common content directories
|
||||
for content_dir in ['OEBPS', 'OPS', 'Content']:
|
||||
if os.path.exists(os.path.join(self.temp_dir, content_dir)):
|
||||
self.content_dir = os.path.join(self.temp_dir, content_dir)
|
||||
return
|
||||
|
||||
|
||||
# If no content directory found, use the root
|
||||
self.content_dir = self.temp_dir
|
||||
|
||||
|
||||
def _parse_package_document(self):
|
||||
"""Parse the package document (content.opf)."""
|
||||
# Find the package document
|
||||
@@ -150,27 +152,27 @@ class EPUBReader:
|
||||
break
|
||||
if opf_path:
|
||||
break
|
||||
|
||||
|
||||
if not opf_path:
|
||||
raise ValueError("No package document (.opf) found in EPUB")
|
||||
|
||||
|
||||
# Parse the package document
|
||||
tree = ET.parse(opf_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Parse metadata
|
||||
self._parse_metadata(root)
|
||||
|
||||
|
||||
# Parse manifest
|
||||
self._parse_manifest(root)
|
||||
|
||||
|
||||
# Parse spine
|
||||
self._parse_spine(root)
|
||||
|
||||
|
||||
def _parse_metadata(self, root: ET.Element):
|
||||
"""
|
||||
Parse metadata from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@@ -178,14 +180,14 @@ class EPUBReader:
|
||||
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
|
||||
if metadata_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse DC metadata
|
||||
for elem in metadata_elem:
|
||||
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
|
||||
# Get the local name (without namespace)
|
||||
name = elem.tag.split('}', 1)[1]
|
||||
value = elem.text
|
||||
|
||||
|
||||
if name == 'title':
|
||||
self.metadata['title'] = value
|
||||
elif name == 'creator':
|
||||
@@ -207,20 +209,20 @@ class EPUBReader:
|
||||
else:
|
||||
# Store other metadata
|
||||
self.metadata[name] = value
|
||||
|
||||
|
||||
# Parse meta elements for cover reference
|
||||
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
|
||||
name = meta.get('name')
|
||||
content = meta.get('content')
|
||||
|
||||
|
||||
if name == 'cover' and content:
|
||||
# This is a reference to the cover image in the manifest
|
||||
self.cover_id = content
|
||||
|
||||
|
||||
def _parse_manifest(self, root: ET.Element):
|
||||
"""
|
||||
Parse manifest from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@@ -228,28 +230,28 @@ class EPUBReader:
|
||||
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
|
||||
if manifest_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse items
|
||||
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
|
||||
id = item.get('id')
|
||||
href = item.get('href')
|
||||
media_type = item.get('media-type')
|
||||
|
||||
|
||||
if id and href:
|
||||
# Resolve relative path
|
||||
href = urllib.parse.unquote(href)
|
||||
path = os.path.normpath(os.path.join(self.content_dir, href))
|
||||
|
||||
|
||||
self.manifest[id] = {
|
||||
'href': href,
|
||||
'path': path,
|
||||
'media_type': media_type
|
||||
}
|
||||
|
||||
|
||||
def _parse_spine(self, root: ET.Element):
|
||||
"""
|
||||
Parse spine from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@@ -257,21 +259,25 @@ class EPUBReader:
|
||||
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
|
||||
if spine_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Get the toc attribute (NCX file ID)
|
||||
toc_id = spine_elem.get('toc')
|
||||
if toc_id and toc_id in self.manifest:
|
||||
self.toc_path = self.manifest[toc_id]['path']
|
||||
|
||||
|
||||
# Parse itemrefs
|
||||
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
for itemref in spine_elem.findall(
|
||||
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
idref = itemref.get('idref')
|
||||
if idref and idref in self.manifest:
|
||||
self.spine.append(idref)
|
||||
|
||||
|
||||
def _parse_toc(self):
|
||||
"""Parse the table of contents."""
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# Try to find the toc.ncx file
|
||||
for root, dirs, files in os.walk(self.content_dir):
|
||||
for file in files:
|
||||
@@ -280,27 +286,30 @@ class EPUBReader:
|
||||
break
|
||||
if hasattr(self, 'toc_path') and self.toc_path:
|
||||
break
|
||||
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# No TOC found
|
||||
return
|
||||
|
||||
|
||||
# Parse the NCX file
|
||||
tree = ET.parse(self.toc_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Parse navMap
|
||||
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
|
||||
if nav_map is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse navPoints
|
||||
self._parse_nav_points(nav_map, [])
|
||||
|
||||
|
||||
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
|
||||
"""
|
||||
Recursively parse navPoints from the NCX file.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent element containing navPoints
|
||||
path: Current path in the TOC hierarchy
|
||||
@@ -309,16 +318,17 @@ class EPUBReader:
|
||||
# Get navPoint attributes
|
||||
id = nav_point.get('id')
|
||||
play_order = nav_point.get('playOrder')
|
||||
|
||||
|
||||
# Get navLabel
|
||||
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
|
||||
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
text_elem = nav_label.find(
|
||||
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
label = text_elem.text if text_elem is not None else ""
|
||||
|
||||
|
||||
# Get content
|
||||
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
|
||||
src = content.get('src') if content is not None else ""
|
||||
|
||||
|
||||
# Create a TOC entry
|
||||
entry = {
|
||||
'id': id,
|
||||
@@ -327,78 +337,83 @@ class EPUBReader:
|
||||
'play_order': play_order,
|
||||
'children': []
|
||||
}
|
||||
|
||||
|
||||
# Add to TOC
|
||||
if path:
|
||||
path[-1]['children'].append(entry)
|
||||
else:
|
||||
self.toc.append(entry)
|
||||
|
||||
|
||||
# Parse child navPoints
|
||||
self._parse_nav_points(nav_point, path + [entry])
|
||||
|
||||
|
||||
def _create_book(self):
|
||||
"""Create a Book object from the parsed metadata."""
|
||||
# Set book metadata
|
||||
if 'title' in self.metadata:
|
||||
self.book.set_title(self.metadata['title'])
|
||||
|
||||
|
||||
if 'creator' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
|
||||
|
||||
|
||||
if 'language' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
|
||||
|
||||
|
||||
if 'description' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
|
||||
|
||||
self.book.set_metadata(
|
||||
MetadataType.DESCRIPTION,
|
||||
self.metadata['description'])
|
||||
|
||||
if 'subjects' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
|
||||
|
||||
self.book.set_metadata(
|
||||
MetadataType.KEYWORDS, ', '.join(
|
||||
self.metadata['subjects']))
|
||||
|
||||
if 'date' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
|
||||
|
||||
|
||||
if 'identifier' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
|
||||
|
||||
|
||||
if 'publisher' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
|
||||
|
||||
|
||||
def _add_cover_chapter(self):
|
||||
"""Add a cover chapter if a cover image is available."""
|
||||
if not self.cover_id or self.cover_id not in self.manifest:
|
||||
return
|
||||
|
||||
|
||||
# Get the cover image path from the manifest
|
||||
cover_item = self.manifest[self.cover_id]
|
||||
cover_path = cover_item['path']
|
||||
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(cover_path):
|
||||
print(f"Warning: Cover image file not found: {cover_path}")
|
||||
return
|
||||
|
||||
|
||||
# Create a cover chapter
|
||||
cover_chapter = self.book.create_chapter("Cover", 0)
|
||||
|
||||
|
||||
try:
|
||||
# Create an Image block for the cover
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from PIL import Image as PILImage
|
||||
import io
|
||||
|
||||
|
||||
# Load the image into memory before the temp directory is cleaned up
|
||||
# We need to fully copy the image data to ensure it persists after temp cleanup
|
||||
# We need to fully copy the image data to ensure it persists after temp
|
||||
# cleanup
|
||||
with open(cover_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
|
||||
# Create PIL image from bytes in memory
|
||||
pil_image = PILImage.open(io.BytesIO(image_bytes))
|
||||
pil_image.load() # Force loading into memory
|
||||
|
||||
|
||||
# Create a copy to ensure all data is in memory
|
||||
pil_image = pil_image.copy()
|
||||
|
||||
|
||||
# Apply image processing if enabled
|
||||
if self.image_processor:
|
||||
try:
|
||||
@@ -406,20 +421,21 @@ class EPUBReader:
|
||||
except Exception as e:
|
||||
print(f"Warning: Image processing failed for cover: {str(e)}")
|
||||
# Continue with unprocessed image
|
||||
|
||||
|
||||
# Create an AbstractImage block with the cover image path
|
||||
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
|
||||
|
||||
|
||||
# Set dimensions from the loaded image
|
||||
cover_image._width = pil_image.width
|
||||
cover_image._height = pil_image.height
|
||||
|
||||
# Store the loaded PIL image in the abstract image so it persists after temp cleanup
|
||||
|
||||
# Store the loaded PIL image in the abstract image so it persists after
|
||||
# temp cleanup
|
||||
cover_image._loaded_image = pil_image
|
||||
|
||||
|
||||
# Add the image to the cover chapter
|
||||
cover_chapter.add_block(cover_image)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating cover chapter: {str(e)}")
|
||||
import traceback
|
||||
@@ -427,42 +443,72 @@ class EPUBReader:
|
||||
# If we can't create the cover image, remove the chapter
|
||||
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
|
||||
self.book.chapters.remove(cover_chapter)
|
||||
|
||||
|
||||
def _process_chapter_images(self, chapter: Chapter):
|
||||
"""
|
||||
Process images in a single chapter.
|
||||
|
||||
Load and process images in a single chapter.
|
||||
|
||||
This method loads images from disk into memory and applies image processing.
|
||||
Images must be loaded before the temporary EPUB directory is cleaned up.
|
||||
|
||||
Args:
|
||||
chapter: The chapter containing images to process
|
||||
"""
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
|
||||
from PIL import Image as PILImage
|
||||
import io
|
||||
|
||||
for block in chapter.blocks:
|
||||
if isinstance(block, AbstractImage):
|
||||
# Only process if image has been loaded and processor is enabled
|
||||
if hasattr(block, '_loaded_image') and block._loaded_image:
|
||||
# Load image into memory if not already loaded
|
||||
if not hasattr(block, '_loaded_image') or not block._loaded_image:
|
||||
try:
|
||||
# Load the image from the source path
|
||||
if os.path.isfile(block.source):
|
||||
with open(block.source, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
# Create PIL image from bytes in memory
|
||||
pil_image = PILImage.open(io.BytesIO(image_bytes))
|
||||
pil_image.load() # Force loading into memory
|
||||
block._loaded_image = pil_image.copy() # Create a copy to ensure it persists
|
||||
|
||||
# Set width and height on the block from the loaded image
|
||||
# This is required for layout calculations
|
||||
block._width = pil_image.width
|
||||
block._height = pil_image.height
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load image '{block.source}': {str(e)}")
|
||||
# Continue without the image
|
||||
continue
|
||||
|
||||
# Apply image processing if enabled and image is loaded
|
||||
if self.image_processor and hasattr(block, '_loaded_image') and block._loaded_image:
|
||||
try:
|
||||
block._loaded_image = self.image_processor(block._loaded_image)
|
||||
except Exception as e:
|
||||
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
|
||||
print(
|
||||
f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}"
|
||||
)
|
||||
# Continue with unprocessed image
|
||||
|
||||
|
||||
def _process_content_images(self):
|
||||
"""Apply image processing to all images in chapters."""
|
||||
if not self.image_processor:
|
||||
return
|
||||
|
||||
"""
|
||||
Load all images into memory and apply image processing.
|
||||
|
||||
This must be called before the temporary EPUB directory is cleaned up,
|
||||
to ensure images are loaded from disk into memory.
|
||||
"""
|
||||
for chapter in self.book.chapters:
|
||||
self._process_chapter_images(chapter)
|
||||
|
||||
|
||||
def _add_chapters(self):
|
||||
"""Add chapters to the book based on the spine and TOC."""
|
||||
# Add cover chapter first if available
|
||||
self._add_cover_chapter()
|
||||
|
||||
|
||||
# Create a mapping from src to TOC entry
|
||||
toc_map = {}
|
||||
|
||||
|
||||
def add_to_toc_map(entries):
|
||||
for entry in entries:
|
||||
if entry['src']:
|
||||
@@ -470,58 +516,61 @@ class EPUBReader:
|
||||
src_parts = entry['src'].split('#', 1)
|
||||
path = src_parts[0]
|
||||
toc_map[path] = entry
|
||||
|
||||
|
||||
# Process children
|
||||
if entry['children']:
|
||||
add_to_toc_map(entry['children'])
|
||||
|
||||
|
||||
add_to_toc_map(self.toc)
|
||||
|
||||
|
||||
# Process spine items
|
||||
# Start from chapter_index = 1 if cover was added, otherwise 0
|
||||
chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
|
||||
for i, idref in enumerate(self.spine):
|
||||
if idref not in self.manifest:
|
||||
continue
|
||||
|
||||
|
||||
item = self.manifest[idref]
|
||||
path = item['path']
|
||||
href = item['href']
|
||||
|
||||
|
||||
# Skip navigation files
|
||||
if (idref == 'nav' or
|
||||
item.get('media_type') == 'application/xhtml+xml' and
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
if (idref == 'nav' or
|
||||
item.get('media_type') == 'application/xhtml+xml' and
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
continue
|
||||
|
||||
|
||||
# Check if this item is in the TOC
|
||||
chapter_title = None
|
||||
if href in toc_map:
|
||||
chapter_title = toc_map[href]['label']
|
||||
|
||||
|
||||
# Create a chapter
|
||||
chapter_index += 1
|
||||
chapter = self.book.create_chapter(chapter_title, chapter_index)
|
||||
|
||||
|
||||
# Parse the HTML content
|
||||
try:
|
||||
# Read the HTML file
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
# Parse HTML and add blocks to chapter
|
||||
blocks = parse_html_string(html, document=self.book)
|
||||
|
||||
|
||||
# Get the directory of the HTML file for resolving relative paths
|
||||
html_dir = os.path.dirname(path)
|
||||
|
||||
# Parse HTML and add blocks to chapter, passing base_path for image resolution
|
||||
blocks = parse_html_string(html, document=self.book, base_path=html_dir)
|
||||
|
||||
# Copy blocks to the chapter
|
||||
for block in blocks:
|
||||
chapter.add_block(block)
|
||||
|
||||
|
||||
# Add a PageBreak after the chapter to ensure next chapter starts on new page
|
||||
# This helps maintain chapter boundaries during pagination
|
||||
chapter.add_block(PageBreak())
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing chapter {i+1}: {str(e)}")
|
||||
print(f"Error parsing chapter {i + 1}: {str(e)}")
|
||||
# Add an error message block
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
@@ -529,7 +578,12 @@ class EPUBReader:
|
||||
error_para = Paragraph()
|
||||
# Create a default font style for the error message
|
||||
default_font = Font()
|
||||
error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font))
|
||||
error_para.add_word(
|
||||
Word(
|
||||
f"Error loading chapter: {str(e)}",
|
||||
default_font
|
||||
)
|
||||
)
|
||||
chapter.add_block(error_para)
|
||||
# Still add PageBreak even after error
|
||||
chapter.add_block(PageBreak())
|
||||
@@ -538,10 +592,10 @@ class EPUBReader:
|
||||
def read_epub(epub_path: str) -> Book:
|
||||
"""
|
||||
Read an EPUB file and convert it to a Book.
|
||||
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
|
||||
|
||||
Returns:
|
||||
Book: The parsed book
|
||||
"""
|
||||
|
||||
@@ -6,10 +6,10 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
|
||||
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from bs4.element import CData, Comment, Doctype, ProcessingInstruction
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block,
|
||||
Paragraph,
|
||||
@@ -27,8 +27,6 @@ from pyWebLayout.abstract.block import (
|
||||
Image,
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style import Alignment as TextAlign
|
||||
|
||||
|
||||
class StyleContext(NamedTuple):
|
||||
@@ -44,6 +42,7 @@ class StyleContext(NamedTuple):
|
||||
element_attributes: Dict[str, Any]
|
||||
parent_elements: List[str] # Stack of parent element names
|
||||
document: Optional[Any] # Reference to document for font registry
|
||||
base_path: Optional[str] = None # Base path for resolving relative URLs
|
||||
|
||||
def with_font(self, font: Font) -> "StyleContext":
|
||||
"""Create new context with modified font."""
|
||||
@@ -72,25 +71,37 @@ class StyleContext(NamedTuple):
|
||||
return self._replace(parent_elements=self.parent_elements + [element_name])
|
||||
|
||||
|
||||
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
|
||||
def create_base_context(
|
||||
base_font: Optional[Font] = None,
|
||||
document=None,
|
||||
base_path: Optional[str] = None) -> StyleContext:
|
||||
"""
|
||||
Create a base style context with default values.
|
||||
|
||||
Args:
|
||||
base_font: Base font to use, defaults to system default
|
||||
document: Document instance for font registry
|
||||
base_path: Base directory path for resolving relative URLs
|
||||
|
||||
Returns:
|
||||
StyleContext with default values
|
||||
"""
|
||||
# Use document's font registry if available, otherwise create default font
|
||||
if base_font is None:
|
||||
if document and hasattr(document, 'get_or_create_font'):
|
||||
base_font = document.get_or_create_font()
|
||||
else:
|
||||
base_font = Font()
|
||||
|
||||
return StyleContext(
|
||||
font=base_font or Font(),
|
||||
font=base_font,
|
||||
background=None,
|
||||
css_classes=set(),
|
||||
css_styles={},
|
||||
element_attributes={},
|
||||
parent_elements=[],
|
||||
document=document,
|
||||
base_path=base_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,7 +141,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
|
||||
new_context = new_context.with_css_styles(css_styles)
|
||||
|
||||
# Apply element-specific default styles
|
||||
font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
|
||||
font = apply_element_font_styles(
|
||||
new_context.font, tag_name, css_styles, new_context)
|
||||
new_context = new_context.with_font(font)
|
||||
|
||||
# Apply background from styles
|
||||
@@ -158,9 +170,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
|
||||
return styles
|
||||
|
||||
|
||||
def apply_element_font_styles(
|
||||
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
|
||||
) -> Font:
|
||||
def apply_element_font_styles(font: Font,
|
||||
tag_name: str,
|
||||
css_styles: Dict[str,
|
||||
str],
|
||||
context: Optional[StyleContext] = None) -> Font:
|
||||
"""
|
||||
Apply font styling based on HTML element and CSS styles.
|
||||
Uses document's font registry when available to avoid creating duplicate fonts.
|
||||
@@ -273,17 +287,19 @@ def apply_element_font_styles(
|
||||
pass
|
||||
|
||||
# Use document's style registry if available to avoid creating duplicate styles
|
||||
if context and context.document and hasattr(context.document, 'get_or_create_style'):
|
||||
if context and context.document and hasattr(
|
||||
context.document, 'get_or_create_style'):
|
||||
# Create an abstract style first
|
||||
from pyWebLayout.style.abstract_style import FontFamily, FontSize
|
||||
|
||||
|
||||
# Map font properties to abstract style properties
|
||||
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
|
||||
if font_size:
|
||||
font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
|
||||
font_size_value = font_size if isinstance(
|
||||
font_size, int) else FontSize.MEDIUM
|
||||
else:
|
||||
font_size_value = FontSize.MEDIUM
|
||||
|
||||
|
||||
# Create abstract style and register it
|
||||
style_id, abstract_style = context.document.get_or_create_style(
|
||||
font_family=font_family,
|
||||
@@ -294,7 +310,7 @@ def apply_element_font_styles(
|
||||
color=colour,
|
||||
language=language
|
||||
)
|
||||
|
||||
|
||||
# Get the concrete font for this style
|
||||
return context.document.get_font_for_style(abstract_style)
|
||||
elif context and context.document and hasattr(context.document, 'get_or_create_font'):
|
||||
@@ -354,23 +370,46 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
element: BeautifulSoup Tag object
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
List of Word objects (including LinkedWord for hyperlinks)
|
||||
"""
|
||||
return extract_words_from_nodes(list(element.children), context)
|
||||
|
||||
|
||||
def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]:
|
||||
"""
|
||||
Extract words from a sequence of sibling nodes.
|
||||
|
||||
Separated from extract_text_content so that a container holding a mix of
|
||||
inline and block children can hand over just the inline runs, without
|
||||
building a synthetic element to wrap them in.
|
||||
|
||||
Args:
|
||||
nodes: BeautifulSoup nodes (Tags and NavigableStrings) in document order
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
List of Word objects (including LinkedWord for hyperlinks)
|
||||
"""
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
words = []
|
||||
|
||||
for child in element.children:
|
||||
for child in nodes:
|
||||
# Comments and processing instructions are NavigableString subclasses;
|
||||
# their text is markup, not content.
|
||||
if isinstance(child, (Comment, Doctype, CData, ProcessingInstruction)):
|
||||
continue
|
||||
|
||||
if isinstance(child, NavigableString):
|
||||
# Plain text - split into words
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
word_texts = text.split()
|
||||
for word_text in word_texts:
|
||||
if word_text:
|
||||
words.append(Word(word_text, context.font, context.background))
|
||||
# Plain text - split into words. Argument-less str.split() already
|
||||
# discards surrounding whitespace and never yields an empty string, so
|
||||
# it needs neither a preceding strip() nor a per-word emptiness test.
|
||||
font = context.font
|
||||
background = context.background
|
||||
words.extend([Word(word_text, font, background)
|
||||
for word_text in str(child).split()])
|
||||
elif isinstance(child, Tag):
|
||||
# Special handling for <a> tags (hyperlinks)
|
||||
if child.name.lower() == "a":
|
||||
@@ -385,14 +424,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
link_type = LinkType.API
|
||||
else:
|
||||
link_type = LinkType.INTERNAL
|
||||
|
||||
|
||||
# Apply link styling
|
||||
child_context = apply_element_styling(context, child)
|
||||
|
||||
|
||||
# Extract text and create LinkedWord for each word
|
||||
link_text = child.get_text(strip=True)
|
||||
title = child.get('title', '')
|
||||
|
||||
|
||||
for word_text in link_text.split():
|
||||
if word_text:
|
||||
linked_word = LinkedWord(
|
||||
@@ -409,7 +448,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
child_context = apply_element_styling(context, child)
|
||||
child_words = extract_text_content(child, child_context)
|
||||
words.extend(child_words)
|
||||
|
||||
|
||||
# Process other inline elements
|
||||
elif child.name.lower() in [
|
||||
"span",
|
||||
@@ -435,7 +474,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
child_words = extract_text_content(child, child_context)
|
||||
words.extend(child_words)
|
||||
else:
|
||||
# Block element - shouldn't happen in well-formed HTML but handle gracefully
|
||||
# Block element - shouldn't happen in well-formed HTML but handle
|
||||
# gracefully
|
||||
child_context = apply_element_styling(context, child)
|
||||
child_result = process_element(child, child_context)
|
||||
if isinstance(child_result, list):
|
||||
@@ -450,6 +490,93 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
return words
|
||||
|
||||
|
||||
# Tags that flow within a line of text rather than forming a block of their own.
|
||||
# They carry no handler of their own: extract_words_from_nodes consumes them,
|
||||
# applying their styling to the words they contain.
|
||||
INLINE_TAGS = frozenset({
|
||||
"a", "span", "strong", "b", "em", "i", "u", "s", "del", "ins", "mark",
|
||||
"small", "sub", "sup", "code", "q", "cite", "abbr", "time",
|
||||
})
|
||||
|
||||
|
||||
def is_inline(node) -> bool:
|
||||
"""
|
||||
Whether a node belongs to a run of text rather than standing as its own block.
|
||||
|
||||
Args:
|
||||
node: A BeautifulSoup Tag or NavigableString
|
||||
|
||||
Returns:
|
||||
True for text and inline tags, False for block-level tags
|
||||
"""
|
||||
if isinstance(node, Tag):
|
||||
return node.name.lower() in INLINE_TAGS
|
||||
if isinstance(node, (Comment, Doctype, CData, ProcessingInstruction)):
|
||||
return False
|
||||
return isinstance(node, NavigableString)
|
||||
|
||||
|
||||
def process_block_children(element: Tag, context: StyleContext) -> List[Block]:
|
||||
"""
|
||||
Process a container's children into a list of blocks.
|
||||
|
||||
Containers may hold a mix of inline and block content. Consecutive inline
|
||||
children are gathered into a run and become one Paragraph; a block child ends
|
||||
the current run and is processed by its own handler. This is the single entry
|
||||
point for every container that is not itself a paragraph - div, li, td, th,
|
||||
blockquote and the semantic containers.
|
||||
|
||||
Without this, inline tags reach process_element, whose handler for them is
|
||||
ignore_handler, and their text is silently dropped.
|
||||
|
||||
Args:
|
||||
element: The container element
|
||||
context: Current style context
|
||||
|
||||
Returns:
|
||||
Blocks in document order
|
||||
"""
|
||||
blocks: List[Block] = []
|
||||
run: List = []
|
||||
|
||||
def flush_run():
|
||||
"""Turn the pending inline run into a paragraph, if it holds any words."""
|
||||
if not run:
|
||||
return
|
||||
words = extract_words_from_nodes(run, context)
|
||||
run.clear()
|
||||
if words:
|
||||
paragraph = Paragraph(context.font)
|
||||
for word in words:
|
||||
paragraph.add_word(word)
|
||||
blocks.append(paragraph)
|
||||
|
||||
for child in element.children:
|
||||
# <br> ends the current line of text and starts a new one.
|
||||
if isinstance(child, Tag) and child.name.lower() == "br":
|
||||
flush_run()
|
||||
continue
|
||||
|
||||
if is_inline(child):
|
||||
run.append(child)
|
||||
continue
|
||||
|
||||
if not isinstance(child, Tag):
|
||||
continue # comments and similar
|
||||
|
||||
flush_run()
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
blocks.extend(result)
|
||||
else:
|
||||
blocks.append(result)
|
||||
|
||||
flush_run()
|
||||
return blocks
|
||||
|
||||
|
||||
def process_element(
|
||||
element: Tag, context: StyleContext
|
||||
) -> Union[Block, List[Block], None]:
|
||||
@@ -469,11 +596,69 @@ def process_element(
|
||||
|
||||
|
||||
# Handler function signatures:
|
||||
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
|
||||
# All handlers receive (element: Tag, context: StyleContext) ->
|
||||
# Union[Block, List[Block], None]
|
||||
|
||||
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
"""Handle <p> elements."""
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, List[Block], Image]:
|
||||
"""
|
||||
Handle <p> elements.
|
||||
|
||||
Special handling for paragraphs containing images:
|
||||
- If the paragraph contains only an image (common in EPUBs), return the image block
|
||||
- If the paragraph contains images mixed with text, split into separate blocks
|
||||
- Otherwise, return a normal paragraph with text content
|
||||
"""
|
||||
# Check if paragraph contains any img tags (including nested ones)
|
||||
img_tags = element.find_all('img')
|
||||
|
||||
if img_tags:
|
||||
# Paragraph contains images - need special handling
|
||||
blocks = []
|
||||
|
||||
# Check if this is an image-only paragraph (very common in EPUBs)
|
||||
# Get text content without the img tags
|
||||
text_content = element.get_text(strip=True)
|
||||
|
||||
if not text_content or len(text_content.strip()) == 0:
|
||||
# Image-only paragraph - return just the image(s)
|
||||
for img_tag in img_tags:
|
||||
child_context = apply_element_styling(context, img_tag)
|
||||
img_block = image_handler(img_tag, child_context)
|
||||
if img_block:
|
||||
blocks.append(img_block)
|
||||
|
||||
# Return single image or list of images
|
||||
if len(blocks) == 1:
|
||||
return blocks[0]
|
||||
return blocks if blocks else Paragraph(context.font)
|
||||
|
||||
# Mixed content - paragraph has both text and images
|
||||
# Process children in order to preserve structure
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
if child.name == 'img':
|
||||
# Add the image as a separate block
|
||||
child_context = apply_element_styling(context, child)
|
||||
img_block = image_handler(child, child_context)
|
||||
if img_block:
|
||||
blocks.append(img_block)
|
||||
else:
|
||||
# Process other inline elements as part of text
|
||||
# This will be handled by extract_text_content below
|
||||
pass
|
||||
|
||||
# Also add a paragraph with the text content
|
||||
paragraph = Paragraph(context.font)
|
||||
words = extract_text_content(element, context)
|
||||
if words:
|
||||
for word in words:
|
||||
paragraph.add_word(word)
|
||||
blocks.insert(0, paragraph) # Text comes before images
|
||||
|
||||
return blocks if blocks else Paragraph(context.font)
|
||||
|
||||
# No images - normal paragraph handling
|
||||
paragraph = Paragraph(context.font)
|
||||
words = extract_text_content(element, context)
|
||||
for word in words:
|
||||
@@ -483,17 +668,7 @@ def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
|
||||
def div_handler(element: Tag, context: StyleContext) -> List[Block]:
|
||||
"""Handle <div> elements - treat as generic container."""
|
||||
blocks = []
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
blocks.extend(result)
|
||||
else:
|
||||
blocks.append(result)
|
||||
return blocks
|
||||
return process_block_children(element, context)
|
||||
|
||||
|
||||
def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||
@@ -518,16 +693,8 @@ def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||
def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
|
||||
"""Handle <blockquote> elements."""
|
||||
quote = Quote(context.font)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
quote.add_block(block)
|
||||
else:
|
||||
quote.add_block(result)
|
||||
for block in process_block_children(element, context):
|
||||
quote.add_block(block)
|
||||
return quote
|
||||
|
||||
|
||||
@@ -581,28 +748,8 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
|
||||
def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
|
||||
"""Handle <li> elements."""
|
||||
list_item = ListItem(None, context.font)
|
||||
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
list_item.add_block(block)
|
||||
else:
|
||||
list_item.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in list item - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
list_item.add_block(paragraph)
|
||||
|
||||
for block in process_block_children(element, context):
|
||||
list_item.add_block(block)
|
||||
return list_item
|
||||
|
||||
|
||||
@@ -654,27 +801,8 @@ def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(False, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in cell - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -685,26 +813,8 @@ def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(True, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content (same as td)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -722,9 +832,19 @@ def line_break_handler(element: Tag, context: StyleContext) -> None:
|
||||
|
||||
def image_handler(element: Tag, context: StyleContext) -> Image:
|
||||
"""Handle <img> elements."""
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
src = context.element_attributes.get("src", "")
|
||||
alt_text = context.element_attributes.get("alt", "")
|
||||
|
||||
# Resolve relative paths if base_path is provided
|
||||
if context.base_path and src and not src.startswith(('http://', 'https://', '/')):
|
||||
# Parse the src to handle URL-encoded characters
|
||||
src_decoded = urllib.parse.unquote(src)
|
||||
# Resolve relative path to absolute path
|
||||
src = os.path.normpath(os.path.join(context.base_path, src_decoded))
|
||||
|
||||
# Parse dimensions if provided
|
||||
width = height = None
|
||||
try:
|
||||
@@ -813,7 +933,7 @@ HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None
|
||||
|
||||
|
||||
def parse_html_string(
|
||||
html_string: str, base_font: Optional[Font] = None, document=None
|
||||
html_string: str, base_font: Optional[Font] = None, document=None, base_path: Optional[str] = None
|
||||
) -> List[Block]:
|
||||
"""
|
||||
Parse HTML string and return list of Block objects.
|
||||
@@ -822,12 +942,14 @@ def parse_html_string(
|
||||
html_string: HTML content to parse
|
||||
base_font: Base font for styling, defaults to system default
|
||||
document: Document instance for font registry to avoid duplicate fonts
|
||||
base_path: Base directory path for resolving relative URLs (e.g., image sources)
|
||||
|
||||
Returns:
|
||||
List of Block objects representing the document structure
|
||||
"""
|
||||
soup = BeautifulSoup(html_string, "html.parser")
|
||||
context = create_base_context(base_font, document)
|
||||
context = create_base_context(base_font, document, base_path)
|
||||
|
||||
blocks = []
|
||||
|
||||
# Process the body if it exists, otherwise process all top-level elements
|
||||
|
||||
@@ -5,16 +5,22 @@ import numpy as np
|
||||
|
||||
from pyWebLayout.concrete import Page, Line, Text
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, Heading, PageBreak, Table
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph,
|
||||
page: Page,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None,
|
||||
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph of text within a given page.
|
||||
|
||||
@@ -44,7 +50,16 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# paragraph.style is already a Font object (concrete), not AbstractStyle
|
||||
# We need to get word spacing constraints from the Font's abstract style if available
|
||||
# For now, use reasonable defaults based on font size
|
||||
|
||||
|
||||
# Alignment for text that does not specify its own. Headings are never
|
||||
# justified - stretching a two-word title across the measure is always wrong -
|
||||
# so they fall back to flush left.
|
||||
default_alignment = getattr(page.style, 'default_alignment', None)
|
||||
if not isinstance(default_alignment, Alignment):
|
||||
default_alignment = Alignment.JUSTIFY
|
||||
if isinstance(paragraph, Heading):
|
||||
default_alignment = Alignment.LEFT
|
||||
|
||||
if isinstance(paragraph.style, Font):
|
||||
# paragraph.style is already a Font (concrete style)
|
||||
font = paragraph.style
|
||||
@@ -53,7 +68,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
min_spacing = float(font.font_size) * 0.25 # 25% of font size
|
||||
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
||||
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
||||
text_align = Alignment.LEFT # Default alignment
|
||||
text_align = default_alignment
|
||||
else:
|
||||
# paragraph.style is an AbstractStyle, resolve it
|
||||
# Ensure font_size is an int (it could be a FontSize enum)
|
||||
@@ -63,7 +78,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
base_font_size = 16
|
||||
else:
|
||||
base_font_size = int(paragraph.style.font_size)
|
||||
|
||||
|
||||
rendering_context = RenderingContext(base_font_size=base_font_size)
|
||||
style_resolver = StyleResolver(rendering_context)
|
||||
style_registry = ConcreteStyleRegistry(style_resolver)
|
||||
@@ -73,59 +88,90 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
int(concrete_style.word_spacing_min),
|
||||
int(concrete_style.word_spacing_max)
|
||||
)
|
||||
text_align = concrete_style.text_align
|
||||
|
||||
# text_align is None when the source did not specify one.
|
||||
text_align = concrete_style.text_align or default_alignment
|
||||
|
||||
# Apply page-level word spacing override if specified
|
||||
if hasattr(
|
||||
page.style,
|
||||
'word_spacing') and isinstance(
|
||||
page.style.word_spacing,
|
||||
int) and page.style.word_spacing > 0:
|
||||
# Add the page-level word spacing to both min and max constraints
|
||||
min_ws, max_ws = word_spacing_constraints
|
||||
word_spacing_constraints = (
|
||||
min_ws + page.style.word_spacing,
|
||||
max_ws + page.style.word_spacing
|
||||
)
|
||||
|
||||
# Apply alignment override if provided
|
||||
if alignment_override is not None:
|
||||
text_align = alignment_override
|
||||
|
||||
|
||||
# Cap font size to page maximum if needed
|
||||
if font.font_size > page.style.max_font_size:
|
||||
font = Font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
|
||||
# Calculate baseline-to-baseline spacing using line spacing multiplier
|
||||
# Use paragraph's font registry to create the capped font
|
||||
if hasattr(paragraph, 'get_or_create_font'):
|
||||
font = paragraph.get_or_create_font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
else:
|
||||
# Fallback to direct creation (will still use global cache)
|
||||
font = Font(
|
||||
font_path=font._font_path,
|
||||
font_size=page.style.max_font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
|
||||
# Calculate baseline-to-baseline spacing: font size + additional line spacing
|
||||
# This is the vertical distance between baselines of consecutive lines
|
||||
baseline_spacing = int(font.font_size * page.style.line_spacing_multiplier)
|
||||
|
||||
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
|
||||
line_spacing_value = getattr(page.style, 'line_spacing', 5)
|
||||
# Ensure line_spacing is an int (could be Mock in tests)
|
||||
if not isinstance(line_spacing_value, int):
|
||||
line_spacing_value = 5
|
||||
baseline_spacing = font.font_size + line_spacing_value
|
||||
|
||||
# Get font metrics for boundary checking
|
||||
ascent, descent = font.font.getmetrics()
|
||||
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None,
|
||||
is_first_line: bool = False) -> Optional[Line]:
|
||||
"""Helper function to create a new line, returns None if page is full."""
|
||||
# Check if this line's baseline and descenders would fit on the page
|
||||
if not page.can_fit_line(baseline_spacing, ascent, descent):
|
||||
return None
|
||||
|
||||
# For the first line, position it so text starts at the top boundary
|
||||
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
|
||||
# For subsequent lines, use current y_offset which tracks
|
||||
# baseline-to-baseline spacing
|
||||
if is_first_line:
|
||||
# Position line origin so that baseline (origin + ascent) is close to top
|
||||
# We want minimal space above the text, so origin should be at boundary
|
||||
y_cursor = page._current_y_offset
|
||||
else:
|
||||
y_cursor = page._current_y_offset
|
||||
x_cursor = page.border_size
|
||||
x_cursor = page.content_origin[0]
|
||||
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
word_width = temp_text.width
|
||||
else:
|
||||
word_width = 0
|
||||
# `word` is accepted for call-site readability only: the line that is about
|
||||
# to be created measures it when it is added, so measuring it here as well
|
||||
# only paid for a Text object that was immediately discarded.
|
||||
|
||||
return Line(
|
||||
spacing=word_spacing_constraints,
|
||||
origin=(x_cursor, y_cursor),
|
||||
size=(page.available_width, baseline_spacing),
|
||||
draw=page.draw,
|
||||
draw=page.measurement_draw,
|
||||
font=font,
|
||||
halign=text_align
|
||||
)
|
||||
@@ -149,7 +195,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# but we may want to create LinkText for LinkedWord instances in future
|
||||
# For now, the abstract layer (LinkedWord) carries the link info,
|
||||
# and the concrete layer (LinkText) would be created during rendering
|
||||
|
||||
|
||||
success, overflow_text = current_line.add_word(word, current_pretext)
|
||||
|
||||
if success:
|
||||
@@ -176,12 +222,24 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
return False, i, overflow_text
|
||||
|
||||
# Check if the word will fit on the new line before adding it
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
temp_text = Text.from_word(word, page.measurement_draw)
|
||||
if temp_text.width > current_line.size[0]:
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
# Try to hyphenate the word
|
||||
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
|
||||
splits = [
|
||||
(Text(
|
||||
pair[0],
|
||||
word.style,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word),
|
||||
Text(
|
||||
pair[1],
|
||||
word.style,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
# Use the first hyphenation point
|
||||
first_part, second_part = splits[0]
|
||||
@@ -209,22 +267,28 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
else:
|
||||
current_pretext = overflow_text # May be None or hyphenated remainder
|
||||
|
||||
# All words processed successfully
|
||||
# All words processed successfully. The line holding the final word is the
|
||||
# end of the paragraph, so it is rendered at its natural width rather than
|
||||
# justified to the full column. A paragraph continued on the next page does
|
||||
# not reach here, so its lines stay justified - which is correct.
|
||||
if current_line is not None:
|
||||
current_line.is_paragraph_end = True
|
||||
|
||||
return True, None, None
|
||||
|
||||
|
||||
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
|
||||
"""
|
||||
Handle a page break element.
|
||||
|
||||
|
||||
A page break signals that all subsequent content should start on a new page.
|
||||
This function always returns False to indicate that the current page is complete
|
||||
and a new page should be created for subsequent content.
|
||||
|
||||
|
||||
Args:
|
||||
page_break: The PageBreak block
|
||||
page: The current page (not used, but kept for consistency)
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Always False to force creation of a new page
|
||||
"""
|
||||
@@ -232,48 +296,54 @@ def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
|
||||
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Layout an image within a given page.
|
||||
|
||||
|
||||
This function places an image on the page, respecting size constraints
|
||||
and available space. Images are centered horizontally by default.
|
||||
|
||||
|
||||
Args:
|
||||
image: The abstract Image object to layout
|
||||
page: The page to layout the image on
|
||||
max_width: Maximum width constraint (defaults to page available width)
|
||||
max_height: Maximum height constraint (defaults to remaining page height)
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if image was successfully laid out, False if page ran out of space
|
||||
"""
|
||||
# Use page available width if max_width not specified
|
||||
if max_width is None:
|
||||
max_width = page.available_width
|
||||
|
||||
|
||||
# Calculate available height on page
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# If no space available, image doesn't fit
|
||||
if available_height <= 0:
|
||||
return False
|
||||
|
||||
if max_height is None:
|
||||
max_height = available_height
|
||||
else:
|
||||
max_height = min(max_height, available_height)
|
||||
|
||||
|
||||
# Calculate scaled dimensions
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
|
||||
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(
|
||||
max_width, max_height)
|
||||
|
||||
# Check if image fits on current page
|
||||
if scaled_height is None or scaled_height > available_height:
|
||||
return False
|
||||
|
||||
|
||||
# Create renderable image
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
_ = page.draw
|
||||
|
||||
|
||||
renderable_image = RenderableImage(
|
||||
image=image,
|
||||
canvas=page._canvas,
|
||||
@@ -284,14 +354,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
halign=Alignment.CENTER,
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
|
||||
|
||||
# Add to page
|
||||
page.add_child(renderable_image)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
|
||||
def table_layouter(
|
||||
table: Table,
|
||||
page: Page,
|
||||
style: Optional[TableStyle] = None) -> bool:
|
||||
"""
|
||||
Layout a table within a given page.
|
||||
|
||||
@@ -308,7 +381,7 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
"""
|
||||
# Calculate available space
|
||||
available_width = page.available_width
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
@@ -328,7 +401,7 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
|
||||
# Check if table fits on current page
|
||||
table_height = renderer.size[1]
|
||||
available_height = page.size[1] - y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
if table_height > available_height:
|
||||
return False
|
||||
@@ -342,8 +415,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
return True
|
||||
|
||||
|
||||
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
def button_layouter(button: Button,
|
||||
page: Page,
|
||||
font: Optional[Font] = None,
|
||||
padding: Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int] = (4,
|
||||
8,
|
||||
4,
|
||||
8)) -> Tuple[bool,
|
||||
str]:
|
||||
"""
|
||||
Layout a button within a given page and register it for callback binding.
|
||||
|
||||
@@ -367,10 +449,10 @@ def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create ButtonText renderable
|
||||
button_text = ButtonText(button, font, page.draw, padding=padding)
|
||||
button_text = ButtonText(button, font, page.measurement_draw, padding=padding)
|
||||
|
||||
# Check if button fits on current page
|
||||
button_height = button_text.size[1]
|
||||
@@ -378,7 +460,7 @@ def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
return False, ""
|
||||
|
||||
# Position the button
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
button_text.set_origin(np.array([x_offset, y_offset]))
|
||||
@@ -417,10 +499,11 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create FormFieldText renderable
|
||||
field_text = FormFieldText(field, font, page.draw, field_height=field_height)
|
||||
field_text = FormFieldText(field, font, page.measurement_draw,
|
||||
field_height=field_height)
|
||||
|
||||
# Check if field fits on current page
|
||||
total_field_height = field_text.size[1]
|
||||
@@ -428,7 +511,7 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
||||
return False, ""
|
||||
|
||||
# Position the field
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
field_text.set_origin(np.array([x_offset, y_offset]))
|
||||
@@ -496,17 +579,17 @@ def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
|
||||
class DocumentLayouter:
|
||||
"""
|
||||
Document layouter that orchestrates layout of various abstract elements.
|
||||
|
||||
|
||||
Delegates to specialized layouters for different content types:
|
||||
- paragraph_layouter for text paragraphs
|
||||
- image_layouter for images
|
||||
- table_layouter for tables
|
||||
|
||||
|
||||
This class acts as a coordinator, managing the overall document flow
|
||||
and page context while delegating specific layout tasks to specialized
|
||||
layouter functions.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, page: Page):
|
||||
"""
|
||||
Initialize the document layouter with a page.
|
||||
@@ -524,24 +607,28 @@ class DocumentLayouter:
|
||||
context = RenderingContext()
|
||||
style_resolver = StyleResolver(context)
|
||||
self.style_registry = ConcreteStyleRegistry(style_resolver)
|
||||
|
||||
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
|
||||
def layout_paragraph(self,
|
||||
paragraph: Paragraph,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph using the paragraph_layouter.
|
||||
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout
|
||||
start_word: Index of the first word to process (for continuation)
|
||||
pretext: Optional pretext from a previous hyphenated word
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, failed_word_index, remaining_pretext)
|
||||
"""
|
||||
return paragraph_layouter(paragraph, self.page, start_word, pretext)
|
||||
|
||||
|
||||
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Layout an image using the image_layouter.
|
||||
|
||||
@@ -568,8 +655,17 @@ class DocumentLayouter:
|
||||
"""
|
||||
return table_layouter(table, self.page, style)
|
||||
|
||||
def layout_button(self, button: Button, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
def layout_button(self,
|
||||
button: Button,
|
||||
font: Optional[Font] = None,
|
||||
padding: Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int] = (4,
|
||||
8,
|
||||
4,
|
||||
8)) -> Tuple[bool,
|
||||
str]:
|
||||
"""
|
||||
Layout a button using the button_layouter.
|
||||
|
||||
@@ -598,7 +694,8 @@ class DocumentLayouter:
|
||||
"""
|
||||
return form_layouter(form, self.page, font, field_spacing)
|
||||
|
||||
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
||||
def layout_document(
|
||||
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
||||
"""
|
||||
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
|
||||
|
||||
|
||||
@@ -13,21 +13,18 @@ with features like:
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Tuple, Optional, Union, Generator, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Dict, Tuple, Optional, Any
|
||||
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell,
|
||||
HList, ListItem, Quote, Image)
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -38,41 +35,60 @@ class RenderingPosition:
|
||||
"""
|
||||
chapter_index: int = 0 # Which chapter (based on headings)
|
||||
block_index: int = 0 # Which block within chapter
|
||||
word_index: int = 0 # Which word within block (for paragraphs)
|
||||
# Which word within block (for paragraphs)
|
||||
word_index: int = 0
|
||||
table_row: int = 0 # Which row for tables
|
||||
table_col: int = 0 # Which column for tables
|
||||
list_item_index: int = 0 # Which item for lists
|
||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||
page_y_offset: int = 0 # Vertical position on page
|
||||
|
||||
|
||||
def _key(self) -> Tuple[Any, ...]:
|
||||
"""
|
||||
The fields in declaration order.
|
||||
|
||||
Copying, comparing and hashing a position all used to go through
|
||||
dataclasses.asdict, which walks the field list and deep-copies each value.
|
||||
Every field here is an immutable scalar, so that traversal bought nothing
|
||||
and these three run constantly during page navigation and buffer lookups.
|
||||
"""
|
||||
return (self.chapter_index, self.block_index, self.word_index,
|
||||
self.table_row, self.table_col, self.list_item_index,
|
||||
self.remaining_pretext, self.page_y_offset)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize position for saving to file/database"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
|
||||
"""Deserialize position from saved state"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def copy(self) -> 'RenderingPosition':
|
||||
"""Create a copy of this position"""
|
||||
return RenderingPosition(**asdict(self))
|
||||
|
||||
return RenderingPosition(*self._key())
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
"""Check if two positions are equal"""
|
||||
if not isinstance(other, RenderingPosition):
|
||||
return False
|
||||
return asdict(self) == asdict(other)
|
||||
|
||||
return self._key() == other._key()
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Make position hashable for use as dict key"""
|
||||
return hash(tuple(asdict(self).values()))
|
||||
return hash(self._key())
|
||||
|
||||
|
||||
class ChapterInfo:
|
||||
"""Information about a chapter/section in the document"""
|
||||
|
||||
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
level: HeadingLevel,
|
||||
position: RenderingPosition,
|
||||
block_index: int):
|
||||
self.title = title
|
||||
self.level = level
|
||||
self.position = position
|
||||
@@ -84,16 +100,36 @@ class ChapterNavigator:
|
||||
Handles chapter/section navigation based on HTML heading structure (H1-H6).
|
||||
Builds a table of contents and provides navigation capabilities.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, blocks: List[Block]):
|
||||
self.blocks = blocks
|
||||
self.chapters: List[ChapterInfo] = []
|
||||
self._build_chapter_map()
|
||||
|
||||
|
||||
def _build_chapter_map(self):
|
||||
"""Scan blocks for headings and build chapter navigation map"""
|
||||
current_chapter_index = 0
|
||||
|
||||
|
||||
# Check if first block is a cover image and add it to TOC
|
||||
if self.blocks and isinstance(self.blocks[0], Image):
|
||||
cover_position = RenderingPosition(
|
||||
chapter_index=0,
|
||||
block_index=0,
|
||||
word_index=0,
|
||||
table_row=0,
|
||||
table_col=0,
|
||||
list_item_index=0
|
||||
)
|
||||
|
||||
cover_info = ChapterInfo(
|
||||
title="Cover",
|
||||
level=HeadingLevel.H1, # Treat as top-level entry
|
||||
position=cover_position,
|
||||
block_index=0
|
||||
)
|
||||
|
||||
self.chapters.append(cover_info)
|
||||
|
||||
for block_index, block in enumerate(self.blocks):
|
||||
if isinstance(block, Heading):
|
||||
# Create position for this heading
|
||||
@@ -105,23 +141,23 @@ class ChapterNavigator:
|
||||
table_col=0,
|
||||
list_item_index=0
|
||||
)
|
||||
|
||||
|
||||
# Extract heading text
|
||||
heading_text = self._extract_heading_text(block)
|
||||
|
||||
|
||||
chapter_info = ChapterInfo(
|
||||
title=heading_text,
|
||||
level=block.level,
|
||||
position=position,
|
||||
block_index=block_index
|
||||
)
|
||||
|
||||
|
||||
self.chapters.append(chapter_info)
|
||||
|
||||
|
||||
# Only increment chapter index for top-level headings (H1)
|
||||
if block.level == HeadingLevel.H1:
|
||||
current_chapter_index += 1
|
||||
|
||||
|
||||
def _extract_heading_text(self, heading: Heading) -> str:
|
||||
"""Extract text content from a heading block"""
|
||||
words = []
|
||||
@@ -129,62 +165,82 @@ class ChapterNavigator:
|
||||
if isinstance(word, Word):
|
||||
words.append(word.text)
|
||||
return " ".join(words)
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
|
||||
def get_table_of_contents(
|
||||
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""Generate table of contents from heading structure"""
|
||||
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
|
||||
|
||||
return [(chapter.title, chapter.level, chapter.position)
|
||||
for chapter in self.chapters]
|
||||
|
||||
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
|
||||
"""Get rendering position for a chapter by title"""
|
||||
for chapter in self.chapters:
|
||||
if chapter.title.lower() == chapter_title.lower():
|
||||
return chapter.position
|
||||
return None
|
||||
|
||||
|
||||
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
|
||||
"""Determine which chapter contains the current position"""
|
||||
if not self.chapters:
|
||||
return None
|
||||
|
||||
|
||||
# Find the chapter that contains this position
|
||||
for i, chapter in enumerate(self.chapters):
|
||||
# Check if this is the last chapter or if position is before next chapter
|
||||
if i == len(self.chapters) - 1:
|
||||
return chapter
|
||||
|
||||
|
||||
next_chapter = self.chapters[i + 1]
|
||||
if position.chapter_index < next_chapter.position.chapter_index:
|
||||
return chapter
|
||||
|
||||
|
||||
return self.chapters[0] if self.chapters else None
|
||||
|
||||
|
||||
class FontScaler:
|
||||
class FontFamilyOverride:
|
||||
"""
|
||||
Handles font scaling operations for ereader font size adjustments.
|
||||
Applies scaling at layout/render time while preserving original font objects.
|
||||
Manages font family preferences for ereader rendering.
|
||||
Allows dynamic font family switching without modifying source blocks.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def scale_font(font: Font, scale_factor: float) -> Font:
|
||||
|
||||
def __init__(self, preferred_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
|
||||
Initialize font family override.
|
||||
|
||||
Args:
|
||||
preferred_family: Preferred bundled font family (None = use original fonts)
|
||||
"""
|
||||
self.preferred_family = preferred_family
|
||||
|
||||
def override_font(self, font: Font) -> Font:
|
||||
"""
|
||||
Create a new font with the preferred family while preserving other attributes.
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size
|
||||
Font with overridden family, or original if no override is set
|
||||
"""
|
||||
if scale_factor == 1.0:
|
||||
if self.preferred_family is None:
|
||||
return font
|
||||
|
||||
scaled_size = max(1, int(font.font_size * scale_factor))
|
||||
|
||||
|
||||
# Get the appropriate font path for the preferred family
|
||||
# preserving the original font's weight and style
|
||||
new_font_path = get_bundled_font_path(
|
||||
family=self.preferred_family,
|
||||
weight=font.weight,
|
||||
style=font.style
|
||||
)
|
||||
|
||||
# If we couldn't find a matching font, fall back to original
|
||||
if new_font_path is None:
|
||||
return font
|
||||
|
||||
# Create a new font with the overridden path
|
||||
return Font(
|
||||
font_path=font._font_path,
|
||||
font_size=scaled_size,
|
||||
font_path=new_font_path,
|
||||
font_size=font.font_size,
|
||||
colour=font.colour,
|
||||
weight=font.weight,
|
||||
style=font.style,
|
||||
@@ -193,13 +249,57 @@ class FontScaler:
|
||||
language=font.language,
|
||||
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_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
|
||||
def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
family_override: Optional font family override
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size and optional family override
|
||||
"""
|
||||
# Apply family override first if specified
|
||||
working_font = font
|
||||
if family_override is not None:
|
||||
working_font = family_override.override_font(font)
|
||||
|
||||
# Then apply scaling
|
||||
if scale_factor == 1.0:
|
||||
return working_font
|
||||
|
||||
scaled_size = max(1, int(working_font.font_size * scale_factor))
|
||||
|
||||
return Font(
|
||||
font_path=working_font._font_path,
|
||||
font_size=scaled_size,
|
||||
colour=working_font.colour,
|
||||
weight=working_font.weight,
|
||||
style=working_font.style,
|
||||
decoration=working_font.decoration,
|
||||
background=working_font.background,
|
||||
language=working_font.language,
|
||||
min_hyphenation_width=working_font.min_hyphenation_width
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def scale_word_spacing(spacing: Tuple[int, int],
|
||||
scale_factor: float) -> Tuple[int, int]:
|
||||
"""Scale word spacing constraints proportionally"""
|
||||
if scale_factor == 1.0:
|
||||
return spacing
|
||||
|
||||
|
||||
min_spacing, max_spacing = spacing
|
||||
return (
|
||||
max(1, int(min_spacing * scale_factor)),
|
||||
@@ -212,112 +312,335 @@ class BidirectionalLayouter:
|
||||
Core layout engine supporting both forward and backward page rendering.
|
||||
Handles font scaling and maintains position state.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600),
|
||||
alignment_override=None,
|
||||
font_family_override: Optional[FontFamilyOverride] = None):
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.page_size = page_size
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.alignment_override = alignment_override
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
self.font_family_override = font_family_override
|
||||
|
||||
# Maps (font_scale, end position) -> the position the page started at.
|
||||
# Filled in as pages are laid out forward, which makes "previous page"
|
||||
# exact and free for anywhere the reader has already been. Keyed by font
|
||||
# scale because changing it repaginates the document.
|
||||
self._page_chain: Dict[Tuple[float, Tuple[int, int, int]],
|
||||
RenderingPosition] = {}
|
||||
|
||||
# Scaled copies of blocks, keyed by (id(block), font_scale). Rebuilding
|
||||
# a block's words on every page render allocated a fresh Paragraph and
|
||||
# Word per word on the hot path. The original block is kept alongside
|
||||
# the copy so its id cannot be recycled while it is a live key.
|
||||
self._scaled_block_cache: Dict[Tuple[int, float], Tuple[Block, Block]] = {}
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page starting from the given position, moving forward through the document.
|
||||
|
||||
|
||||
Args:
|
||||
position: Starting position in document
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
page = Page(size=self.page_size, style=self.page_style)
|
||||
current_pos = position.copy()
|
||||
|
||||
|
||||
# Start laying out blocks from the current position
|
||||
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
|
||||
# Additional bounds check to prevent IndexError
|
||||
if current_pos.block_index >= len(self.blocks):
|
||||
break
|
||||
|
||||
|
||||
block = self.blocks[current_pos.block_index]
|
||||
|
||||
|
||||
# Apply font scaling to the block
|
||||
scaled_block = self._scale_block_fonts(block, font_scale)
|
||||
|
||||
|
||||
# Try to fit the block on the current page
|
||||
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
|
||||
|
||||
success, new_pos = self._layout_block_on_page(
|
||||
scaled_block, page, current_pos, font_scale)
|
||||
|
||||
if not success:
|
||||
# Block doesn't fit, we're done with this page
|
||||
# The block did not fit in its entirety. It may still have been
|
||||
# laid out partially - a paragraph larger than one page places as
|
||||
# many lines as fit and reports the word it stopped at. Keeping
|
||||
# that resume point is what allows the next page to continue;
|
||||
# discarding it tells the caller no progress was made, which
|
||||
# dead-ends navigation on the block forever.
|
||||
if self._position_compare(new_pos, current_pos) > 0:
|
||||
current_pos = new_pos
|
||||
break
|
||||
|
||||
|
||||
# Add inter-block spacing after successfully laying out a block
|
||||
# Only add if we're not at the end of the document and there's space
|
||||
if new_pos.block_index < len(self.blocks):
|
||||
page._current_y_offset += self.page_style.inter_block_spacing
|
||||
|
||||
# Ensure new position doesn't go beyond bounds
|
||||
if new_pos.block_index >= len(self.blocks):
|
||||
# We've reached the end of the document
|
||||
current_pos = new_pos
|
||||
break
|
||||
|
||||
|
||||
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
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
# How many block starts before the target to try as replay anchors before
|
||||
# settling for the best inexact answer.
|
||||
MAX_BACKWARD_ANCHORS = 4
|
||||
|
||||
# Ceiling on pages replayed from a single anchor, so a pathologically long
|
||||
# block cannot make one page turn walk an entire chapter.
|
||||
MAX_REPLAY_PAGES = 8
|
||||
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page that ends at the given position, filling backward.
|
||||
Critical for "previous page" navigation.
|
||||
|
||||
Render the page that ends at the given position - "previous page".
|
||||
|
||||
Pagination is a pure function: laying out from a position q yields a page
|
||||
and the position where it stopped, next(q). The page before P is therefore
|
||||
the q for which next(q) == P, and it is found by *replaying* the chain
|
||||
forward from an anchor, not by guessing q.
|
||||
|
||||
The previous implementation searched instead: it estimated a block index
|
||||
and bisected on it, pinning word_index to 0. Pages routinely start
|
||||
mid-block, so the answer was frequently not in the search space at all -
|
||||
the search then exhausted its iterations and fell back to a position that
|
||||
was not the previous page, usually the start of the document.
|
||||
|
||||
Three sources are tried in order:
|
||||
|
||||
1. The recorded chain, from pages already laid out going forward. Exact,
|
||||
and the common case when the reader is paging back and forth.
|
||||
2. Replay from the start of the block containing P, then from
|
||||
progressively earlier blocks. Exact when P lies on the resulting chain.
|
||||
3. Failing an exact hit - which happens when P was reached by a jump or a
|
||||
restored bookmark rather than by reading forward, so it is on no
|
||||
natural chain - the latest page start before P. That overlaps P's page
|
||||
slightly rather than skipping content, which is the safe direction to
|
||||
be wrong in.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
end_position: Position where the page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
# This is a complex operation that requires iterative refinement
|
||||
# We'll start with an estimated start position and refine it
|
||||
|
||||
estimated_start = self._estimate_page_start(end_position, font_scale)
|
||||
|
||||
# Render forward from estimated start and see if we reach the target
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
# If we overshot or undershot, adjust and try again
|
||||
# This is a simplified implementation - a full version would be more sophisticated
|
||||
if self._position_compare(actual_end, end_position) != 0:
|
||||
# Adjust estimate and try again (simplified)
|
||||
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
return page, estimated_start
|
||||
|
||||
document_start = RenderingPosition()
|
||||
|
||||
# Nothing precedes the start of the document.
|
||||
if self._position_compare(end_position, document_start) <= 0:
|
||||
page, _ = self.render_page_forward(document_start, font_scale)
|
||||
return page, document_start
|
||||
|
||||
# 1. The chain we have already walked.
|
||||
remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
|
||||
if remembered is not None:
|
||||
page, actual_end = self.render_page_forward(remembered, font_scale)
|
||||
if self._position_compare(actual_end, end_position) == 0:
|
||||
return page, remembered
|
||||
|
||||
# 2/3. Replay from anchors, keeping the best inexact result as a fallback.
|
||||
fallback = None
|
||||
for anchor in self._backward_anchors(end_position):
|
||||
page, start, exact = self._replay_to(anchor, end_position, font_scale)
|
||||
if page is None:
|
||||
continue
|
||||
if exact:
|
||||
return page, start
|
||||
if fallback is None:
|
||||
fallback = (page, start)
|
||||
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
page, _ = self.render_page_forward(document_start, font_scale)
|
||||
return page, document_start
|
||||
|
||||
def _backward_anchors(self, target: RenderingPosition):
|
||||
"""
|
||||
Yield positions to replay from, nearest first.
|
||||
|
||||
Block starts are used as anchors because they are the coarsest positions
|
||||
that are certainly valid to lay out from. The block containing the target
|
||||
comes first: when the target is mid-block, the page before it usually
|
||||
starts in that same block or the one before.
|
||||
"""
|
||||
first_block = target.block_index if target.word_index > 0 \
|
||||
else target.block_index - 1
|
||||
|
||||
for offset in range(self.MAX_BACKWARD_ANCHORS):
|
||||
block_index = first_block - offset
|
||||
if block_index < 0:
|
||||
break
|
||||
yield RenderingPosition(
|
||||
chapter_index=target.chapter_index,
|
||||
block_index=block_index,
|
||||
word_index=0,
|
||||
)
|
||||
|
||||
if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
|
||||
yield RenderingPosition()
|
||||
|
||||
def _replay_to(self,
|
||||
anchor: RenderingPosition,
|
||||
target: RenderingPosition,
|
||||
font_scale: float):
|
||||
"""
|
||||
Lay out pages forward from `anchor`, looking for the one ending at `target`.
|
||||
|
||||
Returns:
|
||||
(page, start, exact). `exact` is True when a page ended precisely on
|
||||
the target. When the chain steps over the target instead, the last
|
||||
page starting before it is returned with exact=False. (None, None,
|
||||
False) means the anchor yielded nothing usable.
|
||||
"""
|
||||
position = anchor
|
||||
last = (None, None)
|
||||
|
||||
for _ in range(self.MAX_REPLAY_PAGES):
|
||||
if self._position_compare(position, target) >= 0:
|
||||
break
|
||||
|
||||
page, next_position = self.render_page_forward(position, font_scale)
|
||||
comparison = self._position_compare(next_position, target)
|
||||
|
||||
if comparison == 0:
|
||||
return page, position, True
|
||||
|
||||
if comparison > 0:
|
||||
# Stepped over the target: this chain does not pass through it.
|
||||
return last[0], last[1], False
|
||||
|
||||
if self._position_compare(next_position, position) <= 0:
|
||||
break # no progress; give up on this anchor
|
||||
|
||||
last = (page, position)
|
||||
position = next_position
|
||||
|
||||
return last[0], last[1], False
|
||||
|
||||
@staticmethod
|
||||
def _position_key(position: RenderingPosition) -> Tuple[int, int, int]:
|
||||
"""Hashable identity of a position, for the page chain map."""
|
||||
return (position.chapter_index, position.block_index, position.word_index)
|
||||
|
||||
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
|
||||
"""Apply font scaling to all fonts in a block"""
|
||||
if font_scale == 1.0:
|
||||
"""
|
||||
Apply font scaling and the font family override to every font in a block.
|
||||
|
||||
Returns the block unchanged when there is nothing to apply. Results are
|
||||
memoised per (block, scale) for the life of the layouter, so a page
|
||||
re-render at an unchanged scale costs a dict lookup.
|
||||
"""
|
||||
if font_scale == 1.0 and self.font_family_override is None:
|
||||
return block
|
||||
|
||||
# This is a simplified implementation
|
||||
# In practice, we'd need to handle each block type appropriately
|
||||
|
||||
key = (id(block), font_scale)
|
||||
cached = self._scaled_block_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached[1]
|
||||
|
||||
scaled = self._build_scaled_block(block, font_scale)
|
||||
self._scaled_block_cache[key] = (block, scaled)
|
||||
return scaled
|
||||
|
||||
def _build_scaled_block(self, block: Block, font_scale: float) -> Block:
|
||||
"""Construct the scaled copy of a block. See _scale_block_fonts."""
|
||||
def scale(font: Font) -> Font:
|
||||
return FontScaler.scale_font(font, font_scale, self.font_family_override)
|
||||
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
scaled_block_style = FontScaler.scale_font(block.style, font_scale)
|
||||
if isinstance(block, Heading):
|
||||
scaled_block = Heading(block.level, scaled_block_style)
|
||||
scaled_block = Heading(block.level, scale(block.style))
|
||||
else:
|
||||
scaled_block = Paragraph(scaled_block_style)
|
||||
|
||||
# words_iter() returns tuples of (position, word)
|
||||
for position, word in block.words_iter():
|
||||
scaled_block = Paragraph(scale(block.style))
|
||||
|
||||
# words_iter() yields (position, word) tuples. with_style() keeps
|
||||
# the concrete word class, so a LinkedWord stays linked - rebuilding
|
||||
# these as plain Words silently stripped every hyperlink in the
|
||||
# document as soon as the reader changed font size.
|
||||
for _, word in block.words_iter():
|
||||
if isinstance(word, Word):
|
||||
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
|
||||
scaled_block.add_word(scaled_word)
|
||||
scaled_block.add_word(word.with_style(scale(word.style)))
|
||||
return scaled_block
|
||||
|
||||
|
||||
if isinstance(block, Quote):
|
||||
scaled_quote = Quote(scale(block.style) if block.style else None)
|
||||
for child in block.blocks():
|
||||
scaled_quote.add_block(self._scale_block_fonts(child, font_scale))
|
||||
return scaled_quote
|
||||
|
||||
if isinstance(block, HList):
|
||||
scaled_list = HList(
|
||||
block.style,
|
||||
scale(block.default_style) if block.default_style else None)
|
||||
for item in block.items():
|
||||
scaled_item = ListItem(
|
||||
item.term,
|
||||
scale(item.style) if item.style else None)
|
||||
for child in item.blocks():
|
||||
scaled_item.add_block(self._scale_block_fonts(child, font_scale))
|
||||
scaled_list.add_item(scaled_item)
|
||||
return scaled_list
|
||||
|
||||
if isinstance(block, Table):
|
||||
scaled_table = Table(
|
||||
block.caption,
|
||||
scale(block.style) if block.style else None)
|
||||
# Rows must go back into the section they came from, or a <thead>
|
||||
# row would be re-added as a body row.
|
||||
for section, rows in (('header', block.header_rows()),
|
||||
('body', block.body_rows()),
|
||||
('footer', block.footer_rows())):
|
||||
for row in rows:
|
||||
scaled_row = TableRow(scale(row.style) if row.style else None)
|
||||
for cell in row.cells():
|
||||
scaled_cell = TableCell(
|
||||
is_header=cell.is_header,
|
||||
colspan=cell.colspan,
|
||||
rowspan=cell.rowspan,
|
||||
style=scale(cell.style) if cell.style else None)
|
||||
for child in cell.blocks():
|
||||
scaled_cell.add_block(self._scale_block_fonts(child, font_scale))
|
||||
scaled_row.add_cell(scaled_cell)
|
||||
scaled_table.add_row(scaled_row, section)
|
||||
return scaled_table
|
||||
|
||||
# Blocks with no fonts of their own (Image, HorizontalRule, PageBreak,
|
||||
# CodeBlock - which carries raw lines, not styled words) pass through.
|
||||
return block
|
||||
|
||||
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_block_on_page(self,
|
||||
block: Block,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Try to layout a block on the page starting from the given position.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
"""
|
||||
@@ -329,23 +652,30 @@ class BidirectionalLayouter:
|
||||
return self._layout_table_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, HList):
|
||||
return self._layout_list_on_page(block, page, position, font_scale)
|
||||
elif isinstance(block, Image):
|
||||
return self._layout_image_on_page(block, page, position, font_scale)
|
||||
else:
|
||||
# Skip unknown block types
|
||||
new_pos = position.copy()
|
||||
new_pos.block_index += 1
|
||||
return True, new_pos
|
||||
|
||||
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_paragraph_on_page(self,
|
||||
paragraph: Paragraph,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Layout a paragraph on the page using the core paragraph_layouter.
|
||||
Integrates font scaling and position tracking with the proven layout logic.
|
||||
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
|
||||
page: The page to layout on
|
||||
position: Current rendering position
|
||||
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
"""
|
||||
@@ -360,7 +690,7 @@ class BidirectionalLayouter:
|
||||
line=None,
|
||||
source=None
|
||||
)
|
||||
|
||||
|
||||
# Call the core paragraph layouter with alignment override if set
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph,
|
||||
@@ -369,10 +699,10 @@ class BidirectionalLayouter:
|
||||
pretext=pretext_obj,
|
||||
alignment_override=self.alignment_override
|
||||
)
|
||||
|
||||
|
||||
# Create new position based on the result
|
||||
new_pos = position.copy()
|
||||
|
||||
|
||||
if success:
|
||||
# Paragraph was fully laid out, move to next block
|
||||
new_pos.block_index += 1
|
||||
@@ -384,25 +714,35 @@ class BidirectionalLayouter:
|
||||
if failed_word_index is not None:
|
||||
# Update position to the word that didn't fit
|
||||
new_pos.word_index = failed_word_index
|
||||
|
||||
|
||||
# Convert Text object back to string if there's remaining pretext
|
||||
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
|
||||
new_pos.remaining_pretext = remaining_pretext.text
|
||||
else:
|
||||
new_pos.remaining_pretext = None
|
||||
|
||||
|
||||
return False, new_pos
|
||||
else:
|
||||
# No specific word failed, but layout wasn't successful
|
||||
# This shouldn't normally happen, but handle it gracefully
|
||||
return False, position
|
||||
|
||||
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_heading_on_page(self,
|
||||
heading: Heading,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a heading on the page"""
|
||||
# Similar to paragraph but with heading-specific styling
|
||||
return self._layout_paragraph_on_page(heading, page, position, font_scale)
|
||||
|
||||
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_table_on_page(self,
|
||||
table: Table,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a table on the page with column fitting and row continuation"""
|
||||
# This is a complex operation that would need full table layout logic
|
||||
# For now, skip tables
|
||||
@@ -411,8 +751,13 @@ class BidirectionalLayouter:
|
||||
new_pos.table_row = 0
|
||||
new_pos.table_col = 0
|
||||
return True, new_pos
|
||||
|
||||
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_list_on_page(self,
|
||||
hlist: HList,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a list on the page"""
|
||||
# This would need list-specific layout logic
|
||||
# For now, skip lists
|
||||
@@ -420,33 +765,49 @@ class BidirectionalLayouter:
|
||||
new_pos.block_index += 1
|
||||
new_pos.list_item_index = 0
|
||||
return True, new_pos
|
||||
|
||||
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
|
||||
"""Estimate where a page should start to end at the given position"""
|
||||
# This is a simplified heuristic - a full implementation would be more sophisticated
|
||||
estimated_start = end_position.copy()
|
||||
|
||||
# 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"""
|
||||
# Simplified adjustment logic
|
||||
adjusted = current_start.copy()
|
||||
|
||||
comparison = self._position_compare(actual_end, target_end)
|
||||
if comparison > 0: # Overshot
|
||||
adjusted.block_index = max(0, adjusted.block_index + 1)
|
||||
elif comparison < 0: # Undershot
|
||||
adjusted.block_index = max(0, adjusted.block_index - 1)
|
||||
|
||||
return adjusted
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
|
||||
|
||||
def _layout_image_on_page(self,
|
||||
image: Image,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Layout an image on the page using the image_layouter.
|
||||
|
||||
Args:
|
||||
image: The Image block to layout
|
||||
page: The page to layout on
|
||||
position: Current rendering position (should be at the start of this image block)
|
||||
font_scale: Font scaling factor (not used for images, but kept for consistency)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
- success: True if image was laid out, False if page ran out of space
|
||||
- new_position: Updated position (next block if success, same block if failed)
|
||||
"""
|
||||
# Try to layout the image on the current page
|
||||
success = image_layouter(
|
||||
image=image,
|
||||
page=page,
|
||||
max_width=None, # Use page available width
|
||||
max_height=None # Use page available height
|
||||
)
|
||||
|
||||
new_pos = position.copy()
|
||||
|
||||
if success:
|
||||
# Image was successfully laid out, move to next block
|
||||
new_pos.block_index += 1
|
||||
new_pos.word_index = 0
|
||||
return True, new_pos
|
||||
else:
|
||||
# Image didn't fit on current page, signal to continue on next page
|
||||
# Keep same position so it will be attempted on the next page
|
||||
return False, position
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition,
|
||||
pos2: RenderingPosition) -> int:
|
||||
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
||||
if pos1.chapter_index != pos2.chapter_index:
|
||||
return 1 if pos1.chapter_index > pos2.chapter_index else -1
|
||||
@@ -455,26 +816,3 @@ class BidirectionalLayouter:
|
||||
if pos1.word_index != pos2.word_index:
|
||||
return 1 if pos1.word_index > pos2.word_index else -1
|
||||
return 0
|
||||
|
||||
|
||||
# Add can_fit_line method to Page class if it doesn't exist
|
||||
def _add_page_methods():
|
||||
"""Add missing methods to Page class"""
|
||||
if not hasattr(Page, 'can_fit_line'):
|
||||
def can_fit_line(self, line_height: int) -> bool:
|
||||
"""Check if a line of given height can fit on the page"""
|
||||
available_height = self.content_size[1] - self._current_y_offset
|
||||
return available_height >= line_height
|
||||
|
||||
Page.can_fit_line = can_fit_line
|
||||
|
||||
if not hasattr(Page, 'available_width'):
|
||||
@property
|
||||
def available_width(self) -> int:
|
||||
"""Get available width for content"""
|
||||
return self.content_size[0]
|
||||
|
||||
Page.available_width = available_width
|
||||
|
||||
# Apply the page methods
|
||||
_add_page_methods()
|
||||
|
||||
@@ -1,110 +1,101 @@
|
||||
"""
|
||||
Multi-process page buffering system for high-performance ereader navigation.
|
||||
Page caching for ereader navigation.
|
||||
|
||||
This module provides intelligent page caching with background rendering using
|
||||
multiprocessing to achieve sub-second page navigation performance.
|
||||
`PageBuffer` is an LRU cache of rendered pages plus the position links between
|
||||
them; `BufferedPageRenderer` wraps it around a `BidirectionalLayouter`.
|
||||
|
||||
This module used to render pages ahead of time in a `ProcessPoolExecutor`. That
|
||||
never worked and has been removed — see S12 in docs/LAYOUT_REMEDIATION_SPEC.md
|
||||
and R1/R2 in docs/ARCHITECTURE_REVIEW.md. In short: the worker returned
|
||||
`pickle.dumps(page)`, and a Page holds a live PIL canvas, which is not
|
||||
picklable, so every job failed and the result was discarded. The cost — four
|
||||
interpreter copies and the whole block list shipped per job — was paid in full
|
||||
for no benefit. On Python 3.14, where the default start method became
|
||||
`forkserver`, submitting from module-level code raised outright.
|
||||
|
||||
Prefetch is not needed. Measured on the tests/data Wikipedia fixture (411
|
||||
blocks) with the text caches warm, one page render costs:
|
||||
|
||||
800x600 p50 8.8 ms p95 15.4 ms
|
||||
1072x1448 p50 13.8 ms p95 56.1 ms
|
||||
|
||||
A page turn is cheaper than the IPC that was meant to hide it. If a slower
|
||||
target device ever changes that, the fallback is a synchronous `readahead()`
|
||||
method on this class, or a single worker *thread* — layout is PIL-bound and PIL
|
||||
releases the GIL — not a process pool. Making the concrete tree picklable
|
||||
(Page -> Line -> Text -> Font -> FreeTypeFont) is a large surface area to
|
||||
maintain for a cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
from collections import OrderedDict
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
|
||||
import threading
|
||||
import time
|
||||
import pickle
|
||||
from dataclasses import asdict
|
||||
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter, FontFamilyOverride
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
|
||||
"""
|
||||
Worker function for multiprocess page rendering.
|
||||
|
||||
Args:
|
||||
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
|
||||
|
||||
Returns:
|
||||
Tuple of (original_position, pickled_page, next_position)
|
||||
"""
|
||||
blocks, page_style, position, font_scale, is_backward = args
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, page_style)
|
||||
|
||||
if is_backward:
|
||||
page, next_pos = layouter.render_page_backward(position, font_scale)
|
||||
else:
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale)
|
||||
|
||||
# Serialize the page for inter-process communication
|
||||
pickled_page = pickle.dumps(page)
|
||||
|
||||
return position, pickled_page, next_pos
|
||||
from pyWebLayout.style.fonts import BundledFont
|
||||
|
||||
|
||||
class PageBuffer:
|
||||
"""
|
||||
Intelligent page caching system with LRU eviction and background rendering.
|
||||
Maintains separate forward and backward buffers for optimal navigation performance.
|
||||
LRU cache of rendered pages, with separate forward and backward buffers and
|
||||
the position links between adjacent pages.
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
|
||||
|
||||
def __init__(self, buffer_size: int = 5):
|
||||
"""
|
||||
Initialize the page buffer.
|
||||
|
||||
|
||||
Args:
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
max_workers: Maximum number of worker processes for background rendering
|
||||
"""
|
||||
self.buffer_size = buffer_size
|
||||
self.max_workers = max_workers
|
||||
|
||||
|
||||
# LRU caches for forward and backward pages
|
||||
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
|
||||
|
||||
# Position tracking for next/previous positions
|
||||
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Background rendering
|
||||
self.executor: Optional[ProcessPoolExecutor] = None
|
||||
self.pending_renders: Dict[RenderingPosition, Future] = {}
|
||||
self.render_lock = threading.Lock()
|
||||
|
||||
self.position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Document state
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
self.page_style: Optional[PageStyle] = None
|
||||
self.current_font_scale: float = 1.0
|
||||
|
||||
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
|
||||
self.current_font_family: Optional[BundledFont] = None
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
font_scale: float = 1.0,
|
||||
font_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Initialize the buffer with document blocks and page style.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
font_scale: Current font scaling factor
|
||||
font_family: Optional font family override
|
||||
"""
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.current_font_scale = font_scale
|
||||
|
||||
# Start the process pool
|
||||
if self.executor is None:
|
||||
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
|
||||
|
||||
self.current_font_family = font_family
|
||||
|
||||
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
||||
"""
|
||||
Get a cached page if available.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position to get page for
|
||||
|
||||
|
||||
Returns:
|
||||
Cached page or None if not available
|
||||
"""
|
||||
@@ -114,20 +105,25 @@ class PageBuffer:
|
||||
page = self.forward_buffer.pop(position)
|
||||
self.forward_buffer[position] = page
|
||||
return page
|
||||
|
||||
|
||||
# Check backward buffer
|
||||
if position in self.backward_buffer:
|
||||
# Move to end (most recently used)
|
||||
page = self.backward_buffer.pop(position)
|
||||
self.backward_buffer[position] = page
|
||||
return page
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
|
||||
|
||||
def cache_page(
|
||||
self,
|
||||
position: RenderingPosition,
|
||||
page: Page,
|
||||
next_position: Optional[RenderingPosition] = None,
|
||||
is_backward: bool = False):
|
||||
"""
|
||||
Cache a rendered page with LRU eviction.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position of the page
|
||||
page: Rendered page to cache
|
||||
@@ -135,199 +131,122 @@ class PageBuffer:
|
||||
is_backward: Whether this is a backward-rendered page
|
||||
"""
|
||||
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
|
||||
|
||||
|
||||
# Add to cache
|
||||
target_buffer[position] = page
|
||||
|
||||
|
||||
# Track position relationships
|
||||
if next_position:
|
||||
if is_backward:
|
||||
self.reverse_position_map[next_position] = position
|
||||
else:
|
||||
self.position_map[position] = next_position
|
||||
|
||||
|
||||
# Evict oldest if buffer is full
|
||||
if len(target_buffer) > self.buffer_size:
|
||||
oldest_pos, _ = target_buffer.popitem(last=False)
|
||||
# Clean up position maps
|
||||
self.position_map.pop(oldest_pos, None)
|
||||
self.reverse_position_map.pop(oldest_pos, None)
|
||||
|
||||
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
|
||||
"""
|
||||
Start background rendering of upcoming pages.
|
||||
|
||||
Args:
|
||||
current_position: Current reading position
|
||||
direction: 'forward', 'backward', or 'both'
|
||||
"""
|
||||
if not self.blocks or not self.page_style or not self.executor:
|
||||
return
|
||||
|
||||
with self.render_lock:
|
||||
if direction in ['forward', 'both']:
|
||||
self._queue_forward_renders(current_position)
|
||||
|
||||
if direction in ['backward', 'both']:
|
||||
self._queue_backward_renders(current_position)
|
||||
|
||||
def _queue_forward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue forward page renders starting from the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get next position from cache
|
||||
current_pos = self.position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render completes
|
||||
break
|
||||
|
||||
def _queue_backward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue backward page renders ending at the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
|
||||
# Try to get previous position from cache
|
||||
current_pos = self.reverse_position_map.get(current_pos)
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the render completes
|
||||
break
|
||||
|
||||
def check_completed_renders(self):
|
||||
"""Check for completed background renders and cache the results"""
|
||||
if not self.pending_renders:
|
||||
return
|
||||
|
||||
completed = []
|
||||
|
||||
with self.render_lock:
|
||||
for position, future in self.pending_renders.items():
|
||||
if future.done():
|
||||
try:
|
||||
original_pos, pickled_page, next_pos = future.result()
|
||||
|
||||
# Deserialize the page
|
||||
page = pickle.loads(pickled_page)
|
||||
|
||||
# Cache the page
|
||||
self.cache_page(original_pos, page, next_pos, is_backward=False)
|
||||
|
||||
completed.append(position)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Background render failed for position {position}: {e}")
|
||||
completed.append(position)
|
||||
|
||||
# Remove completed renders
|
||||
for pos in completed:
|
||||
self.pending_renders.pop(pos, None)
|
||||
|
||||
|
||||
def invalidate_all(self):
|
||||
"""Clear all cached pages and cancel pending renders"""
|
||||
with self.render_lock:
|
||||
# Cancel pending renders
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
self.pending_renders.clear()
|
||||
|
||||
# Clear caches
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
|
||||
"""Clear all cached pages"""
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
|
||||
def set_font_scale(self, font_scale: float):
|
||||
"""
|
||||
Update font scale and invalidate cache.
|
||||
|
||||
|
||||
Args:
|
||||
font_scale: New font scaling factor
|
||||
"""
|
||||
if font_scale != self.current_font_scale:
|
||||
self.current_font_scale = font_scale
|
||||
self.invalidate_all()
|
||||
|
||||
|
||||
def set_font_family(self, font_family: Optional[BundledFont]):
|
||||
"""
|
||||
Update font family and invalidate cache.
|
||||
|
||||
Args:
|
||||
font_family: New font family (None = use original fonts)
|
||||
"""
|
||||
if font_family != self.current_font_family:
|
||||
self.current_font_family = font_family
|
||||
self.invalidate_all()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics for debugging/monitoring"""
|
||||
return {
|
||||
'forward_buffer_size': len(self.forward_buffer),
|
||||
'backward_buffer_size': len(self.backward_buffer),
|
||||
'pending_renders': len(self.pending_renders),
|
||||
'position_mappings': len(self.position_map),
|
||||
'reverse_position_mappings': len(self.reverse_position_map),
|
||||
'current_font_scale': self.current_font_scale
|
||||
'current_font_scale': self.current_font_scale,
|
||||
'current_font_family': self.current_font_family.value if self.current_font_family else None
|
||||
}
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the page buffer and clean up resources"""
|
||||
if self.executor:
|
||||
# Cancel pending renders
|
||||
with self.render_lock:
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
self.executor = None
|
||||
|
||||
# Clear all caches
|
||||
"""
|
||||
Release cached pages.
|
||||
|
||||
Cheap and idempotent. There is deliberately no __del__ calling this:
|
||||
blocking work in a finaliser is what deadlocked the interpreter at exit
|
||||
while the process pool existed.
|
||||
"""
|
||||
self.invalidate_all()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
|
||||
|
||||
class BufferedPageRenderer:
|
||||
"""
|
||||
High-level interface for buffered page rendering with automatic background caching.
|
||||
High-level interface for page rendering with an LRU cache in front of the
|
||||
layouter.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
buffer_size: int = 5,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600),
|
||||
font_family: Optional[BundledFont] = None):
|
||||
"""
|
||||
Initialize the buffered renderer.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
page_size: Page size (width, height) in pixels
|
||||
font_family: Optional font family override
|
||||
"""
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
|
||||
# Create font family override if specified
|
||||
font_family_override = FontFamilyOverride(font_family) if font_family else None
|
||||
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size, font_family_override=font_family_override)
|
||||
self.buffer = PageBuffer(buffer_size)
|
||||
self.buffer.initialize(blocks, page_style)
|
||||
|
||||
self.buffer.initialize(blocks, page_style, font_family=font_family)
|
||||
self.page_size = page_size
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
self.font_family = font_family
|
||||
|
||||
def render_page(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page with intelligent caching.
|
||||
|
||||
Render a page, serving it from cache when possible.
|
||||
|
||||
Args:
|
||||
position: Position to render from
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
@@ -335,40 +254,36 @@ class BufferedPageRenderer:
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(position)
|
||||
if cached_page:
|
||||
# Get next position from position map
|
||||
next_pos = self.buffer.position_map.get(position, position)
|
||||
|
||||
# Start background rendering for upcoming pages
|
||||
self.buffer.start_background_rendering(position, 'forward')
|
||||
|
||||
return cached_page, next_pos
|
||||
|
||||
# Only use the cache if we also know where the next page starts;
|
||||
# otherwise fall through and compute it.
|
||||
next_pos = self.buffer.position_map.get(position)
|
||||
if next_pos is not None:
|
||||
return cached_page, next_pos
|
||||
|
||||
# Render the page directly
|
||||
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
||||
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(position, page, next_pos)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
|
||||
return page, next_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page ending at the given position with intelligent caching.
|
||||
|
||||
Render a page ending at the given position, serving it from cache when
|
||||
possible.
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
@@ -376,36 +291,54 @@ class BufferedPageRenderer:
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(end_position)
|
||||
if cached_page:
|
||||
# Get previous position from reverse position map
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
|
||||
|
||||
# Start background rendering for previous pages
|
||||
self.buffer.start_background_rendering(end_position, 'backward')
|
||||
|
||||
return cached_page, prev_pos
|
||||
|
||||
# Only use the cache if we also know where the previous page
|
||||
# starts; otherwise fall through and compute it.
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position)
|
||||
if prev_pos is not None:
|
||||
return cached_page, prev_pos
|
||||
|
||||
# Render the page directly
|
||||
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
||||
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(end_position, 'both')
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
|
||||
return page, start_pos
|
||||
|
||||
|
||||
def set_font_family(self, font_family: Optional[BundledFont]):
|
||||
"""
|
||||
Change the font family and invalidate cache.
|
||||
|
||||
Args:
|
||||
font_family: New font family (None = use original fonts)
|
||||
"""
|
||||
if font_family != self.font_family:
|
||||
self.font_family = font_family
|
||||
|
||||
# Update buffer
|
||||
self.buffer.set_font_family(font_family)
|
||||
|
||||
# Recreate layouter with new font family override
|
||||
font_family_override = FontFamilyOverride(font_family) if font_family else None
|
||||
self.layouter = BidirectionalLayouter(
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
self.page_size,
|
||||
font_family_override=font_family_override
|
||||
)
|
||||
|
||||
def get_font_family(self) -> Optional[BundledFont]:
|
||||
"""Get the current font family override"""
|
||||
return self.font_family
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics"""
|
||||
return self.buffer.get_cache_stats()
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the renderer and clean up resources"""
|
||||
"""Release cached pages"""
|
||||
self.buffer.shutdown()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Table column width optimization for pyWebLayout.
|
||||
|
||||
This module provides intelligent column width distribution for tables,
|
||||
ensuring optimal space usage while respecting content constraints.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from pyWebLayout.abstract.block import Table, TableRow
|
||||
|
||||
|
||||
def optimize_table_layout(table: Table,
|
||||
available_width: int,
|
||||
sample_size: int = 5,
|
||||
style=None) -> List[int]:
|
||||
"""
|
||||
Optimize column widths for a table.
|
||||
|
||||
Strategy:
|
||||
1. Check for HTML width overrides (colspan, width attributes)
|
||||
2. Sample first ~5 rows to estimate column requirements (performance)
|
||||
3. Calculate minimum width for each column (longest unbreakable word)
|
||||
4. Calculate preferred width for each column (no wrapping)
|
||||
5. If total preferred fits: use preferred
|
||||
6. Otherwise: distribute available space proportionally
|
||||
7. Ensure no column < min_width
|
||||
|
||||
Note: Hyphenation threshold is controlled by Font.min_hyphenation_width,
|
||||
not passed as a parameter here to avoid duplication.
|
||||
|
||||
Args:
|
||||
table: The table to optimize
|
||||
available_width: Total width available
|
||||
sample_size: Number of rows to sample for measurement (default 5)
|
||||
style: Optional table style for border/padding calculations
|
||||
|
||||
Returns:
|
||||
List of optimized column widths
|
||||
"""
|
||||
from pyWebLayout.concrete.dynamic_page import DynamicPage
|
||||
|
||||
n_cols = get_column_count(table)
|
||||
if n_cols == 0:
|
||||
return []
|
||||
|
||||
# Account for table borders/padding overhead
|
||||
if style:
|
||||
overhead = calculate_table_overhead(n_cols, style)
|
||||
available_for_content = available_width - overhead
|
||||
else:
|
||||
# Default border overhead
|
||||
border_width = 1
|
||||
overhead = border_width * (n_cols + 1)
|
||||
available_for_content = available_width - overhead
|
||||
|
||||
# Phase 0: Check for HTML width overrides
|
||||
html_widths = extract_html_column_widths(table)
|
||||
fixed_columns = {i: width for i, width in enumerate(html_widths) if width is not None}
|
||||
|
||||
# Phase 1: Sample rows and measure constraints for each column
|
||||
min_widths = [] # Minimum without breaking words (Font handles hyphenation)
|
||||
pref_widths = [] # Preferred (no wrapping)
|
||||
|
||||
# Sample first ~5 rows from each section (header, body, footer)
|
||||
sampled_rows = sample_table_rows(table, sample_size)
|
||||
|
||||
for col_idx in range(n_cols):
|
||||
# Check if this column has HTML width override
|
||||
if col_idx in fixed_columns:
|
||||
fixed_width = fixed_columns[col_idx]
|
||||
min_widths.append(fixed_width)
|
||||
pref_widths.append(fixed_width)
|
||||
continue
|
||||
|
||||
col_min = 50 # Absolute minimum
|
||||
col_pref = 50
|
||||
|
||||
# Check sampled cells in this column
|
||||
for row in sampled_rows:
|
||||
cells = list(row.cells())
|
||||
if col_idx >= len(cells):
|
||||
continue
|
||||
|
||||
cell = cells[col_idx]
|
||||
|
||||
# Create a DynamicPage for this cell with no padding/borders
|
||||
# (we're just measuring content, not rendering a full page)
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
measurement_style = PageStyle(padding=(0, 0, 0, 0), border_width=0)
|
||||
cell_page = DynamicPage(style=measurement_style)
|
||||
|
||||
# Add cell content to page
|
||||
layout_cell_content(cell_page, cell)
|
||||
|
||||
# Measure minimum width (Font's min_hyphenation_width controls breaking)
|
||||
# DynamicPage returns pure content width (no padding since we set it to 0)
|
||||
# TableRenderer will add cell padding later
|
||||
cell_min = cell_page.get_min_width()
|
||||
col_min = max(col_min, cell_min)
|
||||
|
||||
# Measure preferred width (no wrapping)
|
||||
cell_pref = cell_page.get_preferred_width()
|
||||
col_pref = max(col_pref, cell_pref)
|
||||
|
||||
min_widths.append(col_min)
|
||||
pref_widths.append(col_pref)
|
||||
|
||||
# Phase 2: Distribute width (respecting fixed columns)
|
||||
return distribute_column_widths(
|
||||
min_widths,
|
||||
pref_widths,
|
||||
available_for_content,
|
||||
fixed_columns
|
||||
)
|
||||
|
||||
|
||||
def layout_cell_content(page, cell):
|
||||
"""
|
||||
Layout cell content onto a DynamicPage.
|
||||
|
||||
This adds all blocks from the cell (paragraphs, images, etc.)
|
||||
as children of the page so they can be measured.
|
||||
|
||||
Args:
|
||||
page: DynamicPage to add content to
|
||||
cell: TableCell containing blocks
|
||||
"""
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style import FontWeight, Alignment
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading
|
||||
from PIL import Image as PILImage, ImageDraw
|
||||
|
||||
# Default font for measurement
|
||||
font_size = 12
|
||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
font = Font(font_path=font_path, font_size=font_size)
|
||||
|
||||
# Create a minimal draw context for Text measurement
|
||||
# (Text needs this for width calculation)
|
||||
dummy_img = PILImage.new('RGB', (1, 1))
|
||||
dummy_draw = ImageDraw.Draw(dummy_img)
|
||||
|
||||
# Get all blocks from the cell
|
||||
for block in cell.blocks():
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
# Get words from the block
|
||||
word_items = block.words() if callable(block.words) else block.words
|
||||
words = list(word_items)
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Create a line for measurement
|
||||
line = Line(
|
||||
spacing=(3, 6), # word spacing
|
||||
origin=(0, 0),
|
||||
size=(1000, 20), # Large size for measurement
|
||||
draw=dummy_draw,
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add all words to estimate width
|
||||
for word_item in words:
|
||||
# Handle word tuples (index, word_obj)
|
||||
if isinstance(word_item, tuple) and len(word_item) >= 2:
|
||||
word_obj = word_item[1]
|
||||
else:
|
||||
word_obj = word_item
|
||||
|
||||
# Extract text from the word
|
||||
word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
|
||||
|
||||
# Create Text object for the word
|
||||
# Text constructor: (text, style, draw)
|
||||
text_obj = Text(
|
||||
text=word_text,
|
||||
style=font, # Font is the style
|
||||
draw=dummy_draw
|
||||
)
|
||||
|
||||
line._text_objects.append(text_obj)
|
||||
|
||||
# Add line to page
|
||||
page.add_child(line)
|
||||
|
||||
|
||||
def get_column_count(table: Table) -> int:
|
||||
"""
|
||||
Get the number of columns in a table.
|
||||
|
||||
Args:
|
||||
table: The table to analyze
|
||||
|
||||
Returns:
|
||||
Number of columns
|
||||
"""
|
||||
all_rows = list(table.all_rows())
|
||||
if not all_rows:
|
||||
return 0
|
||||
|
||||
# Get from first row
|
||||
first_row = all_rows[0][1]
|
||||
return first_row.cell_count
|
||||
|
||||
|
||||
def sample_table_rows(table: Table, sample_size: int) -> List[TableRow]:
|
||||
"""
|
||||
Sample first ~sample_size rows from each table section.
|
||||
|
||||
Args:
|
||||
table: The table to sample
|
||||
sample_size: Number of rows to sample per section
|
||||
|
||||
Returns:
|
||||
List of sampled rows
|
||||
"""
|
||||
sampled = []
|
||||
|
||||
for section in ["header", "body", "footer"]:
|
||||
section_rows = [row for sec, row in table.all_rows() if sec == section]
|
||||
# Take first sample_size rows (or fewer if section is smaller)
|
||||
sampled.extend(section_rows[:sample_size])
|
||||
|
||||
return sampled
|
||||
|
||||
|
||||
def extract_html_column_widths(table: Table) -> List[Optional[int]]:
|
||||
"""
|
||||
Extract column width overrides from HTML attributes.
|
||||
|
||||
Checks for:
|
||||
- <col width="100px"> elements
|
||||
- <td width="100px"> in first row
|
||||
- <th width="100px"> in header
|
||||
|
||||
Args:
|
||||
table: The table to check
|
||||
|
||||
Returns:
|
||||
List of widths (None for auto-layout columns)
|
||||
"""
|
||||
n_cols = get_column_count(table)
|
||||
widths = [None] * n_cols
|
||||
|
||||
# Check for <col> elements with width
|
||||
if hasattr(table, 'col_widths'):
|
||||
for i, width in enumerate(table.col_widths):
|
||||
if width is not None:
|
||||
widths[i] = parse_html_width(width)
|
||||
|
||||
# Check first row cells for width attributes
|
||||
all_rows = list(table.all_rows())
|
||||
if all_rows:
|
||||
first_row = all_rows[0][1]
|
||||
cells = list(first_row.cells())
|
||||
for i, cell in enumerate(cells):
|
||||
if i < len(widths) and hasattr(cell, 'width') and cell.width is not None:
|
||||
widths[i] = parse_html_width(cell.width)
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def parse_html_width(width_value) -> Optional[int]:
|
||||
"""
|
||||
Parse HTML width value (e.g., "100px", "20%", "100").
|
||||
|
||||
Args:
|
||||
width_value: HTML width attribute value
|
||||
|
||||
Returns:
|
||||
Width in pixels, or None if percentage/invalid
|
||||
"""
|
||||
if isinstance(width_value, int):
|
||||
return width_value
|
||||
|
||||
if isinstance(width_value, str):
|
||||
# Remove whitespace
|
||||
width_value = width_value.strip()
|
||||
|
||||
# Percentage widths not supported yet
|
||||
if '%' in width_value:
|
||||
return None
|
||||
|
||||
# Parse pixel values
|
||||
if width_value.endswith('px'):
|
||||
try:
|
||||
return int(width_value[:-2])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Plain number
|
||||
try:
|
||||
return int(width_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def distribute_column_widths(min_widths: List[int],
|
||||
pref_widths: List[int],
|
||||
available_width: int,
|
||||
fixed_columns: Dict[int, int]) -> List[int]:
|
||||
"""
|
||||
Distribute width among columns, respecting fixed column widths.
|
||||
|
||||
Args:
|
||||
min_widths: Minimum width for each column
|
||||
pref_widths: Preferred width for each column
|
||||
available_width: Total width available
|
||||
fixed_columns: Dict mapping column index to fixed width
|
||||
|
||||
Returns:
|
||||
List of final column widths
|
||||
"""
|
||||
n_cols = len(min_widths)
|
||||
if n_cols == 0:
|
||||
return []
|
||||
|
||||
# Calculate available space for flexible columns
|
||||
fixed_total = sum(fixed_columns.values())
|
||||
flexible_available = available_width - fixed_total
|
||||
|
||||
# Get indices of flexible columns
|
||||
flexible_cols = [i for i in range(n_cols) if i not in fixed_columns]
|
||||
|
||||
if not flexible_cols:
|
||||
# All columns fixed - return as-is
|
||||
return [fixed_columns.get(i, min_widths[i]) for i in range(n_cols)]
|
||||
|
||||
# Calculate totals for flexible columns only
|
||||
flex_min_total = sum(min_widths[i] for i in flexible_cols)
|
||||
flex_pref_total = sum(pref_widths[i] for i in flexible_cols)
|
||||
|
||||
# Distribute space among flexible columns
|
||||
widths = [0] * n_cols
|
||||
|
||||
# Set fixed columns
|
||||
for i, width in fixed_columns.items():
|
||||
widths[i] = width
|
||||
|
||||
# Distribute to flexible columns
|
||||
if flex_pref_total <= flexible_available:
|
||||
# Preferred widths fit - distribute remaining space proportionally
|
||||
extra_space = flexible_available - flex_pref_total
|
||||
|
||||
if extra_space > 0 and flex_pref_total > 0:
|
||||
# Distribute extra space proportionally based on preferred widths
|
||||
for i in flexible_cols:
|
||||
proportion = pref_widths[i] / flex_pref_total
|
||||
widths[i] = int(pref_widths[i] + (extra_space * proportion))
|
||||
else:
|
||||
# No extra space, just use preferred widths
|
||||
for i in flexible_cols:
|
||||
widths[i] = pref_widths[i]
|
||||
elif flex_min_total > flexible_available:
|
||||
# Can't satisfy minimum - force it anyway (graceful degradation)
|
||||
for i in flexible_cols:
|
||||
widths[i] = min_widths[i]
|
||||
else:
|
||||
# Proportional distribution between min and pref
|
||||
extra_space = flexible_available - flex_min_total
|
||||
flex_pref_over_min = flex_pref_total - flex_min_total
|
||||
|
||||
for i in flexible_cols:
|
||||
if flex_pref_over_min > 0:
|
||||
pref_over_min = pref_widths[i] - min_widths[i]
|
||||
proportion = pref_over_min / flex_pref_over_min
|
||||
extra = extra_space * proportion
|
||||
widths[i] = int(min_widths[i] + extra)
|
||||
else:
|
||||
widths[i] = int(min_widths[i])
|
||||
|
||||
return widths
|
||||
|
||||
|
||||
def calculate_table_overhead(n_cols: int, style) -> int:
|
||||
"""
|
||||
Calculate the pixel overhead for table borders and spacing.
|
||||
|
||||
Args:
|
||||
n_cols: Number of columns
|
||||
style: TableStyle object
|
||||
|
||||
Returns:
|
||||
Total pixel overhead
|
||||
"""
|
||||
# Border on each side of each column + outer borders
|
||||
border_overhead = style.border_width * (n_cols + 1)
|
||||
|
||||
# Cell spacing if any
|
||||
spacing_overhead = style.cell_spacing * (n_cols - 1) if n_cols > 1 else 0
|
||||
|
||||
return border_overhead + spacing_overhead
|
||||
@@ -4,8 +4,10 @@ Style system for the pyWebLayout library.
|
||||
This module provides the core styling components used throughout the library.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from .fonts import (
|
||||
Font, FontWeight, FontStyle, TextDecoration,
|
||||
BundledFont, get_bundled_font_path, get_bundled_fonts_dir
|
||||
)
|
||||
from .abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
)
|
||||
@@ -15,6 +17,7 @@ from .alignment import Alignment
|
||||
|
||||
__all__ = [
|
||||
"Font", "FontWeight", "FontStyle", "TextDecoration",
|
||||
"BundledFont", "get_bundled_font_path", "get_bundled_fonts_dir",
|
||||
"AbstractStyle", "AbstractStyleRegistry", "FontFamily", "FontSize",
|
||||
"ConcreteStyle", "PageStyle", "Alignment"
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
|
||||
rendering systems and user preferences.
|
||||
"""
|
||||
|
||||
from .alignment import Alignment
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
@@ -30,7 +31,7 @@ class FontSize(Enum):
|
||||
LARGE = "large"
|
||||
X_LARGE = "x-large"
|
||||
XX_LARGE = "xx-large"
|
||||
|
||||
|
||||
# Allow numeric values as well
|
||||
@classmethod
|
||||
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
|
||||
@@ -50,7 +51,6 @@ class FontSize(Enum):
|
||||
|
||||
|
||||
# Import Alignment from the centralized location
|
||||
from .alignment import Alignment
|
||||
|
||||
# Use Alignment for text alignment
|
||||
TextAlign = Alignment
|
||||
@@ -61,39 +61,40 @@ class AbstractStyle:
|
||||
"""
|
||||
Abstract representation of text styling that captures semantic intent
|
||||
rather than concrete rendering parameters.
|
||||
|
||||
|
||||
This allows the same document to be rendered differently based on
|
||||
user preferences, device capabilities, or accessibility requirements.
|
||||
|
||||
|
||||
Being frozen=True makes this class hashable and immutable, which is
|
||||
perfect for use as dictionary keys and preventing accidental modification.
|
||||
"""
|
||||
|
||||
|
||||
# Font properties (semantic)
|
||||
font_family: FontFamily = FontFamily.SERIF
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM
|
||||
font_weight: FontWeight = FontWeight.NORMAL
|
||||
font_style: FontStyle = FontStyle.NORMAL
|
||||
text_decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
|
||||
# Color (as semantic names or RGB)
|
||||
color: Union[str, Tuple[int, int, int]] = "black"
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||
|
||||
|
||||
# Text properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
# None means "not specified": the page's default_alignment applies.
|
||||
text_align: Optional[TextAlign] = None
|
||||
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
||||
word_spacing: Optional[Union[str, float]] = None
|
||||
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
|
||||
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
|
||||
|
||||
|
||||
# Language and locale
|
||||
language: str = "en-US"
|
||||
|
||||
|
||||
# Hierarchy properties
|
||||
parent_style_id: Optional[str] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize values after creation"""
|
||||
# Normalize font_size if it's a string that could be a number
|
||||
@@ -103,15 +104,25 @@ class AbstractStyle:
|
||||
except ValueError:
|
||||
# Keep as is if it's a semantic size name
|
||||
pass
|
||||
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Custom hash implementation to ensure consistent hashing.
|
||||
|
||||
|
||||
Since this is a frozen dataclass, it should be hashable by default,
|
||||
but we provide a custom implementation to ensure all fields are
|
||||
properly considered and to handle the Union types correctly.
|
||||
|
||||
The result is memoised on first use. Styles are used as dictionary keys
|
||||
throughout parsing and style resolution, and five of the fields are enum
|
||||
members whose own __hash__ is a Python-level call, so rebuilding the
|
||||
15-tuple on every lookup was a measurable share of document parsing. The
|
||||
class is frozen, so the value cannot go stale.
|
||||
"""
|
||||
cached = self.__dict__.get('_hash_cache')
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Convert all values to hashable forms
|
||||
hashable_values = (
|
||||
self.font_family,
|
||||
@@ -130,17 +141,19 @@ class AbstractStyle:
|
||||
self.language,
|
||||
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':
|
||||
"""
|
||||
Create a new AbstractStyle by merging this one with another.
|
||||
The other style's properties take precedence.
|
||||
|
||||
|
||||
Args:
|
||||
other: AbstractStyle to merge with this one
|
||||
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with merged values
|
||||
"""
|
||||
@@ -149,26 +162,26 @@ class AbstractStyle:
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
|
||||
other_dict = {
|
||||
field.name: getattr(other, field.name)
|
||||
for field in other.__dataclass_fields__.values()
|
||||
if getattr(other, field.name) != field.default
|
||||
}
|
||||
|
||||
|
||||
# Merge dictionaries (other takes precedence)
|
||||
merged_dict = current_dict.copy()
|
||||
merged_dict.update(other_dict)
|
||||
|
||||
|
||||
return AbstractStyle(**merged_dict)
|
||||
|
||||
|
||||
def with_modifications(self, **kwargs) -> 'AbstractStyle':
|
||||
"""
|
||||
Create a new AbstractStyle with specified modifications.
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Properties to modify
|
||||
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with modifications applied
|
||||
"""
|
||||
@@ -176,7 +189,7 @@ class AbstractStyle:
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
|
||||
current_dict.update(kwargs)
|
||||
return AbstractStyle(**current_dict)
|
||||
|
||||
@@ -184,20 +197,21 @@ class AbstractStyle:
|
||||
class AbstractStyleRegistry:
|
||||
"""
|
||||
Registry for managing abstract document styles.
|
||||
|
||||
|
||||
This registry stores the semantic styling intent and provides
|
||||
deduplication and inheritance capabilities using hashable AbstractStyle objects.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty abstract style registry."""
|
||||
self._styles: Dict[str, AbstractStyle] = {}
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
|
||||
# Reverse mapping using hashable styles
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {}
|
||||
self._next_id = 1
|
||||
|
||||
|
||||
# Create and register the default style
|
||||
self._default_style = self._create_default_style()
|
||||
|
||||
|
||||
def _create_default_style(self) -> AbstractStyle:
|
||||
"""Create the default document style."""
|
||||
default_style = AbstractStyle()
|
||||
@@ -205,38 +219,41 @@ class AbstractStyleRegistry:
|
||||
self._styles[style_id] = default_style
|
||||
self._style_to_id[default_style] = style_id
|
||||
return default_style
|
||||
|
||||
|
||||
@property
|
||||
def default_style(self) -> AbstractStyle:
|
||||
"""Get the default style for the document."""
|
||||
return self._default_style
|
||||
|
||||
|
||||
def _generate_style_id(self) -> str:
|
||||
"""Generate a unique style ID."""
|
||||
style_id = f"abstract_style_{self._next_id}"
|
||||
self._next_id += 1
|
||||
return style_id
|
||||
|
||||
|
||||
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
|
||||
"""
|
||||
Get the ID for a given style if it exists in the registry.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to find
|
||||
|
||||
|
||||
Returns:
|
||||
Style ID if found, None otherwise
|
||||
"""
|
||||
return self._style_to_id.get(style)
|
||||
|
||||
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
|
||||
|
||||
def register_style(
|
||||
self,
|
||||
style: AbstractStyle,
|
||||
style_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Register a style in the registry.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to register
|
||||
style_id: Optional style ID. If None, one will be generated
|
||||
|
||||
|
||||
Returns:
|
||||
The style ID
|
||||
"""
|
||||
@@ -244,26 +261,26 @@ class AbstractStyleRegistry:
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id
|
||||
|
||||
|
||||
if style_id is None:
|
||||
style_id = self._generate_style_id()
|
||||
|
||||
|
||||
self._styles[style_id] = style
|
||||
self._style_to_id[style] = style_id
|
||||
return style_id
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get an existing style or create a new one.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle object. If None, created from kwargs
|
||||
parent_id: Optional parent style ID
|
||||
**kwargs: Individual style properties (used if style is None)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (style_id, AbstractStyle)
|
||||
"""
|
||||
@@ -274,64 +291,65 @@ class AbstractStyleRegistry:
|
||||
if parent_id:
|
||||
filtered_kwargs['parent_style_id'] = parent_id
|
||||
style = AbstractStyle(**filtered_kwargs)
|
||||
|
||||
|
||||
# Check if we already have this style (using hashable property)
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id, style
|
||||
|
||||
|
||||
# Create new style
|
||||
style_id = self.register_style(style)
|
||||
return style_id, style
|
||||
|
||||
|
||||
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
|
||||
"""Get a style by its ID."""
|
||||
return self._styles.get(style_id)
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **
|
||||
modifications) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Create a new style derived from a base style.
|
||||
|
||||
|
||||
Args:
|
||||
base_style_id: ID of the base style
|
||||
**modifications: Properties to modify
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (new_style_id, new_AbstractStyle)
|
||||
"""
|
||||
base_style = self.get_style_by_id(base_style_id)
|
||||
if base_style is None:
|
||||
raise ValueError(f"Base style '{base_style_id}' not found")
|
||||
|
||||
|
||||
# Create derived style
|
||||
derived_style = base_style.with_modifications(**modifications)
|
||||
return self.get_or_create_style(derived_style)
|
||||
|
||||
|
||||
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
|
||||
"""
|
||||
Resolve the effective style including inheritance.
|
||||
|
||||
|
||||
Args:
|
||||
style_id: Style ID to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
Effective AbstractStyle with inheritance applied
|
||||
"""
|
||||
style = self.get_style_by_id(style_id)
|
||||
if style is None:
|
||||
return self._default_style
|
||||
|
||||
|
||||
if style.parent_style_id is None:
|
||||
return style
|
||||
|
||||
|
||||
# Recursively resolve parent styles
|
||||
parent_style = self.resolve_effective_style(style.parent_style_id)
|
||||
return parent_style.merge_with(style)
|
||||
|
||||
|
||||
def get_all_styles(self) -> Dict[str, AbstractStyle]:
|
||||
"""Get all registered styles."""
|
||||
return self._styles.copy()
|
||||
|
||||
|
||||
def get_style_count(self) -> int:
|
||||
"""Get the number of registered styles."""
|
||||
return len(self._styles)
|
||||
|
||||
@@ -6,6 +6,7 @@ This module provides alignment-related functionality.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Alignment(Enum):
|
||||
"""Text and box alignment options"""
|
||||
# Horizontal alignment
|
||||
@@ -13,10 +14,10 @@ class Alignment(Enum):
|
||||
RIGHT = "right"
|
||||
CENTER = "center"
|
||||
JUSTIFY = "justify"
|
||||
|
||||
|
||||
# Vertical alignment
|
||||
TOP = "top"
|
||||
MIDDLE = "middle"
|
||||
MIDDLE = "middle"
|
||||
BOTTOM = "bottom"
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
|
||||
user preferences, device capabilities, and rendering context.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple, Union, Any
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,24 +18,24 @@ class RenderingContext:
|
||||
Context information for style resolution.
|
||||
Contains user preferences and device capabilities.
|
||||
"""
|
||||
|
||||
|
||||
# User preferences
|
||||
base_font_size: int = 16 # Base font size in points
|
||||
font_scale_factor: float = 1.0 # Global font scaling
|
||||
preferred_serif_font: Optional[str] = None
|
||||
preferred_sans_serif_font: Optional[str] = None
|
||||
preferred_monospace_font: Optional[str] = None
|
||||
|
||||
|
||||
# Device/environment info
|
||||
dpi: int = 96 # Dots per inch
|
||||
available_width: Optional[int] = None # Available width in pixels
|
||||
available_height: Optional[int] = None # Available height in pixels
|
||||
|
||||
|
||||
# Accessibility preferences
|
||||
high_contrast: bool = False
|
||||
large_text: bool = False
|
||||
reduce_motion: bool = False
|
||||
|
||||
|
||||
# Language and locale
|
||||
default_language: str = "en-US"
|
||||
|
||||
@@ -45,37 +44,38 @@ class RenderingContext:
|
||||
class ConcreteStyle:
|
||||
"""
|
||||
Concrete representation of text styling with actual rendering parameters.
|
||||
|
||||
|
||||
This contains the resolved font files, pixel sizes, actual colors, etc.
|
||||
that will be used for rendering. This is also hashable for efficient caching.
|
||||
"""
|
||||
|
||||
|
||||
# Concrete font properties
|
||||
font_path: Optional[str] = None
|
||||
font_size: int = 16 # Always in points/pixels
|
||||
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
|
||||
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
|
||||
|
||||
|
||||
# Font attributes
|
||||
weight: FontWeight = FontWeight.NORMAL
|
||||
style: FontStyle = FontStyle.NORMAL
|
||||
decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
|
||||
# Layout properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
# None means "not specified": the page's default_alignment applies.
|
||||
text_align: Optional[TextAlign] = None
|
||||
line_height: float = 1.0 # Multiplier
|
||||
letter_spacing: float = 0.0 # In pixels
|
||||
word_spacing: float = 0.0 # In pixels
|
||||
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
|
||||
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
|
||||
|
||||
|
||||
# Language and locale
|
||||
language: str = "en-US"
|
||||
min_hyphenation_width: int = 64 # In pixels
|
||||
|
||||
|
||||
# Reference to source abstract style
|
||||
abstract_style: Optional[AbstractStyle] = None
|
||||
|
||||
|
||||
def create_font(self) -> Font:
|
||||
"""Create a Font object from this concrete style."""
|
||||
return Font(
|
||||
@@ -94,21 +94,21 @@ class ConcreteStyle:
|
||||
class StyleResolver:
|
||||
"""
|
||||
Resolves abstract styles to concrete styles based on rendering context.
|
||||
|
||||
|
||||
This class handles the conversion from semantic styling intent to actual
|
||||
rendering parameters, applying user preferences and device capabilities.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, context: RenderingContext):
|
||||
"""
|
||||
Initialize the style resolver with a rendering context.
|
||||
|
||||
|
||||
Args:
|
||||
context: RenderingContext with user preferences and device info
|
||||
"""
|
||||
self.context = context
|
||||
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
|
||||
|
||||
|
||||
# Font size mapping for semantic sizes
|
||||
self._semantic_font_sizes = {
|
||||
FontSize.XX_SMALL: 0.6,
|
||||
@@ -119,7 +119,7 @@ class StyleResolver:
|
||||
FontSize.X_LARGE: 1.5,
|
||||
FontSize.XX_LARGE: 2.0,
|
||||
}
|
||||
|
||||
|
||||
# Color name mapping
|
||||
self._color_names = {
|
||||
"black": (0, 0, 0),
|
||||
@@ -141,35 +141,40 @@ class StyleResolver:
|
||||
"fuchsia": (255, 0, 255),
|
||||
"purple": (128, 0, 128),
|
||||
}
|
||||
|
||||
|
||||
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
|
||||
"""
|
||||
Resolve an abstract style to a concrete style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
ConcreteStyle with concrete rendering parameters
|
||||
"""
|
||||
# Check cache first
|
||||
if abstract_style in self._concrete_cache:
|
||||
return self._concrete_cache[abstract_style]
|
||||
|
||||
|
||||
# Resolve each property
|
||||
font_path = self._resolve_font_path(abstract_style.font_family)
|
||||
font_size = self._resolve_font_size(abstract_style.font_size)
|
||||
# Ensure font_size is always an int before using in arithmetic
|
||||
font_size = int(font_size)
|
||||
color = self._resolve_color(abstract_style.color)
|
||||
background_color = self._resolve_background_color(abstract_style.background_color)
|
||||
background_color = self._resolve_background_color(
|
||||
abstract_style.background_color)
|
||||
line_height = self._resolve_line_height(abstract_style.line_height)
|
||||
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
|
||||
letter_spacing = self._resolve_letter_spacing(
|
||||
abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_max, font_size)
|
||||
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
|
||||
|
||||
|
||||
# Apply default logic for word spacing constraints
|
||||
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
|
||||
# If no constraints specified, use base word_spacing as reference
|
||||
@@ -186,7 +191,7 @@ class StyleResolver:
|
||||
elif word_spacing_max == 0.0:
|
||||
# Only min specified, use base word_spacing or reasonable multiple
|
||||
word_spacing_max = max(word_spacing, word_spacing_min * 2)
|
||||
|
||||
|
||||
# Create concrete style
|
||||
concrete_style = ConcreteStyle(
|
||||
font_path=font_path,
|
||||
@@ -206,11 +211,11 @@ class StyleResolver:
|
||||
min_hyphenation_width=min_hyphenation_width,
|
||||
abstract_style=abstract_style
|
||||
)
|
||||
|
||||
|
||||
# Cache and return
|
||||
self._concrete_cache[abstract_style] = concrete_style
|
||||
return concrete_style
|
||||
|
||||
|
||||
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
|
||||
"""Resolve font family to actual font file path."""
|
||||
if font_family == FontFamily.SERIF:
|
||||
@@ -222,7 +227,7 @@ class StyleResolver:
|
||||
else:
|
||||
# For cursive and fantasy, fall back to sans-serif
|
||||
return self.context.preferred_sans_serif_font
|
||||
|
||||
|
||||
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
|
||||
"""Resolve font size to actual pixel/point size."""
|
||||
# Ensure we handle FontSize enums properly
|
||||
@@ -240,22 +245,23 @@ class StyleResolver:
|
||||
except (ValueError, TypeError):
|
||||
# If conversion fails, use default
|
||||
base_size = self.context.base_font_size
|
||||
|
||||
|
||||
# Apply global font scaling
|
||||
final_size = int(base_size * self.context.font_scale_factor)
|
||||
|
||||
|
||||
# Apply accessibility adjustments
|
||||
if self.context.large_text:
|
||||
final_size = int(final_size * 1.2)
|
||||
|
||||
|
||||
# Ensure we always return an int, minimum 8pt font
|
||||
return max(int(final_size), 8)
|
||||
|
||||
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
|
||||
def _resolve_color(
|
||||
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
"""Resolve color to RGB tuple."""
|
||||
if isinstance(color, tuple):
|
||||
return color
|
||||
|
||||
|
||||
if isinstance(color, str):
|
||||
# Check if it's a named color
|
||||
if color.lower() in self._color_names:
|
||||
@@ -266,7 +272,7 @@ class StyleResolver:
|
||||
hex_color = color[1:]
|
||||
if len(hex_color) == 3:
|
||||
# Short hex format #RGB -> #RRGGBB
|
||||
hex_color = ''.join(c*2 for c in hex_color)
|
||||
hex_color = ''.join(c * 2 for c in hex_color)
|
||||
if len(hex_color) == 6:
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
@@ -278,7 +284,7 @@ class StyleResolver:
|
||||
base_color = (0, 0, 0) # Fallback to black
|
||||
else:
|
||||
base_color = (0, 0, 0) # Fallback to black
|
||||
|
||||
|
||||
# Apply high contrast if needed
|
||||
if self.context.high_contrast:
|
||||
# Simple high contrast: make dark colors black, light colors white
|
||||
@@ -288,56 +294,65 @@ class StyleResolver:
|
||||
base_color = (0, 0, 0) # Black
|
||||
else:
|
||||
base_color = (255, 255, 255) # White
|
||||
|
||||
|
||||
return base_color
|
||||
|
||||
|
||||
return (0, 0, 0) # Fallback to black
|
||||
|
||||
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
|
||||
|
||||
def _resolve_background_color(self,
|
||||
bg_color: Optional[Union[str,
|
||||
Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]]) -> Optional[Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]:
|
||||
"""Resolve background color to RGBA tuple or None."""
|
||||
if bg_color is None:
|
||||
return None
|
||||
|
||||
|
||||
if isinstance(bg_color, tuple):
|
||||
if len(bg_color) == 3:
|
||||
# RGB -> RGBA
|
||||
return bg_color + (255,)
|
||||
return bg_color
|
||||
|
||||
|
||||
if isinstance(bg_color, str):
|
||||
if bg_color.lower() == "transparent":
|
||||
return None
|
||||
|
||||
|
||||
# Resolve as RGB then add alpha
|
||||
rgb = self._resolve_color(bg_color)
|
||||
return rgb + (255,)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
|
||||
"""Resolve line height to multiplier."""
|
||||
if line_height is None or line_height == "normal":
|
||||
return 1.2 # Default line height
|
||||
|
||||
|
||||
if isinstance(line_height, (int, float)):
|
||||
return float(line_height)
|
||||
|
||||
|
||||
if isinstance(line_height, str):
|
||||
try:
|
||||
return float(line_height)
|
||||
except ValueError:
|
||||
return 1.2 # Fallback
|
||||
|
||||
|
||||
return 1.2
|
||||
|
||||
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
|
||||
def _resolve_letter_spacing(
|
||||
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve letter spacing to pixels."""
|
||||
if letter_spacing is None or letter_spacing == "normal":
|
||||
return 0.0
|
||||
|
||||
|
||||
if isinstance(letter_spacing, (int, float)):
|
||||
return float(letter_spacing)
|
||||
|
||||
|
||||
if isinstance(letter_spacing, str):
|
||||
if letter_spacing.endswith("em"):
|
||||
try:
|
||||
@@ -350,17 +365,18 @@ class StyleResolver:
|
||||
return float(letter_spacing)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
return 0.0
|
||||
|
||||
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
|
||||
def _resolve_word_spacing(
|
||||
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve word spacing to pixels."""
|
||||
if word_spacing is None or word_spacing == "normal":
|
||||
return 0.0
|
||||
|
||||
|
||||
if isinstance(word_spacing, (int, float)):
|
||||
return float(word_spacing)
|
||||
|
||||
|
||||
if isinstance(word_spacing, str):
|
||||
if word_spacing.endswith("em"):
|
||||
try:
|
||||
@@ -373,13 +389,13 @@ class StyleResolver:
|
||||
return float(word_spacing)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_context(self, **kwargs):
|
||||
"""
|
||||
Update the rendering context and clear cache.
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Context properties to update
|
||||
"""
|
||||
@@ -389,16 +405,16 @@ class StyleResolver:
|
||||
for field in self.context.__dataclass_fields__.values()
|
||||
}
|
||||
context_dict.update(kwargs)
|
||||
|
||||
|
||||
self.context = RenderingContext(**context_dict)
|
||||
|
||||
|
||||
# Clear cache since context changed
|
||||
self._concrete_cache.clear()
|
||||
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the concrete style cache."""
|
||||
self._concrete_cache.clear()
|
||||
|
||||
|
||||
def get_cache_size(self) -> int:
|
||||
"""Get the number of cached concrete styles."""
|
||||
return len(self._concrete_cache)
|
||||
@@ -407,60 +423,60 @@ class StyleResolver:
|
||||
class ConcreteStyleRegistry:
|
||||
"""
|
||||
Registry for managing concrete styles with efficient caching.
|
||||
|
||||
|
||||
This registry manages the mapping between abstract and concrete styles,
|
||||
and provides efficient access to Font objects for rendering.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, resolver: StyleResolver):
|
||||
"""
|
||||
Initialize the concrete style registry.
|
||||
|
||||
|
||||
Args:
|
||||
resolver: StyleResolver for converting abstract to concrete styles
|
||||
"""
|
||||
self.resolver = resolver
|
||||
self._font_cache: Dict[ConcreteStyle, Font] = {}
|
||||
|
||||
|
||||
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
|
||||
"""
|
||||
Get a concrete style for an abstract style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
ConcreteStyle with rendering parameters
|
||||
"""
|
||||
return self.resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
def get_font(self, abstract_style: AbstractStyle) -> Font:
|
||||
"""
|
||||
Get a Font object for an abstract style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to get font for
|
||||
|
||||
|
||||
Returns:
|
||||
Font object ready for rendering
|
||||
"""
|
||||
concrete_style = self.get_concrete_style(abstract_style)
|
||||
|
||||
|
||||
# Check font cache
|
||||
if concrete_style in self._font_cache:
|
||||
return self._font_cache[concrete_style]
|
||||
|
||||
|
||||
# Create and cache font
|
||||
font = concrete_style.create_font()
|
||||
self._font_cache[concrete_style] = font
|
||||
|
||||
|
||||
return font
|
||||
|
||||
|
||||
def clear_caches(self):
|
||||
"""Clear all caches."""
|
||||
self.resolver.clear_cache()
|
||||
self._font_cache.clear()
|
||||
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics."""
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
# this should contain classes for how different object can be rendered, e.g. bold, italic, regular
|
||||
# this should contain classes for how different object can be rendered,
|
||||
# e.g. bold, italic, regular
|
||||
from PIL import ImageFont
|
||||
from enum import Enum
|
||||
from typing import Tuple, Union, Optional
|
||||
from typing import Tuple, Optional, Dict
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Set up logging for font loading
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global cache for PIL ImageFont objects to avoid reloading fonts from disk
|
||||
# Key: (font_path, font_size), Value: PIL ImageFont object
|
||||
_FONT_CACHE: Dict[Tuple[Optional[str], int], ImageFont.FreeTypeFont] = {}
|
||||
|
||||
# Cache for bundled font path to avoid repeated filesystem lookups
|
||||
_BUNDLED_FONT_PATH: Optional[str] = None
|
||||
|
||||
# Cache for bundled fonts directory
|
||||
_BUNDLED_FONTS_DIR: Optional[str] = None
|
||||
|
||||
|
||||
class FontWeight(Enum):
|
||||
NORMAL = "normal"
|
||||
@@ -25,27 +36,126 @@ class TextDecoration(Enum):
|
||||
STRIKETHROUGH = "strikethrough"
|
||||
|
||||
|
||||
class BundledFont(Enum):
|
||||
"""Bundled font families available in pyWebLayout"""
|
||||
SANS = "sans" # DejaVu Sans - modern sans-serif
|
||||
SERIF = "serif" # DejaVu Serif - classic serif
|
||||
MONOSPACE = "monospace" # DejaVu Sans Mono - fixed-width
|
||||
|
||||
|
||||
def get_bundled_fonts_dir():
|
||||
"""
|
||||
Get the directory containing bundled fonts (cached).
|
||||
|
||||
Returns:
|
||||
str: Path to the fonts directory, or None if not found
|
||||
"""
|
||||
global _BUNDLED_FONTS_DIR
|
||||
|
||||
# Return cached path if available
|
||||
if _BUNDLED_FONTS_DIR is not None:
|
||||
return _BUNDLED_FONTS_DIR
|
||||
|
||||
# First time - determine the path and cache it
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
|
||||
|
||||
if os.path.exists(fonts_dir) and os.path.isdir(fonts_dir):
|
||||
_BUNDLED_FONTS_DIR = fonts_dir
|
||||
logger.debug(f"Found bundled fonts directory at: {fonts_dir}")
|
||||
return fonts_dir
|
||||
else:
|
||||
logger.warning(f"Bundled fonts directory not found at: {fonts_dir}")
|
||||
_BUNDLED_FONTS_DIR = "" # Empty string to indicate "checked but not found"
|
||||
return None
|
||||
|
||||
|
||||
def get_bundled_font_path(
|
||||
family: BundledFont = BundledFont.SANS,
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get the path to a specific bundled font file.
|
||||
|
||||
Args:
|
||||
family: The font family (SANS, SERIF, or MONOSPACE)
|
||||
weight: The font weight (NORMAL or BOLD)
|
||||
style: The font style (NORMAL or ITALIC)
|
||||
|
||||
Returns:
|
||||
str: Full path to the font file, or None if not found
|
||||
|
||||
Example:
|
||||
>>> # Get bold italic sans font
|
||||
>>> path = get_bundled_font_path(BundledFont.SANS, FontWeight.BOLD, FontStyle.ITALIC)
|
||||
>>> font = Font(font_path=path, font_size=16)
|
||||
"""
|
||||
fonts_dir = get_bundled_fonts_dir()
|
||||
if not fonts_dir:
|
||||
return None
|
||||
|
||||
# Map font parameters to filename
|
||||
family_map = {
|
||||
BundledFont.SANS: "DejaVuSans",
|
||||
BundledFont.SERIF: "DejaVuSerif",
|
||||
BundledFont.MONOSPACE: "DejaVuSansMono"
|
||||
}
|
||||
|
||||
base_name = family_map.get(family, "DejaVuSans")
|
||||
|
||||
# Build the font file name
|
||||
parts = [base_name]
|
||||
|
||||
if weight == FontWeight.BOLD and style == FontStyle.ITALIC:
|
||||
# Special case: both bold and italic
|
||||
if family == BundledFont.MONOSPACE:
|
||||
parts.append("BoldOblique")
|
||||
elif family == BundledFont.SERIF:
|
||||
parts.append("BoldItalic")
|
||||
else: # SANS
|
||||
parts.append("BoldOblique")
|
||||
elif weight == FontWeight.BOLD:
|
||||
parts.append("Bold")
|
||||
elif style == FontStyle.ITALIC:
|
||||
# Italic naming differs by family
|
||||
if family == BundledFont.MONOSPACE or family == BundledFont.SANS:
|
||||
parts.append("Oblique")
|
||||
else: # SERIF
|
||||
parts.append("Italic")
|
||||
|
||||
filename = "-".join(parts) + ".ttf"
|
||||
font_path = os.path.join(fonts_dir, filename)
|
||||
|
||||
if os.path.exists(font_path):
|
||||
logger.debug(f"Found bundled font: {filename}")
|
||||
return font_path
|
||||
else:
|
||||
logger.warning(f"Bundled font not found: {filename}")
|
||||
return None
|
||||
|
||||
|
||||
class Font:
|
||||
"""
|
||||
Font class to manage text rendering properties including font face, size, color, and styling.
|
||||
This class is used by the text renderer to determine how to render text.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
def __init__(self,
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL,
|
||||
decoration: TextDecoration = TextDecoration.NONE,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language = "en_EN",
|
||||
language="en_EN",
|
||||
min_hyphenation_width: Optional[int] = None):
|
||||
"""
|
||||
Initialize a Font object with the specified properties.
|
||||
|
||||
|
||||
Args:
|
||||
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
|
||||
font_path: Path to the font file (.ttf, .otf). If None, uses default bundled font.
|
||||
font_size: Size of the font in points.
|
||||
colour: RGB color tuple for the text.
|
||||
weight: Font weight (normal or bold).
|
||||
@@ -67,157 +177,230 @@ class Font:
|
||||
self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
|
||||
# Load the font file or use default
|
||||
self._load_font()
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_family(cls,
|
||||
family: BundledFont = BundledFont.SANS,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL,
|
||||
decoration: TextDecoration = TextDecoration.NONE,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
"""
|
||||
Create a Font using a bundled font family.
|
||||
|
||||
This is a convenient way to use the bundled DejaVu fonts without needing to
|
||||
specify paths manually.
|
||||
|
||||
Args:
|
||||
family: The font family to use (SANS, SERIF, or MONOSPACE)
|
||||
font_size: Size of the font in points.
|
||||
colour: RGB color tuple for the text.
|
||||
weight: Font weight (normal or bold).
|
||||
style: Font style (normal or italic).
|
||||
decoration: Text decoration (none, underline, or strikethrough).
|
||||
background: RGBA background color for the text. If None, transparent background.
|
||||
language: Language code for hyphenation and text processing.
|
||||
min_hyphenation_width: Minimum width in pixels required for hyphenation.
|
||||
|
||||
Returns:
|
||||
Font object configured with the bundled font
|
||||
|
||||
Example:
|
||||
>>> # Create a bold serif font
|
||||
>>> font = Font.from_family(BundledFont.SERIF, font_size=18, weight=FontWeight.BOLD)
|
||||
>>>
|
||||
>>> # Create an italic monospace font
|
||||
>>> code_font = Font.from_family(BundledFont.MONOSPACE, style=FontStyle.ITALIC)
|
||||
"""
|
||||
font_path = get_bundled_font_path(family, weight, style)
|
||||
return cls(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
colour=colour,
|
||||
weight=weight,
|
||||
style=style,
|
||||
decoration=decoration,
|
||||
background=background,
|
||||
language=language,
|
||||
min_hyphenation_width=min_hyphenation_width
|
||||
)
|
||||
|
||||
def _get_bundled_font_path(self):
|
||||
"""Get the path to the bundled font"""
|
||||
"""Get the path to the bundled font (cached)"""
|
||||
global _BUNDLED_FONT_PATH
|
||||
|
||||
# Return cached path if available
|
||||
if _BUNDLED_FONT_PATH is not None:
|
||||
return _BUNDLED_FONT_PATH
|
||||
|
||||
# First time - determine the path and cache it
|
||||
# Get the directory containing this module
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Navigate to the assets/fonts directory
|
||||
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
|
||||
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
|
||||
|
||||
|
||||
logger.debug(f"Font loading: current_dir = {current_dir}")
|
||||
logger.debug(f"Font loading: assets_dir = {assets_dir}")
|
||||
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
|
||||
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
|
||||
|
||||
logger.debug(
|
||||
f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}"
|
||||
)
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
logger.info(f"Found bundled font at: {bundled_font_path}")
|
||||
_BUNDLED_FONT_PATH = bundled_font_path
|
||||
return bundled_font_path
|
||||
else:
|
||||
logger.warning(f"Bundled font not found at: {bundled_font_path}")
|
||||
# Cache None to indicate bundled font is not available
|
||||
_BUNDLED_FONT_PATH = "" # Use empty string instead of None to differentiate from "not checked yet"
|
||||
return None
|
||||
|
||||
|
||||
def _load_font(self):
|
||||
"""Load the font using PIL's ImageFont with consistent bundled font"""
|
||||
"""Load the font using PIL's ImageFont with consistent bundled font and caching"""
|
||||
# Determine the actual font path to use
|
||||
font_path_to_use = self._font_path
|
||||
if not font_path_to_use:
|
||||
font_path_to_use = self._get_bundled_font_path()
|
||||
|
||||
# Create cache key
|
||||
cache_key = (font_path_to_use, self._font_size)
|
||||
|
||||
# Check if font is already cached
|
||||
if cache_key in _FONT_CACHE:
|
||||
self._font = _FONT_CACHE[cache_key]
|
||||
logger.debug(f"Reusing cached font: {font_path_to_use} at size {self._font_size}")
|
||||
return
|
||||
|
||||
# Font not cached, need to load it
|
||||
try:
|
||||
if self._font_path:
|
||||
# Use specified font path
|
||||
logger.info(f"Loading font from specified path: {self._font_path}")
|
||||
self._font = ImageFont.truetype(
|
||||
self._font_path,
|
||||
self._font_path,
|
||||
self._font_size
|
||||
)
|
||||
logger.info(f"Successfully loaded font from: {self._font_path}")
|
||||
else:
|
||||
# Use bundled font for consistency across environments
|
||||
bundled_font_path = self._get_bundled_font_path()
|
||||
|
||||
|
||||
if bundled_font_path:
|
||||
logger.info(f"Loading bundled font from: {bundled_font_path}")
|
||||
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
|
||||
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
|
||||
logger.info(
|
||||
f"Successfully loaded bundled font at size {self._font_size}"
|
||||
)
|
||||
else:
|
||||
# Only fall back to PIL's default font if bundled font is not available
|
||||
logger.warning(f"Bundled font not available, falling back to PIL default font")
|
||||
# Only fall back to PIL's default font if bundled font is not
|
||||
# available
|
||||
logger.warning(
|
||||
"Bundled font not available, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
|
||||
# Cache the loaded font
|
||||
_FONT_CACHE[cache_key] = self._font
|
||||
logger.debug(f"Cached font: {font_path_to_use} at size {self._font_size}")
|
||||
|
||||
except Exception as e:
|
||||
# Ultimate fallback to default font
|
||||
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
# Don't cache the default font as it doesn't have a path
|
||||
|
||||
@property
|
||||
def font(self):
|
||||
"""Get the PIL ImageFont object"""
|
||||
return self._font
|
||||
|
||||
|
||||
@property
|
||||
def font_size(self):
|
||||
"""Get the font size"""
|
||||
return self._font_size
|
||||
|
||||
|
||||
@property
|
||||
def colour(self):
|
||||
"""Get the text color"""
|
||||
return self._colour
|
||||
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
"""Alias for colour (American spelling)"""
|
||||
return self._colour
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def weight(self):
|
||||
"""Get the font weight"""
|
||||
return self._weight
|
||||
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
"""Get the font style"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def decoration(self):
|
||||
"""Get the text decoration"""
|
||||
return self._decoration
|
||||
|
||||
|
||||
@property
|
||||
def min_hyphenation_width(self):
|
||||
"""Get the minimum width required for hyphenation to be considered"""
|
||||
return self._min_hyphenation_width
|
||||
|
||||
|
||||
def _with_modified(self, **kwargs):
|
||||
"""
|
||||
Internal helper to create a new Font with modified parameters.
|
||||
|
||||
This consolidates the duplication across all with_* methods.
|
||||
|
||||
Args:
|
||||
**kwargs: Parameters to override (e.g., font_size=20, colour=(255,0,0))
|
||||
|
||||
Returns:
|
||||
New Font object with modified parameters
|
||||
"""
|
||||
params = {
|
||||
'font_path': self._font_path,
|
||||
'font_size': self._font_size,
|
||||
'colour': self._colour,
|
||||
'weight': self._weight,
|
||||
'style': self._style,
|
||||
'decoration': self._decoration,
|
||||
'background': self._background,
|
||||
'language': self.language,
|
||||
'min_hyphenation_width': self._min_hyphenation_width
|
||||
}
|
||||
params.update(kwargs)
|
||||
return Font(**params)
|
||||
|
||||
def with_size(self, size: int):
|
||||
"""Create a new Font object with modified size"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
return self._with_modified(font_size=size)
|
||||
|
||||
def with_colour(self, colour: Tuple[int, int, int]):
|
||||
"""Create a new Font object with modified colour"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
return self._with_modified(colour=colour)
|
||||
|
||||
def with_weight(self, weight: FontWeight):
|
||||
"""Create a new Font object with modified weight"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
return self._with_modified(weight=weight)
|
||||
|
||||
def with_style(self, style: FontStyle):
|
||||
"""Create a new Font object with modified style"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
return self._with_modified(style=style)
|
||||
|
||||
def with_decoration(self, decoration: TextDecoration):
|
||||
"""Create a new Font object with modified decoration"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
decoration,
|
||||
self._background
|
||||
)
|
||||
return self._with_modified(decoration=decoration)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
from typing import Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageStyle:
|
||||
@@ -9,23 +10,27 @@ class PageStyle:
|
||||
Defines the styling properties for a page including borders, spacing, and layout.
|
||||
"""
|
||||
|
||||
# Alignment applied to body text that does not specify its own. Headings are
|
||||
# never justified regardless of this setting.
|
||||
default_alignment: Alignment = Alignment.JUSTIFY
|
||||
|
||||
# Border properties
|
||||
border_width: int = 0
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0)
|
||||
|
||||
# Spacing properties
|
||||
line_spacing: int = 5
|
||||
inter_block_spacing: int = 15
|
||||
line_spacing: int = 5 # Additional pixels between lines (added to font size)
|
||||
inter_block_spacing: int = 15 # Pixels between blocks (paragraphs, headings, etc.)
|
||||
word_spacing: int = 0 # Additional pixels between words (0 = use font defaults)
|
||||
|
||||
# Padding (top, right, bottom, left)
|
||||
padding: Tuple[int, int, int, int] = (20, 20, 20, 20)
|
||||
|
||||
# Background color
|
||||
background_color: Tuple[int, int, int] = (255, 255, 255)
|
||||
|
||||
|
||||
# Typography properties
|
||||
max_font_size: int = 72 # Maximum font size allowed on a page
|
||||
line_spacing_multiplier: float = 1.2 # Baseline-to-baseline spacing multiplier
|
||||
|
||||
@property
|
||||
def padding_top(self) -> int:
|
||||
|
||||
@@ -4,24 +4,56 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pyWebLayout"
|
||||
version = "0.1.1"
|
||||
description = "A Python library for HTML-like layout and rendering"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.6"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
authors = [
|
||||
{name = "Duncan Tourolle", email = "duncan@tourolle.paris"}
|
||||
]
|
||||
dynamic = ["version"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"Pillow",
|
||||
"numpy",
|
||||
"pyphen",
|
||||
"beautifulsoup4",
|
||||
"flask",
|
||||
"ebooklib",
|
||||
"requests"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.tourolle.paris/pyWebLayout"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Loading images from http(s) URLs. concrete.image imports requests lazily and
|
||||
# degrades to an error message on the image when it is absent, so it is not a
|
||||
# hard requirement.
|
||||
remote-images = ["requests"]
|
||||
test = [
|
||||
"pytest>=6.0",
|
||||
"pytest-cov",
|
||||
"flask", # fixture HTTP server in tests/abstract/test_abstract_blocks.py
|
||||
"werkzeug", # make_server, same fixture
|
||||
"ebooklib", # builds EPUB fixtures; the reader itself uses zipfile + ElementTree
|
||||
"requests", # exercises the remote-images path
|
||||
]
|
||||
dev = [
|
||||
"pyWebLayout[test,remote-images]",
|
||||
"flake8",
|
||||
"coverage-badge",
|
||||
"interrogate",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pyWebLayout*"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["pyWebLayout"]
|
||||
branch = true
|
||||
|
||||