Invoke Method with JDWP

smali breakpoints in Android-Studio

在之前的 smalidea debugging 已經分享過如何在 Android Studio 裡設定好 breakpoints 並且可以看到 registers 的值。而在這次的例子中,需要繼續利用這項工具,還得要自行執行不是原本 smali 上面的程式碼,最後將這個步驟自動化來達成需求。

這次就來利用 JDWP 來執行不在 apk 上面的 code 吧

apk obfuscation

在這次的 target apk 上,發現解開的 smali 都已經過 Obfuscation 了,所以看到許多的 classes, variables and methods 已經沒有原來的名稱了,只剩下 ".a, .b, .c ..." 這樣而已。這樣的確會增加逆向工程的難度,不過在 class 上,還保有原本的 .source 來源檔案名稱,這項資訊幫助很大,雖然不知道裡面的 methods 在做什麼,但至少從這個 來源名稱來猜猜是不錯的入手方向。

.class public final Lcom/hyread/frontepub/e;
.super Lb/a/a/b;
.source "FrontWebServer.java"

# static fields
.field static h:Lcom/hyread/frontepub/e;
# instance fields
.field public final a:Ljava/lang/String;
.field public final b:Ljava/util/List;
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "Ljava/util/List<",
            "Ljava/lang/String;",
            ">;"
        }
    .end annotation
.end field
.field c:Ljava/lang/String;
.field d:Lcom/hyread/domain/AbstractDRMHelper;
.field e:Ljava/lang/String;
.field f:Z

...
.method private static a(Lb/a/a/b$k$a;Ljava/lang/String;Ljava/io/InputStream;)Lb/a/a/b$k;
...
.method private static a(Lb/a/a/b$k$a;Ljava/lang/String;Ljava/lang/String;)Lb/a/a/b$k;
...

WebServer for epub

由於最基本目標是解開加密過後的 epub 檔案,所以先從可以得到的 storage 資料著手,發現到 epub 格式在下載的目錄裡是已經被解壓縮的了,而裡面某些檔案像 .xhtml 等是被加密過的,因為它們看起來一點都不像是個 xml。那麼就從閱讀這份 epub 目錄開始猜測 smali 的中斷點,看看實際閱讀的時候是不是能夠停住。

再經由一番研究之後,就會發現其實目標 apk 會開啟一個 WebServer,在 http://localhost:5001 上,是利用外部的 NanoHTTPD 繼承的,而閱讀器其實就是一個改過的 WebView,對這個位址去發 GET,而拿回來會看到 200 OK,以及解密後原本的檔案內容,即可用 WebView 顯示出來。

事實上這個 5001 只聽本地端連線,而且 apk 要在前景才會服務,所以感覺相對安全。不過在 Android 上可以利用一些背景執行的程式,像是 termux 跑個 script,也是可以正常連到這個位址。而透過 ADB 的話,更可以利用 adb forward 直接轉到本地端,如此其實用常見的 curl 就可以拿到解開的 data 了,一但某本 epub 被開起來,裡面要拿任意其他頁也是沒有問題的。

# forward to localhost:5001
$ adb forward tcp:5001 tcp:5001

# still breakpoint stopped
$ curl http://localhost:5001/NLPIReader/nlpi/some_uuid/46622_EPUB/OEBPS/9789570532968-16.xhtml
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
  ...
# another epub, seems same hash value
$ curl http://localhost:5001/NLPIReader/nlpi/some_uuid/46774_EPUB/item/xhtml/p-016.xhtml
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
  ...

decoded data in registers

不過還是有其他種格式像是 PDF,似乎就不會經過上面 WebServer 了,即使 PDF 檔案也是被解開分散成每個 page 都是一個個 pdf 檔案,看起來像一個 epub 目錄,也不是走同樣的路徑。

再繼續猜測可能的路徑並從 smalidea 設定好的中斷點,然後往暫停的中斷點繼續執行跳進去,就可以發現目標 method 了,還知道這個目標 method 會需要兩個 Ljava/lang/String 的參數,前面那個看起來像是個 timestamp,後面的字串就是被加密的檔案路徑了。最終會吃到一個沒有被改名的 method,那為什麼其他的都有被改成簡化名稱但這些沒有呢?覺得應該是因為這些都是 native method,在 apk 裡都有附上各平台的 JNI shared library,所以反而透過 JNI 的 method 還會保留原本的名稱,這樣可以更容易推測在做什麼事。

- invoke-virtual {v0, v2, p1}, Lcom/xxread/domain/AbstractDRMHelper;->a(Ljava/lang/String;Ljava/lang/String;)Lcom/xxweb/drm/agent/domain/DrmConvertedStatus;
    - v0: DRMHelper
    - p1: DrmConvertedStatus
      - d: 119952 bytes
        - [37, 80, 68, 70, 45, 49, 46, 53, 13, 37, -30, -29, -49, -45, 13, 10, ... more]
          # python
          aaa = [37, 80, 68, 70, 45, 49, 46, 53, 13, 37, -30, -29, -49, -45, 13, 10]
          In [7]: bytearray([a % 256 for a in aaa])
          Out[7]: bytearray(b'%PDF-1.5\r%\xe2\xe3\xcf\xd3\r\n')

看起來這個就是想要的結果了,解完之後就是個 PDF 開頭的 data,接著會丟進 radaee 的 pdf reader,應該是偷過這個 reader 顯示出來到 apk 畫面上。

automatically run these procedure

但總不能每次都要在 Android Studio 裡手動點暫停繼續,複製 register 裡面的 raw data,然後再另存新檔吧,即使可以利用 uiautomator2 自動點選執行,但在 Android Studio / smalidea 裡面的動作如果不能自動化的話,會相當沒有效率而不太實用。

這時候想想既然 Studio 可以達到設定中斷點暫停繼續的話,是不是研究一下這底層是怎麼做怎麼溝通的呢?發現原來是透過 Java 本身就有提供 Debug 用的 Protocol,叫做 JDWP (Java Debug Wire Protocol),而 Android 的 java runtime 本身有提供這項功能的,才有辦法在 Studio 裡中斷繼續。那麼透過這個 JDWP,搭配 adb 以及 uiautomator2,應該就可以自動化這整個步驟達到實用的目的。

Java Debug Wire Protocol

關於 JDWP 的細節都可以在 jdwp-spec.html 找得到,包含各種 data types 的表達格式,以及各 command sets 的細節。詳細研讀的話覺得非常有趣,如果有先上過 VM 相關的課程像是 Nand to Tetris part II 的話,就像是遇到可以實地看真正實做的地方,順便複習一下。

jdp

那麼要怎麼確定我們也可以透過 JDWP 跟 Android 的 Process 溝通,也可以設定中斷點停住呢?搜尋了一下可以先用 jdp 這個工具先來測試一下,原本其實有考慮直接用 jdp 的,不過似乎沒有想要的功能,以及用起來沒有想像中方便,但無論如何這是確定這條路可行的非常第一步。

# find the process
$ adb shell ps | grep nlpi
u0_a121        3339    290 1424340 256772 0                   0 S tw.edu.nlpi.reader
# find monitor
$ adb jdwp
...
3339
^C
# forward port
$ adb forward tcp:8700 jdwp:3339
8700
# jdb attach
$ which jdb
/opt/android-studio/jre/bin/jdb
$ jdb -attach localhost:8700
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
Initializing jdb ...
>

如上面的步驟,透過 adb jdwp 接上正在執行的 apk 之後,便可以來實地操作 jdp 有的命令。

> stop in com.xxweb.drm.agent.DrmAgent.convertData(java.lang.String, java.lang.String)
Set breakpoint com.xxweb.drm.agent.DrmAgent.convertData(java.lang.String, java.lang.String)
# then operate with application could trigger the break point
>
Breakpoint hit: "thread=AsyncTask #6", com.xxweb.drm.agent.DrmAgent.convertData(), line=469 bci=0
> next
Step completed: "thread=AsyncTask #2", com.xxweb.drm.agent.DrmAgent.convertData(), line=472 bci=15
> locals
Method arguments:
= "90802445"
Local variables:
> dump this
...

雖然可以設定中斷點,可以停住了,也可以看到一些 arguments,class 的變數等等,不但有些暫存變數沒有辦法看到,也不適合直接拿 raw data 的啊,而且其實對於程式的宣告有點不太一致,畢竟是基於 Java 的,跟 smali 不太一樣。那麼直接來看看 jdp 呢?發現其實就是基於 Java 寫的,這樣好像不太方便。
既然 JDWP 既然是一個網路的 Protocol,是不是除了 Android Studio / jdp 之外,另外有簡單用這個通訊協定溝通的程式可以參考呢?沒錯沒錯是有的,就是等等會介紹的 jdwp-shellifier。不過這邊先附註一下,要用 wireshark 去解析 jdwp 也是可以的喔,只是原本預料還要晚到 2020 年 10 月後的才有支援。

jdwp-shellifier

jdwp-shellifier 本身其實是一個滲透程式,只是簡單用 python2 來實做必要的 JDWP,目的是在任何有開啟 jdwp 的網路位址上,設定必定會執行到的中斷點,像是 java.net.ServerSocket.accept(),然後利用 Ljava/lang/Runtime 去執行 exec() 想要的命令,獲得原本不能得到的權限。雖然沒有真正裝起來跑,但其實已經是非常好的參考,剛開始 JDWP 文件不清楚的地方跑去看 jdwp-shellifier 獲得許多幫助。

不過畢竟是 python2 有點過時,而且只實做得到 shell 過程中必要的 command sets,所以決定拿來改一改自己用,於是新版本的 JDWPClient 就這麼誕生了。

JDWPClient

改過的 JDWPClient,除了底層基礎地建立好 handshake() 以及拿 idsizes() 這種本來就需要記住 ip/port, idsizes 這些資訊之外,另外只會多紀錄設定過得 breakpoints,以及現在停住的 events,因為後來發現這兩個似乎沒有辦法從 JDWP 再拿到,直接記起來會比較方便。
基本功能大致下如下,理論上可以用這些再兜起想要的進階需求,原則上對外部 import 的使用者來說,應該是不用自己填那些 command set 或是 struck.pack/upack binrary data 的底層細節。

class JDWPClient:
    def handshake(self, host, port) -> JDWPClient:
    def idsizes(self) -> JDWPClient:
    def version(self) -> JDWPClient:
    def set_breakpoint(self, classsig: str, methodsig: str) -> JDWPClient:
    def resume_vm(self):
    def wait_events(self) -> JDWPClient:
    def clear_all_breakpoints(self) -> JDWPClient:
    def disable_collection(self, objid: int) -> dict:
    def enable_collection(self, objid: int) -> dict:
    def info_classes(self, classes: list[dict]) -> JDWPClient:
    def info_methods(self, methods: list[dict]) -> JDWPClient:
    def info_fields(self, fields: list[dict]) -> JDWPClient:
    def info_linetable(self, linetable: dict) -> JDWPClient:
    def info_variabletable(self, variabletable: dict) -> JDWPClient:
    def info_events(self, events: list[dict]) -> JDWPClient:
    def info_frames(self, frames: list[dict]) -> JDWPClient:
    def info_object(self, obj: dict) -> JDWPClient:
    def get_idsizes(self) -> dict:
    def get_version(self) -> dict:
    def get_classes(self) -> list[dict]:
    def get_class_by_signature(self, classsig: str) -> dict:
    def get_signatue(self, reftypeid: int) -> str:
    def get_superclass(self, classid: int) -> int:
    def get_methods(self, pclass: int | str) -> list[dict]:
    def get_method(
            self, pclass: int | str, pmethod: int | str) -> list[dict]:
    def get_fields(self, pclass: int | str) -> list[dict]:
    def get_field(self, pclass: int | str, fieldsig: str) -> dict:
    def get_linetable(self, pclass: int | str, pmethod: int | str) -> dict:
    def get_variabletable(self, pclass: int | str, pmethod: int | str) -> dict:
    def get_frames(self) -> list[dict]:
    def get_this(self) -> dict:
    def get_object_referencetype(self, objid: int) -> dict:
    def get_object_values(self, objid: int, fields: list[dict]) -> list[dict]:
    def get_stack_values(
            self, threadid: int, frameid: int, slots: list[dict]) -> list[dict]:
    def get_string_value(self, valueid: int) -> str:
    def create_string(self, string: str) -> int:
    def get_array_length(self, objectid: int) -> int:
    def get_array_values(
            self, objectid: int, start: int = 0, end: int = 0) -> dict:
    def invoke_object_method(
            self, objectid: int, threadid: int,
            pclass: int | str, pmethod: int | str,
            args: list[dict]) -> dict:

那麼底下就以某個實際利用到 JDWPClient 的例子,分階段來看怎麼達成想要的結果。

setup breakpoints

首先還是得先要設定中斷點,如果之前沒有看過解開的 smali 的話,其實透過 JDWPClient 也是可以列出所有的 Classes 以及 Methods,有這些就可以直接設好中斷點了,設好之後就讓 virtual machine 繼續跑下去,這時候可以做一些會跑到的動作,不管是自動的還是手動的,也許是點一點 apk 上的按鈕之類的動作。

# jdwp.info_classes(jdwp.get_classes())
# jdwp.info_methods(jdwp.get_methods('Lcom/hyread/assetreader/pageRender/b;'))
# self.jdwp = jdwpclient.JDWPClient('localhost', 8700)()

def _break(self):
        jdwp = self.jdwp
        # set breakpoint for pdf
        jdwp.set_breakpoint(
            'Lcom/xxread/domain/DRMHelper;',
            'a(Ljava/lang/String;Ljava/lang/String;)'
            'Lcom/xxweb/drm/agent/domain/DrmConvertedStatus;',)
        # resume
        jdwp.resume_vm()
        return self

def _resolve(self):
        jdwp = self.jdwp
        jdwp.resume_vm().wait_events().resume_vm().wait_events()
        jdwp.clear_all_breakpoints()
        ...

這邊可能注意一下 wait_events() 是一個 synchronous function,所以沒有等到是不會回來的。倒也不用擔心會錯過時機,virtual machine 會先停住然後等著傳 events 進來,總之先想辦法 trigger 就對了。

smali registers

停住之後,會想要拿現在這行可以看到的 method 參數,甚至是 local 變數,這時候就可以像底下這樣,取得這些變數的結果。

def custom_invoke(jdwp):
    # resume
    print('second wait events')
    jdwp.resume_vm().wait_events().info_events(jdwp.events)

    # frames[0]
    frame = jdwp.get_frames()[0]
    location = frame['location']
    # Method/VariableTable, slots
    vartable = jdwp.get_variabletable(location['classID'], location['methodID'])
    slots = vartable['slots']
    # StackFrame/GetValues, slots[1:3]: b'Ljava/lang/String;', values
    values = jdwp.get_stack_values(
        self.events[0]['thread'], frame['frameID'], slots[1:3])
    # StringReference/Value, tsstr, fpstr
    tsval = values[0]
    fpval = values[1]
    tsstr = jdwp.get_string_value(tsval['value'])
    fpstr = jdwp.get_string_value(fpval['value'])
    ...

不過這邊很有趣的地方是怎麼知道參數是放在 slots 的那裡呢?另外也還有一個疑問就是其他的變數呢?更準確地說就是 smali 看到的那些 registers,就是 v3, v4 等等這些。
當初一直百思不得其解,都說 JVM 是 stack-based 的,Android ART/dalvik 才是 register-based,的確這個 JDWP 裡面通篇沒有半個 register 相關的字詞。 那 smalidea 是怎麼拿到所謂的 registers 內容的?
後來在 samli wiki/Registers 獲得了解答,原來 smali register 命名有兩種,一種是 v 開頭,另一種是 p 開頭的,其實 p 開頭的會有一個 v 的別名,是一樣的。

You can reference parameter registers by either name - it makes no difference.

Local Param
v0 the first local register
v1 the second local register
v2 p0 the first paramenter register
v3 p1 the second paramenter register
v4 p2 the thrid paramenter register

而如果是在一個 class method 裡的話,p0 就會是這個 class 的 object instance,也就是 self,接下來的參數就是跟著順序給 slot index,然後每個 method 會先指定好有幾個 local registers,像是這樣

.method private static a(Lb/a/a/b$k$a;Ljava/lang/String;Ljava/lang/String;)Lb/a/a/b$k;
    .locals 2
    ...

這類似的概念讓人想起了 Nand to Tetris part II 的作業啊。

所以 smali 的 vX registers 就是 jdwp 的 local slots

custom invoke method

拿到目前中斷點的參數之後,就可以做點處理了,比方說知道目前要接觸的檔案的路徑之類的,但如果一直 next 下去,只能得到跟原本code flow 一樣的結果,那麼要怎麼做自己想做的事呢?一個就是改變參數的值,然後繼續下去或是再自行呼叫 method。後面這個會比較適合這次的使用,因為這樣一本書就只要停一次拿到所有的 raw data 就好,就可以退出,也不需要繼續執行下去。

def _invoke(href_path) -> bytes:
    href_objid = jdwp.create_string(str(href_path))
    result = jdwp.invoke_object_method(
        drmagentobjid, jdwp.events[0]['thread'],
        drmagentcls['typeID'], convertdatamethod['methodID'],
        [tsval, {'tag': b's', 'value': href_objid}],
    )
    rval = result['returnValue']
    ...
    return href_data

fetch returned data

如果沒有發生其他的 Exception 的話,回傳值就會正確地被放在 returnValue 帶回來,可以從 JDWP Tag 來判斷這是一個什麼樣的物件,這邊的範例的話是事先已經知道其實就是一個 Bytes Array 了,所以直接後續處理把 binrary bytes 拿出來。
不過遇到另外有趣的問題,因為剛好參數是一個 String,所以過程當中會 create 出許多 String,發現到後來常常影響到原本的 object 被 garbage collection 了,所以還需要自行用 enable/disable_collection() 來保證一定可以拿到結果,不會馬上被回收走。

def _invoke(href_path) -> bytes:
    ...
    rval = result['returnValue']

    jdwp.disable_collection(rval['value'])
    rbytesobjid = jdwp.get_object_values(
        rval['value'], [field])[0]['value']
    rbyteslen = jdwp.get_array_length(rbytesobjid)
    if rbyteslen in [0]:
        jdwp.info_object({'objectID': rval['value']})
        raise ValueError('rbyteslen {rbyteslen} should > 0')
    href_data = jdwp.get_array_values(rbytesobjid)['values']
    jdwp.enable_collection(rval['value'])
    return href_data

postfix for the target method

全部整合起來之後,的確就可以自動化一直執行下去,不過還是有遇到其他問題需要修正,把比較有趣的部份專門提一下。其他的部份像是如何正常讓 uiautomator2 自動下載然後掃完所有的書籍,找到 metadata,adb pull,重新打包 epub 等等的就略去了。

python magic

一般來說想要判斷一個檔案的內容會直接使用 file 這個命令,在 python 底的話有個 python-magic 模組可以使用,是用到一樣的 libmagic.so,但使用起來還是有點不太合用的地方。

# should be data
$ file .data/34941_ELSE/XXWEB/115312_100.jpg
.data/34941_ELSE/XXWEB/115312_100.jpg: OpenPGP Secret Key
In [1]: import magic
In [2]: magic.from_file('.data/34941_ELSE/HYWEB/115312_100.jpg')
Out[2]: 'OpenPGP Secret Key'

為了避免造成誤判,所以決定手動自己寫一些常見已知的檔案判斷,剩下的才使用 magic

def _is_href_data(href_path) -> str:
    with open(href_path, 'rb') as f:
        magic_bytes = f.read(16)
    ext = href_path.suffix
    if ext in ['.ncx', '.xhtml']:
        mstr = 'xml' if magic_bytes[:5] in [b'<?xml'] else 'data'
    elif ext in ['.pdf']:
        mstr = 'pdf' if magic_bytes[:5] in [b'%PDF-'] else 'data'
    elif ext in ['.jpeg', '.jpg']:
        mstr = (
            'jpg' if magic_bytes[:3] in [b'\xff\xd8\xff'] else 'data')
    else:
        mstr = magic.from_file(href_path)
        # raise NotImplementedError(f'{href_path} with {ext}')

trailing paddings

在重新打包成一個 epub 檔案的時候,雖然看起來檔案內容都是有意義的,但是在大部分的 app 都說格式錯誤沒有辦法打開。在稍微查看一下某一個 epub reader 的時候,發現原來過 convertData() 的檔案後面都多了一些不定長度的 bytes,值就是多出來的 bytes 個數,就是這些多出來的東西造成解析失敗。

$ find -name "*.xhtml" | xargs -i sh -c "echo {}; hexdump -C {} | tail -n 3"
  ...
  ./169307_EPUB/OEBPS/Text/p-086.xhtml
  00000310  3c 2f 62 6f 64 79 3e 0d  0a 3c 2f 68 74 6d 6c 3e  |</body>..</html>|
  00000320  10 10 10 10 10 10 10 10  10 10 10 10 10 10 10 10  |................|
  00000330
  ./169307_EPUB/OEBPS/Text/p-003.xhtml
  000002d0  3c 2f 64 69 76 3e 0d 0a  3c 2f 62 6f 64 79 3e 0d  |</div>..</body>.|
  000002e0  0a 3c 2f 68 74 6d 6c 3e  08 08 08 08 08 08 08 08  |.</html>........|
  000002f0
  ./169307_EPUB/OEBPS/Text/p-046.xhtml
  000002d0  3c 2f 64 69 76 3e 0d 0a  3c 2f 62 6f 64 79 3e 0d  |</div>..</body>.|
  000002e0  0a 3c 2f 68 74 6d 6c 3e  08 08 08 08 08 08 08 08  |.</html>........|
  000002f0

其實用 hexdump 來看就很清楚了,看起來就是要把原本檔案補成 16 bytes 的倍數所作的 padding 啊,而且就 p-086.xthml 的例子來看,這個值一定在 0x1 到 0x10 之間,就是即使原本剛好 16 byte aligned,也還是要多補一行,換句話說就是不會看到解完的 data 會是 0x00 結尾的樣子出現。也因為如此猜測 convertData() 內部應該就是每次解 16 bytes 為一個單位這樣進行的。

href_data = _invoke(book_dir / href)
pads = href_data[-1]
if bytearray([pads] * pads) == href_data[-pads:]:
    href_data = href_data[:-pads]
else:
    raise ValueError('decrypted data should follow padding bytes')

如此再存好檔案打包之後,就沒有出現無法打開文件的問題了。

per page pdf merge

前面有提到過,即便是 PDF 的格式存起來的目錄裡面看到的其實是一頁一頁分開的 .pdf 檔案,把分開的 .pdf 合在一起是沒有問題,但會不會有些 metadata 等什麼的會缺呢?所以剛好手邊有另一個來源的某期電子雜誌,是一整個的 pdf 檔,就來分析一下裡面的長相。

# .data/鏡週刊第289期.pdf is better
    $ pdfinfo .data/鏡週刊第289期.pdf
    Custom Metadata: no
    Metadata Stream: no
    Tagged:          no
    UserProperties:  no
    Suspects:        no
    Form:            none
    JavaScript:      no
    Pages:           184
    Encrypted:       no
    Page size:       595.44 x 793.8 pts
    Page rot:        0
    File size:       41383986 bytes
    Optimized:       no
    PDF version:     1.4
# same book is 288732_ELSE,
#   opf says its total 184 pages also 184 pdfs
    $ pdfinfo data/pulled/288732_ELSE/XXWEB/291458_184.pdf
    Title:
    Author:
    Creator:         PScript5.dll Version 5.2.2
    Producer:        Acrobat Distiller 10.1.16 (Windows)
    CreationDate:    Wed Apr 13 11:35:50 2022 CST
    ModDate:         Wed Apr 13 11:35:50 2022 CST
    Custom Metadata: no
    Metadata Stream: yes
    Tagged:          no
    UserProperties:  no
    Suspects:        no
    Form:            none
    JavaScript:      no
    Pages:           1
    Encrypted:       no
    Page size:       595.277 x 793.703 pts
    Page rot:        0
    File size:       465901 bytes
    Optimized:       yes
    PDF version:     1.5

所以看起來就是直接串起來就是了,原本想說要針對 href 做排序,但發現反而會亂掉,就相信原本的 href 的出現順序,直接合在一起吧,雖然從 pdfinfo 會得到是 Producer: PyPDF2PDF version: 1.3,但內容看起來是一樣的。

items = root.findall('.//{*}manifest/{*}item')
href_pdfs = [
    root_opf.parent / i.attrib['href']
    for i in items if i.attrib.get('href', '').endswith('.pdf')]
pdfmerger = PyPDF2.PdfFileMerger()
# for href in sorted(href_pdfs):
for href in href_pdfs:
    pdfmerger.append(str(local_book_dir / href))

zipped pages pdf merge

後來在 pdf merge 還有遇到一個有趣的問題,透過上面 PyPDF2 作出來的檔案,實際上看的時候發現有些 page 會破碎亂掉無法正常閱讀。做了一些分析之後發現,這些都是因為原本的 pdf pages 其實都是 zipped 的,那麼用 PyPDF2 的時候就有機會出現問題。為了重現這個問題最小化步驟,了解到原來 PyPDF2 只有做好頭兩頁的 zipped pages,第三頁之後就會破碎。

找了一下替代方案,發覺可以用用看本來就有考慮的 pymupdf,有看到使用這個會比較快,但因為在 mac m1 上要安裝比較麻煩一點就先沒選就是。那麼先安裝起來看看

    $ pip install -U wheel
    $ brew install mupdf swig freetype
    $ pip install -U pymupdf

然後把上面那段改成用 pymupdf 的 module fitz 改寫就可以了。當然是有先在最小化步驟試過沒問題才換的,然後把所有的 pdf 再重新製作一遍,就沒有再看到破碎的檔案了。

       # with pymupdf is good for zipped paged
        doc = fitz.open()
        missing_pages = 0
        for href in href_pdfs:
            try:
                infile = fitz.open(local_book_dir / href)
                doc.insert_pdf(infile)
                infile.close()
            except Exception as e:
                missing_pages += 1
                print(f'[{str(local_book_dir / href)}]: {e}')
                pass
        doc.save(pdf_path)

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *