把飞书云文档变成HTML邮件:问题挑战与解决历程(飞书云文档怎么编辑保存)

把飞书云文档变成HTML邮件:问题挑战与解决历程一、背景
云文档转HTML邮件
基于公司内部的飞书办公套件,早在去年6月,我们就建设了将飞书云文档转译成HTML邮件的能力,方便同学们在编写邮件文

一、背景

将云文档转换为HTML 电子邮件

基于我们内部的飞书办公套件,去年六月我们建立了将飞书云文档转换为HTML 电子邮件的功能。这为学生在创建电子邮件文档和发送电子邮件时提供了更好的体验并提高了效率。

当前的问题

为了能够被邮件客户端识别,飞书云文档内容必须转换为HtmlEmail格式,以兼容不同版本的邮件客户端,尤其是Windows Outlook。此格式不支持许多现代HTML5 和CSS3 功能。飞书云文档中的富文本块格式必须进行翻译,而且有些格式根本不支持,导致编辑和预览提交不一致。

因此,我们对翻译工具进行了重大修改和升级,高度还原最常用的文档块。

达到效果

经过我们坚持不懈的努力,最终取得了较好的修复效果。

2、系统架构的修改

飞书云文档结构

在开始升级方法之前,您应该简单了解一下飞书云文档的信息结构(更多信息请参考官方API)。这是一个简短的解释。

TypeScript 轻松定义文档块的平铺数组,并根据block_id 和parent_id 确定每个块的父子关系,形成一棵树。

{

/** 文档块的唯一标识符。 */

block_id: 字符串;

/** 父区块ID。 */

Parent_id: 字符串;

/** 子块ID 列表。 */

Children: 字符串[];

/** 文档块类型。 */

block_type: 块类型;

/** 页面块内容的描述。 */

页? { . };

/** 文本块内容的描述。 */

文本? { . };

/** 标题1 块内容的描述。 */

标题1? { . };

/** 有序列表的块内容的描述。 */

订单? { . };

/** 表块内容的描述。 */

表? { . };

//总共43 个块定义。

.

}[];

让我们用思维导图作为一个简单的例子。整个文档块的树结构如下所示:根据缩进,某些块自然会成为父块。这样的):

遗留架构

那么我们是如何创建翻译工具的第一个版本的呢?不幸的是,当时恢复的需求较低,因此代码主要重用了现有的部分实现。整体的架构设计基本上可以用一个词来概括:流程。定向编程:

上图:经过提取和封装后,主流程核心代码仍然包含528行。下图:文档块的核心翻译渲染代码。基本上没有写恢复样式,文档块渲染成一体。通过开关和外壳各一个。

新版本架构设计

这次我们痛定思痛,决定尽可能恢复翻译工具的翻译效果,很多同学都投入了。因此,首先需要考虑和迫切解决的问题是:在旧架构下我们如何进行代码扩展、多人协作、高效造型、风格还原?

国际奥委会和DI

是的,凭借我们丰富的多人协作和项目经验,我们很快意识到这个问题需要通过基于IoC 设计原则的DI 来实现。

那么什么是IoC和DI呢?根据维基百科的解释,控制反转(简称IoC)是一种可以用来减少计算机代码之间耦合的设计原则,而最常见的方法就是依赖(dependency),即依赖关系。注射,缩写为DI)。

这可能有点抽象,但是你可以看一下新版本的架构设计,一睹其精妙之处。

在此架构中,您可以看到主要文档块预处理和渲染器反向依赖于核心createDocTranspiler。这与我们的常识理解相反(文档的翻译渲染依赖于每个块的预处理和渲染器,这就是倒置)。这种依赖逆转使得我们在多人协作过程中将每个学生开发的预处理器和渲染器的开发和调试分开,而不会互相影响或影响。避免编码过程中的冲突,大大提高多人协同开发的效率。同时,实现方式是依赖注入(DI)或注册,因此未来将有可能支持“绘图板”和“文档小部件”等更深层次的文档块,以轻松支持新的预处理器和渲染器。可以注册增量和隔离代码开发。如果您想取消特定文档块的呈现,只需取消注册即可。这允许文档块渲染的快速插入和拔出,并且还具有高度可扩展性。

整个翻译主干代码如下:

创建翻译器、注册预处理器和注册渲染器

转换渲染、后处理和完整渲染。代码行数已减少至仅138 行。

函数式编程

接下来我们重点关注IoC架构的核心实现createDocTranspiler。根据维基百科的解释,IoC是面向对象编程的设计原则。那么我们真的在使用面向对象编程吗?

很明显不是。在JavaScript 编程中,面向对象编程显然不是社区所推崇的设计原则。从React 16.8 开始,示例包括函数组件和钩子。编程开始取代相对臃肿的类组件编程。这是前端的一个常见概念。你可以在Google上了解更多关于函数式编程概念的内容,所以这里不再赘述。

我们来说说核心代码createDocTranspiler为什么采用函数式编程。首先,它非常优雅且易于使用。接下来,由于JavaScript 函数闭包,我们使用了一些变量(必须将其设为私有)。或者,可以直接在函数内声明和定义方法。你不必担心它像类一样被暴露(TS 有一个private 关键字,但这只是一个限制,并不意味着它不能使用)。第三是简单,不需要维护类。如果您有主动销毁场景,则可以在返回的结构中公开销毁函数。

整个核心代码为:

上图:用于存储各种预处理器和渲染器并实现文档树的递归渲染的内置变量和函数。下图:返回和公开的函数,用于注册各种预处理器、渲染器和翻译器渲染。整个核心代码只有158行,非常简洁。

“JS 中的CSS”

那么我们就来说说我们是如何完成如此大量的风格修复工作的。在模板字符串中编写内联样式(style=“width: 100px;”)非常繁琐,并且会降低代码的可读性,因为文档树必须转换为最终完整的HTML 字符串将非常低。开发调试效率也很低。

为了解决这个问题,我们立即想到了如何编写React CSSProperties,并研究了它的源码实现。事实上,我们将CSSProperties 中的驼峰式属性名称转换为内联样式连字符属性名称,并且还处理了Webkit、Browser 属性。数值类型的一些属性的值在转换时会自动添加ms、Moz、O、px等前缀后缀。详细代码如下:

//样式处理工具函数库。

从“react”导入{ CSSProperties }。

/* 如果是,则该值可以是数字类型,无需指定px CSSProperties 属性。 */

const isUnitlessNumber: 记录字符串, boolean={

//.

fontWeight: 真实,

lineClamp: 真,

lineHeight: 真实,

//.

//SVG 相关属性。

fillOpacity: 真,

FloodOpacity: 真实,

stopOpacity: 真,

//.

};

//每个浏览器的CSS 属性名称的前缀。

const cssPropertyPrefixes=[\’Webkit\’, \’ms\’, \’Moz\’, \’O\’];

//对于isUnitlessNumber,输入每个浏览器的CSS 属性名称的前缀。

Object.keys(isUnitlessNumber).forEach(属性={

cssPropertyPrefixes.forEach(前缀={

isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`]=

isUnitlessNumber[属性];

});

});

导出{ isUnitlessNumber };

/** 对于CSSProperties 属性值,可以添加单位px 以返回合法值。 */

导出函数addCSSPropertyUnitT extends keyof CSSProperties(property: T, value: CSSProperties[T]) {

if (值类型===\’数字\’ !isUnitlessNumber[属性]) {

//如果值为数字,需要添加单位px,则添加单位px。

返回“${value}px”。

}

返回值;

}

接下来,编写createInlineStyles 方法。输入参数是Recordstring 和CSSProperties 大样式对象。

/* 将CSSProperties 转换为内联样式字符串(例如{ width: 100, flex: 1 }=style=\’width: 100px;\’)。 */

导出函数ConvertCSSPropertiesToInlineStyle(style: CSSProperties) {

const upperCaseReg=/[A-Z]/g;

const inlineStyle=Object.keys(样式)

。地图(

属性=

`${属性.替换(

大写字母注册,

matchLetter=`-${matchLetter.toLowerCase()}`,

)}: ${addCSSPropertyUnit(属性作为CSSProperties 中的键, style[property])};`,

。参加(\’ \’);

如果(内联样式){

返回`style=\’${inlineStyle}\’`。

}

返回\’\’;

}

/** 根据输入样式表(CSSProperties 格式)输出内联样式表(style=\’.\’ 格式的字符串)。示例:{container: {position: \’relative\’ }、title: { fontSize: 18 } }={container: \’style=\’position:relative;\’\’、title: \’style=\’font-size: 18px;\’\’ }。 */

导出函数createInlineStylesT extends string(styles: { [P in T]: CSSProperties }) {

const inlineStyles={} as { [P in T]: string };

Object.keys(styles).forEach(name={

inlineStyles[名称]=ConvertCSSPropertiesToInlineStyle(styles[名称]);

});

返回内联样式。

}

至此,架构已经基本优化完毕,整个项目团队正处于一个高度协作、密切沟通和配合的开发过程中,但实际情况并不是那么顺利,尤其是在Windows Outlook支持方面。没有。由于邮箱客户端不断遇到不同样式的兼容性问题,开发学生必须对电子邮件HTML 和CSS 开发进行一些“考古”。

3.烦人的Outlook兼容性问题

改造系统架构后,我们首先尝试实现有序列表和无序列表解决方案的版本。测试时的结果超出了所有人的预期。

原始文档是什么样子的?

Outlook 网页版的外观

Outlook for Windows 中的外观

在网页版Outlook 中,通过开发者工具可以看到每个项目的justify-content 样式都消失了,但在Windows Outlook 中基本上没有任何样式留下。

Outlook 兼容性差

我们以前从未编写过HTML 电子邮件,因此我们从未考虑过HTML 与不同电子邮件客户端的兼容性。在网上查找了一些资料后,我对Outlook对HTML的兼容性差感到震惊。

首先,Windows Outlook没有自己的HTML渲染引擎,而是使用Word的渲染引擎来解析HTML。不支持HTML5 和CSS3。这意味着飞书所有的文档风格还原和文本解析都必须使用非常古老的技术来实现,以保证最大程度的兼容性。

根据官方文档,显示、位置、最大宽度、最大高度等样式都是不兼容的。

通常:

新的CSS3功能如flex、grid等不可用。

与布局相关的组件只能使用表格进行布局。

仅允许内联样式。仅使用table、tr、td、span、img、a 和div 标签。

有时只有div 的边距被正确识别,其他标签可能会导致填充和边距消失。

如果div 包含表格,则不能使用行高,因为它的边距会混淆表格的背景颜色。

使用div 时要小心。 Outlook 可能会将div 转换为p。具体逻辑目前还不清楚。

控制图像大小的唯一方法是使用img 标签的宽度和高度属性。

如此严格的技术限制意味着后续开发在某些情况下会遇到很多兼容性问题。在这种情况下,为了确保最大的兼容性,我决定不失时机地重新设计每个后续组件的实现,并反向重写无序和有序列表的呈现方式。

4.各种文档块的恢复

首先,将翻译工具原来的“一级标题”美化为“九级标题”,使其看起来像一个会飞的文档。您需要知道如何组织检索到的数据并将其转换为HTML。

标题栏(标题1-9)

标题组件并不是最难实现的。 title组件的数据结构如下:

原始实现

在原来的翻译工具中,我们编写了常用的方法来处理文本内容的下划线、删除线、斜体、加粗、高亮等,生成行距元素,以及在外边框上添加h1到h9。最后将其子节点的渲染结果添加到最后。

实施新版本

这是因为默认的标题样式不满足恢复要求,无法处理对齐。我们会用

创建一个标题组件并添加您自己的样式来恢复您的飞书文档。

案例BlockType.HEADING1: {

块为const blockH1=HeadingBlock;

constalign=blockH1.Heading1.style.align;

const style=makeHeadingStyles({ type: block.block_type,align });

文本+=`div ${styles.HeadingStyles}${transpileTextElements(

blockH1.block_id,

blockH1.Heading1.elements,

这是预览。

)}/div`;

//使用renderChildBlocks方法渲染当前块的所有子节点。

文本+=renderChildBlocks(blockH1.block_id);

休息;

}

其中,makeHeadingStyles是生成样式的方法,将每个组件的样式描述为配置项,以方便后续更改。新样式侧重于调整行高、行距、下划线与文本的距离以及对齐方式。

//部分拦截makeHeadingStyles方法。

导出函数makeHeadingStyles(params: MakeHeadingStylesParams) {

const {类型,对齐方式}=参数;

const BasicStyle: CSSProperties={

行高: 1.4,

LetterSpacing: \’-.02em\’,

字体粗细: 500,

color: \’#1f2329\’,

textAlign: getTextAlignStyle(对齐|| 1),

};

letHeadingStyles: CSSProperties={};

开关(类型){

案例块类型.HEADING1:

标题样式={

字体大小: 26,

边距Top: 26,

边距底部: 10,

.基本风格,

};

休息;

//定义标题2-9 的样式.

//……

//将样式对象转换为内联样式字符串。

返回createInlineStyles\’HeadingStyles\'({HeadingStyles:HeadingStyles });

}

最后,通过发送电子邮件来测试生成的HTML 的有效性。

修订前

修改后

无序列表(项目符号)和有序列表(有序)

原始实现

列表的数据结构与标题块几乎相同,因此这里不再详细解释。原始翻译工具使用原生“ul”和“li”直接渲染无序列表,使用“ol”和“li”渲染有序列表。通过顺序遍历同级节点列表来创建连续的项目符号文档。在块之前和之后添加“ul”和“/ul”,在连续有序块之前和之后添加“ol”和“/ol”。列表中的每一项都显示为“li”。

原生的“ul”和“ol”标记样式很难看,因此为了方便起见,此修订版不允许使用伪类元素或其他方式来独立维护列表层次结构。

实施新版本

列表中不同级别的标记在飞书文档中看起来完全不同。

无序列表

有序列表

为了决定每个列表项使用哪种标记,我们首先需要预处理Faish 提供的数据,并用级别和序列号标记每个列表块。

数据预处理器

由于飞书API没有提供序列号的有序列表,用户可以随意更改序列号,所以我们的想法是:如果有序列表被非空文档块以外的文本块截断,则将重新计算序列号。具体方法如下。

/** 确定文本块是否为空白文本类型的简单方法。 */

导出函数isEmptyTextBlock(block: DocBlockText | undefined) {

if (文档块类型是文本且非空|| 文档块类型不是文本) {return false;}

否则{返回真;}

}

/** 对于每个文本块,计算到文本树根节点的深度,并找到有序列表块的序号。 */

导出函数processBlocks(blocks: DocBlock[]) {

const blockDepths={}; //记录每个节点距离根节点的深度。

const blockOrder={}; //记录每个节点在其同类型兄弟节点中的顺序。如果被其他类型的块打断则重新计数。

函数calcBlockFields(块: DocBlock,深度: 数字) {

blockDepths[block.block_id]=深度;

//查找有序列表的序号。

if(文本块类型已排序){

1、找到与兄弟节点列表BrotherBlocks相同类型的兄弟节点列表likeBrotherBlocks。

2、在上面两个列表中找到当前节点的索引BrotherBlocksIndex、相似的BrotherBlocksIndex。

3. 找到兄弟链表中的前一个节点prevBrotherBlock。和相似兄弟列表中的前一个节点prevSimilarBrotherBlock;

if (当前节点是兄弟节点列表中的第一个节点|| 当前节点是同类型兄弟节点列表中的第一个节点|| 上一个兄弟节点不是同类型兄弟节点是非空文本堵塞) {

blockOrder[block.block_id]=1;

} 除此之外{

blockOrder[block.block_id]=前一个相似兄弟编号+ 1

}

}

递归处理子节点。如果当前节点的类型为Grid_column、tabel_cell、callout 或quoter_container,则深度将重置为1(calcBlockFields(childrenBlock, 1))。否则calcBlockFields(childrenBlock, 深度+ 1);

}

从根节点开始递归处理。 calcBlockFields(rootBlock, 0);

将记录序号和深度(blockOrder, blockDepths)添加到每个节点(block.Depth, block.order)。

}

这样,每个列表项都知道其在文档中的级别,并且有序列表也知道其序列号。

原始方法根本不处理文本块的缩进,因此在渲染子节点时可以使用飞书的缩进规则为常规文本块(表格、网格等以外的文本块)创建容器- 添加25px 的填充。左边。

接下来,使用常用方法来渲染有序列表和无序列表的标记。

列表标签渲染器

/** 用于渲染列表的标签。 */

导入常量listMarkRender=(type: ListType, block: DocBlock)={

const {深度=1,顺序=1 }=块;

if (type===ListType.BULLET) {

const 样式=makeMarkerStyles(ListType.BULLET);

将标记: 设为字符串。

标记=按顺序“·”、“”、“”,每3 个周期,具体取决于深度。

返回`span ${styles.markContainerStyle}${marker}/span`。

} 除此之外{

const 样式=makeMarkerStyles(ListType.OR

DERED);
let markerGenerator: (num: number) => number | string;
markerGenerator = 按照深度,每三个一循环,依次为数字、数字转小写字母、数字转罗马数字;
return `<span ${styles.markContainerStyle}>${markerGenerator(order)}.</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>&nbsp</span>
<span ${styles.todoContentStyles}>${transpileTextElements(
todoBlock.block_id,
done ? checkedTodoElements : originTodoElements,
isPreview,
)}</span>
</div>`;
text += renderChildBlocks(todoBlock.block_id, false);
return text;
};

最终呈现效果

表格(非电子表格)块

文档中另一个最重要的模块就是表格。表格是另一类比较特殊的文本块,他内部并不包含正文。整个表格实际上由三层文档块组合而成,它们的数据结构如下:

依据数据结构和我们的代码模式设计,我们需要使用嵌套的渲染器来实现表格的绘制。

表格渲染器(table块)

由于飞书API中清楚地提供了行数、列数以及列宽,我们可以较为轻松地绘制出大致的表格。这里的重点是要准确地处理合并单元格数据,将它们精准地使用在表格的每个 标签上。表格渲染器的代码如下:

渲染器:
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(\’&\’, \’&amp;\’)
.replaceAll(\'<\’, \’&lt;\’)
.replaceAll(\’>\’, \’&gt;\’)
.replaceAll(\’\”\’, \’&quot;\’)
.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>&nbsp;</span></td>
<td><pre ${styles.codePreStyles}>&nbsp;</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

*文/ fred

本文属得物技术原创,更多精彩文章请看:得物技术

未经得物技术许可严禁转载,否则依法追究法律责任!

#以上关于把飞书云文档变成HTML邮件:问题挑战与解决历程的相关内容来源网络仅供参考,相关信息请以官方公告为准!

原创文章,作者:CSDN,如若转载,请注明出处:https://www.sudun.com/ask/92209.html

Like (0)
CSDN的头像CSDN
Previous 2024年6月25日
Next 2024年6月25日

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注