eofigs 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eofigs/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Analysis-aware, publication-ready figures for Earth observation research.
2
+
3
+ eofigs is under development towards its first release (v0.1). Available now: journal
4
+ styles, figure sizes in millimetres, panel labels, export and figure checks, the variable
5
+ library, print-size raster handling, trend statistics, manuscript figure sets, built-in
6
+ palettes, colour-blind previews, study area maps, trend maps, event composites and accuracy
7
+ scatters.
8
+ """
9
+
10
+ from eofigs import palettes
11
+ from eofigs.accuracy import accuracy_scatter
12
+ from eofigs.check import CheckReport, CheckResult, check_figure, cvd_preview
13
+ from eofigs.events import event_composite
14
+ from eofigs.export import save_figure
15
+ from eofigs.figures import FigureSet, figure_set
16
+ from eofigs.maps import add_inset, add_north_arrow, add_scale_bar, study_area_map
17
+ from eofigs.raster import fit_raster
18
+ from eofigs.stats import trend
19
+ from eofigs.styling import label_panels, new_figure, set_style, style, tidy_axes
20
+ from eofigs.trends import trend_map
21
+ from eofigs.variables import Variable, register_variable, variable
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "CheckReport",
27
+ "CheckResult",
28
+ "FigureSet",
29
+ "Variable",
30
+ "__version__",
31
+ "accuracy_scatter",
32
+ "add_inset",
33
+ "add_north_arrow",
34
+ "add_scale_bar",
35
+ "check_figure",
36
+ "cvd_preview",
37
+ "event_composite",
38
+ "figure_set",
39
+ "fit_raster",
40
+ "label_panels",
41
+ "new_figure",
42
+ "palettes",
43
+ "register_variable",
44
+ "save_figure",
45
+ "set_style",
46
+ "study_area_map",
47
+ "style",
48
+ "tidy_axes",
49
+ "trend",
50
+ "trend_map",
51
+ "variable",
52
+ ]
eofigs/_colour.py ADDED
@@ -0,0 +1,258 @@
1
+ """Colour science helpers: CIELAB, CIEDE2000, colour-vision simulation, colour-map classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from matplotlib.colors import Colormap
7
+
8
+ # Okabe & Ito (2008) categorical colours, black last.
9
+ OKABE_ITO = (
10
+ "#E69F00",
11
+ "#56B4E9",
12
+ "#009E73",
13
+ "#F0E442",
14
+ "#0072B2",
15
+ "#D55E00",
16
+ "#CC79A7",
17
+ "#000000",
18
+ )
19
+ TEXT_COLOUR = "#1A1A1A"
20
+ SECONDARY_COLOUR = "#4D4D4D"
21
+ GRID_COLOUR = "#E0E0E0"
22
+ NO_DATA_COLOUR = "#BDBDBD"
23
+
24
+ # Machado, Oliveira & Fernandes (2009), severity 1.0, applied to linear sRGB.
25
+ CVD_MATRICES = {
26
+ "protan": np.array(
27
+ [
28
+ [0.152286, 1.052583, -0.204868],
29
+ [0.114503, 0.786281, 0.099216],
30
+ [-0.003882, -0.048116, 1.051998],
31
+ ]
32
+ ),
33
+ "deutan": np.array(
34
+ [
35
+ [0.367322, 0.860646, -0.227968],
36
+ [0.280085, 0.672501, 0.047413],
37
+ [-0.011820, 0.042940, 0.968881],
38
+ ]
39
+ ),
40
+ "tritan": np.array(
41
+ [
42
+ [1.255528, -0.076749, -0.178779],
43
+ [-0.078411, 0.930809, 0.147602],
44
+ [0.004733, 0.691367, 0.303900],
45
+ ]
46
+ ),
47
+ }
48
+
49
+ _SRGB_TO_XYZ = np.array(
50
+ [
51
+ [0.4124564, 0.3575761, 0.1804375],
52
+ [0.2126729, 0.7151522, 0.0721750],
53
+ [0.0193339, 0.1191920, 0.9503041],
54
+ ]
55
+ )
56
+ _D65 = np.array([0.95047, 1.0, 1.08883])
57
+
58
+ # Colour-map classes. Names are compared without a "cmc." or "eofigs." prefix and without "_r".
59
+ CMAP_BANNED = frozenset(
60
+ {
61
+ "jet",
62
+ "rainbow",
63
+ "hsv",
64
+ "nipy_spectral",
65
+ "turbo",
66
+ "gist_rainbow",
67
+ "gist_ncar",
68
+ "flag",
69
+ "prism",
70
+ }
71
+ )
72
+ CMAP_SEQUENTIAL = frozenset(
73
+ {
74
+ # cmcrameri
75
+ "acton", "bamako", "batlow", "batlowK", "batlowW", "bilbao", "buda", "davos", "devon",
76
+ "glasgow", "grayC", "hawaii", "imola", "lajolla", "lapaz", "lipari", "navia", "nuuk",
77
+ "oslo", "tokyo", "turku",
78
+ # matplotlib perceptually uniform and single-hue sequential
79
+ "viridis", "plasma", "inferno", "magma", "cividis", "Greys", "Purples", "Blues",
80
+ "Greens", "Oranges", "Reds", "YlOrBr", "YlOrRd", "OrRd", "PuRd", "RdPu", "BuPu", "GnBu",
81
+ "PuBu", "YlGnBu", "PuBuGn", "BuGn", "YlGn", "gray", "binary", "bone",
82
+ # eofigs
83
+ "slate",
84
+ }
85
+ ) # fmt: skip
86
+ CMAP_DIVERGING = frozenset(
87
+ {
88
+ # cmcrameri
89
+ "bam", "berlin", "broc", "cork", "lisbon", "managua", "roma", "tofino", "vanimo", "vik",
90
+ # matplotlib
91
+ "PiYG", "PRGn", "BrBG", "PuOr", "RdGy", "RdBu", "RdYlBu", "RdYlGn", "Spectral",
92
+ "coolwarm", "bwr", "seismic",
93
+ # eofigs
94
+ "terra_slate",
95
+ }
96
+ ) # fmt: skip
97
+ CMAP_MULTI_SEQUENTIAL = frozenset({"bukavu", "fes", "oleron"})
98
+ CMAP_CYCLIC = frozenset({"bamO", "brocO", "corkO", "romaO", "vikO", "twilight", "twilight_shifted"})
99
+ CMAP_CATEGORICAL = frozenset(
100
+ {
101
+ "tab10", "tab20", "tab20b", "tab20c", "Set1", "Set2", "Set3", "Paired", "Accent",
102
+ "Dark2", "Pastel1", "Pastel2",
103
+ }
104
+ ) # fmt: skip
105
+
106
+
107
+ def to_linear(rgb: np.ndarray) -> np.ndarray:
108
+ """Convert sRGB values in [0, 1] to linear RGB."""
109
+ rgb = np.asarray(rgb, dtype=float)
110
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
111
+
112
+
113
+ def to_srgb(lin: np.ndarray) -> np.ndarray:
114
+ """Convert linear RGB to sRGB values in [0, 1]."""
115
+ lin = np.clip(lin, 0.0, 1.0)
116
+ return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)
117
+
118
+
119
+ def simulate_cvd(rgb: np.ndarray, kind: str) -> np.ndarray:
120
+ """Simulate protan, deutan or tritan vision (Machado et al. 2009, full severity)."""
121
+ return to_srgb(to_linear(rgb) @ CVD_MATRICES[kind].T)
122
+
123
+
124
+ def to_lab(rgb: np.ndarray) -> np.ndarray:
125
+ """Convert sRGB values in [0, 1] (last axis = channels) to CIELAB (D65)."""
126
+ xyz = to_linear(rgb) @ _SRGB_TO_XYZ.T / _D65
127
+ f = np.where(xyz > 216 / 24389, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116)
128
+ lightness = 116 * f[..., 1] - 16
129
+ a = 500 * (f[..., 0] - f[..., 1])
130
+ b = 200 * (f[..., 1] - f[..., 2])
131
+ return np.stack([lightness, a, b], axis=-1)
132
+
133
+
134
+ def delta_e2000(lab1: np.ndarray, lab2: np.ndarray) -> float:
135
+ """CIEDE2000 colour difference between two CIELAB colours."""
136
+ l1, a1, b1 = (float(v) for v in lab1)
137
+ l2, a2, b2 = (float(v) for v in lab2)
138
+ c1, c2 = np.hypot(a1, b1), np.hypot(a2, b2)
139
+ c_mean = (c1 + c2) / 2
140
+ g = 0.5 * (1 - np.sqrt(c_mean**7 / (c_mean**7 + 25**7)))
141
+ a1p, a2p = (1 + g) * a1, (1 + g) * a2
142
+ c1p, c2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
143
+ h1p = np.degrees(np.arctan2(b1, a1p)) % 360
144
+ h2p = np.degrees(np.arctan2(b2, a2p)) % 360
145
+ dlp, dcp = l2 - l1, c2p - c1p
146
+ if c1p * c2p == 0:
147
+ dh = 0.0
148
+ else:
149
+ dh = h2p - h1p
150
+ if dh > 180:
151
+ dh -= 360
152
+ elif dh < -180:
153
+ dh += 360
154
+ dhp = 2 * np.sqrt(c1p * c2p) * np.sin(np.radians(dh / 2))
155
+ lp_mean, cp_mean = (l1 + l2) / 2, (c1p + c2p) / 2
156
+ if c1p * c2p == 0:
157
+ hp_mean = h1p + h2p
158
+ elif abs(h1p - h2p) <= 180:
159
+ hp_mean = (h1p + h2p) / 2
160
+ elif h1p + h2p < 360:
161
+ hp_mean = (h1p + h2p + 360) / 2
162
+ else:
163
+ hp_mean = (h1p + h2p - 360) / 2
164
+ t = (
165
+ 1
166
+ - 0.17 * np.cos(np.radians(hp_mean - 30))
167
+ + 0.24 * np.cos(np.radians(2 * hp_mean))
168
+ + 0.32 * np.cos(np.radians(3 * hp_mean + 6))
169
+ - 0.20 * np.cos(np.radians(4 * hp_mean - 63))
170
+ )
171
+ d_theta = 30 * np.exp(-(((hp_mean - 275) / 25) ** 2))
172
+ r_c = 2 * np.sqrt(cp_mean**7 / (cp_mean**7 + 25**7))
173
+ s_l = 1 + 0.015 * (lp_mean - 50) ** 2 / np.sqrt(20 + (lp_mean - 50) ** 2)
174
+ s_c = 1 + 0.045 * cp_mean
175
+ s_h = 1 + 0.015 * cp_mean * t
176
+ r_t = -np.sin(np.radians(2 * d_theta)) * r_c
177
+ return float(
178
+ np.sqrt(
179
+ (dlp / s_l) ** 2 + (dcp / s_c) ** 2 + (dhp / s_h) ** 2 + r_t * (dcp / s_c) * (dhp / s_h)
180
+ )
181
+ )
182
+
183
+
184
+ def base_cmap_name(cmap: Colormap) -> str:
185
+ """Return a colour map's name without a ``cmc.``/``eofigs.`` prefix or ``_r`` suffix."""
186
+ name = cmap.name
187
+ for prefix in ("cmc.", "eofigs."):
188
+ if name.startswith(prefix):
189
+ name = name[len(prefix) :]
190
+ if name.endswith("_r"):
191
+ name = name[:-2]
192
+ return name
193
+
194
+
195
+ def cmap_lightness(cmap: Colormap, n: int = 256) -> np.ndarray:
196
+ """CIELAB lightness L* sampled evenly along a colour map."""
197
+ rgb = cmap(np.linspace(0, 1, n))[:, :3]
198
+ return to_lab(rgb)[:, 0]
199
+
200
+
201
+ def is_monotonic(values: np.ndarray, tol: float = 1.0) -> bool:
202
+ """Test whether values only increase or only decrease.
203
+
204
+ Small reversals are allowed: the values may fall back by at most ``tol`` below their
205
+ running maximum (increasing) or rise by at most ``tol`` above their running minimum
206
+ (decreasing), which absorbs rounding in sampled colour maps.
207
+ """
208
+ values = np.asarray(values, dtype=float)
209
+ increasing = np.max(np.maximum.accumulate(values) - values) <= tol
210
+ decreasing = np.max(values - np.minimum.accumulate(values)) <= tol
211
+ return bool(increasing or decreasing)
212
+
213
+
214
+ def classify_cmap(cmap: Colormap) -> str:
215
+ """Classify a colour map by how its lightness should behave.
216
+
217
+ Returns ``"banned"``, ``"sequential"``, ``"diverging"``, ``"multi-sequential"``,
218
+ ``"cyclic"``, ``"categorical"`` or ``"unknown"``. Known cmcrameri and matplotlib
219
+ names are classified from fixed lists. Other maps are
220
+ classified from their lightness profile; maps that fit no class are returned as
221
+ ``"unknown"``.
222
+ """
223
+ name = base_cmap_name(cmap)
224
+ if name in CMAP_BANNED:
225
+ return "banned"
226
+ for kind, names in (
227
+ ("sequential", CMAP_SEQUENTIAL),
228
+ ("diverging", CMAP_DIVERGING),
229
+ ("multi-sequential", CMAP_MULTI_SEQUENTIAL),
230
+ ("cyclic", CMAP_CYCLIC),
231
+ ("categorical", CMAP_CATEGORICAL),
232
+ ):
233
+ if name in names:
234
+ return kind
235
+ if name.endswith("S") and name[:-1] in CMAP_SEQUENTIAL | {"batlowK", "batlowW"}:
236
+ return "categorical"
237
+ lightness = cmap_lightness(cmap)
238
+ if is_monotonic(lightness):
239
+ return "sequential"
240
+ if diverging_ok(lightness):
241
+ return "diverging"
242
+ return "unknown"
243
+
244
+
245
+ def diverging_ok(lightness: np.ndarray, tol: float = 1.0) -> bool:
246
+ """Test whether lightness is monotonic on each side of its extreme value near the centre."""
247
+ n = lightness.size
248
+ mid = slice(n // 4, 3 * n // 4)
249
+ centre = lightness[mid]
250
+ ends = (lightness[0] + lightness[-1]) / 2
251
+ k = n // 4 + int(np.argmax(centre) if centre.mean() >= ends else np.argmin(centre))
252
+ return is_monotonic(lightness[: k + 1], tol) and is_monotonic(lightness[k:], tol)
253
+
254
+
255
+ def multi_sequential_ok(lightness: np.ndarray, tol: float = 1.0) -> bool:
256
+ """Test whether each half of a two-part colour map has monotonic lightness."""
257
+ half = lightness.size // 2
258
+ return is_monotonic(lightness[:half], tol) and is_monotonic(lightness[half:], tol)
eofigs/_countries.py ADDED
@@ -0,0 +1,157 @@
1
+ """Country outlines for locator insets: bundled default and optional Natural Earth worldviews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import urllib.error
10
+ import urllib.request
11
+ from importlib import resources
12
+ from pathlib import Path
13
+
14
+ NE_VERSION = "5.1.2"
15
+
16
+ #: Natural Earth worldviews (point-of-view country layers), by lower-case code. ``iso`` and
17
+ #: ``tlc`` are Natural Earth's ISO-based and "Top Level Country" variants.
18
+ WORLDVIEWS = (
19
+ "arg", "bdg", "bra", "chn", "deu", "egy", "esp", "fra", "gbr", "grc", "idn", "ind",
20
+ "iso", "isr", "ita", "jpn", "kor", "mar", "nep", "nld", "pak", "pol", "prt", "pse",
21
+ "rus", "sau", "swe", "tlc", "tur", "twn", "ukr", "usa", "vnm",
22
+ ) # fmt: skip
23
+
24
+ #: sha256 of each worldview file at NE_VERSION (docs/scripts/worldview_checksums.py).
25
+ WORLDVIEW_SHA256: dict[str, str] = {
26
+ "arg": "357fbb518e4288d32734087c91ac9f27d630068f7f60552d225ff64a6c04ed12",
27
+ "bdg": "38dc28eea054c58cd828ca846cd08bd31007451b5c3591df0d73483d24380ed7",
28
+ "bra": "131c5102ecf8f87f8462bb2bb50d114e3d80c3076ad61bc29122ace3fb97186d",
29
+ "chn": "16e7589083527d01208b9f645fc8643c767170258e9d13b59d37bc5a1f6a8758",
30
+ "deu": "125fc4b1f425926b8bd22dce23af6f9f873543c2fe330915ccbe0aa7fa21ff82",
31
+ "egy": "11a59f8d01367bfe49e1e65a04349dc670a4341ba9465683b10e70f34576f5ef",
32
+ "esp": "cc953fbb5ee827ca39d49940491fe48b3979a7ca4b10649c36d66948ad3ea7a0",
33
+ "fra": "e5537c00e42ef027db1be55f3cadaf97441a8edea7435c3731c742175267d48c",
34
+ "gbr": "23e586f7225a4a83b20178b78b8b8209b14bc6c385b09add44e3a2e50ac6dac3",
35
+ "grc": "dfb2253a2ee995a4f2a4ea9ae90c6b915080d85144be7a37971be33ca7096592",
36
+ "idn": "358d61ae12a92f9d42bb2193877030b4bff1193ff967ce0f0d9edb51b60acfea",
37
+ "ind": "36a54ee97a509325e98eb44fd91bc7e53b0c5139cf10039918042bbd64102192",
38
+ "iso": "9a097f5c5fe0cd2e0f1f120a507f9f8e421c6ff7e20a2e4c8621311081b55453",
39
+ "isr": "36c2af239f9a04d2d104e893ce82ca52fea7dafbceac91a09be5b9c0569ecf68",
40
+ "ita": "76f28332474ffdd7717fc67dcd8f0b2086eb8405c71fa47bf857ff42642a9615",
41
+ "jpn": "d9dde4daa1b51fef2ee76b7e7ab86eb0f5deffc6bacb64dd9cacdd6cec6e880c",
42
+ "kor": "4d7d99017cc81e5825295e781c21361c3f3b86b702d515302770924cf0c4cbf8",
43
+ "mar": "9956aa888e35c4ca8d8d10a918469b3695316cfc6528d41d48bbfbeff19f3d4b",
44
+ "nep": "4755295da115a74668886f37db1b8a6b631a28e5c30a5cf7e47d799565a6d365",
45
+ "nld": "19f257724022f2c694e80f52c7e3263da75ec0f0f0c7a39ae22bd5672a492375",
46
+ "pak": "cb6730734f7ffd64a087ec8cd801291ddf4507611c9902bd800362f2813a653b",
47
+ "pol": "eef38fb79a91f9d26e1645f761dfafe9550ec217c73ac443f824581d42f5f197",
48
+ "prt": "b1ca5db9522ba7d249b62fec5bab48afed74b42fbbcc79743a9722b7d8dcc35b",
49
+ "pse": "8a69cbfe9402ac5bbd6e24e81cce6950137e0d7524023df9f5cfbd467bfdc976",
50
+ "rus": "8309218a2d9b0b2a7f218a03fa05825a7757390ccec4d793e6e8e6a6015dd04d",
51
+ "sau": "36191159ec58c7beaca1e2508cef84155b9dccba143d621ee336b60b81e786c0",
52
+ "swe": "6ff518e3d0079108fc12996a72352d30c86b251431ec506b6573a1eebd9d6bc6",
53
+ "tlc": "07b9ea4ffe9daa6b5280dc3c3343d176db5c05c77e4975918501356ca2080ce4",
54
+ "tur": "2019fd24f5ac8dc68b90a2f26e236e5e1f5bd97b75e7e88520c3f20ac22e7fde",
55
+ "twn": "5a3a14498c8fed258452de9e207d5e226b6fd279a43ff191897674708c219e9b",
56
+ "ukr": "6fcdaed26c8d551018fbd3b97185df863a4f15f489e602fceae1dee90d6a5ae8",
57
+ "usa": "4dd67d07246421372a3ea069d0b58b8ae28fe42c6a787a18411025b68f614d03",
58
+ "vnm": "09e8e8d4fa210d8d8b5d8d0c988bf3e2ff37beda138ac97af2b044b7859ef7a2",
59
+ }
60
+
61
+ #: Simplification tolerance in degrees for downloaded worldviews (inset scale).
62
+ SIMPLIFY_DEG = 0.02
63
+
64
+
65
+ def worldview_url(code: str) -> str:
66
+ """URL of one worldview file (1:10m admin-0 countries) at the pinned version."""
67
+ return (
68
+ f"https://naciscdn.org/naturalearth/{NE_VERSION}/10m/cultural/"
69
+ f"ne_10m_admin_0_countries_{code}.zip"
70
+ )
71
+
72
+
73
+ def cache_dir() -> Path:
74
+ """Folder for downloaded data (``EOFIGS_CACHE`` overrides the platform default)."""
75
+ if os.environ.get("EOFIGS_CACHE"):
76
+ return Path(os.environ["EOFIGS_CACHE"])
77
+ if sys.platform == "win32":
78
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
79
+ return base / "eofigs" / "Cache"
80
+ if sys.platform == "darwin":
81
+ return Path.home() / "Library" / "Caches" / "eofigs"
82
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "eofigs"
83
+
84
+
85
+ def _normalise(worldview: str) -> str:
86
+ code = str(worldview).strip().lower()
87
+ if code not in WORLDVIEWS:
88
+ raise ValueError(
89
+ f"Unknown worldview {worldview!r}. Natural Earth provides: "
90
+ f"{', '.join(c.upper() for c in WORLDVIEWS)}; or pass worldview=None for the "
91
+ "default (de facto) boundaries."
92
+ )
93
+ return code
94
+
95
+
96
+ def countries(worldview: str | None = None):
97
+ """Country outlines (EPSG:4326) with ``code`` and ``name`` columns.
98
+
99
+ ``None`` returns the bundled Natural Earth 1:110m layer (de facto boundaries). A
100
+ worldview code such as ``"PAK"`` or ``"IND"`` returns that country's point of view,
101
+ downloaded once from Natural Earth (version pinned, checksum verified), simplified
102
+ to inset scale and cached.
103
+ """
104
+ import geopandas as gpd
105
+
106
+ if worldview is None:
107
+ path = resources.files("eofigs").joinpath("data/ne_110m_countries.geojson")
108
+ with resources.as_file(path) as local:
109
+ return gpd.read_file(local)
110
+ code = _normalise(worldview)
111
+ cached = cache_dir() / f"ne_{NE_VERSION}_countries_{code}.geojson"
112
+ if not cached.exists():
113
+ _build_worldview(code, cached)
114
+ return gpd.read_file(cached)
115
+
116
+
117
+ def _download(url: str, target: Path) -> None:
118
+ try:
119
+ with urllib.request.urlopen(url, timeout=60) as response, target.open("wb") as fh: # noqa: S310
120
+ while chunk := response.read(1 << 20):
121
+ fh.write(chunk)
122
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
123
+ raise RuntimeError(
124
+ f"Could not download the Natural Earth worldview file {url} ({exc}). eofigs "
125
+ "needs the internet once for each worldview; after that it is cached in "
126
+ f"{cache_dir()}. Offline, use worldview=None (bundled default boundaries) or "
127
+ "pass your own boundaries as a GeoDataFrame (countries=...)."
128
+ ) from exc
129
+
130
+
131
+ def _build_worldview(code: str, cached: Path) -> None:
132
+ import geopandas as gpd
133
+
134
+ expected = WORLDVIEW_SHA256.get(code)
135
+ if expected is None:
136
+ raise RuntimeError(f"No checksum is recorded for worldview {code.upper()!r}.")
137
+ cached.parent.mkdir(parents=True, exist_ok=True)
138
+ with tempfile.TemporaryDirectory() as tmp:
139
+ archive = Path(tmp) / f"{code}.zip"
140
+ _download(worldview_url(code), archive)
141
+ digest = hashlib.sha256(archive.read_bytes()).hexdigest()
142
+ if digest != expected:
143
+ raise RuntimeError(
144
+ f"The downloaded worldview file for {code.upper()} does not match its "
145
+ f"checksum (got {digest[:12]}..., expected {expected[:12]}...); it was not "
146
+ "used. Try again later."
147
+ )
148
+ data = gpd.read_file(f"zip://{archive.as_posix()}")
149
+ data = data.to_crs("EPSG:4326")
150
+ out = gpd.GeoDataFrame(
151
+ {"code": data["ADM0_A3"], "name": data["NAME_EN"]},
152
+ geometry=data.geometry.simplify(SIMPLIFY_DEG, preserve_topology=True),
153
+ crs="EPSG:4326",
154
+ )
155
+ partial = cached.with_suffix(".part")
156
+ out.to_file(partial, driver="GeoJSON")
157
+ partial.replace(cached)
eofigs/_presets.py ADDED
@@ -0,0 +1,147 @@
1
+ """Journal presets loaded from the bundled ``data/journals.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from importlib import resources
9
+
10
+ MM_PER_INCH = 25.4
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Journal:
15
+ """Figure requirements of one publisher, read from ``journals.json``."""
16
+
17
+ key: str
18
+ name: str
19
+ widths_mm: dict[str, float]
20
+ max_height_mm: float
21
+ min_font_pt: float
22
+ base_font_pt: float
23
+ font_family: str
24
+ fonts: tuple[str, ...]
25
+ dpi: dict[str, int]
26
+ export_dpi: int
27
+ formats: tuple[str, ...]
28
+ source_url: str
29
+ checked: str
30
+ eofigs_defaults: tuple[str, ...]
31
+ notes: str
32
+
33
+ @property
34
+ def max_width_mm(self) -> float:
35
+ """Largest named width (full page or text width)."""
36
+ return max(self.widths_mm.values())
37
+
38
+ def required_dpi(self, art: str = "combination") -> int:
39
+ """Resolution the publisher asks for one artwork type.
40
+
41
+ Parameters
42
+ ----------
43
+ art : {"combination", "line", "halftone"}
44
+ ``"line"`` for line art (plots and drawings), ``"halftone"`` for photographs
45
+ and continuous-tone images, ``"combination"`` for figures that mix both.
46
+ """
47
+ return int(self.dpi[check_art(art)])
48
+
49
+ def export_dpi_for(self, art: str = "combination") -> int:
50
+ """Default raster resolution: the export resolution or the requirement, if higher."""
51
+ return max(self.export_dpi, self.required_dpi(art))
52
+
53
+
54
+ @lru_cache(maxsize=1)
55
+ def _load() -> dict:
56
+ text = resources.files("eofigs").joinpath("data/journals.json").read_text(encoding="utf-8")
57
+ return json.loads(text)
58
+
59
+
60
+ ART_TYPES = ("combination", "line", "halftone")
61
+
62
+
63
+ def check_art(art: str) -> str:
64
+ """Validate an artwork type name.
65
+
66
+ Raises
67
+ ------
68
+ ValueError
69
+ If ``art`` is not ``"combination"``, ``"line"`` or ``"halftone"``.
70
+ """
71
+ if art not in ART_TYPES:
72
+ raise ValueError(f"art must be one of {ART_TYPES} (got {art!r}).")
73
+ return art
74
+
75
+
76
+ def journal_keys() -> list[str]:
77
+ """Names of all bundled journal presets."""
78
+ return sorted(_load()["journals"])
79
+
80
+
81
+ def get_journal(name: str | Journal) -> Journal:
82
+ """Return the preset for ``name`` (case-insensitive; aliases such as ``"tandf"`` accepted).
83
+
84
+ Raises
85
+ ------
86
+ ValueError
87
+ If no preset has that name. The message lists the available presets.
88
+ """
89
+ if isinstance(name, Journal):
90
+ return name
91
+ data = _load()
92
+ key = str(name).strip().lower()
93
+ key = data["aliases"].get(key, key)
94
+ if key not in data["journals"]:
95
+ raise ValueError(
96
+ f"Unknown journal {name!r}. Available presets: {', '.join(journal_keys())}. "
97
+ "Use 'custom' for a generic preset."
98
+ )
99
+ entry = data["journals"][key]
100
+ return Journal(
101
+ key=key,
102
+ name=entry["name"],
103
+ widths_mm={k: float(v) for k, v in entry["widths_mm"].items()},
104
+ max_height_mm=float(entry["max_height_mm"]),
105
+ min_font_pt=float(entry["min_font_pt"]),
106
+ base_font_pt=float(entry["base_font_pt"]),
107
+ font_family=entry["font_family"],
108
+ fonts=tuple(entry["fonts"]),
109
+ dpi={k: int(v) for k, v in entry["dpi"].items()},
110
+ export_dpi=int(entry["export_dpi"]),
111
+ formats=tuple(entry["formats"]),
112
+ source_url=entry["source_url"],
113
+ checked=entry["checked"],
114
+ eofigs_defaults=tuple(entry["eofigs_defaults"]),
115
+ notes=entry["notes"],
116
+ )
117
+
118
+
119
+ def resolve_width(width: str | float, journal: Journal) -> float:
120
+ """Convert a named width (``"single"``, ``"double"``, ...) or a number in mm to mm.
121
+
122
+ Raises
123
+ ------
124
+ ValueError
125
+ For an unknown width name, a non-positive width, or a width larger than the
126
+ journal's full text width.
127
+ """
128
+ if isinstance(width, str):
129
+ if width not in journal.widths_mm:
130
+ raise ValueError(
131
+ f"Unknown width {width!r} for {journal.name}. "
132
+ f"Use one of {sorted(journal.widths_mm)} or a number in mm."
133
+ )
134
+ return journal.widths_mm[width]
135
+ width_mm = float(width)
136
+ if width_mm <= 0:
137
+ raise ValueError(f"width must be positive (got {width_mm} mm).")
138
+ if width_mm > journal.max_width_mm + 0.5:
139
+ raise ValueError(
140
+ f"width {width_mm:g} mm is wider than {journal.name}'s full width "
141
+ f"({journal.max_width_mm:g} mm). Use width='{_widest(journal)}' or a smaller number."
142
+ )
143
+ return width_mm
144
+
145
+
146
+ def _widest(journal: Journal) -> str:
147
+ return max(journal.widths_mm, key=journal.widths_mm.get)