Когато cross-reference таблицата на PDF е неизползваема, поправката е тя да бъде напълно игнорирана и възстановена от тялото на файла. PDFlibPas Delphi PDF Library прави това с еднопроходен token сканер, който записва всеки истински header на indirect обект, който види, после възстановява trailer речника и подава реконструираната таблица на обикновения loader
Какво се чупи първо, когато PDF е повреден
Cross-reference таблицата е най-крехката част от PDF, защото е единствената част, съхраняваща абсолютни байтови offset-и. ISO 32000-1 §7.5.4 дефинира тези записи като десетцифрени offset-и от началото на файла, а §7.5.5 поставя ключовата дума startxref близо до края, сочеща към самата таблица. Всяко от тези числа се анулира от всяка редакция, изместваща байтове. FTP сесия, изпълнена в текстов режим и превела CRLF, прекъснато сваляне, лош сектор на споделен диск, batch инструмент, добавил без правилно записан incremental update: всички те оставят обектните данни напълно четими, а индекса сочещ в боклук
Затова "файлът е повреден и се възстановява" е толкова често срещан диалог. Байтовете почти винаги все още са там. Изгубена е картата. Реконструкцията затова не е криминалистично възстановяване на изгубени данни, а преизграждане на индекс, който може да бъде изведен от тялото, и успява много по-често, отколкото потребителите очакват, защото скъпото съдържание — дърветата на страниците, шрифтовете и изображенията — е недокоснато
Защо сканирането за N 0 obj намира фалшиви съвпадения?
Наивна реконструкция търси в суровите байтове шаблона "цяло число, цяло число, obj" и записва всяко попадение. Тя намира твърде много. PDF е контейнерен формат и три региона на файл са непрозрачни за граматиката на обектите: коментари (§7.2), низове (§7.3.4) и данни от stream (§7.3.8). Всеки от тях може да съдържа байтове, четящи се точно като header на обект, а нито един не е такъв. Надпис в литерален низ, забравен debug коментар или два мегабайта Flate или DCT изход с готовност ще произведат нещо, изглеждащо като 99 0 obj
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
Всеки фалшив запис струва двойно. Той замърсява преизградената таблица с номер на обект, който не съществува, и може да засенчи истински обект със същия номер, появяващ се по-късно във файла. Затова PDFlibPas изобщо не прави pattern-matching. Той токенизира, което означава, че винаги знае дали байтовете под курсора са код или payload, а payload се пропуска без изобщо да бъде интерпретиран
Еднопроходна state machine върху 64 KiB блокове
PDFlibPas сканира целия файл точно веднъж, на блокове по 64 KiB, чрез state machine, изградена върху правилата за токени от ISO 32000-1 §7.2 и синтаксиса за indirect обекти от §7.3.10. Token завършва при whitespace или при един от разделителните символи, а header на обект се записва само когато е видяна пълна последователност от положителен номер на обект, неотрицателен номер на поколение и гола ключова дума obj. Записаният offset е началото на token-а с номера на обекта, което е това, към което трябва да сочи cross-reference запис, не позицията на ключовата дума obj
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
Важният детайл е, че състоянието на token-а и състоянието на низа преживяват границата на блок. Header, който се разпростира през линията от 65536 байта, все пак се разпознава, защото частичният token, чакащата двойка цели числа и флаговете за вътре-в-низ всички се пренасят в следващия блок. Буферите са фиксирани: 64 KiB за сканирането, 32 байта за най-дългия token, който изобщо може да има значение, а единствените масиви, растящи с файла, са списъците с номера на обекти, номера на поколения и 64-битови offset-и, които са пропорционални на реалния брой обекти, а не на размера на файла. На практика сканирането издава последователни четения и най-много две изрични позиционирания над целия документ, което го прави приложимо върху многостотин-мегабайтовите входове, обсъдени в статията за директен достъп при merge и split
Защо не може да се разчита stream да свършва при endstream?
Защото данните на stream са произволни байтове, а произволни байтове могат случайно да изпишат endstream. Stream, започващ след ключовата дума stream, трябва да бъде пропуснат като непрозрачни данни, докато наистина не свърши, но първото срещане на затварящата ключова дума е само кандидат. PDFlibPas разрешава това, изисквайки потвърждение: token endstream се приема за истинския край на stream-а само когато следващият не-whitespace token е самостоятелен endobj — последователността, изисквана от §7.3.8 около stream обект. Случайно попадение вътре в компресирани данни почти никога няма това продължение, така че сканерът остава вътре в stream-а и продължава. Две по-малки правила имат също толкова значение. Ключовата дума stream влиза в състояние на stream само когато е гола ключова дума, така че име-обект като /stream в речник никога не я задейства. А token obj или trailer се зачита само когато token-ът не е препълнил тавана от 32 байта и не е започвал с наклонена черта. Без тези две предпазни мерки речник на ресурси с грешни имена на ключове би бил достатъчен, за да отклони сканирането — точно класът adversarial вход, разгледан в бележките за безопасно парсване на недоверени PDF-и
Намиране на истинския край на trailer речника
Възстановяването на обектите е само половината работа, защото loader-ът все още се нуждае от trailer, за да намери /Root. PDFlibPas помни последните 64 позиции на ключовата дума trailer, намерени по време на сканирането, и ги валидира назад, най-новата първа, така че най-новият използваем trailer печели, а случайна ключова дума, незаследвана от речник, просто не преминава валидацията и пада до предишния кандидат. Всеки кандидат се чете с таван от 1 MiB, а краят на речника се локализира чрез следене на вложената дълбочина на << и >>, заедно с ескейпи на литерални низове, шестнадесетични низове и коментари
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
Следенето на дълбочината не е академично упражнение. Осечен trailer, губещ /Encrypt, превръща възстановим криптиран документ в неотворим, а губенето на /Info или на потребителски под-речник тихо изхвърля метаданни, от които downstream система може да зависи. Ако файлът е криптиран, възстановеният trailer е това, което позволява обикновеният път за credential-и да се изпълни, а семантиката за повторен опит е същата, описана в статията за зареждане на криптирани документи
Какво реконструкцията не може да върне
Реконструкцията е най-добро усилие, а честността за нейните граници е част от доставянето ѝ. Три случая се провалят напълно. Обекти, пакетирани вътре в object streams (§7.5.7), не са индивидуално видими за байтово сканиране, така че ако контейнер оцелее, но неговият cross-reference stream (§7.5.8) не оцелее, обектите, които държи, не са индексирани от реконструкцията. Файл, чието тяло действително е било повредено, а не просто разиндексирано, ще произведе headers, чието съдържание вече не се парсва. А файл без възстановима ключова дума trailer и без четим каталог няма за какво да закрепи дърво на документа, независимо колко header-а на обекти са намерени
Дублирани номера на обекти са интересният среден случай. Инкрементално обновен файл легитимно съдържа няколко поколения на един и същ номер на обект, а оцелялата cross-reference верига е единственият запис кое е било текущото. Реконструкция няма тази верига, така че записва всеки видян header по реда във файла и разрешава по номер на обект след това. Обикновено по-късната ревизия печели, което обикновено е правилно, но документ, който е бил обновен и после частично отменен, може да се върне фино различен от това, което оригиналният xref е описвал. Линеаризираните файлове носят същото предупреждение от другата посока: оформлението на първата страница и hint таблиците стават безсмислени, щом индексът бъде регенериран, така че поправен файл трябва да се третира като обикновен, нелинеаризиран документ
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
Резервният вариант е автоматичен: PDFlibPas пуска суровото сканиране, когато cross-reference веригата не може да бъде прочетена, а също и когато всеки използван запис твърди offset нула, което е сигнатурата на таблица, записана, но никога попълнена. GetDocumentRepaired връща 1, когато този път се е изпълнил, и си струва да се логва, а не игнорира, защото документ, зареден чрез реконструкция, трябва да бъде презаписан в чист файл, а не оставен в pipeline, сякаш нищо не се е случило. Записването му пише свежа, съгласувана cross-reference таблица, което е най-евтината възможна поправка за всеки downstream консуматор
Пътят на реконструкцията, флагът GetDocumentRepaired и streaming loader-ът, показани тук, са част от PDFlibPas Delphi PDF Library, заедно с API-тата за парсване, рендиране и подписване, разгледани другаде в този блог