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

Витяг тексту з PDF у Delphi: пробіли та розриви рядків

HotPDF Delphi Component відбудовує пробіли між словами і розриви рядків у THotPDF.ExtractLoadedPageText з геометрії гліфів, а не з символів пробілу. Пробіл вставляється, коли зазор після власної ширини гліфа перевищує 0.15 висоти тексту, а новий рядок починається лише тоді, коли початок тексту зсувається поперек напрямку письма більш ніж на половину висоти тексту. З v2.768.3 текст сторінки також включає текст, намальований через Form XObjects, і не бере гліфи поза видимою областю обрізу. Решта цієї статті пояснює, чому кожне правило виглядає саме так, бо кожне з них замінило простіше правило, що давало правдоподібний, але неправильний вивід на реальних документах

Симптоми знайомі кожному, хто годував PDF-текстом пошуковий індекс. Титульна сторінка витягується як PDFReferenceManualNovember4,1998, податкова форма розпадається на 156 рядків, діагональний водяний знак прибуває по одному символу на рядок, а обрізана верстка починається з принтерського рядка-слага, який жоден переглядач не показує. Жоден із цих файлів не зламаний. Кожен уживає цілком легальний спосіб розміщення тексту, який наївний екстрактор читає неправильно

Чому витягнутий текст PDF втрачає пробіли між словами?

Витягнутий текст втрачає пробіли, бо PDF ніколи не зобов'язаний їх містити. Продюсер може розділяти слова показом символу пробілу, але так само добре може пересунути перо числом усередині масиву TJ (ISO 32000-1 §9.4.3) чи свіжим Td (§9.4.2), і вивід TeX, чимало файлів Distiller і більшість вимкнених по формату макетів роблять саме це. До v2.766.76 HPDFAssemblePageText дивився лише на вертикальний рух, тож розрив слова, зроблений позиціюванням, просто зникав. Збирач тепер міряє — вздовж напрямку письма попереднього гліфа — відстань від кінця власної ширини того гліфа до початку поточного гліфа і вставляє один пробіл, коли відстань перевищує 0.15 висоти боксу поточного гліфа, міряної від верхнього до нижнього виносу в user space. Пробіл не додається, коли будь-який бік уже порожній, і між двома CJK-символами, бо вимкнення по формату розтягує ідеографи без того, щоб та розтяжка означала межу слова. Записи гліфів відкривають ту саму геометрію, тож ви можете відтворити рішення, коли конкретний файл ставить вас у тупик

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // висота боксу гліфа від верхнього до нижнього виносу, у user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // горизонтальний текст: зазор від кінця власної ширини попереднього гліфа
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Чому міряти від власної ширини гліфа, а не від позиції пера?

HotPDF міряє міжсловні зазори від GlyphEndX / GlyphEndY, бо позиція пера після гліфа вже містить міжсимвольні відстані, які не є зазором. ISO 32000-1 §9.4.4 визначає горизонтальне зміщення як ширину гліфа, помножену на розмір шрифту, плюс міжсимвольну відстань Tc, плюс міжсловну відстань Tw, усе масштабоване на Tz. BaselineEndX / BaselineEndY тримають те повне зміщення, тоді як GlyphEndX / GlyphEndY — лише шрифтовий advance і Tz. Різниця важить для продюсерів, що підбирають трекінг від'ємним Tc, а потім повертають простір підлаштуванням TJ після кожного гліфа: міряне від позиції пера, повернення виглядає як зазор, і китайський термін «95后» витягувався як «9 5 后». Порог прив'язаний до висоти боксу гліфа, а не до розміру Tf, з подібної причини. Word-експорти часто пишуть 1 Tf і несуть справжній розмір у масштабованій Tm, тож Tfs каже 1, тоді як текст заввишки 10 пунктів, і правило, зав'язане на Tfs, трактувало б два написи однієї сторінки по-різному

Правило пробілів HotPDF для ExtractLoadedPageText у Delphi: пробіл вставляється лише коли відстань від GlyphEndX попереднього гліфа до BaselineStartX наступного перевищує 0.15 висоти боксу від верхнього до нижнього виносу, бо позиція пера в BaselineEndX уже містить Tc, Tw і Tz і перетворює вимкнені повернення трекінгу на хибні зазори на кшталт 9 5 后
Геометрія, а не символи пробілу, вирішує, де ламаються слова — записи гліфів відкривають ті самі вимірювання, тож ви можете повторити рішення для будь-якого загадкового файлу

У правила чесні краї. Заголовок, набраний дуже розрідженим трекінгом, де один Tc відкриває понад 0.15 висоти тексту між літерами, витягується з пробілом між кожною літерою — саме так виглядає сторінка, але, можливо, не те, що ви хотіли індексувати. Куски, намальовані не за порядком на одній базовій лінії, дають від'ємний зазор і з'єднуються без пробілу. Жоден випадок не частий у основному тексті, а на тестовому корпусі зміна підняла словесні збіги проти референсного екстрактора на 28 сторінках, не знизивши жодного

Коли HotPDF починає новий рядок у витягнутому тексті?

З v2.766.79 новий рядок починається, коли зсув від початку попереднього гліфа до поточного, спроєкований на нормаль попереднього напрямку письма, перевищує половину більшої висоти боксу з двох гліфів. Раніше правило порівнювало сирий Y-зсув із половиною Tfs, що провалювалося в двох напрямках. З 1 Tf і масштабованою Tm поріг стискався до половини одиниці, тож верхній індекс, піднятий text rise на 0.4, чи звичайне тремтіння базової лінії ламало рядок. Правило також цілком ігнорувало X, тож текст під повернутою Tm ступав униз по сторінці з кожним гліфом і виходив по одному гліфу на рядок. Проєкція на нормаль напрямку робить повернуті пробіги схожими на горизонтальні, а взяття більшої з двох висот тримає велике слово-зразок і його дрібний підпис на одному рядку, коли ті ділять базову лінію. На згаданій вище податковій формі кількість рядків впала зі 156 до 97. Вертикальний текст у writing mode 1 (§9.7.4.3) іде окремим шляхом: ті гліфи групуються в колонки, читаються справа ліворуч і згори вниз, з розривом рядка на кожній зміні колонки

Як ExtractLoadedPageText у HotPDF вирішує розриви рядків у Delphi: зсув між початками гліфів проєктується на нормаль напрямку письма і звіряється з половиною більшої висоти боксу, тож верхній індекс, піднятий малим text rise під шрифтом 1 Tf, і текст, що ступає вниз по сторінці під повернутою Tm, більше не розпадаються по одному гліфу на рядок
Проєкція робить повернуті пробіги схожими на горизонтальні, а взяття більшої з двох висот боксу тримає велике слово-зразок і його дрібний підпис на одному рядку

Який текст ExtractLoadedPageText включає, а який лишає?

ExtractLoadedPageText повертає текст, який показує переглядач. З v2.766.80 він працює лише з видимими гліфами, відкидаючи кожен гліф, чий центр боксу випадає за GetLoadedPageVisibleBox — CropBox, обрізаний до MediaBox (§14.11.2). Це прибирає рядки-слаги та інші принтерські позначки, набрані текстом поза зоною обрізу. ExtractLoadedPageGlyphs навмисно продовжує повертати кожен гліф потоку вмісту сторінки, тож ви все ще знайдете той матеріал, коли він потрібен. Фільтр — тест боксу, а не тест видимості: текст, схований clipping-шляхом, намальований білим чи вкритий зображенням, усе одно витягується

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // кожен гліф потоку вмісту сторінки, з рядком-слагом включно
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // лише те, що показує сторінка, з уплітованим текстом Form XObject
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Текст, намальований через Form XObjects, — частина тексту сторінки з v2.768.3. Колонтитули, штампи та водяні знаки дуже часто живуть у формах, і деякі стандартні документи втрачали 30–35 відсотків символів до цієї зміни. THotPDF.InterpretContentWithForms записує кожен Do разом із чинним CTM, інтерпретує форму на її /Matrix, помноженій на той CTM (§8.10.1), і вплітає гліфи форми на позиції Do, заглиблюючись у вкладені форми. Форма без власних /Resources позичає ті, що в потоку, який її малює, як дозволяє §7.8.3. Формові гліфи несуть TokenIndex = -1, а ExtractLoadedPageGlyphs досі повертає лише гліфи сторінкового потоку, бо пошук, заміна і редакція пишуть зміни назад через TokenIndex і правили б не ті байти, якби формовий гліф прослизнув всередину. Дві спрощення варто знати: формовий текст не обрізається до /BBox форми, а рекурсія зупиняється на 12 рівнях, а не через виявлення циклів, тож деформована форма, що малює сама себе, повторює свій текст, поки не досягне того ліміту

Які гліфи HotPDF включає під час витягання тексту PDF-сторінки в Delphi: ExtractLoadedPageText лишає лише гліфи, чий центр боксу падає всередину GetLoadedPageVisibleBox — CropBox, обрізаний до MediaBox, тож принтерські рядки-слаги зникають, тоді як InterpretContentWithForms вплітає гліфи Form XObject на кожній позиції Do з TokenIndex = -1, а гліфовий API все ще повертає все
Тест боксу на центрі гліфа — не тест видимості: білий текст, обрізаний текст і вкритий текст усе одно виходять, а формовий текст зараховується з v2.768.3

Чому текст після оператора Q декодувався як сміття?

Текст після Q міг декодуватися неправильно до v2.766.73, бо екстрактор зберігав на q лише CTM. Параметри текстового стану, а саме шрифт, розмір, Tc, Tw, Tz, TL, режим рендерингу і rise, належать графічному стану (§9.3.1), тож Q мусить відновлювати їх разом із рештою на стеку (§8.4.2). Один галузевий звіт обирав двобайтовий шрифт Identity-H усередині q … Q, а потім показував однобайтовий текст WinAnsi без власного Tf. Екстрактор тримав внутрішній шрифт, читав відбивки і слово «Adobe» на змісті як двобайтові коди і втрачав 15% символів сторінки. Стек q/Q інтерпретатора тепер тримає повний текстовий стан. Правила витягання, описані тут, стосуються кожної сторінки, тож цілий документ може піти у файл одним викликом

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // порожній діапазон = усі сторінки; form feed між сторінками; UTF-8 BOM
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Який текстовий API HotPDF варто вживати?

ExtractLoadedPageText лишається в порядку потоку вмісту, що є правильним усталеним для пошуку та індексування; ланцюг декодування під ним покритий у витяганні тексту з завантажених PDF з HotPDF. Для тегованих документів, де важить авторський порядок, витягання тексту в порядку структури йде деревом структури замість вгадування з геометрії, а для даних, замкнених у таблицях, типізоване витягання таблиць через розриви сторінок повертає клітинки, а не рядки. Повна API-довідка і пробне завантаження — на сторінці продукту HotPDF Delphi PDF Component