e0bb868f40e372bac4cdfd418654971e3cb43e4c
[gnus] / lisp / shr.el
1 ;;; shr.el --- Simple HTML Renderer
2
3 ;; Copyright (C) 2010 Free Software Foundation, Inc.
4
5 ;; Author: Lars Magne Ingebrigtsen <larsi@gnus.org>
6 ;; Keywords: html
7
8 ;; This file is part of GNU Emacs.
9
10 ;; GNU Emacs is free software: you can redistribute it and/or modify
11 ;; it under the terms of the GNU General Public License as published by
12 ;; the Free Software Foundation, either version 3 of the License, or
13 ;; (at your option) any later version.
14
15 ;; GNU Emacs is distributed in the hope that it will be useful,
16 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
17 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 ;; GNU General Public License for more details.
19
20 ;; You should have received a copy of the GNU General Public License
21 ;; along with GNU Emacs.  If not, see <http://www.gnu.org/licenses/>.
22
23 ;;; Commentary:
24
25 ;; This package takes a HTML parse tree (as provided by
26 ;; libxml-parse-html-region) and renders it in the current buffer.  It
27 ;; does not do CSS, JavaScript or anything advanced: It's geared
28 ;; towards rendering typical short snippets of HTML, like what you'd
29 ;; find in HTML email and the like.
30
31 ;;; Code:
32
33 (require 'browse-url)
34
35 (defgroup shr nil
36   "Simple HTML Renderer"
37   :group 'mail)
38
39 (defcustom shr-max-image-proportion 0.9
40   "How big pictures displayed are in relation to the window they're in.
41 A value of 0.7 means that they are allowed to take up 70% of the
42 width and height of the window.  If they are larger than this,
43 and Emacs supports it, then the images will be rescaled down to
44 fit these criteria."
45   :version "24.1"
46   :group 'shr
47   :type 'float)
48
49 (defcustom shr-blocked-images nil
50   "Images that have URLs matching this regexp will be blocked."
51   :version "24.1"
52   :group 'shr
53   :type 'regexp)
54
55 (defvar shr-content-function nil
56   "If bound, this should be a function that will return the content.
57 This is used for cid: URLs, and the function is called with the
58 cid: URL as the argument.")
59
60 (defvar shr-width 70
61   "Frame width to use for rendering.")
62
63 ;;; Internal variables.
64
65 (defvar shr-folding-mode nil)
66 (defvar shr-state nil)
67 (defvar shr-start nil)
68 (defvar shr-indentation 0)
69 (defvar shr-inhibit-images nil)
70 (defvar shr-list-mode nil)
71
72 (defvar shr-map
73   (let ((map (make-sparse-keymap)))
74     (define-key map "a" 'shr-show-alt-text)
75     (define-key map "i" 'shr-browse-image)
76     (define-key map "I" 'shr-insert-image)
77     (define-key map "u" 'shr-copy-url)
78     (define-key map "v" 'shr-browse-url)
79     (define-key map "\r" 'shr-browse-url)
80     map))
81
82 ;; Public functions and commands.
83
84 ;;;###autoload
85 (defun shr-insert-document (dom)
86   (let ((shr-state nil)
87         (shr-start nil))
88     (shr-descend (shr-transform-dom dom))))
89
90 (defun shr-copy-url ()
91   "Copy the URL under point to the kill ring.
92 If called twice, then try to fetch the URL and see whether it
93 redirects somewhere else."
94   (interactive)
95   (let ((url (get-text-property (point) 'shr-url)))
96     (cond
97      ((not url)
98       (message "No URL under point"))
99      ;; Resolve redirected URLs.
100      ((equal url (car kill-ring))
101       (url-retrieve
102        url
103        (lambda (a)
104          (when (and (consp a)
105                     (eq (car a) :redirect))
106            (with-temp-buffer
107              (insert (cadr a))
108              (goto-char (point-min))
109              ;; Remove common tracking junk from the URL.
110              (when (re-search-forward ".utm_.*" nil t)
111                (replace-match "" t t))
112              (message "Copied %s" (buffer-string))
113              (copy-region-as-kill (point-min) (point-max)))))))
114      ;; Copy the URL to the kill ring.
115      (t
116       (with-temp-buffer
117         (insert url)
118         (copy-region-as-kill (point-min) (point-max))
119         (message "Copied %s" url))))))
120
121 (defun shr-show-alt-text ()
122   "Show the ALT text of the image under point."
123   (interactive)
124   (let ((text (get-text-property (point) 'shr-alt)))
125     (if (not text)
126         (message "No image under point")
127       (message "%s" text))))
128
129 (defun shr-browse-image ()
130   "Browse the image under point."
131   (interactive)
132   (let ((url (get-text-property (point) 'shr-image)))
133     (if (not url)
134         (message "No image under point")
135       (message "Browsing %s..." url)
136       (browse-url url))))
137
138 ;;; Utility functions.
139
140 (defun shr-transform-dom (dom)
141   (let ((result (list (pop dom))))
142     (dolist (arg (pop dom))
143       (push (cons (intern (concat ":" (symbol-name (car arg))) obarray)
144                   (cdr arg))
145             result))
146     (dolist (sub dom)
147       (if (stringp sub)
148           (push (cons :text sub) result)
149         (push (shr-transform-dom sub) result)))
150     (nreverse result)))
151
152 (defun shr-descend (dom)
153   (let ((function (intern (concat "shr-tag-" (symbol-name (car dom))) obarray)))
154     (if (fboundp function)
155         (funcall function (cdr dom))
156       (shr-generic (cdr dom)))))
157
158 (defun shr-generic (cont)
159   (dolist (sub cont)
160     (cond
161      ((eq (car sub) :text)
162       (shr-insert (cdr sub)))
163      ((listp (cdr sub))
164       (shr-descend sub)))))
165
166 (defun shr-insert (text)
167   (when (eq shr-state 'image)
168     (insert "\n")
169     (setq shr-state nil))
170   (cond
171    ((eq shr-folding-mode 'none)
172     (insert text))
173    (t
174     (let ((first t)
175           column)
176       (when (and (string-match "\\`[ \t\n]" text)
177                  (not (bolp)))
178         (insert " ")
179         (setq shr-state 'space))
180       (dolist (elem (split-string text))
181         (setq column (current-column))
182         (when (> column 0)
183           (cond
184            ((and (or (not first)
185                      (eq shr-state 'space))
186                  (> (+ column (length elem) 1) shr-width))
187             (insert "\n"))
188            ((not first)
189             (insert " "))))
190         (setq first nil)
191         (when (and (bolp)
192                    (> shr-indentation 0))
193           (shr-indent))
194         ;; The shr-start is a special variable that is used to pass
195         ;; upwards the first point in the buffer where the text really
196         ;; starts.
197         (unless shr-start
198           (setq shr-start (point)))
199         (insert elem))
200       (setq shr-state nil)
201       (when (and (string-match "[ \t\n]\\'" text)
202                  (not (bolp)))
203         (insert " ")
204         (setq shr-state 'space))))))
205
206 (defun shr-ensure-newline ()
207   (unless (zerop (current-column))
208     (insert "\n")))
209
210 (defun shr-ensure-paragraph ()
211   (unless (bobp)
212     (if (bolp)
213         (unless (save-excursion
214                   (forward-line -1)
215                   (looking-at " *$"))
216           (insert "\n"))
217       (if (save-excursion
218             (beginning-of-line)
219             (looking-at " *$"))
220           (insert "\n")
221         (insert "\n\n")))))
222
223 (defun shr-indent ()
224   (insert (make-string shr-indentation ? )))
225
226 (defun shr-fontize-cont (cont &rest types)
227   (let (shr-start)
228     (shr-generic cont)
229     (dolist (type types)
230       (shr-add-font (or shr-start (point)) (point) type))))
231
232 (defun shr-add-font (start end type)
233   (let ((overlay (make-overlay start end)))
234     (overlay-put overlay 'face type)))
235
236 (defun shr-browse-url ()
237   "Browse the URL under point."
238   (interactive)
239   (let ((url (get-text-property (point) 'shr-url)))
240     (if (not url)
241         (message "No link under point")
242       (browse-url url))))
243
244 (defun shr-image-fetched (status buffer start end)
245   (when (and (buffer-name buffer)
246              (not (plist-get status :error)))
247     (url-store-in-cache (current-buffer))
248     (when (or (search-forward "\n\n" nil t)
249               (search-forward "\r\n\r\n" nil t))
250       (let ((data (buffer-substring (point) (point-max))))
251         (with-current-buffer buffer
252           (let ((alt (buffer-substring start end))
253                 (inhibit-read-only t))
254             (delete-region start end)
255             (shr-put-image data start alt))))))
256   (kill-buffer (current-buffer)))
257
258 (defun shr-put-image (data point alt)
259   (if (not (display-graphic-p))
260       (insert alt)
261     (let ((image (ignore-errors
262                    (shr-rescale-image data))))
263       (when image
264         (put-image image point alt)))))
265
266 (defun shr-rescale-image (data)
267   (if (or (not (fboundp 'imagemagick-types))
268           (not (get-buffer-window (current-buffer))))
269       (create-image data nil t)
270     (let* ((image (create-image data nil t))
271            (size (image-size image t))
272            (width (car size))
273            (height (cdr size))
274            (edges (window-inside-pixel-edges
275                    (get-buffer-window (current-buffer))))
276            (window-width (truncate (* shr-max-image-proportion
277                                       (- (nth 2 edges) (nth 0 edges)))))
278            (window-height (truncate (* shr-max-image-proportion
279                                        (- (nth 3 edges) (nth 1 edges)))))
280            scaled-image)
281       (when (> height window-height)
282         (setq image (or (create-image data 'imagemagick t
283                                       :height window-height)
284                         image))
285         (setq size (image-size image t)))
286       (when (> (car size) window-width)
287         (setq image (or
288                      (create-image data 'imagemagick t
289                                    :width window-width)
290                      image)))
291       image)))
292
293 (defun shr-get-image-data (url)
294   "Get image data for URL.
295 Return a string with image data."
296   (with-temp-buffer
297     (mm-disable-multibyte)
298     (when (ignore-errors
299             (url-cache-extract (url-cache-create-filename url))
300             t)
301       (when (or (search-forward "\n\n" nil t)
302                 (search-forward "\r\n\r\n" nil t))
303         (buffer-substring (point) (point-max))))))
304
305 (defun shr-heading (cont &rest types)
306   (shr-ensure-paragraph)
307   (apply #'shr-fontize-cont cont types)
308   (shr-ensure-paragraph))
309
310 ;;; Tag-specific rendering rules.
311
312 (defun shr-tag-p (cont)
313   (shr-ensure-paragraph)
314   (shr-generic cont)
315   (shr-ensure-paragraph))
316
317 (defun shr-tag-b (cont)
318   (shr-fontize-cont cont 'bold))
319
320 (defun shr-tag-i (cont)
321   (shr-fontize-cont cont 'italic))
322
323 (defun shr-tag-em (cont)
324   (shr-fontize-cont cont 'bold))
325
326 (defun shr-tag-u (cont)
327   (shr-fontize-cont cont 'underline))
328
329 (defun shr-tag-s (cont)
330   (shr-fontize-cont cont 'strike-through))
331
332 (defun shr-tag-a (cont)
333   (let ((url (cdr (assq :href cont)))
334         (start (point))
335         shr-start)
336     (shr-generic cont)
337     (widget-convert-button
338      'link (or shr-start start) (point)
339      :help-echo url)
340     (put-text-property (or shr-start start) (point) 'keymap shr-map)
341     (put-text-property (or shr-start start) (point) 'shr-url url)))
342
343 (defun shr-tag-img (cont)
344   (when (and (> (current-column) 0)
345              (not (eq shr-state 'image)))
346     (insert "\n"))
347   (let ((start (point-marker)))
348     (let ((alt (cdr (assq :alt cont)))
349           (url (cdr (assq :src cont))))
350       (when (zerop (length alt))
351         (setq alt "[img]"))
352       (cond
353        ((and (not shr-inhibit-images)
354              (string-match "\\`cid:" url))
355         (let ((url (substring url (match-end 0)))
356               image)
357           (if (or (not shr-content-function)
358                   (not (setq image (funcall shr-content-function url))))
359               (insert alt)
360             (shr-put-image image (point) alt))))
361        ((or shr-inhibit-images
362             (and shr-blocked-images
363                  (string-match shr-blocked-images url)))
364         (setq shr-start (point))
365         (let ((shr-state 'space))
366           (if (> (length alt) 8)
367               (shr-insert (substring alt 0 8))
368             (shr-insert alt))))
369        ((url-is-cached (browse-url-url-encode-chars url "[&)$ ]"))
370         (shr-put-image (shr-get-image-data url) (point) alt))
371        (t
372         (insert alt)
373         (ignore-errors
374           (url-retrieve url 'shr-image-fetched
375                         (list (current-buffer) start (point-marker))
376                         t))))
377       (insert " ")
378       (put-text-property start (point) 'keymap shr-map)
379       (put-text-property start (point) 'shr-alt alt)
380       (put-text-property start (point) 'shr-image url)
381       (setq shr-state 'image))))
382
383 (defun shr-tag-pre (cont)
384   (let ((shr-folding-mode 'none))
385     (shr-ensure-newline)
386     (shr-generic cont)
387     (shr-ensure-newline)))
388
389 (defun shr-tag-blockquote (cont)
390   (shr-ensure-paragraph)
391   (let ((shr-indentation (+ shr-indentation 4)))
392     (shr-generic cont))
393   (shr-ensure-paragraph))
394
395 (defun shr-tag-ul (cont)
396   (shr-ensure-paragraph)
397   (let ((shr-list-mode 'ul))
398     (shr-generic cont)))
399
400 (defun shr-tag-ol (cont)
401   (let ((shr-list-mode 1))
402     (shr-generic cont)))
403
404 (defun shr-tag-li (cont)
405   (shr-ensure-newline)
406   (let* ((bullet
407           (if (numberp shr-list-mode)
408               (prog1
409                   (format "%d " shr-list-mode)
410                 (setq shr-list-mode (1+ shr-list-mode)))
411             "* "))
412          (shr-indentation (+ shr-indentation (length bullet))))
413     (insert bullet)
414     (shr-generic cont)))
415
416 (defun shr-tag-br (cont)
417   (unless (bobp)
418     (insert "\n"))
419   (shr-generic cont))
420
421 (defun shr-tag-h1 (cont)
422   (shr-heading cont 'bold 'underline))
423
424 (defun shr-tag-h2 (cont)
425   (shr-heading cont 'bold))
426
427 (defun shr-tag-h3 (cont)
428   (shr-heading cont 'italic))
429
430 (defun shr-tag-h4 (cont)
431   (shr-heading cont))
432
433 (defun shr-tag-h5 (cont)
434   (shr-heading cont))
435
436 (defun shr-tag-h6 (cont)
437   (shr-heading cont))
438
439 ;;; Table rendering algorithm.
440
441 ;; Table rendering is the only complicated thing here.  We do this by
442 ;; first counting how many TDs there are in each TR, and registering
443 ;; how wide they think they should be ("width=45%", etc).  Then we
444 ;; render each TD separately (this is done in temporary buffers, so
445 ;; that we can use all the rendering machinery as if we were in the
446 ;; main buffer).  Now we know how much space each TD really takes, so
447 ;; we then render everything again with the new widths, and finally
448 ;; insert all these boxes into the main buffer.
449 (defun shr-tag-table (cont)
450   (shr-ensure-paragraph)
451   (setq cont (or (cdr (assq 'tbody cont))
452                  cont))
453   (let* ((shr-inhibit-images t)
454          ;; Find all suggested widths.
455          (columns (shr-column-specs cont))
456          ;; Compute how many characters wide each TD should be.
457          (suggested-widths (shr-pro-rate-columns columns))
458          ;; Do a "test rendering" to see how big each TD is (this can
459          ;; be smaller (if there's little text) or bigger (if there's
460          ;; unbreakable text).
461          (sketch (shr-make-table cont suggested-widths))
462          (sketch-widths (shr-table-widths sketch (length suggested-widths))))
463     ;; Then render the table again with these new "hard" widths.
464     (shr-insert-table (shr-make-table cont sketch-widths t) sketch-widths))
465   ;; Finally, insert all the images after the table.  The Emacs buffer
466   ;; model isn't strong enough to allow us to put the images actually
467   ;; into the tables.
468   (dolist (elem (shr-find-elements cont 'img))
469     (shr-tag-img (cdr elem))))
470
471 (defun shr-find-elements (cont type)
472   (let (result)
473     (dolist (elem cont)
474       (cond ((eq (car elem) type)
475              (push elem result))
476             ((consp (cdr elem))
477              (setq result (nconc (shr-find-elements (cdr elem) type) result)))))
478     (nreverse result)))
479
480 (defun shr-insert-table (table widths)
481   (shr-insert-table-ruler widths)
482   (dolist (row table)
483     (let ((start (point))
484           (height (let ((max 0))
485                     (dolist (column row)
486                       (setq max (max max (cadr column))))
487                     max)))
488       (dotimes (i height)
489         (shr-indent)
490         (insert "|\n"))
491       (dolist (column row)
492         (goto-char start)
493         (let ((lines (split-string (nth 2 column) "\n"))
494               (overlay-lines (nth 3 column))
495               overlay overlay-line)
496           (dolist (line lines)
497             (setq overlay-line (pop overlay-lines))
498             (when (> (length line) 0)
499               (end-of-line)
500               (insert line "|")
501               (dolist (overlay overlay-line)
502                 (let ((o (make-overlay (- (point) (nth 0 overlay) 1)
503                                        (- (point) (nth 1 overlay) 1)))
504                       (properties (nth 2 overlay)))
505                   (while properties
506                     (overlay-put o (pop properties) (pop properties)))))
507               (forward-line 1)))
508           ;; Add blank lines at padding at the bottom of the TD,
509           ;; possibly.
510           (dotimes (i (- height (length lines)))
511             (end-of-line)
512             (insert (make-string (length (car lines)) ? ) "|")
513             (forward-line 1)))))
514     (shr-insert-table-ruler widths)))
515
516 (defun shr-insert-table-ruler (widths)
517   (shr-indent)
518   (insert "+")
519   (dotimes (i (length widths))
520     (insert (make-string (aref widths i) ?-) ?+))
521   (insert "\n"))
522
523 (defun shr-table-widths (table length)
524   (let ((widths (make-vector length 0)))
525     (dolist (row table)
526       (let ((i 0))
527         (dolist (column row)
528           (aset widths i (max (aref widths i)
529                               (car column)))
530           (incf i))))
531     widths))
532
533 (defun shr-make-table (cont widths &optional fill)
534   (let ((trs nil))
535     (dolist (row cont)
536       (when (eq (car row) 'tr)
537         (let ((tds nil)
538               (columns (cdr row))
539               (i 0)
540               column)
541           (while (< i (length widths))
542             (setq column (pop columns))
543             (when (or (memq (car column) '(td th))
544                       (null column))
545               (push (shr-render-td (cdr column) (aref widths i) fill)
546                     tds)
547               (setq i (1+ i))))
548           (push (nreverse tds) trs))))
549     (nreverse trs)))
550
551 (defun shr-render-td (cont width fill)
552   (with-temp-buffer
553     (let ((shr-width width)
554           (shr-indentation 0))
555       (shr-generic cont))
556     (while (re-search-backward "\n *$" nil t)
557       (delete-region (match-beginning 0) (match-end 0)))
558     (goto-char (point-min))
559     (let ((max 0))
560       (while (not (eobp))
561         (end-of-line)
562         (setq max (max max (current-column)))
563         (forward-line 1))
564       (when fill
565         (goto-char (point-min))
566         ;; If the buffer is totally empty, then put a single blank
567         ;; line here.
568         (if (zerop (buffer-size))
569             (insert (make-string width ? ))
570           ;; Otherwise, fill the buffer.
571           (while (not (eobp))
572             (end-of-line)
573             (when (> (- width (current-column)) 0)
574               (insert (make-string (- width (current-column)) ? )))
575             (forward-line 1))))
576       (list max
577             (count-lines (point-min) (point-max))
578             (buffer-string)
579             (and fill
580                  (shr-collect-overlays))))))
581
582 (defun shr-collect-overlays ()
583   (save-excursion
584     (goto-char (point-min))
585     (let ((overlays nil))
586       (while (not (eobp))
587         (push (shr-overlays-in-region (point) (line-end-position))
588               overlays)
589         (forward-line 1))
590       (nreverse overlays))))
591
592 (defun shr-overlays-in-region (start end)
593   (let (result)
594     (dolist (overlay (overlays-in start end))
595       (push (list (if (> start (overlay-start overlay))
596                       (- end start)
597                     (- end (overlay-start overlay)))
598                   (if (< end (overlay-end overlay))
599                       0
600                     (- end (overlay-end overlay)))
601                   (overlay-properties overlay))
602             result))
603     (nreverse result)))
604
605 (defun shr-pro-rate-columns (columns)
606   (let ((total-percentage 0)
607         (widths (make-vector (length columns) 0)))
608     (dotimes (i (length columns))
609       (incf total-percentage (aref columns i)))
610     (setq total-percentage (/ 1.0 total-percentage))
611     (dotimes (i (length columns))
612       (aset widths i (max (truncate (* (aref columns i)
613                                        total-percentage
614                                        shr-width))
615                           10)))
616     widths))
617
618 ;; Return a summary of the number and shape of the TDs in the table.
619 (defun shr-column-specs (cont)
620   (let ((columns (make-vector (shr-max-columns cont) 1)))
621     (dolist (row cont)
622       (when (eq (car row) 'tr)
623         (let ((i 0))
624           (dolist (column (cdr row))
625             (when (memq (car column) '(td th))
626               (let ((width (cdr (assq :width (cdr column)))))
627                 (when (and width
628                            (string-match "\\([0-9]+\\)%" width))
629                   (aset columns i
630                         (/ (string-to-number (match-string 1 width))
631                            100.0))))
632               (setq i (1+ i)))))))
633     columns))
634
635 (defun shr-count (cont elem)
636   (let ((i 0))
637     (dolist (sub cont)
638       (when (eq (car sub) elem)
639         (setq i (1+ i))))
640     i))
641
642 (defun shr-max-columns (cont)
643   (let ((max 0))
644     (dolist (row cont)
645       (when (eq (car row) 'tr)
646         (setq max (max max (+ (shr-count (cdr row) 'td)
647                               (shr-count (cdr row) 'th))))))
648     max))
649
650 (provide 'shr)
651
652 ;;; shr.el ends here