iOS文字匯出為Docx檔案

語言: CN / TW / HK

近期的需求中有一項任務是將使用者輸入的文字和圖片寫入Word檔案並支援匯出,對於蘋果和微軟的愛恨情仇很早就知道,iOS文字寫入Word難度可想而知,所以在接到這個需求的第一時間,我就明確要求這個需求要先調研,然後再開始。所以這篇文章也算是對我調研結果的一個總結。

技術方案

之前知識做過將文字寫到txt檔案中,因為txt檔案是純文字且不包含文字格式,所以非常簡單因此我最先想到的就是嘗試直接將文字寫到Word檔案中,如果這個方案不行,那就只能通過其他方式轉了,例如html。經過一番谷歌搜尋,基本確定了下面幾個方向

  • 文字直接寫入Word檔案
  • 將文字寫入html模板中 在寫入Word檔案
  • 其他庫實現

下面我們根據上面的幾個方向一次來看這幾種方式的實現

方案驗證

文字直接寫入Excel

方法很簡單,我們直接看程式碼

private func writeToWordFile() {
    // 首先嚐試直接文件
    let text = "下面我們直接將這段文字寫入到Word文件中,然後通過手機端和Mac端檢視是否可以開啟這個docx檔案"
    let path = NSHomeDirectory().appending("/Documents")
    let filePath = path.appending("/1.docx")
    try? text.write(toFile: filePath, atomically: true, encoding: .utf8)
}

通過沙盒路徑我們找到了我們新寫的這個檔案

當我們使用Mac的office元件開啟時提示

因此這種方式應該是不行的。

但是 ,我這裡是直接將文字寫成docx檔案,那如果我在專案裡放一個模型,然後往模型檔案裡寫呢?

我先找一個空的Word檔案,將其放到專案中,然後將這個檔案拷貝到沙盒中然後再寫入內容到這個檔案中

private func writeToWord() {
    // 現將示例檔案拷貝到沙盒位置 有問題 無法開啟對應檔案
    let text = "下面我們直接將這段文字寫入到Word文件中,然後通過手機端和Mac端檢視是否可以開啟這個docx檔案"
    let examplePath = Bundle.main.path(forResource: "example.docx", ofType: nil)
    let destinationPath = NSHomeDirectory().appending("/Documents").appending("/2.docx")
    try? FileManager.default.copyItem(atPath: examplePath!, toPath: destinationPath)
    let data = text.data(using: .utf8)
    try? data?.write(to: URL(fileURLWithPath: destinationPath), options: .atomic)
}

我們發現實際結果與前面的方式是相同的。我們都無法開啟對應檔案,而且這裡 writetofile 應該是重新生成的檔案,因為模板檔案大小為 12KB ,但是寫操作完成時檔案變成了 173位元組

沒關係,我們還有另外一種方式就是通過資料流的形式寫入到已存在的檔案中,這裡要用到的是 FileHandle :

private func fileHandlerWrite() {
    let text = "若為購買過其它非intro offer(連續月、單年、單月)後降級的使用者,\n則兩次彈窗均給出連續包年intro offer(和現有收銀臺一致)的sku"
    let examplePath = Bundle.main.path(forResource: "example.docx", ofType: nil)
    let destinationPath = NSHomeDirectory().appending("/Documents").appending("/3.docx")
    try? FileManager.default.copyItem(atPath: examplePath!, toPath: destinationPath)
    let fileHandle = FileHandle(forWritingAtPath: destinationPath)!
    fileHandle.seekToEndOfFile()
    fileHandle.write(text.data(using: .utf8)!)
    try? fileHandle.close()
}

但是結果一樣,仍然無法開啟檔案,因此這了可以認為此方法行不通:broken_heart: 。如果大家有更好的方式也可以評論指出。

不過,當我嘗試將檔案字尾改為doc時,我發現開啟檔案時會提示

當我選擇其他編碼,並選擇有邊框中的 UTF-8 時,我是可以開啟檔案的。但是目前絕大多數都是使用docx,因此這裡也不深入的去討論doc和docx的區別了。

HTML

既然直接寫入檔案的方式不行,那麼我們必須藉助其他手段來實現我們的目的,首先想到的是html,同時我們在網上也搜到了部分方法

我們先來看下效果再去分析實現,

private func writeHtmlFile() {
     let text = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'> 既然直接寫入檔案的方式不行,那麼我們必須藉助其他手段來實現我們的目的,首先想到的是html</html>"
     let path = NSHomeDirectory().appending("/Documents")
     let filePath = path.appending("/1.doc")
     try? text.write(toFile: filePath, atomically: true, encoding: .utf8)
 }

上面程式碼中html格式為

<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
    // 檔案內容
</html>

我們通過將上面這段包含html標籤和格式的文字寫入到一個 doc檔案 中,就可以生成一個Word文件,我們開啟這個docx文件看下

通過上面的方法,我們驗證了可以通過html的方式去寫Word檔案的思路,既然文字都可以寫那麼圖片呢, 我們知道在寫html的時候我們嵌入圖片一般都是通過圖片路徑的方式嵌入到html檔案中,但是我們如果是通過改字尾的方式生成Word檔案,這就要求我們必須只有一個檔案,因此我這裡嘗試使用直接嵌入圖片的base64資料實現

<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
    <h1>Title level 1</h1>
    <div>
        <img src="data:image/jpeg;base64,xxxxx">
    </div>
</html>

本地圖片生成base64 傳送門

我們在開啟我們生成的 doc 檔案,可以看到圖片已經被展示到正確的位置了

格局開啟 ,既然我們都用了html 那麼是否html中的其他標籤我們都可以使用呢?下面我來來搞一個複雜的例子試試

<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
    <h1>Title level 1</h1>
    <h1>Title level 1</h1>
<h2>Title level 2</h2>
<h3>Title level 3</h3>
<p>Text in level 3</p>
<h2>2nd title level 2</h2>
<h3>Another level 3 title</h3>
 
List:
<ul>
<li>element 1</li>
<li>element 2</li>
<li>element 3</li>
  <ul>
  <li>element 4</li>
  <li>element 5</li>
  <li>element 6</li>
      <ul>
      <li>element 7</li>
      <li>element 8</li>
      </ul>
  </ul>
<li>element 9</li>
<li>element 10</li>
</ul>
 
<table width="100%",border="1">
<thead style="background-color:#A0A0FF;">
    <td nowrap>Column A</td><td nowrap>Column B</td><td nowrap>Column C</td>
</thead>
<tr><td>A1</td><td>B1</td><td>C1</td></tr>
<tr><td>A2</td><td>B2</td><td>C2</td></tr>
<tr><td>A3</td><td>B3</td><td>C3</td></tr>
</table>
    <div>
        <img src="data:image/jpeg;xxx">
    </div>
</html>

這時候我們在開啟對應Word檔案 可以發現,html的這些標籤都可以支援

那麼我們是找到了完美的方案了嗎? 不不不 ,如果你仔細看上面的內容你會發現,上面html儲存的時候我都儲存成了doc檔案,而對於最新的docx型別呢?

:sob: :sob: :sob: :sob:

別放棄,我們繼續看其他方法

直接編輯Word內容

這裡的實現主要是參考了 stackoverflow中的這個 問題 ,回答問題的大佬給出了這段解釋,Word檔案包含了複雜的檔案格式,具體可以通過將一個Word文件修改後綴為zip,然後解壓檢視

Unfortunately, it is nearly impossible to create a .docx file in Swift, given how complicated they are (you can see for yourself by changing the file extension on any old .docx file to .zip, which will reveal their inner structure). The next best thing is to simply create a .txt file, which can also be opened into Pages (though sadly not Docs). If you’re looking for a more polished format, complete with formatting and possibly even images, you could choose to create a .pdf file.

我們隨便將一個docx,修改後綴後,解壓可以看到下面的檔案結構:

通過查詢資料夾中檔案的內容我們發現,我們實際寫入的文字內容在 word/document.xml 檔案中,如下圖

那我們只要能夠將我們想寫入的內容新增到這個檔案中就可以完美實現了,廢話不多說直接試一下

我們先新建一個docx文件(包含圖片) 如下圖

我們開啟 word/document.xml 發現文字實際已經直接寫在了檔案中

<w:p w:rsidR="00EB53D0" w:rsidRDefault="00D6373D">
  <w:r>
    <w:rPr>
      <w:rFonts w:hint="eastAsia"/>
    </w:rPr>
    <w:t>1</w:t>
  </w:r>
  <w:r>
    <w:t>234567</w:t>
  </w:r>
</w:p>
<w:p w:rsidR="00D6373D" w:rsidRDefault="00D6373D">
  <w:pPr>
    <w:rPr>
      <w:b/>
      <w:sz w:val="32"/>
      <w:szCs w:val="32"/>
    </w:rPr>
  </w:pPr>
  <w:r w:rsidRPr="00D6373D">
    <w:rPr>
      <w:rFonts w:hint="eastAsia"/>
      <w:b/>
      <w:sz w:val="32"/>
      <w:szCs w:val="32"/>
    </w:rPr>
    <w:t>啊啊啊啊沒有了對吧</w:t>
  </w:r>
</w:p>

那麼如果我們要寫入文字時就要按照這種格式寫入,不過相對於使用Word軟體直接生成的,咱們自己寫可以相對簡單寫,比如對文字Font和等都沒有要求。

接著我們在來看下圖片是如何儲存的呢?我們在來看下xml檔案中對應內容

<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
                  <pic:nvPicPr>
                    <pic:cNvPr id="1" name="test.jpg"/>
                    <pic:cNvPicPr/>
                  </pic:nvPicPr>
                  <pic:blipFill>
                    <a:blip r:embed="rId4">
                      <a:extLst>
                        <a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}">
                          <a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"></a14:useLocalDpi>
                        </a:ext>
                      </a:extLst>
                    </a:blip>
                    <a:stretch>
                      <a:fillRect/>
                    </a:stretch>
                  </pic:blipFill>
                  <pic:spPr>
                    <a:xfrm>
                      <a:off x="0" y="0"/>
                      <a:ext cx="5080000" cy="3175000"/>
                    </a:xfrm>
                    <a:prstGeom prst="rect">
                      <a:avLst/>
                    </a:prstGeom>
                  </pic:spPr>
                </pic:pic>

我們發現xml檔案中有踢掉一個識別符號 <a:blip r:embed="rId4"> , 然後我們需要知道 rId4 表示的是哪一個資源,我們開啟 document.xml.rels 檔案

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
    <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" Target="webSettings.xml"/>
    <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
    <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
    <Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
    <Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/>
    <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.jpg"/>
</Relationships>

可以看到 rId4 表示的是 "media/image1.jpg" ,然後我們到media資料夾下,果然發現了image1.jpg這張圖片,對應的恰好使我們新增到Word檔案中的那張圖片,這樣我們圖片的新增方式也找到了。

如果你對於docx中的xml檔案標籤不熟悉,請參考 Word-docx檔案圖片資訊格式分析

如何編輯Word

根據第一步的講解,我們匯出一個docx檔案,那麼我們應該有下面幾步:

空Docx檔案資源

這一步較為簡單,實際上我們新建一個空的檔案並進行解壓就可以得到,注意這些檔案要放到bundle中,生成檔案時先拷貝到沙盒,在修改沙盒中的檔案。

編輯 word/document.xml 檔案

這一步應該是最難的,在我們搜尋時發現了已有的庫 DocX ,唯一的缺點就是目前只支援Swift Package,鑑於我們專案中是直接使用的Cocoapods,因此,我這裡直接將用到的三個庫,封裝為一個pod,大家可以直接使用。

壓縮檔案為 zip

壓縮檔案,我們也不多說,這裡直接用的三方 ZipFoundation

修改檔案字尾

這一步也很簡單這裡不做贅述

我們來簡單看下上面四個步驟的程式碼:

private func writeToDocx() {
        var attributeString = NSMutableAttributedString(string: "1.在QQ上或者微信上搜索關鍵詞“班級群”,一些群無需驗證或群管理不到位,騙子就能輕易混進群中。進群后,他們往往潛伏在群裡,觀察一段時間。 2.騙子趁學生玩手機遊戲時,以“免費贈送遊戲面板、驗證身份”為由,要求對方傳送班級微信群日常聊天截圖和微信群聊二維碼,藉此混入班級微信群。3.學生、家長和老師的QQ、微信等社交賬號被盜,個人資訊洩露。進入班級微信群后,騙子還會拉入同夥,克隆班主任的頭像和暱稱,冒充老師在群裡傳送有關學校收取書本費、資料費、報名費等資訊,同夥則在群裡傳送繳費截圖,家長見老師釋出通知往往不會核實真假,向騙子提供的二維碼轉賬匯款或者在群裡傳送繳費紅包。收取的費用從幾十到幾百元不等,不易引起家長懷疑。\n")
        let attachment = NSTextAttachment()
        attachment.image = UIImage(named: "1")!
        attachment.bounds = CGRect(origin: .zero, size: CGSize(width: 300, height: 300))
        let attributeImageText = NSAttributedString(attachment: attachment)
        attributeString.append(attributeImageText)
        self.textView.attributedText=attributeString
        self.textView.backgroundColor = .white
        
        let temp=FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("docx")
        try? DocXWriter.write(pages: [attributeString, attributeString], to: temp)
  }

  public class func write(pages:[NSAttributedString], to url:URL, options:DocXOptions = DocXOptions()) throws{
        guard let first=pages.first else {return}
        let result=NSMutableAttributedString(attributedString: first)
        let pageSeperator=NSAttributedString(string: "\r", attributes: [.breakType:BreakType.page])
        
        for page in pages.dropFirst(){
            result.append(pageSeperator)
            result.append(page)
        }
        
        try result.writeDocX(to: url, options: options)
  }


func writeDocX_builtin(to url: URL, options:DocXOptions = DocXOptions()) throws{
        let tempURL=try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: url, create: true)
        
        defer{
            try? FileManager.default.removeItem(at: tempURL)
        }
        
        let docURL=tempURL.appendingPathComponent(UUID().uuidString, isDirectory: true)
        guard let blankURL=Bundle.blankDocumentURL else{throw DocXSavingErrors.noBlankDocument}
        try FileManager.default.copyItem(at: blankURL, to: docURL)

        let docPath=docURL.appendingPathComponent("word").appendingPathComponent("document").appendingPathExtension("xml")
        
        let linkURL=docURL.appendingPathComponent("word").appendingPathComponent("_rels").appendingPathComponent("document.xml.rels")
        let mediaURL=docURL.appendingPathComponent("word").appendingPathComponent("media", isDirectory: true)
        let propsURL=docURL.appendingPathComponent("docProps").appendingPathComponent("core").appendingPathExtension("xml")
        
        
        let linkData=try Data(contentsOf: linkURL)
        var docOptions=AEXMLOptions()
        docOptions.parserSettings.shouldTrimWhitespace=false
        docOptions.documentHeader.standalone="yes"
        let linkDocument=try AEXMLDocument(xml: linkData, options: docOptions)
        let linkRelations=self.prepareLinks(linkXML: linkDocument, mediaURL: mediaURL)
        let updatedLinks=linkDocument.xmlCompact
        try updatedLinks.write(to: linkURL, atomically: true, encoding: .utf8)
        
        let xmlData = try self.docXDocument(linkRelations: linkRelations)
        
        try xmlData.write(to: docPath, atomically: true, encoding: .utf8)
        
        let metaData=options.xml.xmlCompact
        try metaData.write(to: propsURL, atomically: true, encoding: .utf8)

        let zipURL=tempURL.appendingPathComponent(UUID().uuidString).appendingPathExtension("zip")
        try FileManager.default.zipItem(at: docURL, to: zipURL, shouldKeepParent: false, compressionMethod: .deflate, progress: nil)

        try FileManager.default.copyItem(at: zipURL, to: url)
    }

至此我們就完成了docx的寫入!,如果想更詳細的瞭解寫入的過程,大家可以仔細看下 Word檔案結構 的文章和 docx 這個庫,相信你們可以做的更好。

總結

對於上面的幾種方法我們做一個利弊總結:

方法 優點 缺點 建議
直接寫入 簡單,純文字寫入doc可行 不支援圖片,不支援docx格式 不建議使用,因為生成的檔案打不開
html 簡單快捷,支援html的格式,樣式較多 不支援docx格式 可接受不支援docx的話 推薦使用
修改內部結構 完美支援docx格式,使用封裝庫可直接將富文字轉換為word文件 如果要增加樣式支援 門檻較高需要了解Word檔案格式 沒有硬傷,但是後續擴充套件成本較高

根據你的需求,選擇一個合適你的方案吧!