Extracting TEXT and MTEXT Entities with ezdxf

To extract text with ezdxf, query both TEXT and MTEXT entities, read the string from dxf.text (single-line) or plain_text() (multiline, with formatting codes stripped), and pull position, height, and rotation from the dxf namespace. TEXT is a single-line entity with a straightforward set of attributes; MTEXT is a formatted multiline block whose content is peppered with inline control codes such as \P for paragraph breaks and \f...; for font switches, so its raw string is rarely what you want in an attribute table. Getting clean, positioned labels out of a drawing — the kind you attach to GIS features — means handling both entity types and their differing height attributes correctly. This page is part of the ezdxf Deep Dive reference on production-grade DXF parsing.

How ezdxf Handles TEXT and MTEXT

ezdxf models the two DXF text entities separately because their storage differs. A TEXT entity holds its content in entity.dxf.text, its insertion point in entity.dxf.insert (a Vec3), its cap height in entity.dxf.height, its rotation in degrees in entity.dxf.rotation, and its named text style in entity.dxf.style. Everything you need is a plain attribute read.

MTEXT is richer and trickier. Its content is a single string that carries inline formatting: \P marks a paragraph break, \f<font>; switches font, { } groups a formatted run, and sequences like \H2x; or \C1; change height and colour mid-string. Reading entity.text gives you this raw markup verbatim — useful only if you intend to preserve formatting. For a clean attribute value you call entity.plain_text(), which strips the control codes and returns readable text with paragraph breaks converted to newlines. MTEXT also stores its insertion point in dxf.insert, but its height lives in dxf.char_height (not dxf.height), and its anchor corner is dxf.attachment_point (an integer 1–9 mapping top-left through bottom-right).

TEXT and MTEXT Extraction to Label Records Diagram showing two branches. TEXT entities provide dxf.text, dxf.height and dxf.rotation directly. MTEXT entities are passed through plain_text to strip formatting codes and read dxf.char_height. Both branches merge into a positioned label record with cleaned string, height and rotation. TEXT dxf.text, dxf.height dxf.rotation, dxf.insert MTEXT plain_text() cleanup dxf.char_height normalise + merge NFC unicode, WCS point label record text, x, y, height, angle

What ezdxf does not do is unify the two entities for you. There is no shared .height — reading dxf.height on an MTEXT raises DXFAttributeError, and reading dxf.char_height on a TEXT does the same in reverse. Nor does it decide that block attribute text (ATTRIB/ATTDEF) belongs in your label set; those carry a tag and text pair and represent structured metadata, better handled through the block attribute extraction workflow so their tag semantics survive.

Production-Ready Script

The script harvests every TEXT and MTEXT entity into a list of dicts and, optionally, GeoJSON point features keyed on the insertion point — ready to become labels on the CAD polylines converted to GeoJSON in the same pipeline.

# ezdxf>=1.1.0, Python 3.9+
import ezdxf
import json
import unicodedata
from typing import List, Dict, Any

def harvest_text(dxf_path: str) -> List[Dict[str, Any]]:
    """Harvest TEXT and MTEXT entities as positioned, cleaned label records."""
    doc = ezdxf.readfile(dxf_path)   # ezdxf resolves the header code page
    msp = doc.modelspace()

    records: List[Dict[str, Any]] = []
    # Query both entity types in one pass.
    for e in msp.query("TEXT MTEXT"):
        etype = e.dxftype()
        if etype == "MTEXT":
            # plain_text() strips inline codes: \P, \f...;, {}, height/colour.
            content = e.plain_text()
            height = e.dxf.char_height          # MTEXT uses char_height
        else:  # TEXT
            content = e.dxf.text
            height = e.dxf.height               # TEXT uses height

        content = unicodedata.normalize("NFC", content).strip()
        if not content:
            continue                            # skip empty labels

        insert = e.dxf.insert                   # Vec3 insertion point
        records.append({
            "handle": e.dxf.handle,
            "type": etype,
            "layer": e.dxf.layer,
            "text": content,
            "x": float(insert.x),
            "y": float(insert.y),
            "z": float(insert.z),
            "height": float(height),
            # rotation is degrees; MTEXT stores it as dxf.rotation too.
            "rotation": float(e.dxf.get("rotation", 0.0)),
            "style": e.dxf.get("style", "Standard"),
        })

    return records

def records_to_geojson(records: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Convert label records to a GeoJSON FeatureCollection of points."""
    return {
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "geometry": {"type": "Point", "coordinates": [r["x"], r["y"]]},
                "properties": {
                    "text": r["text"],
                    "height": r["height"],
                    "rotation": r["rotation"],
                    "layer": r["layer"],
                    "source_type": r["type"],
                },
            }
            for r in records
        ],
    }

if __name__ == "__main__":
    recs = harvest_text("input.dxf")
    print(f"Harvested {len(recs)} text labels")
    with open("labels.geojson", "w", encoding="utf-8") as fh:
        json.dump(records_to_geojson(recs), fh, ensure_ascii=False, indent=2)

Key implementation notes:

  • msp.query("TEXT MTEXT") matches both entity types in a single traversal; the space-separated string is a type union, not two queries.
  • Branch on e.dxftype() before reading height: TEXT exposes dxf.height, MTEXT exposes dxf.char_height. Crossing them raises DXFAttributeError.
  • mtext.plain_text() is the correct cleaner. Do not regex-strip entity.text by hand — you will miss nested brace groups and \U+XXXX unicode escapes that plain_text() decodes.
  • unicodedata.normalize("NFC", ...) collapses combining sequences so accented characters compare and store consistently downstream.
  • json.dump(..., ensure_ascii=False) preserves non-ASCII characters directly in the GeoJSON rather than escaping them, which keeps the output readable and smaller.

Compatibility Matrix

Component Supported Range Notes
ezdxf version >=1.0.0 MTEXT.plain_text() stable since 0.16; >=1.1.0 recommended.
Python 3.9+ Uses typing generics and f-strings only.
DXF format R2000 (AC1015) — R2018 (AC1032) TEXT and MTEXT supported across the full range.
Height attribute Type-specific TEXT -> dxf.height; MTEXT -> dxf.char_height. Never shared.
Encoding Code page + UTF-8 ezdxf resolves the header code page on read; normalise with unicodedata.
ATTRIB / ATTDEF Separate workflow Structured tag/value metadata, not free labels; extract via block attributes.

For the group-code anatomy of a TEXT entity (1 for the string, 10/20 for the insertion point, 40 for height, 50 for rotation), see the DXF Entity Structure Breakdown.

Fallback Strategies

1. MTEXT formatting artifacts after cleanup

plain_text() handles the common codes, but drawings authored in specialised tools sometimes carry stacked fractions (\S1/2;), field codes, or vendor extensions that leave residual markers. After cleaning, run a lightweight sanitiser that collapses runs of whitespace and drops any remaining control characters so an odd fraction does not poison a label:

import re

def tidy(text: str) -> str:
    text = re.sub(r"\s+", " ", text)              # collapse whitespace
    return "".join(ch for ch in text if ch.isprintable()).strip()

2. Non-ASCII and legacy code-page text

Files from older non-Unicode AutoCAD builds store text in a code page named in the header ($DWGCODEPAGE). Let ezdxf.readfile() resolve it rather than forcing an encoding, then apply unicodedata.normalize("NFC", ...). If a specific file still garbles, log its handle and code page instead of dropping the record silently — a wrong label is worse than a flagged one.

3. ATTRIB and ATTDEF confused with free text

Block titles, room numbers, and asset tags usually live in ATTRIB entities attached to an INSERT, not in free TEXT. If your label harvest is missing obvious annotations, they are probably block attributes. Route those through the block attribute extraction workflow so the tag/value structure is preserved and used as feature attributes for the GIS labels produced here.

4. Stacked fractions and inline height changes

Stacked fractions and mid-string height changes are legitimate MTEXT formatting but can distort a plain label. Decide policy explicitly: either flatten 1/2-style stacks to a slash form (as plain_text() does) or, when the numeric value matters, parse the fraction into a real number in a post-step. Document the choice so downstream consumers know whether a label is display text or a parseable value.

5. Rotation reference frame for placement

Both entities store rotation in degrees, but placement conventions differ: MTEXT rotation combines with its attachment_point anchor, so a label may appear offset from its insert point unless you account for the anchor corner. When labels look shifted in the map, verify the attachment_point (1 = top-left through 9 = bottom-right) before adjusting coordinates.

FAQ

How do I strip formatting codes from MTEXT?

Call mtext.plain_text() to return the human-readable string with inline codes removed: paragraph breaks (\P), font switches (\f...;), colour and height changes, and brace grouping are stripped or converted. The raw formatted content is still available as mtext.text if you need the original markup.

What is the difference between TEXT and MTEXT height attributes?

TEXT stores its cap height in dxf.height. MTEXT stores its initial character height in dxf.char_height instead, and also carries dxf.width for the wrap box. Reading dxf.height on an MTEXT entity raises DXFAttributeError, so branch on the entity type before reading the height attribute.

Why does extracted text show garbled non-ASCII characters?

Older DXF files saved by non-Unicode AutoCAD versions store text in a code page rather than UTF-8. Open the file with ezdxf.readfile(path) and let ezdxf resolve the header code page, then normalise the result with unicodedata.normalize("NFC", text). MTEXT may also embed unicode as \U+XXXX escapes, which plain_text() decodes.

Should I read ATTRIB entities the same way as TEXT?

ATTRIB and ATTDEF entities carry text but belong to block inserts and hold a tag/value pair, so they represent structured metadata rather than free labels. Extract them through the block attribute workflow instead of the free-text harvester to keep tag semantics intact.