##// END OF EJS Templates
Write output to current directory, instead of to source directory.
Stefan van der Walt -
Show More
@@ -1,705 +1,706 b''
1 1 #!/usr/bin/env python
2 2 """Convert IPython notebooks to other formats, such as ReST, and HTML.
3 3
4 4 Example:
5 5 ./nbconvert.py --format html file.ipynb
6 6
7 7 Produces 'file.rst' and 'file.html', along with auto-generated figure files
8 8 called nb_figure_NN.png. To avoid the two-step process, ipynb -> rst -> html,
9 9 use '--format quick-html' which will do ipynb -> html, but won't look as
10 10 pretty.
11 11 """
12 12 #-----------------------------------------------------------------------------
13 13 # Imports
14 14 #-----------------------------------------------------------------------------
15 15 from __future__ import print_function
16 16
17 17 # Stdlib
18 18 import codecs
19 19 import logging
20 20 import os
21 21 import pprint
22 22 import re
23 23 import subprocess
24 24 import sys
25 25
26 26 # From IPython
27 27 from IPython.external import argparse
28 28 from IPython.nbformat import current as nbformat
29 29 from IPython.utils.text import indent
30 30 from decorators import DocInherit
31 31
32 32 #-----------------------------------------------------------------------------
33 33 # Utility functions
34 34 #-----------------------------------------------------------------------------
35 35
36 36 def remove_fake_files_url(cell):
37 37 """Remove from the cell source the /files/ pseudo-path we use.
38 38 """
39 39 src = cell.source
40 40 cell.source = src.replace('/files/', '')
41 41
42 42
43 43 def remove_ansi(src):
44 44 """Strip all ANSI color escape sequences from input string.
45 45
46 46 Parameters
47 47 ----------
48 48 src : string
49 49
50 50 Returns
51 51 -------
52 52 string
53 53 """
54 54 return re.sub(r'\033\[(0|\d;\d\d)m', '', src)
55 55
56 56
57 57 # Pandoc-dependent code
58 58 def markdown2latex(src):
59 59 """Convert a markdown string to LaTeX via pandoc.
60 60
61 61 This function will raise an error if pandoc is not installed.
62 62
63 63 Any error messages generated by pandoc are printed to stderr.
64 64
65 65 Parameters
66 66 ----------
67 67 src : string
68 68 Input string, assumed to be valid markdown.
69 69
70 70 Returns
71 71 -------
72 72 out : string
73 73 Output as returned by pandoc.
74 74 """
75 75 p = subprocess.Popen('pandoc -f markdown -t latex'.split(),
76 76 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
77 77 out, err = p.communicate(src)
78 78 if err:
79 79 print(err, file=sys.stderr)
80 80 #print('*'*20+'\n', out, '\n'+'*'*20) # dbg
81 81 return out
82 82
83 83
84 84 def rst_directive(directive, text=''):
85 85 out = [directive, '']
86 86 if text:
87 87 out.extend([indent(text), ''])
88 88 return out
89 89
90 90 #-----------------------------------------------------------------------------
91 91 # Class declarations
92 92 #-----------------------------------------------------------------------------
93 93
94 94 class ConversionException(Exception):
95 95 pass
96 96
97 97
98 98 class Converter(object):
99 99 default_encoding = 'utf-8'
100 100 extension = str()
101 101 figures_counter = 0
102 102 infile = str()
103 103 infile_dir = str()
104 104 infile_root = str()
105 105 files_dir = str()
106 106 with_preamble = True
107 107 user_preamble = None
108 108 output = str()
109 109 raw_as_verbatim = False
110 110
111 111 def __init__(self, infile):
112 112 self.infile = infile
113 113 self.infile_dir = os.path.dirname(infile)
114 114 infile_root = os.path.splitext(infile)[0]
115 115 files_dir = infile_root + '_files'
116 116 if not os.path.isdir(files_dir):
117 117 os.mkdir(files_dir)
118 118 self.infile_root = infile_root
119 119 self.files_dir = files_dir
120 120
121 121 def dispatch(self, cell_type):
122 122 """return cell_type dependent render method, for example render_code
123 123 """
124 124 return getattr(self, 'render_' + cell_type, self.render_unknown)
125 125
126 126 def convert(self):
127 127 lines = []
128 128 lines.extend(self.optional_header())
129 129 for worksheet in self.nb.worksheets:
130 130 for cell in worksheet.cells:
131 131 #print(cell.cell_type) # dbg
132 132 conv_fn = self.dispatch(cell.cell_type)
133 133 if cell.cell_type in ('markdown', 'raw'):
134 134 remove_fake_files_url(cell)
135 135 lines.extend(conv_fn(cell))
136 136 lines.append('')
137 137 lines.extend(self.optional_footer())
138 138 return '\n'.join(lines)
139 139
140 140 def render(self):
141 141 "read, convert, and save self.infile"
142 142 self.read()
143 143 self.output = self.convert()
144 144 return self.save()
145 145
146 146 def read(self):
147 147 "read and parse notebook into NotebookNode called self.nb"
148 148 with open(self.infile) as f:
149 149 self.nb = nbformat.read(f, 'json')
150 150
151 151 def save(self, infile=None, encoding=None):
152 152 "read and parse notebook into self.nb"
153 153 if infile is None:
154 infile = os.path.splitext(self.infile)[0] + '.' + self.extension
154 outfile = os.path.basename(self.infile)
155 outfile = os.path.splitext(outfile)[0] + '.' + self.extension
155 156 if encoding is None:
156 157 encoding = self.default_encoding
157 with open(infile, 'w') as f:
158 with open(outfile, 'w') as f:
158 159 f.write(self.output.encode(encoding))
159 return infile
160 return os.path.abspath(outfile)
160 161
161 162 def optional_header(self):
162 163 return []
163 164
164 165 def optional_footer(self):
165 166 return []
166 167
167 168 def _new_figure(self, data, fmt):
168 169 """Create a new figure file in the given format.
169 170
170 171 Returns a path relative to the input file.
171 172 """
172 173 figname = '%s_fig_%02i.%s' % (self.infile_root,
173 174 self.figures_counter, fmt)
174 175 self.figures_counter += 1
175 176 fullname = os.path.join(self.files_dir, figname)
176 177
177 178 # Binary files are base64-encoded, SVG is already XML
178 179 if fmt in ('png', 'jpg', 'pdf'):
179 180 data = data.decode('base64')
180 181 fopen = lambda fname: open(fname, 'wb')
181 182 else:
182 183 fopen = lambda fname: codecs.open(fname, 'wb', self.default_encoding)
183 184
184 185 with fopen(fullname) as f:
185 186 f.write(data)
186 187
187 188 return fullname
188 189
189 190 def render_heading(self, cell):
190 191 """convert a heading cell
191 192
192 193 Returns list."""
193 194 raise NotImplementedError
194 195
195 196 def render_code(self, cell):
196 197 """Convert a code cell
197 198
198 199 Returns list."""
199 200 raise NotImplementedError
200 201
201 202 def render_markdown(self, cell):
202 203 """convert a markdown cell
203 204
204 205 Returns list."""
205 206 raise NotImplementedError
206 207
207 208 def render_pyout(self, output):
208 209 """convert pyout part of a code cell
209 210
210 211 Returns list."""
211 212 raise NotImplementedError
212 213
213 214
214 215 def render_pyerr(self, output):
215 216 """convert pyerr part of a code cell
216 217
217 218 Returns list."""
218 219 raise NotImplementedError
219 220
220 221 def _img_lines(self, img_file):
221 222 """Return list of lines to include an image file."""
222 223 # Note: subclasses may choose to implement format-specific _FMT_lines
223 224 # methods if they so choose (FMT in {png, svg, jpg, pdf}).
224 225 raise NotImplementedError
225 226
226 227 def render_display_data(self, output):
227 228 """convert display data from the output of a code cell
228 229
229 230 Returns list.
230 231 """
231 232 lines = []
232 233
233 234 for fmt in ['png', 'svg', 'jpg', 'pdf']:
234 235 if fmt in output:
235 236 img_file = self._new_figure(output[fmt], fmt)
236 237 # Subclasses can have format-specific render functions (e.g.,
237 238 # latex has to auto-convert all SVG to PDF first).
238 239 lines_fun = getattr(self, '_%s_lines' % fmt, None)
239 240 if not lines_fun:
240 241 lines_fun = self._img_lines
241 242 lines.extend(lines_fun(img_file))
242 243
243 244 return lines
244 245
245 246 def render_stream(self, cell):
246 247 """convert stream part of a code cell
247 248
248 249 Returns list."""
249 250 raise NotImplementedError
250 251
251 252 def render_raw(self, cell):
252 253 """convert a cell with raw text
253 254
254 255 Returns list."""
255 256 raise NotImplementedError
256 257
257 258 def render_unknown(self, cell):
258 259 """Render cells of unkown type
259 260
260 261 Returns list."""
261 262 data = pprint.pformat(cell)
262 263 logging.warning('Unknown cell:\n%s' % data)
263 264 return self._unknown_lines(data)
264 265
265 266 def _unknown_lines(self, data):
266 267 """Return list of lines for an unknown cell.
267 268
268 269 Parameters
269 270 ----------
270 271 data : str
271 272 The content of the unknown data as a single string.
272 273 """
273 274 raise NotImplementedError
274 275
275 276
276 277 class ConverterRST(Converter):
277 278 extension = 'rst'
278 279 heading_level = {1: '=', 2: '-', 3: '`', 4: '\'', 5: '.', 6: '~'}
279 280
280 281 @DocInherit
281 282 def render_heading(self, cell):
282 283 marker = self.heading_level[cell.level]
283 284 return ['{0}\n{1}\n'.format(cell.source, marker * len(cell.source))]
284 285
285 286 @DocInherit
286 287 def render_code(self, cell):
287 288 if not cell.input:
288 289 return []
289 290
290 291 lines = ['In[%s]:' % cell.prompt_number, '']
291 292 lines.extend(rst_directive('.. code:: python', cell.input))
292 293
293 294 for output in cell.outputs:
294 295 conv_fn = self.dispatch(output.output_type)
295 296 lines.extend(conv_fn(output))
296 297
297 298 return lines
298 299
299 300 @DocInherit
300 301 def render_markdown(self, cell):
301 302 return [cell.source]
302 303
303 304 @DocInherit
304 305 def render_raw(self, cell):
305 306 if self.raw_as_verbatim:
306 307 return ['::', '', indent(cell.source), '']
307 308 else:
308 309 return [cell.source]
309 310
310 311 @DocInherit
311 312 def render_pyout(self, output):
312 313 lines = ['Out[%s]:' % output.prompt_number, '']
313 314
314 315 # output is a dictionary like object with type as a key
315 316 if 'latex' in output:
316 317 lines.extend(rst_directive('.. math::', output.latex))
317 318
318 319 if 'text' in output:
319 320 lines.extend(rst_directive('.. parsed-literal::', output.text))
320 321
321 322 return lines
322 323
323 324 @DocInherit
324 325 def render_pyerr(self, output):
325 326 # Note: a traceback is a *list* of frames.
326 327 return ['::', '', indent(remove_ansi('\n'.join(output.traceback))), '']
327 328
328 329 @DocInherit
329 330 def _img_lines(self, img_file):
330 331 return ['.. image:: %s' % img_file, '']
331 332
332 333 @DocInherit
333 334 def render_stream(self, output):
334 335 lines = []
335 336
336 337 if 'text' in output:
337 338 lines.extend(rst_directive('.. parsed-literal::', output.text))
338 339
339 340 return lines
340 341
341 342 @DocInherit
342 343 def _unknown_lines(self, data):
343 344 return rst_directive('.. warning:: Unknown cell') + [data]
344 345
345 346
346 347 class ConverterQuickHTML(Converter):
347 348 extension = 'html'
348 349
349 350 def in_tag(self, tag, src):
350 351 """Return a list of elements bracketed by the given tag"""
351 352 return ['<%s>' % tag, src, '</%s>' % tag]
352 353
353 354 def optional_header(self):
354 355 # XXX: inject the IPython standard CSS into here
355 356 s = """<html>
356 357 <head>
357 358 </head>
358 359
359 360 <body>
360 361 """
361 362 return s.splitlines()
362 363
363 364 def optional_footer(self):
364 365 s = """</body>
365 366 </html>
366 367 """
367 368 return s.splitlines()
368 369
369 370 @DocInherit
370 371 def render_heading(self, cell):
371 372 marker = cell.level
372 373 return ['<h{1}>\n {0}\n</h{1}>'.format(cell.source, marker)]
373 374
374 375 @DocInherit
375 376 def render_code(self, cell):
376 377 if not cell.input:
377 378 return []
378 379
379 380 lines = ['<table>']
380 381 lines.append('<tr><td><tt>In [<b>%s</b>]:</tt></td><td><tt>' % cell.prompt_number)
381 382 lines.append("<br>\n".join(cell.input.splitlines()))
382 383 lines.append('</tt></td></tr>')
383 384
384 385 for output in cell.outputs:
385 386 lines.append('<tr><td></td><td>')
386 387 conv_fn = self.dispatch(output.output_type)
387 388 lines.extend(conv_fn(output))
388 389 lines.append('</td></tr>')
389 390
390 391 lines.append('</table>')
391 392 return lines
392 393
393 394 @DocInherit
394 395 def render_markdown(self, cell):
395 396 return self.in_tag('pre', cell.source)
396 397
397 398 @DocInherit
398 399 def render_raw(self, cell):
399 400 if self.raw_as_verbatim:
400 401 return self.in_tag('pre', cell.source)
401 402 else:
402 403 return [cell.source]
403 404
404 405 @DocInherit
405 406 def render_pyout(self, output):
406 407 lines = ['<tr><td><tt>Out[<b>%s</b>]:</tt></td></tr>' %
407 408 output.prompt_number, '<td>']
408 409
409 410 # output is a dictionary like object with type as a key
410 411 for out_type in ('text', 'latex'):
411 412 if out_type in output:
412 413 lines.extend(self.in_tag('pre', indent(output[out_type])))
413 414
414 415 return lines
415 416
416 417 @DocInherit
417 418 def render_pyerr(self, output):
418 419 # Note: a traceback is a *list* of frames.
419 420 return self.in_tag('pre', remove_ansi('\n'.join(output.traceback)))
420 421
421 422 @DocInherit
422 423 def _img_lines(self, img_file):
423 424 return ['<img src="%s">' % img_file, '']
424 425
425 426 @DocInherit
426 427 def render_stream(self, output):
427 428 lines = []
428 429
429 430 if 'text' in output:
430 431 lines.append(output.text)
431 432
432 433 return lines
433 434
434 435 @DocInherit
435 436 def _unknown_lines(self, data):
436 437 return ['<h2>Warning:: Unknown cell</h2>'] + self.in_tag('pre', data)
437 438
438 439
439 440 class ConverterLaTeX(Converter):
440 441 """Converts a notebook to a .tex file suitable for pdflatex.
441 442
442 443 Note: this converter *needs*:
443 444
444 445 - `pandoc`: for all conversion of markdown cells. If your notebook only
445 446 has Raw cells, pandoc will not be needed.
446 447
447 448 - `inkscape`: if your notebook has SVG figures. These need to be
448 449 converted to PDF before inclusion in the TeX file, as LaTeX doesn't
449 450 understand SVG natively.
450 451
451 452 You will in general obtain much better final PDF results if you configure
452 453 the matplotlib backend to create SVG output with
453 454
454 455 %config InlineBackend.figure_format = 'svg'
455 456
456 457 (or set the equivalent flag at startup or in your configuration profile).
457 458 """
458 459 extension = 'tex'
459 460 documentclass = 'article'
460 461 documentclass_options = '11pt,english'
461 462 heading_map = {1: r'\section',
462 463 2: r'\subsection',
463 464 3: r'\subsubsection',
464 465 4: r'\paragraph',
465 466 5: r'\subparagraph',
466 467 6: r'\subparagraph'}
467 468
468 469 def in_env(self, environment, lines):
469 470 """Return list of environment lines for input lines
470 471
471 472 Parameters
472 473 ----------
473 474 env : string
474 475 Name of the environment to bracket with begin/end.
475 476
476 477 lines: """
477 478 out = [r'\begin{%s}' % environment]
478 479 if isinstance(lines, basestring):
479 480 out.append(lines)
480 481 else: # list
481 482 out.extend(lines)
482 483 out.append(r'\end{%s}' % environment)
483 484 return out
484 485
485 486 def convert(self):
486 487 # The main body is done by the logic in the parent class, and that's
487 488 # all we need if preamble support has been turned off.
488 489 body = super(ConverterLaTeX, self).convert()
489 490 if not self.with_preamble:
490 491 return body
491 492 # But if preamble is on, then we need to construct a proper, standalone
492 493 # tex file.
493 494
494 495 # Tag the document at the top and set latex class
495 496 final = [ r'%% This file was auto-generated by IPython, do NOT edit',
496 497 r'%% Conversion from the original notebook file:',
497 498 r'%% {0}'.format(self.infile),
498 499 r'%%',
499 500 r'\documentclass[%s]{%s}' % (self.documentclass_options,
500 501 self.documentclass),
501 502 '',
502 503 ]
503 504 # Load our own preamble, which is stored next to the main file. We
504 505 # need to be careful in case the script entry point is a symlink
505 506 myfile = __file__ if not os.path.islink(__file__) else \
506 507 os.readlink(__file__)
507 508 with open(os.path.join(os.path.dirname(myfile), 'preamble.tex')) as f:
508 509 final.append(f.read())
509 510
510 511 # Load any additional user-supplied preamble
511 512 if self.user_preamble:
512 513 final.extend(['', '%% Adding user preamble from file:',
513 514 '%% {0}'.format(self.user_preamble), ''])
514 515 with open(self.user_preamble) as f:
515 516 final.append(f.read())
516 517
517 518 # Include document body
518 519 final.extend([ r'\begin{document}', '',
519 520 body,
520 521 r'\end{document}', ''])
521 522 # Retun value must be a string
522 523 return '\n'.join(final)
523 524
524 525 @DocInherit
525 526 def render_heading(self, cell):
526 527 marker = self.heading_map[cell.level]
527 528 return ['%s{%s}' % (marker, cell.source) ]
528 529
529 530 @DocInherit
530 531 def render_code(self, cell):
531 532 if not cell.input:
532 533 return []
533 534
534 535 # Cell codes first carry input code, we use lstlisting for that
535 536 lines = [r'\begin{codecell}']
536 537
537 538 lines.extend(self.in_env('codeinput',
538 539 self.in_env('lstlisting', cell.input)))
539 540
540 541 outlines = []
541 542 for output in cell.outputs:
542 543 conv_fn = self.dispatch(output.output_type)
543 544 outlines.extend(conv_fn(output))
544 545
545 546 # And then output of many possible types; use a frame for all of it.
546 547 if outlines:
547 548 lines.extend(self.in_env('codeoutput', outlines))
548 549
549 550 lines.append(r'\end{codecell}')
550 551
551 552 return lines
552 553
553 554
554 555 @DocInherit
555 556 def _img_lines(self, img_file):
556 557 return self.in_env('center',
557 558 [r'\includegraphics[width=3in]{%s}' % img_file, r'\par'])
558 559
559 560 def _svg_lines(self, img_file):
560 561 base_file = os.path.splitext(img_file)[0]
561 562 pdf_file = base_file + '.pdf'
562 563 subprocess.check_call(['inkscape', '--export-pdf=%s' % pdf_file,
563 564 img_file])
564 565 return self._img_lines(pdf_file)
565 566
566 567 @DocInherit
567 568 def render_stream(self, output):
568 569 lines = []
569 570
570 571 if 'text' in output:
571 572 lines.extend(self.in_env('verbatim', output.text.strip()))
572 573
573 574 return lines
574 575
575 576 @DocInherit
576 577 def render_markdown(self, cell):
577 578 return [markdown2latex(cell.source)]
578 579
579 580 @DocInherit
580 581 def render_pyout(self, output):
581 582 lines = []
582 583
583 584 # output is a dictionary like object with type as a key
584 585 if 'latex' in output:
585 586 lines.extend(output.latex)
586 587
587 588 if 'text' in output:
588 589 lines.extend(self.in_env('verbatim', output.text))
589 590
590 591 return lines
591 592
592 593 @DocInherit
593 594 def render_pyerr(self, output):
594 595 # Note: a traceback is a *list* of frames.
595 596 return self.in_env('traceback',
596 597 self.in_env('verbatim',
597 598 remove_ansi('\n'.join(output.traceback))))
598 599
599 600 @DocInherit
600 601 def render_raw(self, cell):
601 602 if self.raw_as_verbatim:
602 603 return self.in_env('verbatim', cell.source)
603 604 else:
604 605 return [cell.source]
605 606
606 607 @DocInherit
607 608 def _unknown_lines(self, data):
608 609 return [r'{\vspace{5mm}\bf WARNING:: unknown cell:}'] + \
609 610 self.in_env('verbatim', data)
610 611
611 612 #-----------------------------------------------------------------------------
612 613 # Standalone conversion functions
613 614 #-----------------------------------------------------------------------------
614 615
615 616 def rst2simplehtml(infile):
616 617 """Convert a rst file to simplified html suitable for blogger.
617 618
618 619 This just runs rst2html with certain parameters to produce really simple
619 620 html and strips the document header, so the resulting file can be easily
620 621 pasted into a blogger edit window.
621 622 """
622 623
623 624 # This is the template for the rst2html call that produces the cleanest,
624 625 # simplest html I could find. This should help in making it easier to
625 626 # paste into the blogspot html window, though I'm still having problems
626 627 # with linebreaks there...
627 628 cmd_template = ("rst2html --link-stylesheet --no-xml-declaration "
628 629 "--no-generator --no-datestamp --no-source-link "
629 630 "--no-toc-backlinks --no-section-numbering "
630 631 "--strip-comments ")
631 632
632 633 cmd = "%s %s" % (cmd_template, infile)
633 634 proc = subprocess.Popen(cmd,
634 635 stdout=subprocess.PIPE,
635 636 stderr=subprocess.PIPE,
636 637 shell=True)
637 638 html, stderr = proc.communicate()
638 639 if stderr:
639 640 raise IOError(stderr)
640 641
641 642 # Make an iterator so breaking out holds state. Our implementation of
642 643 # searching for the html body below is basically a trivial little state
643 644 # machine, so we need this.
644 645 walker = iter(html.splitlines())
645 646
646 647 # Find start of main text, break out to then print until we find end /div.
647 648 # This may only work if there's a real title defined so we get a 'div class'
648 649 # tag, I haven't really tried.
649 650 for line in walker:
650 651 if line.startswith('<body>'):
651 652 break
652 653
653 654 newfname = os.path.splitext(infile)[0] + '.html'
654 655 with open(newfname, 'w') as f:
655 656 for line in walker:
656 657 if line.startswith('</body>'):
657 658 break
658 659 f.write(line)
659 660 f.write('\n')
660 661
661 662 return newfname
662 663
663 664 known_formats = "rst (default), html, quick-html, latex"
664 665
665 666 def main(infile, format='rst'):
666 667 """Convert a notebook to html in one step"""
667 668 # XXX: this is just quick and dirty for now. When adding a new format,
668 669 # make sure to add it to the `known_formats` string above, which gets
669 670 # printed in in the catch-all else, as well as in the help
670 671 if format == 'rst':
671 672 converter = ConverterRST(infile)
672 673 converter.render()
673 674 elif format == 'html':
674 675 #Currently, conversion to html is a 2 step process, nb->rst->html
675 676 converter = ConverterRST(infile)
676 677 rstfname = converter.render()
677 678 rst2simplehtml(rstfname)
678 679 elif format == 'quick-html':
679 680 converter = ConverterQuickHTML(infile)
680 681 rstfname = converter.render()
681 682 elif format == 'latex':
682 683 converter = ConverterLaTeX(infile)
683 684 latexfname = converter.render()
684 685 else:
685 686 raise SystemExit("Unknown format '%s', " % format +
686 687 "known formats are: " + known_formats)
687 688
688 689 #-----------------------------------------------------------------------------
689 690 # Script main
690 691 #-----------------------------------------------------------------------------
691 692
692 693 if __name__ == '__main__':
693 694 parser = argparse.ArgumentParser(description=__doc__,
694 695 formatter_class=argparse.RawTextHelpFormatter)
695 696 # TODO: consider passing file like object around, rather than filenames
696 697 # would allow us to process stdin, or even http streams
697 698 #parser.add_argument('infile', nargs='?', type=argparse.FileType('r'), default=sys.stdin)
698 699
699 700 #Require a filename as a positional argument
700 701 parser.add_argument('infile', nargs=1)
701 702 parser.add_argument('-f', '--format', default='rst',
702 703 help='Output format. Supported formats: \n' +
703 704 known_formats)
704 705 args = parser.parse_args()
705 706 main(infile=args.infile[0], format=args.format)
General Comments 0
You need to be logged in to leave comments. Login now