Τεχνικό Άρθρο

JBIG2 random-access αρχεία: αποκωδικοποίησή τους στο Delphi

Το PDFlibPas έκδοση 3.539.23 αποκωδικοποιεί standalone αρχεία JBIG2 που χρησιμοποιούν την random-access οργάνωση από το Παράρτημα D.2 του ITU-T T.88, όπου όλα τα segment headers έρχονται πρώτα και τα segment data ακολουθούν στην ίδια σειρά. Ο native Pascal decoder στο PDFlibJBIG2.pas ευρετηριάζει τα header offsets μέχρι το υποχρεωτικό end-of-file header, ελέγχει ότι οι αριθμοί segment αυξάνονται και ότι τα δηλωμένα μήκη δεδομένων αθροίζουν ακριβώς τα bytes που απομένουν, και μετά αποκωδικοποιεί κάθε body σε σειρά header χωρίς αντιγραφή ή αναδιάταξη των συμπιεσμένων δεδομένων. Πριν από αυτή την έκδοση το ίδιο αρχείο πετούσε ένα flat error «random-access organisation is not supported» τη στιγμή που διαβαζόταν τα header flags

Τα random-access αρχεία JBIG2 είναι σπάνια, που είναι ακριβώς ο λόγος που πονάνε όταν εμφανιστούν. Βγαίνουν από archival pipelines και document-imaging συστήματα που θέλουν ο reader να δει κάθε segment header, άρα κάθε εξάρτηση σελίδας και dictionary, πριν αγγίξει ένα συμπιεσμένο byte. Μια εφαρμογή Delphi που μετατρέπει μαζικά σκαναρισμένα αρχεία σε PDF συνήθως πέφτει σε ένα από αυτά στη μέση μιας δουλειάς, αφού εκατοντάδες sequential αρχεία πέρασαν καθαρά, και ένας decoder που σταματά νεκρός σε ένα καλοσχηματισμένο αρχείο είναι οριακά καλύτερος από έναν που αποδίδει σκουπίδια. Η ίδια σειρά εκδόσεων είχε μόλις τελειώσει να μαθαίνει στον decoder το custom Huffman tables και canonical prefix codes του JBIG2, οπότε το random access ήταν το τελευταίο κενό οργάνωσης που απέμενε στα τεκμηριωμένα όρια δυνατοτήτων του decoder

Τι είναι η random-access οργάνωση του JBIG2;

Η random-access οργάνωση είναι ένας από τους τρεις τρόπους που το Παράρτημα D του T.88 επιτρέπει να τοποθετούνται τα ίδια segments: το sequential (D.1) μπλέκει κάθε header με τα δεδομένα του, το random-access (D.2) βάζει όλα τα headers πρώτα και όλα τα δεδομένα μετά, και το embedded (D.3) είναι η χωρίς header μορφή που χρησιμοποιείται μέσα σε άλλα containers όπως το PDF. Ένα standalone αρχείο .jb2 ξεκινά με τον οκτά-byte αναγνωριστικό 97 4A 42 32 0D 0A 1A 0A, ακολουθούμενο από ένα byte flags και, όταν ο αριθμός σελίδων είναι γνωστός, ένα τεσσάρων-byte πλήθος σελίδων. Το bit 0 του byte flags επιλέγει την οργάνωση, με 1 να σημαίνει sequential και 0 random-access· το bit 1 σε 1 σημαίνει ότι ο αριθμός σελίδων είναι άγνωστος και το τεσσάρων-byte πλήθος απουσιάζει. Το PDFlibPas τα διαβάζει στο checkHeader και στο setFileHeaderFlags, και τα reserved bits 2 έως 7 γίνονται ανεκτά αντί να απορρίπτονται

Οργανώσεις αρχείων JBIG2 στο PDFlibPas: το byte flags που διαβάζει το setFileHeaderFlags διαλέγει sequential D.1 με μπλεγμένα headers, random-access D.2 με κάθε header πριν το block δεδομένων, ή embedded D.3, τη χωρίς header μορφή που χρησιμοποιεί ένα stream JBIG2Decode με dictionaries σε JBIG2Globals
Οι τρεις διατάξεις κουβαλούν τα ίδια segments, αλλά μόνο το random access κάνει τον reader να δει κάθε εξάρτηση σελίδας και dictionary πριν αγγίξει συμπιεσμένο byte, γι' αυτό το ζήτησαν οι archival pipelines
// 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
  // PDF stream: κανένα file header, embedded οργάνωση, μία σελίδα
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

Το ίδιο το PDF δεν κουβαλά ποτέ αυτή τη διάταξη. Ένα image stream JBIG2Decode, όπως περιγράφεται στο ISO 32000-1 §7.4.7, κρατά μόνο τα page segments σε embedded οργάνωση, με τα κοινόχρηστα symbol dictionaries μετακομισμένα σε ξεχωριστό stream JBIG2Globals και κανένα file header, end-of-page ή end-of-file segment. Όταν το decodeJBIG2 δεν βρίσκει τον οκτά-byte αναγνωριστικό, υποθέτει ακριβώς αυτό και επιβάλλει sequential, single-page αποκωδικοποίηση. Το native JBIG2 image export πάει προς την άλλη κατεύθυνση και τυλίγει τα PDF segments σε standalone αρχείο με byte flags $03, sequential με άγνωστο πλήθος σελίδων, ακολουθούμενο από προσαρτημένο end-of-file header. Η δουλειά του random access λοιπόν αγγίζει ένα μόνο μονοπάτι: standalone αρχεία που παραδίδονται απευθείας στο TPLJBIG2Decoder, τυπικά πριν μετατραπούν ή ξανασυμπιεστούν για PDF, η δουλειά που αναλαμβάνουν στην έξοδο τα JBIG2 encoder backends στο PDFlibPas

Γιατί ένα random-access αρχείο δεν μπορεί να διαβαστεί σε σειρά αρχείου;

Ένα random-access αρχείο δεν μπορεί να διαβαστεί σε σειρά αρχείου επειδή τίποτα στη ροή bytes δεν σηματοδοτεί πού σταματά το block headers και πού αρχίζει το block δεδομένων, εκτός από το ίδιο το segment header end-of-file. Τα segment headers του JBIG2 έχουν μεταβλητό μήκος: το πλήθος referred-to segment μπορεί να είναι τρίbit σύντομη μορφή ή μεγάλη μορφή με retention bitmap, οι αριθμοί referred-to segment παίρνουν ένα, δύο ή τέσσερα bytes ανάλογα με τον αριθμό του ίδιου του segment, και το πεδίο page association είναι ένα ή τέσσερα bytes. Ένας αφελής sequential reader αναλύει το πρώτο header, διαβάζει το μήκος δεδομένων του, και μετά μεταχειρίζεται τα πρώτα bytes του δεύτερου header ως δεδομένα εκείνου του segment. Ο decoder δεν μπορεί να καταλάβει ότι πήγε στραβά μέχρι πολύ αργότερα, γι' αυτό ο παλιός κώδικας αρνιόταν την οργάνωση εξ ολοκλήρου αντί να την επιχειρήσει

Πώς ευρετηριάζει το PDFlibPas τα random-access segment headers;

Το PDFlibPas ευρετηριάζει τα random-access headers σε ένα pre-scan, το IndexRandomHeaders, που αναλύει κάθε header, καταγράφει μόνο το byte offset του, και σταματά στο πρώτο end-of-file header (segment type 51). Κάθε header αναλύεται πλήρως και πετιέται, οπότε το index είναι πίνακας ακέραιων αντί λίστα objects, και το pre-scan αθροίζει τα δηλωμένα μήκη δεδομένων καθώς προχωρά. Όταν το σκαν τελειώσει, ο reader κάθεται στο πρώτο byte των δεδομένων του πρώτου segment, και εκείνη η θέση γίνεται NextBodyOffset

Pre-scan IndexRandomHeaders στο PDFlibPas: κάθε segment header αναλύεται και πετιέται ενώ κρατείται μόνο το byte offset του, οι αριθμοί segment πρέπει να αυξάνονται αυστηρά, το άγνωστο μήκος 0xFFFFFFFF απορρίπτεται, το σκαν σταματά στο end-of-file header type 51, και τα δηλωμένα μήκη πρέπει να ισούνται ακριβώς τα υπόλοιπα bytes
Η αυστηρότητα είναι σκόπιμη: σε μια διάταξη όπου τα headers δίνουν τον μόνο χάρτη των δεδομένων, ένα παραπάνω byte σημαίνει ότι κάθε μεταγενέστερο body μπορεί να έχει μετατοπιστεί, οπότε ένας decoder που το ανέχεται δεν ξεχωρίζει padding από κακή ευθυγράμμιση
// IndexRandomHeaders, τοπικό στο 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;

Κάθε έλεγχος σε εκείνη τη λούπα υπάρχει επειδή ένα random-access αρχείο έχει λιγότερη πλεονάζουσα πληροφορία από ένα sequential. Οι αριθμοί segment πρέπει να αυξάνονται αυστηρά, συγκρινόμενοι ως unsigned τιμές, επειδή δύο headers που διεκδικούν τον ίδιο αριθμό κάνουν ασαφές ποιο body εννοεί η referred-to λίστα μιας μεταγενέστερης περιοχής. Το πεδίο μήκους δεδομένων διαβάζεται από το handleSegmentDataLength, που αντιστοιχίζει κάθε τιμή με set το πάνω bit, συμπεριλαμβανομένου του δείκτη «unknown length» 0xFFFFFFFF, στο -1· σε random-access διάταξη δεν υπάρχει άλλος τρόπος να βρεθεί πού αρχίζει το επόμενο body, οπότε το PDFlibPas απορρίπτει εκείνο το μήκος αμέσως αντί να σκανάρει για end marker. Το σύνολο πρέπει να ταιριάζει τα υπόλοιπα bytes ακριβώς και προς τις δύο κατευθύνσεις, και ένα μοναδικό παραπάνω byte μετά το τελευταίο body αποτυγχάνει με «trailing random-access data». Η αυστηρότητα είναι σκόπιμη: σε αυτή τη διάταξη ένα mismatch μήκους σημαίνει ότι κάθε body μετά το σημείο λάθους είναι μετατοπισμένο, και ένας decoder που το περνά ελαφρά από ένα παραπάνω byte δεν έχει τρόπο να ξέρει αν είναι αβλαβές padding ή το πρώτο σύμπτωμα παραστρατημένων δεδομένων

Γιατί χάθηκε το τελευταίο segment end-of-page;

Το τελευταίο segment end-of-page χάθηκε επειδή η πρώτη εκδοχή της λούπας αποκωδικοποίησης κράτησε το sequential τεστ τερματισμού, while not reader.isFinished, και σε random-access διάταξη η ροή δεδομένων τελειώνει πριν το header index. Τα segments end-of-page (type 49) και end-of-file κουβαλάνε μηδέν bytes δεδομένων, και κανονικά είναι τα τελευταία headers στο αρχείο. Αφού καταναλωθεί το body της τελικής περιοχής, ο reader κάθεται ακριβώς στο τέλος του buffer, οπότε η λούπα εξέρχεται και εκείνα τα segments μηδενικού μήκους δεν γίνονται ποτέ dispatch, αφήνοντας τη σελίδα ημιτελή. Το fix κάνει τη random-access λούπα να μετράει headers αντί για bytes. Κάθε επανάληψη πηδάει τον reader στο επόμενο ευρετηριασμένο header, μηδενίζει το bitPointer στο 7 επειδή το προηγούμενο body μπορεί να τελείωσε μέσα σε byte, ξανα-αναλύει εκείνο το header, μετά μετακινεί το bytePointer στο NextBodyOffset και το προχωρά πέρα από το body. Οι υπάρχοντες segment handlers, οι έλεγχοι referred-to segment και η διαγνωστική Context τρέχουν αμετάβλητοι, και ένα μήνυμα error αναφέρει ακόμα το πρωτότυπο byte offset του header, όχι τη θέση του body

Λούπα αποκωδικοποίησης random-access στο PDFlibPas: κάθε επανάληψη πηγαίνει στα HeaderOffsets του τρέχοντος header, μηδενίζει το bitPointer στο 7 για να αναιρέσει ουρές μέσα σε byte, πηδά στο NextBodyOffset για το body, και μετρά headers αντί για bytes ώστε τα segments end-of-page μηδενικού μήκους να παίρνουν dispatch πριν τελειώσει η λούπα
Επειδή τα segments end-of-page και end-of-file κουβαλάνε μηδέν bytes δεδομένων, η ροή δεδομένων τελειώνει πριν το header index, και μόνο μια λούπα που μετρά headers μπορεί να δει εκείνα τα τελικά segments να παίρνουν τη σειρά τους
// TJBIG2StreamDecoder.readSegments, κύρια λούπα
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;                    // ευθυγράμμιση μετά από μερικό byte
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // χρησιμοποιείται σε error context
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // άλμα στα δεδομένα αυτού του segment
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... dispatch στον υπάρχοντα segment handler, μετά seek στο DataEnd
end;

Τι αποδεικνύει στην πραγματικότητα η random-access επικύρωση;

Η επικύρωση αποδεικνύει ότι τα αναδιοργανωμένα bytes αποκωδικοποιούνται στα ίδια pixels με τα sequential πρωτότυπά τους, και αποδεικνύει ότι κακοσχηματισμένη random-access είσοδος αποτυγχάνει καθαρά· δεν αποδεικνύει κάλυψη random-access αρχείων από αυθαίρετους encoders. Το κοινό Pascal regression χρησιμοποιεί ένα συνθετικό αρχείο 235 bytes χτισμένο πάνω σε custom-table fixture που πρέπει να αποκωδικοποιηθεί σε σειρά 7 επί 1 μαύρα pixels, τόσο με γνωστό πλήθος σελίδων όσο και με αφαιρεμένο το πεδίο πλήθους, και μετά ταΐζει στον decoder κάθε κομμένο πρόθεμα εκείνου του αρχείου, έναν διπλό αριθμό segment, ένα byte ουράς και ένα άγνωστο μήκος δεδομένων, ισχυριζόμενο κάθε φορά ότι το LoadFromByteArray επιστρέφει False και αφήνει Width και Height στο μηδέν. Η περίπτωση πραγματικής εικόνας είναι ένα refinement image custom-table 500 επί 473 του οποίου τα segments αναδιοργανώθηκαν σε random-access διάταξη με κάθε πρωτότυπο header και συμπιεσμένο byte διατηρημένο· το SHA-256 του ταιριάζει ακριβώς το reviewed sequential baseline. Εκείνο το αρχείο είναι παράγωγο που παρήχθη από μετασχηματισμό οργάνωσης, όχι φυσικό random-access έγγραφο βρεμένο στην άγρια φύση, και κανένα τέτοιο φυσικό δείγμα δεν ήταν διαθέσιμο. Οι suites πέρασαν σε 1.598 tests για Delphi Win32, 42 για το image suite Delphi Win64, 48 για FPC Win32 και 46 για FPC Win64, μαζί με τις τρεις υπάρχουσες sequential pixel περιπτώσεις

Φόρτωση ενός random-access .jb2 αρχείου και τα όριά της

Ο κώδικας εφαρμογής δεν αλλάζει: το TPLJBIG2Decoder.LoadFromByteArray ανιχνεύει μόνο του το file header και την οργάνωση, επιστρέφει False σε κάθε απορριφθείσα είσοδο με την αιτία στο LastError, και εκθέτει την αποκωδικοποιημένη σελίδα μέσω Width, Height και GetScanline, που επιστρέφει ένα byte ανά pixel

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
    // Sequential και random-access standalone αρχεία παίρνουν την ίδια κλήση
    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 εξακολουθεί να επιστρέφει το bitmap της πρώτης σελίδας, και δεν υπάρχει κλήση να διαλέξετε τη σελίδα 7 από αρχείο 40 σελίδων ή να αποκωδικοποιήσετε σελίδες τεμπέλικα. Segments με άγνωστο μήκος δεδομένων απορρίπτονται σε random-access αρχεία, και τα υπάρχοντα όρια στα μήκη προθέματος custom Huffman και στα πλήθη εγγραφών πινάκων μένουν αμετάβλητα. Εκείνα τα όρια είναι αρκετά στενά ώστε μια εφαρμογή Delphi να δρομολογεί τις απορριφθείσες περιπτώσεις αλλού μέσω LastError, και το υπόλοιπο του image pipeline, από εξαγωγή PDF εικόνων ως κωδικοποίηση JBIG2, καλύπτεται στη σελίδα προϊόντος PDFlibPas Delphi PDF library