forked from originalankur/maptoposter
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlayout.py
More file actions
175 lines (147 loc) · 6.02 KB
/
Copy pathlayout.py
File metadata and controls
175 lines (147 loc) · 6.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
"""Typography layout engine: text-block placement, title fitting, logo placement."""
from dataclasses import dataclass
from matplotlib.font_manager import FontProperties
from PIL import Image
# Base font sizes at unit scale (min(width, height) == 12 in)
BASE_MAIN = 60
BASE_SUB = 22
BASE_COORDS = 14
BASE_ATTR = 8
# Vertical metrics in inches at unit scale. These reproduce the legacy
# 12x16 layout exactly: 1.12/16 = 0.07, 2.24/16 = 0.14, 4.0/16 = 0.25.
_COORDS_Y_IN = 1.12
_COUNTRY_Y_IN = 1.60
_DIVIDER_Y_IN = 2.00
_TITLE_Y_IN = 2.24
_GRADIENT_EXTENT_IN = 4.0
MAX_TITLE_WIDTH_FRAC = 0.9
LOGO_POSITIONS = (
"top-left", "top-center", "top-right",
"bottom-left", "bottom-right", "above-title",
)
_LOGO_MARGIN = 0.03
_ABOVE_TITLE_GAP = 0.035
@dataclass(frozen=True)
class TextLayout:
title_y: float
divider_y: float
country_y: float
coords_y: float
gradient_frac: float
at_top: bool
def compute_text_layout(width_in, height_in, position="bottom", fade_height=1.0):
"""Axes-fraction y positions for the text block, or None when position is 'none'.
Metrics are defined in physical inches (scaled by min(w, h)/12) so the
block keeps its size in landscape and non-16in-tall posters.
`fade_height` scales the gradient extent only (UX spec §2.4's Height
control, 0.25-2.0). Nothing in the text block moves with it: the four y
positions above are absolute inch metrics, and `gradient_frac` is consumed
by exactly one caller -- render.create_gradient_fade's `extent_frac` -- so a
taller scrim slides under the same title rather than pushing it anywhere.
"""
if position == "none":
return None
if position not in ("bottom", "top"):
raise ValueError(f"Unknown text position '{position}'. Use bottom, top, or none.")
unit = min(width_in, height_in) / 12.0
inches = {
"title": _TITLE_Y_IN * unit,
"divider": _DIVIDER_Y_IN * unit,
"country": _COUNTRY_Y_IN * unit,
"coords": _COORDS_Y_IN * unit,
}
# = 4*min(w, h)/(12*h), i.e. exactly 1/3 when h <= w and strictly less when
# h > w -- so at the standard extent no artificial upper cap is needed.
# A fade_height above 1 is deliberately allowed past that third, and past
# the half where the two edges begin to overlap: the control is asking for a
# taller scrim, and silently refusing to give one is the control lying.
gradient_frac = _GRADIENT_EXTENT_IN * fade_height * unit / height_in
if position == "bottom":
fracs = {k: v / height_in for k, v in inches.items()}
else:
# Mirror the stack to the top edge, keeping the title topmost.
block_top = _TITLE_Y_IN * unit # title's height above block origin
fracs = {
k: (height_in - _COORDS_Y_IN * unit - (block_top - v)) / height_in
for k, v in inches.items()
}
return TextLayout(
title_y=fracs["title"],
divider_y=fracs["divider"],
country_y=fracs["country"],
coords_y=fracs["coords"],
gradient_frac=gradient_frac,
at_top=(position == "top"),
)
def is_latin_script(text):
"""True if text is primarily Latin script (moved from create_map_poster.py)."""
if not text:
return True
latin_count = 0
total_alpha = 0
for char in text:
if char.isalpha():
total_alpha += 1
if ord(char) < 0x250:
latin_count += 1
if total_alpha == 0:
return True
return (latin_count / total_alpha) > 0.8
def format_coords(lat, lon):
"""Format a lat/lon pair as poster coordinate text."""
ns = "N" if lat >= 0 else "S"
ew = "E" if lon >= 0 else "W"
return f"{abs(lat):.4f}° {ns} / {abs(lon):.4f}° {ew}"
def _font(font_path, size, fallback_weight="bold"):
if font_path:
return FontProperties(fname=font_path, size=size)
return FontProperties(family="monospace", weight=fallback_weight, size=size)
def fit_title_size(fig, ax, text, font_path, base_size, max_width_frac=MAX_TITLE_WIDTH_FRAC):
"""Largest font size <= base_size at which the title fits max_width_frac of the figure."""
renderer = fig.canvas.get_renderer()
fig_width_px = fig.get_size_inches()[0] * fig.dpi
max_width_px = max_width_frac * fig_width_px
size = float(base_size)
for _ in range(8):
probe = ax.text(0.5, -10, text, transform=ax.transAxes,
fontproperties=_font(font_path, size))
width_px = probe.get_window_extent(renderer=renderer).width
probe.remove()
if width_px <= max_width_px or size <= 8:
break
size = max(size * max_width_px / width_px, 8)
return size
@dataclass(frozen=True)
class LogoSpec:
path: str
position: str = "above-title"
size: float = 0.12
opacity: float = 1.0
def compute_logo_box(position, w_frac, h_frac, title_y=None):
"""Figure-fraction (x0, y0) lower-left corner for a logo of w_frac x h_frac."""
m = _LOGO_MARGIN
if position == "top-left":
return (m, 1 - m - h_frac)
if position == "top-center":
return ((1 - w_frac) / 2, 1 - m - h_frac)
if position == "top-right":
return (1 - m - w_frac, 1 - m - h_frac)
if position == "bottom-left":
return (m, m)
if position == "bottom-right":
return (1 - m - w_frac, m)
if position == "above-title":
base = title_y if title_y is not None else 0.07
return ((1 - w_frac) / 2, base + _ABOVE_TITLE_GAP)
raise ValueError(
f"Unknown logo position '{position}'. Available: {', '.join(LOGO_POSITIONS)}"
)
def place_logo(fig, spec, width_in, height_in, title_y=None):
"""Place a logo image on the figure as a dedicated inset axes (zorder above text)."""
image = Image.open(spec.path).convert("RGBA")
w_frac = spec.size
h_frac = w_frac * (image.height / image.width) * (width_in / height_in)
x0, y0 = compute_logo_box(spec.position, w_frac, h_frac, title_y)
logo_ax = fig.add_axes((x0, y0, w_frac, h_frac), zorder=12)
logo_ax.imshow(image, alpha=spec.opacity)
logo_ax.axis("off")