tzf_rs/lib.rs
1//! Fast timezone finder for Rust: convert (longitude, latitude) coordinates
2//! to timezone names, offline.
3//!
4//! Version 2 is protobuf-free: the data source is the TZF embedded binary
5//! format (`.tzb`) shipped by [tzf-dist], and two finder mechanisms consume
6//! it:
7//!
8//! - [`DefaultFinder`] — the recommended general-purpose finder. Expands the
9//! file's geometry into materialized polygons at load time and answers most
10//! queries from the FUZZY preindex tiles, falling back to exact
11//! point-in-polygon for boundary cases.
12//! - [`EmbeddedFinder`] — the low-memory finder. Queries the `.tzb` bytes in
13//! place (no expansion; roughly the file size plus a small open-time index
14//! of chunk skip blocks and per-group latitude stripes, ~100 KB on lite),
15//! with the same FUZZY fast path. Queries are slower than [`DefaultFinder`];
16//! results are identical.
17//!
18//! ```rust
19//! use tzf_rs::DefaultFinder;
20//!
21//! let finder = DefaultFinder::new();
22//! assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
23//! ```
24//!
25//! Creating a finder is expensive — build one and share it (e.g. via
26//! `std::sync::LazyLock`).
27//!
28//! [tzf-dist]: https://github.com/ringsaturn/tzf-dist
29#![cfg_attr(docsrs, feature(doc_cfg))]
30
31/// Compiles and runs every ```` ```rust ```` block in `README.md` as a doctest,
32/// so the README's samples cannot drift from the API. Gated on the features
33/// those samples need; `cargo test --features bundled,export-geojson` (the
34/// `cargo test-all` alias used by `make ci`) covers it.
35#[cfg(all(doctest, feature = "bundled", feature = "export-geojson"))]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39use std::borrow::Cow;
40use std::f64::consts::PI;
41
42#[cfg(all(feature = "bundled", feature = "full"))]
43compile_error!(
44 "feature `bundled` is mutually exclusive with the git-only data feature \
45 `full`; add `default-features = false` when enabling it"
46);
47
48mod finder;
49mod tzb;
50
51#[cfg(feature = "export-geojson")]
52mod geojson;
53#[cfg(feature = "export-geojson")]
54pub use geojson::{
55 BoundaryFile, FeatureItem, GeometryDefine, MultiPolygonCoordinates, PolygonCoordinates,
56 PropertiesDefine,
57};
58
59pub use tzb::Error;
60
61use finder::{DenseGrid, FuzzyIndex, PolyFinder, assemble_items};
62use tzb::Reader;
63
64#[cfg(feature = "bundled")]
65use tzf_dist::load_lite_tzb;
66#[cfg(all(not(feature = "bundled"), feature = "full"))]
67use tzf_dist_git::load_lite_tzb;
68
69/// The recommended finder: FUZZY preindex fast path over materialized
70/// polygon geometry, loaded from a `.tzb` file.
71///
72/// `get_tz_name` answers from the preindex tile when one covers the point
73/// (the vast majority of queries) and falls back to exact point-in-polygon
74/// otherwise; `get_tz_names` always uses the polygon scan, as the
75/// polygon-exact escape hatch. Files without a FUZZY section get the plain
76/// polygon finder.
77pub struct DefaultFinder {
78 fuzzy: Option<FuzzyIndex>,
79 finder: PolyFinder,
80}
81
82impl DefaultFinder {
83 /// Creates the finder from the bundled tzf-dist lite `.tzb` data.
84 ///
85 /// # Panics
86 ///
87 /// Panics when the embedded dataset is malformed — the release pipeline
88 /// validates it, so this only fires on a broken build.
89 ///
90 /// ```rust
91 /// use tzf_rs::DefaultFinder;
92 /// let finder = DefaultFinder::new();
93 /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
94 /// ```
95 #[cfg(any(feature = "bundled", feature = "full"))]
96 #[must_use]
97 pub fn new() -> Self {
98 Self::from_tzb(load_lite_tzb()).expect("tzf-dist lite.tzb is validated at release")
99 }
100
101 /// Creates the finder from the full-precision `.tzb` dataset (~14 MB,
102 /// no topology simplification). Higher fidelity, larger memory footprint.
103 ///
104 /// Requires the `full` feature, which is git-only:
105 /// ```toml
106 /// tzf-rs = { git = "https://github.com/ringsaturn/tzf-rs", features = ["full"], default-features = false }
107 /// ```
108 ///
109 /// # Panics
110 ///
111 /// Panics when the embedded dataset is malformed (release-validated).
112 #[cfg(feature = "full")]
113 #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
114 #[must_use]
115 pub fn new_full() -> Self {
116 Self::from_tzb(tzf_dist_git::load_full_tzb())
117 .expect("tzf-dist full.tzb is validated at release")
118 }
119
120 /// Builds a finder from TZF embedded binary (`.tzb`) bytes by expanding
121 /// the geometry into the materialized polygon engine at load time. `data`
122 /// is only read during loading. When the file carries a FUZZY section it
123 /// becomes the `get_tz_name` fast path.
124 ///
125 /// # Errors
126 ///
127 /// Returns [`Error`] when the bytes are not a structurally valid
128 /// E-profile (`.tzb`) file.
129 pub fn from_tzb(data: &[u8]) -> Result<Self, Error> {
130 let reader = Reader::open(Cow::Borrowed(data))?;
131 let fuzzy = FuzzyIndex::from_reader(&reader)?;
132 let grid = DenseGrid::from_reader(&reader);
133 let expanded = reader.expand()?;
134 Ok(Self {
135 fuzzy,
136 finder: PolyFinder {
137 items: assemble_items(expanded.names, expanded.polygons),
138 grid,
139 version: expanded.version,
140 },
141 })
142 }
143
144 /// Returns the first matching timezone name, or `""` when no timezone
145 /// covers the point.
146 ///
147 /// ```rust
148 /// use tzf_rs::DefaultFinder;
149 /// let finder = DefaultFinder::new();
150 /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
151 /// ```
152 #[must_use]
153 pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
154 if let Some(fuzzy) = &self.fuzzy
155 && let Some(idx) = fuzzy.get(lng, lat)
156 {
157 return &self.finder.items[usize::from(idx)].name;
158 }
159 self.finder.get_tz_name(lng, lat)
160 }
161
162 /// Returns all matching timezone names (overlapping areas produce more
163 /// than one), sorted lexicographically. Always polygon-exact.
164 ///
165 /// ```rust
166 /// use tzf_rs::DefaultFinder;
167 /// let finder = DefaultFinder::new();
168 /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
169 /// ```
170 #[must_use]
171 pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
172 self.finder.get_tz_names(lng, lat)
173 }
174
175 /// Returns all timezone names in the dataset.
176 ///
177 /// ```rust
178 /// use tzf_rs::DefaultFinder;
179 /// let finder = DefaultFinder::new();
180 /// println!("{:?}", finder.timezonenames());
181 /// ```
182 #[must_use]
183 pub fn timezonenames(&self) -> Vec<&str> {
184 self.finder.timezonenames()
185 }
186
187 /// Returns the dataset release this finder was built from (e.g. `2026c`).
188 ///
189 /// ```rust
190 /// use tzf_rs::DefaultFinder;
191 /// let finder = DefaultFinder::new();
192 /// println!("{:?}", finder.data_version());
193 /// ```
194 #[must_use]
195 pub fn data_version(&self) -> &str {
196 &self.finder.version
197 }
198
199 /// Converts all timezone boundaries to a GeoJSON FeatureCollection.
200 #[cfg(feature = "export-geojson")]
201 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
202 #[must_use]
203 pub fn to_geojson(&self) -> BoundaryFile {
204 geojson::collection(
205 self.finder
206 .items
207 .iter()
208 .map(geojson::feature_from_item)
209 .collect(),
210 )
211 }
212
213 /// Converts one timezone's boundaries to a GeoJSON FeatureCollection.
214 /// Returns `None` when the dataset does not contain the name.
215 #[cfg(feature = "export-geojson")]
216 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
217 #[must_use]
218 pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
219 let features: Vec<_> = self
220 .finder
221 .items
222 .iter()
223 .filter(|item| item.name == timezone_name)
224 .map(geojson::feature_from_item)
225 .collect();
226 if features.is_empty() {
227 None
228 } else {
229 Some(geojson::collection(features))
230 }
231 }
232
233 /// Converts one timezone's FUZZY preindex tiles to a GeoJSON
234 /// FeatureCollection: one Feature whose MultiPolygon holds each tile's
235 /// bounding rectangle — the area where `get_tz_name` answers from the
236 /// preindex fast path instead of exact point-in-polygon. Tiles are
237 /// ordered coarsest zoom first.
238 ///
239 /// Returns `None` when the file carries no FUZZY section, the dataset
240 /// does not contain the name, or no preindex tile names it.
241 #[cfg(feature = "export-geojson")]
242 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
243 #[must_use]
244 pub fn get_tz_preindex_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
245 let fuzzy = self.fuzzy.as_ref()?;
246 // The same name may map to more than one directory item; a preindex
247 // tile may name any of them.
248 let indices: Vec<u16> = self
249 .finder
250 .items
251 .iter()
252 .enumerate()
253 .filter(|(_, item)| item.name == timezone_name)
254 .filter_map(|(i, _)| u16::try_from(i).ok())
255 .collect();
256 if indices.is_empty() {
257 return None;
258 }
259 let keys = fuzzy.tile_keys_for(&indices);
260 if keys.is_empty() {
261 return None;
262 }
263 Some(geojson::collection(vec![geojson::feature_from_tile_keys(
264 timezone_name.to_string(),
265 &keys,
266 )]))
267 }
268
269 /// Converts the whole FUZZY preindex to a GeoJSON FeatureCollection: one
270 /// Feature per timezone that owns at least one tile, in dataset order; a
271 /// boundary tile appears in every timezone it names. Returns `None` when
272 /// the file carries no FUZZY section.
273 #[cfg(feature = "export-geojson")]
274 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
275 #[must_use]
276 pub fn to_preindex_geojson(&self) -> Option<BoundaryFile> {
277 let fuzzy = self.fuzzy.as_ref()?;
278 let mut grouped = fuzzy.tile_keys_grouped();
279 let features = self
280 .finder
281 .items
282 .iter()
283 .enumerate()
284 .filter_map(|(i, item)| {
285 let keys = grouped.remove(&u16::try_from(i).ok()?)?;
286 Some(geojson::feature_from_tile_keys(item.name.clone(), &keys))
287 })
288 .collect();
289 Some(geojson::collection(features))
290 }
291}
292
293#[cfg(any(feature = "bundled", feature = "full"))]
294impl Default for DefaultFinder {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300/// The low-memory finder: queries TZF embedded binary (`.tzb`) bytes in
301/// place, without expanding the geometry. Total footprint is roughly the file
302/// itself (the bundled lite data is ~4 MB) plus the timezone names and a
303/// 16-bytes-per-16-chunks skip table built while validating the file at open
304/// (~6 KB for lite, ~20 KB for full at the encoder's default 256-point
305/// chunks).
306///
307/// `get_tz_name` consults the file's FUZZY preindex first and falls back to
308/// the compressed-geometry scan; results match [`DefaultFinder`] over the
309/// same file, only slower (microseconds instead of hundreds of nanoseconds
310/// on boundary queries).
311pub struct EmbeddedFinder {
312 reader: Reader<'static>,
313 names: Vec<String>,
314}
315
316impl EmbeddedFinder {
317 /// Creates the finder over the bundled tzf-dist lite `.tzb` data,
318 /// borrowed in place from the executable's read-only data segment.
319 ///
320 /// # Panics
321 ///
322 /// Panics when the embedded dataset is malformed (release-validated).
323 #[cfg(any(feature = "bundled", feature = "full"))]
324 #[must_use]
325 pub fn new() -> Self {
326 Self::from_tzb(load_lite_tzb()).expect("tzf-dist lite.tzb is validated at release")
327 }
328
329 /// Builds a finder that queries `data` in place. Accepts borrowed
330 /// `&'static [u8]` (e.g. `include_bytes!`) without copying, or an owned
331 /// `Vec<u8>` (e.g. a file read at startup).
332 ///
333 /// # Errors
334 ///
335 /// Returns [`Error`] when the bytes are not a structurally valid
336 /// E-profile (`.tzb`) file. Memory images (`.tzm`) are rejected with
337 /// [`Error::Profile`] — they exist for the Go runtime and offer no
338 /// benefit here.
339 pub fn from_tzb(data: impl Into<Cow<'static, [u8]>>) -> Result<Self, Error> {
340 let reader = Reader::open(data.into())?;
341 let names = (0..reader.timezone_count())
342 .map(|i| reader.name(i).map(str::to_string))
343 .collect::<Result<Vec<_>, _>>()?;
344 Ok(Self { reader, names })
345 }
346
347 /// Returns the first matching timezone name, or `""` when no timezone
348 /// covers the point.
349 ///
350 /// ```rust
351 /// use tzf_rs::EmbeddedFinder;
352 /// let finder = EmbeddedFinder::new();
353 /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
354 /// ```
355 #[must_use]
356 pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
357 if self.reader.has_fuzzy()
358 && let Ok(Some(idx)) = self.reader.fuzzy_lookup(lng, lat)
359 {
360 return &self.names[idx as usize];
361 }
362 match self.reader.lookup(lng, lat) {
363 Ok(Some(idx)) => &self.names[idx as usize],
364 _ => "",
365 }
366 }
367
368 /// Returns all matching timezone names, sorted lexicographically. Always
369 /// polygon-exact.
370 #[must_use]
371 pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
372 let mut indices = Vec::with_capacity(self.reader.lookup_buffer_size());
373 if self.reader.lookup_into(lng, lat, &mut indices).is_err() {
374 return Vec::new();
375 }
376 indices
377 .into_iter()
378 .map(|idx| self.names[idx as usize].as_str())
379 .collect()
380 }
381
382 /// Returns all timezone names in the dataset.
383 #[must_use]
384 pub fn timezonenames(&self) -> Vec<&str> {
385 self.names.iter().map(String::as_str).collect()
386 }
387
388 /// Returns the dataset release this finder was built from (e.g. `2026c`).
389 #[must_use]
390 pub fn data_version(&self) -> &str {
391 self.reader.data_version()
392 }
393
394 /// Converts all timezone boundaries to a GeoJSON FeatureCollection.
395 ///
396 /// Unlike [`DefaultFinder`], which exports polygons it already holds,
397 /// this decodes the whole file's geometry on demand — roughly the cost of
398 /// loading an expanded finder. A timezone that fails to decode is
399 /// omitted; use [`get_tz_geojson`] when the error matters.
400 ///
401 /// [`get_tz_geojson`]: EmbeddedFinder::get_tz_geojson
402 #[cfg(feature = "export-geojson")]
403 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
404 #[must_use]
405 pub fn to_geojson(&self) -> BoundaryFile {
406 let features = (0..self.names.len())
407 .filter_map(|i| {
408 let polys = self.reader.expand_timezone(i as u32).ok()?;
409 Some(geojson::feature_from_expanded(
410 self.names[i].clone(),
411 &polys,
412 ))
413 })
414 .collect();
415 geojson::collection(features)
416 }
417
418 /// Converts one timezone's boundaries to a GeoJSON FeatureCollection,
419 /// decoding only that timezone's rings. Returns `None` when the dataset
420 /// does not contain the name or its geometry fails to decode.
421 #[cfg(feature = "export-geojson")]
422 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
423 #[must_use]
424 pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
425 let mut features = Vec::new();
426 for (i, name) in self.names.iter().enumerate() {
427 if name != timezone_name {
428 continue;
429 }
430 let polys = self.reader.expand_timezone(i as u32).ok()?;
431 features.push(geojson::feature_from_expanded(name.clone(), &polys));
432 }
433 if features.is_empty() {
434 None
435 } else {
436 Some(geojson::collection(features))
437 }
438 }
439
440 /// Converts one timezone's FUZZY preindex tiles to a GeoJSON
441 /// FeatureCollection; see [`DefaultFinder::get_tz_preindex_geojson`].
442 /// Results match [`DefaultFinder`] over the same file.
443 #[cfg(feature = "export-geojson")]
444 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
445 #[must_use]
446 pub fn get_tz_preindex_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
447 if !self.reader.has_fuzzy() {
448 return None;
449 }
450 let indices: Vec<u16> = self
451 .names
452 .iter()
453 .enumerate()
454 .filter(|(_, name)| name.as_str() == timezone_name)
455 .filter_map(|(i, _)| u16::try_from(i).ok())
456 .collect();
457 if indices.is_empty() {
458 return None;
459 }
460 // The FUZZY key array is stored sorted, so the filtered keys keep the
461 // coarsest-zoom-first order DefaultFinder produces by sorting.
462 let entries = self.reader.fuzzy_entries().ok()?;
463 let keys: Vec<u64> = entries
464 .iter()
465 .filter(|(_, idxs)| idxs.iter().any(|i| indices.contains(i)))
466 .map(|(key, _)| *key)
467 .collect();
468 if keys.is_empty() {
469 return None;
470 }
471 Some(geojson::collection(vec![geojson::feature_from_tile_keys(
472 timezone_name.to_string(),
473 &keys,
474 )]))
475 }
476
477 /// Converts the whole FUZZY preindex to a GeoJSON FeatureCollection; see
478 /// [`DefaultFinder::to_preindex_geojson`]. Results match
479 /// [`DefaultFinder`] over the same file.
480 #[cfg(feature = "export-geojson")]
481 #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
482 #[must_use]
483 pub fn to_preindex_geojson(&self) -> Option<BoundaryFile> {
484 if !self.reader.has_fuzzy() {
485 return None;
486 }
487 let entries = self.reader.fuzzy_entries().ok()?;
488 let mut grouped: std::collections::HashMap<u16, Vec<u64>> =
489 std::collections::HashMap::new();
490 for (key, idxs) in &entries {
491 for &idx in idxs {
492 grouped.entry(idx).or_default().push(*key);
493 }
494 }
495 let features = (0..self.names.len())
496 .filter_map(|i| {
497 let keys = grouped.remove(&u16::try_from(i).ok()?)?;
498 Some(geojson::feature_from_tile_keys(
499 self.names[i].clone(),
500 &keys,
501 ))
502 })
503 .collect();
504 Some(geojson::collection(features))
505 }
506}
507
508#[cfg(any(feature = "bundled", feature = "full"))]
509impl Default for EmbeddedFinder {
510 fn default() -> Self {
511 Self::new()
512 }
513}
514
515/// deg2num is used to convert longitude, latitude to [Slippy map tilenames]
516/// under specific zoom level.
517///
518/// [Slippy map tilenames]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
519///
520/// Example:
521///
522/// ```rust
523/// use tzf_rs::deg2num;
524/// let ret = deg2num(116.3883, 39.9289, 7);
525/// assert_eq!((105, 48), ret);
526/// ```
527#[must_use]
528#[allow(
529 clippy::cast_precision_loss,
530 clippy::cast_possible_truncation,
531 clippy::similar_names
532)]
533pub fn deg2num(lng: f64, lat: f64, zoom: i64) -> (i64, i64) {
534 let n = (1i64 << zoom) as f64;
535 let lat_rad = lat.to_radians();
536 let xtile = (lng / 360.0 + 0.5) * n;
537 let ytile = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * n;
538
539 // Possible precision loss here
540 (xtile as i64, ytile as i64)
541}