Un parser HTML des plus léger

Jusqu'à présent, WdPublisher (mon bébé CMS) traitait les gabarits utilisés pour la construction des sites à grands coups de preg_match_callback(), faisant croire que tout allait bien. Malheureusement, aussi merveilleuses que peuvent être les expression rationnelles, leur utilisation a vite posé des limites qu'il fallait contourner par des tours de magie contraignants.

Ce matin, profitant du calme d'un jour férié, je me suis assis devant mon ordi et j'ai réfléchi très fort (aïe) en essayant de tout reprendre like a virgin.

Un constat froid et humide

Le problème de la méthode récursive en utilisant preg_match_callback() c'est que rien ne va plus dès que deux mêmes marqueurs s'emboitent :

<div>
    <span>
        <br />
        <span>
        un bout de texte
        </span>
        <input type="text" />
    </span>
</div>

Dans l'exemple ci-dessus, si l'on cherche à récupérer le contenu du premier span on va se retrouver avec :

<span>
        <br />
        <span>
        un bout de texte
        </span>

alors que tout ce l'on voulait c'était :

<span>
        <br />
        <span>
        un bout de texte
        </span>
        <input type="text" />
    </span>

Je ne vais même pas parler du cas où les tags ne sont pas correctement fermés, c'est totalement le bordel :-(

Que faire alors ? Renoncer ? Partir à la plage ? Feindre l'ignorance jusqu'à ce qu'on se moque de moi qui suis sois-disant exigeant. Je me devais de sauver ma réputation, sinon ma vie de développeur serait foutue et je n'aurai d'autre choix que de sortir tous les samedi soir pour boire mon salaire en compagnie fluorescente.

De la lumière & un arbre

C'est quand même pas compliqué ! Il me faut un arbre. Avec un arbre de marqueurs la vie serait tellement plus belle et facile ! Mais comment le construire ? Hum. Ben avec pas grand chose finalement. J'ai juste besoin de savoir quand un marqueur s'ouvre et quand il se ferme, en gérant les marqueurs qui se ferment eux mêmes, en oubliant pas de récupérer le texte qui traine autour de tout ça.

« Autour de tout ça »

Mais bien sur ! La solution était là, palpitante, dans mon cerveau brumeux mais finalement assez lucide pour un vendredi matin. Plutôt que d'utiliser preg_match_callback() et suer sang et eau à gérer ses retours, pourquoi ne pas séparer les marqueurs du texte, dans une sorte de joli tableau texte / marqueur. Je n'aurai alors plus qu'à créer un véritable arbre en fonction des marqueurs d'ouverture et de fermeture !

Séparer le blanc du jaune

Pour séparer les marqueurs du texte, nous allons rester dans les expressions rationnelles en utilisant la fabuleuse, et finalement méconnue function preg_split(). C'est assez simple de récupérer les marqueurs HTML. Un petit coup de #<(/?)([^>]*)># fait l'affaire. L'important c'est de conserver le marqueur délimiteur.

<?php

$matches = preg_split('#<(/?)([^>]*)>#'$template-1, PREG_SPLIT_DELIM_CAPTURE);

?>

Et voilà le résultat, stupéfiant :

Array
(
    [0] => 
    [1] => 
    [2] => div

    [3] =>     
    [4] => 
    [5] => span

    [6] =>         
    [7] => 
    [8] => br /

    [9] =>         
    [10] => 
    [11] => span

    [12] => 
        un bout de texte        
    [13] => /
    [14] => span

    [15] =>         
    [16] => 
    [17] => input type="text" /

    [18] =>     
    [19] => /
    [20] => span

    [21] => 
    [22] => /
    [23] => div

    [24] => 
)

J'ai associé les entrées par groupe de trois pour que l'on voit mieux ce qui se passe. Si l'on prend i comme index de départ, alors :

  • en i+0 nous avons le texte qui se trouve avant le marqueur HTML.
  • en i+1 nous avons '/' si le marqueur est un marqueur de fermeture.
  • en i+2 nous avons le contenu du marqueur, accompagné de sa barre de fraction / pour les marqueurs qui se ferment eux-mêmes.

Il ne nous reste plus qu'à faire une jolie boucle sur tout ce beau monde en utilisant un modulo de 3 pour récupérer toutes les informations dont nous avons besoin pour créer un arbre.

Construction de l'arbre

Maintenant que nous avons établi les bases de la séparation, il est tant de rassembler tout ça sous la forme bien plus pratique d'un arbre de nœuds texte et marqueur.

Comme nous l'avons vu, i % 3 == 0 nous permet de récupérer le texte qui se trouve avant le marqueur. On en fera un joli nœud texte. i % 3 == 1 nous permet de savoir si le marqueur qui suit est un marqueur de fermeture, auquel cas on trouvera un '/'. Voyons maintenant ce qui se passe lorsque l'on tombe enfin sur le contenu du marqueur en i % 3 == 2.

Comme nous allons faire pas mal de récursion, le mieux est de sortir chaque entrée du tableau jusqu'à ce qu'il n'y en ait plus. Le tableau doit rester accessible de façon à ce qu'au retour d'une récursion on puisse poursuivre la collecte tranquillement. Ainsi :

  • si le marqueur se ferme lui-même on crée un nœud marqueur et on continu la collecte.
  • si le marqueur est un marqueur de fermeture on retourne la branche. Vous verrez dans le code final qu'à ce point je vérifie que le marqueur de fermeture correspond bien au dernier marqueur ouvert. Structure bien formée, quand tu nous tiens.
  • enfin, si le marqueur ne correspond à aucun des cas précédents, il s'agit d'un marqueur ouvert. On crée donc un nœud marqueur avec un champ enfants et l'on fait une récursion sur ce qui reste des entrées du tableau.

On devrait alors obtenir un bel arbre comme celui-ci :

Array
(
    [0] => Array
        (
            [name] => div
            [args] => Array
                (
                )

            [children] => Array
                (
                    [0] => Array
                        (
                            [name] => span
                            [args] => Array
                                (
                                )

                            [children] => Array
                                (
                                    [0] => Array
                                        (
                                            [name] => br
                                            [args] => Array
                                                (
                                                )

                                        )

                                    [1] => Array
                                        (
                                            [name] => span
                                            [args] => Array
                                                (
                                                )

                                            [children] => Array
                                                (
                                                    [0] => 
        un bout de texte
        
                                                )
                                        )

                                    [2] => Array
                                        (
                                            [name] => input
                                            [args] => Array
                                                (
                                                    [type] => text
                                                )
                                        )
                                )
                        )
                )
        )
)

La classe WdHTMLParser

Ne reste plus qu'à fignoler en gérant les commentaires et autre marqueurs d'instruction et le tour et joué !

Voici mesdames et messieurs, devant vos yeux émerveillés, la classe WdHTMLParser qui, je l'espère, vous sera aussi utile qu'à moi.

<?php

class WdHTMLParser
{
    private $encoding;
    private $matches;
    private $escaped;
    private $opened = array();
    
    public $malformed;

    public function parse($html$namespace=NULL$encoding='utf-8')
    {
        $this->malformed = false;
        $this->encoding = $encoding;
        
        #
        # we take care of escaping comments and processing options. they will not be parsed
        # and will end as text nodes
        #
        
        $html = $this->escapeSpecials($html);
        
        #
        # in order to create a tree, we first need to split the HTML using the markups,
        # creating a nice flat array of texts and opening and closing markups.
        #
        # the array can be read as follows :
        #
        # i+0 => some text
        # i+1 => '/' for closing markups, nothing otherwise
        # i+2 => the markup it self, without the '<' '>'
        #
        # note that i+2 might end with a '/' indicating an auto-closing markup
        #
    
        $this->matches = preg_split
        (
            '#<(/?)' . $namespace . '([^>]*)>#'$html-1, PREG_SPLIT_DELIM_CAPTURE
        );
        
        #
        # the flat representation is now ready, we can create our tree
        #
        
        $tree = $this->buildTree();
        
        #
        # if comments or processing options where escaped, we can
        # safely unescape them now
        #
        
        if ($this->escaped)
        {
            $tree = $this->unescapeSpecials($tree);
        }
        
        return $tree;
    }
    
    private function escapeSpecials($html)
    {
        #
        # here we escape comments
        #
        
        $html = preg_replace_callback('#<\!--.+-->#sU'array($this'escapeSpecials_callback')$html);
        
        #
        # and processing options
        #
        
        $html = preg_replace_callback('#<\?.+\?>#sU'array($this'escapeSpecials_callback')$html);
        
        return $html;
    }
    
    private function escapeSpecials_callback($m)
    {
        $this->escaped = true;
        
        $text = $m[0];
        
        $text = str_replace
        (
            array('<''>'),
            array("\x01""\x02"),
            $text
        );
        
        return $text;
    }

    private function unescapeSpecials($tree)
    {
        return is_array($tree) ? array_map(array($this'unescapeSpecials')$tree) : str_replace
        (
            array("\x01""\x02"),
            array('<''>'),
            $tree
        );
    }

    private function buildTree()
    {
        $nodes = array();
            
        $i = 0;
        $text = NULL;
        
        while (($value = array_shift($this->matches)) !== NULL)
        {
            switch ($i++ % 3)
            {
                case 0:
                {
                    #
                    # if the trimed value is not empty we preserve the value,
                    # otherwise we discard it.
                    #
                    
                    if (trim($value))
                    {
                        $nodes[] = $value;
                    }
                }
                break;
                
                case 1:
                {
                    $closing = ($value == '/');
                }
                break;
                
                case 2:
                {
                    if (substr($value-11) == '/')
                    {
                        #
                        # auto closing
                        #
                        
                        $nodes[] = $this->parseMarkup(substr($value0-1));
                    }
                    else if ($closing)
                    {
                        #
                        # closing markup
                        #

                        $open = array_pop($this->opened);
                    
                        if ($value != $open)
                        {
                            $this->error($value$open);
                        }

                        return $nodes;
                    }
                    else
                    {
                        #
                        # this is an open markup with possible children
                        #
                        
                        $node = $this->parseMarkup($value);
                        
                        #
                        # push the markup name into the opened markups
                        #

                        $this->opened[] = $node['name'];
                        
                        #
                        # create the node and parse its children
                        #
                                                                        
                        $node['children'] = $this->buildTree($this->matches);
                        
                        $nodes[] = $node;
                    }
                }
            }
        }
        
        return $nodes;
    }
    
    public function parseMarkup($markup)
    {
        #
        # get markup's name
        #
        
        preg_match('#^[^\s]+#'$markup$matches);
        
        $name = $matches[0];
        
        #
        # get markup's arguments
        #
        
        preg_match_all('#\s+([^=]+)\s*=\s*"([^"]+)"#'$markup$matches, PREG_SET_ORDER);
        
        #
        # transform the matches into a nice key/value array
        #

        $args = array();
        
        foreach ($matches as $m)
        {
            #
            # we unescape the html entities of the argument's value
            #

            $args[$m[1]] = html_entity_decode($m[2], ENT_QUOTES, $this->encoding);
        }

        return array('name' => $name'args' => $args);
    }
    
    public function error($markup$expected)
    {
        $this->malformed = true;
        
        printf('unexpected closing markup "%s", should be "%s"'$markup$expected);
    }
}

?>

Exemple d'utilisation

Ben c'est assez simple :

<?php

$parser = new WdHTMLParser();

$tree = $parser->parse($template);

echo '<pre>' . print_r($treetrue) . '</pre>';

?>

Les espaces de noms

Parce que j'en ai besoin pour créer l'arbre des marqueurs de publication des gabarits pour WdPublisher, il est possible de spécifier l'espace de noms (ou namespace en Anglais) à utiliser lors de la recherche. Dans ce cas, seuls les marqueurs appartenant à l'espace de noms sont récupérés.

<wdp:articles limit="5">
    <ul>    
    <wdp:foreach>
        <li>{this.title}</li>
    </wdp:foreach>
    </ul>
</wdp:articles>

En utilisant l'espace de noms wdp: avec la gabarit ci-dessus nous obtenons l'arbre suivant :

<?php

$parser = new WdHTMLParser();

$tree = $parser->parse($template'wdp:');

echo '<pre>' . print_r($treetrue) . '</pre>';

?>
Array
(
    [0] => Array
        (
            [name] => articles
            [args] => Array
                (
                    [limit] => 5
                )

            [children] => Array
                (
                    [0] => 
    <ul>    
    
                    [1] => Array
                        (
                            [name] => foreach
                            [args] => Array
                                (
                                )

                            [children] => Array
                                (
                                    [0] => 
        <li>{this.title}</li>
    
                                )
                        )

                    [2] => 
    </ul>

                )
        )
)

Pour conclure

On pouvait s'en douter les performances n'ont plus rien à voir. Dans les cas simples la publication est deux fois plus rapide. La publication est encore plus rapide lorsque un même gabarit est utilisé de nombreuses fois. En effet, puisque l'on peut mettre l'arbre en cache, plus besoin de traiter le gabarit à chaque appel !

Évidement la gestion des enfants, comme dans le cas choose/when/otherwise est d'autant plus simple, puisque tout est déjà prêt dans l'arbre.

Rha, les joies méconnues de la programmation :-D

8 commentaires

1
Ivan Enderlin a écrit : Samedi 16 Août 2008 à 13:04

Hey :),

C'est pas mal mais j'ai quelques questions :

  1. pourquoi ne pas utiliser les fonctions de PHP qui sont appropriées (voir http://php.net/xml) ?
  2. pourquoi ne pas utiliser même SimpleXml (voir http://php.net/simplexml), quitte à faire un wrapper ?
  3. pourquoi ne gères-tu pas les CDATA ?

Pour les questions 1 et 2, tu gagnerais en rapidité. SimpleXML est totalement écrit en C, c'est donc plus rapide. D'autant qu'il permet la gestion du DOM, XPath, etc. De plus, SimpleXML est installé sur toutes les machines PHP 5 par défaut. Concernant les fonctions PHP pour la gestion des documents XML, elles sont également écrites en C (on l'aurait deviné ;-)) et sont même disponibles depuis PHP 4.

2
Olivier a écrit : Mardi 19 Août 2008 à 17:05

Merci de tes commentaires Ivan, mais comme le titre l'indique, il ne s'agit pas d'un parser XML, mais d'un parser HTML. J'ai besoin d'un parser HTML pour plus de souplesse avec l'arbre de marqueurs ainsi que les échappements.

Je me sers très souvent de SimpleXML pour parser mes fichiers XML, mais il m'a toujours manqué quelque chose de plus simple pour le HTML. C'est chose faite et pour le moment ça me satisfait.

3
Ivan Enderlin a écrit : Mardi 19 Août 2008 à 22:50

Euh … mais l'HTML se manipule comme du XML :s. Faire le choix de n'utiliser uniquement que l'HTML écarte le problème des CDATA, je te l'accorde, mais pour le reste, je ne comprends pas.

Et si tu cherches quelques choses de plus simple, alors fait un wrapper de SimpleXML. C'est à dire : analyse ton document avec SimpleXML et transforme le résultat (objet) pour avoir ce qui te convient (tableau particulier dans ton cas).

4
Campings Sun Location a écrit : Vendredi 21 Août 2009 à 00:52

Merci pour ce super parser HTML qui tourne très bien !!!

Très rapide et parfait :)

5
Dinosaurehonneur a écrit : Dimanche 13 Septembre 2009 à 10:14

J'ai crée un parseur XML pour essayer de refaire un zCode-like grâce à DomXML … J'ai eu l'honneur que tout fonctionne bien sauf un truc que je n'arrive toujours pas à régler, les erreurs humaines.

Prenons le cas où l'utilisateur entre ceci : <gras>Texte en gras</gras>. Mon parseur va donc sortir ceci <strong>Texte en gras</strong>. Mais si l'utilisateur entre ceci : <gras>Texte en gras</gra> … Alors mon parseur ne pourra pas charger ce qu'a entré l'utilisateur car il y a une erreur. Et le problème ici, c'est que je n'arrive pas à gérer les erreurs de l'utilisateur. La seule chose que je peux dire, c'est que l'utilisateur a fait une erreur …

Donc, après cette mésaventure, je pense qu'il est plus judicieux d'utiliser la technique d'Olivier.

6
Jo a écrit : Vendredi 16 Octobre 2009 à 09:12

Arf les joies du copier coller qui marche pas !

Remplacer : echo '<pre>'.print_r($true, true).'</pre>';
Par : echo '<pre>' . print_r($tree, true) . '</pre>';

Sinon parfait ! C'est ce que je cherchais pour créer des minis Dom HTML ….

7
Olivier a écrit : Samedi 17 Octobre 2009 à 18:49

Merci Jo pour le remplacer/par, j'ai mis l'article à jour.

8
Jbdemonte a écrit : Dimanche 17 Janvier 2010 à 14:36

Super cette classe pour faire des html leger, merci.

une petite modif que je me suis rajouté pour ne pas avoir des node fils de <br> :

<?php

public function parse($html$namespace=NULL$encoding='utf-8')
    {
        $this->malformed = false;
        $this->encoding = $encoding;
        
        $html = preg_replace('#\<br((\s+)?)((/+)?)\>#i''<br />'$html);

?>

Laisser un commentaire

 
 Souhaitez-vous être informé par E-Mail de la réponse à votre message ?