Compare commits

...
61 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 cfc4230713 fix(ereader): restore cover position when navigating back to the cover
Python CI / test (3.10) (push) Successful in 1m2s
Python CI / test (3.11) (push) Successful in 1m5s
Python CI / test (3.12) (push) Successful in 1m5s
Python CI / test (3.13) (push) Successful in 1m43s
previous_page() set the on-cover flag but left current_position at the
first content block, so "showing the cover" had two different internal
representations depending on how you got there: block 0 on a fresh load,
block 1 after going forward and back.

current_position is what gets persisted, so closing the book while on the
cover reopened it past the cover, silently losing it.

Reset current_position to block 0 when returning to the cover, matching
where a fresh load sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:26:38 +02:00
dtourolleandClaude Opus 5 0bb34a4a32 perf(layout): cut page layout time by ~40%
Python CI / test (3.10) (push) Successful in 1m4s
Python CI / test (3.11) (push) Successful in 1m8s
Python CI / test (3.12) (push) Successful in 1m5s
Python CI / test (3.13) (push) Successful in 1m24s
Layout was dominated by work that was either repeated per word or thrown
away. Rendered output is unchanged: every page hashed byte-for-byte
identical across 3 page sizes, 2 font scales, 2 font families, and 4
alignments x 4 column widths chosen to force heavy hyphenation.

  layout, 600x800, 40 pages     ~200ms -> ~124ms
  layout, 1404x1872, 11 pages   ~168ms -> ~111ms

Measured, not guessed. The reflex fix - swapping list comprehensions for
generators - measures slower here (602ns vs 425ns for the width sum), so
those are left alone.

- Line asked its font for the advance width of a space on every
  construction. FreeTypeFont.getlength(" ") costs ~18us, two orders of
  magnitude more than getmetrics(), and it landed once per line. Memoise
  per font object.

- Line.add_word was quadratic in the words on a line. Each candidate word
  re-summed every width and rebuilt the whole per-gap spacing list, when
  fitting only ever reads the first gap. Gap spacings are now a plan
  materialised on demand (only render() reads the list), and widths come
  from a prefix sum. The prefix list, rather than one accumulator, is what
  keeps this exact: widths are floats, (total + w) - w need not give back
  total, and one ulp flips an overflow decision on a line that ends flush.

- RenderingPosition.copy/__eq__/__hash__ all went through
  dataclasses.asdict, a deep recursive walk, over 8 immutable scalars.

- paragraph_layouter built a Text per line purely to discard it.

- AbstractStyle.__hash__ rebuilt a 15-tuple containing 5 enums on every
  dict lookup; memoised on the frozen instance (1037ns -> 160ns).

- The pyphen dictionary wrapper was rebuilt for every word that overflowed
  its line, and word extraction stripped before splitting and tested each
  split result for emptiness, neither of which str.split() needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:27:20 +02:00
dtourolleandClaude Opus 5 745fc8687e ci: run tests in a prebuilt container image
Python CI / test (3.10) (push) Successful in 1m21s
Python CI / test (3.11) (push) Successful in 1m7s
Python CI / test (3.12) (push) Successful in 1m9s
Python CI / test (3.13) (push) Successful in 1m23s
Matches the convention used by pyPhotoAlbum and the other projects here:
runs-on: linux/amd64 with a container image from the Gitea registry,
instead of setup-python plus an ad-hoc `pip install pytest pytest-cov
flake8 coverage-badge interrogate` on a self-hosted runner.

pyWebLayout is a library, so the image carries all four interpreters
pyproject.toml claims to support - 3.10, 3.11, 3.12, 3.13 - each in its
own venv at /opt/py<version> with every dependency pre-installed. The
matrix picks one per job. A CI run now downloads nothing, and coverage
widens from 3.10/3.12/3.13 to the full declared range.

Ubuntu marks its system Python externally-managed, so per-interpreter
venvs are used rather than --break-system-packages; that also keeps the
four dependency sets isolated.

Two defects in the existing workflow are fixed while rewriting it:

- pytest runs under continue-on-error so the badge steps still execute,
  but nothing afterwards checked its outcome - the job reported green on
  a red suite. An explicit gate now fails the job.
- Every matrix leg ran the badge steps and force-pushed the badges
  branch, so three jobs raced to publish. Badges and artifacts are now
  produced by the 3.13 leg only.

setuptools is pinned below 81 in the image: that release dropped
pkg_resources, which coverage-badge imports at startup, and without the
pin the badge step dies with ModuleNotFoundError. Found by running the
workflow's own commands in the image rather than assuming they work.

Also raises the test Flask server's readiness budget from 5s to 30s.
Making that check raise instead of silently falling through (737cf07)
turned runner load into a hard failure; it showed up as 17 spurious
errors in one containerised run and did not reproduce in three repeats.
The loop still exits as soon as the server answers.

Verified locally: image builds, and tests/ passes 916 on each of 3.10,
3.11, 3.12 and 3.13 inside it. The publishing leg was run end to end -
clean-install dependency check, pytest with coverage, both badges,
coverage summary at 81.5%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:10:05 +02:00
dtourolleandClaude Opus 5 3761e00398 docs: record R1-R8 as resolved and add R9
Python CI / test (3.10) (push) Canceled after 0s
Python CI / test (3.12) (push) Canceled after 0s
Python CI / test (3.13) (push) Canceled after 0s
Adds a status table mapping each finding to the commit that resolved it,
and corrects R8's entry: S16 landed anchor replay independently, which is
the design R8 asked for.

Records R9, found while verifying R3. The hit region query_point reports
for a text object is offset from the object's own origin/size by roughly
the font ascent, so probing a LinkText at its own centre returns "empty".
It reproduces at every font scale, so it predates the R3 work, but it
matters more now: R7's highlighting uses those bounds to place overlays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:21:20 +02:00
dtourolleandClaude Opus 5 0ce1aeaa87 feat(ereader): wire pointer interaction into EreaderLayoutManager (R7)
concrete/interaction_handler.py was 310 lines reachable only from
examples/07_pressed_state_demo.py - no library code, no tests. Press and
hover feedback existed but could not be used through the library's own
interface.

Adds to the manager:

    handle_hover(point)        -> frame if hover changed, else None
    handle_touch_down(point)   -> frame showing the pressed state
    handle_touch_up(point)     -> (frame, callback result)
    reset_interaction_state()

Returning None when nothing changed visually lets a UI skip a redraw it
does not need - which matters on e-ink.

Press state belongs to one rendered page, so the InteractionStateManager
is bound lazily and rebound whenever the displayed page changes, resetting
the outgoing one so a press cannot survive a page turn.

Wiring it up immediately surfaced a real bug it had been hiding.
LinkText.render passed [origin, origin + size] - a list of two numpy
arrays - to PIL's draw.rectangle, which needs a flat four-scalar box.
Rendering any hovered or pressed link raised

    TypeError: coordinate list must contain exactly 2 coordinates

so the entire feature was broken on this PIL version. Fixed by building
the box explicitly, and the two branches now share it instead of
duplicating the call.

Tests cover hover/press/release, no-op paths, that an unchanged hover
reports no change, state rebinding across navigation, reset, and
regressions for the rectangle crash.

916 passed. examples/07_pressed_state_demo.py still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:20:40 +02:00
dtourolleandClaude Opus 5 8746d3f549 feat(ereader): wire highlighting into EreaderLayoutManager (R7)
core/highlight.py was 249 lines of fully implemented, fully tested code
that nothing could reach. EreaderLayoutManager had no highlight API, so
highlighting was not available through the library's own interface - it
was tested in isolation and otherwise dead.

Adds to the manager:

    highlight_point(point, color, note, tags)   tap to highlight a word
    highlight_range(start, end, ...)            drag to highlight a span
    remove_highlight(id) / clear_highlights()
    list_highlights() / get_highlights_for_current_page()

Highlights default to the bookmarks directory, so a document's reading
state lives in one place rather than two.

A Highlight carried only pixel bounds, which describe the one rendering
they were taken from: change the font scale or page size and they no
longer point at anything. Highlight now also records the
RenderingPosition of the page it was made on, and page association goes
through that instead of through bounds overlap. The field is optional and
read with .get, so existing stores load unchanged - they simply never
match a page, which is the honest answer for a highlight whose only
anchor is stale pixels.

Also removes the persistence duplication the review called out.
BookmarkManager and HighlightManager each had their own copy of "make the
directory, read the file, swallow and print on failure". Both now use
core/persistence.py, which logs with exc_info instead of printing and
catches specific exceptions rather than bare Exception. File names and
formats are unchanged, so nothing needs migrating.

Tests cover point and range highlighting, colour/note/tag round trips,
misses returning None, page scoping across navigation, persistence across
a restart, and that a corrupt highlight file does not stop a book from
opening.

902 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:18:14 +02:00
dtourolleandClaude Opus 5 bcae45a023 refactor(ereader): drop the estimator helpers S16 superseded (R8)
S16 replaced backward pagination's estimate-render-compare-adjust loop
with anchor replay, but left _estimate_page_start and
_adjust_start_estimate behind. Nothing in the library calls them; only
their own tests did.

_estimate_page_start guessed max(1, int(10 / font_scale)) blocks per
page - a constant with no relationship to page size, block length or
font metrics - and _adjust_start_estimate halved the error each round to
converge on it. Anchor replay makes both meaningless: it walks forward
from a known page boundary instead of guessing at one.

Removes their five tests with them rather than leaving tests pinning
behaviour nothing depends on.

889 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:07:53 +02:00
dtourolleandClaude Opus 5 e81ba48f6d refactor(page): delete the dead child-measurement helpers (R6)
page.py carried a closed cluster of five methods with no callers outside
itself:

    _get_child_property   called only by the four below
    _get_child_height     called by nothing
    _get_child_position   called only by _point_in_child
    _point_in_child       called by nothing
    _get_child_size       called only by _point_in_child

138 lines, verified unreferenced across pyWebLayout/, tests/, examples/
and scripts/.

They existed because Renderable declares no size, so the code probed
_size, size, _height, height, _origin and position in turn with hasattr,
guessing at each child's shape. query_point already does the right thing
instead: it hit-tests through the Queriable interface.

Hardening the Renderable contract so this cannot grow back - Renderable
has origin but no size - belongs with S10.1, which is already going to
revisit the render contract in core/base.py. Left alone here rather than
half-done.

894 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:56:05 +02:00
dtourolleandClaude Opus 5 62ca15159a refactor(ereader): delete the import-time Page monkey patch (R5)
ereader_layout.py defined _add_page_methods() and called it at import
time, attaching can_fit_line and available_width to the Page class if
they were absent. Page defines both, so the patch never fired - but the
two definitions of can_fit_line disagreed:

    Page          can_fit_line(baseline_spacing, ascent=0, descent=0)
    monkey patch  can_fit_line(line_height)

The patched version had no way to express descent, which is exactly the
clipping bug S2 fixed. Had Page.can_fit_line ever been renamed or moved,
this would have silently reinstated pre-S2 behaviour as a side effect of
importing a module in a different package.

Import-time patching of another module's class has no place here. If the
layout engine needs something from Page, it belongs on Page.

Tests pin the outcome: the module exposes no patcher, Page owns both
attributes, and can_fit_line still rejects a line whose descender would
hang past the content box.

894 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:52:38 +02:00
dtourolleandClaude Opus 5 f0dc67541b fix(ereader): keep hyperlinks and nested blocks when font scale changes (R3)
_scale_block_fonts rebuilt a block by constructing a plain
Word(word.text, scaled_style) for every word. LinkedWord is a Word
subclass, so the reconstruction downgraded it and dropped the link
target: every hyperlink in the document disappeared the moment the reader
changed font size. Nothing caught it because the function returns the
block unchanged at scale 1.0 with no family override, which is what the
tests exercised.

Add Word.with_style(), overridden by LinkedWord to carry location, link
type, callback, params and title across. Putting the copy behaviour on
the word class means any future Word subclass either inherits a correct
copy or overrides one, rather than being silently flattened by a
constructor call in the layout engine.

Also extend coverage beyond Paragraph and Heading. Quote, HList and Table
were returned unscaled, so a font-size change left quoted text, list
items and table cells at their original size while the surrounding text
reflowed. Table rows are re-added to the section they came from, so a
<thead> row does not become a body row. Image, HorizontalRule, PageBreak
and CodeBlock still pass through: they carry no styled words.

Scaled blocks are now memoised per (block, scale) for the life of the
layouter. Previously a fresh Paragraph and Word were allocated for every
word on every page render at any scale != 1.0, on the hot path, against
the caching work in concrete/text.py.

Tests cover with_style on both word classes, link survival and target
preservation across all four container types, per-container scaling,
table section preservation, memoisation, and that originals are never
mutated. End-to-end: links remain tappable at 0.8x, 1.5x and 2.0x.

Note: query_point's hit region is offset from LinkText.origin by roughly
the ascent, so probing a link's own centre reports "empty". That
reproduces identically at scale 1.0, predates this change, and is tracked
separately as R9 - the end-to-end test scans instead of probing.

891 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:51:05 +02:00
dtourolleandClaude Opus 5 1924cc234d perf(buffer): remove the process pool from page rendering (S12, R1, R2)
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
PageBuffer started a ProcessPoolExecutor(max_workers=4) and submitted page
renders to it. Every job failed. _render_page_worker returned
pickle.dumps(page), and a Page holds a live PIL canvas, which is not
picklable, so check_completed_renders swallowed a TypeError into a bare
print and cached nothing. The cost was paid in full for zero benefit:
four interpreter copies plus the whole block list shipped per job (~880KB
for a 200-block document).

Three further defects would have had to be fixed before it could ever
have worked: the worker built its BidirectionalLayouter without page_size
so it silently used the (800, 600) default; check_completed_renders
cached every result with is_backward=False, so backward renders landed in
the forward buffer; and both _queue_*_renders broke at the end of their
first loop body, queueing one page each despite looping buffer_size
times.

Two live defects go with it:

  R1 - on Python 3.14 the default start method became forkserver, so
  submit() reaches _check_not_importing_main() and raises unless the
  caller sits inside an `if __name__ == "__main__"` guard.
  EreaderLayoutManager.get_current_page() raised outright from ordinary
  module-level script code.

  R2 - PageBuffer.__del__ called executor.shutdown(wait=True). Blocking
  on a process pool from a finaliser at interpreter teardown deadlocked;
  the test suite finished in 11.5s and then never exited.

S12's measurement gate, on the tests/data Wikipedia fixture (411 blocks)
with text caches warm:

    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 meant to hide it, so the gate says
delete rather than replace. The LRU buffers, position maps and
invalidation logic are kept unchanged; only the executor, worker,
pickling, prefetch queueing and the lock guarding the pending-render dict
are removed. If a slower device ever changes the numbers, the fallback is
a synchronous readahead() method or a single worker thread, not
processes.

EreaderLayoutManager.shutdown() becomes idempotent and its __del__ no
longer propagates exceptions - it was doing bookmark file I/O during
interpreter teardown.

Adds tests/layout/test_page_buffer.py, which the module had none of:
LRU eviction and position-map cleanup, cache hits, font-scale
invalidation, backward round-trip, and subprocess regressions for R1
(no __main__ guard) and R2 (exit without explicit shutdown).

870 passed, and the suite now exits in 12s wall instead of hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:45:20 +02:00
dtourolle 456824d6d6 fix(ereader): replay the page chain instead of searching for it (S16)
render_page_backward searched for the previous page's start: estimate a block
index, lay out forward, bisect on the block difference, up to ten times. Both the
estimator and the adjuster pinned word_index to 0 and moved only block_index -
but pages routinely start mid-block, so the answer was not in the search space.
The loop could never match, exhausted its iterations and fell through to a
fallback that jumped to the start of the document.

Measured on a document with one 1200-word paragraph, whose page starts are
(0,0), (2,208), (2,494), (2,780): three of four backward calls returned (0,0),
each after ten full page layouts. The bisection was unsound in its own space too
- 40 small paragraphs, every page starting on a block boundary, also failed.

Pagination is a pure function, so the page before P is the q with next(q) == P,
and it is found by replaying the chain forward rather than guessing q. Three
sources: the chain recorded as pages are laid out forward (exact, one layout,
covers paging back and forth); replay from the block containing P and then from
earlier blocks (exact when P lies on that chain); and failing that, the last
start before P, which overlaps slightly rather than skipping content.

  warm:                          4/4 exact, 1 layout each
  cold, fresh layouter per call: 12/13 exact, worst 17 layouts
  cold, repeated back presses:   ~4 layouts per turn

Each step returns a page ending exactly where the reader is, so paging back never
skips or repeats content. That chain can differ from the one seen reading forward
from page one if the reader arrived by a jump - pagination from a different start
is a different chain, and nothing can recover the original without replaying the
whole document.

Complementary to S11 rather than caused by it: before S11 forward pagination
dead-ended at the first page-spanning block, so mid-block starts never arose and
the block-granular search looked adequate.
2026-08-08 12:43:30 +02:00
dtourolleandClaude Opus 5 767e4c135c build: consolidate packaging on pyproject.toml and correct the dependency set
The project carried three sets of packaging metadata — pyproject.toml
[project], setup.cfg [options] and setup.py kwargs — declaring different
dependencies. pyproject.toml wins under any modern backend, so the wheel
was correct, but the other two said "Pillow, numpy" and reading either
gave the wrong answer about what the library needs.

Reduce setup.cfg to its [flake8] section and setup.py to a setup() shim,
each pointing at pyproject.toml.

Correct the authoritative list while consolidating:

- flask was a runtime dependency but is imported only by the fixture HTTP
  server in tests. Every user was installing Flask, Jinja2, Werkzeug,
  click, itsdangerous and blinker for nothing.
- ebooklib was a runtime dependency and is never imported by the library;
  epub_reader.py uses zipfile + xml.etree directly. Only the tests use it,
  to build EPUB fixtures.
- requests was declared required but concrete/image.py imports it lazily
  and degrades gracefully when absent, so it belongs in an extra.
- requires-python said >=3.6, which cannot be true: the package uses
  dataclasses and `from __future__ import annotations`, both 3.7+, and CI
  tests 3.10/3.12/3.13.

Runtime install drops from 7 direct dependencies to 4. Adds test,
remote-images and dev extras, and a CI step that installs into an empty
venv with only the runtime deps and imports every subpackage — this class
of defect is only caught by installing what you ship.

Verified: runtime-only install imports all subpackages; `.[test]` runs the
full suite, 853 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:30:08 +02:00
dtourolleandClaude Opus 5 7384a32cdd docs: add architecture review with findings R1-R8
Independent review of the codebase at c5c61a3. Records the architectural
verdict and eight findings not covered by LAYOUT_REMEDIATION_SPEC.md,
cross-referencing the existing spec where they overlap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:21:02 +02:00
dtourolleandClaude Opus 5 4d596ce095 docs: rewrite ARCHITECTURE.md around layers and dependency rules
The previous version described only the abstract/concrete split and had
drifted from the code. Restructure it around the actual layering
(io → abstract → layout → concrete), state the dependency rules that
hold today, and document the layouter contract that makes pagination
resumable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:21:02 +02:00
dtourolleandClaude Opus 5 737cf0771c test(image): bind the fixture Flask server to an ephemeral port
The URL-image tests hard-coded port 5555 and never shut the server down,
so a leftover or concurrent run left the port occupied and the readiness
loop fell through silently — the tests then ran against whatever was
listening, or against nothing.

Use werkzeug's make_server on port 0, record the assigned port, and shut
the server down in tearDownClass. The readiness loop now raises instead
of falling through when the server never comes up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:21:02 +02:00
dtourolle 1985163827 fix(functional): form field labels no longer overprint the field above (S15)
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
FormFieldText treats its origin as the control's top-left - size and in_object
both measure down from it - but drew the label by calling Text.render at that
origin, and Text anchors on the baseline. The label's glyphs therefore landed
above the origin, outside the box the control claims, on top of whatever was
there. In a stacked form that is the preceding field's input box, which is what
example_10_forms.png showed: every label but the first crowding the box above it.

The label is now offset down by its ascent, so it occupies the space the control
accounts for. Height derives from the label's ink height rather than the nominal
font size, which had also eaten into the 5px gap between label and box.

LABEL_GAP names that gap and field_area_offset gives the distance from the origin
to the top of the input box; render, handle_click and the height calculation now
share it instead of each recomputing font_size + 5.

Also recorded under S12: the broken process pool is not merely wasted work. It
forks from a process that already has threads, and
tests/layout/test_ereader_image_rendering.py hangs at interpreter exit roughly
one run in four - every test passes, then the process never returns.
2026-08-06 23:26:03 +02:00
dtourolle c5c61a3503 fix(functional): centre text vertically in buttons and form fields (S14)
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
Both renderers placed the baseline at box_top + height/2 + descent/2. Centring
glyphs of visual height ascent+descent in a box of height H puts the baseline at
box_top + H/2 + (ascent-descent)/2; the two agree only when ascent is exactly
twice descent. DejaVu is nearer 4:1, so labels sat high against the top edge -
measured at 5px above and 11px below for a 14px button.

ButtonText also sized itself from the nominal font size, which is smaller than
the text's visual height (17px of ink for a 14px DejaVu font), leaving the
button too short to centre its label in. It now measures ascent+descent, with a
fallback for font objects that cannot report metrics.

docs/images/example_07_pressed_state.png was stale - no example writes it, the
demo emits demo_07_pressed.png at the repo root and the docs copy had been
placed by hand in November. Refreshed here; the demo should write straight to
docs/images/ so it cannot drift again.
2026-08-06 22:59:27 +02:00
dtourolle 202dacf350 fix(page): separate measurement context from the render canvas (S3)
add_child invalidated the canvas but left _draw bound to it, and the draw
property only rebuilt when _draw was None. Callers therefore got a context
pointing at a discarded image while page._canvas stayed None. table_layouter
reads page._canvas directly, so every image inside a table cell laid out after
any other content silently degraded to a grey [Image: WxH] placeholder.

The property now rebuilds when either half is missing. On its own that would
make layout allocate a full-page RGBA canvas per line, because layout measures
text through the page - so measurement moves to page.measurement_draw, a 1x1
scratch context that is never invalidated. Its mode matches the render canvas
so that Text's width cache does not hold two entries per word.

Children built against the scratch context are re-bound to the live canvas by
render_children, which already synchronised _draw and _canvas; that behaviour
was incidental and is now load-bearing and documented as such.

Regenerating the examples shows table images rendering as images rather than
placeholders. The empty header row in the second table of example 05 is
unrelated and pre-existing - row height ignores cell padding, so text is clipped
as padding grows - recorded as evidence under S6.
2026-08-06 22:37:24 +02:00
dtourolle 284d521125 fix(html): keep inline content inside block containers (S1)
Inline tags map to ignore_handler because they are meant to be consumed by
extract_text_content, but only paragraph_handler and heading_handler ever called
it. Every other container walked its children calling process_element, so inline
tags returned None and their text was dropped:

  <p>hello <b>world</b> again</p>      -> hello world again   (correct)
  <div>hello <b>world</b> again</div>  -> nothing at all
  <li>hello <b>world</b> again</li>    -> nothing at all
  <td>hello <b>world</b> again</td>    -> nothing at all
  <td><a href=u>link</a> text</td>     -> text   (link discarded)

div_handler ignored bare text nodes outright, so a div containing text produced
no blocks whatsoever - which for real HTML and EPUB is most of the document.
Where text did survive, in cells and list items, each text node became its own
paragraph, so "a <b>b</b> c" fragmented onto separate lines.

process_block_children now walks a container's children once, gathering runs of
inline content into a single paragraph and letting block children through to
their own handlers, preserving document order. div, li, td, th and blockquote
all delegate to it, so they gain nested blocks, links and mixed content
together. <br> ends the current run rather than being a no-op.

extract_text_content is split so the run-level logic can be reused without
building a synthetic element: extract_words_from_nodes takes the nodes directly,
and skips comments, which previously had their text extracted as content.

paragraph_handler keeps its own image-splitting path for now; folding it into
process_block_children would also fix the ordering of text around images in a
paragraph, but it carries the EPUB cover-detection behaviour and is left alone.
2026-08-06 22:31:34 +02:00
dtourolle 7bebe08432 Merge layout remediation: pagination dead-end, page padding, text alignment
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
Three fixes from the block/table rendering audit, plus the spec covering the
remaining work.

S11: a paragraph larger than one page dead-ended the reader - the resume
position was discarded, so navigation reported no progress and the book
appeared to end mid-chapter.

S2: horizontal page padding was ignored, so text started flush against the left
border and lines broke short of the right one. Page now describes its content
box directly, and gained an origin so a page can be nested inside another
surface.

S13: ragged alignments stretched their word gaps by a varying amount per line;
justified paragraphs stretched their final line and fell a pixel or two short of
the margin. Body text now defaults to justified, configurable via
PageStyle.default_alignment.
2026-08-06 22:18:32 +02:00
dtourolle 1262be6a38 fix(text): constant word space for ragged alignment, exact justification (S13)
LeftAlignmentHandler spread each line's residual space across its word gaps,
clamped to max_spacing. A line whose residual divided to under max_spacing was
stretched flush, one that exceeded it was not, so left-aligned text was
justified sometimes, by a different amount per line - which reads as a wobbling
right edge rather than as ragged-right. Centre/right did the same, and computed
their start position from a different spacing than the one they returned, so
centred lines were not centred.

Ragged alignments now use a constant word space - the font's own space advance,
clamped to the style's bounds - and report overflow instead of tightening, so
line breaking decides what fits rather than rendering squeezing it.

Justification kept two further defects:

  - the final line of a paragraph was stretched across the measure, so a
    three-word tail was spread edge to edge. Line now carries is_paragraph_end,
    set on the line holding the last word, and renders flush left. A paragraph
    continued on the next page is not marked, so it stays justified.

  - gaps were floored per gap with a truncated remainder, discarding the
    fractional part of both. Lines stopped one or two pixels short, differently
    each time. Distributing by cumulative rounding makes the gaps sum to the
    residual exactly; advance ends now land identically on every line.

Alignment is configurable rather than hardcoded: PageStyle.default_alignment,
defaulting to JUSTIFY for body text. text_align on abstract and concrete styles
defaults to None meaning "unspecified", so HTML without text-align inherits the
page default while explicit CSS still wins. Headings are never justified.
2026-08-06 22:18:04 +02:00
dtourolle f18cec2da8 fix(layout): honour horizontal padding and page origin (S2)
paragraph_layouter placed lines at page.border_size while sizing them to
available_width, which subtracts both paddings. Text therefore started flush
against the left border and the entire padding budget accumulated on the right,
so lines broke well short of the right border.

Page now describes its content box directly - content_origin, content_rect and
remaining_height - and the layouters use it instead of each recomputing the
geometry from border_size. The four block layouters had all been computing
remaining space as size[1] - y_offset - border_size, subtracting the border but
not the bottom padding, so every block type could be placed into the bottom
padding; remaining_height fixes that too.

Page also gains an origin, defaulting to (0, 0). That is inert for a top-level
page but lets a page be positioned inside another surface, which table cells
need in order to be laid out by the normal engine.

Golden images regenerated: content now sits inside the padding on all sides.
2026-08-06 21:11:28 +02:00
dtourolle a57da8011e fix(ereader): keep resume position when a block spans a page (S11)
render_page_forward discarded new_pos on the failure path, but a block that
only partially fitted has still advanced the position: paragraph_layouter
reports the word it stopped at, and _layout_paragraph_on_page packs it into
new_pos. Dropping it told the caller no progress was made, so navigation
dead-ended on any paragraph larger than a single page - the reader saw "end of
document" mid-book.

A 2877-word paragraph at 800x600 rendered 26 lines and reported the start
position back; it now paginates across 12 pages.

Also guard the navigation loop: EreaderManager.next_page treats no-progress as
end-of-document, which is only correct at the actual end. Anywhere else it now
logs the offending block index and skips that block, so a future layout bug
costs one block rather than the rest of the book.
2026-08-06 21:06:29 +02:00
dtourolle 583366ae1d docs: add layout remediation spec
Twelve specs covering the defects found auditing the block/table rendering
path, plus the pagination dead-end and the broken background renderer.

Each spec carries a reproduction of the defect against 2a543d0, a design with
concrete signatures, and acceptance criteria. Five design invariants are stated
up front so future changes can be rejected by reference rather than re-argued.
2026-08-06 21:03:12 +02:00
dtourolleandClaude Opus 5 e000068384 Cache word widths and glyph bitmaps to cut page render time ~3.5x
Rendering a page re-measured and re-rasterised the same words constantly: at
1404x1872 a page issued ~2800 textlength calls and ~2500 draw.text calls for
fewer than 1000 distinct (font, string) pairs. Profiling a page turn showed
FreeType glyph rendering at 57% of total time and width measurement at 38% of
layout.

Both are now cached. Measured on Crime and Punishment at 1404x1872, one page:

    layout  35ms -> 19ms
    render  84ms -> 41ms
    total  120ms -> 60ms

Eviction ranks by use count rather than recency. Word frequency in prose is
Zipfian and stationary, so the words worth keeping are the ones used most, and
unlike recency this lets a document's own frequencies be seeded up front --
see prewarm_caches(). Two details keep the policy from costing more than it
saves, since get() runs once per word drawn:

  - counting is O(1) with no reordering, because structures that reorder on
    every hit measured 3-5ms/page slower than the hit rate they bought;
  - eviction samples 8 entries and drops the least used of those, rather than
    maintaining a global order.

Aging (halving all counts periodically) is on by default. Without it a font
size change drove the hit rate to 0% on a real access trace: every key was new
and the previous size's entries held counts nothing could beat.

Both caches are bounded, since the glyph bitmaps reach ~19MB over a long
session and the target is a 512MB Pi Zero 2. Defaults are 4MB of bitmaps and
8192 widths; configure_text_caches() tunes them. Cache size barely affects
speed (2MB is within 13% of unbounded) because a miss costs only one ~44us
rasterisation, so the bound can be set for memory, not throughput.

EreaderLayoutManager.prewarm_caches() counts the book's word frequencies and
preloads the most common ones, seeding each with its document frequency. This
moves that rasterisation to open time and cut misses by 27%, for ~15% faster
page turns at a one-off ~300ms cost. It is opt-in; nothing calls it yet.

Rendering is no longer bit-identical. PIL positions text at sub-pixel offsets,
so the cache buckets that phase, defaulting to 2 buckets per axis. Total ink
per page is unchanged and the mean pixel difference is 3.6/255 -- a fifth of
one step of a 16-level e-ink panel. subpixel_steps=4 halves that if wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:49:32 +02:00
dtourolle 2a543d0319 reduced redundant code
Python CI / test (3.10) (push) Successful in 2m23s
Python CI / test (3.12) (push) Successful in 2m15s
Python CI / test (3.13) (push) Successful in 2m11s
2025-11-11 21:19:41 +01:00
dtourolle 23d3278b50 update docs and gitignore
Python CI / test (3.10) (push) Successful in 2m23s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m10s
2025-11-11 18:19:49 +01:00
dtourolle 3bcd1bffb5 Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
Python CI / test (3.10) (push) Successful in 2m22s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m9s
2025-11-11 18:10:47 +01:00
dtourolle 889f27e1a3 added fotn change API and examples
Python CI / test (3.10) (push) Successful in 2m18s
Python CI / test (3.12) (push) Successful in 2m8s
Python CI / test (3.13) (push) Successful in 2m6s
2025-11-11 12:44:18 +01:00
dtourolle 9de67d958e cell height now dynamic in tables
Python CI / test (3.10) (push) Successful in 2m16s
Python CI / test (3.12) (push) Successful in 2m8s
Python CI / test (3.13) (push) Successful in 2m2s
2025-11-10 22:06:05 +01:00
dtourolle 41dc904755 fixed issue where last word was counted for spacing
Python CI / test (3.10) (push) Successful in 2m17s
Python CI / test (3.12) (push) Successful in 2m6s
Python CI / test (3.13) (push) Successful in 2m1s
2025-11-10 15:22:18 +01:00
dtourolle 8e720d4037 fix table in cell wrapping
Python CI / test (3.10) (push) Successful in 2m17s
Python CI / test (3.13) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
2025-11-10 15:15:03 +01:00
dtourolle 5afad2ca07 added line wrapping to table 2025-11-10 14:33:30 +01:00
dtourolle 303179865d tests for author names and metadata extraction
Python CI / test (3.10) (push) Successful in 2m16s
Python CI / test (3.12) (push) Successful in 2m7s
Python CI / test (3.13) (push) Successful in 2m2s
2025-11-10 13:54:36 +01:00
dtourolle fb52178cc6 fix for regresssion in fw/bw navigation
Python CI / test (3.10) (push) Successful in 2m15s
Python CI / test (3.12) (push) Successful in 2m7s
Python CI / test (3.13) (push) Successful in 2m0s
2025-11-10 13:25:04 +01:00
dtourolle 890a0e768b fix for reverse redering on restart
Python CI / test (3.10) (push) Successful in 2m10s
Python CI / test (3.12) (push) Successful in 2m2s
Python CI / test (3.13) (push) Successful in 1m56s
2025-11-10 13:17:20 +01:00
dtourolle a8e459bce5 fixed issue with cover and image rendering
Python CI / test (3.10) (push) Successful in 2m10s
Python CI / test (3.12) (push) Successful in 2m3s
Python CI / test (3.13) (push) Successful in 1m57s
2025-11-10 13:06:21 +01:00
dtourolle 9fb6792e10 fix missing images in paras
Python CI / test (3.10) (push) Successful in 2m6s
Python CI / test (3.12) (push) Successful in 1m58s
Python CI / test (3.13) (push) Successful in 1m51s
2025-11-09 22:25:23 +01:00
dtourolle 40c1b913ec doc updates
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
2025-11-09 22:06:42 +01:00
dtourolle cc34c79495 more examples
Python CI / test (3.10) (push) Successful in 2m6s
Python CI / test (3.12) (push) Successful in 1m57s
Python CI / test (3.13) (push) Successful in 1m52s
2025-11-09 21:40:59 +01:00
dtourolle 12ebddaa79 more examples 2025-11-09 21:40:50 +01:00
dtourolle 2b14517344 adding more fonts
Python CI / test (3.10) (push) Successful in 2m2s
Python CI / test (3.12) (push) Successful in 1m52s
Python CI / test (3.13) (push) Successful in 1m47s
2025-11-09 21:17:26 +01:00
dtourolle 849ba2f60f Added press state, fixed font registry
Python CI / test (3.10) (push) Successful in 2m2s
Python CI / test (3.12) (push) Successful in 1m52s
Python CI / test (3.13) (push) Successful in 1m47s
2025-11-09 17:45:53 +01:00
dtourolle 9ae8ddddca fixed example
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
2025-11-09 17:21:17 +01:00
dtourolle 50b9aa5431 fixed issue with bounding box height being wrong
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled
2025-11-09 17:10:50 +01:00
dtourolle 56c2c21021 fix backwards rendering
Python CI / test (3.10) (push) Successful in 7m36s
Python CI / test (3.12) (push) Successful in 7m19s
Python CI / test (3.13) (push) Successful in 7m5s
2025-11-09 15:56:00 +01:00
dtourolle 73700baf87 CI fix
Python CI / test (3.10) (push) Successful in 7m50s
Python CI / test (3.12) (push) Successful in 8m34s
Python CI / test (3.13) (push) Successful in 8m9s
2025-11-09 09:16:10 +01:00
dtourolle 8b833eef0b more fstring fixes
Python CI / test (push) Successful in 6m46s
2025-11-09 00:15:25 +01:00
dtourolle 78745c4e29 more fstring fixes 2025-11-09 00:15:07 +01:00
dtourolle 10612fefae undoing more autoflake8 damage
Python CI / test (push) Failing after 43s
2025-11-09 00:11:48 +01:00
dtourolle ce7293824e fixing unterminated fsting
Python CI / test (push) Failing after 46s
2025-11-09 00:09:04 +01:00
dtourolle 8dce1569c0 fixed typo
Python CI / test (push) Failing after 46s
2025-11-09 00:05:14 +01:00
dtourolle f070121e5c bumpy version
Python CI / test (push) Failing after 45s
2025-11-08 23:59:53 +01:00
dtourolle 4c99282aef setup cfg file
Python CI / test (push) Failing after 46s
2025-11-08 23:46:28 +01:00
dtourolle 781a9b6c08 auto flake and corrections 2025-11-08 23:46:15 +01:00
dtourolle 1ea870eef5 yet more tests 2025-11-08 19:52:19 +01:00
dtourolle 00314c9b4f ADDITIOANL TEST
Python CI / test (push) Successful in 6m49s
2025-11-08 19:39:10 +01:00
dtourolle af18b1794a update readme 2025-11-08 19:25:32 +01:00
dtourolle 13d20c28c5 Api for changing sizes dynamically
Python CI / test (push) Successful in 6m54s
2025-11-08 18:22:58 +01:00
dtourolle f8baf155e9 ereader manager tests 2025-11-08 12:59:57 +01:00
167 changed files with 22217 additions and 5061 deletions
+43
View File
@@ -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
+93 -92
View File
@@ -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
# --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: Download initial failed badges
- 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
$PYBIN/flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Create coverage info directory
if: always()
- 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()
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
+4 -1
View File
@@ -45,9 +45,12 @@ test_output/
examples/output/
# Generated data
bookmarks/
positions/
# Profiling scripts
profile_*.py
# Debug scripts output
debug_*.png
.fish*
+303 -188
View File
@@ -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
+80
View File
@@ -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
+139
View File
@@ -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
![Font Family Switching](docs/images/font_family_switching_vertical.png)
*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).
+113 -8
View File
@@ -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
+595
View File
@@ -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:** R1R7 plus S12 and S10.3 remove roughly 800900 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`) |
| S4S10, 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 956 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≈2039
LinkText claims: y≈3554
```
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.
File diff suppressed because it is too large Load Diff
+116 -3
View File
@@ -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
![Page Navigation](docs/images/ereader_page_navigation.gif)
![Pagination Example](docs/images/example_08_pagination_explicit.png)
```
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">
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+29 -11
View File
@@ -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")
+14 -13
View File
@@ -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
+15 -11
View File
@@ -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")
+18 -12
View File
@@ -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")
+13 -14
View File
@@ -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."""
@@ -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
+452
View File
@@ -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)
+227
View File
@@ -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()
+367
View File
@@ -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()
+390
View File
@@ -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()
+374
View File
@@ -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()
+240
View File
@@ -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()
+312
View File
@@ -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()
+121
View File
@@ -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()
+254
View File
@@ -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()
+291
View File
@@ -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()
+312
View File
@@ -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()
+187 -17
View File
@@ -68,10 +68,10 @@ Demonstrates:
![Table Rendering Example](../docs/images/example_04_table_rendering.png)
### 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
![Table with Images Example](../docs/images/example_05_table_with_images.png)
![Table with Images Example](../docs/images/example_05_html_table_with_images.png)
### 06. Functional Elements (Interactive)
**`06_functional_elements_demo.py`** - Interactive buttons and forms with callbacks
@@ -101,17 +102,139 @@ Demonstrates:
![Functional Elements Example](../docs/images/example_06_functional_elements.png)
## 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
![Button Pressed State Animation](../docs/images/example_07_button_animation.gif)
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
![Bundled Fonts Example](../docs/images/demo_08_bundled_fonts.png)
### 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!
![Pagination Example](../docs/images/example_08_pagination_explicit.png)
### 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!
![Link Navigation Example](../docs/images/example_09_link_navigation.png)
### 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!
![Comprehensive Forms Example](../docs/images/example_10_forms.png)
### 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
![Table Text Wrapping Example](../docs/images/example_11_table_text_wrapping.png)
---
## 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
+252
View File
@@ -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("![Font Family Switching](docs/images/font_family_switching_vertical.png)")
print("```")
print()
+1 -8
View File
@@ -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
+22 -7
View File
@@ -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',
]
+116 -234
View File
@@ -1,4 +1,4 @@
from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
from typing import List, Iterator, Tuple, Dict, Optional, Any
from enum import Enum
import os
import tempfile
@@ -6,8 +6,7 @@ import urllib.request
import urllib.parse
from PIL import Image as PILImage
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..core import Hierarchical, Styleable, FontRegistry
from ..core import Hierarchical, Styleable, FontRegistry, ContainerAware, BlockContainer
class BlockType(Enum):
@@ -51,7 +50,7 @@ class Block(Hierarchical):
return self._block_type
class Paragraph(Styleable, FontRegistry, Block):
class Paragraph(Styleable, FontRegistry, ContainerAware, Block):
"""
A paragraph is a block-level element that contains a sequence of words.
@@ -86,20 +85,15 @@ class Paragraph(Styleable, FontRegistry, Block):
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container)
style = cls._inherit_style(container, style)
# Create the new paragraph
paragraph = cls(style)
# Add the paragraph to the container
if hasattr(container, 'add_block'):
container.add_block(paragraph)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
return paragraph
@@ -216,7 +210,11 @@ class Heading(Paragraph):
self._level = level
@classmethod
def create_and_add_to(cls, container, level: HeadingLevel = HeadingLevel.H1, style=None) -> 'Heading':
def create_and_add_to(
cls,
container,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> 'Heading':
"""
Create a new Heading and add it to a container, inheriting style from
the container if not explicitly provided.
@@ -232,20 +230,15 @@ class Heading(Paragraph):
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container)
style = cls._inherit_style(container, style)
# Create the new heading
heading = cls(level, style)
# Add the heading to the container
if hasattr(container, 'add_block'):
container.add_block(heading)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
return heading
@@ -260,7 +253,7 @@ class Heading(Paragraph):
self._level = level
class Quote(Block):
class Quote(BlockContainer, ContainerAware, Block):
"""
A blockquote element that can contain other block elements.
"""
@@ -273,7 +266,6 @@ class Quote(Block):
style: Optional default style for child blocks
"""
super().__init__(BlockType.QUOTE)
self._blocks: List[Block] = []
self._style = style
@classmethod
@@ -292,20 +284,15 @@ class Quote(Block):
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container)
style = cls._inherit_style(container, style)
# Create the new quote
quote = cls(style)
# Add the quote to the container
if hasattr(container, 'add_block'):
container.add_block(quote)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
return quote
@@ -319,51 +306,6 @@ class Quote(Block):
"""Set the default style for this quote"""
self._style = style
def add_block(self, block: Block):
"""
Add a block element to this quote.
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this quote.
Args:
style: Optional style override. If None, inherits from quote
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
"""
Create a new heading and add it to this quote.
Args:
level: The heading level
style: Optional style override. If None, inherits from quote
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this quote.
Yields:
Each Block in the quote
"""
for block in self._blocks:
yield block
class CodeBlock(Block):
"""
@@ -403,7 +345,9 @@ class CodeBlock(Block):
if hasattr(container, 'add_block'):
container.add_block(code_block)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
raise AttributeError(
f"Container {type(container).__name__} must have an 'add_block' method"
)
return code_block
@@ -449,7 +393,7 @@ class ListStyle(Enum):
DEFINITION = 3 # <dl>
class HList(Block):
class HList(ContainerAware, Block):
"""
An HTML list element (ul, ol, dl).
"""
@@ -468,7 +412,11 @@ class HList(Block):
self._default_style = default_style
@classmethod
def create_and_add_to(cls, container, style: ListStyle = ListStyle.UNORDERED, default_style=None) -> 'HList':
def create_and_add_to(
cls,
container,
style: ListStyle = ListStyle.UNORDERED,
default_style=None) -> 'HList':
"""
Create a new HList and add it to a container, inheriting style from
the container if not explicitly provided.
@@ -484,20 +432,15 @@ class HList(Block):
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Inherit style from container if not provided
if default_style is None and hasattr(container, 'style'):
default_style = container.style
elif default_style is None and hasattr(container, 'default_style'):
default_style = container.default_style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container)
default_style = cls._inherit_style(container, default_style)
# Create the new list
hlist = cls(style, default_style)
# Add the list to the container
if hasattr(container, 'add_block'):
container.add_block(hlist)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
return hlist
@@ -560,7 +503,7 @@ class HList(Block):
return len(self._items)
class ListItem(Block):
class ListItem(BlockContainer, ContainerAware, Block):
"""
A list item element that can contain other block elements.
"""
@@ -574,12 +517,15 @@ class ListItem(Block):
style: Optional default style for child blocks
"""
super().__init__(BlockType.LIST_ITEM)
self._blocks: List[Block] = []
self._term = term
self._style = style
@classmethod
def create_and_add_to(cls, container, term: Optional[str] = None, style=None) -> 'ListItem':
def create_and_add_to(
cls,
container,
term: Optional[str] = None,
style=None) -> 'ListItem':
"""
Create a new ListItem and add it to a container, inheriting style from
the container if not explicitly provided.
@@ -595,20 +541,15 @@ class ListItem(Block):
Raises:
AttributeError: If the container doesn't have the required add_item method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'default_style'):
style = container.default_style
elif style is None and hasattr(container, 'style'):
style = container.style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container, required_method='add_item')
style = cls._inherit_style(container, style)
# Create the new list item
item = cls(term, style)
# Add the list item to the container
if hasattr(container, 'add_item'):
container.add_item(item)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_item' method")
return item
@@ -632,58 +573,18 @@ class ListItem(Block):
"""Set the default style for this list item"""
self._style = style
def add_block(self, block: Block):
"""
Add a block element to this list item.
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this list item.
Args:
style: Optional style override. If None, inherits from list item
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
"""
Create a new heading and add it to this list item.
Args:
level: The heading level
style: Optional style override. If None, inherits from list item
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this list item.
Yields:
Each Block in the list item
"""
for block in self._blocks:
yield block
class TableCell(Block):
class TableCell(BlockContainer, ContainerAware, Block):
"""
A table cell element that can contain other block elements.
"""
def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None):
def __init__(
self,
is_header: bool = False,
colspan: int = 1,
rowspan: int = 1,
style=None):
"""
Initialize a table cell.
@@ -697,7 +598,6 @@ class TableCell(Block):
self._is_header = is_header
self._colspan = colspan
self._rowspan = rowspan
self._blocks: List[Block] = []
self._style = style
@classmethod
@@ -720,18 +620,15 @@ class TableCell(Block):
Raises:
AttributeError: If the container doesn't have the required add_cell method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container, required_method='add_cell')
style = cls._inherit_style(container, style)
# Create the new table cell
cell = cls(is_header, colspan, rowspan, style)
# Add the cell to the container
if hasattr(container, 'add_cell'):
container.add_cell(cell)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_cell' method")
return cell
@@ -775,53 +672,8 @@ class TableCell(Block):
"""Set the default style for this table cell"""
self._style = style
def add_block(self, block: Block):
"""
Add a block element to this cell.
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this table cell.
Args:
style: Optional style override. If None, inherits from cell
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
"""
Create a new heading and add it to this table cell.
Args:
level: The heading level
style: Optional style override. If None, inherits from cell
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this cell.
Yields:
Each Block in the cell
"""
for block in self._blocks:
yield block
class TableRow(Block):
class TableRow(ContainerAware, Block):
"""
A table row element containing table cells.
"""
@@ -838,7 +690,11 @@ class TableRow(Block):
self._style = style
@classmethod
def create_and_add_to(cls, container, section: str = "body", style=None) -> 'TableRow':
def create_and_add_to(
cls,
container,
section: str = "body",
style=None) -> 'TableRow':
"""
Create a new TableRow and add it to a container, inheriting style from
the container if not explicitly provided.
@@ -854,18 +710,15 @@ class TableRow(Block):
Raises:
AttributeError: If the container doesn't have the required add_row method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container, required_method='add_row')
style = cls._inherit_style(container, style)
# Create the new table row
row = cls(style)
# Add the row to the container
if hasattr(container, 'add_row'):
container.add_row(row, section)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_row' method")
return row
@@ -889,7 +742,12 @@ class TableRow(Block):
self._cells.append(cell)
cell.parent = self
def create_cell(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None) -> TableCell:
def create_cell(
self,
is_header: bool = False,
colspan: int = 1,
rowspan: int = 1,
style=None) -> TableCell:
"""
Create a new table cell and add it to this row.
@@ -920,7 +778,7 @@ class TableRow(Block):
return len(self._cells)
class Table(Block):
class Table(ContainerAware, Block):
"""
A table element containing rows and cells.
"""
@@ -941,7 +799,11 @@ class Table(Block):
self._style = style
@classmethod
def create_and_add_to(cls, container, caption: Optional[str] = None, style=None) -> 'Table':
def create_and_add_to(
cls,
container,
caption: Optional[str] = None,
style=None) -> 'Table':
"""
Create a new Table and add it to a container, inheriting style from
the container if not explicitly provided.
@@ -957,20 +819,15 @@ class Table(Block):
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
# Validate container and inherit style using ContainerAware utilities
cls._validate_container(container)
style = cls._inherit_style(container, style)
# Create the new table
table = cls(caption, style)
# Add the table to the container
if hasattr(container, 'add_block'):
container.add_block(table)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
return table
@@ -1084,7 +941,12 @@ class Image(Block):
An image element with source, dimensions, and alternative text.
"""
def __init__(self, source: str = "", alt_text: str = "", width: Optional[int] = None, height: Optional[int] = None):
def __init__(
self,
source: str = "",
alt_text: str = "",
width: Optional[int] = None,
height: Optional[int] = None):
"""
Initialize an image element.
@@ -1101,8 +963,13 @@ class Image(Block):
self._height = height
@classmethod
def create_and_add_to(cls, container, source: str = "", alt_text: str = "",
width: Optional[int] = None, height: Optional[int] = None) -> 'Image':
def create_and_add_to(
cls,
container,
source: str = "",
alt_text: str = "",
width: Optional[int] = None,
height: Optional[int] = None) -> 'Image':
"""
Create a new Image and add it to a container.
@@ -1126,7 +993,9 @@ class Image(Block):
if hasattr(container, 'add_block'):
container.add_block(image)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
raise AttributeError(
f"Container {type(container).__name__} must have an 'add_block' method"
)
return image
@@ -1190,8 +1059,10 @@ class Image(Block):
return self._width / self._height
return None
def calculate_scaled_dimensions(self, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Tuple[Optional[int], Optional[int]]:
def calculate_scaled_dimensions(self,
max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Tuple[Optional[int],
Optional[int]]:
"""
Calculate scaled dimensions that fit within the given constraints.
@@ -1255,19 +1126,21 @@ class Image(Block):
temp_file.write(response.read())
return temp_path
except:
except BaseException:
# Clean up the temporary file if download fails
try:
os.close(temp_fd)
except:
except BaseException:
pass
try:
os.unlink(temp_path)
except:
except BaseException:
pass
raise
def load_image_data(self, auto_update_dimensions: bool = True) -> Tuple[Optional[str], Optional[PILImage.Image]]:
def load_image_data(self,
auto_update_dimensions: bool = True) -> Tuple[Optional[str],
Optional[PILImage.Image]]:
"""
Load image data using PIL, handling both local files and URLs.
@@ -1305,12 +1178,12 @@ class Image(Block):
# Return a copy to avoid issues with the context manager
return file_path, img.copy()
except Exception as e:
except Exception:
# Clean up temporary file on error
if temp_file and os.path.exists(temp_file):
try:
os.unlink(temp_file)
except:
except BaseException:
pass
return None, None
@@ -1348,7 +1221,9 @@ class Image(Block):
# If still no format and we have a URL source, try the original URL
if img_format is None and self._is_url(self._source):
ext = os.path.splitext(urllib.parse.urlparse(self._source).path)[1].lower()
ext = os.path.splitext(
urllib.parse.urlparse(
self._source).path)[1].lower()
img_format = format_map.get(ext)
info = {
@@ -1367,7 +1242,7 @@ class Image(Block):
if file_path and self._is_url(self._source):
try:
os.unlink(file_path)
except:
except BaseException:
pass
return info
@@ -1380,7 +1255,7 @@ class LinkedImage(Image):
def __init__(self, source: str, alt_text: str, location: str,
width: Optional[int] = None, height: Optional[int] = None,
link_type = None,
link_type=None,
callback: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
title: Optional[str] = None):
@@ -1448,7 +1323,10 @@ class LinkedImage(Image):
from pyWebLayout.abstract.functional import LinkType
# Add image info to context
full_context = {**self._params, 'alt_text': self._alt_text, 'source': self._source}
full_context = {
**self._params,
'alt_text': self._alt_text,
'source': self._source}
if context:
full_context.update(context)
@@ -1489,7 +1367,9 @@ class HorizontalRule(Block):
if hasattr(container, 'add_block'):
container.add_block(hr)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
raise AttributeError(
f"Container {type(container).__name__} must have an 'add_block' method"
)
return hr
@@ -1528,6 +1408,8 @@ class PageBreak(Block):
if hasattr(container, 'add_block'):
container.add_block(page_break)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
raise AttributeError(
f"Container {type(container).__name__} must have an 'add_block' method"
)
return page_break
+31 -9
View File
@@ -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.
@@ -68,7 +70,8 @@ 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
@@ -116,7 +119,10 @@ class Document(FontRegistry, MetadataContainer):
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.
@@ -133,7 +139,11 @@ class Document(FontRegistry, MetadataContainer):
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.
@@ -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.
@@ -464,7 +479,10 @@ class Chapter(FontRegistry, MetadataContainer):
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.
@@ -522,7 +540,11 @@ class Book(Document):
"""
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.
+1 -1
View File
@@ -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
+62 -14
View File
@@ -1,15 +1,26 @@
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:
"""
@@ -20,7 +31,13 @@ class Word:
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.
@@ -67,7 +84,8 @@ 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'):
@@ -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,12 +139,12 @@ 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
@@ -153,11 +172,21 @@ class Word:
"""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:
"""
@@ -170,11 +199,11 @@ class Word:
bool: True if the word was hyphenated, False otherwise.
"""
dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text))
...
return list(_hyphen_dict(self._style.language).iterate(self._text))
...
class FormattedSpan:
"""
@@ -195,7 +224,11 @@ class FormattedSpan:
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.
@@ -216,7 +249,8 @@ 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'):
@@ -229,7 +263,8 @@ class FormattedSpan:
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
@@ -337,6 +372,19 @@ class LinkedWord(Word):
"""Get the link title/tooltip"""
return self._title
def with_style(self, style: Font) -> 'LinkedWord':
"""Return a copy carrying a different font, keeping the link intact."""
return LinkedWord(
self._text,
style,
self._location,
link_type=self._link_type,
callback=self._callback,
background=self._background,
params=dict(self._params),
title=self._title,
)
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
+7 -2
View File
@@ -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)
+187
View File
@@ -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$
+67
View File
@@ -0,0 +1,67 @@
[![Build Status](https://travis-ci.org/dejavu-fonts/dejavu-fonts.svg)](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$
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129
View File
@@ -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.
+34 -4
View File
@@ -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',
]
+12 -4
View File
@@ -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._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
+418
View File
@@ -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
+142 -39
View File
@@ -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
@@ -16,7 +16,7 @@ class LinkText(Text, Interactable, Queriable):
"""
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
source=None, line=None, page=None):
"""
Initialize a linkable text object.
@@ -27,13 +27,15 @@ 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:
@@ -45,9 +47,11 @@ class LinkText(Text, Interactable, Queriable):
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Store the link object
# Store the link object and page reference
self._link = link
self._page = page
self._hovered = False
self._pressed = False
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
@@ -61,24 +65,26 @@ class LinkText(Text, Interactable, Queriable):
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
self._mark_page_dirty()
def set_pressed(self, pressed: bool):
"""Set the pressed state for visual feedback"""
self._pressed = pressed
self._mark_page_dirty()
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the link text with optional hover effects.
Render the link text with optional hover and pressed effects.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
# 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
@@ -88,11 +94,28 @@ class LinkText(Text, Interactable, Queriable):
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
origin = np.array(
self._origin) if not isinstance(
self._origin,
np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
# 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)
class ButtonText(Text, Interactable, Queriable):
@@ -103,7 +126,7 @@ class ButtonText(Text, Interactable, Queriable):
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None):
source=None, line=None, page=None):
"""
Initialize a button text object.
@@ -114,6 +137,7 @@ class ButtonText(Text, Interactable, Queriable):
padding: Padding around the button text (top, right, bottom, left)
source: Optional source object
line: Optional line container
page: Optional parent page (for dirty flag management)
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
@@ -124,14 +148,32 @@ class ButtonText(Text, Interactable, Queriable):
# 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:
@@ -146,11 +188,26 @@ class ButtonText(Text, Interactable, Queriable):
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
self._mark_page_dirty()
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
self._mark_page_dirty()
def set_page(self, page):
"""
Set the parent page reference for dirty flag management.
Args:
page: The Page object containing this element
"""
self._page = page
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self):
"""
@@ -200,11 +257,18 @@ class ButtonText(Text, Interactable, Queriable):
# Total button height minus top and bottom padding gives us text area height
text_area_height = self._padded_height - self._padding[0] - self._padding[2]
# Center the text visual height (ascent + descent) within the text area
# The y position is where the baseline sits
# Visual center = area_height/2, baseline should be at center + descent/2
vertical_center = text_area_height / 2
text_y = self._origin[1] + self._padding[0] + vertical_center + (descent / 2)
# Centre the text's visual height (ascent + descent) within the text area.
# text_y is the baseline, since Text renders with anchor "ls".
#
# top of glyphs = area_top + (area_height - (ascent + descent)) / 2
# baseline = top of glyphs + ascent
#
# The previous form, area_top + area_height/2 + descent/2, is only
# equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
# label rendered several pixels above centre, against the top edge.
text_top = self._origin[1] + self._padding[0] \
+ (text_area_height - (ascent + descent)) / 2
text_y = text_top + ascent
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
@@ -238,8 +302,17 @@ class FormFieldText(Text, Interactable, Queriable):
"""
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
The origin is the top-left of the whole control: label, then a gap, then the
input box. Text itself draws from a baseline, so the label is offset down by
its ascent when rendering; without that the glyphs would sit above the origin
and overprint whatever is above, which for a stacked form is the previous
field's input box.
"""
# Vertical gap between the label and its input box, in pixels.
LABEL_GAP = 5
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
@@ -265,14 +338,33 @@ class FormFieldText(Text, Interactable, Queriable):
self._field_height = field_height
self._focused = False
# Calculate total height (label + gap + field)
self._total_height = self._style.font_size + 5 + field_height
# Calculate total height (label + gap + field). The label's height is its
# ink height, ascender to descender, not the nominal font size - the two
# differ by several pixels and the gap between label and box is only 5.
self._label_height = self._visual_label_height()
self._total_height = self._label_height + self.LABEL_GAP + field_height
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
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"""
@@ -291,12 +383,21 @@ class FormFieldText(Text, Interactable, Queriable):
"""
Render the form field with label and input area.
"""
# Render the label
super().render()
# Render the label. Text draws from the baseline, so shift down by the
# ascent to make the origin the top of the label rather than its baseline.
try:
label_ascent = self._style.font.getmetrics()[0]
except (AttributeError, TypeError, ValueError):
label_ascent = self._label_height
# Calculate field position (below label with 5px gap)
label_origin = self._origin
self._origin = np.array([label_origin[0], label_origin[1] + label_ascent])
super().render()
self._origin = label_origin
# Calculate field position (below the label, with the standard gap)
field_x = self._origin[0]
field_y = self._origin[1] + self._style.font_size + 5
field_y = self._origin[1] + self.field_area_offset
# Draw field background and border
bg_color = (255, 255, 255)
@@ -321,11 +422,12 @@ class FormFieldText(Text, Interactable, Queriable):
# Get font metrics to properly center the baseline
ascent, descent = value_font.font.getmetrics()
# Center the text vertically within the field
# The y coordinate is where the baseline sits (anchor="ls")
vertical_center = self._field_height / 2
# Centre the value within the input box. As in ButtonText, the
# baseline sits at the top of the glyphs plus the ascent; centring on
# half the box height plus half the descent only works for a 2:1
# ascent/descent ratio and otherwise rides high.
value_x = field_x + 5
value_y = field_y + vertical_center + (descent / 2)
value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
# Draw the value text
self._draw.text((value_x, value_y), value_text,
@@ -342,7 +444,7 @@ class FormFieldText(Text, Interactable, Queriable):
True if the field was clicked and focused
"""
# Calculate field area
field_y = self._style.font_size + 5
field_y = self.field_area_offset
# Check if click is within the input field area (not just the label)
if (0 <= point[0] <= self._field_width and
@@ -371,7 +473,8 @@ class FormFieldText(Text, Interactable, Queriable):
# 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.
+25 -9
View File
@@ -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
@@ -54,6 +53,9 @@ class RenderableImage(Renderable, Queriable):
if size[0] is None or size[1] is None:
size = (100, 100) # Default size when image dimensions are unavailable
# Ensure dimensions are positive (can be negative if calculated from insufficient space)
size = (max(1, size[0]), max(1, size[1]))
# Set size as numpy array
self._size = np.array(size)
@@ -80,7 +82,9 @@ class RenderableImage(Renderable, Queriable):
"""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
@@ -146,8 +150,14 @@ class RenderableImage(Renderable, Queriable):
# 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()
@@ -165,6 +175,10 @@ class RenderableImage(Renderable, Queriable):
# Get the target dimensions
target_width, target_height = self._size
# Ensure target dimensions are positive
target_width = max(1, int(target_width))
target_height = max(1, int(target_height))
# Get the original dimensions
orig_width, orig_height = self._pil_image.size
@@ -176,15 +190,16 @@ class RenderableImage(Renderable, Queriable):
ratio = min(width_ratio, height_ratio)
# Calculate new dimensions
new_width = int(orig_width * ratio)
new_height = int(orig_height * ratio)
new_width = max(1, int(orig_width * ratio))
new_height = max(1, int(orig_height * ratio))
# Resize the image
if self._pil_image.mode == 'RGBA':
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
@@ -200,7 +215,8 @@ class RenderableImage(Renderable, Queriable):
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)
+310
View File
@@ -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
+106 -118
View File
@@ -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,32 +15,52 @@ 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.
@@ -54,7 +73,8 @@ class Page(Renderable, Queriable):
True if the line fits within page boundaries
"""
# Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
content_y, content_h = self.content_rect[1], self.content_rect[3]
max_y = content_y + content_h
# If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0:
@@ -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]
)
+326 -72
View File
@@ -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,49 +108,138 @@ 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:
# 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 = 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()
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:
@@ -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
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -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',
]
+22 -9
View File
@@ -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,6 +12,7 @@ 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.
@@ -21,16 +20,18 @@ class Renderable(ABC):
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.
@@ -54,17 +55,19 @@ class Interactable(ABC):
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):
@@ -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):
"""
+343
View File
@@ -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
+1 -1
View File
@@ -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
+27 -26
View File
@@ -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.
@@ -177,34 +187,22 @@ class HighlightManager:
def _save_highlights(self) -> None:
"""Persist highlights to disk"""
try:
filepath = self._get_filepath()
data = {
write_json(self._get_filepath(), {
'document_id': self.document_id,
'highlights': [h.to_dict() for h in self.highlights.values()]
}
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error saving highlights: {e}")
})
def _load_highlights(self) -> None:
"""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()
+59
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
"""
# Readers
from pyWebLayout.io.readers.epub_reader import EPUBReader
+79 -25
View File
@@ -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
@@ -61,7 +60,8 @@ class EPUBReader:
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.
@@ -124,10 +124,12 @@ class EPUBReader:
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
@@ -264,14 +266,18 @@ class EPUBReader:
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:
@@ -281,7 +287,10 @@ class EPUBReader:
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
@@ -312,7 +321,8 @@ class EPUBReader:
# 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
@@ -350,10 +360,14 @@ class EPUBReader:
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'])
@@ -388,7 +402,8 @@ class EPUBReader:
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()
@@ -414,7 +429,8 @@ class EPUBReader:
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
@@ -430,28 +446,58 @@ class EPUBReader:
def _process_chapter_images(self, chapter: Chapter):
"""
Process images in a single chapter.
Load and process images in a single chapter.
This method loads images from disk into memory and applies image processing.
Images must be loaded before the temporary EPUB directory is cleaned up.
Args:
chapter: The chapter containing images to process
"""
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image as PILImage
import io
for block in chapter.blocks:
if isinstance(block, AbstractImage):
# Only process if image has been loaded and processor is enabled
if hasattr(block, '_loaded_image') and block._loaded_image:
# Load image into memory if not already loaded
if not hasattr(block, '_loaded_image') or not block._loaded_image:
try:
# Load the image from the source path
if os.path.isfile(block.source):
with open(block.source, 'rb') as f:
image_bytes = f.read()
# Create PIL image from bytes in memory
pil_image = PILImage.open(io.BytesIO(image_bytes))
pil_image.load() # Force loading into memory
block._loaded_image = pil_image.copy() # Create a copy to ensure it persists
# Set width and height on the block from the loaded image
# This is required for layout calculations
block._width = pil_image.width
block._height = pil_image.height
except Exception as e:
print(f"Warning: Failed to load image '{block.source}': {str(e)}")
# Continue without the image
continue
# Apply image processing if enabled and image is loaded
if self.image_processor and hasattr(block, '_loaded_image') and block._loaded_image:
try:
block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e:
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)
@@ -509,8 +555,11 @@ class EPUBReader:
with open(path, 'r', encoding='utf-8') as f:
html = f.read()
# Parse HTML and add blocks to chapter
blocks = parse_html_string(html, document=self.book)
# Get the directory of the HTML file for resolving relative paths
html_dir = os.path.dirname(path)
# Parse HTML and add blocks to chapter, passing base_path for image resolution
blocks = parse_html_string(html, document=self.book, base_path=html_dir)
# Copy blocks to the chapter
for block in blocks:
@@ -521,7 +570,7 @@ class EPUBReader:
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())
+228 -106
View File
@@ -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,14 +287,16 @@ 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
@@ -354,6 +370,24 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
element: BeautifulSoup Tag object
context: Current style context
Returns:
List of Word objects (including LinkedWord for hyperlinks)
"""
return extract_words_from_nodes(list(element.children), context)
def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]:
"""
Extract words from a sequence of sibling nodes.
Separated from extract_text_content so that a container holding a mix of
inline and block children can hand over just the inline runs, without
building a synthetic element to wrap them in.
Args:
nodes: BeautifulSoup nodes (Tags and NavigableStrings) in document order
context: Current style context
Returns:
List of Word objects (including LinkedWord for hyperlinks)
"""
@@ -362,15 +396,20 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
words = []
for child in element.children:
for child in nodes:
# Comments and processing instructions are NavigableString subclasses;
# their text is markup, not content.
if isinstance(child, (Comment, Doctype, CData, ProcessingInstruction)):
continue
if isinstance(child, NavigableString):
# Plain text - split into words
text = str(child).strip()
if text:
word_texts = text.split()
for word_text in word_texts:
if word_text:
words.append(Word(word_text, context.font, context.background))
# Plain text - split into words. Argument-less str.split() already
# discards surrounding whitespace and never yields an empty string, so
# it needs neither a preceding strip() nor a per-word emptiness test.
font = context.font
background = context.background
words.extend([Word(word_text, font, background)
for word_text in str(child).split()])
elif isinstance(child, Tag):
# Special handling for <a> tags (hyperlinks)
if child.name.lower() == "a":
@@ -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:
for block in process_block_children(element, context):
quote.add_block(block)
else:
quote.add_block(result)
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:
for block in process_block_children(element, context):
list_item.add_block(block)
else:
list_item.add_block(result)
elif isinstance(child, NavigableString):
# Direct text in list item - create paragraph
text = str(child).strip()
if text:
paragraph = Paragraph(context.font)
words = text.split()
for word_text in words:
if word_text:
paragraph.add_word(Word(word_text, context.font))
list_item.add_block(paragraph)
return list_item
@@ -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:
for block in process_block_children(element, context):
cell.add_block(block)
else:
cell.add_block(result)
elif isinstance(child, NavigableString):
# Direct text in cell - create paragraph
text = str(child).strip()
if text:
paragraph = Paragraph(context.font)
words = text.split()
for word_text in words:
if word_text:
paragraph.add_word(Word(word_text, context.font))
cell.add_block(paragraph)
return cell
@@ -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:
for block in process_block_children(element, context):
cell.add_block(block)
else:
cell.add_block(result)
elif isinstance(child, NavigableString):
text = str(child).strip()
if text:
paragraph = Paragraph(context.font)
words = text.split()
for word_text in words:
if word_text:
paragraph.add_word(Word(word_text, context.font))
cell.add_block(paragraph)
return cell
@@ -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
+138 -41
View File
@@ -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.
@@ -45,6 +51,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# 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)
@@ -73,7 +88,21 @@ 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:
@@ -81,6 +110,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size:
# 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,
@@ -91,41 +133,45 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
background=font.background
)
# Calculate baseline-to-baseline spacing using line spacing multiplier
# 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
)
@@ -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,7 +267,13 @@ 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
@@ -254,21 +318,27 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
max_width = page.available_width
# Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size
available_height = page.remaining_height
# If no space available, image doesn't fit
if available_height <= 0:
return False
if max_height is None:
max_height = available_height
else:
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
@@ -291,7 +361,10 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
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]))
@@ -525,8 +608,12 @@ class DocumentLayouter:
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.
@@ -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).
+451 -113
View File
@@ -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,13 +35,27 @@ 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)
@@ -56,23 +67,28 @@ class RenderingPosition:
def copy(self) -> 'RenderingPosition':
"""Create a copy of this position"""
return RenderingPosition(**asdict(self))
return RenderingPosition(*self._key())
def __eq__(self, other) -> bool:
"""Check if two positions are equal"""
if not isinstance(other, RenderingPosition):
return False
return asdict(self) == asdict(other)
return self._key() == other._key()
def __hash__(self) -> int:
"""Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values()))
return hash(self._key())
class ChapterInfo:
"""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
@@ -94,6 +110,26 @@ class ChapterNavigator:
"""Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0
# Check if first block is a cover image and add it to TOC
if self.blocks and isinstance(self.blocks[0], Image):
cover_position = RenderingPosition(
chapter_index=0,
block_index=0,
word_index=0,
table_row=0,
table_col=0,
list_item_index=0
)
cover_info = ChapterInfo(
title="Cover",
level=HeadingLevel.H1, # Treat as top-level entry
position=cover_position,
block_index=0
)
self.chapters.append(cover_info)
for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading):
# Create position for this heading
@@ -130,9 +166,11 @@ class ChapterNavigator:
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"""
@@ -159,32 +197,50 @@ class ChapterNavigator:
return self.chapters[0] if self.chapters else None
class FontScaler:
class FontFamilyOverride:
"""
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
Manages font family preferences for ereader rendering.
Allows dynamic font family switching without modifying source blocks.
"""
@staticmethod
def scale_font(font: Font, scale_factor: float) -> Font:
def __init__(self, preferred_family: Optional[BundledFont] = None):
"""
Create a scaled version of a font for layout calculations.
Initialize font family override.
Args:
preferred_family: Preferred bundled font family (None = use original fonts)
"""
self.preferred_family = preferred_family
def override_font(self, font: Font) -> Font:
"""
Create a new font with the preferred family while preserving other attributes.
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
Returns:
New Font object with scaled size
Font with overridden family, or original if no override is set
"""
if scale_factor == 1.0:
if self.preferred_family is None:
return font
scaled_size = max(1, int(font.font_size * scale_factor))
# Get the appropriate font path for the preferred family
# preserving the original font's weight and style
new_font_path = get_bundled_font_path(
family=self.preferred_family,
weight=font.weight,
style=font.style
)
# If we couldn't find a matching font, fall back to original
if new_font_path is None:
return font
# Create a new font with the overridden path
return Font(
font_path=font._font_path,
font_size=scaled_size,
font_path=new_font_path,
font_size=font.font_size,
colour=font.colour,
weight=font.weight,
style=font.style,
@@ -194,8 +250,52 @@ class FontScaler:
min_hyphenation_width=font.min_hyphenation_width
)
class FontScaler:
"""
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
@staticmethod
def scale_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
@@ -213,14 +313,36 @@ class BidirectionalLayouter:
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
self.font_family_override = font_family_override
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
# 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.
@@ -246,12 +368,25 @@ class BidirectionalLayouter:
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
@@ -260,61 +395,249 @@ class BidirectionalLayouter:
current_pos = new_pos
# Remember this link in the chain so stepping back to it later is exact.
if self._position_compare(current_pos, position) > 0:
self._page_chain[(font_scale, self._position_key(current_pos))] = \
position.copy()
return page, current_pos
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
document_start = RenderingPosition()
estimated_start = self._estimate_page_start(end_position, font_scale)
# Nothing precedes the start of the document.
if self._position_compare(end_position, document_start) <= 0:
page, _ = self.render_page_forward(document_start, font_scale)
return page, document_start
# Render forward from estimated start and see if we reach the target
page, actual_end = self.render_page_forward(estimated_start, font_scale)
# 1. The chain we have already walked.
remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
if remembered is not None:
page, actual_end = self.render_page_forward(remembered, font_scale)
if self._position_compare(actual_end, end_position) == 0:
return page, remembered
# If we overshot or undershot, adjust and try again
# This is a simplified implementation - a full version would be more sophisticated
if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified)
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
page, actual_end = self.render_page_forward(estimated_start, font_scale)
# 2/3. Replay from anchors, keeping the best inexact result as a fallback.
fallback = None
for anchor in self._backward_anchors(end_position):
page, start, exact = self._replay_to(anchor, end_position, font_scale)
if page is None:
continue
if exact:
return page, start
if fallback is None:
fallback = (page, start)
return page, estimated_start
if fallback is not None:
return fallback
page, _ = self.render_page_forward(document_start, font_scale)
return page, document_start
def _backward_anchors(self, target: RenderingPosition):
"""
Yield positions to replay from, nearest first.
Block starts are used as anchors because they are the coarsest positions
that are certainly valid to lay out from. The block containing the target
comes first: when the target is mid-block, the page before it usually
starts in that same block or the one before.
"""
first_block = target.block_index if target.word_index > 0 \
else target.block_index - 1
for offset in range(self.MAX_BACKWARD_ANCHORS):
block_index = first_block - offset
if block_index < 0:
break
yield RenderingPosition(
chapter_index=target.chapter_index,
block_index=block_index,
word_index=0,
)
if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
yield RenderingPosition()
def _replay_to(self,
anchor: RenderingPosition,
target: RenderingPosition,
font_scale: float):
"""
Lay out pages forward from `anchor`, looking for the one ending at `target`.
Returns:
(page, start, exact). `exact` is True when a page ended precisely on
the target. When the chain steps over the target instead, the last
page starting before it is returned with exact=False. (None, None,
False) means the anchor yielded nothing usable.
"""
position = anchor
last = (None, None)
for _ in range(self.MAX_REPLAY_PAGES):
if self._position_compare(position, target) >= 0:
break
page, next_position = self.render_page_forward(position, font_scale)
comparison = self._position_compare(next_position, target)
if comparison == 0:
return page, position, True
if comparison > 0:
# Stepped over the target: this chain does not pass through it.
return last[0], last[1], False
if self._position_compare(next_position, position) <= 0:
break # no progress; give up on this anchor
last = (page, position)
position = next_position
return last[0], last[1], False
@staticmethod
def _position_key(position: RenderingPosition) -> Tuple[int, int, int]:
"""Hashable identity of a position, for the page chain map."""
return (position.chapter_index, position.block_index, position.word_index)
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block"""
if font_scale == 1.0:
"""
Apply font scaling and the font family override to every font in a block.
Returns the block unchanged when there is nothing to apply. Results are
memoised per (block, scale) for the life of the layouter, so a page
re-render at an unchanged scale costs a dict lookup.
"""
if font_scale == 1.0 and self.font_family_override is None:
return block
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)):
scaled_block_style = FontScaler.scale_font(block.style, font_scale)
if isinstance(block, Heading):
scaled_block = Heading(block.level, scaled_block_style)
else:
scaled_block = Paragraph(scaled_block_style)
key = (id(block), font_scale)
cached = self._scaled_block_cache.get(key)
if cached is not None:
return cached[1]
# words_iter() returns tuples of (position, word)
for position, word in block.words_iter():
scaled = self._build_scaled_block(block, font_scale)
self._scaled_block_cache[key] = (block, scaled)
return scaled
def _build_scaled_block(self, block: Block, font_scale: float) -> Block:
"""Construct the scaled copy of a block. See _scale_block_fonts."""
def scale(font: Font) -> Font:
return FontScaler.scale_font(font, font_scale, self.font_family_override)
if isinstance(block, (Paragraph, Heading)):
if isinstance(block, Heading):
scaled_block = Heading(block.level, scale(block.style))
else:
scaled_block = Paragraph(scale(block.style))
# words_iter() yields (position, word) tuples. with_style() keeps
# the concrete word class, so a LinkedWord stays linked - rebuilding
# these as plain Words silently stripped every hyperlink in the
# document as soon as the reader changed font size.
for _, word in block.words_iter():
if isinstance(word, Word):
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
scaled_block.add_word(scaled_word)
scaled_block.add_word(word.with_style(scale(word.style)))
return scaled_block
if isinstance(block, Quote):
scaled_quote = Quote(scale(block.style) if block.style else None)
for child in block.blocks():
scaled_quote.add_block(self._scale_block_fonts(child, font_scale))
return scaled_quote
if isinstance(block, HList):
scaled_list = HList(
block.style,
scale(block.default_style) if block.default_style else None)
for item in block.items():
scaled_item = ListItem(
item.term,
scale(item.style) if item.style else None)
for child in item.blocks():
scaled_item.add_block(self._scale_block_fonts(child, font_scale))
scaled_list.add_item(scaled_item)
return scaled_list
if isinstance(block, Table):
scaled_table = Table(
block.caption,
scale(block.style) if block.style else None)
# Rows must go back into the section they came from, or a <thead>
# row would be re-added as a body row.
for section, rows in (('header', block.header_rows()),
('body', block.body_rows()),
('footer', block.footer_rows())):
for row in rows:
scaled_row = TableRow(scale(row.style) if row.style else None)
for cell in row.cells():
scaled_cell = TableCell(
is_header=cell.is_header,
colspan=cell.colspan,
rowspan=cell.rowspan,
style=scale(cell.style) if cell.style else None)
for child in cell.blocks():
scaled_cell.add_block(self._scale_block_fonts(child, font_scale))
scaled_row.add_cell(scaled_cell)
scaled_table.add_row(scaled_row, section)
return scaled_table
# Blocks with no fonts of their own (Image, HorizontalRule, PageBreak,
# CodeBlock - which carries raw lines, not styled words) pass through.
return block
def _layout_block_on_page(self, 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.
@@ -329,13 +652,20 @@ 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.
@@ -397,12 +727,22 @@ class BidirectionalLayouter:
# 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
@@ -412,7 +752,12 @@ class BidirectionalLayouter:
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
@@ -421,32 +766,48 @@ class BidirectionalLayouter:
new_pos.list_item_index = 0
return True, new_pos
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position"""
# This is a simplified heuristic - a full implementation would be more sophisticated
estimated_start = end_position.copy()
def _layout_image_on_page(self,
image: Image,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Layout an image on the page using the image_layouter.
# Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0
Args:
image: The Image block to layout
page: The page to layout on
position: Current rendering position (should be at the start of this image block)
font_scale: Font scaling factor (not used for images, but kept for consistency)
return estimated_start
Returns:
Tuple of (success, new_position)
- success: True if image was laid out, False if page ran out of space
- new_position: Updated position (next block if success, same block if failed)
"""
# Try to layout the image on the current page
success = image_layouter(
image=image,
page=page,
max_width=None, # Use page available width
max_height=None # Use page available height
)
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
"""Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic
adjusted = current_start.copy()
new_pos = position.copy()
comparison = self._position_compare(actual_end, target_end)
if comparison > 0: # Overshot
adjusted.block_index = max(0, adjusted.block_index + 1)
elif comparison < 0: # Undershot
adjusted.block_index = max(0, adjusted.block_index - 1)
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
return adjusted
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
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()
+697 -43
View File
@@ -8,15 +8,23 @@ into a unified, easy-to-use API.
from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable
import json
import os
from pathlib import Path
import logging
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
from .page_buffer import BufferedPageRenderer
from pyWebLayout.abstract.block import Block, HeadingLevel
from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.document_layouter import image_layouter
from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \
create_highlight_from_query_result
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
from pyWebLayout.concrete.interaction_handler import InteractionStateManager
from PIL import Image as Image_
logger = logging.getLogger(__name__)
class BookmarkManager:
@@ -33,8 +41,7 @@ class BookmarkManager:
bookmarks_dir: Directory to store bookmark files
"""
self.document_id = document_id
self.bookmarks_dir = Path(bookmarks_dir)
self.bookmarks_dir.mkdir(exist_ok=True)
self.bookmarks_dir = ensure_dir(bookmarks_dir)
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
@@ -44,29 +51,23 @@ class BookmarkManager:
def _load_bookmarks(self):
"""Load bookmarks from file"""
if self.bookmarks_file.exists():
data = read_json(self.bookmarks_file, {})
try:
with open(self.bookmarks_file, 'r') as f:
data = json.load(f)
self._bookmarks = {
name: RenderingPosition.from_dict(pos_data)
for name, pos_data in data.items()
}
except Exception as e:
print(f"Failed to load bookmarks: {e}")
except (AttributeError, TypeError, KeyError):
logger.warning("Bookmark file %s is not in the expected shape; ignoring it",
self.bookmarks_file, exc_info=True)
self._bookmarks = {}
def _save_bookmarks(self):
"""Save bookmarks to file"""
try:
data = {
write_json(self.bookmarks_file, {
name: position.to_dict()
for name, position in self._bookmarks.items()
}
with open(self.bookmarks_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Failed to save bookmarks: {e}")
})
def add_bookmark(self, name: str, position: RenderingPosition):
"""
@@ -123,11 +124,7 @@ class BookmarkManager:
Args:
position: Current reading position
"""
try:
with open(self.position_file, 'w') as f:
json.dump(position.to_dict(), f, indent=2)
except Exception as e:
print(f"Failed to save reading position: {e}")
write_json(self.position_file, position.to_dict())
def load_reading_position(self) -> Optional[RenderingPosition]:
"""
@@ -136,13 +133,14 @@ class BookmarkManager:
Returns:
Last reading position or None if not found
"""
if self.position_file.exists():
data = read_json(self.position_file, None)
if data is None:
return None
try:
with open(self.position_file, 'r') as f:
data = json.load(f)
return RenderingPosition.from_dict(data)
except Exception as e:
print(f"Failed to load reading position: {e}")
except (TypeError, KeyError):
logger.warning("Position file %s is not in the expected shape; ignoring it",
self.position_file, exc_info=True)
return None
@@ -153,6 +151,7 @@ class EreaderLayoutManager:
Features:
- Sub-second page rendering with intelligent buffering
- Font scaling support
- Dynamic font family switching (Sans, Serif, Monospace)
- Chapter navigation
- Bookmark management
- Position persistence
@@ -165,7 +164,8 @@ class EreaderLayoutManager:
document_id: str = "default",
buffer_size: int = 5,
page_style: Optional[PageStyle] = None,
bookmarks_dir: str = "bookmarks"):
bookmarks_dir: str = "bookmarks",
highlights_dir: Optional[str] = None):
"""
Initialize the ereader layout manager.
@@ -176,6 +176,8 @@ class EreaderLayoutManager:
buffer_size: Number of pages to cache in each direction
page_style: Custom page styling (uses default if None)
bookmarks_dir: Directory to store bookmark files
highlights_dir: Directory to store highlights. Defaults to
bookmarks_dir, so a document's reading state lives in one place.
"""
self.blocks = blocks
self.page_size = page_size
@@ -190,35 +192,180 @@ class EreaderLayoutManager:
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
self.chapter_navigator = ChapterNavigator(blocks)
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
self.highlight_manager = HighlightManager(
document_id, highlights_dir if highlights_dir is not None else bookmarks_dir)
# Current state
self.current_position = RenderingPosition()
self.font_scale = 1.0
# Cover page handling
self._has_cover = self._detect_cover()
self._on_cover_page = self._has_cover # Start on cover if one exists
# Page position history for fast backward navigation
# List of (position, font_scale) tuples representing the start of each page visited
self._page_history: List[Tuple[RenderingPosition, float]] = []
self._max_history_size = 50 # Keep last 50 page positions
# Load last reading position if available
saved_position = self.bookmark_manager.load_reading_position()
if saved_position:
self.current_position = saved_position
self._on_cover_page = False # If we have a saved position, we're past the cover
# Pointer interaction state, rebound whenever the displayed page changes
self._interaction_state_manager: Optional[InteractionStateManager] = None
self._interaction_page: Optional[Page] = None
# Callbacks for UI updates
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
self.position_changed_callback: Optional[Callable[[
RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[
Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
def prewarm_caches(self, max_words: int = 2000,
budget_bytes: Optional[int] = None) -> Tuple[int, int]:
"""
Preload the text caches with this document's most frequent words.
Counts how often each word occurs in the book and rasterises the most
common ones ahead of time, so that the work lands at open time rather than
on the first page turns. Entries are seeded with their document frequency,
which is what keeps them resident under usage-ranked eviction.
Safe to call again after a font change; the fonts differ, so the new
entries simply take their place in the eviction order alongside the old.
Args:
max_words: Maximum distinct words to preload.
budget_bytes: Bytes of glyph cache to fill. Defaults to half the budget.
Returns:
Tuple of (words preloaded, bytes preloaded).
"""
from collections import Counter
from pyWebLayout.concrete.text import prewarm_text_caches
from .ereader_layout import FontScaler
override = getattr(self.renderer.layouter, 'font_family_override', None)
# Count by (style, text): the same word in a heading and in body text is a
# different rasterisation, and both are worth counting separately.
counts: Dict[Tuple[int, str], int] = Counter()
styles: Dict[int, Any] = {}
for block in self.blocks:
words = getattr(block, '_words', None)
if not words:
continue
for word in words:
style = word.style
if style is None:
continue
key = id(style)
styles.setdefault(key, style)
counts[(key, word.text)] += 1
# Resolve each distinct style once through the same scaling the layouter
# applies, so the preloaded keys match what rendering will look up.
scaled: Dict[int, Any] = {}
for key, style in styles.items():
try:
scaled[key] = FontScaler.scale_font(style, self.font_scale, override)
except Exception:
continue
entries = []
for (style_key, text), count in counts.items():
font = scaled.get(style_key)
if font is None:
continue
entries.append((font.font, text, font.colour, count))
return prewarm_text_caches(entries, budget_bytes=budget_bytes,
max_words=max_words)
def set_position_changed_callback(
self, callback: Callable[[RenderingPosition], None]):
"""Set callback for position changes"""
self.position_changed_callback = callback
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
def set_chapter_changed_callback(
self, callback: Callable[[Optional[ChapterInfo]], None]):
"""Set callback for chapter changes"""
self.chapter_changed_callback = callback
def _detect_cover(self) -> bool:
"""
Detect if the document has a cover page.
A cover is detected if:
1. The first block is an Image block, OR
2. The document has cover metadata (future enhancement)
Returns:
True if a cover page should be rendered
"""
if not self.blocks:
return False
# Check if first block is an image - treat it as a cover
first_block = self.blocks[0]
if isinstance(first_block, Image):
return True
return False
def _render_cover_page(self) -> Page:
"""
Render a dedicated cover page.
The cover page displays the first image block (if it exists)
using the standard image layouter with maximum dimensions to fill the page.
Returns:
Rendered cover page
"""
# Create a new page for the cover
page = Page(self.page_size, self.page_style)
if not self.blocks or not isinstance(self.blocks[0], Image):
# No cover image, return blank page
return page
cover_image_block = self.blocks[0]
# Use the image layouter to render the cover image
# Use full page dimensions (minus borders/padding) for cover
try:
max_width = self.page_size[0] - 2 * self.page_style.border_width
max_height = self.page_size[1] - 2 * self.page_style.border_width
# Layout the image on the page
success = image_layouter(
image=cover_image_block,
page=page,
max_width=max_width,
max_height=max_height
)
if not success:
print("Warning: Failed to layout cover image")
except Exception as e:
# If image loading fails, just return the blank page
print(f"Warning: Failed to load cover image: {e}")
return page
def _notify_position_changed(self):
"""Notify UI of position change"""
if self.position_changed_callback:
self.position_changed_callback(self.current_position)
# Check if chapter changed
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
current_chapter = self.chapter_navigator.get_current_chapter(
self.current_position)
if self.chapter_changed_callback:
self.chapter_changed_callback(current_chapter)
@@ -229,9 +376,16 @@ class EreaderLayoutManager:
"""
Get the page at the current reading position.
If on the cover page, returns the rendered cover.
Otherwise, returns the regular content page.
Returns:
Rendered page
"""
# Check if we're on the cover page
if self._on_cover_page and self._has_cover:
return self._render_cover_page()
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
return page
@@ -239,10 +393,28 @@ class EreaderLayoutManager:
"""
Advance to the next page.
If currently on the cover page, advances to the first content page.
Otherwise, advances to the next content page.
Returns:
Next page or None if at end of document
"""
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
# Special case: transitioning from cover to first content page
if self._on_cover_page and self._has_cover:
self._on_cover_page = False
# If first block is an image (the cover), skip it and start from block 1
if self.blocks and isinstance(self.blocks[0], Image):
self.current_position = RenderingPosition(chapter_index=0, block_index=1)
else:
self.current_position = RenderingPosition()
self._notify_position_changed()
return self.get_current_page()
# Save current position to history before moving forward
self._add_to_history(self.current_position, self.font_scale)
page, next_position = self.renderer.render_page(
self.current_position, self.font_scale)
# Check if we made progress
if next_position != self.current_position:
@@ -250,22 +422,71 @@ class EreaderLayoutManager:
self._notify_position_changed()
return self.get_current_page()
# No progress. That is the correct answer only at the end of the
# document; anywhere else a block has failed to lay out and would trap
# the reader on this page. Skipping the block costs one block, not the
# rest of the book.
if self.current_position.block_index < len(self.blocks):
logger.error(
"Block %d made no layout progress; skipping it. This is a layout "
"bug - the block placed nothing and reported no resume point.",
self.current_position.block_index)
self.current_position = RenderingPosition(
chapter_index=self.current_position.chapter_index,
block_index=self.current_position.block_index + 1)
self._notify_position_changed()
return self.get_current_page()
return None # At end of document
def previous_page(self) -> Optional[Page]:
"""
Go to the previous page.
Uses cached page history for instant navigation when available,
falls back to iterative refinement algorithm when needed.
Can navigate back to the cover page if it exists.
Returns:
Previous page or None if at beginning of document
Previous page or None if at beginning of document (or on cover)
"""
# Special case: if at the beginning of content and there's a cover, go back to it
if self._has_cover and self._is_at_beginning() and not self._on_cover_page:
self._on_cover_page = True
# Restore the canonical cover position. Being on the cover must have a
# single representation: a fresh load sits at block 0 with the cover
# showing, so returning to the cover has to land there too. Leaving the
# position at the first content block saves a position that reopens past
# the cover, silently losing it.
self.current_position = RenderingPosition()
self._notify_position_changed()
return self.get_current_page()
# Can't go before the cover
if self._on_cover_page:
return None
if self._is_at_beginning():
return None
# Use backward rendering to find the previous page
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
# Fast path: Check if we have this position in history
previous_position = self._get_from_history(self.current_position, self.font_scale)
if previous_position is not None:
# Cache hit! Use the cached position for instant navigation
self.current_position = previous_position
self._notify_position_changed()
return self.get_current_page()
# Slow path: Use backward rendering to find the previous page
# This uses the iterative refinement algorithm we just fixed
page, start_position = self.renderer.render_page_backward(
self.current_position, self.font_scale)
if start_position != self.current_position:
# Save this calculated position to history for future use
self._add_to_history(start_position, self.font_scale)
self.current_position = start_position
self._notify_position_changed()
return page
@@ -273,9 +494,17 @@ class EreaderLayoutManager:
return None # At beginning of document
def _is_at_beginning(self) -> bool:
"""Check if we're at the beginning of the document"""
"""
Check if we're at the beginning of the document content.
If a cover exists (first block is an Image), the beginning of content
is at block_index=1. Otherwise, it's at block_index=0.
"""
# Determine the first content block index
first_content_block = 1 if (self._has_cover and self.blocks and isinstance(self.blocks[0], Image)) else 0
return (self.current_position.chapter_index == 0 and
self.current_position.block_index == 0 and
self.current_position.block_index == first_content_block and
self.current_position.word_index == 0)
def jump_to_position(self, position: RenderingPosition) -> Page:
@@ -289,6 +518,7 @@ class EreaderLayoutManager:
Page at the new position
"""
self.current_position = position
self._on_cover_page = False # Jumping to a position means we're past the cover
self._notify_position_changed()
return self.get_current_page()
@@ -322,10 +552,75 @@ class EreaderLayoutManager:
return self.jump_to_position(chapters[chapter_index].position)
return None
def _add_to_history(self, position: RenderingPosition, font_scale: float):
"""
Add a page position to the navigation history.
Args:
position: The page start position to remember
font_scale: The font scale at this position
"""
# Only add if it's different from the last entry
if not self._page_history or \
self._page_history[-1][0] != position or \
self._page_history[-1][1] != font_scale:
self._page_history.append((position.copy(), font_scale))
# Trim history if it exceeds max size
if len(self._page_history) > self._max_history_size:
self._page_history.pop(0)
def _get_from_history(
self,
current_position: RenderingPosition,
current_font_scale: float) -> Optional[RenderingPosition]:
"""
Get the previous page position from history.
Searches backward through history to find the last position that
comes before the current position at the same font scale.
Args:
current_position: Current page position
current_font_scale: Current font scale
Returns:
Previous page position or None if not found in history
"""
# Search backward through history
for i in range(len(self._page_history) - 1, -1, -1):
hist_position, hist_font_scale = self._page_history[i]
# Must match font scale
if hist_font_scale != current_font_scale:
continue
# Must be before current position
if (hist_position.chapter_index < current_position.chapter_index or
(hist_position.chapter_index == current_position.chapter_index and
hist_position.block_index < current_position.block_index) or
(hist_position.chapter_index == current_position.chapter_index and
hist_position.block_index == current_position.block_index and
hist_position.word_index < current_position.word_index)):
# Found a previous position - remove it and everything after from history
# since we're navigating backward
self._page_history = self._page_history[:i]
return hist_position.copy()
return None
def _clear_history(self):
"""Clear the page navigation history."""
self._page_history.clear()
def set_font_scale(self, scale: float) -> Page:
"""
Change the font scale and re-render current page.
Clears page history since font changes invalidate all cached positions.
Args:
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
@@ -334,6 +629,8 @@ class EreaderLayoutManager:
"""
if scale != self.font_scale:
self.font_scale = scale
# Clear history since font scale changes invalidate all cached positions
self._clear_history()
# The renderer will handle cache invalidation
return self.get_current_page()
@@ -342,7 +639,154 @@ class EreaderLayoutManager:
"""Get the current font scale"""
return self.font_scale
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
def set_font_family(self, family: Optional[BundledFont]) -> Page:
"""
Change the font family and re-render current page.
Switches all text in the document to use the specified bundled font family
while preserving font weights, styles, sizes, and other attributes.
Clears page history and cache since font changes invalidate all cached positions.
Args:
family: Bundled font family to use (SANS, SERIF, MONOSPACE, or None for original fonts)
Returns:
Re-rendered page with new font family
Example:
>>> from pyWebLayout.style.fonts import BundledFont
>>> manager.set_font_family(BundledFont.SERIF) # Switch to serif
>>> manager.set_font_family(BundledFont.SANS) # Switch to sans
>>> manager.set_font_family(None) # Restore original fonts
"""
# Update the renderer's font family
self.renderer.set_font_family(family)
# Clear history since font changes invalidate all cached positions
self._clear_history()
return self.get_current_page()
def get_font_family(self) -> Optional[BundledFont]:
"""
Get the current font family override.
Returns:
Current font family (SANS, SERIF, MONOSPACE) or None if using original fonts
"""
return self.renderer.get_font_family()
def increase_line_spacing(self, amount: int = 2) -> Page:
"""
Increase line spacing and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to add to line spacing (default: 2)
Returns:
Re-rendered page with increased line spacing
"""
self.page_style.line_spacing += amount
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def decrease_line_spacing(self, amount: int = 2) -> Page:
"""
Decrease line spacing and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to remove from line spacing (default: 2)
Returns:
Re-rendered page with decreased line spacing
"""
self.page_style.line_spacing = max(0, self.page_style.line_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def increase_inter_block_spacing(self, amount: int = 5) -> Page:
"""
Increase spacing between blocks and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to add to inter-block spacing (default: 5)
Returns:
Re-rendered page with increased block spacing
"""
self.page_style.inter_block_spacing += amount
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def decrease_inter_block_spacing(self, amount: int = 5) -> Page:
"""
Decrease spacing between blocks and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to remove from inter-block spacing (default: 5)
Returns:
Re-rendered page with decreased block spacing
"""
self.page_style.inter_block_spacing = max(
0, self.page_style.inter_block_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def increase_word_spacing(self, amount: int = 2) -> Page:
"""
Increase spacing between words and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to add to word spacing (default: 2)
Returns:
Re-rendered page with increased word spacing
"""
self.page_style.word_spacing += amount
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def decrease_word_spacing(self, amount: int = 2) -> Page:
"""
Decrease spacing between words and re-render current page.
Clears page history since spacing changes invalidate all cached positions.
Args:
amount: Pixels to remove from word spacing (default: 2)
Returns:
Re-rendered page with decreased word spacing
"""
self.page_style.word_spacing = max(0, self.page_style.word_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
self._clear_history() # Clear position history
return self.get_current_page()
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""
Get the table of contents.
@@ -412,6 +856,165 @@ class EreaderLayoutManager:
"""
return self.bookmark_manager.list_bookmarks()
# ------------------------------------------------------------------
# Highlights
#
# A Highlight carries pixel bounds, which belong to the one rendering it
# was taken from: change the font scale or page size and they no longer
# describe anything. Each highlight therefore also records the
# RenderingPosition of the page it was made on, and page association goes
# through that rather than through the bounds.
# ------------------------------------------------------------------
def highlight_point(self,
point: Tuple[int, int],
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[Highlight]:
"""
Highlight whatever is at a point on the current page.
Args:
point: (x, y) in page coordinates, as delivered by a tap
color: RGBA fill, e.g. one of HighlightColor
note: Optional annotation
tags: Optional categorization tags
Returns:
The stored Highlight, or None if nothing was at that point.
"""
result = self.get_current_page().query_point(point)
if result is None or result.object_type == "empty":
return None
return self._store_highlight(result, color, note, tags)
def highlight_range(self,
start: Tuple[int, int],
end: Tuple[int, int],
color: Tuple[int, int, int, int] = HighlightColor.YELLOW.value,
note: Optional[str] = None,
tags: Optional[List[str]] = None) -> Optional[Highlight]:
"""
Highlight the text between two points on the current page.
Args:
start: (x, y) where the selection began
end: (x, y) where the selection ended
color: RGBA fill, e.g. one of HighlightColor
note: Optional annotation
tags: Optional categorization tags
Returns:
The stored Highlight, or None if the range selected no text.
"""
selection = self.get_current_page().query_range(start, end)
if not selection.results:
return None
return self._store_highlight(selection, color, note, tags)
def _store_highlight(self, result, color, note, tags) -> Highlight:
"""Build a Highlight from a query result and persist it."""
highlight = create_highlight_from_query_result(
result, color=color, note=note, tags=tags,
position=self.current_position.to_dict())
self.highlight_manager.add_highlight(highlight)
return highlight
def remove_highlight(self, highlight_id: str) -> bool:
"""
Remove a highlight.
Args:
highlight_id: ID of the highlight to remove
Returns:
True if it existed and was removed
"""
return self.highlight_manager.remove_highlight(highlight_id)
def list_highlights(self) -> List[Highlight]:
"""Get every highlight in this document."""
return self.highlight_manager.list_highlights()
def get_highlights_for_current_page(self) -> List[Highlight]:
"""
Get the highlights made on the page currently being displayed.
Matched on the recorded RenderingPosition, so this stays correct across
font changes; highlights saved before the position field existed have
no position and are never matched.
"""
current = self.current_position.to_dict()
return [h for h in self.highlight_manager.list_highlights()
if h.position == current]
def clear_highlights(self) -> None:
"""Remove every highlight in this document."""
self.highlight_manager.clear_all()
# ------------------------------------------------------------------
# Pointer interaction
#
# Press/hover feedback is state that belongs to one rendered page, so the
# state machine is rebound whenever the displayed page changes. Callers get
# a fresh frame back when something changed visually, and None when nothing
# did - so a UI can skip a redraw it does not need.
# ------------------------------------------------------------------
def _interaction_state(self) -> InteractionStateManager:
"""The state machine for the page currently displayed."""
page = self.get_current_page()
if self._interaction_page is not page:
if self._interaction_state_manager is not None:
self._interaction_state_manager.reset()
self._interaction_state_manager = InteractionStateManager(page)
self._interaction_page = page
return self._interaction_state_manager
def handle_hover(self, point: Tuple[int, int]) -> Optional[Image_.Image]:
"""
Update hover feedback for a pointer at `point`.
Args:
point: (x, y) in page coordinates
Returns:
A re-rendered frame if the hover state changed, else None.
"""
return self._interaction_state().update_hover(point)
def handle_touch_down(self, point: Tuple[int, int]) -> Optional[Image_.Image]:
"""
Show pressed feedback for whatever interactive element is at `point`.
Args:
point: (x, y) in page coordinates
Returns:
A frame showing the pressed state, or None if nothing interactive
is there.
"""
return self._interaction_state().handle_mouse_down(point)
def handle_touch_up(self, point: Tuple[int, int]) -> Tuple[Optional[Image_.Image], Any]:
"""
Release the pressed element and run its action.
Args:
point: (x, y) in page coordinates
Returns:
(frame, callback_result). Both are None if no element was pressed.
"""
return self._interaction_state().handle_mouse_up(point)
def reset_interaction_state(self) -> None:
"""Clear any hover or press feedback, e.g. when the pointer leaves."""
if self._interaction_state_manager is not None:
self._interaction_state_manager.reset()
def get_reading_progress(self) -> float:
"""
Get reading progress as a percentage.
@@ -429,6 +1032,38 @@ class EreaderLayoutManager:
return current_block / max(1, total_blocks - 1)
def has_cover(self) -> bool:
"""
Check if the document has a cover page.
Returns:
True if a cover page is available
"""
return self._has_cover
def is_on_cover(self) -> bool:
"""
Check if currently viewing the cover page.
Returns:
True if on the cover page
"""
return self._on_cover_page
def jump_to_cover(self) -> Optional[Page]:
"""
Jump to the cover page if one exists.
Returns:
Cover page or None if no cover exists
"""
if not self._has_cover:
return None
self._on_cover_page = True
self._notify_position_changed()
return self.get_current_page()
def get_position_info(self) -> Dict[str, Any]:
"""
Get detailed information about the current position.
@@ -437,9 +1072,12 @@ class EreaderLayoutManager:
Dictionary with position details
"""
current_chapter = self.get_current_chapter()
font_family = self.get_font_family()
return {
'position': self.current_position.to_dict(),
'on_cover': self._on_cover_page,
'has_cover': self._has_cover,
'chapter': {
'title': current_chapter.title if current_chapter else None,
'level': current_chapter.level if current_chapter else None,
@@ -447,6 +1085,7 @@ class EreaderLayoutManager:
},
'progress': self.get_reading_progress(),
'font_scale': self.font_scale,
'font_family': font_family.value if font_family else None,
'page_size': self.page_size
}
@@ -463,16 +1102,31 @@ class EreaderLayoutManager:
"""
Shutdown the ereader manager and clean up resources.
Call this when the application is closing.
Idempotent: calling it twice saves the position once.
"""
if getattr(self, '_shutdown_done', False):
return
self._shutdown_done = True
# Save current position
self.bookmark_manager.save_reading_position(self.current_position)
# Shutdown renderer and buffer
# Release cached pages
self.renderer.shutdown()
def __del__(self):
"""Cleanup on destruction"""
"""
Best-effort cleanup for callers that never called shutdown().
Finalisers run during interpreter teardown, when modules and globals
may already be torn down, so this must never raise and must never
block. Applications should call shutdown() explicitly.
"""
try:
self.shutdown()
except Exception:
pass
# Convenience function for quick setup
+133 -200
View File
@@ -1,87 +1,80 @@
"""
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
self.current_font_family: Optional[BundledFont] = None
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
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.
@@ -89,14 +82,12 @@ class PageBuffer:
blocks: Document blocks to render
page_style: Page styling configuration
font_scale: Current font scaling factor
font_family: Optional font family override
"""
self.blocks = blocks
self.page_style = page_style
self.current_font_scale = font_scale
# Start the process pool
if self.executor is None:
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
self.current_font_family = font_family
def get_page(self, position: RenderingPosition) -> Optional[Page]:
"""
@@ -124,7 +115,12 @@ class PageBuffer:
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.
@@ -153,104 +149,8 @@ class PageBuffer:
self.position_map.pop(oldest_pos, None)
self.reverse_position_map.pop(oldest_pos, None)
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
"""
Start background rendering of upcoming pages.
Args:
current_position: Current reading position
direction: 'forward', 'backward', or 'both'
"""
if not self.blocks or not self.page_style or not self.executor:
return
with self.render_lock:
if direction in ['forward', 'both']:
self._queue_forward_renders(current_position)
if direction in ['backward', 'both']:
self._queue_backward_renders(current_position)
def _queue_forward_renders(self, start_position: RenderingPosition):
"""Queue forward page renders starting from the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
# Try to get next position from cache
current_pos = self.position_map.get(current_pos)
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the next position yet, so we'll update it when the render completes
break
def _queue_backward_renders(self, start_position: RenderingPosition):
"""Queue backward page renders ending at the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
# Try to get previous position from cache
current_pos = self.reverse_position_map.get(current_pos)
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the previous position yet, so we'll update it when the render completes
break
def check_completed_renders(self):
"""Check for completed background renders and cache the results"""
if not self.pending_renders:
return
completed = []
with self.render_lock:
for position, future in self.pending_renders.items():
if future.done():
try:
original_pos, pickled_page, next_pos = future.result()
# Deserialize the page
page = pickle.loads(pickled_page)
# Cache the page
self.cache_page(original_pos, page, next_pos, is_backward=False)
completed.append(position)
except Exception as e:
print(f"Background render failed for position {position}: {e}")
completed.append(position)
# Remove completed renders
for pos in completed:
self.pending_renders.pop(pos, None)
def invalidate_all(self):
"""Clear all cached pages and cancel pending renders"""
with self.render_lock:
# Cancel pending renders
for future in self.pending_renders.values():
future.cancel()
self.pending_renders.clear()
# Clear caches
"""Clear all cached pages"""
self.forward_buffer.clear()
self.backward_buffer.clear()
self.position_map.clear()
@@ -267,43 +167,53 @@ class PageBuffer:
self.current_font_scale = font_scale
self.invalidate_all()
def set_font_family(self, font_family: Optional[BundledFont]):
"""
Update font family and invalidate cache.
Args:
font_family: New font family (None = use original fonts)
"""
if font_family != self.current_font_family:
self.current_font_family = font_family
self.invalidate_all()
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics for debugging/monitoring"""
return {
'forward_buffer_size': len(self.forward_buffer),
'backward_buffer_size': len(self.backward_buffer),
'pending_renders': len(self.pending_renders),
'position_mappings': len(self.position_map),
'reverse_position_mappings': len(self.reverse_position_map),
'current_font_scale': self.current_font_scale
'current_font_scale': self.current_font_scale,
'current_font_family': self.current_font_family.value if self.current_font_family else None
}
def shutdown(self):
"""Shutdown the page buffer and clean up resources"""
if self.executor:
# Cancel pending renders
with self.render_lock:
for future in self.pending_renders.values():
future.cancel()
"""
Release cached pages.
# Shutdown executor
self.executor.shutdown(wait=True)
self.executor = None
# Clear all caches
Cheap and idempotent. There is deliberately no __del__ calling this:
blocking work in a finaliser is what deadlocked the interpreter at exit
while the process pool existed.
"""
self.invalidate_all()
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
class BufferedPageRenderer:
"""
High-level interface for buffered page rendering with automatic background caching.
High-level interface for page rendering with an LRU cache in front of the
layouter.
"""
def __init__(self, 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.
@@ -312,17 +222,26 @@ class BufferedPageRenderer:
page_style: Page styling configuration
buffer_size: Number of pages to cache in each direction
page_size: Page size (width, height) in pixels
font_family: Optional font family override
"""
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
# Create font family override if specified
font_family_override = FontFamilyOverride(font_family) if font_family else None
self.layouter = BidirectionalLayouter(blocks, page_style, page_size, font_family_override=font_family_override)
self.buffer = PageBuffer(buffer_size)
self.buffer.initialize(blocks, page_style)
self.buffer.initialize(blocks, page_style, font_family=font_family)
self.page_size = page_size
self.blocks = blocks
self.page_style = page_style
self.current_position = RenderingPosition()
self.font_scale = 1.0
self.font_family = font_family
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
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
@@ -339,12 +258,10 @@ class BufferedPageRenderer:
# Check cache first
cached_page = self.buffer.get_page(position)
if cached_page:
# Get next position from position map
next_pos = self.buffer.position_map.get(position, position)
# Start background rendering for upcoming pages
self.buffer.start_background_rendering(position, 'forward')
# 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
@@ -353,17 +270,15 @@ class BufferedPageRenderer:
# Cache the result
self.buffer.cache_page(position, page, next_pos)
# Start background rendering
self.buffer.start_background_rendering(position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, next_pos
def render_page_backward(self, 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
@@ -380,12 +295,10 @@ class BufferedPageRenderer:
# Check cache first
cached_page = self.buffer.get_page(end_position)
if cached_page:
# Get previous position from reverse position map
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
# Start background rendering for previous pages
self.buffer.start_background_rendering(end_position, 'backward')
# 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
@@ -394,18 +307,38 @@ class BufferedPageRenderer:
# Cache the result
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
# Start background rendering
self.buffer.start_background_rendering(end_position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, start_pos
def set_font_family(self, font_family: Optional[BundledFont]):
"""
Change the font family and invalidate cache.
Args:
font_family: New font family (None = use original fonts)
"""
if font_family != self.font_family:
self.font_family = font_family
# Update buffer
self.buffer.set_font_family(font_family)
# Recreate layouter with new font family override
font_family_override = FontFamilyOverride(font_family) if font_family else None
self.layouter = BidirectionalLayouter(
self.blocks,
self.page_style,
self.page_size,
font_family_override=font_family_override
)
def get_font_family(self) -> Optional[BundledFont]:
"""Get the current font family override"""
return self.font_family
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
return self.buffer.get_cache_stats()
def shutdown(self):
"""Shutdown the renderer and clean up resources"""
"""Release cached pages"""
self.buffer.shutdown()
+396
View File
@@ -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
+5 -2
View File
@@ -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"
]
+24 -6
View File
@@ -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
@@ -50,7 +51,6 @@ class FontSize(Enum):
# Import Alignment from the centralized location
from .alignment import Alignment
# Use Alignment for text alignment
TextAlign = Alignment
@@ -81,7 +81,8 @@ class AbstractStyle:
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
# Text properties
text_align: TextAlign = TextAlign.LEFT
# None means "not specified": the page's default_alignment applies.
text_align: Optional[TextAlign] = None
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
word_spacing: Optional[Union[str, float]] = None
@@ -111,7 +112,17 @@ class AbstractStyle:
Since this is a frozen dataclass, it should be hashable by default,
but we provide a custom implementation to ensure all fields are
properly considered and to handle the Union types correctly.
The result is memoised on first use. Styles are used as dictionary keys
throughout parsing and style resolution, and five of the fields are enum
members whose own __hash__ is a Python-level call, so rebuilding the
15-tuple on every lookup was a measurable share of document parsing. The
class is frozen, so the value cannot go stale.
"""
cached = self.__dict__.get('_hash_cache')
if cached is not None:
return cached
# Convert all values to hashable forms
hashable_values = (
self.font_family,
@@ -131,7 +142,9 @@ class AbstractStyle:
self.parent_style_id
)
return hash(hashable_values)
result = hash(hashable_values)
object.__setattr__(self, '_hash_cache', result)
return result
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
"""
@@ -192,7 +205,8 @@ class AbstractStyleRegistry:
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
@@ -229,7 +243,10 @@ class AbstractStyleRegistry:
"""
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.
@@ -288,7 +305,8 @@ class AbstractStyleRegistry:
"""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.
+1
View File
@@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum
class Alignment(Enum):
"""Text and box alignment options"""
# Horizontal alignment
+29 -13
View File
@@ -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)
@@ -62,7 +61,8 @@ class ConcreteStyle:
decoration: TextDecoration = TextDecoration.NONE
# Layout properties
text_align: TextAlign = TextAlign.LEFT
# None means "not specified": the page's default_alignment applies.
text_align: Optional[TextAlign] = None
line_height: float = 1.0 # Multiplier
letter_spacing: float = 0.0 # In pixels
word_spacing: float = 0.0 # In pixels
@@ -162,12 +162,17 @@ class StyleResolver:
# 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
@@ -251,7 +256,8 @@ class StyleResolver:
# 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
@@ -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)
@@ -293,7 +299,15 @@ class StyleResolver:
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
@@ -330,7 +344,8 @@ class StyleResolver:
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
@@ -353,7 +368,8 @@ class StyleResolver:
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
+238 -55
View File
@@ -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,6 +36,105 @@ class TextDecoration(Enum):
STRIKETHROUGH = "strikethrough"
class BundledFont(Enum):
"""Bundled font families available in pyWebLayout"""
SANS = "sans" # DejaVu Sans - modern sans-serif
SERIF = "serif" # DejaVu Serif - classic serif
MONOSPACE = "monospace" # DejaVu Sans Mono - fixed-width
def get_bundled_fonts_dir():
"""
Get the directory containing bundled fonts (cached).
Returns:
str: Path to the fonts directory, or None if not found
"""
global _BUNDLED_FONTS_DIR
# Return cached path if available
if _BUNDLED_FONTS_DIR is not None:
return _BUNDLED_FONTS_DIR
# First time - determine the path and cache it
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
if os.path.exists(fonts_dir) and os.path.isdir(fonts_dir):
_BUNDLED_FONTS_DIR = fonts_dir
logger.debug(f"Found bundled fonts directory at: {fonts_dir}")
return fonts_dir
else:
logger.warning(f"Bundled fonts directory not found at: {fonts_dir}")
_BUNDLED_FONTS_DIR = "" # Empty string to indicate "checked but not found"
return None
def get_bundled_font_path(
family: BundledFont = BundledFont.SANS,
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL
) -> Optional[str]:
"""
Get the path to a specific bundled font file.
Args:
family: The font family (SANS, SERIF, or MONOSPACE)
weight: The font weight (NORMAL or BOLD)
style: The font style (NORMAL or ITALIC)
Returns:
str: Full path to the font file, or None if not found
Example:
>>> # Get bold italic sans font
>>> path = get_bundled_font_path(BundledFont.SANS, FontWeight.BOLD, FontStyle.ITALIC)
>>> font = Font(font_path=path, font_size=16)
"""
fonts_dir = get_bundled_fonts_dir()
if not fonts_dir:
return None
# Map font parameters to filename
family_map = {
BundledFont.SANS: "DejaVuSans",
BundledFont.SERIF: "DejaVuSerif",
BundledFont.MONOSPACE: "DejaVuSansMono"
}
base_name = family_map.get(family, "DejaVuSans")
# Build the font file name
parts = [base_name]
if weight == FontWeight.BOLD and style == FontStyle.ITALIC:
# Special case: both bold and italic
if family == BundledFont.MONOSPACE:
parts.append("BoldOblique")
elif family == BundledFont.SERIF:
parts.append("BoldItalic")
else: # SANS
parts.append("BoldOblique")
elif weight == FontWeight.BOLD:
parts.append("Bold")
elif style == FontStyle.ITALIC:
# Italic naming differs by family
if family == BundledFont.MONOSPACE or family == BundledFont.SANS:
parts.append("Oblique")
else: # SERIF
parts.append("Italic")
filename = "-".join(parts) + ".ttf"
font_path = os.path.join(fonts_dir, filename)
if os.path.exists(font_path):
logger.debug(f"Found bundled font: {filename}")
return font_path
else:
logger.warning(f"Bundled font not found: {filename}")
return None
class Font:
"""
Font class to manage text rendering properties including font face, size, color, and styling.
@@ -39,13 +149,13 @@ class Font:
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).
@@ -68,8 +178,66 @@ class Font:
# Load the font file or use default
self._load_font()
@classmethod
def from_family(cls,
family: BundledFont = BundledFont.SANS,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> 'Font':
"""
Create a Font using a bundled font family.
This is a convenient way to use the bundled DejaVu fonts without needing to
specify paths manually.
Args:
family: The font family to use (SANS, SERIF, or MONOSPACE)
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object configured with the bundled font
Example:
>>> # Create a bold serif font
>>> font = Font.from_family(BundledFont.SERIF, font_size=18, weight=FontWeight.BOLD)
>>>
>>> # Create an italic monospace font
>>> code_font = Font.from_family(BundledFont.MONOSPACE, style=FontStyle.ITALIC)
"""
font_path = get_bundled_font_path(family, weight, style)
return cls(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
def _get_bundled_font_path(self):
"""Get the path to the bundled font"""
"""Get the path to the bundled font (cached)"""
global _BUNDLED_FONT_PATH
# Return cached path if available
if _BUNDLED_FONT_PATH is not None:
return _BUNDLED_FONT_PATH
# First time - determine the path and cache it
# Get the directory containing this module
current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate to the assets/fonts directory
@@ -79,17 +247,37 @@ class Font:
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
@@ -106,16 +294,25 @@ class Font:
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):
@@ -162,62 +359,48 @@ class Font:
"""Get the minimum width required for hyphenation to be considered"""
return self._min_hyphenation_width
def _with_modified(self, **kwargs):
"""
Internal helper to create a new Font with modified parameters.
This consolidates the duplication across all with_* methods.
Args:
**kwargs: Parameters to override (e.g., font_size=20, colour=(255,0,0))
Returns:
New Font object with modified parameters
"""
params = {
'font_path': self._font_path,
'font_size': self._font_size,
'colour': self._colour,
'weight': self._weight,
'style': self._style,
'decoration': self._decoration,
'background': self._background,
'language': self.language,
'min_hyphenation_width': self._min_hyphenation_width
}
params.update(kwargs)
return Font(**params)
def with_size(self, size: int):
"""Create a new Font object with modified size"""
return Font(
self._font_path,
size,
self._colour,
self._weight,
self._style,
self._decoration,
self._background
)
return self._with_modified(font_size=size)
def with_colour(self, colour: Tuple[int, int, int]):
"""Create a new Font object with modified colour"""
return Font(
self._font_path,
self._font_size,
colour,
self._weight,
self._style,
self._decoration,
self._background
)
return self._with_modified(colour=colour)
def with_weight(self, weight: FontWeight):
"""Create a new Font object with modified weight"""
return Font(
self._font_path,
self._font_size,
self._colour,
weight,
self._style,
self._decoration,
self._background
)
return self._with_modified(weight=weight)
def with_style(self, style: FontStyle):
"""Create a new Font object with modified style"""
return Font(
self._font_path,
self._font_size,
self._colour,
self._weight,
style,
self._decoration,
self._background
)
return self._with_modified(style=style)
def with_decoration(self, decoration: TextDecoration):
"""Create a new Font object with modified decoration"""
return Font(
self._font_path,
self._font_size,
self._colour,
self._weight,
self._style,
decoration,
self._background
)
return self._with_modified(decoration=decoration)
+12 -7
View File
@@ -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,13 +10,18 @@ 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)
@@ -25,7 +31,6 @@ class PageStyle:
# 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:
+37 -5
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More