md_to_db: fix clsing inline media objects
[gtk-doc.git] / gtkdoc / md_to_db.py
blob9a5d2692e935845682d617d0179e30ee75bdc205
1 # -*- python; coding: utf-8 -*-
3 # gtk-doc - GTK DocBook documentation generator.
4 # Copyright (C) 1998 Damon Chaplin
5 # 2007-2016 Stefan Sauer
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 2 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program; if not, write to the Free Software
19 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22 """
23 Markdown to Docbook converter
24 """
26 import logging
27 import re
29 # external functions
30 ExpandAbbreviations = MakeXRef = MakeHashXRef = tagify = None
32 # Elements to consider non-block items in MarkDown parsing
33 MD_TEXT_LEVEL_ELEMENTS = {
34 'emphasis', 'envar', 'filename', 'firstterm', 'footnote', 'function', 'literal',
35 'manvolnum', 'option', 'replaceable', 'structfield', 'structname', 'title',
36 'varname'
38 MD_ESCAPABLE_CHARS = r'\`*_{}[]()>#+-.!'
39 MD_GTK_ESCAPABLE_CHARS = r'@%'
42 def Init():
43 # TODO(enonic): find a better way to do this
44 global ExpandAbbreviations, MakeXRef, MakeHashXRef, tagify
45 from .mkdb import ExpandAbbreviations, MakeXRef, MakeHashXRef, tagify
48 def MarkDownParseBlocks(lines, symbol, context):
49 md_blocks = []
50 md_block = {"type": ''}
52 logging.debug("parsing %s lines", len(lines))
53 for line in lines:
54 logging.info("type='%s', int='%s', parsing '%s'", md_block["type"], md_block.get('interrupted'), line)
55 first_char = None
56 if line:
57 first_char = line[0]
59 if md_block["type"] == "markup":
60 if 'closed' not in md_block:
61 if md_block["start"] in line:
62 md_block["depth"] += 1
64 if md_block["end"] in line:
65 if md_block["depth"] > 0:
66 md_block["depth"] -= 1
67 else:
68 logging.info("closing tag '%s'", line)
69 md_block["closed"] = 1
70 # TODO(ensonic): reparse inner text with MarkDownParseLines?
72 md_block["text"] += "\n" + line
73 logging.info("add to markup: '%s'", line)
74 continue
76 deindented_line = line.lstrip()
78 if md_block["type"] == "heading":
79 # a heading is ended by any level less than or equal
80 if md_block["level"] == 1:
81 heading_match = re.search(r'^[#][ \t]+(.+?)[ \t]*[#]*[ \t]*(?:{#([^}]+)})?[ \t]*$', line)
82 if re.search(r'^={4,}[ \t]*$', line):
83 text = md_block["lines"].pop()
84 md_block.pop("interrupted", None)
85 md_blocks.append(md_block)
86 md_block = {'type': "heading",
87 'text': text,
88 'lines': [],
89 'level': 1,
91 continue
92 elif heading_match:
93 md_block.pop("interrupted", None)
94 md_blocks.append(md_block)
95 md_block = {'type': "heading",
96 'text': heading_match.group(1),
97 'lines': [],
98 'level': 1,
100 if heading_match.group(2):
101 md_block['id'] = heading_match.group(2)
102 continue
103 else:
104 # push lines into the block until the end is reached
105 md_block["lines"].append(line)
106 continue
108 else:
109 heading_match = re.search(r'^([#]{1,2})[ \t]+(.+?)[ \t]*[#]*[ \t]*(?:{#([^}]+)})?[ \t]*$', line)
110 if re.search(r'^[=]{4,}[ \t]*$', line):
111 text = md_block["lines"].pop()
112 md_block.pop("interrupted", None)
113 md_blocks.append(md_block)
114 md_block = {'type': "heading",
115 'text': text,
116 'lines': [],
117 'level': 1,
119 continue
120 elif re.search(r'^[-]{4,}[ \t]*$', line):
121 text = md_block["lines"].pop()
122 md_block.pop("interrupted", None)
123 md_blocks.append(md_block)
124 md_block = {'type': "heading",
125 'text': text,
126 'lines': [],
127 'level': 2,
129 continue
130 elif heading_match:
131 md_block.pop("interrupted", None)
132 md_blocks.append(md_block)
133 md_block = {'type': "heading",
134 'text': heading_match.group(2),
135 'lines': [],
136 'level': len(heading_match.group(1))
138 if heading_match.group(3):
139 md_block['id'] = heading_match.group(3)
140 continue
141 else:
142 # push lines into the block until the end is reached
143 md_block["lines"].append(line)
144 continue
145 elif md_block["type"] == "code":
146 end_of_code_match = re.search(r'^[ \t]*\]\|(.*)', line)
147 if end_of_code_match:
148 md_blocks.append(md_block)
149 md_block = {'type': "paragraph",
150 'text': end_of_code_match.group(1),
151 'lines': [],
153 else:
154 md_block["lines"].append(line)
155 continue
157 if deindented_line == '':
158 logging.info('setting "interrupted" due to empty line')
159 md_block["interrupted"] = 1
160 continue
162 if md_block["type"] == "quote":
163 if 'interrupted' not in md_block:
164 line = re.sub(r'^[ ]*>[ ]?', '', line)
165 md_block["lines"].append(line)
166 continue
168 elif md_block["type"] == "li":
169 marker = md_block["marker"]
170 marker_match = re.search(r'^([ ]{0,3})(%s)[ ](.*)' % marker, line)
171 if marker_match:
172 indentation = marker_match.group(1)
173 if md_block["indentation"] != indentation:
174 md_block["lines"].append(line)
175 else:
176 ordered = md_block["ordered"]
177 md_block.pop('last', None)
178 md_blocks.append(md_block)
179 md_block = {'type': "li",
180 'ordered': ordered,
181 'indentation': indentation,
182 'marker': marker,
183 'last': 1,
184 'lines': [re.sub(r'^[ ]{0,4}', '', marker_match.group(3))],
186 continue
188 if 'interrupted' in md_block:
189 if first_char == " ":
190 md_block["lines"].append('')
191 line = re.sub(r'^[ ]{0,4}', '', line)
192 md_block["lines"].append(line)
193 md_block.pop("interrupted", None)
194 continue
195 else:
196 line = re.sub(r'^[ ]{0,4}', '', line)
197 md_block["lines"].append(line)
198 continue
200 # indentation sensitive types
201 heading_match = re.search(r'^([#]{1,2})[ \t]+(.+?)[ \t]*[#]*[ \t]*(?:{#([^}]+)})?[ \t]*$', line)
202 code_match = re.search(r'^[ \t]*\|\[[ ]*(?:<!-- language="([^"]+?)" -->)?', line)
203 if heading_match:
204 # atx heading (#)
205 md_blocks.append(md_block)
206 md_block = {'type': "heading",
207 'text': heading_match.group(2),
208 'lines': [],
209 'level': len(heading_match.group(1)),
211 if heading_match.group(3):
212 md_block['id'] = heading_match.group(3)
213 continue
214 elif re.search(r'^={4,}[ \t]*$', line):
215 # setext heading (====)
217 if md_block["type"] == "paragraph" and "interrupted" in md_block:
218 md_blocks.append(md_block.copy())
219 md_block["type"] = "heading"
220 md_block["lines"] = []
221 md_block["level"] = 1
222 continue
223 elif re.search(r'^-{4,}[ \t]*$', line):
224 # setext heading (-----)
226 if md_block["type"] == "paragraph" and "interrupted" in md_block:
227 md_blocks.append(md_block.copy())
228 md_block["type"] = "heading"
229 md_block["lines"] = []
230 md_block["level"] = 2
232 continue
233 elif code_match:
234 # code
235 md_block["interrupted"] = 1
236 md_blocks.append(md_block)
237 md_block = {'type': "code",
238 'lines': [],
240 if code_match.group(1):
241 md_block['language'] = code_match.group(1)
242 continue
244 # indentation insensitive types
245 markup_match = re.search(r'^[ ]*<\??(\w+)[^>]*([\/\?])?[ \t]*>', line)
246 li_match = re.search(r'^([ ]*)[*+-][ ](.*)', line)
247 quote_match = re.search(r'^[ ]*>[ ]?(.*)', line)
248 if re.search(r'^[ ]*<!DOCTYPE/', line):
249 md_blocks.append(md_block)
250 md_block = {'type': "markup",
251 'text': deindented_line,
252 'start': '<',
253 'end': '>',
254 'depth': 0,
257 elif markup_match:
258 # markup, including <?xml version="1.0"?>
259 tag = markup_match.group(1)
260 is_self_closing = markup_match.group(2) is not None
262 # skip link markdown
263 # TODO(ensonic): consider adding more uri schemes (ftp, ...)
264 if re.search(r'https?', tag):
265 logging.info("skipping link '%s'", tag)
266 else:
267 # for TEXT_LEVEL_ELEMENTS, we want to keep them as-is in the paragraph
268 # instead of creation a markdown block.
269 scanning_for_end_of_text_level_tag = (
270 md_block["type"] == "paragraph" and
271 'start' in md_block and
272 'closed' not in md_block)
273 logging.info("markup found '%s', scanning %s ?", tag, scanning_for_end_of_text_level_tag)
274 if tag not in MD_TEXT_LEVEL_ELEMENTS and not scanning_for_end_of_text_level_tag:
275 md_blocks.append(md_block)
277 if is_self_closing:
278 logging.info("self-closing docbook '%s'", tag)
279 md_block = {'type': "self-closing tag",
280 'text': deindented_line,
282 is_self_closing = 0
283 continue
285 logging.info("new markup '%s'", tag)
286 md_block = {'type': "markup",
287 'text': deindented_line,
288 'start': '<' + tag + '>',
289 'end': '</' + tag + '>',
290 'depth': 0,
292 if re.search(r'<\/%s>' % tag, deindented_line):
293 md_block["closed"] = 1
295 continue
296 else:
297 if tag in MD_TEXT_LEVEL_ELEMENTS:
298 logging.info("text level docbook '%s' in '%s' state", tag, md_block["type"])
299 # TODO(ensonic): handle nesting
300 if not scanning_for_end_of_text_level_tag:
301 if not re.search(r'<\/%s>' % tag, deindented_line):
302 logging.info("new text level markup '%s'", tag)
303 md_block["start"] = '<' + tag + '>'
304 md_block["end"] = '</' + tag + '>'
305 md_block.pop("closed", None)
306 logging.info("scanning for end of '%s'", tag)
308 else:
309 if md_block["end"] in deindented_line:
310 md_block["closed"] = 1
311 logging.info("found end of '%s'", tag)
312 elif li_match:
313 # li
314 md_blocks.append(md_block)
315 indentation = li_match.group(1)
316 md_block = {'type': "li",
317 'ordered': 0,
318 'indentation': indentation,
319 'marker': "[*+-]",
320 'first': 1,
321 'last': 1,
322 'lines': [re.sub(r'^[ ]{0,4}', '', li_match.group(2))],
324 continue
325 elif quote_match:
326 md_blocks.append(md_block)
327 md_block = {'type': "quote",
328 'lines': [quote_match.group(1)],
330 continue
332 # list item
333 list_item_match = re.search(r'^([ ]{0,4})\d+[.][ ]+(.*)', line)
334 if list_item_match:
335 md_blocks.append(md_block)
336 indentation = list_item_match.group(1)
337 md_block = {'type': "li",
338 'ordered': 1,
339 'indentation': indentation,
340 'marker': "\\d+[.]",
341 'first': 1,
342 'last': 1,
343 'lines': [re.sub(r'^[ ]{0,4}', '', list_item_match.group(2))],
345 continue
347 # paragraph
348 if md_block["type"] == "paragraph":
349 if "interrupted" in md_block:
350 md_blocks.append(md_block)
351 md_block = {'type': "paragraph",
352 'text': line,
354 logging.info("new paragraph due to interrupted")
355 else:
356 md_block["text"] += "\n" + line
357 logging.info("add to paragraph: '%s'", line)
359 else:
360 md_blocks.append(md_block)
361 md_block = {'type': "paragraph",
362 'text': line,
364 logging.info("new paragraph due to different block type")
366 md_blocks.append(md_block)
367 md_blocks.pop(0)
369 return md_blocks
372 def MarkDownParseSpanElementsInner(text, markersref):
373 markup = ''
374 markers = {i: 1 for i in markersref}
376 while text != '':
377 closest_marker = ''
378 closest_marker_position = -1
379 text_marker = ''
380 offset = 0
381 markers_rest = []
383 for marker, use in markers.items():
384 if not use:
385 continue
387 marker_position = text.find(marker)
389 if marker_position < 0:
390 markers[marker] = 0
391 continue
393 if closest_marker == '' or marker_position < closest_marker_position:
394 closest_marker = marker
395 closest_marker_position = marker_position
397 if closest_marker_position >= 0:
398 text_marker = text[closest_marker_position:]
400 if text_marker == '':
401 markup += text
402 text = ''
403 continue
405 markup += text[:closest_marker_position]
406 text = text[closest_marker_position:]
407 markers_rest = {k: v for k, v in markers.items() if v and k != closest_marker}
409 if closest_marker == '![' or closest_marker == '[':
410 element = None
412 # FIXME: '(?R)' is a recursive subpattern
413 # match a [...] block with no ][ inside or this thing again
414 # m = re.search(r'\[((?:[^][]|(?R))*)\]', text)
415 m = re.search(r'\[((?:[^][])*)\]', text)
416 if ']' in text and m:
417 element = {'!': text[0] == '!',
418 'a': m.group(1),
421 offset = len(m.group(0))
422 if element['!']:
423 offset += 1
424 logging.debug("Recursive md-expr match: off=%d, text='%s', match='%s'", offset, text, m.group(1))
426 remaining_text = text[offset:]
427 m2 = re.search(r'''^\([ ]*([^)'"]*?)(?:[ ]+['"](.+?)['"])?[ ]*\)''', remaining_text)
428 m3 = re.search(r'^\s*\[([^\]<]*?)\]', remaining_text)
429 if m2:
430 element['»'] = m2.group(1)
431 if m2.group(2):
432 element['#'] = m2.group(2)
433 offset += len(m2.group(0))
434 elif m3:
435 element['ref'] = m3.group(1)
436 offset += len(m3.group(0))
437 else:
438 element = None
440 if element:
441 if '»' in element:
442 element['»'] = element['»'].replace('&', '&amp;').replace('<', '&lt;')
444 if element['!']:
445 # media link
446 markup += '<inlinemediaobject><imageobject><imagedata fileref="' + \
447 element['»'] + '"></imagedata></imageobject>'
449 if 'a' in element:
450 markup += "<textobject><phrase>" + element['a'] + "</phrase></textobject>"
452 markup += "</inlinemediaobject>"
453 elif 'ref' in element:
454 # internal link
455 element['a'] = MarkDownParseSpanElementsInner(element['a'], markers_rest)
456 markup += '<link linkend="' + element['ref'] + '"'
458 if '#' in element:
459 # title attribute not supported
460 pass
462 markup += '>' + element['a'] + "</link>"
463 else:
464 # external link
465 element['a'] = MarkDownParseSpanElementsInner(element['a'], markers_rest)
466 markup += '<ulink url="' + element['»'] + '"'
468 if '#' in element:
469 # title attribute not supported
470 pass
472 markup += '>' + element['a'] + "</ulink>"
474 else:
475 markup += closest_marker
476 if closest_marker == '![':
477 offset = 2
478 else:
479 offset = 1
481 elif closest_marker == '<':
482 m4 = re.search(r'^<(https?:[\/]{2}[^\s]+?)>', text, flags=re.I)
483 m5 = re.search(r'^<([A-Za-z0-9._-]+?@[A-Za-z0-9._-]+?)>', text)
484 m6 = re.search(r'^<[^>]+?>', text)
485 if m4:
486 element_url = m4.group(1).replace('&', '&amp;').replace('<', '&lt;')
488 markup += '<ulink url="' + element_url + '">' + element_url + '</ulink>'
489 offset = len(m4.group(0))
490 elif m5:
491 markup += "<ulink url=\"mailto:" + m5.group(1) + "\">" + m5.group(1) + "</ulink>"
492 offset = len(m5.group(0))
493 elif m6:
494 markup += m6.group(0)
495 offset = len(m6.group(0))
496 else:
497 markup += "&lt;"
498 offset = 1
500 elif closest_marker == "\\":
501 special_char = ''
502 if len(text) > 1:
503 special_char = text[1]
504 if special_char in MD_ESCAPABLE_CHARS or special_char in MD_GTK_ESCAPABLE_CHARS:
505 markup += special_char
506 offset = 2
507 else:
508 markup += "\\"
509 offset = 1
511 elif closest_marker == "`":
512 m7 = re.search(r'^(`+)([^`]+?)\1(?!`)', text)
513 if m7:
514 element_text = m7.group(2)
515 markup += "<literal>" + element_text + "</literal>"
516 offset = len(m7.group(0))
517 else:
518 markup += "`"
519 offset = 1
521 elif closest_marker == "@":
522 # Convert '@param()'
523 # FIXME: we could make those also links ($symbol.$2), but that would be less
524 # useful as the link target is a few lines up or down
525 m7 = re.search(r'^(\A|[^\\])\@(\w+((\.|->)\w+)*)\s*\(\)', text)
526 m8 = re.search(r'^(\A|[^\\])\@(\w+((\.|->)\w+)*)', text)
527 m9 = re.search(r'^\\\@', text)
528 if m7:
529 markup += m7.group(1) + "<parameter>" + m7.group(2) + "()</parameter>\n"
530 offset = len(m7.group(0))
531 elif m8:
532 # Convert '@param', but not '\@param'.
533 markup += m8.group(1) + "<parameter>" + m8.group(2) + "</parameter>\n"
534 offset = len(m8.group(0))
535 elif m9:
536 markup += r"\@"
537 offset = len(m9.group(0))
538 else:
539 markup += "@"
540 offset = 1
542 elif closest_marker == '#':
543 m10 = re.search(r'^(\A|[^\\])#([\w\-:\.]+[\w]+)\s*\(\)', text)
544 m11 = re.search(r'^(\A|[^\\])#([\w\-:\.]+[\w]+)', text)
545 m12 = re.search(r'^\\#', text)
546 if m10:
547 # handle #Object.func()
548 markup += m10.group(1) + MakeXRef(m10.group(2), tagify(m10.group(2) + "()", "function"))
549 offset = len(m10.group(0))
550 elif m11:
551 # Convert '#symbol', but not '\#symbol'.
552 markup += m11.group(1) + MakeHashXRef(m11.group(2), "type")
553 offset = len(m11.group(0))
554 elif m12:
555 markup += '#'
556 offset = len(m12.group(0))
557 else:
558 markup += '#'
559 offset = 1
561 elif closest_marker == "%":
562 m12 = re.search(r'^(\A|[^\\])\%(-?\w+)', text)
563 m13 = re.search(r'^\\%', text)
564 if m12:
565 # Convert '%constant', but not '\%constant'.
566 # Also allow negative numbers, e.g. %-1.
567 markup += m12.group(1) + MakeXRef(m12.group(2), tagify(m12.group(2), "literal"))
568 offset = len(m12.group(0))
569 elif m13:
570 markup += r"\%"
571 offset = len(m13.group(0))
572 else:
573 markup += "%"
574 offset = 1
576 if offset > 0:
577 text = text[offset:]
579 return markup
582 def MarkDownParseSpanElements(text):
583 markers = ["\\", '<', '![', '[', "`", '%', '#', '@']
585 text = MarkDownParseSpanElementsInner(text, markers)
587 # Convert 'function()' or 'macro()'.
588 # if there is abc_*_def() we don't want to make a link to _def()
589 # FIXME: also handle abc(def(....)) : but that would need to be done recursively :/
590 def f(m):
591 return m.group(1) + MakeXRef(m.group(2), tagify(m.group(2) + "()", "function"))
592 text = re.sub(r'([^\*.\w])(\w+)\s*\(\)', f, text)
593 return text
596 def ReplaceEntities(text):
597 entities = [["&lt;", '<'],
598 ["&gt;", '>'],
599 ["&ast;", '*'],
600 ["&num;", '#'],
601 ["&percnt;", '%'],
602 ["&colon;", ':'],
603 ["&quot;", '"'],
604 ["&apos;", "'"],
605 ["&nbsp;", ' '],
606 ["&amp;", '&'], # Do this last, or the others get messed up.
609 # Expand entities in <programlisting> even inside CDATA since
610 # we changed the definition of |[ to add CDATA
611 for i in entities:
612 text = re.sub(i[0], i[1], text)
613 return text
616 def MarkDownOutputDocBook(blocksref, symbol, context):
617 output = ''
618 blocks = blocksref
620 for block in blocks:
621 #$output += "\n<!-- beg type='" . $block->{"type"} . "'-->\n"
623 if block["type"] == "paragraph":
624 text = MarkDownParseSpanElements(block["text"])
625 if context == "li" and output == '':
626 if 'interrupted' in block:
627 output += "\n<para>%s</para>\n" % text
628 else:
629 output += "<para>%s</para>" % text
630 if len(blocks) > 1:
631 output += "\n"
632 else:
633 output += "<para>%s</para>\n" % text
635 elif block["type"] == "heading":
637 title = MarkDownParseSpanElements(block["text"])
639 if block["level"] == 1:
640 tag = "refsect2"
641 else:
642 tag = "refsect3"
644 text = MarkDownParseLines(block["lines"], symbol, "heading")
645 if 'id' in block:
646 output += "<%s id=\"%s\">" % (tag, block["id"])
647 else:
648 output += "<%s>" % tag
650 output += "<title>%s</title>%s</%s>\n" % (title, text, tag)
651 elif block["type"] == "li":
652 tag = "itemizedlist"
654 if "first" in block:
655 if block["ordered"]:
656 tag = "orderedlist"
657 output += "<%s>\n" % tag
659 if "interrupted" in block:
660 block["lines"].append('')
662 text = MarkDownParseLines(block["lines"], symbol, "li")
663 output += "<listitem>" + text + "</listitem>\n"
664 if 'last' in block:
665 if block["ordered"]:
666 tag = "orderedlist"
667 output += "</%s>\n" % tag
669 elif block["type"] == "quote":
670 text = MarkDownParseLines(block["lines"], symbol, "quote")
671 output += "<blockquote>\n%s</blockquote>\n" % text
672 elif block["type"] == "code":
673 tag = "programlisting"
675 if "language" in block:
676 if block["language"] == "plain":
677 output += "<informalexample><screen><![CDATA[\n"
678 tag = "screen"
679 else:
680 output += "<informalexample><programlisting language=\"%s\"><![CDATA[\n" % block['language']
681 else:
682 output += "<informalexample><programlisting><![CDATA[\n"
684 logging.debug('listing for %s: [%s]', symbol, '\n'.join(block['lines']))
685 for line in block["lines"]:
686 output += ReplaceEntities(line) + "\n"
688 output += "]]></%s></informalexample>\n" % tag
689 elif block["type"] == "markup":
690 text = ExpandAbbreviations(symbol, block["text"])
691 output += text + "\n"
692 else:
693 output += block["text"] + "\n"
695 #$output += "\n<!-- end type='" . $block->{"type"} . "'-->\n"
696 return output
699 def MarkDownParseLines(lines, symbol, context):
700 logging.info('md parse: ctx=%s, [%s]', context, '\n'.join(lines))
701 blocks = MarkDownParseBlocks(lines, symbol, context)
702 output = MarkDownOutputDocBook(blocks, symbol, context)
703 return output
706 def MarkDownParse(text, symbol):
707 """Converts mark down syntax to the respective docbook.
709 http://de.wikipedia.org/wiki/Markdown
710 Inspired by the design of ParseDown
711 http://parsedown.org/
712 Copyright (c) 2013 Emanuil Rusev, erusev.com
714 SUPPORTED MARKDOWN
715 ==================
717 Atx-style Headers
718 -----------------
720 # Header 1
722 ## Header 2 ##
724 Setext-style Headers
725 --------------------
727 Header 1
728 ========
730 Header 2
731 --------
733 Ordered (unnested) Lists
734 ------------------------
736 1. item 1
738 1. item 2 with loooong
739 description
741 3. item 3
743 Note: we require a blank line above the list items
745 # TODO(ensonic): it would be nice to add id parameters to the refsect2 elements
747 return MarkDownParseLines(text.splitlines(), symbol, '')