Skip to content

Commit 51ea680

Browse files
authored
fix(qgis): ignore Print Layout legend trees when importing a project (#1965)
* fix(qgis): ignore Print Layout legend trees when importing A Print Layout legend with autoUpdateModel="0" keeps its own frozen <layer-tree-group> snapshot under <Layouts>, independent of the project's layer tree. The importer read layer references with a document-wide querySelectorAll("layer-tree-layer[id]"), so those snapshots counted as extra layers: every layer came in once per legend that referenced it, on top of its real entry. The same query also let a legend's stale Qt::Unchecked state overwrite the layer's actual visibility. Resolve the project's own layer tree once (the <layer-tree-group> that is a direct child of <qgis>) and scope the order, visibility, and group lookups to it, so each physical layer imports exactly once with the visibility the layer tree records. * Address review feedback - Compute the raster layer's `beforeId` once instead of calling `nextLayerId` twice in the same object literal.
1 parent 9c71965 commit 51ea680

2 files changed

Lines changed: 62 additions & 17 deletions

File tree

apps/geolibre-desktop/src/lib/qgis-project-import.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,20 @@ export function importQgisProject(
152152
const project = createEmptyProject(projectName, {
153153
mapView: parseMapView(document),
154154
});
155-
const parsedGroups = parseLayerGroups(document);
156-
const groupByLayerId = layerGroupAssignments(document, parsedGroups.ids);
157-
const visibilityByLayerId = layerVisibility(document);
155+
const treeRoot = layerTreeRoot(document);
156+
const parsedGroups = parseLayerGroups(treeRoot);
157+
const groupByLayerId = layerGroupAssignments(treeRoot, parsedGroups.ids);
158+
const visibilityByLayerId = layerVisibility(treeRoot);
158159
const mapLayers = Array.from(document.querySelectorAll("projectlayers > maplayer"));
159160
const byId = new Map(
160161
mapLayers.map((element) => [text(element.querySelector(":scope > id")), element]),
161162
);
163+
const orderedIds = layerOrder(treeRoot, mapLayers);
162164
const warnings: QgisProjectImportWarning[] = [];
163165
const layers: GeoLibreLayer[] = [];
164166
const rasters: QgisRasterImport[] = [];
165167

166-
for (const id of layerOrder(document, mapLayers)) {
168+
for (const id of orderedIds) {
167169
const element = byId.get(id);
168170
if (!element) continue;
169171
const name = text(element.querySelector(":scope > layername")) || id || "QGIS layer";
@@ -173,7 +175,7 @@ export function importQgisProject(
173175
if (
174176
isOpenStreetMapBasemap(element, provider, dataSource) &&
175177
!groupByLayerId.has(id) &&
176-
id === layerOrder(document, mapLayers)[0]
178+
id === orderedIds[0]
177179
) {
178180
project.basemapStyleUrl = DEFAULT_BASEMAP;
179181
project.basemapVisible = visibilityByLayerId.get(id) ?? true;
@@ -185,16 +187,15 @@ export function importQgisProject(
185187

186188
if (isSupportedRasterLayer(element, provider, source)) {
187189
const state = parseRasterState(element);
190+
const beforeId = nextLayerId(id, orderedIds);
188191
rasters.push({
189192
id,
190193
name,
191194
sourcePath: source,
192195
visible: visibilityByLayerId.get(id) ?? true,
193196
opacity: parseOpacity(element),
194197
...(groupByLayerId.get(id) ? { groupId: groupByLayerId.get(id) } : {}),
195-
...(nextLayerId(id, layerOrder(document, mapLayers))
196-
? { beforeId: nextLayerId(id, layerOrder(document, mapLayers)) }
197-
: {}),
198+
...(beforeId ? { beforeId } : {}),
198199
...(state ? { state } : {}),
199200
});
200201
continue;
@@ -392,11 +393,21 @@ function zoomForBounds(west: number, south: number, east: number, north: number)
392393
return Math.max(0, Math.min(20, Math.log2(360 / span) - 0.75));
393394
}
394395

395-
function parseLayerGroups(document: Document): {
396+
/**
397+
* The project's own layer tree: the `<layer-tree-group>` that is a direct child
398+
* of `<qgis>`. A Print Layout legend with `autoUpdateModel="0"` stores its own
399+
* frozen `<layer-tree-group>` snapshot under `<Layouts>`, so a document-wide
400+
* query would treat every layer those legends reference as another layer to
401+
* import and duplicate it once per legend (GeoLibre#1964).
402+
*/
403+
function layerTreeRoot(document: Document): Element | null {
404+
return document.documentElement.querySelector(":scope > layer-tree-group");
405+
}
406+
407+
function parseLayerGroups(root: Element | null): {
396408
groups: LayerGroup[];
397409
ids: Map<Element, string>;
398410
} {
399-
const root = document.querySelector("layer-tree-group");
400411
if (!root) return { groups: [], ids: new Map() };
401412
const ids = new Map<Element, string>();
402413
const groups = Array.from(root.querySelectorAll("layer-tree-group")).map((element, index) => {
@@ -433,9 +444,12 @@ function groupDisplayName(element: Element, root: Element): string {
433444
return element.getAttribute("name")?.trim() || "Group";
434445
}
435446

436-
function layerGroupAssignments(document: Document, ids: Map<Element, string>): Map<string, string> {
447+
function layerGroupAssignments(
448+
root: Element | null,
449+
ids: Map<Element, string>,
450+
): Map<string, string> {
437451
const assignments = new Map<string, string>();
438-
document.querySelectorAll("layer-tree-layer[id]").forEach((layer) => {
452+
root?.querySelectorAll("layer-tree-layer[id]").forEach((layer) => {
439453
const layerId = layer.getAttribute("id");
440454
const parentGroup = layer.parentElement?.closest("layer-tree-group");
441455
const groupId = parentGroup ? ids.get(parentGroup) : undefined;
@@ -444,20 +458,21 @@ function layerGroupAssignments(document: Document, ids: Map<Element, string>): M
444458
return assignments;
445459
}
446460

447-
function layerVisibility(document: Document): Map<string, boolean> {
461+
function layerVisibility(root: Element | null): Map<string, boolean> {
448462
const result = new Map<string, boolean>();
449-
document.querySelectorAll("layer-tree-layer[id]").forEach((element) => {
463+
root?.querySelectorAll("layer-tree-layer[id]").forEach((element) => {
450464
const id = element.getAttribute("id");
451465
if (id) result.set(id, element.getAttribute("checked") !== "Qt::Unchecked");
452466
});
453467
return result;
454468
}
455469

456-
function layerOrder(document: Document, mapLayers: Element[]): string[] {
457-
const ids = Array.from(document.querySelectorAll("layer-tree-layer[id]"))
470+
function layerOrder(root: Element | null, mapLayers: Element[]): string[] {
471+
const ids = Array.from(root?.querySelectorAll("layer-tree-layer[id]") ?? [])
458472
.map((element) => element.getAttribute("id") ?? "")
459473
.filter(Boolean);
460-
if (ids.length > 0) return ids.reverse();
474+
const unique = Array.from(new Set(ids));
475+
if (unique.length > 0) return unique.reverse();
461476
return mapLayers
462477
.map((element) => text(element.querySelector(":scope > id")))
463478
.filter(Boolean)

tests/qgis-project-import.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,36 @@ describe("QGIS project import", () => {
299299
assert.equal(rendered.find((layer) => layer.name === "Cities")?.visible, false);
300300
});
301301

302+
it("ignores the frozen layer trees inside Print Layout legends", () => {
303+
const legend = (name: string, entries: string) => `
304+
<Layout name="${name}" units="mm">
305+
<LayoutItem type="65642" autoUpdateModel="0">
306+
<layer-tree-group name="" checked="Qt::Checked">${entries}</layer-tree-group>
307+
</LayoutItem>
308+
</Layout>`;
309+
const xml = projectXml().replace(
310+
"</qgis>",
311+
`<Layouts>
312+
${legend(
313+
"Baseline",
314+
`<layer-tree-layer id="roads" checked="Qt::Checked"/>
315+
<layer-tree-layer id="cities" checked="Qt::Unchecked"/>`,
316+
)}
317+
${legend("Proposed", '<layer-tree-layer id="cities" checked="Qt::Unchecked"/>')}
318+
${legend("Stale template", '<layer-tree-layer id="deleted" checked="Qt::Checked"/>')}
319+
</Layouts></qgis>`,
320+
);
321+
const result = importQgisProject(xml, "/work/example.qgs");
322+
323+
assert.deepEqual(
324+
result.project.layers.map((layer) => [layer.id, layer.name, layer.visible]),
325+
[
326+
["cities", "Cities", true],
327+
["roads", "Roads", false],
328+
],
329+
);
330+
});
331+
302332
it("normalizes Windows file URLs, query strings, encoded delimiters, and bare names", () => {
303333
const windows = importQgisProject(
304334
projectXml({

0 commit comments

Comments
 (0)