diff --git a/.github/workflows/type.yaml b/.github/workflows/type.yaml
index cd97e36..5656e65 100644
--- a/.github/workflows/type.yaml
+++ b/.github/workflows/type.yaml
@@ -6,7 +6,7 @@ on:
pull_request:
jobs:
- check-formatting:
+ check-typing:
runs-on: ubuntu-latest
steps:
@@ -19,7 +19,12 @@ jobs:
enable-cache: true
- name: Install the project
- run: uv sync --all-groups
+ run: |
+ uv sync --all-groups
+ uv pip install brand_yml # for some reason I need to add this manually
- - name: Run tests
+ - name: Run type check (ty)
run: uv run ty check
+
+ - name: Run type check (pyrefly)
+ run: uv run pyrefly check
diff --git a/.gitignore b/.gitignore
index 070c009..bf0322f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ sandbox*
uv.lock
scripts/release.sh
+_brand.yml
diff --git a/README.md b/README.md
index 7440cf5..fefd26b 100644
--- a/README.md
+++ b/README.md
@@ -32,12 +32,15 @@ function.
## Themes
-**`morethemes`** offers 16 themes at the moment:
+**`morethemes`** offers 16 built-in themes, as well as a special ‘brand’
+theme, based the [\_brand.yml
+specification](https://posit-dev.github.io/brand-yml/):
### WSJ
``` python
import morethemes as mt
+
mt.set_theme("wsj")
```
@@ -47,6 +50,7 @@ mt.set_theme("wsj")
``` python
import morethemes as mt
+
mt.set_theme("urban")
```
@@ -56,6 +60,7 @@ mt.set_theme("urban")
``` python
import morethemes as mt
+
mt.set_theme("minimal")
```
@@ -65,6 +70,7 @@ mt.set_theme("minimal")
``` python
import morethemes as mt
+
mt.set_theme("ft")
```
@@ -74,6 +80,7 @@ mt.set_theme("ft")
``` python
import morethemes as mt
+
mt.set_theme("nature")
```
@@ -83,6 +90,7 @@ mt.set_theme("nature")
``` python
import morethemes as mt
+
mt.set_theme("economist")
```
@@ -92,6 +100,7 @@ mt.set_theme("economist")
``` python
import morethemes as mt
+
mt.set_theme("greenwave")
```
@@ -101,6 +110,7 @@ mt.set_theme("greenwave")
``` python
import morethemes as mt
+
mt.set_theme("lumen")
```
@@ -110,6 +120,7 @@ mt.set_theme("lumen")
``` python
import morethemes as mt
+
mt.set_theme("ebonis")
```
@@ -119,6 +130,7 @@ mt.set_theme("ebonis")
``` python
import morethemes as mt
+
mt.set_theme("lighter")
```
@@ -128,6 +140,7 @@ mt.set_theme("lighter")
``` python
import morethemes as mt
+
mt.set_theme("vscode-dark")
```
@@ -137,6 +150,7 @@ mt.set_theme("vscode-dark")
``` python
import morethemes as mt
+
mt.set_theme("nord")
```
@@ -146,6 +160,7 @@ mt.set_theme("nord")
``` python
import morethemes as mt
+
mt.set_theme("retro")
```
@@ -155,6 +170,7 @@ mt.set_theme("retro")
``` python
import morethemes as mt
+
mt.set_theme("darker")
```
@@ -164,6 +180,7 @@ mt.set_theme("darker")
``` python
import morethemes as mt
+
mt.set_theme("yellowish")
```
@@ -173,7 +190,20 @@ mt.set_theme("yellowish")
``` python
import morethemes as mt
+
mt.set_theme("monoblue")
```
[](https://y-sunflower.github.io/morethemes/)
+
+### BRAND
+
+The `brand` theme reads a Posit
+[`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and
+generates Matplotlib rcParams from its colors and typography.
+
+``` python
+import morethemes as mt
+
+mt.set_theme("brand")
+```
diff --git a/docs/.gitignore b/docs/.gitignore
index 075b254..0e3521a 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -1 +1,3 @@
/.quarto/
+
+**/*.quarto_ipynb
diff --git a/docs/README.qmd b/docs/README.qmd
index b28ecd4..cc0d224 100644
--- a/docs/README.qmd
+++ b/docs/README.qmd
@@ -16,8 +16,6 @@ default-image-extension: ""
[Documentation site](https://y-sunflower.github.io/morethemes/)
-
-
## Installation
@@ -28,6 +26,7 @@ pip install morethemes
Don't want to add **`morethemes`** as a dependency? You can either browse the [source code](https://github.com/y-sunflower/morethemes/blob/main/morethemes/themes.py) to find the rcParams, or use the `mt.get_rcparams("theme_name")` function.
+
## Themes
@@ -40,9 +39,12 @@ Don't want to add **`morethemes`** as a dependency? You can either browse the [s
import morethemes as mt
import matplotlib.pyplot as plt
-print(f"**`morethemes`** offers {len(mt.ALL_THEMES)} themes at the moment:\n")
+static_themes = [theme for theme in mt.ALL_THEMES if theme != "brand"]
+print(
+ f"**`morethemes`** offers 16 built-in themes, as well as a special 'brand' theme, based the [_brand.yml specification](https://posit-dev.github.io/brand-yml/):\n"
+)
-for theme in mt.ALL_THEMES:
+for theme in static_themes:
print(f"### {theme.upper()}\n\n")
print("```python")
print("import morethemes as mt")
@@ -55,4 +57,13 @@ for theme in mt.ALL_THEMES:
f"[](https://y-sunflower.github.io/morethemes/)"
)
print("\n")
+
+print("### BRAND\n\n")
+print(
+ "The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from its colors and typography.\n"
+)
+print("```python")
+print("import morethemes as mt")
+print('mt.set_theme("brand")')
+print("```\n")
```
diff --git a/docs/contributing.md b/docs/contributing.md
index 1f3fc0e..f23ae55 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -40,9 +40,9 @@ uv run pytest
```python
sunglasses_theme = {
- "name": "sunglasses", # the name of your theme
+ "name": "sunglasses", # the name of your theme
"description": "A description of the theme",
- "theme": { # all arguments passed to plt.rcParams
+ "theme": { # all arguments passed to plt.rcParams
"axes.facecolor": "#232323",
"figure.facecolor": "#282828",
},
@@ -59,7 +59,7 @@ sunglasses_theme = {
ALL_THEMES = {
"darker": darker_theme,
"lighter": lighter_theme,
- "sunglasses": sunglasses_theme, # add your theme here
+ "sunglasses": sunglasses_theme, # add your theme here
}
```
diff --git a/docs/guide/create-your-theme.md b/docs/guide/create-your-theme.md
index f6f7359..2375e91 100644
--- a/docs/guide/create-your-theme.md
+++ b/docs/guide/create-your-theme.md
@@ -69,6 +69,7 @@ For example, when I set the theme to `darker`, it will call all parameters of th
```python
import morethemes as mt
+
mt.set_theme("darker")
```
@@ -131,13 +132,13 @@ plt.rcParams.keys()
Colors is the main thing that changes in a theme. Here you'll find every param name that you might need:
```python
-plt.rcParams["axes.facecolor"] = "black" # Axes color
-plt.rcParams["figure.facecolor"] = "black" # Figure color
-plt.rcParams["axes.labelcolor"] = "grey" # Label color
-plt.rcParams["axes.edgecolor"] = "grey" # Spines color
-plt.rcParams["xtick.color"] = "grey" # x tick color
-plt.rcParams["ytick.color"] = "grey" # y tick color
-plt.rcParams["text.color"] = "white" # Text color
+plt.rcParams["axes.facecolor"] = "black" # Axes color
+plt.rcParams["figure.facecolor"] = "black" # Figure color
+plt.rcParams["axes.labelcolor"] = "grey" # Label color
+plt.rcParams["axes.edgecolor"] = "grey" # Spines color
+plt.rcParams["xtick.color"] = "grey" # x tick color
+plt.rcParams["ytick.color"] = "grey" # y tick color
+plt.rcParams["text.color"] = "white" # Text color
```
By default, matplotlib charts will be **blue**. But if your chart has 2 colors, by default, the colors will be **blue** and **orange**. If there are 3, the colors will be **blue**, **orange** and **green**. This will continue with 7 other colors.
@@ -146,8 +147,9 @@ This is called a `prop_cycle`. Changing this parameter will also to change defau
```python
from cycler import cycler
+
plt.rcParams["axes.prop_cycle"] = cycler(
- "color", ["#FED789", "#A4BED5", "#72874E", "#023743", "#476F84", "#453947"]
+ "color", ["#FED789", "#A4BED5", "#72874E", "#023743", "#476F84", "#453947"]
)
```
@@ -156,12 +158,12 @@ plt.rcParams["axes.prop_cycle"] = cycler(
You can control the appearance of spines around matplotlib Axes with the following parameters:
```python
-plt.rcParams["axes.spines.top"] = False # Remove top spine
-plt.rcParams["axes.spines.bottom"] = False # Remove bottom spine
-plt.rcParams["axes.spines.right"] = False # Remove right spine
-plt.rcParams["axes.spines.left"] = False # Remove left spine
-plt.rcParams["axes.edgecolor"] = "#eeeeee" # Spine color
-plt.rcParams["axes.linewidth"] = 1.5 # Spine width
+plt.rcParams["axes.spines.top"] = False # Remove top spine
+plt.rcParams["axes.spines.bottom"] = False # Remove bottom spine
+plt.rcParams["axes.spines.right"] = False # Remove right spine
+plt.rcParams["axes.spines.left"] = False # Remove left spine
+plt.rcParams["axes.edgecolor"] = "#eeeeee" # Spine color
+plt.rcParams["axes.linewidth"] = 1.5 # Spine width
```
### Fonts
@@ -169,7 +171,7 @@ plt.rcParams["axes.linewidth"] = 1.5 # Spine width
It is highly recommended to use a specific font for a theme, as it plays a major role in the brand image.
```python
-plt.rcParams['font.family'] = _set_family("Font Name")
+plt.rcParams["font.family"] = _set_family("Font Name")
```
### Others
diff --git a/docs/guide/reference.md b/docs/guide/reference.md
index 03af7f4..858e2b0 100644
--- a/docs/guide/reference.md
+++ b/docs/guide/reference.md
@@ -6,7 +6,7 @@ This document explains everything you need to get started using **`morethemes`**
`mt.set_theme()` is the **main function** you'll need. It basically has 2 arguments:
-- `theme_name`: the name of the theme you want to apply. It must be one of the available themes: _"wsj"_, _"urban"_, _"minimal"_, _"ft"_, _"nature"_, _"economist"_, _"retro"_, _"yellowish"_, _"darker"_, _"monoblue"_.
+- `theme_name`: the name of the theme you want to apply. It can be a built-in theme or `"brand"`.
- `reset_to_default`: whether to reset to default matplotlib theme before applying the theme. If not, themes can stack on top of each other. Default is `True` (and recommended).
Once the function is called, it will apply the theme to the **global matplotlib configuration**. You can reset to default theme by calling `mt.set_theme("default")`
@@ -14,9 +14,27 @@ Once the function is called, it will apply the theme to the **global matplotlib
```python
import morethemes as mt
-mt.set_theme("wsj") # Wall Street Journal
+mt.set_theme("wsj") # Wall Street Journal
```
+### The `brand` theme
+
+The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from its colors and typography. Install the optional dependency first:
+
+```bash
+pip install "morethemes[brand]"
+```
+
+Then call it from a project containing `_brand.yml` (or configure the path using the `brand_yml` package's supported discovery settings):
+
+```python
+import morethemes as mt
+
+mt.set_theme("brand")
+```
+
+The generated theme uses the brand foreground/background colors, primary and secondary colors, palette colors for the plot color cycle, and base/heading typography settings supported by Matplotlib. System and Google fonts are supported; local-file and Bunny font sources are not.
+
## `mt.get_rcparams()`
@@ -29,6 +47,8 @@ import morethemes as mt
wsj_rcparams = mt.get_rcparams("wsj")
```
+`mt.get_rcparams("brand")` dynamically parses the discovered `_brand.yml` file and returns the generated rcParams.
+
Then you can apply them to your matplotlib configuration with:
```python
diff --git a/docs/img/darker.png b/docs/img/darker.png
index 9baa960..0ca2808 100644
Binary files a/docs/img/darker.png and b/docs/img/darker.png differ
diff --git a/docs/img/ebonis.png b/docs/img/ebonis.png
index 50113bd..6071b82 100644
Binary files a/docs/img/ebonis.png and b/docs/img/ebonis.png differ
diff --git a/docs/img/economist.png b/docs/img/economist.png
index 083d633..820be0c 100644
Binary files a/docs/img/economist.png and b/docs/img/economist.png differ
diff --git a/docs/img/ft.png b/docs/img/ft.png
index 86d897b..03cdbb8 100644
Binary files a/docs/img/ft.png and b/docs/img/ft.png differ
diff --git a/docs/img/greenwave.png b/docs/img/greenwave.png
index 9d628ce..692e20e 100644
Binary files a/docs/img/greenwave.png and b/docs/img/greenwave.png differ
diff --git a/docs/img/lighter.png b/docs/img/lighter.png
index 8c617b8..40aa98b 100644
Binary files a/docs/img/lighter.png and b/docs/img/lighter.png differ
diff --git a/docs/img/lumen.png b/docs/img/lumen.png
index e37dbcb..ab20018 100644
Binary files a/docs/img/lumen.png and b/docs/img/lumen.png differ
diff --git a/docs/img/minimal.png b/docs/img/minimal.png
index e912c6c..e04d9b4 100644
Binary files a/docs/img/minimal.png and b/docs/img/minimal.png differ
diff --git a/docs/img/monoblue.png b/docs/img/monoblue.png
index b78c9e4..2279b2a 100644
Binary files a/docs/img/monoblue.png and b/docs/img/monoblue.png differ
diff --git a/docs/img/nature.png b/docs/img/nature.png
index a652355..c73ee75 100644
Binary files a/docs/img/nature.png and b/docs/img/nature.png differ
diff --git a/docs/img/nord.png b/docs/img/nord.png
index b654306..7a7d366 100644
Binary files a/docs/img/nord.png and b/docs/img/nord.png differ
diff --git a/docs/img/retro.png b/docs/img/retro.png
index 682ee06..cf52a36 100644
Binary files a/docs/img/retro.png and b/docs/img/retro.png differ
diff --git a/docs/img/urban.png b/docs/img/urban.png
index 1a9c321..b4ab6a4 100644
Binary files a/docs/img/urban.png and b/docs/img/urban.png differ
diff --git a/docs/img/vscode-dark.png b/docs/img/vscode-dark.png
index 5b3f281..efe0141 100644
Binary files a/docs/img/vscode-dark.png and b/docs/img/vscode-dark.png differ
diff --git a/docs/img/wsj.png b/docs/img/wsj.png
index e1d3212..f466819 100644
Binary files a/docs/img/wsj.png and b/docs/img/wsj.png differ
diff --git a/docs/img/yellowish.png b/docs/img/yellowish.png
index b992486..8a9565f 100644
Binary files a/docs/img/yellowish.png and b/docs/img/yellowish.png differ
diff --git a/docs/index.md b/docs/index.md
index 304c32a..5dfb343 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -16,7 +16,7 @@ line of code.
## Themes
-**`morethemes`** offers 16 themes at the moment:
+**`morethemes`** offers 16 built-in themes and a dynamic `brand` theme:
=== "WSJ"
@@ -24,6 +24,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("wsj")
```
@@ -35,6 +36,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("urban")
```
@@ -46,6 +48,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("minimal")
```
@@ -57,6 +60,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("ft")
```
@@ -68,6 +72,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("nature")
```
@@ -79,6 +84,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("economist")
```
@@ -90,6 +96,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("greenwave")
```
@@ -101,6 +108,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("lumen")
```
@@ -112,6 +120,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("ebonis")
```
@@ -123,6 +132,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("lighter")
```
@@ -134,6 +144,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("vscode-dark")
```
@@ -145,6 +156,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("nord")
```
@@ -156,6 +168,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("retro")
```
@@ -167,6 +180,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("darker")
```
@@ -178,6 +192,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("yellowish")
```
@@ -189,6 +204,7 @@ line of code.
```python
import morethemes as mt
+
mt.set_theme("monoblue")
```
@@ -196,6 +212,25 @@ line of code.
+### BRAND
+
+The `brand` theme reads a Posit
+[`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and
+generates Matplotlib rcParams from the brand colors and typography.
+Install it with:
+
+``` bash
+pip install "morethemes[brand]"
+```
+
+``` python
+import morethemes as mt
+
+mt.set_theme("brand")
+```
+
+
+
## Installation
``` bash
diff --git a/docs/index.qmd b/docs/index.qmd
index 225352a..96fd5d7 100644
--- a/docs/index.qmd
+++ b/docs/index.qmd
@@ -30,7 +30,8 @@ from: markdown-smart
import morethemes as mt
import matplotlib.pyplot as plt
-print(f"**`morethemes`** offers {len(mt.ALL_THEMES)} themes at the moment:\n")
+static_themes = [theme for theme in mt.ALL_THEMES if theme != "brand"]
+print(f"**`morethemes`** offers {len(static_themes)} built-in themes and a dynamic `brand` theme:\n")
def print_a_theme(theme: str):
@@ -50,10 +51,25 @@ def print_a_theme(theme: str):
print(theme_string)
-for theme in mt.ALL_THEMES:
+for theme in static_themes:
print_a_theme(theme)
```
+
+
+### BRAND
+
+The `brand` theme reads a Posit [`_brand.yml`](https://posit-dev.github.io/brand-yml/) file and generates Matplotlib rcParams from the brand colors and typography. Install it with:
+
+```bash
+pip install "morethemes[brand]"
+```
+
+```python
+import morethemes as mt
+mt.set_theme("brand")
+```
+
diff --git a/justfile b/justfile
index c71eab8..5d80e4a 100644
--- a/justfile
+++ b/justfile
@@ -1,18 +1,25 @@
readme:
- quarto render docs/README.qmd --output-dir ..
+ uv run --group quarto quarto render docs/README.qmd --output-dir ..
+ uv run ruff format README.md
index:
- quarto render docs/index.qmd
+ quarto render docs/index.qmd
coverage:
- uv run coverage run --source=morethemes -m pytest
- uv run coverage report -m
- uv run coverage xml
- uv run genbadge coverage -i coverage.xml
- rm coverage.xml
+ uv run coverage run --source=morethemes -m pytest
+ uv run coverage report -m
+ uv run coverage xml
+ uv run genbadge coverage -i coverage.xml
+ rm coverage.xml
preview:
- uv run mkdocs serve
+ uv run mkdocs serve
test:
- uv run pytest
+ uv run pytest
+
+check:
+ uv run ty check
+ uv run pyrefly check
+ uv run ruff check
+ uv run ruff format --check .
diff --git a/morethemes/__init__.py b/morethemes/__init__.py
index 66b2f7a..c30ece3 100644
--- a/morethemes/__init__.py
+++ b/morethemes/__init__.py
@@ -1,5 +1,5 @@
-from morethemes.main import set_theme, ALL_THEMES, get_rcparams
+from morethemes.main import ALL_THEMES, get_rcparams, set_theme
from morethemes.preview import preview_theme
-__all__ = ["set_theme", "preview_theme", "ALL_THEMES", "get_rcparams"]
+__all__ = ["ALL_THEMES", "get_rcparams", "preview_theme", "set_theme"]
__version__ = "0.7.0"
diff --git a/morethemes/brand.py b/morethemes/brand.py
new file mode 100644
index 0000000..a26c510
--- /dev/null
+++ b/morethemes/brand.py
@@ -0,0 +1,151 @@
+import os
+from collections.abc import Iterable, Mapping
+from pathlib import Path
+from typing import Any
+
+from cycler import cycler
+
+RcParams = dict[str, Any]
+
+
+def get_brand_rcparams() -> RcParams:
+ """Load the discovered brand.yml file and convert it to Matplotlib rcParams."""
+ brand = _load_brand()
+ return _brand_to_rcparams(brand)
+
+
+def _load_brand() -> Any:
+ try:
+ from brand_yml import Brand
+ except ImportError as exc:
+ raise ImportError(
+ "The 'brand' theme requires the optional 'brand_yml' dependency. "
+ "Install it with `pip install 'morethemes[brand]'`."
+ ) from exc
+
+ path = os.environ.get("BRAND_YML_PATH", Path.cwd())
+ return Brand.from_yaml(path)
+
+
+def _brand_to_rcparams(brand: Any) -> RcParams:
+ rcparams: RcParams = {}
+ color = getattr(brand, "color", None)
+ typography = getattr(brand, "typography", None)
+
+ _add_color_rcparams(rcparams, color)
+ _add_typography_rcparams(rcparams, typography)
+
+ return rcparams
+
+
+def _add_color_rcparams(rcparams: RcParams, color: Any) -> None:
+ if color is None:
+ return
+
+ foreground = getattr(color, "foreground", None)
+ background = getattr(color, "background", None)
+ primary = getattr(color, "primary", None)
+ secondary = getattr(color, "secondary", None)
+
+ if foreground is not None:
+ rcparams.update(
+ {
+ "text.color": foreground,
+ "axes.labelcolor": foreground,
+ "axes.edgecolor": foreground,
+ "xtick.color": foreground,
+ "ytick.color": foreground,
+ }
+ )
+ if background is not None:
+ rcparams.update({"figure.facecolor": background, "axes.facecolor": background})
+ if primary is not None:
+ rcparams["axes.titlecolor"] = primary
+ if secondary is not None:
+ rcparams["legend.edgecolor"] = secondary
+
+ palette = getattr(color, "palette", None)
+ if isinstance(palette, Mapping) and palette:
+ rcparams["axes.prop_cycle"] = cycler("color", list(palette.values()))
+
+
+def _add_typography_rcparams(rcparams: RcParams, typography: Any) -> None:
+ if typography is None:
+ return
+
+ base = getattr(typography, "base", None)
+ headings = getattr(typography, "headings", None)
+
+ if base is not None:
+ family = getattr(base, "family", None)
+ weight = getattr(base, "weight", None)
+ size = getattr(base, "size", None)
+ color = getattr(base, "color", None)
+
+ if family is not None:
+ rcparams["font.family"] = family
+ if weight is not None:
+ rcparams["font.weight"] = _font_weight(weight)
+ if size is not None:
+ rcparams["font.size"] = _font_size(size)
+ if color is not None:
+ rcparams["text.color"] = color
+
+ if headings is not None:
+ weight = getattr(headings, "weight", None)
+ size = getattr(headings, "size", None)
+ color = getattr(headings, "color", None)
+
+ if weight is not None:
+ rcparams["axes.titleweight"] = _font_weight(weight)
+ if size is not None:
+ rcparams["axes.titlesize"] = _font_size(size)
+ if color is not None:
+ rcparams["axes.titlecolor"] = color
+
+
+def _font_weight(weight: Any) -> Any:
+ if isinstance(weight, str):
+ weights = {
+ "thin": 100,
+ "extra-light": 200,
+ "extralight": 200,
+ "light": 300,
+ "normal": 400,
+ "regular": 400,
+ "medium": 500,
+ "semi-bold": 600,
+ "semibold": 600,
+ "bold": 700,
+ "extra-bold": 800,
+ "extrabold": 800,
+ "black": 900,
+ }
+ return weights.get(weight.lower(), weight)
+ return weight
+
+
+def _font_size(size: Any) -> Any:
+ if isinstance(size, (int, float)):
+ return size
+ if not isinstance(size, str):
+ return size
+
+ value = size.strip().lower()
+ for unit, factor in (("px", 0.75), ("pt", 1.0), ("rem", 12.0), ("em", 12.0)):
+ if value.endswith(unit):
+ return float(value[: -len(unit)]) * factor
+ return size
+
+
+def get_brand_font_source(brand: Any, family: str) -> str:
+ """Return the declared source for a brand font family."""
+ typography = getattr(brand, "typography", None)
+ fonts = getattr(typography, "fonts", None) if typography is not None else None
+ if not isinstance(fonts, Iterable):
+ return "system"
+
+ for font in fonts:
+ if getattr(font, "family", None) == family:
+ return getattr(font, "source", "system") or "system"
+ return "system"
diff --git a/morethemes/main.py b/morethemes/main.py
index 359a51a..38d7d1f 100644
--- a/morethemes/main.py
+++ b/morethemes/main.py
@@ -3,8 +3,10 @@
import matplotlib as mpl
import matplotlib.pyplot as plt
+from matplotlib.typing import RcKeyType
from pyfonts import load_google_font, set_default_font
+from morethemes.brand import get_brand_font_source, get_brand_rcparams
from morethemes.themes import ALL_THEMES
RcParams = dict[str, Any]
@@ -27,23 +29,48 @@ def set_theme(theme_name: str, reset_to_default: bool = True) -> None:
if theme_name.lower() == "default":
plt.rcParams.update(mpl.rcParamsDefault)
else:
- theme_dict = get_rcparams(theme_name)
- try:
- font_family = theme_dict["font.family"]
- except KeyError:
- raise KeyError(
- f"Theme '{theme_name}' is missing the required 'font.family' rcParam."
- )
- if not isinstance(font_family, str):
- raise TypeError(
- f"Theme '{theme_name}' has invalid 'font.family': "
- f"expected str, got {type(font_family).__name__}."
- )
- set_default_font(load_google_font(font_family))
+ if theme_name.lower() == "brand":
+ theme_dict = get_brand_rcparams()
+ _set_brand_font(theme_dict)
+ else:
+ theme_dict = get_rcparams(theme_name)
+ _set_static_theme_font(theme_name, theme_dict)
if reset_to_default:
plt.rcParams.update(mpl.rcParamsDefault)
- for key, value in theme_dict.items():
- plt.rcParams[key] = value
+ plt.rcParams.update(cast(dict[RcKeyType, Any], theme_dict))
+
+
+def _set_static_theme_font(theme_name: str, theme_dict: RcParams) -> None:
+ try:
+ font_family = theme_dict["font.family"]
+ except KeyError:
+ raise KeyError(
+ f"Theme '{theme_name}' is missing the required 'font.family' rcParam."
+ )
+ if not isinstance(font_family, str):
+ raise TypeError(
+ f"Theme '{theme_name}' has invalid 'font.family': "
+ f"expected str, got {type(font_family).__name__}."
+ )
+ set_default_font(load_google_font(font_family))
+
+
+def _set_brand_font(theme_dict: RcParams) -> None:
+ font_family = theme_dict.get("font.family")
+ if not isinstance(font_family, str):
+ return
+
+ from morethemes.brand import _load_brand
+
+ brand = _load_brand()
+ source = get_brand_font_source(brand, font_family).lower()
+ if source == "google":
+ set_default_font(load_google_font(font_family))
+ elif source not in {"system", ""}:
+ raise ValueError(
+ f"The 'brand' theme does not support the '{source}' font source "
+ "for Matplotlib fonts. Use a system or Google font."
+ )
def get_rcparams(theme_name: str) -> RcParams:
@@ -66,6 +93,8 @@ def get_rcparams(theme_name: str) -> RcParams:
If the theme name is not found.
"""
theme_name = theme_name.lower()
+ if theme_name == "brand":
+ return get_brand_rcparams()
try:
theme_dict = ALL_THEMES[theme_name]["theme"]
except KeyError:
diff --git a/morethemes/preview.py b/morethemes/preview.py
index 18912c2..5e5e5d4 100644
--- a/morethemes/preview.py
+++ b/morethemes/preview.py
@@ -1,7 +1,8 @@
import matplotlib.pyplot as plt
+import numpy as np
from matplotlib.figure import Figure
+
import morethemes as mt
-import numpy as np
def preview_theme(theme=None) -> Figure:
diff --git a/morethemes/themes.py b/morethemes/themes.py
index fcf8d6a..6ee443e 100644
--- a/morethemes/themes.py
+++ b/morethemes/themes.py
@@ -1,6 +1,5 @@
from cycler import cycler
-
darker_theme = {
"name": "darker",
"description": "A sleek, no-frills dark theme with high contrast and a modern feel",
@@ -401,6 +400,7 @@
"description": "A sleek, dark-themed aesthetic with vibrant green accents, inspired by modern streaming interfaces. Designed for clarity and style, with bold typography and a focus on visual harmony.",
"theme": {
"font.family": "Plus Jakarta Sans",
+ "font.weight": 800,
"axes.facecolor": "#000000",
"figure.facecolor": "#000000",
"axes.labelcolor": "#FFFFFF",
@@ -598,8 +598,14 @@
},
}
+brand_theme = {
+ "name": "brand",
+ "description": "A dynamic theme generated from a _brand.yml file.",
+ "theme": {},
+}
ALL_THEMES = {
+ "brand": brand_theme,
"wsj": wsj_theme,
"urban": urban_theme,
"minimal": minimal_theme,
diff --git a/pyproject.toml b/pyproject.toml
index d9e1710..bb9034e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ name = "morethemes"
version = "0.7.0"
description = "More themes for matplotlib"
readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [
@@ -13,7 +13,7 @@ keywords = ["colors", "matplotlib", "style", "themes"]
classifiers = [
"Framework :: Matplotlib",
"Operating System :: OS Independent",
- "Programming Language :: Python :: 3"
+ "Programming Language :: Python :: 3",
]
dependencies = [
@@ -27,6 +27,9 @@ Homepage = "https://y-sunflower.github.io/morethemes/"
Issues = "https://github.com/y-sunflower/morethemes/issues"
Repository = "https://github.com/y-sunflower/morethemes"
+[project.optional-dependencies]
+brand = ["brand_yml>=0.2.0"]
+
[dependency-groups]
dev = [
"coverage>=7.9.1",
@@ -35,9 +38,11 @@ dev = [
"mkdocs-material>=9.5.49",
"mkdocs-matplotlib>=0.10.1",
"pre-commit>=4.0.1",
+ "pyrefly",
"pytest>=8.3.4",
+ "ruff>=0.16.5",
"setuptools>=75.8.0",
- "ty>=0.0.32",
+ "ty",
]
quarto = [
"jupyter>=1.1.1",
diff --git a/tests/test_main.py b/tests/test_main.py
index 7f7e190..eabb8dc 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,7 +1,9 @@
-from matplotlib.figure import Figure
+import sys
+
import matplotlib as mpl
import matplotlib.pyplot as plt
import pytest
+from matplotlib.figure import Figure
import morethemes as mt
@@ -18,17 +20,135 @@ def test_invalid_theme():
def test_valid_themes():
"""Check that all valid theme names don't raise an error"""
- for theme in mt.ALL_THEMES.keys():
- mt.set_theme(theme)
- assert "name" in mt.ALL_THEMES[theme].keys()
- assert "theme" in mt.ALL_THEMES[theme].keys()
- assert "description" in mt.ALL_THEMES[theme].keys()
+ for theme in mt.ALL_THEMES:
+ assert "name" in mt.ALL_THEMES[theme]
+ assert "theme" in mt.ALL_THEMES[theme]
+ assert "description" in mt.ALL_THEMES[theme]
assert len(mt.ALL_THEMES[theme]["description"]) < 200, (
f"The description of theme {theme} has too many characters. "
f"It must be below 200, not {len(mt.ALL_THEMES[theme]['description'])}"
)
+def test_valid_static_themes():
+ """Check that all built-in themes can still be applied."""
+ for theme in mt.ALL_THEMES:
+ if theme != "brand":
+ mt.set_theme(theme)
+
+
+def test_greenwave_font_weight():
+ """Keep greenwave's bold typography when applying the theme."""
+ mt.set_theme("greenwave")
+
+ assert plt.rcParams["font.weight"] == 800
+
+
+def test_brand_rcparams(tmp_path, monkeypatch):
+ """Convert a brand.yml file into Matplotlib rcParams."""
+ pytest.importorskip("brand_yml")
+ (tmp_path / "_brand.yml").write_text(
+ """
+color:
+ palette:
+ blue: "#123456"
+ orange: "#ff6600"
+ foreground: blue
+ background: "#ffffff"
+ primary: orange
+ secondary: blue
+typography:
+ base:
+ family: DejaVu Sans
+ weight: medium
+ size: 16px
+ headings:
+ family: DejaVu Sans
+ weight: semi-bold
+ color: orange
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+
+ rcparams = mt.get_rcparams("BRAND")
+
+ assert rcparams["font.family"] == "DejaVu Sans"
+ assert rcparams["font.weight"] == 500
+ assert rcparams["font.size"] == 12
+ assert rcparams["axes.titleweight"] == 600
+ assert rcparams["axes.titlecolor"] == "#ff6600"
+ assert rcparams["text.color"] == "#123456"
+ assert rcparams["figure.facecolor"] == "#ffffff"
+ assert rcparams["axes.facecolor"] == "#ffffff"
+ assert rcparams["legend.edgecolor"] == "#123456"
+ assert [color["color"] for color in rcparams["axes.prop_cycle"]] == [
+ "#123456",
+ "#ff6600",
+ ]
+
+
+def test_set_brand_theme(tmp_path, monkeypatch):
+ """Apply a parsed brand theme to Matplotlib."""
+ pytest.importorskip("brand_yml")
+ (tmp_path / "_brand.yml").write_text(
+ "color:\n foreground: '#111111'\n background: '#eeeeee'\n",
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+
+ mt.set_theme("brand")
+
+ assert plt.rcParams["text.color"] == "#111111"
+ assert plt.rcParams["figure.facecolor"] == "#eeeeee"
+
+
+def test_brand_missing_dependency(monkeypatch):
+ """Explain how to install the optional dependency."""
+ monkeypatch.setitem(sys.modules, "brand_yml", None)
+
+ with pytest.raises(ImportError, match=r"morethemes\[brand\]"):
+ mt.get_rcparams("brand")
+
+
+def test_brand_missing_file(tmp_path, monkeypatch):
+ """Surface the parser's missing-file error."""
+ pytest.importorskip("brand_yml")
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(FileNotFoundError):
+ mt.get_rcparams("brand")
+
+
+def test_brand_invalid_file(tmp_path, monkeypatch):
+ """Surface validation errors from brand_yml."""
+ pytest.importorskip("brand_yml")
+ (tmp_path / "_brand.yml").write_text("color: invalid\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(ValueError):
+ mt.get_rcparams("brand")
+
+
+def test_brand_unsupported_font_source(tmp_path, monkeypatch):
+ """Reject font sources that cannot be registered by the Matplotlib adapter."""
+ pytest.importorskip("brand_yml")
+ (tmp_path / "_brand.yml").write_text(
+ """
+typography:
+ fonts:
+ - family: Brand Font
+ source: bunny
+ base: Brand Font
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(ValueError, match="does not support"):
+ mt.set_theme("brand")
+
+
def test_set_theme_default():
"""Check that resetting the default style actually works"""
plt.rcParams["lines.linewidth"] = 3.1415