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.
This commit is contained in:
2026-08-06 22:59:27 +02:00
parent 202dacf350
commit c5c61a3503
6 changed files with 210 additions and 10 deletions
+53
View File
@@ -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 | | [S11](#s11--partial-block-progress-is-discarded) | Partial-block progress is discarded | 0 |
| [S12](#s12--background-rendering) | Background rendering | 4 | | [S12](#s12--background-rendering) | Background rendering | 4 |
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 | | [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 ## Design invariants
@@ -1158,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 ## Test plan
Findings were reproduced with four probe scripts; each becomes a regression test Findings were reproduced with four probe scripts; each becomes a regression test
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+33 -10
View File
@@ -153,7 +153,22 @@ class ButtonText(Text, Interactable, Queriable):
self, '_width', 0) if not hasattr( self, '_width', 0) if not hasattr(
self._width, '__call__') else 0 self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3] self._padded_width = text_width + padding[1] + padding[3]
self._padded_height = self._style.font_size + padding[0] + padding[2]
# Size the button from the text's visual height (ascent + descent), not
# from the nominal font size. The two differ by several pixels - DejaVu at
# 14px measures 17 - so sizing by font_size leaves the button too short to
# centre its own label in.
self._text_height = self._visual_text_height()
self._padded_height = self._text_height + padding[0] + padding[2]
def _visual_text_height(self) -> int:
"""Height of the rendered text, ascender to descender."""
try:
ascent, descent = self._style.font.getmetrics()
return int(ascent + descent)
except (AttributeError, TypeError, ValueError):
# Mock or unusual font object; the nominal size is the best guess.
return int(getattr(self._style, 'font_size', 0) or 0)
@property @property
def button(self) -> Button: def button(self) -> Button:
@@ -237,11 +252,18 @@ class ButtonText(Text, Interactable, Queriable):
# Total button height minus top and bottom padding gives us text area height # Total button height minus top and bottom padding gives us text area height
text_area_height = self._padded_height - self._padding[0] - self._padding[2] text_area_height = self._padded_height - self._padding[0] - self._padding[2]
# Center the text visual height (ascent + descent) within the text area # Centre the text's visual height (ascent + descent) within the text area.
# The y position is where the baseline sits # text_y is the baseline, since Text renders with anchor "ls".
# Visual center = area_height/2, baseline should be at center + descent/2 #
vertical_center = text_area_height / 2 # top of glyphs = area_top + (area_height - (ascent + descent)) / 2
text_y = self._origin[1] + self._padding[0] + vertical_center + (descent / 2) # baseline = top of glyphs + ascent
#
# The previous form, area_top + area_height/2 + descent/2, is only
# equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
# label rendered several pixels above centre, against the top edge.
text_top = self._origin[1] + self._padding[0] \
+ (text_area_height - (ascent + descent)) / 2
text_y = text_top + ascent
# Temporarily set origin for text rendering # Temporarily set origin for text rendering
original_origin = self._origin.copy() original_origin = self._origin.copy()
@@ -360,11 +382,12 @@ class FormFieldText(Text, Interactable, Queriable):
# Get font metrics to properly center the baseline # Get font metrics to properly center the baseline
ascent, descent = value_font.font.getmetrics() ascent, descent = value_font.font.getmetrics()
# Center the text vertically within the field # Centre the value within the input box. As in ButtonText, the
# The y coordinate is where the baseline sits (anchor="ls") # baseline sits at the top of the glyphs plus the ascent; centring on
vertical_center = self._field_height / 2 # half the box height plus half the descent only works for a 2:1
# ascent/descent ratio and otherwise rides high.
value_x = field_x + 5 value_x = field_x + 5
value_y = field_y + vertical_center + (descent / 2) value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
# Draw the value text # Draw the value text
self._draw.text((value_x, value_y), value_text, self._draw.text((value_x, value_y), value_text,
@@ -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")