推薦 Java 反組譯(Java Decompiler)工具

過程中才發現 JAD (JAva Decompiler) 過去在這個領域的重要性,許多工具底層都是以 JAD 做為 decompiling engine:

但 JAD 的官網從 2009-05 後就連不上(這裡還有 mirror),而且這個套件已經沒在維護了,最多只支援到 JDK 1.3 的 .class 檔。

目前知道能支援比較新 JDK .class 的工具只有 Java Decompiler (JD;剛好跟 DJ Java Decompiler 的 “DJ" 相反):


JD | Java Decompiler - JD-Core、JD-GUI 與 JD-Eclipse

The Java Decompiler Project 由 JD-Core、JD-GUI 還有 JD-Eclipse 組成,其中 JD-Core 做為 JD-GUI 跟 JD-Eclipse 的底層。除了 JD-Eclipse 之外,JD-Core 跟 JD-GUI 都是用 C++ 開發的,因此 JD-Core 跟 JD-GUI 在執行期都用不到 JRE。

這個專案的初衷就是要 decompile/analyze Java 5+ 的程式碼(事實上它支援 JDK 1.1.8 到目前最新的 1.7.0),支援 annotations 跟 generics 等語言的新特性。

JD 支援 Windows、Linux 及 Mac OS X 多個平台,甚至還提供了一個線上體驗的版本,只要將 .class 拖進瀏覽器,就會自動做 decompile 的動作。

JD-GUI 提供了很多實用的功能:

  • 除了開啟 .class 之外,也支援直接開啟 Jar 檔。好處是:
    • 可以從 Type Hierarchy 裡看出某個 class 跟同一個 Jar 檔裡其他 class 的繼承關係。
    • 某個 class 如果有引用到同一個 Jar 檔裡其他的 class 時,點 class name 就會連往該 class 的原始碼,相當方便。
  • Navigate > Open Type Hierarchy… 可以瀏覽目前的 class 在 type hierarchy 裡是處於哪個位置。
  • 還有一項特異功能,就是可以解析 .log 檔(Edit > Parse Log)。只要將帶有 stack trace 的 log 貼到視窗裡,點 class name 就會連往該 class 原始碼。

加上 JD-GUI 本身免安裝,使得它很適合在 production 環境下做 debuging 的工作,不需要花時間把對應的原始碼傳到 production 的機器上,尤其是 exception 是從 third-party 套件丟出來時。另外,JD 也透過 JD-Eclipse 提供了開發時期 debugging 的支援。

JD 可以用在非商業用途上,只要不將它包進商用軟體即可。

其他工具還有:


參考資料

以相對路徑新增 Python Module Search Path,誰在呼叫我?

在 Python 裡,執行期動態修改 module search path 的做法似乎很常見。例如:

sys.path.append('../common')

這種做法存在著一些風險:

  • 當 search path 用相對路徑來表示時,其實是相對於 current working directory (CWD);只要 CWD 在執行期一經變動(os.chdir()),相對路徑就無法被解讀成正確的位置。
  • 其他 module 也可能用相同的方式將同一個路徑加到 search path 裡,最後 search path 裡就會有一堆重複的路徑。

現實的問題是,當系統以外部的 .py 做為擴充的手段時,通常這些 .py 無法從 search path 裡找到。而這些外部的程式一旦要做模組化時(通常是將共用的邏輯提出成為可以共用的 module),就不得不在執行期修改 search path,而且在這種情況下,相對路徑的表示法會比絕對表示法來得直覺。

如果我們想要一個方便的 function 用來修改 search path,而且又可以用相對於該 module 的方式來描述位置時,首先必須要知道是誰在呼叫這個 function;關於這一點,Python 內建的 inspect 可以幫上忙…

Note

誰在呼叫我?

如果我可以知道是誰在呼叫我,那麼對方就能以他的立場來描述一些事情,而我也能聽得懂對方在說什麼…

/tmp/foo.py

import os
print 'cwd (before):', os.getcwd()
os.chdir('/')
print 'cwd (after):', os.getcwd()
print 'foo:', __file__

import bar
bar.call_me()

/tmp/bar.py

import inspect
print 'bar: ', __file__

def call_me():
    frame_records = inspect.stack() 1
    print frame_records

    frame = frame_records[1][0]
    print 'bar.call_me:', inspect.getfile(frame) 2
1 基本上,inspect.stack() 會傳回 list of tuple (frame records)。
2 inspect.getfile() 取得 caller 所屬的 module。

執行結果:

$ python foo.py
cwd (before): /tmp
cwd (after): /
foo: foo.py       1
bar:  /tmp/bar.py 2
[(<frame object at 0x92d6554>, '/tmp/bar.py', 5, 'call_me', ['    frame_records = inspect.stack()\n'], 0),
 (<frame object at 0x926cc84>, 'foo.py', 8, '<module>', None, None)] 3
bar.call_me: foo.py
1 Main script 的 __file__ 只記錄檔名,位置是相對於一開始的 CWD,也就是 main script 所在的目錄。
2 非 main script 的 __file__ 記錄著絕對路徑,不論之後 CWD 怎麼切換。
3 第二個元素(frame_records[1])才是代表 caller。

於是 sys_path_append(path) 因此誕生:

pathutils.py

import os, sys, logging, inspect

_logger = logging.getLogger(__name__)

def sys_path_append(path): 1
    """Add a new path to the Python module search path.

    The path can also be relative to the module calling this function.

    """

    abspath = path
    if not os.path.isabs(path):
        caller_file = inspect.getfile(inspect.stack()[1][0])
        if os.path.isabs(caller_file):
            basedir = os.path.dirname(caller_file)
        else: # called by a main script
            basedir = sys.path[0] 2
        abspath = os.path.normpath(os.path.join(basedir, path))

    append = abspath not in sys.path
    _logger.info("Trying to add path '%s' -> '%s' to the module search path; append = %s, sys.path = %s", path, abspath, append, sys.path)

    if append: sys.path.append(abspath)
1 取這個名字是為了跟 sys.path.append() 的用法相呼應,方便換成 sys_path_append()
2 Main script 的路徑預設是 search path 的第一筆。

做一個簡單的測試:(假設 search path 包今 pathutils.py 所在的路徑)

/tmp/common/mylib.py

def brabra():
    print 'mylib: bra bra ...'

/tmp/extension/foobar.py

import sys
sys.path.append('../common')

import mylib
def do_something():
    mylib.brabra()

/tmp/extension/test.py

import foobar
foobar.do_something()

測試結果:

$ pwd
/tmp/extension
$ python test.py
mylib: bra bra ... 1

顯然 sys.path.append('../common') 的寫法沒問題。但如果程式執行的過程中,有人修改了 CWD 呢?

/tmp/extension/test.py

import os
os.chdir('/') 1

import foobar
foobar.do_something()
1 先把 CWD 切到其他地方。

再做一次相同的測試:

$ python test.py
Traceback (most recent call last):
  File "test.py", line 4, in <module>
    import foobar
  File "/tmp/extension/foobar.py", line 4, in <module>
    import mylib
ImportError: No module named mylib 1
1 問題出在以新的 CWD (/) 為起點,/../common 的位置不是你要的(也不存在 :p)。

這時候 sys_path_append() 就可以幫上忙了:(test.py 維持不變)

/tmp/extension/foobar.py

#import sys
#sys.path.append('../common')
from pathutils import sys_path_append
sys_path_append('../common') 1

import mylib
def do_something():
    mylib.brabra()
1 將原來的 sys.path.append('../common') 置換成 sys_path_append('../common') 即可。

測試結果:

$ python test.py
No handlers could be found for logger "pathutils"
mylib: bra bra ...

從此不怕 CWD 怎麼切換,就是能用相對路徑調整 search path!

相同的原理,也可以用來寫一個將相對路徑轉成絕對路徑的 helper function:

def abspath(relpath, module=None):
    if os.path.isabs(relpath): return relpath

    caller_file = module.__file__ if module else inspect.getfile(inspect.stack()[1][0])
    if os.path.isabs(caller_file):
        basedir = os.path.dirname(caller_file)
    else: # called by a main script
        basedir = sys.path[0]

    return os.path.normpath(os.path.join(basedir, relpath))
Note

參考資料

如何自動化測試 PDF 報表的內容

網站提供下載 PDF 報表的功能很常見。自動化測試 PDF 內容(文字、排版、圖像等)的方法,大概分為下列幾種:

  • 將 PDF 每一個頁面轉成圖像,然後做圖像對圖像的比對 – 一次比對到所有使用者會看到的東西,但如果報表內包含有會變動的內容(例如時間),事先要準備好驗證用的圖像就有困難。
  • 將整個 PDF 轉成另一種格式後(例如 HTML、XML 或單純的文字檔),然後再對轉檔後的結果做解析 – 這類的工具有很多選擇,有些還能個別取出圖像。挑戰來自於怎麼對文字檔做 parsing。
  • 透過 Object ID 直接取出要驗證的資料項目 – 這要事先跟程式開發人員溝通好,事先為要自動化驗證的對象埋下對應的 ID 才行。尤其適合用來取出經由 PDF Form Filler 填值的 PDF 文件?

由於測試框架採用 Python,所以對可能的方案有一些要求:

  • 免安裝 – 可以直接放進 VCS,簡化佈署到測試機器的工作,測試機只要更新自己的 local copy 即可。
  • Pure Python – 跨平台;跟上面一條 “免安裝" 的考量多少有點關係。
  • 支援多國語言 – 從 PDF 檔取出的文字要能夠統一轉成 Unicode。

python pdf (parser or extractor) 為關鍵字,經過一番衝浪之後,找到幾個可能的方案:

雖然說網路上也有一些例子用 pyPDF 來取出 PDF 的文字內容,但在 PDF parsing/extraction 這個領域而這,多數人在談論的還是 PDFMiner,畢竟 pyPDF 的專長是在對 PDF 做加工(例如分割、合併、加解密等),而 PDFMiner 才是專注在 extracting and analyzing text data

Note

可能的方案…


PDF Is Eval!

PDF 為什麼邪惡?

PDF is evil. Although it is called a PDF “document", it’s nothing like Word or HTML document. PDF is more like a graphic representation. PDF contents are just a bunch of instructions that tell how to place the stuff at each exact position on a display or paper. In most cases, it has no logical structure such as sentences or paragraphs and it cannot adapt itself when the paper size changes. PDFMiner attempts to reconstruct some of those structures by guessing from its positioning, but there’s nothing guaranteed to work. Ugly, I know. Again, PDF is evil.

Programming with PDFMiner
— Yusuke Shinyama

這也就是為什麼我們需要 PDFMiner 這類工具,幫我們把 “拼貼在一起的文字" 直的串接起來的原因。

PDF 不邪惡,因為它是為了 presentation 跟 printing 而生…

In a PDF, the text is not continous, but made from a lot of small groups of characters positioned absolutely in the page. The focus of PDF is to keep the layout intact. It’s not content oriented but presentation oriented.

Advanced PDF Parsing Using Python. What is the Best Library?
— Etienne


安裝 PDFMiner

最簡單的方式就是透過 EasyInstall 安裝 pdfminer 套件。

$ sudo easy_install pdfminer
install_dir /usr/local/lib/python2.6/dist-packages/
Searching for pdfminer
Reading http://pypi.python.org/simple/pdfminer/
Reading http://www.unixuser.org/~euske/python/pdfminer/index.html
Best match: pdfminer 20110515
Downloading http://pypi.python.org/packages/source/p/pdfminer/pdfminer-20110515.tar.gz#md5=f3905f801ed469900d9e5af959c7631a
Processing pdfminer-20110515.tar.gz
Running pdfminer-20110515/setup.py -q bdist_egg --dist-dir /tmp/easy_install-oY2vXu/pdfminer-20110515/egg-dist-tmp-fsF5jc
zip_safe flag not set; analyzing archive contents...
pdfminer.cmapdb: module references __file__
Adding pdfminer 20110515 to easy-install.pth file
Installing pdf2txt.py script to /usr/local/bin 1
Installing dumppdf.py script to /usr/local/bin
Installing latin2ascii.py script to /usr/local/bin

Installed /usr/local/lib/python2.6/dist-packages/pdfminer-20110515-py2.6.egg
Processing dependencies for pdfminer
Finished processing dependencies for pdfminer
1 過程中額外安裝了 pdf2txt.pydumppdf.py 以及 latin2ascii.py 三個 command-line tools。

在 Windows 下也是一樣:

C:\>easy_install pdfminer
Searching for pdfminer
Reading http://pypi.python.org/simple/pdfminer/
Reading http://www.unixuser.org/~euske/python/pdfminer/index.html
Best match: pdfminer 20110515
Downloading http://pypi.python.org/packages/source/p/pdfminer/pdfminer-20110515.tar.gz#md5=f3905f801ed469900d9e5af959c7631a
Processing pdfminer-20110515.tar.gz
Running pdfminer-20110515\setup.py -q bdist_egg --dist-dir c:\users\jeremy~1\appdata\local\temp\easy_install-dxz4iz\pdfminer-20110515\egg-dist-tmp-1ks9pp
zip_safe flag not set; analyzing archive contents...
pdfminer.cmapdb: module references __file__
Adding pdfminer 20110515 to easy-install.pth file
Installing dumppdf.py script to C:\Python27\Scripts 1
Installing latin2ascii.py script to C:\Python27\Scripts
Installing pdf2txt.py script to C:\Python27\Scripts

Installed c:\python27\lib\site-packages\pdfminer-20110515-py2.7.egg
Processing dependencies for pdfminer
Finished processing dependencies for pdfminer
1 同樣將三個 command-line tools 安裝到 C:\Python27\Scripts,將這個目錄加到 PATH 環境變數,就可以直接叫用這些 tools。

如果要支援 CJK languages,就必須從 source 安裝:

  1. PyPI 下載 pdfminer-<version>.tar.gz 後解壓縮。
  2. 切換到解壓縮縮的目錄,然後執行 make cmap。(不知道為什麼 Makefile 要寫成 PYTHON=python2,改成 python 即可)
    $ make cmap
    python tools/conv_cmap.py pdfminer/cmap Adobe-CNS1 cmaprsrc/cid2code_Adobe_CNS1.txt cp950 big5
    reading 'cmaprsrc/cid2code_Adobe_CNS1.txt'...
    writing 'CNS1-H.pickle.gz'...
    writing 'ETHK-B5-V.pickle.gz'...
    writing 'ETHK-B5-H.pickle.gz'...
    ...
    python tools/conv_cmap.py pdfminer/cmap Adobe-GB1 cmaprsrc/cid2code_Adobe_GB1.txt cp936 gb2312
    reading 'cmaprsrc/cid2code_Adobe_GB1.txt'...
    writing 'GBT-EUC-V.pickle.gz'...
    writing 'GB-EUC-H.pickle.gz'...
    writing 'UniGB-UTF32-H.pickle.gz'...
    ...
    python tools/conv_cmap.py pdfminer/cmap Adobe-Japan1 cmaprsrc/cid2code_Adobe_Japan1.txt cp932 euc-jp
    reading 'cmaprsrc/cid2code_Adobe_Japan1.txt'...
    writing 'Add-V.pickle.gz'...
    writing '78ms-RKSJ-H.pickle.gz'...
    writing 'Hankaku-V.pickle.gz'...
    ...
    python tools/conv_cmap.py pdfminer/cmap Adobe-Korea1 cmaprsrc/cid2code_Adobe_Korea1.txt cp949 euc-kr
    reading 'cmaprsrc/cid2code_Adobe_Korea1.txt'...
    writing 'KSCms-UHC-HW-V.pickle.gz'...
    writing 'UniKS-UTF32-V.pickle.gz'...
    writing 'KSC-V.pickle.gz'...

    在 Windows 下沒有 make,可以仿上面的輸出依序執行:

    python tools\conv_cmap.py pdfminer\cmap Adobe-CNS1 cmaprsrc\cid2code_Adobe_CNS1.txt cp950 big5
    python tools\conv_cmap.py pdfminer\cmap Adobe-GB1 cmaprsrc\cid2code_Adobe_GB1.txt cp936 gb2312
    python tools\conv_cmap.py pdfminer\cmap Adobe-Japan1 cmaprsrc\cid2code_Adobe_Japan1.txt cp932 euc-jp
    python tools\conv_cmap.py pdfminer\cmap Adobe-Korea1 cmaprsrc\cid2code_Adobe_Korea1.txt cp949 euc-kr
  3. 執行 python setup.py install 進行安裝。
    $ sudo python setup.py install
    ...
    running install_lib
    creating /usr/local/lib/python2.6/dist-packages/pdfminer
    copying build/lib.linux-x86_64-2.6/pdfminer/converter.py -> /usr/local/lib/python2.6/dist-packages/pdfminer
    copying build/lib.linux-x86_64-2.6/pdfminer/glyphlist.py -> /usr/local/lib/python2.6/dist-packages/pdfminer
    ...
    running install_scripts
    copying build/scripts-2.6/pdf2txt.py -> /usr/local/bin 1
    copying build/scripts-2.6/dumppdf.py -> /usr/local/bin
    copying build/scripts-2.6/latin2ascii.py -> /usr/local/bin
    changing mode of /usr/local/bin/pdf2txt.py to 755
    changing mode of /usr/local/bin/dumppdf.py to 755
    changing mode of /usr/local/bin/latin2ascii.py to 755
    running install_egg_info
    Writing /usr/local/lib/python2.6/dist-packages/pdfminer-20110515.egg-info
    1 同樣會安裝三個 command-line tools。

pdf2txt.py 測試安裝:

$ pdf2txt.py samples/simple1.pdf
Hello

World

Hello

World

H e l l o

W o r l d

H e l l o

W o r l d

非英文 PDF 也沒問題:

$ pdf2txt.py samples/jo.pdf
...
宇
宙
塵
を
た
べ
、
...
Tip 由於 PDFMiner 是純 Python 的實作,解壓縮後把 pdfminer 子目錄放到目前的工作目錄底下也可以運作。但 make cmap 產出的 *.pickle.gz 好像會跟平台相依?

從程式裡直接將 PDF 的文字取出來

既然 tools/pdf2txt.py 可以正常處理多國語言的 PDF 檔,就沒有什麼好擔心的了。剩下的只是如何把 pdf2txt.py 裡頭 “將 PDF 轉成文字檔" 的功能取出來…

from StringIO import StringIO
from pdfminer.pdfinterp import PDFResourceManager, process_pdf
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams

def pdf_to_text(pdf_file):
    rsrcmgr = PDFResourceManager()
    laparams = LAParams()

    try:
        infp = open(pdf_file, 'rb')
        outfp = StringIO()
        device = TextConverter(rsrcmgr, outfp, codec='utf-8', laparams=laparams) 1
        process_pdf(rsrcmgr, device, infp)

        return outfp.getvalue().decode('utf-8') 2
    finally:
        outfp.close()
        infp.close()
        device.close()
1 codec 一定要給,預設採用 UTF-8。
2 轉回 Unicode,方便後續的處理。

最後?

故事還沒結束…

取出 PDF 的文字內容之後,接下來就是要做對純文字做 parsing 的工作,這才是真正費時費工的部份…

在尋找解決方案的過程中,意外找到了一些有趣的東西,跟大家分享:


PDFMiner 的其他資源

其他文件