从零开始手撸一个阅读器--排版引擎的实现(1)

2,844 阅读10分钟

概述

以起点为例,一个款好的阅读器排版引擎需要处理以下几个问题:

  1. 避头尾:某些标点不能出现在行的开头,例如“逗号”就不应该出现在行首。有些标点不能出现在行的结尾,例如“正引号”就不应该出现在行的结尾
  2. 压缩:如果出现连续的标点,例如冒号后面跟着引号,那么这两个标点不应该占据2个字符的位置,而应该合并起来占据一个字符的位置。
  3. 每行文字两端对齐:每一行的文字都可能出现中文、字母、数字、特殊符号等字符类型,每一行的文字的排版不可能刚好占满整行,每行文字的两端就会无法对齐。
  4. 每页段落底部对齐:每一个段落的行数不确定,每一段和每一行之间的间距也不确定,每一页的排版就会出现底部留白的情况。

以起点app为例子,能够发现,基本上每一页都是两端对齐+底部对齐 qidian.jpg

实现方式

CSS3 column多栏布局

CSS3 中的 column(多栏布局)是一种可以将文本内容或其他元素排列成多列显示的布局方式,就像报纸的排版一样。它可以让内容在水平方向上分布在多个列中,尤其适用于较长的文本段落或者元素列表。

  1. 原理很简单,固定视窗(overflow:hidden),通过设置容器transform来进行左右翻页。但是在第一页和最后一页位置的时候,无法预览章节上一页/下一页的内容。

columns.png

  1. 页码计算:画个图,写个方程式就得出来了,具体和布局有关

pagecount.png

  1. 平移动画:主要分为三个动作
    • touchstart:记录最开始点击位置, 记录初始时间
    • touchmove:记录偏移量,通过 transition 和 transform 设置翻页动画
    • touchend:有三种情况
      • 点击事件: 即没有偏移量,点中间唤出菜单,左右两边进行页面切换
      • 短时间拖拽:即快速滑动页面,处理为页面切换操作
      • 长时间拖拽:若拖拽距离大于1/3屏幕,处理成页面切换操作,否则处理成回弹
/** 有效拖拽时间(毫秒) */
const dragTime = 300;
/** 显示菜单 */
const showMenu = ref(false);
/** 触摸位置 */
let touchPosition = 0;
/** 触摸的时间 */
let touchTime = 0;
/** 是否开始触摸 */
let startTouch = false;
/** touch时原transLateX值 */
let currentTranslateX: number;

function onTouchStart(e: TouchEvent) {
  startTouch = true;
  const pageX = e.touches[0].pageX;
  touchPosition = pageX;

  touchTime = Date.now();

  currentTranslateX = getReaderTranslateX();
}

function onTouchMove(e: TouchEvent) {
  if (!startTouch) return;

  if (showMenu.value) return;
  const pageX = e.touches[0].pageX;
  const slide = pageX - touchPosition;
  if (slide < 0 && isLastPage.value) {
    showToast("没有啦~");
    return;
  }
  if (slide > 0 && isFirstPage.value) {
    showToast("当前为第一页");
    return;
  }
  readerSectionStyle.transition = "0s all";
  readerSectionStyle.transform = `translateX(${slide + currentTranslateX}px)`;
}
function onTouchEnd(e: TouchEvent) {
  if (!startTouch) return;
  startTouch = false;
  if (sliderWindowInfo.show) {
    sliderWindowInfo.show = false;
  }
  if (showMenu.value) {
    showMenu.value = false;
    return;
  }
  const pageX = e.changedTouches[0].pageX;
  const slideX = pageX - touchPosition;
  const value = appOption.screenWidth / 3;
  const now = Date.now();

  // /** 返回原来位置 */
  const backPosition = () => {
    readerSectionStyle.transition = "0.4s all";
    readerSectionStyle.transform = `translateX(${currentTranslateX}px)`;
  };
  // 点击事件
  if (Math.abs(slideX) <= 0) {
    if (pageX < value) {
      // pre
      pagePrev();
    } else if (pageX > value * 2) {
      // next
      pageNext();
    } else {
      //点击中间
      setType.value = "slider";
      showMenu.value = true;
    }
  } else {
    // 短时间拖拽和长时间长距离拖拽
    if (now - touchTime < dragTime || (now - touchTime > dragTime && Math.abs(slideX) >= value)) {
      // 拖拽距离大于 1/3
      if (slideX < 0) {
        // next
        pageNext();
      } else {
        // pre
        pagePrev();
      }
    } else {
      backPosition();
    }
  }
}

注意

  1. uniapp打包成app后不能使用 document ,需要使用uni提供的api
  2. 但是 uni.createSelectorQuery拿不到元素的scrollWidth属性,我是放置了一个占位元素去拿到anchro元素的right值得到scrollWidth
  3. uni.createSelectorQuery是一个异步任务,我在实际使用过程中会出现right值的获取不准确的情况下,导致最终放弃了column布局方案。
  4. 如果一定要使用 column 布局方案,请使用renderjs
  5. 具体思路可以看看 地址src/pages/reader/index_v1.vue 实现
<!-- html -->
<p id="anchro_last_p" style="width: 100%; height: 0"></p>

// js
function updatePageCount() {
  getNodeInfo("#anchro_last_p").then((res) => {
    setTimeout(() => {
      const right = res?.right ?? 0;
      const scrollWidth = right - bookOption.sizeInfo.lrPadding;
      pageCount.value = (scrollWidth + 32) / (appOption.screenWidth - bookOption.sizeInfo.lrPadding * 2 + 32);
    }, 0);
  });
}

function getNodeInfo(selector: string): Promise<UniApp.NodeInfo | null> {
  return new Promise((resolve) => {
    const query = uni.createSelectorQuery();
    query
      .select(selector)
      .boundingClientRect((data) => {
        resolve((data as UniApp.NodeInfo) || null);
      })
      .exec();
  });
}

缺点

  1. 依赖于平台排版渲染能力,小程序不支持,兼容性差。
  2. 所有排版均为平台自动渲染,只有一个容器,无法灵活的对每一页的内容做自定义处理。
  3. 长章节文本的渲染可能会出现性能问题。
  4. 翻页处理困难,基本上只能实现平滑翻页。其余翻页方式实现困难,强行实现只会带来更多的灾难。
  5. 灵活性差:比如每一页底部的留白空缺就无法处理。

优点

  • 简单、快速,在产品功能简单的情况下可以使用

JS计算分页

思路:canvas.measureText

  1. 主要思路就是通过 measureText 去测量一段文本的宽度,再根据屏幕宽度去计算有多少行。根据行数、文字高度和间距计算出一页的高度,去分页就行了。
  2. 但是有下面几个需要注意的点:
    • measureText 计算出来的宽度并不准确,比实际宽度偏小。measureText 计算结果和字体大小、字体类型、字体重量等很多因素有关。这一点影响其实不大,重点是 measureText 计算出来的宽度和浏览器的排版是没关系的。大致就相当于把十行文字全部放在一行,然后测量一行文字的宽度。
    • 在浏览器文字的排版中,如果下一行首字符是以特殊字符开始,往往会自动把上一行的段尾的文字放到下一行的段首,会尽可能的处理这种避头尾的情况。但是这就会导致实际测量出来的段落刚好是两行,而实际看到的却是三行。即计算结果比实际宽度偏小的情况。
    • 还有一些情况,比如说一个小写字符的宽度本身很小,但是如果在一个段落的段首,确可能占一整个中文汉字的的情况,具体和字体类型有关。
    • uni提供了一个api: uni.createCanvasContext,可以创建一个canvas对象(document会有兼容问题)。但是uni.createCanvasContext.measureText 在app端会有严重的性能问题。造成严重的卡顿,应该避免使用!!!
  3. render 实践指南
    • renderjs 网上教程很少,官网写的也很简略。并且不支持 vue3  setup script语法。
    • renderjs 使用可以看这里
  4. renderjs 使用需要注意的地方
    • 最佳实践:以子组件的形式使用,如果放到一起的话,享受不到ts的支持,并且ts还会会报错,而且原页面逻辑不用动(天知道我最开始把我的vue setup重构成 setup() {}, 再重构成 export default {}, 最后又重构成 setup 期间经历了什么)
    • vue3项目中,renderjs 没有提示,应该是vscode插件的问题,反正就是Vetur、Volar 之间的各种冲突(如果我没记错的话vue2使用的是Vetur,vue3使用的是Volar)
    • render 里不可以使用 App、Page 的生命周期,也不能使用 uni 相关接口(如uni.setStorage)等。包括引用的函数里也不能存在uni相关API,不然运行到手机上会直接报错!!(如 import store from "@/store" 但是store里面使用了uni.setStorage,我当时被坑惨了- .-)
    • 看具体思路可以看看 地址src/pages/reader/components/Renderjs_v2.vue 实现

完整代码(写不动了,直接看代码把 - .-)

  methods: {
    loadChapter(newVal, oldValue, ownerInstance, instance) {
      if (newVal?.data?.title && newVal?.data?.title) {
        const { pageWidth, pageHeight, statusBarHeight, bookOption } = newVal.options;
        this.pageWidth = pageWidth;
        this.pageHeight = pageHeight;
        this.statusBarHeight = statusBarHeight;
        this.bookOption = bookOption;
        const d1 = +new Date();
        const chapterPageList = this.updateChapterList(newVal.data);
        this.$ownerInstance.callMethod("updateContainerContent", { params: newVal, chapterPageList });
        const d2 = +new Date();
        console.log(d2 - d1, "total Spend Timer!!!!!");
      }
    },
    updateChapterList(data: { title: string; content: string }) {
      function customRound(num1: number, num2: number) {
        const result = num1 / num2;
        const integerPart = Math.floor(result); // 获取整数部分
        const decimalPart = result - integerPart; // 获取小数部分
        if (decimalPart > 3 / 4) {
          return Math.ceil(result);
        } else {
          return Math.floor(result);
        }
        // return Math.floor(result);
      }
      /** 当前章节的段落列表 */

      const contents = data.content
        .split("\n")
        .map((i) => i.trim())
        .filter((i) => i);

      /** 下边距 */
      const margin = this.bookOption.sizeInfo.margin;
      /** 容器实际宽度 */
      const width = this.pageWidth - this.bookOption.sizeInfo.lrPadding * 2;
      /** 实际一行能展示的宽度 */
      const actualWidth = this.getActualWidth(width);

      /** 计算的页数 */
      let page = 0;
      /** 是否第一页 */
      let firstPage = true;
      /** 一页的字体容器高度 */
      let height = 0;
      /** 一页的字体列表 */
      let list = [];
      /** 下一页超出的数据 */
      let nextPageText = {
        height: 0,
        text: "",
      };

      // 先重置为一个空的数组,因为二维数组的值初始化是`undefined`
      const chapterPageList: Array<{ title: string; content: Array<string>; breakLineIndex?: Array<number> }> = [];
      // 软分段索引值
      const breakLineIdx: Array<number> = [];
      let i = 0;
      while (i < contents.length) {
        const item = contents[i];
        let fontWidth = this.getTextWidth(item, actualWidth, breakLineIdx.includes(i)); // 使用 getTextWidth 来计算文本宽度
        const row = Math.ceil(fontWidth / actualWidth);
        const itemHeight = row * this.bookOption.sizeInfo.pLineHeight + margin;

        if (firstPage) {
          const w = this.getTextWidth(data.title, actualWidth, true, this.bookOption.sizeInfo.title);
          const r = Math.ceil(w / actualWidth);
          const h = r * this.bookOption.sizeInfo.tLineHeight + margin;

          height += h;
          firstPage = false;
        }

        // 把上一页超出的内容加到当前页中去
        if (nextPageText.height) {
          height += nextPageText.height;
          list.push(nextPageText.text);
          // 用完拼接好的页面记得清除
          nextPageText.height = 0;
          nextPageText.text = "";
        }

        // 处理长段落:如果当前段落不能完全放下,分为两部分
        const containerHeight =
          this.pageHeight -
          this.bookOption.sizeInfo.infoHeight -
          this.bookOption.sizeInfo.infoHeight -
          this.bookOption.sizeInfo.tPadding -
          this.bookOption.sizeInfo.bPadding -
          this.statusBarHeight;

        if (height - margin + itemHeight > containerHeight) {
          // 当前段落超出页面,尝试将一部分放到下一页
          const remainingSpace = containerHeight - height; // 剩余的可用空间
          const allowRemainRow = customRound(remainingSpace, this.bookOption.sizeInfo.pLineHeight); // 可填充的行数

          // console.log(
          //   `总空间 ${containerHeight} 第${page + 1}页剩余可用空间 ${remainingSpace} 可填充行数 ${allowRemainRow} 需要填充的字符串 ${item}`,
          // );

          if (allowRemainRow > 0) {
            let currentText = ""; // 当前页已经填充的文本
            let currentLineCount = 0; // 当前页已经填充的行数
            let fontWidth = 0;

            // 逐字符处理,直到文本的宽度大于可以填充的行数宽度
            let remainingText = item; // 剩余的文本

            while (remainingText.length > 0) {
              fontWidth = this.getTextWidth(currentText + remainingText[0], actualWidth); // 加上一个字符的宽度

              const totalWidth = actualWidth * allowRemainRow; // 当前页的总宽度
              // 如果当前行的宽度超出剩余空间,就跳出循环
              if (fontWidth > totalWidth) {
                break;
              }

              currentText += remainingText[0]; // 将当前字符添加到当前页的文本
              remainingText = remainingText.slice(1); // 剩余文本去掉第一个字符
            }

            // 当前页填满后,保存内容
            list.push(currentText);
            height += currentLineCount * this.bookOption.sizeInfo.pLineHeight + margin;

            // 剩余部分的文本
            const nextText = remainingText;

            if (nextText) {
              contents.splice(i + 1, 0, nextText);
              breakLineIdx.push(i + 1);
            }
            // 保存当前页的内容
            chapterPageList[page] = {
              title: "",
              content: list,
            };
            list = [];
            height = 0;
            page++; // 跳到下一页
            i++;
            continue; // 当前段落已经被拆分,跳过继续处理
          }
        }

        // 如果当前段落没有超出页面,直接放到当前页
        list.push(item);
        height += itemHeight;
        // 判断是否超出一页的高度
        if (height - margin > containerHeight) {
          list.pop();
          nextPageText.height = itemHeight;
          nextPageText.text = item;
          chapterPageList[page] = {
            title: "",
            content: list,
          };
          list = [];
          height = 0;
          page++;
        } else {
          if (i === contents.length - 1) {
            // 最后一页
            chapterPageList[page] = {
              title: "",
              content: list,
            };
          }
        }
        i++;
      }

      // 最后一页的标题
      chapterPageList[0].title = data.title;

      if (breakLineIdx.length) {
        let i = 0;
        for (let index = 0; index < chapterPageList.length; index++) {
          const page = chapterPageList[index];

          if (!page.breakLineIndex) {
            page.breakLineIndex = [];
          }
          page.content.forEach((ctx, pos) => {
            if (breakLineIdx.includes(i)) {
              page.breakLineIndex!.push(pos);
            }
            i++;
          });
        }
      }

      return chapterPageList;
    },
    // 获取文本的实际宽度
    getTextWidth(
      text: string,
      actualWidth?: number,
      isBreakLine: boolean = false,
      fontSize: number = this.bookOption.sizeInfo.p,
    ) {
      if (!this.canvas) {
        const cvs = document.createElement("canvas");
        this.canvas = cvs.getContext("2d")!;
      }

      this.canvas.font = `${fontSize}px PingFang SC`; // 设置字体大小

      // 如果文本的第一个字符是半角特殊字符或全角特殊字符,就将它替换为一个中文字符
      if (this.isSpecialCharacter(text.charAt(0))) {
        text = "啊" + text.slice(1); // 用中文字符 '啊' 替换开头的字符
      }

      // 计算实际宽度
      if (!actualWidth) {
        return this.canvas.measureText(text).width;
      }

      // 如果不是软分行段首,即有两个字符缩进
      if (!isBreakLine) {
        text = "啊啊" + text;
      }
      let totalWidth = this.canvas.measureText(text).width;
      if (totalWidth <= actualWidth) {
        return totalWidth;
      }
      // 如果宽度大于实际容器宽度,判断是否有特殊符号,进行换行处理
      // 这里实际是因为,如果下一行是以特殊字符开始,浏览器的排版会自动把上一行的段尾放到下一行的段首,会尽可能的处理以符号开始的情况
      let currentLineWidth = 0;
      let lines = [];
      let textArr = text.split("");
      let line = "";
      // 循环处理每个字符并判断换行
      for (let i = 0; i < textArr.length; i++) {
        let str = line + textArr[i];
        currentLineWidth = this.canvas.measureText(str).width;
        // 如果当前行宽度超过了容器宽度
        if (currentLineWidth <= actualWidth) {
          // 尝试把中间的全角字符转换成一个中文字符
          // 因为  canvas.measureText(str) 中带有全角,计算会不准确
          str = str.replace(/[!“”‘’()、,.:;<>@@[]\^_`{}|~]/g, "啊");
          currentLineWidth = this.canvas.measureText(str).width;
        }
        if (currentLineWidth > actualWidth) {
          // 判断是否需要将特殊符号移到下一行
          if (this.isSpecialCharacter(textArr[i])) {
            lines.push(line.slice(0, -1)); // 将当前行最后一个字符挪到下一行
            line = textArr[i - 1];
          } else {
            lines.push(line);
            line = "";
          }
          currentLineWidth = 0; // 重置当前行宽度
        }
        line += textArr[i];
      }

      // 最后一行如果有剩余的文本,则加入
      if (line.length > 0) {
        lines.push(line);
      }

      if (lines.length > 1) {
        totalWidth = actualWidth * (lines.length - 1) + this.canvas.measureText(lines.pop() || "").width;
      }

      return totalWidth;
    },
    // 判断字符是否是半角特殊字符或全角特殊字符
    isSpecialCharacter(char: string) {
      const halfWidthSpecialChars = /[!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~]/; // 半角特殊字符
      const fullWidthSpecialChars = /[!“”‘’()、,.:;<>@[]\^_`{}|~]/; // 全角特殊字符
      return halfWidthSpecialChars.test(char) || fullWidthSpecialChars.test(char);
    },
    getActualWidth(width: number) {
      // 获取一行实际的宽度
      let sizeWidth = 0;
      let str = "啊";
      while (sizeWidth <= width) {
        str += "啊";
        sizeWidth = this.getTextWidth(str);
      }

      return this.getTextWidth(str.slice(1));
    },
  },
  1. 这段代码只能初步实现分页计算:但是实际处理仍然不够完善,旨在于提供思路。比如:while (remainingText.length > 0) {} 会每个文字计算一个宽度,这是一种很浪费性能的表现。直接通过actualWidth,计算每一行能容纳最大的汉字开始分割计算就行了。
  2. 主要思路其实就是:每一个段落高度(行数x高度)相加,如果大于容器高度。就把最后一个段落进行软分行,拆分一部分放到下一页里面。
  3. customRound 会进行空白填充,如果剩余空白太多,会尝试去多追加一行,即使会超出容器部分高度的情况下(避免留白太多)
  4. 性能较差(200ms以内):仅供参考,感兴趣的话喵一眼94行248行(处理标点符号避头)就行了(ps:因为找到了有大佬现成的轮子,我就放弃优化了 - .-)

最终分页实现

  1. 来源于你不知道的阅读器排版引擎,处理得已经很完善了
  2. 在基础上我添加了一个空白填充功能的处理

remain1.png

remain2.png

两端对齐

  1. text-align: justify; 会存在兼容问题(nvue不支持
  2. 每一个文字占一个元素:然后通过 justify-content: space-between; 两端对齐,兼容性好。

底部对齐

  1. 段落父元素:display: flex; justify-content: space-between; flex-direction: column;
  2. 因为有尝试进行空白填充,所以最终留白的高度不会太多。两端对齐后肉眼不会有很明显的大间距。

JS计算翻页动画

思路

  1. 整个阅读器只加载三个页面:当前页,上一页,下一页。当前页数是第一页的时候,上一页内容为上一章最后一页的数据,当前页数是最后一页的时候,下一页内容为下一章第一页的数据。
  2. 切换页码的时候,动态修改三个页面的内容就行了。
  3. 翻页方式只是改变三个页面的布局和移动动画就行。
  4. 具体处理看 地址src/pages/reader/index.vue 的实现
const pageTextList = ref<Array<{ title: string; content: PageList }>>([  {    title: "", // "page-1",    content: [],
  },
  {
    title: "", // "page-2",
    content: [],
  },
  {
    title: "", // "page-3",
    content: [],
  },
]);

相关

参考

  1. 前端实现网络小说阅读器
  2. 你不知道的阅读器排版引擎
  3. 一个基于Vue.js的小说阅读器
  4. 基于CSS3 column多栏布局实现水平滑页翻页交互
  5. 面试官:你是如何获取文本宽度的?
  6. uniapp中使用renderjs的一些细节