把飞书云文档变成HTML邮件:问题挑战与解决历程|得物技术
目录
一、背景
1. 云文档转HTML邮件
2. 当下问题
3. 实现效果
二、系统架构改版
1. 飞书云文档结构
2. 旧版架构
3. 新版架构设计
三、Outlook麻烦的兼容性问题
四、各类型文档块的还原
1. 标题块(heading 1-9)
2. 无序列表(bullet)与有序列表(ordered)
3. 待办事项
4. 表格(非电子表格)块
5. 图片块
6. 使用表格来布局的几个文档块
五、向前一步
六、大功告成
一
背景
云文档转HTML邮件
当下问题
实现效果
二
系统架构改版
飞书云文档结构
在展开我们如何做升级之前,先要简单了解下飞书云文档的信息结构(详情可参考官方API),在此仅做简单阐述。
{/** 文档块唯一标识。*/block_id: string;/** 父块 ID。*/parent_id: string;/** 子块 ID 列表。*/children: string[];/** 文档块类型。*/block_type: BlockType;/** 页面块内容描述。*/page?: { ... };/** 文本块内容描述。*/text?: { ... };/** 标题 1 块内容描述。*/heading1?: { ... };/** 有序列表块内容描述。*/ordered?: { ... };/** 表格块内容描述。*/table?: { ... };// 总计 43 个块定义。...}[];
旧版架构
新版架构设计
IoC与DI
整个转译主干代码如下:
创建转译器,注册预处理器,注册渲染器
转译渲染,后处理,完成渲染。代码行数缩减到只有138行。
函数式编程
整个核心代码如下:
左图:内置的变量和函数,用于存储各种预处理器和渲染器,并实现文档树的递归渲染;右图:返回并暴露出去的函数,用于注册各种预处理器、渲染器,以及转译渲染。整个核心代码只有158行,非常精炼。
“CSS-in-JS”
// 样式处理工具函数库。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;}
/* 将 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;}
三
Outlook麻烦的兼容性问题
不能使用任何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)
原版实现方式
新版实现方式
由于默认的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 方法的部分截取。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 });}
无序列表(bullet)与有序列表(ordered)
原版实现方式
新版实现方式
数据预处理器
/** 判断文本块是否为空白文本类型的快。*/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);}
列表标号渲染器
/** 渲染列表的标签。*/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>`;}};
无序列表与有序列表渲染器 新版有序列表渲染器 渲染器:
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块)
渲染器:
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;};
单元格容器渲染器(table cell块)
渲染器:
const tableCellRenderer: BlockRenderer = (block, isPreview, renderChildBlocks, _blocks) => {const styles = makeTableCellStyles();return `<div ${styles.tableCellWrapperStyle}>${renderChildBlocks(block.block_id, true)}</div>`;};
最终呈现效果
图片块
限制图片大小
若图片处于类似表格的文档块中,则宽度撑满父容器; 若图片不在类似表格的文档块中,则按照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;};
使用表格来布局的几个文档块
代码块
数据处理
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;};
代码块渲染器
渲染器:
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;};
样式生成:
最终呈现效果:
行间公式
公式数据的预处理
// 公式发送时,后端渲染完成的图片,其展示的高度的系数。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中});}};
function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {// 将每个 SVG 字符串映射到转换函数的调用上。const conversionPromises = svgObjList.map(svgObj => svgToImgThenUpload(svgObj));// 使用 Promise.all 等待所有图片完成转换和上传。return Promise.all(conversionPromises);}
/** 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;});}
最终呈现效果
五
向前一步
六
大功告成
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
往期回顾
1. Apache Flink类型及序列化研读&生产应用|得物技术
2. 可视化流量录制规则探索和实践|得物技术
3. 在得物的小程序生态实践
4. 客服测试流水线编排设计思路和准入准出应用|得物技术
5. 深入剖析时序Prophet模型:工作原理与源码解析|得物技术
文 / Nicolas、Asher
关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
“
扫码添加小助手微信
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: