geojson-tile-renderer 0.1.0

Convert GeoJSON features to PNG tile images with Web Mercator projection
Documentation
use crate::error::Result;
use crate::projection::lat_lon_to_web_mercator;
use crate::svg::types::SvgContext;
use crate::svg::utils::{get_property_num, get_property_str};
use geo_types::Point;
use geojson::Feature;

/// Escapes special XML/HTML characters in text
fn escape_xml(text: &str) -> String {
	text.replace('&', "&")
		.replace('<', "&lt;")
		.replace('>', "&gt;")
		.replace('"', "&quot;")
		.replace('\'', "&apos;")
}

/// Generates SVG items for a Point feature (text labels)
///
/// # Arguments
/// * `feature` - GeoJSON Point feature
/// * `point` - The point geometry
/// * `context` - SVG context for coordinate transformation
///
/// # Returns
/// Vector of SVG element strings
pub fn generate_point_svg_items(
	feature: &Feature,
	point: &Point<f64>,
	context: &SvgContext,
) -> Result<Vec<String>> {
	let lon = point.x();
	let lat = point.y();

	// Transform point to pixel coordinates
	let merc = lat_lon_to_web_mercator(lon, lat)?;
	let x = (merc.x - context.mercator_bbox.min_x) * context.x_scaling_factor;
	let y = context.size - (merc.y - context.mercator_bbox.min_y) * context.y_scaling_factor;

	// Get text property - if not present, return empty
	let text = match feature
		.properties
		.as_ref()
		.and_then(|props| props.get("text"))
		.and_then(|val| val.as_str())
	{
		Some(t) => t,
		None => return Ok(vec![]),
	};

	let font_size = get_property_num(feature, "font-size", 14.0);
	let text_anchor = get_property_str(feature, "text-anchor", "middle");
	let dominant_baseline = get_property_str(feature, "dominant-baseline", "middle");

	// Calculate buffer zones for text visibility
	let estimated_text_width = font_size * text.len() as f64 * 1.0;

	let (left_buffer, right_buffer) = match text_anchor.as_str() {
		"start" => (font_size, estimated_text_width),
		"end" => (estimated_text_width, font_size),
		_ => {
			let buffer = estimated_text_width / 2.0 + font_size;
			(buffer, buffer)
		}
	};

	let (top_buffer, bottom_buffer) = match dominant_baseline.as_str() {
		"top" | "text-before-edge" => (font_size * 0.5, font_size * 2.0),
		"bottom" | "text-after-edge" => (font_size * 2.0, font_size * 0.5),
		_ => {
			let buffer = font_size * 3.0;
			(buffer, buffer)
		}
	};

	// Check if text might be visible on this tile
	if x < -left_buffer
		|| x > context.size + right_buffer
		|| y < -top_buffer
		|| y > context.size + bottom_buffer
	{
		return Ok(vec![]);
	}

	let font_family = get_property_str(feature, "font-family", "Arial");
	let font_weight = get_property_str(feature, "font-weight", "normal");
	let color = get_property_str(feature, "color", "black");
	let opacity = get_property_num(feature, "opacity", 1.0);

	// Map simplified baseline values to SVG values
	let svg_baseline = match dominant_baseline.as_str() {
		"top" => "text-before-edge",
		"bottom" => "text-after-edge",
		other => other,
	};

	// Escape special XML characters in text
	let escaped_text = escape_xml(text);

	Ok(vec![format!(
		r#"<text x="{}" y="{}" font-family="{}" font-size="{}" font-weight="{}" fill="{}" fill-opacity="{}" text-anchor="{}" dominant-baseline="{}">{}</text>"#,
		x, y, font_family, font_size, font_weight, color, opacity, text_anchor, svg_baseline, escaped_text
	)])
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::projection::BoundingBox;
	use geo_types::{Coord, LineString, Polygon};
	use serde_json::json;

	fn create_test_context() -> SvgContext {
		let bbox = BoundingBox::new(-1.0, -1.0, 1.0, 1.0);
		let image_polygon = Polygon::new(
			LineString::from(vec![
				Coord { x: -1.0, y: -1.0 },
				Coord { x: 1.0, y: -1.0 },
				Coord { x: 1.0, y: 1.0 },
				Coord { x: -1.0, y: 1.0 },
				Coord { x: -1.0, y: -1.0 },
			]),
			vec![],
		);

		SvgContext::new(bbox, 256.0, 128.0, 128.0, image_polygon)
	}

	#[test]
	fn test_generate_point_svg_items_basic() {
		let context = create_test_context();
		let point = Point::new(0.0, 0.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
			"properties": {
				"text": "Test Label"
			}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert_eq!(items.len(), 1);
		assert!(items[0].contains("<text"));
		assert!(items[0].contains("Test Label"));
		assert!(items[0].contains("font-family=\"Arial\""));
		assert!(items[0].contains("font-size=\"14\""));
	}

	#[test]
	fn test_generate_point_svg_items_no_text() {
		let context = create_test_context();
		let point = Point::new(0.0, 0.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
			"properties": {}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert_eq!(items.len(), 0); // No text property means no label
	}

	#[test]
	fn test_generate_point_svg_items_with_style() {
		let context = create_test_context();
		let point = Point::new(0.0, 0.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
			"properties": {
				"text": "Styled",
				"font-family": "Helvetica",
				"font-size": 20,
				"font-weight": "bold",
				"color": "red",
				"opacity": 0.8
			}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert_eq!(items.len(), 1);
		assert!(items[0].contains("font-family=\"Helvetica\""));
		assert!(items[0].contains("font-size=\"20\""));
		assert!(items[0].contains("font-weight=\"bold\""));
		assert!(items[0].contains("fill=\"red\""));
		assert!(items[0].contains("fill-opacity=\"0.8\""));
	}

	#[test]
	fn test_generate_point_svg_items_text_anchor() {
		let context = create_test_context();
		let point = Point::new(0.0, 0.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
			"properties": {
				"text": "Test",
				"text-anchor": "start"
			}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert!(items[0].contains("text-anchor=\"start\""));
	}

	#[test]
	fn test_generate_point_svg_items_out_of_bounds() {
		let context = create_test_context();
		// Point far outside the tile bounds
		let point = Point::new(100.0, 100.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [100.0, 100.0]},
			"properties": {
				"text": "Far Away"
			}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert_eq!(items.len(), 0); // Should be filtered out
	}

	#[test]
	fn test_escape_xml() {
		assert_eq!(escape_xml("Hello & <World>"), "Hello &amp; &lt;World&gt;");
		assert_eq!(escape_xml("Quote \"this\""), "Quote &quot;this&quot;");
		assert_eq!(escape_xml("Apostrophe's"), "Apostrophe&apos;s");
	}

	#[test]
	fn test_generate_point_svg_items_xml_escape() {
		let context = create_test_context();
		let point = Point::new(0.0, 0.0);

		let feature: Feature = serde_json::from_value(json!({
			"type": "Feature",
			"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
			"properties": {
				"text": "Test <>&\""
			}
		}))
		.unwrap();

		let items = generate_point_svg_items(&feature, &point, &context).unwrap();
		assert!(items[0].contains("Test &lt;&gt;&amp;&quot;"));
		assert!(!items[0].contains("Test <>&\""));
	}
}