tephpy add_logo — design specification#
Living document. This specification is maintained alongside the code, not archived behind it.
src/tephpy/plotting/logo.pycites it by section —logo spec §3.5and the like — so these sections are the reasoning behind what the code does, and where the two ever diverge it is the specification that gets corrected. Read it as current.
Date: 2026-08-01 (originated; maintained since)
Status: living design specification, implemented in #71
Scope: one new public function,
tephpy.plotting.add_logo, plus six bundled PNG mastersParent spec:
2026-07-22-tephpy-design.md— this extends spec §3.2plottingwith a branding artist and inherits its error-handling (spec §6), testing (spec §7) and engineering-standards (spec §8) rules unchangedPrior art: MetPy’s
add_metpy_logoBrand assets:
docs/src/_static/brand/assets/logo-bundle.zip, catalogued bydocs/src/_static/brand/assets/README.md(PR #69)
1. Purpose#
Give users a one-call way to brand a figure:
tephpy.plotting.add_logo(ax)
MetPy proved the demand for this and the shape of the API. It also shows the two things not
to copy. add_metpy_logo uses Figure.figimage, which places the image in device pixels
— so the same call yields a logo a third the relative size at 300 dpi that it had at 100 dpi,
and there is no way to ask for “half an inch tall”. And it offers a single dark-on-light
raster, so on a dark figure the mark disappears into the background.
tephpy has richer inputs to work with: three logo forms (icon, lockup, stacked) each in a
light-background and a dark-background variant, all published in the brand bundle. The design
below spends those inputs on a dpi-independent, theme-aware placement built from
AnnotationBbox, whose positioning vocabulary users already know from legend.
2. Decisions#
Decision |
Choice |
Rationale |
|---|---|---|
Artist |
|
Sizes in points, so a request in inches renders identically at every dpi. |
Sizing units |
Height in inches; width follows the master’s aspect |
One number per form; the caller never reasons about pixels or aspect ratios |
Masters shipped |
The largest of each form (icon 512, lockup 716, stacked 512), light and dark |
128.3 KiB for six files. One master per (form, variant) downscales to every size; shipping the smaller rungs too would add weight and a second thing to keep in sync |
Positioning vocabulary |
|
Zero new vocabulary. |
What the position is relative to |
The target — a |
Same rule as |
Default form |
|
It carries the wordmark at the smallest height of the three (§3.3), so the brand is legible in the least plot area. A bare icon is meaningless to anyone who does not already know the brand |
Theme selection |
|
Correct without being asked on both the default white figure and a |
Asset packaging |
Copies under |
The function must work from a wheel with no docs tree and no network. Measured: while the files are git-tracked, |
Asset drift |
A test hashes the six copies against |
The zip is the source of truth; the copies are derived. Without the guard they diverge silently on the next rebrand |
Return value |
The |
The caller can restyle or |
Tinted mono variant ( |
Deferred (#72) |
Needs the mono SVGs rasterised offline; the light masters cannot serve as an alpha mask because they are three-tone with substantial white knockout (§8) |
3. Architecture#
src/tephpy/plotting/
├── logo.py # add_logo + the three lookup tables
└── _static/
├── icon-512-light.png # 512 x 512
├── icon-512-dark.png
├── lockup-716-light.png # 716 x 256
├── lockup-716-dark.png
├── stacked-512-light.png # 512 x 720
└── stacked-512-dark.png
One new module, one new package-data directory, one new public name. logo.py imports
nothing from tephpy beyond _constants; it does not touch transforms, axes or
isopleths, and nothing imports it back. tephpy.plotting.__init__ grows
from tephpy.plotting.logo import add_logo and the matching __all__ entry.
3.1 Public API#
def add_logo(
target: Figure | Axes | None = None,
*,
form: str = "lockup",
size: str | float = "small",
theme: str = "auto",
loc: str | tuple[float, float] = "lower left",
pad: float | None = None,
zorder: float | None = None,
**kwargs: Any,
) -> AnnotationBbox:
pad and zorder take None sentinels rather than literals because their values
are numeric conventions, and the rule in spec §3.5 is that nothing numeric is
hard-coded at point of use — a signature default is a point of use. They resolve to
LOGO_PAD (6.0) and LOGO_ZORDER (100.0) from _constants. The string defaults stay
literal: they are API vocabulary, not conventions, and reading them off the signature
is worth more than routing them through a constant.
Parameter |
Accepts |
Meaning |
|---|---|---|
|
|
What to brand, and what the position is relative to. |
|
|
Which logo |
|
|
Preset (§3.3) or an explicit height in inches |
|
|
Which variant; the name is the background the logo is drawn on |
|
ten |
Where (§3.4) |
|
float, |
Points between the logo and the target edge; |
|
float, |
Draw order; |
|
|
Forwarded to |
target=None resolves through plt.gcf(), so add_logo() on its own brands the current
figure — the MetPy-equivalent zero-argument call.
The artist is attached with fig.add_artist(ab) for a Figure target and ax.add_artist(ab)
for an Axes target; the latter is what makes xycoords="axes fraction" resolvable.
3.2 Bundled assets and packaging#
The six masters are byte-for-byte copies of bundle/png/ members of
docs/src/_static/brand/assets/logo-bundle.zip. They are resolved through one table so a
third variant can be added without touching the call site:
_MASTERS = {
("icon", "light"): "icon-512-light.png",
("icon", "dark"): "icon-512-dark.png",
("lockup", "light"): "lockup-716-light.png",
("lockup", "dark"): "lockup-716-dark.png",
("stacked", "light"): "stacked-512-light.png",
("stacked", "dark"): "stacked-512-dark.png",
}
Loading is lazy, via importlib.resources.files("tephpy.plotting") / "_static" / name, read
with matplotlib.image.imread. Nothing is read at import time, so import tephpy costs
nothing extra and the module honours the existing import-discipline test at
tests/plotting/test_isopleths.py:186.
Packaging is declared explicitly:
[tool.setuptools.package-data]
tephpy = ["py.typed", "plotting/_static/*.png"]
py.typed is listed alongside because it currently ships with no declaration at all,
relying on the setuptools_scm file finder — the same implicit mechanism this table replaces
for the PNGs.
3.3 Sizing#
size is a height in inches. Width follows from the master’s aspect ratio, so a form is
never distorted and the caller supplies one number.
|
Master |
Aspect (w/h) |
|
|
Rendered at |
|---|---|---|---|---|---|
|
716 × 256 |
2.797 |
0.30 in |
0.55 in |
0.84 × 0.30 in |
|
512 × 720 |
0.711 |
0.70 in |
1.15 in |
0.50 × 0.70 in |
|
512 × 512 |
1.000 |
0.40 in |
0.70 in |
0.40 × 0.40 in |
The presets differ per form because the forms place the wordmark differently. Measured on the
light masters, the wordmark occupies 44.1% of the lockup’s height but only 17.8% of
the stacked form’s. Setting the "small" presets so the wordmark clears ~12 px at dpi 100
gives 13.2 px for the lockup at 0.30 in and 12.4 px for the stacked form at 0.70 in — the
stacked form needs 2.3× the height for the same legibility. A single shared preset would
either shrink the stacked wordmark to mush or make the lockup gratuitously large. "large"
is 1.6–1.8× "small" in each case. The icon carries no wordmark and so has no legibility
floor to meet; its presets sit between the other two forms’.
3.4 Placement#
loc takes matplotlib’s legend vocabulary minus 'best': 'upper right', 'upper left',
'lower left', 'lower right', 'right', 'center left', 'center right',
'lower center', 'upper center', 'center'. As in legend, 'right' is a synonym for
'center right', so the ten strings name nine positions. 'best' is rejected with a message
saying so — this artist does no collision detection and silently aliasing it to a corner
would be a lie.
One table drives all of them: each string maps to an anchor in target-fraction coordinates, a
box_alignment, and the sign of the padding offset.
|
anchor |
|
offset (points) |
|---|---|---|---|
|
(0, 0) |
(0, 0) |
(+ |
|
(1, 0) |
(1, 0) |
(− |
|
(0, 1) |
(0, 1) |
(+ |
|
(1, 1) |
(1, 1) |
(− |
|
(0.5, 0) |
(0.5, 0) |
(0, + |
|
(0.5, 1) |
(0.5, 1) |
(0, − |
|
(0, 0.5) |
(0, 0.5) |
(+ |
|
(1, 0.5) |
(1, 0.5) |
(− |
|
(0.5, 0.5) |
(0.5, 0.5) |
(0, 0) |
Anchoring the box’s own corner to the matching target corner is what keeps the gap constant:
because box_alignment tracks the anchor, the offset is a pure inward push and never depends
on the logo’s rendered size.
A tuple loc=(x, y) places the logo’s lower-left corner at that point in target-fraction
coordinates — the same meaning legend(loc=(x, y)) has — via box_alignment=(0, 0) and a
zero offset. pad is ignored in this case, and that is documented on the parameter rather
than raised as an error: the caller who gave exact coordinates has already said where they
want it.
pad defaults to 6.0 points, a shade wider than legend’s borderaxespad of 5.0 pt (0.5
font-size units at the 10 pt default font).
3.5 Theme resolution#
theme="light" and theme="dark" name the background the logo will sit on, which is
also how the asset filenames are named, so there is one vocabulary and no inversion to
remember.
theme="auto" reads the target’s facecolor and picks by Rec. 709 luma over the
gamma-encoded sRGB channels (0.2126 R + 0.7152 G + 0.0722 B), choosing dark below 0.5
and light at or above it. This is luma, not relative luminance: the latter applies the
same weights but linearises each channel first, which scores mid grey #808080 at 0.216
instead of 0.502 and so pulls the crossover well into the light half of the range.
Weighting the encoded values keeps the threshold where a reader would put it by eye,
which is all it has to do to choose between two artwork files. An
An Axes target is read from ax.get_facecolor() and a Figure from fig.get_facecolor().
Because either may be translucent, they are alpha-composited back to front — an assumed white
page, then the figure, then the axes — and the luma is measured on the result, so the test
matches what shows through rather than what a layer’s own channels say. Judging a layer alone
would score 10% black over a white figure at 0.0 and pick the dark mark for a background the
reader sees as near-white. The two ends of that range are the behaviour named above: a fully
transparent axes — facecolor="none" — contributes nothing and so falls through to the
figure, and if the figure is also transparent the assumed white page carries the answer,
light. A fully opaque layer hides everything under it exactly as before.
Documented limitation: savefig(transparent=True) does not change any facecolor; it
overrides alpha at draw time. auto therefore still sees white and picks light, which is
the right answer for a figure destined for a white page and the wrong one for a dark page.
Callers in that position pass theme= explicitly. Closing this properly is the deferred
color= work (§8).
3.6 Rendering#
zoom = size_inches * 72 / master_height_px
AnnotationBbox(
OffsetImage(arr, zoom=zoom, **image_kwargs),
xy=anchor,
xycoords="figure fraction", # or "axes fraction"
xybox=offset,
boxcoords="offset points",
box_alignment=alignment,
frameon=False,
pad=0.0,
zorder=zorder,
annotation_clip=False,
)
OffsetImage’s zoom is points-per-pixel, so size_inches * 72 / master_height_px renders
the master at exactly the requested height at any dpi. Verified at dpi 100/300/600: a 0.30 in
lockup measures 0.8391 × 0.3000 in every time, with the corner gap exactly 6.00 pt.
pad=0.0 is mandatory, not cosmetic. AnnotationBbox’s default pad=0.4 is in
font-size units — 4 pt per side at the 10 pt default — which inflates the rendered box by a
constant 0.111 in regardless of dpi or requested size. Left at the default, a "small"
lockup measures 0.411 in instead of 0.300 in and a "large" one 0.661 in instead of 0.550 in.
This is the single most easily reintroduced bug in the module and §6 pins it with a
regression test.
4. Canonical usage#
import matplotlib.pyplot as plt
import tephpy
from tephpy.plotting import add_logo
fig, ax = plt.subplots(subplot_kw={"projection": "tephigram"})
ax.plot(sounding.pressure, sounding.temperature)
add_logo(ax) # lockup, small, auto theme, lower left
add_logo(fig, form="icon", loc="upper right") # figure corner, not the plot's
add_logo(ax, size="large", loc=(0.35, 0.02)) # exact placement, pad ignored
add_logo(ax, theme="dark", alpha=0.6) # explicit variant, watermark weight
5. Error handling#
The plotting layer raises builtin exceptions; TephpyError and its subclasses are for
user-correctable data input (parent spec §6) and are not used here.
Unknown keyword →
TypeErrorwhose message contains"unknown option", matching the convention asserted attests/plotting/test_isopleths.py:363.**kwargsis validated against the allow-list in §3.1 before forwarding, because matplotlib’s own failure for a badOffsetImagekwarg is anAttributeErrorreadingBboxImage.set() got an unexpected keyword argument ...— the wrong type and a message that names an artist the caller never mentioned.Unknown
form,theme, orlocstring →ValueErrornaming the valid set.loc="best"says explicitly that it is unsupported because no collision detection is performed.A
sizethat is neither a preset name nor a positive finite float →ValueError.A
locthat is neither a string nor a two-element sequence of floats →TypeError; a two-element sequence holding a non-finite value →ValueError. Coordinates outside[0, 1]are allowed — they place the logo outside the target box, whichannotation_clip=Falserenders and whichlegendpermits for the same reason.A
targetthat is neither aFigurenor anAxes→TypeError. AnAxeswhose.figureis aSubFigurealso raisesTypeError:SubFigureis out of scope (§8), and saying so beats returning something typedFigurethat is not one.
6. Testing#
Tests live in tests/plotting/test_logo.py, mirroring the source layout.
Tables: every
_LOCentry, every(form, size)preset, and everythemeresolution is asserted, including'right'resolving identically to'center right'.Sizing: the rendered
AnnotationBboxwindow extent equals the requested height in inches, asserted at dpi 100, 300 and 600 — the property MetPy’sfigimageapproach cannot hold.pad=0.0regression: an explicit test pinning the exact rendered inches, which fails by a constant 0.111 in if thepad=0.0argument is ever dropped.Placement: corner gaps measure
padpoints exactly, across dpi and figsize; a tuplelocputs the lower-left corner where asked and ignorespad.Target semantics: an
Axestarget anchors to the axes box and aFiguretarget to the figure box, distinguishable because the two boxes differ.Asset drift: the six shipped PNGs hash equal to their
logo-bundle.zipcounterparts.Packaging: the six PNGs are present in a built wheel — the failure mode a source-tree test cannot see. It guards the outcome, not the mechanism: the mutation that fails it is removing an asset from the source tree, not deleting the
package-dataline, whichinclude-package-datawould cover for.Errors: each case in §5.
Import discipline:
logo.pyreads no asset at import time.Image baseline: one
pytest-mplcomparison, per parent spec §7.
7. Documentation#
A how-to under
docs/src/howtos/, added to thattoctree, covering the default call, the three forms, dark figures, and exact placement.add_logojoins the API reference automatically through autoapi; its numpydoc docstring documents every parameter, the return, and eachraise, per the repo’snumpydoc-validationhook.docs/src/_static/brand/assets/README.mdgains a line recording that six bundle members are also shipped inside the wheel attephpy/plotting/_static/, so a future rebrand knows to update both.
8. Scope#
In scope: everything in §3.
Deferred (#72) — a color= kwarg tinting
a monochrome mark to an arbitrary colour. This is the honest fix for the
transparent-background case in §3.5 and for figures whose background is neither light nor
dark. It is deferred because it needs lockup-tiera-mono.svg and its siblings rasterised
offline into a third variant — tephpy cannot rasterise SVG at runtime (Pillow does not,
and the bundle’s own generators emit SVG rather than consume it). The light masters cannot
substitute as an alpha mask:
measured, they are three-tone with substantial white knockout (19.2% of icon-512-light.png
is pure white), so flattening them by alpha collapses the mark.
Rejected (2026-08-01) — explicitly not in scope: collision detection (loc="best"),
animation, SubFigure targets, per-artist logo placement on subplots other than through
repeated calls, a tephpy.config section (this work adds exactly one public name), and any
change to the published brand assets. Each is a deliberate omission rather than an unbuilt
intention, so none carries an issue (docs spec §3.5).
9. References#
Parent spec:
2026-07-22-tephpy-design.md, spec §3.2 (plotting), spec §6 (error handling), spec §7 (testing), spec §8 (engineering standards)MetPy
add_metpy_logo: Unidata/MetPymatplotlib
AnnotationBbox: https://matplotlib.org/stable/api/_as_gen/matplotlib.offsetbox.AnnotationBbox.htmlmatplotlib
OffsetImage: https://matplotlib.org/stable/api/_as_gen/matplotlib.offsetbox.OffsetImage.htmlmatplotlib
legendlocvocabulary: https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.legend.htmlBrand assets and their provenance:
docs/src/_static/brand/assets/README.md(PR #69)