PDF格式簡單分析

上週因須要編輯了下PDF,用了一兩個試用軟件,感受文字版的PDF仍是挺好編輯的。想要研究一下PDF格式。php

0. 站在前輩的肩膀上

從前輩的文章和書籍瞭解到html

  • PDF文件是一種文本和二進制混排的格式,二進制的內容來自於三個方面:一、圖片;二、字體;三、壓縮後的Post Script。python

  • PDF文件正文由一系列對象組成, 每一個對象前面都有一個對象編號(惟一)、生成號和一行上的 obj 關鍵字, 後面跟另外一行的 endobj 關鍵字。例如:算法

    1 0 obj
    <<
    /Kids [2 0 R]
    /Count 1
    /Type /Pages
    >>
    endobj

    在這裏, 對象編號爲 1, 生成號爲 0 (幾乎老是)。對象1的內容位於兩行之間 1 0 obj 和 endobj 之間。在這種狀況下, 它是字典 <</Kids [2 0 R] /Count 1 /Type /Pages >>數組

  • 對象:PDF對象包括5個基本對象以及3個複合對象app

    • 基本對象:
      Boolean values 布爾值,truefalse編輯器

      Integers and real numbers 數值,包含整型和浮點型,例如 42和3.1415。學習

      Strings 字符串,文字字符串包含在圓括號()內,十六進制字符串包含在單尖括號<>內。測試

      Names 名稱,由 /+字符串 組成,相同的名字表示相同的對象。字體

      Null 空對象,用關鍵字null表示。

      上面是基礎對象,可組合爲複合對象:

    • 複合對象

      Array 數組,包含其餘對象的有序集合,包含在方括號[]內,元素能夠是除了Stream類型外的全部類型,如 [/xx false 1 (onion)] 包含了四種類型,注:用空格分隔。

      Dictionary 字典,包含一個無序鍵值對的集合,包含在雙尖括號<< >>內。兩個元素是一對,鍵是對象的名稱,值是除了Stream外的全部類型。例如, <</Contents 4 0 R /Resources 5 0 R>> , 它將 /Contents 映射到間接引用 4 0 R ,以及 /Resources 映射到間接引用5 0 R

      Stream 流,包含二進制數據,以及描述數據屬性(如長度和壓縮參數)的字典數據。PDF Stream由一個字典和一個字節流(流用於存儲圖像、字體等)組成,字典中定義了流的參數。字典中若是有/Filter鍵,表示指定的過濾器類型,壓縮過濾器 FlateDecode 最爲經常使用。(下一節會看到沒有壓縮過濾器,採用XML的流對象)。

      補:Object Object 流對象類型,PDF 1.5中引入。以及其餘的類型,之後遇到再補充。

    • 間接引用

      除了基本對象和複合對象,還有一種將對象連接在一塊兒的方法:

      Indirect reference 間接引用,它造成從一個對象到另外一個對象的連接。

    PDF 文件由對象圖組成, 間接引用構成它們之間的連接。

1559699185089

圖1 《PDF explained》文件結構->對象

  • PDF由四部分組成

    1559629734691

  • PDF 處理流程

1559629349714

用一個實例來研究下結構。

1. Hello, PDF

爲了研究精簡的PDF文檔結構,新建了一個PDF,內容就是Hello, PDF.

1559629210317

圖4 hello.pdf,一個 hello world級別的 PDF

用編輯器打開,查看文本,流對象不能查看,用...替換掉。文本爲下面部分

%PDF-1.7
%����
1 0 obj
<</Pages 2 0 R /Type/Catalog/Metadata 8 0 R >>
endobj
4 0 obj
<</Resources<</Font<</FXF1 6 0 R >>>>/MediaBox[ 0 0 595.28 841.89]/Contents 7 0 R /Parent 2 0 R /Type/Page/CropBox[ 0 0 595.28 841.89]>>
endobj
7 0 obj
<</Length 86/Filter/FlateDecode>>stream
...
endstream
endobj
8 0 obj
<</Length 865/Type/Metadata/Subtype/XML>>stream
...
endstream
endobj
9 0 obj
<</Type /ObjStm /N 3/First 15/Length 224/Filter /FlateDecode>>stream
...
endstream
endobj
10 0 obj
<</Type /XRef/W[1 4 2]/Index[0 11]/Size 11/Filter /FlateDecode/DecodeParms<</Columns 7/Predictor 12>>/Length 64
/Root 1 0 R
/Info 3 0 R
/ID[<5E1FEDA5466E60C6D70D3004F5E43166><5E1FEDA5466E60C6D70D3004F5E43166>]>>stream
...
endstream
endobj

startxref
1662
%%EOF

簡單分析:

第一行 %PDF-1.7%符號表示一個標題行,這裏給出了文件的PDF版本號 1.7。

第二行 %����%表示另外一個標題行,亂碼內容爲大於127字節的二進制數據。因爲PDF文件幾乎老是包含二進制數據,所以若是更改行結尾(例如,若是文件經過FTP以文本模式傳輸),它們可能會損壞。 爲了容許傳統文件傳輸程序肯定文件是二進制文件,一般在標頭中包含一些字符代碼高於127的字節。

Body

文件正文由一系列對象組成(上節有介紹),這裏有6個對象,分別是

1 0 obj ... endobj
4 0 obj ... endobj
7 0 obj ... endobj
8 0 obj ... endobj
9 0 obj ... endobj
10 0 obj ... endobj

Cross-Reference Table

交叉引用表格式:

xref                    # 標識交叉引用表開始
0 14                    # 說明下面對象編號是從0開始,總共有14個對象, 從 0 到 13
0000000000 65535 f      # 第0個對象,規定生成號爲65535,f 表示 free entry,對象不存在或者刪除
0000003079 00000 n      # 第1個對象,偏移地址爲3079,生成號爲0表示未被修改過, n 表示 in use

從 PDF 1.5 開始, 引入了一種新的機制, 經過容許將許多對象放入單個對象流 (整個流被壓縮) 來進一步壓縮 PDF 文件。同時, 引入了一種引用這些流中對象的新機制--交叉引用流(cross-reference streams)。

在本次測試中的PDF不存在關鍵字 xref開頭的引用,只有基於流的引用,10 0 obj這個對象可能就是了。

交叉引用流格式是這樣的: (如今只接觸過 類型爲 /XRef/ObjStm 的交叉引用流對象)

x 0 obj 
<</Type /XRef ...>>stream
... 
endstream 
endobj
x 0 obj 
<</Type /ObjStm ...>>stream
... 
endstream 
endobj

Trailer

測試文本中沒有看到Trailer關鍵字,相似

trailer                 # 標識文件尾trailer對象開始
<</Root 13 0 R          # 代表根對象的對象號爲13,即交叉表中的最後一個對象
/ID [<4E76CDCEDB1E2EC4AC47475DB4EE376E> <C8B1AEBC2C6615E39860F1C150A2847C>]
/Size 14                # 代表PDF文件的對象數目
/Info 8 0 R>>

之後遇到了再分析。

倒數第三行的 startxref,標明瞭交叉引用表的偏移地址,下面的數字1662表明了偏移量(相對於文件開始)。由於一個文檔中能夠有多個xref,因此這裏要指明要從哪一個xref開始進行解析這個文件。

1662的十六進制爲67E, 將文件以十六進制格式打開,恰好是10 0 obj那個 xref 對象的開始位置。

1559714932549

圖5 16進制查看起始位置的對象

最後一行 %%EOF, 標識 PDF 文件結尾。

字典數據

從起始位置的對象10 0 obj分析。先看其中的字典數據,先格式化一下,按照個人理解簡單標註

<<
/Type /XRef     # 類型爲xref,表示此對象是基於流的交叉引用表
/W[1 4 2]       # W的值爲數組 [1 4 2]
/Index[0 11]    # Index 值爲 [0 11]
/Size 11        # 文件數目?
/Filter /FlateDecode    # 指定壓縮算法,RFC1950,即ZLIB
/DecodeParms <</Columns 7/Predictor 12>>    
/Length 64  # stream 的數據長度
/Root 1 0 R
/Info 3 0 R
/ID[<5E1FEDA5466E60C6D70D3004F5E43166><5E1FEDA5466E60C6D70D3004F5E43166>]   # ID爲數組,數組值爲兩個十六進制字符串,見到了<>(尖括號)內的的十六進制
>>

解析流

10 0 obj下的stream二進制數據,用zlib解壓,沒出來想要的結果。學習一下如何正確的解析流數據。

找到一串代碼,修改後:

import re
import zlib

pdf = open("hello.pdf", "rb").read()
stream = re.compile(b'.*?FlateDecode.*?stream(.*?)endstream', re.S)

for s in re.findall(stream,pdf):
    s = s.strip(b'\r\n')
    try:
        unzip_data = zlib.decompress(s)
        stream = unzip_data.decode('UTF-8')
        print(stream)
    except Exception as e:
        print(e)
二進制的流數據

上面的代碼部分,從文件讀取二進制數據,經過正則能夠拿到流二進制數據,去掉頭尾的\r\n,十六進制爲0D0A

pdf = open("some_doc.pdf", "rb").read()
stream = re.compile(b'.*?FlateDecode.*?stream(.*?)endstream', re.S)

for s in re.findall(stream,pdf):
    s = s.strip(b'\r\n')
    print(s)

匹配到三條流數據:(對應的對象ID分別是7 9 10

#(這裏輸出的是7 0 obj對象的流數據)
b'x\x9c+\xe4r\n\xe12P\x00\xc1\xa2t\x05\x08#\xc8\x1dH\x94+\xe8\xbbE\xb8\x19*\x18\x1a)\x84\xa4)\x18\x82% dH\xae\x82\xa1\xa5\x9e\xb1\x85\xa1\x82\x85\xa1\x91\x9e\xb1\x99\x99B\x88K\xb4\x86GjNN\xbe\x8eB\x80\x8b\x9b\x9efl\x88\x17\x97k\x08W \x17\x00\xad\x10\x14\x84'

#(這裏輸出的是9 0 obj對象的流數據)
b"x\x9cm\x8f\xcdJ\xc3P\x10\x85\x97\xbe\xc6\xec\x9a vn~L\xa3\x94@m-\x8a\x08\xc1\x16\\\x88\x8bk26\x17\xea\x8c\xdcL\xfcy\x93>\x9eO\xa2&\x16]\xb9;\x07\xce\xe1\x9c/\x06\x03\t$\x13\xc8 \xcas\x98Nq\xfd\xfeLX\xda\r\xb5x\xe5\xea\xf6\x0e\xd2>r\x03\xf78\x97\x8e\x15\xa2\xa2\xe8C\xd7R/\xacR\xb08\x8dMtb2\x93FI\x9a\x1d'\x87&\x1f\x193\n\xb1\xf4Rw\x15\xf9`)oN\xa1l,\xab<\xc1\x11\xec\xfdJ\x1e\xf5\xd5z\x82K\xae\xc6!\xae\x9dn)\xf8\xfc\xa2\x8f\xe6`\xb7\x0bq\xd6i#>\x10v\xc2!\xce=Y\xed\xd5?\x8bq\xfe\xbb\xf8s\xeb\xcc\xb6\xb4\x14V\xbc\xa0\xed\x0b\xa9\xab,\x9es%\xb5\xe3\r\xde:\x9eq\xeb\xfe\xfc\xaa{\xd0\x01u\xe0\x8d\xf6\xd4C\xb5(\xbe\x01\x00\xf9V\xa3"

#(這裏輸出的是10 0 obj對象的流數據)
b"x\x9ccb\x00\x81\xff\xff\x99\x18\x81\x94 ##\x98\xfe\xc1\xc0\xc0\x04\x16g`d\xfa\x0f$=\x19\xfe\x83\xe9u@q\x90\x04'\x90\x02\xf1\x9f0\xfc\x03q\x19\xe7B\xd4\xb3l\x80\xd0\x8c.\x0c\x0c\x00\x167\x0b\x9c"

若是沒有從文件二進制中搜索或者想手動看特定的二進制流,就能夠先複製十六進制數據以後轉二進制。直接複製到代碼中實際上是十六進制的字符串,轉二進制數據能夠用 binascii 模塊的 a2b_hex()

# 十六進制字符串轉二進制
stream=b'789C63620081FFFF9918819420232398FEC1C0C00416676064FA0F243D19FE83E97540719004279002F19F30FC037119E742D4B36C80D08C2E0C0C0016370B9C' # 10 0 obj中流的十六進制數據
s2=binascii.a2b_hex(stream)
print(s2)

獲得的二進制數據,和上面正則截取10 0 obj中的二進制流數據的是同樣的

b"x\x9ccb\x00\x81\xff\xff\x99\x18\x81\x94 ##\x98\xfe\xc1\xc0\xc0\x04\x16g`d\xfa\x0f$=\x19\xfe\x83\xe9u@q\x90\x04'\x90\x02\xf1\x9f0\xfc\x03q\x19\xe7B\xd4\xb3l\x80\xd0\x8c.\x0c\x0c\x00\x167\x0b\x9c"
解壓

直接用zlib模塊進行解壓:

unzip_data = zlib.decompress(s)
print(unzip_data)

解壓後的三個對象數據爲:

b'q\nBT\n0 0 0 rg 0 0 0 RG 0 w /FXF1 12 Tf 1 0 0 1 0 0 Tm 19.381 812.366 TD[(Hello, PDF.)]TJ\nET\nQ\n'
b"2 0 3 37 6 188 <</Type/Pages/Kids[ 4 0 R ]/Count 1>><</ModDate(D:20190604134653+08'00')/Producer(Foxit Phantom - Foxit Software Inc.)/Title(\xfe\xffe\xe0h\x07\x98\x98)/Author(onion)/CreationDate(D:20190604134628+08'00')>><</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>"
b'\x02\x00\x00\x00\x00\x00\xff\xff\x02\x01\x00\x00\x00\x11\x01\x01\x02\x01\x00\x00\x00\xf8\x00\x00\x02\x00\x00\x00\x00\x00\x00\x01\x02\xff\x00\x00\x00I\x00\xff\x02\xff\x00\x00\x00\xae\x00\x00\x02\x02\x00\x00\x00\t\x00\x02\x02\xff\x00\x00\x00\xe4\x00\xfe\x02\x00\x00\x00\x01\x9d\x00\x00\x02\x00\x00\x00\x04\xb0\x00\x00\x02\x00\x00\x00\x01D\x00\x00'

解壓很順利,再進行解碼字符串操做:然而三個對象反應各不同,各個分析。

對於7 0 obj數據,回顧下字典指定的參數 , 除了壓縮參數/Filter/FlateDecode和長度以外,再無其餘,直接解壓解碼正確,結果是:(格式就是這樣,具體含義後面再作分析)

q
BT
0 0 0 rg 0 0 0 RG 0 w /FXF1 12 Tf 1 0 0 1 0 0 Tm 19.381 812.366 TD[(Hello, PDF.)]TJ
ET
Q

對於9 0 obj數據,字典數據參數有/Type /ObjStm /N 3/First 15/Length 224/Filter /FlateDecode/TypeObjStm應該指的對象流數據,解碼的時候有報錯

'utf-8' codec can't decode byte 0xfe in position 140: invalid start byte

對應解壓數據查看,發現0xfe出如今Title(\xfe\xffe\xe0h\x07\x98\x98), 報錯緣由就在這:Title裏面的字符串不能用UTF-8編碼正確解析,不知道是什麼編碼。因爲對這個對象流數據不太瞭解,先暫時擱置,之後再來回顧。

對於10 0 obj數據,這個數據也不能正確解碼,是由於除了/filter 參數,還有個 /DecodeParms 參數,這個是指數據加了PNG壓縮算法的預處理(過濾)。接下來就來看看這個。

加過濾的流的解析

本小節圖片和算法部分參考:PDF 參照流/交叉引用流對象(cross-reference stream)的解析方法

交叉引用流對象一般在存儲以前都會進行壓縮,而爲了提升壓縮率,會進行數據的預處理,這個預處理就稱爲過濾(filter)。讀取時再進行反向的處理。

PNG規範中的過濾算法章節有說:

本章介紹可在壓縮以前應用的過濾器算法。這些濾波器的目的是準備圖像數據以實現最佳壓縮。

雖然PDF不是圖片,可是爲實現最佳壓縮,能夠先使用過濾算法。

1559725319805

圖6 帶有PNG壓縮算法的交叉引用流的壓縮和過濾

因此,直接用zlib的zip解壓算法解壓後,還須要PNG過濾反轉。

PNG過濾算法反轉

要反轉解壓縮後Up()過濾器的效果,請輸出如下值:

Up(x)+ Prior(x)

(計算的模256),其中Prior()指的是先前掃描線的解碼字節。

反轉方法封裝:

def show_hex_format(data, rows, columns):
    """ 展現十六進制格式 """
    for i in range(rows):
        for j in range(columns):
            print("%02x" % (data[i*columns+j]), end=" ")
        print()


def filter_up_reverse(stream_data, columns, colors=1, bitsPerComponent=8):
    """ PNG過濾器UP方法,反轉方法 """
    stream_data = bytearray(stream_data)
    xref_data = []

    data_len = len(stream_data)
    width = columns*colors*bitsPerComponent//8
    rows = data_len//(width+1)

    show_hex_format(stream_data, rows, width+1)

    cursor = 1

    # 第一行處理,跳過
    while cursor <= width:
        xref_data.append(stream_data[cursor])
        cursor += 1

    for i in range(1, rows):
        filter_type = stream_data[cursor]
        cursor += 1
        assert(filter_type == 2)

        for j in range(width):
            t = (stream_data[cursor]+stream_data[cursor-width-1]) % 256
            stream_data[cursor] = t
            xref_data.append(t)
            cursor += 1

    print("xref stream data:")
    show_hex_format(xref_data, rows, width)

對於10 0 obj對象,使用filter_up_reverse(stream_unzip_data, 7)調用,列寬爲7來自/DecodeParms <</Columns 7/Predictor 12>>Columns屬性,另外:Predictor屬性表明過濾器採用UP方法,取上行的原始數據。show_hex_format()方法是爲了方便查看十六進制以圖片的行列排列的格式。

1560135051210

圖7 反過濾 xref 數據

看圖7,第一個框中是解壓後的數據,第二個框中是反過濾的數據,即xref原始數據。

xref 交叉引用流數據分析

因爲不知道解析的xref數據格式對不對,還一直去轉,期待轉成7 0 obj的那種可視化的字符格式,最後在 PDF 文件格式 文檔中看到示例才反應過來,xref 流數據就是這樣的格式。

1560135563147

圖8 xref數據分析示例

1560137471619

圖9 交叉引用流 (xref strem) 的屬性

三個位域的字節劃分就來子屬性W,W[1 4 2], 分別是第一個字節,中間四個字節,後兩個字節。(1+4+2也對應到列寬爲7字節)

分析10 0 obj的數據:

00 00 00 00 00 ff ff    # xref 起始標識
01 00 00 00 11 00 00    # 直接對象,偏移量爲0x11,查看該地址,對應 1 0 obj (對象ID:1)的起始位置
02 00 00 00 09 00 00    # 間接對象,對象id爲9,索引0
02 00 00 00 09 00 01    # 間接對象,對象id爲9,索引1
01 00 00 00 52 00 00    # 直接對象,偏移量爲0x52,查看該地址,對應 4 0 obj 的起始位置
00 00 00 00 00 00 00
02 00 00 00 09 00 02    # 間接對象,對象id爲9,索引2
01 00 00 00 ed 00 00    # 直接對象,偏移量爲0xed,查看該地址,對應 7 0 obj 的起始位置
01 00 00 01 8a 00 00    # 直接對象,偏移量爲0x18a,查看該地址,對應 8 0 obj 的起始位置
01 00 00 05 3a 00 00    # 直接對象,偏移量爲0x53a,查看該地址,對應 9 0 obj 的起始位置
01 00 00 06 7e 00 00    # 直接對象,偏移量爲0x67e,查看該地址,對應 10 0 obj 的起始位置

objStm 對象流數據分析

9 0 obj
<</Type /ObjStm /N 3/First 15/Length 224/Filter /FlateDecode>>stream
2 0 3 37 6 188 <</Type/Pages/Kids[ 4 0 R ]/Count 1>><</ModDate(D:20190610144818+08'00')/Producer(Foxit Phantom - Foxit Software Inc.)/Title(\xfe\xffe\xe0h\x07\x98\x98)/Author(onion)/CreationDate(D:20190610144753+08'00')>><</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endstream
endobj

對於9 0 obj/Type/ObjStm,表示此對象爲對象流數據。N表示流中的間接對象數(本例有3個對象),First表示解碼流中第一個可壓縮對象的偏移量(本例中表明解壓數據後的15字節處爲第一個對象)。

流中開始的2 0 3 37 6 188 是鍵值對錶示的索引,鍵值對分別表示:對象ID和該對象在解碼流中的字節偏移量。(例如:3 37 表示 對象3的偏移量爲解壓後數據的37字節處)

示例輸出:(注:>>> 表示輸出的數據)

print(stream_unzip_data) # 解壓後的原始流數據
>>> b"2 0 3 37 6 188 <</Type/Pages/Kids[ 4 0 R ]/Count 1>><</ModDate(D:20190610144818+08'00')/Producer(Foxit Phantom - Foxit Software Inc.)/Title(\xfe\xffe\xe0h\x07\x98\x98)/Author(onion)/CreationDate(D:20190610144753+08'00')>><</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>"
stream_data_r = stream_unzip_data[15:] # 間接對象(所處的位置15)
print(stream_data_r)
>>> b"<</Type/Pages/Kids[ 4 0 R ]/Count 1>><</ModDate(D:20190610144818+08'00')/Producer(Foxit Phantom - Foxit Software Inc.)/Title(\xfe\xffe\xe0h\x07\x98\x98)/Author(onion)/CreationDate(D:20190610144753+08'00')>><</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>"

print("obj id:2 [0-36):", stream_data_r[:37]) # 對象ID:2,從0開始,到第二對象37以前即36
print("obj id:3 [37-187):",stream_data_r[37:188]) # 對象ID:3
print("obj id:6 [188-~):",stream_data_r[188:]) # 對象ID:6

>>> obj id:2 [0-36): b'<</Type/Pages/Kids[ 4 0 R ]/Count 1>>'
>>> obj id:3 [37-187): b"<</ModDate(D:20190610144818+08'00')/Producer(Foxit Phantom - Foxit Software Inc.)/Title(\xfe\xffe\xe0h\x07\x98\x98)/Author(onion)/CreationDate(D:20190610144753+08'00')>>"
>>> obj id:6 [188-~): b'<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>'

最後的三個對象也對應到了上節 xref 數據的 02 00 00 00 09 00 [00,01,02] # 間接對象,對象id爲9,索引0,1,2 。終於連起來了。

分析就到這裏了。

初次與PDF的相遇,到這裏就要說再見了。英語又鎖死了這條科技樹了。原本想要作一個簡單編輯的,發現展現還有很長的路走(圖像的繪製,字體加載)等等。未完待續

參考

  1. PDF 文檔結構

  2. PDF文件格式的一些研究心得

  3. PDF源文件淺析

  4. C# Parsing 類實現的 PDF 文件分析器

  5. John Whitington.PDF explained.O'Reilly Media (2011)

  6. Decompress FlateDecode Objects in PDF in Python

  7. PDF 參照流/交叉引用流對象(cross-reference stream)的解析方法

  8. PNG-Filters#Filter-type-2-Up

  9. 中華人民共和國國家標準文獻管理可移植文檔格式第1 部分

  10. PDF Reference, Sixth Edition, version 1.7

  11. PDF 文件格式, 洋文館

相關文章
相關標籤/搜索