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

Вилучення тексту, зображень та шрифтів з PDF у Delphi з PDF Library for Delphi

Вилучення тексту, зображень і шрифтів з наявного PDF звучить як розв'язана задача, доки не пропустиш крізь неї реальний корпус документів. Наведіть пошуковий індексатор на сорок тисяч клієнтських файлів, і поломки розсортуються на кілька впізнаваних купок. Слова зливаються докупи, бо ніхто не сказав екстрактору, наскільки широкий проміжок вважати пробілом. Інші сторінки повертаються нечитабельною абракадаброю, бо підмножинний (subsetted) шрифт не несе жодної карти від кодів гліфів до реальних символів. А "логотип компанії" виявляється дев'ятьма окремими об'єктами зображень, складеними один за одним позаду м'якої маски. Ніщо з цього не є вадою бібліотеки. Це різниця між тим, щоб просто викликати функцію вилучення, і тим, щоб розуміти, що ця функція може, а що не може відновити з байтів на диску

losLab PDF Library, видання Pascal, дає коду на Delphi та C++Builder більш ніж один спосіб читати кожен з цих трьох потоків, і рівні різняться тим, що саме вони гарантують. Хитрість у тому, щоб зіставити рівень із завданням: пошуковий індекс, рецензент редагування (redaction) і прохід preflight для PDF/A хочуть від однієї й тієї самої сторінки різних речей, і виклик не того рівня марнує зусилля або дає результат, якому не можна довіряти

Рівні вилучення тексту й те, що кожен з них обіцяє

GetPageText приймає значення параметрів від 0 до 8, і це число обирає рушій, а не формат. Значення від 0 до 2 запускають легкий прохід, який годиться для швидкого попереднього перегляду. Значення від 3 до 8 проходять через рушій, що враховує макет, — він відновлює рядки та інтервали за тим, де гліфи насправді розташовані на сторінці. У межах цього діапазону відмінності мають значення: 4 і 6 розбивають вивід на слова, 5 і 6 видають ширини для кожного гліфа, а 7 повертає простий текст, з якого свідомо прибрано метадані шрифту, кольору та блоку. Варіант 7 — той самий, яким слід живити пошуковий індекс, бо індексу потрібні слова й нічого більше

Жодне налаштування параметрів не врятує документ, який від самого початку не ніс потрібної інформації. PDF зіставляє коди символів з формами гліфів, і єдине, що зіставляє ці коди назад із читабельним текстом, — це ToUnicode CMap шрифту (ISO 32000-1 §9.10). Коли підмножинний шрифт постачається без нього, будь-який екстрактор опиняється в глухому куті. Ця бібліотека, копіювання з переглядача, конкурентний набір інструментів — усі вони зведені до вгадування за іменами гліфів або повернення порожнечі. Практична відповідь — це виявлення, а не героїзм. Оцініть сторінку як таку, що має низьку впевненість, і надішліть її на OCR, бо мовчки індексувати сміття гірше, ніж визнати, що прочитати його не вдалося

Діаграма рівнів витягання тексту з PDF у Delphi: опції GetPageText від 0 до 8 маршрутизуються до легкого проходу або рушія з розумінням розкладки, а сторінки, чиї субсет-шрифти не мають ToUnicode CMap, відкатуються до OCR
Значення опції GetPageText від 0 до 8 обирають між легким прогоном попереднього перегляду і рушієм, обізнаним у верстці; опція 7 зарезервована для пошукового індексування, а відсутні ToUnicode CMap перенаправляються на OCR

Для випадків, які не покривають прості параметри — власна токенізація, криміналістика потоку вмісту, лійка тексту, побудована за вашими власними правилами, — декодер доступний на рівень нижче. TPDFExtractor будується над словником ресурсів сторінки та колекцією шрифтів. Його метод ExtractTextW прожене сирі операції тексту з потоку вмісту назад через ту саму машинерію шрифтів, щоб відновити Unicode, а його подія OnFindObject передає вам кожен об'єкт у міру того, як він проходить повз. Більшості коду ніколи не потрібно занурюватися так глибоко. Застосунки, яким це потрібно, раді, що цей рівень публічний, а не похований

Позиційовані блоки: одиниця для збігів пошуку й перегляду редагування

Простий текст каже вам, що написано на сторінці. Рано чи пізно продукту також потрібно знати, де саме це написано, щоб підсвітити збіг пошуку, намалювати рамку навколо кандидата на редагування або прив'язати анотацію до потрібного місця. ExtractPageTextBlocks повертає дескриптор списку текстових фрагментів, і кожен фрагмент несе свій текст, свій обмежувальний прямокутник та ім'я й розмір шрифту, яким його встановлено:

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

Одна деталь у цій ділянці підводить інтеграції частіше за все інше. SetTextExtractionArea, SetTextExtractionWordGap та SetTextExtractionOptions — це стан рівня документа, який зберігається, а не аргументи, що передаються при кожному виклику. Налаштуйте обмеження області для однієї функції, скажімо, читання лише смуги заголовка для класифікації документа, — і воно мовчки обріже кожне наступне вилучення на тому самому дескрипторі, включно з рівнями GetPageText, що враховують макет і до яких ви звернетеся пізніше. Або скидайте стан вилучення між логічними завданнями, або давайте кожному завданню власний дескриптор документа

Поріг проміжку між словами — це важіль для тієї першої купки поломок, слів, що зливаються докупи. SetTextExtractionWordGap повідомляє рушію макета, наскільки великий горизонтальний проміжок, виміряний відносно власного інтервалу гліфів сторінки, відокремлює одне слово від наступного. Щільна таблиця потребує меншого проміжку, ніж вільно набрана рекламна сторінка, тож поріг, підлаштований під клас документів, переважає одну глобальну константу. Він зберігається на документі так само, як і решта стану вилучення, тож плануйте встановлювати його свідомо, а не один раз і забути

Діаграма: стан витягання PDF рівня документа в Delphi зберігається між викликами на одному дескрипторі до скидання, що запобігає мовчазному обрізанню пізніших витягань
Область вилучення, міжсловний інтервал і налаштування опцій живуть на хендлі документа, тож область, задана для однієї функції, мовчки обрізає кожне наступне вилучення, доки стан не скинуть або хендл не замінять

Зображення: оригінальні потоки, а не знімки екрана

Неправильний спосіб дістати зображення з PDF — відрендерити сторінку й обрізати її. Це передискретизує пікселі, вплавлює будь-яке обертання й викидає все, чим був оригінал. Натомість GetPageImageList перелічує реальні ресурси зображень, на які посилається сторінка, і кожен елемент повертає свої властивості та свої оригінальні, недоторкані дані:

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

Перевіряйте GetImageListItemFormatDesc, перш ніж робити будь-які припущення щодо елемента, бо те, на що посилається сторінка, рідко буває однією охайною картинкою на одне видиме зображення. М'яка маска з'являється як власний окремий запис. Той самий XObject часто повторюється на багатьох сторінках, тож дедуплікуйте за хешем вмісту, перш ніж архівувати експорт "усіх зображень", інакше ви запишете той самий логотип сотню разів. CMYK JPEG потребують застосування керування кольором далі за конвеєром, інакше вони відображаються інвертовано в переглядачах, які беруть канали за чисту монету. Коли вам потрібна інвентаризація всього документа, а не однієї сторінки за раз, FindImages разом із SetFindImagesMode сканує весь файл за один прохід

Є одна межа, яку варто озвучити зацікавленим сторонам ще до того, як хтось напише критерії приймання: вилучення зображень повертає лише растрові ресурси. Логотип чи діаграма, намальовані векторними контурами, не є зображенням у сенсі ресурсу і ніколи не з'являться в жодному списку зображень, хай там як чітко це читається як картинка на екрані. Коли вимога справді полягає в тому, щоб доставити цю діаграму як файл, чесний підхід — відрендерити ділянку сторінки в растр, а це вже інша операція з іншою точністю. Ці два типи виводу не повинні опинятися в одній папці експорту без підпису, який саме є яким

Порівняння: рендеринг PDF-сторінки в Delphi заради зображень проти витягання оригінальних потоків зображень через GetPageImageList, з засторогами про м'які маски, дублікати XObject і CMYK
Рендеринг і обрізання пересемплюють пікселі та викидають вихідні дані зображення, тоді як GetPageImageList перелічує збережені ресурси зображень із їхніми властивостями й недоторканими потоками

Шрифти: поверхня аудиту, а не функція експорту

API шрифтів відповідає на запитання про шрифти. Він не віддає вам самі файли шрифтів, і ця відмінність формує все, що на ньому можна побудувати. Після того як FindFonts сканує документ, перелічення обходить шрифти за ID, а виклики властивостей звітують про той шрифт, що обраний наразі:

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // індекси шрифтів починаються з 1, а не з 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

Слідкуйте за межами циклу. Індекси шрифтів ідуть від 1 до FontCount, тоді як індекси текстових блоків і списку зображень кількома абзацами вище нумеруються з нуля. Перенесіть одну умовність в іншу, і отримаєте похибку на одиницю, яка або пропускає перший шрифт, або виходить за межі останнього, і вона пройде побіжне тестування, бо у більшості документів є кілька шрифтів, і навіть не той усе одно виглядає правдоподібно. Так само чітко варто визначити межі можливостей. Цей API не має побайтового експорту шрифтів. Жоден виклик не повертає вбудовану програму шрифту як файл TTF чи OTF, і перелічення разом з оглядом метаданих — це вся задумана модель. Ця модель усе одно покриває те, чого виробнича робота насправді вимагає від шрифтів: виявлення підмножин за шаблоном імені, аудит вбудовування перед архівним перетворенням (невбудований шрифт — це жорсткий блокер для PDF/A, про що детальніше розповідає розділ про preflight PDF/A та PDF/UA у Delphi), і діагностику кодування на випадок, коли впевненість у вилученні падає. Є й ліцензійна причина, чому межа проходить саме тут. Програма підмножинного шрифту — це ліцензований матеріал, і, позбавлена більшості своїх гліфів, все одно марна як шрифт, придатний до встановлення. Ставитися до неї як до метаданих аудиту, а не як до придатного для вилучення ресурсу, — це позиція, яку можна відстояти

Цей останній виклик відпрацьовує свою вагу в тріажі. Запустіть GetFontEncoding на кожному шрифті, прочитайте його разом з прапорцем підмножини, і ви зможете передбачити якість вилучення ще до того, як витягнете хоч один символ. Сторінка, чиї шрифти всі підмножинні з нестандартним кодуванням, — кандидат на OCR уже на вигляд, і це дає змогу пакетному конвеєру спрямувати її правильно, не витрачаючи спершу невдалий прохід вилучення

Вилучення в масштабі без завантаження документів

У пакетному конвеєрі завантаження цілого документа лише для того, щоб прочитати одну сторінку, — це марна витрата вводу-виводу, і в межах корпусу вона швидко накопичується. Варіанти з одним викликом, ExtractFilePageText та ExtractFilePageTextBlocks, беруть ім'я файлу, пароль і номер сторінки безпосередньо й обходяться без повного завантаження. Для файлів гігабайтного масштабу є ще нижча передача. Шлях прямого доступу відкриває файл через потокове читання xref, тож DAOpenFileReadOnly, за яким іде DAExtractPageText, торкається лише тих об'єктів, які справді потрібні одній сторінці. Це супроводжується зміною умовності, яку варто запам'ятати: функції DA адресують сторінки через PageRef, дескриптор посилання на об'єкт, який ви отримуєте з DAFindPage, ніколи за сирим номером сторінки. Передайте номер там, де очікується дескриптор, і виклик спрацює не над тим об'єктом, не піднявши жодної помилки, а це найгірший різновид помилки для налагодження. Решта інструментарію прямого доступу викладена в розділі про злиття, розділення та прямий доступ до великих PDF

Якщо є одна-єдина звичка, що відрізняє код вилучення, який переживає реальний корпус, від коду, що ледь шкутильгає, то це трактувати сторінку як недовірений вхід, а не як чисте джерело даних. Текст, що розходиться з тим, що рендерить переглядач, майже завжди є проблемою кодування — лігатура, що зколапсувала в один гліф, або підмножинний шрифт без записів ToUnicode, — і виправлення полягає в тому, щоб виміряти впевненість і спрямувати погані сторінки на OCR, а не боротися з байтами. API шрифтів за задумом ніколи не видасть TTF чи OTF, тож будуйте робочі процеси навколо шрифтів як питання аудиту. А стійкий стан вилучення, і насамперед прямокутник області, — це налаштування, яким ви володієте протягом усього життя дескриптора документа, а не параметр, який забувають після одного виклику. Опануйте ці три рефлекси правильно, і решта API поводитиметься як слід

Оцінні збірки, демонстраційні проєкти й повний довідник API вилучення розміщені на сторінці продукту losLab PDF Library for Delphi