Руководство пользователя на 40 страниц, чья первая закладка верхнего уровня указывает на страницу 5: разделитель, режущий «от этой закладки до следующей», напишет файлы глав, дающие в сумме 36 страниц. Обложка, страница авторских прав и двухстраничное оглавление стоят перед всякой записью контура, поэтому не попадают ни в один файл. Контур лишь отмечает, где начинаются разделы. Он не покрывает то, что стоит перед первой записью, и ничто в формате не требует перечислять записи в порядке страниц, поэтому надёжный разделитель собирает страницы закладок как точки разреза, сортирует их и добавляет явный диапазон вводных материалов
Один цикл за каждым режимом разделения
PDFium Component разделяет документ одними и теми же четырьмя шагами на втором TPdf, чем бы ни задавались границы: CreateDocument начинает пустой PDF в памяти, ImportPages копирует диапазон страниц из источника в него, SaveAs пишет файл, а Active := False закрывает вывод снова. Постраничное разделение, куски фиксированного размера и секции по закладкам отличаются только строкой диапазона, переданной ImportPages
Сброс в конце каждого прохода не опционален. CreateDocument отказывается выполняться на всё ещё активном компоненте и поднимает EPdfError, чьё сообщение начинается с "Cannot perform this operation on an active", поэтому вторая итерация цикла, забывшего Active := False, останавливается исключением после ровно одного записанного файла. Сброс в блоке finally не даёт неудавшемуся импорту или сохранению оставить вывод открытым для следующего прохода. Один экземпляр TPdf обслуживает весь прогон, и лишь один выходной документ когда-либо держится в памяти
Открытие источника заслуживает собственной проверки. Присваивание Active := True никогда не бросает исключение: когда файл отсутствует, повреждён или требует не переданный пароль, TPdf перехватывает ошибку загрузки и оставляет Active равным False. ImportPages к тому же сам пытается активировать источник, поэтому неоткрытый источник проявляется лишь как возвращённое False на первой странице и не даёт никакого намёка на причину. Явное открытие с проверкой Active превращает это в ошибку, называющую файл, ещё до всякого вывода:
procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
Source, PdfOut: TPdf;
I: Integer;
OutFile: string;
begin
Source := TPdf.Create(nil);
PdfOut := TPdf.Create(nil);
try
Source.FileName := SourceFile;
Source.Active := True; // неудача загрузки оставляет Active = False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // бросает, пока PdfOut ещё активен
try
// Диапазон нумеруется с единицы; позиция 1 — начало пустого документа
if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
raise Exception.CreateFmt('Cannot import page %d', [I]);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('page_%.4d.pdf', [I]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False; // закройте вывод перед следующим проходом
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
Третий аргумент ImportPages — позиция в приёмнике с нумерацией от единицы, куда попадает первая импортированная страница. В пустом документе 1 — единственная позиция, какая есть. Сам синтаксис диапазонов (отдельные страницы, закрытые промежутки вроде '5-10', списки через запятую) и ошибки позиции, разворачивающие объединённый пакет, разобраны в статье об объединении нескольких PDF-файлов в один документ — это тот же API, направленный в другую сторону
Куски фиксированного размера и диапазоны от вызывающего
Резать документ каждые N страниц — задача на счёт, и единственный случай, требующий осторожности, — хвост. Скан на 230 страниц, разрезанный на части по 25, даёт девять полных частей и десятую из пяти страниц, поэтому конец каждого промежутка прижимается к Source.PageCount:
procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
PdfOut: TPdf;
FirstPage, LastPage, Part: Integer;
Range, OutFile: string;
begin
if ChunkSize < 1 then
raise Exception.Create('ChunkSize must be at least 1');
PdfOut := TPdf.Create(nil);
try
Part := 0;
FirstPage := 1;
while FirstPage <= Source.PageCount do
begin
LastPage := FirstPage + ChunkSize - 1;
if LastPage > Source.PageCount then
LastPage := Source.PageCount; // последняя часть обычно короче
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // одностраничный хвост простым числом
else
Range := Format('%d-%d', [FirstPage, LastPage]);
Inc(Part);
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source, Range, 1) then
raise Exception.Create('Cannot import pages ' + Range);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('part_%.3d.pdf', [Part]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
FirstPage := LastPage + 1;
end;
finally
PdfOut.Free;
end;
end;
Диапазоны, набранные пользователем, — другое дело. ImportPages сообщает о плохом диапазоне возвращаемым значением: номер страницы за Source.PageCount, ноль, развёрнутый промежуток или текст, который не разбирается, заставляют вызов вернуть False, и ни одна страница из этой строки не копируется. Внутри цикла эта неудача приходит на четвёртой записи, когда три файла секций уже лежат на диске. Сверка каждой записи с Source.PageCount до первого CreateDocument стоит несколько строк разбора и означает, что опечатка в четвёртой записи не запишет ничего вовсе — именно такое поведение ожидает человек, повторяющий задачу
Когда выделение уже живёт в целочисленном массиве — скажем, страницы, отмеченные в виде миниатюр, — ImportPagesByIndex(Source, PageIndices, InsertAt) пропускает строковый шаг. И индексы, и InsertAt там нумеруются от нуля, поэтому то же выделение, читающееся как '1,3-5', становится [0, 2, 3, 4]
Почему секции по закладкам выходят короткими или пропадают?
Секции по закладкам идут наперекосяк, когда код доверяет контуру быть аккуратным. TPdf.Bookmarks возвращает записи верхнего уровня в порядке, который устроил автора, каждую как запись TBookmark с Title и PageNumber с нумерацией от единицы. Четыре вещи в реальных файлах ломают наивное правило «от этой записи до следующей»:
- Элемент контура может нести цель как запись
/Destили как GoTo-действие в/A(ISO 32000-1 §12.3.3 и §12.6.4.2).PageNumberзаполняется из/Dest; у элемента с действием он остаётся0, а страница лежит вActionPageNumber - Порядок контура — порядок чтения, каким его видел автор. Запись «Index», поставленная второй, или приложение, перечисленное перед главами, делает «следующая закладка минус один» меньше текущего начала, и секция исчезает
- Две записи верхнего уровня могут указывать на одну страницу — как правило, заголовок части и её первая глава
- Запись может указывать за последнюю страницу, когда страницы удаляли инструментом, не тронувшим контур
Версия ниже трактует контур как источник точек разреза. Она разрешает страницу каждой записи, выбрасывает записи, указывающие в никуда или за конец, хранит первый заголовок на общей странице, сортирует по номеру страницы и добавляет секцию «Front matter», когда первый разрез лежит после страницы 1. Вводные материалы получают здесь собственный файл вместо приклейки к главе 1, потому что получатель главы 1 ждёт, что она откроется на странице, которую назвала закладка, а не на оглавлении
procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
Bm: TBookmarks;
Starts: array of Integer;
Titles: array of string;
I, J, K, N, P, EndPage: Integer;
Known: Boolean;
PdfOut: TPdf;
OutFile: string;
begin
Bm := Source.Bookmarks; // только записи верхнего уровня
if Length(Bm) = 0 then
Exit; // резать не по чему
SetLength(Starts, Length(Bm) + 1); // +1 оставляет место для вводных материалов
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // разрешается из /Dest
if P < 1 then
P := Bm[I].ActionPageNumber; // разрешается из GoTo-действия в /A
if (P < 1) or (P > Source.PageCount) then
Continue; // не разрешено или за последней страницей
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // первый заголовок на странице побеждает
if Known then
Continue;
J := N; // сортировка вставкой по номеру страницы
while (J > 0) and (Starts[J - 1] > P) do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
Dec(J);
end;
Starts[J] := P;
Titles[J] := Bm[I].Title;
Inc(N);
end;
if N = 0 then
Exit;
if Starts[0] > 1 then // обложка, оглавление и всё перед ними
begin
for J := N downto 1 do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
end;
Starts[0] := 1;
Titles[0] := 'Front matter';
Inc(N);
end;
PdfOut := TPdf.Create(nil);
try
for K := 0 to N - 1 do
begin
if K < N - 1 then
EndPage := Starts[K + 1] - 1
else
EndPage := Source.PageCount;
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source,
Format('%d-%d', [Starts[K], EndPage]), 1) then
raise Exception.CreateFmt('Cannot import pages %d-%d',
[Starts[K], EndPage]);
// Объявите SafeFileName (показана ниже) до этой подпрограммы
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
end;
finally
PdfOut.Free;
end;
end;
Поскольку после сортировки начала строго возрастают, каждый вычисленный промежуток валиден, и защита на ImportPages не должна срабатывать на читаемом источнике. Документ без закладок просто не производит файлов в этом режиме; решать, откатываться ли к нарезке кусками, вызывающему. Чтобы резать уровнем глубже — скажем, по главам внутри частей — BookmarkChildren[Bm[I]] возвращает потомков записи верхнего уровня в той же форме TBookmarks, и та же логика точек разреза применяется к объединённому списку
Что несёт каждый выходной файл
Каждый выходной файл несёт сами страницы и всё, на что ссылаются их словари страниц: потоки содержимого, шрифты, изображения и список аннотаций каждой страницы. Виджеты форм приходят как аннотации, но у нового файла нет AcroForm, так что они больше не работающие поля. Источник только читается. ImportPages копирует объекты в новый документ и не трогает открытый источник, поэтому один и тот же экземпляр Source кормит каждую итерацию
Контур остаётся позади. Закладки живут в каталоге документа под /Outlines (ISO 32000-1 §7.7.2), вне всякой страницы, поэтому файл, собранный из импортированных страниц, начинается вообще без закладок. Для файлов глав это часто нормально. Когда каждой части нужна собственная навигация, контур приходится строить под новую нумерацию страниц — тема статьи об редактировании контура PDF и перемаркировке его страниц. Внутренние ссылки ведут себя так же: ссылка «см. главу 7» на странице 12 указывает на страницу, ушедшую в другой файл, и в этом файле ей не на что приземлиться
Зашифрованным источникам нужен пароль до старта цикла. Присвойте Source.Password, пока компонент неактивен, затем ставьте Active := True; с неверным паролем компонент остаётся неактивным, что проверка в SplitIntoPages превращает в ошибку. Вывод, записанный SaveAs, — новый документ без словаря шифрования. Это устраивает разделённые файлы, предназначенные разным получателям, а когда каждую часть нужно защитить снова, SaveAsEncrypted(FileName, Options) берёт запись TPdfEncryptOptions с собственными паролями пользователя и владельца
Имена файлов, неудавшиеся сохранения и повторные прогоны
Заголовки закладок — текстовые строки PDF и могут содержать что угодно, включая символы, которые Windows отвергает в именах файлов. Правила идут от файловой системы, а не от PDF: < > : " / \ | ? * и управляющие символы неверны, замыкающие пробелы и точки срезаются файловыми API Win32, а имена вроде CON, PRN или COM1 — зарезервированные устройства. Нумерованный префикс в SplitByBookmarks уже заботится об именах устройств, держит файлы в порядке чтения в листинге каталога и не даёт двум главам с одинаковым названием «Summary» перезаписать друг друга. Остальное — маленькая функция, которую вы пишете сами:
// Хелпер на стороне читателя, не часть PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // держит полные пути далеко под MAX_PATH
for I := 1 to Length(Result) do
if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
Result[I] := '_';
while (Result <> '') and
((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
SetLength(Result, Length(Result) - 1);
if Result = '' then
Result := 'section';
end;
SaveAs с именем файла пишет во временный файл в целевом каталоге и подменяет его на место только после успеха сохранения. Цель поэтому — либо предыдущий файл, либо полный новый, никогда не записанный наполовину, так что повторный запуск разделения по существующему выходному каталогу безвреден. Неудачи возвращаются двумя путями. SaveAs возвращает False, когда документ нельзя сериализовать, и временный файл удаляется. Проблемы файловой системы — несуществующий выходной каталог, путь, отвергнутый ОС, или цель, заблокированная просмотрщиком, — поднимают исключение из файлового слоя
Как пакет реагирует на эти неудачи — выбор, и для разделения он отличается от объединения. Объединённый пакет с пропавшей частью — неверный документ, поэтому остановка там корректна. Выходы разделения независимы друг от друга: если section_147.pdf нельзя записать, потому что коллега держит его открытым, остальные секции всё ещё хороши. Для задач разделения перехватывайте исключения пофайлово внутри цикла, записывайте имя файла и сообщение, продолжайте, а в конце цикла сравнивайте число записанных файлов с числом запланированных диапазонов. Примеры выше бросают на первой неудаче лишь ради краткости. Журнал, говорящий «записано 199 из 200 файлов» и называющий заблокированный, чинится одним повторным запуском, тогда как прогон, оборвавшийся на файле 147, оставляет кого-то вычислять, каких 53 файлов не хватает
Цикл разделения, записи закладок и зашифрованное сохранение, использованные здесь, поставляются вместе с PDFium Component — библиотекой PDF на базе PDFium для Delphi и C++Builder