Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 www.久久,中文字幕一区二区三区在线播放 ,99亚洲精品

          整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          萬能轉換:R圖和統計表轉成發表級的Word、PPT、Excel、HTML

          能轉換:R圖和統計表轉成發表級的Word、PPT、Excel、HTML、Latex、矢量圖等

          R包export可以輕松的將R繪制的圖和統計表輸出到 Microsoft Office (Word、PowerPoint和Excel)、HTML和Latex中,其質量可以直接用于發表。

          • 你和PPT高手之間,就只差一個iSlide
          • Excel改變了你的基因名,30% 相關Nature文章受影響,NCBI也受波及


          特點

          1. 可以用命令將交互式R圖或ggplot2Latticebase R圖保存到Microsoft Word、Powerpoint或其他各種位圖或矢量格式。
          2. 完全可編輯的Powerpoint矢量格式輸出,支持手動整理繪圖布局。
          3. 統計分析的輸出保存為Excel、Word、PowerPoint、Latex或HTML文檔的表格形式。
          4. 自定義R輸出格式。

          安裝

          export包可以在Windows、Ubuntu和Mac上跨平臺運行。不過有些Mac發行版默認情況下沒有安裝cairo設備,需要自行安裝。如果Mac用戶已安裝XQuartz,這個問題就解決了,它可以從https://www.xquartz.org/免費獲得。

          官方CRAN發布 (以不能用)

          install.packages("export")

          從 Github 安裝 (推薦


          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")
          


          該包主要包括以下幾種轉換

          • graph2bitmap
          • graph2office
          • graph2vector
          • rgl2bitmap 轉換3D圖
          • table2office
          • table2spreadsheet
          • table2tex
          • graph2bitmap: 將當前R圖保存到bmp文件中
          • graph2png: 將當前R圖保存到png文件中
          • graph2tif: 將當前R圖保存到TIF文件中
          • graph2jpg: 將當前R圖保存為JPEG文件

          使用幫助信息如下

          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, ...)
          
          • aspectr: 期望縱橫比。如果設置為空,則使用圖形設備的縱橫比。
          • width: 所需寬度(英寸);可以與期望的縱橫比aspectr組合。
          • height: 所需高度(英寸);可以與期望的縱橫比aspectr組合。
          • scaling: 按一定比例縮放寬度和高度。
          • font: PNG和TIFF輸出中標簽所需的字體; Windows系統默認為Arial,其他系統默認為Helvetica。
          • bg: 所需的背景顏色,例如“白色”或“透明”。
          • cairo: 邏輯,指定是否使用Cairographics導出。
          • tiffcompression: 用于TIF文件的壓縮。
          • jpegquality: JPEG壓縮的質量。

          準備開始

          安裝完 export包后,先調用該包

          library(export)
          

          用ggplot2繪圖

          library(ggplot2)
          library(datasets)
          
          x=qplot(Sepal.Length, Petal.Length, data = iris,
                  color = Species, size = Petal.Width, alpha = I(0.7))
          

          qplot()的意思是快速作圖,利用它可以很方便的創建各種復雜的圖形,其他系統需要好幾行代碼才能解決的問題,用qplot只需要一行就能完成。

          使用半透明的顏色可以有效減少圖形元素重疊的現象,要創建半透明的顏色,可以使用alpha圖形屬性,其值從0(完全透明)到1(完全不透明)。更多ggplot2繪圖見ggplot2高效實用指南 (可視化腳本、工具、套路、配色) (往期教程更有很多生物信息相關的例子)。

          鳶尾花(iris)是數據挖掘常用到的一個數據集,包含150個鳶尾花的信息,每50個取自三個鳶尾花種之一(setosa,versicolourvirginica)。每個花的特征用下面的5種屬性描述萼片長度(Sepal.Length)、萼片寬度(Sepal.Width)、花瓣長度(Petal.Length)、花瓣寬度(Petal.Width)、類(Species)。

          在console里展示數據圖 (長寬比自己調節):

          導出圖形對象

          # 需運行上面的ggplot2繪圖
          # Create a file name
          # 程序會自動加后綴
          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
          # 注意這個x,是運行命令,展示圖像
          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)
          

          轉換后的圖形:

          與Office系列的交互

          大部分圖的細節修改都是用代碼完成的,不需要后續的修飾;但如果某一些修改比較特異,不具有程序的通用性特征,或實現起來比較困難,就可以考慮后期修改。比如用AI文章用圖的修改和排版。熟悉PPT的,也可以用PPT,這時R的圖導出PPT,就要用到graph2office系列函數了。

          graph2ppt: 將當前R圖保存到Microsoft Office PowerPoint/LibreOffice Impress演示文稿中。

          graph2doc:將當前的R圖保存到Microsoft Office Word/LibreOffice Writer文檔中。

          函數參數展示和解釋

          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, ...)
          
          • margins: 預設留白邊距向量。
          • paper: 紙張尺寸——“A5”至“A1”用于Powerpoint導出,或“A5”至“A3”用于Word輸出;默認“auto”自動選擇適合您的圖形的紙張大小。如果圖太大,無法在給定的紙張大小上顯示,則按比例縮小。
          • orient: 所需的紙張方向-“自動”,“縱向”或“橫向”; Word輸出默認為“自動”,Powerpoint默認為“橫向”。
          • vector.graphic: 指定是否以可編輯的向量DrawingML格式輸出。默認值為TRUE,在這種情況下,編輯Powerpoint或Word中的圖形時,可以先對圖形元素進行分組。如果設置為FALSE,則將該圖以300 dpi的分辨率柵格化為PNG位圖格式。(柵(shān)格化,是PS中的一個專業術語,柵格即像素,柵格化即將矢量圖形轉化為位圖。)

          同樣有3種導出方式

          # 需運行上面的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
          # 導出圖形對象
          graph2ppt(x=x, file=filen)
          graph2doc(x=x, file=filen, aspectr=0.5)
          ### 2. Get the plot from current screen device
          # 導出當前預覽窗口呈現的圖
          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
          # 導出自定義函數輸出的一系列圖
          graph2ppt(fun=plot.fun, file=filen, aspectr=0.5, append = TRUE)
          graph2doc(fun=plot.fun, file=filen, aspectr=0.5, append = TRUE)
          

          導出到office(ppt和word)中的圖形,是可編輯的:

          其它導出到ppt的例子(設置長寬比)

          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)
          

          禁用矢量化圖像導出

          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)
          

          輸出矢量圖

          • graph2svg: 將當前的R圖保存為SVG格式
          • graph2pdf: 將當前的R圖保存為PDF格式
          • graph2eps: 將當前的R圖保存為EPS格式

          函數參數解釋

          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, ...)
          
          • fallback_resolution: dpi中的分辨率用于柵格化不支持的矢量圖形。
          #需運行上面的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
          # 導出圖形對象
          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")
          # 導出當前預覽窗口呈現的圖
          ### 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")
          # 導出自定義函數輸出的一系列圖
          ### 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")
          

          轉換3D圖形

          rgl2png: 將當前的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

          • table2excel: 導出統計輸出到Microsoft Office Excel/ LibreOffice Calc電子表格中的一個表.
          • table2csv:將統計輸出以CSV格式導出到表中(“,”表示值分隔,“。”表示小數)
          • table2csv2: 將統計輸出以CSV格式導出到表中(“;”表示值分隔,”,”表示小數)
          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, ...)
          
          • sheetName: 一個字符串,給出創建的新工作表的名稱(僅針對type==”XLS”)。它必須是惟一的(不區分大小寫),不受文件中任何現有工作表名稱的影響。
          • digits:除具有p值的列外,要顯示所有列的有效位數的數目。
          • digitspvals:具有p值的列要顯示的有效位數的數目。
          # 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
          # 輸出對象
          table2csv(x=x,file=filen, digits = 1, digitspvals = 3, add.rownames=TRUE)
          # 屏幕輸出導出到文件
          ### 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)
          # 更多參數
          # 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")
          

          原始數據的表格:

          轉換格式之后的,在console中的數據:


          文件(csv和excel)中表格數據:

          導出為Word中的表,再也不用復制粘貼調格式了 table2office

          table2ppt: 導出統計輸出到Microsoft Office PowerPoint/ LibreOffice Impress演示文稿中的表

          table2doc: 將統計輸出導出到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
          

          將表格數據導出到ppt和word中:

          table2tex

          table2html: 導出統計輸出到HTML表。

          table2tex(x = NULL, file = "Rtable", type = "TEX", digits = 2,
            digitspvals = 2, trim.pval = TRUE, summary = FALSE, standAlone = TRUE,
            add.rownames = FALSE, ...)
          

          summary:是否匯總數據文件。

          standAlone:導出的Latex代碼應該是獨立可編譯的,還是應該粘貼到另一個文檔中。

          add.rownames:是否應該將行名添加到表中(在第一列之前插入一列)。

          # 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)
          

          導出到html或tex中的表格數據:


          R統計和作圖

          • Graphpad,經典繪圖工具初學初探
          • 維恩(Venn)圖繪制工具大全 (在線+R包)
          • 在R中贊揚下努力工作的你,獎勵一份CheatShet
          • 別人的電子書,你的電子書,都在bookdown
          • R語言 - 入門環境Rstudio
          • R語言 - 熱圖繪制 (heatmap)
          • R語言 - 基礎概念和矩陣操作
          • R語言 - 熱圖簡化
          • R語言 - 熱圖美化
          • R語言 - 線圖繪制
          • R語言 - 線圖一步法
          • R語言 - 箱線圖(小提琴圖、抖動圖、區域散點圖)
          • R語言 - 箱線圖一步法
          • R語言 - 火山圖
          • R語言 - 富集分析泡泡圖
          • R語言 - 散點圖繪制
          • R語言 - 韋恩圖
          • R語言 - 柱狀圖
          • R語言 - 圖形設置中英字體
          • R語言 - 非參數法生存分析
          • R語言 - 繪制seq logo圖
          • WGCNA分析,簡單全面的最新教程
          • psych +igraph:共表達網絡構建
          • 一文學會網絡分析——Co-occurrence網絡圖在R中的實現
          • 一文看懂PCA主成分分析
          • 富集分析DotPlot,可以服
          • 基因共表達聚類分析和可視化
          • R中1010個熱圖繪制方法
          • 還在用PCA降維?快學學大牛最愛的t-SNE算法吧, 附Python/R代碼
          • 一個函數抓取代謝組學權威數據庫HMDB的所有表格數據
          • 文章用圖的修改和排版
          • network3D: 交互式桑基圖
          • network3D 交互式網絡生成
          • Seq logo 在線繪制工具——Weblogo
          • 生物AI插圖素材獲取和拼裝指導
          • ggplot2高效實用指南 (可視化腳本、工具、套路、配色)
          • 圖像處理R包magick學習筆記
          • SOM基因表達聚類分析初探
          • 利用gganimate可視化全球范圍R-Ladies(R社區性別多樣性組織)發展情況
          • 一分鐘繪制磷脂雙分子層:AI零基礎入門和基本圖形繪制
          • AI科研繪圖(二):模式圖的基本畫法
          • 你知道R中的賦值符號箭頭(<-)和等號(=)的區別嗎?
          • R語言可視化學習筆記之ggridges包
          • 利用ComplexHeatmap繪制熱圖(一)
          • ggplot2學習筆記之圖形排列
          • R包reshape2,輕松實現長、寬數據表格轉換
          • 用R在地圖上繪制網絡圖的三種方法
          • PCA主成分分析實戰和可視化 附R代碼和測試數據
          • iTOL快速繪制顏值最高的進化樹!
          • 12個ggplot2擴展包幫你實現更強大的可視化
          • 編程模板-R語言腳本寫作:最簡單的統計與繪圖,包安裝、命令行參數解析、文件讀取、表格和矢量圖輸出
          • R語言統計入門課程推薦——生物科學中的數據分析Data Analysis for the Life Sciences
          • 數據可視化基本套路總結
          • 你知道R中的賦值符號箭頭<-和等號=的區別嗎?
          • 使用dplyr進行數據操作30例
          • 交集intersect、并集union、找不同setdiff
          • R包reshape2,輕松實現長、寬數據表格轉換
          • 1數據類型(向量、數組、矩陣、 列表和數據框)
          • 2讀寫數據所需的主要函數、與外部環境交互
          • 3數據篩選——提取對象的子集
          • 4向量、矩陣的數學運算
          • 5控制結構
          • 6函數及作用域
          • 7認識循環函數lapply和sapply
          • 8分解數據框split和查看對象str
          • 9模擬—隨機數、抽樣、線性模型
          • 1初識ggplot2繪制幾何對象
          • 2圖層的使用—基礎、加標簽、注釋
          • 3工具箱—誤差線、加權數、展示數據分布
          • 4語法基礎
          • 5通過圖層構建圖像
          • 6標度、軸和圖例
          • 7定位-分面和坐標系
          • 8主題設置、存儲導出
          • 9繪圖需要的數據整理技術
          • 創建屬于自己的調色板
          • 28個實用繪圖包,總有幾個適合你
          • 熱圖繪制
          • R做線性回歸
          • 繪圖相關系數矩陣corrplot
          • 相關矩陣可視化ggcorrplot
          • 繪制交互式圖形recharts
          • 交互式可視化CanvasXpress
          • 聚類分析factoextra
          • LDA分析、作圖及添加置信-ggord
          • 解決散點圖樣品標簽重疊ggrepel
          • 添加P值或顯著性標記ggpubr
          • Alpha多樣性稀釋曲線rarefraction curve
          • 堆疊柱狀圖各成分連線畫法:突出組間變化
          • 沖擊圖展示組間時間序列變化ggalluvial
          • 桑基圖riverplot
          • 微生物環境因子分析ggvegan
          • 五彩進化樹與熱圖更配ggtree
          • 多元回歸樹分析mvpart
          • 隨機森林randomForest 分類Classification 回歸Regression
          • 加權基因共表達網絡分析WGCNA
          • circlize包繪制circos-plot
          • R語言搭建炫酷的線上博客系統
          • 28個實用繪圖包,總有幾個適合你
          • 熱圖繪制
          • R做線性回歸
          • 繪圖相關系數矩陣corrplot
          • 相關矩陣可視化ggcorrplot
          • 繪制交互式圖形recharts
          • 交互式可視化CanvasXpress
          • 聚類分析factoextra
          • LDA分析、作圖及添加置信-ggord
          • 解決散點圖樣品標簽重疊ggrepel
          • 添加P值或顯著性標記ggpubr
          • Alpha多樣性稀釋曲線rarefraction curve
          • 堆疊柱狀圖各成分連線畫法:突出組間變化
          • 沖擊圖展示組間時間序列變化ggalluvial
          • 桑基圖riverplot
          • 微生物環境因子分析ggvegan
          • 五彩進化樹與熱圖更配ggtree
          • 多元回歸樹分析mvpart
          • 隨機森林randomForest 分類Classification 回歸Regression
          • 加權基因共表達網絡分析WGCNA
          • circlize包繪制circos-plot
          • R語言搭建炫酷的線上博客系統
          • 維恩(Venn)圖繪制工具大全 (在線+R包)
          • R包circlize:柱狀圖用膩了?試試好看的弦狀圖
          • 獲取pheatmap聚類后和標準化后的結果
          • 增強火山圖,要不要試一下?
          • 一個震撼的交互型3D可視化R包 - 可直接轉ggplot2圖為3D
          • 贈你一只金色的眼 - 富集分析和表達數據可視化
          • 是Excel的圖,不!是R的圖
          • 道友,來Rstudio里面看動畫了
          • 用了這么多年的PCA可視化竟然是錯的!!!

          術背景

          在前面一篇博客中,我們介紹了一些關于在Windows系統上安裝R Studio來編寫R Markdown,最后編譯成Beamer的演示文檔的過程。而在Windows系統的使用過程中發現,編譯過程還是要依賴于pdflatex的,而根據以往的經驗,在Windows上搞Latex還是略顯麻煩。所以在前一篇博客中,才給出了本地編譯成Tex文件,最后切換到Overleaf上去編譯成pdf的下策。這里我們將給出另外一套解決方案:通過TinyTex來部署R Markdown環境,TinyTeX 是益輝大神基于 TeX Live 管理工具修改的 R 拓展包。

          在寫一些學術演示文檔時,經常有可能用到Beamer——一種Latex的學術風PPT模板,比如下圖所示的這種:

          這種風格的演示文檔有幾個明顯的優點:簡約、嚴肅、可以用Latex敲公式和推導、可微調、定制化程度高,而且一般都是免費的。當然也有一些明顯的缺點:寫Latex麻煩,部署Latex環境更麻煩。因此,更多的人都是硬著頭皮在Overleaf上寫Latex,這也是被逼無奈。但是我們看到在各大平臺用Markdown寫博客,或者在開源代碼倉庫中用Markdown寫說明文檔,都是非常的美觀,那有沒有可能用Markdown替代Latex,至少在演示文檔上用Markdown替代Latex呢?對于這個問題,R Markdown給出了肯定的回答。

          TinyTex環境搭建

          打開R Studio的R語言終端窗口,輸入如下指令:

          install.packages('tinytex')
          tinytex::install_tinytex()
          

          安裝完成后,在R Studio界面點擊Preview->Beamer,就可以直接彈出編譯好的pdf的預覽:

          這樣我們就完成了本地的完整R Markdown的環境搭建,可以通過Markdown來寫Beamer了。

          總結概要

          上一篇博客《通過R Studio用Markdown寫Beamer》的最后遺留了一個問題,那就是如何在本地win11的系統環境下快捷方便的構建一個pdflatex的環境。常規方案都是安裝一個TexLive和下載一大堆的擴展文件,或者用Docker的方案去構建一個完整的環境,但是都很費時間精力。偶然的情況下看到了益輝大神基于TexLive修改了一個R的擴展包TinyTex。經過測試,這毫無疑問是目前在本地構建R Markdown完整環境的最佳解決方案。

          版權聲明

          本文首發鏈接為:https://www.cnblogs.com/dechinphy/p/tinytex.html

          作者ID:DechinPhy

          更多原著文章請參考:https://www.cnblogs.com/dechinphy/

          打賞專用鏈接:https://www.cnblogs.com/dechinphy/gallery/image/379634.html

          騰訊云專欄同步:https://cloud.tencent.com/developer/column/91958

          參考鏈接

          1. https://www.cnblogs.com/dechinphy/p/rmarkdown.html
          2. https://www.jianshu.com/p/4fa7c107fe9c
          3. https://yihui.org/tinytex/cn/

          背景

          云文檔轉HTML郵件

          基于公司內部的飛書辦公套件,早在去年6月,我們就建設了將飛書云文檔轉譯成HTML郵件的能力,方便同學們在編寫郵件文檔和發送郵件時,都能有較好的體驗和較高的效率。

          當下問題

          要被郵件客戶端識別,飛書云文檔內容需要轉譯成HtmlEmail格式,該格式為了兼容各種版本的郵箱客戶端(特別是Windows Outlook),對于現代HTML5和CSS3的很多特性是不支持的,飛書云文檔的多種富文本塊格式都需要轉譯,且部分格式完全不支持,造成編輯和預覽發送不一致的情況。

          因此,我們對轉譯工具做了一次大改版和升級,對大部分常用文檔塊做了高度還原。

          實現效果

          經過我們的不懈努力,最終實現了較為不錯的還原效果:


          二、系統架構改版

          飛書云文檔結構

          在展開我們如何做升級之前,先要簡單了解下飛書云文檔的信息結構(詳情可參考官方API),在此僅做簡單闡述。

          TypeScript簡要定義,一個平鋪的文檔塊數組,根據block_id和parent_id確定各塊的父子關系,從而形成一個樹:

          {
            /** 文檔塊唯一標識。*/
            block_id: string;
            /** 父塊 ID。*/
            parent_id: string;
            /** 子塊 ID 列表。*/
            children: string[];
            /** 文檔塊類型。*/
            block_type: BlockType;
            /** 頁面塊內容描述。*/
            page?: { ... };
            /** 文本塊內容描述。*/
            text?: { ... };
            /** 標題 1 塊內容描述。*/
            heading1?: { ... };
            /** 有序列表塊內容描述。*/
            ordered?: { ... };
            /** 表格塊內容描述。*/
            table?: { ... };
            // 總計 43 個塊定義。
            ...
          }[];

          我們用思維導圖簡單舉例,整個文檔塊的樹結構大致是這樣的,有些塊根據縮進遞進,會形成父子關系,有些塊天然就會成為父塊(比如表格、引用等):

          舊版架構

          那么我們初版轉譯工具是怎么做的呢,比較遺憾的是,由于當時需求的還原度訴求較低,我們的代碼主要是復用現有部分實現,整體的架構設計可以用一個詞概括,基本是面向過程編程:

          上方的圖:經過了一些抽取和封裝,主流程核心代碼仍有528行;下方的圖:文檔塊核心轉譯渲染代碼,基本沒有寫任何還原樣式,通過Switch、Case來一個個渲染文檔塊。

          新版架構設計

          這次我們痛定思痛,勢必要將轉譯工具的轉譯效果做到盡可能還原,也有了多位同學一起投入。因此首要思考和急需解決的問題來了:在老舊的架構下,如何才能做好代碼擴展、多人協同、高效樣式編寫以及樣式還原?

          IoC 與DI

          是的,幾乎一剎那,憑借過往豐富的多人協同以及項目經驗,很快我們就想到了,這個事需要基于IoC的設計原則,并通過DI的方式來實現。

          那么什么是IoC和DI呢,根據維基百科的解釋:控制反轉(Inversion of Control,縮寫為IoC),是面向對象編程中的一種設計原則,可以用來減低計算機代碼之間的耦合度,其中最常見的方式叫做依賴注入(Dependency Injection,縮寫為DI)。

          這么說可能有點抽象,我們可以看下新版的架構設計,從中便能窺見其精妙:

          可以看到,關鍵的文檔塊預處理和渲染器,在該架構中是反向依賴核心的createDocTranspiler了,與我們常識中的理解(文檔轉譯渲染依賴各個塊的預處理和渲染器)是相反的,這就是控制反轉(IoC),通過這樣的依賴倒置,我們能夠把多人協同過程中,由各個同學負責開發的預處理器和渲染器的開發調試解耦出去,互不影響、互不依賴,且合碼過程中基本沒有代碼沖突,大大提效了多人協同合作開發。同時由于實現的方式是依賴注入(DI),或者說注冊,未來我們想要支持更加深水區的文檔塊,比如「畫板」、「文檔小組件」等,可以很方便地注冊新的預處理器和渲染器,做增量且解耦的代碼開發;如果想要取消對某一個文檔塊的渲染,直接unregister即可,由此也實現了文檔塊渲染的快速插拔和極高的可拓展性。

          整個轉譯主干代碼如下:

          創建轉譯器,注冊預處理器,注冊渲染器

          轉譯渲染,后處理,完成渲染。代碼行數縮減到只有138行。


          函數式編程

          接下來我們將目光聚焦到核心函數createDocTranspiler中,這塊是IoC架構的核心實現,根據維基百科描述,IoC是面向對象編程中的一種設計原則,那么我們真的是用面向對象的編程方式嗎?

          顯然不是,我們是高標準的前端同學,在JavaScript編程中,面向對象編程顯然不是社區推崇的設計原則,以React框架為例,早在React 16.8版本,就推出了函數組件和Hooks編程,以取代較為臃腫的類組件編程,這些都是前端老生常談的理念了,大家可以去Google深入學習函數式編程理念,在此不再贅述。

          這里說一下為什么核心代碼createDocTranspiler我要用函數式編程,說一下我的理解:第一是非常優雅,用起來很舒服;第二是得益于JavaScript函數閉包,一些局部(想要private化)的變量或者方法,直接在函數內聲明和定義即可,不用擔心像類一樣會暴露出去(盡管TS有private關鍵字,但只是約束,不代表你不能用);第三是簡單,無需維護類的實例,若有主動銷毀場景,返回的結構中暴露銷毀函數即可。

          整個核心代碼如下:

          上方的圖:內置的變量和函數,用于存儲各種預處理器和渲染器,并實現文檔樹的遞歸渲染;下方的圖:返回并暴露出去的函數,用于注冊各種預處理器、渲染器,以及轉譯渲染。整個核心代碼只有158行,非常精煉。

          “CSS-in-JS”

          然后再來說一下如此大量的樣式還原工作,我們是如何實現的。由于我們要把文檔樹轉譯成最終的一個完整的HTML字符串,在模板字符串中寫內聯樣式(style="width: 100px;...")會非常痛苦,代碼可讀性會很差,開發調試的效率也會很低。

          為了解決這個問題,我們立即想到了React CSSProperties的寫法,并調研了一下它的源碼實現,其實就是將CSSProperties中的駝峰屬性名,轉換成內聯樣式中連字符屬性名,并額外處理了Webkit、ms、Moz、O等瀏覽器屬性前綴,同時針對number 類型的部分屬性的值,轉換時自動加上了px后綴。詳細代碼如下:

          // 樣式處理工具函數庫。
          import { CSSProperties } from 'react';
          
          
          /* 是否是,值可能是數字類型,且不需要指定 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'];
          
          
          // 針對 isUnitlessNumber,填充各瀏覽器 CSS 屬性名前綴。
          Object.keys(isUnitlessNumber).forEach(property => {
            cssPropertyPrefixes.forEach(prefix => {
              isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`] =
                isUnitlessNumber[property];
            });
          });
          
          
          export { isUnitlessNumber };
          
          
          /** 針對 CSSProperties 屬性值,可能添加單位 px,并返回合法的值。*/
          export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
            if (typeof value === 'number' && !isUnitlessNumber[property]) {
              // 值是數字類型,且需要添加單位 px,則添加單位 px。
              return `${value}px`;
            }
            return value;
          }

          然后再編寫createInlineStyles方法,入參即為Record<string, CSSProperties> 大樣式對象:

          /* 將 CSSProperties 轉為內聯 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 '';
          }
          
          
          /** 根據輸入的樣式表(CSSProperties 格式),輸出內聯樣式表(格式為 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;
          }

          至此架構優化的差不多了,整個項目組進入了高度協同、緊密溝通合作的開發中,整個開發過程其實并不是特別順利,尤其是在對Windows Outlook郵箱客戶端的支持上,各種樣式兼容問題Case層出不窮,以至于我們的開發同學不得不去對郵箱HTML和CSS開發進行“考古”。

          三、Outlook麻煩的兼容性問題

          在改版系統架構后,我們先試著實現了一版有序列表和無序列表的解決方案,結果在測試中,我們得到了出乎所有人意料之外的結果:

          原本文檔的樣子

          網頁版Outlook中的樣子

          Windows的Outlook中的樣子

          在網頁版Outlook中,通過開發工具可以看到每一項的justify-content樣式消失了,而在Windows Outlook中,基本沒什么樣式還留著了。

          Outlook糟糕的兼容性

          我們之前從未編寫過HTML郵件,也就完全沒考慮過各個郵件客戶端對HTML的兼容性問題。在網上找到一些資料后,我們被Outlook對HTML的兼容性之差所震驚。

          首先,Windows Outlook并沒有一個自己的HTML渲染引擎,而是使用Word的渲染引擎去解析HTML。它不支持HTML5和CSS3,也就是說我們為了保證最大的兼容性,所有的飛書文檔樣式還原和文本解析都要用極為陳舊的技術去實現。

          據官方文檔所示,display、position、max-width、max-height等樣式全都不兼容。

          總的來說:

          • 不能使用任何CSS3新特性,比如flex、grid等;
          • 和布局有關的組件,只能使用table來進行布局;
          • 只能使用行內樣式;盡量只使用table、tr、td、span、img、a、div這幾個標簽;
          • 只有div的margin會偶爾被正確地識別,其它標簽都有可能讓padding和margin消失;
          • 如果一個div內部含有table,它的margin會讓table背景色和邊框混亂;無法使用line-height;
          • 小心使用div,Outlook有時候會把他轉換為p,具體邏輯還不明確;
          • 圖片唯一能夠控制大小的方法就是使用img標簽上的width屬性和height屬性。

          技術上的限制如此苛刻,就意味著在后面的開發中,我們還會遇到很多特定情況的兼容性問題。在這種情況下,為了最大限度地保證兼容性,我們決定及時止損,重新設計后面各個組件的實現方式,并將無序列表和有序列表的渲染方法推倒重來,再次編寫。

          四、各類型文檔塊的還原

          首先,我們將轉譯工具原有的「一級標題」到「九級標題」美化為接近飛書文檔的樣子。我們需要梳理下將會獲得的數據,來看看如何將它們轉譯為HTML。

          標題塊(heading 1-9)

          標題組件應該是實現難度最低的一個,一個標題組件的數據結構如下:

          原版實現方式

          在原版的轉譯工具中,我們編寫了通用方法來處理文本內容的下劃線、刪除線、斜體、粗體、高亮色等進行處理,生成行間元素,然后在外部框上<h1>-<h9>。最終在后面加上它的子節點渲染結果。

          新版實現方式

          由于默認的heading樣式無法滿足還原度,且并沒有處理對齊方式。我們將使用 <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 方法來渲染當前塊的所有子節點。
            text += renderChildBlocks(blockH1.block_id);
            break;
          }


          其中makeHeadingStyles是我們生成樣式的方法,這樣可以將各個組件的樣式寫成配置項,方便后續修改。新的樣式中,我們著重對行高、行距、下劃線距文字距離、對齊方式進行了調整:

          // 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;
              // 對Heading2-9的樣式進行定義...
            // ......
          
          
            // 將樣式對象轉成行間樣式字符串。
            return createInlineStyles<'headingStyles'>({ headingStyles: headingStyles });
          }

          最后發郵件,測試一下生成的HTML的效果:

          改版之前

          改版之后


          無序列表(bullet)與有序列表(ordered)

          原版實現方式

          列表的數據結構與標題塊大致相同,在此不再贅述。在原來的轉譯工具中,我們使用原生的<ul>和<li>來直接渲染無序列表,<ol><li>來渲染有序列表。我們順序遍歷兄弟節點的列表,為連續的bullet文檔塊的前后加上<ul></ul>,連續的ordered塊前后加上<ol>和</ol>。列表中的每一項,則渲染成<li>。

          由于原生<ul>和<ol>的marker樣式較丑,我們無法使用偽類元素等手段改善它的樣式,為了方便,我們這次改版將自己維護列表的層級關系。

          新版實現方式

          在飛書文檔中,不同層級的列表,marker長得完全不同:

          無序列表

          有序列表

          為了判斷我們每個列表項要使用什么樣的marker,首先我們需要對飛書給我們的數據進行預處理,為每個列表塊標注它的層級和序號。

          • 數據預處理器

          由于飛書API沒有提供有序列表的序號,這個序號用戶又可以隨便更改,所以我們的思路是:如果有序列表中間被非空文檔塊以外的文本塊截斷,序號則重新開始計算。具體方法如下:

          /** 判斷文本塊是否為空白文本類型的快。*/
          export function isEmptyTextBlock(block: DocBlockText | undefined) {
            if (文檔塊的類型為text且不為空 || 文檔塊類型不為text) {返回false;}
            else {返回true;}
          }
          
          
          /** 為每個文本塊計算它到文本樹根節點的深度,為有序列表塊找到它的序號。*/
          export function processBlocks(blocks: DocBlock[]) {
            const blockDepths = {}; // 記錄各節點距根節點的深度。
            const blockOrder = {}; // 記錄各節點在同類兄弟節點中的順序,被其他類型的塊打斷的時候將重新計數。
            function calcBlockFields(block: DocBlock, depth: number) {
              blockDepths[block.block_id] = depth;
          
          
              // 為有序列表找到它的序號。
              if (文本塊類型為 ordered) {
                1. 找到同級兄弟節點列表 brotherBlocks 與同類型同級兄弟節點列表 similarBrotherBlocks;
                2. 找到當前節點在上述兩個列表中的索引 brotherBlocksIndex,similarBrotherBlocksIndex;
                3. 找到兄弟節點列表中的前一個節點 prevBrotherBlock。以及同類兄弟列表的前一個節點 prevSimilarBrotherBlock;
          
          
                if (當前節點是兄弟節點列表中的第一個節點 || 當前節點是同類兄弟節點列表中的第一個節點 || 前一個兄弟節點不是同類兄弟節點,且前一個兄弟節點是非空的文本塊) {
                  blockOrder[block.block_id] = 1;
                } else {
                  blockOrder[block.block_id] = 上一個同類兄弟的編號 + 1
                }
              }
              遞歸處理子節點。如果當前節點的類型為 grid_column、tabel_cell、callout、quoter_container 的時候,深度重置為 1(calcBlockFields(childrenBlock, 1)),其他情況 calcBlockFields(childrenBlock, depth + 1);
            }
          
          
            從根節點開始遞歸處理。calcBlockFields(rootBlock, 0);
            將記錄的序號和深度(blockOrder, blockDepths)添加到每個節點中(block.depth, block.order);
          }

          這樣,每個列表項都知道了自己在文檔中的層級,有序列表也知道了自己的序號。

          由于原來的方法中完全沒有處理過文本塊的縮進,我們根據飛書縮進的規律,為普通的文本塊(表格、柵格等以外的文本塊)在渲染子節點時為子節點的容器添加25px的padding-left。

          接下來我們使用一個通用的方法為有序列表和無序列表渲染它們的marker。

          • 列表標號渲染器
          /** 渲染列表的標簽。*/
          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 = 按照深度,每三個一循環,依次為 '?'、'?'、'?';
              return `<span ${styles.markContainerStyle}>${marker}</span>`;
            } else {
              const styles = makeMarkerStyles(ListType.ORDERED);
              let markerGenerator: (num: number) => number | string;
              markerGenerator = 按照深度,每三個一循環,依次為數字、數字轉小寫字母、數字轉羅馬數字;
              return `<span ${styles.markContainerStyle}>${markerGenerator(order)}.</span>`;
            }
          };

          對于無序列表,標號每三層一循環,順序為 '?'、'?'、'?'。對于有序列表,標號格式也是每三層一循環,順序為阿拉伯數字、小寫字母、羅馬數字。

          使用列表的標號渲染器渲染標號部分,然后簡單的在<div>中將標號<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;
          };
          • 最終呈現結果

          可以看到,我們在滿足使用的前提下以最高的兼容性比較完美的還原了飛書文檔中的有序列表和無序列表。

          待辦事項

          既然漂亮地還原了有序列表和無序列表,待辦事項塊就簡單得多了。代辦事項的具體的數據結構如下:

          可以看到,待辦事項的數據中包含了該條待辦事項是否已完成的數據,從飛書文檔的樣式可以看出,已完成的條目會統一被劃上刪除線,并刪除下劃線樣式。最終的渲染器和樣式生成方法如下:

          待辦事項渲染器

          • 渲染器:
          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 ? '已完成標記圖片地址' : '未完成標記圖片地址';
            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;
          };

          最終呈現效果


          表格(非電子表格)塊

          文檔中另一個最重要的模塊就是表格。表格是另一類比較特殊的文本塊,他內部并不包含正文。整個表格實際上由三層文檔塊組合而成,它們的數據結構如下:

          依據數據結構和我們的代碼模式設計,我們需要使用嵌套的渲染器來實現表格的繪制。

          表格渲染器(table塊)

          由于飛書API中清楚地提供了行數、列數以及列寬,我們可以較為輕松地繪制出大致的表格。這里的重點是要準確地處理合并單元格數據,將它們精準地使用在表格的每個 <td>標簽上。表格渲染器的代碼如下:

          • 渲染器:
          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;
            // 計算出整個表格的整體寬度。
            const totalWidth = column_width.reduce((acc, cur) => acc + cur, 0);
            let text = `
              <div ${tableStyles.tableWrapperStyles}>
                <table width="${totalWidth}" ${tableStyles.tableStyles}>
              `;
            // 初始化單元格處理標記數組,記錄哪些單元格已被處理過數據。
            const processed = Array.from({ length: row_size }, () => Array(column_size).fill(false));
            let mergeIndex = 0; // 追蹤當前 merge_info 索引。
            
            for (let i = 0; i < row_size; i++) {
              text += '<tr>';
              for (let j = 0; j < column_size; ) {
                從 merge_info[mergeIndex] 獲取當前合并信息 col_span 與 row_span,確保 col_span 和 row_span 至少為 1;
                
                // 如果當前單元格未處理過,則進行處理。
                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
                  }>
                      // 與之前的文檔塊直接渲染所有的子節點不同,表格需要在單元格內精準的渲染對應的 table cell 塊,所以此處使用 renderSpecifyBlock 方法。
                      ${renderSpecifyBlock(children[i * column_size + j])}
                    </td>
                    `;
                  // 更新處理標記數組,標記當前單元格及其被合并的單元格為已處理,
                  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; // 跳過被合并的單元格對應的 merge_info。
                } else {
                  j++;
                  mergeIndex++;
                }
              }
              text += '</tr>';
            }
            text += '</table></div>';
          
            return text;
          };

          為了處理合并單元格數據,我們維護了一個已處理標記數組processed,處理完一個單元格后,我們將當前單元格與被它合并的單元格都標記為已處理,來跳過他們的處理與渲染。這里需要特別注意,飛書文檔的接口偶爾會返回錯誤的合并單元格數據:{ row_span: 0, col_span: 0 },這個現象已經反饋給飛書,我們在34-37行做了兼容處理。

          為了最大限度的兼容性,我們堅持能用標簽屬性設置的樣式,就不使用CSS來設置。與列表的渲染不同,在表格中我們沒有像列表渲染一樣先預處理數據再生成DOM字符串,而是使用了在遍歷中邊處理數據邊生成DOM字符串的方法。

          在表格的渲染中,我們沒有像之前的代碼一樣使用renderChildBlocks把所有子文檔塊都渲染出來添加進HTML字符串中,而是使用了新的renderSpecifyBlock方法,給定block_id來渲染特定的子文檔塊。

          單元格容器渲染器(table cell塊)

          單元格容器的渲染器則簡單的多,他沒有任何數據處理,只繪制一個容器用于承載內部的所有子節點,并在內部將單元格內的子節點渲染出來

          • 渲染器:
          const tableCellRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {
            const styles = makeTableCellStyles();
          
            return `
              <div ${styles.tableCellWrapperStyle}>
                ${renderChildBlocks(block.block_id, true)}
              </div>`;
          };

          最終呈現效果

          圖片塊

          圖片塊理應也是一個很容易實現的文檔塊。但在實際處理過程中,由于飛書的API只提供圖片源文件的寬高,并沒有提供云文檔中用戶縮放過后的圖片寬高,我們需要實現一個能滿足絕大多數使用場景的圖片縮放算法來盡可能還原文檔中的圖片樣式。

          圖片塊的數據結構如下:

          限制圖片大小

          源文件的寬高一般都遠大于圖片在云文檔中的實際寬高。我決定使用以下的方法來限制住圖片在文檔中的寬高:

          • 若圖片處于類似表格的文檔塊中,則寬度撐滿父容器;
          • 若圖片不在類似表格的文檔塊中,則按照maxHeight: 780(限制最大高度避免長圖過長),maxWidth: 820(飛書文檔最大寬度),使用如下的算法來計算縮放后的圖片大小:

          • 最后我們在樣式中設置maxWidth = 100%(在Windows的Outlook中不會生效)來在大多數客戶端中保證圖片寬度不會撐出父容器。

          上述算法的代碼實現如下:

          • 查找父容器中是否有表格容器:
          /** 根據 id 找到塊。*/
          function findNodeById(blocks: DocBlock[], id: string) {
            return blocks.find(b => b.block_id === id);
          }
          
          /** 檢查當前塊的父節點中有沒有表格或柵格塊。*/
          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}\}` // 實際發送時,用 ${block_id} 作為占位符,給到服務端填充圖片附件地址。
            }"`;
            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;
          };

          在預覽的時候,我們將圖片地址設為圖片的base64,直接展示。最后傳給后端的HTML字符串中,我們將圖片地址設為一個占位符,供后端解析并轉化為郵件附件地址。

          使用表格來布局的幾個文檔塊

          由于Windows Outlook對CSS的支持程度很差,我們在對一些復雜文檔塊進行排版布局的時候不能使用flex、grid等。且display和position屬性在大多情況下也不會像預期那樣正常生效。我們為了最大的兼容性只能使用表格來解決一切排版問題。代碼塊、高亮塊、柵格等幾個文檔塊就都遵循了這個思路,使用表格來解決排版。我們以最復雜的代碼塊作為代表來進行介紹。

          代碼塊

          飛書云文檔中免不了會出現代碼,所以較好的進行代碼塊的還原也是個重要的工作。代碼塊還原的一個難點就是數據的處理,首先介紹下代碼塊的數據結構:

          理想的話,我們希望element中每一項為一行代碼,我們挨個進行渲染即可。但實際上,element的內容和普通文本類似,只要文本的樣式不變(比如設為斜體、加粗等),這些文本就都會被塞到同一個element項中。

          舉例說明,對于下列文檔中的代碼塊,實際飛書API返回的代碼只有兩項element:

          其中,最后一個大括號被單獨拆成一項令人費解,不過好在代碼塊中,只要一項element的后面出現了另一項,那就一定意味著換行。這減少了我們的處理難度。

          • 數據處理

          我們的大體思路,是將代碼拆分成一個二維數組。第一維中的每一維度為一行代碼,每行代碼中的每一維度為拆分后零碎的代碼塊。我們先將所有的element中的內容根據換行符\n拆分成一個個細小的子塊,同時將與HTML有關的字符替換成HTML編碼,避免這些字符混入HTML字符串中被當做標簽解析:

          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,
                  },
                });
              });
          });

          然后將這些子塊按照換行符進行分組,變成我們需要的二維數組:

          /** 將拆分好的代碼塊列表按行進行分組。*/
          const groupingCodeList = (list: TextElement[] = []) => {
            const result: TextElement[][] = [];
            let currentGroup: TextElement[] = [];
          
            list.forEach(item => {
              // 將當前字符串添加到當前分組。
              currentGroup.push(item);
              // 如果字符串包含 '\n',則結束當前分組,并準備開始新的分組。
              if (item.text_run?.content.includes('\n')) {
                result.push(currentGroup);
                currentGroup = [];
              }
            });
          
            // 最后將 currentGroup 中剩余的項目加入 result。
            if (currentGroup.length > 0) {
              result.push(currentGroup);
            }
          
            return result;
          };

          至此,我們知道了代碼行數n和每行代碼中的小代碼塊有哪些。我們要做的就是將它們放進一個n行2列的表格中

          • 代碼塊渲染器

          最終,代碼塊渲染器的代碼如下。為了保證最大的兼容性,我們使用空的表格行作為內邊距,盡量避免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;
          
            // 分割的時候把 HTML 有關的字符換成 HTML 編碼,避免這些正文直接被當成 HTML 渲染。
            上文中提到的對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;
          };


          • 樣式生成:

          我們本次不會實現代碼的高亮,只會顯示同一種顏色的代碼。對表格中的每個單元格,我們使用pre標簽包裹來保留代碼中的制表符、空格,并將fontFamily設置為'Courier New', Courier, monospace,使用等寬字體來呈現代碼。

          • 最終呈現效果:

          行間公式

          飛書云文檔除文本外支持多種行間元素的插入,比如@文檔、內聯文件、內聯公式等,在此我們介紹下最為復雜的內聯公式是怎么處理的。

          行間公式的數據位于各個文檔塊的內聯塊中,以文本塊為例,具體數據如下:

          我們要做的,就是將公式轉換為圖片,然后在郵件中將公式作為圖片附件來處理。

          • 公式數據的預處理

          我們將使用MathJax來將公式表達式轉換為svg,用于用戶預覽。在發送時,我們將MathJax生成的svg通過cavans轉化為png圖片,上傳到CDN,并將CDN地址給到后端,進行郵件附件轉換。

          公式的預處理方法如下:

          // 公式發送時,后端渲染完成的圖片,其展示的高度的系數。
          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 => {
                // 文本塊內容中包含公式時,轉譯為 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 單位,這里乘以一個參數來轉成近似的 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,
                  });
                }
              });
            });
          
            // 非本地預覽的時候進行公式轉圖片并上傳 CDN(本地環境由于跨域無法上傳 CDN)。
            if (!isPreview) {
              OSS 上傳配置...
              // 公式 svg 轉圖片文件然后上傳 OSS。
              const res = await allSvgsToImgThenUpload(equationSVGList);
              equationElementList.forEach(element => {
                從res中找到當前公式元素對應的圖片,放入element.equation.imageUrl中
              });
            }
          };

          我們先找出所有文檔塊中的內聯公式,將其轉換為svg,存儲到公式塊中。如果當前是發送模式,不是預覽模式,我們就做進一步處理,使用allSvgsToImgThenUpload 將svg再轉化為圖片的CDN地址,此處的allSvgsToImgThenUpload方法讓我們并行處理所有的公式圖片,具體如下:

          function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {
            // 將每個 SVG 字符串映射到轉換函數的調用上。
            const conversionPromises = svgObjList.map(svgObj => svgToImgThenUpload(svgObj));
          
            // 使用 Promise.all 等待所有圖片完成轉換和上傳。
            return Promise.all(conversionPromises);
          }

          核心的svgToImgThenUpload方法如下,它負責將svg轉化為圖片,并上傳CDN:

          
          /** svg 轉圖片,并上傳到 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 并轉為圖片。
              const image = new Image();
              image.onload = () => {
                const canvas = document.createElement('canvas');
                // 為了保證圖片清晰,渲染使用三倍寬高,實際大小使用兩倍寬高。
                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 內容導出為 Blob。
                canvas.toBlob(async blob => {
                  創建 File 對象并上傳 CDN,返回 CDN 鏈接;
                }, 'image/png');
              };
          
              image.onerror = reject;
              image.src = dataUrl;
            });
          }

          為了保證圖片清晰,渲染使用三倍寬高,實際大小使用兩倍寬高。

          至此,我們讓公式塊帶上了圖片CDN地址。在發送時交給后端,轉為郵件附件,即可正常顯示了。

          • 最終呈現效果

          五、向前一步

          好在最終我們克服了重重困難,終于來到了轉譯工具升級的Showcase環節。之前有提到我們有fallbackRenderer,主要用于針對未識別或者未支持的文檔塊,渲染其默認提示,最初我們渲染的效果只是一個簡單的提示,比如:【畫板暫不支持解析】這樣的文案提示。

          但是我們很快發現:1. 這些提示并不明顯,可以做一個類似Antd Alert的提示;2. 在發送時要過濾掉這些提示,因為是無效信息;3. 在預覽時需要讓用戶能夠看到實際的發送效果,需要有開關能隱藏這些提示;4. 發送時存在這些不支持的塊時,需要攔截提示用戶是否去調整文檔內容,以達到信息更全效果更好的發送效果。往往是這些細枝末節的體驗與引導,能夠真正抓住用戶的心,讓用戶覺得這個轉譯工具是真的貼心、好用。

          因此,我們快速增加了這些具體的引導與提示優化,具體效果如下:

          六、大功告成

          經過這一番波折,我們最終成功地將飛書云文檔轉譯為兼容大多數客戶端的HTML郵件。這不僅僅是一項技術上的挑戰,更是一次心態和耐心的考驗。

          在這個過程中,我們深刻體會到在前端開發中,面對各種瀏覽器和客戶端的不一致性時,需要的不僅僅是技術能力,還需要靈活應變和堅持不懈的精神。希望本文能為同樣遇到這些問題的開發者提供一些思路和幫助。

          未來,我們還將繼續優化我們的解決方案,并探索更多高效的方法,期待與大家分享更多經驗。如果有任何問題或建議,歡迎在評論區留言討論!

          感謝閱讀!


          引用:

          • https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/list
          • https://github.com/facebook/react/blob/81d4ee9ca5c405dce62f64e61506b8e155f38d8d/packages/react-dom-bindings/src/shared/CSSProperty.js#L8-L57


          *文/ Nicolas、Asher

          本文屬得物技術原創,未經得物技術許可嚴禁轉載,否則依法追究法律責任!


          主站蜘蛛池模板: 国产精品电影一区二区三区| 黑巨人与欧美精品一区| 国产麻豆剧果冻传媒一区 | 久久久久人妻精品一区| 精品国产免费一区二区| 波多野结衣一区二区三区| 国产在线精品一区二区不卡| 日韩伦理一区二区| 亚洲a∨无码一区二区| 久久亚洲日韩精品一区二区三区| 久久精品国产一区二区| 午夜视频在线观看一区二区| 中文字幕无码不卡一区二区三区| 老熟妇仑乱视频一区二区 | 国产精品免费大片一区二区| 天天综合色一区二区三区| 国产精品高清视亚洲一区二区| 亚洲无删减国产精品一区| 无码人妻久久一区二区三区免费| 亚洲色无码一区二区三区| 成人免费区一区二区三区 | 亚洲av无码一区二区三区人妖 | 国产a∨精品一区二区三区不卡 | 成人精品视频一区二区| 亚洲av无码一区二区三区人妖| 无码中文字幕一区二区三区| 精品少妇人妻AV一区二区三区| 亚洲综合av一区二区三区不卡| 亚洲av日韩综合一区二区三区 | 国产精品亚洲不卡一区二区三区| 国产不卡视频一区二区三区| 嫩B人妻精品一区二区三区| 久久国产免费一区| 国产精品一区视频| 在线视频国产一区| 中文字幕一区在线播放| 日韩一区精品视频一区二区| 天堂一区人妻无码| 福利一区福利二区| 日韩一区二区久久久久久| 精品成人乱色一区二区|