、背景
云文檔轉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等樣式全都不兼容。
總的來說:
技術上的限制如此苛刻,就意味著在后面的開發中,我們還會遇到很多特定情況的兼容性問題。在這種情況下,為了最大限度地保證兼容性,我們決定及時止損,重新設計后面各個組件的實現方式,并將無序列表和有序列表的渲染方法推倒重來,再次編寫。
四、各類型文檔塊的還原
首先,我們將轉譯工具原有的「一級標題」到「九級標題」美化為接近飛書文檔的樣子。我們需要梳理下將會獲得的數據,來看看如何將它們轉譯為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只提供圖片源文件的寬高,并沒有提供云文檔中用戶縮放過后的圖片寬高,我們需要實現一個能滿足絕大多數使用場景的圖片縮放算法來盡可能還原文檔中的圖片樣式。
圖片塊的數據結構如下:
限制圖片大小
源文件的寬高一般都遠大于圖片在云文檔中的實際寬高。我決定使用以下的方法來限制住圖片在文檔中的寬高:
上述算法的代碼實現如下:
/** 根據 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郵件。這不僅僅是一項技術上的挑戰,更是一次心態和耐心的考驗。
在這個過程中,我們深刻體會到在前端開發中,面對各種瀏覽器和客戶端的不一致性時,需要的不僅僅是技術能力,還需要靈活應變和堅持不懈的精神。希望本文能為同樣遇到這些問題的開發者提供一些思路和幫助。
未來,我們還將繼續優化我們的解決方案,并探索更多高效的方法,期待與大家分享更多經驗。如果有任何問題或建議,歡迎在評論區留言討論!
感謝閱讀!
引用:
*文/ Nicolas、Asher
本文屬得物技術原創,未經得物技術許可嚴禁轉載,否則依法追究法律責任!
console.log
是 JavaScript 中一個常用的函數,用于向控制臺輸出信息。
console.log 雖然主要用于調試目的,但也包含了一些有趣的用法, console.log
不僅能輸出文本,還能以更豐富的方式展示信息。
以下代碼將在控制臺輸出?“ Hello, World!”:
console.log("Hello, World!");
你可以同時輸出多個值, console.log
會依次打印它們,并用空格隔開:
let name = "Runoob";
let age = 25;
console.log("Name:", name, "Age:", age);
輸出結果:
Name: Runoob Age: 25
console.log
可以打印變量的值和表達式的結果: let x = 10;
let y = 20;
console.log("x + y =", x + y);
輸出結果:
x + y = 30
console.log
支持 C 語言風格的格式化輸出,常見的占位符包括: %s
:字符串
%d
或 %i
:整數
%f
:浮點數
%o
:對象
console.log("Name: %s, Age: %d", "Runoob", 25);
輸出結果:
Name: Runoob, Age: 25
你可以直接打印對象和數組:
let person = { name: "Runoob", age: 25 };
let?numbers?=?[1,?2,?3,?4,?5];
console.log(person);
console.log(numbers);
輸出結果:
{ name: "Runoob", age: 25 }
[1, 2, 3, 4, 5]
2、花式玩法 接下來我們來看看 console.log 的花式玩法。 你可以通過使用 %c 占位符和 CSS 樣式來樣式化控制臺輸出: console.log("%cHello,?World!",?"color:?blue;?font-size:?20px;?background-color:?yellow;");
console.log("%cGradient text", "background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet); color: white; padding: 2px;");
console.log
語句中使用多個 %c
來應用不同的樣式: function styledLog(message, styles) {
$ npm init vite@latest
// - import { createApp } from 'vue'
// 引入 createSSRApp 替換 createApp
import { createSSRApp } from 'vue'
import App from './App.vue'
// - createApp(App).mount('#app')
// 暴露統一方法createApp
export function createApp() {
const app = createSSRApp(App)
// 先不掛載dom,在客戶端和服務端分別掛載
// - app.mount('#app')
return { app }
}
C++音視頻開發學習資料:點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
import { createApp } from "./main";
const { app } = createApp()
app.mount('#app')
// <script type="module" src="/src/main.js"></script>
<script type="module" src="/src/entry-client.js"></script>
重新運行項目,確保一切正常
const fs = require('fs')
const path = require('path')
const express = require('express')
const { createServer: createViteServer } = require('vite')
async function createServer() {
const app = express()
// 以中間件模式創建 Vite 應用,這將禁用 Vite 自身的 HTML 服務邏輯
// 并讓上級服務器接管控制
//
// 如果你想使用 Vite 自己的 HTML 服務邏輯(將 Vite 作為
// 一個開發中間件來使用),那么這里請用 'html'
const vite = await createViteServer({
server: { middlewareMode: 'ssr' }
})
// 使用 vite 的 Connect 實例作為中間件
app.use(vite.middlewares)
app.use('*', async (req, res) => {
const url = req.originalUrl
try {
// 1. 讀取 index.html
let template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 2. 應用 Vite HTML 轉換。這將會注入 Vite HMR 客戶端,
// 同時也會從 Vite 插件應用 HTML 轉換。
// 例如:@vitejs/plugin-react-refresh 中的 global preambles
template = await vite.transformIndexHtml(url, template)
// 3. 加載服務器入口。vite.ssrLoadModule 將自動轉換
// 你的 ESM 源碼使之可以在 Node.js 中運行!無需打包
// 并提供類似 HMR 的根據情況隨時失效。
const { render } = await vite.ssrLoadModule('/src/entry-server.js')
// 4. 渲染應用的 HTML。這假設 entry-server.js 導出的 `render`
// 函數調用了適當的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const appHtml = await render(url)
// 5. 注入渲染后的應用程序 HTML 到模板中。
const html = template.replace(`<!--ssr-outlet-->`, appHtml)
// 6. 返回渲染后的 HTML。
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
// 如果捕獲到了一個錯誤,讓 Vite 來修復該堆棧,這樣它就可以映射回
// 你的實際源碼中。
vite.ssrFixStacktrace(e)
console.error(e)
res.status(500).end(e.message)
}
})
app.listen(3000, () => console.log('server is running at http://localhost:3000'))
}
createServer()
import { createApp } from "./main"
import { renderToString } from 'vue/server-renderer'
export async function render(url) {
const { app } = createApp()
const html = renderToString(app)
return html
}
// <div id="app"></div>
<div id="app"><!--ssr-outlet--></div>
npm i express -D
node server
C++音視頻開發學習資料:點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
npm i cross-env -D
// package.json
"scripts": {
"dev": "vite",
// 開發環境
"start:ssr": "cross-env NODE_ENV=developemnt node server",
// 線上環境
"prod:ssr": "cross-env NODE_ENV=production node server",
"build": "vite build",
"preview": "vite preview",
// 打包客戶端
"build:client": "vite build --outDir dist/client",
// 打包服務端
"build:server": "vite build --outDir dist/server --ssr src/entry-server.js",
"build:ssr": "npm run build:client && npm run build:server"
},
// 引入serve-static
const serveStatic = require('serve-static')
// 判斷當前環境
const isProd = process.env.NODE_ENV === 'production'
// 根據當前環境使用vite或serve-static
let vite = await createViteServer({
server: { middlewareMode: 'ssr' }
})
if (isProd) {
app.use(serveStatic(path.resolve(__dirname, 'dist/client'), { index: false }))
} else {
app.use(vite.middlewares)
}
if (isProd) {
// 1. 讀取 index.html
template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
)
// 3. 加載服務器入口。vite.ssrLoadModule 將自動轉換
// 你的 ESM 源碼使之可以在 Node.js 中運行!無需打包
// 并提供類似 HMR 的根據情況隨時失效。
render = require('./dist/server/entry-server.js').render
} else {
// 1. 讀取 index.html
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 2. 應用 Vite HTML 轉換。這將會注入 Vite HMR 客戶端,
// 同時也會從 Vite 插件應用 HTML 轉換。
// 例如:@vitejs/plugin-react-refresh 中的 global preambles
template = await vite.transformIndexHtml(url, template)
// 3. 加載服務器入口。vite.ssrLoadModule 將自動轉換
// 你的 ESM 源碼使之可以在 Node.js 中運行!無需打包
// 并提供類似 HMR 的根據情況隨時失效。
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
}
C++音視頻開發學習資料:點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')
const isProd = process.env.NODE_ENV === 'production'
async function createServer() {
const app = express()
let vite = await createViteServer({
server: { middlewareMode: 'ssr' }
})
if (isProd) {
app.use(serveStatic(path.resolve(__dirname, 'dist/client'), { index: false }))
} else {
app.use(vite.middlewares)
}
app.use('*', async (req, res) => {
const url = req.originalUrl
let template
let render
try {
if (isProd) {
template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8'
)
render = require('./dist/server/entry-server.js').render
} else {
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
}
const appHtml = await render(url)
const html = template.replace(`<!--ssr-outlet-->`, appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
vite.ssrFixStacktrace(e)
console.error(e)
res.status(500).end(e.message)
}
})
app.listen(3000, () => console.log(`server is running at ${isProd ? '線上' : '開發'}環境: http://localhost:3000`))
}
createServer()
如果你對音視頻開發感興趣,或者對本文的一些闡述有自己的看法,可以在下方的留言框,一起探討。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。