HotPDF правильно рендерит буквы с акцентами из встроенных шрифтов, обрабатывая два шрифтовых механизма, собирающих один глиф из других: составные глифы TrueType, чьи смещения компонент обязаны применяться в единицах глифа до матрицы глиф-в-устройство, и форму seac у endchar в CFF, которая сшивает базовую букву и акцент по коду StandardEncoding. Ошибётесь в любом из них — и é, Å, ü или типографская кавычка выйдет с недостающей частью, тогда как обычные буквы вокруг выглядят идеально
Последняя деталь — то, что делает эти баги дорогими. Отчёт рендерится, основной текст в порядке, и только в «München» дырка, или цитата открывается одиночной кавычкой и пустотой. Никто не подозревает рендерер. Первая версия всегда — повреждённый шрифт или плохой продюсер, и тикет неделю мотается между людьми. Эта статья проходит по тому, что именно ломается в каждом механизме, как компонент HotPDF Delphi обрабатывает это с v2.768.10 и v2.768.11, и по смежному фиксу сабсеттинга в v2.770.83, который сохраняет составные глифы целыми, когда HotPDF пишет шрифты, а не читает их
Почему из глифа PDF пропадает только акцент?
Акцент пропадает, потому что он — единственная часть глифа, несущая ненулевое смещение. Шрифты и TrueType, и CFF экономят место повторным использованием контуров: é рисуется не как новая форма, а как «глиф e плюс глиф акута, сдвинутый вверх и вправо». Базовая буква почти всегда сидит в смещении (0, 0), поэтому любая ошибка в применении смещений её не трогает. Акцент — та компонента, которая движется, потому и та, что попадает не туда или не рисуется вовсе
Паттерн симптомов сам по себе диагностичен. Если базовые буквы акцентированных символов рендерятся, а диакритика нет, или парная пунктуация вроде “ и ” показывается одной меткой вместо двух — перестаньте смотреть в файл шрифта. Битый шрифт обычно ломает глифы целиком, и ломает их в каждом вьюере. Недостающие части собранных глифов в одном рендерере при исправно показывающем страницу Acrobat указывают на код, собирающий компоненты
Как составные глифы TrueType размещают свои компоненты?
Составной глиф TrueType размещает каждую компоненту собственным маленьким аффинным преобразованием в единицах шрифта, и это преобразование обязано подействовать на контур компоненты раньше матрицы глиф-в-устройство. В таблице glyf OpenType составной глиф имеет отрицательное число контуров, за которым идёт список записей компонент. Каждая запись держит слово флагов, индекс глифа компоненты, два аргумента — байты или слова (ARG_1_AND_2_ARE_WORDS, 0x0001) — и опциональный масштаб: одно значение F2Dot14 для WE_HAVE_A_SCALE (0x0008), два для WE_HAVE_AN_X_AND_Y_SCALE (0x0040) или полную матрицу 2x2 для WE_HAVE_A_TWO_BY_TWO (0x0080). Когда выставлен ARGS_ARE_XY_VALUES (0x0002), два аргумента — смещение dx/dy в единицах шрифта. MORE_COMPONENTS (0x0020) говорит, что следует ещё одна запись
Итак, для каждой компоненты вы строите матрицу компоненты CM из масштабной части и смещения, затем комбинируете её с M — матрицей, отображающей единицы шрифта в пиксели устройства. Порядок этой комбинации и есть весь баг. Внутреннее умножение матриц HotPDF использует конвенцию векторов-столбцов, поэтому произведение A и B применяет сначала B, потом A. Правильная композиция применяет CM первой, в пространстве глифа, а M — второй. Записанное наоборот, M выполняется первой, и смещение CM добавляется уже в пикселях устройства и никогда не масштабируется
// Только иллюстрация: запись 2D-аффинного преобразования с конвенцией
// векторов-столбцов, где Mul(A, B) проводит точку сначала через B, затем через A
type
TAffine = record
a, b, c, d, e, f: Double;
end;
function Mul(const A, B: TAffine): TAffine;
begin
Result.a := A.a * B.a + A.c * B.b;
Result.b := A.b * B.a + A.d * B.b;
Result.c := A.a * B.c + A.c * B.d;
Result.d := A.b * B.c + A.d * B.d;
Result.e := A.a * B.e + A.c * B.f + A.e;
Result.f := A.b * B.e + A.d * B.f + A.f;
end;
// M : единицы шрифта -> пиксели устройства (крошечный масштаб, y перевёрнут)
// CM : преобразование компоненты; e/f держат dx/dy в единицах шрифта
Sub := Mul(M, CM); // верно: смещение сжимается вместе с контуром
// Sub := Mul(CM, M); // неверно: dx/dy попадают как сырые пиксели устройства
Числа делают провал очевидным, стоит их увидеть. Arial использует em в 2048 единиц, и компонента акцента у é несёт смещение в этих единицах шрифта. При верном порядке оно сжимается вместе с контуром до нескольких пикселей при обычном зуме. При обратном порядке сырое значение в единицах шрифта прибавляется как пиксели устройства, и в тестовой отрисовке HotPDF акцент é из Arial оказался в 243 пикселях от своей буквы — далеко за пределами глифа и обычно за пределами всего видимого. Левая двойная кавычка в Arial — две копии формы одинарной кавычки, вторая сдвинута, поэтому она отрисовалась как одинокая ‘ с пустотой следом
Линейная часть редко выдаёт баг. Компоненты акцентов обычно не несут масштаба вовсе, а равномерный масштаб и так коммутирует с M, так что перевёрнутое произведение выдаёт только перенос. Потому и быстрый тест на простом латинском тексте проходит: перевёртыш сидел в коде много релизов, прежде чем v2.768.10 его поправила, и проявляли его только компоненты со смещениями
Рекурсия и циклы
Составные глифы могут ссылаться на другие составные глифы, поэтому рендерер обходит их рекурсивно и обязан защищаться. HotPDF ограничивает глубину вложенности 5 уровнями, суммарное число компонент на глиф — 1024, пропускает компоненту, называющую собственный индекс глифа, и трактует редко используемый режим привязки к точкам (аргументы — номера точек, а не смещения) как нулевое смещение. Враждебный или повреждённый шрифт поэтому может потерять компоненту, но не может повесить отрисовку страницы
Что такое форма seac у CFF endchar?
Форма seac у endchar — устаревший способ, которым charstring CFF Type 2 говорит «этот глиф — базовая буква плюс акцент»: когда у endchar четыре аргумента, это adx ady bchar achar. Adobe Technical Note #5177 описывает её в приложении о совместимости с Type 1. Имя пришло от оператора seac в Type 1 (standard encoding accented character), и сабсеты CFF, сконвертированные из шрифтов Type 1, до сих пор активно используют её для латинских букв с акцентами
Три детали спотыкают реализаторов. Первая: bchar и achar — не ID глифов. Это коды в Adobe StandardEncoding, поэтому рендерер отображает каждый код в имя глифа, а затем ищет это имя через charset шрифта, чтобы найти GID. Вторая: у Type 2 нет аргумента asb, в отличие от Type 1 seac, которая принимает asb adx ady bchar achar. В Type 2 начало акцента идёт прямо в (adx, ady) относительно начала базового глифа, без коррекции на side bearing. Третья: CID-keyed шрифты CFF вообще не могут использовать seac, потому что нет charset имён глифов, через который разрешались бы коды StandardEncoding
Дальше — ширина. Каждый charstring Type 2 может начинать свой первый оператор очистки стека опциональным аргументом advance width, и endchar — один из таких операторов. Обычный endchar с одним аргументом несёт ширину; seac-endchar с пятью аргументами несёт ширину, за которой идут четыре значения seac. Правило — сверять число аргументов с ожидаемым фиксированным, для seac это четыре. До v2.768.11 HotPDF трактовал любой endchar как «взять ширину и остановиться», поэтому seac-глифы давали пустой контур: ни базы, ни акцента — только корректно отмеренный пробел в строке
// Иллюстрация решения по endchar в интерпретаторе Type 2.
// Стек держит операнды перед endchar; Count — сколько их.
if (Count >= 4) and not IsCIDFont then
begin
// 5 операндов: первый — advance width, остальные — seac
if Count = 5 then
TakeWidthAndShift; // сбросить ширину, оставить 4 операнда
if StackDepth = 4 then
begin
BaseGID := GIDForName(StdEncName(Round(Stack[2]))); // bchar
AccentGID := GIDForName(StdEncName(Round(Stack[3]))); // achar
DrawGlyph(BaseGID, Identity);
DrawGlyph(AccentGID, Translate(Stack[0], Stack[1])); // adx, ady
Exit;
end;
end;
// иначе: обычный endchar, опциональная ширина, закрыть путь
В настоящем интерпретаторе оба глифа-компоненты исполняются тем же движком charstring, матрица акцента — текущая матрица глифа, скомпонованная с переносом в (adx, ady), и после выполнения матрица восстанавливается, так что дальнейшие глифы строки не затронуты. Если какое-то из имён не разрешается в GID, эта компонента пропускается вместо провала всего глифа — то снисходительное поведение, которое нужно, когда сабсет обрезали чуть слишком агрессивно
Проверка фикса из собственного кода Delphi
Быстрейший способ проверить обработку составных глифов и seac — отрисовать страницу с акцентированным текстом из встроенных шрифтов через оба публичных выходных пути: THotPDF.RenderLoadedPageToBitmap для растрового вывода и THotPDF.BuildLoadedPageSVG с EmbedFonts в True для векторного. Оба делят один код контуров глифов, поэтому оба получили фиксы v2.768.10 и v2.768.11. Конвейер в целом разобран в том, как HotPDF рендерит встроенные глифы TrueType, CFF и Type 3, а опции битмапов — в рендеринге страниц PDF в битмапы в Delphi
uses
System.Classes, Vcl.Graphics, HPDFDoc;
procedure RenderAccentProbe(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
Svg: AnsiString;
Lines: TStringList;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile(FileName) <= 0 then
Exit;
// Растровый путь: 144 DPI делает съехавшие акценты легко заметными
Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
if Bmp <> nil then
try
Bmp.SaveToFile('accent-probe.bmp');
finally
Bmp.Free;
end;
// Векторный путь: EmbedFonts = True пишет контуры глифов, так что
// сборка composite и seac видна прямо в путях SVG
Svg := Pdf.BuildLoadedPageSVG(0, True);
if Svg <> '' then
begin
Lines := TStringList.Create;
try
Lines.Text := string(Svg);
Lines.SaveToFile('accent-probe.svg');
finally
Lines.Free;
end;
end;
finally
Pdf.Free;
end;
end;
Тестовые документы выбирайте прицельно. Нужен один PDF со встроенным сабсетом TrueType, содержащим é, Å и типографские двойные кавычки, и один со встроенным сабсетом CFF (FontFile3, Type1C), произведённым из шрифта Type 1 — именно там до сих пор живёт seac, типично в старом наборном выводе и некоторых печатных workflow. Текст, набранный шрифтами, которые продюсер не встроил, здесь ничего не скажет: эти глифы приходят из установленных шрифтов Windows, а не из встроенной шрифтовой программы, описанной в ISO 32000-1 §9.9
Почему составные глифы ломались при сабсеттинге шрифтов?
Составные глифы ломались при сабсеттинге, потому что сабсеттер шагал по записям компонент с неверным шагом всякий раз, когда компонента несла масштаб или преобразование 2x2, а затем неверно читал индекс глифа следующей компоненты. HotPDF починил это в v2.770.83. Сабсеттинг — write-сторона, двойник рендерерного бага: встраивая сабсет установленного TrueType-шрифта, HotPDF обязан сохранить каждый глиф, на который ссылается составной глиф, и перенумеровать эти ссылки в новые, компактные ID глифов
Старый обходчик смотрел только на флаг размера аргументов и флаг «есть ещё компоненты», поэтому продвигался на 6 или 8 байт за запись. Он игнорировал 2, 4 или 8 байт данных преобразования, которые добавляют флаги масштаба. После масштабированной компоненты следующий «индекс глифа» был на самом деле байтами преобразования, так что настоящая компонента могла выпасть из сабсета, а шаг перенумерации мог записать новый ID глифа прямо в преобразование. Результат — акцентированная буква, которая в некоторых вьюерах рендерилась с неверным акцентом, искажённым акцентом или без него, и только для шрифтов, масштабирующих компоненты, — потому большинство тестов и не ловили
Рядом с багом шага сидели ещё две проблемы. Перенумерация индекса глифа не идемпотентна, а вложенные составные глифы перенумеровывались дважды: один раз проходом по всем помеченным составным глифам, второй — рекурсивным спуском в них. Теперь HotPDF перенумеровывает каждый составной глиф ровно один раз и ведёт шаг «какие глифы нужны этому составному глифу» от рабочей очереди, поэтому цикл составных глифов, ссылающихся друг на друга, завершается, индексы глифов за пределами числа глифов в maxp шрифта отвергаются, а пустые глифы никогда не разбираются как составные
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'accents-subset.pdf';
Pdf.FontEmbedding := True; // встроить используемые глифы
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 14);
// é, Å, ü и типографские кавычки — всё составные глифы в Arial
Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
'Café, Ångström, München, “quoted”');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Прогоните этот файл через пробу выше и через второй вьюер. С v2.770.83 каждая компонента каждого составного глифа переживает сабсеттинг, включая компоненты за масштабом или преобразованием 2x2, так что акценты рендерятся одинаково в HotPDF, Acrobat и браузерных вьюерах
Чего обработка составных глифов не покрывает
Фикс составных глифов покрывает размещение по смещениям — то, что используют почти все настоящие шрифты, — а остальное аппроксимирует консервативно. Составные глифы TrueType с привязкой к точкам, где два аргумента называют точки для совмещения вместо смещения, рисуются с нулевым смещением. Seac в CID-keyed шрифтах CFF игнорируется намеренно, потому что спецификация запрещает его там. Seac, чей код базы или акцента не имеет глифа в обрезанном сабсете, рисует ту компоненту, какую найдёт. Ни один из этих случаев не роняет рендерер, но если вы выпускаете PDF для архивных или PDF/A целей, безопаснее встраивать шрифты со смещенческими составными глифами и charstring Type 2 без seac, чтобы одинаковые буквы видели все потребители, а не один рендерер
FAQ
Почему буквы с акцентами рендерятся без акцентов в моём PDF-рендерере?
Буквы с акцентами — обычно составные глифы или seac-глифы CFF, и акцент — единственная компонента со смещением. Рендерер, применяющий смещение компоненты после матрицы устройства или игнорирующий seac в endchar, теряет или сдвигает акцент, тогда как базовая буква рендерится нормально
Поддерживает ли HotPDF форму seac у CFF endchar?
Да. С v2.768.11 HotPDF рисует endchar с четырьмя аргументами — или пятью, считая ширину, — как базовый глиф плюс акцент, сдвинутый в (adx, ady), разрешая оба через имена StandardEncoding и charset шрифта, и для растровой отрисовки, и для экспорта SVG со встроенными шрифтами
Сохраняются ли составные глифы при сабсеттинге TrueType в HotPDF?
Да. С v2.770.83 HotPDF корректно переступает через преобразования компонент размером 2, 4 или 8 байт, сохраняет каждую упомянутую компоненту в сабсете, перенумеровывает вложенные составные глифы один раз и безопасно останавливается на циклических или вне диапазона ссылках на глифы
Рендеринг составных глифов, поддержка CFF seac и безопасный TrueType-сабсеттинг поставляются в составе HotPDF Delphi PDF Component — нативной VCL-библиотеки для Delphi и C++Builder, которая создаёт, редактирует, извлекает и рендерит PDF-документы без внешних DLL