MapToPoster / src /maptoposter /typography.py
isaachwf's picture
Sync from IsaacHuo/maptoposter via hub-sync
4fb118c verified
Raw History Blame Contribute Delete
4.72 kB
"""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)