diff --git a/apps/geolibre-desktop/src-tauri/src/lib.rs b/apps/geolibre-desktop/src-tauri/src/lib.rs index 709db49b9..d0b70d1aa 100644 --- a/apps/geolibre-desktop/src-tauri/src/lib.rs +++ b/apps/geolibre-desktop/src-tauri/src/lib.rs @@ -397,20 +397,32 @@ fn read_local_file(path: String) -> Result { .map_err(|error| format!("Could not read local file: {error}")) } -/// Add one user-selected GeoTIFF to the asset-protocol scope. The filesystem -/// and asset scopes are separate in Tauri; dialogs and native drops grant the -/// former, but maplibre-gl-raster fetches through the latter for range reads. +/// Add one GeoTIFF to the asset-protocol scope. The filesystem and asset scopes +/// are separate in Tauri; dialogs and native drops grant the former, but +/// maplibre-gl-raster fetches through the latter for range reads. A GeoTIFF +/// referenced by an imported QGIS project is also accepted when that project +/// file itself was explicitly selected by the user. #[tauri::command] -fn allow_raster_asset(app: tauri::AppHandle, path: String) -> Result<(), String> { +fn allow_raster_asset( + app: tauri::AppHandle, + path: String, + qgis_project_path: Option, +) -> Result<(), String> { let lower = path.to_ascii_lowercase(); if !is_safe_absolute_path(&path) || !(lower.ends_with(".tif") || lower.ends_with(".tiff")) { return Err(format!( "Refusing to expose \"{path}\": not an absolute GeoTIFF path" )); } - if !app.fs_scope().is_allowed(&path) { + let selected_qgis_project = qgis_project_path.is_some_and(|project_path| { + let lower = project_path.to_ascii_lowercase(); + is_safe_absolute_path(&project_path) + && (lower.ends_with(".qgs") || lower.ends_with(".qgz")) + && app.fs_scope().is_allowed(&project_path) + }); + if !app.fs_scope().is_allowed(&path) && !selected_qgis_project { return Err(format!( - "Refusing to expose \"{path}\": the file was not selected or dropped by the user" + "Refusing to expose \"{path}\": neither the file nor its QGIS project was selected by the user" )); } app.asset_protocol_scope() diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 4dc272558..d56f576ca 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -1424,6 +1424,7 @@ export function TopToolbar({ onOpenFromFile={() => void projectFiles.handleOpenFromFile()} onOpenFromUrl={() => projectFiles.setProjectUrlDialogOpen(true)} onOpenGallery={() => setGalleryDialogOpen(true)} + onImportQgisProject={() => void projectFiles.handleImportQgisProject()} onOpenRecent={(path) => { void projectFiles.handleOpenRecent(path).then((error) => { if (error) projectFiles.setActionError(error); diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index 82c03c42c..69047e848 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx @@ -94,6 +94,38 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { + { + if (!open) projectFiles.setQgisImportWarnings(null); + }} + > + + + {t("toolbar.item.qgisImportComplete")} + + {t("toolbar.item.qgisImportWarnings", { + count: projectFiles.qgisImportWarnings?.length ?? 0, + })} + + +
    + {projectFiles.qgisImportWarnings?.map((warning, index) => ( +
  • + {warning.layerName}:{" "} + {t(`toolbar.item.qgisImportReason.${warning.reason}`, { + provider: warning.provider || t("toolbar.item.qgisUnknownProvider"), + })} +
  • + ))} +
+
+ +
+
+
{ diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx index 7b594a257..805e8f509 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx @@ -16,6 +16,7 @@ import { Bookmark, Copy, FileCode2, + FileInput, FilePen, FilePlus2, FileText, @@ -23,6 +24,7 @@ import { FolderOpen, HardDriveDownload, History, + Import, LayoutGrid, Link2, Printer, @@ -43,6 +45,7 @@ interface ProjectMenuProps { onOpenFromFile: () => void; onOpenFromUrl: () => void; onOpenGallery: () => void; + onImportQgisProject: () => void; onOpenRecent: (path: string) => void; onSave: () => void; onSaveAs: () => void; @@ -63,6 +66,7 @@ export function ProjectMenu({ onOpenFromFile, onOpenFromUrl, onOpenGallery, + onImportQgisProject, onOpenRecent, onSave, onSaveAs, @@ -199,6 +203,20 @@ export function ProjectMenu({ )} + {show("project.import") && ( + + + + {t("toolbar.menu.import")} + + + + + {t("toolbar.item.importQgisProjectEllipsis")} + + + + )} {showSaveGroup && } {show("project.save") && ( diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 6679c3201..be785b5fd 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -6,18 +6,20 @@ import { useAppStore, type GeoLibreLayer, } from "@geolibre/core"; -import { materializeEmbeddableVectorLayers } from "@geolibre/plugins"; +import { addRasterToMap, materializeEmbeddableVectorLayers } from "@geolibre/plugins"; import type { FeatureCollection } from "geojson"; import { type FormEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getPluginManager } from "./usePlugins"; +import { createAppAPI, getPluginManager } from "./usePlugins"; import { pluginManifestUrlsForIds } from "../lib/external-plugins"; import { browserSaveFallsBackToDownload, isAbsoluteLocalPath, isHttpUrl, isTauri, + loadDroppedRasterPaths, openProjectFile, + openQgisProjectFile, openRecentProjectFile, RecentProjectGoneError, saveProjectFile, @@ -33,6 +35,11 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre"; import { shareAuthorizedFetch } from "../lib/share-gallery"; import { normalizeProjectUrl } from "../lib/urls"; import { resolveProjectXyzLayers } from "../lib/xyz-url"; +import { + importQgisProject, + materializeQgisRemoteLayers, + type QgisProjectImportWarning, +} from "../lib/qgis-project-import"; import type { MapControllerRef } from "../components/layout/toolbar/constants"; /** A pending "strip env vars before saving?" prompt. */ @@ -99,6 +106,33 @@ function isReloadableLocalFileLayer(layer: GeoLibreLayer): boolean { ); } +/** + * Let React commit a newly loaded project before a plugin attaches native map + * sources. A project load can replace the MapLibre style (and always schedules + * a layer sync); adding a raster in the same tick can therefore attach it to + * the outgoing style. Its store entry survives, but the native raster source + * is removed by the pending style/layer update. + */ +function importedProjectMapReady( + mapControllerRef: MapControllerRef, + basemapWillChange: boolean, +): Promise { + const map = mapControllerRef.current?.getMap(); + const styleReady = + map && basemapWillChange + ? new Promise((resolve) => map.once("style.load", () => resolve())) + : Promise.resolve(); + + return (async () => { + // Let the project store update commit and its MapCanvas effects run first. + // Register the style listener above (before loadProject) so a fast inline + // style cannot finish between the store update and this wait. + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await styleReady; + })(); +} + /** * Bundles every project file action (open from file/URL/recent, save, save as) * along with the related dialog state (Open-from-URL, env-var strip prompt, and @@ -116,6 +150,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const markSaved = useAppStore((s) => s.markSaved); const [actionError, setActionError] = useState(null); + const [qgisImportWarnings, setQgisImportWarnings] = useState( + null, + ); const [projectUrlDialogOpen, setProjectUrlDialogOpen] = useState(false); const [projectUrl, setProjectUrl] = useState(""); const [projectUrlError, setProjectUrlError] = useState(null); @@ -152,6 +189,92 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { } }; + const handleImportQgisProject = async () => { + const result = await openQgisProjectFile(); + if (!result) return; + try { + const imported = await materializeQgisRemoteLayers( + importQgisProject(result.data, result.path), + ); + if (!isTauri()) { + const unavailableLayerIds = new Set(); + for (const layer of imported.project.layers) { + if (layer.sourcePath && !isHttpUrl(layer.sourcePath)) { + unavailableLayerIds.add(layer.id); + imported.warnings.push({ + layerName: layer.name, + reason: "browser-local-file", + }); + } + } + imported.project.layers = imported.project.layers.filter( + (layer) => !unavailableLayerIds.has(layer.id), + ); + const usedGroupIds = new Set( + imported.project.layers.flatMap((layer) => (layer.groupId ? [layer.groupId] : [])), + ); + imported.project.layerGroups = imported.project.layerGroups?.filter((group) => + usedGroupIds.has(group.id), + ); + for (const raster of imported.rasters) { + imported.warnings.push({ + layerName: raster.name, + reason: "browser-local-raster", + }); + } + } + const mapReady = importedProjectMapReady( + mapControllerRef, + useAppStore.getState().basemapStyleUrl !== imported.project.basemapStyleUrl, + ); + loadProject(imported.project, null); + if (isTauri()) { + await mapReady; + const app = createAppAPI(mapControllerRef); + for (const raster of imported.rasters) { + try { + const [loaded] = await loadDroppedRasterPaths([raster.sourcePath], { + qgisProjectPath: result.path, + }); + if (!loaded) throw new Error("Unsupported raster path"); + const rasterLayerId = await addRasterToMap(app, loaded.source, { + name: raster.name, + localPath: raster.sourcePath, + // The Tauri/WebKitGTK WASM backend can stall when its first + // source is created immediately after a project style load. + // GPU renders this local COG directly and preserves the imported + // QGIS ramp, so use the verified backend for project imports. + defaults: { engine: "maplibre-gl-raster" }, + state: { + ...raster.state, + visible: raster.visible, + opacity: raster.opacity, + }, + beforeId: raster.beforeId, + zoomTo: false, + }); + if (raster.groupId) { + useAppStore.getState().moveLayerToGroup(rasterLayerId, raster.groupId); + } + } catch (error) { + console.error(`Failed to import QGIS raster "${raster.name}"`, error); + imported.warnings.push({ + layerName: raster.name, + reason: "format", + }); + } + } + } + useAppStore.setState({ isDirty: true }); + setQgisImportWarnings(imported.warnings.length > 0 ? imported.warnings : null); + } catch (error) { + console.error("Failed to import QGIS project", error); + setActionError( + error instanceof Error ? error.message : t("toolbar.error.couldNotImportQgisProject"), + ); + } + }; + const handleOpenFromUrl = async (event: FormEvent) => { event.preventDefault(); const normalizedUrl = normalizeProjectUrl(projectUrl); @@ -703,6 +826,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { return { actionError, setActionError, + qgisImportWarnings, + setQgisImportWarnings, projectUrlDialogOpen, setProjectUrlDialogOpen, handleProjectUrlDialogOpenChange, @@ -725,6 +850,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { submitSaveNamePrompt, cancelSaveNamePrompt, handleOpenFromFile, + handleImportQgisProject, handleOpenFromUrl, openProjectFromShareUrl, handleOpenRecent, diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 742b4a04d..6eb6cbf94 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1825,6 +1825,7 @@ "toolbar": { "menu": { "project": "Project", + "import": "Import", "edit": "Edit", "view": "View", "addData": "Add Data", @@ -2396,6 +2397,21 @@ "webServices": "Web Services", "commandPalette": "Command Palette", "openProjectFromUrl": "Open project from URL", + "importQgisProjectEllipsis": "Import QGIS Project…", + "qgisImportComplete": "QGIS project imported", + "qgisImportWarnings_one": "The project was imported, but {{count}} layer could not be loaded.", + "qgisImportWarnings_other": "The project was imported, but {{count}} layers could not be loaded.", + "qgisUnknownProvider": "unknown", + "qgisImportReason": { + "non-vector": "Only file-based vector layers are supported in this first version.", + "provider": "The {{provider}} data provider is not supported.", + "missing-source": "The layer has no readable data source.", + "format": "The layer's file format is not supported.", + "remote-file": "Remote QGIS file sources are not supported yet.", + "network-path": "Network share paths are not loaded for security reasons.", + "browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it.", + "browser-local-raster": "The raster is referenced by a local path. Use GeoLibre Desktop to load it." + }, "openProjectFromUrlDesc": "Load a `.geolibre.json` project and add it to recent projects.", "projectUrl": "Project URL", "opening": "Opening...", @@ -2505,6 +2521,7 @@ }, "error": { "couldNotOpenProject": "Could not open project.", + "couldNotImportQgisProject": "Could not import QGIS project.", "invalidProjectUrl": "Enter a valid HTTP or HTTPS project URL.", "couldNotOpenProjectUrl": "Could not open project URL.", "couldNotOpenRecentProject": "Could not open the recent project.", diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts new file mode 100644 index 000000000..09ad9f790 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -0,0 +1,684 @@ +import { + DEFAULT_BASEMAP, + DEFAULT_LAYER_STYLE, + VECTOR_COLOR_RAMPS, + createEmptyProject, + type GeoLibreLayer, + type GeoLibreProject, + type LayerGroup, + type LayerStyle, + type MapViewState, +} from "@geolibre/core"; +import { strFromU8, unzipSync } from "fflate"; +import type { FeatureCollection } from "geojson"; + +export interface QgisProjectImportWarning { + layerName: string; + reason: + | "non-vector" + | "provider" + | "missing-source" + | "format" + | "remote-file" + | "network-path" + | "browser-local-file" + | "browser-local-raster"; + provider?: string; +} + +export interface QgisProjectImportResult { + project: GeoLibreProject; + rasters: QgisRasterImport[]; + warnings: QgisProjectImportWarning[]; +} + +export interface QgisRasterImport { + id: string; + name: string; + sourcePath: string; + visible: boolean; + opacity: number; + groupId?: string; + beforeId?: string; + state?: { + mode: "single"; + bands: number[]; + colormap: string; + gamma: number; + rescale: [number, number][] | null; + reversed: boolean; + }; +} + +const MAX_QGS_BYTES = 25 * 1024 * 1024; +const REMOTE_FETCH_CONCURRENCY = 4; +const REMOTE_FETCH_TIMEOUT_MS = 10_000; + +/** + * Fetch remote GeoJSON sources referenced by a parsed QGIS project. + * + * QGIS commonly stores HTTP-backed OGR layers behind GDAL's `/vsicurl/` + * prefix. The synchronous parser normalizes that prefix to an HTTP URL; this + * step materializes those features before the project enters the store because + * GeoLibre's GeoJSON renderer consumes an in-memory FeatureCollection. + */ +export async function materializeQgisRemoteLayers( + result: QgisProjectImportResult, + fetcher: typeof fetch = fetch, +): Promise { + const failedLayerIds = new Set(); + const remoteLayers = result.project.layers.filter( + (layer) => layer.sourcePath && isHttpSource(layer.sourcePath), + ); + let nextIndex = 0; + async function worker(): Promise { + while (nextIndex < remoteLayers.length) { + const layer = remoteLayers[nextIndex++]; + const sourcePath = layer.sourcePath; + if (!sourcePath) continue; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS); + try { + const response = await fetcher(sourcePath, { signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as unknown; + if (!isFeatureCollection(data)) + throw new Error("Response is not a GeoJSON FeatureCollection"); + layer.geojson = data; + } catch { + failedLayerIds.add(layer.id); + result.warnings.push({ + layerName: layer.name, + reason: "remote-file", + }); + } finally { + clearTimeout(timeout); + } + } + } + await Promise.all( + Array.from({ length: Math.min(REMOTE_FETCH_CONCURRENCY, remoteLayers.length) }, () => worker()), + ); + if (failedLayerIds.size > 0) { + result.project.layers = result.project.layers.filter((layer) => !failedLayerIds.has(layer.id)); + const usedGroupIds = new Set( + result.project.layers.flatMap((layer) => (layer.groupId ? [layer.groupId] : [])), + ); + result.project.layerGroups = result.project.layerGroups?.filter((group) => + usedGroupIds.has(group.id), + ); + } + return result; +} + +// SYNC: VECTOR_FILE_DIALOG_EXTENSIONS in tauri-io.ts and +// RESTORABLE_VECTOR_EXTENSIONS in src-tauri/src/lib.rs. +const SUPPORTED_VECTOR_EXTENSIONS = new Set([ + "csv", + "dxf", + "fgb", + "flatgeobuf", + "geojson", + "geoparquet", + "gml", + "gpkg", + "gpx", + "json", + "kml", + "kmz", + "parquet", + "shp", + "tab", + "tsv", + "zip", +]); +const SUPPORTED_RASTER_EXTENSIONS = new Set(["tif", "tiff"]); + +/** Convert a QGIS project into a GeoLibre project without evaluating QGIS code. */ +export function importQgisProject( + data: ArrayBuffer | Uint8Array | string, + sourcePath: string, +): QgisProjectImportResult { + const xml = qgisProjectXml(data, sourcePath); + const document = new DOMParser().parseFromString(xml, "application/xml"); + if (document.querySelector("parsererror") || document.documentElement.tagName !== "qgis") { + throw new Error("This file is not a valid QGIS project."); + } + + const projectName = + text(document.querySelector("title")) || fileStem(sourcePath) || "Imported QGIS Project"; + const project = createEmptyProject(projectName, { + mapView: parseMapView(document), + }); + const parsedGroups = parseLayerGroups(document); + const groupByLayerId = layerGroupAssignments(document, parsedGroups.ids); + const visibilityByLayerId = layerVisibility(document); + const mapLayers = Array.from(document.querySelectorAll("projectlayers > maplayer")); + const byId = new Map( + mapLayers.map((element) => [text(element.querySelector(":scope > id")), element]), + ); + const warnings: QgisProjectImportWarning[] = []; + const layers: GeoLibreLayer[] = []; + const rasters: QgisRasterImport[] = []; + + for (const id of layerOrder(document, mapLayers)) { + const element = byId.get(id); + if (!element) continue; + const name = text(element.querySelector(":scope > layername")) || id || "QGIS layer"; + const provider = text(element.querySelector(":scope > provider")).toLowerCase(); + const dataSource = text(element.querySelector(":scope > datasource")); + + if ( + isOpenStreetMapBasemap(element, provider, dataSource) && + !groupByLayerId.has(id) && + id === layerOrder(document, mapLayers)[0] + ) { + project.basemapStyleUrl = DEFAULT_BASEMAP; + project.basemapVisible = visibilityByLayerId.get(id) ?? true; + project.basemapOpacity = parseOpacity(element); + continue; + } + + const source = qgisSourcePath(dataSource, sourcePath); + + if (isSupportedRasterLayer(element, provider, source)) { + const state = parseRasterState(element); + rasters.push({ + id, + name, + sourcePath: source, + visible: visibilityByLayerId.get(id) ?? true, + opacity: parseOpacity(element), + ...(groupByLayerId.get(id) ? { groupId: groupByLayerId.get(id) } : {}), + ...(nextLayerId(id, layerOrder(document, mapLayers)) + ? { beforeId: nextLayerId(id, layerOrder(document, mapLayers)) } + : {}), + ...(state ? { state } : {}), + }); + continue; + } + + if (!isSupportedVectorLayer(element, provider, source)) { + warnings.push({ + layerName: name, + reason: unsupportedReason(element, provider, source), + ...(provider ? { provider } : {}), + }); + continue; + } + + layers.push({ + id: uniqueLayerId(id, layers), + name, + type: "geojson", + source: { + type: "geojson", + ...(isHttpSource(source) ? { url: source } : {}), + }, + visible: visibilityByLayerId.get(id) ?? true, + opacity: parseOpacity(element), + style: parseLayerStyle(element), + metadata: { + ...(!isHttpSource(source) ? { localFileReloadable: true } : {}), + importedFrom: "qgis", + qgisLayerId: id, + qgisProvider: provider, + }, + sourcePath: source, + ...(groupByLayerId.get(id) ? { groupId: groupByLayerId.get(id) } : {}), + }); + } + + project.layers = layers; + project.layerGroups = parsedGroups.groups.filter( + (group) => + layers.some((layer) => layer.groupId === group.id) || + rasters.some((raster) => raster.groupId === group.id), + ); + project.metadata = { + importedFrom: "qgis", + qgisProjectPath: sourcePath, + qgisVersion: document.documentElement.getAttribute("version") ?? "", + }; + return { project, rasters, warnings }; +} + +function parseRasterState(element: Element): QgisRasterImport["state"] { + const renderer = element.querySelector(":scope > pipe > rasterrenderer"); + if (!renderer) return undefined; + const type = renderer.getAttribute("type")?.toLowerCase(); + if (type !== "singlebandpseudocolor" && type !== "singlebandgray") return undefined; + + const band = positiveInteger(renderer.getAttribute("band")) ?? 1; + const shader = renderer.querySelector("rastershader > colorrampshader"); + const minimum = numberAttribute(shader, "minimumValue"); + const maximum = numberAttribute(shader, "maximumValue"); + const ramp = shader?.querySelector(":scope > colorramp"); + const first = qgisRampColor(ramp, "color1"); + const last = qgisRampColor(ramp, "color2"); + const matched = matchBuiltInColorRamp(first, last); + const gamma = + numberAttribute(element.querySelector(":scope > pipe > brightnesscontrast"), "gamma") ?? 1; + + return { + mode: "single", + bands: [band], + colormap: type === "singlebandgray" ? "gray" : (matched?.colormap ?? "viridis"), + gamma: gamma > 0 ? gamma : 1, + rescale: + Number.isFinite(minimum) && Number.isFinite(maximum) && minimum < maximum + ? [[minimum, maximum]] + : null, + reversed: matched?.reversed ?? false, + }; +} + +function qgisRampColor(ramp: Element | null | undefined, name: string): string | null { + const option = Array.from(ramp?.querySelectorAll("Option") ?? []).find( + (candidate) => candidate.getAttribute("name") === name, + ); + const channels = option?.getAttribute("value")?.split(",", 3).map(Number); + if (!channels || channels.length !== 3 || channels.some((value) => !Number.isFinite(value))) { + return null; + } + return `#${channels + .map((value) => + Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +function matchBuiltInColorRamp( + first: string | null, + last: string | null, +): { colormap: string; reversed: boolean } | null { + if (!first || !last) return null; + for (const ramp of VECTOR_COLOR_RAMPS) { + const start = ramp.colors[0].toLowerCase(); + const end = ramp.colors[ramp.colors.length - 1].toLowerCase(); + if (first === start && last === end) return { colormap: ramp.value, reversed: false }; + if (first === end && last === start) return { colormap: ramp.value, reversed: true }; + } + return null; +} + +function positiveInteger(value: string | null): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function numberAttribute(element: Element | null | undefined, name: string): number { + const raw = element?.getAttribute(name); + if (raw == null || raw.trim() === "") return Number.NaN; + const value = Number(raw); + return Number.isFinite(value) ? value : Number.NaN; +} + +function qgisProjectXml(data: ArrayBuffer | Uint8Array | string, sourcePath: string): string { + if (typeof data === "string") { + if (new TextEncoder().encode(data).byteLength > MAX_QGS_BYTES) { + throw new Error("The QGIS project is too large to import safely."); + } + return data; + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + if (sourcePath.toLowerCase().endsWith(".qgs")) return strFromU8(bytes); + let entries: Record; + try { + entries = unzipSync(bytes, { + filter: (entry) => { + if (entry.originalSize > MAX_QGS_BYTES) { + throw new Error("The QGIS project is too large to import safely."); + } + return entry.name.toLowerCase().endsWith(".qgs"); + }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("too large to import safely")) throw error; + throw new Error("Could not read the compressed QGIS project."); + } + const qgsName = Object.keys(entries).find((name) => name.toLowerCase().endsWith(".qgs")); + if (!qgsName) throw new Error("The QGZ archive does not contain a QGS project file."); + return strFromU8(entries[qgsName]); +} + +function parseMapView(document: Document): MapViewState { + const extent = + document.querySelector("mapcanvas > extent") ?? + document.querySelector("projectviewsettings extent"); + const xmin = numberText(extent?.querySelector("xmin")); + const ymin = numberText(extent?.querySelector("ymin")); + const xmax = numberText(extent?.querySelector("xmax")); + const ymax = numberText(extent?.querySelector("ymax")); + const authId = + text(document.querySelector("mapcanvas > destinationsrs authid")) || + text(document.querySelector("projectCrs authid")); + if ([xmin, ymin, xmax, ymax].every(Number.isFinite)) { + const southwest = toWgs84(xmin, ymin, authId); + const northeast = toWgs84(xmax, ymax, authId); + if (southwest && northeast) { + const [west, south] = southwest; + const [east, north] = northeast; + return { + center: [(west + east) / 2, (south + north) / 2], + zoom: zoomForBounds(west, south, east, north), + bearing: 0, + pitch: 0, + bbox: [west, south, east, north], + }; + } + } + return { center: [-100, 40], zoom: 2, bearing: 0, pitch: 0 }; +} + +function toWgs84(x: number, y: number, authId: string): [number, number] | null { + const normalized = authId.toUpperCase(); + if (normalized === "EPSG:4326" || normalized === "CRS:84") return [x, y]; + if (normalized === "EPSG:3857") { + return [ + (x / 20037508.34) * 180, + (180 / Math.PI) * (2 * Math.atan(Math.exp((y / 20037508.34) * Math.PI)) - Math.PI / 2), + ]; + } + return null; +} + +function zoomForBounds(west: number, south: number, east: number, north: number): number { + const span = Math.max(Math.abs(east - west), Math.abs(north - south) * 2, 0.000001); + return Math.max(0, Math.min(20, Math.log2(360 / span) - 0.75)); +} + +function parseLayerGroups(document: Document): { + groups: LayerGroup[]; + ids: Map; +} { + const root = document.querySelector("layer-tree-group"); + if (!root) return { groups: [], ids: new Map() }; + const ids = new Map(); + const groups = Array.from(root.querySelectorAll("layer-tree-group")).map((element, index) => { + const name = groupDisplayName(element, root); + const id = `qgis-group-${index}-${slug(name)}`; + ids.set(element, id); + return { + id, + name, + collapsed: element.getAttribute("expanded") === "0", + visible: element.getAttribute("checked") !== "Qt::Unchecked", + opacity: 1, + }; + }); + return { groups, ids }; +} + +function groupDisplayName(element: Element, root: Element): string { + const names: string[] = []; + let current: Element | null = element; + while (current && current !== root) { + const name = current.getAttribute("name")?.trim(); + if (name) names.unshift(name); + current = current.parentElement?.closest("layer-tree-group") ?? null; + } + return names.join(" / ") || "Group"; +} + +function layerGroupAssignments(document: Document, ids: Map): Map { + const assignments = new Map(); + document.querySelectorAll("layer-tree-layer[id]").forEach((layer) => { + const layerId = layer.getAttribute("id"); + const parentGroup = layer.parentElement?.closest("layer-tree-group"); + const groupId = parentGroup ? ids.get(parentGroup) : undefined; + if (layerId && groupId) assignments.set(layerId, groupId); + }); + return assignments; +} + +function layerVisibility(document: Document): Map { + const result = new Map(); + document.querySelectorAll("layer-tree-layer[id]").forEach((element) => { + const id = element.getAttribute("id"); + if (id) result.set(id, element.getAttribute("checked") !== "Qt::Unchecked"); + }); + return result; +} + +function layerOrder(document: Document, mapLayers: Element[]): string[] { + const ids = Array.from(document.querySelectorAll("layer-tree-layer[id]")) + .map((element) => element.getAttribute("id") ?? "") + .filter(Boolean); + if (ids.length > 0) return ids.reverse(); + return mapLayers + .map((element) => text(element.querySelector(":scope > id"))) + .filter(Boolean) + .reverse(); +} + +function qgisSourcePath(dataSource: string, projectPath: string): string { + let source = dataSource.split("|", 1)[0]?.trim() ?? ""; + source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); + if (/^\/?vsizip\//i.test(source)) return ""; + if (source.startsWith("file://")) { + try { + source = decodeURIComponent(new URL(source).pathname).replace(/^\/([A-Za-z]:)/, "$1"); + } catch { + source = source.slice("file://".length); + } + } + source = source.replace(/^file:(?:\/\/)?/i, ""); + source = source.replace(/[?#].*$/, ""); + source = source.replace(/^['"]|['"]$/g, ""); + if (!source || isAbsolutePath(source) || /^[a-z]+:\/\//i.test(source)) return source; + const directory = /[/\\]/.test(projectPath) ? projectPath.replace(/[/\\][^/\\]*$/, "") : ""; + return normalizeJoinedPath(directory, source); +} + +function sourceExtension(source: string): string { + return source.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase() ?? ""; +} + +function normalizeJoinedPath(directory: string, relative: string): string { + const separator = directory.includes("\\") ? "\\" : "/"; + const absolute = directory.startsWith("/"); + const parts = [ + ...directory.replace(/\\/g, "/").split("/"), + ...relative.replace(/\\/g, "/").split("/"), + ]; + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return `${absolute ? "/" : ""}${normalized.join(separator)}`; +} + +function isSupportedVectorLayer(element: Element, provider: string, source: string): boolean { + return ( + element.getAttribute("type")?.toLowerCase() === "vector" && + (provider === "ogr" || provider === "delimitedtext") && + !isUncPath(source) && + SUPPORTED_VECTOR_EXTENSIONS.has(sourceExtension(source)) + ); +} + +function isSupportedRasterLayer(element: Element, provider: string, source: string): boolean { + return ( + element.getAttribute("type")?.toLowerCase() === "raster" && + provider === "gdal" && + !isHttpSource(source) && + !isUncPath(source) && + SUPPORTED_RASTER_EXTENSIONS.has(sourceExtension(source)) + ); +} + +function isOpenStreetMapBasemap(element: Element, provider: string, dataSource: string): boolean { + if (element.getAttribute("type")?.toLowerCase() !== "raster" || provider !== "wms") { + return false; + } + const params = new URLSearchParams(dataSource); + if (params.get("type")?.toLowerCase() !== "xyz") return false; + const tileUrl = params.get("url"); + if (!tileUrl) return false; + try { + const host = new URL(tileUrl).hostname.toLowerCase(); + return host === "tile.openstreetmap.org" || /^[abc]\.tile\.openstreetmap\.org$/.test(host); + } catch { + return false; + } +} + +function unsupportedReason( + element: Element, + provider: string, + source: string, +): QgisProjectImportWarning["reason"] { + if (element.getAttribute("type")?.toLowerCase() === "raster") { + if (isHttpSource(source)) return "remote-file"; + return "format"; + } + if (element.getAttribute("type")?.toLowerCase() !== "vector") { + return "non-vector"; + } + if (provider !== "ogr" && provider !== "delimitedtext") { + return "provider"; + } + if (!source) return "missing-source"; + if (isUncPath(source)) return "network-path"; + return "format"; +} + +function parseLayerStyle(element: Element): LayerStyle { + const style: LayerStyle = structuredClone(DEFAULT_LAYER_STYLE); + const renderer = element.querySelector(":scope > renderer-v2"); + if (renderer?.getAttribute("type") !== "singleSymbol") return style; + const symbolLayer = renderer.querySelector("symbols symbol layer"); + const options = new Map(); + symbolLayer?.querySelectorAll("Option[name]").forEach((option) => { + options.set(option.getAttribute("name") ?? "", option.getAttribute("value") ?? ""); + }); + const fill = qgisColor(options.get("color")); + const stroke = qgisColor(options.get("outline_color") ?? options.get("line_color")); + if (fill) { + style.fillColor = fill.color; + style.fillOpacity = fill.opacity; + style.markerColor = fill.color; + } + if (stroke) { + style.strokeColor = stroke.color; + } + const width = optionalNumber(options.get("outline_width") ?? options.get("line_width")); + const widthUnit = options.get("outline_width_unit") ?? options.get("line_width_unit") ?? "MM"; + if (width !== null && (widthUnit === "MM" || widthUnit === "Pixel")) { + style.strokeWidth = Math.max(0, width * (widthUnit === "MM" ? 3.78 : 1)); + } + const size = optionalNumber(options.get("size")); + const sizeUnit = options.get("size_unit") ?? "MM"; + if ( + symbolLayer?.getAttribute("class") === "SimpleMarker" && + size !== null && + (sizeUnit === "MM" || sizeUnit === "Pixel") + ) { + style.circleRadius = Math.max(1, (size * (sizeUnit === "MM" ? 3.78 : 1)) / 2); + } + const textStyle = element.querySelector("labeling[type='simple'] settings text-style"); + const field = textStyle?.getAttribute("fieldName")?.trim(); + if (field) { + style.labels.enabled = true; + style.labels.field = field; + const color = qgisColor(textStyle?.getAttribute("textColor") ?? undefined); + if (color) style.labels.color = color.color; + const sizeValue = optionalNumber(textStyle?.getAttribute("fontSize") ?? undefined); + if (sizeValue !== null) style.labels.size = sizeValue; + } + return style; +} + +function qgisColor(value: string | undefined): { color: string; opacity: number } | null { + if (!value) return null; + const parts = value.split(",").map(Number); + if (parts.length < 3 || parts.slice(0, 4).some((part) => !Number.isFinite(part))) return null; + const [red, green, blue, alpha = 255] = parts; + return { + color: `#${[red, green, blue] + .map((part) => + Math.max(0, Math.min(255, Math.round(part))) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`, + opacity: Math.max(0, Math.min(1, alpha / 255)), + }; +} + +function parseOpacity(element: Element): number { + const value = optionalNumber(text(element.querySelector(":scope > layerOpacity"))); + return value !== null ? Math.max(0, Math.min(1, value)) : 1; +} + +function optionalNumber(value: string | undefined): number | null { + if (value == null || value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function uniqueLayerId(candidate: string, layers: GeoLibreLayer[]): string { + const base = candidate.trim() || `qgis-layer-${layers.length + 1}`; + let id = base; + let suffix = 2; + while (layers.some((layer) => layer.id === id)) id = `${base}-${suffix++}`; + return id; +} + +function nextLayerId(id: string, orderedIds: string[]): string | undefined { + const index = orderedIds.indexOf(id); + return index >= 0 ? orderedIds[index + 1] : undefined; +} + +function numberText(element: Element | null | undefined): number { + const value = text(element); + return value === "" ? Number.NaN : Number(value); +} + +function text(element: Element | null | undefined): string { + return element?.textContent?.trim() ?? ""; +} + +function fileStem(path: string): string { + return ( + path + .split(/[/\\]/) + .pop() + ?.replace(/\.(qgz|qgs)$/i, "") ?? "" + ); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || path.startsWith("\\\\") || /^[A-Za-z]:[/\\]/.test(path); +} + +function isHttpSource(path: string): boolean { + return /^https?:\/\//i.test(path); +} + +function isFeatureCollection(value: unknown): value is FeatureCollection { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "FeatureCollection" && + Array.isArray((value as { features?: unknown }).features) + ); +} + +function isUncPath(path: string): boolean { + return path.startsWith("\\\\") || path.startsWith("//"); +} + +function slug(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "group" + ); +} diff --git a/apps/geolibre-desktop/src/lib/tauri-io.ts b/apps/geolibre-desktop/src/lib/tauri-io.ts index 194390d0a..15ced90d6 100644 --- a/apps/geolibre-desktop/src/lib/tauri-io.ts +++ b/apps/geolibre-desktop/src/lib/tauri-io.ts @@ -2116,6 +2116,20 @@ export async function openProjectFile(): Promise<{ return { project, path: selected }; } +/** Pick a QGIS project and return its raw bytes for the import converter. */ +export async function openQgisProjectFile(): Promise<{ + data: ArrayBuffer; + path: string; +} | null> { + const result = await openLocalDataFileWithFallback({ + filters: [{ name: "QGIS Project", extensions: ["qgz", "qgs"] }], + accept: ".qgz,.qgs", + readBinary: true, + }); + if (!result?.data) return null; + return { data: result.data, path: result.path }; +} + /** * Thrown when a recent project is permanently gone (HTTP 404/410 or a local * file that no longer exists), signalling the caller that the entry can be @@ -2436,9 +2450,19 @@ export function loadDroppedRasterFiles(droppedFiles: FileList | File[]): Dropped * these with byte-range support, so a COG opens lazily instead of copying the * entire file over IPC and then copying it again into a browser File. */ -export async function loadDroppedRasterPaths(paths: string[]): Promise { +export async function loadDroppedRasterPaths( + paths: string[], + options?: { qgisProjectPath?: string }, +): Promise { const rasterPaths = paths.filter(isRasterFileName); - await Promise.all(rasterPaths.map((path) => invoke("allow_raster_asset", { path }))); + await Promise.all( + rasterPaths.map((path) => + invoke("allow_raster_asset", { + path, + ...(options?.qgisProjectPath ? { qgisProjectPath: options.qgisProjectPath } : {}), + }), + ), + ); return rasterPaths.map((path) => ({ name: fileBaseName(path), source: convertFileSrc(path), diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index db666f0c5..74d0d9677 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -256,6 +256,12 @@ export const MENU_ITEM_CATALOG: readonly MenuItemCatalogEntry[] = [ labelKey: "toolbar.item.openRecent", tier: "basic", }, + { + id: "project.import", + menuId: "project", + labelKey: "toolbar.menu.import", + tier: "basic", + }, { id: "project.save", menuId: "project", labelKey: "common.save", tier: "basic" }, { id: "project.saveAs", diff --git a/package-lock.json b/package-lock.json index c8a8aa07c..bbc0dce1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^26.1.2", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", + "linkedom": "^0.18.13", "tsx": "^4.23.1", "typescript-eslint": "^8.65.0" }, @@ -11979,6 +11980,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -12616,6 +12631,48 @@ "utrie": "^1.0.2" } }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -12871,11 +12928,63 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, "node_modules/dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.4.12", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", @@ -12885,6 +12994,25 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -12997,6 +13125,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz", @@ -15052,6 +15193,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", @@ -15088,6 +15236,111 @@ "node": ">=16.0.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -16431,6 +16684,31 @@ "integrity": "sha512-PosanfyLckGXZbCX+aWmfmHWWhVPnLf9iKcUefaSGGw2IBOef5XdBdyl175LEqRy/sEOZ2SEz/l7K5S93BZlYQ==", "license": "ISC" }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -17696,6 +17974,23 @@ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", "license": "MIT" }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/numcodecs": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", @@ -20462,6 +20757,13 @@ "node": ">=12.17" } }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index f358eca65..6a008fdbf 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/node": "^26.1.2", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", + "linkedom": "^0.18.13", "tsx": "^4.23.1", "typescript-eslint": "^8.65.0" }, diff --git a/packages/plugins/src/plugins/maplibre-raster.ts b/packages/plugins/src/plugins/maplibre-raster.ts index 7877de93e..55f96caa6 100644 --- a/packages/plugins/src/plugins/maplibre-raster.ts +++ b/packages/plugins/src/plugins/maplibre-raster.ts @@ -333,6 +333,8 @@ export async function addRasterToMap( state?: Partial; /** Existing map style layer beneath which the raster is inserted. */ beforeId?: string; + /** Whether to fit the map to the raster after loading. Defaults to true. */ + zoomTo?: boolean; } = {}, ): Promise { const control = await ensureRasterControl(app); @@ -350,7 +352,7 @@ export async function addRasterToMap( } const id = await control.addRaster(source, { name: options.name, - zoomTo: true, + zoomTo: options.zoomTo ?? true, // Safe to pass before the band count is known: the renderer applies a // colormap only in single-band mode and ignores it otherwise. ...(options.state || options.defaults?.colormap diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts new file mode 100644 index 000000000..2779b5aef --- /dev/null +++ b/tests/qgis-project-import.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { strToU8, zipSync } from "fflate"; +import { DOMParser } from "linkedom"; +import { + importQgisProject, + materializeQgisRemoteLayers, +} from "../apps/geolibre-desktop/src/lib/qgis-project-import"; + +globalThis.DOMParser = DOMParser as unknown as typeof globalThis.DOMParser; + +function projectXml( + options: { + authId?: string; + extent?: string; + dataSources?: Array<{ id: string; name: string; source: string }>; + } = {}, +): string { + const dataSources = options.dataSources ?? [ + { id: "roads", name: "Roads", source: "../data/roads.geojson" }, + { + id: "cities", + name: "Cities", + source: "../data/cities.gpkg|layername=cities", + }, + ]; + const mapLayers = dataSources + .map( + ({ id, name, source }) => ` + + ${id} + ${name} + ${source} + ogr + 0.75 + + + + + + + + `, + ) + .join(""); + + return ` + Imported map + + + + + + + + + ${mapLayers} + + ${ + options.extent ?? + "-12524-6650" + } + ${ + options.authId ?? "EPSG:4326" + } + + `; +} + +function rasterProjectXml(source = "../data/dem.tif"): string { + return ` + + + + + + dem + Elevation + ${source} + gdal + 0.6 + + + + + + + + + + + + + + `; +} + +function osmBasemapProjectXml(): string { + return ` + + + + + + osm + OpenStreetMap + crs=EPSG:3857&type=xyz&url=https://tile.openstreetmap.org/%7Bz%7D/%7Bx%7D/%7By%7D.png&zmax=19 + wms + 0.7 + + + `; +} + +describe("QGIS project import", () => { + it("imports vector layers, styles, visibility, nested groups, extent, and relative paths", () => { + const result = importQgisProject(projectXml(), "/work/projects/example.qgs"); + + assert.equal(result.project.name, "Imported map"); + assert.deepEqual(result.project.mapView.center, [-95.5, 37]); + assert.deepEqual( + result.project.layerGroups?.map((group) => group.name), + ["Transport", "Transport / Places"], + ); + assert.deepEqual( + result.project.layers.map((layer) => layer.name), + ["Cities", "Roads"], + ); + + const cities = result.project.layers[0]; + const roads = result.project.layers[1]; + assert.equal(cities.sourcePath, "/work/data/cities.gpkg"); + assert.equal(cities.groupId, result.project.layerGroups?.[1].id); + assert.equal(cities.opacity, 0.75); + assert.equal(cities.style.fillColor, "#ff0000"); + assert.equal(cities.style.fillOpacity, 128 / 255); + assert.equal(cities.style.labels.enabled, true); + assert.equal(cities.style.labels.field, "name"); + assert.equal(roads.visible, false); + assert.equal(roads.groupId, result.project.layerGroups?.[0].id); + assert.deepEqual(result.warnings, []); + }); + + it("reads the QGS document from a QGZ archive", () => { + const qgz = zipSync({ "nested/project.qgs": strToU8(projectXml()) }); + const result = importQgisProject(qgz, "/work/projects/example.qgz"); + assert.equal(result.project.name, "Imported map"); + assert.equal(result.project.layers.length, 2); + assert.deepEqual(result.rasters, []); + }); + + it("collects local GDAL GeoTIFFs for the desktop raster loader", () => { + const result = importQgisProject(rasterProjectXml(), "/work/projects/example.qgz"); + + assert.deepEqual(result.project.layers, []); + assert.deepEqual(result.rasters, [ + { + id: "dem", + name: "Elevation", + sourcePath: "/work/data/dem.tif", + visible: false, + opacity: 0.6, + state: { + mode: "single", + bands: [1], + colormap: "turbo", + gamma: 1.2, + rescale: [[61.42, 1524.33]], + reversed: false, + }, + }, + ]); + assert.deepEqual(result.warnings, []); + }); + + it("maps QGIS OpenStreetMap XYZ layers to the built-in basemap", () => { + const result = importQgisProject(osmBasemapProjectXml(), "/work/example.qgs"); + + assert.equal(result.project.basemapStyleUrl, "https://tiles.openfreemap.org/styles/liberty"); + assert.equal(result.project.basemapVisible, false); + assert.equal(result.project.basemapOpacity, 0.7); + assert.deepEqual(result.project.layers, []); + assert.deepEqual(result.rasters, []); + assert.deepEqual(result.warnings, []); + }); + + it("falls back to the default view for a missing or unsupported-CRS extent", () => { + const missing = importQgisProject(projectXml({ extent: "" }), "/work/example.qgs"); + assert.deepEqual(missing.project.mapView, { + center: [-100, 40], + zoom: 2, + bearing: 0, + pitch: 0, + }); + + const projected = importQgisProject(projectXml({ authId: "EPSG:32618" }), "/work/example.qgs"); + assert.deepEqual(projected.project.mapView, { + center: [-100, 40], + zoom: 2, + bearing: 0, + pitch: 0, + }); + }); + + it("normalizes GDAL VSI URLs and still rejects UNC file sources", () => { + const result = importQgisProject( + projectXml({ + dataSources: [ + { + id: "roads", + name: "Remote", + source: "/vsicurl/https://example.com/roads.geojson|layername=roads", + }, + { + id: "cities", + name: "Network", + source: "\\\\server\\share\\cities.gpkg", + }, + ], + }), + "C:\\projects\\example.qgs", + ); + + assert.equal(result.project.layers.length, 1); + assert.equal(result.project.layers[0].sourcePath, "https://example.com/roads.geojson"); + assert.equal(result.project.layers[0].source.url, "https://example.com/roads.geojson"); + assert.equal(result.project.layers[0].metadata.localFileReloadable, undefined); + assert.deepEqual( + result.warnings.map((warning) => [warning.layerName, warning.reason]), + [["Network", "network-path"]], + ); + }); + + it("normalizes Windows file URLs, query strings, and bare project names", () => { + const windows = importQgisProject( + projectXml({ + dataSources: [ + { + id: "roads", + name: "CSV", + source: "file:///C:/data/points.csv?delimiter=%2C", + }, + ], + }), + "C:\\projects\\example.qgs", + ); + assert.equal(windows.project.layers[0].sourcePath, "C:/data/points.csv"); + + const browser = importQgisProject( + projectXml({ + dataSources: [{ id: "roads", name: "Roads", source: "data/roads.geojson" }], + }), + "example.qgs", + ); + assert.equal(browser.project.layers[0].sourcePath, "data/roads.geojson"); + }); + + it("does not silently flatten categorized styling or mis-scale pixel units", () => { + const xml = projectXml().replaceAll( + '', + '', + ); + const categorized = importQgisProject(xml, "/work/example.qgs"); + assert.notEqual(categorized.project.layers[0].style.fillColor, "#ff0000"); + + const pixelXml = projectXml().replaceAll( + '