Печать транспортных наклеек в 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 (1)
Great insights on handling shipping label printing in C#. Integrating multiple carriers can be challenging since each follows different formatting and API standards. This topic highlights the importance of flexible code structure and proper label generation to ensure smooth logistics, accurate tracking, and reliable shipping workflows across various carrier systems.