<?php
/**
 * Logiciel : HTML2PDF - classe ParsingHTML
 *
 * Convertisseur HTML => PDF
 * Distribué sous la licence LGPL. 
 *
 * @author		Laurent MINGUET <webmaster@html2pdf.fr>
 * @version		4.01
 */

class parsingHTML
{
	protected	$html		= '';				// code HTML à parser
	protected	$num		= 0;				// numéro de table
	protected	$level		= 0;				// niveaux de table
	protected	$encoding	= '';				// encodage
	public		$code		= array();			// code HTML parsé
	
	/**
	 * Constructeur
	 *
	 * @return	null
	 */
	public function __construct($encoding = 'UTF-8')
	{
		$this->num		= 0;
		$this->level	= array($this->num);
		$this->html		= '';
		$this->code		= array();
		$this->setEncoding($encoding);
	}
	
	public function setEncoding($encoding)
	{
		$this->encoding = $encoding;
	}
	
	/**
	 * Définir le code HTML à parser
	 *
	 * @param	string code html
	 * @return	null
	 */
	public function setHTML($html)
	{
		$html = preg_replace('/<!--(.*)-->/isU', '', $html);
		$this->html = $html;
	}
	
	/**
	 * parser le code HTML
	 *
	 * @return	null
	 */
	public function parse()
	{
		$parents = array();

		// chercher les balises HTML du code
		$tmp = array();
		$this->searchCode($tmp);
		
		// identifier les balises une à une
		$pre_in = false;
		$pre_br = array(
					'name' => 'br',
					'close' => false,
					'param' => array(
						'style' => array(),
						'num'	=> 0
					)
				);

		$balises_no_closed = array(
									'br', 'hr', 'img', 'col',
									'input', 'link', 'option',
									'circle', 'ellipse', 'path', 'rect', 'line', 'polygon', 'polyline'
							);
		$todos = array();
		foreach($tmp as $part)
		{
			// si c'est un code
			if ($part[0]=='code')
			{
				$res = $this->analiseCode($part[1]);
				
				// si le code est bien un code analisable
				if ($res)
				{
					$res['html_pos'] = $part[2];
					if (!in_array($res['name'], $balises_no_closed))
					{
						if ($res['close'])
						{
							if (count($parents)<1)
								throw new HTML2PDF_exception(3, $res['name'], $this->getHtmlErrorCode($res['html_pos']));
							else if ($parents[count($parents)-1]!=$res['name'])
								throw new HTML2PDF_exception(4, $parents, $this->getHtmlErrorCode($res['html_pos']));
							else
								unset($parents[count($parents)-1]);
						}
						else
						{
							if ($res['autoclose'])
							{
								$todos[] = $res;
								$res['params'] = array();
								$res['close'] = true;
							}
							else
								$parents[count($parents)] = $res['name'];
													
						}
						if (($res['name']=='pre' || $res['name']=='code') && !$res['autoclose'])
								$pre_in = !$res['close'];
					}
					
					$todos[] = $res;
				}
				// sinon (code non analisable) => on le transforme en texte
				else
				{
					$part[0]='txt';
				}
			}
			// sinon si c'est un texte
			if ($part[0]=='txt')
			{
				// enregistrer l'action correspondante
				if (!$pre_in)
				{
					// remplacer tous les espaces, tabulations, saufs de ligne multiples par de simples espaces
					$todos[] = array(
									'name'	=> 'write',
									'close'	=> false,
									'param' => array('txt' => $this->prepareTxt($part[1])),
								);
				}
				else
				{
					$part[1] = str_replace("\r", '', $part[1]);
					$part[1] = explode("\n", $part[1]);
					
					foreach($part[1] as $k => $txt)
					{
						$txt = str_replace("\t", '        ', $txt);
						$txt = str_replace(' ', '&nbsp;', $txt);
						if ($k>0) $todos[] = $pre_br;

						$todos[] = array(
										'name'	=> 'write',
										'close'	=> false,
										'param' => array('txt' => $this->prepareTxt($txt, false)),
									);
					}
				}
			}	
		}

		// pour chaque action identifiée, il faut nettoyer le début et la fin des textes
		// en fonction des balises qui l'entourent.
		$balises_clean = array('page', 'page_header', 'page_footer', 'form',
								'table', 'thead', 'tfoot', 'tr', 'td', 'th', 'br',
								'div', 'hr', 'p', 'ul', 'ol', 'li',
								'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
								'bookmark', 'fieldset', 'legend',
								'draw', 'circle', 'ellipse', 'path', 'rect', 'line', 'g', 'polygon', 'polyline');
		$nb = count($todos);
		for($k=0; $k<$nb; $k++)
		{
			//si c'est un texte
			if ($todos[$k]['name']=='write')
			{
				// et qu'une balise spécifique le précède => on nettoye les espaces du début du texte
				if ($k>0 && in_array($todos[$k-1]['name'], $balises_clean))
					$todos[$k]['param']['txt'] = ltrim($todos[$k]['param']['txt']);

				// et qu'une balise spécifique le suit => on nettoye les espaces de la fin du texte
				if ($k<$nb-1 && in_array($todos[$k+1]['name'], $balises_clean))
					$todos[$k]['param']['txt'] = rtrim($todos[$k]['param']['txt']);
				
				if (!strlen($todos[$k]['param']['txt']))
					unset($todos[$k]);
			}
		}
		if (count($parents)) throw new HTML2PDF_exception(5, $parents);

		// liste des actions sauvée
		$this->code = array_values($todos);
	}
	
	/**
	 * preparer le texte une seule fois pour gagner du temps. vient de o_WRITE
	 *
	 * @param	string texte
	 * @return	string texte
	 */
	protected function prepareTxt($txt, $spaces = true)
	{
		if ($spaces) $txt = preg_replace('/\s+/is', ' ', $txt);
		$txt = str_replace('&euro;', '€', $txt);
		$txt = html_entity_decode($txt, ENT_QUOTES, $this->encoding);
		return $txt;
	}
	
	/**
	 * parser le code HTML
	 *
	 * @param	&array	tableau de retour des données
	 * @return	null
	 */
	protected function searchCode(&$tmp)
	{
		// séparer les balises du texte
		$tmp = array();
		$reg = '/(<[^>]+>)|([^<]+)+/isU';

		// pour chaque élément trouvé :
		$str = '';
		$offset = 0;
		while(preg_match($reg, $this->html, $parse, PREG_OFFSET_CAPTURE, $offset))
		{
			// si une balise a été détectée
			if ($parse[1][0])
			{
				// sauvegarde du texte précédent si il existe
				if ($str!=='')	$tmp[] = array('txt',$str);
	
				// sauvegarde de la balise
				$tmp[] = array('code',trim($parse[1][0]), $offset);
				
				// initialisation du texte suivant
				$str = '';
			}
			else
			{
				// ajout du texte à la fin de celui qui est déjà détecté
				$str.= $parse[2][0];
			}
			// Update offset to the end of the match
			$offset = $parse[0][1] + strlen($parse[0][0]);
			unset($parse);
		}
		// si un texte est présent à la fin, on l'enregistre
		if ($str!='') $tmp[] = array('txt',$str);
		unset($str);
	}
	
	/**
	 * analyse une balise HTML
	 *
	 * @param	string	code HTML à identifier
	 * @return	array	action correspondante
	 */
	protected function analiseCode($code)
	{
		// nom de la balise et ouverture ou fermeture
		$balise = '<([\/]{0,1})([_a-z0-9]+)([\/>\s]+)';
		if (!preg_match('/'.$balise.'/isU', $code, $match))
			return null;

		$close	= ($match[1]=='/' ? true : false);
		$autoclose = preg_match('/\/>$/isU', $code);
		$name	= strtolower($match[2]);
		
		// paramètres obligatoires en fonction du nom de la balise
		$param	= array();
		$param['style'] = '';
		if ($name=='img')	{ $param['alt'] = '';	$param['src'] = ''; }
		if ($name=='a')		{ $param['href'] = '';	}
		
		// lecture des paramétres du type nom=valeur
		$prop = '([a-zA-Z0-9_]+)=([^"\'\s>]+)';
		preg_match_all('/'.$prop.'/is', $code, $match);
		for($k=0; $k<count($match[0]); $k++)
			$param[trim(strtolower($match[1][$k]))] = trim($match[2][$k]);

		// lecture des paramétres du type nom="valeur"
		$prop = '([a-zA-Z0-9_]+)=["]([^"]*)["]';
		preg_match_all('/'.$prop.'/is', $code, $match);
		for($k=0; $k<count($match[0]); $k++)
			$param[trim(strtolower($match[1][$k]))] = trim($match[2][$k]);

		// lecture des paramétres du type nom='valeur'
		$prop = "([a-zA-Z0-9_]+)=[']([^']*)[']";
		preg_match_all('/'.$prop.'/is', $code, $match);
		for($k=0; $k<count($match[0]); $k++)
			$param[trim(strtolower($match[1][$k]))] = trim($match[2][$k]);
	
		// mise en conformité en style de chaque paramètre
		$color	= "#000000";
		$border	= null;
		foreach($param as $key => $val)
		{
			$key = strtolower($key);
			switch($key)
			{
				case 'width':
					unset($param[$key]);
					$param['style'] .= 'width: '.$val.'px; ';
					break;

				case 'align':
					if ($name==='img')
					{
						unset($param[$key]);
						$param['style'] .= 'float: '.$val.'; ';
					} 
					elseif ($name!=='table')
					{
						unset($param[$key]);
						$param['style'] .= 'text-align: '.$val.'; ';
					}
					break;
					
				case 'valign':
					unset($param[$key]);
					$param['style'] .= 'vertical-align: '.$val.'; ';
					break;
					
				case 'height':
					unset($param[$key]);
					$param['style'] .= 'height: '.$val.'px; ';
					break;

				case 'bgcolor':
					unset($param[$key]);
					$param['style'] .= 'background: '.$val.'; ';
					break;

				case 'bordercolor':
					unset($param[$key]);
					$color = $val;
					break;

				case 'border':
					unset($param[$key]);
					if (preg_match('/^[0-9]+$/isU', $val)) $val = $val.'px';
					$border = $val;
					break;
				
				case 'cellpadding':
				case 'cellspacing':
					if (preg_match('/^([0-9]+)$/isU', $val)) $param[$key] = $val.'px';
					break;
					
				case 'colspan':
				case 'rowspan':
					$val = preg_replace('/[^0-9]/isU', '', $val);
					if (!$val) $val = 1;
					$param[$key] = $val;
					break;
			}
		}
		if ($border!==null)
		{
			if ($border)	$border = 'border: solid '.$border.' '.$color;
			else			$border = 'border: none';
			
			$param['style'] .= $border.'; ';
			$param['border'] = $border;
		}
		
		// lecture des styles - décomposition
		$styles = explode(';', $param['style']);
		$param['style'] = array();
		foreach($styles as $style)
		{
			$tmp = explode(':', $style);
			if (count($tmp)>1)
			{
				$cod = $tmp[0]; unset($tmp[0]); $tmp = implode(':', $tmp); 
				$param['style'][trim(strtolower($cod))] = preg_replace('/[\s]+/isU', ' ', trim($tmp));
			}
		}
		
		// détermination du niveau de table pour les ouverture, avec ajout d'un level
		if (in_array($name, array('ul', 'ol', 'table')) && !$close)
		{
			$this->num++;
			$this->level[count($this->level)] = $this->num;
		} 
		
		// attribution du niveau de table où se trouve l'élément
		if (!isset($param['num'])) $param['num'] = $this->level[count($this->level)-1];

		// pour les fins de table : suppression d'un level
		if (in_array($name, array('ul', 'ol', 'table')) && $close)
		{
			unset($this->level[count($this->level)-1]);
		} 

		if (isset($param['value']))	$param['value']	= $this->prepareTxt($param['value']);
		if (isset($param['alt']))	$param['alt']	= $this->prepareTxt($param['alt']);
		if (isset($param['title']))	$param['title']	= $this->prepareTxt($param['title']);
		if (isset($param['class']))	$param['class']	= $this->prepareTxt($param['class']);
		
		// retour de l'action identifiée
		return array('name' => $name, 'close' => $close ? 1 : 0, 'autoclose' => $autoclose, 'param' => $param);
	}
	
	// récupérer un niveau complet d'HTML entre une ouverture de balise et la fermeture correspondante
	public function getLevel($k)
	{
		// si le code n'existe pas : fin
		if (!isset($this->code[$k])) return array();
		
		// quelle balise faudra-t-il détecter
		$detect = $this->code[$k]['name'];
		
		$level = 0;			// niveau de profondeur
		$end = false;		// etat de fin de recherche
		$code = array();	// code extrait
		
		// tant que c'est pas fini, on boucle
		while (!$end)
		{
			// action courante
			$row = $this->code[$k];
			
			// si write => on ajoute le texte
			if ($row['name']=='write')
			{
				$code[] = $row;
			}
			// sinon, c'est une balise html
			else
			{
				$not = false; // indicateur de non prise en compte de la balise courante
				
				// si c'est la balise que l'on cherche
				if ($row['name']==$detect)
				{
					if ($level==0) { $not = true; }					// si on est à la premiere balise : on l'ignore
					$level+= ($row['close'] ? -1 : 1);				// modification du niveau en cours en fonction de l'ouvertre / fermeture
					if ($level==0) { $not = true; $end = true; }	// si on est au niveau 0 : on a fini
				}
				
				// si on doit prendre en compte la balise courante
				if (!$not)
				{
					if (isset($row['style']['text-align'])) unset($row['style']['text-align']);
					$code[] = $row;
				}
			}
			
			// on continue tant qu'il y a du code à analyser...
			if (isset($this->code[$k+1]))
				$k++;
			else
				$end = true;
		}
		
		// retourne le code extrait
		return $code;
	}
	
	public function getHtmlErrorCode($pos)
	{
		return substr($this->html, $pos-30, 70);
	}
}