diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..8705aee
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,4 @@
+{
+ "permissions": {
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4948361
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on:
+ push:
+ branches: [main, migrate-to-avalonia]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ # The browser (WASM) head needs the wasm-tools workload to build.
+ - name: Install wasm-tools workload
+ run: dotnet workload install wasm-tools
+
+ - name: Restore
+ run: dotnet restore CrossEscPos.slnx
+
+ - name: Build
+ run: dotnet build CrossEscPos.slnx -c Release --no-restore
+
+ - name: Test
+ run: dotnet test CrossEscPos.slnx -c Release --no-build
+
+ - name: Pack libraries
+ run: dotnet pack CrossEscPos.slnx -c Release --no-build -o artifacts/packages
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nupkg
+ path: artifacts/packages/*.nupkg
+ if-no-files-found: warn
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..de1ef1b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,166 @@
+name: Release
+
+# Builds self-contained desktop apps for Windows, Linux and macOS, packs the NuGet libraries, and
+# publishes everything to a GitHub Release. Trigger by pushing a tag like `v1.2.3`, or run manually
+# (workflow_dispatch) to just build artifacts.
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ name: Build ${{ matrix.rid }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { rid: win-x64, os: ubuntu-latest, kind: zip }
+ - { rid: linux-x64, os: ubuntu-latest, kind: targz }
+ - { rid: osx-x64, os: macos-latest, kind: app }
+ - { rid: osx-arm64, os: macos-latest, kind: app }
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Determine version
+ id: ver
+ shell: bash
+ run: |
+ VER=1.0.0
+ if [ "$GITHUB_REF_TYPE" = "tag" ]; then VER="${GITHUB_REF_NAME#v}"; fi
+ echo "version=$VER" >> "$GITHUB_OUTPUT"
+
+ - name: Publish (self-contained)
+ run: >
+ dotnet publish src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj
+ -c Release
+ -r ${{ matrix.rid }}
+ --self-contained true
+ -p:PublishSingleFile=false
+ -p:Version=${{ steps.ver.outputs.version }}
+ -o publish/${{ matrix.rid }}
+
+ - name: Package — zip (Windows)
+ if: matrix.kind == 'zip'
+ run: |
+ cd publish/${{ matrix.rid }}
+ zip -r ../../CrossEscPos-${{ matrix.rid }}.zip .
+
+ - name: Package — tar.gz (Linux)
+ if: matrix.kind == 'targz'
+ run: tar -czf CrossEscPos-${{ matrix.rid }}.tar.gz -C publish/${{ matrix.rid }} .
+
+ - name: Package — .app bundle (macOS)
+ if: matrix.kind == 'app'
+ run: |
+ chmod +x scripts/make-macos-app.sh
+ scripts/make-macos-app.sh publish/${{ matrix.rid }} dist "${{ steps.ver.outputs.version }}"
+ ditto -c -k --keepParent dist/CrossEscPos.app CrossEscPos-${{ matrix.rid }}.zip
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.rid }}
+ path: |
+ CrossEscPos-${{ matrix.rid }}.zip
+ CrossEscPos-${{ matrix.rid }}.tar.gz
+ if-no-files-found: ignore
+
+ pack:
+ name: Pack NuGet libraries
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Determine version
+ id: ver
+ shell: bash
+ run: |
+ VER=1.0.0
+ if [ "$GITHUB_REF_TYPE" = "tag" ]; then VER="${GITHUB_REF_NAME#v}"; fi
+ echo "version=$VER" >> "$GITHUB_OUTPUT"
+
+ # The packable libraries don't need the browser head, so pack each individually (no wasm workload).
+ - name: Pack
+ shell: bash
+ run: |
+ for proj in Abstractions Core Rendering.Skia Rendering.ImageSharp Transports Controls; do
+ dotnet pack "src/CrossEscPos.$proj/CrossEscPos.$proj.csproj" \
+ -c Release -p:Version=${{ steps.ver.outputs.version }} -o packages
+ done
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nupkg
+ path: |
+ packages/*.nupkg
+ packages/*.snupkg
+
+ publish-nuget:
+ name: Publish to NuGet.org
+ needs: pack
+ runs-on: ubuntu-latest
+ # Only publish for real version tags (v*), never on a manual workflow_dispatch dry run.
+ if: startsWith(github.ref, 'refs/tags/')
+ # Scopes the OIDC token to this environment; also lets you add a manual approval gate in
+ # Settings → Environments → release before anything reaches nuget.org.
+ environment: release
+ permissions:
+ id-token: write # required: mints the GitHub OIDC token for NuGet Trusted Publishing
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: nupkg
+ path: packages
+
+ # Exchanges the GitHub OIDC token for a short-lived (1 h) nuget.org API key. No stored key.
+ # NUGET_USER is your nuget.org profile name (NOT your email); set it as a repo/environment secret.
+ - name: NuGet login (OIDC → short-lived API key)
+ id: login
+ uses: NuGet/login@v1
+ with:
+ user: ${{ secrets.NUGET_USER }}
+
+ # Pushing *.nupkg also pushes each matching *.snupkg symbol package automatically.
+ - name: Push packages
+ run: >
+ dotnet nuget push "packages/*.nupkg"
+ --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
+ --source https://api.nuget.org/v3/index.json
+ --skip-duplicate
+
+ release:
+ name: Create GitHub Release
+ needs: [build, pack]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: List artifacts
+ run: find artifacts -type f
+
+ - uses: softprops/action-gh-release@v2
+ with:
+ files: artifacts/**/*
+ generate_release_notes: true
+ fail_on_unmatched_files: false
diff --git a/.gitignore b/.gitignore
index 251f913..ce74ac9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,14 @@ riderModule.iml
/_ReSharper.Caches/
/.vs
/.idea/
+
+# Runtime debug dumps written by ReceiptPrinter.FeedEscPos
+last_escpos_receive.txt
+last_ticket.bin
+last_*.txt
+
+# macOS
+.DS_Store
+
+# Generated: the host publishes the Avalonia WASM client here on first build.
+samples/CrossEscPos.Host/wwwroot/
diff --git a/App.xaml b/App.xaml
deleted file mode 100644
index ef92ca9..0000000
--- a/App.xaml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
diff --git a/App.xaml.cs b/App.xaml.cs
deleted file mode 100644
index 105b9bb..0000000
--- a/App.xaml.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Windows;
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Networking;
-
-namespace ReceiptPrinterEmulator
-{
- public partial class App : Application
- {
- public static ReceiptPrinter? Printer = null;
- public static NetServer? Server = null;
-
- private void App_OnStartup(object sender, StartupEventArgs e)
- {
- Printer = new ReceiptPrinter(PaperConfiguration.Default);
-
- Server = new NetServer(9100);
- _ = Server.Run();
- }
-
- private void App_OnExit(object sender, ExitEventArgs e)
- {
- Server?.Stop();
- }
- }
-}
\ No newline at end of file
diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs
deleted file mode 100644
index 4a05c7d..0000000
--- a/AssemblyInfo.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.Windows;
-
-[assembly: ThemeInfo(
- ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
- //(used if a resource is not found in the page,
- // or application resource dictionaries)
- ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
- //(used if a resource is not found in the page,
- // app, or any theme specific resource dictionaries)
-)]
\ No newline at end of file
diff --git a/CrossEscPos.slnx b/CrossEscPos.slnx
new file mode 100644
index 0000000..8faa7ac
--- /dev/null
+++ b/CrossEscPos.slnx
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..c163408
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,46 @@
+
+
+
+
+ net10.0
+ enable
+ latest
+
+ CrossEscPos
+
+ disable
+ true
+
+ all
+
+ false
+
+
+
+
+ 1.2.0
+ Daniel Meza
+
+ Gasoleo Technology
+ CrossEscPos
+ Copyright (c) 2026 Daniel Meza / Gasoleo Technology
+ https://github.com/danielmeza/CrossEscPosEmulator
+ https://github.com/danielmeza/CrossEscPosEmulator
+ git
+ escpos;receipt;printer;emulator;thermal;pos
+
+ MIT
+ README.md
+ true
+ snupkg
+
+ true
+ true
+
+ true
+
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..208c234
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/Emulator/Abstraction/IReceiptPrintable.cs b/Emulator/Abstraction/IReceiptPrintable.cs
deleted file mode 100644
index 26bd060..0000000
--- a/Emulator/Abstraction/IReceiptPrintable.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.Drawing;
-
-namespace ReceiptPrinterEmulator.Emulator.Abstraction;
-
-public interface IReceiptPrintable
-{
- public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY);
- public int GetPrintHeight();
-}
\ No newline at end of file
diff --git a/Emulator/Printables/ReceiptTextLine.cs b/Emulator/Printables/ReceiptTextLine.cs
deleted file mode 100644
index 889371b..0000000
--- a/Emulator/Printables/ReceiptTextLine.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Drawing;
-using ReceiptPrinterEmulator.Emulator.Abstraction;
-using ReceiptPrinterEmulator.Emulator.Enums;
-
-namespace ReceiptPrinterEmulator.Emulator.Printables;
-
-public class ReceiptTextLine : IReceiptPrintable
-{
- private readonly PaperConfiguration.FontConfiguration _font;
- private readonly int _printWidth;
- private readonly int _charHeight;
- private readonly TextJustification _justification;
- private readonly bool _bold;
- private readonly bool _italic;
- private readonly UnderlineMode _underline;
-
- private int _totalWidth;
- private readonly List<(string text, PrintMode mode)> _strings = new();
-
- public bool IsEmpty => _strings.Count==0;
-
- public ReceiptTextLine(PaperConfiguration paperConfiguration, PrintMode printMode)
- {
- _font = paperConfiguration.GetFont(printMode.Font);
- _printWidth = paperConfiguration.GetPrintWidthInPixels();
- _charHeight = _font.CharacterHeight * printMode.CharHeightScale;
- _justification = printMode.Justification;
- _bold = printMode.Emphasize;
- _italic = printMode.Italic;
- _underline = printMode.Underline;
-
- _totalWidth = 0;
- }
-
- public bool TryWriteChar(char c, PrintMode mode)
- {
- int charWidth = (_font.CharacterWidth * mode.CharWidthScale);
- if ((_totalWidth + charWidth) >= _printWidth)
- return false;
-
- if (_strings.Count > 0 && mode.Equals(_strings[^1].mode))
- {
- // Append to last run
- var (text, lastMode) = _strings[^1];
- _strings[^1] = (text + c, lastMode);
- }
- else
- {
- // Start new run
- _strings.Add((c.ToString(), mode.Clone()));
- }
- _totalWidth += charWidth;
- return true;
- }
-
- public int GetPrintHeight()
- {
- // Use the tallest run's height for correct line spacing
- int maxCharHeight = 0;
- foreach (var (_, mode) in _strings)
- {
- int charHeight = (_font.CharacterHeight / 2) * mode.CharHeightScale;
- if (charHeight > maxCharHeight)
- maxCharHeight = charHeight;
- }
- // Add a small extra space for visual separation (like real printers)
- return maxCharHeight + (_font.CharacterHeight / 4);
- }
-
- public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY)
- {
- // 1. Measure total line width for justification
- float totalWidth = 0;
- var runWidths = new List();
- foreach (var (text, mode) in _strings)
- {
- int baseCharHeight = _font.CharacterHeight / 2;
- var fontStyle = FontStyle.Regular;
- if (mode.Emphasize) fontStyle |= FontStyle.Bold;
- if (mode.Italic) fontStyle |= FontStyle.Italic;
- using var font = new Font(_font.RenderFont, baseCharHeight, fontStyle);
- SizeF baseSize = g.MeasureString(text, font, int.MaxValue, StringFormat.GenericTypographic);
- float scaledWidth = baseSize.Width * mode.CharWidthScale;
- runWidths.Add(scaledWidth);
- totalWidth += scaledWidth;
- }
-
- // 2. Use the justification of the first run (ESC/POS line property)
- TextJustification justification = _strings.Count > 0 ? _strings[0].mode.Justification : TextJustification.Left;
- int x = offsetX;
- if (justification == TextJustification.Center)
- x += (int)((_printWidth - totalWidth) / 2);
- else if (justification == TextJustification.Right)
- x += (int)(_printWidth - totalWidth);
-
-
- // Find the tallest run in this line for baseline alignment
- int maxCharHeight = 0;
- float maxAscent = 0;
- foreach (var (_, mode) in _strings)
- {
- int baseCharHeight = _font.CharacterHeight / 2;
- int charHeight = baseCharHeight * mode.CharHeightScale;
- using var font = new Font(_font.RenderFont, baseCharHeight, FontStyle.Regular);
- var ascent = font.FontFamily.GetCellAscent(font.Style) * font.Size / font.FontFamily.GetEmHeight(font.Style) * mode.CharHeightScale;
- if (charHeight > maxCharHeight)
- maxCharHeight = charHeight;
- if (ascent > maxAscent)
- maxAscent = ascent;
- }
-
- foreach (var (text, mode) in _strings)
- {
- int baseCharWidth = _font.CharacterWidth / 2;
- int baseCharHeight = _font.CharacterHeight / 2;
- int charHeight = baseCharHeight * mode.CharHeightScale;
-
- var fontStyle = FontStyle.Regular;
- if (mode.Emphasize) fontStyle |= FontStyle.Bold;
- if (mode.Italic) fontStyle |= FontStyle.Italic;
-
- using var font = new Font(_font.RenderFont, baseCharHeight, fontStyle);
-
- // Font metrics for baseline alignment
- float ascent = font.FontFamily.GetCellAscent(font.Style) * font.Size / font.FontFamily.GetEmHeight(font.Style) * mode.CharHeightScale;
- float baselineOffset = (float)(maxAscent - ascent + (maxCharHeight - charHeight));
-
- // Measure the string width in base font, then scale
- SizeF baseSize = g.MeasureString(text, font, int.MaxValue, StringFormat.GenericTypographic);
- float scaledWidth = baseSize.Width * mode.CharWidthScale;
-
- var state = g.Save();
-
- // Align baseline of run to line baseline
- g.TranslateTransform(x, offsetY + baselineOffset);
- g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale);
-
- g.DrawString(text, font, Brushes.Black, 0, 0, StringFormat.GenericTypographic);
-
- // Underline (draw in scaled context)
- if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots)
- {
- var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1);
- g.DrawLine(new Pen(Color.Black, dotHeight), 0, baseCharHeight*2, baseSize.Width, baseCharHeight*2);
- }
-
- g.Restore(state);
-
- x += (int)Math.Ceiling(scaledWidth);
- }
- }
-
-
- /* public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY)
- {
- int x = offsetX;
- foreach (var (c, mode) in _strings)
- {
- int charWidth = (_font.CharacterWidth * mode.CharWidthScale)* c.Length;
- int charHeight = _font.CharacterHeight * mode.CharHeightScale;
- var fontStyle = FontStyle.Regular;
- if (mode.Emphasize) fontStyle |= FontStyle.Bold;
- if (mode.Italic) fontStyle |= FontStyle.Italic;
- using var font = new Font(_font.RenderFont, charWidth / 1.5f, fontStyle);
-
- var rect = new Rectangle(x, offsetY, charWidth, charHeight);
- Console.WriteLine($"Drawing char '{c}' at ({x}, {offsetY}) with size ({charWidth}, {charHeight})");
- //var state = g.Save();
- // Justification logic here if needed
- // g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale);
- g.DrawString(c, font, Brushes.Black, rect);
- //g.Restore(state);
-
- if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots)
- {
- var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1);
- g.DrawLine(new Pen(Color.Black, dotHeight), rect.Left, rect.Bottom, rect.Right, rect.Bottom);
- }
-
- x += charWidth;
- }
-
- } */
-
-}
-
-
-/* var state = g.Save();
-
- // Move to the correct position, aligning bottom of char to baseline
- g.TranslateTransform(x, offsetY + (maxCharHeight - charHeight));
-
- // Scale for double width/height
- g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale);
-
- g.DrawString(c.ToString(), font, Brushes.Black, 0, 0);
-
- // Underline (draw in scaled context)
- if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots)
- {
- var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1);
-g.DrawLine(new Pen(Color.Black, dotHeight), 0, baseCharHeight, baseCharWidth, baseCharHeight);
- }
-
- g.Restore(state); */
diff --git a/Emulator/ReceiptPrinter.cs b/Emulator/ReceiptPrinter.cs
deleted file mode 100644
index b05d661..0000000
--- a/Emulator/ReceiptPrinter.cs
+++ /dev/null
@@ -1,230 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Drawing;
-using System.IO;
-using System.Text;
-using ReceiptPrinterEmulator.Emulator.Enums;
-using ReceiptPrinterEmulator.EscPos;
-using ReceiptPrinterEmulator.Logging;
-
-namespace ReceiptPrinterEmulator.Emulator;
-
-public class ReceiptPrinter
-{
- private readonly PaperConfiguration _paperConfiguration;
- private readonly EscPosInterpreter _escPosInterpreter;
-
- private PrintMode _printMode;
- private int _lineSpacing;
- private int _tabSpacing;
-
- public Receipt CurrentReceipt { get; private set; }
- public List ReceiptStack { get; private set; }
-
- public event EventHandler OnActivityEvent;
-
- public ReceiptPrinter(PaperConfiguration paperConfiguration)
- {
- _paperConfiguration = paperConfiguration;
- _escPosInterpreter = new(this);
-
- _printMode = new PrintMode();
-
- ReceiptStack = new();
-
- StartNewReceipt();
-
- PowerCycle();
- }
-
- #region ESC/POS
-
- public void FeedEscPos(string ascii)
- {
- if (ascii.Length>10000)
- {
- File.WriteAllText("last_ticket.bin", ascii, Encoding.ASCII);
- }
- File.WriteAllText("last_escpos_receive.txt", ascii, Encoding.ASCII);
-
- try
- {
- Logger.Info($"Received: {ascii}");
- _escPosInterpreter.Interpret(ascii);
-
- }
- catch (Exception ex)
- {
- Logger.Exception(ex, "ESC/POS Interpreter Error");
- }
-
- OnActivityEvent?.Invoke(this, EventArgs.Empty);
- }
-
- #endregion
-
- #region Receipt meta
-
- public void StartNewReceipt()
- {
- CurrentReceipt = new(_paperConfiguration, _printMode, _lineSpacing);
- ReceiptStack.Add(CurrentReceipt);
-
- Logger.Info($"Starting new receipt (#{ReceiptStack.Count})");
- }
-
- #endregion
-
- #region Emulated
-
- public void PowerCycle()
- {
- Initialize();
- }
-
- #endregion
-
- #region Direct API
-
- public void Initialize()
- {
- _escPosInterpreter.ClearBuffers();
-
- SelectFont(PrinterFont.FontA);
- SelectJustification(TextJustification.Left);
- SelectCharacterSize(1, 1);
- SelectEmphasizeMode(false);
- SelectItalicMode(false);
- SelectUnderlineMode(UnderlineMode.Off);
- SetDefaultLineSpacing();
- SetDefaultTabSpacing();
- }
-
- public void PrintText(string text)
- {
- Logger.Info($"Print: {text}");
-
- CurrentReceipt.PrintText(text,_printMode);
- }
-
- public void Cut(CutFunction cutFunction = CutFunction.Cut, CutShape cutShape = CutShape.Full, int n = 0)
- {
- Logger.Info($"Execute cut: {cutFunction}, {cutShape}, {n}");
-
- LineFeed();
-
- // TODO Support alternate cut modes
-
- StartNewReceipt();
- }
-
- ///
- /// Feeds one line, based on the current line spacing.
- ///
- ///
- /// - The amount of paper fed per line is based on the value set using the line spacing command (ESC 2 or ESC 3).
- ///
- public void LineFeed()
- {
- Logger.Info($"Line feed");
- CurrentReceipt.AdvanceToNewLine();
- }
-
- public void SelectFont(PrinterFont printerFont)
- {
- Logger.Info($"Select font: {printerFont}");
-
- _printMode.Font = printerFont;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SelectJustification(TextJustification justification)
- {
- Logger.Info($"Select justification: {justification}");
-
- _printMode.Justification = justification;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SelectCharacterSize(int width, int height)
- {
- Logger.Info($"Set character size scale: x{width} width, x{height} height");
-
- _printMode.CharWidthScale = width;
- _printMode.CharHeightScale = height;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SelectEmphasizeMode(bool enable)
- {
- Logger.Info($"Set emphasize mode: {enable}");
-
- _printMode.Emphasize = enable;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SelectItalicMode(bool enable)
- {
- Logger.Info($"Set italic mode: {enable}");
-
- _printMode.Italic = enable;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SelectUnderlineMode(UnderlineMode mode)
- {
- Logger.Info($"Set underline mode: {mode}");
-
- _printMode.Underline = mode;
- CurrentReceipt.ChangeFontConfiguration(_printMode);
- }
-
- public void SetLineSpacing(int value)
- {
- Logger.Info($"Set line spacing: {value}");
-
- _lineSpacing = value;
- CurrentReceipt.SetLineSpacing(_lineSpacing);
- }
-
- public void SetTabSpacing(int value)
- {
- Logger.Info($"Set tab spacing: {value}");
-
- _tabSpacing = value;
- CurrentReceipt.SetTabSpacing(_tabSpacing);
- }
-
- public void SetDefaultLineSpacing() => SetLineSpacing(_paperConfiguration.DefaultLineSpacing);
- public void SetDefaultTabSpacing() => SetTabSpacing(_paperConfiguration.DefaultTabSpacing);
-
- public void PrintBitmap(Bitmap bitmap)
- {
- Logger.Info($"Print bitmap: {bitmap.Width}x{bitmap.Height}");
-
- CurrentReceipt.PrintBitmap(bitmap);
- }
-
- #endregion
-
- #region Command API
-
- ///
- /// Prints the data in the print buffer and feeds one line, based on the current line spacing.
- ///
- public void PrintAndLineFeed(string printBuffer)
- {
- PrintText(printBuffer);
- LineFeed();
- }
-
- public void PrintTab()
- {
- string tabs = "";
-
- for (var i = 0; i < _tabSpacing; i++) tabs += " ";
- PrintText(tabs);
- }
-
- #endregion
-}
\ No newline at end of file
diff --git a/EscPos/Commands/ESC/SelectFontCommand.cs b/EscPos/Commands/ESC/SelectFontCommand.cs
deleted file mode 100644
index 60a071e..0000000
--- a/EscPos/Commands/ESC/SelectFontCommand.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
-
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
-
-///
-/// Select character font
-/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=27
-///
-public class SelectFontCommand : BaseCommand
-{
- public override string Prefix => EscPosInterpreter.ESC + "M";
- public override bool HasArgs => true;
-
- private int _n;
-
- public override void Reset()
- {
- _n = 0;
- }
-
- public override bool InterpretNextChar(char c)
- {
- _n = c;
- return false;
- }
-
- public override void Execute(ReceiptPrinter printer, string? args)
- {
- switch (_n)
- {
- case 0 or 48:
- printer.SelectFont(PrinterFont.FontA);
- break;
- case 1 or 49:
- printer.SelectFont(PrinterFont.FontB);
- break;
- case 2 or 50:
- printer.SelectFont(PrinterFont.FontC);
- break;
- case 3 or 51:
- printer.SelectFont(PrinterFont.FontD);
- break;
- case 4 or 52:
- printer.SelectFont(PrinterFont.FontE);
- break;
- case 97:
- printer.SelectFont(PrinterFont.SpecialFontA);
- break;
- case 98:
- printer.SelectFont(PrinterFont.SpecialFontB);
- break;
- }
- }
-}
\ No newline at end of file
diff --git a/EscPos/Commands/GS/SelectCharacterSizeCommand.cs b/EscPos/Commands/GS/SelectCharacterSizeCommand.cs
deleted file mode 100644
index 8e17e7f..0000000
--- a/EscPos/Commands/GS/SelectCharacterSizeCommand.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using ReceiptPrinterEmulator.Emulator;
-
-namespace ReceiptPrinterEmulator.EscPos.Commands.GS;
-
-///
-/// Select character size
-/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=34
-///
-public class SelectCharacterSizeCommand : BaseCommand
-{
- public override string Prefix => EscPosInterpreter.GS + "!";
- public override bool HasArgs => true;
-
- private byte _n;
-
- public override void Reset()
- {
- _n = 0;
- }
-
- public override bool InterpretNextChar(char c)
- {
- _n = (byte)c;
- return false;
- }
-
- public override void Execute(ReceiptPrinter printer, string? args)
- {
- var widthMode = _n & 0b01110000; // bits 6,5,4
- var heightMode = _n & 0b00000111; // bits 2,1,0
-
- var charWidth = widthMode switch
- {
- 0 => 1,
- 16 => 2,
- 32 => 3,
- 48 => 4,
- 64 => 5,
- 80 => 6,
- 96 => 7,
- 112 => 8,
- _ => 1
- };
-
- var charHeight = heightMode switch
- {
- 0 => 1,
- 1 => 2,
- 2 => 3,
- 3 => 4,
- 4 => 5,
- 5 => 6,
- 6 => 7,
- 7 => 8,
- _ => 1
- };
-
- printer.SelectCharacterSize(charWidth, charHeight);
- }
-}
\ No newline at end of file
diff --git a/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs b/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs
deleted file mode 100644
index 7dec680..0000000
--- a/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
-
-namespace ReceiptPrinterEmulator.EscPos.Commands.GS;
-
-///
-/// Select cut mode and cut paper
-/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=87
-///
-public class SelectCutModeAndCutCommand : BaseCommand
-{
- public override string Prefix => EscPosInterpreter.GS + "V";
- public override bool HasArgs => true;
-
- private int _idx;
- private int _m;
- private int _n;
-
- public override void Reset()
- {
- _idx = 0;
- _m = 0;
- _n = 0;
- }
-
- public override bool InterpretNextChar(char c)
- {
- if (_idx == 0)
- {
- _idx++;
- _m = c;
- _n = 0;
-
- // If "m" is greater than 49, it means cut function is B, C, or D with a second arg
- return (_m > 49);
- }
-
- if (_idx == 1)
- {
- _idx++;
- _n = c;
- }
-
- return false;
- }
-
- public override void Execute(ReceiptPrinter printer, string? args)
- {
- var function = CutFunction.Cut;
- var shape = CutShape.Full;
-
- switch (_n)
- {
- case 0 or 48:
- function = CutFunction.Cut;
- shape = CutShape.Full;
- break;
- case 1 or 49:
- function = CutFunction.Cut;
- shape = CutShape.Partial;
- break;
- case 65:
- function = CutFunction.FeedAndCut;
- shape = CutShape.Full;
- break;
- case 66:
- function = CutFunction.FeedAndCut;
- shape = CutShape.Partial;
- break;
- case 97:
- function = CutFunction.SetCutPos;
- shape = CutShape.Full;
- break;
- case 98:
- function = CutFunction.SetCutPos;
- shape = CutShape.Partial;
- break;
- case 103:
- function = CutFunction.FeedAndCutAndReverse;
- shape = CutShape.Full;
- break;
- case 104:
- function = CutFunction.FeedAndCutAndReverse;
- shape = CutShape.Partial;
- break;
- }
-
- printer.Cut(function, shape, _n);
- }
-}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..275bee2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Daniel Meza / Gasoleo Technology
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Logging/Logger.cs b/Logging/Logger.cs
deleted file mode 100644
index 93f5eb8..0000000
--- a/Logging/Logger.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using System;
-using System.Text;
-
-namespace ReceiptPrinterEmulator.Logging;
-
-public static class Logger
-{
- public static void Info(params object[] values)
- {
- PrintMessage("Info", values);
- }
-
- public static void Exception(Exception ex, string? message = null)
- {
- PrintMessage("Exception", new object[] { message ?? string.Empty, ex });
- }
-
- private static void PrintMessage(string prefix, object[] values)
- {
- var combinedMessage = $"[{prefix}] {FormatValues(values)}";
- Console.WriteLine(combinedMessage);
- }
-
- private static string FormatValues(params object[] values)
- {
- var sb = new StringBuilder();
-
- foreach (var val in values)
- {
- var asString = val.ToString();
-
- if (String.IsNullOrWhiteSpace(asString))
- continue;
-
- if (sb.Length > 0)
- sb.Append(' ');
- sb.Append(asString);
- }
-
- return sb.ToString();
- }
-}
\ No newline at end of file
diff --git a/MainWindow.xaml b/MainWindow.xaml
deleted file mode 100644
index 1db292d..0000000
--- a/MainWindow.xaml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Reset
- Test print
- Hex Dump Test Ticket
-
-
-
-
-
-
-
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
deleted file mode 100644
index 24bdc19..0000000
--- a/MainWindow.xaml.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Drawing;
-using System.IO;
-using System.Text;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Logging;
-using ReceiptPrinterEmulator.Utils;
-using Image = System.Windows.Controls.Image;
-
-namespace ReceiptPrinterEmulator
-{
- ///
- /// Interaction logic for MainWindow.xaml
- ///
- public partial class MainWindow : Window
- {
- public MainWindow()
- {
- InitializeComponent();
- }
-
- private void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
- {
- App.Printer!.OnActivityEvent += (o, args) =>
- {
- RefreshUI();
- WindowsUtils.FlashWindow(this);
- WindowsUtils.ExclaimSoft();
- };
-
- RefreshUI();
- }
-
- private void ResetButton_OnClick(object sender, RoutedEventArgs e)
- {
- Logger.Info("Resetting");
-
- App.Printer!.ReceiptStack.Clear();
- App.Printer.Initialize();
- App.Printer.StartNewReceipt();
-
- var toRemove = new List();
- foreach (var childControl in ReceiptImageRoot.Children)
- if (childControl is Image imgControl)
- toRemove.Add(imgControl);
- toRemove.ForEach(img => ReceiptImageRoot.Children.Remove(img));
-
- RefreshUI();
- }
-
- private void ShowButton_OnClick(object sender, RoutedEventArgs e)
- {
- var path = "test_receipt.txt";
- if (!File.Exists(path))
- {
- Console.WriteLine("File not found.");
- return;
- }
-
- byte[] data = File.ReadAllBytes(path);
- var sb = new StringBuilder();
- for (int i = 0; i < data.Length; i++)
- {
- sb.AppendFormat("{0:X2} ", data[i]);
- if ((i + 1) % 16 == 0)
- sb.AppendLine();
- }
- Console.WriteLine(sb.ToString());
- }
-
- private void TestButton_OnClick(object sender, RoutedEventArgs e)
- {
- if (!File.Exists("test_receipt.txt"))
- return;
-
- App.Printer?.FeedEscPos(File.ReadAllText("test_receipt.txt", Encoding.ASCII));
- }
-
- private void RefreshUI()
- {
- // Status label
- Address.Text = $"{App.Server!.EndPoint}";
- Address.Foreground = new SolidColorBrush(App.Server!.IsRunning ? Colors.SpringGreen : Colors.Crimson);
-
- // Receipt images
- foreach (var receipt in App.Printer!.ReceiptStack)
- CreateOrUpdateReceiptControl(ReceiptImageRoot, receipt);
-
- MainScrollView.ScrollToBottom();
- }
-
- private void CreateOrUpdateReceiptControl(Panel parentControl, Receipt receipt)
- {
- if (receipt.IsEmpty)
- return;
-
- var guidName = "R" + receipt.Guid.Replace("-", "");
-
- Image? ourControl = null;
-
- foreach (var childControl in parentControl.Children)
- {
- if (childControl is Image imgControl)
- {
- if (imgControl.Name == guidName)
- {
- ourControl = imgControl;
- break;
- }
- }
- }
-
- if (ourControl == null)
- {
- ourControl = new Image();
- ourControl.Name = guidName;
- ourControl.Stretch = Stretch.None;
- ourControl.Margin = new Thickness(0, 0, 0, 10);
-
- parentControl.Children.Add(ourControl);
- }
-
- ourControl.Source = ConvertBitmap(receipt.Render());
- }
-
- ///
- /// Takes a bitmap and converts it to an image that can be handled by WPF ImageBrush
- ///
- /// A bitmap image
- /// The image as a BitmapImage for WPF
- public BitmapImage ConvertBitmap(Bitmap src)
- {
- MemoryStream ms = new MemoryStream();
- ((System.Drawing.Bitmap)src).Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
- BitmapImage image = new BitmapImage();
- image.BeginInit();
- ms.Seek(0, SeekOrigin.Begin);
- image.StreamSource = ms;
- image.EndInit();
- return image;
- }
- }
-}
\ No newline at end of file
diff --git a/Networking/NetServer.cs b/Networking/NetServer.cs
deleted file mode 100644
index 2b738a4..0000000
--- a/Networking/NetServer.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using System.Collections.Generic;
-using System.Net;
-using System.Net.Sockets;
-using System.Threading;
-using System.Threading.Tasks;
-using ReceiptPrinterEmulator.Logging;
-
-namespace ReceiptPrinterEmulator.Networking;
-
-public class NetServer
-{
- public IPEndPoint EndPoint { get; private set; }
-
- private Socket? _tcpSocket;
- private CancellationTokenSource? _cts;
-
- private List _clients;
-
- public bool IsRunning => _tcpSocket is not null && _tcpSocket.IsBound;
-
- public NetServer(int port)
- {
- EndPoint = new IPEndPoint(IPAddress.Any, port);
-
- _clients = new();
- }
-
- public async Task Run()
- {
- Stop();
-
- Logger.Info($"Starting NetServer on TCP port {EndPoint.Port}");
-
- _tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
- _tcpSocket.Bind(EndPoint);
- _tcpSocket.Listen(8);
-
- Logger.Info($"Server bound to {EndPoint}, starting accept/receive loop");
-
- _cts = new CancellationTokenSource();
- await AcceptLoopAsync(_cts.Token);
- }
-
- public void Stop()
- {
- if (_cts is not null)
- {
- _cts.Cancel();
- _cts = null;
- }
- }
-
- private async Task AcceptLoopAsync(CancellationToken cancellationToken)
- {
- while (IsRunning && !cancellationToken.IsCancellationRequested)
- {
- var clientSocket = await _tcpSocket!.AcceptAsync(cancellationToken);
-
- if (!clientSocket.Connected)
- continue;
-
- var client = new NetClient(this, clientSocket);
- _clients.Add(client);
-
- Logger.Info($"Accepted new connection (RemoteEndPoint={client.RemoteEndPoint})");
-
- _ = client.ReceiveLoopAsync();
- }
- }
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9c86817..d3e813d 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,66 @@
# ESC/POS Receipt Printer Emulator
🖨️ **This app emulates a networked receipt printer to test your ESC/POS commands against.**
-
+
### About
-- Windows application (WPF + .NET 9)
-- Binds to a TCP/IP interface and listens for ESC/POS commands
+- Cross-platform application (Avalonia 12 + SkiaSharp + .NET 10), runs on Windows, macOS and Linux
+- Listens for ESC/POS commands over **TCP/IP** and (optionally) a **serial port**
- Logs commands and visually represents the resulting receipt(s)
+- Renders 1D barcodes and 2D codes (QR, PDF417, DataMatrix, Aztec), bit images, and page mode
+- Answers status queries (`DLE EOT`, `GS r`, Automatic Status Back) — simulate paper-out, cover, drawer, offline and error states from the **Printer state** panel
+- Signals buzzer / cash-drawer events with a sound and on-screen toast
+- Configure the TCP listen address/port and serial port live from the UI
+- Export rendered tickets to PNG — all in one image, or one file per cut
- It support different text formattings in the same line, although a few combinations were tested.
+> **Cross-platform fork.** This project began as a cross-platform port of
+> [roydejong/EscPosEmulator](https://github.com/roydejong/EscPosEmulator) (originally a Windows/WPF
+> app). It has been migrated to Avalonia + SkiaSharp + .NET 10 so it runs on Windows, macOS and
+> Linux, and extended with barcode/QR rendering and a serial transport. All credit for the original
+> emulator goes to the upstream author.
+
👷 **This is an unfinished experiment.** Use at your own risk and keep your expectations low. :)
+### Download
+
+Pre-built, **self-contained** apps (no .NET install required) are published on the
+[Releases](../../releases) page:
+
+| Platform | Artifact |
+|----------|----------|
+| Windows (x64) | `CrossEscPos-win-x64.zip` |
+| Linux (x64) | `CrossEscPos-linux-x64.tar.gz` |
+| macOS (Intel) | `CrossEscPos-osx-x64.zip` (`.app` bundle) |
+| macOS (Apple Silicon) | `CrossEscPos-osx-arm64.zip` (`.app` bundle) |
+
+Releases are produced by the [`Release`](.github/workflows/release.yml) GitHub Actions workflow on
+each `v*` tag.
+
+> **macOS first launch.** The `.app` is **ad-hoc signed but not notarized** (no paid Apple Developer
+> ID). macOS quarantines anything downloaded from the internet, so on first launch you may see
+> *"CrossEscPos is damaged and can't be opened"* (especially on Apple Silicon). Clear the
+> quarantine flag once, then open it:
+>
+> ```sh
+> xattr -dr com.apple.quarantine /path/to/CrossEscPos.app
+> open /path/to/CrossEscPos.app
+> ```
+>
+> (Right-click → **Open** also works once the quarantine is cleared.)
+
+### Built with
+
+- [.NET 10](https://dotnet.microsoft.com/) · [Avalonia 12](https://avaloniaui.net/) ·
+ [SkiaSharp](https://github.com/mono/SkiaSharp) (rendering) ·
+ [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) (MVVM)
+- [ZXing.Net](https://github.com/micjahn/ZXing.Net) (1D barcodes) ·
+ [QRCoder](https://github.com/codebude/QRCoder) (QR codes) ·
+ [Ardalis.SmartEnum](https://github.com/ardalis/SmartEnum) (class-based enums) ·
+ [System.IO.Ports](https://www.nuget.org/packages/System.IO.Ports) (serial)
+- [ESC-POS-.NET](https://github.com/lukevp/ESC-POS-.NET) (the Monitor test client) ·
+ [LibUsbDotNet](https://github.com/LibUsbDotNet/LibUsbDotNet) (direct USB printing)
+
### Supported commands
⚠️ Support is currently limited to only a subset of ESC/POS. Even the commands listed here may only be partially implemented.
@@ -33,6 +83,20 @@
- Partial cut (`ESC i`)
- Print and feed n lines (`ESC d`)
- Print and feed paper (`ESC J`)
+ - Generate pulse / kick cash drawer (`ESC p m t1 t2`)
+ - Select character code table (`ESC t`) — PC437/850/852/858/860/863/865/866/1252 remapped to Unicode
+ - Beeper (`ESC ( A`)
+ - Bit image (`ESC *`) — 8-dot and 24-dot inline raster
+ - Page mode: select page / standard mode (`ESC L` / `ESC S`), print area (`ESC W`), direction (`ESC T`), absolute position (`ESC $`)
+ - User-defined characters (`ESC &` / `ESC %` / `ESC ?`) — parsed & stored
+- Control characters:
+ - Buzzer / beeper (`BEL`, 0x07)
+ - Form feed (`FF`) — prints the page in page mode
+ - Cancel (`CAN`) — cancels page data
+- DLE (real-time) Commands:
+ - Real-time status (`DLE EOT n`, n=1-4)
+ - Real-time request / recover (`DLE ENQ`)
+ - Real-time cash-drawer pulse (`DLE DC4 1 m t`)
- FS Commands:
- Print stored logo (`FS p n m`)
- Auto cut (`FS } 0x60 n`)
@@ -41,10 +105,261 @@
- Select cut mode and cut paper
- Paper eject (`GS e n [m t]`)
- Print raster image (`GS v 0 [m xL xH yL yH ...pixels]`)
+ - Print 1D barcode (`GS k`) — UPC-A/E, EAN-13/8, CODE39, CODE93, CODE128, ITF, CODABAR (both function A & B forms)
+ - Set barcode height / module width (`GS h` / `GS w`)
+ - Select HRI text position / font (`GS H` / `GS f`)
+ - Print 2D symbols (`GS ( k`) — QR Code (cn=49), PDF417 (cn=48), DataMatrix (cn=54), Aztec (cn=55)
+ - **Status / transmit-back**: paper & drawer status (`GS r`), printer ID (`GS I`), Automatic Status Back (`GS a`)
+ - Download bit image: define (`GS *`) and print (`GS /`)
+ - Set motion units (`GS P`), absolute/relative vertical position (`GS $` / `GS \`)
+ - Config (accepted/ignored): user setup (`GS ( E`), print control (`GS ( K`), response request (`GS ( H`)
-### Example
+The emulator is **bidirectional**: status commands (`DLE EOT`, `GS r`, `GS I`) and Automatic Status
+Back reply to the host over the same TCP/serial connection, driven by the **Printer state** panel
+(right side) where you can simulate paper-out/near-end, cover open, cash-drawer open/closed,
+offline, and error conditions. Like a real device, the emulator **refuses to print** while it isn't
+ready (out of paper, cover open, offline, or in an error state) and shows a notification instead.
-
+Barcodes and QR codes render inline on the receipt, with optional HRI text:
+
+
+
+### Not yet implemented
+
+A few things remain partial or unimplemented:
+
+- **Page-mode coordinate system** — page mode buffers output and rasterizes it on `FF`, but absolute/relative positioning (`ESC $`, `GS $`, `GS \`) and print direction (`ESC T`) are accepted as no-ops rather than fully positioned.
+- **User-defined glyph substitution** — `ESC &` glyphs are parsed and stored, but the inline text renderer still draws the font glyph rather than the custom bitmap.
+- **MaxiCode / GS1 DataBar / Composite** 2D symbologies (`GS ( k` cn=50/51/52).
+- **Graphics commands** `GS ( L` / `GS 8 L` (NV/raster graphics store-and-print) and other `ESC *`-family densities.
+- **Real-time `DLE DC4`** functions other than the cash-drawer pulse (power-off, recover-and-cancel, buzzer).
+- **Katakana / CJK code pages** render as missing glyphs since the bundled Latin font has no such glyphs.
+
+Contributions welcome — new commands follow the simple `BaseCommand` pattern in
+[`EscPos/Commands`](EscPos/Commands) and are registered in
+[`EscPosInterpreter.RegisterCommands`](EscPos/EscPosInterpreter.cs).
+
+### Connecting
+
+The emulator accepts ESC/POS data over two transports. Both can be changed **live from the UI**
+(left panel): pick a TCP listen address and port and Start/Stop the listener, or select a serial
+port + baud and Open/Close it (⟳ refreshes the port list). The environment variables below set the
+**initial** values at startup:
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `ESCPOS_LISTEN_ADDRESS` | `0.0.0.0` | Initial TCP bind address (`0.0.0.0` = all interfaces, `127.0.0.1` = localhost). |
+| `ESCPOS_TCP_PORT` | `9100` | Initial TCP listen port. Set to `off` / `0` to start with TCP stopped. |
+| `ESCPOS_SERIAL_PORT` | *(unset)* | Serial device to auto-open (e.g. `/dev/ttys004`, `COM3`). Unset = serial closed. |
+| `ESCPOS_SERIAL_BAUD` | `9600` | Serial baud rate. |
+| `ESCPOS_DEBUG_DUMP` | *(off)* | Set to `1` to dump every received payload to `last_*` files. |
+| `ESCPOS_RENDER_BACKEND` | `skia` | Render backend: `skia` (default) or `imagesharp` (managed). The `--backend` arg overrides it. |
+
+Examples:
+
+```sh
+# (run is shorthand for: dotnet run --project src/CrossEscPos.App.Desktop)
+dotnet run --project src/CrossEscPos.App.Desktop # TCP only, port 9100
+ESCPOS_TCP_PORT=9200 dotnet run --project src/CrossEscPos.App.Desktop # TCP on 9200
+ESCPOS_SERIAL_PORT=/dev/ttys004 dotnet run --project src/CrossEscPos.App.Desktop # TCP 9100 + serial
+ESCPOS_TCP_PORT=off ESCPOS_SERIAL_PORT=COM3 dotnet run --project src/CrossEscPos.App.Desktop # serial only
+```
+
+**Pick the render backend** (for A/B testing the two rendering libraries) with `--backend skia`
+(default) or `--backend imagesharp` (the managed, no-native backend). The active backend is shown in
+the window title bar.
+
+```sh
+dotnet run --project src/CrossEscPos.App.Desktop -- --backend imagesharp # managed ImageSharp backend
+dotnet run --project src/CrossEscPos.App.Desktop -- --backend skia # default SkiaSharp backend
+```
+
+The status panel shows the active TCP endpoint and serial port.
+
+#### Testing serial without hardware (app-to-app on one machine)
+
+You don't need a USB serial adapter. Create a **virtual serial bridge** — a pair of linked ports —
+then point the emulator at one end and your POS application (or a shell) at the other. Bytes written
+to one end appear on the other.
+
+**macOS / Linux** — using [`socat`](http://www.dest-unreach.org/socat/) (`brew install socat` /
+`apt install socat`). A helper script is included:
+
+```sh
+./scripts/serial-bridge.sh
+# It prints a linked pair, e.g.:
+# PORT A (emulator): /dev/ttys004
+# PORT B (your app): /dev/ttys005
+# Leave it running.
+```
+
+Then, in two more terminals:
+
+```sh
+# Terminal 2 — run the emulator on port A
+ESCPOS_SERIAL_PORT=/dev/ttys004 dotnet run --project src/CrossEscPos.App.Desktop
+
+# Terminal 3 — send a receipt from "another app" on port B
+cat test_receipt.txt > /dev/ttys005
+# …or from your own program, just open /dev/ttys005 like a normal serial port
+# (9600 8N1) and write ESC/POS bytes to it.
+```
+
+The receipt appears in the emulator window. Because the interpreter is stateful across reads,
+fragmented serial writes (commands split across packets) are handled correctly.
+
+**Windows** — install [com0com](https://com0com.sourceforge.net/) and create a linked pair
+(e.g. `COM3` ↔ `COM4`). Run the emulator with `ESCPOS_SERIAL_PORT=COM3` and have your application
+write to `COM4`.
+
+### Monitor (built-in test client)
+
+Sending test jobs is the **monitor's** job — the emulator is the device, the monitor is the POS-side
+client that drives it over the wire (just like a real application would). The Monitor is **shared** by
+both heads: click **Open monitor…** to launch it — a window on desktop, an in-page overlay in the
+browser. Its test jobs and status display are identical; only the transport differs:
+
+- **Desktop** (built on [ESC-POS-.NET](https://github.com/lukevp/ESC-POS-.NET)) — pick a transport:
+ - **TCP/IP** — connect to the emulator's listener (or any networked printer).
+ - **Serial** — pick a port + baud; pairs with the emulator's serial transport via a virtual port bridge.
+ - **USB** — print **directly to a real USB printer** selected from the connected-device list (by
+ VID:PID), via libusb. This is send-only (no status), and needs native **libusb** installed
+ (macOS `brew install libusb`, Debian/Ubuntu `apt install libusb-1.0-0`; bundled on Windows) and the
+ OS not already holding the device.
+- **Browser** — sends over the **SignalR proxy** (the `CrossEscPos.Host` hub) to the in-page emulator,
+ for a full round-trip without any native transport.
+
+It then lets you exercise the target without writing any code:
+
+- Print a sample receipt, all 1D barcodes, or QR / PDF417 / DataMatrix / Aztec.
+- Send the full feature test receipt, open the cash drawer, buzz, or cut.
+- Watch the **printer status** the emulator reports back: toggle paper-out / cover / drawer / offline
+ in the **Printer state** panel and the monitor's status display updates live (via Automatic Status
+ Back), confirming the emulator's status responses are wire-correct. When the printer isn't ready,
+ the emulator drops the job and shows a notification, just like real hardware.
+
+
+
+Toggling the emulator's **Printer state** panel pushes status to the monitor in real time — here the
+printer reports *paper low* and a *recoverable error*, so the monitor shows **Not ready**:
+
+
+
+### Exporting tickets
+
+Each cut (`ESC i` / `ESC m` / `GS V`) starts a new receipt — a "page". The **Export** buttons in the
+left panel save the rendered tickets as PNG:
+
+- **Export all (single image)** — stacks every receipt into one tall PNG (a save dialog).
+- **Export each cut (folder)** — writes one `receipt_NNN.png` per cut into a chosen folder.
+
+### Architecture & packages
+
+The emulator is split into layered, independently-publishable packages so the **rendering backend is
+swappable** and the **core runs headless** (and in the browser). The namespace is unified under
+`CrossEscPos.*` (organized by feature/directory, not by package), so types resolve across assemblies.
+
+| Package | Namespace(s) | Role | Depends on |
+| --- | --- | --- | --- |
+| `CrossEscPos.Abstractions` | `CrossEscPos`, `CrossEscPos.Graphics` | Backend-agnostic rendering + printer contracts (`IReceiptCanvas`, `IReceiptImage`, `IReceiptImageFactory`, `ITypefaceProvider`, `IImageEncoder`, `IReceiptPrintable`, `IPrinterResponder`) | — |
+| `CrossEscPos.Core` | `CrossEscPos.Emulator`, `CrossEscPos.EscPos`, … | Headless ESC/POS interpreter, printer state machine, receipt document model, barcode/QR generation. ESC/POS code maps (2D families, code tables, barcode systems, status requests) are behaviour-carrying `SmartEnum`s, not magic-number switches | Abstractions, QRCoder, ZXing.Net, Ardalis.SmartEnum |
+| `CrossEscPos.Rendering.Skia` | `CrossEscPos.Rendering.Skia` | The default **render backend** (SkiaSharp). Swap it for another `IReceiptImageFactory`/`ITypefaceProvider`/`IImageEncoder` | Abstractions, SkiaSharp |
+| `CrossEscPos.Rendering.ImageSharp` | `CrossEscPos.Rendering.ImageSharp` | A **100% managed render backend** (ImageSharp) — no native dependency, so it runs in **Blazor WASM** without a native relink. Same output as the Skia backend | Abstractions, SixLabors.ImageSharp.Drawing |
+| `CrossEscPos.Transports` | `CrossEscPos.Transports` | TCP / serial / USB transports (desktop only) | Core, System.IO.Ports, LibUsbDotNet, ESC-POS-.NET |
+| `CrossEscPos.Controls` | `CrossEscPos.Controls` | Reusable Avalonia controls (`ReceiptView`, `PrinterStatePanel`) — host apps consume these. **Backend-agnostic** (no SkiaSharp dependency) | Core, Avalonia |
+
+`Core` carries **no UI and no graphics-backend dependency**, so the library works headless or in WASM.
+The host (desktop, browser, or your own app) is the composition root: it picks a backend and injects it.
+
+```csharp
+var imageFactory = new SkiaImageFactory();
+var typefaces = new SkiaTypefaceProvider();
+var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces);
+printer.FeedEscPos(escPosBytes); // byte[] — ESC/POS is binary
+using var image = printer.CurrentReceipt.Render(); // IReceiptImage
+new SkiaImageEncoder().EncodePng(image, outputStream);
+```
+
+📦 **Package usage guides:** [`docs/packages/`](docs/packages/README.md) — getting started, the core
+emulator, rendering & custom backends (Skia + managed ImageSharp), the Avalonia controls, and the
+transports. The desktop and browser apps are one shared Avalonia app ([`src/CrossEscPos.App`](src/CrossEscPos.App)).
+
+📖 **Full reference & guides:** the [**project wiki**](https://github.com/danielmeza/CrossEscPosEmulator/wiki),
+including a step-by-step [**Adding a render backend**](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend) guide.
+
+### Building & running
+
+Requires the .NET 10 SDK (and the `wasm-tools` workload for the browser head:
+`dotnet workload install wasm-tools`).
+
+```sh
+# Desktop app (Windows / macOS / Linux)
+dotnet run --project src/CrossEscPos.App.Desktop
+
+# Headless: ESC/POS bytes -> PNG, no UI, no Avalonia
+dotnet run --project samples/CrossEscPos.Headless -- test_receipt.txt out.png
+
+# Browser: the SAME app in the browser (Avalonia WASM head) — full parity with desktop
+dotnet run --project src/CrossEscPos.App.Browser
+
+# Web host: serves the browser app AND the SignalR broker on one origin, giving it TCP reception
+# (browsers can't open raw sockets). First run publishes the WASM app into wwwroot (cached after).
+# Browse to the printed URL; the emulator opens the TCP port itself.
+dotnet run --project samples/CrossEscPos.Host
+
+# Tests (unit + headless UI)
+dotnet test CrossEscPos.slnx
+```
+
+**One app, two heads.** The desktop and browser apps are the **same** Avalonia application
+([`src/CrossEscPos.App`](src/CrossEscPos.App)) — the same views, view models, receipts, printer-state
+panel, PNG export, and the Monitor test-client. Only the platform edges differ, injected via
+`IPlatformServices`:
+
+| | Desktop head | Browser head |
+|---|---|---|
+| Transports | TCP + serial | **Web Serial + WebUSB + SignalR TCP proxy** |
+| Export | native save dialog | browser **download** (same Avalonia storage API) |
+| Monitor | test-client **window** | in-page **overlay** (SignalR round-trip) |
+
+A browser can't open a raw **TCP** listen socket, so the browser head connects over **SignalR** to
+[`samples/CrossEscPos.Host`](samples/CrossEscPos.Host) — one ASP.NET Core host that serves the WASM app
+*and* runs the broker. The emulator asks the host to open a TCP listener on the address:port you choose;
+POS software connects there and its jobs bridge to the in-page emulator, with status flowing back. The
+same hub carries the browser Monitor's jobs, so it round-trips against the in-page emulator too.
+
+- **Windows / macOS:** no extra setup — native rendering libraries ship with the Avalonia packages.
+- **Linux:** install the usual font/render native deps if they are missing, e.g.
+ `sudo apt install libfontconfig1 libfreetype6` (Debian/Ubuntu).
+
+### Testing
+
+Two test projects under [`tests/`](tests):
+
+- **`CrossEscPos.Core.Tests`** — emulation coverage. ESC/POS sequences are fed through the interpreter
+ and the observable results (rendered draws, printer state, host responses, events) asserted: text
+ and control characters, styling (emphasis/italic/size/justification), cutting and feeding, 1D/2D
+ barcodes and bit images, status/transmit-back (`DLE EOT`, `GS r`, `GS I`, `GS a`), cash drawer and
+ buzzer, real-time commands, code pages, page mode and not-ready handling — plus exact `StatusByteBuilder`
+ bit layouts and the `SmartEnum` code maps. Rendering goes to a synthetic backend, so the suite runs
+ with no SkiaSharp (proving the core is headless).
+- **`CrossEscPos.Controls.Tests`** — headless Avalonia UI tests for the controls (two-way state binding,
+ receipt image rendering).
+
+```sh
+dotnet test CrossEscPos.slnx
+```
+
+### Fonts & license
+
+Receipt text is rendered with **[JetBrains Mono](https://www.jetbrains.com/lp/mono/)**, embedded in the
+`CrossEscPos.Rendering.Skia` package (under
+[`src/CrossEscPos.Rendering.Skia/Assets/Fonts/`](src/CrossEscPos.Rendering.Skia/Assets/Fonts)) so output
+is identical across platforms and works in the browser sandbox (no file IO). JetBrains Mono is licensed
+under the **SIL Open Font License 1.1**; the full license text is included at
+[`src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt`](src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt). Per the OFL, the font is redistributed here under its
+original license and "JetBrains Mono" is a trademark of JetBrains s.r.o. To swap in a different
+monospace font, replace the `receipt-mono*.ttf` files (and keep its license alongside).
### Emulated printer
diff --git a/ReceiptPrinterEmulator.csproj b/ReceiptPrinterEmulator.csproj
deleted file mode 100644
index b786aef..0000000
--- a/ReceiptPrinterEmulator.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- Exe
- net9.0-windows7.0
- enable
- true
-
-
-
-
-
-
-
-
- Always
-
-
-
-
diff --git a/ReceiptPrinterEmulator.sln b/ReceiptPrinterEmulator.sln
deleted file mode 100644
index a82252f..0000000
--- a/ReceiptPrinterEmulator.sln
+++ /dev/null
@@ -1,16 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReceiptPrinterEmulator", "ReceiptPrinterEmulator.csproj", "{6D581EC7-33B0-45DF-B829-C3EE718B30F1}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
-EndGlobal
diff --git a/Utils/WindowsUtils.cs b/Utils/WindowsUtils.cs
deleted file mode 100644
index e36ef26..0000000
--- a/Utils/WindowsUtils.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using System;
-using System.Media;
-using System.Runtime.InteropServices;
-using System.Windows;
-using System.Windows.Interop;
-
-namespace ReceiptPrinterEmulator.Utils;
-
-public static class WindowsUtils
-{
- [DllImport("user32")]
- public static extern int FlashWindow(IntPtr hwnd, bool bInvert);
- public static void FlashWindow(Window wnd) => FlashWindow(new WindowInteropHelper(wnd).Handle, true);
-
- public static void Exclaim() => SystemSounds.Exclamation.Play();
- public static void ExclaimSoft() => SystemSounds.Hand.Play();
-}
\ No newline at end of file
diff --git a/docs/Example QR.png b/docs/Example QR.png
new file mode 100644
index 0000000..6ed1c06
Binary files /dev/null and b/docs/Example QR.png differ
diff --git a/docs/Example.png b/docs/Example.png
index 74b7596..b0fe8bb 100644
Binary files a/docs/Example.png and b/docs/Example.png differ
diff --git a/docs/Monitor Invalid State.png b/docs/Monitor Invalid State.png
new file mode 100644
index 0000000..555fc4f
Binary files /dev/null and b/docs/Monitor Invalid State.png differ
diff --git a/docs/Monitor.png b/docs/Monitor.png
new file mode 100644
index 0000000..0fa381c
Binary files /dev/null and b/docs/Monitor.png differ
diff --git a/docs/packages/README.md b/docs/packages/README.md
new file mode 100644
index 0000000..c9576e3
--- /dev/null
+++ b/docs/packages/README.md
@@ -0,0 +1,85 @@
+# Using the CrossEscPos packages
+
+CrossEscPos is split into layered, independently-usable packages so you can take only what you need —
+the headless emulator, a render backend, the transports, and/or the Avalonia controls.
+
+| Package | What it gives you | Depends on |
+| --- | --- | --- |
+| **CrossEscPos.Abstractions** | Backend-agnostic contracts: `IReceiptCanvas`, `IReceiptImage`, `IReceiptImageFactory`, `ITypefaceProvider`, `IImageEncoder`, `IReceiptPrintable`, `IPrinterResponder` | — |
+| **CrossEscPos.Core** | The headless ESC/POS emulator: `ReceiptPrinter`, the interpreter, the receipt document model, barcode/QR | Abstractions |
+| **CrossEscPos.Rendering.Skia** | The default render backend (SkiaSharp); fonts embedded | Abstractions |
+| **CrossEscPos.Rendering.ImageSharp** | A 100% managed render backend (ImageSharp) — no native dependency, so it runs in Blazor WASM with no native relink. Byte-compatible output with Skia | Abstractions |
+| **CrossEscPos.Transports** | TCP / serial / USB transports that feed a printer | Core |
+| **CrossEscPos.Controls** | Reusable Avalonia controls: `ReceiptView`, `PrinterStatePanel` | Core, Avalonia |
+
+Everything lives under the single `CrossEscPos.*` namespace root (organized by feature, e.g.
+`CrossEscPos.Emulator`, `CrossEscPos.Graphics`, `CrossEscPos.Controls`), so types resolve across the
+packages regardless of which one they ship in.
+
+## How the packages depend on each other
+
+```mermaid
+flowchart BT
+ Abstractions["CrossEscPos.Abstractions"]
+ Core["CrossEscPos.Core"] --> Abstractions
+ Skia["CrossEscPos.Rendering.Skia"] --> Abstractions
+ ImageSharp["CrossEscPos.Rendering.ImageSharp"] --> Abstractions
+ Transports["CrossEscPos.Transports"] --> Core
+ Controls["CrossEscPos.Controls"] --> Core
+ Controls --> Abstractions
+
+ %% A host (your app) composes a backend + core + controls/transports
+ Host(["your host app"]) -.-> Core
+ Host -.->|"pick one backend"| Skia
+ Host -.->|"…or"| ImageSharp
+ Host -.-> Controls
+ Host -.-> Transports
+```
+
+## The mental model
+
+```mermaid
+flowchart TD
+ bytes["ESC/POS bytes"] --> printer["ReceiptPrinter (Core · headless)"]
+ printer --> stack["ReceiptStack one Receipt per cut"]
+ stack -->|"Render()"| image["IReceiptImage"]
+ image --> encoder["IImageEncoder"]
+ encoder --> png["PNG bytes (or an Avalonia Bitmap in a host)"]
+
+ backend["render backend Rendering.Skia, Rendering.ImageSharp, or your own"]
+ backend -. "IReceiptImageFactory + ITypefaceProvider" .-> printer
+```
+
+`Core` knows **nothing** about SkiaSharp or Avalonia. You — the host — pick a render backend and inject
+it. That's the whole design: the emulation is portable (headless, server, WASM); rendering is swappable.
+
+## Guides
+
+- **[Getting started](getting-started.md)** — install and render your first ticket headless.
+- **[Core](core.md)** — feeding ESC/POS, receipts, printer state, status responses, events.
+- **[Rendering](rendering.md)** — the Skia and ImageSharp backends, exporting PNGs, and writing your own.
+- **[Controls](controls.md)** — hosting `ReceiptView` and `PrinterStatePanel` in an Avalonia app.
+- **[Transports](transports.md)** — driving the emulator over TCP, serial, or USB.
+- **[Blazor web app](web.md)** — render ESC/POS in the browser with the managed backend (Blazor WASM).
+
+> 📚 For the full reference — including a step-by-step **[Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend)** guide — see the [project wiki](https://github.com/danielmeza/CrossEscPosEmulator/wiki).
+
+## Installing
+
+The packages are on NuGet (published by the [`Release`](../../.github/workflows/release.yml) workflow on each `v*` tag):
+
+```sh
+dotnet add package CrossEscPos.Core
+dotnet add package CrossEscPos.Rendering.Skia # native SkiaSharp backend (desktop default)
+# or the managed, WASM-safe backend:
+dotnet add package CrossEscPos.Rendering.ImageSharp
+# …and Controls / Transports as needed
+```
+
+Prefer local builds? Pack them or reference the projects directly:
+
+```sh
+dotnet pack -c Release # emits the .nupkg files under each project's bin/Release
+# or
+dotnet add reference ../CrossEscPosEmulator/src/CrossEscPos.Core/CrossEscPos.Core.csproj
+```
diff --git a/docs/packages/controls.md b/docs/packages/controls.md
new file mode 100644
index 0000000..6076e89
--- /dev/null
+++ b/docs/packages/controls.md
@@ -0,0 +1,105 @@
+# CrossEscPos.Controls
+
+Reusable Avalonia controls so a host app can show a live receipt stream and drive the simulated printer
+state. The controls are **backend-agnostic** — they depend on the abstraction, not on SkiaSharp. Your
+app is the composition root that picks the render backend.
+
+```mermaid
+flowchart TD
+ app["your Avalonia app (composition root)"]
+ app -->|injects SkiaImageFactory / TypefaceProvider / Encoder| printer["ReceiptPrinter"]
+ app --> view["ReceiptView"]
+ app --> panel["PrinterStatePanel"]
+ printer --> receipts["Receipt(s)"]
+ receipts -->|wrapped as| vm["ReceiptViewModel (exposes an Avalonia Bitmap)"]
+ vm --> view
+ printer --> state["PrinterState"]
+ state --> panel
+```
+
+## The controls
+
+### `ReceiptView`
+
+Renders the live stream of receipts (one image per cut) with a per-page PNG export button. Bind its
+`Receipts` property to an `IEnumerable` of `ReceiptViewModel`.
+
+```xml
+
+
+
+```
+
+It also exposes `ScrollToEnd()` so the host can keep the newest receipt in view.
+
+### `PrinterStatePanel`
+
+Two-way binds the simulated `PrinterState` (online, cover, paper, drawer, error, feed button). Toggling
+it drives the status commands the emulator reports.
+
+```xml
+
+```
+
+## Wiring it up
+
+`ReceiptViewModel` wraps a `Receipt` and exposes a ready-to-bind Avalonia `Bitmap`. It reaches the
+render backend only through `IImageEncoder`, so the control never sees SkiaSharp.
+
+```csharp
+using CrossEscPos.Controls;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Rendering.Skia;
+
+public partial class MainViewModel : ObservableObject
+{
+ private readonly ReceiptPrinter _printer;
+ private readonly SkiaImageEncoder _encoder = new();
+ private readonly IFileDialogService _dialogs; // your Avalonia implementation
+
+ public ObservableCollection Receipts { get; } = new();
+ public PrinterState State => _printer.State;
+
+ public MainViewModel(IFileDialogService dialogs)
+ {
+ _dialogs = dialogs;
+ _printer = new ReceiptPrinter(PaperConfiguration.Default, new SkiaImageFactory(), new SkiaTypefaceProvider());
+ // Marshal off-thread state changes (e.g. a TCP drawer kick) onto the UI thread.
+ _printer.UiDispatch = a => Dispatcher.UIThread.Post(a);
+ }
+
+ // Call after feeding ESC/POS to (re)build the bound view models.
+ private void Refresh()
+ {
+ Receipts.Clear();
+ int index = 1;
+ foreach (var receipt in _printer.ReceiptStack)
+ {
+ if (receipt.IsEmpty) continue;
+ Receipts.Add(new ReceiptViewModel(receipt, _encoder, _dialogs, index++));
+ }
+ }
+}
+```
+
+## Service interfaces
+
+The controls depend on two small host-provided services (in `CrossEscPos.Controls.Services`):
+
+- `IFileDialogService` — `SavePngAsync(suggestedName)` and `PickFolderAsync()` for the export buttons.
+- `INotificationService` — surface buzzer / cash-drawer / activity events (sound, taskbar flash, …).
+
+The desktop app provides Avalonia implementations; a browser/headless host can stub them (e.g. return
+`null` from the dialogs to disable export).
+
+## Converting an image yourself
+
+If you render outside the view models, `AvaloniaImageExtensions` bridges an `IReceiptImage` to an
+Avalonia `Bitmap` via PNG (no SkiaSharp dependency in your UI code):
+
+```csharp
+using CrossEscPos.Controls;
+
+Bitmap bmp = receiptImage.ToAvaloniaBitmap(encoder);
+```
diff --git a/docs/packages/core.md b/docs/packages/core.md
new file mode 100644
index 0000000..390458e
--- /dev/null
+++ b/docs/packages/core.md
@@ -0,0 +1,105 @@
+# CrossEscPos.Core
+
+The headless emulator: the ESC/POS interpreter, the `ReceiptPrinter` state machine, the receipt
+document model, and barcode/QR generation. No SkiaSharp, no Avalonia — you supply a render backend
+(an `IReceiptImageFactory` + `ITypefaceProvider`) when you construct the printer.
+
+```csharp
+using CrossEscPos.Emulator;
+using CrossEscPos.Graphics; // the abstraction types
+```
+
+## Constructing a printer
+
+```csharp
+var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces);
+```
+
+`PaperConfiguration` controls the paper geometry (defaults to 80mm @ 203dpi):
+
+```csharp
+var paper = new PaperConfiguration { PaperWidthMm = 58, PrintWidthMm = 54, DotsPerInch = 203 };
+var printer = new ReceiptPrinter(paper, imageFactory, typefaces);
+```
+
+## Feeding ESC/POS
+
+`FeedEscPos` is the single entry point. ESC/POS is binary, so the `byte[]` / `ReadOnlySpan`
+overloads are the natural ones; a `string` overload (one char per byte, Latin1) is there for convenience.
+
+```csharp
+printer.FeedEscPos(bytes); // byte[]
+printer.FeedEscPos(buffer.AsSpan(0, count)); // ReadOnlySpan
+printer.FeedEscPos("Hello\n"); // string convenience
+```
+
+Each call is parsed and executed immediately. Malformed/unsupported commands are logged and skipped —
+a bad byte never throws out of `FeedEscPos`.
+
+## Receipts
+
+```csharp
+printer.CurrentReceipt // the receipt currently being printed (Receipt)
+printer.ReceiptStack // IReadOnlyList — one entry per cut
+receipt.IsEmpty // nothing printed yet
+using var image = receipt.Render(); // IReceiptImage (dispose it)
+```
+
+## Printer state
+
+`ReceiptPrinter.State` is a `PrinterState` you can both read and drive. Driving it makes the emulator
+answer status commands the way a real device would (out of paper, cover open, …). It implements
+`INotifyPropertyChanged` and raises `Changed` on any mutation.
+
+```csharp
+printer.State.Online = false; // now status replies report offline
+printer.State.Paper = PaperLevel.Out; // and "out of paper"
+printer.State.Changed += () => { /* refresh UI */ };
+```
+
+While the printer isn't ready (offline, no paper, cover open, error), print operations are dropped and
+`OnPrintBlocked` fires with a reason — mirroring real hardware.
+
+## Status / transmit-back
+
+Status commands (`DLE EOT`, `GS r`, `GS I`, Automatic Status Back) reply to the host. Provide a channel
+by implementing `IPrinterResponder`:
+
+```csharp
+sealed class MyResponder : IPrinterResponder
+{
+ public void Send(byte[] data) { /* write back to the host */ }
+}
+
+// Per-request reply (the responder for the bytes currently being processed):
+printer.FeedEscPos(requestBytes, new MyResponder());
+
+// Or register a long-lived responder to also receive Automatic Status Back broadcasts:
+printer.RegisterResponder(myResponder);
+```
+
+The transports in `CrossEscPos.Transports` already implement `IPrinterResponder` for you.
+
+## Events
+
+```csharp
+printer.OnActivityEvent += (_, _) => { }; // a payload was received & processed
+printer.OnBuzzer += () => { }; // BEL / buzzer command
+printer.OnCashDrawer += () => { }; // cash-drawer kick (also sets State.DrawerOpen)
+printer.OnPrintBlocked += reason => { }; // print dropped because not ready
+```
+
+## Threading
+
+Events and status can fire from a transport's receive thread. If you bind `State` to UI, marshal those
+mutations onto the UI thread via `UiDispatch` (it runs synchronously by default — fine for headless):
+
+```csharp
+printer.UiDispatch = action => Dispatcher.UIThread.Post(action); // Avalonia example
+```
+
+## Code pages
+
+`ESC t n` selects a character code table; high bytes are remapped to Unicode for rendering. The legacy
+code pages (437/850/852/858/866/1252, …) are provided by the .NET runtime, so this works on every
+platform with no extra setup.
diff --git a/docs/packages/getting-started.md b/docs/packages/getting-started.md
new file mode 100644
index 0000000..b834b7a
--- /dev/null
+++ b/docs/packages/getting-started.md
@@ -0,0 +1,64 @@
+# Getting started
+
+The smallest useful setup: turn a stream of ESC/POS bytes into a PNG, with no UI.
+
+## 1. Reference the packages
+
+```sh
+dotnet add package CrossEscPos.Core
+dotnet add package CrossEscPos.Rendering.Skia
+```
+
+Prefer a **fully managed** backend (no native dependency — needed for Blazor WASM without a native
+relink)? Swap the render package for `CrossEscPos.Rendering.ImageSharp`; everything below is identical
+apart from the `Skia*` type names becoming `ImageSharp*`.
+
+(See the [package index](README.md#installing) for versions and local builds.)
+
+## 2. Compose a printer and render
+
+```csharp
+using CrossEscPos.Emulator; // ReceiptPrinter, PaperConfiguration
+using CrossEscPos.Rendering.Skia; // SkiaImageFactory, SkiaTypefaceProvider, SkiaImageEncoder
+
+// The composition root: pick a render backend and inject it.
+var imageFactory = new SkiaImageFactory();
+var typefaces = new SkiaTypefaceProvider();
+var encoder = new SkiaImageEncoder();
+
+var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces);
+
+// ESC/POS is binary — feed the raw bytes.
+byte[] escpos = File.ReadAllBytes("ticket.escpos");
+printer.FeedEscPos(escpos);
+
+// Each "cut" produces a Receipt; render the current one to an image and encode it.
+using var image = printer.CurrentReceipt.Render(); // IReceiptImage
+using var output = File.Create("ticket.png");
+encoder.EncodePng(image, output);
+```
+
+That's the entire headless pipeline. No Avalonia, no window, no UI thread — `CrossEscPos.Core` has no
+dependency on a graphics framework; `CrossEscPos.Rendering.Skia` is the backend you plugged in.
+
+## 3. Multiple receipts (cuts)
+
+A `GS V` / `ESC i` / `ESC m` cut starts a new receipt. They accumulate in `printer.ReceiptStack`:
+
+```csharp
+foreach (var receipt in printer.ReceiptStack)
+{
+ if (receipt.IsEmpty) continue;
+ using var img = receipt.Render();
+ // …encode each, or stack them — see Rendering.
+}
+```
+
+## Where to go next
+
+- [Core](core.md) — printer state, status replies, events, code pages.
+- [Rendering](rendering.md) — the Skia + ImageSharp backends, exporting, stacking, and writing your own.
+- [Controls](controls.md) — show receipts live in an Avalonia app.
+- [Transports](transports.md) — feed the printer over TCP/serial/USB instead of a file.
+- [Blazor web app](web.md) — render ESC/POS in the browser with the managed backend.
+- [Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend) (wiki) — the full step-by-step guide.
diff --git a/docs/packages/rendering.md b/docs/packages/rendering.md
new file mode 100644
index 0000000..af59be5
--- /dev/null
+++ b/docs/packages/rendering.md
@@ -0,0 +1,143 @@
+# Rendering: the backends and writing your own
+
+`CrossEscPos.Core` renders the receipt document model through the **abstraction** in
+`CrossEscPos.Abstractions` (`CrossEscPos.Graphics`), never against a concrete graphics library. A
+**render backend** implements that abstraction. Two ship in-box:
+
+| Backend | Package | Best for |
+| --- | --- | --- |
+| **SkiaSharp** (default) | `CrossEscPos.Rendering.Skia` | Desktop / server — fast native rasterization |
+| **ImageSharp** (managed) | `CrossEscPos.Rendering.ImageSharp` | Blazor WASM / anywhere native deps are unwanted — no native relink, byte-compatible output |
+
+Both embed the same monospace font and produce the **same receipts**; a host picks one and injects it.
+
+```mermaid
+flowchart LR
+ subgraph core["CrossEscPos.Core"]
+ receipt["Receipt / printables"]
+ end
+ subgraph abs["CrossEscPos.Abstractions"]
+ factory["IReceiptImageFactory"]
+ canvas["IReceiptCanvas"]
+ image["IReceiptImage"]
+ font["ITypefaceProvider / IReceiptFont"]
+ encoder["IImageEncoder"]
+ end
+ subgraph backends["render backends"]
+ s["Rendering.Skia SkiaImageFactory · SkiaReceiptCanvas · …"]
+ i["Rendering.ImageSharp ImageSharpImageFactory · ImageSharpReceiptCanvas · …"]
+ end
+ receipt --> factory
+ receipt --> canvas
+ receipt --> font
+ factory -.implemented by.-> s
+ factory -.implemented by.-> i
+ canvas -.implemented by.-> s
+ canvas -.implemented by.-> i
+```
+
+## The Skia backend (native, default)
+
+```csharp
+using CrossEscPos.Rendering.Skia;
+
+var imageFactory = new SkiaImageFactory(); // IReceiptImageFactory
+var typefaces = new SkiaTypefaceProvider(); // ITypefaceProvider (fonts embedded)
+var encoder = new SkiaImageEncoder(); // IImageEncoder (PNG)
+```
+
+Backed by native `libSkiaSharp`. In Blazor WASM this needs the `wasm-tools` workload and a native
+relink of the runtime (a larger `dotnet.native.wasm`).
+
+## The ImageSharp backend (100% managed)
+
+Same three types, same output — but pure managed code (SixLabors.ImageSharp), so **no native
+dependency**. It loads like any other assembly and runs in **Blazor WASM with no native relink**.
+
+```csharp
+using CrossEscPos.Rendering.ImageSharp;
+
+var imageFactory = new ImageSharpImageFactory(); // IReceiptImageFactory
+var typefaces = new ImageSharpTypefaceProvider(); // ITypefaceProvider (fonts embedded)
+var encoder = new ImageSharpImageEncoder(); // IImageEncoder (PNG)
+```
+
+Everything downstream (`ReceiptPrinter`, `Render()`, encoding) is identical — only the injected triple
+differs. See the [Blazor web app](web.md), which lets you switch between the two engines at runtime.
+
+The monospace receipt font (JetBrains Mono, OFL) is embedded in each backend and loaded from memory, so
+receipts render identically on every OS and inside the browser sandbox with no file IO.
+
+## Exporting
+
+```csharp
+using var image = printer.CurrentReceipt.Render(); // IReceiptImage
+
+encoder.EncodePng(image, stream); // write to a Stream
+byte[] png = encoder.EncodePng(image); // or get the bytes
+```
+
+Stack every receipt into one tall image (the "export all" use case):
+
+```csharp
+using CrossEscPos.Emulator.Rendering;
+
+var images = printer.ReceiptStack.Where(r => !r.IsEmpty).Select(r => r.Render()).ToList();
+using var combined = ReceiptExporter.StackVertical(images, imageFactory);
+encoder.EncodePng(combined, output);
+foreach (var i in images) i.Dispose();
+```
+
+## Writing your own backend
+
+Want a different backend (System.Drawing, a GPU canvas, an HTML5 ``, a null/measuring backend…)?
+Implement these five interfaces and inject them — `Core` is none the wiser.
+
+| Interface | Responsibility |
+| --- | --- |
+| `IReceiptImageFactory` | create blank images, build images from raw pixels, and create a canvas over an image |
+| `IReceiptCanvas` | draw text, rects, lines and images; transform stack (`Save`/`Translate`/`Scale`/`RestoreToCount`) |
+| `IReceiptImage` | a rasterized image (`Width`, `Height`, `Copy`) |
+| `ITypefaceProvider` / `IReceiptFont` | resolve a font by family/bold/italic/size; measure text (**advance width**) + metrics |
+| `IImageEncoder` | encode an `IReceiptImage` to PNG |
+
+Anti-aliasing is fixed per primitive to keep output consistent: text and lines are anti-aliased,
+filled rects are not (crisp barcode modules), scaled image draws are sampled. A minimal sketch:
+
+```csharp
+using CrossEscPos.Graphics;
+
+public sealed class MyImageFactory : IReceiptImageFactory
+{
+ public IReceiptImage Create(int w, int h, ReceiptColor fill) => /* … */;
+ public IReceiptImage FromPixels(int w, int h, ReceiptColor[] rowMajor) => /* … */;
+ public IReceiptCanvas CreateCanvas(IReceiptImage image) => /* … */;
+}
+
+// …plus MyReceiptCanvas : IReceiptCanvas, MyTypefaceProvider : ITypefaceProvider, etc.
+
+var printer = new ReceiptPrinter(PaperConfiguration.Default, new MyImageFactory(), new MyTypefaceProvider());
+```
+
+That's the entire contract for swapping the render layer.
+
+### 📌 Worked example — the ImageSharp backend (PR #11)
+
+`CrossEscPos.Rendering.ImageSharp` is itself the reference sample for adding a backend: it mirrors the
+Skia one class-for-class against the same contract. Study it end-to-end in
+[**PR #11**](https://github.com/danielmeza/CrossEscPosEmulator/pull/11) (by
+[@yhonc9](https://github.com/yhonc9)) and the sources under
+[`src/CrossEscPos.Rendering.ImageSharp/`](../../src/CrossEscPos.Rendering.ImageSharp).
+
+A couple of contract subtleties that PR worked through — worth knowing if you write your own:
+
+- **`IReceiptFont.MeasureText` must return the *advance width*** (like `SKFont.MeasureText`), counting
+ side bearings **and** trailing whitespace — the layout justifies and advances runs on it. ImageSharp's
+ `TextMeasurer.MeasureSize` returns glyph *bounds* (wrong); `MeasureAdvance` returns the advance, but
+ still trims trailing whitespace, so the backend re-adds it with a sentinel.
+- **`FontMetrics` uses the Skia sign convention** — `Ascent` negative, `Descent` positive.
+- **The transform stack** (`Save`/`Translate`/`Scale`/`RestoreToCount`) must apply per draw op —
+ translate outermost, scale inner — so double-width/height text scales correctly.
+
+The step-by-step **[Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend)**
+guide in the wiki walks through each interface using that backend as the model.
diff --git a/docs/packages/transports.md b/docs/packages/transports.md
new file mode 100644
index 0000000..167a6a8
--- /dev/null
+++ b/docs/packages/transports.md
@@ -0,0 +1,63 @@
+# CrossEscPos.Transports
+
+Desktop transports that feed an ESC/POS stream into a `ReceiptPrinter` the way a real device receives
+it — over **TCP/IP**, a **serial** port, or **USB**. They also implement `IPrinterResponder`, so the
+printer's status replies (`DLE EOT`, `GS r`, Automatic Status Back) are sent back over the same channel.
+
+> Desktop only. These use raw sockets, `System.IO.Ports` and libusb, which don't exist in the browser
+> sandbox — a WASM host feeds ESC/POS in-page instead and doesn't reference this package.
+
+```mermaid
+flowchart LR
+ host["POS app / client"] -->|ESC/POS bytes| t["NetServer / SerialServer"]
+ t -->|FeedEscPos| printer["ReceiptPrinter"]
+ printer -.->|status bytes via IPrinterResponder| t
+ t -.-> host
+```
+
+## TCP/IP server
+
+Listens for connections and feeds whatever bytes arrive. This is how most modern POS software talks to
+a network receipt printer (port 9100 by convention).
+
+```csharp
+using System.Net;
+using CrossEscPos.Transports;
+
+var tcp = new NetServer(printer);
+tcp.Start(IPAddress.Any, 9100);
+// … tcp.IsRunning, tcp.EndPoint …
+tcp.Stop();
+```
+
+## Serial server
+
+Reads ESC/POS from an RS-232 / virtual serial port.
+
+```csharp
+var serial = new SerialServer(printer);
+serial.Start("/dev/ttyUSB0", baudRate: 9600); // or "COM3" on Windows
+// … serial.IsRunning, serial.PortName, serial.BaudRate …
+serial.Stop();
+```
+
+To test serial without hardware, create a virtual serial bridge (a linked pair of ports) and point the
+server at one end and your client at the other — see the main README's "Testing serial without hardware".
+
+## USB
+
+`UsbPrinter` is a USB client (libusb) used to drive a real or emulated printer over a bulk endpoint. It
+needs the native `libusb-1.0` at runtime (`brew install libusb`, `apt install libusb-1.0-0`; bundled on
+Windows).
+
+## Lifetime
+
+Start the transports after constructing the printer, and stop them on shutdown:
+
+```csharp
+tcp.Stop();
+serial.Stop();
+```
+
+Because receive happens on a background thread, set `printer.UiDispatch` if you bind printer state to a
+UI (see [Core](core.md#threading)).
diff --git a/docs/packages/web.md b/docs/packages/web.md
new file mode 100644
index 0000000..1688984
--- /dev/null
+++ b/docs/packages/web.md
@@ -0,0 +1,77 @@
+# Blazor web app — render ESC/POS in the browser
+
+[`samples/CrossEscPos.Web`](../../samples/CrossEscPos.Web) is a **Blazor WebAssembly** app that runs the
+whole emulator in the browser: feed it ESC/POS, see the rendered receipt, and **switch the render engine
+at runtime** — managed **ImageSharp** (default) or native **SkiaSharp**. It's the showcase for the
+managed backend: with ImageSharp there's **no native relink** and the runtime stays the stock size.
+
+```mermaid
+flowchart LR
+ input["paste text / upload file / sample"] --> host["EmulatorHost (ReceiptPrinter + active backend)"]
+ host -->|"Render()"| png["PNG (data URI)"]
+ png --> view["<ReceiptView> (Razor)"]
+ host --> state["PrinterState"] --> panel["<PrinterStatePanel> (Razor)"]
+ selector["engine selector"] -->|"UseBackend()"| host
+```
+
+## Run it
+
+```sh
+dotnet run --project samples/CrossEscPos.Web
+```
+
+Opens a dev server; the page auto-renders a sample ticket. Pick the engine on the left, paste ESC/POS
+text or upload a `.escpos` file, and the receipt renders on the fly. The active engine is remembered
+across reloads (`localStorage`).
+
+> **Building requires the `wasm-tools` workload** (`dotnet workload install wasm-tools`). Because the app
+> includes **both** engines, SkiaSharp's native `libSkiaSharp.a` is relinked into the runtime
+> (`dotnet.native.wasm` ≈ 23 MB). ImageSharp alone needs none of that — that's the whole point of the
+> managed backend; the app carries Skia only so you can A/B the two in one place.
+
+## How it's wired
+
+The app is a thin shell over the same headless `Core` the desktop uses — the only browser-specific part
+is the composition root.
+
+| Piece | Role |
+| --- | --- |
+| [`RenderBackend`](../../samples/CrossEscPos.Web/Rendering/RenderBackend.cs) | A small **named registry** — resolves `"imagesharp"` / `"skia"` to a factory + typefaces + encoder triple |
+| [`EmulatorHost`](../../samples/CrossEscPos.Web/Services/EmulatorHost.cs) | Owns the `ReceiptPrinter` and the active backend; `Render(bytes)`, `UseBackend(id)`, exposes `Receipts` + `State` |
+| [`ReceiptView.razor`](../../samples/CrossEscPos.Web/Components/ReceiptView.razor) | Shows one receipt as a PNG with a download link (the Razor analogue of the Avalonia `ReceiptView`) |
+| [`PrinterStatePanel.razor`](../../samples/CrossEscPos.Web/Components/PrinterStatePanel.razor) | Two-way binds the `PrinterState` (online, cover, paper, drawer, …) |
+
+Switching the engine simply re-creates the printer with the other backend triple and re-renders the
+current input — the core, the receipt model, and the Razor components are all backend-agnostic.
+
+```csharp
+// EmulatorHost — the essence of the runtime switch
+public void UseBackend(string id)
+{
+ var next = RenderBackend.ById(id); // "imagesharp" | "skia"
+ if (next.Id == _backend.Id) return;
+ _backend = next;
+ Rebuild(preserveState: true); // new factory/typefaces/encoder; replay last input
+}
+```
+
+## Publish
+
+```sh
+dotnet publish samples/CrossEscPos.Web -c Release -o publish/web
+```
+
+`publish/web/wwwroot/` is a static site (no server runtime) you can host anywhere — GitHub Pages, a CDN,
+any static host. Serve `_framework/` as static files.
+
+## Just want ESC/POS → PNG, no UI?
+
+Reference `CrossEscPos.Core` + `CrossEscPos.Rendering.ImageSharp` directly in your own Blazor app and
+call the two-line pipeline from [Getting started](getting-started.md) — the managed backend means it
+"just works" in WASM with no extra toolchain.
+
+## The full interactive desktop app
+
+For the native desktop emulator (TCP/serial/USB transports, live UI) see
+[`src/CrossEscPos.App.Desktop`](../../src/CrossEscPos.App.Desktop); it can also switch backends at
+runtime with `--backend skia|imagesharp`.
diff --git a/global.json b/global.json
index f4fd385..bdfc2a0 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.0",
+ "version": "10.0.300",
"rollForward": "latestMajor",
"allowPrerelease": true
}
diff --git a/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj b/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj
new file mode 100644
index 0000000..330150a
--- /dev/null
+++ b/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ crossescpos-render
+
+
+
+
+
+
+
+
+
diff --git a/samples/CrossEscPos.Headless/Program.cs b/samples/CrossEscPos.Headless/Program.cs
new file mode 100644
index 0000000..6332360
--- /dev/null
+++ b/samples/CrossEscPos.Headless/Program.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.Rendering.Skia;
+
+namespace CrossEscPos.Headless;
+
+///
+/// Headless ESC/POS → PNG renderer. Proves the emulator runs with no UI framework: it wires the Core
+/// emulator to the SkiaSharp render backend directly — no Avalonia, no window, no UI thread.
+///
+/// crossescpos-render <input.escpos|--> <output.png>
+///
+/// Reads ESC/POS bytes from the given file (or stdin when the path is "-"), renders every receipt the
+/// stream produced, stacks them top-to-bottom, and writes a single PNG.
+///
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ if (args.Length < 2)
+ {
+ Console.Error.WriteLine("usage: crossescpos-render ");
+ return 2;
+ }
+
+ var inputPath = args[0];
+ var outputPath = args[1];
+
+ byte[] bytes = inputPath == "-"
+ ? ReadAllStdin()
+ : File.ReadAllBytes(inputPath);
+
+ // Compose the render backend explicitly — this is the headless composition root.
+ var imageFactory = new SkiaImageFactory();
+ var typefaces = new SkiaTypefaceProvider();
+ var encoder = new SkiaImageEncoder();
+
+ var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces);
+
+ // ESC/POS is binary — feed the raw bytes straight in.
+ printer.FeedEscPos(bytes);
+
+ // Render every non-empty receipt the stream produced and stack them.
+ var pages = new List();
+ foreach (var receipt in printer.ReceiptStack)
+ {
+ if (receipt.IsEmpty)
+ continue;
+ pages.Add(receipt.Render());
+ }
+
+ if (pages.Count == 0)
+ {
+ Console.Error.WriteLine("No printable content was produced by the stream.");
+ return 1;
+ }
+
+ using var combined = ReceiptExporter.StackVertical(pages, imageFactory);
+ using (var output = File.Create(outputPath))
+ encoder.EncodePng(combined, output);
+
+ foreach (var page in pages)
+ page.Dispose();
+
+ Console.WriteLine($"Rendered {pages.Count} receipt(s) -> {outputPath} ({combined.Width}x{combined.Height})");
+ return 0;
+ }
+
+ private static byte[] ReadAllStdin()
+ {
+ using var stdin = Console.OpenStandardInput();
+ using var buffer = new MemoryStream();
+ stdin.CopyTo(buffer);
+ return buffer.ToArray();
+ }
+}
diff --git a/samples/CrossEscPos.Host/BridgeHub.cs b/samples/CrossEscPos.Host/BridgeHub.cs
new file mode 100644
index 0000000..a1b1bd4
--- /dev/null
+++ b/samples/CrossEscPos.Host/BridgeHub.cs
@@ -0,0 +1,177 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using CrossEscPos.Bridge;
+using Microsoft.AspNetCore.SignalR;
+
+namespace CrossEscPos.Host;
+
+///
+/// The broker hub — strongly typed on both sides: it implements (the methods
+/// clients call) and is a of (the methods it calls back,
+/// e.g. Clients.Client(id).ReceiveEscPos(data) ). When an emulator attaches it opens a TCP listener
+/// on the address:port it asks for; POS software that connects there — or a SignalR monitor — sends jobs
+/// to that emulator and gets its status replies back. The listener is torn down when the emulator leaves.
+///
+public sealed class BridgeHub : Hub, IBridgeServer
+{
+ internal static string? EmulatorConnection;
+ internal static Func? SenderReply; // routes status to the current sender
+ internal static IHubContext? Context_; // strongly-typed client proxy
+
+ private static readonly ConcurrentDictionary Sessions = new();
+ private static readonly ConcurrentDictionary Outbound = new(); // monitor → printer
+
+ private sealed record TcpSession(TcpListener Listener, CancellationTokenSource Cts);
+
+ public Task AttachEmulator(string address, int port)
+ {
+ var connection = Context.ConnectionId;
+ EmulatorConnection = connection;
+ StopSession(connection); // drop any prior listener for this connection (re-attach)
+
+ var ip = IPAddress.TryParse(address, out var parsed) ? parsed : IPAddress.Any;
+ TcpListener listener;
+ try
+ {
+ listener = new TcpListener(ip, port);
+ listener.Start();
+ }
+ catch (Exception ex)
+ {
+ throw new HubException($"Could not listen on {address}:{port} — {ex.Message}");
+ }
+
+ var cts = new CancellationTokenSource();
+ Sessions[connection] = new TcpSession(listener, cts);
+ Console.WriteLine($"[hub] emulator attached; listening on {address}:{port}");
+ _ = AcceptLoop(connection, listener, cts.Token);
+ return Task.CompletedTask;
+ }
+
+ public async Task ConnectTcp(string host, int port)
+ {
+ var connection = Context.ConnectionId;
+ CloseOutbound(connection);
+
+ var client = new TcpClient();
+ try
+ {
+ await client.ConnectAsync(host, port);
+ }
+ catch (Exception ex)
+ {
+ client.Dispose();
+ throw new HubException($"Could not connect to {host}:{port} — {ex.Message}");
+ }
+
+ Outbound[connection] = client;
+ Console.WriteLine($"[tcp-out] monitor → printer {host}:{port}");
+ _ = PumpOutbound(connection, client);
+ }
+
+ public Task SendToEmulator(byte[] data)
+ {
+ var connection = Context.ConnectionId;
+
+ // A monitor that opened an outbound TCP printer writes to that socket instead of the emulator.
+ if (Outbound.TryGetValue(connection, out var printer))
+ {
+ try { return printer.GetStream().WriteAsync(data).AsTask(); }
+ catch { return Task.CompletedTask; }
+ }
+
+ SenderReply = d => Context_!.Clients.Client(connection).ReceiveStatus(d);
+ return ForwardToEmulator(data);
+ }
+
+ public Task ReplyToSender(byte[] data) => SenderReply?.Invoke(data) ?? Task.CompletedTask;
+
+ public override Task OnDisconnectedAsync(Exception? exception)
+ {
+ var connection = Context.ConnectionId;
+ StopSession(connection);
+ CloseOutbound(connection);
+ if (EmulatorConnection == connection)
+ EmulatorConnection = null;
+ return base.OnDisconnectedAsync(exception);
+ }
+
+ private static void CloseOutbound(string connection)
+ {
+ if (Outbound.TryRemove(connection, out var client))
+ {
+ try { client.Close(); client.Dispose(); } catch { /* already gone */ }
+ Console.WriteLine("[tcp-out] printer connection closed");
+ }
+ }
+
+ /// Pump a monitor's outbound TCP printer: its bytes → the monitor as status replies.
+ private static async Task PumpOutbound(string connection, TcpClient client)
+ {
+ try
+ {
+ var stream = client.GetStream();
+ var buffer = new byte[4096];
+ int read;
+ while ((read = await stream.ReadAsync(buffer)) > 0)
+ {
+ if (Context_ is not null)
+ await Context_.Clients.Client(connection).ReceiveStatus(buffer[..read]);
+ }
+ }
+ catch { /* printer gone */ }
+ CloseOutbound(connection);
+ }
+
+ private static Task ForwardToEmulator(byte[] data)
+ => EmulatorConnection is not null && Context_ is not null
+ ? Context_.Clients.Client(EmulatorConnection).ReceiveEscPos(data)
+ : Task.CompletedTask;
+
+ private static void StopSession(string connection)
+ {
+ if (Sessions.TryRemove(connection, out var session))
+ {
+ try { session.Cts.Cancel(); session.Listener.Stop(); } catch { /* already gone */ }
+ Console.WriteLine("[hub] TCP session stopped");
+ }
+ }
+
+ /// Accept POS TCP clients for one emulator session until the listener is stopped.
+ private static async Task AcceptLoop(string emulatorConnection, TcpListener listener, CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var client = await listener.AcceptTcpClientAsync(ct);
+ _ = BridgeTcpAsync(emulatorConnection, client, ct);
+ }
+ }
+ catch { /* listener stopped */ }
+ }
+
+ /// Bridge a raw TCP POS client: its bytes → its emulator; the emulator's status → its stream.
+ private static async Task BridgeTcpAsync(string emulatorConnection, TcpClient client, CancellationToken ct)
+ {
+ using (client)
+ {
+ var stream = client.GetStream();
+ SenderReply = d => stream.WriteAsync(d, CancellationToken.None).AsTask();
+ Console.WriteLine("[tcp] POS connected — bridging to emulator");
+ var buffer = new byte[4096];
+ int read;
+ try
+ {
+ while ((read = await stream.ReadAsync(buffer, ct)) > 0)
+ {
+ if (Context_ is not null)
+ await Context_.Clients.Client(emulatorConnection).ReceiveEscPos(buffer[..read]);
+ }
+ }
+ catch { /* POS gone / cancelled */ }
+ Console.WriteLine("[tcp] POS disconnected");
+ }
+ }
+}
diff --git a/samples/CrossEscPos.Host/CrossEscPos.Host.csproj b/samples/CrossEscPos.Host/CrossEscPos.Host.csproj
new file mode 100644
index 0000000..87d8600
--- /dev/null
+++ b/samples/CrossEscPos.Host/CrossEscPos.Host.csproj
@@ -0,0 +1,53 @@
+
+
+
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(MSBuildThisFileDirectory)..\..\src\CrossEscPos.App.Browser\CrossEscPos.App.Browser.csproj
+
+ $(MSBuildThisFileDirectory)obj\wasmclient\
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/CrossEscPos.Host/Program.cs b/samples/CrossEscPos.Host/Program.cs
new file mode 100644
index 0000000..42846db
--- /dev/null
+++ b/samples/CrossEscPos.Host/Program.cs
@@ -0,0 +1,26 @@
+using CrossEscPos.Bridge;
+using CrossEscPos.Host;
+using Microsoft.AspNetCore.SignalR;
+
+// CrossEscPos.Host — the single web host for the browser experience. It serves the Avalonia WASM app
+// (published into wwwroot) AND hosts the SignalR broker on one origin, so the browser reaches the hub at
+// /bridge with no CORS. The browser emulator asks the hub to open a TCP listener on the address:port it
+// chooses (per session); POS software connects there and its jobs bridge to the in-page emulator, and the
+// Monitor rides the same hub for a full round-trip.
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddSignalR();
+
+var app = builder.Build();
+
+// Serve the WASM app: _framework/* with the right MIME types, then the static content, with a fallback
+// to index.html for the SPA.
+app.UseBlazorFrameworkFiles();
+app.UseStaticFiles();
+
+app.MapHub("/bridge");
+BridgeHub.Context_ = app.Services.GetRequiredService>();
+
+app.MapFallbackToFile("index.html");
+
+app.Run();
diff --git a/scripts/gen-test-receipt.py b/scripts/gen-test-receipt.py
new file mode 100644
index 0000000..c32e7b2
--- /dev/null
+++ b/scripts/gen-test-receipt.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""Generate a comprehensive test_receipt.txt that exercises every supported feature:
+text formatting, alignment, fonts, sizes, all 1D barcodes, all 2D symbols, a bit image,
+the buzzer and the cash drawer. Run from the repo root: python3 scripts/gen-test-receipt.py"""
+
+out = bytearray()
+
+
+def raw(*xs):
+ for x in xs:
+ out.append(x & 0xFF)
+
+
+def S(s):
+ out.extend(s.encode("latin1"))
+
+
+ESC, GS = 0x1B, 0x1D
+
+# --- helpers --------------------------------------------------------------
+def init(): raw(ESC, ord('@'))
+def align(n): raw(ESC, ord('a'), n) # 0 left, 1 center, 2 right
+def bold(on): raw(ESC, ord('E'), 1 if on else 0)
+def italic(on): raw(ESC, 0x34 if on else 0x35) # ESC 4 / ESC 5
+def underline(n): raw(ESC, ord('-'), n) # 0 off, 1 one-dot, 2 two-dot
+def font(n): raw(ESC, ord('M'), n) # 0 A, 1 B
+def size(w, h): raw(GS, ord('!'), ((w - 1) << 4) | (h - 1))
+def feed(n=1): raw(ESC, ord('d'), n)
+def line(s=""): S(s); raw(0x0A)
+def rule(): line("-" * 42)
+def cut(): raw(ESC, ord('i'))
+
+
+def section(title):
+ align(1); bold(True); size(1, 2); line(title)
+ size(1, 1); bold(False); align(0); feed(1)
+
+
+# --- header ---------------------------------------------------------------
+init()
+align(1)
+size(2, 2); bold(True); line("ESC/POS")
+size(1, 1); line("Emulator feature test"); bold(False)
+align(0); rule()
+
+# --- text formatting ------------------------------------------------------
+section("TEXT STYLES")
+line("Normal text")
+bold(True); line("Bold text"); bold(False)
+italic(True); line("Italic text"); italic(False)
+underline(1); line("Underline (1 dot)"); underline(0)
+underline(2); line("Underline (2 dot)"); underline(0)
+font(1); line("Font B (small)"); font(0)
+size(2, 1); line("Double width"); size(1, 1)
+size(1, 2); line("Double height"); size(1, 1)
+size(2, 2); line("Double both"); size(1, 1)
+rule()
+
+# --- alignment ------------------------------------------------------------
+section("ALIGNMENT")
+align(0); line("Left aligned")
+align(1); line("Center aligned")
+align(2); line("Right aligned")
+align(0); rule()
+
+# --- 1D barcodes ----------------------------------------------------------
+section("1D BARCODES")
+raw(GS, ord('h'), 70) # height 70 dots
+raw(GS, ord('w'), 2) # module width 2
+raw(GS, ord('H'), 2) # HRI below
+raw(GS, ord('f'), 0) # HRI font A
+
+
+def barcode(m, data, label):
+ align(1); S(label); raw(0x0A)
+ raw(GS, ord('k'), m, len(data)); S(data); raw(0x0A)
+ align(0); feed(1)
+
+
+barcode(65, "12345678901", "UPC-A")
+barcode(67, "123456789012", "EAN-13")
+barcode(68, "1234567", "EAN-8")
+barcode(69, "CODE39", "CODE39")
+barcode(72, "CODE93", "CODE93")
+barcode(73, "Code128", "CODE128")
+barcode(70, "12345678", "ITF")
+barcode(71, "A123456A", "CODABAR")
+rule()
+
+# --- 2D symbols -----------------------------------------------------------
+section("2D SYMBOLS")
+
+
+def sym2d(cn, data, label, size_dots=5):
+ align(1); S(label); raw(0x0A)
+ raw(GS, ord('('), ord('k'), 3, 0, cn, 67, size_dots) # module size
+ if cn == 49:
+ raw(GS, ord('('), ord('k'), 3, 0, cn, 69, 49) # QR EC = M
+ ln = 3 + len(data)
+ raw(GS, ord('('), ord('k'), ln, 0, cn, 80, 48); S(data) # store
+ raw(GS, ord('('), ord('k'), 3, 0, cn, 81, 48) # print
+ align(0); feed(1)
+
+
+sym2d(49, "https://example.com/qr", "QR Code", 6)
+sym2d(48, "PDF417 sample data", "PDF417", 3)
+sym2d(54, "DataMatrix sample", "DataMatrix")
+sym2d(55, "Aztec sample", "Aztec")
+rule()
+
+# --- bit image (ESC *) ----------------------------------------------------
+section("BIT IMAGE (ESC *)")
+align(1)
+width = 96
+raw(ESC, ord('*'), 33, width & 0xFF, width >> 8) # 24-dot mode
+for col in range(width):
+ # a wavy band
+ top = 0xFF if (col // 6) % 2 == 0 else 0x18
+ mid = 0x3C
+ bot = 0x18 if (col // 6) % 2 == 0 else 0xFF
+ raw(top, mid, bot)
+raw(0x0A)
+align(0); rule()
+
+# --- peripherals ----------------------------------------------------------
+section("PERIPHERALS")
+align(1); bold(True); line(">> BEEP + OPEN DRAWER <<"); bold(False); align(0)
+raw(0x07) # BEL buzzer
+raw(ESC, ord('p'), 0, 25, 25) # cash drawer kick
+feed(1)
+line("Thank you!")
+feed(2)
+cut()
+
+with open("test_receipt.txt", "wb") as f:
+ f.write(out)
+print(f"wrote test_receipt.txt ({len(out)} bytes)")
diff --git a/scripts/make-macos-app.sh b/scripts/make-macos-app.sh
new file mode 100755
index 0000000..d92b809
--- /dev/null
+++ b/scripts/make-macos-app.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+#
+# make-macos-app.sh — wrap a `dotnet publish` output into a macOS .app bundle (with .icns icon).
+# Must run on macOS (uses sips + iconutil).
+#
+# Usage: make-macos-app.sh [icon-png]
+#
+set -euo pipefail
+
+PUBLISH_DIR=${1:?publish dir required}
+OUTPUT_DIR=${2:?output dir required}
+VERSION=${3:-1.0.0}
+ICON_PNG=${4:-src/CrossEscPos.App.Desktop/Assets/Icon/icon.png}
+# The published executable (AssemblyName) vs the visible bundle name.
+EXE_NAME=CrossEscPos.App.Desktop
+APP_NAME=CrossEscPos
+
+APP="$OUTPUT_DIR/$APP_NAME.app"
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+
+# Payload
+cp -R "$PUBLISH_DIR"/. "$APP/Contents/MacOS/"
+chmod +x "$APP/Contents/MacOS/$EXE_NAME"
+
+# Icon (.icns) from the PNG.
+# Prefer macOS tooling (sips + iconutil); fall back to png2icns (icnsutils) on Linux so the bundle
+# can also be assembled off a Mac. If neither is available the .app still runs (default icon).
+ICNS_OUT="$APP/Contents/Resources/icon.icns"
+if command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then
+ ICONSET="$(mktemp -d)/icon.iconset"
+ mkdir -p "$ICONSET"
+ for s in 16 32 128 256 512; do
+ sips -z "$s" "$s" "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}.png" >/dev/null
+ d=$((s * 2))
+ sips -z "$d" "$d" "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null
+ done
+ iconutil -c icns "$ICONSET" -o "$ICNS_OUT"
+elif command -v png2icns >/dev/null 2>&1; then
+ TMP="$(mktemp -d)"
+ if command -v convert >/dev/null 2>&1; then
+ for s in 16 32 48 128 256 512; do convert "$ICON_PNG" -resize "${s}x${s}" "$TMP/i_${s}.png"; done
+ png2icns "$ICNS_OUT" "$TMP"/i_*.png
+ else
+ png2icns "$ICNS_OUT" "$ICON_PNG"
+ fi
+else
+ echo "warning: no iconutil/png2icns found — building .app without a custom icon" >&2
+fi
+
+# Info.plist
+cat > "$APP/Contents/Info.plist" <
+
+
+
+ CFBundleName ESC/POS Emulator
+ CFBundleDisplayName ESC/POS Receipt Printer Emulator
+ CFBundleIdentifier com.github.crossescposemulator
+ CFBundleVersion $VERSION
+ CFBundleShortVersionString $VERSION
+ CFBundleExecutable $EXE_NAME
+ CFBundleIconFile icon
+ CFBundlePackageType APPL
+ LSMinimumSystemVersion 11.0
+ NSHighResolutionCapable
+ LSApplicationCategoryType public.app-category.developer-tools
+
+
+PLIST
+
+# Ad-hoc code-sign the whole bundle (deep) so it runs on Apple Silicon. Without any signature,
+# Gatekeeper reports a downloaded app as "damaged" on arm64. This is NOT Developer-ID/notarized —
+# users still need to clear the download quarantine on first launch (see README). Signing must be
+# the LAST step, after all files (Info.plist, icns, payload) are in place.
+if command -v codesign >/dev/null 2>&1; then
+ echo "Ad-hoc code-signing $APP …"
+ codesign --remove-signature "$APP" 2>/dev/null || true
+ codesign --force --deep --sign - --timestamp=none "$APP"
+ codesign --verify --deep --strict --verbose=2 "$APP" || echo "warning: codesign verify failed" >&2
+else
+ echo "warning: codesign not available — bundle left unsigned (will be 'damaged' on Apple Silicon)" >&2
+fi
+
+echo "Built $APP"
diff --git a/scripts/serial-bridge.sh b/scripts/serial-bridge.sh
new file mode 100755
index 0000000..fd79f29
--- /dev/null
+++ b/scripts/serial-bridge.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+#
+# serial-bridge.sh — create a virtual serial port pair for testing the emulator without hardware.
+#
+# Requires socat (macOS: `brew install socat`, Debian/Ubuntu: `sudo apt install socat`).
+#
+# It links two PTY devices: write ESC/POS bytes to one and they arrive on the other. Point the
+# emulator at PORT A and your POS application (or `cat file > PORT_B`) at PORT B.
+#
+# Usage:
+# ./scripts/serial-bridge.sh
+# # then, in other terminals:
+# ESCPOS_SERIAL_PORT= dotnet run
+# cat test_receipt.txt >
+#
+set -euo pipefail
+
+if ! command -v socat >/dev/null 2>&1; then
+ echo "error: socat is not installed." >&2
+ echo " macOS: brew install socat" >&2
+ echo " Debian/Ubuntu: sudo apt install socat" >&2
+ exit 1
+fi
+
+echo "Creating a virtual serial port pair (Ctrl-C to stop)…"
+echo "Look for two 'PTY is /dev/ttysNNN' lines below:"
+echo " - the FIRST is PORT A -> ESCPOS_SERIAL_PORT= dotnet run"
+echo " - the SECOND is PORT B -> write your ESC/POS to it (e.g. cat test_receipt.txt > )"
+echo
+
+exec socat -d -d pty,raw,echo=0 pty,raw,echo=0
diff --git a/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj b/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj
new file mode 100644
index 0000000..166e998
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ true
+ Backend-agnostic rendering and printer contracts for the CrossEscPos receipt emulator
+ (IReceiptCanvas, IReceiptImage, IReceiptImageFactory, ITypefaceProvider, IImageEncoder,
+ IReceiptPrintable, IPrinterResponder). No external dependencies — headless and WASM safe.
+
+
+
diff --git a/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs b/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs
new file mode 100644
index 0000000..20460f3
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs
@@ -0,0 +1,8 @@
+namespace CrossEscPos.Graphics;
+
+///
+/// Vertical font metrics in device pixels, following the Skia convention used by the receipt layout:
+/// is negative (distance above the baseline) and is positive
+/// (distance below it).
+///
+public readonly record struct FontMetrics(float Ascent, float Descent);
diff --git a/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs b/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs
new file mode 100644
index 0000000..8771303
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs
@@ -0,0 +1,11 @@
+using System.IO;
+
+namespace CrossEscPos.Graphics;
+
+/// Encodes an to PNG — the portable handoff to UI hosts and export.
+public interface IImageEncoder
+{
+ void EncodePng(IReceiptImage image, Stream destination);
+
+ byte[] EncodePng(IReceiptImage image);
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs
new file mode 100644
index 0000000..4fc87ed
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace CrossEscPos.Graphics;
+
+///
+/// A 2D drawing surface over an . This is the seam that lets the render
+/// backend be swapped: the receipt document model (text lines, bit images, barcodes) draws against
+/// this interface and never touches SkiaSharp directly.
+///
+/// Anti-aliasing is fixed per primitive to match the original Skia output: text and lines are
+/// anti-aliased (smooth glyphs / underlines), filled rectangles are not (crisp barcode modules), and
+/// scaled image draws are sampled.
+///
+/// The canvas is created by ; the creator owns and
+/// disposes it. Elements handed a canvas in only draw — they
+/// must not dispose it.
+///
+public interface IReceiptCanvas : IDisposable
+{
+ /// Fills the entire surface with .
+ void Clear(ReceiptColor color);
+
+ /// Draws with its baseline at ( , ).
+ void DrawText(string text, float x, float baselineY, IReceiptFont font, ReceiptColor color);
+
+ /// Fills (no anti-aliasing — crisp module edges).
+ void DrawRect(ReceiptRect rect, ReceiptColor color);
+
+ /// Strokes a line of the given width (anti-aliased).
+ void DrawLine(float x0, float y0, float x1, float y1, ReceiptColor color, float strokeWidth);
+
+ /// Draws at ( , ) at native size.
+ void DrawImage(IReceiptImage image, float x, float y);
+
+ /// Draws scaled to fill (sampled).
+ void DrawImage(IReceiptImage image, ReceiptRect dest);
+
+ /// Saves the current transform and returns a restore token (see ).
+ int Save();
+
+ void Translate(float dx, float dy);
+
+ void Scale(float sx, float sy);
+
+ /// Restores the transform stack to the depth returned by an earlier .
+ void RestoreToCount(int count);
+
+ /// Flushes any buffered drawing onto the backing image.
+ void Flush();
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs
new file mode 100644
index 0000000..d5da049
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace CrossEscPos.Graphics;
+
+///
+/// A resolved font at a specific pixel size, used to measure and draw receipt text. Obtained from an
+/// ; the caller owns it and disposes it.
+///
+public interface IReceiptFont : IDisposable
+{
+ /// The em size in device pixels this font was created at.
+ float Size { get; }
+
+ FontMetrics Metrics { get; }
+
+ /// The advance width of in device pixels at this font's size.
+ float MeasureText(string text);
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs
new file mode 100644
index 0000000..72b4994
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace CrossEscPos.Graphics;
+
+///
+/// A rasterized image (a receipt page, a barcode, an uploaded bit image). Produced by an
+/// and consumed by and
+/// . The owner disposes it.
+///
+public interface IReceiptImage : IDisposable
+{
+ int Width { get; }
+ int Height { get; }
+
+ /// Returns an independent copy whose lifetime the caller owns.
+ IReceiptImage Copy();
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs
new file mode 100644
index 0000000..44e59e4
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs
@@ -0,0 +1,21 @@
+namespace CrossEscPos.Graphics;
+
+///
+/// Creates images and the canvases that draw onto them. This is the backend entry point the emulator
+/// core depends on; the desktop/browser hosts (and the headless sample) supply a concrete factory
+/// (e.g. the SkiaSharp one).
+///
+public interface IReceiptImageFactory
+{
+ /// Creates a blank image of the given size filled with .
+ IReceiptImage Create(int width, int height, ReceiptColor fill);
+
+ ///
+ /// Creates an image from row-major pixels (length must be *
+ /// ). Used by the raster / bit-image ESC/POS commands.
+ ///
+ IReceiptImage FromPixels(int width, int height, ReceiptColor[] rowMajorPixels);
+
+ /// Creates a canvas that draws onto .
+ IReceiptCanvas CreateCanvas(IReceiptImage image);
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs b/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs
new file mode 100644
index 0000000..183c3d1
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs
@@ -0,0 +1,10 @@
+namespace CrossEscPos.Graphics;
+
+///
+/// Resolves a font family + style + size into a drawable . Backends decide
+/// how families are resolved (embedded TTF, system fonts, …). Implementations should cache.
+///
+public interface ITypefaceProvider
+{
+ IReceiptFont GetFont(string family, bool bold, bool italic, float sizePx);
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs b/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs
new file mode 100644
index 0000000..34637cf
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs
@@ -0,0 +1,14 @@
+namespace CrossEscPos.Graphics;
+
+///
+/// A backend-agnostic 32-bit RGBA color. Receipts are monochrome, but a full color keeps the surface
+/// general (and lets the raster/bit-image commands carry grayscale values straight through).
+///
+public readonly record struct ReceiptColor(byte R, byte G, byte B, byte A = 255)
+{
+ public static readonly ReceiptColor Black = new(0, 0, 0);
+ public static readonly ReceiptColor White = new(255, 255, 255);
+
+ /// An opaque gray where R=G=B= .
+ public static ReceiptColor Gray(byte value) => new(value, value, value);
+}
diff --git a/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs b/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs
new file mode 100644
index 0000000..19d2009
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs
@@ -0,0 +1,7 @@
+namespace CrossEscPos.Graphics;
+
+/// An axis-aligned rectangle in device pixels (top-left origin), mirroring SKRect.Create semantics.
+public readonly record struct ReceiptRect(float X, float Y, float Width, float Height)
+{
+ public static ReceiptRect Create(float x, float y, float width, float height) => new(x, y, width, height);
+}
diff --git a/src/CrossEscPos.Abstractions/IPrinterResponder.cs b/src/CrossEscPos.Abstractions/IPrinterResponder.cs
new file mode 100644
index 0000000..b71dd2d
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/IPrinterResponder.cs
@@ -0,0 +1,11 @@
+namespace CrossEscPos;
+
+///
+/// A channel the printer can write bytes back to the host over (TCP client or serial port).
+/// Implemented by the transports so status / transmit-back commands can reply to the host that
+/// sent the request.
+///
+public interface IPrinterResponder
+{
+ void Send(byte[] data);
+}
diff --git a/src/CrossEscPos.Abstractions/IReceiptPrintable.cs b/src/CrossEscPos.Abstractions/IReceiptPrintable.cs
new file mode 100644
index 0000000..886cbc9
--- /dev/null
+++ b/src/CrossEscPos.Abstractions/IReceiptPrintable.cs
@@ -0,0 +1,15 @@
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos;
+
+///
+/// One renderable element of a receipt (a text line, a bit image, vertical space). Draws itself onto a
+/// backend-agnostic .
+///
+public interface IReceiptPrintable
+{
+ /// Draws this line onto the receipt canvas at the given top-left offset.
+ void Render(IReceiptCanvas canvas, int offsetX, int offsetY);
+
+ int GetPrintHeight();
+}
diff --git a/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs b/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs
new file mode 100644
index 0000000..f163bb5
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs
@@ -0,0 +1,51 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices.JavaScript;
+using System.Threading.Tasks;
+using CrossEscPos.Controls.Services;
+
+namespace CrossEscPos.App.Browser;
+
+///
+/// Browser "save PNG" = a real download. Avalonia's browser storage provider goes through the File System
+/// Access API (Chromium-only, and its writable stream rejects the encoder's synchronous writes), so exports
+/// silently failed. Here hands back a memory stream that, when disposed, blobs
+/// its bytes and downloads them via a JS anchor — works in every browser. Folder export isn't offered.
+///
+public sealed class BrowserFileDialogService : IFileDialogService
+{
+ public Task SavePngAsync(string suggestedName)
+ {
+ var name = suggestedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
+ ? suggestedName
+ : suggestedName + ".png";
+ return Task.FromResult(new DownloadStream(name));
+ }
+
+ // No folder picker in the browser; "export each cut to a folder" is a desktop feature.
+ public Task PickFolderAsync() => Task.FromResult(null);
+}
+
+/// A that downloads its bytes as a file when disposed.
+internal sealed partial class DownloadStream : MemoryStream
+{
+ private readonly string _name;
+ private bool _saved;
+
+ public DownloadStream(string name) => _name = name;
+
+ protected override void Dispose(bool disposing)
+ {
+ // Stream.DisposeAsync() (used by `await using`) calls Dispose(), so this covers both paths.
+ if (disposing && !_saved && Length > 0)
+ {
+ _saved = true;
+ try { DownloadFile(_name, Convert.ToBase64String(GetBuffer(), 0, (int)Length)); }
+ catch { /* download unavailable */ }
+ }
+ base.Dispose(disposing);
+ }
+
+ [JSImport("globalThis.crossescpos.downloadFile")]
+ private static partial void DownloadFile(string name, string base64);
+}
diff --git a/src/CrossEscPos.App.Browser/BrowserNotificationService.cs b/src/CrossEscPos.App.Browser/BrowserNotificationService.cs
new file mode 100644
index 0000000..6cad56d
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/BrowserNotificationService.cs
@@ -0,0 +1,14 @@
+using CrossEscPos.Controls.Services;
+
+namespace CrossEscPos.App.Browser;
+
+///
+/// Browser notifications — the on-screen toast (shown by the shared MainViewModel) is the feedback;
+/// sound/flash have no clean browser equivalent here, so they are no-ops.
+///
+public sealed class BrowserNotificationService : INotificationService
+{
+ public void NotifyActivity() { }
+ public void Beep() { }
+ public void OpenCashDrawer() { }
+}
diff --git a/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs b/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs
new file mode 100644
index 0000000..8632f19
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using CrossEscPos.App;
+using CrossEscPos.App.Browser.Monitor;
+using CrossEscPos.App.Browser.Transports;
+using CrossEscPos.App.Monitor;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Graphics;
+using CrossEscPos.Transports.Browser;
+
+namespace CrossEscPos.App.Browser;
+
+///
+/// The browser platform: the Skia render backend (Avalonia's browser renderer already links it),
+/// export via Avalonia's storage provider (a download), no-op notifications, the Web Serial / WebUSB /
+/// SignalR transports, and the in-page Monitor. Injected into the shared ,
+/// which runs as a single view here.
+///
+public sealed class BrowserPlatformServices : IPlatformServices
+{
+ private readonly RenderBackend _backend = RenderBackend.Select(Array.Empty()); // Skia
+ private readonly BrowserFileDialogService _dialogs = new();
+ private readonly BrowserNotificationService _notifications = new();
+ // One bridge instance (its [JSExport] delivery slot is static) shared by the transports + Monitor.
+ private readonly WasmJsTransportBridge _bridge = new();
+
+ public IReceiptImageFactory ImageFactory => _backend.ImageFactory;
+ public ITypefaceProvider Typefaces => _backend.Typefaces;
+ public IImageEncoder Encoder => _backend.Encoder;
+ public string BackendName => _backend.Name;
+ public IFileDialogService FileDialogs => _dialogs;
+ public INotificationService Notifications => _notifications;
+ public byte[] SampleTicket => Sample.Ticket;
+
+ public IReadOnlyList CreateTransports(ReceiptPrinter printer)
+ {
+ var sink = new BrowserTransportSink(printer);
+ var serial = new WebTransport(_bridge, sink, "serial", "Web Serial");
+ var usb = new WebTransport(_bridge, sink, "usb", "WebUSB");
+ var signalr = new SignalRTransport(sink, "SignalR");
+
+ // Default the proxy to the same origin that served the app (the CrossEscPos.Host).
+ var origin = WasmJsTransportBridge.PageOrigin();
+ var bridgeUrl = (string.IsNullOrEmpty(origin) ? "http://localhost:5000" : origin) + "/bridge";
+ signalr.Url = bridgeUrl;
+
+ var baud = new TransportField("Baud", "9600");
+ var proxyUrl = new TransportField("Proxy URL", bridgeUrl);
+ var listenAddress = new TransportField("Listen address", "0.0.0.0");
+ var listenPort = new TransportField("Listen port", "9100");
+
+ return new TransportEntry[]
+ {
+ new ReceiptTransportEntry(serial, "Web Serial", new[] { baud }, () => serial.Options = baud.Value),
+ new ReceiptTransportEntry(usb, "WebUSB"),
+ new ReceiptTransportEntry(signalr, "TCP proxy (SignalR)",
+ new[] { proxyUrl, listenAddress, listenPort },
+ () =>
+ {
+ signalr.Url = proxyUrl.Value;
+ signalr.ListenAddress = listenAddress.Value;
+ signalr.ListenPort = int.TryParse(listenPort.Value, out var p) ? p : 9100;
+ },
+ autoConnect: true), // comes up on load, like the desktop TCP listener
+ };
+ }
+
+ public IMonitorClient CreateMonitorClient() => new WebMonitorClient(_bridge);
+
+ public bool MonitorInWindow => false; // the browser hosts the Monitor as an in-page overlay
+
+ public void ShowMonitorWindow(MonitorViewModel monitor) { /* unused; browser hosts in-page */ }
+
+ public void AttachRoot(Control mainView) { /* browser downloads via JS — no top level needed */ }
+
+ public Window CreateMainWindow(Control content)
+ => throw new NotSupportedException("The browser head runs as a single view.");
+
+ public void Shutdown() { }
+}
diff --git a/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj b/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj
new file mode 100644
index 0000000..8f3f496
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0-browser
+ Exe
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs b/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs
new file mode 100644
index 0000000..ae37b24
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR.Client;
+using CrossEscPos.App.Browser.Transports;
+using CrossEscPos.App.Monitor;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Bridge;
+using CrossEscPos.Transports.Browser;
+
+namespace CrossEscPos.App.Browser.Monitor;
+
+///
+/// The browser Monitor, with the same transports as the desktop one — pick a transport and drive a
+/// printer: Network (TCP) (the host dials a real network printer on the browser's behalf through
+/// the SignalR proxy), Web Serial , WebUSB , or the SignalR proxy round-trip to the
+/// in-page emulator. Serial/USB reuse the shared JS bridge on their own sender channels
+/// (mon-serial /mon-usb ) so they don't clash with the emulator's connections.
+///
+public sealed class WebMonitorClient : IMonitorClient
+{
+ private const string Proxy = "SignalR proxy", Tcp = "Network (TCP)", Serial = "Web Serial", Usb = "WebUSB";
+
+ private readonly IJsTransportBridge _bridge;
+ private readonly string _hubUrl;
+ private readonly TransportField _url;
+ private readonly TransportField _host = new("Host", "127.0.0.1");
+ private readonly TransportField _port = new("Port", "9100");
+ private readonly TransportField _baud = new("Baud", "9600");
+ private readonly TransportField _usbDevice = new("Device (VID:PID)", "", new[] { "" });
+
+ private HubConnection? _hub;
+ private IBridgeServer? _server;
+ private string _mode = Proxy;
+
+ public WebMonitorClient(IJsTransportBridge bridge)
+ {
+ _bridge = bridge;
+ _bridge.DataReceived += OnBridgeData;
+
+ var origin = WasmJsTransportBridge.PageOrigin();
+ _hubUrl = (string.IsNullOrEmpty(origin) ? "http://localhost:5000" : origin) + "/bridge";
+ _url = new TransportField("Proxy URL", _hubUrl);
+ }
+
+ public IReadOnlyList Modes { get; } = new[] { Proxy, Tcp, Serial, Usb };
+
+ public string Mode
+ {
+ get => _mode;
+ set
+ {
+ if (_mode == value)
+ return;
+ _mode = value;
+ FieldsChanged?.Invoke();
+ }
+ }
+
+ public IReadOnlyList Fields => _mode switch
+ {
+ Tcp => new[] { _host, _port },
+ Serial => new[] { _baud },
+ Usb => new[] { _usbDevice },
+ _ => new[] { _url },
+ };
+
+ public bool CanRefresh => _mode == Usb;
+
+ public event Action? FieldsChanged;
+ public event Action? StatusReceived;
+ public event Action? Log;
+
+ private bool IsHubMode => _mode is Proxy or Tcp;
+ private string KindId => _mode == Usb ? "mon-usb" : "mon-serial";
+
+ public async Task RefreshAsync()
+ {
+ if (_mode != Usb)
+ return;
+ var current = _usbDevice.Value;
+ var devices = await _bridge.ListUsbDevicesAsync();
+ var options = _usbDevice.Options!;
+ options.Clear();
+ foreach (var d in devices)
+ options.Add(d);
+ _usbDevice.Value = current is not null && devices.Contains(current) ? current : devices.FirstOrDefault() ?? "";
+ if (devices.Count == 0)
+ Log?.Invoke("No paired USB devices — click Connect to pair one.");
+ }
+
+ // GS a n — enable Automatic Status Back (any non-zero n). The printer then pushes a status block on
+ // every state change; without this the status panel never updates. (The desktop monitor does the same.)
+ private static readonly byte[] EnableAutoStatusBack = [0x1D, 0x61, 0xFF];
+
+ public async Task ConnectAsync()
+ {
+ string description;
+ if (IsHubMode)
+ {
+ await StartHubAsync(_mode == Proxy ? _url.Value : _hubUrl);
+ if (_mode == Tcp)
+ {
+ int port = int.TryParse(_port.Value, out var p) ? p : 9100;
+ await _server!.ConnectTcp(_host.Value, port);
+ description = $"{_host.Value}:{port}";
+ }
+ else
+ {
+ description = _url.Value;
+ }
+ }
+ else
+ {
+ // Serial: pass the baud; USB: pass the selected device's index (or none → the picker).
+ string? options = _mode == Serial ? _baud.Value : IndexOf(_usbDevice);
+ var desc = await _bridge.ConnectAsync(KindId, options);
+ if (string.IsNullOrEmpty(desc))
+ throw new InvalidOperationException("Connection cancelled.");
+ description = desc;
+ }
+
+ try { await SendAsync(EnableAutoStatusBack); }
+ catch { /* status stays best-effort */ }
+ return description;
+ }
+
+ private async Task StartHubAsync(string url)
+ {
+ var hub = new HubConnectionBuilder().WithUrl(url).WithAutomaticReconnect().Build();
+ hub.On(nameof(IBridgeClient.ReceiveStatus), OnStatusBytes);
+ hub.Closed += _ => { Log?.Invoke("proxy connection closed"); return Task.CompletedTask; };
+ await hub.StartAsync();
+ _hub = hub;
+ _server = new BridgeServerProxy(hub);
+ }
+
+ private string? IndexOf(TransportField dropdown)
+ {
+ var options = dropdown.Options;
+ if (options is null)
+ return null;
+ var i = options.IndexOf(dropdown.Value);
+ return i >= 0 ? i.ToString() : null; // null → the JS side opens the device picker
+ }
+
+ public async Task SendAsync(byte[] data)
+ {
+ if (IsHubMode)
+ {
+ var server = _server ?? throw new InvalidOperationException("Not connected.");
+ await server.SendToEmulator(data); // routed to the emulator, or the TCP printer if one is open
+ }
+ else
+ {
+ await _bridge.WriteAsync(KindId, data);
+ }
+ }
+
+ public void Disconnect()
+ {
+ if (IsHubMode)
+ {
+ var hub = _hub;
+ _hub = null;
+ _server = null;
+ if (hub is not null)
+ _ = hub.DisposeAsync();
+ }
+ else
+ {
+ _ = _bridge.DisconnectAsync(KindId);
+ }
+ }
+
+ private void OnBridgeData(string kind, byte[] data)
+ {
+ if (!IsHubMode && kind == KindId)
+ OnStatusBytes(data);
+ }
+
+ private void OnStatusBytes(byte[] data)
+ {
+ var status = MonitorStatus.FromAutoStatusBack(data);
+ if (status is not null)
+ StatusReceived?.Invoke(status);
+ }
+}
diff --git a/src/CrossEscPos.App.Browser/Program.cs b/src/CrossEscPos.App.Browser/Program.cs
new file mode 100644
index 0000000..0acbaa4
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Program.cs
@@ -0,0 +1,21 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Browser;
+
+namespace CrossEscPos.App.Browser;
+
+internal sealed partial class Program
+{
+ private static Task Main(string[] args)
+ {
+ // Compose the browser platform (Skia backend, download export, web transports) and hand it to
+ // the shared app, which runs here as a single view.
+ CrossEscPos.App.App.Platform = new BrowserPlatformServices();
+ return BuildAvaloniaApp()
+ .WithInterFont()
+ .StartBrowserAppAsync("out");
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure();
+}
diff --git a/src/CrossEscPos.App.Browser/Properties/launchSettings.json b/src/CrossEscPos.App.Browser/Properties/launchSettings.json
new file mode 100644
index 0000000..6f02844
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "CrossEscPos.App.Browser": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "http://localhost:5210",
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}"
+ }
+ }
+}
diff --git a/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs b/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs
new file mode 100644
index 0000000..e75202f
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs
@@ -0,0 +1,17 @@
+using CrossEscPos;
+using CrossEscPos.Emulator;
+using CrossEscPos.Transports.Browser;
+
+namespace CrossEscPos.App.Browser.Transports;
+
+/// Feeds live transport bytes into the shared printer; the MainViewModel refreshes on activity.
+public sealed class BrowserTransportSink : ITransportSink
+{
+ private readonly ReceiptPrinter _printer;
+
+ public BrowserTransportSink(ReceiptPrinter printer) => _printer = printer;
+
+ public void Feed(byte[] data, IPrinterResponder responder) => _printer.FeedEscPos(data, responder);
+ public void Attach(IPrinterResponder responder) => _printer.RegisterResponder(responder);
+ public void Detach(IPrinterResponder responder) => _printer.UnregisterResponder(responder);
+}
diff --git a/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs b/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs
new file mode 100644
index 0000000..032dd05
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Transports.Browser;
+
+namespace CrossEscPos.App.Browser.Transports;
+
+///
+/// A that wraps any browser (Web Serial,
+/// WebUSB, SignalR proxy) so it renders in the shared connections view. Its s
+/// are copied onto the transport via right before connecting.
+///
+public sealed class ReceiptTransportEntry : TransportEntry
+{
+ private readonly IReceiptTransport _transport;
+ private readonly Action? _applyBeforeConnect;
+ private readonly bool _autoConnect;
+ private bool _supported = true;
+
+ ///
+ /// Connect on startup without a user gesture. Only valid for gesture-free transports (the SignalR
+ /// proxy) — so the browser's TCP bridge comes up automatically, matching the desktop TCP listener.
+ ///
+ public ReceiptTransportEntry(IReceiptTransport transport, string name,
+ IReadOnlyList? fields = null, Action? applyBeforeConnect = null, bool autoConnect = false) : base(name)
+ {
+ _transport = transport;
+ _applyBeforeConnect = applyBeforeConnect;
+ _autoConnect = autoConnect;
+ if (fields is not null)
+ Fields = fields;
+ _transport.StateChanged += OnStateChanged;
+ _ = InitAsync();
+ }
+
+ private async Task InitAsync()
+ {
+ _supported = await _transport.IsSupportedAsync();
+ OnStateChanged();
+
+ // Bring the proxy up on its own so POS software can reach the emulator over TCP immediately —
+ // the browser equivalent of the desktop's auto-started TCP listener. Fails quietly if the host
+ // isn't serving the hub (e.g. the app is served standalone).
+ if (_autoConnect && _supported && !_transport.IsConnected)
+ {
+ _applyBeforeConnect?.Invoke();
+ try { await _transport.ConnectAsync(); }
+ catch { /* hub unreachable — the entry just shows "not connected" */ }
+ }
+ }
+
+ private void OnStateChanged()
+ => Set(_transport.IsConnected,
+ _transport.IsConnected ? (_transport.Description ?? "connected")
+ : !_supported ? "unsupported"
+ // A description while disconnected is a connect error (e.g. "port in use").
+ : _transport.Description ?? "not connected");
+
+ protected override Task ToggleAsync()
+ {
+ if (_transport.IsConnected)
+ return _transport.DisconnectAsync();
+ _applyBeforeConnect?.Invoke();
+ return _transport.ConnectAsync(); // opens the device picker (user gesture)
+ }
+
+ public override void Shutdown() => _ = _transport.DisposeAsync();
+}
diff --git a/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs b/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs
new file mode 100644
index 0000000..e32ef4d
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices.JavaScript;
+using System.Threading.Tasks;
+using CrossEscPos.Transports.Browser;
+
+namespace CrossEscPos.App.Browser.Transports;
+
+///
+/// Avalonia WASM implementation of — bridges the shared
+/// transports.js over [JSImport] /[JSExport] (the raw browser interop Avalonia uses).
+/// Outbound calls target the globalThis.crossescpos.* functions; inbound delivery arrives at the
+/// / exports (wired to the JS callback slots in
+/// main.js ). Bytes cross as base64 so the interop marshaling stays trivial.
+///
+public sealed partial class WasmJsTransportBridge : IJsTransportBridge
+{
+ private static WasmJsTransportBridge? _instance;
+
+ public event Action? DataReceived;
+ public event Action? Closed;
+
+ public WasmJsTransportBridge() => _instance = this;
+
+ public ValueTask IsSupportedAsync(string kind) => ValueTask.FromResult(IsSupported(kind));
+
+ public async ValueTask ConnectAsync(string kind, string? options)
+ {
+ var description = await Connect(kind, options ?? string.Empty);
+ return string.IsNullOrEmpty(description) ? null : description;
+ }
+
+ public ValueTask WriteAsync(string kind, byte[] data) => new(Write(kind, Convert.ToBase64String(data)));
+
+ public ValueTask DisconnectAsync(string kind) => new(Disconnect(kind));
+
+ public async ValueTask> ListUsbDevicesAsync()
+ {
+ var joined = await ListUsb();
+ return string.IsNullOrEmpty(joined) ? Array.Empty() : joined.Split('\n');
+ }
+
+ /// The page origin (e.g. http://localhost:5000 ) that served the app.
+ public static string PageOrigin()
+ {
+ try { return Origin(); }
+ catch { return string.Empty; }
+ }
+
+ // JS -> .NET delivery (base64). Wired from main.js.
+ [JSExport]
+ internal static void DeliverData(string kind, string base64)
+ => _instance?.DataReceived?.Invoke(kind, Convert.FromBase64String(base64));
+
+ [JSExport]
+ internal static void DeliverClosed(string kind)
+ => _instance?.Closed?.Invoke(kind);
+
+ // .NET -> JS (globals defined by transports.js; window === globalThis on the browser main thread).
+ [JSImport("globalThis.crossescpos.isSupported")]
+ private static partial bool IsSupported(string kind);
+
+ [JSImport("globalThis.crossescpos.connect")]
+ private static partial Task Connect(string kind, string options);
+
+ [JSImport("globalThis.crossescpos.write")]
+ private static partial Task Write(string kind, string base64);
+
+ [JSImport("globalThis.crossescpos.disconnect")]
+ private static partial Task Disconnect(string kind);
+
+ [JSImport("globalThis.crossescpos.origin")]
+ private static partial string Origin();
+
+ [JSImport("globalThis.crossescpos.listUsb")]
+ private static partial Task ListUsb();
+}
diff --git a/src/CrossEscPos.App.Browser/wwwroot/app.css b/src/CrossEscPos.App.Browser/wwwroot/app.css
new file mode 100644
index 0000000..2f3dc54
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/wwwroot/app.css
@@ -0,0 +1,12 @@
+html, body {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ height: 100%;
+ background: #262626;
+ font-family: sans-serif;
+}
+
+#out {
+ height: 100vh;
+}
diff --git a/src/CrossEscPos.App.Browser/wwwroot/index.html b/src/CrossEscPos.App.Browser/wwwroot/index.html
new file mode 100644
index 0000000..ac6b6ec
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/wwwroot/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ CrossEscPos Emulator — WASM
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App.Browser/wwwroot/main.js b/src/CrossEscPos.App.Browser/wwwroot/main.js
new file mode 100644
index 0000000..47eded0
--- /dev/null
+++ b/src/CrossEscPos.App.Browser/wwwroot/main.js
@@ -0,0 +1,30 @@
+import { dotnet } from './_framework/dotnet.js'
+
+const is_browser = typeof window != "undefined";
+if (!is_browser) throw new Error(`Expected to be running in a browser`);
+
+const dotnetRuntime = await dotnet
+ .withDiagnosticTracing(false)
+ .withApplicationArgumentsFromQuery()
+ .create();
+
+const config = dotnetRuntime.getConfig();
+
+// Wire the shared transports.js delivery slots to the app's [JSExport] callbacks. transports.js loads
+// as a classic script first, so globalThis.crossescpos already exists; we override the no-op slots.
+try {
+ const exports = await dotnetRuntime.getAssemblyExports(config.mainAssemblyName);
+ const bridge = exports.CrossEscPos.App.Browser.Transports.WasmJsTransportBridge;
+ const cx = (globalThis.crossescpos = globalThis.crossescpos || {});
+ const toBase64 = (u8) => {
+ let s = '';
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
+ return btoa(s);
+ };
+ cx.onData = (kind, u8) => bridge.DeliverData(kind, toBase64(u8));
+ cx.onClosed = (kind) => bridge.DeliverClosed(kind);
+} catch (e) {
+ console.warn('CrossEscPos: transport callbacks not wired', e);
+}
+
+await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]);
diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png b/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png
new file mode 100644
index 0000000..4e5703b
Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png differ
diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico
new file mode 100644
index 0000000..0ff1268
Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico differ
diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png
new file mode 100644
index 0000000..c0ad858
Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png differ
diff --git a/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj b/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj
new file mode 100644
index 0000000..559969b
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj
@@ -0,0 +1,31 @@
+
+
+
+ WinExe
+ true
+ true
+ Assets/Icon/icon.ico
+ CrossEscPos.App.Desktop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs b/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs
new file mode 100644
index 0000000..1d6237c
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using CrossEscPos.App;
+using CrossEscPos.App.Desktop.Monitor;
+using CrossEscPos.App.Desktop.Transports;
+using CrossEscPos.App.Desktop.Views;
+using CrossEscPos.App.Monitor;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos.App.Desktop;
+
+///
+/// The desktop platform: native file dialogs + sound/flash notifications, TCP + serial transports, and
+/// the Monitor test-client window. Injected into the shared .
+///
+public sealed class DesktopPlatformServices : IPlatformServices
+{
+ private readonly RenderBackend _backend;
+ private readonly FileDialogService _dialogs = new();
+ private readonly NotificationService _notifications = new();
+ private TcpTransportEntry? _tcp;
+ private MonitorWindow? _monitorWindow;
+
+ public DesktopPlatformServices(RenderBackend backend) => _backend = backend;
+
+ public IReceiptImageFactory ImageFactory => _backend.ImageFactory;
+ public ITypefaceProvider Typefaces => _backend.Typefaces;
+ public IImageEncoder Encoder => _backend.Encoder;
+ public string BackendName => _backend.Name;
+ public IFileDialogService FileDialogs => _dialogs;
+ public INotificationService Notifications => _notifications;
+ public byte[] SampleTicket => Sample.Ticket;
+
+ public IReadOnlyList CreateTransports(ReceiptPrinter printer)
+ {
+ var (tcpEnabled, tcpPort) = ReadTcpSettings();
+ var serialPort = Environment.GetEnvironmentVariable("ESCPOS_SERIAL_PORT");
+ int serialBaud = int.TryParse(Environment.GetEnvironmentVariable("ESCPOS_SERIAL_BAUD"), out var b) ? b : 9600;
+ var listenAddress = Environment.GetEnvironmentVariable("ESCPOS_LISTEN_ADDRESS") ?? "0.0.0.0";
+
+ _tcp = new TcpTransportEntry(printer, listenAddress, tcpPort, tcpEnabled);
+ var serial = new SerialTransportEntry(printer,
+ string.IsNullOrWhiteSpace(serialPort) ? null : serialPort, serialBaud, autoStart: serialPort is not null);
+ return new TransportEntry[] { _tcp, serial };
+ }
+
+ public IMonitorClient CreateMonitorClient() => new DesktopMonitorClient(_tcp?.CurrentPort ?? 9100);
+
+ public bool MonitorInWindow => true;
+
+ public void ShowMonitorWindow(MonitorViewModel monitor)
+ {
+ if (_monitorWindow is not null)
+ {
+ _monitorWindow.Activate();
+ return;
+ }
+ _monitorWindow = new MonitorWindow { DataContext = monitor };
+ _monitorWindow.Closed += (_, _) => _monitorWindow = null;
+ _monitorWindow.Show();
+ }
+
+ public void AttachRoot(Control mainView) => _dialogs.AttachControl(mainView);
+
+ public Window CreateMainWindow(Control content)
+ {
+ var window = new MainWindow
+ {
+ Content = content,
+ Title = $"ESC/POS Receipt Printer Emulator • Render: {_backend.Name}"
+ };
+ _notifications.AttachWindow(window);
+ _dialogs.AttachTopLevel(window);
+ return window;
+ }
+
+ public void Shutdown() { /* transports are stopped by MainViewModel.Shutdown */ }
+
+ private static (bool enabled, int port) ReadTcpSettings()
+ {
+ var setting = Environment.GetEnvironmentVariable("ESCPOS_TCP_PORT");
+ if (setting is "off" or "none" or "disabled" or "0")
+ return (false, 9100);
+ int port = int.TryParse(setting, out var p) && p > 0 ? p : 9100;
+ return (true, port);
+ }
+}
diff --git a/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs b/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs
new file mode 100644
index 0000000..0c3d7a3
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+using System.IO.Ports;
+using System.Linq;
+using System.Threading.Tasks;
+using ESCPOS_NET;
+using ESCPOS_NET.Emitters;
+using CrossEscPos.App.Monitor;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Transports;
+
+namespace CrossEscPos.App.Desktop.Monitor;
+
+///
+/// Desktop : connects to the emulator/printer over TCP, serial, or USB using
+/// ESC-POS-.NET's family. Enables Automatic Status Back on connect and maps the
+/// library's parsed onto the shared .
+///
+public sealed class DesktopMonitorClient : IMonitorClient
+{
+ private const string Tcp = "TCP", Serial = "Serial", Usb = "USB";
+
+ private readonly EPSON _e = new();
+ private BasePrinter? _printer;
+
+ // TCP fields.
+ private readonly TransportField _host = new("Host", "127.0.0.1");
+ private readonly TransportField _port;
+ // Serial fields.
+ private readonly TransportField _serialPort = new("Port", "", new[] { "" });
+ private readonly TransportField _baud = new("Baud", "9600");
+ // USB fields.
+ private readonly TransportField _usbDevice = new("Device", "", new[] { "" });
+ private readonly Dictionary _usbByDisplay = new();
+
+ private string _mode = Tcp;
+
+ public DesktopMonitorClient(int defaultPort)
+ {
+ _port = new TransportField("Port", defaultPort.ToString());
+ RefreshPorts();
+ }
+
+ public IReadOnlyList Modes { get; } = new[] { Tcp, Serial, Usb };
+
+ public string Mode
+ {
+ get => _mode;
+ set
+ {
+ if (_mode == value)
+ return;
+ _mode = value;
+ if (_mode == Usb)
+ RefreshUsb();
+ FieldsChanged?.Invoke();
+ }
+ }
+
+ public IReadOnlyList Fields => _mode switch
+ {
+ Serial => new[] { _serialPort, _baud },
+ Usb => new[] { _usbDevice },
+ _ => new[] { _host, _port },
+ };
+
+ public bool CanRefresh => _mode is Serial or Usb;
+
+ public event Action? FieldsChanged;
+ public event Action? StatusReceived;
+ public event Action? Log;
+
+ public Task RefreshAsync()
+ {
+ if (_mode == Serial) RefreshPorts();
+ else if (_mode == Usb) RefreshUsb();
+ return Task.CompletedTask;
+ }
+
+ private void RefreshPorts()
+ {
+ var current = _serialPort.Value;
+ var names = Array.Empty();
+ try { names = SerialPort.GetPortNames().OrderBy(n => n).ToArray(); }
+ catch (Exception ex) { Log?.Invoke($"could not list serial ports: {ex.Message}"); }
+ SetOptions(_serialPort, names, current);
+ }
+
+ private void RefreshUsb()
+ {
+ var current = _usbDevice.Value;
+ _usbByDisplay.Clear();
+ try
+ {
+ foreach (var d in UsbPrinter.ListDevices())
+ _usbByDisplay[d.Display] = d;
+ }
+ catch (Exception ex)
+ {
+ if (IsLibusbMissing(ex)) AppendLibusbHelp();
+ else Log?.Invoke($"USB list failed: {ex.Message}");
+ }
+ SetOptions(_usbDevice, _usbByDisplay.Keys.ToArray(), current);
+ }
+
+ private static void SetOptions(TransportField field, string[] options, string? keep)
+ {
+ var opts = field.Options!; // dropdown fields are constructed with a non-null Options collection
+ opts.Clear();
+ foreach (var o in options)
+ opts.Add(o);
+ field.Value = keep is not null && options.Contains(keep) ? keep : options.FirstOrDefault() ?? "";
+ }
+
+ public Task ConnectAsync()
+ {
+ string target;
+ switch (_mode)
+ {
+ case Usb:
+ if (!_usbByDisplay.TryGetValue(_usbDevice.Value ?? "", out var dev))
+ throw new InvalidOperationException("No USB device selected.");
+ _printer = new UsbPrinter(dev.Vid, dev.Pid);
+ target = dev.Display;
+ break;
+
+ case Serial:
+ if (string.IsNullOrWhiteSpace(_serialPort.Value))
+ throw new InvalidOperationException("No serial port selected.");
+ int baud = int.TryParse(_baud.Value, out var b) && b > 0 ? b : 9600;
+ _printer = new SerialPrinter(portName: _serialPort.Value, baudRate: baud);
+ target = $"serial {_serialPort.Value} @ {baud}";
+ break;
+
+ default: // TCP
+ _printer = new NetworkPrinter(new NetworkPrinterSettings
+ {
+ ConnectionString = $"{_host.Value}:{_port.Value}",
+ PrinterName = "Monitor"
+ });
+ target = $"{_host.Value}:{_port.Value}";
+ break;
+ }
+
+ try
+ {
+ _printer.StatusChanged += OnStatusChanged;
+ // Ask the emulator to push status on every state change (panel toggles show up here).
+ _printer.Write(_e.EnableAutomaticStatusBack());
+ }
+ catch (Exception ex)
+ {
+ if (_mode == Usb && IsLibusbMissing(ex))
+ AppendLibusbHelp();
+ Disconnect();
+ throw;
+ }
+
+ return Task.FromResult(target);
+ }
+
+ public Task SendAsync(byte[] data)
+ {
+ var printer = _printer ?? throw new InvalidOperationException("Not connected.");
+ return Task.Run(() => printer.Write(data));
+ }
+
+ public void Disconnect()
+ {
+ try
+ {
+ if (_printer is not null)
+ {
+ _printer.StatusChanged -= OnStatusChanged;
+ _printer.Dispose();
+ }
+ }
+ catch { /* ignore */ }
+ _printer = null;
+ }
+
+ private void OnStatusChanged(object? sender, EventArgs e)
+ {
+ if (e is not PrinterStatusEventArgs s)
+ return;
+ StatusReceived?.Invoke(new MonitorStatus(
+ Online: s.IsPrinterOnline == true,
+ PaperOut: s.IsPaperOut == true,
+ PaperLow: s.IsPaperLow == true,
+ CoverOpen: s.IsCoverOpen == true,
+ DrawerOpen: s.IsCashDrawerOpen == true,
+ Error: s.IsInErrorState == true));
+ }
+
+ /// True when an exception was caused by libusb not being loadable.
+ private static bool IsLibusbMissing(Exception ex)
+ {
+ for (var e = ex; e is not null; e = e.InnerException)
+ if (e is DllNotFoundException || e.Message.Contains("libusb", StringComparison.OrdinalIgnoreCase))
+ return true;
+ return false;
+ }
+
+ private void AppendLibusbHelp()
+ {
+ string install = OperatingSystem.IsMacOS()
+ ? "brew install libusb"
+ : OperatingSystem.IsLinux()
+ ? "sudo apt install libusb-1.0-0 (Debian/Ubuntu) — or your distro's libusb-1.0 package"
+ : "libusb ships with the app on Windows; reinstall the app if it's missing";
+ Log?.Invoke("Could not find libusb (the native USB library) — USB printing is unavailable.\n" +
+ $" Install it: {install}\n" +
+ " Then click the ⟳ button to refresh the USB device list.");
+ }
+}
diff --git a/src/CrossEscPos.App.Desktop/Program.cs b/src/CrossEscPos.App.Desktop/Program.cs
new file mode 100644
index 0000000..7c5d269
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Program.cs
@@ -0,0 +1,34 @@
+using Avalonia;
+using CrossEscPos.App;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.App.Desktop;
+
+public static class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [System.STAThread]
+ public static void Main(string[] args)
+ {
+ // Register process-wide exception logging before anything else so startup failures and
+ // background-thread crashes (e.g. transport teardown) are recorded rather than lost.
+ Logger.InstallGlobalHandlers();
+
+ // Compose the desktop platform (render backend, native dialogs/notifications, TCP+serial
+ // transports, Monitor) and hand it to the shared app.
+ var backend = RenderBackend.Select(args);
+ Logger.Info($"Render backend: {backend.Name}");
+ CrossEscPos.App.App.Platform = new DesktopPlatformServices(backend);
+
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/CrossEscPos.App.Desktop/Services/NotificationService.cs b/src/CrossEscPos.App.Desktop/Services/NotificationService.cs
new file mode 100644
index 0000000..412bc34
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Services/NotificationService.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.Controls.Services;
+
+///
+/// Default notification service. Plays a best-effort sound for buzzer / cash-drawer events
+/// (afplay on macOS, Console.Beep on Windows, paplay/aplay/terminal-bell on Linux) and flashes the
+/// taskbar on Windows. The primary, always-visible feedback is the on-screen toast shown by the view
+/// model — sound here is supplementary since the machine may be muted.
+///
+public class NotificationService : INotificationService
+{
+ private Window? _window;
+
+ public void AttachWindow(Window window) => _window = window;
+
+ public void NotifyActivity() => Flash();
+
+ public void Beep()
+ {
+ Logger.Info("Buzzer");
+ PlaySound(isDrawer: false);
+ Flash();
+ }
+
+ public void OpenCashDrawer()
+ {
+ Logger.Info("Cash drawer kick");
+ PlaySound(isDrawer: true);
+ Flash();
+ }
+
+ private static void PlaySound(bool isDrawer)
+ {
+ try
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ // Built-in macOS system sounds.
+ var sound = isDrawer ? "/System/Library/Sounds/Funk.aiff"
+ : "/System/Library/Sounds/Glass.aiff";
+ if (!TryStart("/usr/bin/afplay", sound))
+ TryStart("/usr/bin/osascript", "-e", "beep");
+ }
+ else if (OperatingSystem.IsWindows())
+ {
+ // Console.Beep is synchronous — run it off the UI thread. The platform guard is
+ // repeated inside the lambda so the analyzer sees it at the call site (CA1416).
+ Task.Run(() =>
+ {
+ if (!OperatingSystem.IsWindows()) return;
+ try
+ {
+ Console.Beep(isDrawer ? 600 : 1000, 180);
+ if (isDrawer) Console.Beep(900, 180);
+ }
+ catch { /* ignore */ }
+ });
+ }
+ else // Linux / other Unix
+ {
+ if (!TryStart("canberra-gtk-play", "-i", isDrawer ? "message" : "bell") &&
+ !TryStart("paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga") &&
+ !TryStart("aplay", "-q", "/usr/share/sounds/alsa/Front_Center.wav"))
+ {
+ Console.Out.Write('\a');
+ Console.Out.Flush();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, "Failed to play notification sound");
+ }
+ }
+
+ private static bool TryStart(string fileName, params string[] args)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ foreach (var a in args)
+ psi.ArgumentList.Add(a);
+
+ return Process.Start(psi) is not null;
+ }
+ catch
+ {
+ return false; // tool not installed — caller falls back
+ }
+ }
+
+ private void Flash()
+ {
+ if (OperatingSystem.IsWindows())
+ FlashOnWindows();
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void FlashOnWindows()
+ {
+ var handle = _window?.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
+ if (handle != IntPtr.Zero)
+ FlashWindow(handle, true);
+ }
+
+ [SupportedOSPlatform("windows")]
+ [DllImport("user32")]
+ private static extern int FlashWindow(IntPtr hwnd, bool bInvert);
+}
diff --git a/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs b/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs
new file mode 100644
index 0000000..b78ad23
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs
@@ -0,0 +1,66 @@
+using System;
+using System.IO.Ports;
+using System.Linq;
+using System.Threading.Tasks;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Emulator;
+using CrossEscPos.Transports;
+
+namespace CrossEscPos.App.Desktop.Transports;
+
+/// The desktop serial reader (RS-232 / USB-serial), refreshable port list.
+public sealed class SerialTransportEntry : TransportEntry
+{
+ private readonly SerialServer _serial;
+ private readonly TransportField _port;
+ private readonly TransportField _baud;
+
+ public SerialTransportEntry(ReceiptPrinter printer, string? port, int baud, bool autoStart) : base("Serial")
+ {
+ _serial = new SerialServer(printer);
+ var ports = Ports();
+ _port = new TransportField("Port", port ?? (ports.Length > 0 ? ports[0] : string.Empty), ports);
+ _baud = new TransportField("Baud rate", baud.ToString());
+ Fields = new[] { _port, _baud };
+ Set(false, "Serial: closed");
+ if (autoStart)
+ StartCore();
+ }
+
+ public override bool CanRefresh => true;
+ public override string ButtonText => IsActive ? "Close" : "Open";
+
+ protected override Task ToggleAsync()
+ {
+ if (_serial.IsRunning) { _serial.Stop(); Set(false, "Serial: closed"); }
+ else StartCore();
+ return Task.CompletedTask;
+ }
+
+ private void StartCore()
+ {
+ if (string.IsNullOrWhiteSpace(_port.Value)) { Status = "No port selected"; return; }
+ int baud = int.TryParse(_baud.Value, out var b) && b > 0 ? b : 9600;
+ _serial.Start(_port.Value, baud);
+ Set(true, $"{_serial.PortName} @ {_serial.BaudRate} (open)");
+ }
+
+ protected override Task RefreshAsync()
+ {
+ var options = _port.Options!;
+ var current = _port.Value;
+ options.Clear();
+ foreach (var name in Ports())
+ options.Add(name);
+ _port.Value = current is not null && options.Contains(current) ? current : options.FirstOrDefault() ?? string.Empty;
+ return Task.CompletedTask;
+ }
+
+ public override void Shutdown() => _serial.Stop();
+
+ private static string[] Ports()
+ {
+ try { return SerialPort.GetPortNames().OrderBy(n => n).ToArray(); }
+ catch { return Array.Empty(); }
+ }
+}
diff --git a/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs b/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs
new file mode 100644
index 0000000..2477212
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+using CrossEscPos.Transports;
+
+namespace CrossEscPos.App.Desktop.Transports;
+
+/// The desktop TCP/IP listener (a network receipt printer on port 9100 by convention).
+public sealed class TcpTransportEntry : TransportEntry
+{
+ private readonly NetServer _tcp;
+ private readonly TransportField _address;
+ private readonly TransportField _port;
+
+ public TcpTransportEntry(ReceiptPrinter printer, string address, int port, bool autoStart) : base("TCP/IP")
+ {
+ _tcp = new NetServer(printer);
+ _address = new TransportField("Listen address", address, Addresses(address));
+ _port = new TransportField("Port", port.ToString());
+ Fields = new[] { _address, _port };
+ Set(false, "TCP stopped");
+ if (autoStart)
+ StartCore();
+ }
+
+ public override string ButtonText => IsActive ? "Stop" : "Start";
+
+ public int CurrentPort => int.TryParse(_port.Value, out var p) ? p : 9100;
+
+ protected override Task ToggleAsync()
+ {
+ if (_tcp.IsRunning) { _tcp.Stop(); Set(false, "TCP stopped"); }
+ else StartCore();
+ return Task.CompletedTask;
+ }
+
+ private void StartCore()
+ {
+ if (!IPAddress.TryParse(_address.Value, out var addr)) { Status = $"Invalid address '{_address.Value}'"; return; }
+ if (!int.TryParse(_port.Value, out var port) || port is < 1 or > 65535) { Status = $"Invalid port '{_port.Value}'"; return; }
+ _tcp.Start(addr, port);
+ Set(true, $"Listening on {_tcp.EndPoint}");
+ }
+
+ public override void Shutdown() => _tcp.Stop();
+
+ private static string[] Addresses(string preferred)
+ {
+ var list = new List { "0.0.0.0", "127.0.0.1" };
+ try
+ {
+ foreach (var ip in Dns.GetHostAddresses(Dns.GetHostName()))
+ if (ip.AddressFamily == AddressFamily.InterNetwork && !list.Contains(ip.ToString()))
+ list.Add(ip.ToString());
+ }
+ catch (Exception ex) { Logger.Exception(ex, "Failed to enumerate local addresses"); }
+ if (!string.IsNullOrWhiteSpace(preferred) && !list.Contains(preferred))
+ list.Add(preferred);
+ return list.ToArray();
+ }
+}
diff --git a/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml
new file mode 100644
index 0000000..4910f34
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..12881c2
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace CrossEscPos.App.Desktop.Views;
+
+/// Thin desktop window shell; its content is the shared MainView.
+public partial class MainWindow : Window
+{
+ public MainWindow() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml
new file mode 100644
index 0000000..3f3bf24
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs
new file mode 100644
index 0000000..b20500b
--- /dev/null
+++ b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace CrossEscPos.App.Desktop.Views;
+
+/// Desktop window that hosts the shared .
+public partial class MonitorWindow : Window
+{
+ public MonitorWindow() => InitializeComponent();
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/CrossEscPos.App/App.axaml b/src/CrossEscPos.App/App.axaml
new file mode 100644
index 0000000..5258f88
--- /dev/null
+++ b/src/CrossEscPos.App/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App/App.axaml.cs b/src/CrossEscPos.App/App.axaml.cs
new file mode 100644
index 0000000..9f6d69a
--- /dev/null
+++ b/src/CrossEscPos.App/App.axaml.cs
@@ -0,0 +1,53 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using CrossEscPos.App.ViewModels;
+using CrossEscPos.App.Views;
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.App;
+
+///
+/// The shared Avalonia application, reused by the Desktop and Browser heads. It composes the headless
+/// emulator with the head's and shows the shared
+/// in whichever lifetime the head runs (a desktop Window or a browser single view).
+///
+public partial class App : Application
+{
+ /// Set by the head's Program before the app starts.
+ public static IPlatformServices Platform { get; set; } = null!;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ var platform = Platform ?? throw new InvalidOperationException(
+ "App.Platform must be set by the head before startup.");
+
+ var printer = new ReceiptPrinter(PaperConfiguration.Default, platform.ImageFactory, platform.Typefaces);
+ // Transports fire events from background threads (desktop) — marshal to the UI thread.
+ printer.UiDispatch = a => Dispatcher.UIThread.Post(a);
+
+ var viewModel = new MainViewModel(printer, platform);
+ var mainView = new MainView { DataContext = viewModel };
+ platform.AttachRoot(mainView);
+
+ switch (ApplicationLifetime)
+ {
+ case IClassicDesktopStyleApplicationLifetime desktop:
+ var window = platform.CreateMainWindow(mainView);
+ desktop.MainWindow = window;
+ desktop.ShutdownRequested += (_, _) => { viewModel.Shutdown(); platform.Shutdown(); };
+ break;
+
+ case ISingleViewApplicationLifetime singleView:
+ singleView.MainView = mainView;
+ break;
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/CrossEscPos.App/CrossEscPos.App.csproj b/src/CrossEscPos.App/CrossEscPos.App.csproj
new file mode 100644
index 0000000..50b59b1
--- /dev/null
+++ b/src/CrossEscPos.App/CrossEscPos.App.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net10.0
+
+ false
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App/IPlatformServices.cs b/src/CrossEscPos.App/IPlatformServices.cs
new file mode 100644
index 0000000..6ca4f35
--- /dev/null
+++ b/src/CrossEscPos.App/IPlatformServices.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos.App;
+
+///
+/// Everything the shared / needs from the host
+/// platform. The Desktop and Browser heads implement this; the rest of the app (rendering, receipts,
+/// printer state, export, notifications, the paste/upload input) is shared and platform-agnostic.
+///
+public interface IPlatformServices
+{
+ // Render backend (chosen by the head — Skia on desktop, Skia in the Avalonia browser).
+ IReceiptImageFactory ImageFactory { get; }
+ ITypefaceProvider Typefaces { get; }
+ IImageEncoder Encoder { get; }
+ string BackendName { get; }
+
+ // Shared services (Avalonia's implementations are cross-platform — export via IStorageProvider even
+ // triggers a browser download).
+ IFileDialogService FileDialogs { get; }
+ INotificationService Notifications { get; }
+
+ /// The bundled sample ESC/POS ticket (text + barcodes + QR), or empty if none.
+ byte[] SampleTicket { get; }
+
+ ///
+ /// The platform's transports, wired to — desktop yields TCP + serial;
+ /// the browser yields Web Serial + WebUSB + SignalR. The shared connections view renders them all.
+ ///
+ IReadOnlyList CreateTransports(ReceiptPrinter printer);
+
+ ///
+ /// Creates the platform's Monitor transport (desktop: TCP/serial/USB over ESC-POS-.NET; browser:
+ /// SignalR to the host's proxy hub), or null if the platform has no Monitor. The shared Monitor view
+ /// + test-job generation are platform-agnostic.
+ ///
+ Monitor.IMonitorClient? CreateMonitorClient();
+
+ /// Whether the Monitor opens in its own window (desktop) or an in-page panel (browser).
+ bool MonitorInWindow { get; }
+
+ /// Opens the Monitor in a platform window (desktop only; unused when it hosts in-page).
+ void ShowMonitorWindow(Monitor.MonitorViewModel monitor);
+
+ ///
+ /// Called with the shared main view once created, so the platform can wire top-level-dependent
+ /// services (e.g. the export dialog resolves its TopLevel from this control).
+ ///
+ void AttachRoot(Control mainView);
+
+ /// Wraps the shared main view in the platform's top-level window (desktop only).
+ Window CreateMainWindow(Control content);
+
+ /// Called on application shutdown so the head can stop transports, etc.
+ void Shutdown();
+}
diff --git a/src/CrossEscPos.App/Monitor/IMonitorClient.cs b/src/CrossEscPos.App/Monitor/IMonitorClient.cs
new file mode 100644
index 0000000..fa19431
--- /dev/null
+++ b/src/CrossEscPos.App/Monitor/IMonitorClient.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CrossEscPos.App.Transports;
+
+namespace CrossEscPos.App.Monitor;
+
+///
+/// The transport the shared uses to reach the emulator/printer. Desktop
+/// implements TCP / serial / USB over ESC-POS-.NET; the browser implements a single SignalR mode that
+/// rides the host's proxy hub to the in-page emulator. The view model owns the test-job generation and the
+/// status/indicator UI; the client only carries bytes and surfaces the status the emulator sends back.
+///
+public interface IMonitorClient
+{
+ /// Selectable transport modes (e.g. TCP / Serial / USB, or a single "SignalR proxy").
+ IReadOnlyList Modes { get; }
+
+ /// The current mode; setting it swaps and raises .
+ string Mode { get; set; }
+
+ /// Connection fields for the current mode (host/port, serial port + baud, proxy URL, …).
+ IReadOnlyList Fields { get; }
+
+ /// Raised when changes (mode switch or a device/port refresh).
+ event Action? FieldsChanged;
+
+ /// Whether the current mode can re-enumerate devices (serial ports / USB).
+ bool CanRefresh { get; }
+
+ /// Re-enumerate devices for the current mode.
+ Task RefreshAsync();
+
+ /// Connects using the current mode + fields; returns a human-readable target. Throws on failure.
+ Task ConnectAsync();
+
+ /// Sends an ESC/POS job to the printer.
+ Task SendAsync(byte[] data);
+
+ /// Closes the connection (idempotent).
+ void Disconnect();
+
+ /// Raised when the emulator reports status back (ASB / parsed).
+ event Action? StatusReceived;
+
+ /// Raised for connection/transport log lines to append to the activity log.
+ event Action? Log;
+}
diff --git a/src/CrossEscPos.App/Monitor/MonitorStatus.cs b/src/CrossEscPos.App/Monitor/MonitorStatus.cs
new file mode 100644
index 0000000..7e2fb5c
--- /dev/null
+++ b/src/CrossEscPos.App/Monitor/MonitorStatus.cs
@@ -0,0 +1,40 @@
+namespace CrossEscPos.App.Monitor;
+
+///
+/// A platform-neutral snapshot of the printer status the emulator reports back. The desktop client maps
+/// ESC-POS-.NET's parsed status onto this; the browser client parses the raw 4-byte Automatic Status Back
+/// block itself (see ), so both drive the same indicator UI.
+///
+public sealed record MonitorStatus(
+ bool Online,
+ bool PaperOut,
+ bool PaperLow,
+ bool CoverOpen,
+ bool DrawerOpen,
+ bool Error)
+{
+ public bool Ready => Online && !PaperOut && !CoverOpen && !Error;
+
+ ///
+ /// Reverses StatusByteBuilder.AutoStatusBack : interprets the emulator's 4-byte ASB block.
+ /// Returns null when the buffer isn't a 4-byte block (e.g. an unrelated response).
+ ///
+ public static MonitorStatus? FromAutoStatusBack(byte[] data)
+ {
+ // The emulator emits ASB as discrete 4-byte frames; if several arrive coalesced, read the last.
+ if (data is null || data.Length < 4 || data.Length % 4 != 0)
+ return null;
+
+ int off = data.Length - 4;
+ byte b0 = data[off], b1 = data[off + 1], b2 = data[off + 2];
+
+ bool drawerOpen = (b0 & 0x04) == 0; // bit2 SET = drawer closed
+ bool online = (b0 & 0x08) == 0; // bit3 SET = offline
+ bool coverOpen = (b0 & 0x20) != 0; // bit5 = cover open
+ bool error = (b1 & 0x60) != 0; // bits5,6 = recoverable / unrecoverable error
+ bool paperOut = (b2 & 0x0C) != 0; // bits2,3 = paper end
+ bool paperLow = !paperOut && (b2 & 0x03) != 0; // bits0,1 = near-end
+
+ return new MonitorStatus(online, paperOut, paperLow, coverOpen, drawerOpen, error);
+ }
+}
diff --git a/src/CrossEscPos.App/Monitor/MonitorViewModel.cs b/src/CrossEscPos.App/Monitor/MonitorViewModel.cs
new file mode 100644
index 0000000..1b9ab01
--- /dev/null
+++ b/src/CrossEscPos.App/Monitor/MonitorViewModel.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Reflection;
+using System.Threading.Tasks;
+using Avalonia.Media;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ESCPOS_NET.Emitters;
+using ESCPOS_NET.Utilities;
+using CrossEscPos.App.Transports;
+using CrossEscPos.Emulator.Rendering;
+
+namespace CrossEscPos.App.Monitor;
+
+/// One status row: a colored dot, a label, and the current value.
+public sealed class StatusIndicator
+{
+ public StatusIndicator(string label, string value, IBrush color)
+ {
+ Label = label;
+ Value = value;
+ Color = color;
+ }
+
+ public string Label { get; }
+ public string Value { get; }
+ public IBrush Color { get; }
+}
+
+///
+/// The shared Monitor: a POS-client that connects to the emulator (over TCP/serial/USB on desktop, or the
+/// SignalR proxy in the browser), sends ESC/POS test jobs built with ESC-POS-.NET, and shows the printer
+/// status the emulator reports back. Transport is delegated to an injected so
+/// the same view model, test jobs, and indicator UI run identically on both heads.
+///
+public partial class MonitorViewModel : ObservableObject
+{
+ private const byte Bel = 0x07; // bell / buzzer control code
+ private const int DefaultModuleSize = 5; // dots per module for 2D symbols
+
+ private readonly EPSON _e = new();
+ private readonly IMonitorClient _client;
+
+ [ObservableProperty] private bool _isConnected;
+ [ObservableProperty] private string _connectButtonText = "Connect";
+ [ObservableProperty] private string _statusText = "Not connected.";
+ [ObservableProperty] private bool _canRefresh;
+
+ public IReadOnlyList Modes => _client.Modes;
+ public bool HasModes => _client.Modes.Count > 1;
+
+ public string Mode
+ {
+ get => _client.Mode;
+ set
+ {
+ if (_client.Mode == value)
+ return;
+ _client.Mode = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public IReadOnlyList Fields => _client.Fields;
+
+ public ObservableCollection Log { get; } = new();
+ public ObservableCollection Indicators { get; } = new();
+
+ // Fully qualified: ESCPOS_NET.Emitters also defines a Color type.
+ private static readonly IBrush Green = new SolidColorBrush(Avalonia.Media.Color.Parse("#3CE07A"));
+ private static readonly IBrush Red = new SolidColorBrush(Avalonia.Media.Color.Parse("#E0533C"));
+ private static readonly IBrush Amber = new SolidColorBrush(Avalonia.Media.Color.Parse("#E0A93C"));
+ private static readonly IBrush Blue = new SolidColorBrush(Avalonia.Media.Color.Parse("#3C9AE0"));
+ private static readonly IBrush Gray = new SolidColorBrush(Avalonia.Media.Color.Parse("#777777"));
+
+ public MonitorViewModel(IMonitorClient client)
+ {
+ _client = client;
+ _client.Log += Append;
+ _client.StatusReceived += OnStatusReceived;
+ _client.FieldsChanged += OnFieldsChanged;
+ _canRefresh = _client.CanRefresh;
+ }
+
+ private void OnFieldsChanged() => Dispatcher.UIThread.Post(() =>
+ {
+ OnPropertyChanged(nameof(Fields));
+ CanRefresh = _client.CanRefresh;
+ });
+
+ [RelayCommand]
+ private async Task Refresh()
+ {
+ try { await _client.RefreshAsync(); }
+ catch (Exception ex) { Append($"refresh failed: {ex.Message}"); }
+ OnFieldsChanged();
+ }
+
+ private void Append(string message) => Dispatcher.UIThread.Post(() =>
+ {
+ Log.Insert(0, message);
+ if (Log.Count > 200) Log.RemoveAt(Log.Count - 1);
+ });
+
+ #region Connection
+
+ [RelayCommand]
+ private async Task ToggleConnect()
+ {
+ if (IsConnected) { Disconnect(); return; }
+
+ try
+ {
+ var target = await _client.ConnectAsync();
+ IsConnected = true;
+ ConnectButtonText = "Disconnect";
+ StatusText = "Monitoring… toggle the Printer state panel to see updates.";
+ Append($"Connected ({target})");
+ }
+ catch (Exception ex)
+ {
+ Append($"connect failed: {ex.Message}");
+ Disconnect();
+ }
+ }
+
+ private void Disconnect()
+ {
+ try { _client.Disconnect(); }
+ catch { /* ignore */ }
+ IsConnected = false;
+ ConnectButtonText = "Connect";
+ StatusText = "Not connected.";
+ Indicators.Clear();
+ Append("Disconnected");
+ }
+
+ private void OnStatusReceived(MonitorStatus s) => Dispatcher.UIThread.Post(() =>
+ {
+ StatusText = s.Ready ? "● Ready" : "● Not ready";
+ Indicators.Clear();
+ Indicators.Add(new("Printer", s.Online ? "Online" : "Offline", s.Online ? Green : Red));
+ Indicators.Add(new("Paper", s.PaperOut ? "Out" : s.PaperLow ? "Low" : "OK",
+ s.PaperOut ? Red : s.PaperLow ? Amber : Green));
+ Indicators.Add(new("Cover", s.CoverOpen ? "Open" : "Closed", s.CoverOpen ? Red : Green));
+ Indicators.Add(new("Cash drawer", s.DrawerOpen ? "Open" : "Closed", s.DrawerOpen ? Blue : Gray));
+ Indicators.Add(new("Error", s.Error ? "Yes" : "None", s.Error ? Red : Green));
+ Append("← status update");
+ });
+
+ #endregion
+
+ #region Send commands
+
+ private async Task Send(string label, params byte[][] parts)
+ {
+ try
+ {
+ await _client.SendAsync(ByteSplicer.Combine(parts));
+ Append($"→ {label}");
+ }
+ catch (Exception ex)
+ {
+ Append($"send failed: {ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task SendSample() => await Send("sample receipt",
+ _e.CenterAlign(),
+ _e.SetStyles(PrintStyle.Bold | PrintStyle.DoubleWidth | PrintStyle.DoubleHeight),
+ _e.PrintLine("MONITOR"),
+ _e.SetStyles(PrintStyle.None),
+ _e.PrintLine("Sent via ESC-POS-.NET"),
+ _e.LeftAlign(),
+ _e.PrintLine("Item ................ 9.99"),
+ _e.SetStyles(PrintStyle.Bold),
+ _e.PrintLine("TOTAL ............... 9.99"),
+ _e.SetStyles(PrintStyle.None),
+ _e.FeedLines(1),
+ _e.CenterAlign(),
+ _e.PrintQRCode("https://example.com/monitor"),
+ _e.FeedLines(2),
+ _e.PartialCut());
+
+ [RelayCommand]
+ private async Task SendBarcodes()
+ {
+ var parts = new List
+ {
+ _e.CenterAlign(),
+ _e.SetBarcodeHeightInDots(80),
+ _e.SetBarWidth(BarWidth.Default),
+ _e.SetBarLabelPosition(BarLabelPrintPosition.Below)
+ };
+ void Bc(BarcodeType t, string data, string label)
+ {
+ parts.Add(_e.PrintLine(label));
+ parts.Add(_e.PrintBarcode(t, data));
+ parts.Add(_e.FeedLines(1));
+ }
+ Bc(BarcodeType.UPC_A, "12345678901", "UPC-A");
+ Bc(BarcodeType.JAN13_EAN13, "123456789012", "EAN-13");
+ Bc(BarcodeType.CODE39, "CODE39", "CODE39");
+ Bc(BarcodeType.CODE128, "Code128", "CODE128");
+ Bc(BarcodeType.ITF, "12345678", "ITF");
+ parts.Add(_e.PartialCut());
+ await Send("all barcodes", parts.ToArray());
+ }
+
+ [RelayCommand]
+ private async Task SendQr() => await Send("QR + PDF417 + DataMatrix + Aztec",
+ _e.CenterAlign(),
+ _e.PrintLine("QR"),
+ _e.PrintQRCode("https://example.com"),
+ _e.PrintLine("PDF417"),
+ _e.Print2DCode(TwoDimensionCodeType.PDF417, "PDF417 from monitor"),
+ // DataMatrix / Aztec aren't in the ESC-POS-.NET emitter, so send the raw GS ( k bytes —
+ // the emulator supports them (cn=54 / cn=55).
+ _e.PrintLine("DataMatrix"),
+ Raw2D(TwoDimensionCode.DataMatrix.Value, "DataMatrix from monitor", DefaultModuleSize),
+ _e.PrintLine("Aztec"),
+ Raw2D(TwoDimensionCode.Aztec.Value, "Aztec from monitor", DefaultModuleSize),
+ _e.FeedLines(1),
+ _e.PartialCut());
+
+ /// Builds raw GS ( k bytes for a 2D symbol the emitter doesn't expose (DataMatrix/Aztec).
+ private static byte[] Raw2D(int cn, string data, int moduleSize)
+ {
+ const int GS = 0x1D, ParenK0 = '(', ParenK1 = 'k';
+ const int FnModuleSize = 67, FnStore = 80, FnPrint = 81, StoreM = 48;
+ const int HeaderLen = 3; // cn + fn + m
+
+ var b = new List();
+ void By(params int[] xs) { foreach (var x in xs) b.Add((byte)x); }
+
+ By(GS, ParenK0, ParenK1, HeaderLen, 0, cn, FnModuleSize, moduleSize);
+ int len = HeaderLen + data.Length;
+ By(GS, ParenK0, ParenK1, len & 0xFF, len >> 8, cn, FnStore, StoreM);
+ b.AddRange(System.Text.Encoding.ASCII.GetBytes(data));
+ By(GS, ParenK0, ParenK1, HeaderLen, 0, cn, FnPrint, StoreM);
+ return b.ToArray();
+ }
+
+ [RelayCommand]
+ private async Task OpenDrawer() => await Send("open cash drawer", _e.CashDrawerOpenPin2());
+
+ [RelayCommand]
+ private async Task Beep() => await Send("buzzer (BEL)", [Bel]);
+
+ [RelayCommand]
+ private async Task Cut() => await Send("cut", _e.PartialCut());
+
+ [RelayCommand]
+ private async Task SendFullTest()
+ {
+ // Embedded in the shared app so it works on both heads (desktop and browser).
+ using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("test_receipt.txt");
+ if (stream is null) { Append("test_receipt.txt not found"); return; }
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms);
+ await Send("full feature test receipt", ms.ToArray());
+ }
+
+ #endregion
+
+ public void Shutdown() => Disconnect();
+}
diff --git a/src/CrossEscPos.App/RenderBackend.cs b/src/CrossEscPos.App/RenderBackend.cs
new file mode 100644
index 0000000..70d64a6
--- /dev/null
+++ b/src/CrossEscPos.App/RenderBackend.cs
@@ -0,0 +1,56 @@
+using System;
+using CrossEscPos.Graphics;
+using CrossEscPos.Rendering.ImageSharp;
+using CrossEscPos.Rendering.Skia;
+
+namespace CrossEscPos.App;
+
+///
+/// Selects the render backend at startup so the two rendering libraries can be A/B tested visually.
+///
+/// Choose with a command-line argument — --backend imagesharp (or --backend=imagesharp ,
+/// or -b imagesharp ) — or the ESCPOS_RENDER_BACKEND environment variable. Accepted values
+/// are skia (default) and imagesharp . Anything unrecognised falls back to Skia.
+///
+public sealed record RenderBackend(
+ string Name,
+ IReceiptImageFactory ImageFactory,
+ ITypefaceProvider Typefaces,
+ IImageEncoder Encoder)
+{
+ public static RenderBackend Select(string[] args) => Resolve(args) switch
+ {
+ "imagesharp" => new RenderBackend(
+ "ImageSharp (managed)",
+ new ImageSharpImageFactory(),
+ new ImageSharpTypefaceProvider(),
+ new ImageSharpImageEncoder()),
+ _ => new RenderBackend(
+ "SkiaSharp",
+ new SkiaImageFactory(),
+ new SkiaTypefaceProvider(),
+ new SkiaImageEncoder()),
+ };
+
+ // CLI wins over the environment variable; both are normalised the same way.
+ private static string Resolve(string[] args)
+ {
+ for (int i = 0; i < args.Length; i++)
+ {
+ var a = args[i];
+ if ((a.Equals("--backend", StringComparison.OrdinalIgnoreCase) || a.Equals("-b", StringComparison.OrdinalIgnoreCase))
+ && i + 1 < args.Length)
+ return Normalize(args[i + 1]);
+ if (a.StartsWith("--backend=", StringComparison.OrdinalIgnoreCase))
+ return Normalize(a["--backend=".Length..]);
+ }
+
+ return Normalize(Environment.GetEnvironmentVariable("ESCPOS_RENDER_BACKEND"));
+ }
+
+ private static string Normalize(string? value) => value?.Trim().ToLowerInvariant() switch
+ {
+ "imagesharp" or "managed" or "is" => "imagesharp",
+ _ => "skia",
+ };
+}
diff --git a/src/CrossEscPos.App/Sample.cs b/src/CrossEscPos.App/Sample.cs
new file mode 100644
index 0000000..6fe2a27
--- /dev/null
+++ b/src/CrossEscPos.App/Sample.cs
@@ -0,0 +1,20 @@
+using System;
+using System.IO;
+
+namespace CrossEscPos.App;
+
+/// The bundled sample ESC/POS ticket (text + barcodes + QR), embedded in the shared app.
+public static class Sample
+{
+ public static byte[] Ticket { get; } = Load();
+
+ private static byte[] Load()
+ {
+ using var stream = typeof(Sample).Assembly.GetManifestResourceStream("sample.escpos");
+ if (stream is null)
+ return Array.Empty();
+ using var buffer = new MemoryStream();
+ stream.CopyTo(buffer);
+ return buffer.ToArray();
+ }
+}
diff --git a/src/CrossEscPos.App/Services/FileDialogService.cs b/src/CrossEscPos.App/Services/FileDialogService.cs
new file mode 100644
index 0000000..0841b65
--- /dev/null
+++ b/src/CrossEscPos.App/Services/FileDialogService.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+
+namespace CrossEscPos.Controls.Services;
+
+///
+/// backed by Avalonia's — cross-platform,
+/// so on the browser head the same "save PNG" path becomes a download. The top level is resolved either
+/// from an explicit window (desktop) or lazily from a control (browser single-view).
+///
+public class FileDialogService : IFileDialogService
+{
+ private TopLevel? _topLevel;
+ private Control? _control;
+
+ public void AttachTopLevel(TopLevel topLevel) => _topLevel = topLevel;
+
+ /// Attach a control; the top level is resolved from it at call time (browser single-view).
+ public void AttachControl(Control control) => _control = control;
+
+ private IStorageProvider? Provider =>
+ (_topLevel ?? (_control is null ? null : TopLevel.GetTopLevel(_control)))?.StorageProvider;
+
+ public async Task SavePngAsync(string suggestedName)
+ {
+ var provider = Provider;
+ if (provider is null)
+ return null;
+
+ var file = await provider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export receipt",
+ SuggestedFileName = suggestedName,
+ DefaultExtension = "png",
+ FileTypeChoices = new List
+ {
+ new("PNG image") { Patterns = new[] { "*.png" } }
+ }
+ });
+
+ if (file is null)
+ return null;
+
+ return await file.OpenWriteAsync();
+ }
+
+ public async Task PickFolderAsync()
+ {
+ var provider = Provider;
+ if (provider is null)
+ return null;
+
+ var folders = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Choose export folder",
+ AllowMultiple = false
+ });
+
+ if (folders is null || folders.Count == 0)
+ return null;
+
+ return folders[0].TryGetLocalPath();
+ }
+}
diff --git a/src/CrossEscPos.App/Transports/TransportEntry.cs b/src/CrossEscPos.App/Transports/TransportEntry.cs
new file mode 100644
index 0000000..3947a74
--- /dev/null
+++ b/src/CrossEscPos.App/Transports/TransportEntry.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace CrossEscPos.App.Transports;
+
+///
+/// A platform-agnostic, self-describing transport shown in the shared connections view: a name, a
+/// status line, some configurable s, and a toggle action. The desktop
+/// (TCP, serial) and browser (Web Serial, WebUSB, WebSocket/SignalR) transports all subclass this, so
+/// one shared view renders them uniformly.
+///
+public abstract partial class TransportEntry : ObservableObject
+{
+ protected TransportEntry(string name) => Name = name;
+
+ public string Name { get; }
+
+ [ObservableProperty] private string _status = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ButtonText))]
+ private bool _isActive;
+
+ /// Connect/Disconnect by default; Start/Stop or Open/Close for the desktop server transports.
+ public virtual string ButtonText => IsActive ? "Disconnect" : "Connect";
+
+ public IReadOnlyList Fields { get; protected set; } = Array.Empty();
+
+ /// True to show a refresh button (e.g. re-enumerate serial ports).
+ public virtual bool CanRefresh => false;
+
+ [RelayCommand] private Task Toggle() => ToggleAsync();
+ [RelayCommand] private Task Refresh() => RefreshAsync();
+
+ protected abstract Task ToggleAsync();
+ protected virtual Task RefreshAsync() => Task.CompletedTask;
+
+ protected void Set(bool active, string status)
+ {
+ IsActive = active;
+ Status = status;
+ }
+
+ /// Stop/disconnect on shutdown.
+ public virtual void Shutdown() { }
+}
diff --git a/src/CrossEscPos.App/Transports/TransportField.cs b/src/CrossEscPos.App/Transports/TransportField.cs
new file mode 100644
index 0000000..a5ac6c7
--- /dev/null
+++ b/src/CrossEscPos.App/Transports/TransportField.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace CrossEscPos.App.Transports;
+
+///
+/// One configurable field of a transport (e.g. a TCP port, a serial baud rate, a WebSocket URL). The
+/// shared connections view renders it as a text box, or a combo box when is set.
+///
+public sealed partial class TransportField : ObservableObject
+{
+ public TransportField(string label, string value, IEnumerable? options = null)
+ {
+ Label = label;
+ _value = value;
+ Options = options is null ? null : new ObservableCollection(options);
+ }
+
+ public string Label { get; }
+
+ [ObservableProperty]
+ private string _value;
+
+ /// Non-null for a dropdown field; mutable so it can be refreshed (e.g. serial ports).
+ public ObservableCollection? Options { get; }
+
+ public bool IsDropdown => Options is not null;
+}
diff --git a/src/CrossEscPos.App/ViewModels/MainViewModel.cs b/src/CrossEscPos.App/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..1d9f96a
--- /dev/null
+++ b/src/CrossEscPos.App/ViewModels/MainViewModel.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CrossEscPos.Controls;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.Graphics;
+using CrossEscPos.App.Monitor;
+
+namespace CrossEscPos.App.ViewModels;
+
+///
+/// The shared main view model, reused by both heads. It owns the platform-agnostic features — the
+/// paste/upload ESC/POS input, the rendered receipts, printer-state simulation, export, and the
+/// buzzer/cash-drawer toast — and hosts the platform's transport UI via .
+///
+public partial class MainViewModel : ObservableObject
+{
+ private const string DefaultInput =
+ "*** CrossEscPos ***\n" +
+ "Type or paste ESC/POS here\n" +
+ "and press Render, or connect\n" +
+ "a device on the left.\n" +
+ "\n" +
+ "Item $ 4.50\n" +
+ "Tax $ 0.36\n" +
+ "TOTAL $ 4.86\n" +
+ "\n" +
+ "Thank you!\n";
+
+ private static readonly TimeSpan ToastDuration = TimeSpan.FromSeconds(3.5);
+
+ private readonly ReceiptPrinter _printer;
+ private readonly IPlatformServices _platform;
+ private readonly IImageEncoder _encoder;
+ private readonly IFileDialogService _dialogs;
+ private readonly INotificationService _notifications;
+ private readonly DispatcherTimer _toastTimer;
+ private readonly Dictionary _receiptsById = new();
+
+ public ObservableCollection Receipts { get; } = new();
+ public bool HasReceipts => Receipts.Count > 0;
+ public PrinterState State => _printer.State;
+
+ /// The platform's transports, rendered uniformly by the shared connections view.
+ public IReadOnlyList Transports { get; }
+
+ /// The shared Monitor test-client, or null if the platform has no Monitor.
+ public MonitorViewModel? Monitor { get; }
+ public bool SupportsMonitor => Monitor is not null;
+ public string BackendName => _platform.BackendName;
+
+ [ObservableProperty] private string _input = DefaultInput;
+ [ObservableProperty] private string _toastMessage = string.Empty;
+ [ObservableProperty] private bool _toastVisible;
+ /// Browser only: shows the Monitor as an in-page overlay (desktop uses a window instead).
+ [ObservableProperty] private bool _isMonitorOpen;
+
+ /// Raised on the UI thread after receipts change (the view scrolls to the newest).
+ public event EventHandler? ReceiptsUpdated;
+
+ public MainViewModel(ReceiptPrinter printer, IPlatformServices platform)
+ {
+ _printer = printer;
+ _platform = platform;
+ _encoder = platform.Encoder;
+ _dialogs = platform.FileDialogs;
+ _notifications = platform.Notifications;
+
+ _toastTimer = new DispatcherTimer { Interval = ToastDuration };
+ _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); ToastVisible = false; };
+
+ // Transport receive threads raise these — marshal to the UI thread.
+ _printer.OnActivityEvent += (_, _) => Dispatcher.UIThread.Post(() => { RefreshReceipts(); _notifications.NotifyActivity(); });
+ _printer.OnBuzzer += () => Dispatcher.UIThread.Post(() => { _notifications.Beep(); ShowToast("🔔 Buzzer"); });
+ _printer.OnCashDrawer += () => Dispatcher.UIThread.Post(() => { _notifications.OpenCashDrawer(); ShowToast("💵 Cash drawer opened"); });
+ _printer.OnPrintBlocked += reason => Dispatcher.UIThread.Post(() => ShowToast($"🚫 {reason} — print dropped"));
+
+ Transports = platform.CreateTransports(printer);
+
+ if (platform.CreateMonitorClient() is { } monitorClient)
+ Monitor = new MonitorViewModel(monitorClient);
+
+ Render(); // render the default input so the app shows something on launch
+ }
+
+ [RelayCommand]
+ private void Render()
+ {
+ ResetPrinter();
+ _printer.FeedEscPos(Encoding.Latin1.GetBytes(Input));
+ RefreshReceipts();
+ }
+
+ [RelayCommand]
+ private void LoadSample()
+ {
+ var sample = _platform.SampleTicket;
+ if (sample.Length == 0)
+ return;
+ ResetPrinter();
+ _printer.FeedEscPos(sample);
+ RefreshReceipts();
+ }
+
+ [RelayCommand]
+ private void Clear()
+ {
+ ResetPrinter();
+ RefreshReceipts();
+ }
+
+ [RelayCommand]
+ private void OpenMonitor()
+ {
+ if (Monitor is null)
+ return;
+ if (_platform.MonitorInWindow)
+ _platform.ShowMonitorWindow(Monitor);
+ else
+ IsMonitorOpen = true; // in-page overlay (browser)
+ }
+
+ [RelayCommand]
+ private void CloseMonitor() => IsMonitorOpen = false;
+
+ private void ResetPrinter()
+ {
+ _printer.ReceiptStack.Clear();
+ _printer.Initialize();
+ _printer.StartNewReceipt();
+ _receiptsById.Clear();
+ Receipts.Clear();
+ }
+
+ #region Export
+
+ private List NonEmptyReceipts() => _printer.ReceiptStack.Where(r => !r.IsEmpty).ToList();
+
+ [RelayCommand(CanExecute = nameof(HasReceipts))]
+ private async Task ExportAll()
+ {
+ var receipts = NonEmptyReceipts();
+ if (receipts.Count == 0)
+ return;
+
+ var bitmaps = receipts.Select(r => r.Render()).ToList();
+ try
+ {
+ using var combined = ReceiptExporter.StackVertical(bitmaps, _printer.ImageFactory);
+ var stream = await _dialogs.SavePngAsync("receipts");
+ if (stream is null)
+ return;
+ await using (stream)
+ _encoder.EncodePng(combined, stream);
+ }
+ finally
+ {
+ foreach (var b in bitmaps)
+ b.Dispose();
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(HasReceipts))]
+ private async Task ExportEach()
+ {
+ var receipts = NonEmptyReceipts();
+ if (receipts.Count == 0)
+ return;
+
+ var folder = await _dialogs.PickFolderAsync();
+ if (folder is null)
+ return;
+
+ for (int i = 0; i < receipts.Count; i++)
+ {
+ using var bmp = receipts[i].Render();
+ var path = Path.Combine(folder, $"receipt_{i + 1:D3}.png");
+ await using var fs = File.Create(path);
+ _encoder.EncodePng(bmp, fs);
+ }
+ }
+
+ #endregion
+
+ /// Called on shutdown — stop every transport.
+ public void Shutdown()
+ {
+ Monitor?.Shutdown();
+ foreach (var transport in Transports)
+ transport.Shutdown();
+ }
+
+ private void ShowToast(string message)
+ {
+ ToastMessage = message;
+ ToastVisible = true;
+ _toastTimer.Stop();
+ _toastTimer.Start();
+ }
+
+ private void RefreshReceipts()
+ {
+ foreach (var receipt in _printer.ReceiptStack)
+ {
+ if (receipt.IsEmpty)
+ continue;
+
+ if (_receiptsById.TryGetValue(receipt.Guid, out var vm))
+ {
+ vm.Refresh();
+ }
+ else
+ {
+ vm = new ReceiptViewModel(receipt, _encoder, _dialogs, Receipts.Count + 1);
+ _receiptsById[receipt.Guid] = vm;
+ Receipts.Add(vm);
+ }
+ }
+
+ OnPropertyChanged(nameof(HasReceipts));
+ ExportAllCommand.NotifyCanExecuteChanged();
+ ExportEachCommand.NotifyCanExecuteChanged();
+ ReceiptsUpdated?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/CrossEscPos.App/Views/MainView.axaml b/src/CrossEscPos.App/Views/MainView.axaml
new file mode 100644
index 0000000..7796fdf
--- /dev/null
+++ b/src/CrossEscPos.App/Views/MainView.axaml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App/Views/MainView.axaml.cs b/src/CrossEscPos.App/Views/MainView.axaml.cs
new file mode 100644
index 0000000..0d980bc
--- /dev/null
+++ b/src/CrossEscPos.App/Views/MainView.axaml.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using CrossEscPos.App.ViewModels;
+using CrossEscPos.Controls;
+
+namespace CrossEscPos.App.Views;
+
+/// The shared main view (used inside a desktop window and as the browser single view).
+public partial class MainView : UserControl
+{
+ private ReceiptView? _receipts;
+
+ public MainView()
+ {
+ InitializeComponent();
+ _receipts = this.FindControl("Receipts");
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private void OnDataContextChanged(object? sender, System.EventArgs e)
+ {
+ if (DataContext is MainViewModel vm)
+ vm.ReceiptsUpdated += (_, _) => _receipts?.ScrollToEnd();
+ }
+}
diff --git a/src/CrossEscPos.App/Views/MonitorView.axaml b/src/CrossEscPos.App/Views/MonitorView.axaml
new file mode 100644
index 0000000..4c1c012
--- /dev/null
+++ b/src/CrossEscPos.App/Views/MonitorView.axaml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.App/Views/MonitorView.axaml.cs b/src/CrossEscPos.App/Views/MonitorView.axaml.cs
new file mode 100644
index 0000000..dffcea8
--- /dev/null
+++ b/src/CrossEscPos.App/Views/MonitorView.axaml.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using CrossEscPos.App.Monitor;
+
+namespace CrossEscPos.App.Views;
+
+///
+/// The shared Monitor view — hosted in a window on desktop and as an in-page panel in the browser. It
+/// binds a whose transport is platform-specific (TCP/serial/USB or SignalR).
+///
+public partial class MonitorView : UserControl
+{
+ public MonitorView() => InitializeComponent();
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private async void OnCopyLog(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MonitorViewModel vm)
+ return;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ return;
+
+ // The log is newest-first; copy oldest-first so it reads chronologically when pasted.
+ var text = string.Join(Environment.NewLine, vm.Log.Reverse());
+ try { await clipboard.SetValueAsync(DataFormat.Text, text); }
+ catch { /* clipboard unavailable — ignore */ }
+ }
+}
diff --git a/src/CrossEscPos.App/sample.escpos b/src/CrossEscPos.App/sample.escpos
new file mode 100644
index 0000000..4832b11
Binary files /dev/null and b/src/CrossEscPos.App/sample.escpos differ
diff --git a/src/CrossEscPos.App/test_receipt.txt b/src/CrossEscPos.App/test_receipt.txt
new file mode 100644
index 0000000..4832b11
Binary files /dev/null and b/src/CrossEscPos.App/test_receipt.txt differ
diff --git a/src/CrossEscPos.Bridge/CrossEscPos.Bridge.csproj b/src/CrossEscPos.Bridge/CrossEscPos.Bridge.csproj
new file mode 100644
index 0000000..5e5eeab
--- /dev/null
+++ b/src/CrossEscPos.Bridge/CrossEscPos.Bridge.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0
+
+ false
+
+
+
diff --git a/src/CrossEscPos.Bridge/IBridgeClient.cs b/src/CrossEscPos.Bridge/IBridgeClient.cs
new file mode 100644
index 0000000..0352bc8
--- /dev/null
+++ b/src/CrossEscPos.Bridge/IBridgeClient.cs
@@ -0,0 +1,17 @@
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Bridge;
+
+///
+/// The methods the server invokes on a connected client. The hub is Hub<IBridgeClient> , so the
+/// server calls these in a strongly-typed way (Clients.Client(id).ReceiveEscPos(...) ); clients
+/// register handlers against the same method names.
+///
+public interface IBridgeClient
+{
+ /// Deliver an ESC/POS job to the emulator.
+ Task ReceiveEscPos(byte[] data);
+
+ /// Deliver the emulator's status reply to the sender (monitor/POS).
+ Task ReceiveStatus(byte[] data);
+}
diff --git a/src/CrossEscPos.Bridge/IBridgeServer.cs b/src/CrossEscPos.Bridge/IBridgeServer.cs
new file mode 100644
index 0000000..0808f29
--- /dev/null
+++ b/src/CrossEscPos.Bridge/IBridgeServer.cs
@@ -0,0 +1,33 @@
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Bridge;
+
+///
+/// The broker hub's server methods — what a client invokes on the server. The hub implements this, and
+/// clients proxy it (see the browser-side BridgeServerProxy ) so both ends share one contract.
+///
+public interface IBridgeServer
+{
+ ///
+ /// Register this connection as the printer (emulator) and ask the proxy to open a TCP listener on
+ /// : for this session, so POS software can reach it.
+ /// Throws if the port can't be bound. The listener is torn down when the emulator disconnects.
+ ///
+ Task AttachEmulator(string address, int port);
+
+ ///
+ /// As a monitor , ask the proxy to open an outbound TCP connection to a real network printer at
+ /// : . After this, writes to
+ /// that socket and its replies come back via ReceiveStatus . Throws if the printer can't be reached.
+ ///
+ Task ConnectTcp(string host, int port);
+
+ ///
+ /// Send an ESC/POS job to this connection's target — the attached emulator, or the TCP printer opened
+ /// with if one is active.
+ ///
+ Task SendToEmulator(byte[] data);
+
+ /// The emulator's status reply, routed back to whoever last sent.
+ Task ReplyToSender(byte[] data);
+}
diff --git a/src/CrossEscPos.Controls/AvaloniaImageExtensions.cs b/src/CrossEscPos.Controls/AvaloniaImageExtensions.cs
new file mode 100644
index 0000000..94d1e04
--- /dev/null
+++ b/src/CrossEscPos.Controls/AvaloniaImageExtensions.cs
@@ -0,0 +1,20 @@
+using System.IO;
+using Avalonia.Media.Imaging;
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos.Controls;
+
+///
+/// Bridges a backend-agnostic into an Avalonia via
+/// PNG. Going through (rather than SkiaSharp directly) keeps the control
+/// library independent of the render backend and works in the browser sandbox.
+///
+public static class AvaloniaImageExtensions
+{
+ /// Encodes to PNG and loads it as an Avalonia .
+ public static Bitmap ToAvaloniaBitmap(this IReceiptImage source, IImageEncoder encoder)
+ {
+ using var stream = new MemoryStream(encoder.EncodePng(source));
+ return new Bitmap(stream);
+ }
+}
diff --git a/src/CrossEscPos.Controls/CrossEscPos.Controls.csproj b/src/CrossEscPos.Controls/CrossEscPos.Controls.csproj
new file mode 100644
index 0000000..55ad0b2
--- /dev/null
+++ b/src/CrossEscPos.Controls/CrossEscPos.Controls.csproj
@@ -0,0 +1,21 @@
+
+
+
+ true
+ true
+ Reusable Avalonia controls for hosting the CrossEscPos emulator: ReceiptView (renders
+ the live receipt stream), PrinterStatePanel (online/paper/cover/drawer/error), and PNG export
+ helpers. Backend-agnostic — depends on the rendering abstraction, not on SkiaSharp.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Controls/PrinterStatePanel.axaml b/src/CrossEscPos.Controls/PrinterStatePanel.axaml
new file mode 100644
index 0000000..89d46ea
--- /dev/null
+++ b/src/CrossEscPos.Controls/PrinterStatePanel.axaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Controls/PrinterStatePanel.axaml.cs b/src/CrossEscPos.Controls/PrinterStatePanel.axaml.cs
new file mode 100644
index 0000000..43410bf
--- /dev/null
+++ b/src/CrossEscPos.Controls/PrinterStatePanel.axaml.cs
@@ -0,0 +1,41 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.Controls;
+
+///
+/// A panel that drives the simulated (online, cover, paper, error, cash
+/// drawer, feed button). Two-way bound, so toggling it feeds the status commands (DLE EOT, GS r) and
+/// Automatic Status Back (GS a). Bind the property to a printer's State.
+///
+public partial class PrinterStatePanel : UserControl
+{
+ public static readonly StyledProperty StateProperty =
+ AvaloniaProperty.Register(nameof(State));
+
+ public PrinterState? State
+ {
+ get => GetValue(StateProperty);
+ set => SetValue(StateProperty, value);
+ }
+
+ /// Paper-level choices for the combo box.
+ public Array PaperLevels { get; } = Enum.GetValues(typeof(PaperLevel));
+
+ /// Error-state choices for the combo box.
+ public Array ErrorStates { get; } = Enum.GetValues(typeof(PrinterErrorState));
+
+ public PrinterStatePanel()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/CrossEscPos.Controls/ReceiptView.axaml b/src/CrossEscPos.Controls/ReceiptView.axaml
new file mode 100644
index 0000000..59237fc
--- /dev/null
+++ b/src/CrossEscPos.Controls/ReceiptView.axaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Controls/ReceiptView.axaml.cs b/src/CrossEscPos.Controls/ReceiptView.axaml.cs
new file mode 100644
index 0000000..0f2fc94
--- /dev/null
+++ b/src/CrossEscPos.Controls/ReceiptView.axaml.cs
@@ -0,0 +1,37 @@
+using System.Collections;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace CrossEscPos.Controls;
+
+///
+/// Displays the live stream of rendered receipts. Bind to an
+/// of . Backend-agnostic — the view models
+/// carry already-rendered Avalonia bitmaps.
+///
+public partial class ReceiptView : UserControl
+{
+ public static readonly StyledProperty ReceiptsProperty =
+ AvaloniaProperty.Register(nameof(Receipts));
+
+ public IEnumerable? Receipts
+ {
+ get => GetValue(ReceiptsProperty);
+ set => SetValue(ReceiptsProperty, value);
+ }
+
+ public ReceiptView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ /// Scrolls the receipt list to the most recent page.
+ public void ScrollToEnd()
+ => this.FindControl("ReceiptScrollView")?.ScrollToEnd();
+}
diff --git a/src/CrossEscPos.Controls/ReceiptViewModel.cs b/src/CrossEscPos.Controls/ReceiptViewModel.cs
new file mode 100644
index 0000000..0a20c38
--- /dev/null
+++ b/src/CrossEscPos.Controls/ReceiptViewModel.cs
@@ -0,0 +1,63 @@
+using System.Threading.Tasks;
+using Avalonia.Media.Imaging;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CrossEscPos.Graphics;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.Controls;
+
+///
+/// Wraps a single and exposes its rendered image for binding, plus a per-page
+/// PNG export command. The render backend reaches the control only through .
+///
+public partial class ReceiptViewModel : ObservableObject
+{
+ private readonly Receipt _receipt;
+ private readonly IImageEncoder _encoder;
+ private readonly IFileDialogService _dialogs;
+ private readonly int _index;
+
+ [ObservableProperty]
+ private Bitmap? _image;
+
+ public ReceiptViewModel(Receipt receipt, IImageEncoder encoder, IFileDialogService dialogs, int index)
+ {
+ _receipt = receipt;
+ _encoder = encoder;
+ _dialogs = dialogs;
+ _index = index;
+ Refresh();
+ }
+
+ public string Id => _receipt.Guid;
+
+ public string Title => $"Cut #{_index}";
+
+ public bool IsEmpty => _receipt.IsEmpty;
+
+ /// Re-renders the receipt bitmap from its current state.
+ public void Refresh()
+ {
+ if (_receipt.IsEmpty)
+ return;
+
+ var old = Image;
+ using var image = _receipt.Render();
+ Image = image.ToAvaloniaBitmap(_encoder);
+ old?.Dispose();
+ }
+
+ [RelayCommand]
+ private async Task Export()
+ {
+ using var image = _receipt.Render();
+ var stream = await _dialogs.SavePngAsync($"receipt_{_index:D3}");
+ if (stream is null)
+ return;
+
+ await using (stream)
+ _encoder.EncodePng(image, stream);
+ }
+}
diff --git a/src/CrossEscPos.Controls/Services/IFileDialogService.cs b/src/CrossEscPos.Controls/Services/IFileDialogService.cs
new file mode 100644
index 0000000..b1e5474
--- /dev/null
+++ b/src/CrossEscPos.Controls/Services/IFileDialogService.cs
@@ -0,0 +1,19 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Controls.Services;
+
+///
+/// Abstraction over native save/folder pickers so view models can export without referencing the UI.
+///
+public interface IFileDialogService
+{
+ ///
+ /// Shows a "save PNG" dialog. Returns a writable stream for the chosen file (caller disposes),
+ /// or null if cancelled.
+ ///
+ Task SavePngAsync(string suggestedName);
+
+ /// Shows a folder picker. Returns the chosen folder's local path, or null if cancelled.
+ Task PickFolderAsync();
+}
diff --git a/src/CrossEscPos.Controls/Services/INotificationService.cs b/src/CrossEscPos.Controls/Services/INotificationService.cs
new file mode 100644
index 0000000..6b7a64e
--- /dev/null
+++ b/src/CrossEscPos.Controls/Services/INotificationService.cs
@@ -0,0 +1,17 @@
+namespace CrossEscPos.Controls.Services;
+
+///
+/// Surfaces user-facing printer events. Cross-platform by design; platform-specific affordances
+/// (e.g. flashing the taskbar, playing a sound) are best-effort.
+///
+public interface INotificationService
+{
+ /// The printer received and processed data.
+ void NotifyActivity();
+
+ /// The printer buzzer/beeper fired (ESC/POS BEL or buzzer command).
+ void Beep();
+
+ /// A cash-drawer kick pulse was issued (ESC p / DLE DC4).
+ void OpenCashDrawer();
+}
diff --git a/src/CrossEscPos.Core/CrossEscPos.Core.csproj b/src/CrossEscPos.Core/CrossEscPos.Core.csproj
new file mode 100644
index 0000000..78258a7
--- /dev/null
+++ b/src/CrossEscPos.Core/CrossEscPos.Core.csproj
@@ -0,0 +1,28 @@
+
+
+
+ true
+ Headless ESC/POS emulator: byte-stream interpreter, printer state machine, receipt
+ document model and barcode/QR generation. Renders through the CrossEscPos.Abstractions surface,
+ so it carries no UI or graphics-backend dependency and runs headless or in WASM.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Core/Emulator/CharacterCodeTable.cs b/src/CrossEscPos.Core/Emulator/CharacterCodeTable.cs
new file mode 100644
index 0000000..1286a20
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/CharacterCodeTable.cs
@@ -0,0 +1,29 @@
+using Ardalis.SmartEnum;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// ESC/POS character code tables (ESC t n) and the .NET code page each maps to. The SmartEnum value is
+/// the ESC/POS table id, so selecting a table is a lookup instead of a magic-number switch.
+///
+public sealed class CharacterCodeTable : SmartEnum
+{
+ public static readonly CharacterCodeTable Pc437 = new(nameof(Pc437), 0, 437); // USA / standard Europe
+ public static readonly CharacterCodeTable Katakana = new(nameof(Katakana), 1, 932); // approx via Shift-JIS
+ public static readonly CharacterCodeTable Pc850 = new(nameof(Pc850), 2, 850); // multilingual
+ public static readonly CharacterCodeTable Pc860 = new(nameof(Pc860), 3, 860); // Portuguese
+ public static readonly CharacterCodeTable Pc863 = new(nameof(Pc863), 4, 863); // Canadian-French
+ public static readonly CharacterCodeTable Pc865 = new(nameof(Pc865), 5, 865); // Nordic
+ public static readonly CharacterCodeTable Windows1252 = new(nameof(Windows1252), 16, 1252);
+ public static readonly CharacterCodeTable Pc866 = new(nameof(Pc866), 17, 866); // Cyrillic
+ public static readonly CharacterCodeTable Pc852 = new(nameof(Pc852), 18, 852); // Latin-2
+ public static readonly CharacterCodeTable Pc858 = new(nameof(Pc858), 19, 858); // Euro
+
+ /// The .NET code page id this table maps to.
+ public int CodePage { get; }
+
+ private CharacterCodeTable(string name, int value, int codePage) : base(name, value) => CodePage = codePage;
+
+ /// Resolves an ESC t table id, defaulting to PC437 for unknown ids (prior behaviour).
+ public static CharacterCodeTable FromTableId(int table) => TryFromValue(table, out var t) ? t : Pc437;
+}
diff --git a/Emulator/Enums/CutFunction.cs b/src/CrossEscPos.Core/Emulator/Enums/CutFunction.cs
similarity index 93%
rename from Emulator/Enums/CutFunction.cs
rename to src/CrossEscPos.Core/Emulator/Enums/CutFunction.cs
index 020e178..58b8fe8 100644
--- a/Emulator/Enums/CutFunction.cs
+++ b/src/CrossEscPos.Core/Emulator/Enums/CutFunction.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Emulator.Enums;
+namespace CrossEscPos.Emulator.Enums;
public enum CutFunction : byte
{
diff --git a/Emulator/Enums/CutShape.cs b/src/CrossEscPos.Core/Emulator/Enums/CutShape.cs
similarity index 54%
rename from Emulator/Enums/CutShape.cs
rename to src/CrossEscPos.Core/Emulator/Enums/CutShape.cs
index be92973..5b45e57 100644
--- a/Emulator/Enums/CutShape.cs
+++ b/src/CrossEscPos.Core/Emulator/Enums/CutShape.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Emulator.Enums;
+namespace CrossEscPos.Emulator.Enums;
public enum CutShape : byte
{
diff --git a/src/CrossEscPos.Core/Emulator/Enums/HriPosition.cs b/src/CrossEscPos.Core/Emulator/Enums/HriPosition.cs
new file mode 100644
index 0000000..7ba9e53
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Enums/HriPosition.cs
@@ -0,0 +1,12 @@
+namespace CrossEscPos.Emulator.Enums;
+
+///
+/// Human Readable Interpretation (HRI) text position for 1D barcodes (ESC/POS GS H).
+///
+public enum HriPosition
+{
+ None = 0,
+ Above = 1,
+ Below = 2,
+ Both = 3
+}
diff --git a/src/CrossEscPos.Core/Emulator/Enums/PrinterCondition.cs b/src/CrossEscPos.Core/Emulator/Enums/PrinterCondition.cs
new file mode 100644
index 0000000..2ba755c
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Enums/PrinterCondition.cs
@@ -0,0 +1,17 @@
+namespace CrossEscPos.Emulator.Enums;
+
+/// Roll-paper level reported by the paper sensors.
+public enum PaperLevel
+{
+ Adequate = 0,
+ NearEnd = 1,
+ Out = 2
+}
+
+/// Printer error condition reported by the error-status commands.
+public enum PrinterErrorState
+{
+ None = 0,
+ Recoverable = 1,
+ Unrecoverable = 2
+}
diff --git a/Emulator/Enums/PrinterFont.cs b/src/CrossEscPos.Core/Emulator/Enums/PrinterFont.cs
similarity index 74%
rename from Emulator/Enums/PrinterFont.cs
rename to src/CrossEscPos.Core/Emulator/Enums/PrinterFont.cs
index 74f05ef..1b3ff4b 100644
--- a/Emulator/Enums/PrinterFont.cs
+++ b/src/CrossEscPos.Core/Emulator/Enums/PrinterFont.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Emulator.Enums;
+namespace CrossEscPos.Emulator.Enums;
public enum PrinterFont : byte
{
diff --git a/Emulator/Enums/TextJustification.cs b/src/CrossEscPos.Core/Emulator/Enums/TextJustification.cs
similarity index 62%
rename from Emulator/Enums/TextJustification.cs
rename to src/CrossEscPos.Core/Emulator/Enums/TextJustification.cs
index 251a0d9..a9f7d9a 100644
--- a/Emulator/Enums/TextJustification.cs
+++ b/src/CrossEscPos.Core/Emulator/Enums/TextJustification.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Emulator.Enums;
+namespace CrossEscPos.Emulator.Enums;
public enum TextJustification : byte
{
diff --git a/Emulator/Enums/UnderlineMode.cs b/src/CrossEscPos.Core/Emulator/Enums/UnderlineMode.cs
similarity index 62%
rename from Emulator/Enums/UnderlineMode.cs
rename to src/CrossEscPos.Core/Emulator/Enums/UnderlineMode.cs
index a401201..4b2dc1b 100644
--- a/Emulator/Enums/UnderlineMode.cs
+++ b/src/CrossEscPos.Core/Emulator/Enums/UnderlineMode.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Emulator.Enums;
+namespace CrossEscPos.Emulator.Enums;
public enum UnderlineMode : byte
{
diff --git a/Emulator/PaperConfiguration.cs b/src/CrossEscPos.Core/Emulator/PaperConfiguration.cs
similarity index 77%
rename from Emulator/PaperConfiguration.cs
rename to src/CrossEscPos.Core/Emulator/PaperConfiguration.cs
index 903f91c..4a4e1e0 100644
--- a/Emulator/PaperConfiguration.cs
+++ b/src/CrossEscPos.Core/Emulator/PaperConfiguration.cs
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.Emulator;
+namespace CrossEscPos.Emulator;
public class PaperConfiguration
{
@@ -16,16 +16,17 @@ public class PaperConfiguration
public int DefaultLineSpacing = 10;
public int DefaultTabSpacing = 8;
+ // Render font is a preferred monospace family name. The typeface provider resolves it cross-platform,
+ // falling back through Consolas / Menlo / DejaVu Sans Mono / Courier New / monospace, or an
+ // embedded TTF when present. (Original WPF app used the Windows-only "MS Gothic".)
+ private const string RenderFontFamily = "Consolas";
+
public Dictionary _printerFonts = new()
{
- //{PrinterFont.FontA, new FontConfiguration(PrinterFont.FontA, 12, 24, "Consolas")},
- //{PrinterFont.FontB, new FontConfiguration(PrinterFont.FontB, 9, 24, "Consolas")},
- //{PrinterFont.FontC, new FontConfiguration(PrinterFont.FontC, 24, 48, "Consolas")},
- //{PrinterFont.FontD, new FontConfiguration(PrinterFont.FontD, 16, 24, "Consolas")}
- {PrinterFont.FontA, new FontConfiguration(PrinterFont.FontA, 12, 24, "MS Gothic")},
- {PrinterFont.FontB, new FontConfiguration(PrinterFont.FontB, 9, 24, "MS Gothic")},
- {PrinterFont.FontC, new FontConfiguration(PrinterFont.FontC, 24, 48, "MS Gothic")},
- {PrinterFont.FontD, new FontConfiguration(PrinterFont.FontD, 16, 24, "MS Gothic")}
+ {PrinterFont.FontA, new FontConfiguration(PrinterFont.FontA, 12, 24, RenderFontFamily)},
+ {PrinterFont.FontB, new FontConfiguration(PrinterFont.FontB, 9, 24, RenderFontFamily)},
+ {PrinterFont.FontC, new FontConfiguration(PrinterFont.FontC, 24, 48, RenderFontFamily)},
+ {PrinterFont.FontD, new FontConfiguration(PrinterFont.FontD, 16, 24, RenderFontFamily)}
};
public FontConfiguration GetFont(PrinterFont printerFont)
diff --git a/Emulator/PrintMode.cs b/src/CrossEscPos.Core/Emulator/PrintMode.cs
similarity index 93%
rename from Emulator/PrintMode.cs
rename to src/CrossEscPos.Core/Emulator/PrintMode.cs
index 3fe7116..3cf3cad 100644
--- a/Emulator/PrintMode.cs
+++ b/src/CrossEscPos.Core/Emulator/PrintMode.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator.Enums;
using System;
-namespace ReceiptPrinterEmulator.Emulator;
+namespace CrossEscPos.Emulator;
public class PrintMode
{
diff --git a/Emulator/Printables/ReceiptBitmapLine.cs b/src/CrossEscPos.Core/Emulator/Printables/ReceiptBitmapLine.cs
similarity index 62%
rename from Emulator/Printables/ReceiptBitmapLine.cs
rename to src/CrossEscPos.Core/Emulator/Printables/ReceiptBitmapLine.cs
index fd3b376..ae85b7b 100644
--- a/Emulator/Printables/ReceiptBitmapLine.cs
+++ b/src/CrossEscPos.Core/Emulator/Printables/ReceiptBitmapLine.cs
@@ -1,11 +1,11 @@
-using ReceiptPrinterEmulator.Emulator.Abstraction;
-using ReceiptPrinterEmulator.Logging;
using System;
-using System.Drawing;
+using CrossEscPos;
+using CrossEscPos.Graphics;
+using CrossEscPos.Logging;
-namespace ReceiptPrinterEmulator.Emulator.Printables;
+namespace CrossEscPos.Emulator.Printables;
-public class ReceiptBitmapLine(PaperConfiguration paperConfiguration, Bitmap image) : IReceiptPrintable
+public class ReceiptBitmapLine(PaperConfiguration paperConfiguration, IReceiptImage image) : IReceiptPrintable
{
public int GetPrintHeight()
{
@@ -16,7 +16,7 @@ public int GetPrintHeight()
return (int)Math.Ceiling(image.Height * (float)printWidth / image.Width);
}
- public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY)
+ public void Render(IReceiptCanvas canvas, int offsetX, int offsetY)
{
Logger.Info($"Rendering bitmap line at offset ({offsetX}, {offsetY}) with size ({image.Width}, {image.Height})");
@@ -25,11 +25,12 @@ public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY)
{
// Center the image horizontally if it fits within the print width
offsetX += (printWidth - image.Width) / 2;
- g.DrawImageUnscaled(image, offsetX, offsetY, image.Width, image.Height);
+ canvas.DrawImage(image, offsetX, offsetY);
}
else
{
- g.DrawImage(image, offsetX, offsetY, printWidth, image.Height * (float)printWidth / image.Width);
+ var scaledHeight = image.Height * (float)printWidth / image.Width;
+ canvas.DrawImage(image, ReceiptRect.Create(offsetX, offsetY, printWidth, scaledHeight));
}
}
}
diff --git a/Emulator/Printables/ReceiptEmptyLine.cs b/src/CrossEscPos.Core/Emulator/Printables/ReceiptEmptyLine.cs
similarity index 50%
rename from Emulator/Printables/ReceiptEmptyLine.cs
rename to src/CrossEscPos.Core/Emulator/Printables/ReceiptEmptyLine.cs
index a277a29..b98589c 100644
--- a/Emulator/Printables/ReceiptEmptyLine.cs
+++ b/src/CrossEscPos.Core/Emulator/Printables/ReceiptEmptyLine.cs
@@ -1,20 +1,20 @@
-using System.Drawing;
-using ReceiptPrinterEmulator.Emulator.Abstraction;
+using CrossEscPos;
+using CrossEscPos.Graphics;
-namespace ReceiptPrinterEmulator.Emulator.Printables;
+namespace CrossEscPos.Emulator.Printables;
public class ReceiptEmptyLine : IReceiptPrintable
{
private readonly int _height;
-
+
public ReceiptEmptyLine(int height)
{
_height = height;
}
public int GetPrintHeight() => _height;
-
- public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY)
+
+ public void Render(IReceiptCanvas canvas, int offsetX, int offsetY)
{
}
-}
\ No newline at end of file
+}
diff --git a/src/CrossEscPos.Core/Emulator/Printables/ReceiptTextLine.cs b/src/CrossEscPos.Core/Emulator/Printables/ReceiptTextLine.cs
new file mode 100644
index 0000000..edfbd9f
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Printables/ReceiptTextLine.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using CrossEscPos;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.Emulator.Printables;
+
+public class ReceiptTextLine : IReceiptPrintable
+{
+ // GDI+ Font sizes were specified in points and rendered onto a 96-DPI bitmap, so the original
+ // app effectively used an em size of (points * 96/72) pixels. We keep that ratio so receipt
+ // text keeps the same proportions now that font sizes are specified in pixels.
+ private const float PointsToPixels = 96f / 72f;
+
+ private readonly PaperConfiguration.FontConfiguration _font;
+ private readonly ITypefaceProvider _typefaces;
+ private readonly int _printWidth;
+
+ private int _totalWidth;
+ private readonly List<(string text, PrintMode mode)> _strings = new();
+
+ public bool IsEmpty => _strings.Count == 0;
+
+ public ReceiptTextLine(PaperConfiguration paperConfiguration, PrintMode printMode, ITypefaceProvider typefaces)
+ {
+ // Style (font/bold/italic/underline/justification) is read per-run from each char's PrintMode
+ // in Render, so only the font config and print width need to be captured here.
+ _font = paperConfiguration.GetFont(printMode.Font);
+ _typefaces = typefaces;
+ _printWidth = paperConfiguration.GetPrintWidthInPixels();
+ _totalWidth = 0;
+ }
+
+ public bool TryWriteChar(char c, PrintMode mode)
+ {
+ int charWidth = (_font.CharacterWidth * mode.CharWidthScale);
+ if ((_totalWidth + charWidth) >= _printWidth)
+ return false;
+
+ if (_strings.Count > 0 && mode.Equals(_strings[^1].mode))
+ {
+ // Append to last run
+ var (text, lastMode) = _strings[^1];
+ _strings[^1] = (text + c, lastMode);
+ }
+ else
+ {
+ // Start new run
+ _strings.Add((c.ToString(), mode.Clone()));
+ }
+ _totalWidth += charWidth;
+ return true;
+ }
+
+ public int GetPrintHeight()
+ {
+ // Use the tallest run's height for correct line spacing
+ int maxCharHeight = 0;
+ foreach (var (_, mode) in _strings)
+ {
+ int charHeight = (_font.CharacterHeight / 2) * mode.CharHeightScale;
+ if (charHeight > maxCharHeight)
+ maxCharHeight = charHeight;
+ }
+ // Add a small extra space for visual separation (like real printers)
+ return maxCharHeight + (_font.CharacterHeight / 4);
+ }
+
+ private IReceiptFont CreateFont(PrintMode mode)
+ {
+ float sizePx = (_font.CharacterHeight / 2f) * PointsToPixels;
+ return _typefaces.GetFont(_font.RenderFont, mode.Emphasize, mode.Italic, sizePx);
+ }
+
+ public void Render(IReceiptCanvas canvas, int offsetX, int offsetY)
+ {
+ if (_strings.Count == 0)
+ return;
+
+ // 1. Measure each run (unscaled) and the total justified width.
+ var runFonts = new List(_strings.Count);
+ var runWidths = new List(_strings.Count);
+ float totalWidth = 0;
+ try
+ {
+ foreach (var (text, mode) in _strings)
+ {
+ var font = CreateFont(mode);
+ runFonts.Add(font);
+ float measured = font.MeasureText(text);
+ float scaledWidth = measured * mode.CharWidthScale;
+ runWidths.Add(scaledWidth);
+ totalWidth += scaledWidth;
+ }
+
+ // 2. Justification (taken from the first run, an ESC/POS line property).
+ TextJustification justification = _strings[0].mode.Justification;
+ int x = offsetX;
+ if (justification == TextJustification.Center)
+ x += (int)((_printWidth - totalWidth) / 2);
+ else if (justification == TextJustification.Right)
+ x += (int)(_printWidth - totalWidth);
+
+ // 3. Find the tallest run for baseline alignment (device pixels).
+ float maxAscent = 0;
+ float maxCharHeight = 0;
+ for (int i = 0; i < _strings.Count; i++)
+ {
+ var mode = _strings[i].mode;
+ var metrics = runFonts[i].Metrics;
+ float ascent = -metrics.Ascent * mode.CharHeightScale;
+ float charHeight = runFonts[i].Size * mode.CharHeightScale;
+ if (ascent > maxAscent) maxAscent = ascent;
+ if (charHeight > maxCharHeight) maxCharHeight = charHeight;
+ }
+
+ // 4. Draw each run, baseline-aligned, applying width/height scale via the canvas.
+ for (int i = 0; i < _strings.Count; i++)
+ {
+ var (text, mode) = _strings[i];
+ var font = runFonts[i];
+ var metrics = font.Metrics;
+
+ float ascent = -metrics.Ascent; // unscaled, device px
+ float scaledAscent = ascent * mode.CharHeightScale;
+ float scaledCharHeight = font.Size * mode.CharHeightScale;
+ float baselineOffset = (maxAscent - scaledAscent) + (maxCharHeight - scaledCharHeight);
+
+ int count = canvas.Save();
+ // Translate (device px) then scale, mirroring the original GDI+ transform order.
+ canvas.Translate(x, offsetY + baselineOffset);
+ canvas.Scale(mode.CharWidthScale, mode.CharHeightScale);
+
+ // The baseline sits at y = ascent so the glyph top lands at 0.
+ canvas.DrawText(text, 0, ascent, font, ReceiptColor.Black);
+
+ // Underline drawn in the scaled context, just below the glyph box.
+ if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots)
+ {
+ float dotHeight = mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1;
+ float underlineY = ascent + metrics.Descent;
+ float runWidthUnscaled = font.MeasureText(text);
+ canvas.DrawLine(0, underlineY, runWidthUnscaled, underlineY, ReceiptColor.Black, dotHeight);
+ }
+
+ canvas.RestoreToCount(count);
+
+ x += (int)Math.Ceiling(runWidths[i]);
+ }
+ }
+ finally
+ {
+ foreach (var font in runFonts)
+ font.Dispose();
+ }
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/PrinterIdRequest.cs b/src/CrossEscPos.Core/Emulator/PrinterIdRequest.cs
new file mode 100644
index 0000000..f35580d
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/PrinterIdRequest.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using System.Text;
+using Ardalis.SmartEnum;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// GS I printer-information requests. Each request carries the bytes it replies with — the single-byte
+/// "Info A" IDs (model/type/ROM) and the framed "Info B" text responses (0x5F <text> 0x00 ) —
+/// so answering is a lookup instead of a switch on n . The parameter accepts numeric or ASCII form.
+///
+public sealed class PrinterIdRequest : SmartEnum
+{
+ // Info A — single-byte IDs.
+ public static readonly PrinterIdRequest ModelId = new(nameof(ModelId), 1, new byte[] { 0x02 });
+ public static readonly PrinterIdRequest TypeId = new(nameof(TypeId), 2, new byte[] { 0x00 });
+ public static readonly PrinterIdRequest RomVersion = new(nameof(RomVersion), 3, new byte[] { 0x01 });
+
+ // Info B — text responses framed as 0x5F 0x00.
+ public static readonly PrinterIdRequest FirmwareVersion = new(nameof(FirmwareVersion), 65, InfoB("1.0"));
+ public static readonly PrinterIdRequest MakerName = new(nameof(MakerName), 66, InfoB("CrossEscPos"));
+ public static readonly PrinterIdRequest ModelName = new(nameof(ModelName), 67, InfoB("EMU-80"));
+
+ // Fallback for unknown ids — a generic Info B name (value -1 never matches a real parameter).
+ public static readonly PrinterIdRequest Unknown = new(nameof(Unknown), -1, InfoB("EMU"));
+
+ public byte[] Response { get; }
+
+ private PrinterIdRequest(string name, int value, byte[] response) : base(name, value) => Response = response;
+
+ /// Resolves a GS I parameter (numeric or ASCII digit), defaulting to a generic Info B reply.
+ public static PrinterIdRequest FromParameter(int n)
+ => TryFromValue(n is >= '0' and <= '9' ? n - '0' : n, out var request) ? request : Unknown;
+
+ private static byte[] InfoB(string text)
+ {
+ var bytes = new List { 0x5F };
+ bytes.AddRange(Encoding.ASCII.GetBytes(text));
+ bytes.Add(0x00);
+ return bytes.ToArray();
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/PrinterState.cs b/src/CrossEscPos.Core/Emulator/PrinterState.cs
new file mode 100644
index 0000000..ce05d2a
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/PrinterState.cs
@@ -0,0 +1,30 @@
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// The simulated physical/logical state of the printer (online, cover, paper, cash-drawer sensor,
+/// errors, feed button). Status commands report from this, and the UI panel drives it. Implemented
+/// as an so the panel can two-way bind directly;
+/// is raised on any change to drive Automatic Status Back (ASB).
+///
+public partial class PrinterState : ObservableObject
+{
+ [ObservableProperty] private bool _online = true;
+ [ObservableProperty] private bool _coverOpen;
+ [ObservableProperty] private PaperLevel _paper = PaperLevel.Adequate;
+ [ObservableProperty] private bool _drawerOpen; // cash-drawer kick sensor: true = open
+ [ObservableProperty] private PrinterErrorState _error = PrinterErrorState.None;
+ [ObservableProperty] private bool _feedButtonPressed; // momentary
+
+ /// Raised after any state property changes (used to push ASB status to the host).
+ public event Action? Changed;
+
+ protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ base.OnPropertyChanged(e);
+ Changed?.Invoke();
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/RealtimeStatusRequest.cs b/src/CrossEscPos.Core/Emulator/RealtimeStatusRequest.cs
new file mode 100644
index 0000000..96bb750
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/RealtimeStatusRequest.cs
@@ -0,0 +1,26 @@
+using System;
+using Ardalis.SmartEnum;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// DLE EOT n real-time status requests. Each kind carries the status byte it builds, so dispatch is a
+/// lookup + polymorphic call instead of a switch on n .
+///
+public sealed class RealtimeStatusRequest : SmartEnum
+{
+ public static readonly RealtimeStatusRequest Printer = new(nameof(Printer), 1, StatusByteBuilder.PrinterStatus);
+ public static readonly RealtimeStatusRequest Offline = new(nameof(Offline), 2, StatusByteBuilder.OfflineStatus);
+ public static readonly RealtimeStatusRequest Error = new(nameof(Error), 3, StatusByteBuilder.ErrorStatus);
+ public static readonly RealtimeStatusRequest PaperSensor = new(nameof(PaperSensor), 4, StatusByteBuilder.PaperSensorStatus);
+
+ private readonly Func _build;
+
+ private RealtimeStatusRequest(string name, int value, Func build) : base(name, value)
+ => _build = build;
+
+ public byte Build(PrinterState state) => _build(state);
+
+ /// Resolves a DLE EOT parameter, defaulting to the printer status (prior behaviour).
+ public static RealtimeStatusRequest FromParameter(int n) => TryFromValue(n, out var request) ? request : Printer;
+}
diff --git a/Emulator/Receipt.cs b/src/CrossEscPos.Core/Emulator/Receipt.cs
similarity index 78%
rename from Emulator/Receipt.cs
rename to src/CrossEscPos.Core/Emulator/Receipt.cs
index 9464571..a7b4dfc 100644
--- a/Emulator/Receipt.cs
+++ b/src/CrossEscPos.Core/Emulator/Receipt.cs
@@ -1,18 +1,20 @@
-using System;
+using System;
using System.Collections.Generic;
-using System.Drawing;
using System.Linq;
-using ReceiptPrinterEmulator.Emulator.Abstraction;
-using ReceiptPrinterEmulator.Emulator.Printables;
+using CrossEscPos;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator.Printables;
-namespace ReceiptPrinterEmulator.Emulator;
+namespace CrossEscPos.Emulator;
public class Receipt
{
private readonly PaperConfiguration _paperConfiguration;
+ private readonly IReceiptImageFactory _imageFactory;
+ private readonly ITypefaceProvider _typefaces;
public readonly string Guid;
-
+
private int PaperWidth => _paperConfiguration.GetPaperWidthInPixels();
private int PrintWidth => _paperConfiguration.GetPrintWidthInPixels();
private int PaperMargins => (PaperWidth - PrintWidth) / 2;
@@ -25,11 +27,14 @@ public class Receipt
public bool IsEmpty => (_currentTextLine == null || _currentTextLine.IsEmpty) && _renderLines.Count == 0;
- public Receipt(PaperConfiguration paperConfiguration, PrintMode printMode, int lineSpacing)
+ public Receipt(PaperConfiguration paperConfiguration, PrintMode printMode, int lineSpacing,
+ IReceiptImageFactory imageFactory, ITypefaceProvider typefaces)
{
Guid = System.Guid.NewGuid().ToString();
-
+
_paperConfiguration = paperConfiguration;
+ _imageFactory = imageFactory;
+ _typefaces = typefaces;
_printMode = printMode;
_renderLines = new();
@@ -55,8 +60,8 @@ public void SetTabSpacing(int value)
_tabSpacing = value;
}
- private ReceiptTextLine CreateNewTextLine() => new(_paperConfiguration, _printMode);
-
+ private ReceiptTextLine CreateNewTextLine() => new(_paperConfiguration, _printMode, _typefaces);
+
public void PrintText(string text,PrintMode printMode)
{
if (_currentTextLine is null)
@@ -96,7 +101,7 @@ public void FinalizeTextLine(bool insertLineSpacing)
public void AdvanceToNewLine() => FinalizeTextLine(true);
- public void PrintBitmap(Bitmap image)
+ public void PrintBitmap(IReceiptImage image)
{
FinalizeTextLine(false);
@@ -109,27 +114,25 @@ public int GetTotalPrintHeight()
public int GetTotalPaperHeight() =>
GetTotalPrintHeight() + (PaperMargins * 2);
- public Bitmap Render(bool drawPartials = true)
+ public IReceiptImage Render(bool drawPartials = true)
{
var paperWidth = PaperWidth;
- var paperHeight = GetTotalPaperHeight();
-
- var bmp = new Bitmap(paperWidth, paperHeight);
- using var g = Graphics.FromImage(bmp);
-
- // Fill white background
- g.FillRectangle(Brushes.White, 0, 0, paperWidth, paperHeight);
-
+ var paperHeight = Math.Max(1, GetTotalPaperHeight());
+
+ var image = _imageFactory.Create(paperWidth, paperHeight, ReceiptColor.White);
+ using var canvas = _imageFactory.CreateCanvas(image);
+
// Draw all rendered lines
var offsetX = PaperMargins;
var offsetY = PaperMargins;
foreach (var line in _renderLines)
{
- line.Render(bmp, g, offsetX, offsetY);
+ line.Render(canvas, offsetX, offsetY);
offsetY += line.GetPrintHeight();
}
- return bmp;
+ canvas.Flush();
+ return image;
}
-}
\ No newline at end of file
+}
diff --git a/src/CrossEscPos.Core/Emulator/ReceiptPrinter.cs b/src/CrossEscPos.Core/Emulator/ReceiptPrinter.cs
new file mode 100644
index 0000000..e9afeef
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/ReceiptPrinter.cs
@@ -0,0 +1,701 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using CrossEscPos;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator.Enums;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.EscPos;
+using CrossEscPos.Logging;
+using QRCoder;
+using ZXing;
+
+namespace CrossEscPos.Emulator;
+
+public class ReceiptPrinter
+{
+ private readonly PaperConfiguration _paperConfiguration;
+ private readonly EscPosInterpreter _escPosInterpreter;
+ private readonly ITypefaceProvider _typefaces;
+ private readonly BarcodeRenderer _barcodeRenderer;
+
+ /// The image backend used to rasterize receipts, barcodes and bit images.
+ public IReceiptImageFactory ImageFactory { get; }
+
+ private PrintMode _printMode;
+ private int _lineSpacing;
+ private int _tabSpacing;
+
+ // Barcode (1D) state — ESC/POS GS h / GS w / GS H / GS f
+ private int _barcodeHeight = 162;
+ private int _barcodeModuleWidth = 3;
+ private HriPosition _hriPosition = HriPosition.None;
+ private PrinterFont _hriFont = PrinterFont.FontA;
+
+ // 2D symbol state — ESC/POS GS ( k
+ private int _qrModuleSize = 3;
+ private QRCodeGenerator.ECCLevel _qrEcc = QRCodeGenerator.ECCLevel.M;
+ private string _qrData = string.Empty;
+ private TwoDimensionCode _2dSymbol = TwoDimensionCode.QrCode;
+
+ public Receipt CurrentReceipt { get; private set; } = null!; // set via StartNewReceipt in ctor
+ public List ReceiptStack { get; private set; }
+
+ /// Simulated physical/logical printer state (drives status commands; driven by the UI).
+ public PrinterState State { get; } = new();
+
+ ///
+ /// Marshals state mutations that originate off the UI thread (e.g. a drawer kick arriving over
+ /// TCP) onto the UI thread, so bound controls update safely. The App wires this to the Avalonia
+ /// dispatcher; the default runs synchronously (fine for tests/headless).
+ ///
+ public Action UiDispatch { get; set; } = static a => a();
+
+ // Host response channel (status / transmit-back commands).
+ private readonly List _responders = new();
+ private IPrinterResponder? _currentResponder;
+ private int _asbMask; // Automatic Status Back: 0 = disabled
+
+ public event EventHandler? OnActivityEvent;
+ public event Action? OnBuzzer;
+ public event Action? OnCashDrawer;
+
+ /// Raised (with a human-readable reason) when a print operation is dropped because the
+ /// printer isn't ready (out of paper, cover open, offline, error).
+ public event Action? OnPrintBlocked;
+
+ private bool _blockedNotified;
+
+ /// Whether the printer can currently put marks on paper, like a real device.
+ public bool CanPrint => State.Online
+ && !State.CoverOpen
+ && State.Paper != PaperLevel.Out
+ && State.Error == PrinterErrorState.None;
+
+ private string NotReadyReason() =>
+ !State.Online ? "Printer offline"
+ : State.CoverOpen ? "Cover is open"
+ : State.Paper == PaperLevel.Out ? "Out of paper"
+ : State.Error != PrinterErrorState.None ? $"Printer error ({State.Error})"
+ : string.Empty;
+
+ /// Returns true (and notifies once) when printing must be dropped due to printer state.
+ private bool Blocked()
+ {
+ if (CanPrint)
+ return false;
+
+ if (!_blockedNotified)
+ {
+ _blockedNotified = true;
+ var reason = NotReadyReason();
+ Logger.Info($"Print blocked: {reason}");
+ OnPrintBlocked?.Invoke(reason);
+ }
+ return true;
+ }
+
+ // Active character code table (ESC t). Default PC437; high bytes are remapped to Unicode.
+ private Encoding _codePage = Encoding.Latin1;
+
+ static ReceiptPrinter()
+ {
+ // Enable legacy code pages (437/850/852/858/866/1252…) on every platform.
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
+
+ public ReceiptPrinter(PaperConfiguration paperConfiguration, IReceiptImageFactory imageFactory,
+ ITypefaceProvider typefaces)
+ {
+ _paperConfiguration = paperConfiguration;
+ ImageFactory = imageFactory;
+ _typefaces = typefaces;
+ _barcodeRenderer = new BarcodeRenderer(imageFactory, typefaces);
+ _escPosInterpreter = new(this);
+
+ _printMode = new PrintMode();
+
+ ReceiptStack = new();
+
+ StartNewReceipt();
+
+ PowerCycle();
+
+ // Push Automatic Status Back to the host whenever the simulated state changes, and re-arm
+ // the "print blocked" notification once the printer becomes ready again.
+ State.Changed += () =>
+ {
+ if (CanPrint) _blockedNotified = false;
+ if (_asbMask != 0) BroadcastStatus(StatusByteBuilder.AutoStatusBack(State));
+ };
+ }
+
+ #region Host responses
+
+ public void RegisterResponder(IPrinterResponder responder)
+ {
+ lock (_responders) _responders.Add(responder);
+ }
+
+ public void UnregisterResponder(IPrinterResponder responder)
+ {
+ lock (_responders) _responders.Remove(responder);
+ }
+
+ /// Sends bytes back to the host that issued the request currently being processed.
+ public void SendResponse(byte[] data) => _currentResponder?.Send(data);
+
+ public void SendResponse(byte value) => SendResponse(new[] { value });
+
+ /// Sends bytes to every connected host (used by Automatic Status Back).
+ public void BroadcastStatus(byte[] data)
+ {
+ IPrinterResponder[] targets;
+ lock (_responders) targets = _responders.ToArray();
+ foreach (var r in targets)
+ {
+ try { r.Send(data); } catch (Exception ex) { Logger.Exception(ex, "Status broadcast failed"); }
+ }
+ }
+
+ /// Enables/disables Automatic Status Back (GS a). Sends the current status immediately when enabled.
+ public void SetAutoStatusBack(int mask)
+ {
+ _asbMask = mask;
+ Logger.Info($"Automatic Status Back: {(mask != 0 ? $"enabled (0x{mask:X2})" : "disabled")}");
+ if (mask != 0)
+ BroadcastStatus(StatusByteBuilder.AutoStatusBack(State));
+ }
+
+ #endregion
+
+ #region ESC/POS
+
+ ///
+ /// When set, every received ESC/POS payload is dumped to disk (last_escpos_receive.txt, and
+ /// last_ticket.bin for large payloads) for debugging. Enable via the ESCPOS_DEBUG_DUMP
+ /// environment variable. Off by default to avoid writing files on every receive.
+ ///
+ public static bool DebugDumpEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ESCPOS_DEBUG_DUMP") is "1" or "true";
+
+ ///
+ /// Feeds a raw ESC/POS byte stream — the natural form, since ESC/POS is binary. Each byte maps 1:1
+ /// to the interpreter's chars (Latin1), so this is equivalent to the string overload.
+ ///
+ public void FeedEscPos(byte[] data, IPrinterResponder? responder = null)
+ => FeedEscPos(Encoding.Latin1.GetString(data), responder);
+
+ ///
+ public void FeedEscPos(ReadOnlySpan data, IPrinterResponder? responder = null)
+ => FeedEscPos(Encoding.Latin1.GetString(data), responder);
+
+ ///
+ /// Feeds ESC/POS where each char is one byte (Latin1). Prefer the byte[] overload for raw
+ /// device input; this overload is convenient when the payload is already a string.
+ ///
+ public void FeedEscPos(string ascii, IPrinterResponder? responder = null)
+ {
+ if (DebugDumpEnabled)
+ {
+ if (ascii.Length > 10000)
+ File.WriteAllText("last_ticket.bin", ascii, Encoding.ASCII);
+ File.WriteAllText("last_escpos_receive.txt", ascii, Encoding.ASCII);
+ }
+
+ _currentResponder = responder;
+ _blockedNotified = false; // re-arm per received job so every blocked job notifies once
+ try
+ {
+ Logger.Info($"Received: {ascii}");
+ _escPosInterpreter.Interpret(ascii);
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, "ESC/POS Interpreter Error");
+ }
+ finally
+ {
+ _currentResponder = null;
+ }
+
+ OnActivityEvent?.Invoke(this, EventArgs.Empty);
+ }
+
+ #endregion
+
+ #region Receipt meta
+
+ public void StartNewReceipt()
+ {
+ CurrentReceipt = new(_paperConfiguration, _printMode, _lineSpacing, ImageFactory, _typefaces);
+ ReceiptStack.Add(CurrentReceipt);
+
+ Logger.Info($"Starting new receipt (#{ReceiptStack.Count})");
+ }
+
+ #endregion
+
+ #region Emulated
+
+ public void PowerCycle()
+ {
+ Initialize();
+ }
+
+ #endregion
+
+ #region Direct API
+
+ public void Initialize()
+ {
+ _escPosInterpreter.ClearBuffers();
+
+ SelectFont(PrinterFont.FontA);
+ SelectJustification(TextJustification.Left);
+ SelectCharacterSize(1, 1);
+ SelectEmphasizeMode(false);
+ SelectItalicMode(false);
+ SelectUnderlineMode(UnderlineMode.Off);
+ SetDefaultLineSpacing();
+ SetDefaultTabSpacing();
+ }
+
+ public void PrintText(string text)
+ {
+ if (Blocked()) return;
+
+ text = RemapCodePage(text);
+ Logger.Info($"Print: {text}");
+
+ CurrentReceipt.PrintText(text, _printMode);
+ }
+
+ /// Selects the character code table (ESC t n), mapping the ESC/POS table to a code page.
+ public void SetCodePage(int table)
+ {
+ var codeTable = CharacterCodeTable.FromTableId(table);
+ try
+ {
+ _codePage = Encoding.GetEncoding(codeTable.CodePage);
+ Logger.Info($"Select character table {table} -> {codeTable.Name} (code page {codeTable.CodePage})");
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Code page {codeTable.CodePage} unavailable; keeping current");
+ }
+ }
+
+ /// Remaps high bytes (>=0x80) of received text through the active code page to Unicode.
+ private string RemapCodePage(string text)
+ {
+ if (ReferenceEquals(_codePage, Encoding.Latin1))
+ return text;
+
+ var sb = new StringBuilder(text.Length);
+ foreach (var ch in text)
+ {
+ if (ch < 0x80)
+ sb.Append(ch);
+ else
+ {
+ var mapped = _codePage.GetString(new[] { (byte)ch });
+ sb.Append(mapped.Length > 0 ? mapped : "?");
+ }
+ }
+ return sb.ToString();
+ }
+
+ public void Cut(CutFunction cutFunction = CutFunction.Cut, CutShape cutShape = CutShape.Full, int n = 0)
+ {
+ Logger.Info($"Execute cut: {cutFunction}, {cutShape}, {n}");
+
+ LineFeed();
+
+ // TODO Support alternate cut modes
+
+ StartNewReceipt();
+ }
+
+ ///
+ /// Feeds one line, based on the current line spacing.
+ ///
+ ///
+ /// - The amount of paper fed per line is based on the value set using the line spacing command (ESC 2 or ESC 3).
+ ///
+ public void LineFeed()
+ {
+ if (Blocked()) return; // don't advance paper (adds blank lines) while not ready
+ Logger.Info($"Line feed");
+ CurrentReceipt.AdvanceToNewLine();
+ }
+
+ public void SelectFont(PrinterFont printerFont)
+ {
+ Logger.Info($"Select font: {printerFont}");
+
+ _printMode.Font = printerFont;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SelectJustification(TextJustification justification)
+ {
+ Logger.Info($"Select justification: {justification}");
+
+ _printMode.Justification = justification;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SelectCharacterSize(int width, int height)
+ {
+ Logger.Info($"Set character size scale: x{width} width, x{height} height");
+
+ _printMode.CharWidthScale = width;
+ _printMode.CharHeightScale = height;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SelectEmphasizeMode(bool enable)
+ {
+ Logger.Info($"Set emphasize mode: {enable}");
+
+ _printMode.Emphasize = enable;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SelectItalicMode(bool enable)
+ {
+ Logger.Info($"Set italic mode: {enable}");
+
+ _printMode.Italic = enable;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SelectUnderlineMode(UnderlineMode mode)
+ {
+ Logger.Info($"Set underline mode: {mode}");
+
+ _printMode.Underline = mode;
+ CurrentReceipt.ChangeFontConfiguration(_printMode);
+ }
+
+ public void SetLineSpacing(int value)
+ {
+ Logger.Info($"Set line spacing: {value}");
+
+ _lineSpacing = value;
+ CurrentReceipt.SetLineSpacing(_lineSpacing);
+ }
+
+ public void SetTabSpacing(int value)
+ {
+ Logger.Info($"Set tab spacing: {value}");
+
+ _tabSpacing = value;
+ CurrentReceipt.SetTabSpacing(_tabSpacing);
+ }
+
+ public void SetDefaultLineSpacing() => SetLineSpacing(_paperConfiguration.DefaultLineSpacing);
+ public void SetDefaultTabSpacing() => SetTabSpacing(_paperConfiguration.DefaultTabSpacing);
+
+ public void PrintBitmap(IReceiptImage bitmap)
+ {
+ if (Blocked()) return;
+
+ Logger.Info($"Print bitmap: {bitmap.Width}x{bitmap.Height}");
+
+ CurrentReceipt.PrintBitmap(bitmap);
+ }
+
+ // User-defined characters (ESC & / % / ?). Captured/stored; inline glyph substitution during
+ // text rendering is not applied (the font glyph is drawn). These rarely appear in modern streams.
+ private readonly Dictionary _userGlyphs = new();
+ private bool _userDefinedEnabled;
+
+ public void DefineUserGlyph(int code, IReceiptImage bmp)
+ {
+ Logger.Info($"Define user glyph 0x{code:X2} ({bmp.Width}x{bmp.Height})");
+ _userGlyphs[code] = bmp;
+ }
+
+ public void EnableUserDefined(bool on)
+ {
+ _userDefinedEnabled = on;
+ Logger.Info($"User-defined characters: {(on ? "enabled" : "disabled")}");
+ }
+
+ public void CancelUserGlyph(int code)
+ {
+ Logger.Info($"Cancel user glyph 0x{code:X2}");
+ _userGlyphs.Remove(code);
+ }
+
+ private IReceiptImage? _downloadBitImage;
+
+ /// Stores a downloaded bit image (GS * x y ...) for later printing by GS /.
+ public void DefineDownloadBitImage(IReceiptImage bmp)
+ {
+ Logger.Info($"Define download bit image {bmp.Width}x{bmp.Height}");
+ _downloadBitImage?.Dispose();
+ _downloadBitImage = bmp;
+ }
+
+ /// Prints the stored downloaded bit image (GS / m) with the given scaling mode.
+ public void PrintDownloadBitImage(int mode)
+ {
+ if (_downloadBitImage is null)
+ return;
+ if (Blocked()) return;
+
+ int sx = mode is 1 or 3 ? 2 : 1; // double-width on modes 1,3
+ int sy = mode is 2 or 3 ? 2 : 1; // double-height on modes 2,3
+
+ if (sx == 1 && sy == 1)
+ {
+ CurrentReceipt.PrintBitmap(_downloadBitImage.Copy());
+ return;
+ }
+
+ var scaled = ImageFactory.Create(_downloadBitImage.Width * sx, _downloadBitImage.Height * sy, ReceiptColor.White);
+ using (var canvas = ImageFactory.CreateCanvas(scaled))
+ {
+ canvas.DrawImage(_downloadBitImage, ReceiptRect.Create(0, 0, scaled.Width, scaled.Height));
+ canvas.Flush();
+ }
+ CurrentReceipt.PrintBitmap(scaled);
+ }
+
+ public void Buzz()
+ {
+ Logger.Info("Buzzer");
+ OnBuzzer?.Invoke();
+ }
+
+ public void KickCashDrawer(int pin)
+ {
+ Logger.Info($"Cash drawer kick (pin {pin})");
+ // The kick opens the drawer; the sensor reads open until closed. Marshal to the UI thread.
+ UiDispatch(() => State.DrawerOpen = true);
+ OnCashDrawer?.Invoke();
+ }
+
+ /// Transmits a real-time status byte (DLE EOT n) back to the requesting host.
+ public void TransmitRealtimeStatus(int n)
+ {
+ var request = RealtimeStatusRequest.FromParameter(n);
+ byte b = request.Build(State);
+ Logger.Info($"DLE EOT {n} ({request.Name}) -> 0x{b:X2}");
+ SendResponse(b);
+ }
+
+ /// Transmits status (GS r n) back to the requesting host.
+ public void TransmitStatus(int n)
+ {
+ var kind = TransmitStatusKind.FromParameter(n);
+ byte b = kind?.Build(State) ?? 0;
+ Logger.Info($"GS r {n} ({kind?.Name ?? "unsupported"}) -> 0x{b:X2}");
+ SendResponse(b);
+ }
+
+ /// Transmits a printer info / ID response (GS I n).
+ public void TransmitPrinterId(int n)
+ {
+ var request = PrinterIdRequest.FromParameter(n);
+ SendResponse(request.Response);
+ Logger.Info($"GS I {n} ({request.Name})");
+ }
+
+ /// Real-time request to recover (DLE ENQ) — clears recoverable error.
+ public void RealtimeRecover()
+ {
+ Logger.Info("DLE ENQ: real-time recover");
+ UiDispatch(() =>
+ {
+ if (State.Error == PrinterErrorState.Recoverable)
+ State.Error = PrinterErrorState.None;
+ });
+ }
+
+ #endregion
+
+ #region Page mode
+
+ private bool _pageMode;
+ private Receipt? _standardReceipt; // the standard-mode receipt, parked while in page mode
+
+ public bool IsPageMode => _pageMode;
+
+ ///
+ /// ESC L — enter page mode. Output is buffered into an off-stack receipt and flushed as one image
+ /// by FF (PrintPage). This is an approximation: content is buffered then rasterized, rather than
+ /// positioned with the full page-mode coordinate system.
+ ///
+ public void EnterPageMode()
+ {
+ if (_pageMode) return;
+ Logger.Info("Enter page mode");
+ _standardReceipt = CurrentReceipt;
+ // not added to the stack
+ CurrentReceipt = new Receipt(_paperConfiguration, _printMode, _lineSpacing, ImageFactory, _typefaces);
+ _pageMode = true;
+ }
+
+ /// ESC S — return to standard mode, discarding any buffered page data.
+ public void SelectStandardMode()
+ {
+ if (!_pageMode) return;
+ Logger.Info("Select standard mode");
+ RestoreStandard();
+ }
+
+ /// FF — in page mode, rasterize the page buffer onto the receipt and return to standard mode.
+ public void PrintPage()
+ {
+ if (!_pageMode) return;
+ Logger.Info("Print page (FF)");
+ var page = CurrentReceipt;
+ RestoreStandard();
+ if (!page.IsEmpty)
+ {
+ using var bmp = page.Render();
+ CurrentReceipt.PrintBitmap(bmp.Copy());
+ }
+ }
+
+ /// CAN — cancel the buffered page data and return to standard mode.
+ public void CancelPageData()
+ {
+ if (!_pageMode) { Logger.Info("CAN (not in page mode)"); return; }
+ Logger.Info("Cancel page data");
+ RestoreStandard();
+ }
+
+ private void RestoreStandard()
+ {
+ CurrentReceipt = _standardReceipt!;
+ _standardReceipt = null;
+ _pageMode = false;
+ }
+
+ #endregion
+
+ #region Barcodes (1D)
+
+ public void SetBarcodeHeight(int dots)
+ {
+ Logger.Info($"Set barcode height: {dots}");
+ _barcodeHeight = Math.Max(1, dots);
+ }
+
+ public void SetBarcodeModuleWidth(int dots)
+ {
+ Logger.Info($"Set barcode module width: {dots}");
+ _barcodeModuleWidth = dots;
+ }
+
+ public void SetHriPosition(HriPosition position)
+ {
+ Logger.Info($"Set HRI position: {position}");
+ _hriPosition = position;
+ }
+
+ public void SetHriFont(PrinterFont font)
+ {
+ Logger.Info($"Set HRI font: {font}");
+ _hriFont = font;
+ }
+
+ public void PrintBarcode(BarcodeFormat format, string data)
+ {
+ if (Blocked()) return;
+ Logger.Info($"Print barcode [{format}]: {data}");
+
+ var hriFontConfig = _paperConfiguration.GetFont(_hriFont);
+ int hriTextSize = (int)(hriFontConfig.CharacterHeight / 2f * 1.3333f);
+ bool showHri = _hriPosition is HriPosition.Below or HriPosition.Both or HriPosition.Above;
+
+ try
+ {
+ var bmp = _barcodeRenderer.RenderBarcode1D(
+ data, format, _barcodeModuleWidth, _barcodeHeight,
+ showHri, hriFontConfig.RenderFont, hriTextSize);
+
+ CurrentReceipt.PrintBitmap(bmp);
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to render barcode [{format}] for data '{data}'");
+ PrintText($"[barcode error: {format}]");
+ LineFeed();
+ }
+ }
+
+ #endregion
+
+ #region 2D symbols (QR / PDF417 / DataMatrix / Aztec)
+
+ public void SetQrModuleSize(int dots)
+ {
+ Logger.Info($"Set 2D module size: {dots}");
+ _qrModuleSize = dots;
+ }
+
+ public void SetQrErrorCorrection(QRCodeGenerator.ECCLevel ecc)
+ {
+ Logger.Info($"Set QR error correction: {ecc}");
+ _qrEcc = ecc;
+ }
+
+ /// Stores data for a 2D symbol of family (GS ( k fn 80).
+ public void Store2DData(int cn, string data)
+ {
+ _2dSymbol = TwoDimensionCode.FromCn(cn);
+ Logger.Info($"Store 2D data ({_2dSymbol.Name}, {data.Length} bytes)");
+ _qrData = data;
+ }
+
+ /// Renders and prints the stored 2D symbol (GS ( k fn 81).
+ public void Print2D()
+ {
+ if (string.IsNullOrEmpty(_qrData))
+ return;
+ if (Blocked()) return;
+
+ Logger.Info($"Print 2D symbol ({_2dSymbol.Name}): {_qrData}");
+ try
+ {
+ var bmp = _2dSymbol.Render(_barcodeRenderer, _qrData, _qrModuleSize, _qrEcc);
+ CurrentReceipt.PrintBitmap(bmp);
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to render 2D symbol ({_2dSymbol.Name}) for '{_qrData}'");
+ PrintText("[2D symbol error]");
+ LineFeed();
+ }
+ }
+
+ #endregion
+
+ #region Command API
+
+ ///
+ /// Prints the data in the print buffer and feeds one line, based on the current line spacing.
+ ///
+ public void PrintAndLineFeed(string printBuffer)
+ {
+ PrintText(printBuffer);
+ LineFeed();
+ }
+
+ public void PrintTab()
+ {
+ string tabs = "";
+
+ for (var i = 0; i < _tabSpacing; i++) tabs += " ";
+ PrintText(tabs);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/CrossEscPos.Core/Emulator/Rendering/BarcodeRenderer.cs b/src/CrossEscPos.Core/Emulator/Rendering/BarcodeRenderer.cs
new file mode 100644
index 0000000..24e1d07
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Rendering/BarcodeRenderer.cs
@@ -0,0 +1,149 @@
+using System;
+using CrossEscPos.Graphics;
+using QRCoder;
+using ZXing;
+using ZXing.Common;
+
+namespace CrossEscPos.Emulator.Rendering;
+
+///
+/// Generates 1D barcodes (via ZXing.Net) and 2D QR codes (via QRCoder) as s
+/// so they can flow through the same receipt-rendering path as raster images. Only managed matrix APIs
+/// are used; the modules are drawn through the backend-agnostic , so there
+/// is no graphics-backend dependency here.
+///
+public class BarcodeRenderer
+{
+ private readonly IReceiptImageFactory _imageFactory;
+ private readonly ITypefaceProvider _typefaces;
+
+ public BarcodeRenderer(IReceiptImageFactory imageFactory, ITypefaceProvider typefaces)
+ {
+ _imageFactory = imageFactory;
+ _typefaces = typefaces;
+ }
+
+ ///
+ /// Renders a 1D barcode. The symbol is encoded at native (1 dot/module) resolution and then
+ /// scaled horizontally by dots for precise bar widths, matching
+ /// the ESC/POS GS w / GS h semantics. Optionally appends Human Readable Interpretation text.
+ ///
+ public IReceiptImage RenderBarcode1D(
+ string content,
+ BarcodeFormat format,
+ int moduleWidth,
+ int heightDots,
+ bool showHri,
+ string hriFontFamily,
+ int hriTextSizeDots)
+ {
+ moduleWidth = Math.Clamp(moduleWidth, 1, 6);
+ heightDots = Math.Max(1, heightDots);
+
+ var writer = new BarcodeWriterGeneric
+ {
+ Format = format,
+ Options = new EncodingOptions
+ {
+ Width = 1, // force native module resolution; we scale manually
+ Height = 1,
+ Margin = 0,
+ PureBarcode = true
+ }
+ };
+
+ BitMatrix matrix = writer.Encode(content);
+ int modules = matrix.Width;
+
+ int barWidth = modules * moduleWidth;
+ int hriHeight = showHri ? hriTextSizeDots + (hriTextSizeDots / 2) : 0;
+ int totalHeight = heightDots + hriHeight;
+
+ var image = _imageFactory.Create(barWidth, totalHeight, ReceiptColor.White);
+ using var canvas = _imageFactory.CreateCanvas(image);
+
+ for (int x = 0; x < modules; x++)
+ {
+ if (matrix[x, 0])
+ canvas.DrawRect(ReceiptRect.Create(x * moduleWidth, 0, moduleWidth, heightDots), ReceiptColor.Black);
+ }
+
+ if (showHri)
+ DrawHriText(canvas, content, hriFontFamily, hriTextSizeDots, barWidth, heightDots);
+
+ canvas.Flush();
+ return image;
+ }
+
+ ///
+ /// Renders a QR code. Each QR module is drawn as a square,
+ /// matching ESC/POS GS ( k function 67. The QRCoder matrix already includes the 4-module quiet zone.
+ ///
+ public IReceiptImage RenderQr(string content, int moduleSizeDots, QRCodeGenerator.ECCLevel ecc)
+ {
+ moduleSizeDots = Math.Clamp(moduleSizeDots, 1, 16);
+
+ using var generator = new QRCodeGenerator();
+ using var data = generator.CreateQrCode(content, ecc);
+ var matrix = data.ModuleMatrix;
+ int modules = matrix.Count;
+
+ int size = modules * moduleSizeDots;
+ var image = _imageFactory.Create(size, size, ReceiptColor.White);
+ using var canvas = _imageFactory.CreateCanvas(image);
+
+ for (int row = 0; row < modules; row++)
+ {
+ var bits = matrix[row];
+ for (int col = 0; col < modules; col++)
+ {
+ if (bits[col])
+ canvas.DrawRect(ReceiptRect.Create(col * moduleSizeDots, row * moduleSizeDots,
+ moduleSizeDots, moduleSizeDots), ReceiptColor.Black);
+ }
+ }
+
+ canvas.Flush();
+ return image;
+ }
+
+ ///
+ /// Renders a 2D matrix symbology (PDF417, DataMatrix, Aztec, …) via ZXing. The symbol is encoded
+ /// at native resolution and each module drawn as a square.
+ ///
+ public IReceiptImage Render2D(string content, BarcodeFormat format, int moduleSize)
+ {
+ moduleSize = Math.Clamp(moduleSize, 1, 16);
+
+ var writer = new BarcodeWriterGeneric
+ {
+ Format = format,
+ Options = new EncodingOptions { Width = 0, Height = 0, Margin = 0, PureBarcode = true }
+ };
+
+ BitMatrix matrix = writer.Encode(content);
+ int cols = matrix.Width, rows = matrix.Height;
+
+ var image = _imageFactory.Create(cols * moduleSize, rows * moduleSize, ReceiptColor.White);
+ using var canvas = _imageFactory.CreateCanvas(image);
+
+ for (int y = 0; y < rows; y++)
+ for (int x = 0; x < cols; x++)
+ if (matrix[x, y])
+ canvas.DrawRect(ReceiptRect.Create(x * moduleSize, y * moduleSize, moduleSize, moduleSize), ReceiptColor.Black);
+
+ canvas.Flush();
+ return image;
+ }
+
+ private void DrawHriText(IReceiptCanvas canvas, string text, string fontFamily, int sizeDots,
+ int barWidth, int yTop)
+ {
+ using var font = _typefaces.GetFont(fontFamily, bold: false, italic: false, sizeDots);
+
+ float textWidth = font.MeasureText(text);
+ float x = (barWidth - textWidth) / 2f;
+ float baseline = yTop + (-font.Metrics.Ascent) + (sizeDots / 4f);
+ canvas.DrawText(text, x, baseline, font, ReceiptColor.Black);
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/Rendering/ReceiptExporter.cs b/src/CrossEscPos.Core/Emulator/Rendering/ReceiptExporter.cs
new file mode 100644
index 0000000..9c59a14
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Rendering/ReceiptExporter.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos.Emulator.Rendering;
+
+public static class ReceiptExporter
+{
+ ///
+ /// Composes several receipt images into a single tall image, stacked top-to-bottom on a white
+ /// background with a gap between each. Used by "export all". The caller owns the source images; the
+ /// returned image is new.
+ ///
+ public static IReceiptImage StackVertical(IReadOnlyList receipts,
+ IReceiptImageFactory imageFactory, int gap = 24)
+ {
+ if (receipts.Count == 0)
+ return imageFactory.Create(1, 1, ReceiptColor.White);
+
+ int width = 0;
+ int height = 0;
+ foreach (var r in receipts)
+ {
+ width = Math.Max(width, r.Width);
+ height += r.Height;
+ }
+ height += gap * (receipts.Count - 1);
+
+ var combined = imageFactory.Create(width, Math.Max(1, height), ReceiptColor.White);
+ using var canvas = imageFactory.CreateCanvas(combined);
+
+ int y = 0;
+ foreach (var r in receipts)
+ {
+ // Center each receipt horizontally within the widest one.
+ int x = (width - r.Width) / 2;
+ canvas.DrawImage(r, x, y);
+ y += r.Height + gap;
+ }
+
+ canvas.Flush();
+ return combined;
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/Rendering/TwoDimensionCode.cs b/src/CrossEscPos.Core/Emulator/Rendering/TwoDimensionCode.cs
new file mode 100644
index 0000000..3e0767a
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/Rendering/TwoDimensionCode.cs
@@ -0,0 +1,47 @@
+using Ardalis.SmartEnum;
+using CrossEscPos.Graphics;
+using QRCoder;
+using ZXing;
+
+namespace CrossEscPos.Emulator.Rendering;
+
+///
+/// ESC/POS 2D symbol families (the cn parameter of GS ( k). Each family knows how to render
+/// itself, so printing a stored 2D symbol is a polymorphic call rather than a switch on cn .
+///
+public abstract class TwoDimensionCode : SmartEnum
+{
+ public static readonly TwoDimensionCode Pdf417 = new ZXingSymbol(48, nameof(Pdf417), BarcodeFormat.PDF_417);
+ public static readonly TwoDimensionCode QrCode = new QrSymbol(49, nameof(QrCode));
+ public static readonly TwoDimensionCode DataMatrix = new ZXingSymbol(54, nameof(DataMatrix), BarcodeFormat.DATA_MATRIX);
+ public static readonly TwoDimensionCode Aztec = new ZXingSymbol(55, nameof(Aztec), BarcodeFormat.AZTEC);
+
+ private TwoDimensionCode(string name, int value) : base(name, value) { }
+
+ /// Renders the stored data as this 2D family to an image.
+ public abstract IReceiptImage Render(BarcodeRenderer renderer, string data, int moduleSize,
+ QRCodeGenerator.ECCLevel ecc);
+
+ /// Resolves a cn value, defaulting to QR for unknown families (prior behaviour).
+ public static TwoDimensionCode FromCn(int cn) => TryFromValue(cn, out var code) ? code : QrCode;
+
+ private sealed class QrSymbol : TwoDimensionCode
+ {
+ public QrSymbol(int value, string name) : base(name, value) { }
+
+ public override IReceiptImage Render(BarcodeRenderer renderer, string data, int moduleSize,
+ QRCodeGenerator.ECCLevel ecc)
+ => renderer.RenderQr(data, moduleSize, ecc);
+ }
+
+ private sealed class ZXingSymbol : TwoDimensionCode
+ {
+ private readonly BarcodeFormat _format;
+
+ public ZXingSymbol(int value, string name, BarcodeFormat format) : base(name, value) => _format = format;
+
+ public override IReceiptImage Render(BarcodeRenderer renderer, string data, int moduleSize,
+ QRCodeGenerator.ECCLevel ecc)
+ => renderer.Render2D(data, _format, moduleSize);
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/StatusByteBuilder.cs b/src/CrossEscPos.Core/Emulator/StatusByteBuilder.cs
new file mode 100644
index 0000000..1eb28cd
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/StatusByteBuilder.cs
@@ -0,0 +1,94 @@
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// Builds ESC/POS status bytes from . Bit layouts follow the Epson
+/// TM-series reference (DLE EOT n, GS r n, and the 4-byte Automatic Status Back block).
+/// Bits 1 and 4 are fixed to 1 in the DLE EOT / GS r single-byte responses.
+///
+public static class StatusByteBuilder
+{
+ private const byte Fixed = 0b0001_0010; // bit1 + bit4
+
+ /// DLE EOT 1 — printer status.
+ public static byte PrinterStatus(PrinterState s)
+ {
+ byte b = Fixed;
+ if (s.DrawerOpen) b |= 0x04; // bit2: drawer kick-out connector pin 3
+ if (!s.Online) b |= 0x08; // bit3: 0 = online, 1 = offline
+ return b;
+ }
+
+ /// DLE EOT 2 — offline cause.
+ public static byte OfflineStatus(PrinterState s)
+ {
+ byte b = Fixed;
+ if (s.CoverOpen) b |= 0x04; // bit2: cover open
+ if (s.FeedButtonPressed) b |= 0x08; // bit3: paper fed by feed button
+ if (s.Paper == PaperLevel.Out) b |= 0x20; // bit5: printing stopped (paper end)
+ if (s.Error != PrinterErrorState.None) b |= 0x40; // bit6: error occurred
+ return b;
+ }
+
+ /// DLE EOT 3 — error cause.
+ public static byte ErrorStatus(PrinterState s)
+ {
+ byte b = Fixed;
+ if (s.Error == PrinterErrorState.Unrecoverable) b |= 0x20; // bit5
+ if (s.Error == PrinterErrorState.Recoverable) b |= 0x40; // bit6: auto-recoverable
+ return b;
+ }
+
+ /// DLE EOT 4 — roll paper sensor.
+ public static byte PaperSensorStatus(PrinterState s)
+ {
+ byte b = Fixed;
+ if (s.Paper >= PaperLevel.NearEnd) b |= 0x0C; // bits2,3: roll paper near-end
+ if (s.Paper == PaperLevel.Out) b |= 0x60; // bits5,6: roll paper end
+ return b;
+ }
+
+ /// GS r 1 — transmit paper sensor status.
+ public static byte TransmitPaperStatus(PrinterState s)
+ {
+ byte b = 0;
+ if (s.Paper >= PaperLevel.NearEnd) b |= 0x03; // bits0,1: near-end
+ if (s.Paper == PaperLevel.Out) b |= 0x0C; // bits2,3: paper end
+ return b;
+ }
+
+ /// GS r 2 — transmit drawer kick-out connector status.
+ public static byte TransmitDrawerStatus(PrinterState s)
+ => (byte)(s.DrawerOpen ? 0x01 : 0x00);
+
+ ///
+ /// The 4-byte Automatic Status Back block (sent on GS a / on every state change while enabled).
+ /// Byte layout matches the Epson TM ASB format as parsed by ESC-POS-.NET: byte 0 has fixed bit 4
+ /// (and bits 0,1,7 clear); the cash-drawer bit is inverted (open = bit 2 clear); paper-low/out
+ /// use paired bits.
+ ///
+ public static byte[] AutoStatusBack(PrinterState s)
+ {
+ // Byte 0 — printer info (bit4 fixed 1; bits 0,1,7 fixed 0).
+ byte b0 = 0x10;
+ if (!s.DrawerOpen) b0 |= 0x04; // bit2 SET = drawer closed (open => clear)
+ if (!s.Online) b0 |= 0x08; // bit3 SET = offline
+ if (s.CoverOpen) b0 |= 0x20; // bit5 = cover open
+ if (s.FeedButtonPressed) b0 |= 0x40; // bit6 = paper currently feeding
+
+ // Byte 1 — error info.
+ byte b1 = 0;
+ if (s.FeedButtonPressed) b1 |= 0x02; // bit1 = feed button pushed
+ if (s.Error == PrinterErrorState.Unrecoverable) b1 |= 0x20; // bit5
+ if (s.Error == PrinterErrorState.Recoverable) b1 |= 0x40; // bit6
+
+ // Byte 2 — roll paper sensors (paired bits).
+ byte b2 = 0;
+ if (s.Paper >= PaperLevel.NearEnd) b2 |= 0x03; // bits0,1 = paper low
+ if (s.Paper == PaperLevel.Out) b2 |= 0x0C; // bits2,3 = paper out
+
+ byte b3 = 0;
+ return new[] { b0, b1, b2, b3 };
+ }
+}
diff --git a/src/CrossEscPos.Core/Emulator/TransmitStatusKind.cs b/src/CrossEscPos.Core/Emulator/TransmitStatusKind.cs
new file mode 100644
index 0000000..e270af0
--- /dev/null
+++ b/src/CrossEscPos.Core/Emulator/TransmitStatusKind.cs
@@ -0,0 +1,25 @@
+using System;
+using Ardalis.SmartEnum;
+
+namespace CrossEscPos.Emulator;
+
+///
+/// GS r n transmit-status requests. Each kind carries the status byte it builds. The parameter accepts
+/// both numeric (1, 2) and ASCII ('1', '2') forms, as ESC/POS allows.
+///
+public sealed class TransmitStatusKind : SmartEnum
+{
+ public static readonly TransmitStatusKind Paper = new(nameof(Paper), 1, StatusByteBuilder.TransmitPaperStatus);
+ public static readonly TransmitStatusKind Drawer = new(nameof(Drawer), 2, StatusByteBuilder.TransmitDrawerStatus);
+
+ private readonly Func _build;
+
+ private TransmitStatusKind(string name, int value, Func build) : base(name, value)
+ => _build = build;
+
+ public byte Build(PrinterState state) => _build(state);
+
+ /// Resolves a GS r parameter (numeric or ASCII digit); null when unsupported.
+ public static TransmitStatusKind? FromParameter(int n)
+ => TryFromValue(n >= '0' ? n - '0' : n, out var kind) ? kind : null;
+}
diff --git a/src/CrossEscPos.Core/EscPos/Barcode1DSystem.cs b/src/CrossEscPos.Core/EscPos/Barcode1DSystem.cs
new file mode 100644
index 0000000..a229bbc
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Barcode1DSystem.cs
@@ -0,0 +1,40 @@
+using System.Linq;
+using Ardalis.SmartEnum;
+using ZXing;
+
+namespace CrossEscPos.EscPos;
+
+///
+/// GS k 1D barcode systems. ESC/POS encodes each symbology twice — Function A (m = 0..6) and Function
+/// B (m = 65..73). The SmartEnum value is the Function-B code; the Function-A code (where one exists)
+/// and the ZXing format are carried alongside, so decoding m is a lookup, not a switch.
+///
+public sealed class Barcode1DSystem : SmartEnum
+{
+ public static readonly Barcode1DSystem UpcA = new(nameof(UpcA), 65, 0, BarcodeFormat.UPC_A);
+ public static readonly Barcode1DSystem UpcE = new(nameof(UpcE), 66, 1, BarcodeFormat.UPC_E);
+ public static readonly Barcode1DSystem Ean13 = new(nameof(Ean13), 67, 2, BarcodeFormat.EAN_13);
+ public static readonly Barcode1DSystem Ean8 = new(nameof(Ean8), 68, 3, BarcodeFormat.EAN_8);
+ public static readonly Barcode1DSystem Code39 = new(nameof(Code39), 69, 4, BarcodeFormat.CODE_39);
+ public static readonly Barcode1DSystem Itf = new(nameof(Itf), 70, 5, BarcodeFormat.ITF);
+ public static readonly Barcode1DSystem Codabar = new(nameof(Codabar), 71, 6, BarcodeFormat.CODABAR);
+ public static readonly Barcode1DSystem Code93 = new(nameof(Code93), 72, null, BarcodeFormat.CODE_93);
+ public static readonly Barcode1DSystem Code128 = new(nameof(Code128), 73, null, BarcodeFormat.CODE_128);
+
+ /// The Function-A code (0..6), or null for systems only available via Function B.
+ public int? FunctionACode { get; }
+
+ /// The ZXing format used to encode this symbology.
+ public BarcodeFormat Format { get; }
+
+ private Barcode1DSystem(string name, int functionBCode, int? functionACode, BarcodeFormat format)
+ : base(name, functionBCode)
+ {
+ FunctionACode = functionACode;
+ Format = format;
+ }
+
+ /// Resolves a GS k system code m from either Function A or B; null if unsupported.
+ public static Barcode1DSystem? FromCommandCode(int m)
+ => TryFromValue(m, out var byFunctionB) ? byFunctionB : List.FirstOrDefault(s => s.FunctionACode == m);
+}
diff --git a/EscPos/BaseCommand.cs b/src/CrossEscPos.Core/EscPos/BaseCommand.cs
similarity index 93%
rename from EscPos/BaseCommand.cs
rename to src/CrossEscPos.Core/EscPos/BaseCommand.cs
index fe3d4f3..94e66c8 100644
--- a/EscPos/BaseCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/BaseCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos;
+namespace CrossEscPos.EscPos;
public abstract class BaseCommand
{
diff --git a/EscPos/BaseCommandNoArgs.cs b/src/CrossEscPos.Core/EscPos/BaseCommandNoArgs.cs
similarity index 87%
rename from EscPos/BaseCommandNoArgs.cs
rename to src/CrossEscPos.Core/EscPos/BaseCommandNoArgs.cs
index 1b6c214..a34af1d 100644
--- a/EscPos/BaseCommandNoArgs.cs
+++ b/src/CrossEscPos.Core/EscPos/BaseCommandNoArgs.cs
@@ -1,6 +1,6 @@
using System;
-namespace ReceiptPrinterEmulator.EscPos;
+namespace CrossEscPos.EscPos;
public abstract class BaseCommandNoArgs : BaseCommand
{
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/BeeperCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/BeeperCommand.cs
new file mode 100644
index 0000000..cdefa03
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/BeeperCommand.cs
@@ -0,0 +1,14 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+///
+/// Beeper (ESC ( A pL pH ...) — the Epson manufacturer buzzer command. Parameters (number of beeps,
+/// pattern, timing) are accepted but the emulator simply triggers its buzzer feedback once.
+///
+public class BeeperCommand : ParenCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "(A";
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.Buzz();
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/GeneratePulseCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/GeneratePulseCommand.cs
new file mode 100644
index 0000000..1b4d0aa
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/GeneratePulseCommand.cs
@@ -0,0 +1,34 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+///
+/// Generate pulse / kick the cash drawer (ESC p m t1 t2).
+/// m selects the drawer-kick connector pin; t1/t2 are on/off pulse times (ignored by the emulator).
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=66
+///
+public class GeneratePulseCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "p";
+ public override bool HasArgs => true;
+
+ private int _index;
+ private int _m;
+
+ public override void Reset()
+ {
+ _index = 0;
+ _m = 0;
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ if (_index == 0)
+ _m = (byte)c;
+
+ _index++;
+ return _index < 3; // consume m, t1, t2
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.KickCashDrawer(_m);
+}
diff --git a/EscPos/Commands/ESC/InitializePrinterCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/InitializePrinterCommand.cs
similarity index 72%
rename from EscPos/Commands/ESC/InitializePrinterCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/InitializePrinterCommand.cs
index 8ff59ac..dc416cf 100644
--- a/EscPos/Commands/ESC/InitializePrinterCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/InitializePrinterCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
public class InitializePrinterCommand : BaseCommandNoArgs
{
diff --git a/EscPos/Commands/ESC/ItalicOffCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOffCommand.cs
similarity index 81%
rename from EscPos/Commands/ESC/ItalicOffCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOffCommand.cs
index 1a18415..14d4158 100644
--- a/EscPos/Commands/ESC/ItalicOffCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOffCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Turn italics mode off
diff --git a/EscPos/Commands/ESC/ItalicOnCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOnCommand.cs
similarity index 81%
rename from EscPos/Commands/ESC/ItalicOnCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOnCommand.cs
index b9a4cb9..fb86641 100644
--- a/EscPos/Commands/ESC/ItalicOnCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/ItalicOnCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Turn italics mode on
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/PageModeCommands.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/PageModeCommands.cs
new file mode 100644
index 0000000..ce79f3c
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/PageModeCommands.cs
@@ -0,0 +1,17 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+/// ESC L — select page mode.
+public class SelectPageModeCommand : BaseCommandNoArgs
+{
+ public override string Prefix => EscPosInterpreter.ESC + "L";
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.EnterPageMode();
+}
+
+/// ESC S — select standard mode.
+public class SelectStandardModeCommand : BaseCommandNoArgs
+{
+ public override string Prefix => EscPosInterpreter.ESC + "S";
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.SelectStandardMode();
+}
diff --git a/EscPos/Commands/ESC/PaperFullCut.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperFullCut.cs
similarity index 77%
rename from EscPos/Commands/ESC/PaperFullCut.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/PaperFullCut.cs
index 30fa8f4..7ccb13b 100644
--- a/EscPos/Commands/ESC/PaperFullCut.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperFullCut.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// 2024.02.18 Leo
diff --git a/EscPos/Commands/ESC/PaperPartialCut.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPartialCut.cs
similarity index 77%
rename from EscPos/Commands/ESC/PaperPartialCut.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPartialCut.cs
index df080ae..5ec3bd1 100644
--- a/EscPos/Commands/ESC/PaperPartialCut.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPartialCut.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
diff --git a/EscPos/Commands/ESC/PaperPrintFeed.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeed.cs
similarity index 86%
rename from EscPos/Commands/ESC/PaperPrintFeed.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeed.cs
index da5ac78..2c26698 100644
--- a/EscPos/Commands/ESC/PaperPrintFeed.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeed.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
diff --git a/EscPos/Commands/ESC/PaperPrintFeednLines.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeednLines.cs
similarity index 87%
rename from EscPos/Commands/ESC/PaperPrintFeednLines.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeednLines.cs
index 3b6b002..0ea7e78 100644
--- a/EscPos/Commands/ESC/PaperPrintFeednLines.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/PaperPrintFeednLines.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectBitImageModeCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectBitImageModeCommand.cs
new file mode 100644
index 0000000..494fe3e
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectBitImageModeCommand.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+///
+/// Select bit-image mode (ESC * m nL nH d1...dk) — the classic inline raster band. m selects the
+/// vertical density: 0/1 = 8-dot (1 byte/column), 32/33 = 24-dot (3 bytes/column). Width is
+/// nL + nH*256 dot-columns. Each column byte is 8 vertical dots, MSB at top.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=88
+///
+public class SelectBitImageModeCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "*";
+ public override bool HasArgs => true;
+
+ private int _index;
+ private int _m, _nL, _width, _bytesPerColumn, _dataLen;
+ private readonly List _data = new();
+
+ public override void Reset()
+ {
+ _index = 0;
+ _m = _nL = _width = _bytesPerColumn = _dataLen = 0;
+ _data.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ switch (_index++)
+ {
+ case 0:
+ _m = (byte)c;
+ return true;
+ case 1:
+ _nL = (byte)c;
+ return true;
+ case 2:
+ _width = _nL + ((byte)c << 8);
+ _bytesPerColumn = _m is 0 or 1 ? 1 : 3;
+ _dataLen = _width * _bytesPerColumn;
+ return _dataLen > 0;
+ default:
+ _data.Add((byte)c);
+ return _data.Count < _dataLen;
+ }
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ if (_width <= 0 || _data.Count == 0)
+ return;
+
+ int height = _bytesPerColumn * 8;
+ var pixels = new ReceiptColor[_width * height];
+ System.Array.Fill(pixels, ReceiptColor.White);
+
+ for (int col = 0; col < _width; col++)
+ {
+ for (int byteIdx = 0; byteIdx < _bytesPerColumn; byteIdx++)
+ {
+ int di = col * _bytesPerColumn + byteIdx;
+ if (di >= _data.Count) break;
+ byte b = _data[di];
+ for (int bit = 0; bit < 8; bit++)
+ if ((b & (0x80 >> bit)) != 0)
+ pixels[(byteIdx * 8 + bit) * _width + col] = ReceiptColor.Black;
+ }
+ }
+
+ printer.PrintBitmap(printer.ImageFactory.FromPixels(_width, height, pixels));
+ }
+}
diff --git a/EscPos/Commands/ESC/SelectCharTableCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharTableCommand.cs
similarity index 79%
rename from EscPos/Commands/ESC/SelectCharTableCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharTableCommand.cs
index 7747542..dda6279 100644
--- a/EscPos/Commands/ESC/SelectCharTableCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharTableCommand.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Select character font
@@ -27,6 +27,6 @@ public override bool InterpretNextChar(char c)
public override void Execute(ReceiptPrinter printer, string? args)
{
-
+ printer.SetCodePage(_n);
}
}
\ No newline at end of file
diff --git a/EscPos/Commands/ESC/SelectCharsetCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharsetCommand.cs
similarity index 80%
rename from EscPos/Commands/ESC/SelectCharsetCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharsetCommand.cs
index 39278d2..a86b82c 100644
--- a/EscPos/Commands/ESC/SelectCharsetCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectCharsetCommand.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Select character font
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectFontCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectFontCommand.cs
new file mode 100644
index 0000000..83ed9cf
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectFontCommand.cs
@@ -0,0 +1,32 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+///
+/// Select character font
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=27
+///
+public class SelectFontCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "M";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset()
+ {
+ _n = 0;
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ if (FontSelection.FromParameter(_n) is { } selection)
+ printer.SelectFont(selection.Font);
+ }
+}
\ No newline at end of file
diff --git a/EscPos/Commands/ESC/SelectJustificationCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectJustificationCommand.cs
similarity index 51%
rename from EscPos/Commands/ESC/SelectJustificationCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SelectJustificationCommand.cs
index b2bcc46..5bc32e3 100644
--- a/EscPos/Commands/ESC/SelectJustificationCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SelectJustificationCommand.cs
@@ -1,7 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Select justification
@@ -27,17 +26,7 @@ public override bool InterpretNextChar(char c)
public override void Execute(ReceiptPrinter printer, string? args)
{
- switch (_n)
- {
- case 0 or 48:
- printer.SelectJustification(TextJustification.Left);
- break;
- case 1 or 49:
- printer.SelectJustification(TextJustification.Center);
- break;
- case 2 or 50:
- printer.SelectJustification(TextJustification.Right);
- break;
- }
+ if (JustificationMode.FromParameter(_n) is { } mode)
+ printer.SelectJustification(mode.Justification);
}
}
\ No newline at end of file
diff --git a/EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs
similarity index 80%
rename from EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs
index 6757c5d..f42ed52 100644
--- a/EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetDefaultLineSpacingCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Select default line spacing
diff --git a/EscPos/Commands/ESC/SetLineSpacingCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetLineSpacingCommand.cs
similarity index 86%
rename from EscPos/Commands/ESC/SetLineSpacingCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SetLineSpacingCommand.cs
index 93aa56c..6b85fb6 100644
--- a/EscPos/Commands/ESC/SetLineSpacingCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetLineSpacingCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Set line spacing
diff --git a/EscPos/Commands/ESC/SetPrintTextMode.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetPrintTextMode.cs
similarity index 91%
rename from EscPos/Commands/ESC/SetPrintTextMode.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/SetPrintTextMode.cs
index 8ae6a2f..7765cbd 100644
--- a/EscPos/Commands/ESC/SetPrintTextMode.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/SetPrintTextMode.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// 2024.02.18 Leo
diff --git a/EscPos/Commands/ESC/ToggleEmphasizeCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleEmphasizeCommand.cs
similarity index 88%
rename from EscPos/Commands/ESC/ToggleEmphasizeCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleEmphasizeCommand.cs
index c3297a5..23e9c47 100644
--- a/EscPos/Commands/ESC/ToggleEmphasizeCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleEmphasizeCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Turn emphasized mode on/off
diff --git a/EscPos/Commands/ESC/ToggleUnderlineCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleUnderlineCommand.cs
similarity index 85%
rename from EscPos/Commands/ESC/ToggleUnderlineCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleUnderlineCommand.cs
index e463ee5..9dbe41f 100644
--- a/EscPos/Commands/ESC/ToggleUnderlineCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/ToggleUnderlineCommand.cs
@@ -1,7 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Emulator.Enums;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
-namespace ReceiptPrinterEmulator.EscPos.Commands.ESC;
+namespace CrossEscPos.EscPos.Commands.ESC;
///
/// Turn underline mode on/off
diff --git a/src/CrossEscPos.Core/EscPos/Commands/ESC/UserDefinedCharCommands.cs b/src/CrossEscPos.Core/EscPos/Commands/ESC/UserDefinedCharCommands.cs
new file mode 100644
index 0000000..a06afde
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/ESC/UserDefinedCharCommands.cs
@@ -0,0 +1,113 @@
+using System.Collections.Generic;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.ESC;
+
+///
+/// Define user-defined characters (ESC & y c1 c2 [x d1...d(x*y)]...). For each code c1..c2 a
+/// width byte x is followed by x*y column-major bytes (each byte = 8 vertical dots). Glyphs are
+/// parsed and stored; see .
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=26
+///
+public class DefineUserDefinedCharsCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "&";
+ public override bool HasArgs => true;
+
+ private int _phase, _y, _c1, _c2, _x, _dataLeft, _curCode;
+ private readonly List _cur = new();
+ private readonly List<(int code, int w, int h, byte[] data)> _defs = new();
+
+ public override void Reset()
+ {
+ _phase = _y = _c1 = _c2 = _x = _dataLeft = _curCode = 0;
+ _cur.Clear();
+ _defs.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ byte v = (byte)c;
+ switch (_phase)
+ {
+ case 0: _y = v; _phase = 1; return true;
+ case 1: _c1 = v; _phase = 2; return true;
+ case 2:
+ _c2 = v;
+ _curCode = _c1;
+ if (_c2 < _c1) return false;
+ _phase = 3;
+ return true;
+ case 3: // width of the current character
+ _x = v;
+ _dataLeft = _x * _y;
+ _cur.Clear();
+ if (_dataLeft == 0)
+ {
+ _curCode++;
+ return _curCode <= _c2; // skip empty glyph; read next width or finish
+ }
+ _phase = 4;
+ return true;
+ case 4:
+ _cur.Add(v);
+ _dataLeft--;
+ if (_dataLeft == 0)
+ {
+ _defs.Add((_curCode, _x, _y, _cur.ToArray()));
+ _curCode++;
+ _phase = 3;
+ return _curCode <= _c2;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ foreach (var (code, w, h, data) in _defs)
+ {
+ int width = w;
+ int height = h * 8;
+ var pixels = new ReceiptColor[width * height];
+ System.Array.Fill(pixels, ReceiptColor.White);
+ for (int col = 0; col < width; col++)
+ for (int r = 0; r < h; r++)
+ {
+ int di = col * h + r;
+ if (di >= data.Length) break;
+ byte b = data[di];
+ for (int bit = 0; bit < 8; bit++)
+ if ((b & (0x80 >> bit)) != 0)
+ pixels[(r * 8 + bit) * width + col] = ReceiptColor.Black;
+ }
+ printer.DefineUserGlyph(code, printer.ImageFactory.FromPixels(width, height, pixels));
+ }
+ }
+}
+
+/// Select/cancel user-defined character set (ESC % n) — bit 0 enables.
+public class EnableUserDefinedCharsCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "%";
+ public override bool HasArgs => true;
+
+ private int _n;
+ public override void Reset() => _n = 0;
+ public override bool InterpretNextChar(char c) { _n = (byte)c; return false; }
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.EnableUserDefined((_n & 1) != 0);
+}
+
+/// Cancel a user-defined character (ESC ? n).
+public class CancelUserDefinedCharCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.ESC + "?";
+ public override bool HasArgs => true;
+
+ private int _n;
+ public override void Reset() => _n = 0;
+ public override bool InterpretNextChar(char c) { _n = (byte)c; return false; }
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.CancelUserGlyph(_n);
+}
diff --git a/EscPos/Commands/FS/PaperAutoCut.cs b/src/CrossEscPos.Core/EscPos/Commands/FS/PaperAutoCut.cs
similarity index 90%
rename from EscPos/Commands/FS/PaperAutoCut.cs
rename to src/CrossEscPos.Core/EscPos/Commands/FS/PaperAutoCut.cs
index e4ec348..0697dcb 100644
--- a/EscPos/Commands/FS/PaperAutoCut.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/FS/PaperAutoCut.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.FS;
+namespace CrossEscPos.EscPos.Commands.FS;
///
diff --git a/EscPos/Commands/FS/PrintStoredLogo.cs b/src/CrossEscPos.Core/EscPos/Commands/FS/PrintStoredLogo.cs
similarity index 92%
rename from EscPos/Commands/FS/PrintStoredLogo.cs
rename to src/CrossEscPos.Core/EscPos/Commands/FS/PrintStoredLogo.cs
index 4e852fb..4b49fc0 100644
--- a/EscPos/Commands/FS/PrintStoredLogo.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/FS/PrintStoredLogo.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.FS;
+namespace CrossEscPos.EscPos.Commands.FS;
///
diff --git a/src/CrossEscPos.Core/EscPos/Commands/FixedArgNoOpCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/FixedArgNoOpCommand.cs
new file mode 100644
index 0000000..e185a14
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/FixedArgNoOpCommand.cs
@@ -0,0 +1,38 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos.Commands;
+
+///
+/// A command with a fixed number of argument bytes that are consumed (so they don't corrupt the
+/// stream) and logged but otherwise ignored. Used for positioning/area commands the emulator
+/// approximates (page-mode print area/direction, absolute/relative position).
+///
+public sealed class FixedArgNoOpCommand : BaseCommand
+{
+ private readonly string _prefix;
+ private readonly int _count;
+ private readonly string _name;
+ private int _read;
+
+ public FixedArgNoOpCommand(string prefix, int count, string name)
+ {
+ _prefix = prefix;
+ _count = count;
+ _name = name;
+ }
+
+ public override string Prefix => _prefix;
+ public override bool HasArgs => _count > 0;
+
+ public override void Reset() => _read = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _read++;
+ return _read < _count;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ => Logger.Info($"[{_name}] accepted and ignored");
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/DefineDownloadBitImageCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/DefineDownloadBitImageCommand.cs
new file mode 100644
index 0000000..9552dc3
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/DefineDownloadBitImageCommand.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Define downloaded bit image (GS * x y d1...d(x*y*8)). The image is x*8 dots wide and y*8 dots
+/// tall. Data is column-major: for each of the x*8 columns there are y vertical bytes (8 dots each,
+/// MSB at top). Printed later by GS /.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=92
+///
+public class DefineDownloadBitImageCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "*";
+ public override bool HasArgs => true;
+
+ private int _index;
+ private int _x, _y, _dataLen;
+ private readonly List _data = new();
+
+ public override void Reset()
+ {
+ _index = 0;
+ _x = _y = _dataLen = 0;
+ _data.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ switch (_index++)
+ {
+ case 0:
+ _x = (byte)c;
+ return true;
+ case 1:
+ _y = (byte)c;
+ _dataLen = _x * _y * 8;
+ return _dataLen > 0;
+ default:
+ _data.Add((byte)c);
+ return _data.Count < _dataLen;
+ }
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ if (_dataLen == 0 || _data.Count == 0)
+ return;
+
+ int width = _x * 8;
+ int height = _y * 8;
+ var pixels = new ReceiptColor[width * height];
+ System.Array.Fill(pixels, ReceiptColor.White);
+
+ for (int col = 0; col < width; col++)
+ {
+ for (int r = 0; r < _y; r++)
+ {
+ int di = col * _y + r;
+ if (di >= _data.Count) break;
+ byte b = _data[di];
+ for (int bit = 0; bit < 8; bit++)
+ if ((b & (0x80 >> bit)) != 0)
+ pixels[(r * 8 + bit) * width + col] = ReceiptColor.Black;
+ }
+ }
+
+ printer.DefineDownloadBitImage(printer.ImageFactory.FromPixels(width, height, pixels));
+ }
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/EnableAutoStatusBackCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/EnableAutoStatusBackCommand.cs
new file mode 100644
index 0000000..d9a0334
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/EnableAutoStatusBackCommand.cs
@@ -0,0 +1,26 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Enable/disable Automatic Status Back (GS a n). n is a bitmask of which status changes to report;
+/// 0 disables. While enabled the printer pushes a 4-byte status block on every state change.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=24
+///
+public class EnableAutoStatusBackCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "a";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.SetAutoStatusBack(_n);
+}
diff --git a/EscPos/Commands/GS/PaperEjectCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/PaperEjectCommand.cs
similarity index 92%
rename from EscPos/Commands/GS/PaperEjectCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/GS/PaperEjectCommand.cs
index 180ec21..3a459bc 100644
--- a/EscPos/Commands/GS/PaperEjectCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/PaperEjectCommand.cs
@@ -1,6 +1,6 @@
-using ReceiptPrinterEmulator.Emulator;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.GS;
+namespace CrossEscPos.EscPos.Commands.GS;
///
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/PrintBarcodeCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintBarcodeCommand.cs
new file mode 100644
index 0000000..262a9a4
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintBarcodeCommand.cs
@@ -0,0 +1,74 @@
+using System.Text;
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Print 1D barcode (GS k). Supports both ESC/POS forms:
+/// Function A: GS k m d1...dk NUL (m = 0..6, NUL-terminated data)
+/// Function B: GS k m n d1...dn (m = 65..73, n = data length)
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=88
+///
+public class PrintBarcodeCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "k";
+ public override bool HasArgs => true;
+
+ private enum Phase { System, LengthB, DataA, DataB }
+
+ private Phase _phase;
+ private int _m;
+ private int _remaining;
+ private readonly StringBuilder _data = new();
+
+ public override void Reset()
+ {
+ _phase = Phase.System;
+ _m = 0;
+ _remaining = 0;
+ _data.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ switch (_phase)
+ {
+ case Phase.System:
+ _m = (byte)c;
+ bool formB = _m is >= 65 and <= 73;
+ _phase = formB ? Phase.LengthB : Phase.DataA;
+ return true;
+
+ case Phase.LengthB:
+ _remaining = (byte)c;
+ _phase = Phase.DataB;
+ return _remaining > 0;
+
+ case Phase.DataA:
+ if (c == EscPosInterpreter.NUL)
+ return false;
+ _data.Append(c);
+ return true;
+
+ case Phase.DataB:
+ _data.Append(c);
+ _remaining--;
+ return _remaining > 0;
+ }
+
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ var system = Barcode1DSystem.FromCommandCode(_m);
+ if (system is null)
+ {
+ Logger.Info($"Unsupported barcode system m={_m}");
+ return;
+ }
+
+ printer.PrintBarcode(system.Format, _data.ToString());
+ }
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/PrintDownloadBitImageCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintDownloadBitImageCommand.cs
new file mode 100644
index 0000000..60e4ce6
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintDownloadBitImageCommand.cs
@@ -0,0 +1,26 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Print downloaded bit image (GS / m). m selects scaling: 0 normal, 1 double-width,
+/// 2 double-height, 3 quadruple.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=93
+///
+public class PrintDownloadBitImageCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "/";
+ public override bool HasArgs => true;
+
+ private int _m;
+
+ public override void Reset() => _m = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _m = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.PrintDownloadBitImage(_m);
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/PrintQrCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintQrCommand.cs
new file mode 100644
index 0000000..de148cd
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintQrCommand.cs
@@ -0,0 +1,88 @@
+using System.Text;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// 2D symbol commands (GS ( k pL pH cn fn ...). Supports QR Code (cn=49), PDF417 (cn=48),
+/// DataMatrix (cn=54) and Aztec (cn=55):
+/// fn 67: module size fn 69: error correction level (QR)
+/// fn 80: store data fn 81: print stored symbol
+/// A typical symbol is emitted as: set size, (set EC), store data, then print.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=140
+///
+public class PrintQrCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "(k";
+ public override bool HasArgs => true;
+
+ private enum Phase { PL, PH, CN, FN, Params }
+
+ private Phase _phase;
+ private int _pL;
+ private int _remaining;
+ private int _cn;
+ private int _fn;
+ private readonly StringBuilder _params = new();
+
+ public override void Reset()
+ {
+ _phase = Phase.PL;
+ _pL = 0;
+ _remaining = 0;
+ _cn = 0;
+ _fn = 0;
+ _params.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ switch (_phase)
+ {
+ case Phase.PL:
+ _pL = (byte)c;
+ _phase = Phase.PH;
+ return true;
+
+ case Phase.PH:
+ _remaining = _pL + ((byte)c << 8); // counts cn, fn and parameters
+ _phase = Phase.CN;
+ return _remaining > 0;
+
+ case Phase.CN:
+ _cn = (byte)c;
+ _remaining--;
+ _phase = Phase.FN;
+ return _remaining > 0;
+
+ case Phase.FN:
+ _fn = (byte)c;
+ _remaining--;
+ _phase = Phase.Params;
+ return _remaining > 0;
+
+ case Phase.Params:
+ _params.Append(c);
+ _remaining--;
+ return _remaining > 0;
+ }
+
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ if (!TwoDimensionCode.TryFromValue(_cn, out _))
+ {
+ Logger.Info($"Unsupported 2D symbol cn={_cn}");
+ return;
+ }
+
+ if (TwoDSymbolFunction.TryFromValue(_fn, out var function))
+ function.Apply(printer, _cn, _params.ToString());
+ else
+ Logger.Info($"Unsupported 2D function fn={_fn}");
+ }
+}
diff --git a/EscPos/Commands/GS/PrintRasterBitImageCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintRasterBitImageCommand.cs
similarity index 68%
rename from EscPos/Commands/GS/PrintRasterBitImageCommand.cs
rename to src/CrossEscPos.Core/EscPos/Commands/GS/PrintRasterBitImageCommand.cs
index 0f5954b..d9ecc1e 100644
--- a/EscPos/Commands/GS/PrintRasterBitImageCommand.cs
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/PrintRasterBitImageCommand.cs
@@ -1,11 +1,7 @@
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.Logging;
-using System;
-using System.Drawing;
-using System.Drawing.Imaging;
-using System.Windows.Input;
+using CrossEscPos.Graphics;
+using CrossEscPos.Emulator;
-namespace ReceiptPrinterEmulator.EscPos.Commands.GS;
+namespace CrossEscPos.EscPos.Commands.GS;
public class PrintRasterBitImageCommand : BaseCommand
{
@@ -69,23 +65,18 @@ public override void Reset()
public override void Execute(ReceiptPrinter printer, string? args)
{
- var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb);
var values = ReadBytesByBits(length);
- BitmapData bitmapData = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, bmp.PixelFormat);
- IntPtr ptr = bitmapData.Scan0;
- byte value = 0;
- for (int i = 0; i < values.Length; i++)
+ // Each bit is one pixel: 0 -> white, 1 -> black (row-major).
+ var pixels = new ReceiptColor[width * height];
+ int count = System.Math.Min(values.Length, pixels.Length);
+ for (int i = 0; i < count; i++)
{
- value = values[i] == 0 ? (byte)255 : (byte)0;
- System.Runtime.InteropServices.Marshal.WriteByte(ptr, i * 3 + 0, value);
- System.Runtime.InteropServices.Marshal.WriteByte(ptr, i * 3 + 1, value);
- System.Runtime.InteropServices.Marshal.WriteByte(ptr, i * 3 + 2, value);
+ byte value = values[i] == 0 ? (byte)255 : (byte)0;
+ pixels[i] = ReceiptColor.Gray(value);
}
- bmp.UnlockBits(bitmapData);
-
- printer.PrintBitmap(bmp);
+ printer.PrintBitmap(printer.ImageFactory.FromPixels(width, height, pixels));
}
private byte[] ReadBytesByBits(int size)
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCharacterSizeCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCharacterSizeCommand.cs
new file mode 100644
index 0000000..61f274b
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCharacterSizeCommand.cs
@@ -0,0 +1,35 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Select character size
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=34
+///
+public class SelectCharacterSizeCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "!";
+ public override bool HasArgs => true;
+
+ private byte _n;
+
+ public override void Reset()
+ {
+ _n = 0;
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ // GS ! n: bits 6,5,4 = width magnification - 1; bits 2,1,0 = height magnification - 1 (1..8).
+ var widthScale = ((_n >> 4) & 0b111) + 1;
+ var heightScale = (_n & 0b111) + 1;
+
+ printer.SelectCharacterSize(widthScale, heightScale);
+ }
+}
\ No newline at end of file
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs
new file mode 100644
index 0000000..2d6c907
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs
@@ -0,0 +1,51 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Select cut mode and cut paper
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=87
+///
+public class SelectCutModeAndCutCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "V";
+ public override bool HasArgs => true;
+
+ private int _idx;
+ private int _m;
+ private int _n;
+
+ public override void Reset()
+ {
+ _idx = 0;
+ _m = 0;
+ _n = 0;
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ if (_idx == 0)
+ {
+ _idx++;
+ _m = c;
+ _n = 0;
+
+ // A letter-form m (> '1') is cut function B/C/D and carries a second arg (n).
+ return _m > '1';
+ }
+
+ if (_idx == 1)
+ {
+ _idx++;
+ _n = c;
+ }
+
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ var mode = CutMode.FromParameter(_n);
+ printer.Cut(mode.Function, mode.Shape, _n);
+ }
+}
\ No newline at end of file
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriFontCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriFontCommand.cs
new file mode 100644
index 0000000..323df4e
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriFontCommand.cs
@@ -0,0 +1,30 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Select font for HRI characters (GS f n). n: 0/48 = Font A, 1/49 = Font B.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=122
+///
+public class SelectHriFontCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "f";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ var font = (_n is 1 or 49) ? PrinterFont.FontB : PrinterFont.FontA;
+ printer.SetHriFont(font);
+ }
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriPositionCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriPositionCommand.cs
new file mode 100644
index 0000000..8ae631e
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SelectHriPositionCommand.cs
@@ -0,0 +1,35 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Select HRI (Human Readable Interpretation) character print position (GS H n).
+/// n: 0/48 = none, 1/49 = above, 2/50 = below, 3/51 = both.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=123
+///
+public class SelectHriPositionCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "H";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ {
+ var position = (_n is 1 or 49) ? HriPosition.Above
+ : (_n is 2 or 50) ? HriPosition.Below
+ : (_n is 3 or 51) ? HriPosition.Both
+ : HriPosition.None;
+
+ printer.SetHriPosition(position);
+ }
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeHeightCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeHeightCommand.cs
new file mode 100644
index 0000000..d9e71de
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeHeightCommand.cs
@@ -0,0 +1,25 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Set barcode height (GS h n)
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=125
+///
+public class SetBarcodeHeightCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "h";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.SetBarcodeHeight(_n);
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeWidthCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeWidthCommand.cs
new file mode 100644
index 0000000..4003814
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SetBarcodeWidthCommand.cs
@@ -0,0 +1,25 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Set barcode module width (GS w n) — n is the width in dots of the narrow module (typically 2-6).
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=126
+///
+public class SetBarcodeWidthCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "w";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.SetBarcodeModuleWidth(_n);
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/SetMotionUnitsCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/SetMotionUnitsCommand.cs
new file mode 100644
index 0000000..b1eeeff
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/SetMotionUnitsCommand.cs
@@ -0,0 +1,35 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Set horizontal and vertical motion units (GS P x y). The emulator renders at a fixed DPI, so the
+/// values are accepted and logged but not applied.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=89
+///
+public class SetMotionUnitsCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "P";
+ public override bool HasArgs => true;
+
+ private int _index;
+ private int _x, _y;
+
+ public override void Reset()
+ {
+ _index = 0;
+ _x = _y = 0;
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ if (_index == 0) _x = (byte)c;
+ else _y = (byte)c;
+ _index++;
+ return _index < 2;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ => Logger.Info($"Set motion units x={_x} y={_y} (ignored)");
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitPrinterIdCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitPrinterIdCommand.cs
new file mode 100644
index 0000000..029626b
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitPrinterIdCommand.cs
@@ -0,0 +1,26 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Transmit printer ID / information (GS I n). Replies model/type/ROM IDs (single byte) or, for the
+/// "info B" variants, a framed name string.
+/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=69
+///
+public class TransmitPrinterIdCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "I";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.TransmitPrinterId(_n);
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitStatusCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitStatusCommand.cs
new file mode 100644
index 0000000..7622cb2
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/GS/TransmitStatusCommand.cs
@@ -0,0 +1,25 @@
+using CrossEscPos.Emulator;
+
+namespace CrossEscPos.EscPos.Commands.GS;
+
+///
+/// Transmit status (GS r n) — n=1 paper sensor, n=2 drawer kick-out connector. Replies one byte
+/// to the host. https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=104
+///
+public class TransmitStatusCommand : BaseCommand
+{
+ public override string Prefix => EscPosInterpreter.GS + "r";
+ public override bool HasArgs => true;
+
+ private int _n;
+
+ public override void Reset() => _n = 0;
+
+ public override bool InterpretNextChar(char c)
+ {
+ _n = (byte)c;
+ return false;
+ }
+
+ public override void Execute(ReceiptPrinter printer, string? args) => printer.TransmitStatus(_n);
+}
diff --git a/src/CrossEscPos.Core/EscPos/Commands/NoOpParenCommand.cs b/src/CrossEscPos.Core/EscPos/Commands/NoOpParenCommand.cs
new file mode 100644
index 0000000..b9e2ac1
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/Commands/NoOpParenCommand.cs
@@ -0,0 +1,26 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos.Commands;
+
+///
+/// A length-prefixed "ESC ( X" / "GS ( X" command that is parsed (so its bytes don't corrupt the
+/// stream) but otherwise ignored — used for configuration/mechanism commands that have no visual
+/// effect in an emulator (print density, mechanical setup, print control, etc.).
+///
+public sealed class NoOpParenCommand : ParenCommand
+{
+ private readonly string _prefix;
+ private readonly string _name;
+
+ public NoOpParenCommand(string prefix, string name)
+ {
+ _prefix = prefix;
+ _name = name;
+ }
+
+ public override string Prefix => _prefix;
+
+ public override void Execute(ReceiptPrinter printer, string? args)
+ => Logger.Info($"[{_name}] accepted and ignored ({Params.Count} param bytes)");
+}
diff --git a/src/CrossEscPos.Core/EscPos/CutMode.cs b/src/CrossEscPos.Core/EscPos/CutMode.cs
new file mode 100644
index 0000000..0269cea
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/CutMode.cs
@@ -0,0 +1,32 @@
+using Ardalis.SmartEnum;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.EscPos;
+
+///
+/// GS V cut modes mapped to a ( , ) pair. The numeric
+/// full/partial codes accept both numeric and ASCII form; the feed/position variants use letter codes.
+///
+public sealed class CutMode : SmartEnum
+{
+ public static readonly CutMode FullCut = new(nameof(FullCut), 0, CutFunction.Cut, CutShape.Full);
+ public static readonly CutMode PartialCut = new(nameof(PartialCut), 1, CutFunction.Cut, CutShape.Partial);
+ public static readonly CutMode FeedFull = new(nameof(FeedFull), 'A', CutFunction.FeedAndCut, CutShape.Full);
+ public static readonly CutMode FeedPartial = new(nameof(FeedPartial), 'B', CutFunction.FeedAndCut, CutShape.Partial);
+ public static readonly CutMode SetPositionFull = new(nameof(SetPositionFull), 'a', CutFunction.SetCutPos, CutShape.Full);
+ public static readonly CutMode SetPositionPartial = new(nameof(SetPositionPartial), 'b', CutFunction.SetCutPos, CutShape.Partial);
+ public static readonly CutMode FeedReverseFull = new(nameof(FeedReverseFull), 'g', CutFunction.FeedAndCutAndReverse, CutShape.Full);
+ public static readonly CutMode FeedReversePartial = new(nameof(FeedReversePartial), 'h', CutFunction.FeedAndCutAndReverse, CutShape.Partial);
+
+ public CutFunction Function { get; }
+ public CutShape Shape { get; }
+
+ private CutMode(string name, int value, CutFunction function, CutShape shape) : base(name, value)
+ {
+ Function = function;
+ Shape = shape;
+ }
+
+ /// Resolves a GS V parameter, defaulting to a full cut for unknown codes (prior behaviour).
+ public static CutMode FromParameter(int n) => TryFromValue(EscPosParameter.Digit(n), out var mode) ? mode : FullCut;
+}
diff --git a/EscPos/EscPosInterpreter.cs b/src/CrossEscPos.Core/EscPos/EscPosInterpreter.cs
similarity index 57%
rename from EscPos/EscPosInterpreter.cs
rename to src/CrossEscPos.Core/EscPos/EscPosInterpreter.cs
index beb159d..f038399 100644
--- a/EscPos/EscPosInterpreter.cs
+++ b/src/CrossEscPos.Core/EscPos/EscPosInterpreter.cs
@@ -1,13 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
-using ReceiptPrinterEmulator.Emulator;
-using ReceiptPrinterEmulator.EscPos.Commands.ESC;
-using ReceiptPrinterEmulator.EscPos.Commands.GS;
-using ReceiptPrinterEmulator.EscPos.Commands.FS;
-using ReceiptPrinterEmulator.Logging;
+using CrossEscPos.Emulator;
+using CrossEscPos.EscPos.Commands.ESC;
+using CrossEscPos.EscPos.Commands.GS;
+using CrossEscPos.EscPos.Commands.FS;
+using CrossEscPos.Logging;
-namespace ReceiptPrinterEmulator.EscPos;
+namespace CrossEscPos.EscPos;
public class EscPosInterpreter
{
@@ -22,6 +22,9 @@ public class EscPosInterpreter
private bool _interpretingCommandArgs;
private BaseCommand? _activeCommand;
+ private bool _realtimeMode;
+ private readonly List _realtimeBuffer = new();
+
public EscPosInterpreter(ReceiptPrinter printer)
{
_printer = printer;
@@ -59,6 +62,7 @@ private void RegisterCommands()
RegisterCommand(new PaperPartialCut()); // 0x1B, 0x69
RegisterCommand(new PaperPrintFeednLines()); // 0x1B, 0x64
RegisterCommand(new PaperPrintFeed()); // 0x1B, 0x4A
+ RegisterCommand(new GeneratePulseCommand()); // 0x1B, 0x70 (cash drawer kick)
// FS = 0x1C
RegisterCommand(new PrintStoredLogo()); // 0x1C, 0x70, n, m
@@ -69,6 +73,43 @@ private void RegisterCommands()
RegisterCommand(new SelectCutModeAndCutCommand());
RegisterCommand(new PaperEjectCommand()); // 0x1D, 0x65, n, [m, t]
RegisterCommand(new PrintRasterBitImageCommand());
+
+ // GS - barcodes (1D) and QR (2D)
+ RegisterCommand(new SetBarcodeHeightCommand()); // 0x1D, 0x68, n
+ RegisterCommand(new SetBarcodeWidthCommand()); // 0x1D, 0x77, n
+ RegisterCommand(new SelectHriPositionCommand()); // 0x1D, 0x48, n
+ RegisterCommand(new SelectHriFontCommand()); // 0x1D, 0x66, n
+ RegisterCommand(new PrintBarcodeCommand()); // 0x1D, 0x6B, m ...
+ RegisterCommand(new PrintQrCommand()); // 0x1D, 0x28, 0x6B, ...
+
+ // GS - status / transmit-back
+ RegisterCommand(new TransmitStatusCommand()); // 0x1D, 0x72, n
+ RegisterCommand(new TransmitPrinterIdCommand()); // 0x1D, 0x49, n
+ RegisterCommand(new EnableAutoStatusBackCommand()); // 0x1D, 0x61, n
+
+ // Buzzer / configuration
+ RegisterCommand(new BeeperCommand()); // ESC ( A — manufacturer beeper
+ RegisterCommand(new SelectBitImageModeCommand()); // ESC * m nL nH ...
+ RegisterCommand(new SetMotionUnitsCommand()); // GS P x y
+ RegisterCommand(new DefineDownloadBitImageCommand()); // GS * x y ...
+ RegisterCommand(new PrintDownloadBitImageCommand()); // GS / m
+
+ // Page mode
+ RegisterCommand(new SelectPageModeCommand()); // ESC L
+ RegisterCommand(new SelectStandardModeCommand()); // ESC S
+ RegisterCommand(new Commands.FixedArgNoOpCommand(EscPosInterpreter.ESC + "W", 8, "ESC W set print area"));
+ RegisterCommand(new Commands.FixedArgNoOpCommand(EscPosInterpreter.ESC + "T", 1, "ESC T print direction"));
+ RegisterCommand(new Commands.FixedArgNoOpCommand(EscPosInterpreter.ESC + "$", 2, "ESC $ absolute position"));
+ RegisterCommand(new Commands.FixedArgNoOpCommand(EscPosInterpreter.GS + "$", 2, "GS $ absolute vertical"));
+ RegisterCommand(new Commands.FixedArgNoOpCommand(EscPosInterpreter.GS + "\\", 2, "GS \\ relative vertical"));
+
+ // User-defined characters
+ RegisterCommand(new DefineUserDefinedCharsCommand()); // ESC &
+ RegisterCommand(new EnableUserDefinedCharsCommand()); // ESC %
+ RegisterCommand(new CancelUserDefinedCharCommand()); // ESC ?
+ RegisterCommand(new Commands.NoOpParenCommand(EscPosInterpreter.GS + "(E", "GS ( E user setup"));
+ RegisterCommand(new Commands.NoOpParenCommand(EscPosInterpreter.GS + "(K", "GS ( K print control"));
+ RegisterCommand(new Commands.NoOpParenCommand(EscPosInterpreter.GS + "(H", "GS ( H response request"));
}
private void RegisterCommand(BaseCommand command)
@@ -110,12 +151,68 @@ private string FinalizeCommandBuffer()
#endregion
+ ///
+ /// Dispatches a real-time DLE command once enough bytes have accumulated. Returns true when the
+ /// command is complete (or unsupported and abandoned), false when more bytes are needed.
+ ///
+ // DLE real-time sub-commands (the byte following DLE) and their argument layout.
+ private const int RtEot = 0x04; // transmit real-time status
+ private const int RtEnq = 0x05; // real-time request / recover
+ private const int RtDc4 = 0x14; // real-time request (DC4)
+ private const int RtDc4GeneratePulse = 0x01; // DLE DC4 fn=1: cash-drawer pulse
+ private const int RtEotLength = 2; // EOT + n
+ private const int RtDc4PulseLength = 4; // DC4 + fn + m + t
+
+ private bool TryDispatchRealtime()
+ {
+ int first = _realtimeBuffer[0];
+ switch (first)
+ {
+ case RtEot: // DLE EOT n — real-time status
+ if (_realtimeBuffer.Count < RtEotLength) return false;
+ _printer.TransmitRealtimeStatus(_realtimeBuffer[1]);
+ return true;
+
+ case RtEnq: // DLE ENQ — real-time recover
+ _printer.RealtimeRecover();
+ return true;
+
+ case RtDc4: // DLE DC4 fn ... — real-time request
+ if (_realtimeBuffer.Count < 2) return false;
+ int fn = _realtimeBuffer[1];
+ if (fn == RtDc4GeneratePulse) // generate pulse (cash drawer): DLE DC4 1 m t
+ {
+ if (_realtimeBuffer.Count < RtDc4PulseLength) return false;
+ _printer.KickCashDrawer(_realtimeBuffer[2]);
+ return true;
+ }
+ Logger.Info($"Unsupported real-time DLE DC4 fn={fn}");
+ return true;
+
+ default:
+ Logger.Info($"Unsupported real-time DLE sequence: 0x{first:X2}");
+ return true;
+ }
+ }
+
public void Interpret(string ascii)
{
for (var i = 0; i < ascii.Length; i++)
{
var currentChar = ascii[i];
+ // Real-time commands (DLE ...) bypass the print buffer and are answered immediately.
+ if (_realtimeMode)
+ {
+ _realtimeBuffer.Add((byte)currentChar);
+ if (TryDispatchRealtime())
+ {
+ _realtimeMode = false;
+ _realtimeBuffer.Clear();
+ }
+ continue;
+ }
+
#region Command modes
if (_interpretingCommandArgs)
@@ -192,6 +289,13 @@ public void Interpret(string ascii)
#region Normal mode
+ if (currentChar == BEL)
+ {
+ // Bell — sound the buzzer/beeper
+ _printer.Buzz();
+ continue;
+ }
+
if (currentChar == HT)
{
// Horizontal tab
@@ -207,23 +311,29 @@ public void Interpret(string ascii)
if (currentChar == FF)
{
- // Print and return to Standard mode (in Page mode)
- //throw new NotImplementedException("Not supported: page mode");
+ // In page mode: print the buffered page and return to standard mode.
+ _printer.PrintText(FinalizePrintBuffer());
+ _printer.PrintPage();
continue;
}
if (currentChar == DLE)
{
- // Prefix for real-time commands (pulse, power-off, buzzer, status, etc)
- throw new NotImplementedException("Not supported: DLE / real time commands");
+ // Prefix for real-time commands (status, pulse/drawer, recover, etc).
+ _realtimeMode = true;
+ _realtimeBuffer.Clear();
+ continue;
}
if (currentChar == CAN)
{
- // Cancel print data in Page mode
- throw new NotImplementedException("Not supported: page mode");
+ // Cancel print data in the current page-mode area (page mode handled below).
+ _printer.CancelPageData();
+ continue;
}
+ // (page-mode FF/CAN are wired through the printer's page-mode methods)
+
if (currentChar == ESC || currentChar == FS || currentChar == GS)
{
// ESC, FS and GS commands - begin command mode
@@ -249,6 +359,7 @@ public void Interpret(string ascii)
}
public static readonly char NUL = Convert.ToChar(0);
+ public static readonly char BEL = Convert.ToChar(7); // 0x07
public static readonly char HT = Convert.ToChar(9);
public static readonly char LF = Convert.ToChar(10); // 0x0A
public static readonly char FF = Convert.ToChar(12); // 0x0C
diff --git a/src/CrossEscPos.Core/EscPos/EscPosParameter.cs b/src/CrossEscPos.Core/EscPos/EscPosParameter.cs
new file mode 100644
index 0000000..42dd8a1
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/EscPosParameter.cs
@@ -0,0 +1,11 @@
+namespace CrossEscPos.EscPos;
+
+///
+/// Helpers for ESC/POS command parameters, which often accept a value either as a raw number (e.g. 0)
+/// or as its ASCII digit ('0' = 48).
+///
+internal static class EscPosParameter
+{
+ /// Folds an ASCII digit ('0'..'9') to its numeric value; leaves other bytes unchanged.
+ public static int Digit(int n) => n is >= '0' and <= '9' ? n - '0' : n;
+}
diff --git a/src/CrossEscPos.Core/EscPos/FontSelection.cs b/src/CrossEscPos.Core/EscPos/FontSelection.cs
new file mode 100644
index 0000000..5cbf0e5
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/FontSelection.cs
@@ -0,0 +1,24 @@
+using Ardalis.SmartEnum;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.EscPos;
+
+/// ESC M font codes mapped to (numeric/ASCII digit, or 'a'/'b').
+public sealed class FontSelection : SmartEnum
+{
+ public static readonly FontSelection FontA = new(nameof(FontA), 0, PrinterFont.FontA);
+ public static readonly FontSelection FontB = new(nameof(FontB), 1, PrinterFont.FontB);
+ public static readonly FontSelection FontC = new(nameof(FontC), 2, PrinterFont.FontC);
+ public static readonly FontSelection FontD = new(nameof(FontD), 3, PrinterFont.FontD);
+ public static readonly FontSelection FontE = new(nameof(FontE), 4, PrinterFont.FontE);
+ public static readonly FontSelection SpecialA = new(nameof(SpecialA), 'a', PrinterFont.SpecialFontA);
+ public static readonly FontSelection SpecialB = new(nameof(SpecialB), 'b', PrinterFont.SpecialFontB);
+
+ public PrinterFont Font { get; }
+
+ private FontSelection(string name, int value, PrinterFont font) : base(name, value) => Font = font;
+
+ /// Resolves an ESC M parameter (numeric or ASCII form); null if unsupported.
+ public static FontSelection? FromParameter(int n)
+ => TryFromValue(EscPosParameter.Digit(n), out var selection) ? selection : null;
+}
diff --git a/src/CrossEscPos.Core/EscPos/JustificationMode.cs b/src/CrossEscPos.Core/EscPos/JustificationMode.cs
new file mode 100644
index 0000000..2b98004
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/JustificationMode.cs
@@ -0,0 +1,21 @@
+using Ardalis.SmartEnum;
+using CrossEscPos.Emulator.Enums;
+
+namespace CrossEscPos.EscPos;
+
+/// ESC a justification codes mapped to (numeric or ASCII form).
+public sealed class JustificationMode : SmartEnum
+{
+ public static readonly JustificationMode Left = new(nameof(Left), 0, TextJustification.Left);
+ public static readonly JustificationMode Center = new(nameof(Center), 1, TextJustification.Center);
+ public static readonly JustificationMode Right = new(nameof(Right), 2, TextJustification.Right);
+
+ public TextJustification Justification { get; }
+
+ private JustificationMode(string name, int value, TextJustification justification) : base(name, value)
+ => Justification = justification;
+
+ /// Resolves an ESC a parameter (numeric or ASCII digit); null if unsupported.
+ public static JustificationMode? FromParameter(int n)
+ => TryFromValue(EscPosParameter.Digit(n), out var mode) ? mode : null;
+}
diff --git a/src/CrossEscPos.Core/EscPos/ParenCommand.cs b/src/CrossEscPos.Core/EscPos/ParenCommand.cs
new file mode 100644
index 0000000..1817c55
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/ParenCommand.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+
+namespace CrossEscPos.EscPos;
+
+///
+/// Base for the length-prefixed "ESC ( X" / "GS ( X" command family: after the prefix come
+/// pL pH (little-endian byte count) followed by that many parameter bytes. Subclasses read
+/// in Execute .
+///
+public abstract class ParenCommand : BaseCommand
+{
+ public override bool HasArgs => true;
+
+ private int _phase; // 0 = pL, 1 = pH, 2 = params
+ private int _pL;
+ private int _remaining;
+
+ protected readonly List Params = new();
+
+ public override void Reset()
+ {
+ _phase = 0;
+ _pL = 0;
+ _remaining = 0;
+ Params.Clear();
+ }
+
+ public override bool InterpretNextChar(char c)
+ {
+ switch (_phase)
+ {
+ case 0:
+ _pL = (byte)c;
+ _phase = 1;
+ return true;
+ case 1:
+ _remaining = _pL + ((byte)c << 8);
+ _phase = 2;
+ return _remaining > 0;
+ default:
+ Params.Add((byte)c);
+ _remaining--;
+ return _remaining > 0;
+ }
+ }
+}
diff --git a/src/CrossEscPos.Core/EscPos/QrErrorCorrectionLevel.cs b/src/CrossEscPos.Core/EscPos/QrErrorCorrectionLevel.cs
new file mode 100644
index 0000000..559eb64
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/QrErrorCorrectionLevel.cs
@@ -0,0 +1,22 @@
+using Ardalis.SmartEnum;
+using QRCoder;
+
+namespace CrossEscPos.EscPos;
+
+/// GS ( k fn 69 QR error-correction codes mapped to QRCoder's .
+public sealed class QrErrorCorrectionLevel : SmartEnum
+{
+ public static readonly QrErrorCorrectionLevel Low = new(nameof(Low), 0, QRCodeGenerator.ECCLevel.L);
+ public static readonly QrErrorCorrectionLevel Medium = new(nameof(Medium), 1, QRCodeGenerator.ECCLevel.M);
+ public static readonly QrErrorCorrectionLevel Quartile = new(nameof(Quartile), 2, QRCodeGenerator.ECCLevel.Q);
+ public static readonly QrErrorCorrectionLevel High = new(nameof(High), 3, QRCodeGenerator.ECCLevel.H);
+
+ public QRCodeGenerator.ECCLevel Level { get; }
+
+ private QrErrorCorrectionLevel(string name, int value, QRCodeGenerator.ECCLevel level) : base(name, value)
+ => Level = level;
+
+ /// Resolves a QR EC parameter (ASCII '0'..'3' or numeric), defaulting to Medium.
+ public static QRCodeGenerator.ECCLevel FromParameter(int n)
+ => (TryFromValue(EscPosParameter.Digit(n), out var level) ? level : Medium).Level;
+}
diff --git a/src/CrossEscPos.Core/EscPos/TwoDSymbolFunction.cs b/src/CrossEscPos.Core/EscPos/TwoDSymbolFunction.cs
new file mode 100644
index 0000000..4cb8a8a
--- /dev/null
+++ b/src/CrossEscPos.Core/EscPos/TwoDSymbolFunction.cs
@@ -0,0 +1,40 @@
+using System;
+using Ardalis.SmartEnum;
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.EscPos;
+
+///
+/// GS ( k 2D-symbol functions (the fn parameter). Each function carries the action it performs
+/// on the printer, so dispatching a 2D command is a lookup + call rather than a switch on fn .
+/// The action receives the printer, the symbol family (cn ) and the raw parameter bytes.
+///
+public sealed class TwoDSymbolFunction : SmartEnum
+{
+ public static readonly TwoDSymbolFunction SelectModel = new(nameof(SelectModel), 65,
+ (_, _, p) => Logger.Info($"2D select model/options: {(p.Length > 0 ? (int)p[0] : 0)}"));
+
+ public static readonly TwoDSymbolFunction SetModuleSize = new(nameof(SetModuleSize), 67,
+ (printer, _, p) => { if (p.Length > 0) printer.SetQrModuleSize((byte)p[0]); });
+
+ public static readonly TwoDSymbolFunction SetErrorCorrection = new(nameof(SetErrorCorrection), 69,
+ (printer, _, p) => { if (p.Length > 0) printer.SetQrErrorCorrection(QrErrorCorrectionLevel.FromParameter((byte)p[0])); });
+
+ public static readonly TwoDSymbolFunction StoreData = new(nameof(StoreData), 80,
+ (printer, cn, p) => printer.Store2DData(cn, p.Length > 1 ? p.Substring(1) : string.Empty));
+
+ public static readonly TwoDSymbolFunction PrintSymbol = new(nameof(PrintSymbol), 81,
+ (printer, _, _) => printer.Print2D());
+
+ public static readonly TwoDSymbolFunction TransmitSize = new(nameof(TransmitSize), 82,
+ (_, _, _) => { /* size info — not applicable to an emulator */ });
+
+ private readonly Action _apply;
+
+ private TwoDSymbolFunction(string name, int value, Action apply) : base(name, value)
+ => _apply = apply;
+
+ /// Performs this function against for symbol family .
+ public void Apply(ReceiptPrinter printer, int cn, string parameters) => _apply(printer, cn, parameters);
+}
diff --git a/src/CrossEscPos.Core/Logging/Logger.cs b/src/CrossEscPos.Core/Logging/Logger.cs
new file mode 100644
index 0000000..8c55ee5
--- /dev/null
+++ b/src/CrossEscPos.Core/Logging/Logger.cs
@@ -0,0 +1,108 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Logging;
+
+public static class Logger
+{
+ private static readonly Lock FileLock = new();
+ private static bool _globalHandlersInstalled;
+
+ /// Absolute path of the rolling log file (errors and unhandled exceptions land here).
+ public static string LogFilePath { get; } = ResolveLogFilePath();
+
+ public static void Info(params object[] values)
+ {
+ PrintMessage("Info", values);
+ }
+
+ public static void Exception(Exception ex, string? message = null)
+ {
+ PrintMessage("Exception", new object[] { message ?? string.Empty, ex });
+ }
+
+ ///
+ /// Registers process-wide handlers so no exception goes unlogged — including ones thrown on
+ /// background threads (e.g. the printer's read/write loops) and faulted tasks that would
+ /// otherwise tear down the process silently. Call once, as early as possible at startup.
+ ///
+ public static void InstallGlobalHandlers()
+ {
+ if (_globalHandlersInstalled)
+ return;
+ _globalHandlersInstalled = true;
+
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ {
+ if (e.ExceptionObject is Exception ex)
+ Exception(ex, $"Unhandled exception (terminating={e.IsTerminating})");
+ else
+ PrintMessage("Exception", new[] { $"Unhandled non-Exception error (terminating={e.IsTerminating}): {e.ExceptionObject}" });
+ };
+
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ Exception(e.Exception, "Unobserved task exception");
+ e.SetObserved(); // keep a faulted background task from terminating the process.
+ };
+ }
+
+ private static void PrintMessage(string prefix, object[] values)
+ {
+ var combinedMessage = $"[{prefix}] {FormatValues(values)}";
+ Console.WriteLine(combinedMessage);
+ AppendToFile(combinedMessage);
+ }
+
+ private static void AppendToFile(string message)
+ {
+ try
+ {
+ lock (FileLock)
+ File.AppendAllText(LogFilePath, $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
+ }
+ catch
+ {
+ // Logging must never throw — if the file can't be written, the console line above stands.
+ }
+ }
+
+ private static string ResolveLogFilePath()
+ {
+ try
+ {
+ var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (string.IsNullOrEmpty(baseDir))
+ baseDir = Path.GetTempPath();
+ var dir = Path.Combine(baseDir, "CrossEscPosEmulator", "logs");
+ Directory.CreateDirectory(dir);
+ return Path.Combine(dir, "app.log");
+ }
+ catch
+ {
+ return Path.Combine(Path.GetTempPath(), "CrossEscPosEmulator.log");
+ }
+ }
+
+ private static string FormatValues(params object[] values)
+ {
+ var sb = new StringBuilder();
+
+ foreach (var val in values)
+ {
+ var asString = val.ToString();
+
+ if (String.IsNullOrWhiteSpace(asString))
+ continue;
+
+ if (sb.Length > 0)
+ sb.Append(' ');
+ sb.Append(asString);
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/Utils/ByteExtensions.cs b/src/CrossEscPos.Core/Utils/ByteExtensions.cs
similarity index 78%
rename from Utils/ByteExtensions.cs
rename to src/CrossEscPos.Core/Utils/ByteExtensions.cs
index 56f4ba8..308d432 100644
--- a/Utils/ByteExtensions.cs
+++ b/src/CrossEscPos.Core/Utils/ByteExtensions.cs
@@ -1,4 +1,4 @@
-namespace ReceiptPrinterEmulator.Utils;
+namespace CrossEscPos.Utils;
public static class ByteExtensions
{
diff --git a/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/OFL.txt b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/OFL.txt
new file mode 100644
index 0000000..5ceee00
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bold.ttf b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bold.ttf
new file mode 100644
index 0000000..cd1bee0
Binary files /dev/null and b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bold.ttf differ
diff --git a/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bolditalic.ttf b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bolditalic.ttf
new file mode 100644
index 0000000..6ff8032
Binary files /dev/null and b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-bolditalic.ttf differ
diff --git a/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-italic.ttf b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-italic.ttf
new file mode 100644
index 0000000..71429b0
Binary files /dev/null and b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono-italic.ttf differ
diff --git a/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono.ttf b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono.ttf
new file mode 100644
index 0000000..711830e
Binary files /dev/null and b/src/CrossEscPos.Rendering.ImageSharp/Assets/Fonts/receipt-mono.ttf differ
diff --git a/src/CrossEscPos.Rendering.ImageSharp/CrossEscPos.Rendering.ImageSharp.csproj b/src/CrossEscPos.Rendering.ImageSharp/CrossEscPos.Rendering.ImageSharp.csproj
new file mode 100644
index 0000000..7acfe9e
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/CrossEscPos.Rendering.ImageSharp.csproj
@@ -0,0 +1,34 @@
+
+
+
+ true
+ ImageSharp implementation of the CrossEscPos rendering abstraction (canvas, image,
+ image factory, typeface provider and PNG encoder). A fully managed, WASM-safe render backend —
+ no native dependencies — mirroring the SkiaSharp backend so the interpreter produces identical
+ output. Fonts are embedded.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageEncoder.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageEncoder.cs
new file mode 100644
index 0000000..cc278d1
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageEncoder.cs
@@ -0,0 +1,19 @@
+using System.IO;
+using CrossEscPos.Graphics;
+using SixLabors.ImageSharp;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+/// ImageSharp — encodes receipt images to PNG.
+public sealed class ImageSharpImageEncoder : IImageEncoder
+{
+ public void EncodePng(IReceiptImage image, Stream destination)
+ => ((ImageSharpReceiptImage)image).Image.SaveAsPng(destination);
+
+ public byte[] EncodePng(IReceiptImage image)
+ {
+ using var stream = new MemoryStream();
+ EncodePng(image, stream);
+ return stream.ToArray();
+ }
+}
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageFactory.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageFactory.cs
new file mode 100644
index 0000000..c4c40e0
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpImageFactory.cs
@@ -0,0 +1,36 @@
+using System;
+using CrossEscPos.Graphics;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+/// ImageSharp — creates Image<Rgba32>-backed images and canvases.
+public sealed class ImageSharpImageFactory : IReceiptImageFactory
+{
+ public IReceiptImage Create(int width, int height, ReceiptColor fill)
+ {
+ var image = new Image(
+ Math.Max(1, width),
+ Math.Max(1, height),
+ ImageSharpReceiptImage.ToColor(fill));
+ return new ImageSharpReceiptImage(image);
+ }
+
+ public IReceiptImage FromPixels(int width, int height, ReceiptColor[] rowMajorPixels)
+ {
+ int w = Math.Max(1, width);
+ int h = Math.Max(1, height);
+ var image = new Image(w, h);
+ int count = Math.Min(rowMajorPixels.Length, w * h);
+ for (int i = 0; i < count; i++)
+ {
+ var c = rowMajorPixels[i];
+ image[i % w, i / w] = new Rgba32(c.R, c.G, c.B, c.A);
+ }
+ return new ImageSharpReceiptImage(image);
+ }
+
+ public IReceiptCanvas CreateCanvas(IReceiptImage image)
+ => new ImageSharpReceiptCanvas((ImageSharpReceiptImage)image);
+}
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptCanvas.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptCanvas.cs
new file mode 100644
index 0000000..96657aa
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptCanvas.cs
@@ -0,0 +1,119 @@
+using System.Collections.Generic;
+using System.Numerics;
+using CrossEscPos.Graphics;
+using SixLabors.Fonts;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+///
+/// An that draws onto an 's backing
+/// . ImageSharp has no canvas-level save/restore, so the transform is kept
+/// as a stack here and applied per draw op via
+/// /SetDrawingTransform , mirroring the Skia canvas semantics
+/// (translate outermost, scale inner).
+///
+public sealed class ImageSharpReceiptCanvas : IReceiptCanvas
+{
+ private readonly ImageSharpReceiptImage _target;
+ private Matrix3x2 _current = Matrix3x2.Identity;
+ private readonly List _stack = new();
+
+ public ImageSharpReceiptCanvas(ImageSharpReceiptImage target) => _target = target;
+
+ private static Color ToColor(ReceiptColor c) => ImageSharpReceiptImage.ToColor(c);
+
+ private void Draw(System.Action draw)
+ {
+ _target.Image.Mutate(ctx =>
+ {
+ if (_current != Matrix3x2.Identity)
+ ctx.SetDrawingTransform(_current);
+ draw(ctx);
+ });
+ }
+
+ public void Clear(ReceiptColor color)
+ => _target.Image.Mutate(ctx => ctx.Fill(ToColor(color)));
+
+ public void DrawText(string text, float x, float baselineY, IReceiptFont font, ReceiptColor color)
+ {
+ var slFont = ((ImageSharpReceiptFont)font).Font;
+ // ImageSharp positions text by the layout top-left, so convert baseline → top.
+ float ascenderPx = -font.Metrics.Ascent; // Ascent is negative; ascenderPx is positive.
+ float top = baselineY - ascenderPx;
+ Draw(ctx => ctx.DrawText(
+ new RichTextOptions(slFont) { Origin = new PointF(x, top) },
+ text,
+ ToColor(color)));
+ }
+
+ public void DrawRect(ReceiptRect rect, ReceiptColor color)
+ {
+ // Antialiasing OFF so barcode/QR modules keep crisp, scannable edges — matches the Skia backend
+ // (IsAntialias = false). The current transform is carried in the options (explicit DrawingOptions
+ // otherwise ignore the context transform set in Draw()).
+ var options = new DrawingOptions
+ {
+ GraphicsOptions = new GraphicsOptions { Antialias = false },
+ Transform = _current,
+ };
+ _target.Image.Mutate(ctx => ctx.Fill(
+ options,
+ ToColor(color),
+ new RectangularPolygon(rect.X, rect.Y, rect.Width, rect.Height)));
+ }
+
+ public void DrawLine(float x0, float y0, float x1, float y1, ReceiptColor color, float strokeWidth)
+ => Draw(ctx => ctx.DrawLine(
+ ToColor(color), strokeWidth, new PointF(x0, y0), new PointF(x1, y1)));
+
+ public void DrawImage(IReceiptImage image, float x, float y)
+ {
+ var src = ((ImageSharpReceiptImage)image).Image;
+ Draw(ctx => ctx.DrawImage(src, new Point((int)x, (int)y), 1f));
+ }
+
+ public void DrawImage(IReceiptImage image, ReceiptRect dest)
+ {
+ var src = ((ImageSharpReceiptImage)image).Image;
+ using var scaled = src.Clone(c => c.Resize(
+ System.Math.Max(1, (int)dest.Width),
+ System.Math.Max(1, (int)dest.Height)));
+ Draw(ctx => ctx.DrawImage(scaled, new Point((int)dest.X, (int)dest.Y), 1f));
+ }
+
+ public int Save()
+ {
+ _stack.Add(_current);
+ return _stack.Count;
+ }
+
+ public void Translate(float dx, float dy)
+ => _current = Matrix3x2.CreateTranslation(dx, dy) * _current;
+
+ public void Scale(float sx, float sy)
+ => _current = Matrix3x2.CreateScale(sx, sy) * _current;
+
+ public void RestoreToCount(int count)
+ {
+ while (_stack.Count >= count && _stack.Count > 0)
+ {
+ _current = _stack[^1];
+ _stack.RemoveAt(_stack.Count - 1);
+ }
+ }
+
+ public void Flush()
+ {
+ }
+
+ // Does not own the backing image; nothing to dispose.
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptFont.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptFont.cs
new file mode 100644
index 0000000..c698949
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptFont.cs
@@ -0,0 +1,55 @@
+using CrossEscPos.Graphics;
+using SixLabors.Fonts;
+using FontMetrics = CrossEscPos.Graphics.FontMetrics;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+/// An backed by a SixLabors at a fixed pixel size.
+public sealed class ImageSharpReceiptFont : IReceiptFont
+{
+ internal Font Font { get; }
+
+ public ImageSharpReceiptFont(Font font) => Font = font;
+
+ public float Size => Font.Size;
+
+ public FontMetrics Metrics
+ {
+ get
+ {
+ var vertical = Font.FontMetrics.VerticalMetrics;
+ float unitsPerEm = Font.FontMetrics.UnitsPerEm;
+ // Skia convention: Ascent negative, Descent positive.
+ // Ascender is positive in font units → negate for a negative Ascent.
+ // Descender is negative in font units → negate for a positive Descent.
+ float ascent = -(Font.Size * vertical.Ascender / unitsPerEm);
+ float descent = -(Font.Size * vertical.Descender / unitsPerEm);
+ return new FontMetrics(ascent, descent);
+ }
+ }
+
+ private float? _sentinelAdvance;
+
+ // Advance width — matches SkiaSharp's SKFont.MeasureText, which the receipt layout relies on for
+ // justification, multi-run advance, and underline length. Two SixLabors quirks are corrected here:
+ // * MeasureSize returns the ink bounding box (drops side bearings) — MeasureAdvance is the advance.
+ // * MeasureAdvance trims *trailing* whitespace, but Skia (and the IReceiptFont contract) counts it;
+ // receipts pad columns with trailing spaces. Appending a non-whitespace sentinel makes those
+ // spaces interior, then we subtract the sentinel's own advance. The receipt font is monospace
+ // with no kerning, so this reproduces Skia's advance exactly.
+ public float MeasureText(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return 0f;
+
+ const string sentinel = ".";
+ var options = new TextOptions(Font);
+ _sentinelAdvance ??= TextMeasurer.MeasureAdvance(sentinel, options).Width;
+ return TextMeasurer.MeasureAdvance(text + sentinel, options).Width - _sentinelAdvance.Value;
+ }
+
+ // SixLabors.Fonts.Font is not IDisposable; the font family is shared/cached by the provider.
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptImage.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptImage.cs
new file mode 100644
index 0000000..8fd2b32
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpReceiptImage.cs
@@ -0,0 +1,22 @@
+using CrossEscPos.Graphics;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+/// An backed by an .
+public sealed class ImageSharpReceiptImage : IReceiptImage
+{
+ internal Image Image { get; }
+
+ public ImageSharpReceiptImage(Image image) => Image = image;
+
+ public int Width => Image.Width;
+ public int Height => Image.Height;
+
+ public IReceiptImage Copy() => new ImageSharpReceiptImage(Image.Clone());
+
+ public void Dispose() => Image.Dispose();
+
+ internal static Color ToColor(ReceiptColor c) => Color.FromRgba(c.R, c.G, c.B, c.A);
+}
diff --git a/src/CrossEscPos.Rendering.ImageSharp/ImageSharpTypefaceProvider.cs b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpTypefaceProvider.cs
new file mode 100644
index 0000000..4c7022a
--- /dev/null
+++ b/src/CrossEscPos.Rendering.ImageSharp/ImageSharpTypefaceProvider.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Reflection;
+using CrossEscPos.Graphics;
+using SixLabors.Fonts;
+
+namespace CrossEscPos.Rendering.ImageSharp;
+
+///
+/// ImageSharp/SixLabors . Resolves and caches instances.
+///
+/// To keep receipt output identical across platforms, a monospace font (JetBrains Mono, OFL) is bundled
+/// as an embedded resource and loaded into a shared from the assembly
+/// manifest — 100% managed (SixLabors.Fonts), so it also works inside the browser sandbox. If the
+/// embedded font is unavailable, a monospace family is resolved from the OS with synthesized emphasis.
+///
+public sealed class ImageSharpTypefaceProvider : ITypefaceProvider
+{
+ // Embedded JetBrains Mono weight files. Real bold/italic glyphs are preferred over synthesized emphasis.
+ private const string RegularFile = "receipt-mono.ttf";
+ private const string BoldFile = "receipt-mono-bold.ttf";
+ private const string ItalicFile = "receipt-mono-italic.ttf";
+ private const string BoldItalicFile = "receipt-mono-bolditalic.ttf";
+
+ // Fallback families tried in order when no embedded font is present.
+ private static readonly string[] MonospaceFallbacks =
+ {
+ "Consolas", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Courier New", "monospace"
+ };
+
+ private static readonly Assembly ResourceAssembly = typeof(ImageSharpTypefaceProvider).Assembly;
+
+ // One shared collection holds the embedded weights.
+ private static readonly FontCollection Collection = new();
+
+ // The embedded families (loaded lazily on first use), keyed by the weight file we added.
+ private static readonly ConcurrentDictionary EmbeddedFamilies = new();
+ private static readonly object EmbeddedLock = new();
+ private static bool _embeddedLoaded;
+
+ private readonly ConcurrentDictionary<(string family, bool bold, bool italic, float size), Font> _cache = new();
+
+ public IReceiptFont GetFont(string family, bool bold, bool italic, float sizePx)
+ {
+ var font = _cache.GetOrAdd((family, bold, italic, sizePx),
+ key => Resolve(key.family, key.bold, key.italic, key.size));
+ return new ImageSharpReceiptFont(font);
+ }
+
+ private static Font Resolve(string family, bool bold, bool italic, float sizePx)
+ {
+ // 1. Embedded font weight — guarantees identical output everywhere.
+ if (LoadEmbedded(bold, italic) is FontFamily embedded)
+ return embedded.CreateFont(sizePx, FontStyle.Regular);
+
+ // 2. Requested family, then the monospace fallback chain (synthesizing bold/italic).
+ var style = (bold, italic) switch
+ {
+ (true, true) => FontStyle.BoldItalic,
+ (true, false) => FontStyle.Bold,
+ (false, true) => FontStyle.Italic,
+ _ => FontStyle.Regular
+ };
+
+ foreach (var candidate in Prepend(family, MonospaceFallbacks))
+ {
+ if (SystemFonts.Collection.TryGet(candidate, out var sysFamily))
+ return sysFamily.CreateFont(sizePx, style);
+ }
+
+ // 3. Any available system family (never returns null on a real OS).
+ foreach (var any in SystemFonts.Families)
+ return any.CreateFont(sizePx, style);
+
+ throw new InvalidOperationException(
+ "No fonts available: embedded resource missing and no system fonts found.");
+ }
+
+ private static FontFamily? LoadEmbedded(bool bold, bool italic)
+ {
+ EnsureEmbeddedLoaded();
+
+ var file = (bold, italic) switch
+ {
+ (true, true) => BoldItalicFile,
+ (true, false) => BoldFile,
+ (false, true) => ItalicFile,
+ _ => RegularFile
+ };
+
+ // Try the exact weight, then fall back to the regular weight.
+ if (EmbeddedFamilies.TryGetValue(file, out var exact))
+ return exact;
+ if (EmbeddedFamilies.TryGetValue(RegularFile, out var regular))
+ return regular;
+ return null;
+ }
+
+ private static void EnsureEmbeddedLoaded()
+ {
+ if (_embeddedLoaded)
+ return;
+
+ lock (EmbeddedLock)
+ {
+ if (_embeddedLoaded)
+ return;
+
+ foreach (var file in new[] { RegularFile, BoldFile, ItalicFile, BoldItalicFile })
+ {
+ if (AddEmbeddedResource(file) is FontFamily family)
+ EmbeddedFamilies[file] = family;
+ }
+
+ _embeddedLoaded = true;
+ }
+ }
+
+ private static FontFamily? AddEmbeddedResource(string fileName)
+ {
+ try
+ {
+ var resourceName = Array.Find(
+ ResourceAssembly.GetManifestResourceNames(),
+ n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
+ if (resourceName is null)
+ return null;
+
+ using var stream = ResourceAssembly.GetManifestResourceStream(resourceName);
+ if (stream is null)
+ return null;
+
+ return Collection.Add(stream);
+ }
+ catch
+ {
+ // Fall through to system fonts.
+ return null;
+ }
+ }
+
+ private static IEnumerable Prepend(string first, string[] rest)
+ {
+ yield return first;
+ foreach (var item in rest)
+ if (!string.Equals(item, first, StringComparison.OrdinalIgnoreCase))
+ yield return item;
+ }
+}
diff --git a/src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt
new file mode 100644
index 0000000..5ceee00
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bold.ttf b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bold.ttf
new file mode 100644
index 0000000..cd1bee0
Binary files /dev/null and b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bold.ttf differ
diff --git a/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bolditalic.ttf b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bolditalic.ttf
new file mode 100644
index 0000000..6ff8032
Binary files /dev/null and b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-bolditalic.ttf differ
diff --git a/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-italic.ttf b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-italic.ttf
new file mode 100644
index 0000000..71429b0
Binary files /dev/null and b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono-italic.ttf differ
diff --git a/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono.ttf b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono.ttf
new file mode 100644
index 0000000..711830e
Binary files /dev/null and b/src/CrossEscPos.Rendering.Skia/Assets/Fonts/receipt-mono.ttf differ
diff --git a/src/CrossEscPos.Rendering.Skia/CrossEscPos.Rendering.Skia.csproj b/src/CrossEscPos.Rendering.Skia/CrossEscPos.Rendering.Skia.csproj
new file mode 100644
index 0000000..4a658f9
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/CrossEscPos.Rendering.Skia.csproj
@@ -0,0 +1,31 @@
+
+
+
+ true
+ SkiaSharp implementation of the CrossEscPos rendering abstraction (canvas, image,
+ image factory, typeface provider and PNG encoder). The default render backend; works on desktop
+ and in the browser (Avalonia ships the SkiaSharp WASM natives). Fonts are embedded.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaImageEncoder.cs b/src/CrossEscPos.Rendering.Skia/SkiaImageEncoder.cs
new file mode 100644
index 0000000..26127cd
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaImageEncoder.cs
@@ -0,0 +1,23 @@
+using System.IO;
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+/// SkiaSharp — encodes receipt images to PNG.
+public sealed class SkiaImageEncoder : IImageEncoder
+{
+ public void EncodePng(IReceiptImage image, Stream destination)
+ {
+ using var skImage = SKImage.FromBitmap(((SkiaReceiptImage)image).Bitmap);
+ using var data = skImage.Encode(SKEncodedImageFormat.Png, 100);
+ data.SaveTo(destination);
+ }
+
+ public byte[] EncodePng(IReceiptImage image)
+ {
+ using var stream = new MemoryStream();
+ EncodePng(image, stream);
+ return stream.ToArray();
+ }
+}
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaImageFactory.cs b/src/CrossEscPos.Rendering.Skia/SkiaImageFactory.cs
new file mode 100644
index 0000000..ce8b6c8
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaImageFactory.cs
@@ -0,0 +1,31 @@
+using System;
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+/// SkiaSharp — creates SKBitmap-backed images and canvases.
+public sealed class SkiaImageFactory : IReceiptImageFactory
+{
+ public IReceiptImage Create(int width, int height, ReceiptColor fill)
+ {
+ var bmp = new SKBitmap(Math.Max(1, width), Math.Max(1, height));
+ using (var canvas = new SKCanvas(bmp))
+ canvas.Clear(SkiaReceiptImage.ToSk(fill));
+ return new SkiaReceiptImage(bmp);
+ }
+
+ public IReceiptImage FromPixels(int width, int height, ReceiptColor[] rowMajorPixels)
+ {
+ var bmp = new SKBitmap(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Opaque));
+ var pixels = new SKColor[width * height];
+ int count = Math.Min(rowMajorPixels.Length, pixels.Length);
+ for (int i = 0; i < count; i++)
+ pixels[i] = SkiaReceiptImage.ToSk(rowMajorPixels[i]);
+ bmp.Pixels = pixels;
+ return new SkiaReceiptImage(bmp);
+ }
+
+ public IReceiptCanvas CreateCanvas(IReceiptImage image)
+ => new SkiaReceiptCanvas(new SKCanvas(((SkiaReceiptImage)image).Bitmap));
+}
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaReceiptCanvas.cs b/src/CrossEscPos.Rendering.Skia/SkiaReceiptCanvas.cs
new file mode 100644
index 0000000..7bcb9e3
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaReceiptCanvas.cs
@@ -0,0 +1,69 @@
+using System;
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+///
+/// An backed by an . Per-primitive anti-aliasing
+/// matches the original direct-Skia receipt rendering exactly (see ).
+///
+public sealed class SkiaReceiptCanvas : IReceiptCanvas
+{
+ private readonly SKCanvas _canvas;
+ private readonly bool _ownsCanvas;
+
+ public SkiaReceiptCanvas(SKCanvas canvas, bool ownsCanvas = true)
+ {
+ _canvas = canvas;
+ _ownsCanvas = ownsCanvas;
+ }
+
+ public void Clear(ReceiptColor color) => _canvas.Clear(SkiaReceiptImage.ToSk(color));
+
+ public void DrawText(string text, float x, float baselineY, IReceiptFont font, ReceiptColor color)
+ {
+ using var paint = new SKPaint { Color = SkiaReceiptImage.ToSk(color), IsAntialias = true };
+ _canvas.DrawText(text, x, baselineY, ((SkiaReceiptFont)font).Font, paint);
+ }
+
+ public void DrawRect(ReceiptRect rect, ReceiptColor color)
+ {
+ using var paint = new SKPaint { Color = SkiaReceiptImage.ToSk(color), IsAntialias = false };
+ _canvas.DrawRect(SKRect.Create(rect.X, rect.Y, rect.Width, rect.Height), paint);
+ }
+
+ public void DrawLine(float x0, float y0, float x1, float y1, ReceiptColor color, float strokeWidth)
+ {
+ using var paint = new SKPaint
+ {
+ Color = SkiaReceiptImage.ToSk(color),
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = strokeWidth
+ };
+ _canvas.DrawLine(x0, y0, x1, y1, paint);
+ }
+
+ public void DrawImage(IReceiptImage image, float x, float y)
+ => _canvas.DrawBitmap(((SkiaReceiptImage)image).Bitmap, x, y);
+
+ public void DrawImage(IReceiptImage image, ReceiptRect dest)
+ {
+ using var paint = new SKPaint { IsAntialias = true };
+ _canvas.DrawBitmap(((SkiaReceiptImage)image).Bitmap,
+ SKRect.Create(dest.X, dest.Y, dest.Width, dest.Height), paint);
+ }
+
+ public int Save() => _canvas.Save();
+ public void Translate(float dx, float dy) => _canvas.Translate(dx, dy);
+ public void Scale(float sx, float sy) => _canvas.Scale(sx, sy);
+ public void RestoreToCount(int count) => _canvas.RestoreToCount(count);
+ public void Flush() => _canvas.Flush();
+
+ public void Dispose()
+ {
+ if (_ownsCanvas)
+ _canvas.Dispose();
+ }
+}
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaReceiptFont.cs b/src/CrossEscPos.Rendering.Skia/SkiaReceiptFont.cs
new file mode 100644
index 0000000..d4fed00
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaReceiptFont.cs
@@ -0,0 +1,28 @@
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+/// An backed by an at a fixed pixel size.
+public sealed class SkiaReceiptFont : IReceiptFont
+{
+ public SKFont Font { get; }
+
+ public SkiaReceiptFont(SKFont font) => Font = font;
+
+ public float Size => Font.Size;
+
+ public FontMetrics Metrics
+ {
+ get
+ {
+ var m = Font.Metrics; // Ascent negative, Descent positive — preserved as-is.
+ return new FontMetrics(m.Ascent, m.Descent);
+ }
+ }
+
+ public float MeasureText(string text) => Font.MeasureText(text);
+
+ // Disposes only the SKFont; the underlying typeface is shared/cached by the provider.
+ public void Dispose() => Font.Dispose();
+}
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaReceiptImage.cs b/src/CrossEscPos.Rendering.Skia/SkiaReceiptImage.cs
new file mode 100644
index 0000000..4d10dbf
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaReceiptImage.cs
@@ -0,0 +1,21 @@
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+/// An backed by an .
+public sealed class SkiaReceiptImage : IReceiptImage
+{
+ public SKBitmap Bitmap { get; }
+
+ public SkiaReceiptImage(SKBitmap bitmap) => Bitmap = bitmap;
+
+ public int Width => Bitmap.Width;
+ public int Height => Bitmap.Height;
+
+ public IReceiptImage Copy() => new SkiaReceiptImage(Bitmap.Copy());
+
+ public void Dispose() => Bitmap.Dispose();
+
+ internal static SKColor ToSk(ReceiptColor c) => new(c.R, c.G, c.B, c.A);
+}
diff --git a/src/CrossEscPos.Rendering.Skia/SkiaTypefaceProvider.cs b/src/CrossEscPos.Rendering.Skia/SkiaTypefaceProvider.cs
new file mode 100644
index 0000000..49270fb
--- /dev/null
+++ b/src/CrossEscPos.Rendering.Skia/SkiaTypefaceProvider.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using CrossEscPos.Graphics;
+using SkiaSharp;
+
+namespace CrossEscPos.Rendering.Skia;
+
+///
+/// SkiaSharp . Resolves and caches instances.
+///
+/// To keep receipt output identical across platforms (the original WPF app rendered with Windows-only
+/// families like "MS Gothic"), a monospace font (JetBrains Mono, OFL) is bundled as an embedded
+/// resource and loaded via — no file IO, so this also
+/// works inside the browser sandbox. If the embedded font is unavailable, a monospace family is
+/// resolved from the OS.
+///
+public sealed class SkiaTypefaceProvider : ITypefaceProvider
+{
+ // Embedded JetBrains Mono weight files (logical resource names set in the csproj). Real bold/italic
+ // glyphs are preferred over Skia's synthesized emphasis.
+ private const string RegularFile = "receipt-mono.ttf";
+ private const string BoldFile = "receipt-mono-bold.ttf";
+ private const string ItalicFile = "receipt-mono-italic.ttf";
+ private const string BoldItalicFile = "receipt-mono-bolditalic.ttf";
+
+ // Fallback families tried in order when no embedded font is present.
+ private static readonly string[] MonospaceFallbacks =
+ {
+ "Consolas", "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Courier New", "monospace"
+ };
+
+ private static readonly Assembly ResourceAssembly = typeof(SkiaTypefaceProvider).Assembly;
+
+ private readonly ConcurrentDictionary<(string family, bool bold, bool italic), SKTypeface> _cache = new();
+
+ public IReceiptFont GetFont(string family, bool bold, bool italic, float sizePx)
+ {
+ var typeface = _cache.GetOrAdd((family, bold, italic), key => Resolve(key.family, key.bold, key.italic));
+ return new SkiaReceiptFont(new SKFont(typeface, sizePx));
+ }
+
+ private static SKTypeface Resolve(string family, bool bold, bool italic)
+ {
+ // 1. Embedded font weight — guarantees identical output everywhere.
+ var embedded = LoadEmbedded(bold, italic);
+ if (embedded is not null)
+ return embedded;
+
+ // 2. Requested family, then the monospace fallback chain.
+ var style = new SKFontStyle(
+ bold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
+ SKFontStyleWidth.Normal,
+ italic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
+
+ foreach (var candidate in Prepend(family, MonospaceFallbacks))
+ {
+ var typeface = SKTypeface.FromFamilyName(candidate, style);
+ if (typeface is not null && MatchesFamily(typeface, candidate))
+ return typeface;
+ }
+
+ // 3. Platform default (never null).
+ return SKTypeface.FromFamilyName(family, style) ?? SKTypeface.Default;
+ }
+
+ private static SKTypeface? LoadEmbedded(bool bold, bool italic)
+ {
+ var file = (bold, italic) switch
+ {
+ (true, true) => BoldItalicFile,
+ (true, false) => BoldFile,
+ (false, true) => ItalicFile,
+ _ => RegularFile
+ };
+
+ try
+ {
+ return LoadResource(file) ?? LoadResource(RegularFile); // fall back to regular weight
+ }
+ catch
+ {
+ return null; // fall through to system fonts
+ }
+ }
+
+ private static SKTypeface? LoadResource(string logicalName)
+ {
+ using var stream = ResourceAssembly.GetManifestResourceStream(logicalName);
+ return stream is null ? null : SKTypeface.FromStream(stream);
+ }
+
+ private static bool MatchesFamily(SKTypeface typeface, string requested)
+ {
+ // "monospace" is a fontconfig alias, not a real family name — accept whatever it resolves to.
+ if (string.Equals(requested, "monospace", StringComparison.OrdinalIgnoreCase))
+ return true;
+ return string.Equals(typeface.FamilyName, requested, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static IEnumerable Prepend(string first, string[] rest)
+ {
+ yield return first;
+ foreach (var item in rest)
+ if (!string.Equals(item, first, StringComparison.OrdinalIgnoreCase))
+ yield return item;
+ }
+}
diff --git a/src/CrossEscPos.Transports.Browser/BridgeServerProxy.cs b/src/CrossEscPos.Transports.Browser/BridgeServerProxy.cs
new file mode 100644
index 0000000..0a5ef4c
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/BridgeServerProxy.cs
@@ -0,0 +1,25 @@
+using System.Threading.Tasks;
+using CrossEscPos.Bridge;
+using Microsoft.AspNetCore.SignalR.Client;
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// Client-side typed proxy of the broker's : each call forwards to the hub by
+/// the interface method name, so the emulator and monitor clients invoke server methods type-safely
+/// instead of with magic strings. The matching server→client direction is the strongly-typed hub itself.
+///
+public sealed class BridgeServerProxy : IBridgeServer
+{
+ private readonly HubConnection _hub;
+
+ public BridgeServerProxy(HubConnection hub) => _hub = hub;
+
+ public Task AttachEmulator(string address, int port)
+ => _hub.InvokeAsync(nameof(IBridgeServer.AttachEmulator), address, port);
+
+ public Task ConnectTcp(string host, int port)
+ => _hub.InvokeAsync(nameof(IBridgeServer.ConnectTcp), host, port);
+ public Task SendToEmulator(byte[] data) => _hub.InvokeAsync(nameof(IBridgeServer.SendToEmulator), data);
+ public Task ReplyToSender(byte[] data) => _hub.InvokeAsync(nameof(IBridgeServer.ReplyToSender), data);
+}
diff --git a/src/CrossEscPos.Transports.Browser/CrossEscPos.Transports.Browser.csproj b/src/CrossEscPos.Transports.Browser/CrossEscPos.Transports.Browser.csproj
new file mode 100644
index 0000000..5816767
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/CrossEscPos.Transports.Browser.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CrossEscPos.Transports.Browser/IJsTransportBridge.cs b/src/CrossEscPos.Transports.Browser/IJsTransportBridge.cs
new file mode 100644
index 0000000..7be9b3b
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/IJsTransportBridge.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// The host-specific bridge to the browser's JavaScript transport API (transports.js ). This is
+/// the only part of the transport stack that differs per host: the Blazor host implements it over
+/// IJSRuntime , the Avalonia WASM host over [JSImport] /[JSExport] . Everything above
+/// it ( , the UI) is shared.
+///
+/// is the transport id — "serial" or "usb" .
+///
+public interface IJsTransportBridge
+{
+ ValueTask IsSupportedAsync(string kind);
+
+ ///
+ /// Opens the device picker and connects; returns a description, or null if cancelled.
+ /// is a transport-specific hint (for serial, the baud rate as a string).
+ ///
+ ValueTask ConnectAsync(string kind, string? options);
+
+ ValueTask WriteAsync(string kind, byte[] data);
+
+ ValueTask DisconnectAsync(string kind);
+
+ /// Paired WebUSB devices as "vid:pid name" labels (for the Monitor's device list).
+ ValueTask> ListUsbDevicesAsync();
+
+ /// Raised when a chunk of ESC/POS arrives from a device: (kind, bytes) .
+ event Action DataReceived;
+
+ /// Raised when a device is closed / unplugged: (kind) .
+ event Action Closed;
+}
diff --git a/src/CrossEscPos.Transports.Browser/IReceiptTransport.cs b/src/CrossEscPos.Transports.Browser/IReceiptTransport.cs
new file mode 100644
index 0000000..d62a661
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/IReceiptTransport.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Threading.Tasks;
+using CrossEscPos; // IPrinterResponder
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// A browser transport that feeds a live ESC/POS session into the emulator and carries the printer's
+/// status replies back — the browser analogue of the desktop NetServer / SerialServer .
+/// The receive → FeedEscPos → respond logic is identical to the desktop transports (which is why
+/// this is also an ); only the underlying Web API differs, and that is
+/// hidden behind so this type is shared by every browser host.
+///
+public interface IReceiptTransport : IPrinterResponder, IAsyncDisposable
+{
+ /// Human label — e.g. "Web Serial", "WebUSB".
+ string Kind { get; }
+
+ /// The connected device's description, or null when disconnected.
+ string? Description { get; }
+
+ bool IsConnected { get; }
+
+ /// Raised when the connection state or description changes.
+ event Action? StateChanged;
+
+ /// True when the running browser exposes this API (feature detection).
+ ValueTask IsSupportedAsync();
+
+ /// Prompts the user to pick a device and opens it — must be called from a user gesture.
+ Task ConnectAsync();
+
+ Task DisconnectAsync();
+}
diff --git a/src/CrossEscPos.Transports.Browser/ITransportSink.cs b/src/CrossEscPos.Transports.Browser/ITransportSink.cs
new file mode 100644
index 0000000..23d0abe
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/ITransportSink.cs
@@ -0,0 +1,20 @@
+using CrossEscPos; // IPrinterResponder
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// The host's hook for a live transport: where received ESC/POS is fed and how a transport is
+/// registered as a responder. The Blazor host's EmulatorHost and the Avalonia host both
+/// implement this, so stays host-agnostic.
+///
+public interface ITransportSink
+{
+ /// Feed a chunk of received ESC/POS into the current printer (append — no reset).
+ void Feed(byte[] data, IPrinterResponder responder);
+
+ /// Register the transport as a long-lived responder (status / Automatic Status Back).
+ void Attach(IPrinterResponder responder);
+
+ /// Unregister the transport responder.
+ void Detach(IPrinterResponder responder);
+}
diff --git a/src/CrossEscPos.Transports.Browser/SignalRTransport.cs b/src/CrossEscPos.Transports.Browser/SignalRTransport.cs
new file mode 100644
index 0000000..9ca09a9
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/SignalRTransport.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Threading.Tasks;
+using CrossEscPos.Bridge;
+using Microsoft.AspNetCore.SignalR.Client;
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// Receives ESC/POS over a SignalR hub (the CrossEscPos.Host broker). SignalR's client runs in
+/// WASM over the browser WebSocket, so this works in the Avalonia browser head and on desktop alike. The
+/// emulator attaches as the "printer" and asks the proxy to open a TCP listener on :
+/// for this session; POS software connects there and the proxy relays jobs to it
+/// and its status replies back. This is the TCP-in-the-browser path the sandbox otherwise forbids.
+///
+public sealed class SignalRTransport : IReceiptTransport
+{
+ private readonly ITransportSink _sink;
+ private HubConnection? _hub;
+ private IBridgeServer? _server;
+
+ public SignalRTransport(ITransportSink sink, string kind)
+ {
+ _sink = sink;
+ Kind = kind;
+ }
+
+ public string Kind { get; }
+ public string? Description { get; private set; }
+ public bool IsConnected { get; private set; }
+ public event Action? StateChanged;
+
+ /// The proxy hub endpoint, e.g. http://localhost:5000/bridge .
+ public string Url { get; set; } = "http://localhost:5000/bridge";
+
+ /// Address the proxy should bind the per-session TCP listener to (default: all interfaces).
+ public string ListenAddress { get; set; } = "0.0.0.0";
+
+ /// TCP port the proxy should listen on for POS software (default: 9100).
+ public int ListenPort { get; set; } = 9100;
+
+ public ValueTask IsSupportedAsync() => new(true);
+
+ public async Task ConnectAsync()
+ {
+ if (IsConnected)
+ return;
+
+ var hub = new HubConnectionBuilder().WithUrl(Url).WithAutomaticReconnect().Build();
+ hub.On(nameof(IBridgeClient.ReceiveEscPos), data => _sink.Feed(data, this));
+ hub.Closed += _ => { OnClosed(); return Task.CompletedTask; };
+
+ var server = new BridgeServerProxy(hub);
+ // Re-open the TCP listener after an automatic reconnect (the connection id changes).
+ hub.Reconnected += async _ =>
+ {
+ try { await server.AttachEmulator(ListenAddress, ListenPort); } catch { /* surfaces on next use */ }
+ };
+
+ try
+ {
+ await hub.StartAsync();
+ await server.AttachEmulator(ListenAddress, ListenPort);
+ }
+ catch (Exception ex)
+ {
+ try { await hub.DisposeAsync(); } catch { }
+ Description = ex.Message; // e.g. "Could not listen on 0.0.0.0:9100 — port in use"
+ StateChanged?.Invoke();
+ return;
+ }
+
+ _hub = hub;
+ _server = server;
+ IsConnected = true;
+ Description = $"{Url} (TCP {ListenAddress}:{ListenPort})";
+ _sink.Attach(this);
+ StateChanged?.Invoke();
+ }
+
+ /// The printer's status reply → back through the hub to whoever sent the job.
+ public void Send(byte[] data)
+ {
+ if (_hub is { State: HubConnectionState.Connected } && _server is not null)
+ _ = _server.ReplyToSender(data);
+ }
+
+ public async Task DisconnectAsync()
+ {
+ if (!IsConnected)
+ return;
+ _sink.Detach(this);
+ IsConnected = false;
+ Description = null;
+ var hub = _hub;
+ _hub = null;
+ _server = null;
+ if (hub is not null)
+ {
+ try { await hub.DisposeAsync(); } catch { }
+ }
+ StateChanged?.Invoke();
+ }
+
+ private void OnClosed()
+ {
+ if (!IsConnected)
+ return;
+ _sink.Detach(this);
+ IsConnected = false;
+ Description = null;
+ StateChanged?.Invoke();
+ }
+
+ public async ValueTask DisposeAsync() => await DisconnectAsync();
+}
diff --git a/src/CrossEscPos.Transports.Browser/WebTransport.cs b/src/CrossEscPos.Transports.Browser/WebTransport.cs
new file mode 100644
index 0000000..e374690
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/WebTransport.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Threading.Tasks;
+
+namespace CrossEscPos.Transports.Browser;
+
+///
+/// The shared, host-agnostic browser transport. It owns the connection state machine and wires a
+/// (the platform API) to an (the host's
+/// printer): received bytes are fed to the sink, and the printer's status replies go back out through
+/// the bridge. Both the Blazor and Avalonia hosts use this class unchanged — they differ only in the
+/// injected bridge and sink.
+///
+public class WebTransport : IReceiptTransport
+{
+ private readonly IJsTransportBridge _bridge;
+ private readonly ITransportSink _sink;
+ private readonly string _kindId;
+
+ /// Transport id passed to the bridge — "serial" or "usb" .
+ /// Human label shown in the UI.
+ public WebTransport(IJsTransportBridge bridge, ITransportSink sink, string kindId, string kind)
+ {
+ _bridge = bridge;
+ _sink = sink;
+ _kindId = kindId;
+ Kind = kind;
+ _bridge.DataReceived += OnBridgeData;
+ _bridge.Closed += OnBridgeClosed;
+ }
+
+ public string Kind { get; }
+ public string? Description { get; private set; }
+ public bool IsConnected { get; private set; }
+ public event Action? StateChanged;
+
+ /// Transport-specific connect hint passed to the bridge (for serial: the baud rate).
+ public string? Options { get; set; }
+
+ public ValueTask IsSupportedAsync() => _bridge.IsSupportedAsync(_kindId);
+
+ public async Task ConnectAsync()
+ {
+ if (IsConnected)
+ return;
+
+ var description = await _bridge.ConnectAsync(_kindId, Options); // opens the picker (user gesture)
+ if (string.IsNullOrEmpty(description))
+ return; // cancelled
+
+ Description = description;
+ IsConnected = true;
+ _sink.Attach(this);
+ StateChanged?.Invoke();
+ }
+
+ public async Task DisconnectAsync()
+ {
+ if (!IsConnected)
+ return;
+
+ _sink.Detach(this);
+ IsConnected = false;
+ Description = null;
+ try { await _bridge.DisconnectAsync(_kindId); }
+ catch { /* already gone */ }
+ StateChanged?.Invoke();
+ }
+
+ /// Printer status / Automatic-Status-Back reply → write it back to the device.
+ public void Send(byte[] data) => _ = _bridge.WriteAsync(_kindId, data); // fire-and-forget
+
+ private void OnBridgeData(string kind, byte[] data)
+ {
+ if (kind == _kindId && IsConnected && data.Length > 0)
+ _sink.Feed(data, this);
+ }
+
+ private void OnBridgeClosed(string kind)
+ {
+ if (kind != _kindId || !IsConnected)
+ return;
+ _sink.Detach(this);
+ IsConnected = false;
+ Description = null;
+ StateChanged?.Invoke();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _bridge.DataReceived -= OnBridgeData;
+ _bridge.Closed -= OnBridgeClosed;
+ await DisconnectAsync();
+ }
+}
diff --git a/src/CrossEscPos.Transports.Browser/wwwroot/transports.js b/src/CrossEscPos.Transports.Browser/wwwroot/transports.js
new file mode 100644
index 0000000..63123ab
--- /dev/null
+++ b/src/CrossEscPos.Transports.Browser/wwwroot/transports.js
@@ -0,0 +1,213 @@
+// Shared browser transports for CrossEscPos (used by both the Blazor and the Avalonia WASM hosts).
+//
+// Exposes `window.crossescpos` with kind-dispatched calls — isSupported/connect/write/disconnect, where
+// kind is "serial" (Web Serial API) or "usb" (WebUSB API). Incoming ESC/POS is delivered to the host by
+// calling the `crossescpos.onData(kind, Uint8Array)` / `crossescpos.onClosed(kind)` slots, which each
+// host wires to .NET (Blazor via a DotNetObjectReference — see registerDotNet; Avalonia WASM via
+// [JSExport], wired in main.js). Requires Chromium + a secure context + a user gesture for the picker.
+
+(function () {
+ const cx = (window.crossescpos = window.crossescpos || {});
+
+ // Host-set delivery slots (no-ops until a host wires them).
+ cx.onData = cx.onData || function () {}; // (kind, Uint8Array)
+ cx.onClosed = cx.onClosed || function () {}; // (kind)
+
+ // Blazor helper: route the delivery slots through a DotNetObjectReference.
+ cx.registerDotNet = function (ref) {
+ cx.onData = (kind, u8) => ref.invokeMethodAsync('OnData', kind, u8);
+ cx.onClosed = (kind) => ref.invokeMethodAsync('OnClosed', kind);
+ };
+
+ const toBytes = (payload) =>
+ payload instanceof Uint8Array ? payload
+ : (typeof payload === 'string' ? Uint8Array.from(atob(payload), c => c.charCodeAt(0))
+ : new Uint8Array(payload));
+
+ // A SerialPort-shaped object backed by WebUSB (CDC-ACM) — the "serial port lib in JS" that lets the
+ // serial transport work on browsers without native Web Serial (mirrors google/web-serial-polyfill).
+ // It exposes open()/readable/writable/close()/getInfo() so the serial code below is identical whether
+ // the port came from navigator.serial or from here.
+ function usbSerialPort(device) {
+ let epIn = 0, epOut = 0, ctrlIface = 0, dataIface = 0, packet = 64, readable = null, writable = null;
+ const findBulk = (alt) => ({
+ inEp: alt.endpoints.find(e => e.direction === 'in' && e.type === 'bulk'),
+ outEp: alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk'),
+ });
+ return {
+ getInfo: () => ({ usbVendorId: device.vendorId, usbProductId: device.productId }),
+ get readable() { return readable; },
+ get writable() { return writable; },
+ async open(options) {
+ const baud = (options && options.baudRate) || 9600;
+ await device.open();
+ if (device.configuration === null) await device.selectConfiguration(1);
+ // Prefer a CDC data interface (class 0x0A) + its comm interface (0x02) for the control transfers;
+ // otherwise fall back to the first interface exposing bulk in+out.
+ for (const iface of device.configuration.interfaces) {
+ const alt = iface.alternate, b = findBulk(alt);
+ if (alt.interfaceClass === 0x0a && b.inEp && b.outEp) {
+ dataIface = iface.interfaceNumber; epIn = b.inEp.endpointNumber; epOut = b.outEp.endpointNumber; packet = b.inEp.packetSize || 64;
+ }
+ if (alt.interfaceClass === 0x02) ctrlIface = iface.interfaceNumber;
+ }
+ if (!epIn || !epOut) {
+ for (const iface of device.configuration.interfaces) {
+ const b = findBulk(iface.alternate);
+ if (b.inEp && b.outEp) { dataIface = ctrlIface = iface.interfaceNumber; epIn = b.inEp.endpointNumber; epOut = b.outEp.endpointNumber; packet = b.inEp.packetSize || 64; break; }
+ }
+ }
+ await device.claimInterface(dataIface);
+ if (ctrlIface !== dataIface) { try { await device.claimInterface(ctrlIface); } catch {} }
+ // CDC: SET_LINE_CODING (baud, 8N1) + SET_CONTROL_LINE_STATE (DTR|RTS). Harmless on non-CDC devices.
+ try {
+ const coding = new DataView(new ArrayBuffer(7));
+ coding.setUint32(0, baud, true); coding.setUint8(6, 8);
+ await device.controlTransferOut({ requestType: 'class', recipient: 'interface', request: 0x20, value: 0, index: ctrlIface }, coding.buffer);
+ await device.controlTransferOut({ requestType: 'class', recipient: 'interface', request: 0x22, value: 0x03, index: ctrlIface });
+ } catch { /* not a CDC device — raw bulk still works */ }
+ readable = new ReadableStream({
+ async pull(controller) {
+ try {
+ const r = await device.transferIn(epIn, packet);
+ if (r.status === 'stall') { await device.clearHalt('in', epIn); return; }
+ if (r.data && r.data.byteLength) controller.enqueue(new Uint8Array(r.data.buffer));
+ } catch (e) { controller.error(e); }
+ },
+ });
+ writable = new WritableStream({ write: (chunk) => device.transferOut(epOut, chunk) });
+ },
+ async close() { try { if (device.opened) await device.close(); } catch {} },
+ };
+ }
+
+ // ---- Web Serial (native, with a WebUSB CDC-ACM fallback) ----
+ // A factory (not a singleton) so independent channels can each own a device — e.g. the emulator
+ // receiving on "serial" and the Monitor sending on "mon-serial" at the same time. `chan` is the kind
+ // used for the onData/onClosed callbacks.
+ function makeSerial(chan) {
+ let port = null, reader = null, keep = false;
+ // Supported natively (Chromium) or emulated over WebUSB anywhere WebUSB exists.
+ const isSupported = () => ('serial' in navigator) || ('usb' in navigator);
+ async function connect(options) {
+ const baud = parseInt(options, 10) || 9600;
+ let sel, via = '';
+ if ('serial' in navigator) {
+ try { sel = await navigator.serial.requestPort(); } catch { return null; }
+ } else if ('usb' in navigator) {
+ let dev; try { dev = await navigator.usb.requestDevice({ filters: [] }); } catch { return null; }
+ sel = usbSerialPort(dev); via = ' (via WebUSB)';
+ } else { return null; }
+ try { await sel.open({ baudRate: baud }); } catch { return null; }
+ port = sel; keep = true; loop();
+ const info = sel.getInfo ? sel.getInfo() : {};
+ return `Serial port${info.usbVendorId ? ` (VID 0x${info.usbVendorId.toString(16)})` : ''} @ ${baud} baud${via}`;
+ }
+ async function loop() {
+ while (port && port.readable && keep) {
+ reader = port.readable.getReader();
+ try { for (;;) { const { value, done } = await reader.read(); if (done) break; if (value && value.length) cx.onData(chan, value); } }
+ catch {} finally { try { reader.releaseLock(); } catch {} reader = null; }
+ }
+ cx.onClosed(chan);
+ }
+ async function write(payload) {
+ if (!port || !port.writable) return;
+ const w = port.writable.getWriter();
+ try { await w.write(toBytes(payload)); } catch {} finally { w.releaseLock(); }
+ }
+ async function disconnect() {
+ keep = false;
+ try { if (reader) await reader.cancel(); } catch {}
+ try { if (port) await port.close(); } catch {}
+ port = null; reader = null;
+ }
+ return { isSupported, connect, write, disconnect };
+ }
+
+ // ---- WebUSB ----
+ // Factory, like makeSerial. `connect(options)` uses the picker; when `options` is a numeric string it
+ // opens that entry from navigator.usb.getDevices() instead (so the Monitor can pick from a list).
+ function makeUsb(chan) {
+ let device = null, epIn = null, epOut = null, packet = 64, reading = false;
+ const isSupported = () => 'usb' in navigator;
+ async function connect(options) {
+ let dev;
+ const idx = parseInt(options, 10);
+ if (Number.isInteger(idx) && idx >= 0) {
+ try { dev = (await navigator.usb.getDevices())[idx]; } catch {}
+ }
+ if (!dev) { try { dev = await navigator.usb.requestDevice({ filters: [] }); } catch { return null; } }
+ try {
+ await dev.open();
+ if (dev.configuration === null) await dev.selectConfiguration(1);
+ const claimed = await claim(dev);
+ if (!claimed) { await close(dev); return null; }
+ device = dev; epIn = claimed.epIn; epOut = claimed.epOut; packet = claimed.packet; reading = true; loop();
+ const hex = n => (n || 0).toString(16).padStart(4, '0');
+ return [dev.manufacturerName, dev.productName].filter(Boolean).join(' ') || `USB ${hex(dev.vendorId)}:${hex(dev.productId)}`;
+ } catch { await close(dev); return null; }
+ }
+ async function claim(dev) {
+ for (const iface of dev.configuration.interfaces) {
+ const alt = iface.alternate;
+ const inEp = alt.endpoints.find(e => e.direction === 'in' && e.type === 'bulk');
+ if (!inEp) continue;
+ const outEp = alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk');
+ try { await dev.claimInterface(iface.interfaceNumber); return { epIn: inEp.endpointNumber, epOut: outEp ? outEp.endpointNumber : null, packet: inEp.packetSize || 64 }; }
+ catch {}
+ }
+ return null;
+ }
+ async function loop() {
+ while (device && reading) {
+ try {
+ const r = await device.transferIn(epIn, packet);
+ if (r.status === 'stall') { await device.clearHalt('in', epIn); continue; }
+ if (r.data && r.data.byteLength) cx.onData(chan, new Uint8Array(r.data.buffer));
+ } catch { break; }
+ }
+ cx.onClosed(chan);
+ }
+ async function write(payload) {
+ if (!device || epOut === null) return;
+ try { await device.transferOut(epOut, toBytes(payload)); } catch {}
+ }
+ async function disconnect() { reading = false; const d = device; device = null; await close(d); }
+ async function close(d) { try { if (d && d.opened) await d.close(); } catch {} }
+ return { isSupported, connect, write, disconnect };
+ }
+
+ // One instance per kind, created on demand. "serial"/"usb" are the emulator's; "mon-serial"/"mon-usb"
+ // are the Monitor's independent sender channels — a *usb kind uses WebUSB, anything else Web Serial.
+ const channels = {};
+ const impl = (kind) => (channels[kind] ||= (kind.indexOf('usb') >= 0 ? makeUsb(kind) : makeSerial(kind)));
+
+ cx.isSupported = (kind) => impl(kind).isSupported();
+ cx.connect = (kind, options) => impl(kind).connect(options);
+ cx.write = (kind, payload) => impl(kind).write(payload);
+ cx.disconnect = (kind) => impl(kind).disconnect();
+
+ // Paired WebUSB devices as newline-joined "vid:pid name" labels (index = getDevices order) — lets the
+ // Monitor list devices to pick from. Newline-joined (not JSON) so the .NET side stays trim-safe.
+ cx.listUsb = async () => {
+ if (!('usb' in navigator)) return '';
+ const hex = n => (n || 0).toString(16).padStart(4, '0');
+ const devices = await navigator.usb.getDevices();
+ return devices.map(d =>
+ `${hex(d.vendorId)}:${hex(d.productId)}${d.productName ? ' ' + d.productName : ''}`).join('\n');
+ };
+
+ // The page's origin, so the SignalR TCP-proxy transport defaults to the same host that served the app.
+ cx.origin = () => globalThis.location.origin;
+
+ // Download bytes as a file (blob + anchor) — works in every browser, unlike the File System Access API.
+ cx.downloadFile = (name, b64) => {
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
+ const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
+ const a = document.createElement('a');
+ a.href = url; a.download = name || 'receipt.png';
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ };
+})();
diff --git a/src/CrossEscPos.Transports/CrossEscPos.Transports.csproj b/src/CrossEscPos.Transports/CrossEscPos.Transports.csproj
new file mode 100644
index 0000000..2e827b7
--- /dev/null
+++ b/src/CrossEscPos.Transports/CrossEscPos.Transports.csproj
@@ -0,0 +1,25 @@
+
+
+
+ true
+ Desktop transports that feed an ESC/POS stream into a CrossEscPos.Core printer:
+ TCP/IP server, RS-232 serial server, and a USB client. Desktop-only (sockets, System.IO.Ports,
+ libusb) — not referenced by the browser app.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Networking/NetClient.cs b/src/CrossEscPos.Transports/NetClient.cs
similarity index 61%
rename from Networking/NetClient.cs
rename to src/CrossEscPos.Transports/NetClient.cs
index fe1f8d8..4704876 100644
--- a/Networking/NetClient.cs
+++ b/src/CrossEscPos.Transports/NetClient.cs
@@ -1,43 +1,65 @@
-using System;
+using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
-using ReceiptPrinterEmulator.Logging;
+using CrossEscPos.Emulator;
+using CrossEscPos;
+using CrossEscPos.Logging;
-namespace ReceiptPrinterEmulator.Networking;
+namespace CrossEscPos.Transports;
-public class NetClient
+public class NetClient : IPrinterResponder
{
public readonly NetServer Server;
public readonly EndPoint RemoteEndPoint;
-
+
+ private readonly ReceiptPrinter _printer;
private Socket _socket;
private CancellationTokenSource _lifetimeCts;
public bool IsConnected => _socket.Connected;
-
- public NetClient(NetServer server, Socket clientSocket)
+
+ public NetClient(NetServer server, ReceiptPrinter printer, Socket clientSocket)
{
Server = server;
+ _printer = printer;
RemoteEndPoint = clientSocket.RemoteEndPoint!;
-
+
_socket = clientSocket;
_lifetimeCts = new();
+
+ _printer.RegisterResponder(this); // allow status/transmit-back commands to reply to us
}
public void Close()
{
if (!_lifetimeCts.IsCancellationRequested)
_lifetimeCts.Cancel();
-
+
+ _printer.UnregisterResponder(this);
+
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
-
+
Logger.Info($"Closed client connection {RemoteEndPoint}");
}
+ /// Writes a printer response (status bytes, printer ID, …) back to this client.
+ public void Send(byte[] data)
+ {
+ try
+ {
+ if (_socket.Connected)
+ _socket.Send(data);
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to send {data.Length} bytes to {RemoteEndPoint}");
+ }
+ }
+
public async Task ReceiveLoopAsync()
{
try
@@ -67,6 +89,6 @@ public async Task ReceiveLoopAsync()
}
}
- private static void HandleIncomingData(ReadOnlySpan data) =>
- App.Printer?.FeedEscPos(Encoding.Latin1.GetString(data));
-}
\ No newline at end of file
+ private void HandleIncomingData(ReadOnlySpan data) =>
+ _printer.FeedEscPos(data, this);
+}
diff --git a/src/CrossEscPos.Transports/NetServer.cs b/src/CrossEscPos.Transports/NetServer.cs
new file mode 100644
index 0000000..ea6dffd
--- /dev/null
+++ b/src/CrossEscPos.Transports/NetServer.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using CrossEscPos.Emulator;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.Transports;
+
+///
+/// TCP listener that receives ESC/POS data and feeds it to the printer. Can be started, stopped and
+/// re-bound at runtime (e.g. from the UI) to a different address/port.
+///
+public class NetServer
+{
+ public IPEndPoint? EndPoint { get; private set; }
+ public bool IsRunning { get; private set; }
+
+ private readonly ReceiptPrinter _printer;
+ private Socket? _tcpSocket;
+ private CancellationTokenSource? _cts;
+ private readonly List _clients = new();
+
+ public NetServer(ReceiptPrinter printer)
+ {
+ _printer = printer;
+ }
+
+ ///
+ /// Binds and starts listening on the given address/port. Returns true on success; on failure the
+ /// server is left stopped and the error is logged. Re-binding stops any previous listener first.
+ ///
+ public bool Start(IPAddress address, int port)
+ {
+ Stop();
+
+ try
+ {
+ EndPoint = new IPEndPoint(address, port);
+
+ Logger.Info($"Starting NetServer on {EndPoint}");
+
+ _tcpSocket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
+ _tcpSocket.Bind(EndPoint);
+ _tcpSocket.Listen(8);
+
+ IsRunning = true;
+ _cts = new CancellationTokenSource();
+
+ _ = AcceptLoopAsync(_cts.Token);
+
+ Logger.Info($"Server bound to {EndPoint}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to start TCP listener on {address}:{port}");
+ Stop();
+ return false;
+ }
+ }
+
+ public void Stop()
+ {
+ _cts?.Cancel();
+ _cts = null;
+
+ if (_tcpSocket is not null)
+ {
+ try { _tcpSocket.Close(); }
+ catch (Exception ex) { Logger.Exception(ex, "Error closing TCP socket"); }
+ _tcpSocket.Dispose();
+ _tcpSocket = null;
+ }
+
+ IsRunning = false;
+ }
+
+ private async Task AcceptLoopAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (IsRunning && !cancellationToken.IsCancellationRequested)
+ {
+ var clientSocket = await _tcpSocket!.AcceptAsync(cancellationToken);
+
+ if (!clientSocket.Connected)
+ continue;
+
+ var client = new NetClient(this, _printer, clientSocket);
+ _clients.Add(client);
+
+ Logger.Info($"Accepted new connection (RemoteEndPoint={client.RemoteEndPoint})");
+
+ _ = client.ReceiveLoopAsync();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on Stop().
+ }
+ catch (ObjectDisposedException)
+ {
+ // Socket closed during Stop().
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, "TCP accept loop error");
+ }
+ }
+}
diff --git a/src/CrossEscPos.Transports/SerialServer.cs b/src/CrossEscPos.Transports/SerialServer.cs
new file mode 100644
index 0000000..dec5b51
--- /dev/null
+++ b/src/CrossEscPos.Transports/SerialServer.cs
@@ -0,0 +1,135 @@
+using System;
+using System.IO.Ports;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using CrossEscPos.Emulator;
+using CrossEscPos;
+using CrossEscPos.Logging;
+
+namespace CrossEscPos.Transports;
+
+///
+/// Receives ESC/POS data over a serial port (RS-232 / USB-serial / virtual PTY) and feeds it to the
+/// printer, mirroring . Can be opened/closed at runtime (e.g. from the UI).
+/// The interpreter keeps its state across calls, so the byte stream may arrive in arbitrary chunks
+/// (commands spanning reads are handled correctly).
+///
+/// To simulate without hardware, create a virtual serial pair:
+/// macOS/Linux: socat -d -d pty,raw,echo=0 pty,raw,echo=0
+/// Windows: com0com (creates a linked COM pair, e.g. COM3 <-> COM4)
+///
+public class SerialServer : IPrinterResponder
+{
+ public string PortName { get; private set; } = string.Empty;
+ public int BaudRate { get; private set; }
+ public bool IsRunning { get; private set; }
+
+ private readonly ReceiptPrinter _printer;
+ private SerialPort? _port;
+ private CancellationTokenSource? _cts;
+
+ public SerialServer(ReceiptPrinter printer)
+ {
+ _printer = printer;
+ }
+
+ ///
+ /// Opens the given serial port. Returns true on success; on failure the server is left closed and
+ /// the error is logged. Re-opening closes any previous port first.
+ ///
+ public bool Start(string portName, int baudRate = 9600)
+ {
+ Stop();
+
+ try
+ {
+ PortName = portName;
+ BaudRate = baudRate;
+
+ Logger.Info($"Opening serial port {PortName} @ {BaudRate} baud");
+
+ _port = new SerialPort(PortName, BaudRate)
+ {
+ ReadTimeout = SerialPort.InfiniteTimeout,
+ Handshake = Handshake.None
+ };
+ _port.Open();
+ IsRunning = true;
+ _printer.RegisterResponder(this);
+
+ _cts = new CancellationTokenSource();
+ _ = ReceiveLoopAsync(_cts.Token);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to open serial port {portName}");
+ Stop();
+ return false;
+ }
+ }
+
+ public void Stop()
+ {
+ _cts?.Cancel();
+ _cts = null;
+
+ _printer.UnregisterResponder(this);
+
+ if (_port is not null)
+ {
+ try { if (_port.IsOpen) _port.Close(); }
+ catch (Exception ex) { Logger.Exception(ex, "Error closing serial port"); }
+ _port.Dispose();
+ _port = null;
+ }
+
+ IsRunning = false;
+ }
+
+ /// Writes a printer response (status bytes, printer ID, …) back over the serial port.
+ public void Send(byte[] data)
+ {
+ try
+ {
+ var port = _port;
+ if (port is { IsOpen: true })
+ port.BaseStream.Write(data, 0, data.Length);
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Failed to send {data.Length} bytes over {PortName}");
+ }
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
+ {
+ var stream = _port!.BaseStream;
+ var buffer = new byte[64 * 1024];
+
+ try
+ {
+ while (IsRunning && !cancellationToken.IsCancellationRequested)
+ {
+ int read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
+ if (read <= 0)
+ continue;
+
+ Logger.Info($"Received serial data (byteCount={read}, port={PortName})");
+
+ _printer.FeedEscPos(buffer.AsSpan(0, read), this);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on Stop().
+ }
+ catch (Exception ex)
+ {
+ Logger.Exception(ex, $"Serial read error on {PortName}");
+ IsRunning = false;
+ }
+ }
+}
diff --git a/src/CrossEscPos.Transports/UsbPrinter.cs b/src/CrossEscPos.Transports/UsbPrinter.cs
new file mode 100644
index 0000000..74d412e
--- /dev/null
+++ b/src/CrossEscPos.Transports/UsbPrinter.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using ESCPOS_NET;
+using LibUsbDotNet.LibUsb;
+using LibUsbDotNet.Main;
+
+namespace CrossEscPos.Transports;
+
+/// A connected USB device, identified by vendor/product id.
+public sealed record UsbDeviceInfo(int Vid, int Pid)
+{
+ public string Display => $"USB {Vid:X4}:{Pid:X4}";
+}
+
+///
+/// A backed by a USB device's bulk endpoints: writes go to the bulk-OUT
+/// endpoint, reads come from the bulk-IN endpoint (the printer's status channel).
+///
+/// USB bulk transfers are packet-oriented, but reads its status channel
+/// one byte at a time. So reads are buffered: a single bulk transfer fills an internal buffer that
+/// is then served byte-by-byte. returns 0 on timeout (no data yet) rather than
+/// blocking, which keeps the printer's read loop responsive without hot-spinning.
+///
+internal sealed class UsbStream : Stream
+{
+ private const int WriteTimeoutMs = 5000;
+ private const int ReadTimeoutMs = 200;
+ private const int ReadPacketSize = 64; // covers a full-speed bulk max packet; ASB blocks are 4 bytes.
+
+ private readonly UsbContext _context;
+ private readonly IDisposable _devices; // the device collection — keep alive so the device handle stays valid.
+ private readonly IUsbDevice _device;
+ private readonly UsbEndpointWriter _writer;
+ private readonly UsbEndpointReader? _reader;
+
+ private readonly byte[] _readBuffer = new byte[ReadPacketSize];
+ private int _readPos;
+ private int _readLen;
+
+ // Native libusb transfers and the device/context teardown must never overlap: closing the
+ // handle while a bulk read is in flight on the printer's background read thread is a native
+ // use-after-free that crashes the whole process (no managed catch can stop it). All native I/O
+ // and the close run under this lock, and _closing short-circuits any I/O issued after teardown.
+ private readonly Lock _ioLock = new();
+ private volatile bool _closing;
+
+ public UsbStream(UsbContext context, IDisposable devices, IUsbDevice device,
+ UsbEndpointWriter writer, UsbEndpointReader? reader)
+ {
+ _context = context;
+ _devices = devices;
+ _device = device;
+ _writer = writer;
+ _reader = reader;
+ }
+
+ public override bool CanRead => _reader is not null;
+ public override bool CanWrite => true;
+ public override bool CanSeek => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override void Flush() { }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ var data = offset == 0 && count == buffer.Length ? buffer : buffer.AsSpan(offset, count).ToArray();
+ lock (_ioLock)
+ {
+ if (_closing) return;
+ _writer.Write(data, WriteTimeoutMs, out _);
+ }
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_reader is null || _closing)
+ {
+ // Send-only device (no bulk-IN endpoint), or we're tearing down: idle briefly so the
+ // printer's read loop doesn't spin, and never touch the (closing) native handle.
+ Thread.Sleep(ReadTimeoutMs);
+ return 0;
+ }
+
+ if (_readPos >= _readLen)
+ {
+ lock (_ioLock)
+ {
+ if (_closing) return 0; // device closed out from under us between the check and the lock.
+ _reader.Read(_readBuffer, ReadTimeoutMs, out int got);
+ _readPos = 0;
+ _readLen = Math.Max(0, got);
+ }
+ if (_readLen == 0)
+ return 0; // timed out with no status bytes — try again next loop.
+ }
+
+ int n = Math.Min(count, _readLen - _readPos);
+ Array.Copy(_readBuffer, _readPos, buffer, offset, n);
+ _readPos += n;
+ return n;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // Flag first so any read/write that hasn't yet taken the lock bails out without touching
+ // the native handle, then take the lock to wait for any in-flight transfer (bounded by
+ // the read/write timeouts) to finish before freeing the device and context.
+ _closing = true;
+ lock (_ioLock)
+ {
+ try { _device.Close(); } catch { /* ignore */ }
+ try { _devices.Dispose(); } catch { /* ignore */ }
+ try { _context.Dispose(); } catch { /* ignore */ }
+ }
+ }
+ base.Dispose(disposing);
+ }
+}
+
+///
+/// Direct USB printing via libusb (LibUsbDotNet), exposed as an ESC-POS so
+/// it shares the exact same write queue and Automatic-Status-Back pipeline as the serial/TCP
+/// printers — bytes go out the bulk-OUT endpoint and status comes back on the bulk-IN endpoint, so
+/// the monitor reflects the emulator's reported state for USB too.
+///
+/// Requires native libusb-1.0 at runtime (macOS: brew install libusb ; Debian/Ubuntu:
+/// apt install libusb-1.0-0 ; bundled on Windows). If it's missing, or the OS already owns the
+/// device (e.g. a print queue has claimed it), construction throws and the caller surfaces the error.
+///
+public sealed class UsbPrinter : BasePrinter
+{
+ private const byte EndpointDirectionMask = 0x80; // bit 7 of bEndpointAddress: 0 = OUT, 1 = IN.
+ private const byte EndpointTransferTypeMask = 0x03; // low 2 bits of bmAttributes select the transfer type.
+
+ private readonly UsbStream _stream;
+
+ static UsbPrinter() => EnsureNativeSearchPath();
+
+ public UsbPrinter(int vid, int pid) : base($"USB {vid:X4}:{pid:X4}")
+ {
+ EnsureNativeSearchPath();
+
+ var context = new UsbContext();
+ var devices = context.List();
+ var transferred = false;
+ try
+ {
+ var device = devices.FirstOrDefault(d => d.VendorId == vid && d.ProductId == pid)
+ ?? throw new InvalidOperationException($"USB device {vid:X4}:{pid:X4} is not connected.");
+
+ device.Open();
+ if (device.Configs.Count == 0 || device.Configs[0].Interfaces.Count == 0)
+ throw new InvalidOperationException("USB device exposes no usable interface.");
+
+ device.ClaimInterface(device.Configs[0].Interfaces[0].Number);
+
+ var (outEndpoint, inEndpoint) = FindBulkEndpoints(device);
+ var writer = device.OpenEndpointWriter(outEndpoint);
+
+ UsbEndpointReader? reader = null;
+ if (inEndpoint.HasValue)
+ {
+ // A missing/unsupported status endpoint shouldn't block printing — fall back to send-only.
+ try { reader = device.OpenEndpointReader(inEndpoint.Value); }
+ catch { reader = null; }
+ }
+
+ _stream = new UsbStream(context, devices, device, writer, reader);
+ Writer = new BinaryWriter(_stream);
+ Reader = new BinaryReader(_stream);
+ transferred = true; // _stream now owns the device collection + context.
+ }
+ finally
+ {
+ if (!transferred)
+ {
+ try { devices.Dispose(); } catch { /* ignore */ }
+ context.Dispose();
+ }
+ }
+ }
+
+ protected override void OverridableDispose()
+ {
+ try { _stream?.Dispose(); } catch { /* ignore */ }
+ }
+
+ /// Enumerates connected USB devices (vendor/product ids).
+ public static IReadOnlyList ListDevices()
+ {
+ EnsureNativeSearchPath();
+ using var context = new UsbContext();
+ using var devices = context.List();
+ return devices.Select(d => new UsbDeviceInfo(d.VendorId, d.ProductId)).ToList();
+ }
+
+ ///
+ /// Picks the device's first bulk-OUT endpoint (for printing) and first bulk-IN endpoint (for
+ /// status), reading directions/types straight from the descriptors. Falls back to
+ /// for output if none is advertised; the status endpoint is
+ /// optional (null ⇒ send-only).
+ ///
+ private static (WriteEndpointID outEndpoint, ReadEndpointID? inEndpoint) FindBulkEndpoints(IUsbDevice device)
+ {
+ WriteEndpointID? outEndpoint = null;
+ ReadEndpointID? inEndpoint = null;
+ try
+ {
+ foreach (var iface in device.Configs[0].Interfaces)
+ {
+ foreach (var endpoint in iface.Endpoints)
+ {
+ if ((endpoint.Attributes & EndpointTransferTypeMask) != (byte)EndpointType.Bulk)
+ continue;
+
+ if ((endpoint.EndpointAddress & EndpointDirectionMask) != 0)
+ inEndpoint ??= (ReadEndpointID)endpoint.EndpointAddress;
+ else
+ outEndpoint ??= (WriteEndpointID)endpoint.EndpointAddress;
+ }
+ }
+ }
+ catch { /* descriptor walk failed — fall back below */ }
+
+ return (outEndpoint ?? WriteEndpointID.Ep01, inEndpoint);
+ }
+
+ private static bool _searchPathConfigured;
+
+ private static void EnsureNativeSearchPath()
+ {
+ if (_searchPathConfigured)
+ return;
+ _searchPathConfigured = true;
+
+ // LibUsbDotNet loads libusb itself and searches NATIVE_DLL_SEARCH_DIRECTORIES (then the
+ // default OS path). The default loader doesn't look in Homebrew/manual install locations, so
+ // append them here — before any libusb call — making an installed libusb discoverable without
+ // DYLD_LIBRARY_PATH or symlinks. (Do NOT register a DllImportResolver: LibUsbDotNet registers
+ // its own on the same assembly and a second registration throws during its type init.)
+ try
+ {
+ string[] dirs =
+ OperatingSystem.IsMacOS()
+ ? new[] { "/opt/homebrew/lib", "/opt/homebrew/opt/libusb/lib", "/usr/local/lib", "/usr/local/opt/libusb/lib" }
+ : OperatingSystem.IsLinux()
+ ? new[] { "/usr/lib", "/usr/local/lib", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/lib/x86_64-linux-gnu" }
+ : Array.Empty();
+
+ if (dirs.Length > 0)
+ {
+ var existing = AppContext.GetData("NATIVE_DLL_SEARCH_DIRECTORIES") as string;
+ var all = string.IsNullOrEmpty(existing) ? dirs : new[] { existing }.Concat(dirs);
+ AppContext.SetData("NATIVE_DLL_SEARCH_DIRECTORIES", string.Join(":", all));
+ }
+ }
+ catch { /* best-effort; fall back to default resolution */ }
+ }
+}
diff --git a/test_receipt.txt b/test_receipt.txt
deleted file mode 100644
index fb76d44..0000000
Binary files a/test_receipt.txt and /dev/null differ
diff --git a/tests/CrossEscPos.Controls.Tests/ControlsTests.cs b/tests/CrossEscPos.Controls.Tests/ControlsTests.cs
new file mode 100644
index 0000000..1692fd4
--- /dev/null
+++ b/tests/CrossEscPos.Controls.Tests/ControlsTests.cs
@@ -0,0 +1,60 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using CrossEscPos.Controls.Services;
+using CrossEscPos.Emulator;
+using CrossEscPos.Rendering.Skia;
+using Xunit;
+
+namespace CrossEscPos.Controls.Tests;
+
+public class ControlsTests
+{
+ [AvaloniaFact]
+ public void PrinterStatePanel_ToggleUpdatesBoundState()
+ {
+ var state = new PrinterState { Online = true };
+ var panel = new PrinterStatePanel { State = state };
+ var window = new Window { Content = panel };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ // The first ToggleSwitch in the panel is the Online switch, two-way bound to State.Online.
+ var online = panel.GetVisualDescendants().OfType().First();
+ online.IsChecked = false;
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.False(state.Online);
+ }
+
+ [AvaloniaFact]
+ public void ReceiptView_RendersReceiptImages()
+ {
+ var printer = new ReceiptPrinter(PaperConfiguration.Default, new SkiaImageFactory(), new SkiaTypefaceProvider());
+ printer.FeedEscPos("Hello control\n");
+
+ var encoder = new SkiaImageEncoder();
+ var dialogs = new StubFileDialogService();
+ var receipts = new ObservableCollection(
+ printer.ReceiptStack.Where(r => !r.IsEmpty)
+ .Select((r, i) => new ReceiptViewModel(r, encoder, dialogs, i + 1)));
+
+ var view = new ReceiptView { Receipts = receipts };
+ var window = new Window { Content = view };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.NotEmpty(view.GetVisualDescendants().OfType());
+ }
+
+ private sealed class StubFileDialogService : IFileDialogService
+ {
+ public Task SavePngAsync(string suggestedName) => Task.FromResult(null);
+ public Task PickFolderAsync() => Task.FromResult(null);
+ }
+}
diff --git a/tests/CrossEscPos.Controls.Tests/CrossEscPos.Controls.Tests.csproj b/tests/CrossEscPos.Controls.Tests/CrossEscPos.Controls.Tests.csproj
new file mode 100644
index 0000000..cc22686
--- /dev/null
+++ b/tests/CrossEscPos.Controls.Tests/CrossEscPos.Controls.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ false
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CrossEscPos.Controls.Tests/TestApp.cs b/tests/CrossEscPos.Controls.Tests/TestApp.cs
new file mode 100644
index 0000000..fc79034
--- /dev/null
+++ b/tests/CrossEscPos.Controls.Tests/TestApp.cs
@@ -0,0 +1,22 @@
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Themes.Fluent;
+using CrossEscPos.Controls.Tests;
+
+[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
+
+namespace CrossEscPos.Controls.Tests;
+
+/// Minimal Avalonia app for headless control tests — just the Fluent theme.
+public class App : Application
+{
+ public override void Initialize() => Styles.Add(new FluentTheme());
+}
+
+public static class TestAppBuilder
+{
+ // Real Skia drawing (UseHeadlessDrawing = false) so receipt PNGs actually decode into bitmaps.
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UseSkia()
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
+}
diff --git a/tests/CrossEscPos.Core.Tests/CrossEscPos.Core.Tests.csproj b/tests/CrossEscPos.Core.Tests/CrossEscPos.Core.Tests.csproj
new file mode 100644
index 0000000..34e167f
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/CrossEscPos.Core.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ false
+ true
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CrossEscPos.Core.Tests/EmulatorTests.cs b/tests/CrossEscPos.Core.Tests/EmulatorTests.cs
new file mode 100644
index 0000000..a96de6e
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/EmulatorTests.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.Linq;
+using CrossEscPos;
+using CrossEscPos.Emulator;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// Exercises the emulator core against the — i.e. with no graphics
+/// backend at all. These tests would not compile or run if Core had a hard dependency on SkiaSharp.
+///
+public class EmulatorTests
+{
+ private static ReceiptPrinter NewPrinter(out FakeImageFactory factory)
+ {
+ factory = new FakeImageFactory();
+ return new ReceiptPrinter(PaperConfiguration.Default, factory, new FakeTypefaceProvider());
+ }
+
+ [Fact]
+ public void PlainText_ProducesNonEmptyReceipt()
+ {
+ var printer = NewPrinter(out _);
+
+ printer.FeedEscPos("Hello world\n");
+
+ Assert.Contains(printer.ReceiptStack, r => !r.IsEmpty);
+ }
+
+ [Fact]
+ public void Core_RendersThroughAbstraction_WithNoSkia()
+ {
+ var printer = NewPrinter(out var factory);
+ printer.FeedEscPos("Hello world\n");
+
+ using var image = printer.CurrentReceipt.Render();
+
+ // Rendered onto the fake backend at the paper's pixel width, and text was actually drawn.
+ Assert.Equal(PaperConfiguration.Default.GetPaperWidthInPixels(), image.Width);
+ Assert.True(image.Height > 0);
+ Assert.True(factory.Canvases.Sum(c => c.TextDraws) > 0, "expected text to be drawn");
+ }
+
+ [Fact]
+ public void Cut_StartsNewReceipt()
+ {
+ var printer = NewPrinter(out _);
+
+ printer.FeedEscPos("First\n");
+ printer.Cut();
+ printer.FeedEscPos("Second\n");
+
+ Assert.Equal(2, printer.ReceiptStack.Count(r => !r.IsEmpty));
+ }
+
+ [Fact]
+ public void Offline_DropsPrint()
+ {
+ var printer = NewPrinter(out _);
+ printer.State.Online = false;
+
+ printer.FeedEscPos("Should not print\n");
+
+ Assert.DoesNotContain(printer.ReceiptStack, r => !r.IsEmpty);
+ }
+
+ [Fact]
+ public void PrinterState_RaisesChanged()
+ {
+ var printer = NewPrinter(out _);
+ var raised = false;
+ printer.State.Changed += () => raised = true;
+
+ printer.State.CoverOpen = true;
+
+ Assert.True(raised);
+ }
+
+ [Fact]
+ public void AutomaticStatusBack_BroadcastsToResponder()
+ {
+ var printer = NewPrinter(out _);
+ var responder = new CapturingResponder();
+ printer.RegisterResponder(responder);
+
+ printer.SetAutoStatusBack(0x01); // enable -> sends current status immediately
+
+ Assert.NotEmpty(responder.Received);
+ }
+
+ private sealed class CapturingResponder : IPrinterResponder
+ {
+ public List Received { get; } = new();
+ public void Send(byte[] data) => Received.Add(data);
+ }
+}
diff --git a/tests/CrossEscPos.Core.Tests/EscPosInterpretationTests.cs b/tests/CrossEscPos.Core.Tests/EscPosInterpretationTests.cs
new file mode 100644
index 0000000..91f78f9
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/EscPosInterpretationTests.cs
@@ -0,0 +1,344 @@
+using System.Collections.Generic;
+using System.Linq;
+using CrossEscPos;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+using Xunit;
+using static CrossEscPos.Core.Tests.EscPosSequence;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// End-to-end coverage of the ESC/POS interpreter and command set: raw byte sequences are fed through
+/// and the observable results
+/// (rendered draws, printer state, host responses, events) are asserted. Rendering goes to the fake
+/// backend, so these tests exercise the whole core with no SkiaSharp.
+///
+public class EscPosInterpretationTests
+{
+ private readonly FakeImageFactory _factory = new();
+ private readonly FakeTypefaceProvider _typefaces = new();
+ private readonly ReceiptPrinter _printer;
+
+ public EscPosInterpretationTests()
+ => _printer = new ReceiptPrinter(PaperConfiguration.Default, _factory, _typefaces);
+
+ /// Renders the current receipt and returns the canvas it drew onto.
+ private FakeCanvas RenderReceipt()
+ {
+ using var _ = _printer.CurrentReceipt.Render();
+ return _factory.Canvases[^1];
+ }
+
+ private sealed class CapturingResponder : IPrinterResponder
+ {
+ public List Bytes { get; } = new();
+ public void Send(byte[] data) => Bytes.AddRange(data);
+ }
+
+ // ---- text & control characters -------------------------------------------------------------
+
+ [Fact]
+ public void PlainText_DrawsText()
+ {
+ _printer.FeedEscPos("Hello" + Bytes(LF));
+ Assert.Contains("Hello", RenderReceipt().DrawnText);
+ }
+
+ [Fact]
+ public void LineFeed_AndCarriageReturn_BothEndLines()
+ {
+ _printer.FeedEscPos("A" + Bytes(LF) + "B" + Bytes(CR));
+ var canvas = RenderReceipt();
+ Assert.Contains("A", canvas.DrawnText);
+ Assert.Contains("B", canvas.DrawnText);
+ }
+
+ [Fact]
+ public void HorizontalTab_EmitsSpaces()
+ {
+ _printer.FeedEscPos(Bytes(HT) + "x" + Bytes(LF));
+ // The tab expands to spaces before the 'x' on the same line.
+ Assert.Contains(RenderReceipt().DrawnText, t => t.Contains(' '));
+ }
+
+ [Fact]
+ public void NulByte_IsIgnored()
+ {
+ _printer.FeedEscPos(Bytes(NUL, NUL) + "x" + Bytes(LF));
+ Assert.Contains("x", RenderReceipt().DrawnText);
+ }
+
+ // ---- text styling --------------------------------------------------------------------------
+
+ [Fact]
+ public void Emphasize_RequestsBoldFont()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'E', 1) + "x" + Bytes(LF));
+ RenderReceipt();
+ Assert.Contains(_typefaces.Requests, r => r.bold);
+ }
+
+ [Fact]
+ public void EmphasizeOff_RequestsNonBoldFont()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'E', 1, ESC, 'E', 0) + "x" + Bytes(LF));
+ RenderReceipt();
+ Assert.All(_typefaces.Requests, r => Assert.False(r.bold));
+ }
+
+ [Fact]
+ public void Italic_RequestsItalicFont()
+ {
+ _printer.FeedEscPos(Bytes(ESC, '4') + "x" + Bytes(LF)); // ESC 4 = italic on
+ RenderReceipt();
+ Assert.Contains(_typefaces.Requests, r => r.italic);
+ }
+
+ [Fact]
+ public void Initialize_ClearsEmphasis()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'E', 1, ESC, '@') + "x" + Bytes(LF));
+ RenderReceipt();
+ Assert.All(_typefaces.Requests, r => Assert.False(r.bold));
+ }
+
+ [Fact]
+ public void CharacterSize_AppliesScale()
+ {
+ _printer.FeedEscPos(Bytes(GS, '!', 0x11) + "x" + Bytes(LF)); // width x2, height x2
+ Assert.Contains(RenderReceipt().Scales, s => s.sx == 2 && s.sy == 2);
+ }
+
+ [Theory]
+ [InlineData(2)] // numeric
+ [InlineData(50)] // ASCII '2'
+ public void Justification_Right_ShiftsTextFartherThanLeft(int rightCode)
+ {
+ _printer.FeedEscPos("AAA" + Bytes(LF) + // left (default)
+ Bytes(ESC, 'a', rightCode) + "AAA" + Bytes(LF)); // right
+ // Each text line translates to its justified x before drawing; right starts farther right.
+ var dx = RenderReceipt().Translates.Select(t => t.dx).ToList();
+ Assert.True(dx.Count >= 2);
+ Assert.True(dx[1] > dx[0], "right-justified text should start farther right than left-justified");
+ }
+
+ // ---- cutting & feeding ---------------------------------------------------------------------
+
+ [Fact]
+ public void GsVCut_StartsNewReceipt()
+ {
+ _printer.FeedEscPos("First" + Bytes(LF, GS, 'V', 0) + "Second" + Bytes(LF));
+ Assert.Equal(2, _printer.ReceiptStack.Count(r => !r.IsEmpty));
+ }
+
+ [Fact]
+ public void EscMCut_StartsNewReceipt()
+ {
+ _printer.FeedEscPos("First" + Bytes(LF, ESC, 'm') + "Second" + Bytes(LF));
+ Assert.Equal(2, _printer.ReceiptStack.Count(r => !r.IsEmpty));
+ }
+
+ [Fact]
+ public void FeedNLines_AddsContent()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'd', 3));
+ Assert.False(_printer.CurrentReceipt.IsEmpty);
+ }
+
+ // ---- barcodes & images ---------------------------------------------------------------------
+
+ [Fact]
+ public void Barcode_FunctionA_DrawsImageAndModules()
+ {
+ _printer.FeedEscPos(Barcode(4 /*CODE39*/, "ABC123")); // m=4 -> CODE39 Function A
+ Assert.True(RenderReceipt().ImageDraws > 0, "barcode should be placed on the receipt");
+ Assert.Contains(_factory.Canvases, c => c.RectDraws > 0); // modules were drawn
+ }
+
+ [Fact]
+ public void QrSymbol_StoreAndPrint_DrawsImage()
+ {
+ _printer.FeedEscPos(Symbol2D(49 /*QR*/, "https://example.com"));
+ Assert.True(RenderReceipt().ImageDraws > 0);
+ }
+
+ [Fact]
+ public void DataMatrixSymbol_DrawsImage()
+ {
+ _printer.FeedEscPos(Symbol2D(54 /*DataMatrix*/, "DM-DATA"));
+ Assert.True(RenderReceipt().ImageDraws > 0);
+ }
+
+ [Fact]
+ public void RasterBitImage_DrawsImage()
+ {
+ _printer.FeedEscPos(Raster());
+ Assert.True(RenderReceipt().ImageDraws > 0);
+ }
+
+ [Fact]
+ public void BitImageMode_DrawsImage()
+ {
+ _printer.FeedEscPos(Bytes(ESC, '*', 0, 1, 0, 0xFF)); // ESC * m=0 nL=1 nH=0 + 1 byte
+ Assert.True(RenderReceipt().ImageDraws > 0);
+ }
+
+ // ---- status / transmit-back ----------------------------------------------------------------
+
+ [Fact]
+ public void DleEot_PrinterStatus_ReflectsOnlineState()
+ {
+ var responder = new CapturingResponder();
+ _printer.FeedEscPos(Bytes(DLE, EOT, 1), responder);
+ Assert.Equal(new byte[] { StatusByteBuilder.PrinterStatus(_printer.State) }, responder.Bytes);
+ }
+
+ [Fact]
+ public void DleEot_PrinterStatus_ReportsOffline()
+ {
+ _printer.State.Online = false;
+ var responder = new CapturingResponder();
+ _printer.FeedEscPos(Bytes(DLE, EOT, 1), responder);
+ Assert.Equal(new byte[] { 0x1A }, responder.Bytes); // fixed 0x12 | offline 0x08
+ }
+
+ [Fact]
+ public void GsR_PaperStatus_ReportsPaperOut()
+ {
+ _printer.State.Paper = PaperLevel.Out;
+ var responder = new CapturingResponder();
+ _printer.FeedEscPos(Bytes(GS, 'r', 1), responder);
+ Assert.Equal(new byte[] { StatusByteBuilder.TransmitPaperStatus(_printer.State) }, responder.Bytes);
+ }
+
+ [Fact]
+ public void GsI_PrinterId_ReturnsModelByte()
+ {
+ var responder = new CapturingResponder();
+ _printer.FeedEscPos(Bytes(GS, 'I', 1), responder);
+ Assert.Equal(new byte[] { 0x02 }, responder.Bytes);
+ }
+
+ [Fact]
+ public void GsA_AutomaticStatusBack_BroadcastsFourBytes()
+ {
+ var responder = new CapturingResponder();
+ _printer.RegisterResponder(responder);
+ _printer.FeedEscPos(Bytes(GS, 'a', 0xFF));
+ Assert.Equal(4, responder.Bytes.Count);
+ }
+
+ // ---- peripherals & real-time ---------------------------------------------------------------
+
+ [Fact]
+ public void Buzzer_Bell_RaisesEvent()
+ {
+ var buzzed = false;
+ _printer.OnBuzzer += () => buzzed = true;
+ _printer.FeedEscPos(Bytes(BEL));
+ Assert.True(buzzed);
+ }
+
+ [Fact]
+ public void CashDrawer_EscP_OpensDrawerAndRaisesEvent()
+ {
+ var kicked = false;
+ _printer.OnCashDrawer += () => kicked = true;
+ _printer.FeedEscPos(Bytes(ESC, 'p', 0, 50, 50)); // ESC p m t1 t2
+ Assert.True(kicked);
+ Assert.True(_printer.State.DrawerOpen);
+ }
+
+ [Fact]
+ public void CashDrawer_RealtimeDc4_OpensDrawer()
+ {
+ _printer.FeedEscPos(Bytes(DLE, DC4, 1, 0, 0)); // DLE DC4 fn=1 m t
+ Assert.True(_printer.State.DrawerOpen);
+ }
+
+ [Fact]
+ public void RealtimeRecover_DleEnq_ClearsRecoverableError()
+ {
+ _printer.State.Error = PrinterErrorState.Recoverable;
+ _printer.FeedEscPos(Bytes(DLE, ENQ));
+ Assert.Equal(PrinterErrorState.None, _printer.State.Error);
+ }
+
+ // ---- code pages ----------------------------------------------------------------------------
+
+ [Fact]
+ public void CodePage_EscT_RemapsHighBytes()
+ {
+ // PC850 (table 2): byte 0x80 is 'Ç' (U+00C7).
+ _printer.FeedEscPos(Bytes(ESC, 't', 2, 0x80) + Bytes(LF));
+ Assert.Contains(RenderReceipt().DrawnText, t => t.Contains('Ç'));
+ }
+
+ // ---- page mode -----------------------------------------------------------------------------
+
+ [Fact]
+ public void PageMode_EscL_Ff_FlushesPageAsImage()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'L') + "page" + Bytes(LF, FF));
+ Assert.True(RenderReceipt().ImageDraws > 0); // the page is rasterized onto the receipt
+ }
+
+ [Fact]
+ public void PageMode_EscS_DiscardsBufferedPage()
+ {
+ _printer.FeedEscPos(Bytes(ESC, 'L') + "page" + Bytes(ESC, 'S'));
+ Assert.True(_printer.CurrentReceipt.IsEmpty);
+ }
+
+ // ---- not-ready handling --------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("offline")]
+ [InlineData("paper")]
+ [InlineData("cover")]
+ public void Print_IsDropped_WhenNotReady(string condition)
+ {
+ switch (condition)
+ {
+ case "offline": _printer.State.Online = false; break;
+ case "paper": _printer.State.Paper = PaperLevel.Out; break;
+ case "cover": _printer.State.CoverOpen = true; break;
+ }
+
+ string? blockedReason = null;
+ _printer.OnPrintBlocked += r => blockedReason = r;
+
+ _printer.FeedEscPos("should not print" + Bytes(LF));
+
+ Assert.True(_printer.CurrentReceipt.IsEmpty);
+ Assert.NotNull(blockedReason);
+ }
+
+ // ---- robustness ----------------------------------------------------------------------------
+
+ [Fact]
+ public void UnsupportedCommand_DoesNotThrow()
+ {
+ // An unknown ESC sequence: the interpreter logs and recovers rather than crashing the feed.
+ var ex = Record.Exception(() => _printer.FeedEscPos(Bytes(ESC, 0x99, 0x99) + "after" + Bytes(LF)));
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void FeedEscPos_RaisesActivityEvent()
+ {
+ int activity = 0;
+ _printer.OnActivityEvent += (_, _) => activity++;
+ _printer.FeedEscPos("x" + Bytes(LF));
+ Assert.Equal(1, activity);
+ }
+
+ [Fact]
+ public void FeedEscPos_ByteArray_RendersSameAsString()
+ {
+ byte[] data = System.Text.Encoding.Latin1.GetBytes("Bytes" + Bytes(LF));
+ _printer.FeedEscPos(data); // the byte[] overload — ESC/POS is binary
+ Assert.Contains("Bytes", RenderReceipt().DrawnText);
+ }
+}
diff --git a/tests/CrossEscPos.Core.Tests/EscPosSequence.cs b/tests/CrossEscPos.Core.Tests/EscPosSequence.cs
new file mode 100644
index 0000000..14aafa5
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/EscPosSequence.cs
@@ -0,0 +1,40 @@
+using System.Text;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// Builds raw ESC/POS byte strings (one char per byte, matching how the interpreter consumes input)
+/// for the end-to-end emulator tests.
+///
+internal static class EscPosSequence
+{
+ public const int ESC = 0x1B, GS = 0x1D, FS = 0x1C, DLE = 0x10;
+ public const int NUL = 0x00, BEL = 0x07, HT = 0x09, LF = 0x0A, FF = 0x0C, CR = 0x0D, CAN = 0x18;
+ public const int EOT = 0x04, ENQ = 0x05, DC4 = 0x14;
+
+ /// Packs byte values (chars are fine — they convert) into a one-char-per-byte string.
+ public static string Bytes(params int[] values)
+ {
+ var sb = new StringBuilder(values.Length);
+ foreach (var v in values)
+ sb.Append((char)(v & 0xFF));
+ return sb.ToString();
+ }
+
+ public static string Text(string s) => s;
+
+ /// GS k Function A: print a 1D barcode (NUL-terminated data).
+ public static string Barcode(int systemA, string data) => Bytes(GS, 'k', systemA) + data + Bytes(NUL);
+
+ /// GS ( k: store then print a 2D symbol of family .
+ public static string Symbol2D(int cn, string data)
+ {
+ const int store = 80, print = 81, m = 48;
+ int len = 3 + data.Length; // cn + fn + m + data
+ return Bytes(GS, '(', 'k', len & 0xFF, len >> 8, cn, store, m) + data
+ + Bytes(GS, '(', 'k', 3, 0, cn, print, m);
+ }
+
+ /// GS v 0: a minimal 8x1 raster bit image.
+ public static string Raster() => Bytes(GS, 'v', '0', 0, 1, 0, 1, 0, 0xFF);
+}
diff --git a/tests/CrossEscPos.Core.Tests/FakeRenderBackend.cs b/tests/CrossEscPos.Core.Tests/FakeRenderBackend.cs
new file mode 100644
index 0000000..175b103
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/FakeRenderBackend.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using CrossEscPos.Graphics;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// A pure-managed render backend with no graphics library behind it. Its existence is the point: the
+/// emulator core renders against the abstraction only, so it runs with a completely synthetic backend
+/// — proof that the render layer is swappable and that Core is truly headless (no SkiaSharp at all).
+/// It also records primitive draw calls so tests can assert that drawing actually happened.
+///
+public sealed class FakeImageFactory : IReceiptImageFactory
+{
+ public List Canvases { get; } = new();
+
+ public IReceiptImage Create(int width, int height, ReceiptColor fill) => new FakeImage(width, height);
+
+ public IReceiptImage FromPixels(int width, int height, ReceiptColor[] rowMajorPixels) => new FakeImage(width, height);
+
+ public IReceiptCanvas CreateCanvas(IReceiptImage image)
+ {
+ var canvas = new FakeCanvas((FakeImage)image);
+ Canvases.Add(canvas);
+ return canvas;
+ }
+}
+
+public sealed class FakeImage : IReceiptImage
+{
+ public FakeImage(int width, int height) { Width = width; Height = height; }
+ public int Width { get; }
+ public int Height { get; }
+ public IReceiptImage Copy() => new FakeImage(Width, Height);
+ public void Dispose() { }
+}
+
+public sealed class FakeCanvas : IReceiptCanvas
+{
+ public FakeCanvas(FakeImage image) => Image = image;
+ public FakeImage Image { get; }
+ public int TextDraws => DrawnText.Count;
+ public int RectDraws { get; private set; }
+ public int ImageDraws { get; private set; }
+
+ /// The text strings drawn, in order.
+ public List DrawnText { get; } = new();
+
+ /// The (sx, sy) of every Scale applied (character magnification shows up here).
+ public List<(float sx, float sy)> Scales { get; } = new();
+
+ /// Every Translate applied — text lines translate to their justified x before drawing.
+ public List<(float dx, float dy)> Translates { get; } = new();
+
+ private int _saveDepth;
+
+ public void Clear(ReceiptColor color) { }
+
+ public void DrawText(string text, float x, float baselineY, IReceiptFont font, ReceiptColor color)
+ => DrawnText.Add(text);
+
+ public void DrawRect(ReceiptRect rect, ReceiptColor color) => RectDraws++;
+ public void DrawLine(float x0, float y0, float x1, float y1, ReceiptColor color, float strokeWidth) { }
+ public void DrawImage(IReceiptImage image, float x, float y) => ImageDraws++;
+ public void DrawImage(IReceiptImage image, ReceiptRect dest) => ImageDraws++;
+ public int Save() => ++_saveDepth;
+ public void Translate(float dx, float dy) => Translates.Add((dx, dy));
+ public void Scale(float sx, float sy) => Scales.Add((sx, sy));
+ public void RestoreToCount(int count) => _saveDepth = count;
+ public void Flush() { }
+ public void Dispose() { }
+}
+
+///
+/// A typeface provider with fixed, deterministic metrics — no font files, no SkiaSharp. Records every
+/// font request so tests can assert that bold/italic styles were applied.
+///
+public sealed class FakeTypefaceProvider : ITypefaceProvider
+{
+ public List<(string family, bool bold, bool italic, float size)> Requests { get; } = new();
+
+ public IReceiptFont GetFont(string family, bool bold, bool italic, float sizePx)
+ {
+ Requests.Add((family, bold, italic, sizePx));
+ return new FakeFont(sizePx);
+ }
+}
+
+public sealed class FakeFont : IReceiptFont
+{
+ public FakeFont(float size) => Size = size;
+ public float Size { get; }
+ public FontMetrics Metrics => new(-Size * 0.8f, Size * 0.2f);
+ public float MeasureText(string text) => text.Length * (Size * 0.5f);
+ public void Dispose() { }
+}
diff --git a/tests/CrossEscPos.Core.Tests/ImageSharpRenderTests.cs b/tests/CrossEscPos.Core.Tests/ImageSharpRenderTests.cs
new file mode 100644
index 0000000..1b751b3
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/ImageSharpRenderTests.cs
@@ -0,0 +1,55 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.Rendering.ImageSharp;
+using QRCoder;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// End-to-end render tests for the managed backend — the mirror of
+/// . This backend has no native dependency, so it works in Blazor WASM.
+///
+public class ImageSharpRenderTests
+{
+ private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+
+ private static ReceiptPrinter NewImageSharpPrinter() =>
+ new(PaperConfiguration.Default, new ImageSharpImageFactory(), new ImageSharpTypefaceProvider());
+
+ [Fact]
+ public void Render_ProducesPaperWidthImage()
+ {
+ var printer = NewImageSharpPrinter();
+ printer.FeedEscPos("ImageSharp render test\n");
+
+ using var image = printer.CurrentReceipt.Render();
+
+ Assert.Equal(PaperConfiguration.Default.GetPaperWidthInPixels(), image.Width);
+ Assert.True(image.Height > 0);
+ }
+
+ [Fact]
+ public void EncodePng_EmitsValidPngSignature()
+ {
+ var printer = NewImageSharpPrinter();
+ printer.FeedEscPos("Encode me\n");
+
+ using var image = printer.CurrentReceipt.Render();
+ var png = new ImageSharpImageEncoder().EncodePng(image);
+
+ Assert.True(png.Length > PngSignature.Length);
+ Assert.Equal(PngSignature, png[..PngSignature.Length]);
+ }
+
+ [Fact]
+ public void BarcodeRenderer_RenderQr_ProducesSquareImage()
+ {
+ var renderer = new BarcodeRenderer(new ImageSharpImageFactory(), new ImageSharpTypefaceProvider());
+
+ using var image = renderer.RenderQr("https://example.com", moduleSizeDots: 3, QRCodeGenerator.ECCLevel.M);
+
+ Assert.True(image.Width > 0);
+ Assert.Equal(image.Width, image.Height); // QR symbols are square
+ }
+}
diff --git a/tests/CrossEscPos.Core.Tests/ReceiptModelTests.cs b/tests/CrossEscPos.Core.Tests/ReceiptModelTests.cs
new file mode 100644
index 0000000..5973438
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/ReceiptModelTests.cs
@@ -0,0 +1,81 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+/// Coverage for the receipt document model and the supporting value types.
+public class ReceiptModelTests
+{
+ private static Receipt NewReceipt() =>
+ new(PaperConfiguration.Default, new PrintMode(), lineSpacing: 10, new FakeImageFactory(), new FakeTypefaceProvider());
+
+ [Fact]
+ public void NewReceipt_IsEmpty()
+ => Assert.True(NewReceipt().IsEmpty);
+
+ [Fact]
+ public void PrintText_MakesReceiptNonEmpty()
+ {
+ var receipt = NewReceipt();
+ receipt.PrintText("hello", new PrintMode());
+ Assert.False(receipt.IsEmpty);
+ }
+
+ [Fact]
+ public void Render_UsesPaperWidth_AndAccountsForContent()
+ {
+ var receipt = NewReceipt();
+ using var empty = receipt.Render();
+ receipt.PrintText("line", new PrintMode());
+ receipt.AdvanceToNewLine();
+ using var withText = receipt.Render();
+
+ Assert.Equal(PaperConfiguration.Default.GetPaperWidthInPixels(), withText.Width);
+ Assert.True(withText.Height >= empty.Height);
+ }
+
+ [Fact]
+ public void PrintBitmap_AddsImageLine()
+ {
+ var factory = new FakeImageFactory();
+ var receipt = new Receipt(PaperConfiguration.Default, new PrintMode(), 10, factory, new FakeTypefaceProvider());
+ receipt.PrintBitmap(factory.Create(48, 48, default));
+
+ using var _ = receipt.Render();
+ Assert.Contains(factory.Canvases, c => c.ImageDraws > 0);
+ }
+
+ [Fact]
+ public void PrinterState_Changed_FiresOncePerMutation()
+ {
+ var state = new PrinterState();
+ int changes = 0;
+ state.Changed += () => changes++;
+
+ state.Online = false;
+ state.Paper = PaperLevel.Out;
+
+ Assert.Equal(2, changes);
+ }
+
+ [Fact]
+ public void PaperConfiguration_PixelWidths_DeriveFromMillimetres()
+ {
+ var config = PaperConfiguration.Default;
+ // 80mm @ 203 dpi ≈ 640 dots; print width is narrower than paper width.
+ Assert.True(config.GetPaperWidthInPixels() > config.GetPrintWidthInPixels());
+ Assert.InRange(config.GetPaperWidthInPixels(), 600, 700);
+ }
+
+ [Fact]
+ public void PrintMode_Clone_IsIndependentButEqual()
+ {
+ var mode = new PrintMode { Emphasize = true, Justification = TextJustification.Center };
+ var clone = mode.Clone();
+
+ Assert.Equal(mode, clone);
+ clone.Emphasize = false;
+ Assert.NotEqual(mode, clone);
+ }
+}
diff --git a/tests/CrossEscPos.Core.Tests/SkiaRenderTests.cs b/tests/CrossEscPos.Core.Tests/SkiaRenderTests.cs
new file mode 100644
index 0000000..a4d5aff
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/SkiaRenderTests.cs
@@ -0,0 +1,55 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Rendering;
+using CrossEscPos.Rendering.Skia;
+using QRCoder;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// End-to-end tests through the real SkiaSharp backend — the same composition the headless sample,
+/// desktop and browser hosts use.
+///
+public class SkiaRenderTests
+{
+ private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+
+ private static ReceiptPrinter NewSkiaPrinter() =>
+ new(PaperConfiguration.Default, new SkiaImageFactory(), new SkiaTypefaceProvider());
+
+ [Fact]
+ public void Render_ProducesPaperWidthImage()
+ {
+ var printer = NewSkiaPrinter();
+ printer.FeedEscPos("Skia render test\n");
+
+ using var image = printer.CurrentReceipt.Render();
+
+ Assert.Equal(PaperConfiguration.Default.GetPaperWidthInPixels(), image.Width);
+ Assert.True(image.Height > 0);
+ }
+
+ [Fact]
+ public void EncodePng_EmitsValidPngSignature()
+ {
+ var printer = NewSkiaPrinter();
+ printer.FeedEscPos("Encode me\n");
+
+ using var image = printer.CurrentReceipt.Render();
+ var png = new SkiaImageEncoder().EncodePng(image);
+
+ Assert.True(png.Length > PngSignature.Length);
+ Assert.Equal(PngSignature, png[..PngSignature.Length]);
+ }
+
+ [Fact]
+ public void BarcodeRenderer_RenderQr_ProducesSquareImage()
+ {
+ var renderer = new BarcodeRenderer(new SkiaImageFactory(), new SkiaTypefaceProvider());
+
+ using var image = renderer.RenderQr("https://example.com", moduleSizeDots: 3, QRCodeGenerator.ECCLevel.M);
+
+ Assert.True(image.Width > 0);
+ Assert.Equal(image.Width, image.Height); // QR symbols are square
+ }
+}
diff --git a/tests/CrossEscPos.Core.Tests/SmartEnumTests.cs b/tests/CrossEscPos.Core.Tests/SmartEnumTests.cs
new file mode 100644
index 0000000..07777c8
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/SmartEnumTests.cs
@@ -0,0 +1,158 @@
+using CrossEscPos.EscPos;
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+using CrossEscPos.Emulator.Rendering;
+using QRCoder;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+///
+/// Locks the SmartEnum code maps that replaced the magic-number switches (ESC/POS code tables, 1D/2D
+/// systems, status requests) — value lookups and unknown-value fallbacks.
+///
+public class SmartEnumTests
+{
+ [Theory]
+ [InlineData(0, 437)]
+ [InlineData(2, 850)]
+ [InlineData(16, 1252)]
+ [InlineData(19, 858)]
+ public void CharacterCodeTable_MapsTableToCodePage(int table, int expectedCodePage)
+ => Assert.Equal(expectedCodePage, CharacterCodeTable.FromTableId(table).CodePage);
+
+ [Fact]
+ public void CharacterCodeTable_UnknownTable_FallsBackToPc437()
+ => Assert.Equal(CharacterCodeTable.Pc437, CharacterCodeTable.FromTableId(99));
+
+ [Theory]
+ [InlineData(48, "Pdf417")]
+ [InlineData(49, "QrCode")]
+ [InlineData(54, "DataMatrix")]
+ [InlineData(55, "Aztec")]
+ public void TwoDimensionCode_ResolvesCn(int cn, string expectedName)
+ => Assert.Equal(expectedName, TwoDimensionCode.FromCn(cn).Name);
+
+ [Fact]
+ public void TwoDimensionCode_UnknownCn_FallsBackToQr()
+ => Assert.Equal(TwoDimensionCode.QrCode, TwoDimensionCode.FromCn(99));
+
+ [Fact]
+ public void TwoDimensionCode_QrCode_RendersSquareImage()
+ {
+ var renderer = new BarcodeRenderer(new FakeImageFactory(), new FakeTypefaceProvider());
+
+ using var image = TwoDimensionCode.QrCode.Render(renderer, "hello", 3, QRCodeGenerator.ECCLevel.M);
+
+ Assert.True(image.Width > 0);
+ Assert.Equal(image.Width, image.Height); // QR symbols are square
+ }
+
+ [Theory]
+ [InlineData(0, "UpcA")] // Function A
+ [InlineData(65, "UpcA")] // Function B (same symbology)
+ [InlineData(6, "Codabar")]
+ [InlineData(72, "Code93")] // Function-B only
+ [InlineData(73, "Code128")]
+ public void Barcode1DSystem_ResolvesFunctionAandB(int m, string expectedName)
+ => Assert.Equal(expectedName, Barcode1DSystem.FromCommandCode(m)!.Name);
+
+ [Fact]
+ public void Barcode1DSystem_UnknownCode_IsNull()
+ => Assert.Null(Barcode1DSystem.FromCommandCode(200));
+
+ [Theory]
+ [InlineData(1, "Printer")]
+ [InlineData(2, "Offline")]
+ [InlineData(4, "PaperSensor")]
+ [InlineData(99, "Printer")] // unknown -> default
+ public void RealtimeStatusRequest_ResolvesParameter(int n, string expectedName)
+ => Assert.Equal(expectedName, RealtimeStatusRequest.FromParameter(n).Name);
+
+ [Theory]
+ [InlineData(1, "Paper")] // numeric
+ [InlineData(49, "Paper")] // ASCII '1'
+ [InlineData(2, "Drawer")]
+ [InlineData(50, "Drawer")] // ASCII '2'
+ public void TransmitStatusKind_AcceptsNumericAndAscii(int n, string expectedName)
+ => Assert.Equal(expectedName, TransmitStatusKind.FromParameter(n)!.Name);
+
+ [Fact]
+ public void TransmitStatusKind_UnknownParameter_IsNull()
+ => Assert.Null(TransmitStatusKind.FromParameter(9));
+
+ [Theory]
+ [InlineData(0, TextJustification.Left)]
+ [InlineData(48, TextJustification.Left)] // ASCII '0'
+ [InlineData(1, TextJustification.Center)]
+ [InlineData(50, TextJustification.Right)] // ASCII '2'
+ public void JustificationMode_AcceptsNumericAndAscii(int n, TextJustification expected)
+ => Assert.Equal(expected, JustificationMode.FromParameter(n)!.Justification);
+
+ [Fact]
+ public void JustificationMode_UnknownParameter_IsNull()
+ => Assert.Null(JustificationMode.FromParameter(9));
+
+ [Theory]
+ [InlineData(0, PrinterFont.FontA)]
+ [InlineData(48, PrinterFont.FontA)] // ASCII '0'
+ [InlineData(52, PrinterFont.FontE)] // ASCII '4'
+ [InlineData('a', PrinterFont.SpecialFontA)]
+ [InlineData('b', PrinterFont.SpecialFontB)]
+ public void FontSelection_ResolvesFontCodes(int n, PrinterFont expected)
+ => Assert.Equal(expected, FontSelection.FromParameter(n)!.Font);
+
+ [Theory]
+ [InlineData(0, CutFunction.Cut, CutShape.Full)]
+ [InlineData(49, CutFunction.Cut, CutShape.Partial)] // ASCII '1'
+ [InlineData('A', CutFunction.FeedAndCut, CutShape.Full)]
+ [InlineData('h', CutFunction.FeedAndCutAndReverse, CutShape.Partial)]
+ [InlineData(200, CutFunction.Cut, CutShape.Full)] // unknown -> full cut
+ public void CutMode_ResolvesFunctionAndShape(int n, CutFunction function, CutShape shape)
+ {
+ var mode = CutMode.FromParameter(n);
+ Assert.Equal(function, mode.Function);
+ Assert.Equal(shape, mode.Shape);
+ }
+
+ [Theory]
+ [InlineData('0', QRCodeGenerator.ECCLevel.L)]
+ [InlineData('1', QRCodeGenerator.ECCLevel.M)]
+ [InlineData('3', QRCodeGenerator.ECCLevel.H)]
+ [InlineData(99, QRCodeGenerator.ECCLevel.M)] // unknown -> medium
+ public void QrErrorCorrectionLevel_MapsParameter(int n, QRCodeGenerator.ECCLevel expected)
+ => Assert.Equal(expected, QrErrorCorrectionLevel.FromParameter(n));
+
+ [Theory]
+ [InlineData(1, new byte[] { 0x02 })] // ModelId (numeric)
+ [InlineData(49, new byte[] { 0x02 })] // ModelId (ASCII '1')
+ [InlineData(2, new byte[] { 0x00 })] // TypeId
+ [InlineData(3, new byte[] { 0x01 })] // RomVersion
+ public void PrinterIdRequest_InfoA_ReturnsSingleByte(int n, byte[] expected)
+ => Assert.Equal(expected, PrinterIdRequest.FromParameter(n).Response);
+
+ [Fact]
+ public void PrinterIdRequest_InfoB_FramesTextResponse()
+ {
+ var response = PrinterIdRequest.FromParameter(66).Response; // MakerName
+ Assert.Equal(0x5F, response[0]);
+ Assert.Equal(0x00, response[^1]);
+ Assert.Equal("CrossEscPos", System.Text.Encoding.ASCII.GetString(response, 1, response.Length - 2));
+ }
+
+ [Fact]
+ public void PrinterIdRequest_UnknownParameter_FallsBackToGenericInfoB()
+ => Assert.Equal(PrinterIdRequest.Unknown, PrinterIdRequest.FromParameter(99));
+
+ [Theory]
+ [InlineData(67, "SetModuleSize")]
+ [InlineData(69, "SetErrorCorrection")]
+ [InlineData(80, "StoreData")]
+ [InlineData(81, "PrintSymbol")]
+ public void TwoDSymbolFunction_ResolvesFn(int fn, string expectedName)
+ => Assert.Equal(expectedName, TwoDSymbolFunction.FromValue(fn).Name);
+
+ [Fact]
+ public void TwoDSymbolFunction_UnknownFn_NotFound()
+ => Assert.False(TwoDSymbolFunction.TryFromValue(99, out _));
+}
diff --git a/tests/CrossEscPos.Core.Tests/StatusByteBuilderTests.cs b/tests/CrossEscPos.Core.Tests/StatusByteBuilderTests.cs
new file mode 100644
index 0000000..7824016
--- /dev/null
+++ b/tests/CrossEscPos.Core.Tests/StatusByteBuilderTests.cs
@@ -0,0 +1,82 @@
+using CrossEscPos.Emulator;
+using CrossEscPos.Emulator.Enums;
+using Xunit;
+
+namespace CrossEscPos.Core.Tests;
+
+/// Exact bit-layout coverage for the ESC/POS status bytes (DLE EOT, GS r, GS a).
+public class StatusByteBuilderTests
+{
+ private const byte Fixed = 0x12; // bits 1 + 4, always set in the single-byte responses
+
+ private static PrinterState State(
+ bool online = true, bool cover = false, PaperLevel paper = PaperLevel.Adequate,
+ bool drawer = false, PrinterErrorState error = PrinterErrorState.None, bool feed = false)
+ => new() { Online = online, CoverOpen = cover, Paper = paper, DrawerOpen = drawer, Error = error, FeedButtonPressed = feed };
+
+ [Fact]
+ public void PrinterStatus_Default_IsFixedBitsOnly()
+ => Assert.Equal(Fixed, StatusByteBuilder.PrinterStatus(State()));
+
+ [Fact]
+ public void PrinterStatus_DrawerOpen_SetsBit2()
+ => Assert.Equal((byte)(Fixed | 0x04), StatusByteBuilder.PrinterStatus(State(drawer: true)));
+
+ [Fact]
+ public void PrinterStatus_Offline_SetsBit3()
+ => Assert.Equal((byte)(Fixed | 0x08), StatusByteBuilder.PrinterStatus(State(online: false)));
+
+ [Fact]
+ public void OfflineStatus_CoverOpen_SetsBit2()
+ => Assert.Equal((byte)(Fixed | 0x04), StatusByteBuilder.OfflineStatus(State(cover: true)));
+
+ [Fact]
+ public void OfflineStatus_PaperOut_SetsBit5()
+ => Assert.Equal((byte)(Fixed | 0x20), StatusByteBuilder.OfflineStatus(State(paper: PaperLevel.Out)));
+
+ [Fact]
+ public void OfflineStatus_Error_SetsBit6()
+ => Assert.Equal((byte)(Fixed | 0x40), StatusByteBuilder.OfflineStatus(State(error: PrinterErrorState.Recoverable)));
+
+ [Fact]
+ public void ErrorStatus_Unrecoverable_SetsBit5()
+ => Assert.Equal((byte)(Fixed | 0x20), StatusByteBuilder.ErrorStatus(State(error: PrinterErrorState.Unrecoverable)));
+
+ [Fact]
+ public void ErrorStatus_Recoverable_SetsBit6()
+ => Assert.Equal((byte)(Fixed | 0x40), StatusByteBuilder.ErrorStatus(State(error: PrinterErrorState.Recoverable)));
+
+ [Fact]
+ public void PaperSensorStatus_NearEnd_SetsBits2And3()
+ => Assert.Equal((byte)(Fixed | 0x0C), StatusByteBuilder.PaperSensorStatus(State(paper: PaperLevel.NearEnd)));
+
+ [Fact]
+ public void PaperSensorStatus_Out_SetsNearEndAndEndBits()
+ => Assert.Equal((byte)(Fixed | 0x0C | 0x60), StatusByteBuilder.PaperSensorStatus(State(paper: PaperLevel.Out)));
+
+ [Theory]
+ [InlineData(PaperLevel.Adequate, 0x00)]
+ [InlineData(PaperLevel.NearEnd, 0x03)]
+ [InlineData(PaperLevel.Out, 0x0F)]
+ public void TransmitPaperStatus_MapsPaperLevel(PaperLevel paper, int expected)
+ => Assert.Equal((byte)expected, StatusByteBuilder.TransmitPaperStatus(State(paper: paper)));
+
+ [Theory]
+ [InlineData(false, 0x00)]
+ [InlineData(true, 0x01)]
+ public void TransmitDrawerStatus_MapsDrawer(bool open, int expected)
+ => Assert.Equal((byte)expected, StatusByteBuilder.TransmitDrawerStatus(State(drawer: open)));
+
+ [Fact]
+ public void AutoStatusBack_Default_HasDrawerClosedBitSet()
+ {
+ var asb = StatusByteBuilder.AutoStatusBack(State());
+ Assert.Equal(4, asb.Length);
+ Assert.Equal(0x14, asb[0]); // bit4 fixed + bit2 (drawer closed)
+ Assert.Equal(0x00, asb[1]);
+ }
+
+ [Fact]
+ public void AutoStatusBack_Offline_SetsBit3()
+ => Assert.Equal(0x14 | 0x08, StatusByteBuilder.AutoStatusBack(State(online: false))[0]);
+}