整合營銷服務商

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

          免費咨詢熱線:

          把飛書云文檔變成HTML郵件:問題挑戰與解決歷程

          背景

          云文檔轉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

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

          言:

          本人也是股票投資愛好著,這段時間行情不是很好,就想著做些其他事情轉移下注意力,突然想起之前寫過的小項目代碼,運行后會對你想要的時間日期進行數據的分析生成,然后保存到本地,數據庫之前我用的是tushare的,不過太久沒用現在不知道數據的接口有沒有改變,要想運行成功還是得要去他們那里搞個賬號,具體我就不細節展開了。下面我放出代碼,供各位大佬們參考,有能力的應該可以改成精美的圖形html展示形式。

          import os

          import docx

          import time

          import warnings

          import pandas as pd

          import tushare as ts

          from docx.shared import Cm, Inches

          from docx.enum.text import WD_ALIGN_PARAGRAPH

          from docx.shared import RGBColor,Pt

          warnings.filterwarnings('ignore')

          #pd.set_option()就是pycharm輸出控制顯示的設置

          pd.set_option('expand_frame_repr', False)#True就是可以換行顯示。設置成False的時候不允許換行

          pd.set_option('display.max_columns', None)# 顯示所有列

          #pd.set_option('display.max_rows', None)# 顯示所有行

          pd.set_option('colheader_justify', 'centre')# 顯示居中

          #os.chdir()用于改變當前工作目錄到指定的路徑

          #此路徑必須改為放數據的路徑且中間的不能缺失任何一天數據,例如get_analysis_stockdata('20200101', '20200106'),

          #那么你放數據文件夾內不能缺少任何一個這段時期內的交易數據文件,否則報錯

          os.chdir('D:/stock_data/') #保存的絕對路徑,需要自己修改跟創建,就是切換默認的工作目錄到你設置的路徑

          pro = ts.pro_api('要到tushare官網注冊個賬戶然后將token復制到這里')

          #df_basic = pro.stock_basic() 獲取基礎信息數據,包括股票代碼、名稱、上市日期、退市日期等

          #df_daily = pro.daily() 獲取所有股票日行情信息,或通過通用行情接口獲取數據,包含了前后復權數據,停牌期間不提供數據

          #df_daily_basic = pro.daily_basic()獲取全部股票每日重要的基本面指標,可用于選股分析、報表展示等。

          def get_all_stockdata(st_date, ed_date):

          trade_d = pro.trade_cal(exchange='SSE', is_open='1',start_date=st_date,end_date=ed_date,fields='cal_date')

          for date in trade_d['cal_date'].values:

          df_basic = pro.stock_basic(exchange='', list_status='L') #再獲取所有股票的基本信息

          df_daily = pro.daily(trade_date=date) # 先獲得所有股票的行情數據,成交額單位是千元,成交量是手

          df_daily_basic = pro.daily_basic(ts_code='', trade_date=date,fields='ts_code, turnover_rate, turnover_rate_f,'

          ' volume_ratio, pe, pe_ttm, pb, ps, ps_ttm,'

          ' dv_ratio, dv_ttm, total_share, float_share,'

          ' free_share, total_mv, circ_mv ') #獲取每日指標,單位是萬股,萬元

          #基本數據跟行情數據合并,再跟每日指標數據合并生成一個csv數據文件

          df_first = pd.merge(left=df_basic, right=df_daily, on='ts_code', how='outer') #on='ts_code'以ts_code為索引,合并數據,how='outer',取并集

          df_all = pd.merge(left=df_first, right=df_daily_basic, on='ts_code', how='outer')

          #數據清洗,刪除symbol列數據,跟ts_code數據重復

          df_all = df_all.drop('symbol', axis=1)

          for w in ['name', 'area', 'industry', 'market']: #在'name', 'area', 'industry', 'market'列內循環填充NaN值

          df_all[w].fillna('問題股', inplace=True)

          df_all['ts_code'] = df_all['ts_code'].astype(str) #強制轉換成str字符串格式

          df_all['list_date'] = pd.to_datetime(df_all['list_date'])

          df_all['trade_date'] = pd.to_datetime(df_all['trade_date'])


          df_all.to_csv(str(date) + '_ts.csv', index=False, encoding='gbk') #保存數據,不保存索引,如果index=True,則保存索引會多出一列

          print(df_all)

          print('%s is downloaded.' % (str(date)))

          return df_all

          #分析數據并生成docx文檔,存儲至本地D盤D:/stock_analysis/

          def get_analysis_stockdata(st_date, ed_date):

          trade_d = pro.trade_cal(exchange='SSE', is_open='1',start_date=st_date,end_date=ed_date,fields='cal_date') #獲取st_date,ed_date時間段內的交易日期

          for date_now in trade_d['cal_date'].values: #將以上獲取時間段的交易日期賦值給date_now

          df = pd.read_csv('{}_ts.csv'.format(str(date_now)), encoding='gbk') #讀取時間段內每日的股票數據

          df.fillna(0, inplace=True) #fillna填充缺失數據,傳入inplace=True直接修改原對象

          #astype強制將漲幅,PE,總市值,流通市值轉換成float格式,ts_code轉化成str后,NAN也變成nan str格式

          df[['change', 'pe', 'total_mv', 'circ_mv']] = df[['change', 'pe', 'total_mv', 'circ_mv']].astype(float)

          df['list_date'] = pd.to_datetime(df['list_date'])

          df['ts_code'] = df['ts_code'].astype(str)

          # 添加交易所列

          df.loc[df['ts_code'].str.startswith('3'), 'exchange'] = 'CY'

          df.loc[df['ts_code'].str.startswith('6'), 'exchange'] = 'SH'

          df.loc[df['ts_code'].str.startswith('0'), 'exchange'] = 'SZ'

          df_up = df[df['change'] > 0.00] #找出上漲的股票

          df_even = df[df['change'] == 0.00] #找出走平的股票

          df_down = df[df['change'] < 0.00] #找出下跌的股票

          # 找出漲停的股票

          limit_up = df[df['change']/df['pre_close'] >= 0.097]

          limit_down = df[df['change']/df['pre_close'] <= -0.0970]

          # 漲停股數中的未封板股,上市日期小于15天

          limit_up_new = limit_up[pd.to_datetime(date_now) - limit_up['list_date'] <= pd.Timedelta(days=15)]

          # 漲停股數中次新股,上市日期小于1年

          limit_up_fresh = limit_up[pd.to_datetime(date_now) - limit_up['list_date'] <= pd.Timedelta(days=365)]

          # 漲停股數中的未封板股,上市日期小于15天

          limit_down_new = limit_down[pd.to_datetime(date_now) - limit_down['list_date'] <= pd.Timedelta(days=15)]

          # 漲停股數中次新股,上市日期小于1年

          limit_down_fresh = limit_down[pd.to_datetime(date_now) - limit_down['list_date'] <= pd.Timedelta(days=365)]

          #df_up.shape[0]獲取上漲的行數

          print('A股上漲個數: %d, A股下跌個數: %d, A股走平個數: %d。' % (df_up.shape[0], df_down.shape[0], df_even.shape[0]))

          print('A股總成交額:%d, 總成交量:%d' % (df['amount'].sum(), df['vol'].sum()))

          print('A股平均市盈率:%.2f, 平均流通市值 %.2f 億, 平均總市值 %.2f 億' % (df['pe'].mean(), df['circ_mv'].mean(), df['total_mv'].mean()))

          print('漲停數量:%d 個, 漲停中上市日期小于15天的:%d, 漲停中上市日期小于1年的:%d' % (limit_up.shape[0], limit_up_new.shape[0], limit_up_fresh.shape[0]))

          print('跌停數量:%d 個, 跌停中上市日期小于15天的:%d, 跌停中上市日期小于1年的:%d' % (limit_down.shape[0], limit_down_new.shape[0], limit_down_fresh.shape[0]))

          file = docx.Document()

          #設置總標題,居中

          headb = file.add_heading('%s中國股市今日收盤分析報告' % (date_now), level=0).alignment = WD_ALIGN_PARAGRAPH.CENTER

          head1 = file.add_heading('股市基本概況:',level=1) #設置一級標題

          #添加段落內容

          text1 = file.add_paragraph() #首先創建一個空的段落,然后再往里面加文字,這樣方便設置文字格式字體等設置,另外一種寫法,缺點不能單獨設置字體屬性

          # text1 = file.add_paragraph('A股上漲個數: %d, A股下跌個數: %d, A股走平個數: %d。' % (df_up.shape[0], df_down.shape[0], df_even.shape[0]))

          text1.add_run('A股上漲個數:').bold = True #添加文字并設置粗體

          text1.add_run('{} '.format(str(df_up.shape[0]))).font.color.rgb = RGBColor(255, 0, 0) #添加變量

          text1.add_run('A股下跌個數:').bold = True

          text1.add_run('{} '.format(str(df_down.shape[0]))).font.color.rgb = RGBColor(0, 255, 0)

          text1.add_run('A股走平個數:').bold = True

          text1.add_run('{} '.format(str(df_even.shape[0]))).font.color.rgb = RGBColor(0, 0, 255)

          text1.line_spacing = Pt(25) #設置段落行距

          text1.style = 'List Bullet' # 設置項目符號列表

          text2 = file.add_paragraph()

          text2.add_run('A股總成交額:').bold = True

          text2.add_run('{}'.format(str(round(df['amount'].sum(),2)))).font.color.rgb = RGBColor(128, 0, 128)

          text2.add_run('千元 ')

          text2.add_run('總成交量:').bold = True

          text2.add_run('{}'.format(str(round(df['vol'].sum(),2)))).font.color.rgb = RGBColor(128, 0, 128)

          text2.add_run('手 ')

          text2.line_spacing = Pt(25)

          text2.style = 'List Bullet'

          text3 = file.add_paragraph()

          text3.add_run('A股平均市盈率:').bold = True

          text3.add_run('{} '.format(str(round(df['pe'].mean())))).font.color.rgb = RGBColor(128, 0, 128)

          text3.add_run('平均流通市值:').bold = True

          text3.add_run('{}'.format(str(round(df['circ_mv'].mean(),2)))).font.color.rgb = RGBColor(128, 0, 128)

          text3.add_run('萬元')

          text3.add_run('\n')

          text3.add_run('平均總市值:').bold = True

          text3.add_run('{}'.format(str(round(df['total_mv'].mean(),2)))).font.color.rgb = RGBColor(128, 0, 128)

          text3.add_run('萬元 ')

          text3.line_spacing = Pt(25)

          text3.style = 'List Bullet'

          text3.add_run('\n')

          text4 = file.add_paragraph()

          text4.add_run('漲停數量:').bold = True

          text4.add_run('{}'.format(str(limit_up.shape[0]))).font.color.rgb = RGBColor(255, 0, 0)

          text4.add_run('個 ')

          text4.add_run('漲停中上市日期小于15天的:').bold = True

          text4.add_run('{}'.format(str(limit_up_new.shape[0]))).font.color.rgb = RGBColor(255, 0, 0)

          text4.add_run('個 ')

          text4.add_run('\n')

          text4.add_run('漲停中上市日期小于1年的:').bold = True

          text4.add_run('{}'.format(str(limit_up_fresh.shape[0]))).font.color.rgb = RGBColor(255, 0, 0)

          text4.add_run('個 ')

          text4.line_spacing = Pt(25)

          text4.style = 'List Bullet'

          text5 = file.add_paragraph()

          text5.add_run('跌停數量:').bold = True

          text5.add_run('{}'.format(str(limit_down.shape[0]))).font.color.rgb = RGBColor(0, 255, 0)

          text5.add_run('個 ')

          text5.add_run('跌停中上市日期小于15天的:').bold = True

          text5.add_run('{}'.format(str(limit_down_new.shape[0]))).font.color.rgb = RGBColor(0, 255, 0)

          text5.add_run('個 ')

          text5.add_run('\n')

          text5.add_run('跌停中上市日期小于1年的:').bold = True

          text5.add_run('{}'.format(str(limit_down_fresh.shape[0]))).font.color.rgb = RGBColor(0, 255, 0)

          text5.add_run('個 ')

          text5.line_spacing = Pt(25)

          text5.style = 'List Bullet'

          file.add_page_break() #添加分頁符

          def get_output(df, columns='_industry', name='_limit_up'):

          # df.copy(deep= False)和df.copy()都是淺拷貝,是復制了舊對象的內容,然后重新生成一個新對象,改變舊對象不會影響新對象。

          df = df.copy()

          output = pd.DataFrame()

          #df.groupby(columns)根據列值分組數據,并根據股票代碼統計數據

          output = pd.DataFrame(df.groupby(columns)['ts_code'].count())

          output['平均市盈率'] = round(df.groupby(columns)['pe'].mean(),2)

          output['平均流通市值(萬)'] = round(df.groupby(columns)['circ_mv'].mean(),2)

          output['平均總市值(萬)'] = round(df.groupby(columns)['total_mv'].mean(),2)

          output['平均成交量(手)'] = round(df.groupby(columns)['vol'].mean(),2)

          output['平均成交額(千)'] = round(df.groupby(columns)['amount'].mean(),2)

          #依據ts_code進行降序,排序后的數據集替換原來的數據

          output.sort_values('ts_code', ascending=False, inplace=True)

          #改列值名字,將ts_code改成name+‘_count’的形式

          output.rename(columns={'ts_code': name + '合計'}, inplace=True)

          return output

          for i in ['industry', 'exchange', 'area']:

          # 對漲停的股票分析

          output_limit_up = get_output(limit_up, columns=i, name='漲停').reset_index()

          # 對跌停的股票分析

          output_limit_down = get_output(limit_down, columns=i, name='跌停').reset_index()

          # 對全量的股票分析

          output_total = get_output(df, columns=i, name='全部').reset_index()

          #添加表格開頭類別說明

          tabletext = file.add_paragraph()

          tabletext.add_run('類別:').bold = True

          tabletext.add_run('{} '.format(str(i))).font.color.rgb = RGBColor(222, 125, 44)

          print(output_limit_up)

          print(output_limit_down)

          print(output_total)

          for j in [output_limit_up, output_limit_down, output_total]: #, output_total

          tb = file.add_table(rows=len(j.index)+1, cols=len(j.columns),style='Medium Grid 3 Accent 1')

          tb.autofit = True #關閉表格行寬自適應

          for x in range(len(j.columns)):

          tb.cell(0, x).text = j.columns[x] #添加表列頭

          #tb.cell(0, x).width = Inches(1.2) #設置行寬

          tb.cell(0, x).paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER #文字居中

          for row in range(len(j.index)):

          for col in range(len(j.columns)):

          tb.cell(row+1, col).text = str(j.iloc[row, col]) #設置行寬

          tb.cell(row+1, col).paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER #文字居中

          file.add_paragraph('\n') #表格換行

          file.add_page_break() #每制成一個類別表格分一次頁面

          #之所以生成文件慢是因為output_total這個統計需要長時間計算,如果需要速度快你可以試著把output_total去掉

          #生成一個docx文件我的電腦需要3到4分鐘左右

          file.save('D:\stock_analysis\{}_分析報告.docx'.format(str(date_now)))

          print('{}_分析報告分析完成'.format(str(date_now)))

          if __name__=="__main__":

          get_all_stockdata('20200611', '20200611')

          get_analysis_stockdata('20200611', '20200611')

          家好,很高興又見面了,我是"高級前端?進階?",由我帶著大家一起關注前端前沿、深入前端底層技術,大家一起進步,也歡迎大家關注、點贊、收藏、轉發!

          今天給大家帶來的主題是富文本編輯器中的Tiptap Editor。話不多說,直接進入正題!

          1.什么是 Tiptap Editor

          Tiptap 編輯器是一個無頭、與框架無關的富文本編輯器,可以通過擴展程序進行自定義和擴展。 無頭性質意味著 Tiptap 沒有固定的用戶界面,提供完全的設計自由,同時 Tiptap 基于高度可靠的 ProseMirror 庫。

          ProseMirror 是一個基于 contentEditable 的功能良好的富語義內容編輯器,支持協作編輯和自定義文檔模式。ProseMirror 庫由許多獨立的模塊組成,該存儲庫僅充當中央問題跟蹤器,并包含一個腳本以幫助輕松檢查所有核心模塊以進行開發。

          Tiptap Editor 得到協作開源后端 Hocuspocus 的補充, Editor 和 Hocuspocus 構成了 Tiptap Suite 的基礎。

          Tiptap 編輯器的工作原理可以概括為:

          • 無頭框架:Tippap 不依賴于用戶界面, 因此不需要類覆蓋或代碼修改。 如果確實需要示例 UI,可以輕松集成相應的 UI 模板。
          • 與框架無關:Tiptap 編輯器旨在跨不同的前端框架工作。 這意味著無論開發者使用的是 Vue、React 還是純 JavaScript,Tippap 都可以集成而不會出現兼容性問題。
          • 基于擴展:Tippap 中的擴展允許定制編輯體驗,從簡單的文本樣式到拖放塊編輯等高級功能。 開發者可以從文檔和社區中提供的 100 多個擴展中進行選擇,以增強編輯器的功能。
          • 自定義用戶體驗:編輯器的構建是為了讓開發者能夠控制定義自己的擴展和節點。

          當然,Tiptap 編輯器還提供了專業版的擴展(Editor Pro Extensions),可增強 Tiptap 編輯器的功能,其是可以集成到基本編輯器中以提供更復雜的編輯選項的附加功能。

          Editor Pro Extensions 主要功能包括:協作編輯(允許多個用戶同時編輯文檔)、拖放文件管理(以便更輕松地處理文檔和媒體)以及唯一的節點 ID 分配。但是,缺點就是申請 Tiptap 帳戶。

          目前 Tiptap Editor 在 Github 通過 MIT 協議開源,有超過 22.8k 的 star、1.9k 的 fork、項目依賴量 4.5k、代碼貢獻者 300+、妥妥的前端優質開源項目。

          2.如何使用 Tiptap Editor

          安裝 Tiptap Editor

          Tiptap Editor 可以借助 Yarn 或者 NPM 來安裝:

          npm install --save tiptap
          yarn add tiptap
          

          安裝了 tiptap 后就可以直接開始使用,但是還可以預先安裝特定的擴展,而 Tiptap(或者說 Prosemirror)的強大之處就在于擴展,即想要的任何功能都可以編寫為擴展并插入 Tiptap 的運行實例中,從而擁有一個具有自定義功能的編輯器。

          Tiptap 團隊在 NPM 包 Tiptap-extensions 中有一組很好的擴展,并且積極維護,這使得其可以始終和 Tiptap 一起工作,包括: Blockquote、CodeBlock、HardBreak、標題、OrderedList、BulletList、ListItem、TodoItem、TodoList、粗體、代碼、斜體、鏈接、刪除線、下劃線、歷史記錄。

          可以通過下面方式安裝擴展:

          npm install --save tiptap-extensions
          yarn add tiptap-extensions

          初始化 Tiptap Editor

          接下來將僅使用基本擴展,例如:粗體、斜體、下劃線、標題、歷史擴展。 在項目中,可以創建一個新的特定文件夾來保存為編輯器編寫的所有 JS 文件和 VueJS 組件。接下來使用 Babel 和 ESLint 創建了一個 VueJS 2 項目,并在 src 文件夾中創建了一個名為 editor 的目錄,如下所示。

          然后安裝 tiptap 和 tiptap-extensions。在這里,使用 NPM 一次性安裝兩個包。

          npm install --save tiptap tiptap-extensions

          安裝后,可以繼續查看上面屏幕截圖中顯示的 tiptap.js 文件并開始使用 Tiptap。

          import {Editor} from "tiptap";
          import {Bold, Italic, Underline, Heading, History} from "tiptap-extensions";
          
          export const ourEditor = new Editor({
              // 導入相應的插件
            extensions: [
              new Bold(),
              new Italic(),
              new Underline(),
              new Heading({levels: [1, 2, 3] }),
              new History()
            ]
          });

          在此文件中,使用想要使用的擴展來初始化 Tiptap Editor 的實例,接下來是在 Vue 中使用 Tiptap Editor:

          <template>
            <div class="tiptap-editor">
              <EditorMenuBar
                class="editor-menubar"
                :editor="editor"
                v-slot="{commands, isActive}"
              >
                <div>
                  <button
                    :class="{'is-active': isActive.bold() }"
                    @click="commands.bold"
                  >
                    B
                  </button>
                  <button
                    :class="{'is-active': isActive.italic() }"
                    @click="commands.italic"
                  >
                    I
                  </button>
                  <button
                    :class="{'is-active': isActive.underline() }"
                    @click="commands.underline"
                  >
                    U
                  </button>
          
                  <button
                    v-for="level in [1, 2, 3]"
                    :key="level"
                    :class="{'is-active': isActive.heading({ level}) }"
                    @click="commands.heading({level: level})"
                  >
                    H{{level}}
                  </button>
                </div>
              </EditorMenuBar>
              <EditorContent class="editor-content" :editor="editor" />
            </div>
          </template>
          
          <script>
          import {EditorContent, EditorMenuBar} from "tiptap";
          import {ourEditor} from "./tiptap";
          export default {
            name: "Tiptap",
            components: {
              EditorContent,
              EditorMenuBar
            },
            data() {
              return {
                editor: ourEditor
              };
            },
            beforeDestroy() {
              ourEditor.destroy();
            }
          };
          </script>
          <style>
          .tiptap-editor {
            display: flex;
            align-items: center;
            flex-direction: column;
            justify-content: center;
          }
          .editor-menubar {
            margin: 0 0 20px 0;
          }
          .editor-content {
            max-width: 35em;
            min-width: 35rem;
            display: block;
            text-align: unset;
            border-radius: 20px;
            padding: 1rem;
            border: 1px solid rgba(0, 0, 0, 0.363);
          }
          .ProseMirror {
            min-height: 30vh;
            padding: 2px;
            outline: none;
          }
          button {
            padding: 20px;
            margin: 0 5px;
            background: white;
            outline: none;
            border: 1px solid grey;
            border-radius: 10px;
            font-size: 1.3rem;
            transition: 0.2s all;
          }
          button:hover {
            cursor: pointer;
            background: rgba(0, 0, 0, 0.1);
            transition: none;
          }
          button.is-active {
            background: rgba(0, 128, 0, 0.2);
          }
          </style>

          以上所寫的只是 Tiptap 提供的很小部分功能,其在 GitHub 存儲庫上提供了對該技術的更深入的說明。同時,不僅是維護者,社區也提供了很好的擴展,非常多的擴展,例如:

          • KaTex 集成
          • :表情符號:支持
          • 帶有手動語言選擇的代碼塊
          • 文本顏色、文本對齊、文本突出顯示
          • 使用 Y.js 進行實時多用戶編輯

          如果使用 VueJS 組件框架(例如 ElementUI 或 VuetifyJS),甚至還有像 element-tiptap 和 Tiptap-vuetify 這樣的包,提供了大量的抽象,并允許開發者非常快速地將 Tiptap 與項目集成。

          3.本文總結

          本文主要和大家介紹 Tiptap 編輯器,其是一個無頭、與框架無關的富文本編輯器,可以通過擴展程序進行自定義和擴展。 無頭性質意味著 Tiptap 沒有固定的用戶界面,提供完全的設計自由。因為篇幅問題,關于 Tiptap 主題只是做了一個簡短的介紹,但是文末的參考資料提供了大量優秀文檔以供學習,如果有興趣可以自行閱讀。如果大家有什么疑問歡迎在評論區留言。

          參考資料

          https://github.com/ueberdosis/tiptap

          https://github.com/ProseMirror/prosemirror

          https://dev.to/sereneinserenade/next-gen-text-editing-in-browser-with-tiptap-2943

          https://www.youtube.com/watch?v=VcnROkRhJ34


          主站蜘蛛池模板: 不卡一区二区在线| 精品人妻一区二区三区浪潮在线| 无码精品黑人一区二区三区| 久久一区二区三区精品| 国产综合无码一区二区色蜜蜜| 一区二区视频传媒有限公司| 无码一区二区三区免费| 精品少妇人妻AV一区二区三区| 国产精品夜色一区二区三区| 亚洲AV无码一区二区乱子仑 | 精品不卡一区二区| 久久国产精品最新一区| 日韩精品无码一区二区中文字幕| 国产亚洲一区二区精品| 波多野结衣精品一区二区三区| 一区三区三区不卡| 中文字幕无线码一区| 秋霞鲁丝片一区二区三区| 精品一区二区三区色花堂| 国产欧美色一区二区三区| 亚洲无线码一区二区三区| 日本一区二区三区日本免费| 久久亚洲AV午夜福利精品一区| 无码国产精品一区二区免费16| 无码人妻精品一区二区三区9厂| 精品国产日韩亚洲一区| 亚洲AV成人精品日韩一区| 亚洲欧美一区二区三区日产| 国产成人精品一区二区三在线观看 | 一区二区在线播放视频| 亚洲色婷婷一区二区三区| 精品人妻无码一区二区三区蜜桃一| 精品人体无码一区二区三区| 中文字幕一区二区三区久久网站| 日本精品夜色视频一区二区| 亚洲一区二区三区丝袜| 免费av一区二区三区| 国产免费一区二区三区VR| 亚洲熟妇av一区| 波多野结衣高清一区二区三区| 国产一区二区不卡老阿姨|