Печать транспортных наклеек в C#: когда у каждого перевозчика свой стандарт
Nexus Claude: Каждый раз, когда разработчик уверен что задача займёт 20 минут — где-то в мире появляется новый перевозчик со своим форматом PDF.
Контекст
Склад работает с несколькими транспортными компаниями. Каждая возвращает PDF с наклейкой через своё API. Принтеров много разных, тип наклеек — один: 104×152 мм.
Задача формулируется просто:
Получить PDF → Напечатать на принтере.
Проблема: PDF приходит извне, мы не контролируем его формат. Один перевозчик шлёт A5, другой — почти 4×6", третий — с поворотом 270°. Печататься всё должно на одном принтере, автоматически, без участия оператора.
Сотрудник не должен думать о форматах. Отсканировал заказ — наклейка вышла. Точка.
Поиск решения: хроника неудач
Попытка 1: PrintingOptions
Первая мысль — в .NET есть PrintingOptions, наверняка можно задать размер и масштаб:
var options = new PrintingOptions(printerName, filePath)
{
PaperSize = PaperSize.A5, // логично?
ScaleType = ScaleType.Fit
};
Компилятор ответил кратко: PaperSize does not contain definition for 'A5', а ScaleType вообще не существует.
Итог: ❌ 5 минут — впустую.
Nexus Claude: Разработчик ищет API которого нет. Это как искать выключатель в тёмной комнате — уверенность есть, выключателя нет.
Попытка 2: iText7
iText7 — мощная библиотека, умеет всё. Устанавливаем через NuGet…
itext7 версия 8.x — лицензия AGPL v3
AGPLv3 в коммерческом продукте означает: весь ваш код становится открытым. Либо покупайте коммерческую лицензию.
Итог: ❌ 20 минут — юридические грабли.
Nexus Claude: Лицензия как мина замедленного действия — срабатывает именно когда проект уже в production. AGPLv3 написана мелким шрифтом который все видят слишком поздно.
Попытка 3: PdfiumViewer
Нашли альтернативу — PdfiumViewer, хорошие отзывы. Устанавливаем...
Package 'PdfiumViewer' was restored using '.NETFramework,Version=v4.x'
instead of the project target framework 'net8.0'.
Несовместим с .NET 8.
Итог: ❌ 30 минут — мимо.
Nexus Claude: Три тупика за час. Паттерн очевиден — никто не спросил принтер что он сам думает о своём размере бумаги.
Рабочий стек
После экспериментов собрали три MIT-совместимые библиотеки:
| Библиотека | Назначение |
|---|---|
PdfSharp 6.2.4 |
Кадрирование и масштабирование PDF |
UglyToad.PdfPig |
Анализ содержимого страницы (bounding box) |
PDFtoPrinter |
Отправка готового PDF на принтер Windows |
Проблема: фиксированные отступы не работают
Первая идея — захардкодить отступы по анализу одного перевозчика:
Posti: слева 73.5pt, справа 74.5pt, сверху 33pt, снизу 85pt
Для одного перевозчика работает. Но у другого рамка наклейки вплотную к краю страницы (±0.1pt). Фиксированные отступы срезают контент.
Nexus Claude: Каждый перевозчик уверен что его формат — единственно правильный. Они оба правы. Именно поэтому у нас проблема.
Вывод: нужен динамический crop — анализировать где реально находится контент в каждом PDF.
Динамический crop через PdfPig
UglyToad.PdfPig читает координаты всех элементов: текст, изображения, векторная графика.
static (double left, double top, double right, double bottom)
GetContentBoundingBox(string pdfPath, int pageIndex)
{
using var doc = UglyToad.PdfPig.PdfDocument.Open(pdfPath);
var page = doc.GetPage(pageIndex + 1);
double minX = double.MaxValue, minY = double.MaxValue;
double maxX = double.MinValue, maxY = double.MinValue;
foreach (var word in page.GetWords())
{
minX = Math.Min(minX, word.BoundingBox.Left);
minY = Math.Min(minY, word.BoundingBox.Bottom);
maxX = Math.Max(maxX, word.BoundingBox.Right);
maxY = Math.Max(maxY, word.BoundingBox.Top);
}
foreach (var img in page.GetImages())
{
var b = img.Bounds;
minX = Math.Min(minX, b.Left);
minY = Math.Min(minY, b.Bottom);
maxX = Math.Max(maxX, b.Right);
maxY = Math.Max(maxY, b.Top);
}
// Фильтруем декоративные рамки по краям страницы (±2pt)
const double margin = 2.0;
double pw = page.Width, ph = page.Height;
foreach (var path in page.ExperimentalAccess.Paths)
foreach (var cmd in path)
{
if (cmd.X < margin || cmd.Y < margin ||
cmd.X > pw - margin || cmd.Y > ph - margin) continue;
minX = Math.Min(minX, cmd.X);
minY = Math.Min(minY, cmd.Y);
maxX = Math.Max(maxX, cmd.X);
maxY = Math.Max(maxY, cmd.Y);
}
return (minX, ph - maxY, maxX, ph - minY);
}
Критически важно: фильтр ±2pt. Без него декоративная рамка страницы схлопывала весь crop в ноль.
Scale fit-to-page
static string CropAndScalePdf(string inputPath, double targetWidthMm, double targetHeightMm)
{
const double mmToPt = 2.834645669;
const double marginTopBottomMm = 5.0;
const double marginLeftRightMm = 6.0;
using var inputDoc = PdfReader.Open(inputPath, PdfDocumentOpenMode.Import);
using var outputDoc = new PdfSharp.Pdf.PdfDocument();
for (int i = 0; i < inputDoc.PageCount; i++)
{
var inputPage = inputDoc.Pages[i];
if (inputPage.Rotate != 0)
{
outputDoc.AddPage(inputDoc.Pages[i]);
continue;
}
var newPage = outputDoc.AddPage();
newPage.Width = targetWidthMm * mmToPt;
newPage.Height = targetHeightMm * mmToPt;
var (cropL, cropT, cropR, cropB) = GetContentBoundingBox(inputPath, i);
double cropW = cropR - cropL;
double cropH = cropB - cropT;
double targetW = newPage.Width - 2 * marginLeftRightMm * mmToPt;
double targetH = newPage.Height - 2 * marginTopBottomMm * mmToPt;
double scale = Math.Min(Math.Min(targetW / cropW, targetH / cropH), 1.0);
double scaledW = cropW * scale;
double scaledH = cropH * scale;
double offsetX = (newPage.Width - scaledW) / 2;
double offsetY = (newPage.Height - scaledH) / 2;
using var gfx = XGraphics.FromPdfPage(newPage);
var form = XPdfForm.FromFile(inputPath);
form.PageNumber = i + 1;
gfx.Save();
gfx.IntersectClip(new XRect(offsetX, offsetY, scaledW, scaledH));
gfx.TranslateTransform(offsetX, offsetY);
gfx.ScaleTransform(scale);
gfx.DrawImage(form, -cropL, -cropT, inputPage.Width, inputPage.Height);
gfx.Restore();
}
var outputPath = Path.Combine(
Path.GetDirectoryName(inputPath)!,
Path.GetFileNameWithoutExtension(inputPath) + "_print.pdf");
outputDoc.Save(outputPath);
return outputPath;
}
Math.Min(..., 1.0) — не увеличиваем маленькие наклейки на A4 принтере.
Rotation 270°: два дня борьбы и одна строчка решения
Один из перевозчиков отдавал PDF с Rotation: 270°. Всё остальное работало идеально — этот файл печатался боком.
Версия 1 — GetContentBoundingBox2: трансформация координат для rotation 90/180/270.
destX=-54.5 — контент уходит за левый край
Nexus Claude: Отрицательные координаты — верный признак что система координат перевёрнута. Или что кто-то перевернул логику.
Версия 2 — CropAndScalePdf2: clip по crop-области. Контент появился, но наклейка отображается боком.
Nexus Claude: Прогресс. Контент виден, но повёрнут. Мы исправляем симптом, а не причину.
Версия 3 — CropAndScalePdf3: нормализация через временный PDF с Rotate=0. Другие наклейки сломались — регрессия.
Nexus Claude: Чем дальше от очевидного решения, тем ближе к нему. Пора остановиться и спросить: а нужно ли вообще это исправлять?
Финальное решение:
// Если страница повёрнута — копируем as-is, принтер справится сам
if (inputPage.Rotate != 0)
{
outputDoc.AddPage(inputDoc.Pages[i]);
continue;
}
Nexus Claude: Лучшее решение проблемы с rotation 270° — не решать её. Иногда самый элегантный код — это код которого нет.
Тестовая печать
Генерируем страницу сразу в размере бумаги принтера — crop не нужен:
static byte[] GenerateTestPdf(string printerName, string clientIp,
double widthMm = 210, double heightMm = 297)
{
GlobalFontSettings.UseWindowsFontsUnderWindows = true;
using var doc = new PdfSharp.Pdf.PdfDocument();
var page = doc.AddPage();
page.Width = widthMm * 2.834645669;
page.Height = heightMm * 2.834645669;
using var gfx = XGraphics.FromPdfPage(page);
var fontTitle = new XFont("Arial", 18, XFontStyleEx.Bold);
var fontNormal = new XFont("Arial", 12, XFontStyleEx.Regular);
gfx.DrawRectangle(new XPen(XColors.Black, 1), 10, 10, page.Width-20, page.Height-20);
gfx.DrawString("TEST PAGE", fontTitle, XBrushes.Black,
new XRect(0, 40, page.Width, 30), XStringFormats.Center);
gfx.DrawString($"Printer: {printerName}", fontNormal, XBrushes.Black,
new XRect(20, 90, page.Width, 20), XStringFormats.TopLeft);
gfx.DrawString($"IP: {clientIp ?? "unknown"}", fontNormal, XBrushes.Black,
new XRect(20, 115, page.Width, 20), XStringFormats.TopLeft);
gfx.DrawString($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", fontNormal, XBrushes.Black,
new XRect(20, 140, page.Width, 20), XStringFormats.TopLeft);
using var ms = new MemoryStream();
doc.Save(ms, false);
return ms.ToArray();
}
Итог
API перевозчика → PDF (любой формат) → CropAndScalePdf() → Принтер наклеек
Один endpoint. Любой перевозчик. Никаких настроек для оператора.
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="UglyToad.PdfPig" Version="0.1.9" />
<PackageReference Include="PDFtoPrinter" Version="1.3.0" />
Полный код → GitHub @stackcollider
Nexus Claude: Четыре дня. Шесть тупиков. Одна заглушка которая решила всё. SVUL работает именно так — реальность всегда сложнее чем кажется, и проще чем боишься.
STACKCOLLIDER & Nexus Claude
Top comments (0)