diff --git a/.vscode/settings.json b/.vscode/settings.json index 26710c7..bf6734c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -90,8 +90,12 @@ "cSpell.words": [ "fakelabel", "fontsize", + "gboolean", + "gint", + "gobject", "halign", "libusb", - "ptrnt" + "ptrnt", + "strv" ], } \ No newline at end of file diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..e9085f3 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,43 @@ +# Git Hooks + +This directory contains git hooks for the ptouch-prnt repository. + +## Installation + +To install the hooks, run: + +```bash +./hooks/install_hooks.sh +``` + +This will copy all hooks from this directory to `.git/hooks/` and make them executable. + +## Available Hooks + +### pre-commit + +The pre-commit hook automatically updates copyright headers in source files before each commit. + +**What it does:** +- Runs `scripts/update_copyright.sh` to update copyright years in source files +- Automatically re-stages any modified files +- Ensures copyright headers are always up-to-date + +**Requirements:** +- `scripts/update_copyright.sh` must exist and be executable + +## Skipping Hooks + +If you need to skip the pre-commit hook for a specific commit (not recommended), use: + +```bash +git commit --no-verify +``` + +## Uninstalling + +To remove a hook, simply delete it from `.git/hooks/`: + +```bash +rm .git/hooks/pre-commit +``` diff --git a/hooks/install_hooks.sh b/hooks/install_hooks.sh new file mode 100755 index 0000000..24e66f0 --- /dev/null +++ b/hooks/install_hooks.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Install git hooks for ptouch-prnt repository + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get the root directory of the git repository +ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) + +if [ -z "$ROOT_DIR" ]; then + echo -e "${RED}Error: Not in a git repository${NC}" + exit 1 +fi + +HOOKS_SOURCE_DIR="$ROOT_DIR/hooks" +HOOKS_TARGET_DIR="$ROOT_DIR/.git/hooks" + +echo "Installing git hooks..." +echo " Source: $HOOKS_SOURCE_DIR" +echo " Target: $HOOKS_TARGET_DIR" +echo "" + +# Check if hooks directory exists +if [ ! -d "$HOOKS_SOURCE_DIR" ]; then + echo -e "${RED}Error: Hooks source directory not found: $HOOKS_SOURCE_DIR${NC}" + exit 1 +fi + +# Check if .git/hooks directory exists +if [ ! -d "$HOOKS_TARGET_DIR" ]; then + echo -e "${RED}Error: Git hooks directory not found: $HOOKS_TARGET_DIR${NC}" + exit 1 +fi + +# Install each hook +installed_count=0 +for hook_file in "$HOOKS_SOURCE_DIR"/*; do + # Skip the install script itself + if [[ "$(basename "$hook_file")" == "install_hooks.sh" ]]; then + continue + fi + + # Skip if not a file + if [ ! -f "$hook_file" ]; then + continue + fi + + hook_name=$(basename "$hook_file") + target_file="$HOOKS_TARGET_DIR/$hook_name" + + # Check if hook already exists + if [ -f "$target_file" ]; then + echo -e "${YELLOW}Warning: Hook already exists: $hook_name${NC}" + read -p " Overwrite? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo " Skipped: $hook_name" + continue + fi + fi + + # Copy and make executable + cp "$hook_file" "$target_file" + chmod +x "$target_file" + echo -e "${GREEN}✓${NC} Installed: $hook_name" + ((installed_count++)) +done + +echo "" +if [ $installed_count -eq 0 ]; then + echo -e "${YELLOW}No hooks were installed${NC}" +else + echo -e "${GREEN}Successfully installed $installed_count hook(s)${NC}" +fi + +# Verify update_copyright.sh exists and is executable +if [ -f "$ROOT_DIR/scripts/update_copyright.sh" ]; then + if [ ! -x "$ROOT_DIR/scripts/update_copyright.sh" ]; then + echo "" + echo -e "${YELLOW}Making update_copyright.sh executable...${NC}" + chmod +x "$ROOT_DIR/scripts/update_copyright.sh" + echo -e "${GREEN}✓${NC} update_copyright.sh is now executable" + fi +else + echo "" + echo -e "${YELLOW}Warning: scripts/update_copyright.sh not found${NC}" + echo " The pre-commit hook requires this script to function properly" +fi + +echo "" +echo "Hook installation complete!" diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..12c4008 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,43 @@ +#!/bin/bash +# Pre-commit hook to update copyright headers + +# Get the root directory of the git repository +ROOT_DIR=$(git rev-parse --show-toplevel) + +# Check if update_copyright.sh exists and is executable +if [ ! -x "$ROOT_DIR/scripts/update_copyright.sh" ]; then + echo "Warning: scripts/update_copyright.sh not found or not executable" + exit 1 +fi + +# Get list of staged C++ source files +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(cpp|hpp|h|c|cc)$' || true) + +if [ -z "$STAGED_FILES" ]; then + # No C++ files staged, nothing to do + exit 0 +fi + +echo "Updating copyright headers for staged files..." + +# Update copyright for each staged file +updated=0 +for file in $STAGED_FILES; do + if [ -f "$ROOT_DIR/$file" ]; then + # Run update_copyright.sh on the file + if "$ROOT_DIR/scripts/update_copyright.sh" "$ROOT_DIR/$file" > /dev/null 2>&1; then + # Re-stage the file if it was modified + git add "$ROOT_DIR/$file" + echo " ✓ Updated: $file" + ((updated++)) + fi + fi +done + +if [ $updated -gt 0 ]; then + echo "Updated copyright headers in $updated file(s)" +else + echo "No copyright headers needed updating" +fi + +exit 0 diff --git a/meson.build b/meson.build index 358f0fb..7f1a8b1 100644 --- a/meson.build +++ b/meson.build @@ -57,9 +57,10 @@ ptprnt_exe = executable( ### Unit tests -# GTest +# GTest and GMock gtest_proj = subproject('gtest') gtest_dep = gtest_proj.get_variable('gtest_main_dep') +gmock_dep = gtest_proj.get_variable('gmock_main_dep') if not gtest_dep.found() error('MESON_SKIP_TEST: gtest not installed.') endif diff --git a/update_copyright.sh b/scripts/update_copyright.sh similarity index 100% rename from update_copyright.sh rename to scripts/update_copyright.sh diff --git a/src/PtouchPrint.cpp b/src/PtouchPrint.cpp index 93cefed..f52ae59 100644 --- a/src/PtouchPrint.cpp +++ b/src/PtouchPrint.cpp @@ -132,11 +132,46 @@ bool PtouchPrint::handlePrinting() { return true; } - // Build label using LabelBuilder + // Build label incrementally, appending when --new is encountered graphics::LabelBuilder labelBuilder(printer->getPrinterInfo().pixelLines); + std::unique_ptr finalLabel = nullptr; + + // Debug: print command sequence + spdlog::debug("Processing {} commands:", options.commands.size()); + for (size_t i = 0; i < options.commands.size(); ++i) { + const auto& [cmdType, value] = options.commands[i]; + spdlog::debug(" Command {}: type={}, value='{}'", i, static_cast(cmdType), value); + } for (const auto& [cmdType, value] : options.commands) { switch (cmdType) { + case cli::CommandType::NewLabel: { + // Finish current label and append to final label + spdlog::debug("Encountered --new, finishing current label segment"); + auto currentLabel = labelBuilder.build(); + if (!finalLabel) { + // First label becomes the base + finalLabel = std::move(currentLabel); + } else { + // If finalLabel is empty (width=0), replace it instead of appending + if (finalLabel->getWidth() == 0) { + spdlog::debug("Final label is empty, replacing instead of appending"); + finalLabel = std::move(currentLabel); + } else if (currentLabel->getWidth() == 0) { + // Current label is empty, skip appending + spdlog::debug("Current label is empty, skipping append"); + } else { + // Both labels have content, append + if (!finalLabel->append(*currentLabel)) { + spdlog::error("Failed to append label"); + return false; + } + } + } + // Reset builder for next label + labelBuilder = graphics::LabelBuilder(printer->getPrinterInfo().pixelLines); + break; + } case cli::CommandType::Text: labelBuilder.addText(value); break; @@ -178,9 +213,31 @@ bool PtouchPrint::handlePrinting() { } } - // Build and print the label - auto label = labelBuilder.build(); - if (!mPrinterService->printLabel(std::move(label))) { + // Build and append final label segment + auto lastLabel = labelBuilder.build(); + if (!finalLabel) { + // Only one label, no --new was used + finalLabel = std::move(lastLabel); + } else { + // Handle empty labels + if (finalLabel->getWidth() == 0) { + // Final label is empty, replace it + spdlog::debug("Final label is empty, replacing with last segment"); + finalLabel = std::move(lastLabel); + } else if (lastLabel->getWidth() == 0) { + // Last segment is empty, skip appending + spdlog::debug("Last label segment is empty, skipping append"); + } else { + // Both have content, append + if (!finalLabel->append(*lastLabel)) { + spdlog::error("Failed to append final label segment"); + return false; + } + } + } + + // Print the final label + if (!mPrinterService->printLabel(std::move(finalLabel))) { spdlog::error("An error occurred while printing"); return false; } diff --git a/src/PtouchPrint.hpp b/src/PtouchPrint.hpp index 38c5c97..12c30e5 100644 --- a/src/PtouchPrint.hpp +++ b/src/PtouchPrint.hpp @@ -21,6 +21,7 @@ #include #include +#include namespace ptprnt::cli { class ICliParser; @@ -30,6 +31,10 @@ namespace ptprnt::core { class IPrinterService; } +namespace ptprnt::graphics { +class ILabel; +} + namespace ptprnt { /** diff --git a/src/cli/CliParser.cpp b/src/cli/CliParser.cpp index 275b1b3..b4c7342 100644 --- a/src/cli/CliParser.cpp +++ b/src/cli/CliParser.cpp @@ -43,9 +43,52 @@ int CliParser::parse(int argc, char** argv) { mApp.exit(e); return -1; // Signal: error } + + // Post-process: Re-order commands based on actual command line order + // This is needed because CLI11 groups options by type + reorderCommandsByArgv(argc, argv); + return 0; } +void CliParser::reorderCommandsByArgv(int argc, char** argv) { + std::vector reorderedCommands; + + // Parse argv to determine the actual order + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + + if (arg == "--new") { + reorderedCommands.emplace_back(CommandType::NewLabel, ""); + } else if (arg == "-t" || arg == "--text") { + if (i + 1 < argc) { + reorderedCommands.emplace_back(CommandType::Text, argv[++i]); + } + } else if (arg == "-f" || arg == "--font") { + if (i + 1 < argc) { + reorderedCommands.emplace_back(CommandType::Font, argv[++i]); + } + } else if (arg == "-s" || arg == "--fontsize") { + if (i + 1 < argc) { + reorderedCommands.emplace_back(CommandType::FontSize, argv[++i]); + } + } else if (arg == "--valign") { + if (i + 1 < argc) { + reorderedCommands.emplace_back(CommandType::VAlign, argv[++i]); + } + } else if (arg == "--halign") { + if (i + 1 < argc) { + reorderedCommands.emplace_back(CommandType::HAlign, argv[++i]); + } + } + } + + // Only replace if we found relevant commands + if (!reorderedCommands.empty()) { + mOptions.commands = std::move(reorderedCommands); + } +} + void CliParser::setupParser() { // Version callback auto printVersion = [this](std::size_t) { @@ -89,6 +132,12 @@ void CliParser::setupParser() { mApp.add_option("--halign", "Horizontal alignment of the following text occurrences") ->multi_option_policy(CLI::MultiOptionPolicy::TakeAll) ->each([this](const std::string& align) { mOptions.commands.emplace_back(CommandType::HAlign, align); }); + + // Label separator - use an option with multi_option_policy to maintain parse order + // We need to use a dummy string parameter since .each() expects a string callback + mApp.add_flag("--new", "Start a new label (multiple labels will be stitched together)") + ->multi_option_policy(CLI::MultiOptionPolicy::TakeAll) + ->each([this](const std::string&) { mOptions.commands.emplace_back(CommandType::NewLabel, ""); }); } } // namespace ptprnt::cli diff --git a/src/cli/CliParser.hpp b/src/cli/CliParser.hpp index d4b83cd..6912439 100644 --- a/src/cli/CliParser.hpp +++ b/src/cli/CliParser.hpp @@ -66,6 +66,7 @@ class CliParser : public ICliParser { private: void setupParser(); + void reorderCommandsByArgv(int argc, char** argv); CLI::App mApp; std::string mVersionString; diff --git a/src/cli/interface/ICliParser.hpp b/src/cli/interface/ICliParser.hpp index aa1b278..fa60272 100644 --- a/src/cli/interface/ICliParser.hpp +++ b/src/cli/interface/ICliParser.hpp @@ -27,7 +27,7 @@ namespace ptprnt::cli { /** * @brief Types of CLI commands that can be issued */ -enum class CommandType { None = 0, Text = 1, FontSize = 2, Font = 3, VAlign = 4, HAlign = 5 }; +enum class CommandType { None = 0, Text = 1, FontSize = 2, Font = 3, VAlign = 4, HAlign = 5, NewLabel = 6 }; /** * @brief A command with its type and value @@ -55,6 +55,12 @@ class ICliParser { public: virtual ~ICliParser() = default; + ICliParser() = default; + ICliParser(const ICliParser&) = default; + ICliParser& operator=(const ICliParser&) = default; + ICliParser(ICliParser&&) noexcept = default; + ICliParser& operator=(ICliParser&&) noexcept = default; + /** * @brief Parse command line arguments * @param argc Argument count diff --git a/src/core/PrinterService.cpp b/src/core/PrinterService.cpp index 535c4cb..b61de91 100644 --- a/src/core/PrinterService.cpp +++ b/src/core/PrinterService.cpp @@ -45,7 +45,15 @@ std::vector> PrinterService::detectPrinters() { for (auto& usbDev : usbDevs) { auto driver = driverFactory->create(usbDev->getUsbId()); if (driver != nullptr) { - mDetectedPrinters.push_back(driver); + // Attach the USB device to the printer driver + // Convert unique_ptr to shared_ptr for attachment + std::shared_ptr sharedUsbDev = std::move(usbDev); + if (driver->attachUsbDevice(sharedUsbDev)) { + mDetectedPrinters.push_back(driver); + spdlog::debug("Successfully attached USB device to printer driver: {}", driver->getName()); + } else { + spdlog::warn("Failed to attach USB device to printer driver: {}", driver->getName()); + } } } @@ -59,6 +67,9 @@ std::shared_ptr PrinterService::selectPrinter(const std::string& auto driverFactory = std::make_unique(); auto printer = driverFactory->createByName(printerName); if (printer) { + // For virtual/fake printers, call attachUsbDevice with nullptr to initialize + // For real printers selected explicitly, they would need actual USB device + printer->attachUsbDevice(nullptr); mCurrentPrinter = printer; spdlog::info("Using explicitly selected printer: {}", printerName); return mCurrentPrinter; diff --git a/src/graphics/Bitmap.hpp b/src/graphics/Bitmap.hpp index 09fefc5..10c8c59 100644 --- a/src/graphics/Bitmap.hpp +++ b/src/graphics/Bitmap.hpp @@ -20,8 +20,6 @@ #pragma once #include -#include -#include #include namespace ptprnt::graphics { diff --git a/src/graphics/CairoWrapper.hpp b/src/graphics/CairoWrapper.hpp new file mode 100644 index 0000000..472c4f9 --- /dev/null +++ b/src/graphics/CairoWrapper.hpp @@ -0,0 +1,139 @@ +/* + ptrnt - print labels on linux + Copyright (C) 2025 Moritz Martinius + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + */ + +#pragma once + +#include "graphics/interface/ICairoWrapper.hpp" + +namespace ptprnt::graphics { + +/** + * @brief Real implementation of ICairoWrapper that forwards to actual Cairo/Pango C API + * + * This class simply forwards all calls to the real Cairo and Pango library functions. + * It's used as the default implementation in production code. + */ +class CairoWrapper : public ICairoWrapper { + public: + ~CairoWrapper() override = default; + + // Cairo image surface functions + cairo_surface_t* cairo_image_surface_create(cairo_format_t format, int width, int height) override { + return ::cairo_image_surface_create(format, width, height); + } + + void cairo_surface_destroy(cairo_surface_t* surface) override { ::cairo_surface_destroy(surface); } + + void cairo_surface_flush(cairo_surface_t* surface) override { ::cairo_surface_flush(surface); } + + void cairo_surface_mark_dirty(cairo_surface_t* surface) override { ::cairo_surface_mark_dirty(surface); } + + cairo_status_t cairo_surface_status(cairo_surface_t* surface) override { return ::cairo_surface_status(surface); } + + cairo_format_t cairo_image_surface_get_format(cairo_surface_t* surface) override { + return ::cairo_image_surface_get_format(surface); + } + + int cairo_image_surface_get_width(cairo_surface_t* surface) override { + return ::cairo_image_surface_get_width(surface); + } + + int cairo_image_surface_get_height(cairo_surface_t* surface) override { + return ::cairo_image_surface_get_height(surface); + } + + int cairo_image_surface_get_stride(cairo_surface_t* surface) override { + return ::cairo_image_surface_get_stride(surface); + } + + unsigned char* cairo_image_surface_get_data(cairo_surface_t* surface) override { + return ::cairo_image_surface_get_data(surface); + } + + cairo_status_t cairo_surface_write_to_png(cairo_surface_t* surface, const char* filename) override { + return ::cairo_surface_write_to_png(surface, filename); + } + + // Cairo context functions + cairo_t* cairo_create(cairo_surface_t* surface) override { return ::cairo_create(surface); } + + void cairo_destroy(cairo_t* cr) override { ::cairo_destroy(cr); } + + void cairo_move_to(cairo_t* cr, double x, double y) override { ::cairo_move_to(cr, x, y); } + + void cairo_set_source_rgb(cairo_t* cr, double red, double green, double blue) override { + ::cairo_set_source_rgb(cr, red, green, blue); + } + + // Pango-Cairo functions + PangoFontMap* pango_cairo_font_map_new() override { return ::pango_cairo_font_map_new(); } + + PangoContext* pango_cairo_create_context(cairo_t* cr) override { return ::pango_cairo_create_context(cr); } + + void pango_cairo_show_layout(cairo_t* cr, PangoLayout* layout) override { ::pango_cairo_show_layout(cr, layout); } + + // Pango layout functions + PangoLayout* pango_layout_new(PangoContext* context) override { return ::pango_layout_new(context); } + + void pango_layout_set_font_description(PangoLayout* layout, const PangoFontDescription* desc) override { + ::pango_layout_set_font_description(layout, desc); + } + + void pango_layout_set_text(PangoLayout* layout, const char* text, int length) override { + ::pango_layout_set_text(layout, text, length); + } + + void pango_layout_set_height(PangoLayout* layout, int height) override { + ::pango_layout_set_height(layout, height); + } + + void pango_layout_set_alignment(PangoLayout* layout, PangoAlignment alignment) override { + ::pango_layout_set_alignment(layout, alignment); + } + + void pango_layout_set_justify(PangoLayout* layout, gboolean justify) override { + ::pango_layout_set_justify(layout, justify); + } + +#if PANGO_VERSION_MAJOR >= 1 && PANGO_VERSION_MINOR >= 50 + void pango_layout_set_justify_last_line(PangoLayout* layout, gboolean justify) override { + ::pango_layout_set_justify_last_line(layout, justify); + } +#endif + + void pango_layout_get_size(PangoLayout* layout, int* width, int* height) override { + ::pango_layout_get_size(layout, width, height); + } + + // Pango font description functions + PangoFontDescription* pango_font_description_new() override { return ::pango_font_description_new(); } + + void pango_font_description_set_size(PangoFontDescription* desc, gint size) override { + ::pango_font_description_set_size(desc, size); + } + + void pango_font_description_set_family(PangoFontDescription* desc, const char* family) override { + ::pango_font_description_set_family(desc, family); + } + + // GObject reference counting + void g_object_unref(gpointer object) override { ::g_object_unref(object); } +}; + +} // namespace ptprnt::graphics diff --git a/src/graphics/Label.cpp b/src/graphics/Label.cpp index 22e4515..3f68d58 100644 --- a/src/graphics/Label.cpp +++ b/src/graphics/Label.cpp @@ -25,37 +25,63 @@ #include #include #include +#include #include #include #include "cairo.h" +#include "graphics/CairoWrapper.hpp" +#include "graphics/interface/ICairoWrapper.hpp" #include "graphics/interface/ILabel.hpp" #include "pango/pango-font.h" #include "pango/pango-layout.h" #include "pango/pango-types.h" -#include "pango/pangocairo.h" namespace ptprnt::graphics { -Label::Label(const uint16_t heightPixel) - : mPrinterHeight(heightPixel) { - // Initialize resources in correct order with RAII - mFontMap.reset(pango_cairo_font_map_new()); + +// Deleter implementations +void CairoSurfaceDeleter::operator()(cairo_surface_t* surface) const { + if (surface && wrapper) + wrapper->cairo_surface_destroy(surface); } -std::vector Label::getRaw() { +void CairoDeleter::operator()(cairo_t* cr) const { + if (cr && wrapper) + wrapper->cairo_destroy(cr); +} + +void GObjectDeleter::operator()(gpointer obj) const { + if (obj && wrapper) + wrapper->g_object_unref(obj); +} + +// Default constructor - creates real Cairo/Pango wrapper +Label::Label(const uint16_t heightPixel) : Label(heightPixel, std::make_shared()) {} + +// Constructor with dependency injection +Label::Label(const uint16_t heightPixel, std::shared_ptr cairoWrapper) + : mCairoWrapper(std::move(cairoWrapper)), mPrinterHeight(heightPixel) { + // Initialize resources in correct order with RAII + // Pass wrapper to deleter so cleanup uses the wrapper + GObjectDeleter deleter; + deleter.wrapper = mCairoWrapper; + mFontMap = std::unique_ptr(mCairoWrapper->pango_cairo_font_map_new(), deleter); +} + +std::vector Label::getRaw() const { assert(mSurface != nullptr); auto* surface = mSurface.get(); - cairo_surface_flush(surface); - assert(cairo_image_surface_get_format(surface) == CAIRO_FORMAT_A8); + mCairoWrapper->cairo_surface_flush(surface); + assert(mCairoWrapper->cairo_image_surface_get_format(surface) == CAIRO_FORMAT_A8); - int width = cairo_image_surface_get_width(surface); - int height = cairo_image_surface_get_height(surface); - int stride = cairo_image_surface_get_stride(surface); + int width = mCairoWrapper->cairo_image_surface_get_width(surface); + int height = mCairoWrapper->cairo_image_surface_get_height(surface); + int stride = mCairoWrapper->cairo_image_surface_get_stride(surface); spdlog::debug("Cairo Surface data: W: {}; H: {}; S:{}", width, height, stride); - auto data = cairo_image_surface_get_data(surface); + auto data = mCairoWrapper->cairo_image_surface_get_data(surface); // If stride equals width, we can return data directly if (stride == width) { @@ -80,41 +106,41 @@ uint8_t Label::getNumLines(std::string_view strv) { return std::count(strv.begin(), strv.end(), '\n'); } -int Label::getWidth() { +int Label::getWidth() const { // Return the actual Cairo surface width (which is the layout width) return mLayoutWidth; } -int Label::getHeight() { +int Label::getHeight() const { // Return the actual Cairo surface height (which is the printer height) return mPrinterHeight; } void Label::configureLayout(PangoLayout* layout, const std::string& text, PangoFontDescription* fontDesc) { - pango_layout_set_font_description(layout, fontDesc); - pango_layout_set_text(layout, text.c_str(), static_cast(text.length())); - pango_layout_set_height(layout, getNumLines(text) * -1); + mCairoWrapper->pango_layout_set_font_description(layout, fontDesc); + mCairoWrapper->pango_layout_set_text(layout, text.c_str(), static_cast(text.length())); + mCairoWrapper->pango_layout_set_height(layout, getNumLines(text) * -1); } void Label::applyHorizontalAlignment(PangoLayout* layout) { switch (mHAlign) { case HAlignPosition::LEFT: - pango_layout_set_alignment(layout, PANGO_ALIGN_LEFT); + mCairoWrapper->pango_layout_set_alignment(layout, PANGO_ALIGN_LEFT); break; case HAlignPosition::RIGHT: - pango_layout_set_alignment(layout, PANGO_ALIGN_RIGHT); + mCairoWrapper->pango_layout_set_alignment(layout, PANGO_ALIGN_RIGHT); break; case HAlignPosition::JUSTIFY: - pango_layout_set_alignment(layout, PANGO_ALIGN_LEFT); - pango_layout_set_justify(layout, true); + mCairoWrapper->pango_layout_set_alignment(layout, PANGO_ALIGN_LEFT); + mCairoWrapper->pango_layout_set_justify(layout, true); #if PANGO_VERSION_MAJOR >= 1 && PANGO_VERSION_MINOR >= 50 - pango_layout_set_justify_last_line(layout, true); + mCairoWrapper->pango_layout_set_justify_last_line(layout, true); #endif break; case HAlignPosition::CENTER: [[fallthrough]]; default: - pango_layout_set_alignment(layout, PANGO_ALIGN_CENTER); + mCairoWrapper->pango_layout_set_alignment(layout, PANGO_ALIGN_CENTER); break; } } @@ -133,40 +159,49 @@ bool Label::create(const std::string& labelText) { // see: https://gist.github.com/CallumDev/7c66b3f9cf7a876ef75f // Create a temporary surface for layout size calculations - auto* tempSurface = cairo_image_surface_create(CAIRO_FORMAT_A8, 1, 1); - auto* tempCr = cairo_create(tempSurface); - auto* tempPangoCtx = pango_cairo_create_context(tempCr); - auto* tempPangoLyt = pango_layout_new(tempPangoCtx); + auto* tempSurface = mCairoWrapper->cairo_image_surface_create(CAIRO_FORMAT_A8, 1, 1); + auto* tempCr = mCairoWrapper->cairo_create(tempSurface); + auto* tempPangoCtx = mCairoWrapper->pango_cairo_create_context(tempCr); + auto* tempPangoLyt = mCairoWrapper->pango_layout_new(tempPangoCtx); - PangoFontDescription* regularFont = pango_font_description_new(); - pango_font_description_set_size(regularFont, static_cast(mFontSize * PANGO_SCALE)); - pango_font_description_set_family(regularFont, mFontFamily.c_str()); + PangoFontDescription* regularFont = mCairoWrapper->pango_font_description_new(); + mCairoWrapper->pango_font_description_set_size(regularFont, static_cast(mFontSize * PANGO_SCALE)); + mCairoWrapper->pango_font_description_set_family(regularFont, mFontFamily.c_str()); // Configure temporary layout for size calculation configureLayout(tempPangoLyt, labelText, regularFont); applyHorizontalAlignment(tempPangoLyt); // Calculate label size from temporary layout - pango_layout_get_size(tempPangoLyt, &mLayoutWidth, &mLayoutHeight); + mCairoWrapper->pango_layout_get_size(tempPangoLyt, &mLayoutWidth, &mLayoutHeight); mLayoutWidth /= PANGO_SCALE; mLayoutHeight /= PANGO_SCALE; spdlog::debug("Layout width: {}, height: {}", mLayoutWidth, mLayoutHeight); - //auto alignedWidth = mLayoutWidth + (8 - (mLayoutWidth % 8)); - //spdlog::debug("Aligned Layout width: {}, height: {}", alignedWidth, mLayoutHeight); // Clean up temporary resources - g_object_unref(tempPangoLyt); - g_object_unref(tempPangoCtx); - cairo_destroy(tempCr); - cairo_surface_destroy(tempSurface); + mCairoWrapper->g_object_unref(tempPangoLyt); + mCairoWrapper->g_object_unref(tempPangoCtx); + mCairoWrapper->cairo_destroy(tempCr); + mCairoWrapper->cairo_surface_destroy(tempSurface); // Now create the final surface and Pango context for actual rendering - mSurface.reset(cairo_image_surface_create(CAIRO_FORMAT_A8, mLayoutWidth, mPrinterHeight)); - cairo_t* cr = cairo_create(mSurface.get()); - mCairoCtx.reset(cr); - mPangoCtx.reset(pango_cairo_create_context(cr)); - mPangoLyt.reset(pango_layout_new(mPangoCtx.get())); + // Create deleters with wrapper reference + CairoSurfaceDeleter surfaceDeleter; + surfaceDeleter.wrapper = mCairoWrapper; + CairoDeleter cairoDeleter; + cairoDeleter.wrapper = mCairoWrapper; + GObjectDeleter gobjectDeleter; + gobjectDeleter.wrapper = mCairoWrapper; + + mSurface = std::unique_ptr( + mCairoWrapper->cairo_image_surface_create(CAIRO_FORMAT_A8, mLayoutWidth, mPrinterHeight), surfaceDeleter); + cairo_t* cr = mCairoWrapper->cairo_create(mSurface.get()); + mCairoCtx = std::unique_ptr(cr, cairoDeleter); + mPangoCtx = + std::unique_ptr(mCairoWrapper->pango_cairo_create_context(cr), gobjectDeleter); + mPangoLyt = + std::unique_ptr(mCairoWrapper->pango_layout_new(mPangoCtx.get()), gobjectDeleter); // Configure final layout with same settings configureLayout(mPangoLyt.get(), labelText, regularFont); @@ -177,31 +212,104 @@ bool Label::create(const std::string& labelText) { case VAlignPosition::TOP: break; case VAlignPosition::BOTTOM: - cairo_move_to(mCairoCtx.get(), 0.0, mPrinterHeight - mLayoutHeight); + mCairoWrapper->cairo_move_to(mCairoCtx.get(), 0.0, mPrinterHeight - mLayoutHeight); break; case VAlignPosition::MIDDLE: - cairo_move_to(mCairoCtx.get(), 0.0, (mPrinterHeight - mLayoutHeight) / 2); + mCairoWrapper->cairo_move_to(mCairoCtx.get(), 0.0, (mPrinterHeight - mLayoutHeight) / 2); break; default: break; } // Finally show the layout on the Cairo surface - pango_cairo_show_layout(mCairoCtx.get(), mPangoLyt.get()); + mCairoWrapper->pango_cairo_show_layout(mCairoCtx.get(), mPangoLyt.get()); - cairo_set_source_rgb(mCairoCtx.get(), 0.0, 0.0, 0.0); - cairo_surface_flush(mSurface.get()); + mCairoWrapper->cairo_set_source_rgb(mCairoCtx.get(), 0.0, 0.0, 0.0); + mCairoWrapper->cairo_surface_flush(mSurface.get()); // mCairoCtx smart pointer will handle cleanup return true; } void Label::writeToPng(const std::string& file) { if (mSurface) { - cairo_surface_flush(mSurface.get()); - cairo_surface_write_to_png(mSurface.get(), file.c_str()); + mCairoWrapper->cairo_surface_flush(mSurface.get()); + mCairoWrapper->cairo_surface_write_to_png(mSurface.get(), file.c_str()); } } +bool Label::append(const ILabel& other, uint32_t spacingPx) { + // Check that heights match + if (getHeight() != other.getHeight()) { + spdlog::error("Cannot append labels with different heights: {} vs {}", getHeight(), other.getHeight()); + return false; + } + + int currentWidth = getWidth(); + int otherWidth = other.getWidth(); + int height = getHeight(); + int spacing = static_cast(spacingPx); + int newWidth = currentWidth + spacing + otherWidth; + + spdlog::debug("Appending label: current={}x{}, other={}x{}, spacing={}, new={}x{}", currentWidth, height, + otherWidth, height, spacing, newWidth, height); + + // Get current and other label data + auto currentData = getRaw(); + auto otherData = other.getRaw(); + + // Create new surface with extended width + CairoSurfaceDeleter surfaceDeleter; + surfaceDeleter.wrapper = mCairoWrapper; + auto newSurface = std::unique_ptr( + mCairoWrapper->cairo_image_surface_create(CAIRO_FORMAT_A8, newWidth, height), surfaceDeleter); + + if (mCairoWrapper->cairo_surface_status(newSurface.get()) != CAIRO_STATUS_SUCCESS) { + spdlog::error("Failed to create new surface for appended label"); + return false; + } + + // Get data pointer and stride + mCairoWrapper->cairo_surface_flush(newSurface.get()); + unsigned char* newData = mCairoWrapper->cairo_image_surface_get_data(newSurface.get()); + int newStride = mCairoWrapper->cairo_image_surface_get_stride(newSurface.get()); + + // Clear the new surface (set to transparent/white) + std::memset(newData, 0x00, newStride * height); + + // Copy current label data + for (int y = 0; y < height; ++y) { + for (int x = 0; x < currentWidth; ++x) { + size_t srcIdx = y * currentWidth + x; + size_t dstIdx = y * newStride + x; + if (srcIdx < currentData.size()) { + newData[dstIdx] = currentData[srcIdx]; + } + } + } + + // Copy other label data (with spacing offset) + int xOffset = currentWidth + spacing; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < otherWidth; ++x) { + size_t srcIdx = y * otherWidth + x; + size_t dstIdx = y * newStride + (xOffset + x); + if (srcIdx < otherData.size()) { + newData[dstIdx] = otherData[srcIdx]; + } + } + } + + mCairoWrapper->cairo_surface_mark_dirty(newSurface.get()); + + // Replace current surface with new one + mSurface = std::move(newSurface); + + // Update layout dimensions + mLayoutWidth = newWidth; + + return true; +} + void Label::setFontSize(const double fontSize) { mFontSize = fontSize; } diff --git a/src/graphics/Label.hpp b/src/graphics/Label.hpp index 524ab92..be79147 100644 --- a/src/graphics/Label.hpp +++ b/src/graphics/Label.hpp @@ -32,31 +32,34 @@ namespace ptprnt::graphics { -// Custom deleters for Cairo/Pango resources +// Forward declaration +class ICairoWrapper; + +// Custom deleters for Cairo/Pango resources that use the wrapper +// Implementation in Label.cpp to avoid incomplete type issues struct CairoSurfaceDeleter { - void operator()(cairo_surface_t* surface) const { - if (surface) - cairo_surface_destroy(surface); - } + std::shared_ptr wrapper; + void operator()(cairo_surface_t* surface) const; }; struct CairoDeleter { - void operator()(cairo_t* cr) const { - if (cr) - cairo_destroy(cr); - } + std::shared_ptr wrapper; + void operator()(cairo_t* cr) const; }; struct GObjectDeleter { - void operator()(gpointer obj) const { - if (obj) - g_object_unref(obj); - } + std::shared_ptr wrapper; + void operator()(gpointer obj) const; }; class Label : public ILabel { public: - Label(const uint16_t heightPixel); + // Default constructor using real Cairo/Pango implementation + explicit Label(uint16_t heightPixel); + + // Constructor for dependency injection (testing) + Label(uint16_t heightPixel, std::shared_ptr cairoWrapper); + ~Label() override; Label(const Label&) = delete; @@ -67,9 +70,9 @@ class Label : public ILabel { bool create(PrintableText printableText) override; bool create(const std::string& labelText) override; void writeToPng(const std::string& file); - [[nodiscard]] int getWidth() override; - [[nodiscard]] int getHeight() override; - [[nodiscard]] std::vector getRaw() override; + [[nodiscard]] int getWidth() const override; + [[nodiscard]] int getHeight() const override; + [[nodiscard]] std::vector getRaw() const override; void setFontSize(const double fontSize) override; void setFontFamily(const std::string& fontFamily) override; @@ -77,6 +80,8 @@ class Label : public ILabel { void setHAlign(HAlignPosition hpos) override; void setVAlign(VAlignPosition vpos) override; + bool append(const ILabel& other, uint32_t spacingPx = 60) override; + private: // methods [[nodiscard]] uint8_t getNumLines(std::string_view str); @@ -84,6 +89,9 @@ class Label : public ILabel { void configureLayout(PangoLayout* layout, const std::string& text, PangoFontDescription* fontDesc); void applyHorizontalAlignment(PangoLayout* layout); + // Cairo/Pango wrapper for dependency injection + std::shared_ptr mCairoWrapper; + std::unique_ptr mSurface{nullptr}; std::unique_ptr mCairoCtx{nullptr}; std::unique_ptr mPangoCtx{nullptr}; diff --git a/src/graphics/interface/ICairoWrapper.hpp b/src/graphics/interface/ICairoWrapper.hpp new file mode 100644 index 0000000..b181787 --- /dev/null +++ b/src/graphics/interface/ICairoWrapper.hpp @@ -0,0 +1,83 @@ +/* + ptrnt - print labels on linux + Copyright (C) 2025 Moritz Martinius + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + */ + +#pragma once + +#include +#include +#include + +namespace ptprnt::graphics { + +/** + * @brief Interface wrapper for Cairo and Pango C API functions + * + * This interface allows for dependency injection and mocking of Cairo/Pango + * functionality in unit tests, making the Label class fully testable. + */ +class ICairoWrapper { + public: + virtual ~ICairoWrapper() = default; + + // Cairo image surface functions + virtual cairo_surface_t* cairo_image_surface_create(cairo_format_t format, int width, int height) = 0; + virtual void cairo_surface_destroy(cairo_surface_t* surface) = 0; + virtual void cairo_surface_flush(cairo_surface_t* surface) = 0; + virtual void cairo_surface_mark_dirty(cairo_surface_t* surface) = 0; + virtual cairo_status_t cairo_surface_status(cairo_surface_t* surface) = 0; + virtual cairo_format_t cairo_image_surface_get_format(cairo_surface_t* surface) = 0; + virtual int cairo_image_surface_get_width(cairo_surface_t* surface) = 0; + virtual int cairo_image_surface_get_height(cairo_surface_t* surface) = 0; + virtual int cairo_image_surface_get_stride(cairo_surface_t* surface) = 0; + virtual unsigned char* cairo_image_surface_get_data(cairo_surface_t* surface) = 0; + virtual cairo_status_t cairo_surface_write_to_png(cairo_surface_t* surface, const char* filename) = 0; + + // Cairo context functions + virtual cairo_t* cairo_create(cairo_surface_t* surface) = 0; + virtual void cairo_destroy(cairo_t* cr) = 0; + virtual void cairo_move_to(cairo_t* cr, double x, double y) = 0; + virtual void cairo_set_source_rgb(cairo_t* cr, double red, double green, double blue) = 0; + + // Pango-Cairo functions + virtual PangoFontMap* pango_cairo_font_map_new() = 0; + virtual PangoContext* pango_cairo_create_context(cairo_t* cr) = 0; + virtual void pango_cairo_show_layout(cairo_t* cr, PangoLayout* layout) = 0; + + // Pango layout functions + virtual PangoLayout* pango_layout_new(PangoContext* context) = 0; + virtual void pango_layout_set_font_description(PangoLayout* layout, const PangoFontDescription* desc) = 0; + virtual void pango_layout_set_text(PangoLayout* layout, const char* text, int length) = 0; + virtual void pango_layout_set_height(PangoLayout* layout, int height) = 0; + virtual void pango_layout_set_alignment(PangoLayout* layout, PangoAlignment alignment) = 0; + virtual void pango_layout_set_justify(PangoLayout* layout, gboolean justify) = 0; +#if PANGO_VERSION_MAJOR >= 1 && PANGO_VERSION_MINOR >= 50 + virtual void pango_layout_set_justify_last_line(PangoLayout* layout, gboolean justify) = 0; +#endif + virtual void pango_layout_get_size(PangoLayout* layout, int* width, int* height) = 0; + + // Pango font description functions + virtual PangoFontDescription* pango_font_description_new() = 0; + virtual void pango_font_description_set_size(PangoFontDescription* desc, gint size) = 0; + virtual void pango_font_description_set_family(PangoFontDescription* desc, const char* family) = 0; + + // GObject reference counting + virtual void g_object_unref(gpointer object) = 0; +}; + +} // namespace ptprnt::graphics diff --git a/src/graphics/interface/ILabel.hpp b/src/graphics/interface/ILabel.hpp index b25cc5a..a089cb2 100644 --- a/src/graphics/interface/ILabel.hpp +++ b/src/graphics/interface/ILabel.hpp @@ -65,16 +65,24 @@ class ILabel { public: virtual ~ILabel() = default; - virtual bool create(PrintableText printableText) = 0; - virtual bool create(const std::string& labelText) = 0; - virtual std::vector getRaw() = 0; - virtual int getWidth() = 0; - virtual int getHeight() = 0; + virtual bool create(PrintableText printableText) = 0; + virtual bool create(const std::string& labelText) = 0; + virtual std::vector getRaw() const = 0; + virtual int getWidth() const = 0; + virtual int getHeight() const = 0; virtual void setText(const std::string& text) = 0; virtual void setFontSize(const double fontSize) = 0; virtual void setFontFamily(const std::string& fontFamily) = 0; virtual void setHAlign(HAlignPosition hpos) = 0; virtual void setVAlign(VAlignPosition vpos) = 0; + + /** + * @brief Append another label horizontally with spacing + * @param other The label to append + * @param spacingPx Spacing between labels in pixels (default: 60px ~5mm at 300dpi) + * @return true on success, false if heights don't match + */ + virtual bool append(const ILabel& other, uint32_t spacingPx = 60) = 0; }; } // namespace ptprnt::graphics \ No newline at end of file diff --git a/src/printers/FakePrinter.cpp b/src/printers/FakePrinter.cpp index 6a5e329..8efe009 100644 --- a/src/printers/FakePrinter.cpp +++ b/src/printers/FakePrinter.cpp @@ -39,27 +39,30 @@ const PrinterInfo FakePrinter::mInfo = {.driverName = "FakePrinter", .usbId{0x0000, 0x0000}, // No USB ID - virtual printer created explicitly .pixelLines = 128}; -const std::string_view FakePrinter::getDriverName() { +std::string_view FakePrinter::getDriverName() { return mInfo.driverName; } -const std::string_view FakePrinter::getName() { +std::string_view FakePrinter::getName() { return mInfo.name; } -const std::string_view FakePrinter::getVersion() { +std::string_view FakePrinter::getVersion() { return mInfo.version; } -const PrinterInfo FakePrinter::getPrinterInfo() { +PrinterInfo FakePrinter::getPrinterInfo() { return mInfo; } -const PrinterStatus FakePrinter::getPrinterStatus() { +PrinterStatus FakePrinter::getPrinterStatus() { + if (!mHasAttachedDevice) { + return {}; + } return mStatus; } -const libusbwrap::usbId FakePrinter::getUsbId() { +libusbwrap::usbId FakePrinter::getUsbId() { return mInfo.usbId; } @@ -86,6 +89,9 @@ bool FakePrinter::printBitmap(const graphics::Bitmap& bitmap) } bool FakePrinter::printMonochromeData(const graphics::MonochromeData& data) { + if (!mHasAttachedDevice) { + return false; + } spdlog::debug("FakePrinter: Simulating printing of {}x{} bitmap", data.width, data.height); // Simulate the printing process by reconstructing the bitmap @@ -125,6 +131,9 @@ bool FakePrinter::printLabel(const std::unique_ptr label) { } bool FakePrinter::print() { + if (!mHasAttachedDevice) { + return false; + } spdlog::debug("FakePrinter: Print command (no-op for virtual printer)"); return true; } diff --git a/src/printers/FakePrinter.hpp b/src/printers/FakePrinter.hpp index d8d61e4..5193bfe 100644 --- a/src/printers/FakePrinter.hpp +++ b/src/printers/FakePrinter.hpp @@ -52,12 +52,12 @@ class FakePrinter : public ::ptprnt::IPrinterDriver { static const PrinterInfo mInfo; // IPrinterDriver interface - [[nodiscard]] const std::string_view getDriverName() override; - [[nodiscard]] const std::string_view getName() override; - [[nodiscard]] const libusbwrap::usbId getUsbId() override; - [[nodiscard]] const std::string_view getVersion() override; - [[nodiscard]] const PrinterInfo getPrinterInfo() override; - [[nodiscard]] const PrinterStatus getPrinterStatus() override; + [[nodiscard]] std::string_view getDriverName() override; + [[nodiscard]] std::string_view getName() override; + [[nodiscard]] libusbwrap::usbId getUsbId() override; + [[nodiscard]] std::string_view getVersion() override; + [[nodiscard]] PrinterInfo getPrinterInfo() override; + [[nodiscard]] PrinterStatus getPrinterStatus() override; bool attachUsbDevice(std::shared_ptr usbHndl) override; bool detachUsbDevice() override; bool printBitmap(const graphics::Bitmap& bitmap) override; diff --git a/src/printers/P700Printer.cpp b/src/printers/P700Printer.cpp index a8ec3ed..1ebcdeb 100644 --- a/src/printers/P700Printer.cpp +++ b/src/printers/P700Printer.cpp @@ -48,24 +48,30 @@ P700Printer::~P700Printer() { } } -const std::string_view P700Printer::getDriverName() { +std::string_view P700Printer::getDriverName() { return mInfo.driverName; } -const std::string_view P700Printer::getName() { +std::string_view P700Printer::getName() { return mInfo.name; } -const std::string_view P700Printer::getVersion() { +std::string_view P700Printer::getVersion() { return mInfo.version; } -const PrinterInfo P700Printer::getPrinterInfo() { +PrinterInfo P700Printer::getPrinterInfo() { return mInfo; } -const PrinterStatus P700Printer::getPrinterStatus() { +PrinterStatus P700Printer::getPrinterStatus() { using namespace std::chrono_literals; + + if (!mUsbHndl) { + spdlog::error("USB Handle is invalid!"); + return {}; + } + send(p700::commands::GET_STATUS); int tx = 0; @@ -79,7 +85,7 @@ const PrinterStatus P700Printer::getPrinterStatus() { return PrinterStatus{.tapeWidthMm = recvBuf[10]}; } -const libusbwrap::usbId P700Printer::getUsbId() { +libusbwrap::usbId P700Printer::getUsbId() { return mInfo.usbId; } diff --git a/src/printers/P700Printer.hpp b/src/printers/P700Printer.hpp index af3cecd..b2ec39d 100644 --- a/src/printers/P700Printer.hpp +++ b/src/printers/P700Printer.hpp @@ -66,12 +66,12 @@ class P700Printer : public ::ptprnt::IPrinterDriver { static const PrinterInfo mInfo; // IPrinterDriver - [[nodiscard]] const std::string_view getDriverName() override; - [[nodiscard]] const std::string_view getName() override; - [[nodiscard]] const libusbwrap::usbId getUsbId() override; - [[nodiscard]] const std::string_view getVersion() override; - [[nodiscard]] const PrinterInfo getPrinterInfo() override; - [[nodiscard]] const PrinterStatus getPrinterStatus() override; + [[nodiscard]] std::string_view getDriverName() override; + [[nodiscard]] std::string_view getName() override; + [[nodiscard]] libusbwrap::usbId getUsbId() override; + [[nodiscard]] std::string_view getVersion() override; + [[nodiscard]] PrinterInfo getPrinterInfo() override; + [[nodiscard]] PrinterStatus getPrinterStatus() override; bool attachUsbDevice(std::shared_ptr usbHndl) override; bool detachUsbDevice() override; bool printBitmap(const graphics::Bitmap& bitmap) override; diff --git a/src/printers/interface/IPrinterDriver.hpp b/src/printers/interface/IPrinterDriver.hpp index daae31b..2ba4e1d 100644 --- a/src/printers/interface/IPrinterDriver.hpp +++ b/src/printers/interface/IPrinterDriver.hpp @@ -32,12 +32,12 @@ namespace ptprnt { class IPrinterDriver { public: virtual ~IPrinterDriver() = default; - [[nodiscard]] virtual const std::string_view getDriverName() = 0; - [[nodiscard]] virtual const std::string_view getName() = 0; - [[nodiscard]] virtual const std::string_view getVersion() = 0; - [[nodiscard]] virtual const libusbwrap::usbId getUsbId() = 0; - [[nodiscard]] virtual const PrinterInfo getPrinterInfo() = 0; - [[nodiscard]] virtual const PrinterStatus getPrinterStatus() = 0; + [[nodiscard]] virtual std::string_view getDriverName() = 0; + [[nodiscard]] virtual std::string_view getName() = 0; + [[nodiscard]] virtual std::string_view getVersion() = 0; + [[nodiscard]] virtual libusbwrap::usbId getUsbId() = 0; + [[nodiscard]] virtual PrinterInfo getPrinterInfo() = 0; + [[nodiscard]] virtual PrinterStatus getPrinterStatus() = 0; virtual bool attachUsbDevice(std::shared_ptr usbHndl) = 0; virtual bool detachUsbDevice() = 0; virtual bool printBitmap(const graphics::Bitmap& bitmap) = 0; diff --git a/test_copyright.cpp b/test_copyright.cpp new file mode 100644 index 0000000..ed1eb04 --- /dev/null +++ b/test_copyright.cpp @@ -0,0 +1 @@ +# Test file diff --git a/tests/bitmap_test/bitmap_test.cpp b/tests/bitmap_test/bitmap_test.cpp index 4e71d4f..154a392 100644 --- a/tests/bitmap_test/bitmap_test.cpp +++ b/tests/bitmap_test/bitmap_test.cpp @@ -1,6 +1,6 @@ /* ptrnt - print labels on linux - Copyright (C) 2023 Moritz Martinius + Copyright (C) 2023-2025 Moritz Martinius This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/tests/label_test/label_test.cpp b/tests/label_test/label_test.cpp index 08e3ce7..e0a3ea1 100644 --- a/tests/label_test/label_test.cpp +++ b/tests/label_test/label_test.cpp @@ -1,6 +1,6 @@ -/* +/* ptrnt - print labels on linux - Copyright (C) 2023 Moritz Martinius + Copyright (C) 2025 Moritz Martinius This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -19,8 +19,244 @@ #include "graphics/Label.hpp" +#include #include +#include +#include + +#include "../../tests/mocks/MockCairoWrapper.hpp" +#include "graphics/interface/ILabel.hpp" + +using ::testing::_; +using ::testing::DoAll; +using ::testing::NiceMock; +using ::testing::Return; +using ::testing::SetArgPointee; + +namespace ptprnt::graphics { + +// Test fixture for Label tests with comprehensive mock setup +class LabelTest : public ::testing::Test { + protected: + void SetUp() override { + mockWrapper = std::make_shared>(); + + // Mock pointers for temporary surface (used in size calculation) + mockTempSurface = reinterpret_cast(0x2000); + mockTempCr = reinterpret_cast(0x2001); + mockTempCtx = reinterpret_cast(0x2002); + mockTempLayout = reinterpret_cast(0x2003); + + // Mock pointers for final surface (used in rendering) + mockFinalSurface = reinterpret_cast(0x3000); + mockFinalCr = reinterpret_cast(0x3001); + mockFinalCtx = reinterpret_cast(0x3002); + mockFinalLayout = reinterpret_cast(0x3003); + + // Mock font description + mockFontDesc = reinterpret_cast(0x2004); + + // Default layout size: 100x30 pixels (in PANGO_SCALE units) + defaultLayoutWidth = 100; + defaultLayoutHeight = 30; + + SetupDefaultBehaviors(); + } + + void SetupDefaultBehaviors() { + // Font map + ON_CALL(*mockWrapper, pango_cairo_font_map_new()) + .WillByDefault(Return(reinterpret_cast(0x1000))); + + // Temporary surface creation (for size calculation) + ON_CALL(*mockWrapper, cairo_image_surface_create(CAIRO_FORMAT_A8, 1, 1)).WillByDefault(Return(mockTempSurface)); + ON_CALL(*mockWrapper, cairo_create(mockTempSurface)).WillByDefault(Return(mockTempCr)); + ON_CALL(*mockWrapper, pango_cairo_create_context(mockTempCr)).WillByDefault(Return(mockTempCtx)); + ON_CALL(*mockWrapper, pango_layout_new(mockTempCtx)).WillByDefault(Return(mockTempLayout)); + + // Final surface creation (for rendering) - use _ for width since it varies + ON_CALL(*mockWrapper, cairo_image_surface_create(CAIRO_FORMAT_A8, _, _)) + .WillByDefault(Return(mockFinalSurface)); + ON_CALL(*mockWrapper, cairo_create(mockFinalSurface)).WillByDefault(Return(mockFinalCr)); + ON_CALL(*mockWrapper, pango_cairo_create_context(mockFinalCr)).WillByDefault(Return(mockFinalCtx)); + ON_CALL(*mockWrapper, pango_layout_new(mockFinalCtx)).WillByDefault(Return(mockFinalLayout)); + + // Font description + ON_CALL(*mockWrapper, pango_font_description_new()).WillByDefault(Return(mockFontDesc)); + + // Layout size - return default dimensions + ON_CALL(*mockWrapper, pango_layout_get_size(_, _, _)) + .WillByDefault(DoAll(SetArgPointee<1>(defaultLayoutWidth * PANGO_SCALE), + SetArgPointee<2>(defaultLayoutHeight * PANGO_SCALE))); + + // Surface status - always success + ON_CALL(*mockWrapper, cairo_surface_status(_)).WillByDefault(Return(CAIRO_STATUS_SUCCESS)); + + // Surface properties for getRaw() + ON_CALL(*mockWrapper, cairo_image_surface_get_format(_)).WillByDefault(Return(CAIRO_FORMAT_A8)); + ON_CALL(*mockWrapper, cairo_image_surface_get_width(_)).WillByDefault(Return(defaultLayoutWidth)); + ON_CALL(*mockWrapper, cairo_image_surface_get_height(_)).WillByDefault(Return(128)); + ON_CALL(*mockWrapper, cairo_image_surface_get_stride(_)).WillByDefault(Return(defaultLayoutWidth)); + + // Mock data pointer + mockSurfaceData.resize(defaultLayoutWidth * 128, 0xFF); + ON_CALL(*mockWrapper, cairo_image_surface_get_data(_)).WillByDefault(Return(mockSurfaceData.data())); + } + + // Helper method to set custom layout dimensions + void SetLayoutSize(int width, int height) { + defaultLayoutWidth = width; + defaultLayoutHeight = height; + + // Update the mock to return new dimensions + ON_CALL(*mockWrapper, pango_layout_get_size(_, _, _)) + .WillByDefault(DoAll(SetArgPointee<1>(width * PANGO_SCALE), SetArgPointee<2>(height * PANGO_SCALE))); + + ON_CALL(*mockWrapper, cairo_image_surface_get_width(_)).WillByDefault(Return(width)); + ON_CALL(*mockWrapper, cairo_image_surface_get_stride(_)).WillByDefault(Return(width)); + + // Resize mock data + mockSurfaceData.resize(width * 128, 0xFF); + ON_CALL(*mockWrapper, cairo_image_surface_get_data(_)).WillByDefault(Return(mockSurfaceData.data())); + } + + std::shared_ptr> mockWrapper; + + // Mock pointers + cairo_surface_t* mockTempSurface; + cairo_t* mockTempCr; + PangoContext* mockTempCtx; + PangoLayout* mockTempLayout; + + cairo_surface_t* mockFinalSurface; + cairo_t* mockFinalCr; + PangoContext* mockFinalCtx; + PangoLayout* mockFinalLayout; + + PangoFontDescription* mockFontDesc; + + // Default layout dimensions + int defaultLayoutWidth; + int defaultLayoutHeight; + + // Mock surface data + std::vector mockSurfaceData; +}; + +// Smoke test with real Cairo/Pango TEST(basic_test, Label_smokeTest_succeeds) { - auto im = ptprnt::graphics::Label(4711); + auto label = Label(128); + EXPECT_EQ(label.getHeight(), 128); + EXPECT_EQ(label.getWidth(), 0); // No label created yet } + +// Constructor test with mock +TEST_F(LabelTest, Constructor_InitializesFontMap) { + EXPECT_CALL(*mockWrapper, pango_cairo_font_map_new()).Times(1); + + auto label = Label(128, mockWrapper); + + EXPECT_EQ(label.getHeight(), 128); + EXPECT_EQ(label.getWidth(), 0); +} + +// Test getters before label creation +TEST_F(LabelTest, Getters_BeforeCreate_ReturnDefaults) { + auto label = Label(256, mockWrapper); + + EXPECT_EQ(label.getHeight(), 256); + EXPECT_EQ(label.getWidth(), 0); +} + +// Test setters +TEST_F(LabelTest, Setters_ModifyProperties) { + auto label = Label(128, mockWrapper); + + label.setFontSize(24.0); + label.setFontFamily("Arial"); + label.setText("Test"); + label.setHAlign(HAlignPosition::CENTER); + label.setVAlign(VAlignPosition::BOTTOM); + + // Properties are set (no way to verify without create, but no crash is good) + SUCCEED(); +} + +// Test create() - basic functionality with simplified setup +TEST_F(LabelTest, Create_WithText_Succeeds) { + auto label = Label(128, mockWrapper); + label.setFontSize(12.0); + label.setFontFamily("Sans"); + + bool result = label.create("Hello"); + + EXPECT_TRUE(result); + EXPECT_EQ(label.getWidth(), defaultLayoutWidth); + EXPECT_EQ(label.getHeight(), 128); +} + +// Test horizontal alignment - RIGHT +TEST_F(LabelTest, Create_WithRightAlignment_SetsCorrectPangoAlignment) { + auto label = Label(128, mockWrapper); + label.setHAlign(HAlignPosition::RIGHT); + + // Verify RIGHT alignment is set (temp + final layout) + EXPECT_CALL(*mockWrapper, pango_layout_set_alignment(_, PANGO_ALIGN_RIGHT)).Times(2); + + label.create("Right"); +} + +// Test horizontal alignment - JUSTIFY +TEST_F(LabelTest, Create_WithJustifyAlignment_SetsJustifyAndAlignment) { + auto label = Label(128, mockWrapper); + label.setHAlign(HAlignPosition::JUSTIFY); + + // Verify JUSTIFY requires LEFT alignment + justify flag + EXPECT_CALL(*mockWrapper, pango_layout_set_alignment(_, PANGO_ALIGN_LEFT)).Times(2); + EXPECT_CALL(*mockWrapper, pango_layout_set_justify(_, true)).Times(2); +#if PANGO_VERSION_MAJOR >= 1 && PANGO_VERSION_MINOR >= 50 + EXPECT_CALL(*mockWrapper, pango_layout_set_justify_last_line(_, true)).Times(2); +#endif + + label.create("Justify"); +} + +// Test vertical alignment - TOP (no cairo_move_to) +TEST_F(LabelTest, Create_WithTopAlignment_NoMoveToCall) { + auto label = Label(128, mockWrapper); + label.setVAlign(VAlignPosition::TOP); + + // TOP alignment should NOT call cairo_move_to + EXPECT_CALL(*mockWrapper, cairo_move_to(_, _, _)).Times(0); + + label.create("Top"); +} + +// Test vertical alignment - BOTTOM +TEST_F(LabelTest, Create_WithBottomAlignment_CallsMoveToWithCorrectOffset) { + auto label = Label(128, mockWrapper); + label.setVAlign(VAlignPosition::BOTTOM); + + SetLayoutSize(50, 20); // Use helper to set custom size + + // BOTTOM alignment: offset = printerHeight - layoutHeight = 128 - 20 = 108 + EXPECT_CALL(*mockWrapper, cairo_move_to(mockFinalCr, 0.0, 108.0)).Times(1); + + label.create("Bottom"); +} + +// Test vertical alignment - MIDDLE +TEST_F(LabelTest, Create_WithMiddleAlignment_CallsMoveToWithCenteredOffset) { + auto label = Label(128, mockWrapper); + label.setVAlign(VAlignPosition::MIDDLE); + + SetLayoutSize(50, 20); // Use helper to set custom size + + // MIDDLE alignment: offset = (printerHeight - layoutHeight) / 2 = (128 - 20) / 2 = 54 + EXPECT_CALL(*mockWrapper, cairo_move_to(mockFinalCr, 0.0, 54.0)).Times(1); + + label.create("Middle"); +} + +} // namespace ptprnt::graphics diff --git a/tests/meson.build b/tests/meson.build index f2c6146..71e0ce7 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -4,11 +4,6 @@ tests = [ 'bitmap_test_exe', ['../src/graphics/Bitmap.cpp', 'bitmap_test/bitmap_test.cpp'], ], - [ - 'label_test', - 'label_test_exe', - ['../src/graphics/Label.cpp', 'label_test/label_test.cpp'], - ], [ 'monochrome_test', 'monochrome_test_exe', @@ -35,4 +30,22 @@ foreach test : tests ], ), ) -endforeach \ No newline at end of file +endforeach + +# Label test requires GMock for mocking Cairo/Pango +test( + 'label_test', + executable( + 'label_test_exe', + sources: ['../src/graphics/Label.cpp', 'label_test/label_test.cpp'], + include_directories: incdir, + dependencies: [ + gmock_dep, + gtest_dep, + usb_dep, + log_dep, + pangocairo_dep, + cli11_dep, + ], + ), +) \ No newline at end of file diff --git a/tests/mocks/MockCairoWrapper.hpp b/tests/mocks/MockCairoWrapper.hpp new file mode 100644 index 0000000..b65eaaf --- /dev/null +++ b/tests/mocks/MockCairoWrapper.hpp @@ -0,0 +1,84 @@ +/* + ptrnt - print labels on linux + Copyright (C) 2025 Moritz Martinius + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + */ + +#pragma once + +#include +#include "graphics/interface/ICairoWrapper.hpp" + +namespace ptprnt::graphics { + +/** + * @brief GMock implementation of ICairoWrapper for unit testing + * + * This mock allows tests to verify that the Label class correctly interacts + * with the Cairo/Pango API without requiring actual graphics rendering. + */ +class MockCairoWrapper : public ICairoWrapper { + public: + // Cairo image surface functions + MOCK_METHOD(cairo_surface_t*, cairo_image_surface_create, (cairo_format_t format, int width, int height), + (override)); + MOCK_METHOD(void, cairo_surface_destroy, (cairo_surface_t * surface), (override)); + MOCK_METHOD(void, cairo_surface_flush, (cairo_surface_t * surface), (override)); + MOCK_METHOD(void, cairo_surface_mark_dirty, (cairo_surface_t * surface), (override)); + MOCK_METHOD(cairo_status_t, cairo_surface_status, (cairo_surface_t * surface), (override)); + MOCK_METHOD(cairo_format_t, cairo_image_surface_get_format, (cairo_surface_t * surface), (override)); + MOCK_METHOD(int, cairo_image_surface_get_width, (cairo_surface_t * surface), (override)); + MOCK_METHOD(int, cairo_image_surface_get_height, (cairo_surface_t * surface), (override)); + MOCK_METHOD(int, cairo_image_surface_get_stride, (cairo_surface_t * surface), (override)); + MOCK_METHOD(unsigned char*, cairo_image_surface_get_data, (cairo_surface_t * surface), (override)); + MOCK_METHOD(cairo_status_t, cairo_surface_write_to_png, (cairo_surface_t * surface, const char* filename), + (override)); + + // Cairo context functions + MOCK_METHOD(cairo_t*, cairo_create, (cairo_surface_t * surface), (override)); + MOCK_METHOD(void, cairo_destroy, (cairo_t * cr), (override)); + MOCK_METHOD(void, cairo_move_to, (cairo_t * cr, double x, double y), (override)); + MOCK_METHOD(void, cairo_set_source_rgb, (cairo_t * cr, double red, double green, double blue), (override)); + + // Pango-Cairo functions + MOCK_METHOD(PangoFontMap*, pango_cairo_font_map_new, (), (override)); + MOCK_METHOD(PangoContext*, pango_cairo_create_context, (cairo_t * cr), (override)); + MOCK_METHOD(void, pango_cairo_show_layout, (cairo_t * cr, PangoLayout* layout), (override)); + + // Pango layout functions + MOCK_METHOD(PangoLayout*, pango_layout_new, (PangoContext * context), (override)); + MOCK_METHOD(void, pango_layout_set_font_description, (PangoLayout * layout, const PangoFontDescription* desc), + (override)); + MOCK_METHOD(void, pango_layout_set_text, (PangoLayout * layout, const char* text, int length), (override)); + MOCK_METHOD(void, pango_layout_set_height, (PangoLayout * layout, int height), (override)); + MOCK_METHOD(void, pango_layout_set_alignment, (PangoLayout * layout, PangoAlignment alignment), (override)); + MOCK_METHOD(void, pango_layout_set_justify, (PangoLayout * layout, gboolean justify), (override)); +#if PANGO_VERSION_MAJOR >= 1 && PANGO_VERSION_MINOR >= 50 + MOCK_METHOD(void, pango_layout_set_justify_last_line, (PangoLayout * layout, gboolean justify), (override)); +#endif + MOCK_METHOD(void, pango_layout_get_size, (PangoLayout * layout, int* width, int* height), (override)); + + // Pango font description functions + MOCK_METHOD(PangoFontDescription*, pango_font_description_new, (), (override)); + MOCK_METHOD(void, pango_font_description_set_size, (PangoFontDescription * desc, gint size), (override)); + MOCK_METHOD(void, pango_font_description_set_family, (PangoFontDescription * desc, const char* family), + (override)); + + // GObject reference counting + MOCK_METHOD(void, g_object_unref, (gpointer object), (override)); +}; + +} // namespace ptprnt::graphics diff --git a/tests/monochrome_test/monochrome_test.cpp b/tests/monochrome_test/monochrome_test.cpp index 2b5decf..8d5ce0b 100644 --- a/tests/monochrome_test/monochrome_test.cpp +++ b/tests/monochrome_test/monochrome_test.cpp @@ -1,6 +1,6 @@ /* ptrnt - print labels on linux - Copyright (C) 2023 Moritz Martinius + Copyright (C) 2023-2025 Moritz Martinius This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by