diff --git a/.gitignore b/.gitignore index 90263dd..1e7cb60 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,9 @@ build/publish/ *.cache *.log -# Installers (generated files) -installer/*.exe -installer/*.sha256 +# Installers (generated files; includes installer/Output/) +installer/**/*.exe +installer/**/*.sha256 # WinGet temporary files winget-pkgs-*/ @@ -41,6 +41,10 @@ Assets/ # Release artifacts (build outputs uploaded to GitHub Release; do not version) release/* !release/README.md +!release/scripts/ +!release/scripts/** +!release/packages/ +!release/packages/** # Generated EditorConfig *.editorconfig @@ -60,9 +64,6 @@ release/* Thumbs.db Desktop.ini -# Cursor -.cursor/ - # Test directories and script output test-docker/ test-*/ @@ -87,11 +88,12 @@ TESTES-MANUAIS-*.md coverage-out/ TestResults/ -# Cursor (local IDE/agent config) +# Cursor / local tooling (optional) .cursor/ # GitHub prompts/skills (not versioned) -.github/ -# Agent instructions (not versioned) +.github/prompts/ +.github/skills/ +# Agent instructions (optional local-only; remove this line to version AGENTS.md) AGENTS.md # OpenSpec (local change tracking) openspec/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c6301a3..9898fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.1.1] - 2026-03-20 + +### 🔧 Changed + +- **Release layout**: Release automation scripts live under `release/scripts/`; Linux package sources under `scripts/release/packages/` (paths updated in docs and `CONTRIBUTING.md`). +- **Conversion pipeline**: Shared Labelary PDF fallback path in `ConversionService` for CLI, REST API, TCP server, and daemon queue; internal cleanup of dimension types and daemon PID handling (no reflection). + +### 🛠️ Maintenance + +- **Windows installer build**: `release/scripts/08-build-installer.ps1` — safer post-build cleanup; optional `release/scripts/cleanup-installer-output.ps1` for `installer/Output`. +- **Docker build context**: `.dockerignore` excludes non-essential trees (e.g. `tests`, `docs`, `release`, `.cursor`) for smaller/faster `docker build`. +- **Repository**: `.gitignore` — track `.github` except `.github/prompts/` and `.github/skills/` (local Cursor/GitHub Copilot prompts stay private). + +--- + ## [3.1.0] - 2026-03-19 ### ✨ Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9228aa5..929bf98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ ZPL2PDF/ │ ├── ZPL2PDF.Unit/ # Unit tests │ └── ZPL2PDF.Integration/ # Integration tests ├── docs/ # Documentation -└── build/ # Build scripts +└── scripts/ # Build and utility scripts ``` ## 🧪 Testing diff --git a/Dockerfile b/Dockerfile index 0927e6c..93306a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,4 +102,4 @@ CMD ["/app/ZPL2PDF", "run", "-l", "/app/watch"] # Metadata LABEL maintainer="brunoleocam" \ description="ZPL2PDF - Alpine Linux (Ultra Lightweight)" \ - version="3.1.0" + version="3.1.1" diff --git a/README.md b/README.md index 2d22707..0ae6283 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ZPL2PDF - ZPL to PDF Converter -[![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)](https://github.com/brunoleocam/ZPL2PDF/releases) +[![Version](https://img.shields.io/badge/version-3.1.1-blue.svg)](https://github.com/brunoleocam/ZPL2PDF/releases) ![GitHub all releases](https://img.shields.io/github/downloads/brunoleocam/ZPL2PDF/total) [![.NET](https://img.shields.io/badge/.NET-9.0-purple.svg)](https://dotnet.microsoft.com/download) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)](https://github.com/brunoleocam/ZPL2PDF) @@ -16,78 +16,23 @@ A powerful, cross-platform command-line tool that converts ZPL (Zebra Programmin --- -## 🔜 **CLI & packaging (unreleased / main branch)** +## 🚀 **What's New in v3.1.1** -These items are documented in [CHANGELOG.md](CHANGELOG.md) under **Unreleased**: +### 🔧 Changed -- **`--stdout`**: write the PDF to standard output (binary). `-o` is not required; no status text is written to stdout. -- **Default output name (`-n`)**: if omitted, uses the input file base name (e.g. `label.txt` → `label.pdf`); with `-z`, a timestamped `ZPL2PDF_*.pdf` name is used. -- **Dimensions**: `-u` is required whenever `-w` / `-h` are set, including for `mm`. -- **Linux package scripts**: `scripts/build-deb.sh` and `scripts/build-rpm.sh` read the version from `ZPL2PDF.csproj`. -- **Rendering stack**: PDFsharp 6.x and updated BinaryKits packages; Aztec `^B0` is normalized for offline rendering (`^BO`). +- **Release tooling**: Scripts under `release/scripts/`; Linux packaging assets under `scripts/release/packages/` (documentation and contributor paths updated). +- **Internals**: Single Labelary PDF fallback path in `ConversionService` across CLI, API, TCP server, and daemon; dimension/value-object consolidation and daemon PID handling without reflection. ---- - -## 🚀 **What's New in v3.1.0** - -### 🐛 Bug Fixes -- **Fixed Issue #45**: Duplicate or blank labels when `^XA` appears inside `~DGR:` base64 payload — `^XA` is now treated as label start only at line start or after `^XZ`. - -### ✨ New Features -- **Issue #48 – TCP Server**: Virtual Zebra printer mode is now implemented. Use `ZPL2PDF server start --port 9101 -o output/`, `server stop`, and `server status`. -- **REST API (PR #47)**: Run `ZPL2PDF --api --host localhost --port 5000` for `POST /api/convert` (ZPL to PDF or PNG) and `GET /api/health`. - ---- - -## 🚀 **What's New in v3.1.0** - -### 🐛 Bug Fixes -- **Fixed Issue #39**: Sequential graphic processing for multiple graphics with same name - - ZPL files with multiple `~DGR` graphics now process correctly - - Each label uses the correct graphic based on sequential state - - `^IDR` cleanup commands no longer generate blank pages - - Resolves issue where all labels were identical in Shopee shipping label files - -### 🔧 Improvements -- Added input validation in public methods -- Improved exception handling -- Performance optimizations with compiled regex -- Code cleanup and removal of unused methods +### 🛠️ Maintenance ---- - -## 🚀 **What's New in v3.1.0** - -### 🎉 Major New Features -- 🎨 **Labelary API Integration** - High-fidelity ZPL rendering with vector PDF output -- 🖨️ **TCP Server Mode** - Virtual Zebra printer on TCP port (default: 9101) -- 🔤 **Custom Fonts** - Load TrueType/OpenType fonts with `--fonts-dir` and `--font` -- 📁 **Extended File Support** - Added `.zpl` and `.imp` file extensions -- 📝 **Custom Naming** - Set output filename via `^FX FileName:` in ZPL +- **Installer pipeline**: More reliable cleanup in `08-build-installer.ps1`; optional `cleanup-installer-output.ps1` for `installer/Output`. +- **Docker**: Leaner default build context via `.dockerignore`. +- **Repo hygiene**: `.github/prompts/` and `.github/skills/` remain untracked; the rest of `.github` (workflows, templates) stays versioned. -### 🔧 Rendering Options -```bash ---renderer offline # BinaryKits (default, works offline) ---renderer labelary # Labelary API (high-fidelity, requires internet) ---renderer auto # Try Labelary, fallback to BinaryKits -``` - -### 🖨️ TCP Server (Virtual Printer) -```bash -ZPL2PDF server start --port 9101 -o output/ -ZPL2PDF server status -ZPL2PDF server stop -``` +### Recent highlights (v3.1.1) -### v2.x Features (Still Available) -- 🌍 **Multi-language Support** - 8 languages (EN, PT, ES, FR, DE, IT, JA, ZH) -- 🔄 **Daemon Mode** - Automatic folder monitoring and batch conversion -- 🏗️ **Clean Architecture** - Completely refactored with SOLID principles -- 🌍 **Cross-Platform** - Native support for Windows, Linux, and macOS -- 📐 **Smart Dimensions** - Automatic ZPL dimension extraction (`^PW`, `^LL`) -- ⚡ **High Performance** - Async processing with retry mechanisms -- 🐳 **Docker Support** - Alpine Linux optimized (470MB) -- 📦 **Professional Installer** - Windows installer with multi-language setup +- **`--stdout`**, smarter default PDF naming, BinaryKits/PDFsharp bumps, dimension validation fix, Aztec `^B0` → `^BO` preprocessing. +- Thanks to Jacques Caruso (jacques.caruso@exhibitgroup.fr) for contributions that landed in v3.1.0. --- @@ -122,7 +67,7 @@ ZPL2PDF server start --port 9101 -o output/ Set your preferred language: ```bash # Temporary (current session) -ZPL2PDF --language pt-BR status +ZPL2PDF status --language pt-BR # Permanent (all sessions) ZPL2PDF --set-language pt-BR @@ -163,25 +108,25 @@ winget install brunoleocam.ZPL2PDF #### Ubuntu/Debian (.deb package) ```bash # Download .deb package from releases -wget https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.0/ZPL2PDF-v3.1.0-linux-amd64.deb +wget https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.1/ZPL2PDF-v3.1.1-linux-amd64.deb # Install package -sudo dpkg -i ZPL2PDF-v3.1.0-linux-amd64.deb +sudo dpkg -i ZPL2PDF-v3.1.1-linux-amd64.deb # Fix dependencies if needed sudo apt-get install -f # Verify installation -zpl2pdf --help +zpl2pdf -help ``` #### Fedora/CentOS/RHEL (.tar.gz) ```bash # Download tarball from releases -wget https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.0/ZPL2PDF-v3.1.0-linux-x64-rpm.tar.gz +wget https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.1/ZPL2PDF-v3.1.1-linux-x64-rpm.tar.gz # Extract to system -sudo tar -xzf ZPL2PDF-v3.1.0-linux-x64-rpm.tar.gz -C / +sudo tar -xzf ZPL2PDF-v3.1.1-linux-x64-rpm.tar.gz -C / # Make executable sudo chmod +x /usr/bin/ZPL2PDF @@ -190,7 +135,7 @@ sudo chmod +x /usr/bin/ZPL2PDF sudo ln -s /usr/bin/ZPL2PDF /usr/bin/zpl2pdf # Verify installation -zpl2pdf --help +zpl2pdf -help ``` #### Docker (All Linux distributions) @@ -204,7 +149,7 @@ docker run -v ./watch:/app/watch -v ./output:/app/output brunoleocam/zpl2pdf:lat #### Intel Macs ```bash # Download -curl -L https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.0/ZPL2PDF-v3.1.0-osx-x64.tar.gz -o zpl2pdf.tar.gz +curl -L https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.1/ZPL2PDF-v3.1.1-osx-x64.tar.gz -o zpl2pdf.tar.gz # Extract and run tar -xzf zpl2pdf.tar.gz @@ -213,7 +158,7 @@ tar -xzf zpl2pdf.tar.gz #### Apple Silicon (M1/M2/M3) ```bash -curl -L https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.0/ZPL2PDF-v3.1.0-osx-arm64.tar.gz -o zpl2pdf.tar.gz +curl -L https://github.com/brunoleocam/ZPL2PDF/releases/download/v3.1.1/ZPL2PDF-v3.1.1-osx-arm64.tar.gz -o zpl2pdf.tar.gz tar -xzf zpl2pdf.tar.gz ./ZPL2PDF -help ``` @@ -342,8 +287,8 @@ ZPL2PDF -i label.txt -o output/ --renderer offline ZPL2PDF -i label.txt -o output/ --renderer labelary ``` - ✅ Exact Zebra printer emulation -- ✅ Vector PDF output (smaller files) -- ✅ Automatic batching for 50+ labels +- ✅ High-fidelity rendering via Labelary (uses Labelary to generate PNGs) +- ✅ Works for multi-label ZPL inputs - ⚠️ Requires internet connection ### **Auto (Fallback)** @@ -355,6 +300,126 @@ ZPL2PDF -i label.txt -o output/ --renderer auto --- +## 🌐 REST API +Start the API server: + +```bash +ZPL2PDF --api --host localhost --port 5000 +``` + +### Health check + +```bash +curl -s http://localhost:5000/api/health +``` + +```json +{ + "status": "ok", + "service": "ZPL2PDF API" +} +``` + +### Convert (ZPL to PDF/PNG) + +Endpoint: `POST /api/convert` + +#### Request body + +```json +{ + "zpl": "^XA...^XZ", + "zplArray": ["^XA...^XZ"], + "format": "pdf", + "width": 7.5, + "height": 15, + "unit": "in", + "dpi": 203, + "renderer": "offline" +} +``` + +Notes: +- You must provide either `zpl` or `zplArray` (at least one non-empty ZPL string). +- `renderer` supports `offline` (BinaryKits), `labelary` (Labelary online API), or `auto` (try Labelary then fall back). +- If `width`/`height` are not set or are `0`, dimensions are extracted from ZPL (`^PW` / `^LL`) by default. + +#### Example: PDF (offline renderer) + +```bash +curl -s -X POST http://localhost:5000/api/convert -H "Content-Type: application/json" -d '{ + "zpl": "^XA^FO50,50^A0N,50,50^FDHello^FS^XZ", + "format": "pdf", + "renderer": "offline", + "width": 7.5, + "height": 3, + "unit": "in", + "dpi": 203 +}' +``` + +```json +{ + "success": true, + "format": "pdf", + "pdf": "JVBERi0xLjQKJc...base64...", + "pages": 1, + "message": "Conversion successful" +} +``` + +#### Example: PDF (Labelary renderer - direct PDF) + +```bash +curl -s -X POST http://localhost:5000/api/convert -H "Content-Type: application/json" -d '{ + "zpl": "^XA^FO50,50^A0N,50,50^FDHello^FS^XZ", + "format": "pdf", + "renderer": "labelary", + "width": 7.5, + "height": 3, + "unit": "in", + "dpi": 203 +}' +``` + +```json +{ + "success": true, + "format": "pdf", + "pdf": "JVBERi0xLjQKJc...base64...", + "pages": 1, + "message": "Conversion successful" +} +``` + +#### Example: PNG (Labelary renderer) + +```bash +curl -s -X POST http://localhost:5000/api/convert -H "Content-Type: application/json" -d '{ + "zpl": "^XA^FO50,50^A0N,50,50^FDHello^FS^XZ", + "format": "png", + "renderer": "labelary", + "width": 7.5, + "height": 3, + "unit": "in", + "dpi": 203 +}' +``` + +```json +{ + "success": true, + "format": "png", + "image": "iVBORw0KGgo...base64...", + "pages": 1, + "message": "Conversion successful" +} +``` + +If more than one label/image is produced, the response uses `images` (array) instead of `image` (single string). + +--- + ## 🐳 **Docker Usage** ### **Quick Start with Docker** @@ -396,7 +461,7 @@ Run: docker-compose up -d ``` -📘 **Full Docker Guide:** [docs/DOCKER_GUIDE.md](docs/DOCKER_GUIDE.md) +📘 **Full Docker Guide:** [docs/guides/DOCKER_GUIDE.md](docs/guides/DOCKER_GUIDE.md) --- @@ -409,7 +474,7 @@ Create a `zpl2pdf.json` file in the application directory: ```json { "language": "en-US", - "defaultWatchFolder": "C:\\Users\\user\\Documents\\ZPL2PDF Auto Converter", + "defaultListenFolder": "C:\\Users\\user\\Documents\\ZPL2PDF Auto Converter", "labelWidth": 10, "labelHeight": 5, "unit": "cm", @@ -427,9 +492,8 @@ See [zpl2pdf.json.example](zpl2pdf.json.example) for full configuration options. | Variable | Description | Example | |----------|-------------|---------| | `ZPL2PDF_LANGUAGE` | Application language | `pt-BR` | -| `ZPL2PDF_LOG_LEVEL` | Logging level | `Debug` | -📘 **Language Configuration Guide:** [docs/LANGUAGE_CONFIGURATION.md](docs/LANGUAGE_CONFIGURATION.md) +📘 **Language Configuration Guide:** [docs/guides/LANGUAGE_CONFIGURATION.md](docs/guides/LANGUAGE_CONFIGURATION.md) --- @@ -474,7 +538,7 @@ src/ │ ├── Services/ # Business logic │ └── Interfaces/ # Service contracts ├── Domain/ # Business entities & rules -│ ├── ValueObjects/ # Immutable data objects +│ ├── ValueObjects/ # Value objects │ └── Services/ # Domain interfaces ├── Infrastructure/ # External concerns │ ├── FileSystem/ # File operations @@ -518,15 +582,15 @@ dotnet test --collect:"XPlat Code Coverage" ### **User Guides** - 📖 [Complete Documentation](docs/README.md) - Full user manual -- 🌍 [Multi-language Configuration](docs/LANGUAGE_CONFIGURATION.md) -- 🐳 [Docker Usage Guide](docs/DOCKER_GUIDE.md) +- 🌍 [Multi-language Configuration](docs/guides/LANGUAGE_CONFIGURATION.md) +- 🐳 [Docker Usage Guide](docs/guides/DOCKER_GUIDE.md) - 📦 [Inno Setup Guide](docs/INNO_SETUP_GUIDE.md) ### **Developer Guides** - 🛠️ [Contributing Guide](CONTRIBUTING.md) - 📋 [Changelog](CHANGELOG.md) -- 🏗️ [Architecture Overview](docs/ARCHITECTURE.md) -- 🔄 [CI/CD Workflow](docs/CI_CD_WORKFLOW.md) +- 🏗️ [Architecture Overview](wiki/Architecture-Overview.md) +- 🔄 [CI/CD Workflow](docs/development/CI_CD_WORKFLOW.md) ### **Build & Deployment** - 🔨 [Build Scripts](scripts/README.md) @@ -655,8 +719,9 @@ ZPL2PDF/ ### **Debug Mode** ```bash -# Enable verbose logging -ZPL2PDF -i label.txt -o output/ --log-level Debug +# Enable verbose logging by setting "logLevel": "Debug" in `zpl2pdf.json` +# (then re-run your command) +ZPL2PDF -i label.txt -o output/ ``` ### **Get Help** diff --git a/ZPL2PDF.csproj b/ZPL2PDF.csproj index 9da5a31..d25469a 100644 --- a/ZPL2PDF.csproj +++ b/ZPL2PDF.csproj @@ -5,7 +5,7 @@ net9.0 enable enable - 3.1.0 + 3.1.1 bin\Debug\net9.0\ZPL2PDF.xml true diff --git a/docs/README.md b/docs/README.md index dbe6027..290fdf5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,7 +61,7 @@ Documentation for contributors and developers: - 🔄 [Git Workflow](development/GIT_WORKFLOW_GUIDE.md) - Git workflow and branching strategy ### Internal Documentation -- 🏗️ [Architecture Overview](development/ARCHITECTURE.md) *(Coming soon)* +- 🏗️ [Architecture Overview](../wiki/Architecture-Overview.md) - 📚 [API Documentation](development/API.md) *(Coming soon)* --- diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 5388e54..a888aae 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -431,7 +431,7 @@ docker run --rm -e ZPL2PDF_LANGUAGE=es-ES zpl2pdf:2.0.0 /app/ZPL2PDF -help cat > zpl2pdf.json <List of image data for PDF generation List ConvertWithExplicitDimensions(string zplContent, double width, double height, string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null); + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline); /// /// Converts ZPL content to PDF by extracting dimensions from ZPL @@ -33,7 +35,8 @@ List ConvertWithExplicitDimensions(string zplContent, double width, doub /// List of image data for PDF generation List ConvertWithExtractedDimensions(string zplContent, string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null); + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline); /// /// Converts ZPL content to PDF using mixed approach (explicit or extracted) @@ -47,6 +50,35 @@ List ConvertWithExtractedDimensions(string zplContent, string unit, int /// Optional font ID to path mappings /// List of image data for PDF generation List Convert(string zplContent, double explicitWidth, double explicitHeight, string unit, int dpi, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline); + + /// + /// Converts the entire ZPL template directly to a PDF using the Labelary API. + /// + byte[] ConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null); + + /// + /// Tries direct PDF conversion via Labelary according to renderer policy. + /// Returns false when direct mode is not enabled or when auto mode falls back. + /// Throws when renderer is Labelary and direct conversion fails. + /// + bool TryConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + RendererEngine rendererEngine, + out byte[]? pdfBytes, string? fontsDirectory = null, IReadOnlyList<(string Id, string Path)>? fontMappings = null); } diff --git a/src/Application/Services/ConversionService.cs b/src/Application/Services/ConversionService.cs index fcb377e..fa1812e 100644 --- a/src/Application/Services/ConversionService.cs +++ b/src/Application/Services/ConversionService.cs @@ -3,6 +3,7 @@ using ZPL2PDF.Application.Interfaces; using ZPL2PDF.Shared; using ZPL2PDF.Domain.Services; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF.Application.Services { @@ -44,14 +45,19 @@ private List PrepareLabels(string zplContent) /// public List ConvertWithExplicitDimensions(string zplContent, double width, double height, string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { var labels = PrepareLabels(zplContent); if (labels.Count == 0) return new List(); - var renderer = new LabelRenderer(width, height, dpi, unit, fontsDirectory, fontMappings); - return renderer.RenderLabels(labels); + return rendererEngine switch + { + RendererEngine.Labelary => new LabelaryRenderer(width, height, unit, dpi).RenderLabels(labels), + RendererEngine.Auto => new AutoRenderer(width, height, unit, dpi, fontsDirectory, fontMappings).RenderLabels(labels), + _ => new LabelRenderer(width, height, dpi, unit, fontsDirectory, fontMappings).RenderLabels(labels) + }; } /// @@ -59,7 +65,8 @@ public List ConvertWithExplicitDimensions(string zplContent, double widt /// public List ConvertWithExtractedDimensions(string zplContent, string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { var labels = PrepareLabels(zplContent); if (labels.Count == 0) @@ -73,8 +80,12 @@ public List ConvertWithExtractedDimensions(string zplContent, string uni var label = labels[i]; var labelDimensions = i < extractedDimensionsList.Count ? extractedDimensionsList[i] : extractedDimensionsList[0]; var finalDimensions = _dimensionExtractor.ApplyPriorityLogic(null, null, unit, labelDimensions, dpi); - var labelRenderer = new LabelRenderer(finalDimensions, fontsDirectory, fontMappings); - var labelImages = labelRenderer.RenderLabels(new List { label }); + var labelImages = rendererEngine switch + { + RendererEngine.Labelary => new LabelaryRenderer(finalDimensions).RenderLabels(new List { label }), + RendererEngine.Auto => new AutoRenderer(finalDimensions, fontsDirectory, fontMappings).RenderLabels(new List { label }), + _ => new LabelRenderer(finalDimensions, fontsDirectory, fontMappings).RenderLabels(new List { label }) + }; allImageData.AddRange(labelImages); } @@ -86,12 +97,94 @@ public List ConvertWithExtractedDimensions(string zplContent, string uni /// public List Convert(string zplContent, double explicitWidth, double explicitHeight, string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { bool hasExplicitDimensions = explicitWidth > 0 && explicitHeight > 0; if (hasExplicitDimensions) - return ConvertWithExplicitDimensions(zplContent, explicitWidth, explicitHeight, unit, dpi, fontsDirectory, fontMappings); - return ConvertWithExtractedDimensions(zplContent, unit, dpi, fontsDirectory, fontMappings); + return ConvertWithExplicitDimensions(zplContent, explicitWidth, explicitHeight, unit, dpi, fontsDirectory, fontMappings, rendererEngine); + return ConvertWithExtractedDimensions(zplContent, unit, dpi, fontsDirectory, fontMappings, rendererEngine); } + + /// + /// Converts the entire ZPL template directly to a PDF using the Labelary API + /// (Labelary returns application/pdf when requested via Accept header). + /// + public byte[] ConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + // Labelary handles label splitting internally for PDF requests (index omitted). + // We still preprocess for compatibility with BinaryKits workarounds (e.g. ^B0 -> ^BO). + var processedContent = LabelFileReader.PreprocessZpl(zplContent); + + if (explicitWidth > 0 && explicitHeight > 0) + { + return new LabelaryRenderer(explicitWidth, explicitHeight, unit, dpi).RenderPdf(processedContent); + } + + // Otherwise, extract dimensions and use the first label as a best-effort size for Labelary. + var extractedDimensionsList = _dimensionExtractor.ExtractDimensions(zplContent); + var firstExtracted = extractedDimensionsList.Count > 0 + ? extractedDimensionsList[0] + : _dimensionExtractor.GetDefaultDimensions(); + + var finalDimensions = _dimensionExtractor.ApplyPriorityLogic( + explicitWidth: null, + explicitHeight: null, + explicitUnit: unit, + zplDimensions: firstExtracted, + dpi: dpi); + + // Labelary width/height are expressed in inches; we pass mm and let LabelaryRenderer convert. + return new LabelaryRenderer(finalDimensions.WidthMm, finalDimensions.HeightMm, "mm", dpi) + .RenderPdf(processedContent); + } + + public bool TryConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + RendererEngine rendererEngine, + out byte[]? pdfBytes, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + pdfBytes = null; + if (rendererEngine != RendererEngine.Labelary && rendererEngine != RendererEngine.Auto) + { + return false; + } + + try + { + pdfBytes = ConvertPdfDirectWithLabelary( + zplContent, + explicitWidth, + explicitHeight, + unit, + dpi, + fontsDirectory, + fontMappings); + return true; + } + catch + { + if (rendererEngine == RendererEngine.Labelary) + { + throw; + } + + return false; + } + } + } } diff --git a/src/Application/Services/FileValidationService.cs b/src/Application/Services/FileValidationService.cs index a32a197..c51898b 100644 --- a/src/Application/Services/FileValidationService.cs +++ b/src/Application/Services/FileValidationService.cs @@ -12,7 +12,7 @@ public class FileValidationService : IFileValidationService /// /// Valid file extensions for ZPL processing /// - private static readonly string[] ValidExtensions = { ".txt", ".prn" }; + private static readonly string[] ValidExtensions = { ".txt", ".prn", ".zpl", ".imp" }; /// /// Checks if the file is valid for processing diff --git a/src/Domain/Services/IDimensionExtractor.cs b/src/Domain/Services/IDimensionExtractor.cs index 3471a58..e0b7784 100644 --- a/src/Domain/Services/IDimensionExtractor.cs +++ b/src/Domain/Services/IDimensionExtractor.cs @@ -12,14 +12,14 @@ public interface IDimensionExtractor /// /// ZPL content to analyze /// List of extracted dimensions for each label - List ExtractDimensions(string zplContent); + List ExtractDimensions(string zplContent); /// /// Extracts dimensions from a single ZPL label /// /// Single ZPL label string /// Extracted dimensions or null if not found - LabelDimensions ExtractDimensionsFromLabel(string zplLabel); + global::ZPL2PDF.LabelDimensions ExtractDimensionsFromLabel(string zplLabel); /// /// Applies priority logic to determine final dimensions @@ -30,7 +30,12 @@ public interface IDimensionExtractor /// Extracted dimensions from ZPL /// DPI to use for conversions /// Final dimensions to use - LabelDimensions ApplyPriorityLogic(double? explicitWidth, double? explicitHeight, string unit, LabelDimensions extractedDimensions, int dpi = 203); + global::ZPL2PDF.LabelDimensions ApplyPriorityLogic( + double? explicitWidth, + double? explicitHeight, + string unit, + global::ZPL2PDF.LabelDimensions extractedDimensions, + int dpi = 203); /// /// Converts points to millimeters @@ -41,14 +46,4 @@ public interface IDimensionExtractor double ConvertPointsToMm(int points, int dpi = 203); } - /// - /// Represents label dimensions - /// - public class LabelDimensions - { - public double Width { get; set; } - public double Height { get; set; } - public string Unit { get; set; } = "mm"; - public int Dpi { get; set; } = 203; - } } diff --git a/src/Domain/ValueObjects/DaemonConfiguration.cs b/src/Domain/ValueObjects/DaemonConfiguration.cs index ec26aba..4ff94d1 100644 --- a/src/Domain/ValueObjects/DaemonConfiguration.cs +++ b/src/Domain/ValueObjects/DaemonConfiguration.cs @@ -61,7 +61,7 @@ public class DaemonConfiguration /// /// Gets or sets the file filter pattern /// - public string FileFilter { get; set; } = "*.txt;*.prn"; + public string FileFilter { get; set; } = "*.txt;*.prn;*.zpl;*.imp"; /// /// Gets or sets the language/culture code (e.g., "pt-BR", "en-US", "es-ES") diff --git a/src/Domain/ValueObjects/FileInfo.cs b/src/Domain/ValueObjects/FileInfo.cs index 3441f70..df24980 100644 --- a/src/Domain/ValueObjects/FileInfo.cs +++ b/src/Domain/ValueObjects/FileInfo.cs @@ -139,7 +139,7 @@ public bool IsValid() /// True if valid, False otherwise public bool IsValidExtension() { - var validExtensions = new[] { ".txt", ".prn" }; + var validExtensions = new[] { ".txt", ".prn", ".zpl", ".imp" }; return Array.Exists(validExtensions, ext => ext.Equals(Extension, StringComparison.OrdinalIgnoreCase)); } @@ -157,7 +157,7 @@ public string GetValidationError() return "File extension cannot be null or empty"; if (!IsValidExtension()) - return $"Invalid file extension: {Extension}. Valid extensions are: .txt, .prn"; + return $"Invalid file extension: {Extension}. Valid extensions are: .txt, .prn, .zpl, .imp"; return string.Empty; } diff --git a/src/Domain/ValueObjects/LabelDimensions.cs b/src/Domain/ValueObjects/LabelDimensions.cs deleted file mode 100644 index 6846832..0000000 --- a/src/Domain/ValueObjects/LabelDimensions.cs +++ /dev/null @@ -1,233 +0,0 @@ -using System; -using ZPL2PDF.Shared.Constants; - -namespace ZPL2PDF.Domain.ValueObjects -{ - /// - /// Represents label dimensions with validation and conversion methods - /// - public class LabelDimensions - { - /// - /// Gets or sets the width - /// - public double Width { get; set; } - - /// - /// Gets or sets the height - /// - public double Height { get; set; } - - /// - /// Gets or sets the unit of measurement - /// - public string Unit { get; set; } = "mm"; - - /// - /// Gets or sets the print density in DPI - /// - public int Dpi { get; set; } = 203; - - /// - /// Initializes a new instance of LabelDimensions - /// - public LabelDimensions() - { - } - - /// - /// Initializes a new instance of LabelDimensions with values - /// - /// Width value - /// Height value - /// Unit of measurement - /// Print density - public LabelDimensions(double width, double height, string unit = "mm", int dpi = 203) - { - Width = width; - Height = height; - Unit = unit; - Dpi = dpi; - } - - /// - /// Validates the label dimensions - /// - /// True if valid, False otherwise - public bool IsValid() - { - if (Width <= 0) - return false; - - if (Height <= 0) - return false; - - if (string.IsNullOrWhiteSpace(Unit)) - return false; - - if (Dpi <= 0) - return false; - - return IsValidUnit(Unit); - } - - /// - /// Gets validation error message - /// - /// Error message if invalid, empty string if valid - public string GetValidationError() - { - if (Width <= 0) - return "Width must be greater than 0"; - - if (Height <= 0) - return "Height must be greater than 0"; - - if (string.IsNullOrWhiteSpace(Unit)) - return "Unit cannot be null or empty"; - - if (Dpi <= 0) - return "DPI must be greater than 0"; - - if (!IsValidUnit(Unit)) - return $"Invalid unit: {Unit}. Valid units are: mm, cm, in"; - - return string.Empty; - } - - /// - /// Checks if the unit is valid - /// - /// Unit to check - /// True if valid, False otherwise - public static bool IsValidUnit(string unit) - { - if (string.IsNullOrWhiteSpace(unit)) - return false; - - var validUnits = new[] { "mm", "cm", "in" }; - return Array.Exists(validUnits, u => - u.Equals(unit, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Converts dimensions to millimeters - /// - /// New LabelDimensions instance in millimeters - public LabelDimensions ToMillimeters() - { - if (Unit.Equals("mm", StringComparison.OrdinalIgnoreCase)) - return Clone(); - - var widthMm = ConvertToMillimeters(Width, Unit); - var heightMm = ConvertToMillimeters(Height, Unit); - - return new LabelDimensions(widthMm, heightMm, "mm", Dpi); - } - - /// - /// Converts dimensions to centimeters - /// - /// New LabelDimensions instance in centimeters - public LabelDimensions ToCentimeters() - { - var mmDimensions = ToMillimeters(); - return new LabelDimensions( - mmDimensions.Width / 10.0, - mmDimensions.Height / 10.0, - "cm", - Dpi - ); - } - - /// - /// Converts dimensions to inches - /// - /// New LabelDimensions instance in inches - public LabelDimensions ToInches() - { - var mmDimensions = ToMillimeters(); - return new LabelDimensions( - mmDimensions.Width / 25.4, - mmDimensions.Height / 25.4, - "in", - Dpi - ); - } - - /// - /// Converts a value to millimeters based on unit - /// - /// Value to convert - /// Source unit - /// Value in millimeters - private static double ConvertToMillimeters(double value, string fromUnit) - { - return fromUnit.ToLowerInvariant() switch - { - "mm" => value, - "cm" => value * 10.0, - "in" => value * 25.4, - _ => value - }; - } - - /// - /// Converts dimensions to points (for PDF generation) - /// - /// Tuple with width and height in points - public (int widthPoints, int heightPoints) ToPoints() - { - var mmDimensions = ToMillimeters(); - var widthPoints = (int)Math.Round((mmDimensions.Width / 25.4) * Dpi); - var heightPoints = (int)Math.Round((mmDimensions.Height / 25.4) * Dpi); - return (widthPoints, heightPoints); - } - - /// - /// Creates a copy of the label dimensions - /// - /// New instance with same values - public LabelDimensions Clone() - { - return new LabelDimensions(Width, Height, Unit, Dpi); - } - - /// - /// Returns a string representation of the label dimensions - /// - /// String representation - public override string ToString() - { - return $"LabelDimensions: {Width}x{Height} {Unit}, Print Density: {ApplicationConstants.ConvertDpiToDpmm(ApplicationConstants.DEFAULT_DPI):F1} dpmm ({ApplicationConstants.DEFAULT_DPI} dpi)"; - } - - /// - /// Determines if two LabelDimensions instances are equal - /// - /// Object to compare - /// True if equal, False otherwise - public override bool Equals(object? obj) - { - if (obj is LabelDimensions other) - { - return Math.Abs(Width - other.Width) < 0.001 && - Math.Abs(Height - other.Height) < 0.001 && - Unit.Equals(other.Unit, StringComparison.OrdinalIgnoreCase) && - Dpi == other.Dpi; - } - return false; - } - - /// - /// Gets hash code for the label dimensions - /// - /// Hash code - public override int GetHashCode() - { - // Align with Equals: unit comparison is ordinal-ignore-case. - var unitKey = Unit?.ToLowerInvariant() ?? string.Empty; - return HashCode.Combine(Width, Height, unitKey, Dpi); - } - } -} diff --git a/src/Infrastructure/DaemonManager.cs b/src/Infrastructure/DaemonManager.cs index 7282c78..b49c9e2 100644 --- a/src/Infrastructure/DaemonManager.cs +++ b/src/Infrastructure/DaemonManager.cs @@ -275,9 +275,7 @@ private void SaveDaemonInfoToFile(int pid) ProcessId = pid }; - // Get the directory where the PID file is stored - var pidFilePath = _pidManager.GetType().GetField("_pidFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(_pidManager)?.ToString(); - var infoFilePath = Path.Combine(Path.GetDirectoryName(pidFilePath ?? ""), "zpl2pdf.info"); + var infoFilePath = GetDaemonInfoFilePath(); // Save daemon info to file in a simple format var lines = new[] @@ -307,9 +305,7 @@ private DaemonInfo GetDaemonInfoFromFile() { try { - // Get the directory where the PID file is stored - var pidFilePath = _pidManager.GetType().GetField("_pidFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(_pidManager)?.ToString(); - var infoFilePath = Path.Combine(Path.GetDirectoryName(pidFilePath ?? ""), "zpl2pdf.info"); + var infoFilePath = GetDaemonInfoFilePath(); if (!File.Exists(infoFilePath)) { @@ -364,5 +360,16 @@ private DaemonInfo GetDaemonInfoFromFile() return null; } } + + private string GetDaemonInfoFilePath() + { + var pidDirectory = Path.GetDirectoryName(_pidManager.PidFilePath); + if (string.IsNullOrWhiteSpace(pidDirectory)) + { + return "zpl2pdf.info"; + } + + return Path.Combine(pidDirectory, "zpl2pdf.info"); + } } } \ No newline at end of file diff --git a/src/Infrastructure/FileSystem/FolderMonitor.cs b/src/Infrastructure/FileSystem/FolderMonitor.cs index e555d05..8dabde6 100644 --- a/src/Infrastructure/FileSystem/FolderMonitor.cs +++ b/src/Infrastructure/FileSystem/FolderMonitor.cs @@ -6,7 +6,7 @@ namespace ZPL2PDF { /// - /// Monitors a folder to detect .txt and .prn files and adds them to the processing queue + /// Monitors a folder to detect ZPL text files and adds them to the processing queue. /// public class FolderMonitor : IDisposable { @@ -110,7 +110,7 @@ public void StartWatching() Console.WriteLine($"Polling timer started (interval: {_pollingIntervalMs}ms)"); Console.WriteLine($"Monitoring folder: {_listenFolder}"); - Console.WriteLine($"File types: .txt, .prn"); + Console.WriteLine($"File types: .txt, .prn, .zpl, .imp"); Console.WriteLine($"Dimensions: {(_useFixedDimensions ? "Fixed" : "Extracted from ZPL")}"); if (_useFixedDimensions) @@ -399,7 +399,7 @@ private async Task ProcessFileAsync(string filePath, string eventType) private bool IsValidFile(string filePath) { var extension = Path.GetExtension(filePath).ToLowerInvariant(); - return extension == ".txt" || extension == ".prn"; + return extension == ".txt" || extension == ".prn" || extension == ".zpl" || extension == ".imp"; } /// diff --git a/src/Infrastructure/PidManager.cs b/src/Infrastructure/PidManager.cs index 727ec79..41deb19 100644 --- a/src/Infrastructure/PidManager.cs +++ b/src/Infrastructure/PidManager.cs @@ -9,6 +9,7 @@ namespace ZPL2PDF public class PidManager { private readonly string _pidFilePath; + public string PidFilePath => _pidFilePath; /// /// Initializes a new instance of the PidManager (default PID file: zpl2pdf.pid). diff --git a/src/Infrastructure/Processing/ProcessingQueue.cs b/src/Infrastructure/Processing/ProcessingQueue.cs index 051fc29..6c1051b 100644 --- a/src/Infrastructure/Processing/ProcessingQueue.cs +++ b/src/Infrastructure/Processing/ProcessingQueue.cs @@ -2,6 +2,10 @@ using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; +using ZPL2PDF.Application.Interfaces; +using ZPL2PDF.Application.Services; +using ZPL2PDF.Domain.Services; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF { @@ -17,6 +21,8 @@ public class ProcessingQueue : IDisposable private readonly ZplDimensionExtractor _dimensionExtractor; private readonly ConfigManager _configManager; private readonly string? _customOutputFolder; + private readonly RendererEngine _rendererEngine; + private readonly IConversionService _conversionService; private bool _isDisposed = false; private bool _isProcessing = false; @@ -50,11 +56,35 @@ public ProcessingQueue(ZplDimensionExtractor dimensionExtractor, ConfigManager c _dimensionExtractor = dimensionExtractor; _configManager = configManager; _customOutputFolder = customOutputFolder; + _rendererEngine = RendererEngine.Offline; + _conversionService = new ConversionService(); // Start processing task _processingTask = Task.Run(ProcessQueueAsync); } + /// + /// ProcessingQueue constructor (with renderer engine). + /// + public ProcessingQueue( + ZplDimensionExtractor dimensionExtractor, + ConfigManager configManager, + int maxConcurrentFiles, + string? customOutputFolder, + RendererEngine rendererEngine) + { + _queue = new ConcurrentQueue(); + _semaphore = new SemaphoreSlim(maxConcurrentFiles, maxConcurrentFiles); + _cancellationTokenSource = new CancellationTokenSource(); + _dimensionExtractor = dimensionExtractor; + _configManager = configManager; + _customOutputFolder = customOutputFolder; + _rendererEngine = rendererEngine; + _conversionService = new ConversionService(); + + _processingTask = Task.Run(ProcessQueueAsync); + } + /// /// Adds a file to the processing queue /// @@ -207,22 +237,40 @@ private async Task ConvertFileAsync(ProcessingItem item) item.Dimensions = finalDimensions; } - - for (int i = 0; i < labels.Count; i++) + + // Output file info (used by both Labelary-direct PDF and PNG->PDF fallback). + var outputFileName = Path.ChangeExtension(item.FileName, ".pdf"); + var outputFolder = _customOutputFolder ?? Path.GetDirectoryName(item.FilePath)!; + var outputPath = Path.Combine(outputFolder, outputFileName); + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + // If requested, try to return PDF directly from Labelary (no PNG composition). + if (_conversionService.TryConvertPdfDirectWithLabelary( + item.Content, + finalDimensions.WidthMm, + finalDimensions.HeightMm, + "mm", + finalDimensions.Dpi, + _rendererEngine, + out var directPdfBytes)) { - var label = labels[i]; - - // Use the computed dimensions for this file. - // Debug: Log what we're using - //Console.WriteLine($"DEBUG - Using dimensions for label {i + 1}: {finalDimensions.WidthMm:F1}mm x {finalDimensions.HeightMm:F1}mm [{finalDimensions.Source}]"); - - // Create specific renderer for this label - var labelRenderer = new LabelRenderer(finalDimensions); - var labelImages = labelRenderer.RenderLabels(new List { label }); - allImageData.AddRange(labelImages); - - Console.WriteLine($"Label {i + 1}: {finalDimensions.WidthMm:F1}mm x {finalDimensions.HeightMm:F1}mm [{finalDimensions.Source}]"); + File.WriteAllBytes(outputPath, directPdfBytes!); + var directFileInfo = new FileInfo(outputPath); + Console.WriteLine($"PDF generated successfully: {outputFileName} ({directFileInfo.Length} bytes)"); + return true; } + + var renderer = _rendererEngine switch + { + RendererEngine.Labelary => (ILabelRenderer)new LabelaryRenderer(finalDimensions), + RendererEngine.Auto => (ILabelRenderer)new AutoRenderer(finalDimensions), + _ => (ILabelRenderer)new LabelRenderer(finalDimensions) + }; + + var labelImages = renderer.RenderLabels(labels); + allImageData.AddRange(labelImages); + Console.WriteLine($"Rendered {labels.Count} label(s) using renderer: {_rendererEngine}"); if (allImageData.Count == 0) { @@ -230,17 +278,8 @@ private async Task ConvertFileAsync(ProcessingItem item) return false; } - // Generate PDF - var outputFileName = Path.ChangeExtension(item.FileName, ".pdf"); - var outputFolder = _customOutputFolder ?? Path.GetDirectoryName(item.FilePath)!; - var outputPath = Path.Combine(outputFolder, outputFileName); - Console.WriteLine($"Output folder: {outputFolder}"); Console.WriteLine($"Output path: {outputPath}"); - - // Ensure destination folder exists - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); - Console.WriteLine($"Calling PdfGenerator.GeneratePdf with {allImageData.Count} images..."); PdfGenerator.GeneratePdf(allImageData, outputPath); diff --git a/src/Infrastructure/Rendering/AutoRenderer.cs b/src/Infrastructure/Rendering/AutoRenderer.cs new file mode 100644 index 0000000..65658cb --- /dev/null +++ b/src/Infrastructure/Rendering/AutoRenderer.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using ZPL2PDF.Domain.Services; + +namespace ZPL2PDF +{ + /// + /// Renderer that tries Labelary first and falls back to BinaryKits offline rendering per label. + /// + public class AutoRenderer : ILabelRenderer + { + private readonly LabelaryRenderer _labelaryRenderer; + private readonly LabelRenderer _offlineRenderer; + + public AutoRenderer( + double labelWidth, + double labelHeight, + string unit, + int dpi, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + _labelaryRenderer = new LabelaryRenderer(labelWidth, labelHeight, unit, dpi); + _offlineRenderer = new LabelRenderer(labelWidth, labelHeight, dpi, unit, fontsDirectory, fontMappings); + } + + public AutoRenderer( + LabelDimensions dimensions, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + _labelaryRenderer = new LabelaryRenderer(dimensions); + _offlineRenderer = new LabelRenderer(dimensions, fontsDirectory, fontMappings); + } + + public (double width, double height, string unit, int dpi) GetDimensions() + => _offlineRenderer.GetDimensions(); + + public List RenderLabels(List labels) + { + if (labels == null) throw new ArgumentNullException(nameof(labels)); + + var images = new List(); + foreach (var label in labels) + { + images.Add(RenderSingleLabel(label)); + } + return images; + } + + private byte[] RenderSingleLabel(string zpl) + { + try + { + var res = _labelaryRenderer.RenderLabels(new List { zpl }); + return res.Count > 0 ? res[0] : Array.Empty(); + } + catch + { + var res = _offlineRenderer.RenderLabels(new List { zpl }); + return res.Count > 0 ? res[0] : Array.Empty(); + } + } + } +} + diff --git a/src/Infrastructure/Rendering/LabelRenderer.cs b/src/Infrastructure/Rendering/LabelRenderer.cs index b1b2e25..04ead94 100644 --- a/src/Infrastructure/Rendering/LabelRenderer.cs +++ b/src/Infrastructure/Rendering/LabelRenderer.cs @@ -6,18 +6,23 @@ using BinaryKits.Zpl.Viewer; using BinaryKits.Zpl.Viewer.ElementDrawers; using SkiaSharp; +using ZPL2PDF.Domain.Services; namespace ZPL2PDF { /// /// Responsible for processing labels, generating images in memory, and returning image data. /// - public class LabelRenderer { + public class LabelRenderer : ILabelRenderer { private readonly IPrinterStorage _printerStorage; private readonly ZplAnalyzer _analyzer; private readonly ZplElementDrawer _drawer; private readonly double _labelWidthMm; private readonly double _labelHeightMm; private readonly int _printDpi; + private readonly double _labelWidthInput; + private readonly double _labelHeightInput; + private readonly string _labelUnitInput; + private readonly int _labelDpi; private readonly string? _fontsDirectory; private readonly IReadOnlyList<(string Id, string Path)>? _fontMappings; @@ -113,6 +118,10 @@ public LabelRenderer(double labelWidth, double labelHeight, int printDpi, string // Store DPI (will be converted to DPMM when rendering) _printDpi = printDpi; + _labelWidthInput = labelWidth; + _labelHeightInput = labelHeight; + _labelUnitInput = unit; + _labelDpi = printDpi; } /// @@ -132,6 +141,10 @@ public LabelRenderer(LabelDimensions dimensions, _labelWidthMm = dimensions.WidthMm; _labelHeightMm = dimensions.HeightMm; _printDpi = dimensions.Dpi; + _labelWidthInput = dimensions.WidthMm; + _labelHeightInput = dimensions.HeightMm; + _labelUnitInput = "mm"; + _labelDpi = dimensions.Dpi; } /// @@ -178,5 +191,11 @@ public List RenderLabels(List labels) { } return images; } + + /// + public (double width, double height, string unit, int dpi) GetDimensions() + { + return (_labelWidthInput, _labelHeightInput, _labelUnitInput, _labelDpi); + } } } \ No newline at end of file diff --git a/src/Infrastructure/Rendering/LabelaryRenderer.cs b/src/Infrastructure/Rendering/LabelaryRenderer.cs new file mode 100644 index 0000000..cec3b4c --- /dev/null +++ b/src/Infrastructure/Rendering/LabelaryRenderer.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using ZPL2PDF.Domain.Services; + +namespace ZPL2PDF +{ + /// + /// Renders ZPL labels using Labelary online API (returns PNG bytes). + /// + public class LabelaryRenderer : ILabelRenderer + { + private static readonly HttpClient HttpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + private static readonly int[] AllowedDpmm = { 6, 8, 12, 24 }; + + private readonly double _labelWidth; + private readonly double _labelHeight; + private readonly string _unit; + private readonly int _dpi; + + public LabelaryRenderer(double labelWidth, double labelHeight, string unit, int dpi) + { + _labelWidth = labelWidth; + _labelHeight = labelHeight; + _unit = unit; + _dpi = dpi; + } + + // Extracted dimensions are already in millimeters. + public LabelaryRenderer(LabelDimensions dimensions) + { + _labelWidth = dimensions.WidthMm; + _labelHeight = dimensions.HeightMm; + _unit = "mm"; + _dpi = dimensions.Dpi; + } + + public (double width, double height, string unit, int dpi) GetDimensions() + => (_labelWidth, _labelHeight, _unit, _dpi); + + public List RenderLabels(List labels) + { + if (labels == null) throw new ArgumentNullException(nameof(labels)); + + var images = new List(); + foreach (var label in labels) + { + images.Add(RenderSingleLabel(label)); + } + return images; + } + + /// + /// Renders the entire ZPL template to a PDF directly from Labelary. + /// This requests application/pdf and omits the label index so Labelary can include all labels. + /// + public byte[] RenderPdf(string zpl) + { + if (string.IsNullOrWhiteSpace(zpl)) + throw new ArgumentException("ZPL label content is required.", nameof(zpl)); + + var dpmm = MapDpiToDpmm(_dpi); + var widthInches = ConvertToInches(_labelWidth, _unit); + var heightInches = ConvertToInches(_labelHeight, _unit); + + // For PDF requests, the index parameter is optional. + // If omitted, Labelary returns a PDF with all labels (one page per label). + var width = widthInches.ToString("0.###", CultureInfo.InvariantCulture); + var height = heightInches.ToString("0.###", CultureInfo.InvariantCulture); + + var url = $"http://api.labelary.com/v1/printers/{dpmm}dpmm/labels/{width}x{height}/"; + + using var content = new StringContent(zpl, Encoding.UTF8, "application/x-www-form-urlencoded"); + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; + + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/pdf")); + + using var response = HttpClient.SendAsync(request).GetAwaiter().GetResult(); + var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + + if (!response.IsSuccessStatusCode) + { + var msg = Encoding.UTF8.GetString(bytes); + throw new InvalidOperationException($"Labelary request failed (HTTP {(int)response.StatusCode}): {msg}"); + } + + return bytes; + } + + private byte[] RenderSingleLabel(string zpl) + { + if (string.IsNullOrWhiteSpace(zpl)) + throw new ArgumentException("ZPL label content is required.", nameof(zpl)); + + var dpmm = MapDpiToDpmm(_dpi); + var widthInches = ConvertToInches(_labelWidth, _unit); + var heightInches = ConvertToInches(_labelHeight, _unit); + + // Labelary expects width/height in inches in the URL (any numeric value is allowed). + var width = widthInches.ToString("0.###", CultureInfo.InvariantCulture); + var height = heightInches.ToString("0.###", CultureInfo.InvariantCulture); + + // Labelary docs (service.html): POST http://api.labelary.com/v1/printers/{dpmm}dpmm/labels/{width}x{height}/{index}/ + var url = $"http://api.labelary.com/v1/printers/{dpmm}dpmm/labels/{width}x{height}/0/"; + + using var content = new StringContent(zpl, Encoding.UTF8, "application/x-www-form-urlencoded"); + + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = content + }; + + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("image/png")); + + using var response = HttpClient.SendAsync(request).GetAwaiter().GetResult(); + var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + + if (!response.IsSuccessStatusCode) + { + // Error bodies are small (typically UTF-8 text). + var msg = Encoding.UTF8.GetString(bytes); + throw new InvalidOperationException($"Labelary request failed (HTTP {(int)response.StatusCode}): {msg}"); + } + + return bytes; + } + + private static double ConvertToInches(double value, string unit) + { + if (unit.Equals("in", StringComparison.OrdinalIgnoreCase)) + return value; + if (unit.Equals("cm", StringComparison.OrdinalIgnoreCase)) + return value / 2.54; + if (unit.Equals("mm", StringComparison.OrdinalIgnoreCase)) + return value / 25.4; + + // Fallback: treat as mm. + return value / 25.4; + } + + private static int MapDpiToDpmm(int dpi) + { + // dpmm = dpi / 25.4, but Labelary only accepts 6/8/12/24. + var dpmmRaw = dpi / 25.4; + var best = AllowedDpmm[0]; + var bestDelta = Math.Abs(best - dpmmRaw); + + foreach (var candidate in AllowedDpmm) + { + var delta = Math.Abs(candidate - dpmmRaw); + if (delta < bestDelta) + { + best = candidate; + bestDelta = delta; + } + } + + return best; + } + } +} + diff --git a/src/Infrastructure/Rendering/PdfGenerator.cs b/src/Infrastructure/Rendering/PdfGenerator.cs index 902387a..9aeffa7 100644 --- a/src/Infrastructure/Rendering/PdfGenerator.cs +++ b/src/Infrastructure/Rendering/PdfGenerator.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; using PdfSharp.Drawing; namespace ZPL2PDF { @@ -65,5 +66,31 @@ public static byte[] GeneratePdfToBytes(List imageDataList) { } } } + + /// + /// Merges multiple PDF documents (given as bytes) into a single PDF. + /// + public static byte[] MergePdfsToBytes(List pdfDocuments) + { + using var outputDocument = new PdfDocument(); + + foreach (var pdfBytes in pdfDocuments) + { + if (pdfBytes == null || pdfBytes.Length == 0) + continue; + + using var ms = new MemoryStream(pdfBytes); + using var inputDocument = PdfReader.Open(ms, PdfDocumentOpenMode.Import); + + for (int i = 0; i < inputDocument.PageCount; i++) + { + outputDocument.AddPage(inputDocument.Pages[i]); + } + } + + using var outStream = new MemoryStream(); + outputDocument.Save(outStream, false); + return outStream.ToArray(); + } } } \ No newline at end of file diff --git a/src/Infrastructure/TcpServer/TcpPrinterServer.cs b/src/Infrastructure/TcpServer/TcpPrinterServer.cs index 283c15e..1fe6f49 100644 --- a/src/Infrastructure/TcpServer/TcpPrinterServer.cs +++ b/src/Infrastructure/TcpServer/TcpPrinterServer.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading; using ZPL2PDF.Application.Services; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF { @@ -17,17 +18,19 @@ public class TcpPrinterServer private readonly int _port; private readonly string _outputFolder; private readonly ConversionService _conversionService; + private readonly RendererEngine _rendererEngine; private TcpListener? _listener; private volatile bool _running; private const int ReadTimeoutMs = 30000; private const int DefaultDpi = 203; private const string DefaultUnit = "mm"; - public TcpPrinterServer(int port, string outputFolder) + public TcpPrinterServer(int port, string outputFolder, RendererEngine rendererEngine = RendererEngine.Offline) { _port = port; _outputFolder = outputFolder ?? throw new ArgumentNullException(nameof(outputFolder)); _conversionService = new ConversionService(); + _rendererEngine = rendererEngine; } /// @@ -109,15 +112,31 @@ private void ProcessClient(TcpClient client) try { - var imageDataList = _conversionService.ConvertWithExtractedDimensions(zplContent, DefaultUnit, DefaultDpi); - if (imageDataList == null || imageDataList.Count == 0) + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var fileName = $"ZPL2PDF_TCP_{timestamp}.pdf"; + var outputPath = Path.Combine(_outputFolder, fileName); + + if (_conversionService.TryConvertPdfDirectWithLabelary( + zplContent, + explicitWidth: 0, + explicitHeight: 0, + unit: DefaultUnit, + dpi: DefaultDpi, + rendererEngine: _rendererEngine, + out var pdfBytes)) { + File.WriteAllBytes(outputPath, pdfBytes!); return; } - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - var fileName = $"ZPL2PDF_TCP_{timestamp}.pdf"; - var outputPath = Path.Combine(_outputFolder, fileName); + var imageDataList = _conversionService.ConvertWithExtractedDimensions( + zplContent, + DefaultUnit, + DefaultDpi, + rendererEngine: _rendererEngine); + if (imageDataList == null || imageDataList.Count == 0) + return; + PdfGenerator.GeneratePdf(imageDataList, outputPath); } catch (Exception ex) diff --git a/src/Presentation/Api/Models/ConvertRequest.cs b/src/Presentation/Api/Models/ConvertRequest.cs index 3ae1700..63b3ef6 100644 --- a/src/Presentation/Api/Models/ConvertRequest.cs +++ b/src/Presentation/Api/Models/ConvertRequest.cs @@ -43,5 +43,10 @@ public class ConvertRequest /// Print density in DPI (optional, default: 203) /// public int? Dpi { get; set; } + + /// + /// Rendering engine: "offline" (default), "labelary", or "auto" + /// + public string Renderer { get; set; } = "offline"; } } diff --git a/src/Presentation/ArgumentParser.cs b/src/Presentation/ArgumentParser.cs index 16d56bb..d5797d8 100644 --- a/src/Presentation/ArgumentParser.cs +++ b/src/Presentation/ArgumentParser.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF { @@ -87,6 +88,20 @@ public ConversionArguments ParseConversionMode(string[] args, int startIndex) case "--stdout": result.StandardOutput = true; break; + case "--renderer": + if (!string.IsNullOrWhiteSpace(nextArg)) + { + var renderer = nextArg.Trim().ToLowerInvariant(); + result.RendererEngine = renderer switch + { + "offline" => RendererEngine.Offline, + "labelary" => RendererEngine.Labelary, + "auto" => RendererEngine.Auto, + _ => result.RendererEngine + }; + i++; // Skip next argument as it's the value + } + break; case "-d": if (i + 1 < args.Length && int.TryParse(args[i + 1], out int dpi)) { @@ -190,6 +205,20 @@ public DaemonArguments ParseDaemonMode(string[] args, int startIndex) i++; // Skip next argument as it's the value } break; + case "--renderer": + if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1])) + { + var renderer = args[i + 1].Trim().ToLowerInvariant(); + result.RendererEngine = renderer switch + { + "offline" => RendererEngine.Offline, + "labelary" => RendererEngine.Labelary, + "auto" => RendererEngine.Auto, + _ => result.RendererEngine + }; + i++; // Skip next argument as it's the value + } + break; } } @@ -240,6 +269,18 @@ public ServerArguments ParseServerMode(string[] args, int startIndex) result.OutputFolder = args[i + 1]; i++; } + else if (arg.Equals("--renderer", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1])) + { + var renderer = args[i + 1].Trim().ToLowerInvariant(); + result.RendererEngine = renderer switch + { + "offline" => RendererEngine.Offline, + "labelary" => RendererEngine.Labelary, + "auto" => RendererEngine.Auto, + _ => result.RendererEngine + }; + i++; + } else if (arg.Equals("--foreground", StringComparison.OrdinalIgnoreCase)) { result.Foreground = true; @@ -287,6 +328,8 @@ public class ConversionArguments public int Dpi { get; set; } = 203; /// When enabled, the conversion writes PDF bytes to stdout. public bool StandardOutput { get; set; } = false; + /// Rendering engine selection (offline/labelary/auto). + public RendererEngine RendererEngine { get; set; } = RendererEngine.Offline; /// Directory containing TTF/OTF fonts (e.g. for --fonts-dir). public string FontsDirectory { get; set; } = string.Empty; /// Font ID to file path (e.g. A=arial.ttf, B=another.ttf). @@ -303,6 +346,8 @@ public class DaemonArguments public double Height { get; set; } = 0; public string Unit { get; set; } = "mm"; public int Dpi { get; set; } = 203; + /// Rendering engine selection (offline/labelary/auto). + public RendererEngine RendererEngine { get; set; } = RendererEngine.Offline; } /// @@ -313,5 +358,7 @@ public class ServerArguments public int Port { get; set; } = 9101; public string OutputFolder { get; set; } = string.Empty; public bool Foreground { get; set; } = false; + /// Rendering engine selection (offline/labelary/auto). + public RendererEngine RendererEngine { get; set; } = RendererEngine.Offline; } } diff --git a/src/Presentation/ArgumentProcessor.cs b/src/Presentation/ArgumentProcessor.cs index 5f52c74..ed881be 100644 --- a/src/Presentation/ArgumentProcessor.cs +++ b/src/Presentation/ArgumentProcessor.cs @@ -85,6 +85,11 @@ public ArgumentProcessor() /// public bool ServerForeground { get; private set; } = false; + /// + /// Rendering engine selection (offline/labelary/auto). + /// + public RendererEngine RendererEngine { get; private set; } = RendererEngine.Offline; + /// /// Gets or sets the input file path. /// @@ -218,6 +223,7 @@ private void ProcessConversionMode(string[] args) Height = conversionArgs.Height; Unit = conversionArgs.Unit; Dpi = conversionArgs.Dpi; + RendererEngine = conversionArgs.RendererEngine; FontsDirectory = conversionArgs.FontsDirectory ?? string.Empty; FontMappings = conversionArgs.FontMappings ?? new List<(string, string)>(); @@ -287,6 +293,7 @@ private void ProcessDaemonMode(string[] args) Unit = daemonArgs.Unit; Dpi = daemonArgs.Dpi; + RendererEngine = daemonArgs.RendererEngine; // Only validate dimensions if they were explicitly provided (not auto-applied) // Check if dimensions are different from defaults (indicating explicit user input) @@ -322,6 +329,7 @@ private void ProcessServerMode(string[] args) ServerPort = serverArgs.Port; ServerOutputFolder = serverArgs.OutputFolder; ServerForeground = serverArgs.Foreground; + RendererEngine = serverArgs.RendererEngine; if (ServerCommand == "start") { diff --git a/src/Presentation/ArgumentValidator.cs b/src/Presentation/ArgumentValidator.cs index 9ce8ef1..55967fd 100644 --- a/src/Presentation/ArgumentValidator.cs +++ b/src/Presentation/ArgumentValidator.cs @@ -50,9 +50,9 @@ public class ArgumentValidator } var extension = Path.GetExtension(inputFilePath).ToLowerInvariant(); - if (extension != ".txt" && extension != ".prn") + if (extension != ".txt" && extension != ".prn" && extension != ".zpl" && extension != ".imp") { - return (false, "Input file must be .txt or .prn"); + return (false, "Input file must be .txt, .prn, .zpl or .imp"); } } diff --git a/src/Presentation/ConversionModeHandler.cs b/src/Presentation/ConversionModeHandler.cs index 625d299..3c75b58 100644 --- a/src/Presentation/ConversionModeHandler.cs +++ b/src/Presentation/ConversionModeHandler.cs @@ -3,6 +3,7 @@ using System.IO; using ZPL2PDF.Shared; using ZPL2PDF.Domain.Services; +using ZPL2PDF.Shared.Constants; using ZPL2PDF.Application.Interfaces; using ZPL2PDF.Application.Services; @@ -44,6 +45,33 @@ public void HandleConversion(ArgumentProcessor argumentProcessor) fileContent = argumentProcessor.ZplContent; } + // If requested, try direct PDF generation via Labelary. + if (_conversionService.TryConvertPdfDirectWithLabelary( + fileContent, + argumentProcessor.Width, + argumentProcessor.Height, + argumentProcessor.Unit, + argumentProcessor.Dpi, + argumentProcessor.RendererEngine, + out var pdfBytes, + string.IsNullOrWhiteSpace(argumentProcessor.FontsDirectory) ? null : argumentProcessor.FontsDirectory, + argumentProcessor.FontMappings?.Count > 0 ? argumentProcessor.FontMappings : null)) + { + if (argumentProcessor.StandardOutput) + { + Stream stdout = Console.OpenStandardOutput(); + stdout.Write(pdfBytes!, 0, pdfBytes!.Length); + stdout.Flush(); + return; + } + + _pathService.EnsureDirectoryExists(argumentProcessor.OutputFolderPath); + string outputPdf = Path.Combine(argumentProcessor.OutputFolderPath, argumentProcessor.OutputFileName); + File.WriteAllBytes(outputPdf, pdfBytes!); + Console.WriteLine($"PDF generated successfully: {outputPdf}"); + return; + } + var imageDataList = _conversionService.Convert( fileContent, argumentProcessor.Width, @@ -51,10 +79,10 @@ public void HandleConversion(ArgumentProcessor argumentProcessor) argumentProcessor.Unit, argumentProcessor.Dpi, string.IsNullOrWhiteSpace(argumentProcessor.FontsDirectory) ? null : argumentProcessor.FontsDirectory, - argumentProcessor.FontMappings?.Count > 0 ? argumentProcessor.FontMappings : null - ); + argumentProcessor.FontMappings?.Count > 0 ? argumentProcessor.FontMappings : null, + argumentProcessor.RendererEngine); - // Process the generated images + // Process the generated PNG images and compose the final PDF locally. ProcessImages(imageDataList, argumentProcessor); } catch (FileNotFoundException ex) diff --git a/src/Presentation/DaemonModeHandler.cs b/src/Presentation/DaemonModeHandler.cs index 920fb13..65ad715 100644 --- a/src/Presentation/DaemonModeHandler.cs +++ b/src/Presentation/DaemonModeHandler.cs @@ -64,7 +64,12 @@ private async Task RunDaemonMode(ArgumentProcessor argumentProcessor) var configManager = new ConfigManager(); // Create processing queue - var processingQueue = new ProcessingQueue(dimensionExtractor, configManager); + var processingQueue = new ProcessingQueue( + dimensionExtractor, + configManager, + maxConcurrentFiles: 1, + customOutputFolder: null, + rendererEngine: argumentProcessor.RendererEngine); // Create fixed dimensions if using fixed dimensions LabelDimensions? fixedDimensions = null; diff --git a/src/Presentation/Program.cs b/src/Presentation/Program.cs index 9f2c7e5..64802b8 100644 --- a/src/Presentation/Program.cs +++ b/src/Presentation/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Cors.Infrastructure; using ZPL2PDF.Shared.Localization; +using ZPL2PDF.Shared.Constants; using ZPL2PDF.Presentation.Api.Models; using ZPL2PDF.Application.Services; @@ -224,37 +225,40 @@ static async Task StartApiMode(string[] args) var unit = request.Unit ?? "mm"; var dpi = request.Dpi ?? 203; + // Renderer selection ("offline" | "labelary" | "auto") + if (!TryGetRendererEngine(request.Renderer, out var rendererEngine)) + { + await WriteBadRequest(context, "Renderer must be 'offline', 'labelary', or 'auto'"); + return; + } + // Convert ZPL(s) var conversionService = new ConversionService(); - var imageDataList = new List(); - if (!string.IsNullOrWhiteSpace(request.Zpl)) + // When format=pdf, try direct PDF via Labelary according to renderer policy. + // In auto mode, failures fall back to PNG pipeline. + if (format == "pdf" && TryConvertDirectPdfResponse(request, conversionService, width, height, unit, dpi, rendererEngine, out var directPdfBytes, out var pages)) { - // Single ZPL string (can contain multiple labels) - imageDataList = conversionService.Convert(request.Zpl, width, height, unit, dpi); - } - else if (request.ZplArray != null && request.ZplArray.Count > 0) - { - // Multiple ZPL strings - convert each and combine - foreach (var zpl in request.ZplArray) + var directResponse = new ConvertResponse { - if (!string.IsNullOrWhiteSpace(zpl)) - { - var images = conversionService.Convert(zpl, width, height, unit, dpi); - imageDataList.AddRange(images); - } - } + Success = true, + Format = format, + Pdf = Convert.ToBase64String(directPdfBytes!), + Pages = pages, + Message = "Conversion successful" + }; + + context.Response.StatusCode = 200; + await context.Response.WriteAsJsonAsync(directResponse); + return; } + // PNG pipeline (also used as fallback for PDF). + var imageDataList = ConvertToImages(request, conversionService, width, height, unit, dpi, rendererEngine); + if (imageDataList == null || imageDataList.Count == 0) { - var errorResponse = new ConvertResponse - { - Success = false, - Message = "No images generated from ZPL content" - }; - context.Response.StatusCode = 400; - await context.Response.WriteAsJsonAsync(errorResponse); + await WriteBadRequest(context, "No images generated from ZPL content"); return; } @@ -269,21 +273,17 @@ static async Task StartApiMode(string[] args) if (format == "pdf") { - // Generate PDF from images var pdfBytes = PdfGenerator.GeneratePdfToBytes(imageDataList); response.Pdf = Convert.ToBase64String(pdfBytes); } else // png { - // Convert images to base64 if (imageDataList.Count == 1) { - // Single image response.Image = Convert.ToBase64String(imageDataList[0]); } else { - // Multiple images response.Images = imageDataList.Select(img => Convert.ToBase64String(img)).ToList(); } } @@ -313,5 +313,129 @@ static async Task StartApiMode(string[] args) await app.RunAsync($"http://{host}:{port}"); } + + private static async Task WriteBadRequest(HttpContext context, string message) + { + var errorResponse = new ConvertResponse + { + Success = false, + Message = message + }; + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync(errorResponse); + } + + private static bool TryGetRendererEngine(string? rendererRaw, out RendererEngine rendererEngine) + { + var renderer = (rendererRaw ?? "offline").Trim().ToLowerInvariant(); + rendererEngine = renderer switch + { + "offline" => RendererEngine.Offline, + "labelary" => RendererEngine.Labelary, + "auto" => RendererEngine.Auto, + _ => RendererEngine.Offline + }; + + return renderer == "offline" || renderer == "labelary" || renderer == "auto"; + } + + private static bool TryConvertDirectPdfResponse( + ConvertRequest request, + ConversionService conversionService, + double width, + double height, + string unit, + int dpi, + RendererEngine rendererEngine, + out byte[]? directPdfBytes, + out int pages) + { + directPdfBytes = null; + pages = 0; + + if (!string.IsNullOrWhiteSpace(request.Zpl)) + { + var preprocessed = LabelFileReader.PreprocessZpl(request.Zpl); + pages = LabelFileReader.SplitLabels(preprocessed).Count; + + conversionService.TryConvertPdfDirectWithLabelary( + request.Zpl, + width, + height, + unit, + dpi, + rendererEngine, + out directPdfBytes); + } + else if (request.ZplArray != null && request.ZplArray.Count > 0) + { + var pdfParts = new List(); + foreach (var zpl in request.ZplArray) + { + if (string.IsNullOrWhiteSpace(zpl)) + continue; + + var preprocessed = LabelFileReader.PreprocessZpl(zpl); + pages += LabelFileReader.SplitLabels(preprocessed).Count; + + if (!conversionService.TryConvertPdfDirectWithLabelary( + zpl, + width, + height, + unit, + dpi, + rendererEngine, + out var partPdfBytes)) + { + pdfParts.Clear(); + break; + } + + pdfParts.Add(partPdfBytes!); + } + + if (pdfParts.Count > 0) + { + directPdfBytes = pdfParts.Count == 1 + ? pdfParts[0] + : PdfGenerator.MergePdfsToBytes(pdfParts); + } + } + + return directPdfBytes != null; + } + + private static List ConvertToImages( + ConvertRequest request, + ConversionService conversionService, + double width, + double height, + string unit, + int dpi, + RendererEngine rendererEngine) + { + var imageDataList = new List(); + + if (!string.IsNullOrWhiteSpace(request.Zpl)) + { + return conversionService.Convert(request.Zpl, width, height, unit, dpi, rendererEngine: rendererEngine); + } + + if (request.ZplArray == null || request.ZplArray.Count == 0) + { + return imageDataList; + } + + foreach (var zpl in request.ZplArray) + { + if (string.IsNullOrWhiteSpace(zpl)) + continue; + + var images = conversionService.Convert(zpl, width, height, unit, dpi, rendererEngine: rendererEngine); + imageDataList.AddRange(images); + } + + return imageDataList; + } } } \ No newline at end of file diff --git a/src/Presentation/TcpServerModeHandler.cs b/src/Presentation/TcpServerModeHandler.cs index f60ceda..9d7dbe1 100644 --- a/src/Presentation/TcpServerModeHandler.cs +++ b/src/Presentation/TcpServerModeHandler.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF { @@ -53,13 +54,13 @@ private void HandleStart(ArgumentProcessor args) if (args.ServerForeground) { - RunServerInForeground(args.ServerPort, args.ServerOutputFolder); + RunServerInForeground(args.ServerPort, args.ServerOutputFolder, args.RendererEngine); return; } // Background: spawn process with --foreground so the child runs the server var processManager = new ProcessManager(); - var serverArgs = BuildServerArguments(args.ServerPort, args.ServerOutputFolder, foreground: true); + var serverArgs = BuildServerArguments(args.ServerPort, args.ServerOutputFolder, args.RendererEngine, foreground: true); var startedPid = processManager.StartBackgroundProcess(serverArgs); if (startedPid <= 0) { @@ -143,9 +144,9 @@ private static bool IsServerProcessRunning(PidManager pidManager) } } - private static void RunServerInForeground(int port, string outputFolder) + private static void RunServerInForeground(int port, string outputFolder, RendererEngine rendererEngine) { - var server = new TcpPrinterServer(port, outputFolder); + var server = new TcpPrinterServer(port, outputFolder, rendererEngine); Console.WriteLine($"TCP server listening on port {port}. Output folder: {outputFolder}"); Console.WriteLine("Press Ctrl+C to stop."); using var cts = new CancellationTokenSource(); @@ -165,10 +166,11 @@ private static void RunServerInForeground(int port, string outputFolder) } } - private static string BuildServerArguments(int port, string outputFolder, bool foreground) + private static string BuildServerArguments(int port, string outputFolder, RendererEngine rendererEngine, bool foreground) { var output = outputFolder.Contains(" ") ? $"\"{outputFolder}\"" : outputFolder; - var args = $"server start --port {port} -o {output}"; + var renderer = rendererEngine.ToString().ToLowerInvariant(); + var args = $"server start --port {port} -o {output} --renderer {renderer}"; if (foreground) args += " --foreground"; return args; diff --git a/src/Shared/Constants/ApplicationConstants.cs b/src/Shared/Constants/ApplicationConstants.cs index 90438ec..d10828b 100644 --- a/src/Shared/Constants/ApplicationConstants.cs +++ b/src/Shared/Constants/ApplicationConstants.cs @@ -17,8 +17,8 @@ public static class ApplicationConstants #endregion #region File Extensions - public static readonly string[] VALID_FILE_EXTENSIONS = { ".txt", ".prn" }; - public const string DEFAULT_FILE_FILTER = "*.txt;*.prn"; + public static readonly string[] VALID_FILE_EXTENSIONS = { ".txt", ".prn", ".zpl", ".imp" }; + public const string DEFAULT_FILE_FILTER = "*.txt;*.prn;*.zpl;*.imp"; public const string PDF_EXTENSION = ".pdf"; public const string CONFIG_EXTENSION = ".json"; public const string PID_EXTENSION = ".pid"; diff --git a/src/Shared/Constants/RendererEngine.cs b/src/Shared/Constants/RendererEngine.cs new file mode 100644 index 0000000..c09b3eb --- /dev/null +++ b/src/Shared/Constants/RendererEngine.cs @@ -0,0 +1,24 @@ +namespace ZPL2PDF.Shared.Constants +{ + /// + /// Rendering engine selection for ZPL to PDF. + /// + public enum RendererEngine + { + /// + /// Offline rendering using BinaryKits (no internet). + /// + Offline, + + /// + /// Rendering via Labelary online API. + /// + Labelary, + + /// + /// Try Labelary first, fallback to Offline on failure. + /// + Auto + } +} + diff --git a/src/Shared/ZplDimensionExtractor.cs b/src/Shared/ZplDimensionExtractor.cs index 269fc2d..aa69aba 100644 --- a/src/Shared/ZplDimensionExtractor.cs +++ b/src/Shared/ZplDimensionExtractor.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; using System.Text.RegularExpressions; +using ZPL2PDF.Domain.Services; namespace ZPL2PDF { /// /// Extracts ZPL label dimensions (^PW and ^LL) and converts points to mm /// - public class ZplDimensionExtractor + public class ZplDimensionExtractor : IDimensionExtractor { /// diff --git a/tests/ZPL2PDF.Unit/Mocks/MockConversionService.cs b/tests/ZPL2PDF.Unit/Mocks/MockConversionService.cs index b05defe..1d9b4f6 100644 --- a/tests/ZPL2PDF.Unit/Mocks/MockConversionService.cs +++ b/tests/ZPL2PDF.Unit/Mocks/MockConversionService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ZPL2PDF.Application.Interfaces; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF.Tests.Mocks { @@ -16,7 +17,8 @@ public List ConvertWithExplicitDimensions( string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { // Mock implementation - returns fake PDF data return new List { GenerateMockPdfData(zplContent, width, height, unit, dpi) }; @@ -27,7 +29,8 @@ public List ConvertWithExtractedDimensions( string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { // Mock implementation - returns fake PDF data return new List { GenerateMockPdfData(zplContent, 100, 150, unit, dpi) }; @@ -40,12 +43,55 @@ public List Convert( string unit, int dpi, string? fontsDirectory = null, - IReadOnlyList<(string Id, string Path)>? fontMappings = null) + IReadOnlyList<(string Id, string Path)>? fontMappings = null, + RendererEngine rendererEngine = RendererEngine.Offline) { // Mock implementation - returns fake PDF data return new List { GenerateMockPdfData(zplContent, explicitWidth, explicitHeight, unit, dpi) }; } + public byte[] ConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + // Mock implementation - return some bytes that look like a PDF payload. + var content = $"Mock Labelary PDF for ZPL: {zplContent.Substring(0, Math.Min(50, zplContent.Length))}..."; + return System.Text.Encoding.UTF8.GetBytes(content); + } + + public bool TryConvertPdfDirectWithLabelary( + string zplContent, + double explicitWidth, + double explicitHeight, + string unit, + int dpi, + RendererEngine rendererEngine, + out byte[]? pdfBytes, + string? fontsDirectory = null, + IReadOnlyList<(string Id, string Path)>? fontMappings = null) + { + if (rendererEngine == RendererEngine.Offline) + { + pdfBytes = null; + return false; + } + + pdfBytes = ConvertPdfDirectWithLabelary( + zplContent, + explicitWidth, + explicitHeight, + unit, + dpi, + fontsDirectory, + fontMappings); + return true; + } + private byte[] GenerateMockPdfData(string zplContent, double width, double height, string unit, int dpi) { // Generate fake PDF data for testing diff --git a/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/FileInfoTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/FileInfoTests.cs index 62a7c41..519709d 100644 --- a/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/FileInfoTests.cs +++ b/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/FileInfoTests.cs @@ -214,6 +214,40 @@ public void IsValid_WithValidPrnFile_ReturnsTrue() result.Should().BeTrue(); } + [Fact] + public void IsValid_WithValidZplFile_ReturnsTrue() + { + // Arrange + var fileInfo = new FileInfo + { + FileName = "test.zpl", + Extension = ".zpl" + }; + + // Act + var result = fileInfo.IsValid(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsValid_WithValidImpFile_ReturnsTrue() + { + // Arrange + var fileInfo = new FileInfo + { + FileName = "test.imp", + Extension = ".imp" + }; + + // Act + var result = fileInfo.IsValid(); + + // Assert + result.Should().BeTrue(); + } + [Fact] public void IsValid_WithEmptyFileName_ReturnsFalse() { @@ -312,6 +346,32 @@ public void IsValidExtension_WithPrnExtension_ReturnsTrue() result.Should().BeTrue(); } + [Fact] + public void IsValidExtension_WithZplExtension_ReturnsTrue() + { + // Arrange + var fileInfo = new FileInfo { Extension = ".zpl" }; + + // Act + var result = fileInfo.IsValidExtension(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsValidExtension_WithImpExtension_ReturnsTrue() + { + // Arrange + var fileInfo = new FileInfo { Extension = ".imp" }; + + // Act + var result = fileInfo.IsValidExtension(); + + // Assert + result.Should().BeTrue(); + } + [Fact] public void IsValidExtension_WithTxtExtensionUpperCase_ReturnsTrue() { @@ -446,7 +506,7 @@ public void GetValidationError_WithInvalidExtension_ReturnsExtensionError() var result = fileInfo.GetValidationError(); // Assert - result.Should().Be("Invalid file extension: .doc. Valid extensions are: .txt, .prn"); + result.Should().Be("Invalid file extension: .doc. Valid extensions are: .txt, .prn, .zpl, .imp"); } #endregion diff --git a/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/LabelDimensionsTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/LabelDimensionsTests.cs deleted file mode 100644 index c6547c2..0000000 --- a/tests/ZPL2PDF.Unit/UnitTests/Domain/ValueObjects/LabelDimensionsTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using FluentAssertions; -using Xunit; -// Avoid collision with ZPL2PDF.LabelDimensions (Shared / dimension extractor DTO). -using DomainLabelDimensions = ZPL2PDF.Domain.ValueObjects.LabelDimensions; - -namespace ZPL2PDF.Tests.UnitTests.Domain.ValueObjects -{ - /// - /// Unit tests for . - /// - public class LabelDimensionsTests - { - [Fact] - public void DefaultConstructor_SetsReasonableDefaults() - { - var d = new DomainLabelDimensions(); - - d.Width.Should().Be(0); - d.Height.Should().Be(0); - d.Unit.Should().Be("mm"); - d.Dpi.Should().Be(203); - } - - [Fact] - public void Constructor_WithValidParameters_SetsProperties() - { - var d = new DomainLabelDimensions(100, 50, "cm", 300); - - d.Width.Should().Be(100); - d.Height.Should().Be(50); - d.Unit.Should().Be("cm"); - d.Dpi.Should().Be(300); - } - - [Theory] - [InlineData(10, 20, "mm", 203)] - [InlineData(1, 2, "cm", 203)] - [InlineData(4, 2, "in", 300)] - public void IsValid_WithPositiveDimensionsAndKnownUnit_ReturnsTrue( - double w, double h, string unit, int dpi) - { - var d = new DomainLabelDimensions(w, h, unit, dpi); - d.IsValid().Should().BeTrue(); - d.GetValidationError().Should().BeEmpty(); - } - - [Fact] - public void IsValid_WithZeroWidth_ReturnsFalse() - { - var d = new DomainLabelDimensions(0, 20, "mm", 203); - d.IsValid().Should().BeFalse(); - d.GetValidationError().Should().Contain("Width"); - } - - [Fact] - public void IsValid_WithZeroHeight_ReturnsFalse() - { - var d = new DomainLabelDimensions(10, 0, "mm", 203); - d.IsValid().Should().BeFalse(); - d.GetValidationError().Should().Contain("Height"); - } - - [Fact] - public void IsValid_WithEmptyUnit_ReturnsFalse() - { - var d = new DomainLabelDimensions(10, 20, " ", 203); - d.IsValid().Should().BeFalse(); - d.GetValidationError().Should().Contain("Unit"); - } - - [Fact] - public void IsValid_WithZeroDpi_ReturnsFalse() - { - var d = new DomainLabelDimensions(10, 20, "mm", 0); - d.IsValid().Should().BeFalse(); - d.GetValidationError().Should().Contain("DPI"); - } - - [Fact] - public void IsValid_WithInvalidUnit_ReturnsFalse() - { - var d = new DomainLabelDimensions(10, 20, "px", 203); - d.IsValid().Should().BeFalse(); - d.GetValidationError().Should().Contain("Invalid unit"); - } - - [Theory] - [InlineData("mm", true)] - [InlineData("MM", true)] - [InlineData("cm", true)] - [InlineData("in", true)] - [InlineData("px", false)] - [InlineData("", false)] - [InlineData(" ", false)] - public void IsValidUnit_MatchesExpected(string unit, bool expected) - { - DomainLabelDimensions.IsValidUnit(unit).Should().Be(expected); - } - - [Fact] - public void ToMillimeters_FromMm_ReturnsEquivalentValues() - { - var d = new DomainLabelDimensions(12.5, 8, "mm", 203); - var mm = d.ToMillimeters(); - - mm.Width.Should().BeApproximately(12.5, 0.001); - mm.Height.Should().BeApproximately(8, 0.001); - mm.Unit.Should().Be("mm"); - mm.Dpi.Should().Be(203); - } - - [Fact] - public void ToMillimeters_FromCm_ConvertsCorrectly() - { - var d = new DomainLabelDimensions(2, 3, "cm", 203); - var mm = d.ToMillimeters(); - - mm.Width.Should().BeApproximately(20, 0.001); - mm.Height.Should().BeApproximately(30, 0.001); - mm.Unit.Should().Be("mm"); - } - - [Fact] - public void ToCentimeters_FromMm_ConvertsCorrectly() - { - var d = new DomainLabelDimensions(100, 50, "mm", 203); - var cm = d.ToCentimeters(); - - cm.Unit.Should().Be("cm"); - cm.Width.Should().BeApproximately(10, 0.001); - cm.Height.Should().BeApproximately(5, 0.001); - } - - [Fact] - public void ToInches_FromMm_ConvertsCorrectly() - { - var d = new DomainLabelDimensions(25.4, 50.8, "mm", 203); - var inches = d.ToInches(); - - inches.Unit.Should().Be("in"); - inches.Width.Should().BeApproximately(1, 0.01); - inches.Height.Should().BeApproximately(2, 0.01); - } - - [Fact] - public void ToPoints_UsesDpiAndMillimeters() - { - // 25.4 mm @ 203 dpi => 1 inch => 203 points width - var d = new DomainLabelDimensions(25.4, 25.4, "mm", 203); - (int w, int h) = d.ToPoints(); - - w.Should().Be(203); - h.Should().Be(203); - } - - [Fact] - public void Clone_ReturnsEqualButDistinctInstance() - { - var original = new DomainLabelDimensions(7, 8, "in", 300); - var copy = original.Clone(); - - copy.Should().NotBeSameAs(original); - copy.Should().Be(original); - } - - [Fact] - public void Equals_WithSameValues_ReturnsTrue() - { - var a = new DomainLabelDimensions(10, 20, "MM", 203); - var b = new DomainLabelDimensions(10, 20, "mm", 203); - - a.Equals(b).Should().BeTrue(); - (a == b).Should().BeFalse(); // no operator overload - } - - [Fact] - public void Equals_WithDifferentValues_ReturnsFalse() - { - var a = new DomainLabelDimensions(10, 20, "mm", 203); - var b = new DomainLabelDimensions(11, 20, "mm", 203); - - a.Equals(b).Should().BeFalse(); - } - - [Fact] - public void GetHashCode_WithSameValues_ReturnsSameHash() - { - var a = new DomainLabelDimensions(10, 20, "mm", 203); - var b = new DomainLabelDimensions(10, 20, "MM", 203); - - a.GetHashCode().Should().Be(b.GetHashCode()); - } - - [Fact] - public void ToString_IncludesDimensionsAndUnit() - { - var d = new DomainLabelDimensions(100, 50, "mm", 203); - var s = d.ToString(); - - s.Should().Contain("100"); - s.Should().Contain("50"); - s.Should().Contain("mm"); - } - } -} diff --git a/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/FolderMonitorTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/FolderMonitorTests.cs index 9bbf268..b09cd3a 100644 --- a/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/FolderMonitorTests.cs +++ b/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/FolderMonitorTests.cs @@ -296,6 +296,66 @@ public void FileDetection_WithValidPrnFile_DetectsFile() fileDetected.Should().BeTrue(); } + [Fact] + public void FileDetection_WithValidZplFile_DetectsFile() + { + // Arrange + var fixedDimensions = new LabelDimensions { Width = 100, Height = 200, Dpi = 203 }; + var folderMonitor = new FolderMonitor( + _testDirectory, + _processingQueue, + _dimensionExtractor, + _configManager, + fixedDimensions, + true + ); + + bool fileDetected = false; + folderMonitor.FileDetected += (sender, e) => fileDetected = true; + + folderMonitor.StartWatching(); + + // Act + var testFile = Path.Combine(_testDirectory, "test.zpl"); + File.WriteAllText(testFile, "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"); + + // Wait a bit for file detection + Thread.Sleep(500); + + // Assert + fileDetected.Should().BeTrue(); + } + + [Fact] + public void FileDetection_WithValidImpFile_DetectsFile() + { + // Arrange + var fixedDimensions = new LabelDimensions { Width = 100, Height = 200, Dpi = 203 }; + var folderMonitor = new FolderMonitor( + _testDirectory, + _processingQueue, + _dimensionExtractor, + _configManager, + fixedDimensions, + true + ); + + bool fileDetected = false; + folderMonitor.FileDetected += (sender, e) => fileDetected = true; + + folderMonitor.StartWatching(); + + // Act + var testFile = Path.Combine(_testDirectory, "test.imp"); + File.WriteAllText(testFile, "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"); + + // Wait a bit for file detection + Thread.Sleep(500); + + // Assert + fileDetected.Should().BeTrue(); + } + [Fact] public void FileDetection_WithInvalidFile_IgnoresFile() { diff --git a/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/PdfGeneratorTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/PdfGeneratorTests.cs new file mode 100644 index 0000000..22a78e2 --- /dev/null +++ b/tests/ZPL2PDF.Unit/UnitTests/Infrastructure/PdfGeneratorTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FluentAssertions; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using Xunit; +using ZPL2PDF; + +namespace ZPL2PDF.Tests.UnitTests.Infrastructure +{ + /// + /// Unit tests for (PDF merge behavior). + /// + public class PdfGeneratorTests + { + private static byte[] CreatePdfWithPageCount(int pageCount) + { + pageCount.Should().BeGreaterThan(0, "test PDF must contain at least one page"); + + var document = new PdfDocument(); + for (var i = 0; i < pageCount; i++) + document.AddPage(); + + using var ms = new MemoryStream(); + document.Save(ms, false); + return ms.ToArray(); + } + + [Fact] + public void MergePdfsToBytes_WithTwoValidPdfDocuments_ReturnsMergedPdfWithTwoPages() + { + // Arrange + var pdf1 = CreatePdfWithPageCount(1); + var pdf2 = CreatePdfWithPageCount(1); + + // Act + var merged = PdfGenerator.MergePdfsToBytes(new List { pdf1, pdf2 }); + + // Assert + using var mergedMs = new MemoryStream(merged); + using var mergedDoc = PdfReader.Open(mergedMs, PdfDocumentOpenMode.Import); + mergedDoc.PageCount.Should().Be(2); + } + + [Fact] + public void MergePdfsToBytes_WithNullAndEmptyEntries_IgnoresThemAndMergesRemainingPages() + { + // Arrange + var valid = CreatePdfWithPageCount(2); + var empty = Array.Empty(); + + // Act + var merged = PdfGenerator.MergePdfsToBytes(new List { valid, null!, empty }); + + // Assert + using var mergedMs = new MemoryStream(merged); + using var mergedDoc = PdfReader.Open(mergedMs, PdfDocumentOpenMode.Import); + mergedDoc.PageCount.Should().Be(2); + } + } +} + diff --git a/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentParserTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentParserTests.cs index 1af832e..4c5ec46 100644 --- a/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentParserTests.cs +++ b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentParserTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Xunit; using ZPL2PDF; +using ZPL2PDF.Shared.Constants; namespace ZPL2PDF.Tests.UnitTests.Presentation { @@ -67,6 +68,48 @@ public void ParseConversionMode_WithStdoutFlag_SetsStandardOutput() // Assert result.StandardOutput.Should().BeTrue(); } + + [Fact] + public void ParseConversionMode_WithRendererLabelary_SetsRendererEngine() + { + // Arrange + var parser = new ArgumentParser(); + var zplContent = "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"; + + var args = new[] + { + "-z", zplContent, + "-o", "/tmp", + "--renderer", "labelary" + }; + + // Act + var result = parser.ParseConversionMode(args, 0); + + // Assert + result.RendererEngine.Should().Be(RendererEngine.Labelary); + } + + [Fact] + public void ParseConversionMode_WithRendererAuto_SetsRendererEngine() + { + // Arrange + var parser = new ArgumentParser(); + var zplContent = "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"; + + var args = new[] + { + "-z", zplContent, + "-o", "/tmp", + "--renderer", "auto" + }; + + // Act + var result = parser.ParseConversionMode(args, 0); + + // Assert + result.RendererEngine.Should().Be(RendererEngine.Auto); + } } } diff --git a/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentValidatorTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentValidatorTests.cs index e3f1eaf..358c088 100644 --- a/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentValidatorTests.cs +++ b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ArgumentValidatorTests.cs @@ -105,7 +105,7 @@ public void ValidateConversionMode_WithInvalidFileExtension_ReturnsInvalid() // Assert result.IsValid.Should().BeFalse(); - result.ErrorMessage.Should().Contain("Input file must be .txt or .prn"); + result.ErrorMessage.Should().Contain("Input file must be .txt, .prn, .zpl or .imp"); } [Fact] @@ -138,6 +138,36 @@ public void ValidateConversionMode_WithPrnFile_ReturnsValid() result.ErrorMessage.Should().BeEmpty(); } + [Fact] + public void ValidateConversionMode_WithZplFile_ReturnsValid() + { + // Arrange + var testFile = Path.Combine(_testDirectory, "test.zpl"); + File.WriteAllText(testFile, "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"); + + // Act + var result = _validator.ValidateConversionMode(testFile, "", _testDirectory, 0, 0, "mm", false); + + // Assert + result.IsValid.Should().BeTrue(); + result.ErrorMessage.Should().BeEmpty(); + } + + [Fact] + public void ValidateConversionMode_WithImpFile_ReturnsValid() + { + // Arrange + var testFile = Path.Combine(_testDirectory, "test.imp"); + File.WriteAllText(testFile, "^XA^FO50,50^A0N,50,50^FDTest Label^FS^XZ"); + + // Act + var result = _validator.ValidateConversionMode(testFile, "", _testDirectory, 0, 0, "mm", false); + + // Assert + result.IsValid.Should().BeTrue(); + result.ErrorMessage.Should().BeEmpty(); + } + [Fact] public void ValidateConversionMode_WithEmptyOutputFolder_ReturnsInvalid() { diff --git a/tests/ZPL2PDF.Unit/UnitTests/Presentation/ConvertRequestTests.cs b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ConvertRequestTests.cs new file mode 100644 index 0000000..e373974 --- /dev/null +++ b/tests/ZPL2PDF.Unit/UnitTests/Presentation/ConvertRequestTests.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using FluentAssertions; +using Xunit; +using ZPL2PDF.Presentation.Api.Models; + +namespace ZPL2PDF.Tests.UnitTests.Presentation +{ + public class ConvertRequestTests + { + private static ConvertRequest Deserialize(string json) + { + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + })!; + } + + [Fact] + public void Deserialize_WhenOnlyZplProvided_UsesDefaults() + { + // Arrange + var json = """ + { + "zpl": "^XA^FO10,10^A0N,30,30^FDHi^FS^XZ" + } + """; + + // Act + var request = Deserialize(json); + + // Assert + request.Format.Should().Be("pdf"); + request.Renderer.Should().Be("offline"); + request.Unit.Should().Be("mm"); + request.Dpi.Should().BeNull(); + } + + [Fact] + public void Deserialize_WhenRendererProvided_SetsRenderer() + { + // Arrange + var json = """ + { + "zpl": "^XA^FO10,10^A0N,30,30^FDHi^FS^XZ", + "format": "pdf", + "renderer": "labelary" + } + """; + + // Act + var request = Deserialize(json); + + // Assert + request.Format.Should().Be("pdf"); + request.Renderer.Should().Be("labelary"); + } + } +} + diff --git a/wiki/Architecture-Overview.md b/wiki/Architecture-Overview.md index bf1f5f1..ffec6cd 100644 --- a/wiki/Architecture-Overview.md +++ b/wiki/Architecture-Overview.md @@ -34,9 +34,8 @@ src/ │ │ ├── IPdfGenerator.cs │ │ ├── IFileValidator.cs │ │ └── IDimensionExtractor.cs -│ └── ValueObjects/ # Immutable Value Objects +│ └── ValueObjects/ # Value Objects │ ├── ConversionOptions.cs -│ ├── LabelDimensions.cs │ ├── FileInfo.cs │ ├── ProcessingResult.cs │ └── DaemonConfiguration.cs @@ -125,7 +124,6 @@ Output PDFs **Purpose**: Business logic and domain rules **Key Concepts**: -- `LabelDimensions`: Width, height, unit, DPI - `ConversionOptions`: User-specified options - `ProcessingResult`: Conversion outcomes - Domain interfaces (no implementations) @@ -171,14 +169,12 @@ public class ConversionService : IConversionService ### Value Objects ```csharp -public record LabelDimensions( - double Width, - double Height, - string Unit, - int Dpi) +public class ConversionOptions { - public double WidthInMm => ConvertToMm(Width, Unit); - public double HeightInMm => ConvertToMm(Height, Unit); + public double Width { get; set; } + public double Height { get; set; } + public string Unit { get; set; } = "mm"; + public int Dpi { get; set; } = 203; } ``` diff --git a/zpl2pdf.json.example b/zpl2pdf.json.example index a138c2f..549d218 100644 --- a/zpl2pdf.json.example +++ b/zpl2pdf.json.example @@ -5,7 +5,7 @@ "language": "en-US", // Daemon Configuration - "defaultWatchFolder": "C:\\Users\\user\\Documents\\ZPL2PDF Auto Converter", + "defaultListenFolder": "C:\\Users\\user\\Documents\\ZPL2PDF Auto Converter", "labelWidth": 7.5, "labelHeight": 15, "unit": "in",