Техническа статия

Рендериране на PDF страници в 1-битов монохром в Delphi

Fax gateway не иска вашия 24-битов render на страница. Нито архивният pipeline, който съхранява милион сканирани фактури, нито OCR front-end-ът, който прагово превръща всичко в черно-бяло, преди изобщо да потърси character. И трите искат едно и също: чист 1-битов bitmap, по един bit на pixel, където всяка точка е или мастило, или хартия. Дайте им пълноцветен BMP и те така или иначе ще изхвърлят 23 bits на pixel, обикновено с по-лош dithering, отколкото бихте направили сами. Интересният въпрос е къде трябва да стане това down-conversion и отговорът в PDFlibPas всъщност казва нещо полезно за това как да разширите renderer, който предпочитате да не пренаписвате

PDFlibPas е нативна Object Pascal PDF библиотека за Delphi и C++Builder. Нейният rendering core rasterizes page до bitmap и може да излъчва BMP, PNG, JPEG, WMF и още няколко формата. Това, което не правеше до скоро, беше да връща истински monochrome bitmap или да рендерира само част от page. И двете пристигнаха във v3.83.0 и двете бяха изградени като тънки convenience layers върху съществуващия renderer, а не като промяна на самия rasterizer. Това ограничение е цялата история

Защо да down-convert-вате след рендериране, а не вътре в renderer-а

Очевидният начин да произведете 1-битов image е да кажете на rasterizer-а да рисува в 1-bit. Това е и начинът да счупите всичко останало. Вътрешният bitmap на renderer-а се създава с hardcoded PixelFormat := pf24bit в PDFlibRenderer constructor-а и тази 24-битова surface се споделя от всеки render path: PNG export, device-context preview, JPEG output, всичко. Ако я обърнете към pf1bit в source-а, не сте добавили monochrome feature; влошили сте color fidelity за всеки caller в библиотеката и сте си купили дузина downstream regression-и за debug

Затова RenderPageToMonochromeFile взема обратния път. Той рендерира page-а нормално, в временен 24-битов BMP, и чак след това го свива до 1-bit като post-processing step. Renderer-ът остава недокоснат. Monochrome поведението живее изцяло в convenience метода, което означава, че не може да засегне никого, който не го извика. Това е типът trade-off, който си струва да се назове изрично: post-process-ът плаща още една bitmap allocation и временен файл и в замяна оставя един load-bearing core напълно извън обхвата. За feature, който съществува да обслужва fax и архивни edge case-ове, това е правилната страна на ledger-а

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI is the classic Group 4 fax resolution; page index is 1-based
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

Как всъщност става 1-битовото свиване

Down-conversion-ът се опира на GDI, а не на ръчно написан threshold loop, и изборът има значение за output quality-та. Вътре в метода 24-битовият временен bitmap се зарежда в TBitmap, създава се втори TBitmap с PixelFormat := pf1bit в същите размери и пикселите се прехвърлят с едно blit:

// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

Трикът е SetStretchBltMode с HALFTONE. Въпреки че source и destination са с еднакъв размер, така че не става scaling, stretch mode-ът все пак управлява как GDI maps-ва colors към 1-битовата palette. HALFTONE кара да приложи halftone dithering, превръщайки сивите области и anti-aliased text edges в patterns от черни и бели точки, вместо твърдо изрязване до най-близкия от двата цвята. Свалете mode извикването или използвайте default BLACKONWHITE, и grayscale content-ът се posterize-ва в blocky thresholded shapes. За scanned-document и OCR-preprocessing output-а dithering-ът почти винаги е това, което искате

Един детайл е неподлежащ на преговори и лесен за грешка: временният render трябва да е BMP. RenderPageToMonochromeFile вика общия renderer с options code 0, който е BMP. Options аргументът на RenderPageToFile е малък integer enum и стойностите не са взаимозаменяеми за тази цел: 0 е BMP, 1 JPEG, 2 WMF, 3 EMF, 5 PNG и така нататък. Down-converter-ът после прави TBitmap.LoadFromStream върху временния файл. Подайте му WMF, като подадете 2, и това load-ване хвърля "Bitmap image is not valid", защото Windows Metafile е vector record stream, не DIB. Monochrome down-conversion е raster операция от край до край, така че междинният резултат трябва да е raster формат

Рендериране само на подрегион от page

Вторият метод, RenderPageRegionToFile, рендерира само rectangle от page-а, вместо цялото нещо. Use case-овете са познати, щом веднъж сте изградили какъвто и да е document viewer: изрязване на signature block от contract, генериране на tile за zoomed map на голям drawing или извличане на един stamped регион за thumbnail без да плащате за rasterize-ване на цялата page на висок DPI. Signature-ът е праволинеен:

// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

Clip string-ът е четири comma-separated double-ове в PDF points, парсвани ръчно вътре в метода, за да се заобиколят locale и DelimitedText quirks. От width и height методът изчислява размера на output bitmap-а като Round(Width * DPI / 72) по Round(Height * DPI / 72), allocates in-memory pf24bit bitmap с точно този размер и рендерира в device context-а му през RenderPageToDCClip. Резултатният файл съдържа само изрязания rectangle, оразмерен към region-а, а не към цялата page

Clip параметърът, който не правеше нищо

Ето къде работата беше по-рязка, отколкото изглежда. RenderPageToDCClip е носел Clip parameter от дълго време и това беше лъжа. Call-ът приемаше аргумента, подаваше го към TPDFPageTree.RenderPageToDC, а тази implementation го игнорираше напълно и никога не го подаваше на renderer-а. Можеше да подадете който rectangle си искате и да получите обратно цялата page. Всеки, който беше вързал RenderPageToDCClip с очакване за crop, получаваше full-page render и, в зависимост от layout-а, може изобщо да не е забелязал

v3.83.0 включи wire-а. RenderPageToDC сега парсва същия "Left,Top,Width,Height" point rectangle и го прилага като реален GDI clip region върху target device context-а, преди renderer-ът да рисува. Конверсията от points към device pixels е обичайният DPI / 72 scale factor, приложен към всички четири edges. Последователността около render-а е стандартният save/clip/restore dance:

// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);

Парата SaveDC/ RestoreDC(-1) е това, което прави този call безопасен за многократна употреба: clip region-ът се push-ва върху DC state stack-а, page-ът се рисува и оригиналният clip се pop-ва обратно, независимо как завършва render-ът. RestoreDC(TargetDC, -1) възстановява най-скоро saved state-а, което е стандартният idiom за балансиран save/restore. Пропуснете restore-а и caller, който reuse-ва същия DC за следващ full-page render, ще го намери загадъчно clipped до последния region. Поправката на мъртвия parameter оправи и RenderPageRegionToFile безплатно, защото този нов метод минава точно през този path

Една поведенческа точка, която трябва да усвоите: clip crops, не scales. Page-ът все още се rasterize-ва на DPI-то, което сте поискали, в нормалната си позиция, а clip region-ът просто изхвърля всичко извън rectangle-а. Не zoom-вате region-а, за да запълни output-а; режете прозорец от full-resolution render-а. Ако искате region magnified, вдигнете DPI. Координатите на rectangle-а се интерпретират в device space след points-to-pixels scaling-а, измерени от горния ляв ъгъл на rendered surface-а, така че планирайте Left и Top от върха на page-а надолу. За по-дълбок тур как PDFlibPas управлява device context за on-screen output, companion piece-ът за print preview and device-context output обхожда същия DC plumbing от display side-а

Честната граница: 1-битов BMP, а не G4 TIFF

It would be easy to oversell this as "fax-ready output," so here is the limit stated plainly. RenderPageToMonochromeFile produces a pf1bit BMP. It does not produce a CCITT Group 4 TIFF, which is the format a real fax workflow or a TIFF archive usually expects. The reason is concrete rather than an oversight: PDFlibPas's CCITT unit currently decodes G4 streams but has no G4 encoder. Without an encoder there is nowhere to write compressed monochrome runs, so the monochrome path stops at an uncompressed 1-bit DIB

In practice that is still useful. A 1-bit BMP is the correct pixel format, dithered and ready, and most fax, archival, or OCR toolchains will happily ingest it or convert it to G4 themselves with one downstream step. But if your requirement is literally a Group 4 TIFF straight out of the library, this is not that yet, and you should plan a compression stage of your own. Knowing where a feature stops is worth as much as knowing what it does

Both methods are deliberately small, and that is the design lesson worth carrying off this page: a convenience API that sits on top of a renderer can add real capability, monochrome output, region cropping, without reaching into the rasterizer and destabilizing every other caller. When you do need to pick between rendering engines for the underlying rasterization, the overview of multi-engine PDF rendering in Delphi covers the trade-offs in depth. To see the full rendering surface and the rest of the API, the обхваща trade-off-ите в дълбочина. За да видите цялата rendering surface и останалата част от API-то, PDFlibPas Delphi PDF Library