包阅导读总结
1. 飞书云文档、HTML邮件、系统架构、兼容性、文档块还原
2.
– 文章讲述了将飞书云文档转译成HTML邮件的历程,包括背景、系统架构改版、兼容性问题及各类型文档块的还原等。
– 初版转译工具存在问题,新版基于IoC和DI原则进行架构优化,解决了代码扩展和多人协同等问题。
– 在样式还原工作中,采用特定方法处理,面对Outlook兼容性问题重新设计实现方式。
3.
– 背景
– 云文档转HTML邮件的需求
– 存在的问题及实现效果
– 系统架构改版
– 飞书云文档结构
– 旧版架构的不足
– 新版架构基于IoC和DI原则,实现多人协同、高效开发和可扩展性
– Outlook兼容性问题
– Outlook对HTML兼容性差,技术限制苛刻
– 重新设计组件实现方式
– 各类型文档块的还原
– 标题块的美化和样式调整
– 无序列表和有序列表的处理,包括层级和序号标注、marker渲染等
– 最终成果
– 实现较好的还原效果,项目成功
思维导图:
文章地址:https://mp.weixin.qq.com/s/FVKrC94NjnFvoI-7IyNjZw
文章来源:mp.weixin.qq.com
作者:Nicolas、Asher
发布时间:2024/6/24 10:11
语言:中文
总字数:11567字
预计阅读时间:47分钟
评分:84分
标签:飞书,云文档,HTML 邮件,电子邮件客户端兼容性,软件开发
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
一、背景
1. 云文档转HTML邮件
2. 当下问题
3. 实现效果
二、系统架构改版
1. 飞书云文档结构
2. 旧版架构
3. 新版架构设计
三、Outlook麻烦的兼容性问题
四、各类型文档块的还原
1. 标题块(heading 1-9)
2. 无序列表(bullet)与有序列表(ordered)
3. 待办事项
4. 表格(非电子表格)块
5. 图片块
6. 使用表格来布局的几个文档块
五、向前一步
六、大功告成
在展开我们如何做升级之前,先要简单了解下飞书云文档的信息结构(详情可参考官方API),在此仅做简单阐述。
{
block_id: string;
parent_id: string;
children: string[];
block_type: BlockType;
page?: { ... };
text?: { ... };
heading1?: { ... };
ordered?: { ... };
table?: { ... };
...
}[];
整个转译主干代码如下:
转译渲染,后处理,完成渲染。代码行数缩减到只有138行。
整个核心代码如下:
左图:内置的变量和函数,用于存储各种预处理器和渲染器,并实现文档树的递归渲染;右图:返回并暴露出去的函数,用于注册各种预处理器、渲染器,以及转译渲染。整个核心代码只有158行,非常精炼。
import { CSSProperties } from 'react';
const isUnitlessNumber: Record<string, boolean> = {
fontWeight: true,
lineClamp: true,
lineHeight: true,
fillOpacity: true,
floodOpacity: true,
stopOpacity: true,
};
const cssPropertyPrefixes = ['Webkit', 'ms', 'Moz', 'O'];
Object.keys(isUnitlessNumber).forEach(property => {
cssPropertyPrefixes.forEach(prefix => {
isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`] =
isUnitlessNumber[property];
});
});
export { isUnitlessNumber };
export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
if (typeof value === 'number' && !isUnitlessNumber[property]) {
return `${value}px`;
}
return value;
}
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 '';
}
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;
}
-
不能使用任何CSS3新特性,比如flex、grid等; -
只能使用行内样式;尽量只使用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样式无法满足还原度,且并没有处理对齐方式。我们将使用 <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>`;
text += renderChildBlocks(blockH1.block_id);
break;
}
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;
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;
};
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;
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;
} else {
j++;
mergeIndex++;
}
}
text += '</tr>';
}
text += '</table></div>';
return text;
};
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中不会生效)来在大多数客户端中保证图片宽度不会撑出父容器。
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] {
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);
if (item.text_run?.content.includes('\n')) {
result.push(currentGroup);
currentGroup = [];
}
});
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;
const codeList: TextElement[] = [];
const elements = blockCode.code.elements;
上文中提到的对elements的处理...
const groupedCodeLines = groupingCodeList(codeList);
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 => {
if (textEl.equation) {
equationElementList.push(textEl);
const equationId = `${block.block_id}_equation_${++equationIndex}`;
const svgEl = window.MathJax.tex2svg(textEl.equation.content).children[0];
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,
});
}
});
});
if (!isPreview) {
OSS 上传配置...
const res = await allSvgsToImgThenUpload(equationSVGList);
equationElementList.forEach(element => {
从res中找到当前公式元素对应的图片,放入element.equation.imageUrl中
});
}
};
function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {
const conversionPromises = svgObjList.map(svgObj => svgToImgThenUpload(svgObj));
return Promise.all(conversionPromises);
}
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;
}
const encodedString = encodeURIComponent(svgString).replace(/'/g, '%27').replace(/"/g, '%22');
const dataUrl = 'data:image/svg+xml,' + encodedString;
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.toBlob(async blob => {
创建 File 对象并上传 CDN,返回 CDN 链接;
}, 'image/png');
};
image.onerror = reject;
image.src = dataUrl;
});
}
文 /Nicolas、Asher
关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: