1<?php
2
3/**
4* @package   s9e\TextFormatter
5* @copyright Copyright (c) 2010-2021 The s9e authors
6* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7*/
8namespace s9e\TextFormatter\Renderers;
9
10use DOMNode;
11use DOMXPath;
12use RuntimeException;
13use s9e\TextFormatter\Renderer;
14use s9e\TextFormatter\Utils\XPath;
15
16abstract class PHP extends Renderer
17{
18	/**
19	* @var array[] Stack of dictionaries used by the Quick renderer [[attrName => attrValue]]
20	*/
21	protected $attributes;
22
23	/**
24	* @var array Dictionary of replacements used by the Quick renderer [id => [match, replace]]
25	*/
26	protected $dynamic;
27
28	/**
29	* @var bool Whether to enable the Quick renderer
30	*/
31	public $enableQuickRenderer = false;
32
33	/**
34	* @var string Renderer's output
35	*/
36	protected $out;
37
38	/**
39	* @var string Regexp that matches XML elements to be rendered by the quick renderer
40	*/
41	protected $quickRegexp = '((?!))';
42
43	/**
44	* @var string Regexp that matches nodes that SHOULD NOT be rendered by the quick renderer
45	*/
46	protected $quickRenderingTest = '((?<=<)[!?])';
47
48	/**
49	* @var array Dictionary of static replacements used by the Quick renderer [id => replacement]
50	*/
51	protected $static;
52
53	/**
54	* @var DOMXPath XPath object used to query the document being rendered
55	*/
56	protected $xpath;
57
58	/**
59	* Render given DOMNode
60	*
61	* @param  DOMNode $node
62	* @return void
63	*/
64	abstract protected function renderNode(DOMNode $node);
65
66	public function __sleep()
67	{
68		return ['enableQuickRenderer', 'params'];
69	}
70
71	/**
72	* Render the content of given node
73	*
74	* Matches the behaviour of an xsl:apply-templates element
75	*
76	* @param  DOMNode $root  Context node
77	* @param  string  $query XPath query used to filter which child nodes to render
78	* @return void
79	*/
80	protected function at(DOMNode $root, $query = null)
81	{
82		if ($root->nodeType === XML_TEXT_NODE)
83		{
84			// Text nodes are outputted directly
85			$this->out .= htmlspecialchars($root->textContent, ENT_NOQUOTES);
86		}
87		else
88		{
89			$nodes = (isset($query)) ? $this->xpath->query($query, $root) : $root->childNodes;
90			foreach ($nodes as $node)
91			{
92				$this->renderNode($node);
93			}
94		}
95	}
96
97	/**
98	* Test whether given XML can be rendered with the Quick renderer
99	*
100	* @param  string $xml
101	* @return bool
102	*/
103	protected function canQuickRender($xml)
104	{
105		return ($this->enableQuickRenderer && !preg_match($this->quickRenderingTest, $xml) && substr($xml, -4) === '</r>');
106	}
107
108	/**
109	* Ensure that a tag pair does not contain a start tag of itself
110	*
111	* Detects malformed matches such as <X><X></X>
112	*
113	* @param  string $id
114	* @param  string $xml
115	* @return void
116	*/
117	protected function checkTagPairContent($id, $xml)
118	{
119		if (strpos($xml, '<' . $id, 1) !== false)
120		{
121			throw new RuntimeException;
122		}
123	}
124
125	/**
126	* Return a parameter's value as an XPath expression
127	*
128	* @param  string $paramName
129	* @return string
130	*/
131	protected function getParamAsXPath($paramName)
132	{
133		return (isset($this->params[$paramName])) ? XPath::export($this->params[$paramName]) : "''";
134	}
135
136	/**
137	* Extract the text content from given XML
138	*
139	* NOTE: numeric character entities are decoded beforehand, we don't need to decode them here
140	*
141	* @param  string $xml Original XML
142	* @return string      Text content, with special characters decoded
143	*/
144	protected function getQuickTextContent($xml)
145	{
146		return htmlspecialchars_decode(strip_tags($xml));
147	}
148
149	/**
150	* Test whether given array has any non-null values
151	*
152	* @param  array $array
153	* @return bool
154	*/
155	protected function hasNonNullValues(array $array)
156	{
157		foreach ($array as $v)
158		{
159			if (isset($v))
160			{
161				return true;
162			}
163		}
164
165		return false;
166	}
167
168	/**
169	* Capture and return the attributes of an XML element
170	*
171	* NOTE: XML character entities are left as-is
172	*
173	* @param  string $xml Element in XML form
174	* @return array       Dictionary of [attrName => attrValue]
175	*/
176	protected function matchAttributes($xml)
177	{
178		if (strpos($xml, '="') === false)
179		{
180			return [];
181		}
182
183		// Match all name-value pairs until the first right bracket
184		preg_match_all('(([^ =]++)="([^"]*))S', substr($xml, 0, strpos($xml, '>')), $m);
185
186		return array_combine($m[1], $m[2]);
187	}
188
189	/**
190	* Render an intermediate representation using the Quick renderer
191	*
192	* @param  string $xml Intermediate representation
193	* @return string
194	*/
195	protected function renderQuick($xml)
196	{
197		$this->attributes = [];
198		$xml = $this->decodeSMP($xml);
199		$html = preg_replace_callback(
200			$this->quickRegexp,
201			[$this, 'renderQuickCallback'],
202			substr($xml, 1 + strpos($xml, '>'), -4)
203		);
204
205		return str_replace('<br/>', '<br>', $html);
206	}
207
208	/**
209	* Render a string matched by the Quick renderer
210	*
211	* This stub should be overwritten by generated renderers
212	*
213	* @param  string[] $m
214	* @return string
215	*/
216	protected function renderQuickCallback(array $m)
217	{
218		if (isset($m[3]))
219		{
220			return $this->renderQuickSelfClosingTag($m);
221		}
222
223		if (isset($m[2]))
224		{
225			// Single tag
226			$id = $m[2];
227		}
228		else
229		{
230			// Tag pair
231			$id = $m[1];
232			$this->checkTagPairContent($id, $m[0]);
233		}
234
235		if (isset($this->static[$id]))
236		{
237			return $this->static[$id];
238		}
239		if (isset($this->dynamic[$id]))
240		{
241			return preg_replace($this->dynamic[$id][0], $this->dynamic[$id][1], $m[0], 1);
242		}
243
244		return $this->renderQuickTemplate($id, $m[0]);
245	}
246
247	/**
248	* Render a self-closing tag using the Quick renderer
249	*
250	* @param  string[] $m
251	* @return string
252	*/
253	protected function renderQuickSelfClosingTag(array $m)
254	{
255		unset($m[3]);
256
257		$m[0] = substr($m[0], 0, -2) . '>';
258		$html = $this->renderQuickCallback($m);
259
260		$m[0] = '</' . $m[2] . '>';
261		$m[2] = '/' . $m[2];
262		$html .= $this->renderQuickCallback($m);
263
264		return $html;
265	}
266
267	/**
268	* Render a string matched by the Quick renderer using a generated PHP template
269	*
270	* This stub should be overwritten by generated renderers
271	*
272	* @param  integer $id  Tag's ID (tag name optionally preceded by a slash)
273	* @param  string  $xml Tag's XML or tag pair's XML including their content
274	* @return string       Rendered template
275	*/
276	protected function renderQuickTemplate($id, $xml)
277	{
278		throw new RuntimeException('Not implemented');
279	}
280
281	/**
282	* {@inheritdoc}
283	*/
284	protected function renderRichText($xml)
285	{
286		$this->setLocale();
287
288		try
289		{
290			if ($this->canQuickRender($xml))
291			{
292				$html = $this->renderQuick($xml);
293				$this->restoreLocale();
294
295				return $html;
296			}
297		}
298		catch (RuntimeException $e)
299		{
300			// Do nothing
301		}
302
303		$dom         = $this->loadXML($xml);
304		$this->out   = '';
305		$this->xpath = new DOMXPath($dom);
306		$this->at($dom->documentElement);
307		$html        = $this->out;
308		$this->reset();
309		$this->restoreLocale();
310
311		return $html;
312	}
313
314	/**
315	* Reset object properties that are populated during rendering
316	*
317	* @return void
318	*/
319	protected function reset()
320	{
321		unset($this->attributes);
322		unset($this->out);
323		unset($this->xpath);
324	}
325}