Normalising XREF-Prefixed Layer Names in Python
A layer that arrived through a bound external reference carries a prefix naming the reference it came from, so A-WALL becomes something like SITE-PLAN$0$A-WALL. Strip the prefix iteratively — nesting produces several — upper-case the result, and keep the original name alongside it, because classification wants the stripped form and provenance wants the original. This page is part of Layer Mapping Logic.
How Binding Mangles a Layer Name
An external reference brings its own layer table with it. While the reference is merely attached, those layers live in the reference and cannot collide with the host’s. Binding brings them into the host drawing, and to keep them distinct the binding process rewrites each name with a prefix derived from the reference.
There are two binding styles and they produce different results. One inserts the reference’s contents and prefixes with the reference name and a separator; the other binds and produces a different separator with an index. A drawing that has been through several rounds of federation can carry both forms, and a reference bound inside a reference produces a name with two prefixes stacked.
The consequence for a mapping pipeline is that a rule table written against clean layer names matches nothing on a federated drawing, and the layers all land in the unmapped bucket. The volume makes it obvious; the cause does not, because the layer names look plausible.
Production-Ready Script
# ezdxf>=1.1.0, Python 3.9+
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
import ezdxf
# Both binding conventions, plus the nested-reference form.
XREF_PREFIX = re.compile(r"^[^|$]+(?:\$\d+\$|\|)")
@dataclass(frozen=True)
class LayerName:
original: str # exactly as stored — provenance
normalised: str # prefix-free, upper case — classification
xref_chain: tuple[str, ...] # outermost first
@property
def from_xref(self) -> bool:
return bool(self.xref_chain)
def normalise(name: str) -> LayerName:
"""Strip stacked external-reference prefixes and upper-case the remainder."""
remainder = name
chain: list[str] = []
while True:
match = XREF_PREFIX.match(remainder)
if not match:
break
prefix = match.group(0)
chain.append(prefix.rstrip("$|0123456789"))
remainder = remainder[len(prefix):]
if not remainder: # a name that was ONLY a prefix
remainder = name
chain.clear()
break
return LayerName(original=name, normalised=remainder.upper(),
xref_chain=tuple(chain))
def normalise_document(dxf_path: str) -> dict[str, LayerName]:
doc = ezdxf.readfile(dxf_path)
return {layer.dxf.name: normalise(layer.dxf.name) for layer in doc.layers}
def summarise(names: dict[str, LayerName]) -> dict:
depth = Counter(len(n.xref_chain) for n in names.values())
collisions = Counter(n.normalised for n in names.values())
return {
"layers": len(names),
"from_xref": sum(1 for n in names.values() if n.from_xref),
"max_nesting": max(depth) if depth else 0,
"normalised_collisions": {k: v for k, v in collisions.items() if v > 1},
}
if __name__ == "__main__":
names = normalise_document("federated.dxf")
print(summarise(names))
Key implementation notes:
- The loop strips repeatedly. A single strip handles one level and leaves a nested reference still prefixed, which is the bug this page is really about.
- A name consisting only of a prefix restores the original rather than returning an empty string, so a malformed name never becomes a blank classification key.
normalised_collisionsis reported because stripping prefixes deliberately merges layers that binding deliberately separated. Two references each contributingA-WALLcollapse to one classification key, which is usually correct and occasionally not — either way it should be visible.- The original name is kept on every record. An audit asking which drawing a feature came from is answered from
xref_chainwithout re-reading the file.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
ezdxf |
>=1.1.0 |
doc.layers iteration |
| DXF revision | R2000 – R2018 | binding conventions unchanged across this range |
| Separator forms | $n$ and ` |
` |
| Nesting depth | unbounded | the loop handles stacked prefixes |
| Case | upper-cased output | match rule tables in one form only |
Fallback Strategies
1. Everything lands unmapped on a federated drawing. The signature of unstripped prefixes. Check from_xref counts before suspecting the rule table.
2. A reference name contains the separator. The pattern strips too much. Constrain it with the set of reference names actually present in the document rather than by pattern alone.
3. Normalised names collide. Two references contributed the same layer name. Decide whether they should merge; where they should not, key the classification on the pair of chain and name rather than on the name alone.
4. Layer 0 inside a reference. Entities on layer 0 inherit properties from the reference placement, so a normalised 0 is not the same thing as a host 0. Handle it as a distinct case rather than mapping it.
5. Names differing only in case. CAD layer names are case-insensitive but stored with case, so A-Wall and A-WALL are one layer conceptually and two strings. Upper-casing before matching, as above, is what makes the rule table small.
6. Non-ASCII characters in reference names. Project directories in languages other than English produce reference names with accented or non-Latin characters, and those survive into the bound layer name. The pattern above is character-class based rather than alphabet based, so it handles them, but a rule table written with the unaccented spelling will not match. Normalise Unicode to a single composition form before comparing, or two visually identical names remain two distinct strings.
Validating the Normaliser
Because the normaliser is a pure function from string to record, it is unusually cheap to test, and the cases worth committing are the ones a real federation produced:
# pytest
CASES = [
("A-WALL", "A-WALL", 0),
("SITE$0$A-WALL", "A-WALL", 1),
("SITE$0$SERVICES$0$M-DUCT", "M-DUCT", 2),
("SITE|C-ROAD-CNTR", "C-ROAD-CNTR", 1),
("a-wall", "A-WALL", 0),
]
def test_normalise():
for raw, expected, depth in CASES:
result = normalise(raw)
assert result.normalised == expected, raw
assert len(result.xref_chain) == depth, raw
assert result.original == raw # provenance is never lost
Add a case each time an unexpected layer name appears in a delivery, whatever the outcome. Over a few projects the list becomes a description of what the offices you work with actually produce, which is more useful than the pattern itself when a convention changes.
FAQ
Why do bound XREF layers have prefixes at all?
To keep them unique. When an external reference is bound into a host drawing its layers have to coexist with the host layers, so the binding process prefixes each one with the reference name. Without the prefix, a layer called A-WALL in the reference would silently merge with the host layer of the same name.
Is the separator always the same?
No, and that is the practical problem. Insert-binding uses one separator and full binding uses another, and reference names themselves can contain characters that look like separators. Detect the separator from the set of names present rather than hard-coding one, and handle both forms.
Should I strip the prefix at all?
For classification, yes — the geometry means the same thing whichever drawing it came from. For provenance, no: which reference a layer came from is often exactly what an audit needs. Keep both, which is why the normaliser returns a record rather than a string.
Related Pages
- Layer Mapping Logic — parent reference on rule routing and the unmapped bucket
- Mapping CAD Layers to GIS Feature Classes in Python — the rule engine this normaliser feeds
- Loading Layer Mapping Rules from YAML in Python — where the rules the normalised name is matched against live