能轉(zhuǎn)換:R圖和統(tǒng)計(jì)表轉(zhuǎn)成發(fā)表級(jí)的Word、PPT、Excel、HTML、Latex、矢量圖等
R包export可以輕松的將R繪制的圖和統(tǒng)計(jì)表輸出到 Microsoft Office (Word、PowerPoint和Excel)、HTML和Latex中,其質(zhì)量可以直接用于發(fā)表。
export包可以在Windows、Ubuntu和Mac上跨平臺(tái)運(yùn)行。不過有些Mac發(fā)行版默認(rèn)情況下沒有安裝cairo設(shè)備,需要自行安裝。如果Mac用戶已安裝XQuartz,這個(gè)問題就解決了,它可以從https://www.xquartz.org/免費(fèi)獲得。
install.packages("export")
install.packages("officer")
install.packages("rvg")
install.packages("openxlsx")
install.packages("ggplot2")
install.packages("flextable")
install.packages("xtable")
install.packages("rgl")
install.packages("stargazer")
install.packages("tikzDevice")
install.packages("xml2")
install.packages("broom")
install.packages("devtools")
devtools::install_github("tomwenseleers/export")
該包主要包括以下幾種轉(zhuǎn)換
使用幫助信息如下:
graph2bitmap(x = NULL, file = "Rplot", fun = NULL, type = c("PNG","JPG", "TIF"),
aspectr = NULL, width = NULL, height = NULL, dpi = 300,scaling = 100,
font =ifelse(Sys.info()["sysname"] == "Windows", "Arial",
"Helvetica")[[1]], bg = "white", cairo = TRUE,
tiffcompression = c("lzw", "rle", "jpeg", "zip", "lzw+p", "zip+p"),
jpegquality = 99, ...)
安裝完 export包后,先調(diào)用該包
library(export)
library(ggplot2)
library(datasets)
x=qplot(Sepal.Length, Petal.Length, data = iris,
color = Species, size = Petal.Width, alpha = I(0.7))
qplot()的意思是快速作圖,利用它可以很方便的創(chuàng)建各種復(fù)雜的圖形,其他系統(tǒng)需要好幾行代碼才能解決的問題,用qplot只需要一行就能完成。
使用半透明的顏色可以有效減少圖形元素重疊的現(xiàn)象,要?jiǎng)?chuàng)建半透明的顏色,可以使用alpha圖形屬性,其值從0(完全透明)到1(完全不透明)。更多ggplot2繪圖見ggplot2高效實(shí)用指南 (可視化腳本、工具、套路、配色) (往期教程更有很多生物信息相關(guān)的例子)。
鳶尾花(iris)是數(shù)據(jù)挖掘常用到的一個(gè)數(shù)據(jù)集,包含150個(gè)鳶尾花的信息,每50個(gè)取自三個(gè)鳶尾花種之一(setosa,versicolour或virginica)。每個(gè)花的特征用下面的5種屬性描述萼片長度(Sepal.Length)、萼片寬度(Sepal.Width)、花瓣長度(Petal.Length)、花瓣寬度(Petal.Width)、類(Species)。
在console里展示數(shù)據(jù)圖 (長寬比自己調(diào)節(jié)):
# 需運(yùn)行上面的ggplot2繪圖
# Create a file name
# 程序會(huì)自動(dòng)加后綴
filen <- "output_filename" # or
# filen <- paste("YOUR_DIR/ggplot")
# There are 3 ways to use graph2bitmap():
### 1. Pass the plot as an object
graph2png(x=x, file=filen, dpi=400, height = 5, aspectr=4)
graph2tif(x=x, file=filen, dpi=400, height = 5, aspectr=4)
graph2jpg(x=x, file=filen, dpi=400, height = 5, aspectr=4)
### 2. Get the plot from current screen device
# 注意這個(gè)x,是運(yùn)行命令,展示圖像
x
graph2png(file=filen, dpi=400, height = 5, aspectr=4)
graph2tif(file=filen, dpi=400, height = 5, aspectr=4)
graph2jpg(file=filen, dpi=400, height = 5, aspectr=4)
### 3. Pass the plot as a functio
plot.fun <- function(){
print(qplot(Sepal.Length, Petal.Length, data = iris,
color = Species, size = Petal.Width, alpha = 0.7))
}
graph2png(file=filen, fun=plot.fun, dpi=400, height = 5, aspectr=4)
graph2tif(file=filen, fun=plot.fun, dpi=400, height = 5, aspectr=4)
graph2jpg(file=filen, fun=plot.fun, dpi=400, height = 5, aspectr=4)
轉(zhuǎn)換后的圖形:
大部分圖的細(xì)節(jié)修改都是用代碼完成的,不需要后續(xù)的修飾;但如果某一些修改比較特異,不具有程序的通用性特征,或?qū)崿F(xiàn)起來比較困難,就可以考慮后期修改。比如用AI文章用圖的修改和排版。熟悉PPT的,也可以用PPT,這時(shí)R的圖導(dǎo)出PPT,就要用到graph2office系列函數(shù)了。
graph2ppt: 將當(dāng)前R圖保存到Microsoft Office PowerPoint/LibreOffice Impress演示文稿中。
graph2doc:將當(dāng)前的R圖保存到Microsoft Office Word/LibreOffice Writer文檔中。
函數(shù)參數(shù)展示和解釋
graph2office(x = NULL, file = "Rplot", fun = NULL, type = c("PPT", "DOC"),
append = FALSE, aspectr = NULL, width = NULL, height = NULL,scaling = 100,
paper = "auto", orient = ifelse(type[1] == "PPT","landscape", "auto"),
margins = c(top = 0.5, right = 0.5, bottom = 0.5, left= 0.5),
center = TRUE, offx = 1, offy = 1, upscale = FALSE, vector.graphic = TRUE, ...)
# 需運(yùn)行上面的ggplot2繪圖
# Create a file name
filen <- "output_filename" # or
# filen <- paste("YOUR_DIR/ggplot")
# There are 3 ways to use graph2office():
### 1. Pass the plot as an object
# 導(dǎo)出圖形對(duì)象
graph2ppt(x=x, file=filen)
graph2doc(x=x, file=filen, aspectr=0.5)
### 2. Get the plot from current screen device
# 導(dǎo)出當(dāng)前預(yù)覽窗口呈現(xiàn)的圖
x
graph2ppt(file=filen, width=9, aspectr=2, append = TRUE)
graph2doc(file=filen, aspectr=1.7, append =TRUE)
### 3. Pass the plot as a function
# 導(dǎo)出自定義函數(shù)輸出的一系列圖
graph2ppt(fun=plot.fun, file=filen, aspectr=0.5, append = TRUE)
graph2doc(fun=plot.fun, file=filen, aspectr=0.5, append = TRUE)
導(dǎo)出到office(ppt和word)中的圖形,是可編輯的:
其它導(dǎo)出到ppt的例子(設(shè)置長寬比)
graph2ppt(file="ggplot2_plot.pptx", aspectr=1.7)
增加第二張同樣的圖,9英寸寬和A4長寬比的幻燈片 (append=T,追加)
graph2ppt(file="ggplot2_plot.pptx", width=9, aspectr=sqrt(2), append=TRUE)
添加相同圖形的第三張幻燈片,寬度和高度固定
graph2ppt(file="ggplot2_plot.pptx", width=6, height=5, append=TRUE)
禁用矢量化圖像導(dǎo)出
graph2ppt(x=x, file=filen, vector.graphic=FALSE, width=9, aspectr=sqrt(2), append = TRUE)
用圖填滿幻燈片
graph2ppt(x=x, file=filen, margins=0, upscale=TRUE, append=TRUE)
函數(shù)參數(shù)解釋
graph2vector(x = NULL, file = "Rplot", fun = NULL, type = "SVG",aspectr = NULL,
width = NULL, height = NULL, scaling = 100,
font = ifelse(Sys.info()["sysname"] == "Windows",
"Arial","Helvetica")[[1]], bg = "white", colormodel = "rgb",
cairo = TRUE,fallback_resolution = 600, ...)
#需運(yùn)行上面的ggplot2繪圖
# Create a file name
filen <- "output_filename" # or
# filen <- paste("YOUR_DIR/ggplot")
# There are 3 ways to use graph2vector():
### 1. Pass the plot as an object
# 導(dǎo)出圖形對(duì)象
graph2svg(x=x, file=filen, aspectr=2, font = "Times New Roman",
height = 5, bg = "white")
graph2pdf(x=x, file=filen, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
graph2eps(x=x, file=filen, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
# 導(dǎo)出當(dāng)前預(yù)覽窗口呈現(xiàn)的圖
### 2. Get the plot from current screen device
x
graph2svg(file=filen, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
graph2pdf(file=filen, aspectr=2, font = "Times New Roman",
height = 5, bg = "white")
graph2eps(file=filen, aspectr=2, font = "Times New Roman",
height = 5, bg = "white")
# 導(dǎo)出自定義函數(shù)輸出的一系列圖
### 3. Pass the plot as a function
graph2svg(file=filen, fun = plot.fun, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
graph2pdf(file=filen, fun=plot.fun, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
graph2eps(file=filen, fun=plot.fun, aspectr=2, font = "Arial",
height = 5, bg = "transparent")
rgl2png: 將當(dāng)前的rgl 3D圖形保存為PNG格式。
rgl2bitmap(file = "Rplot", type = c("PNG"))
# Create a file name
filen <- tempfile("rgl") # or
# filen <- paste("YOUR_DIR/rgl")
# Generate a 3D plot using 'rgl'
x = y = seq(-10, 10, length = 20)
z = outer(x, y, function(x, y) x^2 + y^2)
rgl::persp3d(x, y, z, col = 'lightblue')
# Save the plot as a png
rgl2png(file = filen)
# Note that omitting 'file' will save in current directory
生成的3D圖形:
將生成的3D圖形保存為PNG格式:
table2spreadsheet(x = NULL, file = "Rtable", type = c("XLS", "CSV",
"CSV2"), append = FALSE, sheetName = "new sheet", digits = 2,
digitspvals = 2, trim.pval = TRUE, add.rownames = FALSE, ...)
# Create a file name
filen <- "table_aov" # or
# filen <- paste("YOUR_DIR/table_aov")
# Generate ANOVA output
fit=aov(yield ~ block + N * P + K, data = npk) # 'npk' dataset from base 'datasets'
x=summary(fit)
# Save ANOVA table as a CSV
### Option 1: pass output as object
# 輸出對(duì)象
table2csv(x=x,file=filen, digits = 1, digitspvals = 3, add.rownames=TRUE)
# 屏幕輸出導(dǎo)出到文件
### Option 2: get output from console
summary(fit)
table2csv(file=filen, digits = 2, digitspvals = 4, add.rownames=TRUE)
# Save ANOVA table as an Excel
# Without formatting of the worksheet
x
table2excel(file=filen, sheetName="aov_noformatting", digits = 1, digitspvals = 3, add.rownames=TRUE)
# 更多參數(shù)
# With formatting of the worksheet
table2excel(x=x,file=filen, sheetName="aov_formated", append = TRUE, add.rownames=TRUE, fontName="Arial", fontSize = 14, fontColour = rgb(0.15,0.3,0.75), border=c("top", "bottom"), fgFill = rgb(0.9,0.9,0.9), halign = "center", valign = "center", textDecoration="italic")
原始數(shù)據(jù)的表格:
轉(zhuǎn)換格式之后的,在console中的數(shù)據(jù):
文件(csv和excel)中表格數(shù)據(jù):
table2ppt: 導(dǎo)出統(tǒng)計(jì)輸出到Microsoft Office PowerPoint/ LibreOffice Impress演示文稿中的表
table2doc: 將統(tǒng)計(jì)輸出導(dǎo)出到Microsoft Office Word/ LibreOffice Writer文檔中的表
table2office(x = NULL, file = "Rtable", type = c("PPT", "DOC"),
append = FALSE, digits = 2, digitspvals = 2, trim.pval = TRUE,
width = NULL, height = NULL, offx = 1, offy = 1,
font = ifelse(Sys.info()["sysname"] == "Windows", "Arial",
"Helvetica")[[1]], pointsize = 12, add.rownames = FALSE)
# Create a file name
filen <- "table_aov"
# filen <- paste("YOUR_DIR/table_aov")
# Generate ANOVA output
fit=aov(yield ~ block + N * P + K, data = npk) # 'npk' dataset from base 'datasets'
# Save ANOVA table as a PPT
### Option 1: pass output as object
x=summary(fit)
table2ppt(x=x,file=filen, digits = 1, digitspvals = 3, add.rownames =TRUE)
### Option 2: get output from console
summary(fit)
table2ppt(x=x,file=filen, width=5, font="Times New Roman", pointsize=14, digits=4, digitspvals=1, append=TRUE, add.rownames =TRUE) # append table to previous slide
# Save ANOVA table as a DOC file
table2doc(x=x,file=filen, digits = 1, digitspvals = 3, add.rownames =TRUE)
summary(fit)
table2doc(file=filen, width=3.5, font="Times New Roman", pointsize=14, digits=4, digitspvals=1, append=TRUE, add.rownames =TRUE) # append table at end of document
將表格數(shù)據(jù)導(dǎo)出到ppt和word中:
table2html: 導(dǎo)出統(tǒng)計(jì)輸出到HTML表。
table2tex(x = NULL, file = "Rtable", type = "TEX", digits = 2,
digitspvals = 2, trim.pval = TRUE, summary = FALSE, standAlone = TRUE,
add.rownames = FALSE, ...)
summary:是否匯總數(shù)據(jù)文件。
standAlone:導(dǎo)出的Latex代碼應(yīng)該是獨(dú)立可編譯的,還是應(yīng)該粘貼到另一個(gè)文檔中。
add.rownames:是否應(yīng)該將行名添加到表中(在第一列之前插入一列)。
# Create a file name
filen <- tempfile(pattern = "table_aov") # or
# filen <- paste("YOUR_DIR/table_aov")
# Generate ANOVA output
fit=aov(yield ~ block + N * P + K, data = npk) # 'npk' dataset from base 'datasets'
x=summary(fit)
# Export to Latex in standAlone format
table2tex(x=x,file=filen,add.rownames = TRUE)
# Export to Latex to paste in tex document
summary(fit) # get output from the console
table2tex(file=filen, standAlone = FALSE,add.rownames = TRUE)
# Export to HTML
table2html(x=x,file=filen) # or
summary(fit) # get output from the console
table2html(file=filen,add.rownames = TRUE)
導(dǎo)出到html或tex中的表格數(shù)據(jù):
在前面一篇博客中,我們介紹了一些關(guān)于在Windows系統(tǒng)上安裝R Studio來編寫R Markdown,最后編譯成Beamer的演示文檔的過程。而在Windows系統(tǒng)的使用過程中發(fā)現(xiàn),編譯過程還是要依賴于pdflatex的,而根據(jù)以往的經(jīng)驗(yàn),在Windows上搞Latex還是略顯麻煩。所以在前一篇博客中,才給出了本地編譯成Tex文件,最后切換到Overleaf上去編譯成pdf的下策。這里我們將給出另外一套解決方案:通過TinyTex來部署R Markdown環(huán)境,TinyTeX 是益輝大神基于 TeX Live 管理工具修改的 R 拓展包。
在寫一些學(xué)術(shù)演示文檔時(shí),經(jīng)常有可能用到Beamer——一種Latex的學(xué)術(shù)風(fēng)PPT模板,比如下圖所示的這種:
這種風(fēng)格的演示文檔有幾個(gè)明顯的優(yōu)點(diǎn):簡約、嚴(yán)肅、可以用Latex敲公式和推導(dǎo)、可微調(diào)、定制化程度高,而且一般都是免費(fèi)的。當(dāng)然也有一些明顯的缺點(diǎn):寫Latex麻煩,部署Latex環(huán)境更麻煩。因此,更多的人都是硬著頭皮在Overleaf上寫Latex,這也是被逼無奈。但是我們看到在各大平臺(tái)用Markdown寫博客,或者在開源代碼倉庫中用Markdown寫說明文檔,都是非常的美觀,那有沒有可能用Markdown替代Latex,至少在演示文檔上用Markdown替代Latex呢?對(duì)于這個(gè)問題,R Markdown給出了肯定的回答。
打開R Studio的R語言終端窗口,輸入如下指令:
install.packages('tinytex')
tinytex::install_tinytex()
安裝完成后,在R Studio界面點(diǎn)擊Preview->Beamer,就可以直接彈出編譯好的pdf的預(yù)覽:
這樣我們就完成了本地的完整R Markdown的環(huán)境搭建,可以通過Markdown來寫B(tài)eamer了。
上一篇博客《通過R Studio用Markdown寫B(tài)eamer》的最后遺留了一個(gè)問題,那就是如何在本地win11的系統(tǒng)環(huán)境下快捷方便的構(gòu)建一個(gè)pdflatex的環(huán)境。常規(guī)方案都是安裝一個(gè)TexLive和下載一大堆的擴(kuò)展文件,或者用Docker的方案去構(gòu)建一個(gè)完整的環(huán)境,但是都很費(fèi)時(shí)間精力。偶然的情況下看到了益輝大神基于TexLive修改了一個(gè)R的擴(kuò)展包TinyTex。經(jīng)過測(cè)試,這毫無疑問是目前在本地構(gòu)建R Markdown完整環(huán)境的最佳解決方案。
本文首發(fā)鏈接為:https://www.cnblogs.com/dechinphy/p/tinytex.html
作者ID:DechinPhy
更多原著文章請(qǐng)參考:https://www.cnblogs.com/dechinphy/
打賞專用鏈接:https://www.cnblogs.com/dechinphy/gallery/image/379634.html
騰訊云專欄同步:https://cloud.tencent.com/developer/column/91958
、背景
云文檔轉(zhuǎn)HTML郵件
基于公司內(nèi)部的飛書辦公套件,早在去年6月,我們就建設(shè)了將飛書云文檔轉(zhuǎn)譯成HTML郵件的能力,方便同學(xué)們?cè)诰帉戉]件文檔和發(fā)送郵件時(shí),都能有較好的體驗(yàn)和較高的效率。
當(dāng)下問題
要被郵件客戶端識(shí)別,飛書云文檔內(nèi)容需要轉(zhuǎn)譯成HtmlEmail格式,該格式為了兼容各種版本的郵箱客戶端(特別是Windows Outlook),對(duì)于現(xiàn)代HTML5和CSS3的很多特性是不支持的,飛書云文檔的多種富文本塊格式都需要轉(zhuǎn)譯,且部分格式完全不支持,造成編輯和預(yù)覽發(fā)送不一致的情況。
因此,我們對(duì)轉(zhuǎn)譯工具做了一次大改版和升級(jí),對(duì)大部分常用文檔塊做了高度還原。
實(shí)現(xiàn)效果
經(jīng)過我們的不懈努力,最終實(shí)現(xiàn)了較為不錯(cuò)的還原效果:
二、系統(tǒng)架構(gòu)改版
飛書云文檔結(jié)構(gòu)
在展開我們?nèi)绾巫錾?jí)之前,先要簡單了解下飛書云文檔的信息結(jié)構(gòu)(詳情可參考官方API),在此僅做簡單闡述。
TypeScript簡要定義,一個(gè)平鋪的文檔塊數(shù)組,根據(jù)block_id和parent_id確定各塊的父子關(guān)系,從而形成一個(gè)樹:
{
/** 文檔塊唯一標(biāo)識(shí)。*/
block_id: string;
/** 父塊 ID。*/
parent_id: string;
/** 子塊 ID 列表。*/
children: string[];
/** 文檔塊類型。*/
block_type: BlockType;
/** 頁面塊內(nèi)容描述。*/
page?: { ... };
/** 文本塊內(nèi)容描述。*/
text?: { ... };
/** 標(biāo)題 1 塊內(nèi)容描述。*/
heading1?: { ... };
/** 有序列表塊內(nèi)容描述。*/
ordered?: { ... };
/** 表格塊內(nèi)容描述。*/
table?: { ... };
// 總計(jì) 43 個(gè)塊定義。
...
}[];
我們用思維導(dǎo)圖簡單舉例,整個(gè)文檔塊的樹結(jié)構(gòu)大致是這樣的,有些塊根據(jù)縮進(jìn)遞進(jìn),會(huì)形成父子關(guān)系,有些塊天然就會(huì)成為父塊(比如表格、引用等):
舊版架構(gòu)
那么我們初版轉(zhuǎn)譯工具是怎么做的呢,比較遺憾的是,由于當(dāng)時(shí)需求的還原度訴求較低,我們的代碼主要是復(fù)用現(xiàn)有部分實(shí)現(xiàn),整體的架構(gòu)設(shè)計(jì)可以用一個(gè)詞概括,基本是面向過程編程:
上方的圖:經(jīng)過了一些抽取和封裝,主流程核心代碼仍有528行;下方的圖:文檔塊核心轉(zhuǎn)譯渲染代碼,基本沒有寫任何還原樣式,通過Switch、Case來一個(gè)個(gè)渲染文檔塊。
新版架構(gòu)設(shè)計(jì)
這次我們痛定思痛,勢(shì)必要將轉(zhuǎn)譯工具的轉(zhuǎn)譯效果做到盡可能還原,也有了多位同學(xué)一起投入。因此首要思考和急需解決的問題來了:在老舊的架構(gòu)下,如何才能做好代碼擴(kuò)展、多人協(xié)同、高效樣式編寫以及樣式還原?
IoC 與DI
是的,幾乎一剎那,憑借過往豐富的多人協(xié)同以及項(xiàng)目經(jīng)驗(yàn),很快我們就想到了,這個(gè)事需要基于IoC的設(shè)計(jì)原則,并通過DI的方式來實(shí)現(xiàn)。
那么什么是IoC和DI呢,根據(jù)維基百科的解釋:控制反轉(zhuǎn)(Inversion of Control,縮寫為IoC),是面向?qū)ο缶幊讨械囊环N設(shè)計(jì)原則,可以用來減低計(jì)算機(jī)代碼之間的耦合度,其中最常見的方式叫做依賴注入(Dependency Injection,縮寫為DI)。
這么說可能有點(diǎn)抽象,我們可以看下新版的架構(gòu)設(shè)計(jì),從中便能窺見其精妙:
可以看到,關(guān)鍵的文檔塊預(yù)處理和渲染器,在該架構(gòu)中是反向依賴核心的createDocTranspiler了,與我們常識(shí)中的理解(文檔轉(zhuǎn)譯渲染依賴各個(gè)塊的預(yù)處理和渲染器)是相反的,這就是控制反轉(zhuǎn)(IoC),通過這樣的依賴倒置,我們能夠把多人協(xié)同過程中,由各個(gè)同學(xué)負(fù)責(zé)開發(fā)的預(yù)處理器和渲染器的開發(fā)調(diào)試解耦出去,互不影響、互不依賴,且合碼過程中基本沒有代碼沖突,大大提效了多人協(xié)同合作開發(fā)。同時(shí)由于實(shí)現(xiàn)的方式是依賴注入(DI),或者說注冊(cè),未來我們想要支持更加深水區(qū)的文檔塊,比如「畫板」、「文檔小組件」等,可以很方便地注冊(cè)新的預(yù)處理器和渲染器,做增量且解耦的代碼開發(fā);如果想要取消對(duì)某一個(gè)文檔塊的渲染,直接unregister即可,由此也實(shí)現(xiàn)了文檔塊渲染的快速插拔和極高的可拓展性。
整個(gè)轉(zhuǎn)譯主干代碼如下:
創(chuàng)建轉(zhuǎn)譯器,注冊(cè)預(yù)處理器,注冊(cè)渲染器
轉(zhuǎn)譯渲染,后處理,完成渲染。代碼行數(shù)縮減到只有138行。
函數(shù)式編程
接下來我們將目光聚焦到核心函數(shù)createDocTranspiler中,這塊是IoC架構(gòu)的核心實(shí)現(xiàn),根據(jù)維基百科描述,IoC是面向?qū)ο缶幊讨械囊环N設(shè)計(jì)原則,那么我們真的是用面向?qū)ο蟮木幊谭绞絾幔?/span>
顯然不是,我們是高標(biāo)準(zhǔn)的前端同學(xué),在JavaScript編程中,面向?qū)ο缶幊田@然不是社區(qū)推崇的設(shè)計(jì)原則,以React框架為例,早在React 16.8版本,就推出了函數(shù)組件和Hooks編程,以取代較為臃腫的類組件編程,這些都是前端老生常談的理念了,大家可以去Google深入學(xué)習(xí)函數(shù)式編程理念,在此不再贅述。
這里說一下為什么核心代碼createDocTranspiler我要用函數(shù)式編程,說一下我的理解:第一是非常優(yōu)雅,用起來很舒服;第二是得益于JavaScript函數(shù)閉包,一些局部(想要private化)的變量或者方法,直接在函數(shù)內(nèi)聲明和定義即可,不用擔(dān)心像類一樣會(huì)暴露出去(盡管TS有private關(guān)鍵字,但只是約束,不代表你不能用);第三是簡單,無需維護(hù)類的實(shí)例,若有主動(dòng)銷毀場景,返回的結(jié)構(gòu)中暴露銷毀函數(shù)即可。
整個(gè)核心代碼如下:
上方的圖:內(nèi)置的變量和函數(shù),用于存儲(chǔ)各種預(yù)處理器和渲染器,并實(shí)現(xiàn)文檔樹的遞歸渲染;下方的圖:返回并暴露出去的函數(shù),用于注冊(cè)各種預(yù)處理器、渲染器,以及轉(zhuǎn)譯渲染。整個(gè)核心代碼只有158行,非常精煉。
“CSS-in-JS”
然后再來說一下如此大量的樣式還原工作,我們是如何實(shí)現(xiàn)的。由于我們要把文檔樹轉(zhuǎn)譯成最終的一個(gè)完整的HTML字符串,在模板字符串中寫內(nèi)聯(lián)樣式(style="width: 100px;...")會(huì)非常痛苦,代碼可讀性會(huì)很差,開發(fā)調(diào)試的效率也會(huì)很低。
為了解決這個(gè)問題,我們立即想到了React CSSProperties的寫法,并調(diào)研了一下它的源碼實(shí)現(xiàn),其實(shí)就是將CSSProperties中的駝峰屬性名,轉(zhuǎn)換成內(nèi)聯(lián)樣式中連字符屬性名,并額外處理了Webkit、ms、Moz、O等瀏覽器屬性前綴,同時(shí)針對(duì)number 類型的部分屬性的值,轉(zhuǎn)換時(shí)自動(dòng)加上了px后綴。詳細(xì)代碼如下:
// 樣式處理工具函數(shù)庫。
import { CSSProperties } from 'react';
/* 是否是,值可能是數(shù)字類型,且不需要指定 px 為單位的 CSSProperties 屬性。*/
const isUnitlessNumber: Record<string, boolean> = {
// ...
fontWeight: true,
lineClamp: true,
lineHeight: true,
// ...
// SVG-related properties.
fillOpacity: true,
floodOpacity: true,
stopOpacity: true,
// ...
};
// 各瀏覽器 CSS 屬性名前綴。
const cssPropertyPrefixes = ['Webkit', 'ms', 'Moz', 'O'];
// 針對(duì) isUnitlessNumber,填充各瀏覽器 CSS 屬性名前綴。
Object.keys(isUnitlessNumber).forEach(property => {
cssPropertyPrefixes.forEach(prefix => {
isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`] =
isUnitlessNumber[property];
});
});
export { isUnitlessNumber };
/** 針對(duì) CSSProperties 屬性值,可能添加單位 px,并返回合法的值。*/
export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
if (typeof value === 'number' && !isUnitlessNumber[property]) {
// 值是數(shù)字類型,且需要添加單位 px,則添加單位 px。
return `${value}px`;
}
return value;
}
然后再編寫createInlineStyles方法,入?yún)⒓礊镽ecord<string, CSSProperties> 大樣式對(duì)象:
/* 將 CSSProperties 轉(zhuǎn)為內(nèi)聯(lián) style 字符串,e.g. { width: 100, flex: 1 } => style="width: 100px; flex: 1;"。*/
export function convertCSSPropertiesToInlineStyle(style: CSSProperties) {
const upperCaseReg = /[A-Z]/g;
const inlineStyle = Object.keys(style)
.map(
property =>
`${property.replace(
upperCaseReg,
matchLetter => `-${matchLetter.toLowerCase()}`,
)}: ${addCSSPropertyUnit(property as keyof CSSProperties, style[property])};`,
)
.join(' ');
if (inlineStyle) {
return `style="${inlineStyle}"`;
}
return '';
}
/** 根據(jù)輸入的樣式表(CSSProperties 格式),輸出內(nèi)聯(lián)樣式表(格式為 style="..." 的字符串),e.g. { container: { position: 'relative' }, title: { fontSize: 18 } } => { container: 'style="position: relative;"', title: 'style="font-size: 18px;"' }。*/
export function createInlineStyles<T extends string>(styles: { [P in T]: CSSProperties }) {
const inlineStyles = {} as { [P in T]: string };
Object.keys(styles).forEach(name => {
inlineStyles[name] = convertCSSPropertiesToInlineStyle(styles[name]);
});
return inlineStyles;
}
至此架構(gòu)優(yōu)化的差不多了,整個(gè)項(xiàng)目組進(jìn)入了高度協(xié)同、緊密溝通合作的開發(fā)中,整個(gè)開發(fā)過程其實(shí)并不是特別順利,尤其是在對(duì)Windows Outlook郵箱客戶端的支持上,各種樣式兼容問題Case層出不窮,以至于我們的開發(fā)同學(xué)不得不去對(duì)郵箱HTML和CSS開發(fā)進(jìn)行“考古”。
三、Outlook麻煩的兼容性問題
在改版系統(tǒng)架構(gòu)后,我們先試著實(shí)現(xiàn)了一版有序列表和無序列表的解決方案,結(jié)果在測(cè)試中,我們得到了出乎所有人意料之外的結(jié)果:
原本文檔的樣子
網(wǎng)頁版Outlook中的樣子
Windows的Outlook中的樣子
在網(wǎng)頁版Outlook中,通過開發(fā)工具可以看到每一項(xiàng)的justify-content樣式消失了,而在Windows Outlook中,基本沒什么樣式還留著了。
Outlook糟糕的兼容性
我們之前從未編寫過HTML郵件,也就完全沒考慮過各個(gè)郵件客戶端對(duì)HTML的兼容性問題。在網(wǎng)上找到一些資料后,我們被Outlook對(duì)HTML的兼容性之差所震驚。
首先,Windows Outlook并沒有一個(gè)自己的HTML渲染引擎,而是使用Word的渲染引擎去解析HTML。它不支持HTML5和CSS3,也就是說我們?yōu)榱吮WC最大的兼容性,所有的飛書文檔樣式還原和文本解析都要用極為陳舊的技術(shù)去實(shí)現(xiàn)。
據(jù)官方文檔所示,display、position、max-width、max-height等樣式全都不兼容。
總的來說:
技術(shù)上的限制如此苛刻,就意味著在后面的開發(fā)中,我們還會(huì)遇到很多特定情況的兼容性問題。在這種情況下,為了最大限度地保證兼容性,我們決定及時(shí)止損,重新設(shè)計(jì)后面各個(gè)組件的實(shí)現(xiàn)方式,并將無序列表和有序列表的渲染方法推倒重來,再次編寫。
四、各類型文檔塊的還原
首先,我們將轉(zhuǎn)譯工具原有的「一級(jí)標(biāo)題」到「九級(jí)標(biāo)題」美化為接近飛書文檔的樣子。我們需要梳理下將會(huì)獲得的數(shù)據(jù),來看看如何將它們轉(zhuǎn)譯為HTML。
標(biāo)題塊(heading 1-9)
標(biāo)題組件應(yīng)該是實(shí)現(xiàn)難度最低的一個(gè),一個(gè)標(biāo)題組件的數(shù)據(jù)結(jié)構(gòu)如下:
原版實(shí)現(xiàn)方式
在原版的轉(zhuǎn)譯工具中,我們編寫了通用方法來處理文本內(nèi)容的下劃線、刪除線、斜體、粗體、高亮色等進(jìn)行處理,生成行間元素,然后在外部框上<h1>-<h9>。最終在后面加上它的子節(jié)點(diǎn)渲染結(jié)果。
新版實(shí)現(xiàn)方式
由于默認(rèn)的heading樣式無法滿足還原度,且并沒有處理對(duì)齊方式。我們將使用 <div> 制作heading組件,自行添加樣式來還原飛書文檔:
case BlockType.HEADING1: {
const blockH1 = block as HeadingBlock;
const align = blockH1.heading1.style.align;
const styles = makeHeadingStyles({ type: block.block_type, align });
text += `<div ${styles.headingStyles}>${transpileTextElements(
blockH1.block_id,
blockH1.heading1.elements,
isPreview,
)}</div>`;
// renderChildBlocks 方法來渲染當(dāng)前塊的所有子節(jié)點(diǎn)。
text += renderChildBlocks(blockH1.block_id);
break;
}
其中makeHeadingStyles是我們生成樣式的方法,這樣可以將各個(gè)組件的樣式寫成配置項(xiàng),方便后續(xù)修改。新的樣式中,我們著重對(duì)行高、行距、下劃線距文字距離、對(duì)齊方式進(jìn)行了調(diào)整:
// makeHeadingStyles 方法的部分截取。
export function makeHeadingStyles(params: MakeHeadingStylesParams) {
const { type, align } = params;
const basicStyle: CSSProperties = {
lineHeight: 1.4,
letterSpacing: '-.02em',
fontWeight: 500,
color: '#1f2329',
textAlign: getTextAlignStyle(align || 1),
};
let headingStyles: CSSProperties = {};
switch (type) {
case BlockType.HEADING1:
headingStyles = {
fontSize: 26,
marginTop: 26,
marginBottom: 10,
...basicStyle,
};
break;
// 對(duì)Heading2-9的樣式進(jìn)行定義...
// ......
// 將樣式對(duì)象轉(zhuǎn)成行間樣式字符串。
return createInlineStyles<'headingStyles'>({ headingStyles: headingStyles });
}
最后發(fā)郵件,測(cè)試一下生成的HTML的效果:
改版之前
改版之后
無序列表(bullet)與有序列表(ordered)
原版實(shí)現(xiàn)方式
列表的數(shù)據(jù)結(jié)構(gòu)與標(biāo)題塊大致相同,在此不再贅述。在原來的轉(zhuǎn)譯工具中,我們使用原生的<ul>和<li>來直接渲染無序列表,<ol><li>來渲染有序列表。我們順序遍歷兄弟節(jié)點(diǎn)的列表,為連續(xù)的bullet文檔塊的前后加上<ul></ul>,連續(xù)的ordered塊前后加上<ol>和</ol>。列表中的每一項(xiàng),則渲染成<li>。
由于原生<ul>和<ol>的marker樣式較丑,我們無法使用偽類元素等手段改善它的樣式,為了方便,我們這次改版將自己維護(hù)列表的層級(jí)關(guān)系。
新版實(shí)現(xiàn)方式
在飛書文檔中,不同層級(jí)的列表,marker長得完全不同:
無序列表
有序列表
為了判斷我們每個(gè)列表項(xiàng)要使用什么樣的marker,首先我們需要對(duì)飛書給我們的數(shù)據(jù)進(jìn)行預(yù)處理,為每個(gè)列表塊標(biāo)注它的層級(jí)和序號(hào)。
由于飛書API沒有提供有序列表的序號(hào),這個(gè)序號(hào)用戶又可以隨便更改,所以我們的思路是:如果有序列表中間被非空文檔塊以外的文本塊截?cái)?,序?hào)則重新開始計(jì)算。具體方法如下:
/** 判斷文本塊是否為空白文本類型的快。*/
export function isEmptyTextBlock(block: DocBlockText | undefined) {
if (文檔塊的類型為text且不為空 || 文檔塊類型不為text) {返回false;}
else {返回true;}
}
/** 為每個(gè)文本塊計(jì)算它到文本樹根節(jié)點(diǎn)的深度,為有序列表塊找到它的序號(hào)。*/
export function processBlocks(blocks: DocBlock[]) {
const blockDepths = {}; // 記錄各節(jié)點(diǎn)距根節(jié)點(diǎn)的深度。
const blockOrder = {}; // 記錄各節(jié)點(diǎn)在同類兄弟節(jié)點(diǎn)中的順序,被其他類型的塊打斷的時(shí)候?qū)⒅匦掠?jì)數(shù)。
function calcBlockFields(block: DocBlock, depth: number) {
blockDepths[block.block_id] = depth;
// 為有序列表找到它的序號(hào)。
if (文本塊類型為 ordered) {
1. 找到同級(jí)兄弟節(jié)點(diǎn)列表 brotherBlocks 與同類型同級(jí)兄弟節(jié)點(diǎn)列表 similarBrotherBlocks;
2. 找到當(dāng)前節(jié)點(diǎn)在上述兩個(gè)列表中的索引 brotherBlocksIndex,similarBrotherBlocksIndex;
3. 找到兄弟節(jié)點(diǎn)列表中的前一個(gè)節(jié)點(diǎn) prevBrotherBlock。以及同類兄弟列表的前一個(gè)節(jié)點(diǎn) prevSimilarBrotherBlock;
if (當(dāng)前節(jié)點(diǎn)是兄弟節(jié)點(diǎn)列表中的第一個(gè)節(jié)點(diǎn) || 當(dāng)前節(jié)點(diǎn)是同類兄弟節(jié)點(diǎn)列表中的第一個(gè)節(jié)點(diǎn) || 前一個(gè)兄弟節(jié)點(diǎn)不是同類兄弟節(jié)點(diǎn),且前一個(gè)兄弟節(jié)點(diǎn)是非空的文本塊) {
blockOrder[block.block_id] = 1;
} else {
blockOrder[block.block_id] = 上一個(gè)同類兄弟的編號(hào) + 1
}
}
遞歸處理子節(jié)點(diǎn)。如果當(dāng)前節(jié)點(diǎn)的類型為 grid_column、tabel_cell、callout、quoter_container 的時(shí)候,深度重置為 1(calcBlockFields(childrenBlock, 1)),其他情況 calcBlockFields(childrenBlock, depth + 1);
}
從根節(jié)點(diǎn)開始遞歸處理。calcBlockFields(rootBlock, 0);
將記錄的序號(hào)和深度(blockOrder, blockDepths)添加到每個(gè)節(jié)點(diǎn)中(block.depth, block.order);
}
這樣,每個(gè)列表項(xiàng)都知道了自己在文檔中的層級(jí),有序列表也知道了自己的序號(hào)。
由于原來的方法中完全沒有處理過文本塊的縮進(jìn),我們根據(jù)飛書縮進(jìn)的規(guī)律,為普通的文本塊(表格、柵格等以外的文本塊)在渲染子節(jié)點(diǎn)時(shí)為子節(jié)點(diǎn)的容器添加25px的padding-left。
接下來我們使用一個(gè)通用的方法為有序列表和無序列表渲染它們的marker。
/** 渲染列表的標(biāo)簽。*/
export const listMarkRender = (type: ListType, block: DocBlock) => {
const { depth = 1, order = 1 } = block;
if (type === ListType.BULLET) {
const styles = makeMarkerStyles(ListType.BULLET);
let marker: string;
marker = 按照深度,每三個(gè)一循環(huán),依次為 '?'、'?'、'?';
return `<span ${styles.markContainerStyle}>${marker}</span>`;
} else {
const styles = makeMarkerStyles(ListType.ORDERED);
let markerGenerator: (num: number) => number | string;
markerGenerator = 按照深度,每三個(gè)一循環(huán),依次為數(shù)字、數(shù)字轉(zhuǎn)小寫字母、數(shù)字轉(zhuǎn)羅馬數(shù)字;
return `<span ${styles.markContainerStyle}>${markerGenerator(order)}.</span>`;
}
};
對(duì)于無序列表,標(biāo)號(hào)每三層一循環(huán),順序?yàn)?'?'、'?'、'?'。對(duì)于有序列表,標(biāo)號(hào)格式也是每三層一循環(huán),順序?yàn)榘⒗當(dāng)?shù)字、小寫字母、羅馬數(shù)字。
使用列表的標(biāo)號(hào)渲染器渲染標(biāo)號(hào)部分,然后簡單的在<div>中將標(biāo)號(hào)<span>和處理過樣式的正文<span>組合。
const orderedRenderer: BlockRenderer = (block, isPreview, renderChildBlocks) => {
const orderedBlock = block as OrderedBlock;
const align = orderedBlock.ordered.style.align;
const styles = makeOrderedStyles(align);
let text = '';
text += `
<div ${styles.listWrapper}>
${listMarkRender(ListType.ORDERED, orderedBlock,)}
<span ${styles.listContent}>
${transpileTextElements(orderedBlock.block_id, orderedBlock.ordered.elements, isPreview,)}
</span>
</div>
`;
text += renderChildBlocks(orderedBlock.block_id, false);
return text;
};
const bulletRenderer: BlockRenderer = (block, isPreview, renderChildBlocks) => {
const bulletBlock = block as BulletBlock;
const align = bulletBlock.bullet.style.align;
const styles = makeBulletStyles(align);
let text = '';
text += `
<div ${styles.listWrapper}>
${listMarkRender(ListType.BULLET, bulletBlock,)}
<span ${styles.listContent}>${transpileTextElements(
bulletBlock.block_id,
bulletBlock.bullet.elements,
isPreview,
)}</span>
</div>`;
text += renderChildBlocks(bulletBlock.block_id, false);
return text;
};
可以看到,我們?cè)跐M足使用的前提下以最高的兼容性比較完美的還原了飛書文檔中的有序列表和無序列表。
待辦事項(xiàng)
既然漂亮地還原了有序列表和無序列表,待辦事項(xiàng)塊就簡單得多了。代辦事項(xiàng)的具體的數(shù)據(jù)結(jié)構(gòu)如下:
可以看到,待辦事項(xiàng)的數(shù)據(jù)中包含了該條待辦事項(xiàng)是否已完成的數(shù)據(jù),從飛書文檔的樣式可以看出,已完成的條目會(huì)統(tǒng)一被劃上刪除線,并刪除下劃線樣式。最終的渲染器和樣式生成方法如下:
待辦事項(xiàng)渲染器
const todoRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
const todoBlock = block as TodoBlock;
const { align, done } = todoBlock.todo.style;
const originTodoElements = todoBlock.todo.elements;
const markerSrc = done ? '已完成標(biāo)記圖片地址' : '未完成標(biāo)記圖片地址';
const styles = makeTodoStyles(align || 1, done);
const checkedTodoElements = cloneDeep(originTodoElements);
checkedTodoElements.forEach(element => {
為所有文本元素去掉下劃線,添加刪除線
});
let text = '';
text += `
<div ${styles.todoWrapperStyles}>
<img width="18" height="18" ${styles.todoMarkerStyles} src="${markerSrc}" alt="todo_mark"/>
<span> </span>
<span ${styles.todoContentStyles}>${transpileTextElements(
todoBlock.block_id,
done ? checkedTodoElements : originTodoElements,
isPreview,
)}</span>
</div>`;
text += renderChildBlocks(todoBlock.block_id, false);
return text;
};
最終呈現(xiàn)效果
表格(非電子表格)塊
文檔中另一個(gè)最重要的模塊就是表格。表格是另一類比較特殊的文本塊,他內(nèi)部并不包含正文。整個(gè)表格實(shí)際上由三層文檔塊組合而成,它們的數(shù)據(jù)結(jié)構(gòu)如下:
依據(jù)數(shù)據(jù)結(jié)構(gòu)和我們的代碼模式設(shè)計(jì),我們需要使用嵌套的渲染器來實(shí)現(xiàn)表格的繪制。
表格渲染器(table塊)
由于飛書API中清楚地提供了行數(shù)、列數(shù)以及列寬,我們可以較為輕松地繪制出大致的表格。這里的重點(diǎn)是要準(zhǔn)確地處理合并單元格數(shù)據(jù),將它們精準(zhǔn)地使用在表格的每個(gè) <td>標(biāo)簽上。表格渲染器的代碼如下:
const tableRenderer: BlockRenderer = (block, renderSpecifyBlock) => {
const blockTable = block as TableBlock;
const children = blockTable.table.cells;
const tableStyles = makeTableStyles();
const { column_size, row_size, column_width, merge_info } = blockTable.table.property;
// 計(jì)算出整個(gè)表格的整體寬度。
const totalWidth = column_width.reduce((acc, cur) => acc + cur, 0);
let text = `
<div ${tableStyles.tableWrapperStyles}>
<table width="${totalWidth}" ${tableStyles.tableStyles}>
`;
// 初始化單元格處理標(biāo)記數(shù)組,記錄哪些單元格已被處理過數(shù)據(jù)。
const processed = Array.from({ length: row_size }, () => Array(column_size).fill(false));
let mergeIndex = 0; // 追蹤當(dāng)前 merge_info 索引。
for (let i = 0; i < row_size; i++) {
text += '<tr>';
for (let j = 0; j < column_size; ) {
從 merge_info[mergeIndex] 獲取當(dāng)前合并信息 col_span 與 row_span,確保 col_span 和 row_span 至少為 1;
// 如果當(dāng)前單元格未處理過,則進(jìn)行處理。
if (!processed[i][j]) {
const tDStyles = makeTDStyles(column_width[j]);
const colspanAttr = col_span > 1 ? `colspan="${col_span}"` : '';
const rowspanAttr = row_span > 1 ? `rowspan="${row_span}"` : '';
text += `
<td valign="top" width="${column_width[j]}" ${colspanAttr} ${rowspanAttr} ${
tDStyles.tDStyles
}>
// 與之前的文檔塊直接渲染所有的子節(jié)點(diǎn)不同,表格需要在單元格內(nèi)精準(zhǔn)的渲染對(duì)應(yīng)的 table cell 塊,所以此處使用 renderSpecifyBlock 方法。
${renderSpecifyBlock(children[i * column_size + j])}
</td>
`;
// 更新處理標(biāo)記數(shù)組,標(biāo)記當(dāng)前單元格及其被合并的單元格為已處理,
for (let m = i; m < Math.min(i + row_span, row_size); m++) {
for (let n = j; n < Math.min(j + col_span, column_size); n++) {
processed[m][n] = true;
}
}
j += col_span; // 跳過被合并的單元格。
mergeIndex += col_span; // 跳過被合并的單元格對(duì)應(yīng)的 merge_info。
} else {
j++;
mergeIndex++;
}
}
text += '</tr>';
}
text += '</table></div>';
return text;
};
為了處理合并單元格數(shù)據(jù),我們維護(hù)了一個(gè)已處理標(biāo)記數(shù)組processed,處理完一個(gè)單元格后,我們將當(dāng)前單元格與被它合并的單元格都標(biāo)記為已處理,來跳過他們的處理與渲染。這里需要特別注意,飛書文檔的接口偶爾會(huì)返回錯(cuò)誤的合并單元格數(shù)據(jù):{ row_span: 0, col_span: 0 },這個(gè)現(xiàn)象已經(jīng)反饋給飛書,我們?cè)?4-37行做了兼容處理。
為了最大限度的兼容性,我們堅(jiān)持能用標(biāo)簽屬性設(shè)置的樣式,就不使用CSS來設(shè)置。與列表的渲染不同,在表格中我們沒有像列表渲染一樣先預(yù)處理數(shù)據(jù)再生成DOM字符串,而是使用了在遍歷中邊處理數(shù)據(jù)邊生成DOM字符串的方法。
在表格的渲染中,我們沒有像之前的代碼一樣使用renderChildBlocks把所有子文檔塊都渲染出來添加進(jìn)HTML字符串中,而是使用了新的renderSpecifyBlock方法,給定block_id來渲染特定的子文檔塊。
單元格容器渲染器(table cell塊)
單元格容器的渲染器則簡單的多,他沒有任何數(shù)據(jù)處理,只繪制一個(gè)容器用于承載內(nèi)部的所有子節(jié)點(diǎn),并在內(nèi)部將單元格內(nèi)的子節(jié)點(diǎn)渲染出來
const tableCellRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
const styles = makeTableCellStyles();
return `
<div ${styles.tableCellWrapperStyle}>
${renderChildBlocks(block.block_id, true)}
</div>`;
};
最終呈現(xiàn)效果
圖片塊
圖片塊理應(yīng)也是一個(gè)很容易實(shí)現(xiàn)的文檔塊。但在實(shí)際處理過程中,由于飛書的API只提供圖片源文件的寬高,并沒有提供云文檔中用戶縮放過后的圖片寬高,我們需要實(shí)現(xiàn)一個(gè)能滿足絕大多數(shù)使用場景的圖片縮放算法來盡可能還原文檔中的圖片樣式。
圖片塊的數(shù)據(jù)結(jié)構(gòu)如下:
限制圖片大小
源文件的寬高一般都遠(yuǎn)大于圖片在云文檔中的實(shí)際寬高。我決定使用以下的方法來限制住圖片在文檔中的寬高:
上述算法的代碼實(shí)現(xiàn)如下:
/** 根據(jù) id 找到塊。*/
function findNodeById(blocks: DocBlock[], id: string) {
return blocks.find(b => b.block_id === id);
}
/** 檢查當(dāng)前塊的父節(jié)點(diǎn)中有沒有表格或柵格塊。*/
function checkIsInTable(blocks: DocBlock[], parentId: string) {
const parentNode = findNodeById(blocks, parentId);
if (parentNode) {
if (WRAPPERS_LIKE_TABLE.includes(parentNode.block_type)) {
return true;
}
return checkIsInTable(blocks, parentNode.parent_id);
}
return false;
}
function restrictImageSize(
width: number,
height: number,
maxWidth: number = 820,
maxHeight: number = 780,
): [number, number] {
// 寬和高按照長邊縮放(高度大于寬度 50px 視為長圖),并為縮放后的寬高向上取整。
if (width >= height - 50) {
if (width > maxWidth) {
return [maxWidth, Math.ceil(height * divide(maxWidth, width))];
}
} else {
if (height > maxHeight) {
return [Math.ceil(width * divide(maxHeight, height)), maxHeight];
}
}
return [width, height];
}
圖片渲染器
const imageRenderer: BlockRenderer = (block, isPreview, _renderChildBlocks, blocks) => {
let text = '';
const blockImage = block as DocBlockImage;
const align = blockImage.image.align;
const src = `"${
isPreview ? blockImage.image.base64Url : `\$\{${blockImage.block_id}\}` // 實(shí)際發(fā)送時(shí),用 ${block_id} 作為占位符,給到服務(wù)端填充圖片附件地址。
}"`;
const [width] = restrictImageSize(blockImage.image.width, blockImage.image.height);
const isInTable = checkIsInTable(blocks, blockImage.parent_id);
const styles = makeImageStyles({ width, align, isInTable });
text += `
<div ${styles.imgWrapperStyle}>
<img width="${isInTable ? '100%' : width}" ${styles.imgStyle} src=${src}>
</div>
`;
return text;
};
在預(yù)覽的時(shí)候,我們將圖片地址設(shè)為圖片的base64,直接展示。最后傳給后端的HTML字符串中,我們將圖片地址設(shè)為一個(gè)占位符,供后端解析并轉(zhuǎn)化為郵件附件地址。
使用表格來布局的幾個(gè)文檔塊
由于Windows Outlook對(duì)CSS的支持程度很差,我們?cè)趯?duì)一些復(fù)雜文檔塊進(jìn)行排版布局的時(shí)候不能使用flex、grid等。且display和position屬性在大多情況下也不會(huì)像預(yù)期那樣正常生效。我們?yōu)榱俗畲蟮募嫒菪灾荒苁褂帽砀駚斫鉀Q一切排版問題。代碼塊、高亮塊、柵格等幾個(gè)文檔塊就都遵循了這個(gè)思路,使用表格來解決排版。我們以最復(fù)雜的代碼塊作為代表來進(jìn)行介紹。
代碼塊
飛書云文檔中免不了會(huì)出現(xiàn)代碼,所以較好的進(jìn)行代碼塊的還原也是個(gè)重要的工作。代碼塊還原的一個(gè)難點(diǎn)就是數(shù)據(jù)的處理,首先介紹下代碼塊的數(shù)據(jù)結(jié)構(gòu):
理想的話,我們希望element中每一項(xiàng)為一行代碼,我們挨個(gè)進(jìn)行渲染即可。但實(shí)際上,element的內(nèi)容和普通文本類似,只要文本的樣式不變(比如設(shè)為斜體、加粗等),這些文本就都會(huì)被塞到同一個(gè)element項(xiàng)中。
舉例說明,對(duì)于下列文檔中的代碼塊,實(shí)際飛書API返回的代碼只有兩項(xiàng)element:
其中,最后一個(gè)大括號(hào)被單獨(dú)拆成一項(xiàng)令人費(fèi)解,不過好在代碼塊中,只要一項(xiàng)element的后面出現(xiàn)了另一項(xiàng),那就一定意味著換行。這減少了我們的處理難度。
我們的大體思路,是將代碼拆分成一個(gè)二維數(shù)組。第一維中的每一維度為一行代碼,每行代碼中的每一維度為拆分后零碎的代碼塊。我們先將所有的element中的內(nèi)容根據(jù)換行符\n拆分成一個(gè)個(gè)細(xì)小的子塊,同時(shí)將與HTML有關(guān)的字符替換成HTML編碼,避免這些字符混入HTML字符串中被當(dāng)做標(biāo)簽解析:
elements.forEach(element => {
const textStyles = element.text_run?.text_element_style;
const elementSplit = (element.text_run?.content || '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
.match(/(.*?\n|.+)/g);
elementSplit &&
elementSplit.forEach(line => {
codeList.push({
text_run: {
content: line,
text_element_style: textStyles as TextElementStyle,
},
});
});
});
然后將這些子塊按照換行符進(jìn)行分組,變成我們需要的二維數(shù)組:
/** 將拆分好的代碼塊列表按行進(jìn)行分組。*/
const groupingCodeList = (list: TextElement[] = []) => {
const result: TextElement[][] = [];
let currentGroup: TextElement[] = [];
list.forEach(item => {
// 將當(dāng)前字符串添加到當(dāng)前分組。
currentGroup.push(item);
// 如果字符串包含 '\n',則結(jié)束當(dāng)前分組,并準(zhǔn)備開始新的分組。
if (item.text_run?.content.includes('\n')) {
result.push(currentGroup);
currentGroup = [];
}
});
// 最后將 currentGroup 中剩余的項(xiàng)目加入 result。
if (currentGroup.length > 0) {
result.push(currentGroup);
}
return result;
};
至此,我們知道了代碼行數(shù)n和每行代碼中的小代碼塊有哪些。我們要做的就是將它們放進(jìn)一個(gè)n行2列的表格中
最終,代碼塊渲染器的代碼如下。為了保證最大的兼容性,我們使用空的表格行作為內(nèi)邊距,盡量避免CSS解析問題:
const codeRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
const styles = makeCodeStyles();
const blockCode = block as DocBlockCode;
const codeLanguage = blockCode.code.style.language || 0;
// 將代碼塊中的正文將帶 \n 的分割開。
const codeList: TextElement[] = [];
const elements = blockCode.code.elements;
// 分割的時(shí)候把 HTML 有關(guān)的字符換成 HTML 編碼,避免這些正文直接被當(dāng)成 HTML 渲染。
上文中提到的對(duì)elements的處理...
const groupedCodeLines = groupingCodeList(codeList);
// 將按行分類好的代碼塊填入 td。
const codeTr = groupedCodeLines
.map((line, index) => {
return `
<tr bgcolor="f5f6f7">
<td width="46" align="right" valign="top">
<pre ${styles.codeIndexStyles}>${index + 1}</pre>
</td>
<td>
<pre ${styles.codePreStyles}>${transpileTextElements(blockCode.block_id, line, isPreview,)}</pre>
</td>
</tr>
`;
})
.join('');
const emptyTr = `
<tr bgcolor="f5f6f7">
<td width="46" align="right"><span> </span></td>
<td><pre ${styles.codePreStyles}> </pre></td>
</tr>
`;
let text = `
<div ${styles.codeWrapperStyles}>
<table width="100%" ${styles.codeTableStyles}>
${emptyTr}
${codeTr}
${emptyTr}
</table>
</div>
`;
text += renderChildBlocks(blockCode.block_id, false);
return text;
};
我們本次不會(huì)實(shí)現(xiàn)代碼的高亮,只會(huì)顯示同一種顏色的代碼。對(duì)表格中的每個(gè)單元格,我們使用pre標(biāo)簽包裹來保留代碼中的制表符、空格,并將fontFamily設(shè)置為'Courier New', Courier, monospace,使用等寬字體來呈現(xiàn)代碼。
行間公式
飛書云文檔除文本外支持多種行間元素的插入,比如@文檔、內(nèi)聯(lián)文件、內(nèi)聯(lián)公式等,在此我們介紹下最為復(fù)雜的內(nèi)聯(lián)公式是怎么處理的。
行間公式的數(shù)據(jù)位于各個(gè)文檔塊的內(nèi)聯(lián)塊中,以文本塊為例,具體數(shù)據(jù)如下:
我們要做的,就是將公式轉(zhuǎn)換為圖片,然后在郵件中將公式作為圖片附件來處理。
我們將使用MathJax來將公式表達(dá)式轉(zhuǎn)換為svg,用于用戶預(yù)覽。在發(fā)送時(shí),我們將MathJax生成的svg通過cavans轉(zhuǎn)化為png圖片,上傳到CDN,并將CDN地址給到后端,進(jìn)行郵件附件轉(zhuǎn)換。
公式的預(yù)處理方法如下:
// 公式發(fā)送時(shí),后端渲染完成的圖片,其展示的高度的系數(shù)。
const equationCoefficient = 8.421;
const enrichEquationElements: BlockPreprocessor = async (blocks, isPreview) => {
if (!window.MathJax) {
await loadScript('https://cdn.dewu.com/node-common/bc7b5cfc-1c7c-e649-710a-929f109e505e.js');
}
const equationSVGList: SvgObj[] = []; // 待上傳的公式列表。
const equationElementList: TextElement[] = []; // 帶有公式的元素列表。
blocks.forEach(block => {
const elements = getBlockElements(block);
let equationIndex = 0;
elements.forEach(textEl => {
// 文本塊內(nèi)容中包含公式時(shí),轉(zhuǎn)譯為 SVG HTML。
if (textEl.equation) {
equationElementList.push(textEl);
const equationId = `${block.block_id}_equation_${++equationIndex}`;
const svgEl = window.MathJax.tex2svg(textEl.equation.content).children[0];
// 由于生成的公式 svg 的高度使用 ex 單位,這里乘以一個(gè)參數(shù)來轉(zhuǎn)成近似的 px 單位。
const svgHeight = svgEl的ex高度 * equationCoefficient;
const svgWidth = svgEl的ex寬度 * equationCoefficient;
textEl.equation.svgHTML = svgEl.outerHTML;
textEl.equation.imageHeight = svgHeight;
textEl.equation.imageWidth = svgWidth;
textEl.equation.id = equationId;
equationSVGList.push({
id: equationId,
svg: svgEl.outerHTML,
height: svgHeight,
width: svgWidth,
});
}
});
});
// 非本地預(yù)覽的時(shí)候進(jìn)行公式轉(zhuǎn)圖片并上傳 CDN(本地環(huán)境由于跨域無法上傳 CDN)。
if (!isPreview) {
OSS 上傳配置...
// 公式 svg 轉(zhuǎn)圖片文件然后上傳 OSS。
const res = await allSvgsToImgThenUpload(equationSVGList);
equationElementList.forEach(element => {
從res中找到當(dāng)前公式元素對(duì)應(yīng)的圖片,放入element.equation.imageUrl中
});
}
};
我們先找出所有文檔塊中的內(nèi)聯(lián)公式,將其轉(zhuǎn)換為svg,存儲(chǔ)到公式塊中。如果當(dāng)前是發(fā)送模式,不是預(yù)覽模式,我們就做進(jìn)一步處理,使用allSvgsToImgThenUpload 將svg再轉(zhuǎn)化為圖片的CDN地址,此處的allSvgsToImgThenUpload方法讓我們并行處理所有的公式圖片,具體如下:
function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {
// 將每個(gè) SVG 字符串映射到轉(zhuǎn)換函數(shù)的調(diào)用上。
const conversionPromises = svgObjList.map(svgObj => svgToImgThenUpload(svgObj));
// 使用 Promise.all 等待所有圖片完成轉(zhuǎn)換和上傳。
return Promise.all(conversionPromises);
}
核心的svgToImgThenUpload方法如下,它負(fù)責(zé)將svg轉(zhuǎn)化為圖片,并上傳CDN:
/** svg 轉(zhuǎn)圖片,并上傳到 OSS。*/
function svgToImgThenUpload(svgObj: SvgObj): Promise<{ id: string; url: string }> {
return new Promise((resolve, reject) => {
const { width, height, id } = svgObj;
const svgString = svgObj.svg;
if (!width || !height) {
reject(`公式svg大小獲取失敗: ${id}`);
return;
}
// 生成 svg 的 base64 編碼。
const encodedString = encodeURIComponent(svgString).replace(/'/g, '%27').replace(/"/g, '%22');
const dataUrl = 'data:image/svg+xml,' + encodedString;
// 使用 canvas 渲染 svg 并轉(zhuǎn)為圖片。
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
// 為了保證圖片清晰,渲染使用三倍寬高,實(shí)際大小使用兩倍寬高。
canvas.width = width * 3;
canvas.height = height * 3;
canvas.style.width = `${width * 2}px`;
canvas.style.height = `${height * 2}px`;
const ctx = canvas.getContext('2d');
ctx && ctx.drawImage(image, 0, 0, width * 3, height * 3);
// 將 canvas 內(nèi)容導(dǎo)出為 Blob。
canvas.toBlob(async blob => {
創(chuàng)建 File 對(duì)象并上傳 CDN,返回 CDN 鏈接;
}, 'image/png');
};
image.onerror = reject;
image.src = dataUrl;
});
}
為了保證圖片清晰,渲染使用三倍寬高,實(shí)際大小使用兩倍寬高。
至此,我們讓公式塊帶上了圖片CDN地址。在發(fā)送時(shí)交給后端,轉(zhuǎn)為郵件附件,即可正常顯示了。
五、向前一步
好在最終我們克服了重重困難,終于來到了轉(zhuǎn)譯工具升級(jí)的Showcase環(huán)節(jié)。之前有提到我們有fallbackRenderer,主要用于針對(duì)未識(shí)別或者未支持的文檔塊,渲染其默認(rèn)提示,最初我們渲染的效果只是一個(gè)簡單的提示,比如:【畫板暫不支持解析】這樣的文案提示。
但是我們很快發(fā)現(xiàn):1. 這些提示并不明顯,可以做一個(gè)類似Antd Alert的提示;2. 在發(fā)送時(shí)要過濾掉這些提示,因?yàn)槭菬o效信息;3. 在預(yù)覽時(shí)需要讓用戶能夠看到實(shí)際的發(fā)送效果,需要有開關(guān)能隱藏這些提示;4. 發(fā)送時(shí)存在這些不支持的塊時(shí),需要攔截提示用戶是否去調(diào)整文檔內(nèi)容,以達(dá)到信息更全效果更好的發(fā)送效果。往往是這些細(xì)枝末節(jié)的體驗(yàn)與引導(dǎo),能夠真正抓住用戶的心,讓用戶覺得這個(gè)轉(zhuǎn)譯工具是真的貼心、好用。
因此,我們快速增加了這些具體的引導(dǎo)與提示優(yōu)化,具體效果如下:
六、大功告成
經(jīng)過這一番波折,我們最終成功地將飛書云文檔轉(zhuǎn)譯為兼容大多數(shù)客戶端的HTML郵件。這不僅僅是一項(xiàng)技術(shù)上的挑戰(zhàn),更是一次心態(tài)和耐心的考驗(yàn)。
在這個(gè)過程中,我們深刻體會(huì)到在前端開發(fā)中,面對(duì)各種瀏覽器和客戶端的不一致性時(shí),需要的不僅僅是技術(shù)能力,還需要靈活應(yīng)變和堅(jiān)持不懈的精神。希望本文能為同樣遇到這些問題的開發(fā)者提供一些思路和幫助。
未來,我們還將繼續(xù)優(yōu)化我們的解決方案,并探索更多高效的方法,期待與大家分享更多經(jīng)驗(yàn)。如果有任何問題或建議,歡迎在評(píng)論區(qū)留言討論!
感謝閱讀!
引用:
*文/ Nicolas、Asher
本文屬得物技術(shù)原創(chuàng),未經(jīng)得物技術(shù)許可嚴(yán)禁轉(zhuǎn)載,否則依法追究法律責(zé)任!
*請(qǐng)認(rèn)真填寫需求信息,我們會(huì)在24小時(shí)內(nèi)與您取得聯(lián)系。