บทความเทคนิค

ไฟล์ JBIG2 แบบ random-access: การ decode ใน Delphi

PDFlibPas เวอร์ชัน 3.539.23 decode ไฟล์ JBIG2 แบบ standalone ที่ใช้รูปแบบ random-access จาก ITU-T T.88 Annex D.2 ซึ่ง segment header ทุกตัวมาก่อนแล้วข้อมูล segment ค่อยตามมาในลำดับเดียวกัน decoder Pascal แท้ใน PDFlibJBIG2.pas ทำ index ของ offset หัวข้อไปจนถึง end-of-file header ที่บังคับมี เช็กว่าหมายเลข segment เพิ่มขึ้นและความยาวข้อมูลที่ประกาศรวมกันเท่ากับไบต์ที่เหลือพอดี แล้วจึง decode body ทีละตัวตามลำดับ header โดยไม่คัดลอกหรือเรียงใหม่ข้อมูลที่บีบอัด ก่อนรีลีสนี้ไฟล์เดียวกันโยน error แบบแห้ง ๆ ว่า "random-access organisation is not supported" ตั้งแต่อ่าน header flags จบ

ไฟล์ JBIG2 แบบ random-access หายาก ซึ่งเป็นเหตุผลพอดีว่าทำไมพอเจอทีไรมันถึงเจ็บทุกที ไฟล์แบบนี้มาจาก pipeline เชิง archive และระบบ document imaging ที่อยากให้ reader เห็น segment header ทุกตัว และต่อให้เห็น dependency ของทุกหน้ากับทุก dictionary ก่อนแตะไบต์บีบอัดสักไบต์ แอปพลิเคชัน Delphi ที่แปลง archive ที่สแกนไว้เป็น PDF เป็นชุดมักเจอพวกนี้กลางงานพอดี หลังจากไฟล์ sequential หลายร้อยไฟล์ผ่านไปเรียบร้อย และ decoder ที่หยุดคาตรงไฟล์ที่ผ่านรูปแบบมาถูกต้องก็ดีกว่าตัวที่ render ออกมาเป็นขยะได้แค่นิดเดียว release line เดียวกันเพิ่งสอน decoder เรื่องcustom Huffman table และ canonical prefix code ของ JBIG2 จบไป random access จึงเหลือเป็นช่องว่างรูปแบบไฟล์สุดท้ายในขีดจำกัดความสามารถที่เอกสารของ decoder ระบุไว้

รูปแบบ random-access ของ JBIG2 คืออะไร

รูปแบบ random-access เป็นหนึ่งในสามวิธีที่ T.88 Annex D ยอมให้จัดวาง segment ชุดเดิม: sequential (D.1) สอด header กับข้อมูลของมันไปเรื่อย ๆ, random-access (D.2) วาง header ทั้งหมดไว้หน้าแล้วข้อมูลทั้งหมดอยู่หลัง ส่วน embedded (D.3) เป็นรูปแบบไร้ header ที่ใช้ข้างใน container อื่นอย่าง PDF ไฟล์ .jb2 standalone เริ่มด้วย identifier แปดไบต์ 97 4A 42 32 0D 0A 1A 0A ตามด้วย flags byte หนึ่งไบต์และ เมื่อรู้จำนวนหน้า จำนวนหน้าแบบสี่ไบต์ bit 0 ของ flags byte เลือกรูปแบบ โดย 1 หมายถึง sequential และ 0 หมายถึง random-access; bit 1 ถูกตั้งแปลว่าจำนวนหน้าไม่ทราบและไม่มีตัวเลขสี่ไบต์ตามมา PDFlibPas อ่านสิ่งเหล่านี้ใน checkHeader กับ setFileHeaderFlags และ bit สงวน 2 ถึง 7 ถูกปล่อยผ่านแทนที่จะปฏิเสธ

รูปแบบไฟล์ JBIG2 ใน PDFlibPas: flags byte ที่ setFileHeaderFlags อ่านเลือก sequential D.1 ที่สอด header แทรกเรื่อย ๆ, random-access D.2 ที่ header ทุกตัวอยู่ก่อนบล็อกข้อมูล หรือ embedded D.3 รูปแบบไร้ header ที่ stream JBIG2Decode ใช้โดยมี dictionary อยู่ใน JBIG2Globals
layout ทั้งสามแบก segment ชุดเดียวกัน แต่มีแค่ random access เท่านั้นที่บังคับให้ reader เห็น dependency ของทุกหน้ากับทุก dictionary ก่อนแตะไบต์บีบอัด ซึ่งเป็นเหตุผลที่ pipeline เชิง archive เรียกหามัน
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = ไม่ระบุจำนวนหน้า
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // stream PDF: ไม่มี file header, เป็น embedded organisation, หนึ่งหน้า
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

ตัว PDF เองไม่เคยแบก layout นี้ สตรีมภาพ JBIG2Decode ที่อธิบายไว้ใน ISO 32000-1 §7.4.7 เก็บแต่ page segment ในรูปแบบ embedded โดยย้าย symbol dictionary ที่ใช้ร่วมกันไปไว้ใน stream JBIG2Globals แยกต่างหาก และไม่มี file header, end-of-page หรือ end-of-file segment เลย เมื่อ decodeJBIG2 หา identifier แปดไบต์ไม่เจอ มันจะถือว่าเป็นเคสนั้นพอดีและบังคับ decode แบบ sequential หน้าเดียว ส่วนการ export ภาพ JBIG2 แบบ native วิ่งสวนทาง คือห่อ segment ของ PDF ในไฟล์ standalone ที่ flags byte เป็น $03 sequential โดยไม่รู้จำนวนหน้า ตามด้วย end-of-file header ที่แถบท้ายไฟล์ งาน random-access จึงแตะเส้นทางเดียว: ไฟล์ standalone ที่ส่งให้ TPLJBIG2Decoder ตรง ๆ ตามปกติคือก่อนมันถูกแปลงหรือบีบอัดใหม่เป็น PDF ซึ่งเป็นงานที่backend encoder JBIG2 ใน PDFlibPas รับไว้ทำฝั่ง output

ทำไมไฟล์ random-access อ่านตามลำดับไฟล์ไม่ได้

ไฟล์ random-access อ่านตามลำดับไฟล์ไม่ได้เพราะไม่มีอะไรในสายไบต์บอกว่าบล็อก header จบตรงไหนและบล็อกข้อมูลเริ่มตรงไหน นอกจาก end-of-file segment header ตัวเอง header ของ segment JBIG2 มีความยาวไม่คงที่: จำนวน segment ที่อ้างถึงเป็นได้ทั้งฟอร์มสั้นสามบิตหรือฟอร์มยาวที่มี retention bitmap, หมายเลข segment ที่อ้างถึงใช้หนึ่ง สอง หรือสี่ไบต์ขึ้นกับหมายเลขของ segment นั้นเอง และฟิลด์ page association เป็นหนึ่งหรือสี่ไบต์ reader แบบ sequential ที่เดาสุ่มจะ parse header ตัวแรก อ่านความยาวข้อมูลของมัน แล้วถือเอาไบต์แรก ๆ ของ header ตัวที่สองเป็นข้อมูลของ segment นั้น decoder จะรู้ตัวว่าพลาดเมื่อไรไม่รู้เลยจนนานมาก นี่คือเหตุผลที่โค้ดเดิมปฏิเสธรูปแบบนี้ซะดื้อ ๆ แทนที่จะลอง

PDFlibPas ทำ index ของ random-access segment header อย่างไร

PDFlibPas ทำ index ของ random-access header ในการ pre-scan ครั้งเดียวคือ IndexRandomHeaders ที่ parse header ทุกตัว จดแค่ offset เป็นไบต์ของมัน แล้วหยุดที่ end-of-file header ตัวแรก (segment type 51) header แต่ละตัวถูก parse เต็ม ๆ แล้วทิ้ง index จึงเป็น array ของจำนวนเต็ม ไม่ใช่ list ของ object และการ pre-scan สะสมความยาวข้อมูลที่ประกาศไว้ระหว่างทาง เมื่อ scan จบ reader จะยืนอยู่บนไบต์แรกของข้อมูล segment แรก และตำแหน่งนั้นกลายเป็น NextBodyOffset

การ pre-scan IndexRandomHeaders ใน PDFlibPas: header ของ segment ทุกตัวถูก parse แล้วทิ้งโดยเก็บแค่ offset เป็นไบต์ หมายเลข segment ต้องเพิ่มขึ้นเคร่งครัด ความยาวไม่ทราบ 0xFFFFFFFF ถูกปฏิเสธ scan หยุดที่ end-of-file header ชนิด 51 และความยาวที่ประกาศต้องเท่ากับไบต์ที่เหลือพอดี
ความเข้มงวดนี้ตั้งใจมา: ใน layout ที่ header เป็นแผนที่เพียงแผนเดียวของข้อมูล ไบต์เร่ร่อนเพียงตัวเดียวแปลว่า body ทุกตัวหลังจากนั้นอาจเลื่อนตำแหน่ง decoder ที่ยอมให้ผ่านจึงแยก padding กับข้อมูลที่เลื่อนแนวไม่ออก
// IndexRandomHeaders, local ภายใน TJBIG2StreamDecoder.readSegments
while not reader.isFinished do
begin
  Offset := reader.bytePointer;
  Header := TSegmentHeader.Create;
  try
    readSegmentHeader(Header);
    if reader.BufferOverrun then
      raise EJBIG2DecodeError.CreateFmt(
        'JBIG2 truncated random-access header at byte %d', [Offset]);
    if (HeaderCount > 0) and
       (Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
      raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
    PreviousNumber := Header.getSegmentNumber;
    Count := Header.getSegmentDataLength;
    if Count < 0 then
      raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
    Inc(TotalLength, Count);                   // ตัวสะสมแบบ Int64
    HeaderOffsets[HeaderCount] := Offset;      // ขยายเป็นช่วง ๆ
    Inc(HeaderCount);
    if Header.getSegmentType = JBIG2_END_OF_FILE then
    begin
      if Count <> 0 then
        raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
      FoundEnd := True;
      Break;
    end;
  finally
    Header.Free;
  end;
end;
if not FoundEnd then
  raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;

การเช็กทุกอย่างใน loop นั้นมีอยู่เพราะไฟล์ random-access มีความซ้ำซ้อนน้อยกว่าแบบ sequential หมายเลข segment ต้องเพิ่มขึ้นเคร่งครัด เทียบกันแบบ unsigned เพราะ header สองตัวที่อ้างหมายเลขเดียวกันทำให้ไม่รู้ว่า body ที่ list อ้างถึงของ region หลัง ๆ หมายถึงตัวไหน ฟิลด์ความยาวข้อมูลถูกอ่านโดย handleSegmentDataLength ซึ่งแปลงค่าใด ๆ ที่บิตบนสุดถูกตั้ง รวมถึงตัวหมาย "unknown length" 0xFFFFFFFF เป็น -1 ใน layout random-access ไม่มีทางอื่นหาจุดเริ่มของ body ถัดไป ดังนั้น PDFlibPas จึงปฏิเสธความยาวแบบนั้นทันทีแทนที่จะวิ่งหา end marker ผลรวมต้องตรงกับไบต์ที่เหลือพอดีทั้งสองทิศ และไบต์เกินแม้แต่ตัวเดียวหลัง body สุดท้ายก็พังด้วย "trailing random-access data" ความเข้มงวดนี้ตั้งใจมา: ใน layout นี้ความยาวที่ไม่ตรงแปลว่า body ทุกตัวหลังจุดผิดพลาดถูกเลื่อนตำแหน่ง และ decoder ที่เมินไบต์เร่ร่อนหนึ่งตัวไม่มีทางรู้ได้เลยว่ามันเป็น padding ที่ไร้พิษภัยหรืออาการแรกของข้อมูลที่เลื่อนแนว

ทำไม end-of-page segment ตัวสุดท้ายถึงหายไป

end-of-page segment ตัวสุดท้ายหายไปเพราะเวอร์ชันแรกของ decode loop ยังเก็บเงื่อนไขจบแบบ sequential คือ while not reader.isFinished ไว้ ขณะที่ใน layout random-access สายข้อมูลหมดก่อน index ของ header end-of-page (type 49) กับ end-of-file segment แบกข้อมูลมาศูนย์ไบต์ และปกติมันคือ header ตัวสุดท้ายของไฟล์ หลัง body ของ region สุดท้ายถูกกินหมด reader ยืนอยู่ที่ปลาย buffer พอดี loop จึงออกและ segment ที่ยาวศูนย์เหล่านั้นไม่เคยถูก dispatch ทิ้งหน้าไว้ไม่เสร็จ การแก้ทำให้ loop ของ random-access นับ header แทนการนับไบต์ แต่ละรอบข้าม reader ไป header ถัดไปตาม index, ตั้ง bitPointer กลับเป็น 7 เพราะ body ก่อนหน้าอาจจบกลางไบต์, parse header นั้นใหม่ แล้วย้าย bytePointer ไปที่ NextBodyOffset และเดินมันผ่าน body ส่วน segment handler ที่มีอยู่ การเช็ก segment ที่อ้างถึง และ diagnostic ของ Context รันเหมือนเดิมทุกอย่าง และข้อความ error ยังรายงาน offset ไบต์เดิมของ header ไม่ใช่ตำแหน่งของ body

decode loop แบบ random-access ใน PDFlibPas: แต่ละรอบไปหา HeaderOffsets ของ header ปัจจุบัน ตั้ง bitPointer กลับเป็น 7 เพื่อแก้หางกลางไบต์ ข้ามไป NextBodyOffset เพื่อกิน body และนับ header แทนไบต์ ทำให้ end-of-page segment ที่ยาวศูนย์ได้รับคิวก่อน loop จบ
เพราะ end-of-page กับ end-of-file segment แบกข้อมูลมาศูนย์ไบต์ สายข้อมูลจึงหมดก่อน index ของ header และมีแค่ loop ที่นับ header เท่านั้นที่ให้ segment ปิดท้ายเหล่านี้ได้ถึงคิวของตัวเอง
// TJBIG2StreamDecoder.readSegments, loop หลัก
if randomAccessOrganisation then
  IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
      ((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
  if randomAccessOrganisation then
  begin
    reader.bytePointer := HeaderOffsets[HeaderIndex];
    reader.bitPointer := 7;                    // จัดแนวใหม่หลังไบต์ครึ่ง ๆ
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // ใช้ใน context ของ error
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // ข้ามไปข้อมูลของ segment นี้
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... dispatch เข้า segment handler ที่มีอยู่ แล้วไปที่ DataEnd
end;

การ validate แบบ random-access พิสูจน์อะไรกันแน่

การ validate พิสูจน์ว่าไบต์ที่จัดใหม่ decode ออกมาเป็นพิกเซลชุดเดียวกับต้นฉบับ sequential และพิสูจน์ว่า input random-access ที่ผิดรูปพังลงอย่างสะอาด มันไม่ได้พิสูจน์ความครอบคลุมไฟล์ random-access จาก encoder อะไรก็ได้ regression Pascal ที่ใช้ร่วมกันใช้ไฟล์สังเคราะห์ 235 ไบต์สร้างบน fixture custom-table ที่ต้อง decode ออกมาเป็นแถวพิกเซลดำ 7 คูณ 1 ทั้งเคสรู้จำนวนหน้าและเคสลบฟิลด์จำนวนหน้าทิ้ง จากนั้นป้อน decoder ทุก prefix ที่ถูกตัดขาดของไฟล์นั้น, หมายเลข segment ที่ซ้ำ, ไบต์แถมหนึ่งตัวและความยาวข้อมูลที่ไม่ทราบ โดย assert ทุกครั้งว่า LoadFromByteArray คืน False และทิ้ง Width กับ Height ไว้ที่ศูนย์ เคสภาพจริงเป็นภาพ refinement custom-table ขนาด 500 คูณ 473 ที่ segment ถูกจัดใหม่เป็น layout random-access โดยเก็บ header เดิมและไบต์บีบอัดทุกไบต์ไว้ SHA-256 ของมันตรงกับ baseline sequential ที่ผ่านการตรวจทานแล้วแบบเป๊ะ ไฟล์นั้นเป็นอนุพันธ์ที่ผลิตจากการ transform รูปแบบ ไม่ใช่เอกสาร random-access ธรรมชาติที่เก็บมาได้จากป่า และก็ไม่มีตัวอย่างธรรมชาติแบบนั้นให้ใช้ ชุดทดสอบผ่านที่ 1,598 test สำหรับ Delphi Win32, 42 สำหรับชุดภาพ Delphi Win64, 48 สำหรับ FPC Win32 และ 46 สำหรับ FPC Win64 ควบคู่กับ pixel case แบบ sequential เดิมสามตัว

การโหลดไฟล์ .jb2 แบบ random-access กับขีดจำกัดของมัน

โค้ดแอปพลิเคชันไม่ต้องแก้: TPLJBIG2Decoder.LoadFromByteArray ตรวจจับ file header กับรูปแบบด้วยตัวเอง คืน False กับ input ที่ถูกปฏิเสธโดยเหตุผลอยู่ใน LastError และเปิดหน้าที่ decode แล้วผ่าน Width, Height กับ GetScanline ซึ่งคืนหนึ่งไบต์ต่อพิกเซล

uses
  SysUtils, Classes, PDFlibJBIG2;

function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, FS.Size);
    if Length(Result) > 0 then
      FS.ReadBuffer(Result[0], Length(Result));
  finally
    FS.Free;
  end;
end;

function CountBlackPixels(const FileName: string): Integer;
var
  Decoder: TPLJBIG2Decoder;
  Row: TJBIG2ByteArray;
  X, Y: Integer;
begin
  Result := 0;
  Decoder := TPLJBIG2Decoder.Create;
  try
    // ไฟล์ standalone ทั้งแบบ sequential และ random-access ใช้ call เดียวกัน
    if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
      raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
    for Y := 0 to Decoder.Height - 1 do
      if Decoder.GetScanline(Y, Row) then
        for X := 0 to Decoder.Width - 1 do
          if Row[X] = 1 then
            Inc(Result);
  finally
    Decoder.Free;
  end;
end;

ขอบเขตควรพูดกันตรง ๆ การรองรับ random-access เป็นฟีเจอร์ด้านรูปแบบไฟล์ ไม่ใช่ API สุ่มหน้า: TPLJBIG2Decoder ยังคืนบิตแมปของหน้าแรก และไม่มี call ไว้เลือกหน้า 7 จากไฟล์ 40 หน้าหรือ decode หน้าแบบขี้เกียจได้ segment ที่ความยาวข้อมูลไม่ทราบถูกปฏิเสธในไฟล์ random-access และขีดจำกัดเดิมเรื่องความยาว prefix ของ custom Huffman กับจำนวน entry ในตารางก็เหมือนเดิม ขีดจำกัดพวกนี้แคบพอให้แอปพลิเคชัน Delphi ส่งต่อเคสที่ถูกปฏิเสธไปที่อื่นผ่าน LastError ได้ และส่วนที่เหลือของ pipeline ภาพ ตั้งแต่การดึงภาพจาก PDF ไปจนถึงการ encode JBIG2 ครอบคลุมอยู่ในหน้าผลิตภัณฑ์ PDFlibPas Delphi PDF library