Технічна стаття

Відтворення PDF-сторінок у 1-бітний монохром у Delphi

Факс-шлюзу не потрібен ваш 24-бітний рендер сторінки. Так само як і архівному конвеєру, що зберігає мільйон рахунків, схожих на скани, або OCR-фронтенду, який перед тим, як побачити символ, порогує все в чорно-біле. Усім трьом потрібне одне й те саме: чистий 1-бітний bitmap, по одному біту на піксель, де кожна крапка або чорнило, або папір. Дайте їм повноколірний BMP, і вони все одно відкинуть 23 біти на піксель, зазвичай з гіршим етапом дизерингу, ніж ви могли б зробити самі. Цікаве питання тут у тому, де саме має відбуватися це зниження розрядності, і відповідь у PDFlibPas виявляється корисною для розуміння того, як розширювати рендерер, який ви б воліли не переписувати

PDFlibPas - це нативна бібліотека PDF на Object Pascal для Delphi та C++Builder. Її ядро відтворення растрирує сторінку в bitmap і може виводити BMP, PNG, JPEG, WMF та ще кілька інших форматів. До недавнього часу воно не вміло повертати справжній монохромний bitmap або відтворювати лише частину сторінки. Обидві можливості з’явилися у v3.83.0, і обидві були побудовані як тонкі допоміжні шари поверх наявного рендерера, а не як зміни самого растеризатора. Саме це обмеження і є всією історією

Чому знижувати розрядність після відтворення, а не всередині рендерера

Очевидний спосіб отримати 1-бітне зображення - наказати растеризатору малювати в 1-бітному режимі. Саме так ламається все інше. Внутрішній bitmap рендерера створюється з жорстко заданим PixelFormat := pf24bit у PDFlibRenderer конструкторі, і ця 24-бітна поверхня спільна для всіх шляхів відтворення: експорт PNG, попередній перегляд у контексті пристрою, вивід JPEG, усе. Перемкніть її на pf1bitpf1bit у джерелі, і ви не додасте монохромну можливість, а погіршите кольоропередачу для кожного виклику в бібліотеці та отримаєте зобов’язання відлагоджувати з десяток регресій вниз по ланцюгу

Тож RenderPageToMonochromeFile обирає протилежний шлях. Вона відтворює сторінку у звичайному режимі, у тимчасовий 24-бітний BMP, і лише після цього згортає його до 1 біта як крок постобробки. Рендерер не зачіпається. Монохромна поведінка живе цілком у допоміжному методі, а це означає, що вона не може вплинути ні на кого, хто його не викликає. Саме такий компроміс варто називати прямо: постобробка коштує однієї додаткової алокації bitmap і тимчасового файла, а натомість повністю виводить із зони ризику критично важливе ядро. Для можливості, яка існує, щоб обслуговувати факсові та архівні крайові випадки, це правильна сторона балансу

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 біта

Зниження розрядності спирається на GDI, а не на саморобний цикл порогування, і це впливає на якість виводу. Усередині методу тимчасовий 24-бітний bitmap завантажується в TBitmap, другий TBitmap створюється з PixelFormat := pf1bit тих самих розмірів, і пікселі переходять одним блітом:

// 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. Хоча джерело і призначення однакового розміру, тож масштабування не відбувається, режим розтягування все одно визначає, як GDI мапує кольори в 1-бітну палітру. HALFTONE змушує його застосовувати напівтоновий дизеринг, перетворюючи сірі області та згладжені краї тексту на візерунки з чорних і білих крапок, а не на жорстке обрізання до найближчого з двох кольорів. Приберіть виклик режиму або використайте типовий BLACKONWHITE, і сірі зображення стають грубими пороговими блоками. Для виводу відсканованих документів і попередньої обробки OCR такий дизеринг майже завжди саме те, що потрібно

Одна деталь незмінна і тут легко схибити: тимчасове відтворення має бути BMP. RenderPageToMonochromeFile викликає загальний рендерер з кодом параметра 0, який відповідає BMP. Аргумент options у RenderPageToFile - це невеликий цілочисельний enum, і значення тут не взаємозамінні: 0 - BMP, 1 JPEG, 2 WMF, 3 EMF, 5 PNG тощо. Потім конвертер виконує TBitmap.LoadFromStream над тимчасовим файлом. Передайте йому WMF, вказавши 2, і це завантаження викличе "Bitmap image is not valid", тому що Windows Metafile - це потік векторних записів, а не DIB. Монохромне зниження розрядності є растровою операцією від початку до кінця, тож проміжний формат теж має бути растровим

Відтворення лише підобласті сторінки

Другий метод, RenderPageRegionToFile, відтворює лише прямокутник сторінки, а не всю її цілком. Варіанти використання знайомі кожному, хто збирав будь-який переглядач документів: обрізати блок підпису з контракту, згенерувати тайл для збільшеної карти великого креслення або витягти одну штамповану ділянку для мініатюри, не витрачаючи ресурси на растеризацію всієї сторінки у високому DPI. Підпис виглядає просто:

// 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 складається з чотирьох значень типу double, розділених комами, у PDF points, і розбирається вручну всередині методу, щоб обійти особливості локалі та DelimitedText форматування. З ширини та висоти метод обчислює розмір вихідного bitmap як Round(Width * DPI / 72) на Round(Height * DPI / 72), створює в пам'яті pf24bit bitmap саме такого розміру і виводить у його device context через RenderPageToDCClip. У вихідному файлі лишається лише обрізаний прямокутник, розмір якого відповідає області, а не всій сторінці

Параметр clip, який нічого не робив

Ось тут робота виявилася тоншою, ніж здається. RenderPageToDCClip уже давно мав Clip параметр, і це було лише видимістю. Виклик приймав аргумент, передавав його в TPDFPageTree.RenderPageToDC, а та реалізація повністю його ігнорувала, ніколи не передаючи його рендереру. Можна було передати будь-який прямокутник і все одно отримати всю сторінку. Той, хто підключив RenderPageToDCClip у розрахунку на обрізання, натомість отримував рендер усієї сторінки і, залежно від верстки, міг цього навіть не помітити

У v3.83.0 цей дріт нарешті під'єднали. RenderPageToDC тепер розбирає той самий "Left,Top,Width,Height" прямокутник у point і застосовує його як справжню GDI clip region у цільовому контексті пристрою перед тим, як рендерер почне малювати. Перетворення з points у пікселі пристрою виконується звичним DPI / 72 коефіцієнтом масштабування, який застосовується до всіх чотирьох сторін. Послідовність навколо рендеру тут стандартна: save/clip/restore:

// 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) саме що й робить повторні виклики безпечними: clip region записується в стек стану DC, сторінка малюється, а початковий clip відновлюється незалежно від того, як завершується рендер. RestoreDC(TargetDC, -1) відновлює найостанніший збережений стан, що є стандартним прийомом для парного save/restore. Пропустіть restore, і виклик, який повторно використовує той самий DC для наступного рендеру всієї сторінки, несподівано побачить обрізання до останньої області. Виправлення мертвого параметра також виправило RenderPageRegionToFile без додаткових зусиль, бо новий метод проходить рівно цим шляхом

Один поведінковий нюанс, який варто запам'ятати: clip обрізає, він не масштабує. Сторінка й далі растризується з тим DPI, який ви задали, у звичайному положенні, а clip region просто відкидає все поза прямокутником. Ви не збільшуєте область, щоб вона заповнила вихід; ви вирізаєте вікно з рендеру повної роздільної здатності. Якщо потрібне збільшення області, підніміть DPI. Координати прямокутника інтерпретуються в device space після масштабування з points у пікселі, відлічуючись від лівого верхнього кута відрендереної поверхні, тож плануйте свої Left і Top від верхнього краю сторінки вниз. Для докладнішої подорожі тим, як PDFlibPas керує контекстом пристрою для виводу на екран, супровідний матеріал про print preview and device-context output проходить той самий шлях роботи з DC з боку відображення

На практиці це все ще корисно. 1-бітний BMP є правильним форматом пікселів, уже з дизерингом і готовий до роботи, а більшість ланцюжків для факсу, архівації чи OCR без проблем приймуть його або самі перетворять у G4 одним наступним кроком. Але якщо вам буквально потрібен Group 4 TIFF просто з бібліотеки, це ще не він, і вам слід запланувати власний етап стиснення. Знати, де закінчується можливість, так само важливо, як і знати, що вона робить

Було б легко подати це як «вихід, готовий для факсу», тож ось межа без прикрас. RenderPageToMonochromeFile створює pf1bit BMP. Це не створює CCITT Group 4 TIFF, якого зазвичай очікує справжній fax workflow або архів TIFF. Причина конкретна, а не випадкова: модуль CCITT у PDFlibPas наразі декодує потоки G4, але не має G4 кодувача. Без кодувача нікуди записувати стиснені монохромні послідовності, тож монохромний шлях зупиняється на нестисненому 1-бітному DIB

На практиці це все ще корисно. 1-бітний BMP є правильним форматом пікселів, уже з дизерингом і готовий до роботи, а більшість ланцюжків для факсу, архівації чи OCR без проблем приймуть його або самі перетворять у G4 одним наступним кроком. Але якщо вам буквально потрібен Group 4 TIFF просто з бібліотеки, це ще не він, і вам слід запланувати власний етап стиснення. Знати, де закінчується можливість, так само важливо, як і знати, що вона робить

Обидва методи свідомо невеликі, і саме в цьому полягає дизайнерський урок, який варто винести з цієї сторінки: зручний API, що стоїть поверх рендерера, може додати справжні можливості, монохромний вивід, обрізання області, не лізучи в растеризатор і не дестабілізуючи всіх інших викликачів. Коли ж потрібно вибирати між рушіями рендерингу для базової растеризації, огляд multi-engine PDF rendering in Delphi докладно розбирає компроміси. Щоб побачити всю поверхню рендеру та решту API, PDFlibPas Delphi PDF Library сторінка продукту дає повне уявлення