你說:
I have a leaflet map app that runs in a standalone HTML file. I'd like to run the app in a Python GUI app by using "from HTML_CONTENT import html_content" so that the HTML need not be modified. Is it possible?
Yes, it’s possible 👍 — you can embed your standalone Leaflet HTML file inside a Python GUI app without modifying the HTML, by treating the HTML as a string (html_content
) and loading it in a browser widget.
The approach depends on which GUI toolkit you want to use:
pythonfrom PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtCore import QUrl
import sys
# Suppose you have HTML_CONTENT.py with:
# html_content = """<html> ... your leaflet map ... </html>"""
from HTML_CONTENT import html_content
app = QApplication(sys.argv)
view = QWebEngineView()
view.setHtml(html_content, QUrl("file:///")) # Base URL important if leaflet loads local assets
view.resize(800, 600)
view.show()
sys.exit(app.exec_())
If your Leaflet HTML references local files (.js
, .css
, images, GPX, etc.), you need to provide a base URL so it can find them.
Easiest: put everything in the same folder and use QUrl.fromLocalFile("path/to/folder/")
.
Exactly the same as PyQt5/6 but with PySide6
imports.
Tkinter’s built-in browser support is weak. You can use tkhtmlview
, but Leaflet (JS-heavy) usually won’t work properly in it. So I don’t recommend Tkinter if you want a fully working Leaflet map.
PyWebview is a wrapper around a real browser engine (Edge/Chromium/WebKit). Perfect for loading full HTML/JS apps.
pythonimport webview
from HTML_CONTENT import html_content
# Load string as HTML
webview.create_window("Leaflet Map", html=html_content, width=800, height=600)
webview.start()
Since your HTML is self-contained, importing it as html_content
works fine.
If it references external files (e.g., leaflet.css
, leaflet.js
), you must make sure they’re accessible:
Either embed them inline in the HTML (you said you don’t want to modify — then keep them in the same folder and use correct src
paths).
Or serve them via a small local webserver (Flask, http.server) and point the browser widget at http://127.0.0.1:...
.
👉 Question for you: do you want the GUI app to be lightweight (so pywebview
is fine) or full-featured (so PyQt is better)?
Got it 👍 — if you want to extend your Leaflet app with interactive features like drawing a rectangle and reporting coordinates back to Python, then you’ll need a GUI option that:
Runs full Leaflet JavaScript (so Tkinter is out).
Lets JavaScript ↔ Python talk to each other.
That narrows it down to:
PyQt5/6 (QWebEngine) → very powerful, direct JS–Python bridge.
PyWebview → simpler API, also allows JS–Python communication.
HTML_CONTENT.py
:pythonhtml_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Leaflet Rectangle Example</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw/dist/leaflet.draw.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw/dist/leaflet.draw.css"/>
<style>
#map { width:100%; height:100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
var map = L.map('map').setView([25.04, 121.56], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
// Add draw control (rectangle only)
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
var drawControl = new L.Control.Draw({
draw: { rectangle: true, polygon: false, circle: false, marker: false, polyline: false },
edit: { featureGroup: drawnItems }
});
map.addControl(drawControl);
// Handle rectangle creation
map.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer;
drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
var bounds = layer.getBounds();
var topLeft = bounds.getNorthWest();
var bottomRight = bounds.getSouthEast();
// Send coordinates to Python
if (window.pywebview) {
window.pywebview.api.send_coords(topLeft.lat, topLeft.lng, bottomRight.lat, bottomRight.lng);
}
}
});
</script>
</body>
</html>
"""
pythonimport webview
from HTML_CONTENT import html_content
class API:
def send_coords(self, lat1, lon1, lat2, lon2):
print(f"Top-left: ({lat1}, {lon1}), Bottom-right: ({lat2}, {lon2})")
api = API()
webview.create_window("Leaflet App", html=html_content, js_api=api, width=800, height=600)
webview.start()
✅ When you draw a rectangle, Python will print the coordinates.
Later you can display them in a label, save them, or trigger more actions.
PyQt lets you connect via QWebChannel
so that JS calls Python directly. It’s more boilerplate but more powerful (better debugging, custom dialogs, etc.). If you think you’ll add lots of non-map GUI elements (menus, tables, buttons, file dialogs), PyQt is worth it.
👉 So my advice:
If you want to keep the app minimal and map-focused, use PyWebview.
If you plan to build a complex desktop app with menus, docked widgets, data tables, go with PyQt.
Would you like me to show you the PyQt/QWebEngine version of this rectangle-coordinates example so you can compare?
Short answer: No, you don’t have to modify your HTML to add the rectangle + coordinate feature. You can inject the needed JavaScript from Python at runtime. That said, adding a few lines to your HTML is a tiny bit simpler and more robust. Below are both ways.
python# pip install pywebview
import webview
from HTML_CONTENT import html_content # your unmodified standalone HTML (Leaflet already working)
class API:
def send_coords(self, lat1, lon1, lat2, lon2):
# Top-left = (lat1, lon1), Bottom-right = (lat2, lon2)
print(f"Top-left: ({lat1:.6f}, {lon1:.6f}), Bottom-right: ({lat2:.6f}, {lon2:.6f})")
api = API()
# 1) Create window using your existing HTML
window = webview.create_window("Leaflet App", html=html_content, js_api=api, width=1000, height=700)
# 2) JavaScript to inject Leaflet.Draw and hook up the rectangle tool.
# Assumptions:
# - Your HTML already creates a global Leaflet map object named `map`.
# - Your HTML already loads Leaflet core (L).
# This script:
# (a) loads Leaflet.Draw CSS & JS,
# (b) adds the rectangle draw control,
# (c) on creation, sends the coords back to Python via pywebview API.
injection_js = r"""
(function () {
function loadCss(href) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
function loadJs(src) {
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
// Load Leaflet.Draw (only if not present)
loadCss('https://unpkg.com/leaflet-draw/dist/leaflet.draw.css');
var drawLoaded = (window.L && L.Draw) ? Promise.resolve() :
loadJs('https://unpkg.com/leaflet-draw/dist/leaflet.draw.js');
drawLoaded.then(function () {
if (!window.map || !window.L) {
console.warn('Leaflet map not found as global `map`.');
return;
}
// Add a layer to hold drawn items
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
// Rectangle-only draw control
var drawControl = new L.Control.Draw({
draw: { rectangle: true, polygon: false, circle: false, circlemarker: false, marker: false, polyline: false },
edit: { featureGroup: drawnItems }
});
map.addControl(drawControl);
// When user draws a rectangle, send coords to Python
map.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer;
drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
var b = layer.getBounds();
var nw = b.getNorthWest(); // top-left
var se = b.getSouthEast(); // bottom-right
if (window.pywebview && window.pywebview.api && window.pywebview.api.send_coords) {
window.pywebview.api.send_coords(nw.lat, nw.lng, se.lat, se.lng);
}
}
});
}).catch(function (err) {
console.error('Failed to load Leaflet.Draw:', err);
});
})();
"""
def inject():
# Runs after the window is ready; inject our JS once (no HTML edits needed)
window.evaluate_js(injection_js)
# 3) Start the app & inject the tool
webview.start(inject)
What this does
Loads Leaflet.Draw from CDN (CSS/JS) on the fly.
Assumes your page exposes a global map
(typical Leaflet pattern).
Adds a rectangle button.
When you finish drawing, it emits send_coords(topLeft.lat, topLeft.lng, bottomRight.lat, bottomRight.lng)
to Python.
If you’re okay adding ~15 lines in your HTML, this is the cleanest. Works with both PyWebview and PyQt.
Place these in <head>
:
html<link rel="stylesheet" href="https://unpkg.com/leaflet-draw/dist/leaflet.draw.css">
<script src="https://unpkg.com/leaflet-draw/dist/leaflet.draw.js"></script>
map
is createdhtml<script>
// assumes you already have: var map = L.map('map', ...)
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
var drawControl = new L.Control.Draw({
draw: { rectangle: true, polygon: false, circle: false, circlemarker: false, marker: false, polyline: false },
edit: { featureGroup: drawnItems }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer;
drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
var b = layer.getBounds();
var nw = b.getNorthWest(); // top-left
var se = b.getSouthEast(); // bottom-right
// (A) send to Python if running inside PyWebview:
if (window.pywebview && window.pywebview.api && window.pywebview.api.send_coords) {
window.pywebview.api.send_coords(nw.lat, nw.lng, se.lat, se.lng);
}
// (B) also show on the page (optional)
var msg = 'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + '), ' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')';
console.log(msg);
var el = document.getElementById('bbox-readout');
if (el) el.textContent = msg;
}
});
</script>
Add a tiny status bar anywhere in <body>
:
html<div id="bbox-readout"
style="position:absolute; bottom:10px; left:10px; z-index:1000; background:#fff; padding:6px 10px; border-radius:8px; box-shadow:0 2px 6px rgba(0,0,0,0.2); font: 14px/1.2 system-ui;">
Draw a rectangle to see coordinates…
</div>
pythonimport webview
from HTML_CONTENT import html_content
class API:
def send_coords(self, lat1, lon1, lat2, lon2):
print(f"Top-left: ({lat1:.6f}, {lon1:.6f}), Bottom-right: ({lat2:.6f}, {lon2:.6f})")
webview.create_window("Leaflet App", html=html_content, js_api=API(), width=1000, height=700)
webview.start()
Global map
: Both approaches assume your Leaflet map instance is globally accessible as window.map
. If you created it inside a closure, expose it with window.map = map;
.
Local assets: If your HTML references local files, pass a base URL (PyQt) or make paths relative to the HTML file so the engine can resolve them.
PyQt alternative: You can do the same in PyQt (QWebEngine) using runJavaScript()
to inject Leaflet.Draw and, if you want JS→Python calls, use QWebChannel (needs a small JS snippet to initialize the channel). PyWebview is just less boilerplate.
If you tell me how your current HTML declares the map
variable, I can tailor the injection to your exact structure (no HTML edits).
Perfect 👍 glad the injection approach worked for you.
Right now, your Python console gets the coordinates via send_coords()
. If you also want them to show up directly on the map, we can extend the injected JavaScript to add a Leaflet popup or a small status div.
Add this to the injection_js right after sending the coords:
js// Show a popup on the rectangle with the coordinates
var msg = 'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + ')<br>' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')';
layer.bindPopup(msg).openPopup();
So your injection script’s rectangle event handler becomes:
jsmap.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer;
drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
var b = layer.getBounds();
var nw = b.getNorthWest(); // top-left
var se = b.getSouthEast(); // bottom-right
// Send to Python
if (window.pywebview && window.pywebview.api && window.pywebview.api.send_coords) {
window.pywebview.api.send_coords(nw.lat, nw.lng, se.lat, se.lng);
}
// Also show a popup on the rectangle
var msg = 'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + ')<br>' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')';
layer.bindPopup(msg).openPopup();
}
});
If you prefer a persistent readout (like a status bar), inject a div
into the map container:
js// Create a readout box once
var infoBox = document.createElement('div');
infoBox.id = 'bbox-readout';
infoBox.style.position = 'absolute';
infoBox.style.bottom = '10px';
infoBox.style.left = '10px';
infoBox.style.zIndex = 1000;
infoBox.style.background = 'white';
infoBox.style.padding = '6px 10px';
infoBox.style.borderRadius = '8px';
infoBox.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
infoBox.style.font = '14px/1.2 system-ui';
infoBox.textContent = 'Draw a rectangle to see coordinates…';
document.body.appendChild(infoBox);
Then update it inside the rectangle handler:
jsinfoBox.textContent = 'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) +
'), Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')';
✅ Both options can coexist: you get a popup and a persistent readout.
👉 Do you want the coordinates always visible in a floating box (like a status panel), or just as a popup on the rectangle?
That’s a really good observation 👍 — Leaflet’s default popups don’t allow text selection (they disable pointer events for text). That’s why you can’t copy the coordinates out of them.
You have a few ways to fix this:
When binding the popup, wrap the message in a <div>
with CSS that enables selection:
jsvar msg = '<div style="user-select:text; -webkit-user-select:text; -moz-user-select:text;">' +
'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + ')<br>' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')' +
'</div>';
layer.bindPopup(msg).openPopup();
Now you can highlight and copy from the popup.
Instead of just showing text, insert a button that copies the coordinates:
jsvar coordsText = 'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + ')\n' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')';
var msg = '<div style="user-select:text;">' + coordsText.replace(/\n/g, '<br>') + '</div>' +
'<button onclick="navigator.clipboard.writeText(\'' + coordsText + '\')">Copy</button>';
layer.bindPopup(msg).openPopup();
Clicking the Copy button puts the coordinates in your clipboard.
As I showed before, a <div>
in the map corner is always selectable. That way you don’t even need to open the popup to copy.
jsinfoBox.textContent = coordsText;
✅ Recommendation: if you just want quick manual copy-paste, option 1 (user-select) is enough.
If you want something smoother, option 2 (copy button) makes it one click.
Would you like me to rewrite your working injection_js to include the copy-button popup so you can drop it in directly?
Great 👍 glad the popup is now copyable.
Now you want to add a tile downloader UI directly on the Leaflet map.
That means:
A control panel on the map where the user can input:
Tile service URL (default = OSM)
Min zoom
Max zoom
A button “Download”
When pressed, your app should enumerate all tiles in the current map bounds for the chosen zoom levels, and download them (to local storage or trigger a download in browser / Python).
JavaScript-only (inside Leaflet) can only download files one by one as blobs (user gets a ZIP or multiple files).
Python side (PyWebview) can save files to disk much more easily. Best flow: JS calculates which tiles are needed → sends tile URLs to Python → Python downloads & saves them.
This way you keep your HTML untouched and only inject JS.
Leaflet allows us to add custom controls with L.Control
.
Here’s an injected control UI (small form in top-right corner):
jsvar DownloadControl = L.Control.extend({
onAdd: function(map) {
var div = L.DomUtil.create('div');
div.style.background = 'white';
div.style.padding = '6px';
div.style.borderRadius = '6px';
div.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
div.innerHTML = `
<label style="font-size:12px">Tile URL:<br>
<input id="tile-url" type="text" value="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" style="width:200px"/>
</label><br>
<label style="font-size:12px">Min zoom:
<input id="min-zoom" type="number" value="0" style="width:40px"/>
</label>
<label style="font-size:12px">Max zoom:
<input id="max-zoom" type="number" value="18" style="width:40px"/>
</label><br>
<button id="download-tiles" style="margin-top:4px; width:100%">Download</button>
`;
return div;
}
});
map.addControl(new DownloadControl({ position: 'topright' }));
When user clicks Download, JS computes all tile coordinates within current map bounds for each zoom, and generates URLs:
jsfunction latLngToTile(lat, lng, zoom) {
var x = Math.floor((lng + 180) / 360 * Math.pow(2, zoom));
var y = Math.floor(
(1 - Math.log(Math.tan(lat * Math.PI/180) + 1/Math.cos(lat * Math.PI/180)) / Math.PI) / 2 * Math.pow(2, zoom)
);
return {x:x, y:y};
}
document.getElementById('download-tiles').onclick = function() {
var urlTemplate = document.getElementById('tile-url').value;
var minZoom = parseInt(document.getElementById('min-zoom').value);
var maxZoom = parseInt(document.getElementById('max-zoom').value);
var bounds = map.getBounds();
var sw = bounds.getSouthWest();
var ne = bounds.getNorthEast();
var urls = [];
for (var z = minZoom; z <= maxZoom; z++) {
var t1 = latLngToTile(ne.lat, sw.lng, z); // top-left
var t2 = latLngToTile(sw.lat, ne.lng, z); // bottom-right
for (var x = t1.x; x <= t2.x; x++) {
for (var y = t1.y; y <= t2.y; y++) {
var s = ['a','b','c'][ (x+y) % 3 ]; // for OSM subdomains
var url = urlTemplate.replace('{s}', s).replace('{z}', z).replace('{x}', x).replace('{y}', y);
urls.push(url);
}
}
}
if (window.pywebview && window.pywebview.api && window.pywebview.api.download_tiles) {
window.pywebview.api.download_tiles(urls);
} else {
alert("Tile URLs collected: " + urls.length);
}
};
On the Python side, add a method in your API class to actually fetch and save the tiles:
pythonimport requests, os
class API:
def send_coords(self, lat1, lon1, lat2, lon2):
print(f"Top-left: ({lat1}, {lon1}), Bottom-right: ({lat2}, {lon2})")
def download_tiles(self, urls):
out_dir = "tiles"
os.makedirs(out_dir, exist_ok=True)
for url in urls:
try:
r = requests.get(url, timeout=10)
if r.status_code == 200:
parts = url.split('/')
z, x, yfile = parts[-3], parts[-2], parts[-1]
folder = os.path.join(out_dir, z, x)
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, yfile), 'wb') as f:
f.write(r.content)
except Exception as e:
print("Failed:", url, e)
print(f"Downloaded {len(urls)} tiles.")
return f"Downloaded {len(urls)} tiles."
This saves tiles in a tiles/z/x/y.png
folder structure (same as OSM).
✅ Result:
Your Leaflet map gets a visible control panel (top-right) with textboxes & button.
User specifies min/max zoom and URL template.
Python downloads all the tiles in the map’s visible bounding box for that zoom range.
👉 Question: Do you want the tiles saved in a folder structure (z/x/y.png) like OSM does, or bundled into a single SQLite/MBTiles file (which many mobile apps use)?
Excellent catch 🚀 — what you’re noticing isn’t a bug in your code execution, it’s about design choice in my last answer.
Here’s what happened:
In my snippet, I used map.getBounds()
(the current viewport bounds).
But you wanted tiles inside the rectangle you drew (top-left, bottom-right that you already capture).
So, you’re absolutely right: it’s by design, but not what you intended.
You already have this in your rectangle handler:
jsvar b = layer.getBounds();
var nw = b.getNorthWest(); // top-left
var se = b.getSouthEast(); // bottom-right
So instead of map.getBounds()
, use nw
and se
from the drawn rectangle.
Replace the download click handler with this:
jsdocument.getElementById('download-tiles').onclick = function() {
var urlTemplate = document.getElementById('tile-url').value;
var minZoom = parseInt(document.getElementById('min-zoom').value);
var maxZoom = parseInt(document.getElementById('max-zoom').value);
if (!window.selectedBounds) {
alert("Please draw a rectangle first.");
return;
}
var nw = window.selectedBounds.getNorthWest();
var se = window.selectedBounds.getSouthEast();
var urls = [];
for (var z = minZoom; z <= maxZoom; z++) {
var t1 = latLngToTile(nw.lat, nw.lng, z); // top-left of rectangle
var t2 = latLngToTile(se.lat, se.lng, z); // bottom-right of rectangle
for (var x = t1.x; x <= t2.x; x++) {
for (var y = t1.y; y <= t2.y; y++) {
var s = ['a','b','c'][ (x+y) % 3 ]; // subdomain rotation
var url = urlTemplate.replace('{s}', s).replace('{z}', z).replace('{x}', x).replace('{y}', y);
urls.push(url);
}
}
}
if (window.pywebview && window.pywebview.api && window.pywebview.api.download_tiles) {
window.pywebview.api.download_tiles(urls);
} else {
alert("Tile URLs collected: " + urls.length);
}
};
When the rectangle is created, store its bounds:
jsmap.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer;
drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
var b = layer.getBounds();
window.selectedBounds = b; // ✅ save globally for downloader
var nw = b.getNorthWest();
var se = b.getSouthEast();
if (window.pywebview && window.pywebview.api && window.pywebview.api.send_coords) {
window.pywebview.api.send_coords(nw.lat, nw.lng, se.lat, se.lng);
}
var msg = '<div style="user-select:text;">' +
'Top-left: (' + nw.lat.toFixed(6) + ', ' + nw.lng.toFixed(6) + ')<br>' +
'Bottom-right: (' + se.lat.toFixed(6) + ', ' + se.lng.toFixed(6) + ')' +
'</div>';
layer.bindPopup(msg).openPopup();
}
});
✅ Now:
User draws rectangle → bounds stored in window.selectedBounds
.
Clicking Download uses those bounds, not the whole map viewport.
The tiles downloaded should exactly cover your chosen rectangle across min→max zoom.
👉 Do you also want me to add an on-map indicator (like a label “Tiles: N” after download) so the user gets confirmation visually instead of only in the Python console?
Perfect 👍 Let’s add a status indicator directly on the map so the user sees how many tiles were downloaded, instead of relying only on console logs.
We create a little floating info box, similar to the download control:
js// Status control
var StatusControl = L.Control.extend({
onAdd: function(map) {
var div = L.DomUtil.create('div');
div.id = 'download-status';
div.style.background = 'white';
div.style.padding = '6px 10px';
div.style.borderRadius = '6px';
div.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
div.style.font = '13px system-ui';
div.textContent = 'Ready.';
return div;
}
});
map.addControl(new StatusControl({ position: 'bottomleft' }));
Now you’ll have a box in the bottom-left that always says Ready.
until updated.
After collecting URLs and calling Python, update the status box:
jsif (window.pywebview && window.pywebview.api && window.pywebview.api.download_tiles) {
document.getElementById('download-status').textContent =
'Downloading ' + urls.length + ' tiles...';
window.pywebview.api.download_tiles(urls).then(function(result){
document.getElementById('download-status').textContent = result;
});
} else {
document.getElementById('download-status').textContent =
'Collected ' + urls.length + ' tile URLs (not downloaded)';
}
Modify your Python download_tiles
to return a message string so the JS can display it:
pythondef download_tiles(self, urls):
out_dir = "tiles"
os.makedirs(out_dir, exist_ok=True)
count = 0
for url in urls:
try:
r = requests.get(url, timeout=10)
if r.status_code == 200:
parts = url.split('/')
z, x, yfile = parts[-3], parts[-2], parts[-1]
folder = os.path.join(out_dir, z, x)
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, yfile), 'wb') as f:
f.write(r.content)
count += 1
except Exception as e:
print("Failed:", url, e)
msg = f"Downloaded {count}/{len(urls)} tiles."
print(msg)
return msg # ✅ goes back to JS
✅ Now the flow is:
User draws a rectangle → selects min/max zoom → clicks Download.
Status box changes to “Downloading X tiles…”.
When Python finishes, the box updates to “Downloaded N/M tiles.”
👉 Do you want the status box to stay permanent (always visible), or disappear (fade out) after a few seconds once download finishes?
Got it ✅ — what you’re noticing is that my earlier code might have skipped edge tiles if the rectangle didn’t align perfectly with tile boundaries.
👉 Correct behavior: we should always include all tiles that intersect the rectangle, even if only partially covered. That way, the entire rectangle area is guaranteed to be filled.
The bug comes from how tile ranges are calculated:
jsvar xMin = Math.floor((nw.lng + 180) / 360 * n);
var xMax = Math.floor((se.lng + 180) / 360 * n);
var yMin = Math.floor((1 - Math.log(Math.tan(nw.lat * Math.PI/180) +
1/Math.cos(nw.lat * Math.PI/180)) / Math.PI) / 2 * n);
var yMax = Math.floor((1 - Math.log(Math.tan(se.lat * Math.PI/180) +
1/Math.cos(se.lat * Math.PI/180)) / Math.PI) / 2 * n);
Here Math.floor
means the upper boundary tile may be discarded.
Instead, we should floor the min side and ceil the max side.
Replace that section with:
jsfunction latLngToTile(lat, lng, z) {
var n = Math.pow(2, z);
var x = (lng + 180) / 360 * n;
var y = (1 - Math.log(Math.tan(lat * Math.PI/180) +
1/Math.cos(lat * Math.PI/180)) / Math.PI) / 2 * n;
return {x: x, y: y};
}
for (var z = minZoom; z <= maxZoom; z++) {
var nwTile = latLngToTile(nw.lat, nw.lng, z);
var seTile = latLngToTile(se.lat, se.lng, z);
var xMin = Math.floor(nwTile.x);
var xMax = Math.ceil(seTile.x); // include last tile fully
var yMin = Math.floor(nwTile.y);
var yMax = Math.ceil(seTile.y); // include last tile fully
for (var x = xMin; x <= xMax; x++) {
for (var y = yMin; y <= yMax; y++) {
var url = urlTemplate.replace('{z}', z).replace('{x}', x).replace('{y}', y);
urls.push(url);
}
}
}
The tile grid now covers 100% of the rectangle area.
No tiles are dropped, even if the rectangle only clips a small part of them.
This matches how offline map apps usually cache tiles.
⚡ Question for you:
Do you want me to also draw the exact tile grid overlay on the map while downloading (so the user sees the included tiles in real time), or is the status box confirmation enough?
Ah, I see exactly what happened 👍
The error you hit:
cppFailed: https://{s}.tile.openstreetmap.org/15/27397/14053.png
This means the {s}
placeholder (used in Leaflet tile URLs for subdomains like a
, b
, c
) wasn’t replaced before trying to download.
In Leaflet, L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")
works because Leaflet automatically substitutes {s}
with a
, b
, or c
. But in our custom downloader, we’re doing string replacement manually, and I only replaced {z}
, {x}
, {y}
.
{s}
substitutionAdd a small random subdomain replacement when building the URL:
jsvar subdomains = ['a', 'b', 'c']; // OSM has these
var s = subdomains[Math.floor(Math.random() * subdomains.length)];
var url = urlTemplate
.replace('{s}', s)
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', y);
urls.push(url);
jsfor (var z = minZoom; z <= maxZoom; z++) {
var nwTile = latLngToTile(nw.lat, nw.lng, z);
var seTile = latLngToTile(se.lat, se.lng, z);
var xMin = Math.floor(nwTile.x);
var xMax = Math.ceil(seTile.x);
var yMin = Math.floor(nwTile.y);
var yMax = Math.ceil(seTile.y);
for (var x = xMin; x <= xMax; x++) {
for (var y = yMin; y <= yMax; y++) {
var subdomains = ['a', 'b', 'c'];
var s = subdomains[Math.floor(Math.random() * subdomains.length)];
var url = urlTemplate
.replace('{s}', s)
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', y);
urls.push(url);
}
}
}
✅ With this fix:
No more %7bs%7d
(unsubstituted {s}
) errors.
All tiles intersecting the rectangle will be downloaded.
Works with OSM and other TMS providers that use {s}
.
👉 Do you also want me to add an optional overlay of the exact tile grid on the map (like a semi-transparent grid showing every tile that will be fetched)? This would let you confirm visually before downloading.
Perfect 👍 — an on-map tile grid overlay will give you visual confirmation of exactly which tiles will be downloaded.
We can achieve this by drawing rectangles for each tile that intersects your selected bounding box. I’ll add a checkbox (“Show Tile Grid”) to toggle this overlay.
js// Add a control for toggling tile grid overlay
var gridControl = L.control({position: 'topright'});
gridControl.onAdd = function() {
var div = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
div.innerHTML = '<label style="background:white;padding:4px;display:block;font-size:12px;">' +
'<input type="checkbox" id="showGrid"> Show Tile Grid' +
'</label>';
return div;
};
gridControl.addTo(map);
// LayerGroup to hold tile grid rectangles
var gridLayer = L.layerGroup().addTo(map);
// Function to draw tile grid rectangles
function drawTileGrid(nw, se, minZoom, maxZoom, urlTemplate) {
gridLayer.clearLayers(); // reset
if (!document.getElementById("showGrid").checked) return;
for (var z = minZoom; z <= maxZoom; z++) {
var nwTile = latLngToTile(nw.lat, nw.lng, z);
var seTile = latLngToTile(se.lat, se.lng, z);
var xMin = Math.floor(nwTile.x);
var xMax = Math.ceil(seTile.x);
var yMin = Math.floor(nwTile.y);
var yMax = Math.ceil(seTile.y);
for (var x = xMin; x <= xMax; x++) {
for (var y = yMin; y <= yMax; y++) {
var bounds = tileToBounds(x, y, z);
L.rectangle(bounds, {
color: 'red',
weight: 1,
fillOpacity: 0
}).addTo(gridLayer);
}
}
}
}
// Converts tile coords to geographic bounds
function tileToBounds(x, y, z) {
var nw = tile2LatLng(x, y, z);
var se = tile2LatLng(x + 1, y + 1, z);
return [[se.lat, nw.lng], [nw.lat, se.lng]];
}
function tile2LatLng(x, y, z) {
var n = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
return {
lat: (180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))),
lng: (x / Math.pow(2, z) * 360 - 180)
};
}
User checks “Show Tile Grid” on the map.
When they hit Download Tiles, the same bounding box logic is used, but now every intersecting tile is also drawn as a red rectangle.
This lets them visually confirm coverage.
Modify your downloadTiles
handler to also call drawTileGrid
:
js// Example: after selecting rectangle and pressing "Download"
drawTileGrid(nw, se, minZoom, maxZoom, urlTemplate);
✅ This way, the download set matches exactly the red-outlined tiles you see on the map.
Do you want me to update this so the grid rectangles also display the tile indices (z/x/y) as labels inside each tile (like a mini debug overlay)? That can be useful if you want to double-check filenames vs. actual tiles.
You’re right — saving by “last path segment” breaks on non-OSM URLs (query strings, no extension, different subdomains, etc.). The robust fix is:
Build filenames from the tile indices you already know (z/x/y
) instead of the URL,
Let Python infer the extension from HTTP Content-Type (fallback to URL or a default), and
Add a couple of UI fields so any TMS works (subdomains, layer name, optional Y-flip).
Below is a drop-in update for your current pywebview + JS injection approach.
js(function () {
// ------ Helpers ------
function loadCss(href){var l=document.createElement('link');l.rel='stylesheet';l.href=href;document.head.appendChild(l);}
function loadJs(src){return new Promise(function(res,rej){var s=document.createElement('script');s.src=src;s.onload=res;s.onerror=rej;document.head.appendChild(s);});}
// Load Leaflet.Draw if needed
loadCss('https://unpkg.com/leaflet-draw/dist/leaflet.draw.css');
var drawLoaded = (window.L && L.Draw) ? Promise.resolve() :
loadJs('https://unpkg.com/leaflet-draw/dist/leaflet.draw.js');
var gridLayer = L.layerGroup().addTo(map);
var statusDiv;
function ensureStatusControl(){
if (statusDiv) return statusDiv;
var StatusControl = L.Control.extend({
onAdd: function(){
var d = L.DomUtil.create('div');
d.id='download-status';
d.style.background='white';
d.style.padding='6px 10px';
d.style.borderRadius='6px';
d.style.boxShadow='0 2px 6px rgba(0,0,0,.2)';
d.style.font='13px system-ui';
d.textContent='Ready.';
statusDiv = d;
return d;
}
});
map.addControl(new StatusControl({position:'bottomleft'}));
return statusDiv;
}
// tile math (Web Mercator XYZ)
function latLngToTileFloat(lat,lng,z){
var n = Math.pow(2,z);
var x = (lng + 180) / 360 * n;
var y = (1 - Math.log(Math.tan(lat*Math.PI/180)+1/Math.cos(lat*Math.PI/180))/Math.PI)/2 * n;
return {x:x,y:y};
}
function tile2LatLng(x,y,z){
var n = Math.PI - 2*Math.PI*y/Math.pow(2,z);
return {
lat: (180/Math.PI * Math.atan(0.5*(Math.exp(n)-Math.exp(-n)))),
lng: (x/Math.pow(2,z)*360 - 180)
};
}
function tileToBounds(x,y,z){
var nw = tile2LatLng(x, y, z);
var se = tile2LatLng(x+1, y+1, z);
// Leaflet expects [[southWest],[northEast]]
return [[se.lat, nw.lng], [nw.lat, se.lng]];
}
function drawTileGrid(nw, se, minZ, maxZ, flipY){
gridLayer.clearLayers();
if (!document.getElementById('showGrid').checked) return;
for (var z=minZ; z<=maxZ; z++){
var nwF = latLngToTileFloat(nw.lat, nw.lng, z);
var seF = latLngToTileFloat(se.lat, se.lng, z);
var xMin = Math.floor(Math.min(nwF.x, seF.x));
var xMax = Math.ceil (Math.max(nwF.x, seF.x));
var yMin = Math.floor(Math.min(nwF.y, seF.y));
var yMax = Math.ceil (Math.max(nwF.y, seF.y));
for (var x=xMin; x<=xMax; x++){
for (var y=yMin; y<=yMax; y++){
var yy = flipY ? (Math.pow(2,z)-1-y) : y;
L.rectangle(tileToBounds(x, yy, z), {color:'#d00', weight:1, fillOpacity:0})
.addTo(gridLayer);
}
}
}
}
// inject UI control (URL, min/max zoom, subdomains, layer, flipY, grid toggle, download)
var Control = L.Control.extend({
onAdd: function(){
var div = L.DomUtil.create('div');
div.style.background='white';
div.style.padding='8px';
div.style.borderRadius='8px';
div.style.boxShadow='0 2px 6px rgba(0,0,0,.2)';
div.style.font='12px system-ui';
div.innerHTML =
'<div style="margin-bottom:6px;"><b>Tile downloader</b></div>'+
'<div style="margin-bottom:4px;">URL template</div>'+
'<input id="tile-url" type="text" style="width:270px" '+
'value="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png">'+
'<div style="margin-top:6px; display:flex; gap:6px; align-items:center;">'+
'<label>Min z <input id="min-zoom" type="number" value="10" style="width:48px"></label>'+
'<label>Max z <input id="max-zoom" type="number" value="15" style="width:48px"></label>'+
'</div>'+
'<div style="margin-top:6px;">Subdomains (CSV, optional): '+
'<input id="subdomains" type="text" placeholder="a,b,c" style="width:170px">'+
'</div>'+
'<div style="margin-top:6px;">Layer name: '+
'<input id="layer-name" type="text" value="custom" style="width:120px">'+
'</div>'+
'<label style="margin-top:6px; display:block;"><input id="flipY" type="checkbox"> Flip Y (TMS)</label>'+
'<label style="margin-top:4px; display:block;"><input id="showGrid" type="checkbox"> Show tile grid</label>'+
'<button id="download-tiles" style="margin-top:8px; width:100%">Download</button>';
return div;
}
});
map.addControl(new Control({position:'topright'}));
ensureStatusControl();
// Remember rectangle bounds when created
drawLoaded.then(function(){
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
var drawControl = new L.Control.Draw({
draw: { rectangle:true, polygon:false, circle:false, circlemarker:false, marker:false, polyline:false },
edit: { featureGroup: drawnItems }
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function (e) {
var layer = e.layer; drawnItems.addLayer(layer);
if (e.layerType === 'rectangle') {
window.selectedBounds = layer.getBounds();
var b = window.selectedBounds, nw=b.getNorthWest(), se=b.getSouthEast();
// copyable popup
var msg = '<div style="user-select:text;">Top-left: ('+nw.lat.toFixed(6)+', '+nw.lng.toFixed(6)+')<br>'+
'Bottom-right: ('+se.lat.toFixed(6)+', '+se.lng.toFixed(6)+')</div>';
layer.bindPopup(msg).openPopup();
// update grid preview immediately if toggled
var minZ = parseInt(document.getElementById('min-zoom').value||0);
var maxZ = parseInt(document.getElementById('max-zoom').value||0);
var flipY = document.getElementById('flipY').checked;
drawTileGrid(nw, se, minZ, maxZ, flipY);
}
});
});
// Re-draw grid when toggled or zoom inputs change
function hookGridRefresh(){
['showGrid','flipY','min-zoom','max-zoom'].forEach(function(id){
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', function(){
if (!window.selectedBounds) return;
var b=window.selectedBounds, nw=b.getNorthWest(), se=b.getSouthEast();
var minZ = parseInt(document.getElementById('min-zoom').value||0);
var maxZ = parseInt(document.getElementById('max-zoom').value||0);
var flipY = document.getElementById('flipY').checked;
drawTileGrid(nw, se, minZ, maxZ, flipY);
});
});
}
hookGridRefresh();
// Build metas and download
document.getElementById('download-tiles').onclick = function(){
if (!window.selectedBounds) { alert('Please draw a rectangle first.'); return; }
var urlTmpl = document.getElementById('tile-url').value.trim();
var minZ = parseInt(document.getElementById('min-zoom').value);
var maxZ = parseInt(document.getElementById('max-zoom').value);
var layer = (document.getElementById('layer-name').value || 'custom').trim();
var subsCSV = (document.getElementById('subdomains').value || '').trim();
var flipY = document.getElementById('flipY').checked;
var subdomains = subsCSV ? subsCSV.split(/\s*,\s*/) : null;
// auto subdomains for common templates if left blank
if (!subdomains && urlTmpl.includes('{s}')){
if (urlTmpl.includes('google.com')) subdomains = ['mt0','mt1','mt2','mt3'];
else subdomains = ['a','b','c'];
}
var b=window.selectedBounds, nw=b.getNorthWest(), se=b.getSouthEast();
var metas = []; // {url,z,x,y,layer}
for (var z=minZ; z<=maxZ; z++){
var nwF = latLngToTileFloat(nw.lat, nw.lng, z);
var seF = latLngToTileFloat(se.lat, se.lng, z);
var xMin = Math.floor(Math.min(nwF.x, seF.x));
var xMax = Math.ceil (Math.max(nwF.x, seF.x));
var yMin = Math.floor(Math.min(nwF.y, seF.y));
var yMax = Math.ceil (Math.max(nwF.y, seF.y));
var n = Math.pow(2,z);
for (var x=xMin; x<=xMax; x++){
for (var y=yMin; y<=yMax; y++){
// clamp to tile range
if (x<0 || x>=n || y<0 || y>=n) continue;
var yy = flipY ? (n - 1 - y) : y;
var s = '{s}';
if (urlTmpl.includes('{s}')){
if (subdomains && subdomains.length) s = subdomains[(x+y) % subdomains.length];
else s = ''; // allow empty replacement if user wants
}
var url = urlTmpl
.replace('{s}', s)
.replace('{z}', z)
.replace('{x}', x)
.replace('{y}', yy);
metas.push({ url:url, z:z, x:x, y:yy, layer:layer });
}
}
}
ensureStatusControl().textContent = 'Downloading '+metas.length+' tiles...';
drawTileGrid(nw, se, minZ, maxZ, flipY);
if (window.pywebview && window.pywebview.api && window.pywebview.api.download_tiles_meta){
window.pywebview.api.download_tiles_meta(metas).then(function(msg){
ensureStatusControl().textContent = msg || ('Done. '+metas.length+' tiles.');
}).catch(function(err){
ensureStatusControl().textContent = 'Error: '+err;
});
} else {
alert('Collected '+metas.length+' tile metas (no Python handler).');
}
};
})();
What’s new
Inputs: Subdomains (CSV), Layer name, Flip Y (TMS), Show tile grid.
Grid overlay draws all intersecting tiles; uses Y-flip if checked.
We pass metadata objects to Python: {url, z, x, y, layer}
— no guessing from URL needed.
{layer}/{z}/{x}/{y}.{ext}
python# pip install requests
import os, re, requests
from urllib.parse import urlparse
from pathlib import Path
class API:
def send_coords(self, lat1, lon1, lat2, lon2):
print(f"Top-left: ({lat1}, {lon1}), Bottom-right: ({lat2}, {lon2})")
def download_tiles_meta(self, metas):
"""
metas: list of dicts with keys url, z, x, y, layer
Saves to tiles/<layer>/<z>/<x>/<y>.<ext>
"""
base = Path("tiles")
count = 0
session = requests.Session()
session.headers.update({
# some providers require a UA; adjust Referer if your provider requires it
"User-Agent": "TileCacher/1.0 (+https://example.local)"
})
for m in metas:
url = m["url"]
z = str(m["z"])
x = str(m["x"])
y = str(m["y"])
layer = self._safe_layer(m.get("layer") or "custom")
ext = None
try:
r = session.get(url, timeout=15)
if r.status_code != 200:
print("Failed HTTP", r.status_code, url)
continue
# Pick extension by Content-Type first
ctype = r.headers.get("Content-Type","").split(";")[0].strip().lower()
ext = self._ext_from_content_type(ctype)
# Fallback: derive from URL path
if not ext:
path = urlparse(url).path
ext = os.path.splitext(path)[1].lstrip(".").lower() or None
# Final fallback
if not ext:
ext = "tile"
out_dir = base / layer / z / x
out_dir.mkdir(parents=True, exist_ok=True)
with open(out_dir / f"{y}.{ext}", "wb") as f:
f.write(r.content)
count += 1
except Exception as e:
print("Failed:", url, e)
msg = f"Downloaded {count}/{len(metas)} tiles into {base.resolve()}"
print(msg)
return msg
@staticmethod
def _safe_layer(name: str) -> str:
# filesystem-safe layer name
name = name.strip()
name = re.sub(r"[^\w\-.]+", "_", name)
return name or "custom"
@staticmethod
def _ext_from_content_type(ctype: str) -> str | None:
mapping = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/webp": "webp",
"image/avif": "avif",
"application/x-protobuf": "pbf", # vector tiles (MVT)
"application/vnd.mapbox-vector-tile": "pbf",
"application/octet-stream": None, # unknown; may still be pbf
}
return mapping.get(ctype, None)
Why this works
Filenames are driven by indices you computed: layer/z/x/y.ext
.
Any TMS/XYZ URL (with or without query strings / file extensions) now saves cleanly.
Extension is detected from the response (more reliable than URL parsing).
TMS (Flip-Y): Some endpoints (classical TMS) index Y bottom-to-top. Tick Flip Y (TMS) if tiles look inverted.
Subdomains: For https://{s}.google.com/...
, set subdomains to mt0,mt1,mt2,mt3
. For OSM, a,b,c
. Leave blank if your URL has no {s}
.
Licensing / ToS: Many providers forbid bulk caching. Make sure you’re permitted to store tiles offline for your use case.
Grid = filenames: The red grid corresponds to the exact (z/x/y)
you’ll see in the saved paths.
If you want, I can add tiny labels inside each red rectangle showing z/x/y
so you can visually correlate a tile with its saved file (it’s optional and a bit busy at high zoom).