import type { RenderRouteOptions } from "./index.ts"; import { renderPipeline } from "./render-pipeline.ts"; import type { RenderTrack } from "./render-pipeline.ts"; import { simplifyCoordinates } from "./simplify.ts"; import type { BadgeStyle } from "./badge.ts"; import { computeStatistics, formatStatistics } from "./statistics.ts"; import { buildElevationProfile } from "./elevation-chart.ts"; import type { ElevationProfileStyle } from "./elevation-chart.ts"; import type { LegendStyle } from "./legend.ts"; const NAME_TAG = /([\s\S]*?)<\/name>/; function extractAttr(attrsOrTag: string, name: string): string | null { const match = attrsOrTag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`)); if (!match) return null; return match[1] ?? match[2] ?? null; } const XML_ENTITIES: Record = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", }; function decodeXmlEntities(text: string): string { return text.replace(/&(?:amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]!); } function extractNameFrom(content: string): string | undefined { const match = content.match(NAME_TAG); return match ? decodeXmlEntities(match[1]!.trim()) : undefined; } interface RawElement { /** Raw attribute text between the tag name and the closing `>`/`/>`. */ attrs: string; /** Inner content between opening and closing tags; undefined if self-closing. */ inner: string | undefined; } const WORD_CHAR = /[A-Za-z0-9_]/; /** * Scans `content` for top-level `...` (or * self-closing ``) elements using plain forward `indexOf` * search rather than a `[\s\S]*?`-style regex. * * A lazy-wildcard regex re-scans the remaining input from every candidate * tag-open position, which is quadratic when a closing tag (or even a bare * `>`) never appears — e.g. `"".repeat(200_000)` previously took * multiple seconds. `indexOf` calls here always start from a * monotonically-advancing cursor, so total work stays linear in * `content.length` regardless of malformed input. */ function* scanElements(content: string, tagName: string): Generator { const openTag = `<${tagName}`; const closeTag = ``; let pos = 0; while (pos < content.length) { const start = content.indexOf(openTag, pos); if (start === -1) return; const afterName = start + openTag.length; const boundaryChar = content[afterName]; if (boundaryChar !== undefined && WORD_CHAR.test(boundaryChar)) { // e.g. found "", afterName); if (tagEnd === -1) return; // no `>` anywhere from here on — nothing further can close either. const selfClosing = content[tagEnd - 1] === "/"; const attrs = content.slice(afterName, selfClosing ? tagEnd - 1 : tagEnd); if (selfClosing) { yield { attrs, inner: undefined }; pos = tagEnd + 1; continue; } const closeStart = content.indexOf(closeTag, tagEnd + 1); if (closeStart === -1) return; // no closing tag anywhere from here on either. yield { attrs, inner: content.slice(tagEnd + 1, closeStart) }; pos = closeStart + closeTag.length; } } /** Matches every top-level `...` (or self-closing) element within `content`. */ function matchAllElements(content: string, tagName: string): RawElement[] { return Array.from(scanElements(content, tagName)); } function matchFirstElement(content: string, tagName: string): RawElement | null { for (const el of scanElements(content, tagName)) return el; return null; } function parseLonLat(attrs: string, tagLabel: string): { lon: number; lat: number } { const latStr = extractAttr(attrs, "lat"); const lonStr = extractAttr(attrs, "lon"); if (latStr === null || lonStr === null) { throw new Error(`<${tagLabel}> element is missing a lat or lon attribute`); } const lat = Number(latStr); const lon = Number(lonStr); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { throw new Error(`<${tagLabel}> has a non-numeric lat or lon attribute`); } return { lon, lat }; } export interface GpxPoint { lon: number; lat: number; elevation?: number; /** ISO 8601, as GPX stores it — validated parseable (`Date.parse`) but not normalized/reformatted. */ time?: string; } export interface GpxTrack { name?: string; /** Line color from an embedded GPX Style extension (`gpx_style:color`), normalized to `#RRGGBB`. */ color?: string; points: GpxPoint[]; } export interface GpxWaypoint { lon: number; lat: number; name?: string; } export interface GpxDocument { /** The file-level name, from `` — distinct from each track's own name. */ name?: string; tracks: GpxTrack[]; waypoints: GpxWaypoint[]; } function parsePoint(el: RawElement, tagLabel: string): GpxPoint { const { lon, lat } = parseLonLat(el.attrs, tagLabel); let elevation: number | undefined; let time: string | undefined; if (el.inner) { const eleMatch = el.inner.match(/([\s\S]*?)<\/ele>/); if (eleMatch) { const ele = Number(eleMatch[1]!.trim()); if (Number.isFinite(ele)) elevation = ele; } const timeMatch = el.inner.match(/