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

Чтение свойств шрифта PDF с помощью PDFium Component в Delphi

Каждый видимый символ в PDF несет в себе ссылку на шрифт, которым он был нарисован, а PDFium Component позволяет вам проследовать по этой ссылке обратно к объекту шрифта и прочитать то, что он знает. Единицей доступа является символ, а не документ: вы выбираете символ по его индексу в тексте страницы и запрашиваете название семейства, базовое название, плотность (weight), угол наклона (italic angle) и то, действительно ли используемая гарнитура содержится внутри файла. Последнее свойство - то, что большинство анализов на самом деле ищут, потому что внедренный шрифт перемещается вместе с документом, а невнедренный - это лишь обещание того, что на машине читателя установлен тот же шрифт

Компонент предоставляет их через те же объекты TPdf и TPdfView, которые вы используете для рендеринга и извлечения текста. Отдельного объекта «таблица шрифтов», который нужно было бы открывать, нет. После того, как текст страницы был разобран, свойства шрифта привязываются к индексу символа, и вы считываете их по одному глифу за раз. Этот дизайн соответствует тому, как PDF хранит информацию с самого начала: на одной странице шрифты могут меняться десятки раз, и единственный честный ответ на вопрос «какой шрифт в этом документе» — «зависит от того, какой символ вы имеете в виду»

Чтение шрифта, стоящего за одним символом

Самая маленькая полезная операция - это взять индекс символа и выгрузить всё, что PDFium может сказать вам о его шрифте. Каждое свойство шрифта в TPdf и TPdfView индексируется по позиции символа, поэтому индекс проходит через них все. Страница также должна быть текущей страницей, чтобы индекс разрешался относительно правильного текста, что имеет значение, как только вы переходите со страницы номер один

procedure DescribeFontAt(Pdf: TPdf; CharIndex: Integer);
var
  Report: TStringList;
  PtSize: Single;
begin
  Report := TStringList.Create;
  try
    PtSize := Pdf.FontSize[CharIndex];

    Report.Add('Символ    : ' + Pdf.Character[CharIndex]);
    Report.Add('Семейство : ' + Pdf.FontFamilyName[CharIndex]);
    Report.Add('Базовое   : ' + Pdf.FontBaseName[CharIndex]);
    Report.Add('Толщина   : ' + IntToStr(Pdf.FontWeight[CharIndex]));
    Report.Add('Курсив    : ' + IntToStr(Pdf.FontItalicAngle[CharIndex]) + ' град');
    Report.Add('Размер    : ' + FormatFloat('0.0', PtSize) + ' пт');
    Report.Add('Подъем    : ' + FormatFloat('0.0', Pdf.FontAscent[CharIndex, PtSize]));
    Report.Add('Спуск     : ' + FormatFloat('0.0', Pdf.FontDescent[CharIndex, PtSize]));
    Report.Add('Встроен   : ' + BoolToStr(Pdf.FontIsEmbedded[CharIndex], True));

    ShowMessage(Report.Text);
  finally
    Report.Free;
  end;
end;

Пара сигнатур удивляет людей, приходящих из других библиотек. FontAscent и FontDescent принимают два аргумента: индекс символа и размер в пунктах, потому что PDFium сообщает об этих метриках в единицах пространства глифов, которые становятся пикселями только после того, как вы масштабируете их в соответствии с размером, с которым был установлен текст. Передайте значение, которое вы уже считали из FontSize[CharIndex], и вы получите подъем и спуск в тех же пунктах, что и остальной макет. Спуск возвращается отрицательным, так как он измеряется ниже базовой линии. Название семейства и базовое название намеренно представляют собой разные строки: базовое название — это необработанная запись /BaseFont из PDF-файла, часто несущая префикс подмножества, такой как ABCDEF+, тогда как название семейства — это очищенное имя, в которое его преобразует рендерер

Превращение клика в индекс символа

Во вьюере вы редко знаете индекс заранее. Пользователь щелкает по глифу, и вам необходимо перевести пиксельные координаты в символ, находящийся под ним. CharacterIndexAtPos делает именно это, принимая положение мыши и допуск и возвращая индекс ближайшего символа или отрицательное значение, когда щелчок пришелся на пробел или пустую страницу

procedure TfrmMain.PdfViewMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Index: Integer;
begin
  if not PdfView.Active then
    Exit;

  // 4 пикселя запаса в каждом направлении, чтобы небольшой промах все равно попадал в глиф.
  Index := PdfView.CharacterIndexAtPos(X, Y, 4.0, 4.0);
  if Index < 0 then
    Exit;                      // щелчок между глифами; оставьте панель в покое

  PdfView.CurrentCharIndex := Index;
  DescribeFontAt(PdfView.Pdf, Index);
end;

Допуск стоит настроить. Слишком жесткий — и пользователи будут чувствовать, что им нужно попасть точно в штрих буквы; слишком свободный — и щелчок по полю привязывается к какому-то далекому символу, не имеющему ничего общего с тем, что они имели в виду. От трех до пяти пикселей устройства — разумная начальная точка для просмотра на экране. Возвращаемый индекс указывает на разобранный текст текущей страницы, то же самое пространство индексов, которое ожидает каждое свойство шрифта, поэтому вы можете передать его прямо подпрограмме выше. Сохранение его в CurrentCharIndex не является обязательным, но удобным: представление сохраняет его как свое представление о сфокусированном глифе, что удобно, если другие части пользовательского интерфейса хотят прочитать выделение без его повторного вывода

Встраивание — это свойство, которое имеет значение

Для большей части реальной работы единственный вопрос, на который стоит ответить, — встроен ли каждый шрифт. Документ, чьи шрифты находятся внутри него, отображается одинаково на RIP в полиграфическом бюро, на ноутбуке коллеги и на сервере вообще без графического интерфейса. Документ, опирающийся на невстроенный Helvetica, рискует тем, что на каждой из этих машин установлен подходящий шрифт, и когда риск не оправдывается, читатель заменяет его чем-то близким, метрики смещаются, и тщательно сверстанная форма перестраивается ровно настолько, чтобы сломаться. Перебор текста страницы и группировка шрифтов по статусу встраивания позволяет получить этот ответ дешево

procedure ReportNonEmbeddedFonts(Pdf: TPdf);
var
  Embedded, External: TStringList;
  I: Integer;
  Name: string;
begin
  Embedded := TStringList.Create;
  External := TStringList.Create;
  try
    Embedded.Sorted := True;
    Embedded.Duplicates := dupIgnore;
    External.Sorted := True;
    External.Duplicates := dupIgnore;

    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      Name := Pdf.FontBaseName[I];
      if Name = '' then
        Continue;              // у сгенерированных пробелов и т.п. нет шрифта
      if Pdf.FontIsEmbedded[I] then
        Embedded.Add(Name)
      else
        External.Add(Name);
    end;

    if External.Count > 0 then
      ShowMessage(IntToStr(External.Count) +
        ' невстроенный(ых) шрифт(ов):' + sLineBreak + External.Text)
    else
      ShowMessage('Все ' + IntToStr(Embedded.Count) +
        ' шрифт(а/ов) на этой странице встроены.');
  finally
    Embedded.Free;
    External.Free;
  end;
end;

Пара деталей позволяет сохранить честность этого кода. Во-первых, CharacterCount считается по страницам, поэтому проверка всего документа означает установку Pdf.PageNumber на каждую страницу по очереди и повторный запуск цикла с объединением результатов. Во-вторых, текстовый слой содержит сгенерированные символы, такие как пробелы, которые читатель выводит между словами, и у них нет объекта шрифта за ними; проверка на пустое базовое имя пропускает их, а не регистрирует фантом. Базовое имя является здесь правильным ключом для дедупликации, потому что префикс подмножества, который оно несет, отличает два разных подмножества одного и того же семейства, что обычно и требуется знать

Извлечение встроенного шрифта

Если шрифт встроен, вы можете считать его байты напрямую. FontData возвращает необработанную программу шрифта, те же данные TrueType или CFF, которые содержит PDF-файл, чего достаточно для создания отдельного файла шрифта или для снятия «отпечатка» шрифта (fingerprint) для сравнения с известной библиотекой. Он возвращает пустой массив, когда шрифт не встроен, поэтому проверка встраивания и проверка длины вместе защищают запись

procedure SaveEmbeddedFont(Pdf: TPdf; CharIndex: Integer;
  const OutputFile: string);
var
  Data: TBytes;
  Stream: TFileStream;
begin
  if not Pdf.FontIsEmbedded[CharIndex] then
  begin
    ShowMessage('Шрифт этого глифа не встроен; извлекать нечего.');
    Exit;
  end;

  Data := Pdf.FontData[CharIndex];
  if Length(Data) = 0 then
    Exit;

  Stream := TFileStream.Create(OutputFile, fmCreate);
  try
    Stream.WriteBuffer(Data[0], Length(Data));
  finally
    Stream.Free;
  end;
  ShowMessage('Записано ' + IntToStr(Length(Data)) + ' байт.');
end;

Эти байты являются встроенным подмножеством, а не оригинальным розничным шрифтом, поэтому то, что вы получаете обратно, обычно покрывает только те глифы, которые фактически использовались в документе. Это идеально подходит для криминалистики и верификации, но плохо для повторного использования; подмножество Times New Roman, содержащее тридцать глифов, не является шрифтом, который можно установить и печатать им. Относитесь к извлечению как к способу проверить, что именно было отправлено, а не как к инструменту для восстановления шрифтов. Если вам нужно совпадающее базовое имя для маркировки вывода, считайте FontBaseName[CharIndex] вместе с данными, и удалите начальный тег подмножества, если вам нужно чистое семейство

Понимание значения плотности (weight)

FontWeight возвращает числовой класс плотности, ту же шкалу от 100 до 900, которую использует CSS, где 400 — это обычный шрифт (regular), а 700 — полужирный (bold). PDFium сообщает то, что заявляет шрифт, и это не всегда круглая сотня; шрифт может заявлять 350 или 650, и отношение ко всему, что равно 600 или выше, как к «достаточно полужирному, чтобы иметь значение», работает лучше, чем проверка на соответствие ровно 700. Угол курсива является сопутствующим сигналом: ненулевое значение, обычно отрицательное, означает, что шрифт имеет наклонный или истинно курсивный дизайн, а ноль означает прямое начертание. Вместе они позволяют отличить полужирный курсив от обычного текста без рендеринга чего-либо, что и требуется для массовых проверок (preflight) или аудита доступности

Ни одно из этих чтений не требует отрисованного растрового изображения. Они берутся из разобранного текстового слоя, поэтому открытый документ на правильной странице — это вся необходимая настройка, что делает инспекцию шрифтов дешевой для запуска по всему архиву. Если вы связываете это с извлечением текста, то те же индексы символов совпадают с текстом, который вы извлекаете, поэтому шрифт глифа и его значение Unicode — это два чтения по одному индексу. В сопутствующей статье об извлечении текста из документов PDF с помощью PDFium Component эта сторона текстового слоя рассматривается более подробно

Свойства шрифтов, показанные здесь, являются частью PDFium Delphi VCL Component