File size: 4,724 Bytes
008947a
 
 
 
 
 
 
d79b6e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
008947a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fb118c
 
 
 
 
 
 
 
 
 
 
 
 
008947a
 
 
 
 
 
 
 
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
"""Typography formatting and fitting helpers."""

from __future__ import annotations

import re


def resolve_text_positions(
    *,
    figure_height: float,
    text_rect_y: float,
    text_rect_height: float,
    title_y: float,
    subtitle_y: float,
    caption_y: float,
    coordinates_y: float,
    title_size: float,
    subtitle_size: float,
    caption_size: float,
    coordinate_size: float,
    has_title: bool,
    has_subtitle: bool,
    has_caption: bool,
    show_coordinates: bool,
) -> dict[str, float]:
    """Keep poster text boxes separated when the figure becomes short and wide.

    Layout coordinates are normalized to the figure. Font sizes are points, so
    their normalized height changes with the physical figure height. Computing
    the stack from those two units prevents the fixed portrait coordinates from
    causing overlap in landscape posters.
    """
    if figure_height <= 0:
        raise ValueError("Figure height must be greater than zero.")

    gap = 0.012

    def normalized_height(size: float) -> float:
        return size / 72 / figure_height

    heights = {
        "title": normalized_height(title_size),
        "subtitle": normalized_height(subtitle_size),
        "caption": normalized_height(caption_size),
        "coordinates": normalized_height(coordinate_size),
    }
    positions = {
        "title": title_y,
        "subtitle": subtitle_y,
        "caption": caption_y,
        "coordinates": coordinates_y,
    }
    active = [
        ("title", title_y, has_title),
        ("subtitle", subtitle_y, has_subtitle),
        ("caption", caption_y, has_caption),
        ("coordinates", coordinates_y, show_coordinates),
    ]

    previous_key: str | None = None
    for key, desired, enabled in active:
        if not enabled:
            continue
        if previous_key is not None:
            desired = min(
                desired,
                positions[previous_key]
                - heights[previous_key] / 2
                - gap
                - heights[key] / 2,
            )
        positions[key] = desired
        previous_key = key

    if has_title:
        top_limit = text_rect_y + text_rect_height - heights["title"] / 2
        positions["title"] = min(positions["title"], top_limit)

    if has_title and has_subtitle:
        title_bottom = positions["title"] - heights["title"] / 2
        subtitle_top = positions["subtitle"] + heights["subtitle"] / 2
        positions["divider"] = (title_bottom + subtitle_top) / 2
    elif has_title:
        positions["divider"] = positions["title"] - heights["title"] / 2 - gap / 2
    else:
        positions["divider"] = subtitle_y

    return positions


def has_chinese(text: str) -> bool:
    """Return whether text contains a CJK Unified Ideograph."""
    return any("\u4e00" <= char <= "\u9fff" for char in text)


def format_coordinates(latitude: float, longitude: float) -> str:
    """Format signed coordinates with cardinal directions."""
    lat_direction = "N" if latitude >= 0 else "S"
    lon_direction = "E" if longitude >= 0 else "W"
    return f"{abs(latitude):.4f}° {lat_direction} / {abs(longitude):.4f}° {lon_direction}"


def apply_letter_spacing(text: str, spacing: float) -> str:
    """Approximate tracking for Latin text without altering CJK text."""
    if spacing <= 0 or has_chinese(text):
        return text
    gap = " " if spacing < 0.16 else "  "
    return gap.join(text.upper())


def safe_slug(value: str, fallback: str = "map-poster") -> str:
    """Create a readable Unicode-safe filename slug."""
    value = value.replace("_", "-")
    value = re.sub(r"[^\w\-]+", "-", value.strip().lower(), flags=re.UNICODE)
    value = re.sub(r"-+", "-", value).strip("-_")
    return value[:80] or fallback


def safe_ascii_slug(value: str, fallback: str = "map-poster") -> str:
    """Create an ASCII-only filename slug by dropping non-ASCII characters.

    Used to build the Latin-1-safe ``filename=`` fallback of an HTTP
    ``Content-Disposition`` header, while ``safe_slug`` keeps the readable
    Unicode form transported through RFC 5987 ``filename*``.
    """
    value = value.replace("_", "-")
    value = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower())
    value = re.sub(r"-+", "-", value).strip("-_")
    return value[:80] or fallback


def fitted_font_size(text: str, requested: float, max_units: float, minimum: float = 18) -> float:
    """Return a deterministic first-pass title size for a bounded layout region."""
    if not text:
        return requested
    units = sum(1.0 if has_chinese(char) else 0.62 for char in text)
    if units <= max_units:
        return requested
    return max(minimum, requested * max_units / units)