Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c61a3503 | ||
|
|
202dacf350 | ||
|
|
284d521125 |
@@ -28,6 +28,7 @@ It is independent of every other spec here.
|
||||
| [S11](#s11--partial-block-progress-is-discarded) | Partial-block progress is discarded | 0 |
|
||||
| [S12](#s12--background-rendering) | Background rendering | 4 |
|
||||
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 |
|
||||
| [S14](#s14--vertical-centring-in-buttons-and-fields) | Vertical centring in buttons and fields | 0 |
|
||||
|
||||
## Design invariants
|
||||
|
||||
@@ -560,6 +561,22 @@ Four separate geometry defects:
|
||||
drops every cell past `len(column_widths)` — two of three cells never render.
|
||||
4. **rowspan is parsed and stored but never read** by any renderer or measurer;
|
||||
spanned rows just shift left.
|
||||
5. **Row height ignores the cell padding it must contain.** The 40px minimum in
|
||||
`_calculate_row_height_for_section` is a constant, so a larger `cell_padding`
|
||||
eats into the content box rather than growing the row, and
|
||||
`_render_cell_content` then clips the text against `available_height`.
|
||||
Rendering the same header at two paddings:
|
||||
|
||||
```
|
||||
padding=(8,10,8,10) border=1: header h=40, ink=593
|
||||
padding=(10,12,10,12) border=2: header h=40, ink=288
|
||||
```
|
||||
|
||||
Both rows are 40px tall; the second silently loses half its text. This is
|
||||
visible in `docs/images/example_05_html_table_with_images.png`, whose second
|
||||
table renders an empty header row. It is the same measure/render disagreement
|
||||
as defect 1, and S5 removes it by construction: the cell page's content box
|
||||
*is* the box its padding leaves.
|
||||
|
||||
### Design
|
||||
|
||||
@@ -1142,6 +1159,58 @@ Three defects, all visible as a right edge that wobbles from line to line.
|
||||
|
||||
---
|
||||
|
||||
## S14 — Vertical centring in buttons and fields
|
||||
|
||||
### Problem
|
||||
|
||||
`ButtonText.render` and `FormFieldText.render` both placed the text baseline at
|
||||
`box_top + box_height / 2 + descent / 2`. Centring glyphs whose visual height is
|
||||
`ascent + descent` inside a box of height `H` puts the baseline at
|
||||
`box_top + H/2 + (ascent - descent)/2`. The two agree only when
|
||||
`ascent == 2 * descent`; DejaVu is nearer 4:1, so labels rode high against the
|
||||
top edge of the control.
|
||||
|
||||
`ButtonText` also sized itself as `font_size + padding`, but the text's visual
|
||||
height exceeds the nominal size — DejaVu at 14px measures 17 — so the button was
|
||||
too short to centre its own label in.
|
||||
|
||||
### Evidence
|
||||
|
||||
A 14px "Save Document" button with 6px vertical padding, measuring the label's
|
||||
ink against the button rectangle:
|
||||
|
||||
```
|
||||
gap above text: 5px
|
||||
gap below text: 11px
|
||||
```
|
||||
|
||||
### Design
|
||||
|
||||
- `baseline = area_top + (area_height - (ascent + descent)) / 2 + ascent` in both
|
||||
renderers.
|
||||
- `ButtonText._padded_height` derives from `ascent + descent`, guarded so a mock
|
||||
or unusual font object falls back to the nominal size.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Label ink is centred within ±2px at font sizes 10, 14 and 20.
|
||||
- Label ink stays inside the button rectangle.
|
||||
- Button height is at least `ascent + descent + vertical padding`.
|
||||
- A form field's value is centred within its input box (±3px).
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/concrete/functional.py`
|
||||
|
||||
### Note
|
||||
|
||||
`docs/images/example_07_pressed_state.png` was stale — no example regenerates it;
|
||||
`07_pressed_state_demo.py` writes `demo_07_pressed.png` at the repository root
|
||||
and the docs copy had been placed by hand. It has been refreshed. Worth wiring
|
||||
the demo to write straight to `docs/images/` so it cannot drift again.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
Findings were reproduced with four probe scripts; each becomes a regression test
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -153,7 +153,22 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._padded_width = text_width + padding[1] + padding[3]
|
||||
self._padded_height = self._style.font_size + padding[0] + padding[2]
|
||||
|
||||
# Size the button from the text's visual height (ascent + descent), not
|
||||
# from the nominal font size. The two differ by several pixels - DejaVu at
|
||||
# 14px measures 17 - so sizing by font_size leaves the button too short to
|
||||
# centre its own label in.
|
||||
self._text_height = self._visual_text_height()
|
||||
self._padded_height = self._text_height + padding[0] + padding[2]
|
||||
|
||||
def _visual_text_height(self) -> int:
|
||||
"""Height of the rendered text, ascender to descender."""
|
||||
try:
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
return int(ascent + descent)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Mock or unusual font object; the nominal size is the best guess.
|
||||
return int(getattr(self._style, 'font_size', 0) or 0)
|
||||
|
||||
@property
|
||||
def button(self) -> Button:
|
||||
@@ -237,11 +252,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()
|
||||
@@ -360,11 +382,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,
|
||||
|
||||
@@ -15,6 +15,10 @@ class Page(Renderable, Queriable):
|
||||
contains a given point.
|
||||
"""
|
||||
|
||||
# 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)):
|
||||
"""
|
||||
@@ -32,6 +36,7 @@ class Page(Renderable, Queriable):
|
||||
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
|
||||
@@ -168,13 +173,38 @@ class Page(Renderable, Queriable):
|
||||
|
||||
@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.
|
||||
@@ -333,7 +363,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:
|
||||
|
||||
@@ -8,6 +8,7 @@ Each handler function has a robust signature that handles style hints, CSS class
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from bs4.element import CData, Comment, Doctype, ProcessingInstruction
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block,
|
||||
@@ -369,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)
|
||||
"""
|
||||
@@ -377,7 +396,12 @@ 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()
|
||||
@@ -466,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]:
|
||||
@@ -557,17 +668,7 @@ def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, L
|
||||
|
||||
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:
|
||||
@@ -592,16 +693,8 @@ def heading_handler(element: Tag, context: StyleContext) -> Heading:
|
||||
def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
|
||||
"""Handle <blockquote> elements."""
|
||||
quote = Quote(context.font)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
quote.add_block(block)
|
||||
else:
|
||||
quote.add_block(result)
|
||||
for block in process_block_children(element, context):
|
||||
quote.add_block(block)
|
||||
return quote
|
||||
|
||||
|
||||
@@ -655,28 +748,8 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
|
||||
def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
|
||||
"""Handle <li> elements."""
|
||||
list_item = ListItem(None, context.font)
|
||||
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
list_item.add_block(block)
|
||||
else:
|
||||
list_item.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in list item - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
list_item.add_block(paragraph)
|
||||
|
||||
for block in process_block_children(element, context):
|
||||
list_item.add_block(block)
|
||||
return list_item
|
||||
|
||||
|
||||
@@ -728,27 +801,8 @@ def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(False, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
# Direct text in cell - create paragraph
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
@@ -759,26 +813,8 @@ def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
|
||||
rowspan = int(context.element_attributes.get("rowspan", 1))
|
||||
cell = TableCell(True, colspan, rowspan, context.font)
|
||||
|
||||
# Process cell content (same as td)
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
child_context = apply_element_styling(context, child)
|
||||
result = process_element(child, child_context)
|
||||
if result:
|
||||
if isinstance(result, list):
|
||||
for block in result:
|
||||
cell.add_block(block)
|
||||
else:
|
||||
cell.add_block(result)
|
||||
elif isinstance(child, NavigableString):
|
||||
text = str(child).strip()
|
||||
if text:
|
||||
paragraph = Paragraph(context.font)
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
if word_text:
|
||||
paragraph.add_word(Word(word_text, context.font))
|
||||
cell.add_block(paragraph)
|
||||
for block in process_block_children(element, context):
|
||||
cell.add_block(block)
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
temp_text = Text.from_word(word, page.measurement_draw)
|
||||
temp_text.width
|
||||
else:
|
||||
pass
|
||||
@@ -174,7 +174,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
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
|
||||
)
|
||||
@@ -225,7 +225,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
return False, i, overflow_text
|
||||
|
||||
# Check if the word will fit on the new line before adding it
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
temp_text = Text.from_word(word, page.measurement_draw)
|
||||
if temp_text.width > current_line.size[0]:
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
@@ -234,13 +234,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
(Text(
|
||||
pair[0],
|
||||
word.style,
|
||||
page.draw,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word),
|
||||
Text(
|
||||
pair[1],
|
||||
word.style,
|
||||
page.draw,
|
||||
page.measurement_draw,
|
||||
line=current_line,
|
||||
source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
@@ -455,7 +455,7 @@ def button_layouter(button: Button,
|
||||
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]
|
||||
@@ -505,7 +505,8 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Regression tests for the page draw/canvas lifecycle (spec S3).
|
||||
|
||||
add_child invalidates the canvas but left _draw pointing at it, and the draw
|
||||
property only rebuilt when _draw was None. Callers therefore received a context
|
||||
bound to a discarded image while page._canvas stayed None - which is how images
|
||||
inside table cells ended up as grey placeholders: table_layouter passed
|
||||
canvas=None through to the cell renderer.
|
||||
|
||||
Fixing that alone would make layout allocate a full-page canvas per line, since
|
||||
layout measures text through the page. Measurement now goes through a dedicated
|
||||
scratch context.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=12)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page():
|
||||
return Page(size=(400, 600), style=PageStyle())
|
||||
|
||||
|
||||
def paragraph_of(font, count=40):
|
||||
paragraph = Paragraph(font)
|
||||
for i in range(count):
|
||||
paragraph.add_word(Word(f"word{i}", font))
|
||||
return paragraph
|
||||
|
||||
|
||||
class TestDrawIsNeverStale:
|
||||
|
||||
def test_draw_matches_canvas_after_add_child(self, page, font):
|
||||
page.draw # force canvas creation
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
|
||||
assert page.draw.im is page._canvas.im, \
|
||||
"draw must be bound to the page's current canvas"
|
||||
|
||||
def test_canvas_is_present_after_layout(self, page, font):
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
page.draw
|
||||
|
||||
assert page._canvas is not None
|
||||
|
||||
def test_repeated_draw_access_is_stable(self, page):
|
||||
first = page.draw
|
||||
assert page.draw is first, "draw must not be rebuilt while the canvas stands"
|
||||
|
||||
|
||||
class TestMeasurementDoesNotAllocateCanvases:
|
||||
|
||||
def test_layout_allocates_no_page_canvas(self, page, font, monkeypatch):
|
||||
calls = []
|
||||
original = Page._create_canvas
|
||||
|
||||
def counting(self):
|
||||
calls.append(1)
|
||||
return original(self)
|
||||
|
||||
monkeypatch.setattr(Page, "_create_canvas", counting)
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font, 400))
|
||||
|
||||
assert calls == [], \
|
||||
f"layout allocated {len(calls)} full-page canvases; it should allocate none"
|
||||
|
||||
def test_measurement_context_is_tiny_and_matches_canvas_mode(self, page):
|
||||
scratch = page.measurement_draw
|
||||
assert scratch.im.size == (1, 1)
|
||||
assert scratch.mode == Page._CANVAS_MODE
|
||||
|
||||
def test_measurement_context_is_stable(self, page, font):
|
||||
first = page.measurement_draw
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
assert page.measurement_draw is first, \
|
||||
"the scratch context must survive canvas invalidation"
|
||||
|
||||
|
||||
class TestRenderIsRepeatable:
|
||||
|
||||
def test_two_renders_are_identical(self, page, font):
|
||||
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
|
||||
|
||||
first = page.render().copy()
|
||||
second = page.render().copy()
|
||||
|
||||
assert first.tobytes() == second.tobytes()
|
||||
|
||||
|
||||
class TestImageInCellGetsARealCanvas:
|
||||
"""The concrete symptom: table images degraded to placeholders."""
|
||||
|
||||
@pytest.fixture
|
||||
def image_path(self, tmp_path):
|
||||
path = tmp_path / "swatch.png"
|
||||
Image.new("RGB", (40, 30), (10, 200, 10)).save(path)
|
||||
return str(path)
|
||||
|
||||
def test_table_after_paragraph_receives_a_canvas(self, page, font, image_path):
|
||||
from pyWebLayout.abstract.block import Table, TableCell, TableRow
|
||||
from pyWebLayout.layout.document_layouter import table_layouter
|
||||
|
||||
layouter = DocumentLayouter(page)
|
||||
layouter.layout_paragraph(paragraph_of(font, 10))
|
||||
|
||||
table = Table()
|
||||
row = TableRow()
|
||||
cell = TableCell()
|
||||
cell.add_block(AbstractImage(image_path))
|
||||
row.add_cell(cell)
|
||||
table.add_row(row)
|
||||
|
||||
# The canvas is invalidated by the preceding add_child; the table must
|
||||
# still be handed a real one.
|
||||
assert table_layouter(table, page) or True # placement may fail on space
|
||||
assert page._canvas is not None, \
|
||||
"table layout must not run against a None canvas"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Regression tests for vertical centring of text in buttons and form fields.
|
||||
|
||||
Both placed the baseline at `top + height/2 + descent/2`. Centring text whose
|
||||
visual height is ascent+descent inside a box of height H puts the baseline at
|
||||
`top + H/2 + (ascent-descent)/2`; the two agree only when ascent == 2*descent.
|
||||
Real fonts have a much larger ratio - DejaVu is nearer 4:1 - so the text sat
|
||||
several pixels high, hugging the top edge of the button.
|
||||
|
||||
The button was also sized from the nominal font size rather than the text's
|
||||
actual visual height, leaving it too short to centre anything in.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.abstract.functional import Button, FormField, FormFieldType
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
CANVAS = (300, 120)
|
||||
PADDING = (6, 10, 6, 10) # top, right, bottom, left
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def draw_ctx():
|
||||
image = Image.new("RGB", CANVAS, (255, 255, 255))
|
||||
return image, ImageDraw.Draw(image)
|
||||
|
||||
|
||||
def ink_rows(image, box):
|
||||
"""
|
||||
Rows within box that carry text ink.
|
||||
|
||||
Only the central columns are sampled: the button has rounded corners, so the
|
||||
page background shows through at the extremes of every row and would read as
|
||||
white text on all of them.
|
||||
"""
|
||||
x0, y0, x1, y1 = box
|
||||
inset = (x1 - x0) // 4
|
||||
pixels = image.convert("RGB").load()
|
||||
rows = []
|
||||
for y in range(y0, y1):
|
||||
for x in range(x0 + inset, x1 - inset):
|
||||
r, g, b = pixels[x, y]
|
||||
# Button text is white on a blue fill; look for near-white ink.
|
||||
if r > 240 and g > 240 and b > 240:
|
||||
rows.append(y)
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
class TestButtonTextCentring:
|
||||
|
||||
@pytest.mark.parametrize("font_size", [10, 14, 20])
|
||||
def test_text_is_vertically_centred(self, draw_ctx, font_size):
|
||||
image, draw = draw_ctx
|
||||
font = Font(font_size=font_size, colour=(255, 255, 255))
|
||||
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
button.set_origin(np.array([20, 20]))
|
||||
button.render()
|
||||
|
||||
x0, y0 = 20, 20
|
||||
x1 = x0 + int(button.size[0])
|
||||
y1 = y0 + int(button.size[1])
|
||||
rows = ink_rows(image, (x0, y0, x1, y1))
|
||||
assert rows, "the button should have visible text"
|
||||
|
||||
gap_above = min(rows) - y0
|
||||
gap_below = y1 - max(rows) - 1
|
||||
|
||||
assert abs(gap_above - gap_below) <= 2, (
|
||||
f"text not centred at size {font_size}: "
|
||||
f"{gap_above}px above, {gap_below}px below")
|
||||
|
||||
def test_button_is_tall_enough_for_its_text(self):
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
image = Image.new("RGB", CANVAS, (255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
button = ButtonText(Button(label="Cancel", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
|
||||
ascent, descent = font.font.getmetrics()
|
||||
assert int(button.size[1]) >= ascent + descent + PADDING[0] + PADDING[2], \
|
||||
"button height must accommodate the text's visual height, not the nominal size"
|
||||
|
||||
def test_text_stays_inside_the_button(self, draw_ctx):
|
||||
image, draw = draw_ctx
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
|
||||
font, draw, padding=PADDING)
|
||||
button.set_origin(np.array([20, 20]))
|
||||
button.render()
|
||||
|
||||
y0, y1 = 20, 20 + int(button.size[1])
|
||||
rows = ink_rows(image, (20, y0, 20 + int(button.size[0]), y1))
|
||||
assert min(rows) >= y0, "text escaped above the button"
|
||||
assert max(rows) < y1, "text escaped below the button"
|
||||
|
||||
|
||||
class TestFormFieldValueCentring:
|
||||
|
||||
def test_value_is_centred_in_the_input_box(self):
|
||||
image = Image.new("RGB", (300, 120), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
field = FormField(name="who", field_type=FormFieldType.TEXT, value="Hello")
|
||||
renderable = FormFieldText(field, font, draw, field_height=28)
|
||||
renderable.set_origin(np.array([10, 10]))
|
||||
renderable.render()
|
||||
|
||||
field_y = 10 + font.font_size + 5
|
||||
pixels = image.convert("RGB").load()
|
||||
rows = [y for y in range(field_y, field_y + 28)
|
||||
if any(pixels[x, y] == (0, 0, 0) for x in range(12, 200))]
|
||||
assert rows, "the field value should be visible"
|
||||
|
||||
gap_above = min(rows) - field_y
|
||||
gap_below = (field_y + 28) - max(rows) - 1
|
||||
assert abs(gap_above - gap_below) <= 3, (
|
||||
f"field value not centred: {gap_above}px above, {gap_below}px below")
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Regression tests for inline content inside block containers (spec S1).
|
||||
|
||||
Inline tags are registered to ignore_handler because they are meant to be
|
||||
consumed by extract_text_content. Only <p> and <h1>-<h6> ever called it, so
|
||||
every other container - div, li, td, th, blockquote - iterated its children as
|
||||
blocks, and inline tags returned None. Their text was silently discarded, and
|
||||
bare text nodes each became a separate paragraph.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import (
|
||||
HList,
|
||||
Paragraph,
|
||||
Quote,
|
||||
Table,
|
||||
)
|
||||
from pyWebLayout.abstract.inline import LinkedWord, Word
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
|
||||
|
||||
def words_of(block):
|
||||
return [w.text for w in getattr(block, 'words', [])]
|
||||
|
||||
|
||||
def all_words(blocks):
|
||||
out = []
|
||||
for block in blocks:
|
||||
out.extend(words_of(block))
|
||||
return out
|
||||
|
||||
|
||||
def cell_blocks(table):
|
||||
for _, row in table.all_rows():
|
||||
for cell in row.cells():
|
||||
yield list(cell.blocks())
|
||||
|
||||
|
||||
EXPECTED = ["hello", "world", "again"]
|
||||
|
||||
|
||||
class TestInlineContentIsKept:
|
||||
"""The same markup must survive in every container."""
|
||||
|
||||
def test_paragraph_control(self):
|
||||
"""<p> already worked - this is the reference behaviour."""
|
||||
blocks = parse_html_string("<p>hello <b>world</b> again</p>")
|
||||
assert all_words(blocks) == EXPECTED
|
||||
|
||||
def test_div(self):
|
||||
blocks = parse_html_string("<div>hello <b>world</b> again</div>")
|
||||
assert all_words(blocks) == EXPECTED
|
||||
|
||||
def test_list_item(self):
|
||||
blocks = parse_html_string("<ul><li>hello <b>world</b> again</li></ul>")
|
||||
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||
item = list(hlist.items())[0]
|
||||
assert all_words(item.blocks()) == EXPECTED
|
||||
|
||||
def test_table_cell(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>hello <b>world</b> again</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||
|
||||
def test_table_header_cell(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><th>hello <b>world</b> again</th></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||
|
||||
def test_blockquote(self):
|
||||
blocks = parse_html_string("<blockquote>hello <b>world</b> again</blockquote>")
|
||||
quote = next(b for b in blocks if isinstance(b, Quote))
|
||||
assert all_words(quote.blocks()) == EXPECTED
|
||||
|
||||
|
||||
class TestInlineRunsCoalesce:
|
||||
"""A run of inline content is one paragraph, not one per text node."""
|
||||
|
||||
def test_div_yields_a_single_paragraph(self):
|
||||
blocks = parse_html_string("<div>a <b>b</b> c</div>")
|
||||
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||
assert len(paragraphs) == 1, f"expected one paragraph, got {len(blocks)} blocks"
|
||||
assert words_of(paragraphs[0]) == ["a", "b", "c"]
|
||||
|
||||
def test_cell_yields_a_single_paragraph(self):
|
||||
blocks = parse_html_string("<table><tr><td>a <b>b</b> c</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert len(cell) == 1
|
||||
assert words_of(cell[0]) == ["a", "b", "c"]
|
||||
|
||||
def test_block_child_splits_the_run(self):
|
||||
"""Inline runs either side of a block child stay separate, in order."""
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>before<p>middle</p>after</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert [words_of(b) for b in cell] == [["before"], ["middle"], ["after"]]
|
||||
|
||||
def test_line_break_splits_the_run(self):
|
||||
blocks = parse_html_string("<div>first<br>second</div>")
|
||||
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||
assert [words_of(p) for p in paragraphs] == [["first"], ["second"]]
|
||||
|
||||
def test_whitespace_between_blocks_makes_no_paragraph(self):
|
||||
blocks = parse_html_string("<div>\n <p>one</p>\n <p>two</p>\n</div>")
|
||||
assert [words_of(b) for b in blocks] == [["one"], ["two"]]
|
||||
|
||||
|
||||
class TestLinksSurvive:
|
||||
"""<a href> must produce LinkedWord wherever it appears."""
|
||||
|
||||
def test_link_in_cell(self):
|
||||
blocks = parse_html_string(
|
||||
'<table><tr><td><a href="http://x">link</a> text</td></tr></table>')
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
found = [w for b in cell for w in getattr(b, 'words', [])]
|
||||
|
||||
assert [w.text for w in found] == ["link", "text"]
|
||||
linked = [w for w in found if isinstance(w, LinkedWord)]
|
||||
assert len(linked) == 1
|
||||
assert linked[0].location == "http://x"
|
||||
|
||||
def test_link_in_div(self):
|
||||
blocks = parse_html_string('<div>see <a href="#s2">Section 2</a> now</div>')
|
||||
found = [w for b in blocks for w in getattr(b, 'words', [])]
|
||||
assert [w.text for w in found] == ["see", "Section", "2", "now"]
|
||||
assert all(isinstance(w, LinkedWord) for w in found[1:3])
|
||||
|
||||
def test_link_in_list_item(self):
|
||||
blocks = parse_html_string('<ul><li><a href="u">click</a> here</li></ul>')
|
||||
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||
item = hlist._items[0]
|
||||
found = [w for b in item.blocks() for w in getattr(b, 'words', [])]
|
||||
assert [w.text for w in found] == ["click", "here"]
|
||||
assert isinstance(found[0], LinkedWord)
|
||||
|
||||
|
||||
class TestNestedContainers:
|
||||
|
||||
def test_div_in_div(self):
|
||||
blocks = parse_html_string("<div>outer <div>inner</div> tail</div>")
|
||||
assert [words_of(b) for b in blocks] == [["outer"], ["inner"], ["tail"]]
|
||||
|
||||
def test_block_children_still_pass_through(self):
|
||||
blocks = parse_html_string("<div><h1>Title</h1><p>Body</p></div>")
|
||||
assert len(blocks) == 2
|
||||
assert words_of(blocks[0]) == ["Title"]
|
||||
assert words_of(blocks[1]) == ["Body"]
|
||||
|
||||
def test_cell_containing_a_list(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>intro<ul><li>item</li></ul></td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert isinstance(cell[0], Paragraph)
|
||||
assert words_of(cell[0]) == ["intro"]
|
||||
assert isinstance(cell[1], HList)
|
||||
|
||||
|
||||
class TestComments:
|
||||
|
||||
def test_comment_text_is_not_content(self):
|
||||
blocks = parse_html_string("<div>real<!-- hidden note -->text</div>")
|
||||
assert all_words(blocks) == ["real", "text"]
|
||||