Повечето PDF страници се растеризират за няколко милисекунди и никога не се замисляте за това. След това потребител отваря инженерен чертеж във формат A1, страница, пълна с десетки хиляди векторни щрихи, или плакат, претъпкан с групи за прозрачност (transparency groups) и меки маски (soft masks), и единственото извикване, което ги рисува, отнема две или три секунди. Ако това извикване се изпълнява в UI нишката, прозорецът спира да се прерисува, заглавната лента посивява и операционната система предлага да убие приложението. Работата е легитимна. Страницата наистина се нуждае от толкова време. Дефектът е, че рендирането е едно неделимо блокиращо извикване без начин да си поеме въздух и без начин да спре
Тази статия е за точно един от тези два проблема: отмяна на дълго рендиране на една страница без замразяване на потребителския интерфейс (UI). Потребителят е щракнал на следващата страница, увеличил е мащаба (zoomed) или е затворил документа, и рендирането в движение вече е загубена работа, която трябва да приключи при първа възможност, вместо да стигне докрай. Изглаждането на превъртането (scroll) и мащабирането (zoom) чрез кеширане на вече растеризираното е отделен въпрос със свой собствен дизайн, разгледан в придружаващата статия, свързана в края. Тук единственият въпрос е как да накараме едно прогресивно рендиране да отговори на заявка за отмяна бързо и чисто
API-то за прогресивно рендиране, което PDFium вече доставя
PDFium е предвидил половината от проблема със замразяването. Наред с еднократното FPDF_RenderPageBitmap, той излага прогресивен вариант, който разделя страница на парчета (chunks) работа. Извиквате FPDF_RenderPageBitmap_Start веднъж, за да настроите рендирането спрямо целево растерно изображение (bitmap), след което извиквате FPDF_RenderPage_Continue многократно. Всяко Continue растеризира ограничен отрязък (slice) и връща статус. FPDF_RENDER_TOBECONTINUED означава, че има още какво да се прави, FPDF_RENDER_DONE означава, че страницата е завършена, а FPDF_RENDER_FAILED означава, че е спряло при грешка. Когато цикълът приключи, извиквате FPDF_RenderPage_Close, за да освободите прогресивното състояние за страницата. Тъй като контролът се връща към вашия код между отрязъците, можете да изпомпвате съобщения (pump messages), да актуализирате индикатор за напредък или да проверите дали работата все още е желана
Механизмът, който PDFium предоставя за вземане на решение кога да отстъпи (yield), е callback структура, наречена IFSDK_PAUSE. Вие я предавате на Start и на всяко Continue. След всяко парче PDFium извиква нейния указател към функция NeedToPauseNow и ако той върне ненулева стойност, текущото Continue спира рано и връща контрола обратно с FPDF_RENDER_TOBECONTINUED. Структурата също така носи поле version, което трябва да бъде зададено на 1, и указател user в свободна форма, до който PDFium никога не се докосва и предава непокътнат. Този непокътнат указател е цялата панта (hinge) на дизайна, който следва
Пренасочване на паузата като отмяна
Първоначалното намерение на NeedToPauseNow е разделяне на времето (time-slicing). Върнете ненулева стойност, когато бюджетът ви за кадър е изразходван, върнете нула, за да продължите рендирането, и PDFium прави пауза, за да можете да направите нещо друго, преди да възобновите същото рендиране. PDFium Component използва повторно същия сигнал за различен глагол. Вместо да отговаря "трябва ли да направя пауза и да ви позволя да възобновите", callback-ът отговаря "тази работа отменена ли е". Двете се картографират чисто едно върху друго поради това, което цикълът прави, когато види флага. Истинска пауза очаква по-късно Continue; отмяна не го прави. След като извикващият цикъл забележи, че токенът е отменен, той затваря контекста на рендиране и никога не извиква Continue отново, така че същата ненулева върната стойност, която PDFium чете като "спри това парче", на практика се превръща в "спри завинаги"
Отмяната се изразява чрез интерфейс, IPdfCancellationToken, чието свойство IsCancelled се превключва от false на true, когато друга част от програмата поиска рендирането да спре. Мостът между този Pascal интерфейс и C callback-а на PDFium е един единствен указател. Референцията към интерфейса на токена се записва в IFSDK_PAUSE.user и статичен cdecl callback я прочита обратно и я запитва. Това е класическият проблем да позволим на C библиотека да се обажда обратно (call back) в Pascal: callback-ът трябва да бъде обикновена функция със C конвенция за извикване (calling convention), а не метод, защото PDFium съхранява и извиква гол указател към функция, който не знае нищо за Pascal обекти или Self
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium чете това; .user държи токена
Token: IPdfCancellationToken; // силната референция държи токена жив
end;
function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
Token: IPdfCancellationToken;
begin
Result := 0;
if (pThis = nil) or (pThis^.user = nil) then
Exit;
Token := IPdfCancellationToken(pThis^.user);
if Token.IsCancelled then
Result := 1; // не-нула: PDFium спира това парче
end;
Callback-ът възстановява токена чрез cast на pThis^.user обратно към типа на интерфейса и прочита IsCancelled. Нищо в него не алокира (allocates), не заключва или блокира, което има значение, защото PDFium го извиква в нишката за рендиране след всяко парче и всяка работа, свършена тук, се добавя към цената на самото рендиране. Предпазването от nil структура или nil поле user означава, че същата функция е безопасна за инсталиране дори при рендиране, на което никога не е бил даден истински токен
Поддържане на токена жив по време на цикъла
Прехвърлянето (casting) на интерфейсен указател през суров (raw) Pointer и обратно е мястото, където се раждат бъгове с времето на живот (lifetime bugs). Един IInterface в Delphi е с преброяване на референциите (reference counted) и броят се движи само когато компилаторът може да види присвояване на променлива от интерфейсен тип. Съхраняването на токена единствено като гол указател вътре в IFSDK_PAUSE.user би го скрило напълно от брояча на референции. Ако единствената друга референция към този токен излезе от обхват, докато цикълът Continue все още работи, обектът би бил освободен под callback-а, а следващото парче би дереференцирало висящ указател (dangling pointer)
Ето защо дескрипторът е запис (record), съдържащ две неща, а не едно. Полето Pause е структурата, която PDFium чете. Полето Token е истинска референция от интерфейсен тип, която компилаторът брои, и съществува само за да забоде (pin) токена в паметта, докато записът живее. Записът е локална променлива в стека на рутината за рендиране, така че остава валиден през цялото времетраене на цикъла и се разрушава (torn down) само когато рутината излезе. Голият указател в user и преброената референция в Token назовават един и същ обект; едното е това, което PDFium може да прочете, а другото е това, което предпазва обекта от събиране (collected)
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... изберете EffectiveToken ...
// Първо силна референция, след това публикуване на същия обект в PDFium чрез .user.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
Затваряне на контекста на рендиране, независимо как завършва цикълът
Всяко извикване на FPDF_RenderPageBitmap_Start алокира прогресивно състояние, което PDFium свързва със страницата, и това състояние се освобождава само от FPDF_RenderPage_Close. Има три пътя за изход от задвижващия цикъл. Страницата завършва и последният статус е FPDF_RENDER_DONE. Токенът се задейства (trips) и цикълът излиза рано, съобщавайки за отмяна. Нещо се проваля и статусът е FPDF_RENDER_FAILED. И при трите трябва да се извика Close, а пътят на отмяната е най-лесен за объркване, защото естествената форма на "виж отмяна, излез" (see cancel, break out) има тенденция да пропуска почистването (cleanup) по пътя си към изхода. Оставянето на Close недостигнато води до изтичане на състоянието за страницата (leaks the per-page state) и програма за преглед (viewer), която позволява на потребителя да отменя рендиране след рендиране, би натрупала този теч на всяка прекратена страница
Солидната форма поставя цикъла и класификацията на резултатите в try, а FPDF_RenderPage_Close в съответстващото finally. Целевото растерно изображение се унищожава в същия блок. Отмяната може да напусне цикъла чрез ранно излизане с Exit и finally все пак се изпълнява, така че има точно едно място, което освобождава прогресивното състояние и то не може да бъде заобиколено
Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
while Status = FPDF_RENDER_TOBECONTINUED do
begin
if EffectiveToken.IsCancelled then
begin
Result := prsCancelled;
Exit;
end;
Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
end;
if EffectiveToken.IsCancelled then
Result := prsCancelled
else if Status = FPDF_RENDER_DONE then
Result := prsDone
else
Result := prsFailed;
finally
// Освобождава прогресивното състояние, алокирано от Start; задължително при всеки път.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
Цикълът проверява токена преди всяко Continue, както и разчита на callback-а вътре в него. Callback-ът съкращава текущото парче; проверката в цикъла спира започването на следващото. Заедно те ограничават времето, необходимо за влизане в сила на отмяна, до приблизително продължителността на едно парче
Три изхода и какво държи растерното изображение след отмяна
Публичната входна точка е TPdf.RenderPageProgressive и връща TPdfProgressiveStatus, който е един от prsDone, prsCancelled или prsFailed. Стойностите отразяват константите FPDF_RENDER_* на PDFium в Pascal идиом, но сгъват случая на отмяна като първокласен резултат, а не като грешка
Точката, която хваща хората, е какво съдържа целевото растерно изображение след prsCancelled. То не е празно. PDFium рендира прогресивно в същото растерно изображение парче след парче, така че когато отмяна спре цикъла, растерното изображение държи това, което е било нарисувано до този момент, което е частично изображение: някои ленти (bands) са готови, останалите все още показват цвета на запълване (fill colour). Дали този частичен резултат е полезен зависи от извикващия. Програма за преглед, която е на път да изхвърли растерното изображение, защото потребителят е навигирал другаде, може просто да го игнорира. Програма за преглед, която иска да покаже визуализация с ниска цена (low-cost preview), може да го запази. Това, което не трябва да правите, е да предполагате, че prsCancelled предполага празно или недефинирано растерно изображение; то предполага правдива моментна снимка (truthful snapshot) на недовършено рендиране
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// Токенът стартира не-отменен; превключете Token.IsCancelled от друго място
// (действие в UI, събитие за навигация), за да прекратите рендирането в движение.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // напълно рендирано
prsCancelled: ; // частично растерно изображение, обикновено се изхвърля
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
Nil токенът и път за callback без разклонения
Отмяната се включва по желание (opt-in). Извикващ, който просто иска прогресивно рендиране заради ползата от изпомпване на съобщения (message-pumping), без намерение да прекратява, трябва да може да подаде nil за токена. Наивният начин да се поддържа това е да се разпръснат проверки "ако е предоставен токен" през callback-а и цикъла, което означава разклонение на всяко парче и callback, който трябва да обработва както истински токен, така и неговото отсъствие
Имплементацията избягва това, като замества сингълтън (singleton), когато извикващият не подаде нищо. Един nil токен се заменя с PdfNoCancellationToken, интерфейс, чието свойство IsCancelled винаги е false. От този момент нататък callback-ът и цикълът имат токен за заявка във всеки случай, така че нито едното не се нуждае от проверка за nil и нито едното не се нуждае от специален път. Токенът, който никога не отменя, просто винаги отговаря false, callback-ът винаги връща нула, а рендирането се изпълнява докрай точно както би го направило неотменяемо. Опционалното поведение се моделира като токен, който никога не се задейства (never fires), вместо като отсъствие на токен, което поддържа горещия път (hot path) еднообразен
// nil -> сингълтън "никога не отменяй", така че пътят на callback-а е идентичен
// независимо дали извикващият е избрал отмяна.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
Формата, която се появява, е малка и си струва да се повтори, защото тя е частта за многократна употреба. C библиотека, която поддържа callback, ви дава точно един канал за предаване на състояние в този callback, непрозрачния (opaque) потребителски указател. Поставете преброена Pascal интерфейсна референция зад този указател, запазете втора истинска референция жива до структурата, така че обектът да не може да бъде събран по време на извикването, и прочетете интерфейса обратно в статична функция cdecl. Увийте целия задвижващ цикъл в try и освободете нативния контекст във finally. Същият шаблон се пренася към всяка прогресивна или базирана на callback PDFium операция, при която Pascal кодът трябва да остане в контрол на времето на живот, докато C държи указател
Отмяната е само едната половина на отзивчива програма за преглед. Другата половина е да не рендирате отново страници, които вече сте нарисували, и да поддържате гладко мащабиране и превъртане чрез сервиране на кеширани растерни изображения, което е разгледано в нашата статия за кеширане на рендирането и производителност при мащабиране. За това как отменяемото рендиране се вписва в пълна програма за преглед наред с навигацията, избора и търсенето, вижте изграждане на богата на функции програма за преглед на PDF с PDFium Component. Прогресивното рендиране, описано тук, се доставя като част от PDFium Component за Delphi и Lazarus заедно с API за зареждане, рендиране и формуляри, разгледани на други места в този блог