Rails Everyday: HTML導出成Word文檔

從深度出來以後, 認識Tower的老沈和古靈很多年, 在Tower做了一段時間研發工作, 今天把Tower工作期間做的一個庫分享下: HTML文檔導出成Word文檔.

Pandoc

做爲資深的Linux開發者和Haskell愛好者, 第一時間想到的是 Pandoc 這個文檔轉換神器, 比如下面的命令就可以直接轉換Html字符串成Word文檔:

pandoc -f html -t docx -o export.doc input.html

pandoc 號稱文檔轉換利器, 速度非常快, 最開始我也很滿意, 但是最後發現幾個問題, 導致沒法真正當做產品解決方案:

  1. 轉出的文檔的很多佈局細節都有問題, 佈局完全無法和瀏覽器中看到的相比
  2. 樣式幾乎沒有, 比如字體顏色、大小、粗細等, 當然 pandoc 可以支持樣式自定義, 但是想一想要把每個 CSS 樣式改成 Pandoc 的格式, 一旦將來設計師改頁面細節, 又要手動調一次, 而且還不一定完全一樣, 想一想這種半自動方案, 頭皮都發麻
  3. 轉換出來的表格沒有邊框, 原因是 Pandoc 默認把 Table 樣式寫死了, 如果要解決表格邊框看不到的問題, 還需要修改 Haskell 源代碼重新編譯, 然後自己維護一個 pandoc 版本, 還要定期合併上游補丁, 對於我這種懶人來說, 想想就好麻煩
  4. 最大的問題是, html 中 img tag的下載問題, 比如有些是牆外的圖片(比如維基百科), 有些是雲主機的圖片, 必須要用 cookie 信息才能獲取用戶自己的隱私圖片, 而這些 pandoc 完全沒有支持, 會導致轉換出來的 Word 文檔沒有任何圖片

所以, Pandoc 只適合個人的文檔轉一轉, 方便自己整理Word文字素材還可以, 像商業化圖文混排的複雜佈局, 完全沒法用.

Word解析器

中間也想過寫一個專門HTML轉Word的解析器, 但是原來做操作系統的時候, 和WPS的朋友就聊過微軟Office那恐怖的隱晦標準和複雜的格式, 即使微軟開發Office的人也很難理解所有標準和實現方式.

所以這個方式因爲自己的開發經驗和無法預期的結果, 很快就作罷了.

從 CHM 想到的解決方案

原來玩 Windows XP 的時候, 有大量的 CHM 電子書 (那時候 PDF 還不流行), CHM本質就是一堆 HTML 文件和本地資源的打包文件.

而且當時知道 CHM 的內容是可以直接拷貝到微軟Office裏面, 同時保留佈局格式的, 所以我就問自己, 是不是微軟Office本身就可以支持 HTML 文件呢?

最後一番研究, 微軟Office是支持MHTML這種格式的Word文檔的, MHTML詳細格式信息可以自行Google, 簡單的理解就是:

  1. MHTML這種Word格式本質就是一個單個的文本文件: HTML字符串 + Word文檔模板字符串
  2. MHTML既然是HTML,同時也就很自然的支持CSS文件的, 這樣以後設計樣式改變了, 直接拷貝樣式就可以了.
  3. MHTML裏面的圖片以 base64 的形式存在, 也就是說, 我們可以自己寫代碼下載 img tag 的圖片, 然後轉換成 base64 字符串插入 MHTML 文檔中

MHTML 方案

既然知道了MHTML格式信息, 代碼方案就非常清晰了, 下面是僞代碼邏輯:

  1. 提取HTML文檔字符串
  2. 遍歷所有 img tag 標籤, 根據 img src 是在雲端, 牆外等各種信息, 先把圖片下載下來, 然後通過程序庫把下載下來的圖片文件轉成 base64 字符串插入MHTML
  3. 傳遞HTML CSS文件 (包括表格的樣式) 的內容插入MHTML
  4. 最後根據MHTML的Office模板字符串對上面所有信息進行拼裝, 並以 *.doc 的格式進行保存即可

根據上面的邏輯, 我寫了一個 Rails 的庫 html-to-word

下面是html-to-word這個庫的源碼註釋:

#coding: utf-8

# 一些需要引入的庫
require 'base64'
require 'cgi'
require 'digest/sha1'
require 'fastimage'

module HTMLToWord
  # 微軟Office的模板, 用於組裝出格式合法的 Word 文檔
  PAGE_VIEW_HTML_TEMPLATE = "xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:w=\"urn:schemas-microsoft-com:office:word\" xmlns:m=\"http://schemas.microsoft.com/office/2004/12/omml\" xmlns=\"http://www.w3.org/TR/REC-html40\""

  PAGE_VIEW_HEAD_TEMPLATE = "<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View><w:TrackMoves>false</w:TrackMoves><w:TrackFormatting/><w:ValidateAgainstSchemas/><w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid><w:IgnoreMixedContent>false</w:IgnoreMixedContent><w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText><w:DoNotPromoteQF/><w:LidThemeOther>EN-US</w:LidThemeOther><w:LidThemeAsian>ZH-CN</w:LidThemeAsian><w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript><w:Compatibility><w:BreakWrappedTables/><w:SnapToGridInCell/><w:WrapTextWithPunct/><w:UseAsianBreakRules/><w:DontGrowAutofit/><w:SplitPgBreakAndParaMark/><w:DontVertAlignCellWithSp/><w:DontBreakConstrainedForcedTables/><w:DontVertAlignInTxbx/><w:Word11KerningPairs/><w:CachedColBalance/><w:UseFELayout/></w:Compatibility><w:BrowserLevel>MicrosoftInternetExplorer4</w:BrowserLevel><m:mathPr><m:mathFont m:val=\"Cambria Math\"/><m:brkBin m:val=\"before\"/><m:brkBinSub m:val=\"--\"/><m:smallFrac m:val=\"off\"/><m:dispDef/><m:lMargin m:val=\"0\"/> <m:rMargin m:val=\"0\"/><m:defJc m:val=\"centerGroup\"/><m:wrapIndent m:val=\"1440\"/><m:intLim m:val=\"subSup\"/><m:naryLim m:val=\"undOvr\"/></m:mathPr></w:WordDocument></xml><![endif]-->\n"

  def self.convert(html,
                   document_guid,
                   max_image_width,
                   css_files,
                   image_filter,
                   percent_start_number,
                   percent_end_number,
                   percent_update,
                   development_proxy_address,
                   development_proxy_port,
                   production_proxy_address,
                   production_proxy_port)
    # Make sure all images' size small than max size.
    no_size_attr_hash = Hash.new

    # 遍歷 img tag 提取所有圖片的大小信息, 以方便後續進行圖片下載和縮放操作.
    doc = Nokogiri::HTML(html)
    doc.css("img").each do |img|
      if (img.keys.include? "width") && (img.keys.include? "height")
        img_width = img["width"].to_i
        img_height = img["height"].to_i

        # Image won't show in Word file if image's width or height equal 0.
        if (img_width != 0) && (img_height != 0)
          render_width = [img_width, max_image_width].min
          render_height = render_width * 1.0 / img_width * img_height

          img["width"] = render_width
          img["height"] = render_height
        end
      else
        no_size_attr_hash[img["src"]] = nil
      end
    end

    # Unescape html first to avoid base64's link not same as image tag's link.
    html = CGI.unescapeHTML(doc.to_html)

    # 進度顯示的初始化操作
    download_image_count = doc.css("img").length
    if download_image_count > 0
      percent_update.call(percent_start_number)
    end

    # 下面一大段都是在抓取圖片的 base64 字符串, 因爲現實場景很複雜, 做了想大多的容錯處理
    # Fetch image's base64.
    base64_cache = Hash.new
    filter_image_replace_hash = Hash.new
    mhtml_bottom = "\n"
    download_image_index = 0
    Nokogiri::HTML(html).css('img').each do |img|
      if img.keys.include? "src"
        # Init.
        image_src = img.attr("src")
        begin
          uri = URI(image_src)
          proxy_addr = nil
          proxy_port = nil

          # Use image_filter to convert internal images to real image uri.
          real_image_src = image_filter.call(image_src)
          base64_image_src = image_src
          if real_image_src != image_src
            uri = URI(real_image_src)

            # We need use convert image to hash string make sure all internal image visible in Word file.
            uri_hash = Digest::SHA1.hexdigest(image_src)
            placeholder_uri = "https://placeholder/#{uri_hash}"
            filter_image_replace_hash[image_src] = placeholder_uri
            base64_image_src = placeholder_uri
          else
            # Use proxy when image is not store inside of webside.
            # Of course, you don't need proxy if your code not running in China.
            if %w(test development).include?(Rails.env.to_s)
              proxy_addr = development_proxy_address
              proxy_port = development_proxy_port
            else
              proxy_addr = production_proxy_address
              proxy_port = production_proxy_port
            end
          end

          # Fetch image's base64.
          image_base64 = ""

          if base64_cache.include? image_src
            # Read from cache if image has fetched.
            image_base64 = base64_cache[image_src]
          else
            # Get image response.
            # 
            # URI is invalid if method request_uri not exists.
            if uri.respond_to? :request_uri
              response = Net::HTTP.start(uri.hostname, uri.port, proxy_addr, proxy_port, use_ssl: uri.scheme == "https") do |http|
                http.request(Net::HTTP::Get.new(uri.request_uri))
              end

              if response.is_a? Net::HTTPSuccess
                image_base64 = Base64.encode64(response.body)

                base64_cache[image_src] = image_base64
              end
            end
          end

          # Fetch image size if img tag haven't any size attributes.
          if (no_size_attr_hash.include? image_src) && (no_size_attr_hash[image_src] == nil)
            proxy_for_fast_image = nil
            if proxy_addr && proxy_port
              proxy_for_fast_image = "http://#{proxy_addr}:#{proxy_port}"
            end

            # NOTE:
            # This value maybe nil if remote image unreachable.
            no_size_attr_hash[image_src] = FastImage.size(real_image_src, { proxy: proxy_for_fast_image })
          end

          # 如果抓到圖片的 base64 字符串就按照下面的方式插入 base64 字符串到 MHTML 文檔中
          # Build image base64 template.
          if image_base64 != ""
            mhtml_bottom += "--NEXT.ITEM-BOUNDARY\n"
            mhtml_bottom += "Content-Location: #{base64_image_src}\n"
            mhtml_bottom += "Content-Type: image/png\n"
            mhtml_bottom += "Content-Transfer-Encoding: base64\n\n"
            mhtml_bottom += "#{image_base64}\n\n"
          else
            print("Can't fetch image base64: " + base64_image_src + "\n")
          end
        rescue URI::InvalidURIError, URI::InvalidComponentError
          Rails.logger.info "[FILE] Document #{document_guid} contain invalid url, pass it. error: #{e}; backtraces:\n #{e.backtrace.join("\n")}"
        end

        # Update download index to calcuate percent.
        download_image_index += 1
        percent_update.call(percent_start_number + (percent_end_number - percent_start_number) * (download_image_index * 1.0 / download_image_count))
      end
    end
    mhtml_bottom += "--NEXT.ITEM-BOUNDARY--"

    # 很多 img tag 沒有 width/height 屬性, 需要添加大小屬性, 避免賊大的圖片在Word裏沒法正常顯示
    # Adjust image size of img tag that haven't size attributes.
    doc = Nokogiri::HTML(html)
    doc.css("img").each do |img|
      if img.keys.include? "src"
        # no_size_attr_hash[img["src"]] will got nil if remote image unreachable.
        # So give up scale image size here because the image won't show up in Word.
        if no_size_attr_hash[img["src"]].present?
          size = no_size_attr_hash[img["src"]]

          render_width = [size.first, max_image_width].min
          render_height = render_width * 1.0 / size.first * size.second

          img["width"] = render_width
          img["height"] = render_height
        end
      end
    end
    html = CGI.unescapeHTML(doc.to_html)

    # Replace image hash.
    filter_image_replace_hash.each do |key, value|
      html = html.gsub key, value
    end

    # 這裏插入CSS文件的內容
    # Pick up style content from stylesheet file.
    stylesheet = ""
    css_files.each do |scss_file|
      if %w(test development).include?(Rails.env.to_s)
        stylesheet += Rails.application.assets.find_asset(scss_file).source
      else
        stylesheet += File.read(File.join(Rails.root, "public", ActionController::Base.helpers.asset_url(scss_file.ext("css"))))
      end
    end

    if download_image_count > 0
      percent_update.call(percent_end_number)
    end

    # 最後的總體拼裝
    # Return word content.
    head = "<head>\n #{PAGE_VIEW_HEAD_TEMPLATE} <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n"
    head += "<style>\n #{stylesheet} _\n</style>\n</head>\n"
    body = "<body> #{html} </body>"

    mhtml_top = "Mime-Version: 1.0\nContent-Base: #{document_guid} \n"
    mhtml_top += "Content-Type: Multipart/related; boundary=\"NEXT.ITEM-BOUNDARY\";type=\"text/html\"\n\n--NEXT.ITEM-BOUNDARY\n"
    mhtml_top += "Content-Type: text/html; charset=\"utf-8\"\nContent-Location: #{document_guid} \n\n"
    mhtml_top += "<!DOCTYPE html>\n<html #{PAGE_VIEW_HTML_TEMPLATE}  >\n #{head} #{body} </html>"

    mhtml_top + mhtml_bottom
  end
end

其實邏輯非常簡單, 但是因爲現實場景下, 很多html文檔非常不標準, 加上很多 img tag 的字符串有各種各樣的問題 (比如沒有大小屬性, http字符串是錯的, 等等), 所以上面代碼做了非常多的容錯處理.

如果你是經驗豐富的Rails程序員, 應該很容易看懂上面的 Ruby 代碼.
當然, 你也可以直接像下面的 demo 代碼那樣去使用:

  1. 首先安裝依賴庫: NokogiriFastImage
  2. Nokogiri是用於把HTML文檔中各種Tag屬性提取出來的庫
  3. FastImage 是用於抓取圖片的 head 信息來獲取服務器圖片的大小, 因爲只讀取圖片文件的 head 內容, 所以不管圖片文件本身有大多, 都能非常快速的讀取遠程圖片文件的大小信息
  4. 然後把下面代碼拷貝到你的項目中就可以使用了.
# 進度回調函數, 用於向前端推送轉換進度, 你可以把下面的 print 函數改成 WebSocket 相關代碼實現, 以此來實現向前端推送進度的功能
updater = ->(percent) { print percent }

# 圖片轉換回調函數, 比如阿里雲的 OSS 需要根據 key 才能得到真實的圖片地址, 如果HTML只包含外網圖片, 這個回調函數可以不用修改
filter = ->(uri) { uri }

# 導出成Word文檔
File.open("export.doc", "w") do |f|
  f.write(
  ::HTMLToWord.convert(
    html_string,              # 需要轉換的 HTML 字符串
    document_guid,            # 任意字符串, 只要能在導出Word文檔中得知是什麼文件, 方便調試即可
    420,                      # Word文檔中圖片最大的寬度, 我調試的 420 像素的寬度就很好
    ["html.scss"],            # Rails中 scss 文件的名稱, html-to-word 庫會自動查找 scss 文件的路徑並提取出樣式信息, 建議把需要轉換的樣式(包括表格樣式)單獨寫一個文件用於轉換用
    filter,                   # 圖片連接過濾函數, 主要用於雲端圖片地址轉換
    5,                        # 起始進度, 比如一開始就顯示 5%
    80,                       # 結束進度, 我選擇 80%, 這樣給下載文件留一點進度, 用戶體驗比較真實
    updater,                  # 進度回調函數
    "127.0.0.1", 1080,        # 本地http代理配置, 保證可以本地抓取牆外圖片
    "192.168.xxx.xxx", 8080   # 服務器生產環境的 http 代理配置
  )
end

上面的代碼, 就可以在你的 Rails 項目中快速測試 html-to-word 的效果, 但是你需要自己很多代碼去融入到你自己的 Rail 項目, 比如:

  • 編寫 Sidekiq 代碼用於隊列處理, 避免長時間轉換卡住 http 請求
  • 完善 WebSocket 代碼向前端推送進度
  • 寫代碼 push doc 文檔到瀏覽器, 注意這個步驟, 要保證文件擴展名爲 *.doc 而不是 *.docx , 同時文件的 MIME type 應該是 application/msword;charset=utf-8, 否則轉換出來的 Word 文檔會報格式錯誤的問題

MHTML轉換方案的優點:

  1. 轉換出來Word文檔中的佈局和你在瀏覽器看到的一模一樣
  2. 可以支持樣式自定義, 直接拷貝 css 文件內容即可, 維護方便
  3. 支持各種複雜圖片的內嵌和佈局

缺點只有一個:

  1. MHTML雖然是RFC標準, 但是目前只有微軟Office和WPS實現了, 其他Office軟件像蘋果的 Pages 等就不能打開MHTML格式的Word文檔

其他坑

如果你看懂我上面 HTML 轉 Word 的原理, 很容易改寫成其他編程語言的, 比如 Python, PHP啊, 因爲本質上就是下載圖片文件的base64, 再結合Office模板, css和html字符串進行拼裝.

但是有一點, 不要用JS在瀏覽器拼裝, 我在實現 html-to-word 這個庫的前面一個版本就是利用 new Canvas 的方法, 先把 img src 賦值給 Canvas, 然後根據Canvas的內容在瀏覽器端轉成 base64 來做的, 這樣有一個最大問題是:

瀏覽器會因爲本地安全策略的限制, 限制直接從Canvas提取base64的操作, 瀏覽器本身會報CROS的跨域問題. 當然你可以從服務器配置, 雲廠商配置, Rails配置一直配置到JS庫, 但是真的真的好麻煩, 配置一堆東西的複雜度都超過轉換庫本身的複雜度了, 而且這種配置都是分散在服務器各個地方, 維護和調試都非常脆弱.

所以還是建議HTML轉換Word的工作用服務器後端來做, 邏輯簡單清晰, 也容易維護.

最後

上面就是HTML轉Word文檔的各種折騰歷程和坑經驗分享, 希望可以幫到你完成你項目中轉換Word文檔的需求, 不要像我這樣折騰了.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章