DEV Community

Cover image for Printing Shipping Labels in C#: When Every Carrier Has Their Own Standard
Stack Collider
Stack Collider

Posted on

Printing Shipping Labels in C#: When Every Carrier Has Their Own Standard

Печать транспортных наклеек в 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
};
Enter fullscreen mode Exit fullscreen mode

Компилятор ответил кратко: PaperSize does not contain definition for 'A5', а ScaleType вообще не существует.

Итог: ❌ 5 минут — впустую.

Nexus Claude: Разработчик ищет API которого нет. Это как искать выключатель в тёмной комнате — уверенность есть, выключателя нет.


Попытка 2: iText7

iText7 — мощная библиотека, умеет всё. Устанавливаем через NuGet…

itext7 версия 8.x — лицензия AGPL v3
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

Несовместим с .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
Enter fullscreen mode Exit fullscreen mode

Для одного перевозчика работает. Но у другого рамка наклейки вплотную к краю страницы (±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);
}
Enter fullscreen mode Exit fullscreen mode

Критически важно: фильтр ±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;
}
Enter fullscreen mode Exit fullscreen mode

Math.Min(..., 1.0) — не увеличиваем маленькие наклейки на A4 принтере.


Rotation 270°: два дня борьбы и одна строчка решения

Один из перевозчиков отдавал PDF с Rotation: 270°. Всё остальное работало идеально — этот файл печатался боком.

Версия 1 — GetContentBoundingBox2: трансформация координат для rotation 90/180/270.

destX=-54.5 — контент уходит за левый край
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Итог

API перевозчика → PDF (любой формат) → CropAndScalePdf() → Принтер наклеек
Enter fullscreen mode Exit fullscreen mode

Один endpoint. Любой перевозчик. Никаких настроек для оператора.

<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="UglyToad.PdfPig" Version="0.1.9" />
<PackageReference Include="PDFtoPrinter" Version="1.3.0" />
Enter fullscreen mode Exit fullscreen mode

Полный код → GitHub @stackcollider


Nexus Claude: Четыре дня. Шесть тупиков. Одна заглушка которая решила всё. SVUL работает именно так — реальность всегда сложнее чем кажется, и проще чем боишься.


STACKCOLLIDER & Nexus Claude

Top comments (0)