diff --git a/PrintService/Models/PrinterConfig.cs b/PrintService/Models/PrinterConfig.cs
new file mode 100644
index 0000000..1961fef
--- /dev/null
+++ b/PrintService/Models/PrinterConfig.cs
@@ -0,0 +1,44 @@
+namespace PrintService.Models;
+
+///
+/// Root configuration loaded from printer-config.json
+///
+public class PrinterConfiguration
+{
+ public Dictionary DocumentTypes { get; set; } = new();
+}
+
+///
+/// Configuration for a single document type (replaces DocumentConfig for printer/tray mapping)
+///
+public class DocumentTypeConfig
+{
+ public string Name { get; set; } = string.Empty;
+ public List Pages { get; set; } = new();
+
+ // Rendering settings
+ public string FontName { get; set; } = "Courier New";
+ public float FontSize { get; set; } = 10f;
+ public int HorizontalOffset { get; set; } = 0;
+ public int VerticalOffset { get; set; } = 0;
+
+ // Post-processing settings
+ public bool ArchiveAfterPrint { get; set; } = true;
+ public string? ArchivePath { get; set; }
+ public string? OutputPath { get; set; }
+ public bool SkipPostProcessing { get; set; } = false;
+
+ // Text transformation rules
+ public List Transformations { get; set; } = new();
+}
+
+///
+/// Configuration for a single page (printer and tray assignment)
+///
+public class PageConfig
+{
+ public int PageNumber { get; set; }
+ public string PrinterName { get; set; } = string.Empty;
+ public int TrayNumber { get; set; }
+ public string? TrayLabel { get; set; }
+}
diff --git a/PrintService/Program.cs b/PrintService/Program.cs
index c9ac78d..f5276cd 100644
--- a/PrintService/Program.cs
+++ b/PrintService/Program.cs
@@ -14,6 +14,7 @@ IHost host = Host.CreateDefaultBuilder(args)
services.Configure(hostContext.Configuration.GetSection("AppSettings"));
// Register services
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/PrintService/Services/ConfigurationService.cs b/PrintService/Services/ConfigurationService.cs
new file mode 100644
index 0000000..3ff94c5
--- /dev/null
+++ b/PrintService/Services/ConfigurationService.cs
@@ -0,0 +1,87 @@
+using System.Text.Json;
+using PrintService.Models;
+
+namespace PrintService.Services;
+
+///
+/// Loads printer-config.json (global and local), merging local over global.
+/// Mirrors the same logic used in PrintServiceTray.
+///
+public class ConfigurationService
+{
+ private readonly string _globalConfigPath;
+ private readonly string _localConfigPath;
+ private readonly ILogger _logger;
+
+ public ConfigurationService(ILogger logger)
+ {
+ _logger = logger;
+
+ _globalConfigPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "LAAPC", "printer-config.json");
+
+ _localConfigPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "LAAPC", "printer-config.json");
+ }
+
+ ///
+ /// Load merged configuration. Returns an empty config (not null) if no files exist.
+ ///
+ public PrinterConfiguration Load()
+ {
+ var config = new PrinterConfiguration();
+
+ if (File.Exists(_globalConfigPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(_globalConfigPath);
+ var global = JsonSerializer.Deserialize(json);
+ if (global != null)
+ config = global;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to read global printer config from {Path}", _globalConfigPath);
+ }
+ }
+
+ if (File.Exists(_localConfigPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(_localConfigPath);
+ var local = JsonSerializer.Deserialize(json);
+ if (local != null)
+ {
+ // Local overrides global per document type
+ foreach (var kvp in local.DocumentTypes)
+ config.DocumentTypes[kvp.Key] = kvp.Value;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to read local printer config from {Path}", _localConfigPath);
+ }
+ }
+
+ if (config.DocumentTypes.Count == 0)
+ _logger.LogWarning("No document types found in printer-config.json. Checked: {Global} and {Local}",
+ _globalConfigPath, _localConfigPath);
+
+ return config;
+ }
+
+ ///
+ /// Get a single document type config by name (case-insensitive).
+ ///
+ public DocumentTypeConfig? GetDocumentType(string documentType)
+ {
+ var config = Load();
+ return config.DocumentTypes
+ .FirstOrDefault(kvp => kvp.Key.Equals(documentType, StringComparison.OrdinalIgnoreCase))
+ .Value;
+ }
+}
diff --git a/PrintService/Services/DocumentProcessor.cs b/PrintService/Services/DocumentProcessor.cs
index 432d7d5..408107c 100644
--- a/PrintService/Services/DocumentProcessor.cs
+++ b/PrintService/Services/DocumentProcessor.cs
@@ -12,18 +12,23 @@ namespace PrintService.Services;
public class DocumentProcessor
{
private readonly AppSettings _settings;
+ private readonly ConfigurationService _configService;
private readonly ILogger _logger;
- public DocumentProcessor(IOptions settings, ILogger logger)
+ public DocumentProcessor(
+ IOptions settings,
+ ConfigurationService configService,
+ ILogger logger)
{
_settings = settings.Value;
+ _configService = configService;
_logger = logger;
}
///
/// Process and transform document content
///
- public string ProcessDocument(string content, DocumentConfig config)
+ public string ProcessDocument(string content, DocumentTypeConfig config)
{
var processed = content;
@@ -48,12 +53,11 @@ public class DocumentProcessor
}
///
- /// Get document configuration by type
+ /// Get document configuration by type from printer-config.json
///
- public DocumentConfig? GetDocumentConfig(string documentType)
+ public DocumentTypeConfig? GetDocumentConfig(string documentType)
{
- return _settings.DocumentTypes.FirstOrDefault(dt =>
- dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase));
+ return _configService.GetDocumentType(documentType);
}
///
@@ -67,7 +71,7 @@ public class DocumentProcessor
///
/// Archive or delete file after processing
///
- public void PostProcess(PrintJob job, DocumentConfig config)
+ public void PostProcess(PrintJob job, DocumentTypeConfig config)
{
try
{
diff --git a/PrintService/Services/PrinterService.cs b/PrintService/Services/PrinterService.cs
index 291dc64..86799d2 100644
--- a/PrintService/Services/PrinterService.cs
+++ b/PrintService/Services/PrinterService.cs
@@ -20,42 +20,51 @@ public class PrinterService
}
///
- /// Print document to specified printer with tray sequence
+ /// Print document using the new DocumentTypeConfig (printer-config.json).
+ /// Each PageConfig entry defines one output copy per original content page,
+ /// so a 4-page document with 4 PageConfig entries produces 16 output pages.
///
- public void Print(string content, DocumentConfig config, string originalFileName)
+ public void Print(string content, DocumentTypeConfig config, string originalFileName)
{
- if (config.TraySequence.Length == 0)
+ if (config.Pages.Count == 0)
{
- throw new InvalidOperationException("No tray sequence defined for document type");
+ throw new InvalidOperationException("No pages/trays defined for document type");
}
var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.None);
- var linesPerPage = CalculateLinesPerPage(config);
- var currentPageIndex = 0;
+ var linesPerPage = CalculateLinesPerPage(config.FontSize);
+ var totalOriginalPages = Math.Max(1, (int)Math.Ceiling(lines.Length / (double)linesPerPage));
+ var originalPageIndex = 0;
+ var copyIndex = 0; // index into config.Pages (one entry per tray copy)
+
+ // All pages within one document type share the same printer in typical use,
+ // but PageConfig supports per-page overrides — use the first page's printer for
+ // the PrintDocument; tray (PaperSource) is set per page in the event handler.
+ var primaryPrinterName = config.Pages[0].PrinterName;
var printDoc = new PrintDocument
{
- PrinterSettings = { PrinterName = config.PrinterName }
+ PrinterSettings = { PrinterName = primaryPrinterName }
};
// Handle PDF output if using Microsoft Print to PDF
- if (config.PrinterName.Equals("Microsoft Print to PDF", StringComparison.OrdinalIgnoreCase) &&
+ if (primaryPrinterName.Equals("Microsoft Print to PDF", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(config.OutputPath))
{
Directory.CreateDirectory(config.OutputPath);
var pdfFileName = Path.GetFileNameWithoutExtension(originalFileName) + ".pdf";
var pdfPath = Path.Combine(config.OutputPath, pdfFileName);
-
+
printDoc.PrinterSettings.PrintToFile = true;
printDoc.PrinterSettings.PrintFileName = pdfPath;
-
+
_logger.LogInformation("PDF will be saved to: {Path}", pdfPath);
}
- // Verify printer exists
- if (!PrinterSettings.InstalledPrinters.Cast().Contains(config.PrinterName))
+ // Verify primary printer exists
+ if (!PrinterSettings.InstalledPrinters.Cast().Contains(primaryPrinterName))
{
- throw new InvalidOperationException($"Printer '{config.PrinterName}' not found");
+ throw new InvalidOperationException($"Printer '{primaryPrinterName}' not found");
}
printDoc.PrintPage += (sender, e) =>
@@ -63,52 +72,67 @@ public class PrinterService
if (e.Graphics == null || e.PageSettings == null)
return;
- // Select tray for current page
- var trayIndex = config.TraySequence[currentPageIndex % config.TraySequence.Length];
-
+ var pageConfig = config.Pages[copyIndex];
+
try
{
- // Map logical tray number to PaperSource
- var paperSource = GetPaperSource(printDoc.PrinterSettings, trayIndex);
+ var paperSource = GetPaperSource(printDoc.PrinterSettings, pageConfig.TrayNumber);
if (paperSource != null)
{
e.PageSettings.PaperSource = paperSource;
if (_settings.VerboseLogging)
{
- _logger.LogDebug("Page {Page}: Using tray {Tray} ({Source})",
- currentPageIndex + 1, trayIndex, paperSource.SourceName);
+ _logger.LogDebug(
+ "Output page {OutputPage}: original page {OriginalPage}, tray {Tray} ({Label}) ({Source})",
+ (originalPageIndex * config.Pages.Count) + copyIndex + 1,
+ originalPageIndex + 1,
+ pageConfig.TrayNumber,
+ pageConfig.TrayLabel ?? "?",
+ paperSource.SourceName);
}
}
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to set tray {Tray}, using default", trayIndex);
+ _logger.LogWarning(ex, "Failed to set tray {Tray}, using default", pageConfig.TrayNumber);
}
- // Render page content
- RenderPage(e.Graphics, lines, currentPageIndex * linesPerPage, linesPerPage, config);
+ // Render the same original page for every copy before moving to next original page.
+ RenderPage(e.Graphics, lines, originalPageIndex * linesPerPage, linesPerPage,
+ config.FontName, config.FontSize, config.HorizontalOffset, config.VerticalOffset);
- currentPageIndex++;
- e.HasMorePages = (currentPageIndex < config.TraySequence.Length);
+ copyIndex++;
+ if (copyIndex >= config.Pages.Count)
+ {
+ copyIndex = 0;
+ originalPageIndex++;
+ }
+
+ e.HasMorePages = originalPageIndex < totalOriginalPages;
};
- _logger.LogInformation("Printing to {Printer} with {Pages} pages",
- config.PrinterName, config.TraySequence.Length);
-
+ _logger.LogInformation(
+ "Printing to {Printer}: {OriginalPages} original page(s) × {Copies} copies = {Total} output pages",
+ primaryPrinterName,
+ totalOriginalPages,
+ config.Pages.Count,
+ totalOriginalPages * config.Pages.Count);
+
printDoc.Print();
}
///
/// Render page content using Graphics API
///
- private void RenderPage(Graphics graphics, string[] lines, int startLine, int linesPerPage, DocumentConfig config)
+ private void RenderPage(Graphics graphics, string[] lines, int startLine, int linesPerPage,
+ string fontName, float fontSize, int horizontalOffset, int verticalOffset)
{
- var font = new Font(config.FontName, config.FontSize);
+ var font = new Font(fontName, fontSize);
var brush = Brushes.Black;
var lineHeight = font.GetHeight(graphics);
- var x = (float)config.HorizontalOffset;
- var y = (float)config.VerticalOffset;
+ var x = (float)horizontalOffset;
+ var y = (float)verticalOffset;
var endLine = Math.Min(startLine + linesPerPage, lines.Length);
@@ -123,13 +147,12 @@ public class PrinterService
}
///
- /// Calculate lines per page based on font and page size
+ /// Calculate lines per page based on font size
///
- private int CalculateLinesPerPage(DocumentConfig config)
+ private int CalculateLinesPerPage(float fontSize)
{
// Estimate: standard letter size is 11 inches, at 10pt font ~66 lines
- // This is simplified; in production you'd calculate based on actual page dimensions
- var estimatedLineHeight = config.FontSize * 1.2f; // points
+ var estimatedLineHeight = fontSize * 1.2f; // points
var pageHeightInPoints = 11 * 72; // 11 inches * 72 points per inch
return (int)Math.Floor(pageHeightInPoints / estimatedLineHeight);
}
diff --git a/PrintService/Worker.cs b/PrintService/Worker.cs
index fa4fe2a..f119fa6 100644
--- a/PrintService/Worker.cs
+++ b/PrintService/Worker.cs
@@ -112,11 +112,11 @@ public class Worker : BackgroundService
try
{
- // Get document configuration
+ // Get document configuration from printer-config.json
var config = _documentProcessor.GetDocumentConfig(job.DocumentType);
if (config == null)
{
- throw new InvalidOperationException($"No configuration found for document type: {job.DocumentType}");
+ throw new InvalidOperationException($"No configuration found for document type '{job.DocumentType}' in printer-config.json");
}
// Read file content
diff --git a/RUST_TRANSFORMATION_FLOWS.md b/RUST_TRANSFORMATION_FLOWS.md
new file mode 100644
index 0000000..dd61a27
--- /dev/null
+++ b/RUST_TRANSFORMATION_FLOWS.md
@@ -0,0 +1,263 @@
+# Rust Content Transformation Flows
+
+This document describes the content transformation steps performed by the Rust CLI (`cgwprint`) that need to be ported to the C# PrintService.
+
+## Configuration Overview
+
+Each job type is defined in `printers.json` under the `process` section with the following possible properties:
+
+- **label**: Display name for the job type
+- **line_padding**: Left margin/indent for all lines (in units)
+- **remove_ord_num**: Remove both "Order #: " label and the order number entirely
+- **remove_ord_num_label**: Remove "Order #: " label but keep the order number (and make it bold)
+- **indent_ord_num**: Add spacing before the order number (shifts it right)
+- **row_shift**: Dictionary of line numbers → vertical adjustment (in 1/100 inch units)
+- **row_trim**: Dictionary of line numbers → {start, end} (removes characters from start to end)
+- **printer**: Array of printer/tray assignments
+
+## Universal Transformation Flow (All Job Types)
+
+Every document goes through this sequence:
+
+### Step 1: File Reading & Page Splitting
+- Read entire file content as string
+- Split content by **form-feed character** (`\x0C` / ASCII 12)
+- Filter out empty pages
+- Each page becomes an array of lines
+
+### Step 2: Font Code Normalization (Pre-processing)
+- Prefix entire content with `\x1Bw0` (ensure normal font at start)
+- Replace `\x1BW1` → `\x1Bw1` (normalize uppercase W to lowercase)
+- Replace `\x1BW0` → `\x1Bw0` (normalize uppercase W to lowercase)
+- Deduplicate consecutive `\x1Bw1` sequences → single `\x1Bw1`
+- Deduplicate consecutive `\x1Bw0` sequences → single `\x1Bw0`
+
+### Step 3: Order Number Manipulation (If order number provided)
+**Note:** Only ONE of these operations executes per job based on configuration flags.
+
+**3a. Remove Order Number Label** (if `remove_ord_num_label: true`)
+- Find: `"Order #: {ordnum}"`
+- Replace with: `" \x1Bw1{ordnum}\x1Bw0"` (spaces equal to "Order #: " length + bold order number)
+
+**3b. Remove Order Number Completely** (if `remove_ord_num: true`)
+- Find: `"Order #: {ordnum}"`
+- Replace with: spaces equal to the entire string length
+
+**3c. Indent Order Number** (if `indent_ord_num` is not empty)
+- Find: `\x1Bw1{ordnum}\x1Bw0` (regex search)
+- Replace with: `{indent_ord_num}\x1Bw1{ordnum}\x1Bw0`
+
+### Step 4: Font Code Replacement
+Replace ESC sequences with actual PCL font commands from printer configuration:
+
+- `\x1Bw1` → Bold font PCL code (e.g., `\x1B(0N\x1B(s0p5h0s3b4099T`)
+- `\x1Bw0` → Normal font PCL code (e.g., `\x1B(0N\x1B(s0p10h0s0b4099T`)
+
+### Step 5: Process All Hex Escapes
+Convert remaining `\xHH` escape sequences to actual bytes throughout content.
+
+### Step 6: Page Layout Construction
+For each original page, for each assigned tray:
+
+**6a. Tray Selection**
+- Insert PCL tray selection code (e.g., `\x1B&l4H` for tray 1)
+
+**6b. Line-by-Line Rendering**
+For each line (index = `pos_v`) in the page:
+
+1. **Check for Row Shift** (Vertical positioning adjustment)
+ - If `row_shift["{pos_v}"]` exists, add adjustment to cumulative `line_spacing`
+ - Example: `row_shift["7"]: -200` means line 7 moves UP 200 units (negative = up, positive = down)
+ - This allows overlaying text or changing vertical line order
+
+2. **Check for Row Trim** (Horizontal character manipulation)
+ - If `row_trim["{pos_v}"]` exists:
+ - Extract substring: `line[0..start] + line[end..]` (removes characters from start to end)
+ - Use trimmed line for rendering
+ - Example: `row_trim["8"]: {start: 0, end: 65}` removes first 65 characters
+ - This is used to add/remove horizontal spacing or adjust text positions on a line
+
+3. **Position Cursor & Write Line**
+ - Insert PCL positioning: `\x1B&a{line_padding}h{vertical_position}V`
+ - `vertical_position = (pos_v × spacing) + line_spacing`
+ - `spacing` = printer's spacing value (100 or 150 = 1/100 inch per line)
+ - Append the line content
+**Note:** The Rust implementation wraps content with PCL/PJL commands because it sends raw data via TCP socket. The C# implementation uses Windows printer drivers, so **most of these commands may not be needed**. We'll need to test what's actually required.
+
+**7a. Header (based on PCL version from printer config) - MAY NOT BE NEEDED IN C#**
+```
+\x1B%-12345X // PCL mode enter
+@PJL ENTER LANGUAGE=PCL6 // PJL language (if PCL 6)
+@PJL SET RENDERMODE=GRAYSCALE // PJL grayscale (if PCL 6)
+@PJL SET RESOLUTION=600 // PJL resolution (if PCL 6)
+\x1B&l0O // Portrait orientation (if PCL 6)
+{content here}
+```
+
+**7b. Footer - MAY NOT BE NEEDED IN C#**
+```
+\x1B%-12345X // PCL mode exit
+```
+
+### Step 8: Send to Printer
+**Rust approach:** Raw TCP socket to printer IP:port 9100
+**C# approach:** Windows PrintDocument API with Graphics renderingket to printer IP:port (typically 9100)
+- Send raw bytes
+- Close socket
+
+---
+
+## Job Type Specific Examples
+
+### **DELIVERY**
+```json
+{
+ "label": "Delivery",
+ "indent_ord_num": " ", // Shift order number right 4 spaces
+ "line_padding": 10, // 10 units left margin
+ "printer": [{
+ "name": "HL-L6415DW",
+ "tray": [3, 2] // Print to tray 3, then tray 2
+ }],
+ "row_shift": {
+ "0": 625, // Move line 0 down 625 units
+ "7": -200, // Move line 7 up 200 units
+ "8": -100, // Move line 8 up 100 units
+ "14": 100, // Move line 14 down 100 units
+ "19": 172 // Move line 19 down 172 units
+ }
+}
+```
+
+**Transformations Applied:**
+- Order number gets 4 spaces prepended
+- Specific lines get vertical position adjustments
+- Each page prints twice (tray 3, then tray 2)
+
+---
+
+### **INVOICE**
+```json
+{
+ "label": "Invoice",
+ "remove_ord_num": true, // Remove "Order #: 12345" entirely
+ "line_padding": 10,
+ "printer": [
+ {
+ "name": "HL-L6415DW",
+ "tray": [2, 4, 5] // 3 copies on different trays
+ },
+ {
+ "name": "SAVIN-100",
+ "tray": [1] // Additional copy on different printer
+ }
+ ],
+ "row_shift": {
+ "0": 625,
+ "7": -200,
+ "8": -100,
+ "14": 100,
+ "19": 172
+ }
+}
+```
+
+**Transformations Applied:**
+- "Order #: 12345" → " " (spaces)
+- Same row shifts as delivery
+- Prints 4 times total: HL-L6415DW tray 2, 4, 5, then SAVIN tray 1
+
+---
+
+### **PRE-BILL**
+```json
+{
+ "label": "Pre-bill",
+ "remove_ord_num": false,
+ "remove_ord_num_label": true, // Keep number but remove label
+ "indent_ord_num": "",
+ "line_padding": 4,
+ "printer": [{
+ "name": "HP-LJP4001",
+ "tray": [1]
+ }],
+ "row_shift": {
+ "8": -2000, // Major upward shift for line 8
+ "12": 1550 // Major downward shift for line 12
+ },
+ "row_trim": {
+ "8": {
+ "start": 0,
+ "end": 65 // Remove chars 0-65 from line 8
+ },
+ "12": {
+ "start": 0,
+ "end": 6 // Remove chars 0-6 from line 12
+ }
+ }
+}
+```
+
+**Transformations Applied:**
+- "Order #: 12345" → " **12345**" (bold number, no label)
+- Line 8: Characters 0-65 removed, positioned -2000 units (major upward shift)
+- Line 12: Characters 0-6 removed, positioned +1550 units (major downward shift)
+
+---
+
+### **PRODUCTION** / **ORDERDESK** / **BACKORDER** / **GOLDEN** / etc.
+Similar patterns with variations in:
+- Order number handling
+- Line padding amounts
+- Tray assignments
+- Row shift values (some have none)
+
+---
+
+## Implementation Notes
+
+### Coordinate System
+- Horizontal: `\x1B&a{H}h{V}V` where H = horizontal position (1/300 inch), V = vertical (1/300 inch)
+- `line_padding` = left margin in 1/300 inch units
+- `spacing` = line height in 1/100 inch units (100 or 150)
+- `row_shift` = vertical adjustment in 1/100 inch units
+
+### Font Codes
+Fonts are printer-specific PCL sequences. Example for HL-L6415DW:
+- **Normal**: `\x1B(0N\x1B(s0p10h0s0b4099T`
+- **Bold**: `\x1B(0N\x1B(s0p5h0s3b4099T`
+
+### Tray Codes
+Tray selection uses PCL commands. Example for HL-L6415DW:
+- Tray 1: `\x1B&l4H`
+- Tray 2: `\x1B&l5H`
+- Tray 3: `\x1B&l8H`
+- Tray 4: `\x1B&l9H`
+- Tray 5: `\x1B&l10H`
+
+### Multi-Printer Jobs
+When multiple printers are specified (like Invoice), the entire transformation process runs separately for each printer with its own font codes, tray codes, and PCL version.
+
+---
+
+## Questions for Review
+
+1. Answers to Review Questions
+
+1. **Order of Operations**: ✅ Only ONE order number manipulation happens per job based on flags, so order doesn't matter.
+
+2. **Row Shift vs Row Trim**: ✅ Independent operations:
+ - **Row Shift**: Vertical movement (up/down on page) - changes line order or overlays
+ - **Row Trim**: Horizontal character manipulation (add/remove spacing or characters)
+
+3. **Tray Duplication Pattern**: ✅ Current C# approach is CORRECT:
+ - Page 1 → Tray 1, Tray 4, Tray 5, Tray 2
+ - Page 2 → Tray 1, Tray 4, Tray 5, Tray 2
+ - This maintains carbon copy layer order
+ - **Future consideration**: Add optional flag to change tray ordering per job type
+
+4. **Empty Row Shift/Trim**: ✅ Skip if not present in configuration.
+
+5. **Hex Escape Timing**: ⚠️ May not be needed in C# since we use Windows drivers instead of raw PCL. Test to determine what's actually required.
+
+6. **Multiple Printers**: ✅ Either sequential or parallel is fine, just maintain page order within each printer.
\ No newline at end of file