То, что съедало мои пятницы
Каждый пятничный полдень, около года, у меня был один и тот же маленький ритуал. Контракт приходил в виде трёх файлов — основной договор в Word, приложение с ценами в Excel и лист условий партнёра в PDF — и мне нужно было собрать их в один чистый PDF. Ничего сложного. Открыть Word, экспортировать в PDF. Открыть Excel, экспортировать в PDF. Открыть какое‑то бесплатное приложение‑слияние PDF, перетащить три файла, проверить порядок, сохранить.
Это занимало примерно восемь минут. Умножьте на пятнадцать контрактов в неделю — получаем два часа, потраченных на движение мышью. Хуже того, каждые несколько недель кто‑то отправлял бандер, где приложение оказалось на первой странице, потому что имена файлов сортировались по алфавиту в приложении‑слиянии.
Если это звучит знакомо, остальная часть поста — это тот полдень, когда я наконец заменил ритуал кодом.
Реальная стоимость — не время, а один контракт из пятидесяти, где страницы идут в неправильном порядке, и никто не замечает, пока клиент не подпишет неверную версию.
Что я действительно хотел
Не «изысканный конвейер документов». Просто три вещи:
- Передать методу список файлов (любое сочетание DOCX, XLSX, PDF) и получить один PDF.
- Направить ту же логику на папку, чтобы она сама определила список файлов.
- Вытащить диапазон страниц из готового бандера без повторного выполнения всего слияния.
Это и есть вся задача. Если библиотека не умеет делать эти три вещи чисто, я не хочу об этом знать.
Настройка
- .NET 6.0 или новее
- GroupDocs.Merger for .NET 24.10+ (получите временную лицензию, чтобы не поставлять водяной знак оценки)
- Папка с любым набором документов, которые вы обычно собираете вручную
dotnet add package GroupDocs.Merger
И всё, что касается зависимостей. Никакого внешнего конвертера, безголового Office, никаких библиотек для работы с PDF сверху.
Шаг 1 — Пусть папка будет входом
Я всегда начинаю здесь, потому что это реалистичная точка входа. На практике что‑то другое (обработчик загрузки, задача по приёму электронной почты, ночной дамп из финансов) кладёт кучу файлов в каталог, а мой код должен разбираться с тем, что найдено.
// Pick up every supported file in the drop folder; the PDF wins
// the tie-break for position 0 so the merger keeps the output
// as a PDF regardless of how files are named.
string[] extensions = { ".pdf", ".docx", ".xlsx" };
var files = Directory.EnumerateFiles(folderPath)
.Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.OrderBy(f => Path.GetExtension(f).ToLowerInvariant() == ".pdf" ? 0 : 1)
.ThenBy(f => f)
.ToArray();
if (files.Length == 0)
throw new InvalidOperationException(
$"No supported documents found in '{folderPath}'.");
Трюк с OrderBy — интересная часть. GroupDocs.Merger выбирает формат вывода из того файла, который открыт первым — если я передаю ему DOCX в качестве основного документа, я получаю DOCX на выходе. Поскольку мой конвейер всегда хочет PDF, я заставляю любой существующий PDF в папке занимать позицию 0.
Две детали, которые стоит упомянуть:
ToLowerInvariant()потому, что партнёр когда‑нибудь пришлёт вамREPORT.PDF, а ваш фильтр, работающий только с нижним регистром, молча его отбросит.ThenBy(f)нужен лишь для того, чтобы вывод был детерминированным. Без него два запуска в одной и той же папке могут различаться в зависимости от «настроения» файловой системы.
Шаг 2 — Само слияние
Как только у меня есть упорядоченный список путей, процесс слияния короче, чем его описание.
Console.WriteLine($"Primary source: {sourcePaths[0]}");
using var merger = new Merger(sourcePaths[0]);
var joinOptions = new JoinOptions();
for (int i = 1; i < sourcePaths.Length; i++)
{
Console.WriteLine($"Joining: {sourcePaths[i]}");
merger.Join(sourcePaths[i], joinOptions);
}
merger.Save(outputPath);
Console.WriteLine($"Unified PDF binder saved to: {Path.GetFullPath(outputPath)}");
Несколько замечаний из практики:
usingимеет значение.Mergerдержит файловые дескрипторы открытыми на исходных файлах; если забыть его освободить, ваш воркер‑сканер папки в конце концов не сможет удалить свои собственные входные файлы.JoinOptionsздесь пустой, потому что значения по умолчанию подходят в 95 % случаев. Когда нужны другие параметры, именно туда попадают диапазоны страниц, вращение и позиции вставки.- Когда Excel попадает в бандер, разметка лист‑страница определяется областью печати исходной книги. Если ваш XLSX оказывается на 38 страницах, а нужно три, исправляйте это в таблице, а не в
JoinOptions.
Один проверочный кусок кода, который я всегда добавляю сразу после сохранения:
using var verify = new Merger(outputPath);
Console.WriteLine($"Result pages: {verify.GetDocumentInfo().PageCount}");
Две секунды кода, поймавшие больше «тихо отброшенных приложений», чем любой мой тест.
Шаг 3 — Извлечение части позже
Запрос, который я получаю каждый раз: «Можно ли просто выслать обложку?» или «Клиенту нужны только подписи». Пересобирать весь бандер, чтобы отдать две страницы, глупо — извлечение делает это напрямую.
using var merger = new Merger(binderPath);
merger.ExtractPages(new ExtractOptions(pages));
merger.Save(outputPath);
Console.WriteLine($"Extracted pages [{string.Join(",", pages)}] to " +
Path.GetFullPath(outputPath));
pages — это int[] номеров страниц (нумерация с 1), которые вы хотите оставить. Всё остальное отбрасывается. Это быстро, потому что результат уже PDF — без обратных конвертаций.
До и после, честно
| Как я делал раньше | С Merger.Join |
|
|---|---|---|
| Время на один контракт | 5–10 минут кликов | менее 30 секунд от начала до конца |
| Типичная ошибка | Страницы в неправильном порядке, никто не замечает | любой порядок, указанный в списке файлов, воспроизводим |
| Масштаб до 100/день | Не работает — нанимаете человека | Один воркер, в большинстве времени без дела |
| Поддерживаемый код | Страница Confluence «Binder Process v4» | Один класс, ~70 строк |
| Вывод | Три PDF и надежда | Один бандер, с подсчётом страниц, который можно логировать |
Строка, которая меня больше всего волнует, — это «ошибка». Ручное слияние падает молча; код, который логирует количество страниц, падает громко.
Реальная история из крошечной legal‑tech команды
Двухчленная стартап‑компания, в которой я работал, имела паралегала, чьё утро начиналось сборкой контракта. Соглашение в Word, цены в Excel, приложение в PDF, склеивались в приложении, загружались в DocuSign. Около восьми минут на пакет, а при 30 пакетах в день это занимало почти всё её утро.
Они внедрили метод сканирования папки в бекенд‑службу, которая уже следила за входящей почтой. Двадцать секунд на пакет, плюс строка лога с количеством страниц. Паралегал перешёл к проверке контрактов, а не к их сборке. Никто больше не отправлял бандер в неправильном порядке — не потому что библиотека волшебна, а потому что список файлов явно прописан в коде, и его можно сравнивать.
string folder = @"C:\IncomingContracts";
string output = @"C:\Processed\ContractPackage.pdf";
var files = CreatePdfBinderFromFolder(folder, output);
Console.WriteLine($"Package created: {files}");
Это вся интеграция. Всё, что было выше (слушатель почты, путь хранения), уже было готово.
То, что мне не понадобилось сегодня, но понадобится завтра
Та же библиотека умеет кучу вещей, которые я не освещал, потому что статья стала бы слишком длинной. Примерный порядок, в котором я к ним обращался:
- Watermarks on the output для штампов «DRAFT» на предварительных копиях.
- Page rotation для сканов, пришедших боком.
- Custom page ordering когда порядок источников отличается от желаемого порядка доставки.
- PDF encryption для отправки внешним контрагентам.
Всё это доступно через тот же API Merger. Полный список в docs — я просто хотел отметить, что «merge» — это дешёвый старт, а остальное доступно, когда понадобится.
Что я бы сказал своему прошлому «я»
Если вы собираетесь писать собственный шаг DOCX‑to‑PDF, потому что «это всего один метод», остановитесь. Конверсия — это та часть, которая тихо гниёт — новые возможности Office, обработка сканов, встроенные шрифты и т.д. Позвольте чему‑то другому управлять этой поверхностью, а пятничный полдень посвятите чему‑то, что не сводится к сортировке имён файлов.
Куда двигаться дальше:
- Temporary license — требуется для вывода без водяного знака
- Advanced merging options — JoinOptions, параметры сохранения, сжатие
- Supported formats — далеко за пределами трёх показанных здесь
- Sample projects on GitHub — включая этот