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

На странице PDF нет ни абзацев, ни текстовых блоков. Есть список инструкций: в этой позиции, этим шрифтом и этого размера нарисовать такие-то глифы. Изменить текст означает удалить инструкции, рисующие эту строку, и записать новые в той же позиции, тем же шрифтом, того же размера и цвета. Поэтому Править PDF правит по одной строке и не умеет переносить текст: это прямое следствие того, как текст хранится.
Как выглядит одна строка текста в файле
Содержимое страницы лежит в потоке содержимого, небольшой программе из операторов. Для текста основную работу делают четыре оператора: BT и ET обрамляют текстовый блок, Tf выбирает шрифт и размер, Td переходит к следующей позиции, Tj рисует строку. Со стандартным шрифтом простейший случай выглядит так:
BT /F1 18 Tf 20 150 Td (Hello World) Tj ET
Это значит: выбрать шрифт F1 размером 18 пунктов, перейти в точку x 20, y 150 и нарисовать Hello World. PDF, напечатанный из Chrome, обычно выглядит иначе: в скобках уже не символы, а номера глифов шрифта, а позицию один раз задаёт Tm. Ниже строка Hello World from Georgia со страницы, экспортированной из Chrome; каждые четыре шестнадцатеричные цифры — один номер глифа:
BT /F4 24 Tf 1 0 0 -1 30 52 Tm <002b0048004f004f00520003003a00520055004f0047000300490055005200500003002a004800520055004a004c0044> Tj ET
002b — это H, 0048 — e, 004f — l, 0003 — пробел, 003a — W и так далее. Эти числа имеют смысл только внутри одного конкретного шрифта; это не Unicode. Когда вы копируете текст, просмотрщик превращает номера обратно в символы по таблице соответствия, хранящейся в другом месте файла.
Отсюда два вывода. Во-первых, в потоке содержимого нигде не сказано «это абзац»: перенос строки — просто ещё один Td или Tm, который перемещает позицию на следующую строку, а сколько строк в абзаце, приходится выводить из позиций. Во-вторых, чтобы записать в строку новый символ, нужен номер этого символа в данном шрифте, а в шрифте часто есть только те глифы, что использовались при записи файла. Это результат подрезания шрифтов.
Потоки можно развернуть с помощью qpdf:
qpdf --qdf --object-streams=disable in.pdf out.pdf
Откройте out.pdf в текстовом редакторе и найдите Tj, чтобы увидеть все строки, нарисованные на странице. О том, что ещё содержит PDF, читайте в статье Что внутри PDF: объекты, потоки и таблица перекрёстных ссылок.
Как редактор собирает «строку» из глифов
Сначала редактор должен сгруппировать разрозненные глифы в строку, по которой можно щёлкнуть. Фрагмент — это группа соседних глифов с одним шрифтом, размером и цветом, у которых зазор до предыдущего глифа не больше одного размера шрифта; перенос строки, новый шрифт или новый цвет фрагмент заканчивают. Текст, идущий справа налево или по вертикали, не группируется и не открывается для редактирования.
Два следствия можно увидеть напрямую. Строка с двумя шрифтами, например китайская строка с английским словом, делится на два соседних фрагмента, и щёлкать по ним нужно отдельно. После правки происходит то же самое: если новый текст набран другим шрифтом, строка становится двумя фрагментами, и правка одного из них не затрагивает другой.
Правка строки: удалить старый текст, затем записать новый
Удаление использует вариант закрашивания только для текста: глифы внутри области строки убираются из потока содержимого, изображения и линии остаются на месте, поверх старого текста не рисуется цветной блок, поэтому копирование и поиск его больше не находят. Меняется только содержимое страницы; если тот же текст есть ещё и в аннотации или в свойствах документа, они не затрагиваются.
Запись добавляет новую текстовую инструкцию в конец потока содержимого страницы, начиная с позиции базовой линии исходной строки и повторяя её размер и цвет. После замены строки выше на Hello World 2 в конец страницы добавляется следующее:
q 1 0 0 1 22.5 148.92 cm 0 0 0 rg
/Span <</ActualText <feff00480065006c006c006f00200057006f0072006c006400200032>>> BDC
BT /F4 18 Tf 0 0 Td <002b0048004f004f00520003003a00520055004f00470003> Tj ET
BT /F9 18 Tf 102.18 0 Td (2) Tj ET
EMC
Q
Первый BT использует исходный шрифт F4 и исходные номера глифов, поэтому Hello World повторяется в точности как был. Цифры «2» нет в исходном подмножестве шрифта, поэтому второй BT подключает новый стандартный шрифт Helvetica (F9), чтобы нарисовать её. q и Q ограничивают преобразование координат и цвет этого блока им самим; редактор также оборачивает исходное содержимое в пару q/Q, чтобы несбалансированное состояние, оставшееся в конце исходной страницы, не повлияло на новый текст. Так выглядел один фрагмент; числа от файла к файлу различаются.
Добавление в конец имеет три последствия:
- Новый текст рисуется последним, поверх всего остального на странице. Текст, который находился под изображением или цветным блоком, после правки оказывается над ним.
- Инструмент, извлекающий текст в порядке потока содержимого (например,
pdftotext -raw), читает отредактированную строку последней на этой странице, а извлечение по координатам (pdftotext -layout) изменений не показывает. Страница выглядит так же, но порядок чтения изменился. - Редактор прикрепляет к новому тексту ActualText, который сообщает просмотрщику, каким настоящим символам соответствуют глифы. Один глиф может соответствовать нескольким символам, и поиск только по номеру глифа иногда возвращает символ совместимости (например, для 文 находится радикал Канси ⽂); ActualText гарантирует, что скопировано будет то, что вы набрали.
Каким шрифтом рисуется новый текст
Безопаснее всего использовать исходный шрифт: форма глифов, насыщенность и ширина совпадают с соседним текстом. Можно ли его использовать, зависит от того, способен ли редактор определить, какой код имеет символ в этом шрифте:
- Составной шрифт, как выше (Type0, двухбайтные номера глифов, кодировка Identity-H): символ ищется в шрифте, чтобы получить номер глифа, и если он найден, используется. Если не найден, символа в подмножестве нет.
- Однобайтный простой шрифт (TrueType, Type1) с кодировкой WinAnsi или MacRoman: соответствие символа байту фиксировано, а ширины берутся из таблицы ширин в файле.
- Шрифт с нестандартной кодировкой (Differences) или опирающийся на встроенную кодировку шрифта: надёжного соответствия нет, поэтому редактор не гадает и переходит к запасному шрифту, иначе текст получился бы искажённым.
Если символа нет в исходном шрифте, используется запасной шрифт. Обычные латинские буквы рисуются ближайшим из Helvetica, Times и Courier, стандартных шрифтов, которые есть у любого просмотрщика, поэтому ничего встраивать не нужно. Всё остальное, например китайские иероглифы, кана, хангыль, тайское письмо, латинские буквы с диакритикой, кириллица и греческое письмо, использует открытый шрифт Noto: сервер берёт символы из запроса и вырезает из полного шрифта только эти глифы с помощью библиотеки HarfBuzz с открытым кодом для создания подмножеств, обычно несколько КБ, а браузер встраивает их в ваш файл. Региональная форма китайского иероглифа определяется языком интерфейса: упрощённая, традиционная, японская или корейская.
Глифы встраиваются, а не ограничиваются записью имени шрифта, потому что шрифт, заданный лишь именем, каждый просмотрщик заменяет тем запасным, какой выберет сам, и насыщенность у разных просмотрщиков может различаться; после встраивания все просмотрщики показывают одни и те же формы.
Почему только одна строка за раз
У редактора нет движка вёрстки. Позиция каждого глифа зафиксирована в файле; новый текст просто размещается вправо от начальной точки по ширинам шрифта, без логики переноса у края, и ничто после него не сдвигается. Если удлинить строку, лишние символы продолжатся вправо, а всё, что выйдет за край страницы, не будет видно. Переписать абзац или добавить строки в таблицу — значит заново сверстать текст, а для этого нужно вернуться к исходному файлу, из которого сделан PDF.
Ширины, кроме того, берутся только из самого шрифта (на сколько сдвигается позиция после каждого глифа), без кернинга и подстановки глифов. Текст, которому не нужен шейпинг, то есть подбор и позиционирование глифов по контексту (латиница, кириллица, греческий и китайские иероглифы), укладывается правильно. Тайским тоновым знакам для правильного положения нужно позиционирование глифов, которого редактор не делает, поэтому они могут сместиться. Арабскому нужны контекстные формы глифов и раскладка справа налево, ивриту нужна раскладка справа налево, а индийским письменностям нужно объединять согласные и знаки гласных в глифы; всё это требует полного шейпинга, которого редактор сейчас не делает, поэтому ввод такого текста отклоняется, а не превращается в неверные символы.
Ещё два ограничения идут от самого файла. Содержимое отсканированной страницы — одно изображение без текстовых операторов, поэтому удалять и менять нечего. После правки файла с цифровой подписью исходное содержимое, которое защищала подпись, оказывается изменённым, и подпись перестаёт быть действительной.
Проверьте сами
Описанные изменения можно увидеть в скачанном файле тремя командами:
pdftotext -raw edited.pdf - # извлечение в порядке потока содержимого: старого текста нет, отредактированная строка последняя
pdffonts edited.pdf # список шрифтов: исходные повторно использованные шрифты и новое встроенное подмножество шрифта
qpdf --qdf --object-streams=disable edited.pdf edited.qdf # разворачивает поток содержимого; добавленный блок BT ... ET находится в конце
Что почитать дальше
- Почему подрезание шрифтов ломает редактирование, но не чтение: почему в исходном шрифте может не хватать символа и что происходит, когда он отсутствует.
- Что внутри PDF: объекты, потоки и таблица перекрёстных ссылок: общая картина потоков содержимого, объектов и структуры файла.
- Редактирование текста в PDF онлайн: исправить файл без исходника: как этим пользоваться, для чего это подходит и когда стоит вернуться к исходному файлу.