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文件格式 沒有硬傷,但是後續擴展成本較高

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