StreamPDFStreamPDF
返回博客

PDF.js 源码解读: run_length_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: run_length_stream

前言

这是「字节与流」层里最短的两个过滤器之一,对象是 Worker 端 src/core/run_length_stream.js(59 行)中的 RunLengthStream。

上一篇 ascii_hex_stream 处理的是「文本换二进制」:两位十六进制字符拼成一个字节,解码器只需要记住有没有半个字节。本篇处理另一种朴素编码——行程编码(run-length encoding)。它不改变字符集,而是把「连续重复的字节」压成「一个头字节加一个样本字节」,把「一段不重复的字节」压成「一个头字节加若干原样字节」。

这种编码在 PDF 里并不承担通用压缩的职责。规范把它列在 RunLengthDecode 名下,主要服务两类数据:早期从 TIFF、传真、扫描仪转过来的位图,以及大片同色区域的掩码图像。这类数据的特点是「重复极多」,一行里常常有几百个相同的 0 或 255,行程编码因此非常划算。反过来,对已经用 Flate 压过的文本再套一层 RLE 几乎没有意义,所以它很少出现在现代生成器的输出里。

它的入口同样只有一行:

        case "RL":
        case "RunLengthDecode":
          return new RunLengthStream(stream, maybeLength);

来自 Parser.makeFilter。与 ASCIIHex 不同,这里没有单独的「扫描内联图像」分支:RLE 的结束标记是一个字节值,无法像大于号那样用文本扫描的方式安全定位,因此内联图像如果真用了 RunLengthDecode,会走通用的结束符查找。

它的编码单位是「run」,一个 run 最多产出 128 字节,最少产出 1 字节。解码器每次 readBlock 只展开一个 run,不为调用方的请求量做任何循环——循环在基类里。

一句话概括它的职责:读取一个头字节和紧随其后的样本,按 run 类型把样本复制或重复到输出缓冲,遇到结束字节立即停止。

模块总览

run_length_stream.js 只有 59 行,去掉版权头与 import,实际只有两个方法:

区块关键成员职责
构造constructor接管上游流与字典,不缩放尺寸提示
解码readBlock读两字节头、按类型展开一个 run、写入缓冲
继承设施DecodeStream缓冲、bufferLength、eof、ensureBuffer
对外读取getByte、getBytes按需驱动 readBlock,返回连续字节

数据路径可以写成:

编码字节(头字节 + 数据字节)
  → RunLengthStream.readBlock     (本文件,一次一个 run)
  → DecodeStream.buffer           (输出缓冲,每次追加 1 到 128 字节)
  → getBytes / getByte            (循环驱动,直到凑够调用方要的数量)
  → 图像解码器 / Lexer

与 ASCIIHex 相比,这里没有跨块保存的输入状态。每个 run 的头部、样本、数据都在同一次 readBlock 里读完;块与块之间只共享输出缓冲。这个差异直接决定了两个类一个需要 firstDigit 字段,另一个什么都不需要。

readBlock 同样忽略 decoderOptions。RLE 没有任何可调参数,所有信息都在头字节里。

构造:为什么不缩放尺寸提示

constructor 方法

class RunLengthStream extends DecodeStream {
  constructor(str, maybeLength) {
    super(maybeLength);
 
    this.stream = str;
    this.dict = str.dict;
  }

构造函数只有三行有效代码,和 ASCIIHex 的构造相比少了两样东西:尺寸缩放与状态字段。

先看尺寸提示为什么不缩放。ASCIIHex 的输出一定不超过输入的一半,ASCII85 约为五分之四,所以它们把 /Length 乘以一个系数,让首次分配更贴近真实输出。RLE 没有固定比例:

编码形态输入消耗输出产出输出与输入之比
复制型 run,n 等于 02 字节1 字节0.5
复制型 run,n 等于 127129 字节128 字节约 0.99
重复型 run,头字节 2552 字节2 字节1
重复型 run,头字节 1292 字节128 字节64

同一个文件里,膨胀率可以从 0.5 一路跳到 64。任何固定系数都会在某一端失效:乘 0.5 会让重复型流反复扩容,乘 2 又会让复制型流一开始就多占一倍内存。既然没有可靠的先验,就不猜。

maybeLength 于是只作为 DecodeStream 的起始容量提示,被抬到不小于它的 2 的幂。重复型数据真正需要的容量可能远远超过它,但那由 ensureBuffer 的几何扩容兜底。这与 ASCIIHex 的做法在原则上是同一个:提示只影响首次分配,正确性靠扩容。

再看状态字段为什么不需要。ASCIIHex 的输入可能在一个字符中间被切断,因此必须留半个字节。RLE 的输入以 run 为单位,一个 run 的所有字节在同一次 readBlock 里读完;上游 getBytes 本身会透明地跨块,所以解码器看不到「run 被切开」这种情况。没有需要跨调用保存的东西,构造函数自然只剩两个赋值。

this.dict = str.dict 依旧是必要的。RLE 常用于图像,下游解码器需要 /Width、/Height、/BitsPerComponent 等条目;字典在过滤器链里必须一路传递,否则图像无法解释。

设计要点:尺寸提示的价值取决于编码的确定性。输出上限明确的编码可以缩放提示,输出上限与数据强相关的编码不缩放。这里的选择不是省略,而是承认「不知道」,把不确定性交给扩容策略。

readBlock:一次只展开一个 run

readBlock 方法

  readBlock() {
    // The repeatHeader has following format. The first byte defines type of run
    // and amount of bytes to repeat/copy: n = 0 through 127 - copy next n bytes
    // (in addition to the second byte from the header), n = 129 through 255 -
    // duplicate the second byte from the header (257 - n) times, n = 128 - end.
    const repeatHeader = this.stream.getBytes(2);
    if (repeatHeader.length < 2 || repeatHeader[0] === 128) {
      this.eof = true;
      return;
    }
 
    let buffer;
    let bufferLength = this.bufferLength;
    let n = repeatHeader[0];

方法开头的注释就是完整规范:头字节决定 run 的类型与长度,第二个字节是样本。读懂这段注释,剩下的代码几乎是逐句直译。

第一个动作是 this.stream.getBytes(2)。为什么一次读两个字节,而不是先读头、再按需读样本?因为第二个字节在两种 run 里都必须用到:

  • 复制型 run 里,它是第一个被复制的字节,头字节的 n 只计算它之后的额外字节。
  • 重复型 run 里,它是被重复的那一个值。

既然两种情况下都要取,一次读两字节就把「取头」与「取样本」合并,少一次上游调用,也少一次截断检查。

repeatHeader.length < 2 覆盖两种情况:上游已经完全耗尽,或者只剩一个字节。无论剩下的那个字节是不是结束标记,都无法构成一个完整的 run,因此统一归到 eof,不产出任何数据。

repeatHeader[0] === 128 是规范里唯一的结束值。判断放在分配缓冲之前,因为结束 run 不需要任何输出;如果先 ensureBuffer 再判断,会为一个不存在的 run 白白扩容。

buffer 声明为 let 而不是 const,是因为它由 ensureBuffer 返回,可能是一个新数组。bufferLength 复制到局部变量,减少方法内的属性访问,这一点和 ASCIIHex 的做法一致。

n 先取头字节原值,后面按类型分别解释。这里没有对 n 做任何合法性检查:0 到 255 的每个值都有确定语义,不存在非法头。

readBlock 每次只处理一个 run,不会为了填满缓冲而循环。理由是基类已经负责循环:DecodeStream.getBytes 会反复调用,直到攒够调用方要的字节数或遇到 eof。把循环放在基类,子类就只需要保证「一次调用要么产出至少一个字节,要么置 eof」。

设计要点:一个方法只做一件事。readBlock 的语义被压缩成「展开一个 run」,因此它不需要知道调用方要多少字节,也不需要处理「半个 run」的残留。所有跨调用的事情都交给输出缓冲。

头字节语义

头字节把一个字节的空间切成三段,每段连续、无空隙:

头字节 n类型输出长度输入消耗数据来源
0 到 127复制型n + 1(1 到 128)n + 2 字节头后的样本字节,再加后续 n 个字节
128结束01 到 2 字节无
129 到 255重复型257 - n(2 到 128)2 字节头后的样本字节重复若干次

复制型的 n 表示「样本之外的额外字节数」。n 等于 0 时,一个 run 只产出一个字节,就是紧跟头部的那个样本;n 等于 127 时,产出 128 字节。复制型的输入消耗比输出长度多一个字节,因为多了一个头字节。

重复型的长度是 257 减头字节。头字节 255 得到 2,头字节 129 得到 128。这个反向计数不是随意设计,而是把「有符号的负数」映射到无符号字节上的结果:PackBits 用 -1 到 -127 表示重复长度 2 到 128,把这些有符号数按补码读出,正好是 255 到 129。PDF 规范沿用了这套映射,于是头字节的数值越小,重复次数越多。

128 被留作结束,正好填补两段之间的空位。按 257 - 128 计算它会得到 129,超过单 run 的上限 128,所以这个值本来也无法表示合法的重复长度。把它定义为结束标记,既没有浪费编码空间,也让「头字节等于 128 就停」成为一条无需查表的规则。

两个类型的长度上限都是 128。这个上限不是巧合:它保证任何一个 run 都不需要用两个字节来表示长度,头字节的所有取值都能被有效利用。代价是长行程必须拆成多个 run,例如 300 个相同的字节会被拆成 128、128、44 三段。

设计要点:一个字节同时编码「类型」与「长度」,靠区间划分而不是标志位。复制型用正向计数,重复型用补码式的反向计数,两者在数值上首尾相接,中间只留下一个结束值。这种布局让解码器的判断只剩一次比较。

复制型 run:n 小于 128

if (n < 128) {
  // copy n bytes
  buffer = this.ensureBuffer(bufferLength + n + 1);
  buffer[bufferLength++] = repeatHeader[1];
  if (n > 0) {
    const source = this.stream.getBytes(n);
    buffer.set(source, bufferLength);
    bufferLength += n;
  }
}

复制型的展开几乎是内存拷贝。

ensureBuffer(bufferLength + n + 1) 一次把容量扩到「已有输出加本 run 的输出」。输出正好是 n + 1 字节:样本一个,加上后续 n 个。扩容只在这一处发生,后面的写入不再检查容量。

先写样本字节:buffer[bufferLength++] = repeatHeader[1]。此时 buffer 已经是 ensureBuffer 的返回值,即使扩容换了数组,写入的也是新数组。这个顺序不能反:若先取旧引用再扩容,写进去的字节会落在被丢弃的旧缓冲里。

if (n > 0) 是一个小优化。n 等于 0 时不需要再向上游要数据,直接结束本 run;省掉一次 getBytes(0) 调用。getBytes(0) 在 DecodeStream 里的语义是「读到末尾」,如果不加这个判断,会把整个剩余流读进来,语义完全错位。这个分支不是可有可无的防御,而是必须的。

buffer.set(source, bufferLength) 把上游返回的视图整段复制到输出缓冲的指定位置。source 可能是一个 subarray,指向上游缓冲;set 把它逐字节拷进输出,两个数组互不影响。这里没有用循环,也没有用展开运算符,原生批量拷贝比 JS 逐字节快得多。

bufferLength += n 用的是请求量 n,而不是 source.length。正常情况下 getBytes(n) 会返回正好 n 个字节;如果上游提前耗尽,它可能返回更少。此时按 n 推进会越过实际写入的数据,尾部留空。

这是本方法唯一一处「乐观」的地方。按 n 推进会越过实际写入的数据,多出来的位置保持 0:缓冲在分配时是零填充的,而 bufferLength 只增不减,这些位置从未被写过。于是截断的输入不会报错,而是产出一段「长度按 run 计算、尾部全为零」的字节。下一次 readBlock 调用 getBytes(2) 只能拿到零个或一个字节,立刻置 eof,读取循环随之停止。真正的损坏发生在被截断的文件里,此时任何解码策略都无法还原数据,尾部是否正确已经没有意义。

设计要点:复制型 run 的本质是 memcpy。解码器只做两次写入:一次单个字节,一次批量复制。没有逐字节循环,因此即使是最坏情况下的 128 字节 run,JS 层的开销也只是一个常数。

重复型 run:n 大于等于 128

    } else {
      n = 257 - n;
      buffer = this.ensureBuffer(bufferLength + n + 1);
      buffer.fill(repeatHeader[1], bufferLength, bufferLength + n);
      bufferLength += n;
    }
    this.bufferLength = bufferLength;

重复型只有三行,但信息密度很高。

n = 257 - n 把反向计数还原成重复次数。头字节 129 变成 128,255 变成 2。这个减法也顺带把 n 从「头字节语义」切换到「输出长度语义」,后面两行不再需要区分类型。

ensureBuffer(bufferLength + n + 1) 沿用了复制型的表达式,多分配一个字节。重复型实际只需要 n 字节,多出的一个位置不会被写入。这是为了让两个分支的扩容表达式保持一致,代价最多一字节,换来的是读代码时不需要在两种公式之间切换。

buffer.fill(repeatHeader[1], bufferLength, bufferLength + n) 是核心。Uint8Array.fill 的区间是左闭右开:起点包含,终点不包含,因此正好填满 n 个位置。这是原生的内存填充,行为等价于 memset,不需要在 JS 里写循环。

举两个具体的头字节:

输入字节头字节解释输出
0x81 0x00重复 128 次128 个 0x00
0x81 0xFF重复 128 次128 个 0xFF
0xFF 0x41重复 2 次两个 A
0x80结束无

第一行就是「大片同色」的典型形态:两个字节换 128 个字节,膨胀 64 倍。一张全白的 1 位掩码图,压缩后可能只有几十个字节。

bufferLength += n 之后,方法末尾统一写回 this.bufferLength。两个分支都在这里汇合,避免在分支内部重复赋值。

设计要点:重复型 run 不复制样本,只填充。fill 与 set 覆盖了两种 run 的全部写入,解码器没有任何按字节推进的路径。这类「把工作交给原生数组方法」的写法,是短小过滤器能跑得足够快的主要原因。

一段数据的完整展开

把编码字节逐个走一遍,比抽象描述更容易看清 run 的边界。假设上游提供这样八个字节:

03 41 42 43 44 81 00 80

第一次 readBlock:

  1. getBytes(2) 取到 03 与 41。
  2. 头字节 3 小于 128,是复制型,输出长度是 3 加 1,也就是 4。
  3. 先写样本 0x41,再 getBytes(3) 取到 42 43 44,整段复制。
  4. bufferLength 从 0 变成 4,上游位置到 5。

第二次调用:

  1. getBytes(2) 取到 81 与 00。
  2. 头字节 129 大于等于 128,是重复型;257 - 129 等于 128。
  3. fill 把 0x00 写到输出缓冲的 128 个位置。
  4. bufferLength 从 4 变成 132,上游位置到 7。

第三次调用:

  1. 上游只剩 80 一个字节,getBytes(2) 返回长度为 1 的视图。
  2. repeatHeader.length < 2 先命中,直接置 eof,不产出。
  3. bufferLength 保持 132。

整个过程可以列成一张表:

调用上游读取头字节展开方式本次产出输出累计上游位置
103 41 42 43 443复制 4 字节445
281 00129重复 128 次1281327
380128结束01328

输出缓冲的前八个字节是 41 42 43 44 00 00 00 00,后面继续跟着 124 个 00。

这张表还解释了「按需读取」的含义。如果调用方只请求 4 个字节,getBytes(4) 在第一次 readBlock 之后就满足条件,第二、三个 run 完全不会被展开;剩下的字节留在上游,等下一次读取时再处理。如果调用方一次请求 200 字节,则要跑完三次调用,最后返回 132 字节,而不是报错或补零。

第三个调用还有一个容易被忽略的顺序细节:结束标记只占一个字节,但代码总是请求两个。当它恰好是文件末尾时,「不足两字节」的判断比「等于 128」更早生效。两条路径的结果都是 eof,所以这个顺序不影响正确性,但它说明头部读取是「按对」而不是「按语义」进行的。

设计要点:解码进度由 run 的边界决定,而不是由调用方请求的长度决定。请求 4 字节只展开一个 run,请求 200 字节展开到 eof;同一份数据可以按任意粒度被消费,状态始终一致。

解码器与图像消费者的边界

RunLengthDecode 在 PDF 里几乎只用于图像。这意味着它输出的字节不是交给 Lexer 去分词,而是交给图像解码器去解释成像素。这条边界决定了本类不需要理解行、列或颜色空间。

this.dict = str.dict 是这条边界上的关键一行。RLE 本身不读字典,但它把字典完整传给下游,图像解码器才能拿到 /Width、/Height、/BitsPerComponent、/ColorSpace。如果这一层把字典丢掉,过滤器链虽然还能解码,像素却无法被正确组装。

DecodeParms 对 RLE 没有意义。Parser.makeFilter 的 RL 分支不接收 params,因为规范没有为 RunLengthDecode 定义任何参数。对比同一个 switch 里的 FlateDecode,后者会把 params 交给 PredictorStream;RLE 没有任何类似的横向差分或行宽配置。

还有一个容易误解的点:RLE 的 run 可以跨越图像的行边界。编码是按字节流进行的,编码器完全可以把一行的末尾与下一行的开头合并成一个 run;解码器也不需要知道一行有多宽,它只负责连续展开。分行是图像解码器的事,位置由 /Width 与 /BitsPerComponent 决定。

内存上,本类始终只持有「已解码输出」。图像解码器通常请求整张图,getBytes 会反复驱动 readBlock,缓冲一路增长到完整像素大小;扩容是按几何倍数发生的,因此总复制量仍是线性的。对于一张以大片同色为主的图,输入很小、输出很大,这个增长过程会经历多次翻倍,但每次翻倍的代价都被后续的大量读取摊薄。

设计要点:过滤器只负责字节,不负责语义。RLE 不知道什么是行、什么是像素、什么是颜色空间;它把字典原样传递,把字节连续输出,把解释权留给下游。这条边界让同一个解码器可以服务任意图像类型。

输出累积与跨块

readBlock 一次只产出一个 run,但调用方要的往往是一整段数据。累积靠的是两个东西:this.bufferLength 与 ensureBuffer。

this.bufferLength 在对象上持续存在,每次 readBlock 从它开始写,结束时更新它。因此第二次调用看到的是上一次的终点,输出自然首尾相接。ensureBuffer 只在容量不够时分配新数组,并把旧内容整体复制过去;逻辑长度不受影响。

repeatHeader 是上游缓冲的视图,不因为输出缓冲扩容而失效。它指向的是另一块内存,与 this.buffer 无关。这也是「先读头再扩容」这种顺序能成立的原因:读到的两个字节在整个方法里都有效。

一次 getBytes(1000) 需要多少次 readBlock,取决于数据形态:

数据形态单次产出调用次数说明
全为同一字节128 字节约 8 次重复型最大 run
随机字节,长行程128 字节约 8 次复制型最大 run,n 等于 127
大量单字节 run1 字节最多 1000 次n 等于 0,每次只出一个字节
截断的尾部0 字节1 次头部不足两字节,立即 eof

最坏情况是「每个 run 只产出一个字节」,此时每读一个字节要调用一次 readBlock,每次还要向上游要两个字节。这种输入在现实中很少见:行程编码存在的意义就是把连续重复压短,如果数据本身没有行程,编码器通常不会选择它。即便如此,代价也只是函数调用开销,不会退化成平方复杂度。

另一个值得注意的点是:readBlock 从不「预读」下一个 run。它不知道后面还有没有数据,也不需要知道。头字节是否等于 128、上游是否还有两个字节,都要等下一次调用才能确定。这种「一次一个」的节奏让内存占用始终等于「已解码输出加一个 run」,与流的长度无关。

设计要点:跨块只共享输出,不共享输入。RLE 的每个 run 都在单次调用内完成,因此解码器没有任何「待续状态」。这一点与 ASCIIHex 正好相反:那边块边界落在字符之间,必须留半个字节;这边块边界永远落在 run 之间。

结束、截断与容错

RLE 没有独立的结束标记章节,因为结束就是头字节的一个取值。把四种收尾放在一起看:

情况检测点行为
正常结束头字节等于 128置 eof,不产出
上游耗尽getBytes(2) 返回少于 2 字节置 eof,不产出
复制型数据不足getBytes(n) 返回更短写入已有部分,下一次调用补上 eof
重复型数据不足不可能只需要第二个字节,已在头部读取

第一种情况有一个细节:结束字节只有一个字节,但代码读了两个。如果文件恰好在这里结束,getBytes(2) 返回一个字节,length < 2 先命中,repeatHeader[0] === 128 根本不会被求值。两条路径的结果相同,都是 eof。结束标记后面如果还有字节,那两个字节都会被读进来,第二个字节被丢弃——它本来也不属于这个流。

第二种情况是真正的截断。一个头字节加零个样本,无法构成任何 run,只能停止。

第三种情况是部分数据。getBytes(n) 会尽量返回,靠近末尾时截短。代码仍然按 n 推进逻辑长度,这一点前面已经讨论过:输出尾部会被补成零字节,而不是回退长度。下一次调用立刻结束,调用方拿到的是一段「声明长度按 run 计算、尾部为零」的数据。

重复型没有第三种情况,因为它只需要两个字节就能完成一个 run,而这两个字节在开头已经一起读到了。

解码器不做任何头字节校验。每个 0 到 255 的取值都有确定语义,没有「非法头」这种东西;损坏的数据只会被解释成某种 run,产出看起来合法但内容错误的字节。这是有意的取舍:RLE 在 PDF 里几乎只用于图像,图像损坏时上层的图像解码器会报错或降级,解码器本身没有必要再维护一套错误码。

设计要点:头字节即真相。解码器不保存「待补数据」,也不回头验证 run 是否合理;所有异常都收敛到同一个 eof。对一种只服务图像的简单编码来说,这样的容错粒度已经足够。

与 ASCIIHex 的对照

两个过滤器都很短,但它们的差异恰好覆盖了「块边界」的两种处理方式:

维度ASCIIHexDecodeRunLengthDecode
数据形态文本二进制
跨块状态半个字节(firstDigit)无
结束标记大于号0x80
单次产出最多 4000 字节1 到 128 字节
输入消耗每次最多 8000 字节每次 2 到 129 字节
最坏膨胀0.564
尺寸提示乘以 0.5不缩放
写入方式逐字节写set 或 fill 批量写

ASCIIHexDecode 的边界落在字符之间,所以必须保存半个字节;RunLengthDecode 的边界落在 run 之间,所以什么都不用保存。两者都用「带内标记」结束:一个用大于号,一个用特定字节值。区别在于,大于号在文本里几乎不会自然出现,可以安全扫描;0x80 在二进制里完全可能出现在数据中,所以只能按 run 语义顺序读取,不能事先扫描。

这也是为什么内联图像只给 ASCIIHex 和 ASCII85 写了专用的结束扫描函数,而 RLE 走通用路径。能否「先扫描后切分」,取决于标记是否与数据空间可区分。

总结

RunLengthStream 是 PDF.js 的行程展开过滤器:读取一个头字节与一个样本,把样本复制或重复到输出缓冲,再由基类的读取循环把一个个 run 拼成连续字节。

它在整条链路中的位置

阶段模块输入输出
过滤器选择Parser.makeFilterRL、/LengthRunLengthStream
展开RunLengthStream.readBlock头字节与样本1 到 128 字节
累积DecodeStream多次 readBlock 的产出连续输出缓冲
读取DecodeStream.getBytes调用方需要的长度字节视图
消费图像解码器、Lexer字节像素、token

用一句话串起来:上游提供编码字节,readBlock 每次展开一个 run,getBytes 把多个 run 缝成连续数据,图像解码器与解析器只看结果。

五条设计原则

  1. 头字节自带类型与长度。复制型用 0 到 127,结束用 128,重复型用 129 到 255;判断只需要一次比较,不需要额外的标志位。
  2. 一个 run 一次调用。readBlock 不在内部循环凑长度,也不跨调用保存输入状态。跨块由基类的读取循环与持续累积的输出缓冲共同解决。
  3. 先读头部两字节,再按需读数据。两种 run 都必然用到第二个字节,合并读取减少一次上游调用与一次截断判断;结束与截断也在同一处被发现。
  4. 批量写入交给原生方法。复制用 set,重复用 fill,JS 层只做常数级调度。这是短小过滤器仍能高效处理大图的原因。
  5. 不确定的比例不做提示。输出与输入之比在 0.5 到 64 之间浮动,因此不缩放 maybeLength,把容量问题交给几何扩容。

与上一篇 ascii_hex_stream 放在一起看,这两个类构成过滤器链里最短的一对:一个把文本拆成半字节再拼字节,一个把字节按 run 展开。它们都不需要字典、码表或窗口,却完整展示了 DecodeStream 契约的两个极端——一个有跨块状态,一个完全没有。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章