2009年5月24日 星期日

透過 GNU Emacs 將 source code 轉成 HTML

有在寫部落或是做網頁的人可能多少都會碰過要將原始碼貼上網頁的清況,不過光是貼上原始碼這一大堆文字確實會讓人讀起來很吃力,現在一般人在看原始碼可能都已經習慣了各種編輯程式所提供的 syntax highlighting 語法標注的功能。可惜這些功能通常只能在本機上使用,一般網頁不太會提供這種功能。還好,網路上有已經有很多人提供解決的方法,有些人是用 client 端的 JavaScript (CSJS) 來實作,而有些人是透過 server 端PHP 來實作 syntax highlighting 語法標注功能。

此外還有另一種方式,就是先將程式或原始碼先透過網路上的服務或是本機的工具程式先轉成 HTML,然後再把 HTML 貼到網路上。這種方法雖然沒有 JavaScript 或 PHP 便捷,但是有些網站基於管理或是安全上的理由,並不允許執行使用者的 JavaScript,有時甚至連 CSS 都無法修改,而 PHP 通常則需要有 server 的管理權限,所以這個方法自然有它的優點。因為 Emacs 是我寫程式慣用的編輯器,它不但對許多原始碼都提供 syntax highlighting 的功能,此外自動縮排的功能也非常好用,所以也使我想開始尋找 Emacs 是否也具有某種機制可以將 buffer 中 highlight 過的文字轉成 HTML,如此轉好後就可以直接貼到網路上去了。然而經過一番摸索後似乎沒有發現這樣的功能,但是我並沒有因此而放棄,因為 Emacs 還有內建的 script 語言叫做 Emacs Lisp (或 Elisp) 是個很 powerful 的工具,可以透過它來存取編輯器內部的物件以及執行一些程式化的動作。稍微思考一下並嘗試一些 function 做點小試驗,發現要寫這樣一個程式並不會很難,buffer 中的原始碼 Emacs 都已經幫你 highlight 好了,所以只要將文字的屬性抓出來再根據這些資訊在文字加上對應的 HTML markup 就行了。下面這段程式就是今天努力的結果,程式很簡單不到 200 行 (這個 highlight 過的程式碼也是用該程式本身轉出來的):

下載 faced-buf2htm.el
;;; faced-buf2htm.el --- convert buffer text with face properties into HTML
;; Copyright (c) 2009 Justin Lee

;; Author: Justin Lee <cf9404@yahoo.com.tw>
;; Created: 24 May 2009
;; Version: 1.0
;; Keywords:

;; This is free software; you can redistribute it and/or modify it
;; under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 2 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be
;; useful, but WITHOUT ANY WARRANTY; without even the implied
;; warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
;; PURPOSE.  See the GNU General Public License for more details.

;; You should have received a copy of the GNU General Public
;; License along with this program; if not, write to the Free
;; Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
;; MA 02111-1307 USA

;;; Commentary:

(defvar g-r-dat)
(defvar g-g-dat)
(defvar g-b-dat)

(defun get-cdat (cv0 cv1)
  (cons cv0 (- cv1 cv0)))

(defun cv2htmcv (cv cdat)
  (/ (* 255 (- cv (car cdat)))
     (cdr cdat)))

(defun cvs2htmcvs (cvs)
  (format "#%02x%02x%02x"
          (cv2htmcv (nth 0 cvs) g-r-dat)
          (cv2htmcv (nth 1 cvs) g-g-dat)
          (cv2htmcv (nth 2 cvs) g-b-dat)))

(defun faced-buf2htm-init ()
  (let (a-v1 a-v2)
    (setq a-v1 (color-values "black"))
    (setq a-v2 (color-values "white"))
    (setq g-r-dat (get-cdat (nth 0 a-v1) (nth 0 a-v2)))
    (setq g-g-dat (get-cdat (nth 1 a-v1) (nth 1 a-v2)))
    (setq g-b-dat (get-cdat (nth 2 a-v1) (nth 2 a-v2)))

    ;;  Make a face with all attribute values being `unspecified'. It is merely used to be
    ;;  overridden by the inherited face(s) (the last parameter of function `face-attribute')
    ;;  for simulating a merge process.
    ;;
    ;;  Emacs help info: You can specify more than one face for a given piece of text; Emacs
    ;;  merges the attributes of all the faces to determine how to display the text. If a
    ;;  list of faces is used, attributes from faces earlier in the list override those from
    ;;  later faces.
    (make-face 'nil-face)))

;;  <span style='color:red;'><s><u><b><i>txt</i></b></u></s></span>
(defun format-faced-txt (txt face)
  (let (a-v1 a-v2 a-v3)
    (setq a-v1 txt)
    (setq a-v1 (replace-regexp-in-string "&" "&amp;" a-v1))
    (setq a-v1 (replace-regexp-in-string "<" "&lt;" a-v1))
    (setq a-v1 (replace-regexp-in-string ">" "&gt;" a-v1))
    (setq a-v1 (list a-v1))
    (setq a-v2 a-v1)

    (setq a-v3 (face-attribute 'nil-face :slant nil face))
    (unless (eq a-v3 'unspecified)
      (setq a-v1 (cons "<i>" a-v1))
      (rplacd a-v2 (list "</i>"))
      (setq a-v2 (cdr a-v2)))
    (setq a-v3 (face-attribute 'nil-face :weight nil face))
    (unless (eq a-v3 'unspecified)
      (setq a-v1 (cons "<b>" a-v1))
      (rplacd a-v2 (list "</b>"))
      (setq a-v2 (cdr a-v2)))
    (setq a-v3 (face-attribute 'nil-face :underline nil face))
    (unless (eq a-v3 'unspecified)
      (setq a-v1 (cons "<u>" a-v1))
      (rplacd a-v2 (list "</u>"))
      (setq a-v2 (cdr a-v2)))
    (setq a-v3 (face-attribute 'nil-face :strike-through nil face))
    (unless (eq a-v3 'unspecified)
      (setq a-v1 (cons "<s>" a-v1))
      (rplacd a-v2 (list "</s>"))
      (setq a-v2 (cdr a-v2)))
    (setq a-v3 (face-attribute 'nil-face :foreground nil face))
    (unless (eq a-v3 'unspecified)
      (setq a-v1 (append (list "<span style='color: " (cvs2htmcvs (color-values a-v3)) ";'>") a-v1))
      (rplacd a-v2 (list "</span>"))
      (setq a-v2 (cdr a-v2)))
    a-v1))

(defun faced-buf2htm-process (buf1 buf2)
  (let (a-v1 a-v2 a-v3 a-v4 a-v5)

    (with-current-buffer buf1
      (setq a-v1 (point-min)))

    (setq a-v3 t)
    (while a-v3
      (setq a-v2 (next-single-property-change a-v1 'face buf1))
      (unless a-v2
        (setq a-v2 (with-current-buffer buf1 (point-max)))
        (setq a-v3 nil))

      (with-current-buffer buf1
        (setq a-v4 (buffer-substring a-v1 a-v2)))
      (setq a-v5 (get-text-property 0 'face a-v4))
      (with-current-buffer buf2
        (apply 'insert (format-faced-txt a-v4 a-v5)))

      (setq a-v1 a-v2))

    ))

;;  Make sure the text properties are updated by scrolling through the whole buffer.
(defun update-txt-prop ()
  (goto-char (point-min))
  (set-window-start (selected-window) (point-min))
  (scroll-up (1- (count-lines (point-min) (point-max)))))

(defun faced-buf2htm ()
  (interactive)
  (let (a-v1)
    (save-excursion

      (setq a-v1 (get-buffer-create (generate-new-buffer-name "*HtmlFromFacedBuf*")))

      (message nil) (message "Updating text properties of the buffer ...")
      (update-txt-prop)

      (with-current-buffer a-v1
        (insert "<pre>"))

      (message nil) (message "Converting buffer text ...")
      (faced-buf2htm-init)
      (faced-buf2htm-process (current-buffer) a-v1)

      (with-current-buffer a-v1
        (insert "</pre>")
        (html-mode))

      (switch-to-buffer a-v1)

      )))

(provide 'faced-buf2htm)

;;;  Code snippets for experiments.

;; (text-properties-at (point))
;; (goto-char (next-property-change (point)))
;; (goto-char (next-single-property-change (point) 'face))
;; (color-values (face-attribute 'font-lock-type-face :foreground))

;;  (defun update-txt-prop ()
;;    (goto-char (point-min))
;;    (scroll-up (1- (count-lines (point-min) (point-max)))))
;;
;;  (defun update-txt-prop ()
;;    (set-window-start (selected-window) (point-min))
;;    (beginning-of-buffer)
;;    (scroll-up (1- (count-lines (point-min) (point-max)))))
;;
;;  (defun update-txt-prop ()
;;    (beginning-of-buffer)
;;    (set-window-start (selected-window) (point-min))
;;    (scroll-up (1- (count-lines (point-min) (point-max)))))
程式產生出來的 HTML 中,所有的 style 都是內嵌的,因此沒有需要再修改 CSS,不過缺點就是轉出來的 HTML 碼會比較大。

下面是一個由 PHP 所寫成的簡單 hello world 範例其轉換前後的對照。轉換前的 PHP 原始碼:
<?php
echo "Hello, world!";
?>
轉換後的 HTML 原始碼:
<pre><span style='color: #5f9ea0;'>&lt;?php</span>
<span style='color: #a020f0;'>echo</span> <span style='color: #bc8f8f;'>"Hello, world!"</span>;
<span style='color: #5f9ea0;'>?&gt;</span>
</pre>

使用方法

使用方法很簡單,先將上面的 Elisp load 進來:
M-x load-file
上面輸入後會提示你輸入 Elisp 的路徑:
/path/to/faced-buf2htm.el
load 完成後 switch 到任何一個要被轉換的 buffer 然後打:
M-x faced-buf2htm
等程式跑完後就會自動開一個新的 buffer,裡面就是轉出來的 HTML,複製下來再貼到網路上去即可。

實作註記

程式主要的功能是將文字的 face 屬性擷取出來並轉成 HTML,當時寫完後開了幾個檔來轉轉看,但是發現有時開一個大一點的檔來轉,轉完了以後某些文字竟然沒有 face 屬性。再多試了幾次以後發現這些沒有 face 的文字大多是落在檔案的後段,不過也有一些例外。最後發現原來這些沒 face 的文字幾乎都是在檔案開啟後,在瀏覽 buffer 的過程中沒有被瀏覽到的文字,所以如果在檔案開啟後,按住 page down 鍵不放,讓 Emacs 從頭捲到尾把整個 buffer 瀏覽一遍就不會有這個問題了。猜想這個現象應該是 Emacs 為了節省開啟檔案的時間或為了減少一些不必要的工作,因此未被瀏覽過的 buffer 部份它就不作 syntax highlighting 所以這些文字也就沒有 face 屬性了。接下來的重點就是如何讓這個瀏覽的動作自動化,以強迫所有的文字屬性都有被更新到。試了幾種方法後終於試出來了,就是程式中 update-txt-prop 函數,只要三行就可以做到:
;;  Make sure the text properties are updated by scrolling through the whole buffer.
(defun update-txt-prop ()
  (goto-char (point-min))
  (set-window-start (selected-window) (point-min))
  (scroll-up (1- (count-lines (point-min) (point-max)))))
因為轉出來的文字會被當成 HTML 來使用,因此在文字中具有 HTML 意義的部份都要 escape 掉以免這些文字被當作 HTML 而造成轉換上的失真。例如 HTML 中的 tag 都是用角括號 (即 <...> ) 括起來的,因此轉換時要將文字中大於 (>) 及小於 (<) 的符號 escape 掉,以避免包夾在中間的中文字被當作 HTML tag,這裡大於及小於可以用對應的 character entity reference 來取代,分別為 &gt; 及 &lt; 除此之外用來起始 character entity reference 的 ampersand (&) 同樣也要用 &amp; 取代掉,所以這就是函數 format-faced-txt 前面幾行所做的工作:
(defun format-faced-txt (txt face)
  (let (a-v1 a-v2 a-v3)
    (setq a-v1 txt)
    (setq a-v1 (replace-regexp-in-string "&" "&amp;" a-v1))
    (setq a-v1 (replace-regexp-in-string "<" "&lt;" a-v1))
    (setq a-v1 (replace-regexp-in-string ">" "&gt;" a-v1))
;                .                 .
;                .                 .
;                .                 .
小弟目前所想到需要被 escape 的只有這些符號,其它需要 escape 的符號如果各位前輩有發現的話可以跟小弟告知。此外感謝 alan 在這篇文章的意見中提到在 EmacsWiki 上有一個 package 叫 Htmlize 提供類似但更成熟的功能,各位如果覺得 faced-buf2htm.el 的功能不符合需要,也可以試試 Htmlize。

Demonstration of source code to HTML conversion by Emacs

4 意見:

alan 提到...

之前好像有一個Htmlize
http://www.emacswiki.org/cgi-bin/wiki/Htmlize

Justin Lee 提到...

謝謝你的意見,大概看了一下 Htmlize 的 source,覺得會有不少功能,值得參考 =]

木头 提到...

vim
:TOhtml

Justin Lee 提到...

謝謝你的意見,不過小弟沒有使用 vim 的習慣,對它不太了解,google 了一下有找到 Vim TOhtml,猜想或許就是木頭說的 :TOhtml,有在使用 vim 的朋友或許也可以參考看看 =]