cityposter 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. cityposter-0.1.0/LICENSE +22 -0
  2. cityposter-0.1.0/PKG-INFO +169 -0
  3. cityposter-0.1.0/README.md +125 -0
  4. cityposter-0.1.0/cityposter/__init__.py +0 -0
  5. cityposter-0.1.0/cityposter/cli.py +353 -0
  6. cityposter-0.1.0/cityposter/config.py +80 -0
  7. cityposter-0.1.0/cityposter/config_poi.py +128 -0
  8. cityposter-0.1.0/cityposter/coordinate_systems.py +97 -0
  9. cityposter-0.1.0/cityposter/data/__init__.py +0 -0
  10. cityposter-0.1.0/cityposter/data/cache.py +102 -0
  11. cityposter-0.1.0/cityposter/data/fetcher.py +115 -0
  12. cityposter-0.1.0/cityposter/data/sinogdb.py +448 -0
  13. cityposter-0.1.0/cityposter/fonts/__init__.py +0 -0
  14. cityposter-0.1.0/cityposter/fonts/manager.py +163 -0
  15. cityposter-0.1.0/cityposter/geocoding.py +166 -0
  16. cityposter-0.1.0/cityposter/poi/__init__.py +0 -0
  17. cityposter-0.1.0/cityposter/poi/categories.py +189 -0
  18. cityposter-0.1.0/cityposter/rendering/__init__.py +0 -0
  19. cityposter-0.1.0/cityposter/rendering/gradients.py +54 -0
  20. cityposter-0.1.0/cityposter/rendering/layers.py +239 -0
  21. cityposter-0.1.0/cityposter/rendering/poi.py +368 -0
  22. cityposter-0.1.0/cityposter/rendering/poster.py +499 -0
  23. cityposter-0.1.0/cityposter/rendering/typography.py +96 -0
  24. cityposter-0.1.0/cityposter/themes/__init__.py +0 -0
  25. cityposter-0.1.0/cityposter/themes/manager.py +105 -0
  26. cityposter-0.1.0/cityposter.egg-info/PKG-INFO +169 -0
  27. cityposter-0.1.0/cityposter.egg-info/SOURCES.txt +31 -0
  28. cityposter-0.1.0/cityposter.egg-info/dependency_links.txt +1 -0
  29. cityposter-0.1.0/cityposter.egg-info/entry_points.txt +2 -0
  30. cityposter-0.1.0/cityposter.egg-info/requires.txt +32 -0
  31. cityposter-0.1.0/cityposter.egg-info/top_level.txt +1 -0
  32. cityposter-0.1.0/pyproject.toml +74 -0
  33. cityposter-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Ankur Gupta (original maptoposter)
4
+ Copyright (c) 2026 cityposter contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: cityposter
3
+ Version: 0.1.0
4
+ Summary: Generate beautiful, minimalist map posters for any city — now with offline SinoGDB support for Chinese cities.
5
+ Author-email: Ankur Gupta <originalankur@github.com>
6
+ License: MIT
7
+ Keywords: map,poster,osm,openstreetmap,art,visualization,china,sinogdb
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: aiohttp>=3.14.1
12
+ Requires-Dist: certifi==2026.1.4
13
+ Requires-Dist: charset-normalizer==3.4.4
14
+ Requires-Dist: contourpy==1.3.3
15
+ Requires-Dist: cycler==0.12.1
16
+ Requires-Dist: fonttools==4.61.1
17
+ Requires-Dist: geocode-amap>=0.1.0
18
+ Requires-Dist: geographiclib==2.1
19
+ Requires-Dist: geopandas==1.1.2
20
+ Requires-Dist: geopy==2.4.1
21
+ Requires-Dist: idna==3.11
22
+ Requires-Dist: kiwisolver==1.4.9
23
+ Requires-Dist: lat-lon-parser==1.3.1
24
+ Requires-Dist: matplotlib==3.10.8
25
+ Requires-Dist: networkx==3.6.1
26
+ Requires-Dist: numpy==2.4.0
27
+ Requires-Dist: osmnx==2.0.7
28
+ Requires-Dist: packaging==25.0
29
+ Requires-Dist: pandas==2.3.3
30
+ Requires-Dist: pillow==12.1.0
31
+ Requires-Dist: pyogrio==0.12.1
32
+ Requires-Dist: pyparsing==3.3.1
33
+ Requires-Dist: pyproj==3.7.2
34
+ Requires-Dist: python-dateutil==2.9.0.post0
35
+ Requires-Dist: pytz==2025.2
36
+ Requires-Dist: requests==2.32.5
37
+ Requires-Dist: scipy==1.16.3
38
+ Requires-Dist: shapely==2.1.2
39
+ Requires-Dist: six==1.17.0
40
+ Requires-Dist: tqdm==4.67.1
41
+ Requires-Dist: tzdata==2025.3
42
+ Requires-Dist: urllib3==2.6.3
43
+ Dynamic: license-file
44
+
45
+ # CityPoster — 城市地图海报生成器
46
+
47
+ 基于 [maptoposter](https://github.com/originalankur/maptoposter) 改造,专注中国城市离线地图海报。
48
+
49
+ ## 安装
50
+
51
+ ```bash
52
+ git clone https://github.com/songwupei/cityposter.git
53
+ cd cityposter
54
+ uv sync
55
+ ```
56
+
57
+ ### SinoGDB 数据 (中国城市离线生成)
58
+
59
+ 下载 [SinoGDB-PackSet](https://docs.qq.com/smartsheet/DSHBNQ1FUTEZJek9q),设置路径:
60
+
61
+ ```bash
62
+ export SINOGDB_DIR="$HOME/Downloads/SinoGDB-PackSet_gpkg_20260208"
63
+ ```
64
+
65
+ ## 使用
66
+
67
+ ```bash
68
+ # 中国城市 — 完全离线,秒级出图
69
+ cityposter --lat 39.9042 --lon 116.4074 -dc "北京" -dC "中国" -t noir --poi
70
+ cityposter --lat 30.5728 --lon 104.0668 -dc "成都" -dC "中国" -t gongqiang --poi
71
+
72
+ # 国际城市 — 走 OSMnx
73
+ cityposter -c "Paris" -C "France" -t pastel_dream -d 10000
74
+ ```
75
+
76
+ ### 参数
77
+
78
+ | 参数 | 说明 | 默认 |
79
+ |------|------|------|
80
+ | `--lat`, `--lon` | 中心坐标 (WGS-84) | |
81
+ | `--gcj02` | 坐标视为 GCJ-02 | |
82
+ | `-dc`, `-dC` | 显示名称 | |
83
+ | `-t` | 主题 | terracotta |
84
+ | `-d` | 半径 (米) | 8000(中国) |
85
+ | `--poi` | POI 标注 | 关闭 |
86
+ | | 开启后显示:地铁站、火车站、景点、学校、医院等 | |
87
+
88
+ ### `--poi` 详情
89
+
90
+ 开启后从 SinoGDB 拉取以下数据并标注(最多 50 个标记、15 个标签):
91
+
92
+ | 数据源 | 类型 | 标记 |
93
+ |--------|------|------|
94
+ | `railway_point` | 地铁站 | ○ 空心圆环 + 站名 |
95
+ | | 火车站 | ■ 方块 |
96
+ | `transport_point` | 公交站/机场/港口 | ●/◆ 按类型 |
97
+ | `natural_point` | 山峰/洞穴/温泉 | ▲/★ |
98
+ | `landuse` (学校/医院/寺庙) | 学校/医院/寺庙 | ●/■/▲ |
99
+
100
+ 不加 `--poi` 则不显示任何标注,纯地图。
101
+
102
+ ### 自定义 POI
103
+
104
+ 在 `config/custom-poi/` 放置 JSON 文件,`--poi` 时自动加载:
105
+
106
+ ```json
107
+ [
108
+ {"name": "故宫", "lat": 39.9163, "lon": 116.3972, "category": "landmark"},
109
+ {"name": "天安门", "lat": 39.9087, "lon": 116.3975, "category": "sightseeing_spot"}
110
+ ]
111
+ ```
112
+
113
+ 支持 category: `landmark`, `sightseeing_spot`, `museum`, `park`, `temple`, `school`, `cafe`, `mall` 等。
114
+
115
+ | `-W`, `-H` | 画布 (英寸) | 12x16 |
116
+ | `-f` | png/svg | png |
117
+ | `--font-family` | Google Fonts | Noto Sans SC(中国) |
118
+
119
+ ### 所有主题 (41)
120
+
121
+ | 主题 | 名称 | 风格 |
122
+ |------|------|------|
123
+ | `autumn` | Autumn | 秋日橙红 |
124
+ | `blueprint` | Blueprint | 建筑蓝图 |
125
+ | `brutalist_concrete` | Brutalist Concrete | 粗野主义 |
126
+ | `carbon_fiber` | Carbon Fiber | 碳纤维黑 |
127
+ | `contrast_zones` | Contrast Zones | 高对比度 |
128
+ | `copper_patina` | Copper Patina | 铜绿氧化 |
129
+ | `cotton_candy` | Cotton Candy | 棉花糖粉紫 |
130
+ | `cyberpunk_neon` | Cyberpunk Neon | 赛博朋克 |
131
+ | `desert_rose` | Desert Rose | 沙漠玫瑰 |
132
+ | `emerald` | Emerald City | 翡翠绿 |
133
+ | `forest` | Forest | 森林绿 |
134
+ | `forest_moss` | Forest Moss | 苔藓绿金 |
135
+ | `gilded_noir` | Gilded Noir | 黑金奢华 |
136
+ | `glitch_purple` | Glitch Purple | 故障紫绿 |
137
+ | `gongqiang` | 宫墙 Gongqiang | 红底鎏金 |
138
+ | `gradient_roads` | Gradient Roads | 渐变道路 |
139
+ | `japanese_ink` | Japanese Ink | 日式水墨 |
140
+ | `lavender_mist` | Lavender Mist | 薰衣草雾 |
141
+ | `matcha_latte` | Matcha Latte | 抹茶拿铁 |
142
+ | `mediterranean_summer` | Mediterranean Summer | 地中海蓝橙 |
143
+ | `mediterranean_summer_plus` | Mediterranean Summer + Rail | 地中海+铁路 |
144
+ | `midnight_blue` | Midnight Blue | 午夜蓝金 |
145
+ | `monochrome_blue` | Monochrome Blue | 单色蓝 |
146
+ | `neon_cyberpunk` | Neon Cyberpunk | 霓虹赛博朋克 |
147
+ | `noir` | Noir | 纯黑白 |
148
+ | `nordic_frost` | Nordic Frost | 北欧冰霜 |
149
+ | `ocean` | Ocean | 海洋蓝 |
150
+ | `ocean_abyss` | Ocean Abyss | 深海深渊 |
151
+ | `pastel_dream` | Pastel Dream | 梦幻粉彩 |
152
+ | `qingzhuan` | 青砖 Qingzhuan | 胡同灰砖 |
153
+ | `red_alert` | Red Alert | 红色警报 |
154
+ | `royal_velvet` | Royal Velvet | 皇家天鹅绒 |
155
+ | `sakura_branch` | Sakura Branch | 樱花粉 |
156
+ | `shuimo` | 水墨 Shuimo | 水墨白底 |
157
+ | `solarized_dark` | Solarized Dark | Solarized 暗色 |
158
+ | `sulfur_slate` | Sulfur & Slate | 硫磺金灰 |
159
+ | `sunset` | Sunset | 落日橙粉 |
160
+ | `terra_clay` | Terra Clay | 陶土暖色 |
161
+ | `terracotta` | Terracotta | 地中海陶 |
162
+ | `vintage_nautical` | Vintage Nautical | 复古航海 |
163
+ | `warm_beige` | Warm Beige | 暖米色 |
164
+
165
+ ## Credit
166
+
167
+ Forked from [maptoposter](https://github.com/originalankur/maptoposter) by Ankur Gupta. MIT License.
168
+
169
+ SinoGDB data derived from OpenStreetMap, processed by QGIS.
@@ -0,0 +1,125 @@
1
+ # CityPoster — 城市地图海报生成器
2
+
3
+ 基于 [maptoposter](https://github.com/originalankur/maptoposter) 改造,专注中国城市离线地图海报。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ git clone https://github.com/songwupei/cityposter.git
9
+ cd cityposter
10
+ uv sync
11
+ ```
12
+
13
+ ### SinoGDB 数据 (中国城市离线生成)
14
+
15
+ 下载 [SinoGDB-PackSet](https://docs.qq.com/smartsheet/DSHBNQ1FUTEZJek9q),设置路径:
16
+
17
+ ```bash
18
+ export SINOGDB_DIR="$HOME/Downloads/SinoGDB-PackSet_gpkg_20260208"
19
+ ```
20
+
21
+ ## 使用
22
+
23
+ ```bash
24
+ # 中国城市 — 完全离线,秒级出图
25
+ cityposter --lat 39.9042 --lon 116.4074 -dc "北京" -dC "中国" -t noir --poi
26
+ cityposter --lat 30.5728 --lon 104.0668 -dc "成都" -dC "中国" -t gongqiang --poi
27
+
28
+ # 国际城市 — 走 OSMnx
29
+ cityposter -c "Paris" -C "France" -t pastel_dream -d 10000
30
+ ```
31
+
32
+ ### 参数
33
+
34
+ | 参数 | 说明 | 默认 |
35
+ |------|------|------|
36
+ | `--lat`, `--lon` | 中心坐标 (WGS-84) | |
37
+ | `--gcj02` | 坐标视为 GCJ-02 | |
38
+ | `-dc`, `-dC` | 显示名称 | |
39
+ | `-t` | 主题 | terracotta |
40
+ | `-d` | 半径 (米) | 8000(中国) |
41
+ | `--poi` | POI 标注 | 关闭 |
42
+ | | 开启后显示:地铁站、火车站、景点、学校、医院等 | |
43
+
44
+ ### `--poi` 详情
45
+
46
+ 开启后从 SinoGDB 拉取以下数据并标注(最多 50 个标记、15 个标签):
47
+
48
+ | 数据源 | 类型 | 标记 |
49
+ |--------|------|------|
50
+ | `railway_point` | 地铁站 | ○ 空心圆环 + 站名 |
51
+ | | 火车站 | ■ 方块 |
52
+ | `transport_point` | 公交站/机场/港口 | ●/◆ 按类型 |
53
+ | `natural_point` | 山峰/洞穴/温泉 | ▲/★ |
54
+ | `landuse` (学校/医院/寺庙) | 学校/医院/寺庙 | ●/■/▲ |
55
+
56
+ 不加 `--poi` 则不显示任何标注,纯地图。
57
+
58
+ ### 自定义 POI
59
+
60
+ 在 `config/custom-poi/` 放置 JSON 文件,`--poi` 时自动加载:
61
+
62
+ ```json
63
+ [
64
+ {"name": "故宫", "lat": 39.9163, "lon": 116.3972, "category": "landmark"},
65
+ {"name": "天安门", "lat": 39.9087, "lon": 116.3975, "category": "sightseeing_spot"}
66
+ ]
67
+ ```
68
+
69
+ 支持 category: `landmark`, `sightseeing_spot`, `museum`, `park`, `temple`, `school`, `cafe`, `mall` 等。
70
+
71
+ | `-W`, `-H` | 画布 (英寸) | 12x16 |
72
+ | `-f` | png/svg | png |
73
+ | `--font-family` | Google Fonts | Noto Sans SC(中国) |
74
+
75
+ ### 所有主题 (41)
76
+
77
+ | 主题 | 名称 | 风格 |
78
+ |------|------|------|
79
+ | `autumn` | Autumn | 秋日橙红 |
80
+ | `blueprint` | Blueprint | 建筑蓝图 |
81
+ | `brutalist_concrete` | Brutalist Concrete | 粗野主义 |
82
+ | `carbon_fiber` | Carbon Fiber | 碳纤维黑 |
83
+ | `contrast_zones` | Contrast Zones | 高对比度 |
84
+ | `copper_patina` | Copper Patina | 铜绿氧化 |
85
+ | `cotton_candy` | Cotton Candy | 棉花糖粉紫 |
86
+ | `cyberpunk_neon` | Cyberpunk Neon | 赛博朋克 |
87
+ | `desert_rose` | Desert Rose | 沙漠玫瑰 |
88
+ | `emerald` | Emerald City | 翡翠绿 |
89
+ | `forest` | Forest | 森林绿 |
90
+ | `forest_moss` | Forest Moss | 苔藓绿金 |
91
+ | `gilded_noir` | Gilded Noir | 黑金奢华 |
92
+ | `glitch_purple` | Glitch Purple | 故障紫绿 |
93
+ | `gongqiang` | 宫墙 Gongqiang | 红底鎏金 |
94
+ | `gradient_roads` | Gradient Roads | 渐变道路 |
95
+ | `japanese_ink` | Japanese Ink | 日式水墨 |
96
+ | `lavender_mist` | Lavender Mist | 薰衣草雾 |
97
+ | `matcha_latte` | Matcha Latte | 抹茶拿铁 |
98
+ | `mediterranean_summer` | Mediterranean Summer | 地中海蓝橙 |
99
+ | `mediterranean_summer_plus` | Mediterranean Summer + Rail | 地中海+铁路 |
100
+ | `midnight_blue` | Midnight Blue | 午夜蓝金 |
101
+ | `monochrome_blue` | Monochrome Blue | 单色蓝 |
102
+ | `neon_cyberpunk` | Neon Cyberpunk | 霓虹赛博朋克 |
103
+ | `noir` | Noir | 纯黑白 |
104
+ | `nordic_frost` | Nordic Frost | 北欧冰霜 |
105
+ | `ocean` | Ocean | 海洋蓝 |
106
+ | `ocean_abyss` | Ocean Abyss | 深海深渊 |
107
+ | `pastel_dream` | Pastel Dream | 梦幻粉彩 |
108
+ | `qingzhuan` | 青砖 Qingzhuan | 胡同灰砖 |
109
+ | `red_alert` | Red Alert | 红色警报 |
110
+ | `royal_velvet` | Royal Velvet | 皇家天鹅绒 |
111
+ | `sakura_branch` | Sakura Branch | 樱花粉 |
112
+ | `shuimo` | 水墨 Shuimo | 水墨白底 |
113
+ | `solarized_dark` | Solarized Dark | Solarized 暗色 |
114
+ | `sulfur_slate` | Sulfur & Slate | 硫磺金灰 |
115
+ | `sunset` | Sunset | 落日橙粉 |
116
+ | `terra_clay` | Terra Clay | 陶土暖色 |
117
+ | `terracotta` | Terracotta | 地中海陶 |
118
+ | `vintage_nautical` | Vintage Nautical | 复古航海 |
119
+ | `warm_beige` | Warm Beige | 暖米色 |
120
+
121
+ ## Credit
122
+
123
+ Forked from [maptoposter](https://github.com/originalankur/maptoposter) by Ankur Gupta. MIT License.
124
+
125
+ SinoGDB data derived from OpenStreetMap, processed by QGIS.
File without changes
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI entry point for maptoposter.
4
+ """
5
+
6
+ import argparse
7
+ import sys
8
+ import os
9
+ import traceback
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from cityposter.config import (
14
+ DEFAULT_THEME,
15
+ DEFAULT_DISTANCE_M,
16
+ DEFAULT_WIDTH_IN,
17
+ DEFAULT_HEIGHT_IN,
18
+ POSTERS_DIR,
19
+ CHINA_DEFAULT_DISTANCE_M,
20
+ )
21
+ from cityposter.themes.manager import load_theme, get_available_themes, list_themes
22
+ from cityposter.fonts.manager import load_fonts
23
+ from cityposter.rendering.poster import (
24
+ create_poster_osmnx,
25
+ create_poster_sinogdb,
26
+ generate_output_filename,
27
+ )
28
+ from cityposter.coordinate_systems import is_out_of_china, gcj02_to_wgs84, wgs84_to_gcj02
29
+ from cityposter.data.sinogdb import fetch_china_city_data, SinoGDBReader
30
+
31
+
32
+ def parse_coord(val: str) -> float:
33
+ """Parse a coordinate value supporting DMS and decimal formats."""
34
+ try:
35
+ return float(val)
36
+ except ValueError:
37
+ from lat_lon_parser import parse
38
+ return parse(val)
39
+
40
+
41
+ def main():
42
+ parser = argparse.ArgumentParser(
43
+ description="Generate beautiful map posters for any city",
44
+ formatter_class=argparse.RawDescriptionHelpFormatter,
45
+ epilog="""
46
+ Examples:
47
+ # Chinese city with SinoGDB (offline, fast)
48
+ maptoposter --lat 39.9042 --lon 116.4074 -dc "北京" -dC "中国" --font-family "Noto Sans SC"
49
+
50
+ # International city with OSMnx
51
+ maptoposter -c "Paris" -C "France" -t pastel_dream -d 10000
52
+
53
+ # GCJ-02 coordinates from Amap/Gaode
54
+ maptoposter --lat 39.9151 --lon 116.4040 --gcj02 -dc "北京" -dC "中国"
55
+ """,
56
+ )
57
+
58
+ # Location
59
+ parser.add_argument("--city", "-c", type=str, help="City name")
60
+ parser.add_argument("--country", "-C", type=str, help="Country name")
61
+ parser.add_argument("--latitude", "-lat", type=str, help="Latitude (WGS-84 or GCJ-02 with --gcj02)")
62
+ parser.add_argument("--longitude", "-lon", type=str, help="Longitude (WGS-84 or GCJ-02 with --gcj02)")
63
+ parser.add_argument("--display-city", "-dc", type=str, help="Display name for city")
64
+ parser.add_argument("--display-country", "-dC", type=str, help="Display name for country")
65
+ parser.add_argument("--country-label", type=str, help="Override country text on poster")
66
+
67
+ # Data source
68
+ parser.add_argument("--data-source", choices=["sinogdb", "osmnx", "overpass"],
69
+ default=None,
70
+ help="Data source (sinogdb for China, osmnx/overpass for international)")
71
+ parser.add_argument("--sinogdb-dir", type=str,
72
+ help="SinoGDB data directory path")
73
+
74
+ # Theme
75
+ parser.add_argument("--theme", "-t", type=str, default=DEFAULT_THEME, help="Theme name")
76
+ parser.add_argument("--all-themes", action="store_true", help="Generate posters for all themes")
77
+ parser.add_argument("--list-themes", action="store_true", help="List all available themes")
78
+
79
+ # Map geometry
80
+ parser.add_argument("--distance", "-d", type=int, default=None,
81
+ help="Map radius in meters")
82
+ parser.add_argument("--width", "-W", type=float, default=DEFAULT_WIDTH_IN,
83
+ help="Image width in inches (max 20)")
84
+ parser.add_argument("--height", "-H", type=float, default=DEFAULT_HEIGHT_IN,
85
+ help="Image height in inches (max 20)")
86
+
87
+ # Format
88
+ parser.add_argument("--format", "-f", default="png", choices=["png", "svg", "pdf"],
89
+ help="Output format (default: png)")
90
+
91
+ # Fonts
92
+ parser.add_argument("--font-family", type=str,
93
+ help='Google Fonts family name (e.g., "Noto Sans SC")')
94
+
95
+ # China-specific
96
+ parser.add_argument("--gcj02", action="store_true",
97
+ help="Treat input coordinates as GCJ-02 (convert to WGS-84 for OSM data)")
98
+
99
+ # Road styling
100
+ parser.add_argument("--road-width-boost", type=float, default=None,
101
+ help="Multiplier for road line widths (default: 2.5 for sinogdb, 1.0 for osmnx)")
102
+
103
+ # POI
104
+ parser.add_argument("--poi", action="store_true", default=False,
105
+ help="Show all POI markers (landmarks, nature, schools, etc.). Default: subway + train only")
106
+ parser.add_argument("--custom-poi", nargs="?", dest="custom_pois", default=None,
107
+ const=[], metavar="NAME,LAT,LON,CATEGORY",
108
+ help="Load config/custom-poi/ POIs and optional CLI custom POI")
109
+
110
+ args = parser.parse_args()
111
+
112
+ # ── No args: show help ──────────────────────────────────
113
+ if len(sys.argv) == 1:
114
+ parser.print_help()
115
+ return
116
+
117
+ # ── List themes ─────────────────────────────────────────
118
+ if args.list_themes:
119
+ list_themes()
120
+ return
121
+
122
+ # ── Validate location ───────────────────────────────────
123
+ if not args.latitude or not args.longitude:
124
+ if not args.city or not args.country:
125
+ print("Error: Provide either (--lat AND --lon) or (--city AND --country).\n")
126
+ parser.print_help()
127
+ sys.exit(1)
128
+
129
+ # ── Parse coordinates ───────────────────────────────────
130
+ lat = parse_coord(args.latitude) if args.latitude else None
131
+ lon = parse_coord(args.longitude) if args.longitude else None
132
+
133
+ # GCJ-02 conversion
134
+ if args.gcj02 and lat is not None and lon is not None:
135
+ print(f"Input (GCJ-02): {lat:.6f}, {lon:.6f}")
136
+ lon, lat = gcj02_to_wgs84(lon, lat)
137
+ print(f"Converted (WGS-84): {lat:.6f}, {lon:.6f}")
138
+
139
+ # ── Auto-detect data source ─────────────────────────────
140
+ data_source = args.data_source
141
+ if data_source is None:
142
+ if lat is not None and lon is not None and not is_out_of_china(lon, lat):
143
+ data_source = "sinogdb"
144
+ print("✓ Auto-detected China location → using SinoGDB (offline)")
145
+ else:
146
+ data_source = "osmnx"
147
+
148
+ # ── Distance defaults ───────────────────────────────────
149
+ if args.distance is None:
150
+ if data_source == "sinogdb":
151
+ args.distance = CHINA_DEFAULT_DISTANCE_M
152
+ else:
153
+ args.distance = DEFAULT_DISTANCE_M
154
+
155
+ # ── Dimension limits ────────────────────────────────────
156
+ if args.width > 20:
157
+ print(f"⚠ Width {args.width} exceeds max (20). Using 20.")
158
+ args.width = 20.0
159
+ if args.height > 20:
160
+ print(f"⚠ Height {args.height} exceeds max (20). Using 20.")
161
+ args.height = 20.0
162
+
163
+ # ── Themes ──────────────────────────────────────────────
164
+ available_themes = get_available_themes()
165
+ if not available_themes:
166
+ print("No themes found in 'themes/' directory.")
167
+ sys.exit(1)
168
+
169
+ if args.all_themes:
170
+ themes_to_generate = available_themes
171
+ else:
172
+ if args.theme not in available_themes:
173
+ print(f"Error: Theme '{args.theme}' not found.")
174
+ print(f"Available: {', '.join(available_themes)}")
175
+ sys.exit(1)
176
+ themes_to_generate = [args.theme]
177
+
178
+ # ── Display names ───────────────────────────────────────
179
+ display_city = args.display_city or args.city or ""
180
+ display_country = args.display_country or args.country_label or args.country or ""
181
+
182
+ # Coordinates for display — use GCJ-02 if China
183
+ display_lat, display_lon = lat, lon
184
+ if data_source == "sinogdb" and not args.gcj02:
185
+ # Data is WGS-84; convert to GCJ-02 for display (matches Chinese user expectation)
186
+ display_lon, display_lat = wgs84_to_gcj02(lon, lat)
187
+
188
+ # ── Fonts ───────────────────────────────────────────────
189
+ # Priority: user-specified > LXGWNeoZhiSong + Fraunces > Noto Sans SC > Roboto
190
+ custom_fonts = None
191
+ if args.font_family:
192
+ custom_fonts = load_fonts(args.font_family)
193
+
194
+ if data_source == "sinogdb" and not custom_fonts:
195
+ # 1st: local LXGWNeoZhiSong (CJK serif) + Fraunces (Latin serif)
196
+ lxgw = Path("fonts/LXGWNeoZhiSong.ttf")
197
+ fraunces = Path("fonts/Fraunces_72pt-Regular.ttf")
198
+ if lxgw.exists():
199
+ custom_fonts = {
200
+ "bold": str(lxgw), # LXGW covers both CJK + Latin
201
+ "regular": str(lxgw),
202
+ "light": str(lxgw),
203
+ }
204
+ print(f"✓ Using LXGW Neo ZhiSong + Fraunces")
205
+ else:
206
+ # 2nd: Google Fonts Noto Sans SC
207
+ print("Auto-loading Chinese font...")
208
+ custom_fonts = load_fonts("Noto Sans SC")
209
+ if not custom_fonts:
210
+ # 3rd: local cache
211
+ cached = Path("fonts/cache")
212
+ local = {
213
+ "bold": str(cached / "noto_sans_sc_bold.ttf"),
214
+ "regular": str(cached / "noto_sans_sc_regular.ttf"),
215
+ "light": str(cached / "noto_sans_sc_light.ttf"),
216
+ }
217
+ if all(Path(p).exists() for p in local.values()):
218
+ custom_fonts = local
219
+ print("✓ Using cached Noto Sans SC")
220
+
221
+ # ── Generate ────────────────────────────────────────────
222
+ print("=" * 50)
223
+ print("City Map Poster Generator")
224
+ print(f"Data source: {data_source}")
225
+ print("=" * 50)
226
+
227
+ # Resolve city/country names
228
+ city_name = args.city or args.display_city or f"{lat:.4f},{lon:.4f}"
229
+ country_name = args.country or args.display_country or ""
230
+
231
+ # Coordinates
232
+ if lat is None or lon is None:
233
+ # Need geocoding (OSMnx path)
234
+ from cityposter.geocoding import get_coordinates
235
+ lat, lon = get_coordinates(args.city, args.country)
236
+ display_lat, display_lon = lat, lon
237
+
238
+ point = (lat, lon)
239
+ display_point = (display_lat, display_lon)
240
+
241
+ # Build reproduction shell command (will be updated per theme)
242
+ shell_cmd_base = "cityposter " + " ".join(
243
+ f'"{a}"' if " " in a else a for a in sys.argv[1:]
244
+ )
245
+
246
+ try:
247
+ for theme_name in themes_to_generate:
248
+ theme = load_theme(theme_name)
249
+ output_file = generate_output_filename(city_name, theme_name, args.format)
250
+
251
+ if data_source == "sinogdb":
252
+ print(f"\nGenerating SinoGDB poster for {display_city or city_name}...")
253
+ sino_data = fetch_china_city_data(
254
+ lat, lon, args.distance,
255
+ data_dir=args.sinogdb_dir,
256
+ include_pois=True, # always fetch railway/transport for custom POIs too
257
+ include_buildings=False,
258
+ )
259
+ # Inject custom POIs (only when --custom-poi flag is used)
260
+ if args.custom_pois is not None:
261
+ from geopandas import GeoDataFrame
262
+ from shapely.geometry import Point as ShapelyPoint
263
+
264
+ # 1. CLI inline custom-poi value
265
+ if args.custom_pois: # non-empty list
266
+ spec = args.custom_pois if isinstance(args.custom_pois, str) else args.custom_pois[0]
267
+ parts = spec.split(",")
268
+ if len(parts) >= 4:
269
+ name, clat, clon, cat = parts[0], float(parts[1]), float(parts[2]), parts[3]
270
+ cli_gdf = GeoDataFrame(
271
+ [{"name": name, "station": cat, "geometry": ShapelyPoint(clon, clat)}],
272
+ crs="EPSG:4326"
273
+ )
274
+ if sino_data.get("railway_stations") is not None:
275
+ sino_data["railway_stations"] = GeoDataFrame(
276
+ list(sino_data["railway_stations"].to_dict("records")) +
277
+ cli_gdf.to_dict("records"), crs="EPSG:4326"
278
+ )
279
+ else:
280
+ sino_data["railway_stations"] = cli_gdf
281
+ print(f"✓ Custom POI: {name}")
282
+
283
+ # 2. Config file POIs
284
+ from cityposter.config_poi import load_custom_pois
285
+ config_gdf = load_custom_pois(city=display_city)
286
+ if not config_gdf.empty:
287
+ config_records = config_gdf.to_dict("records")
288
+ if sino_data.get("railway_stations") is not None and not sino_data["railway_stations"].empty:
289
+ sino_data["railway_stations"] = GeoDataFrame(
290
+ list(sino_data["railway_stations"].to_dict("records")) + config_records,
291
+ crs="EPSG:4326"
292
+ )
293
+ else:
294
+ sino_data["railway_stations"] = config_gdf
295
+ print(f"✓ Custom POI: {len(config_gdf)} from config/")
296
+ # Default road width boost for SinoGDB: thicker lines compensate for
297
+ # the fact that SinoGDB roads are individual line segments (vs OSMnx graph edges)
298
+ road_boost = args.road_width_boost if args.road_width_boost is not None else 2.5
299
+ create_poster_sinogdb(
300
+ city=display_city,
301
+ country=display_country,
302
+ sino_data=sino_data,
303
+ point=display_point,
304
+ dist=args.distance,
305
+ theme=theme,
306
+ output_file=output_file,
307
+ output_format=args.format,
308
+ width=args.width,
309
+ height=args.height,
310
+ display_city=display_city,
311
+ display_country=display_country,
312
+ fonts=custom_fonts,
313
+ road_width_boost=road_boost,
314
+ include_pois=args.poi,
315
+ shell_cmd=shell_cmd_base,
316
+ )
317
+ else:
318
+ # OSMnx path (existing)
319
+ from cityposter.data.fetcher import fetch_osmnx_data
320
+ print(f"\nGenerating OSMnx poster for {display_city or city_name}...")
321
+ g, water, parks = fetch_osmnx_data(point, args.distance, args.width, args.height)
322
+
323
+ create_poster_osmnx(
324
+ city=display_city,
325
+ country=display_country,
326
+ g=g,
327
+ water=water,
328
+ parks=parks,
329
+ point=display_point,
330
+ dist=args.distance,
331
+ theme=theme,
332
+ output_file=output_file,
333
+ output_format=args.format,
334
+ width=args.width,
335
+ height=args.height,
336
+ display_city=display_city,
337
+ display_country=display_country,
338
+ fonts=custom_fonts,
339
+ shell_cmd=shell_cmd_base,
340
+ )
341
+
342
+ print("\n" + "=" * 50)
343
+ print("✓ Poster generation complete!")
344
+ print("=" * 50)
345
+
346
+ except Exception as e:
347
+ print(f"\n✗ Error: {e}")
348
+ traceback.print_exc()
349
+ sys.exit(1)
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()