從深度出來以後, 認識Tower的老沈和古靈很多年, 在Tower做了一段時間研發工作, 今天把Tower工作期間做的一個庫分享下: HTML文檔導出成Word文檔.
Pandoc
做爲資深的Linux開發者和Haskell愛好者, 第一時間想到的是 Pandoc 這個文檔轉換神器, 比如下面的命令就可以直接轉換Html字符串成Word文檔:
pandoc -f html -t docx -o export.doc input.html
pandoc 號稱文檔轉換利器, 速度非常快, 最開始我也很滿意, 但是最後發現幾個問題, 導致沒法真正當做產品解決方案:
- 轉出的文檔的很多佈局細節都有問題, 佈局完全無法和瀏覽器中看到的相比
- 樣式幾乎沒有, 比如字體顏色、大小、粗細等, 當然 pandoc 可以支持樣式自定義, 但是想一想要把每個 CSS 樣式改成 Pandoc 的格式, 一旦將來設計師改頁面細節, 又要手動調一次, 而且還不一定完全一樣, 想一想這種半自動方案, 頭皮都發麻
- 轉換出來的表格沒有邊框, 原因是 Pandoc 默認把 Table 樣式寫死了, 如果要解決表格邊框看不到的問題, 還需要修改 Haskell 源代碼重新編譯, 然後自己維護一個 pandoc 版本, 還要定期合併上游補丁, 對於我這種懶人來說, 想想就好麻煩
- 最大的問題是, 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, 簡單的理解就是:
- MHTML這種Word格式本質就是一個單個的文本文件: HTML字符串 + Word文檔模板字符串
- MHTML既然是HTML,同時也就很自然的支持CSS文件的, 這樣以後設計樣式改變了, 直接拷貝樣式就可以了.
- MHTML裏面的圖片以 base64 的形式存在, 也就是說, 我們可以自己寫代碼下載 img tag 的圖片, 然後轉換成 base64 字符串插入 MHTML 文檔中
MHTML 方案
既然知道了MHTML格式信息, 代碼方案就非常清晰了, 下面是僞代碼邏輯:
- 提取HTML文檔字符串
- 遍歷所有 img tag 標籤, 根據 img src 是在雲端, 牆外等各種信息, 先把圖片下載下來, 然後通過程序庫把下載下來的圖片文件轉成 base64 字符串插入MHTML
- 傳遞HTML CSS文件 (包括表格的樣式) 的內容插入MHTML
- 最後根據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 代碼那樣去使用:
- 首先安裝依賴庫: Nokogiri 和 FastImage
- Nokogiri是用於把HTML文檔中各種Tag屬性提取出來的庫
- FastImage 是用於抓取圖片的 head 信息來獲取服務器圖片的大小, 因爲只讀取圖片文件的 head 內容, 所以不管圖片文件本身有大多, 都能非常快速的讀取遠程圖片文件的大小信息
- 然後把下面代碼拷貝到你的項目中就可以使用了.
# 進度回調函數, 用於向前端推送轉換進度, 你可以把下面的 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轉換方案的優點:
- 轉換出來Word文檔中的佈局和你在瀏覽器看到的一模一樣
- 可以支持樣式自定義, 直接拷貝 css 文件內容即可, 維護方便
- 支持各種複雜圖片的內嵌和佈局
缺點只有一個:
- 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文檔的需求, 不要像我這樣折騰了.