Apuntes DAM
Volver al inicio

Comprobador de XML bien formado: el primer paso de cualquier analizador

Ejercicio de PythonDifícilUnos 80 minutos

Escribe lo que comprueba cualquier analizador antes de leer un XML: etiquetas bien anidadas, un único elemento raíz, atributos entre comillas y sin repetir, referencias a entidades válidas, comentarios, CDATA y la declaración en su sitio, con el primer error y su línea y columna exactas.

  • Documentos XML bien formados
  • Análisis léxico a mano
  • Pilas para comprobar el anidamiento
  • Expresiones regulares
  • Entidades y referencias de carácter
  • Posición de un error (línea y columna)

Enunciado

Un documento XML bien formado cumple las reglas de sintaxis del propio XML, sin mirar ninguna DTD ni esquema: un solo elemento raíz, cada etiqueta cerrada en el orden inverso al que se abrió, los atributos entre comillas y sin repetir, & solo para empezar una referencia válida (&amp;, &lt;, &#233;…) y < solo para empezar una etiqueta. Si un documento no es bien formado, ningún analizador puede leerlo: se detiene en el primer error y lo indica con su línea y columna.

La entrada trae varios documentos separados por una línea =====. Para cada uno, tu programa dice si es bien formado y, si lo es, cuántos elementos y atributos tiene, su profundidad y su raíz; si no, el primer error. El código de partida ya separa los documentos, calcula la línea y la columna de una posición y escribe los resultados.

Qué tiene que hacer el programa

  1. Recorre el documento de izquierda a derecha; en el primer error lanza MalFormado(posición, mensaje) con la posición del < (o del &, del atributo o de lo que falle). Instrucciones de procesamiento <?…?> (la declaración <?xml …?> solo en la posición 0: la declaración XML solo puede ir al principio); comentarios <!--…--> (sin -- dentro: un comentario no puede contener --, en la posición de ese --); secciones <![CDATA[…]]> (solo dentro del raíz); y <!DOCTYPE…> (solo antes del raíz). Si alguno no se cierra: falta cerrar … (la instrucción de procesamiento con ?>, el comentario con -->, la sección CDATA con ]]> o la declaración DOCTYPE).
  2. Una etiqueta de apertura: < seguido de un nombre válido (letra o _, después letras, cifras, _, - o ., y un prefijo opcional con :; si no, nombre de etiqueta no válido) y sus atributos, separados por espacios: atributo mal escrito en <x>, atributo repetido «a», al atributo «a» le falta el valor, el valor del atributo «a» debe ir entre comillas, falta cerrar las comillas del atributo «a», el valor del atributo «a» no puede contener < (en la posición del <) y falta cerrar la etiqueta <x>. Puede terminar en /> (vacía) o en >.
  3. Una etiqueta de cierre </x> (con espacios opcionales antes del >; si no, etiqueta de cierre mal escrita) debe cerrar la última abierta: se esperaba </a> y se encontró </b>, o etiqueta de cierre </b> sin abrir. Un segundo elemento cuando el raíz ya se cerró: solo puede haber un elemento raíz.
  4. Fuera del raíz solo puede haber espacios (texto fuera del elemento raíz, en la posición del primer carácter que no es espacio). En el texto y en los valores de los atributos, cada & debe empezar una referencia &amp;, &lt;, &gt;, &quot;, &apos;, &#N; o &#xH;; si no: referencia a entidad no válida «…» con lo que sigue al & hasta un espacio, ;, < o & (incluido el ; si está).
  5. Al final, si queda algo abierto: falta cerrar <x> (el último abierto) en la posición del final; si no hubo ningún elemento: el documento no tiene elemento raíz. Si todo está bien, devuelve (elementos, profundidad máxima, atributos, raíz).

Entrada

Uno o varios documentos XML separados por una línea =====.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Un documento correcto y los errores típicos

Entrada

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE biblioteca SYSTEM "biblioteca.dtd">
<!-- Préstamos de octubre -->
<biblioteca centro="IES Ejemplo" curso='2026-2027'>
  <libro isbn="978-84-0000-001-1" disponible="no">
    <titulo>Programación &amp; algoritmos</titulo>
    <autor>Ana Ruiz</autor>
    <nota><![CDATA[Usa <b>negrita</b> & más]]></nota>
    <portada/>
  </libro>
  <libro isbn="978-84-0000-002-8">
    <titulo>Redes &#233;picas</titulo>
  </libro>
</biblioteca>
=====
<alumnos>
  <alumno id="1">
    <nombre>Eva</nombre>
  </alumnno>
</alumnos>
=====
<alumnos>
  <alumno id="1" id="2">Ana</alumno>
</alumnos>
=====
<config>
  <ruta valor=C:\datos></ruta>
</config>
=====
<a>
  <b>Pan & vino</b>
</a>
=====
<pedido>
  <linea>1</linea>
</pedido>
<pedido>
  <linea>2</linea>
</pedido>
=====
<notas>
  <!-- revisar -- urgente -->
</notas>

Salida por consola

Documento 1: bien formado
  Elementos: 8 · profundidad máxima: 3 · atributos: 5 · raíz: <biblioteca>
Documento 2: mal formado
  Línea 4, columna 3: se esperaba </alumno> y se encontró </alumnno>
Documento 3: mal formado
  Línea 2, columna 18: atributo repetido «id»
Documento 4: mal formado
  Línea 2, columna 15: el valor del atributo «valor» debe ir entre comillas
Documento 5: mal formado
  Línea 2, columna 10: referencia a entidad no válida «&»
Documento 6: mal formado
  Línea 4, columna 1: solo puede haber un elemento raíz
Documento 7: mal formado
  Línea 2, columna 16: un comentario no puede contener --

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Un bucle que mira qué empieza aquí

En cada posición, decide qué hay por los primeros caracteres: <?, <!--, <![CDATA[, <!DOCTYPE, </, < o texto. Cada caso consume lo suyo y deja i justo detrás.

python
while i < n:
    if doc[i] != "<":
        ...  # texto hasta el siguiente <
    elif doc.startswith("<!--", i):
        ...
    elif doc.startswith("</", i):
        ...
2. La pila de etiquetas abiertas

Al abrir un elemento lo apilas; al cerrarlo, el nombre tiene que coincidir con la cima. La profundidad máxima es el tamaño máximo que alcanza la pila (contando las etiquetas vacías <x/>).

3. Los atributos

Tras el nombre, repite: espacios, nombre del atributo, =, comilla de apertura y la misma comilla de cierre. Un conjunto con los nombres ya vistos detecta los repetidos.

4. Posiciones exactas

Lanza siempre el error con la posición donde empieza el problema; posicion(doc, i) la convierte en línea y columna contando los saltos de línea anteriores.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🐍PythonComprobador de XML bien formado: el primer paso de cualquier analizadorDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE biblioteca SYSTEM "biblioteca.dtd">
<!-- Préstamos de octubre -->
<biblioteca centro="IES Ejemplo" curso='2026-2027'>
  <libro isbn="978-84-0000-001-1" disponible="no">
    <titulo>Programación &amp; algoritmos</titulo>
    <autor>Ana Ruiz</autor>
    <nota><![CDATA[Usa <b>negrita</b> & más]]></nota>
    <portada/>
  </libro>
  <libro isbn="978-84-0000-002-8">
    <titulo>Redes &#233;picas</titulo>
  </libro>
</biblioteca>
=====
<alumnos>
  <alumno id="1">
    <nombre>Eva</nombre>
  </alumnno>
</alumnos>
=====
<alumnos>
  <alumno id="1" id="2">Ana</alumno>
</alumnos>
=====
<config>
  <ruta valor=C:\datos></ruta>
</config>
=====
<a>
  <b>Pan & vino</b>
</a>
=====
<pedido>
  <linea>1</linea>
</pedido>
<pedido>
  <linea>2</linea>
</pedido>
=====
<notas>
  <!-- revisar -- urgente -->
</notas>
Salida esperada
Documento 1: bien formado
  Elementos: 8 · profundidad máxima: 3 · atributos: 5 · raíz: <biblioteca>
Documento 2: mal formado
  Línea 4, columna 3: se esperaba </alumno> y se encontró </alumnno>
Documento 3: mal formado
  Línea 2, columna 18: atributo repetido «id»
Documento 4: mal formado
  Línea 2, columna 15: el valor del atributo «valor» debe ir entre comillas
Documento 5: mal formado
  Línea 2, columna 10: referencia a entidad no válida «&»
Documento 6: mal formado
  Línea 4, columna 1: solo puede haber un elemento raíz
Documento 7: mal formado
  Línea 2, columna 16: un comentario no puede contener --
⏳
Test oculto #2
⏳
Test oculto #3
0/3 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import re
2import sys
3
4NOMBRE = re.compile(r"[A-Za-z_][\w.\-]*(?::[A-Za-z_][\w.\-]*)?")
5REFERENCIA = re.compile(r"&(?:amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);")
6
7
8class MalFormado(Exception):
9    def __init__(self, pos, mensaje):
10        self.pos = pos
11        self.mensaje = mensaje
12
13
14def posicion(texto, pos):
15    """Línea y columna (desde 1) de una posición del texto."""
16    linea = texto.count("\n", 0, pos) + 1
17    columna = pos - (texto.rfind("\n", 0, pos) + 1) + 1
18    return linea, columna
19
20def comprobar_texto(doc, inicio, fin):
21    """Dentro del texto, & solo puede empezar una referencia válida."""
22    i = doc.find("&", inicio, fin)
23    while i != -1:
24        if not REFERENCIA.match(doc, i):
25            trozo = re.match(r"&[^\s;<&]*;?", doc[i:fin]).group()
26            raise MalFormado(i, f"referencia a entidad no válida «{trozo}»")
27        i = doc.find("&", i + 1, fin)
28
29
30def analizar(doc):
31    """Recorre el documento; lanza MalFormado en el primer error y si no, devuelve las estadísticas."""
32    pila, i, n = [], 0, len(doc)
33    raiz, cerrada = None, False
34    elementos = profundidad = atributos = 0
35    while i < n:
36        if doc[i] != "<":
37            fin = doc.find("<", i)
38            fin = n if fin == -1 else fin
39            if not pila and doc[i:fin].strip():
40                k = i + len(doc[i:fin]) - len(doc[i:fin].lstrip())
41                raise MalFormado(k, "texto fuera del elemento raíz")
42            comprobar_texto(doc, i, fin)
43            i = fin
44            continue
45        if doc.startswith("<?", i):
46            fin = doc.find("?>", i)
47            if fin == -1:
48                raise MalFormado(i, "falta cerrar la instrucción de procesamiento con ?>")
49            objetivo = re.match(r"<\?([^\s?]*)", doc[i:]).group(1)
50            if objetivo.lower() == "xml" and i != 0:
51                raise MalFormado(i, "la declaración XML solo puede ir al principio")
52            i = fin + 2
53        elif doc.startswith("<!--", i):
54            fin = doc.find("-->", i + 4)
55            if fin == -1:
56                raise MalFormado(i, "falta cerrar el comentario con -->")
57            dentro = doc.find("--", i + 4, fin)
58            if dentro != -1:
59                raise MalFormado(dentro, "un comentario no puede contener --")
60            i = fin + 3
61        elif doc.startswith("<![CDATA[", i):
62            if not pila:
63                raise MalFormado(i, "una sección CDATA solo puede ir dentro del elemento raíz")
64            fin = doc.find("]]>", i)
65            if fin == -1:
66                raise MalFormado(i, "falta cerrar la sección CDATA con ]]>")
67            i = fin + 3
68        elif doc.startswith("<!DOCTYPE", i):
69            if raiz is not None:
70                raise MalFormado(i, "la declaración DOCTYPE debe ir antes del elemento raíz")
71            fin = doc.find(">", i)
72            if fin == -1:
73                raise MalFormado(i, "falta cerrar la declaración DOCTYPE")
74            i = fin + 1
75        elif doc.startswith("</", i):
76            m = re.match(r"</\s*(" + NOMBRE.pattern + r")\s*>", doc[i:])
77            if not m:
78                raise MalFormado(i, "etiqueta de cierre mal escrita")
79            nombre = m.group(1)
80            if not pila:
81                raise MalFormado(i, f"etiqueta de cierre </{nombre}> sin abrir")
82            if pila[-1] != nombre:
83                raise MalFormado(i, f"se esperaba </{pila[-1]}> y se encontró </{nombre}>")
84            pila.pop()
85            cerrada = not pila
86            i += m.end()
87        else:
88            m = NOMBRE.match(doc, i + 1)
89            if not m:
90                raise MalFormado(i, "nombre de etiqueta no válido")
91            nombre = m.group()
92            if cerrada or (raiz is not None and not pila):
93                raise MalFormado(i, "solo puede haber un elemento raíz")
94            j, vistos = m.end(), set()
95            while True:
96                espacios = re.match(r"\s*", doc[j:]).end()
97                k = j + espacios
98                if doc.startswith("/>", k) or doc.startswith(">", k):
99                    break
100                if k >= n:
101                    raise MalFormado(i, f"falta cerrar la etiqueta <{nombre}>")
102                a = NOMBRE.match(doc, k)
103                if not a or espacios == 0:
104                    raise MalFormado(k, f"atributo mal escrito en <{nombre}>")
105                attr = a.group()
106                if attr in vistos:
107                    raise MalFormado(k, f"atributo repetido «{attr}»")
108                vistos.add(attr)
109                igual = re.match(r"\s*=\s*", doc[a.end():])
110                if not igual:
111                    raise MalFormado(k, f"al atributo «{attr}» le falta el valor")
112                v = a.end() + igual.end()
113                comilla = doc[v] if v < n else ""
114                if comilla not in ("'", '"'):
115                    raise MalFormado(v, f"el valor del atributo «{attr}» debe ir entre comillas")
116                cierre = doc.find(comilla, v + 1)
117                if cierre == -1:
118                    raise MalFormado(v, f"falta cerrar las comillas del atributo «{attr}»")
119                if "<" in doc[v + 1:cierre]:
120                    raise MalFormado(doc.index("<", v + 1), f"el valor del atributo «{attr}» no puede contener <")
121                comprobar_texto(doc, v + 1, cierre)
122                atributos += 1
123                j = cierre + 1
124            elementos += 1
125            raiz = raiz or nombre
126            if doc.startswith("/>", k):
127                profundidad = max(profundidad, len(pila) + 1)
128                cerrada = cerrada or not pila
129                i = k + 2
130            else:
131                pila.append(nombre)
132                profundidad = max(profundidad, len(pila))
133                i = k + 1
134    if pila:
135        raise MalFormado(n, f"falta cerrar <{pila[-1]}>")
136    if raiz is None:
137        raise MalFormado(n, "el documento no tiene elemento raíz")
138    return elementos, profundidad, atributos, raiz
139
140
141def main():
142    documentos = re.split(r"^=====\s*$\n?", sys.stdin.read(), flags=re.M)
143    for k, doc in enumerate(documentos, 1):
144        doc = doc.rstrip("\n")
145        try:
146            elementos, profundidad, atributos, raiz = analizar(doc)
147            print(f"Documento {k}: bien formado")
148            print(f"  Elementos: {elementos} · profundidad máxima: {profundidad} · atributos: {atributos} · raíz: <{raiz}>")
149        except MalFormado as e:
150            linea, columna = posicion(doc, e.pos)
151            print(f"Documento {k}: mal formado")
152            print(f"  Línea {linea}, columna {columna}: {e.mensaje}")
153
154
155main()

Ser bien formado es la condición mínima para que un documento sea XML. Ser válido es otra cosa: cumplir además una DTD o un XML Schema. Un documento puede estar bien formado y no ser válido, pero nunca al revés.

La pila es la estructura natural para cualquier lenguaje con elementos anidados: HTML, JSON, los paréntesis de una expresión o los bloques de un programa se comprueban igual.

XML es estricto a propósito: ante el primer error, el analizador para (es un error fatal). HTML hizo lo contrario y los navegadores corrigen lo que pueden, con reglas complicadísimas; XML prefirió que todos los programas lean igual cada documento.

Las referencias &lt; y &amp; existen porque < y & tienen significado especial; dentro de una sección CDATA no hace falta escaparlos, por eso se usan para meter código o HTML dentro de un XML.

Para ir más allá

  • Comprueba también que los prefijos de los espacios de nombres estén declarados con xmlns:prefijo.
  • En lugar de parar en el primer error, recupérate y lista todos (como hace un editor).
  • Construye el árbol del documento y escríbelo con sangría.

Dónde se explica