Pull to refresh
267.95
TINKOFF
IT’s Tinkoff — просто о сложном

[PF] Печать PDF под .NET, векторный подход, практика

Reading time 13 min
Views 6.2K

Как и обещал, продолжаю тему(раз, два) управляемой печати PDF из под .NET в векторном формате. О теоретических аспектах работы с PCL я рассказал в предыдущей статье, настало время разобрать программу для вывода на принтер PDF файла в векторе. Наше приложение будет полезно, например, когда нужно распечатать пачку многостраничных бланков или анкет на бумаге разных цветов и разной плотности. Если мы научимся управлять лотками принтера, избавим себя от ручного прокладывания страниц ;) В шаблоне будет указан номер лотка, из которого принтер заберет бумагу для текущей страницы. Причем шаблон будет применяться к документу циклически: если в документе 32 страницы, а в шаблоне 4, то шаблон повторится 8 раз для Simplex режима и 4 раза для Duplex.

Напомню порядок действий:
  • конвертировать PDF в PCL;
  • модифицировать PCL по шаблону;
  • отправить поток байт PCL на принтер.

Из зависимостей у приложения будет только Ghostscript.NET, при помощи которого будем конвертировать PDF в PCL. Что такое Ghostscript.NET и как его использовать, можно прочитать в первой статье цикла.


Для работы с Ghostscript будем использовать обертку для .NET, которая так и называется — Ghostscript.NET. В .NET обертке реализован универсальный класс GhostscriptProcessor, позволяющий использовать Ghostscript с произвольными настройками. Создадим класс Pdf2Pcl с единственным методом ConvertPcl2Pdf, он на вход принимает путь к PDF файлу и возвращает поток PCL в виде массива байт.

public class Pdf2Pcl {
    public static byte[] ConvertPcl2Pdf(string pdfFileName) {
        byte[] rawDocumentData = null;
        var gsPipedOutput = new GhostscriptPipedOutput();
        var outputPipeHandle = "%handle%" + int.Parse(gsPipedOutput.ClientHandle).ToString("X2");

        using (var processor = new GhostscriptProcessor()) {
            var switches = new List<string>();
            switches.Add("-dQUIET");
            switches.Add("-dSAFER");
            switches.Add("-dBATCH");
            switches.Add("-dNOPAUSE");
            switches.Add("-dNOPROMPT");
            switches.Add("-sDEVICE=pxlmono");
            switches.Add("-dNumRenderingThreads=20");
            switches.Add("-o" + outputPipeHandle);
            switches.Add("-f");
            switches.Add(pdfFileName);

            try {
                processor.StartProcessing(switches.ToArray(), new GsIoHandler());
                rawDocumentData = gsPipedOutput.Data;
            }
            catch (Exception ex) {
                Console.WriteLine(ex.Message);
            }
            finally {
                gsPipedOutput.Dispose();
                gsPipedOutput = null;
            }
        }
        return rawDocumentData;
    }

    public class GsIoHandler : GhostscriptStdIO {
        public GsIoHandler() : base(true, true, true) { }

        public override void StdIn(out string input, int count) {
            input = string.Empty;
        }

        public override void StdOut(string output) {
            if (string.IsNullOrWhiteSpace(output)) return;

            output = output.Trim();
            Console.WriteLine("GS: {0}",output);
        }

        public override void StdError(string error) {
            if (string.IsNullOrWhiteSpace(error)) return;

            error = error.Trim();
            Console.WriteLine("GS: {0}", error);
        }
    }
}


Класс GsIoHandler нужен исключительно для вывода сообщений от GhostScript в консоль, он не является обязательным. Вместо объекта GsIoHandler вторым аргументом StartProcessing может быть null.


В потоке PCl, который нам вернул конвертер, нужно найти места где объявлены страницы документа. GhostScript генерирует такие объявления одинаковыми, поэтому найти нужные места можно по единственному шаблону.

Для описания шаблона определим константы:
private const byte SkipTheByte = 0xff;
private const byte UByte = 0xc0;
private const byte AttrUByte = 0xf8;
private const byte Orientation = 0x28;
private const byte MediaSize = 0x25;
private const byte MediaSource = 0x26;
private const byte SimplexPageMode = 0x34;
private const byte DuplexPageMode = 0x35;
private const byte DuplexHorizontalBinding = 0x00;
private const byte SimplexFrontSide = 0x00;
private const byte DuplexVerticalBinding = 0x01;
private const byte BeginPage = 0x43;


Некоторые байты паттерна могут принимать разные значения, для их исключения служит константа SkipTheByte. Шаблон страницы будет иметь вид:
var pagePattern = new byte[] { UByte, SkipTheByte, AttrUByte, Orientation, UByte, SkipTheByte, AttrUByte, MediaSize, UByte, SkipTheByte, AttrUByte, MediaSource, UByte, SkipTheByte, AttrUByte, SimplexPageMode, BeginPage };


В потоке байт это будет соответствовать следующему фрагменту:






Не самый эффективный, но довольно наглядный алгоритм поиска PatternMatching выглядит так:
static int[] PatternMatching(byte[] data, byte[] pattern) {
    var pageShiftLst = new List<int>();

    for (var i = 0; i < data.Count(); i++) {
        if (IsOnPattern(data, i, pattern)) {
            pageShiftLst.Add(i);

            Console.Write("{0:X8} | ", i);

            for (var j = 0; j < pattern.Count(); j++) {
                Console.Write("{0:X}  ", data[i + j]);
            }

            Console.WriteLine("");

            i += pattern.Count() - 1;
        }
    }

    return pageShiftLst.ToArray();
}

static bool IsOnPattern(byte[] data, int shift, byte[] pattern) {
    for (var i = 0; i < pattern.Count() ; i++) {
        if (!((shift + i) < data.Count())) return false;

        if (pattern[i] != SkipTheByte) {
            if (pattern[i] != data[shift + i]) {
                return false;
            }
        }
    }
    return true;
}


Функция PatternMatching вернет массив смещений для страниц. Теперь можно модифицировать страницы по шаблону, указав режим печати Duplex/Simplex, и лоток для текущей страницы. Эти изменения не приводят к изменению размера файла. Мы меняем значения байт но не их количество, поэтому можно не бояться что последующие смещения окажутся неактуальны.

Чтобы модифицировать аргументы страницы, понадобятся смещения изменяемых байт относительно смещения начала описания страницы. Для этого заведем соответствующие константы:

private const int MediaSourceValueShift = 9;
private const int DuplexBindingShift = 13;
private const int PageModeShift = 15;


Имея смещения страниц в потоке байт и смещения меняемых байт относительно смещения страниц, можно модифицировать поток данных PCL, например, применив следующую функцию:

static byte[] ApplyPattern(byte[] data, int[] pageIndexes, byte[] extraPattern, bool isDuplex) {
    for (int i = 0; i < pageIndexes.Length; i++) {
        var pageIndex = pageIndexes[i];

        data[pageIndex + PageModeShift] = isDuplex ? DuplexPageMode : SimplexPageMode;
        data[pageIndex + DuplexBindingShift] = isDuplex ? DuplexVerticalBinding : SimplexFrontSide;
        data[pageIndex + MediaSourceValueShift] = extraPattern[i];
    }

    return data;
}



.Net не имеет встроенных средств для отправки на принтер массива байт. Но на сайте support.microsoft.com есть пример как это сделать. Если быть точнее, там описано, как отправить строку или файл в качестве RawData. Пример подошел бы нам, если бы мы работали с PostScript, а для отправки потока данных PCL он мало подходит. Доработаем пример так, чтобы была возможность отправлять на принтер массив байт. Для это к классу RawPrinterHelper нужно добавить метод:

public static bool SendRawDataToPrinter(string szPrinterName, byte[] data, string docName) {
    bool bSuccess = false;
    IntPtr pUnmanagedBytes = new IntPtr(0);

    pUnmanagedBytes = Marshal.AllocCoTaskMem(data.Length);
    Marshal.Copy(data, 0, pUnmanagedBytes, data.Length);
    bSuccess = SendBytesToPrinter(szPrinterName, pUnmanagedBytes, data.Length, docName);
    Marshal.FreeCoTaskMem(pUnmanagedBytes);

    return bSuccess;
}


Имя принтера szPrinterName можно получить стандартными средствами через класс PrinterSettings.InstalledPrinters. Аргумент docName содержит имя, которое будет отображаться в очереди печати.

Мы разобрали основные моменты программы для печати PDF документов по шаблону, которая будет 100% работоспособна на современных принтерах от HP. Для принтеров от других производителей лучше заглянуть в документацию на предмет поддержки PCL. Но так как сейчас многие производители встраивают процессор PCL, проблемы вряд ли возникнут. На если вдруг PCL печатать не заработает, то на этот случай у нас есть подход описанный в первой статье цикла.

Подробно описав ключевые моменты, при этом намеренно опустили некоторые детали, так как они не имеют прямого отношения к теме. Однако без них приложение не взлетит, поэтому приведу полный исходник консольной программы. Она далека от совершенства (хотя со своей задачей справляется), так как была разработана только для проверки идеи:

Program.cs
using System;
using System.Collections.Generic;
using System.Drawing.Printing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PCL_processing {
    class Program {

        private const byte SkipTheByte = 0xff; // 0xfc - 0xff Reserved for future use.
        private const byte UByte = 0xc0;
        private const byte AttrUByte = 0xf8;
        private const byte Orientation = 0x28;
        private const byte MediaSize = 0x25;
        private const byte MediaSource = 0x26;
        private const byte SimplexPageMode = 0x34;
        private const byte DuplexPageMode = 0x35;
        private const byte DuplexHorizontalBinding = 0x00;
        private const byte SimplexFrontSide = 0x00;
        private const byte DuplexVerticalBinding = 0x01;
        private const byte BeginPage = 0x43;

        private const int MediaSourceValueShift = 9;
        private const int DuplexBindingShift = 13;
        private const int PageModeShift = 15;
        
        static void Main(string[] args) {
            var pagePattern = new byte[] { UByte, SkipTheByte, AttrUByte, Orientation, UByte, SkipTheByte, AttrUByte, MediaSize, UByte, SkipTheByte, AttrUByte, MediaSource, UByte, SkipTheByte, AttrUByte, SimplexPageMode, BeginPage };

            var fileName = "";

            if (!args.Any()) {
                while (true) {
                    Console.WriteLine("Please write pdf file name:");
                    fileName = Console.ReadLine();

                    if (string.IsNullOrWhiteSpace(fileName)) {
                        Console.WriteLine("You have wrote empty string");
                        continue;
                    }

                    break;
                }
            }
            else {
                fileName = args[0];
            }

            if (!File.Exists(fileName)) {
                Console.WriteLine("File \"{0}\" not found", fileName);
                return;
            }


            var data = Pdf2Pcl.ConvertPcl2Pdf(fileName);

            var pageIndexes = PatternMatching(data, pagePattern);

            Console.WriteLine("Found {0} pages", pageIndexes.Length);

            var printPattern = GetSourecPattern();

            var isDuplex = Menu(new[] {"Simplex", "Duplex"}, "Selec mode:") > 0;

            var extraPattern = ExtractPattern(printPattern, pageIndexes.Length, isDuplex);

            data = ApplyPattern(data, pageIndexes, extraPattern, isDuplex);

            for (int i = 0; i < pageIndexes.Length; i++) {
                Console.Write("{0:X8} | ", pageIndexes[i]);

                for (var j = 0; j < pagePattern.Count(); j++) {
                    Console.Write("{0:X}  ", data[pageIndexes[i] + j]);
                }

                Console.WriteLine("");
            }

            var printer = GetPrinter();

            RawPrinter.SendRawDataToPrinter(printer, data, fileName);

            Console.WriteLine("*** DONE ***");

            Console.ReadLine();
        }

        static byte[] ApplyPattern(byte[] data, int[] pageIndexes, byte[] extraPattern, bool isDuplex) {
            for (int i = 0; i < pageIndexes.Length; i++) {
                var pageIndex = pageIndexes[i];

                data[pageIndex + PageModeShift] = isDuplex ? DuplexPageMode : SimplexPageMode;
                data[pageIndex + DuplexBindingShift] = isDuplex ? DuplexVerticalBinding : SimplexFrontSide;
                data[pageIndex + MediaSourceValueShift] = extraPattern[i];
            }

            return data;
        }

        static int[] PatternMatching(byte[] data, byte[] pattern) {
            var pageShiftLst = new List<int>();

            for (var i = 0; i < data.Count(); i++) {
                if (IsOnPattern(data, i, pattern)) {

                    pageShiftLst.Add(i);

                    Console.Write("{0:X8} | ", i);

                    for (var j = 0; j < pattern.Count(); j++) {
                        Console.Write("{0:X}  ", data[i + j]);
                    }

                    Console.WriteLine("");

                    i += pattern.Count() - 1;

                }
            }

            return pageShiftLst.ToArray();
        }

        static bool IsOnPattern(byte[] data, int shift, byte[] pattern) {
            for (var i = 0; i < pattern.Count() ; i++) {
                if (!((shift + i) < data.Count())) return false;

                if (pattern[i] != SkipTheByte) {
                    if (pattern[i] != data[shift + i]) {
                        return false;
                    }
                }
            }
            return true;
        }

        private static byte[] ExtractPattern(int[] pattern, int pageCount, bool isDublex) {
            var srcPoint = 0;

            var expandedPattern = new List<byte>();
            for (var pageNumber = 0; pageNumber < pageCount; pageNumber++) { // expand-pattern

                expandedPattern.Add((byte)pattern[srcPoint]);

                if (isDublex) {
                    if (pageNumber % 2 != 0) {
                        srcPoint++;
                    }
                } else {
                    srcPoint++;

                }

                srcPoint = srcPoint < pattern.Count() ? srcPoint : 0;
            }

            return expandedPattern.ToArray();
        }

        private static int[] GetSourecPattern() {
            var bindingsFile = "source-bindings.kv";
            var patternsFile = "patterns.csv";

            var Bindings = GetBindings(bindingsFile);
            var Patterns = GetPatterns(patternsFile, Bindings);

            var patternindex = Menu(Patterns.Keys.ToArray(), "Please select pattern:");

            var pattern = Patterns.ElementAt(patternindex).Value;

            var srcPattern = pattern.Select(i => Bindings[i]).ToList();

            return srcPattern.ToArray();
        }

        private static int Menu(string[] items, string message) {
            var selectedIndex = -1;

            while (true) {
                Console.WriteLine(message);

                for (int i = 0; i < items.Length; i++) {
                    Console.WriteLine("[{0}] -- \"{1}\"", i, items[i]);
                }

                var str = Console.ReadLine();

                if (Int32.TryParse(str, out selectedIndex)) {
                    if (selectedIndex >= 0 && selectedIndex < items.Length) {
                        break;
                    }
                }

            }

            return selectedIndex;
        }

        private static Dictionary<int, int> GetBindings(string fileName) {
            if (!File.Exists(fileName)) {
                Console.WriteLine("Файл привязок не найден \"{0}\"", fileName);
                return new Dictionary<int, int>();
            }

            var res = new Dictionary<int, int>();
            var lines = File.ReadAllLines(fileName, Encoding.Default);

            foreach (var line in lines) {
                var kv = line.Split('=');
                if (kv.Count() != 2) {
                    Console.WriteLine("Ошибка разбора файла привязок: \"{0}\"", line);
                    return new Dictionary<int, int>();
                }

                int k = 0;
                int v = 0;

                if (!int.TryParse(kv[0], out k)) {
                    Console.WriteLine("Ошибка ключа при разборе файла привязок: \"{0}\"", line);
                    return new Dictionary<int, int>();
                }

                if (!int.TryParse(kv[1], out v)) {
                    Console.WriteLine("Ошибка значения при разборе файла привязок: \"{0}\"", line);
                    return new Dictionary<int, int>();
                }

                res[k] = v;
            }

            return res;
        }

        private static Dictionary<string, int[]> GetPatterns(string fileName, Dictionary<int, int> bindings) {
            if (!File.Exists(fileName)) {
                Console.WriteLine("Файл шаблонов не найден \"{0}\"", fileName);
                return new Dictionary<string, int[]>();
            }
            var lines = File.ReadAllLines(fileName, Encoding.Default);
            var res = new Dictionary<string, int[]>();

            foreach (var line in lines) {
                var splt = line.Split(';');
                if (!splt.Any()) {
                    Console.WriteLine("Некорректный шаблон \"{0}\"", line);
                    return new Dictionary<string, int[]>();
                }

                var patternName = splt[0];
                var patternBody = new List<int>();
                for (var i = 1; i < splt.Count(); i++) {
                    int item = 0;

                    if (!int.TryParse(splt[i], out item)) {
                        Console.WriteLine("Некорректный номер лотка в шаблоне \"{0}\"", line);
                        break;
                    }

                    if (!bindings.ContainsKey(item)) {
                        Console.WriteLine("Нет привязки номера лотка в шаблоне \"{0}\"", line);
                        break;
                    }

                    patternBody.Add(item);
                }

                res[patternName] = patternBody.ToArray();
            }

            return res;
        }

        static string GetPrinter() {
            var printers = new string[PrinterSettings.InstalledPrinters.Count];
            PrinterSettings.InstalledPrinters.CopyTo(printers, 0);

            var printerIndex = Menu(printers, "Please select printer:");

            return printers[printerIndex];
        }
    }
}


Pdf2Pcl.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Ghostscript.NET;
using Ghostscript.NET.Processor;

namespace PCL_processing {
    public class Pdf2Pcl {
        public static byte[] ConvertPcl2Pdf(string pdfFileName) {
            byte[] rawDocumentData = null;

            var gsPipedOutput = new GhostscriptPipedOutput();

            var outputPipeHandle = "%handle%" + int.Parse(gsPipedOutput.ClientHandle).ToString("X2");

            using (var processor = new GhostscriptProcessor()) {
                var switches = new List<string>();
                switches.Add("-dQUIET");
                switches.Add("-dSAFER");
                switches.Add("-dBATCH");
                switches.Add("-dNOPAUSE");
                switches.Add("-dNOPROMPT");
                switches.Add("-sDEVICE=pxlmono");
                switches.Add("-o" + outputPipeHandle);
                switches.Add("-f");
                switches.Add(pdfFileName);

                try {
                    processor.StartProcessing(switches.ToArray(), new GsIoHandler());
                    rawDocumentData = gsPipedOutput.Data;
                }
                catch (Exception ex) {
                    Console.WriteLine(ex.Message);
                }
                finally {
                    gsPipedOutput.Dispose();
                    gsPipedOutput = null;
                }
            }

            return rawDocumentData;
        }

        public class GsIoHandler : GhostscriptStdIO {
            public GsIoHandler() : base(true, true, true) { }

            public override void StdIn(out string input, int count) {
                input = string.Empty;
            }

            public override void StdOut(string output) {
                if (string.IsNullOrWhiteSpace(output)) return;

                output = output.Trim();
                
                Console.WriteLine("GS: {0}",output);
            }

            public override void StdError(string error) {
                if (string.IsNullOrWhiteSpace(error)) return;

                error = error.Trim();

                Console.WriteLine("GS: {0}", error);
            }
        }
    }
}


RawPrinter.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace PCL_processing {
    class RawPrinter {
        // Structure and API declarions:
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
        private class DOCINFOA {
            [MarshalAs(UnmanagedType.LPStr)]
            public string pDocName;
            [MarshalAs(UnmanagedType.LPStr)]
            public string pOutputFile;
            [MarshalAs(UnmanagedType.LPStr)]
            public string pDataType;
        }

        [DllImport("winspool.Drv", EntryPoint = "OpenPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool OpenPrinter([MarshalAs(UnmanagedType.LPStr)] string szPrinter, out IntPtr hPrinter, IntPtr pd);

        [DllImport("winspool.Drv", EntryPoint = "ClosePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool ClosePrinter(IntPtr hPrinter);

        [DllImport("winspool.Drv", EntryPoint = "StartDocPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool StartDocPrinter(IntPtr hPrinter, Int32 level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFOA di);

        [DllImport("winspool.Drv", EntryPoint = "EndDocPrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool EndDocPrinter(IntPtr hPrinter);

        [DllImport("winspool.Drv", EntryPoint = "StartPagePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool StartPagePrinter(IntPtr hPrinter);

        [DllImport("winspool.Drv", EntryPoint = "EndPagePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool EndPagePrinter(IntPtr hPrinter);

        [DllImport("winspool.Drv", EntryPoint = "WritePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
        private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, Int32 dwCount, out Int32 dwWritten);

        private static bool SendBytesToPrinter(string szPrinterName, IntPtr pBytes, Int32 dwCount, string docName) {
            Int32 dwError = 0, dwWritten = 0;
            IntPtr hPrinter = new IntPtr(0);
            DOCINFOA di = new DOCINFOA();
            bool bSuccess = false; // Assume failure unless you specifically succeed.

            di.pDocName = docName;
            di.pDataType = "RAW";

            // Open the printer.
            if (OpenPrinter(szPrinterName.Normalize(), out hPrinter, IntPtr.Zero)) {
                // Start a document.
                if (StartDocPrinter(hPrinter, 1, di)) {
                    // Start a page.
                    if (StartPagePrinter(hPrinter)) {
                        // Write your bytes.
                        bSuccess = WritePrinter(hPrinter, pBytes, dwCount, out dwWritten);
                        EndPagePrinter(hPrinter);
                    }
                    EndDocPrinter(hPrinter);
                }
                ClosePrinter(hPrinter);
            }
            // If you did not succeed, GetLastError may give more information
            // about why not.
            if (bSuccess == false) {
                dwError = Marshal.GetLastWin32Error();
            }
            return bSuccess;
        }

        public static bool SendRawDataToPrinter(string szPrinterName, byte[] data, string docName) {

            bool bSuccess = false;
            
            // Your unmanaged pointer.
            IntPtr pUnmanagedBytes = new IntPtr(0);


            // Allocate some unmanaged memory for those bytes.
            pUnmanagedBytes = Marshal.AllocCoTaskMem(data.Length);
            // Copy the managed byte array into the unmanaged array.
            Marshal.Copy(data, 0, pUnmanagedBytes, data.Length);
            // Send the unmanaged bytes to the printer.
            bSuccess = SendBytesToPrinter(szPrinterName, pUnmanagedBytes, data.Length, docName);
            // Free the unmanaged memory that you allocated earlier.
            Marshal.FreeCoTaskMem(pUnmanagedBytes);
            return bSuccess;
        }
    }
}



Для работы понадобятся файлы шаблонов:

patterns.csv
Первый шаблон;2;3;3;4;4
Второй шаблон;2;3;4
Третий шаблон;2;3;3;4
Четвертый шаблон;2;4
Пятый шаблон;2


Первый столбец — имя шаблона, остальные — номера лотков

Для составления шаблонов удобно оперировать номером лотка, а не Id источника из мануала:


Создадим файл привязки, который содержит соответствие номера лотка и id источника:

source-bindings.kv
1=3
2=4
3=5
4=7


Надеюсь, в статье мне удалось заполнить информационный пробел, который касается управляемой печати, да и печати вообще из под .Net.

Одна из целей статьи — обратить внимание девелоперов, которые так или иначе сталкиваются с задачами для печати документов, на язык PCL. Хоть PCL и не такой читабельный и удобный как PostScript, зато позволяет тонко управлять принтером. А это жизненно необходимо для некоторых проектов и не реализуемо на PostScript.

Если у вас есть предложения или вы заметили неточности, пишите пожалуйста в комментариях.

Цикл статей:
Растровый подход
Векторный подход теория
Векторный подход практика
Tags:
Hubs:
+8
Comments 7
Comments Comments 7

Articles

Information

Website
www.tinkoff.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия