diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yaml similarity index 100% rename from .github/workflows/pre-commit.yml rename to .github/workflows/pre-commit.yaml diff --git a/.gitignore b/.gitignore index 7a440ee..0a64310 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,6 @@ wheels/ # venv /.venv -# PyCharm -.idea/ - # Backup files **.~ *.swp diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cfb9046 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Examples", + "type": "debugpy", + "request": "launch", + "module": "pdfbaker", + "args": ["bake", "examples/examples.yaml"], + "console": "integratedTerminal" + }, + { + "name": "Debug Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["-v", "tests"], + "justMyCode": false, + "env": {"PYTEST_ADDOPTS": "--no-cov"}, + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4094739 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,52 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Bake", + "type": "shell", + "command": "python -m pdfbaker bake ${input:configPath}", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "Run Tests", + "type": "shell", + "command": "pytest -v tests", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "Run Coverage", + "type": "shell", + "command": "pytest --cov=pdfbaker --cov-report=html", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "View Coverage", + "type": "shell", + "command": "python -c \"import webbrowser; webbrowser.open('htmlcov/index.html')\"", + "presentation": { + "reveal": "never", + "panel": "new" + } + } + ], + "inputs": [ + { + "id": "configPath", + "type": "promptString", + "description": "Path to main YAML config file", + "default": "examples/examples.yaml" + } + ] +} diff --git a/README.md b/README.md index 518e7ff..8d6b357 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ pipx ensurepath sudo apt install ghostscript ``` -- If you want to embed particular fonts, they need to be installed. For example for - [Roboto fonts](https://fonts.google.com/specimen/Roboto): +- If you your templates embed particular fonts, they need to be installed. For example + for [Roboto fonts](https://fonts.google.com/specimen/Roboto): ```bash sudo apt install fonts-roboto ``` @@ -55,14 +55,22 @@ pdfbaker bake This will produce your PDF files in a `dist/` directory where your configuration file lives. It will also create a `build/` directory with intermediate files, which is only -kept if you specify `--debug`. +kept if you specify `--keep-build-files` (or `--debug`). ## Examples -For working examples, see the [examples](examples) directory.
Create all PDFs with: +For working examples, see the [examples](examples) directory: + +- [minimal](examples/minimal) - Basic usage +- [regular](examples/regular) - Standard features +- [variants](examples/variants) - Document variants +- [custom_locations](examples/custom_locations) - Custom file/directory locations +- [custom_processing](examples/custom_processing) - Custom processing with Python + +Create all PDFs with: ```bash -pdfbaker bake examples/examples.yml +pdfbaker bake examples/examples.yaml ``` ## Documentation @@ -76,6 +84,8 @@ pdfbaker bake examples/examples.yml ## Development +All source code is [on GitHub](https://github.com/pythonnz/pdfbaker). + This project uses [uv](https://github.com/astral-sh/uv) for dependency management. The `uv.lock` file ensures reproducible builds. diff --git a/docs/configuration.md b/docs/configuration.md index 09509d9..72477f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,24 +4,46 @@ ``` project/ -├── kiwipycon.yml # Main configuration +├── kiwipycon.yaml # Main configuration ├── material_specs/ # A document -│ ├── config.yml # Document configuration +│ ├── config.yaml # Document configuration │ ├── images/ # Images │ ├── pages/ # Page configurations │ └── templates/ # SVG templates └── prospectus/ # Another document - ├── config.yml + ├── config.yaml ├── images/ ├── pages/ └── templates/ ``` +## Configuration Workflow + +For every page, your main configuration (for all documents), document configuration (for +all pages of this document) and the page configuration are merged to form the context +provided to your page template. + +```mermaid +flowchart TD + subgraph Configuration + Main[YAML Main Config] -->|inherits| Doc[YAML Document Config] + Doc -->|inherits| Page[YAML Page Config] + end + + subgraph Page Processing + Template[SVG Template] + Page -->|context| Render[Template Rendering] + Template -->|jinja2| Render + Render -->|output| SVG[SVG File] + SVG -->|cairosvg| PDF[PDF File] + end +``` + ## Main Configuration File | Option | Type | Default | Description | | ----------------- | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | -| `documents` | array | Yes | List of document directories. Each directory must contain a `config.yml` file | +| `documents` | array | Yes | List of document directories. Each directory must contain a `config.yaml` file | | `style` | object | No | Global style definitions that may reference `theme` values | | `theme` | object | No | Reusable values (colors, fonts, spacing, etc.) used by `style` | | `compress_pdf` | boolean | `false` | Whether to compress the final PDF. Requires Ghostscript. | @@ -33,7 +55,7 @@ highlighted in the color specified by the `highlight_color` in your `style`. Example: ```yaml -# kiwipycon.yml +# kiwipycon.yaml documents: - prospectus @@ -74,13 +96,13 @@ for each document. | Option | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `filename` | string | Yes | Filename (without extension) of the final PDF document. Can use variables, particularly `variant` (see [Variants](variants.md)) | -| `pages` | array | Yes | List of page names. Each page must have a corresponding `.yml` file in the `pages/` directory | +| `pages` | array | Yes | List of page names. Each page must have a corresponding `.yaml` file in the `pages/` directory | | `variants` | array | No | List of document variants (see [Variants](variants.md)) | Example: ```yaml -# prospectus/config.yml +# prospectus/config.yaml filename: "Kiwi PyCon {{ conference.year }} - Prospectus" # Use config values in config values! title: "Sponsorship Prospectus" @@ -105,7 +127,7 @@ merged with this for access to all settings in your template. Example: ```yaml -# pages/conference_schedule.yml +# pages/conference_schedule.yaml template: list_section.svg.j2 diff --git a/docs/custom_processing.md b/docs/custom_processing.md index 6665ce8..265b66d 100644 --- a/docs/custom_processing.md +++ b/docs/custom_processing.md @@ -10,86 +10,22 @@ customize the document generation process. This allows you to: ## Basic Structure -Your `bake.py` should define a `process_document` function: +As a naming convention, your `bake.py` needs to define a `process_document` function: ```python from pdfbaker.document import PDFBakerDocument def process_document(document: PDFBakerDocument) -> None: # Custom processing logic here - pass -``` - -## Document Object - -The `document` parameter provides access to: - -- Document configuration -- Variant processing -- Page rendering -- File management - -### Key Methods and Properties - -```python -# Access configuration -config = document.config - -# Process variants -for variant in document.config.get('variants', []): - # Process variant... - -# Process pages -for page in document.config.get('pages', []): - # Process page... - -# File management -build_dir = document.build_dir -dist_dir = document.dist_dir -``` - -## Example: Dynamic Pricing - -Here's an example that calculates dynamic pricing based on features: - -```python -def process_document(document): - # Load pricing data - with open('content/pricing_data.yaml') as f: - pricing_data = yaml.safe_load(f) - - # Calculate pricing for each variant - for variant in document.config.get('variants', []): - base_price = document.config['content']['base_price'] - features = len(variant['content']['features']) - - # Adjust price based on features - adjusted_price = base_price * (1 + (features - 1) * 0.1) - final_price = adjusted_price * (1 - variant['content']['discount']) - - # Update variant content - variant['content']['final_price'] = round(final_price, 2) - - # Process as usual document.process() ``` -## Example: Content Generation - -Generate content dynamically based on external data: - -```python -def process_document(document): - # Fetch data from API - response = requests.get('https://api.example.com/data') - data = response.json() +You will usually just manipulate the data for your templates, and then call `.process()` +on the document to continue with the built-in stages of combining pages and compressing +the PDF as configured. - # Update document content - document.config['content'].update({ - 'latest_data': data, - 'generated_at': datetime.now().isoformat() - }) +See `examples/custom_processing/bake.py` for a simple example of how to do this. - # Process as usual - document.process() -``` +If you need to fully customise the processing, make sure that your function returns a +Path or list of Path objects (the PDF files that were created) as that is the expected +type of return value for logging. diff --git a/docs/overview.md b/docs/overview.md index 7cf0d27..10e8eba 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,37 +13,112 @@ documents with minimal effort. - **Custom Processing**: Extend the processing workflow with Python - **PDF Compression**: Optional compression of final PDFs +## Quickstart + +For a quick introduction, see the [README](../README.md). + +## Workflow + +### From configuration to PDF documents + +Your main configuration defines which documents to create.
Each document +configuration defines which pages make up the document. + +```mermaid +flowchart TD + Main[YAML Main Config] -->|document 1| Doc1[Document Processing] + Main -->|document 2| Doc2[Document Processing] + + subgraph Document[YAML Document Config] + Doc1 -->|page 1| Page1[Page Processing] + Doc1 -->|page 2| Page2[Page Processing] + Doc1 -->|page ...| PageN[Page Processing] + + Page1 --> PDF1[PDF File Page 1] + Page2 --> PDF2[PDF File Page 2] + PageN --> PDFN[PDF File Page ...] + + PDF1 --> PDF[PDF Pages] + PDF2 --> PDF[PDF Pages] + PDFN --> PDF[PDF Pages] + + PDF -->|combine| PDFDocument[PDF Document] + end + + Doc2 -->|pages| Doc2PageProcessing[...] + Doc2PageProcessing --> Doc2PageFile[...] + Doc2PageFile --> Doc2Pages[...] + Doc2Pages -->|combine| Doc2PDFDocument[PDF Document] +``` + +### Inheriting common values + +Settings in the main configuration are available to all documents.
Settings in a +document configuration are available to all of its pages.
Each page configuration can +hold page-specific settings/content, so that the template of the page is only +responsible for layout/design. + +```mermaid +flowchart TD + subgraph Configuration + Main[YAML Main Config] -->|inherits| Doc[YAML Document Config] + Doc -->|inherits| Page[YAML Page Config] + end + + subgraph Page Processing + Template[SVG Template] + Page -->|context| Render[Template Rendering] + Template -->|jinja2| Render + Render -->|output| SVG[SVG File] + SVG -->|cairosvg| PDF[PDF File] + end +``` + +### Pages make up a document + +After each page template was rendered and the resulting SVG file converted to PDF, these +page PDFs are combined to create the document.
This PDF document may optionally get +compressed for a nice end result. + +```mermaid +flowchart LR + subgraph Document Creation + Page1[PDF File Page 1] -->|combine| Document[PDF Document] + Page2[PDF File Page 2] -->|combine| Document + PageN[PDF File Page ...] -->|combine| Document + Document -.->|ghostscript| Compressed[PDF Document compressed] + linkStyle 3 stroke-dasharray: 5 5 + end +``` + ## Documentation - [Configuration](configuration.md) - How to set up your documents - [Document Variants](variants.md) - Create multiple versions of a document - [Custom Processing](custom_processing.md) - Extend the processing workflow -## Quickstart - -For a quick introduction, see the [README](../README.md). - ## Examples -See the [examples](../examples) directory for simple working examples: +See the [examples](examples) directory: -- [minimal](../examples/minimal) - Basic single page -- [regular](../examples/regular) - Using various features -- [variants](../examples/variants) - Different PDFs of the same document -- [custom_processing](../examples/custom_processing) - Custom Python code +- [minimal](examples/minimal) - Basic usage +- [regular](examples/regular) - Standard features +- [variants](examples/variants) - Document variants +- [custom_locations](examples/custom_locations) - Custom file/directory locations +- [custom_processing](examples/custom_processing) - Custom processing with Python ## Example Project Structure ``` project/ -├── kiwipycon.yml # Main configuration +├── kiwipycon.yaml # Main configuration ├── material_specs/ # A document -│ ├── config.yml # Document configuration +│ ├── config.yaml # Document configuration │ ├── images/ # Images │ ├── pages/ # Page configurations │ └── templates/ # SVG templates └── prospectus/ # Another document - ├── config.yml + ├── config.yaml ├── images/ ├── pages/ └── templates/ diff --git a/docs/variants.md b/docs/variants.md index 53676af..b117ed0 100644 --- a/docs/variants.md +++ b/docs/variants.md @@ -10,7 +10,7 @@ different content or settings. This is useful for: ## Basic Structure -Variants are defined in your document's `config.yml`: +Variants are defined in your document's `config.yaml`: ```yaml filename: "My Document - {{ variant.name }}" diff --git a/examples/custom_locations/other_pages/custom_page.yaml b/examples/custom_locations/other_pages/custom_page.yaml new file mode 100644 index 0000000..646784a --- /dev/null +++ b/examples/custom_locations/other_pages/custom_page.yaml @@ -0,0 +1,6 @@ +title: "Custom Location Example" +description: "This page uses custom directory structure" +template: + # If you just wrote this directly it would be relative to the templates directory + # We want it to be relative to the config file: + path: "../other_templates/custom_page.svg.j2" diff --git a/examples/custom_locations/other_templates/custom_page.svg.j2 b/examples/custom_locations/other_templates/custom_page.svg.j2 new file mode 100644 index 0000000..83bac48 --- /dev/null +++ b/examples/custom_locations/other_templates/custom_page.svg.j2 @@ -0,0 +1,5 @@ + + + {{ title }} + {{ description }} + diff --git a/examples/custom_locations/your_directory/config.yaml b/examples/custom_locations/your_directory/config.yaml new file mode 100644 index 0000000..a79e9ce --- /dev/null +++ b/examples/custom_locations/your_directory/config.yaml @@ -0,0 +1,9 @@ +title: Custom Locations Example +filename: custom_locations_custom +pages: + # Simple notation - uses conventional location (pages/standard_page.yaml) + - standard_page + # Path notation - uses custom location + - path: ../other_pages/custom_page.yaml +# Custom images directory name and location +images_dir: ../your_images diff --git a/examples/custom_locations/your_directory/pages/content.yaml b/examples/custom_locations/your_directory/pages/content.yaml new file mode 100644 index 0000000..ca92c23 --- /dev/null +++ b/examples/custom_locations/your_directory/pages/content.yaml @@ -0,0 +1,4 @@ +title: "Content Page" +description: + "This document is located in a nested directory structure, demonstrating how pdfbaker + can handle documents located anywhere in your filesystem." diff --git a/examples/custom_locations/your_directory/pages/intro.yaml b/examples/custom_locations/your_directory/pages/intro.yaml new file mode 100644 index 0000000..4d7bdf5 --- /dev/null +++ b/examples/custom_locations/your_directory/pages/intro.yaml @@ -0,0 +1,4 @@ +title: "Custom Locations Example" +subtitle: "Mixing conventional and custom locations" +template: "templates/intro.svg.j2" +# Uses conventional location (templates/intro.svg.j2) diff --git a/examples/custom_locations/your_directory/pages/standard_page.yaml b/examples/custom_locations/your_directory/pages/standard_page.yaml new file mode 100644 index 0000000..9c3cb7c --- /dev/null +++ b/examples/custom_locations/your_directory/pages/standard_page.yaml @@ -0,0 +1,3 @@ +title: "Standard Location Example" +subtitle: "This page uses conventional directory structure" +template: "standard_page.svg.j2" diff --git a/examples/custom_locations/your_directory/templates/standard_page.svg.j2 b/examples/custom_locations/your_directory/templates/standard_page.svg.j2 new file mode 100644 index 0000000..603c847 --- /dev/null +++ b/examples/custom_locations/your_directory/templates/standard_page.svg.j2 @@ -0,0 +1,5 @@ + + + {{ title }} + {{ subtitle }} + diff --git a/examples/custom_locations/your_directory/your_images/pythonnz.png b/examples/custom_locations/your_directory/your_images/pythonnz.png new file mode 100644 index 0000000..8b67974 Binary files /dev/null and b/examples/custom_locations/your_directory/your_images/pythonnz.png differ diff --git a/examples/custom_processing/bake.py b/examples/custom_processing/bake.py index 98dc66f..b6e6f48 100644 --- a/examples/custom_processing/bake.py +++ b/examples/custom_processing/bake.py @@ -6,7 +6,7 @@ from datetime import datetime from pdfbaker.document import PDFBakerDocument -from pdfbaker.errors import PDFBakeError +from pdfbaker.errors import PDFBakerError def process_document(document: PDFBakerDocument) -> None: @@ -30,8 +30,7 @@ def process_document(document: PDFBakerDocument) -> None: "fetched_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "image_data": image_data, } - - # Process as usual - document.process() except Exception as exc: - raise PDFBakeError(f"Failed to process XKCD example: {exc}") from exc + raise PDFBakerError(f"Failed to process XKCD example: {exc}") from exc + + return document.process() diff --git a/examples/custom_processing/config.yml b/examples/custom_processing/config.yaml similarity index 100% rename from examples/custom_processing/config.yml rename to examples/custom_processing/config.yaml diff --git a/examples/custom_processing/pages/main.yml b/examples/custom_processing/pages/main.yaml similarity index 100% rename from examples/custom_processing/pages/main.yml rename to examples/custom_processing/pages/main.yaml diff --git a/examples/examples.yml b/examples/examples.yaml similarity index 63% rename from examples/examples.yml rename to examples/examples.yaml index ca4171c..d182401 100644 --- a/examples/examples.yml +++ b/examples/examples.yaml @@ -2,4 +2,5 @@ documents: - minimal - regular - variants + - "./custom_locations/your_directory" - custom_processing diff --git a/examples/minimal/config.yml b/examples/minimal/config.yaml similarity index 74% rename from examples/minimal/config.yml rename to examples/minimal/config.yaml index 1b61666..dc733c8 100644 --- a/examples/minimal/config.yml +++ b/examples/minimal/config.yaml @@ -1,3 +1,4 @@ +template: main.svg.j2 filename: minimal_example pages: - main diff --git a/examples/minimal/pages/main.yml b/examples/minimal/pages/main.yaml similarity index 100% rename from examples/minimal/pages/main.yml rename to examples/minimal/pages/main.yaml diff --git a/examples/regular/config.yml b/examples/regular/config.yaml similarity index 84% rename from examples/regular/config.yml rename to examples/regular/config.yaml index f546ede..a339870 100644 --- a/examples/regular/config.yml +++ b/examples/regular/config.yaml @@ -1,4 +1,5 @@ filename: regular_example +compress_pdf: true pages: - intro - features diff --git a/examples/regular/pages/benefits.yml b/examples/regular/pages/benefits.yaml similarity index 100% rename from examples/regular/pages/benefits.yml rename to examples/regular/pages/benefits.yaml diff --git a/examples/regular/pages/features.yml b/examples/regular/pages/features.yaml similarity index 100% rename from examples/regular/pages/features.yml rename to examples/regular/pages/features.yaml diff --git a/examples/regular/pages/intro.yml b/examples/regular/pages/intro.yaml similarity index 100% rename from examples/regular/pages/intro.yml rename to examples/regular/pages/intro.yaml diff --git a/examples/variants/config.yml b/examples/variants/config.yaml similarity index 100% rename from examples/variants/config.yml rename to examples/variants/config.yaml diff --git a/examples/variants/pages/main.yml b/examples/variants/pages/main.yaml similarity index 100% rename from examples/variants/pages/main.yml rename to examples/variants/pages/main.yaml diff --git a/src/pdfbaker/__init__.py b/src/pdfbaker/__init__.py index 14314dc..933613c 100644 --- a/src/pdfbaker/__init__.py +++ b/src/pdfbaker/__init__.py @@ -2,6 +2,5 @@ from importlib.metadata import version -__all__ = ["__version__"] - __version__ = version("pdfbaker") +__all__ = ["__version__"] diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 725471c..a875573 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -7,10 +7,9 @@ import click from pdfbaker import __version__ -from pdfbaker.baker import PDFBaker -from pdfbaker.errors import PDFBakeError +from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.errors import PDFBakerError -logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -25,30 +24,44 @@ def cli() -> None: "config_file", type=click.Path(exists=True, dir_okay=False, path_type=Path), ) +@click.option("-q", "--quiet", is_flag=True, help="Show errors only") +@click.option("-v", "--verbose", is_flag=True, help="Show debug information") @click.option( - "--debug", is_flag=True, help="Debug mode (implies --verbose, keeps build files)" + "-t", + "--trace", + is_flag=True, + help="Show trace information (even more detailed than --verbose)", ) -@click.option("-v", "--verbose", is_flag=True, help="Show debug information") -@click.option("-q", "--quiet", is_flag=True, help="Show errors only") -def bake(config_file: Path, debug: bool, verbose: bool, quiet: bool) -> int: +@click.option("--keep-build", is_flag=True, help="Keep build artifacts") +@click.option("--debug", is_flag=True, help="Debug mode (--verbose and --keep-build)") +# pylint: disable=too-many-arguments,too-many-positional-arguments +def bake( + config_file: Path, + quiet: bool, + verbose: bool, + trace: bool, + keep_build: bool, + debug: bool, +) -> int: """Parse config file and bake PDFs.""" if debug: verbose = True - if quiet: - logging.getLogger().setLevel(logging.ERROR) - elif verbose: - logging.getLogger().setLevel(logging.DEBUG) - else: - logging.getLogger().setLevel(logging.INFO) + keep_build = True try: - baker = PDFBaker(config_file) - baker.bake(debug=debug) - return 0 - except PDFBakeError as exc: + options = PDFBakerOptions( + quiet=quiet, + verbose=verbose, + trace=trace, + keep_build=keep_build, + ) + baker = PDFBaker(config_file, options=options) + success = baker.bake() + sys.exit(0 if success else 1) + except PDFBakerError as exc: logger.error(str(exc)) - return 1 + sys.exit(1) if __name__ == "__main__": - sys.exit(cli()) + cli() diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 932db4f..16a7ad7 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -1,161 +1,165 @@ -"""Main PDF baker class.""" +"""PDFBaker class. -import logging +Overall orchestration and logging. + +Is given a configuration file and sets up logging. +bake() delegates to its documents and reports back the end result. +""" + +from dataclasses import dataclass from pathlib import Path from typing import Any -import yaml - -from . import errors +from .config import PDFBakerConfiguration, deep_merge from .document import PDFBakerDocument -from .errors import PDFBakeError +from .errors import ConfigurationError +from .logging import LoggingMixin, setup_logging -__all__ = ["PDFBaker"] +__all__ = ["PDFBaker", "PDFBakerOptions"] -class PDFBaker: - """Main class for PDF document generation.""" - - def __init__(self, config_file: Path) -> None: - """Initialize PDFBaker with config file path. +DEFAULT_BAKER_CONFIG = { + # Default to directories relative to the config file + "directories": { + "documents": ".", + "build": "build", + "dist": "dist", + }, +} - Args: - config_file: Path to config file, document directory is its parent - """ - self.logger = logging.getLogger(__name__) - self.base_dir = config_file.parent - self.build_dir = self.base_dir / "build" - self.dist_dir = self.base_dir / "dist" - self.config = self._load_config(config_file) - # Add convenience methods for logging - def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a debug message.""" - self.logger.debug(msg, *args, **kwargs) +@dataclass +class PDFBakerOptions: + """Options for controlling PDFBaker behavior. - def info(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log an info message.""" - self.logger.info(msg, *args, **kwargs) + Attributes: + quiet: Show errors only + verbose: Show debug information + trace: Show trace information (even more detailed than debug) + keep_build: Keep build artifacts after processing + default_config_overrides: Dictionary of values to override the built-in defaults + before loading the main configuration + """ - def warning(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a warning message.""" - self.logger.warning(msg, *args, **kwargs) + quiet: bool = False + verbose: bool = False + trace: bool = False + keep_build: bool = False + default_config_overrides: dict[str, Any] | None = None - def error(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log an error message.""" - self.logger.error(msg, *args, **kwargs) - def critical(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a critical message.""" - self.logger.critical(msg, *args, **kwargs) +class PDFBaker(LoggingMixin): + """Main class for PDF document generation.""" - def bake(self, debug: bool = False) -> None: - """Generate PDFs from configuration. + class Configuration(PDFBakerConfiguration): + """PDFBaker configuration.""" + + def __init__( + self, baker: "PDFBaker", base_config: dict[str, Any], config_file: Path + ) -> None: + """Initialize baker configuration (needs documents).""" + self.baker = baker + self.baker.log_debug_section("Loading main configuration: %s", config_file) + super().__init__(base_config, config_file) + self.baker.log_trace(self.pretty()) + if "documents" not in self: + raise ConfigurationError( + 'Key "documents" missing - is this the main configuration file?' + ) + self.build_dir = self["directories"]["build"] + self.documents = [ + self.resolve_path(doc_spec, directory=self["directories"]["documents"]) + for doc_spec in self["documents"] + ] + + def __init__( + self, + config_file: Path, + options: PDFBakerOptions | None = None, + ) -> None: + """Initialize PDFBaker with config file path. Set logging level. Args: - debug: If True, keep build files for debugging + config_file: Path to config file + options: Optional options for logging and build behavior + """ + super().__init__() + options = options or PDFBakerOptions() + setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose) + self.keep_build = options.keep_build + + base_config = DEFAULT_BAKER_CONFIG.copy() + if options and options.default_config_overrides: + base_config = deep_merge(base_config, options.default_config_overrides) + base_config["directories"]["config"] = config_file.parent.resolve() + + self.config = self.Configuration( + baker=self, + base_config=base_config, + config_file=config_file, + ) + + def bake(self) -> None: + """Create PDFs for all documents. + + Returns: + bool: True if all documents were processed successfully, False if any failed """ - document_paths = self._get_document_paths(self.config.get("documents", [])) - pdfs_created: list[str] = [] + pdfs_created: list[Path] = [] failed_docs: list[tuple[str, str]] = [] - for doc_name, doc_path in document_paths.items(): + self.log_debug_subsection("Documents to process:") + self.log_debug(self.config.documents) + for doc_config in self.config.documents: doc = PDFBakerDocument( - name=doc_name, - doc_dir=doc_path, baker=self, + base_config=self.config, + config_path=doc_config, ) - doc.setup_directories() - pdf_file, error_message = doc.process_document() - if pdf_file is None: - self.error( - "Failed to process document '%s': %s", doc_name, error_message + pdf_files, error_message = doc.process_document() + if error_message: + self.log_error( + "Failed to process document '%s': %s", + doc.config.name, + error_message, ) - failed_docs.append((doc_name, error_message)) + failed_docs.append((doc.config.name, error_message)) else: - pdfs_created.append(pdf_file) + if isinstance(pdf_files, Path): + pdf_files = [pdf_files] + pdfs_created.extend(pdf_files) + if not self.keep_build: + doc.teardown() - if not debug: - self._teardown_build_directories(list(document_paths.keys())) - - self.info("Done.") if pdfs_created: - self.info("PDF files created in %s", self.dist_dir.resolve()) + self.log_info("Successfully created PDFs:") + for pdf in pdfs_created: + self.log_info(" %s", pdf) else: - self.warning("No PDF files created.") + self.log_warning("No PDFs were created.") + if failed_docs: - self.warning("There were errors.") - - def _load_config(self, config_file: Path) -> dict[str, Any]: - """Load configuration from YAML file.""" - try: - with open(config_file, encoding="utf-8") as f: - config = yaml.safe_load(f) - if "documents" not in config: - raise errors.PDFBakeError( - 'Not a main configuration file - "documents" key missing' - ) - return config - except Exception as exc: - raise errors.PDFBakeError(f"Failed to load config file: {exc}") from exc - - def _get_document_paths( - self, documents: list[dict[str, str] | str] | None - ) -> dict[str, Path]: - """Resolve document paths to absolute paths. + self.log_warning( + "Failed to process %d document%s:", + len(failed_docs), + "" if len(failed_docs) == 1 else "s", + ) + for doc_name, error in failed_docs: + self.log_error(" %s: %s", doc_name, error) - Args: - documents: List of document names or dicts with name/path, - or None if no documents specified + if not self.keep_build: + self.teardown() - Returns: - Dictionary mapping document names to their paths - """ - if not documents: - return {} - - document_paths: dict[str, Path] = {} - for doc_name in documents: - if isinstance(doc_name, dict): - # Format: {"name": "doc_name", "path": "/absolute/path/to/doc"} - doc_path = Path(doc_name["path"]) - doc_name = doc_name["name"] - else: - # Default: document in subdirectory with same name as doc_name - doc_path = self.base_dir / doc_name - - if not doc_path.exists(): - raise PDFBakeError(f"Document directory not found: {doc_path}") - document_paths[doc_name] = doc_path.resolve() - - return document_paths - - def _teardown_build_directories(self, doc_names: list[str]) -> None: - """Clean up build directories after successful processing.""" - for doc_name in doc_names: - doc_build_dir = self.build_dir / doc_name - if doc_build_dir.exists(): - # Remove all files in the document's build directory - for file_path in doc_build_dir.iterdir(): - if file_path.is_file(): - file_path.unlink() - - # Try to remove the document's build directory if empty - try: - doc_build_dir.rmdir() - except OSError: - # Directory not empty (might contain subdirectories) - self.logger.warning( - "Build directory of document not empty, keeping %s", - doc_build_dir, - ) - - # Try to remove the base build directory if it exists and is empty - if self.build_dir.exists(): + return not failed_docs + + def teardown(self) -> None: + """Clean up (top-level) build directory after processing.""" + self.log_debug_subsection( + "Tearing down top-level build directory: %s", self.config.build_dir + ) + if self.config.build_dir.exists(): try: - self.build_dir.rmdir() + self.log_debug("Removing top-level build directory...") + self.config.build_dir.rmdir() except OSError: - # Directory not empty - self.logger.warning( - "Build directory not empty, keeping %s", self.build_dir - ) + self.log_warning("Top-level build directory not empty - not removing") diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py new file mode 100644 index 0000000..ac5bfc8 --- /dev/null +++ b/src/pdfbaker/config.py @@ -0,0 +1,165 @@ +"""Base configuration for pdfbaker classes.""" + +import logging +import pprint +from pathlib import Path +from typing import Any + +import yaml +from jinja2 import Template + +from .errors import ConfigurationError +from .logging import truncate_strings +from .types import PathSpec + +__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] + +logger = logging.getLogger(__name__) + + +def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Deep merge two dictionaries.""" + result = base.copy() + for key, value in update.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +class PDFBakerConfiguration(dict): + """Base class for handling config loading/merging/parsing.""" + + def __init__( + self, + base_config: dict[str, Any], + config_file: Path, + ) -> None: + """Initialize configuration from a file. + + Args: + base_config: Existing base configuration + config: Path to YAML file to merge with base_config + """ + try: + with open(config_file, encoding="utf-8") as f: + config = yaml.safe_load(f) + except yaml.scanner.ScannerError as exc: + raise ConfigurationError( + f"Invalid YAML syntax in config file {config_file}: {exc}" + ) from exc + except Exception as exc: + raise ConfigurationError(f"Failed to load config file: {exc}") from exc + + # Determine all relevant directories + self["directories"] = directories = {"config": config_file.parent.resolve()} + for directory in ( + "documents", + "pages", + "templates", + "images", + "build", + "dist", + ): + if directory in config.get("directories", {}): + # Set in this config file, relative to this config file + directories[directory] = self.resolve_path( + config["directories"][directory] + ) + elif directory in base_config.get("directories", {}): + # Inherited (absolute) or default (relative to _this_ config) + directories[directory] = self.resolve_path( + str(base_config["directories"][directory]) + ) + super().__init__(deep_merge(base_config, config)) + self["directories"] = directories + + def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: + """Resolve a possibly relative path specification. + + Args: + spec: Path specification (string or dict with path/name) + directory: Optional directory to use for resolving paths + Returns: + Resolved Path object + """ + directory = directory or self["directories"]["config"] + if isinstance(spec, str): + return directory / spec + + if "path" not in spec and "name" not in spec: + raise ConfigurationError("Invalid path specification: needs path or name") + + if "path" in spec: + return Path(spec["path"]) + + return directory / spec["name"] + + def pretty(self, max_chars: int = 60) -> str: + """Return readable presentation (for debugging).""" + truncated = truncate_strings(self, max_chars=max_chars) + return pprint.pformat(truncated, indent=2) + + +def _convert_paths_to_strings(config: dict[str, Any]) -> dict[str, Any]: + """Convert all Path objects in config to strings.""" + result = {} + for key, value in config.items(): + if isinstance(value, Path): + result[key] = str(value) + elif isinstance(value, dict): + result[key] = _convert_paths_to_strings(value) + elif isinstance(value, list): + result[key] = [ + _convert_paths_to_strings(item) + if isinstance(item, dict) + else str(item) + if isinstance(item, Path) + else item + for item in value + ] + else: + result[key] = value + return result + + +def render_config(config: dict[str, Any]) -> dict[str, Any]: + """Resolve all template strings in config using its own values. + + This allows the use of "{{ variant }}" in the "filename" etc. + + Args: + config: Configuration dictionary to render + + Returns: + Resolved configuration dictionary + + Raises: + ConfigurationError: If maximum number of iterations is reached + (circular references) + """ + max_iterations = 10 + current_config = dict(config) + current_config = _convert_paths_to_strings(current_config) + + for _ in range(max_iterations): + config_yaml = Template(yaml.dump(current_config)) + resolved_yaml = config_yaml.render(**current_config) + new_config = yaml.safe_load(resolved_yaml) + + # Check for direct self-references + for key, value in new_config.items(): + if isinstance(value, str) and f"{{{{ {key} }}}}" in value: + raise ConfigurationError( + f"Circular reference detected: {key} references itself" + ) + + if new_config == current_config: # No more changes + return new_config + current_config = new_config + + raise ConfigurationError( + "Maximum number of iterations reached. " + "Check for circular references in your configuration." + ) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index a05b831..5922d69 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -1,255 +1,239 @@ -"""Document processing classes.""" +"""PDFBakerDocument class. + +Document-level processing, variants, custom bake modules. + +Delegates the jobs of rendering and converting to its pages, +combines and compresses the result and reports back to its baker. +""" import importlib import os from pathlib import Path from typing import Any -import jinja2 -import yaml - -from .common import ( - combine_pdfs, - compress_pdf, - convert_svg_to_pdf, +from .config import ( + PDFBakerConfiguration, deep_merge, - resolve_config, + render_config, ) from .errors import ( - PDFBakeError, + ConfigurationError, + PDFBakerError, PDFCombineError, PDFCompressionError, - SVGConversionError, ) -from .render import create_env, prepare_template_context - -__all__ = [ - "PDFBakerDocument", - "PDFBakerPage", -] - - -class PDFBakerPage: # pylint: disable=too-few-public-methods - """A single page of a document.""" - - def __init__( - self, - document: "PDFBakerDocument", - name: str, - number: int, - config: dict[str, Any] | None = None, - ) -> None: - """Initialize a page. - - Args: - document: Parent PDFBakerDocument instance - name: Name of the page - number: Page number (for output filename) - config: Optional variant-specific config to use instead of document config - """ - self.document = document - self.name = name - self.number = number - base_config = config or document.config - config_path = document.doc_dir / "pages" / f"{name}.yml" - self.config = self._load_config(config_path, base_config) - - def _load_config( - self, config_path: Path, base_config: dict[str, Any] - ) -> dict[str, Any]: - """Load and merge page configuration with document configuration.""" - try: - with open(config_path, encoding="utf-8") as f: - page_config = yaml.safe_load(f) - return deep_merge(base_config, page_config) - except Exception as exc: - raise PDFBakeError(f"Failed to load page config file: {exc}") from exc - - def process(self) -> Path: - """Process the page from SVG template to PDF.""" - if "template" not in self.config: - raise PDFBakeError( - f'Page "{self.name}" in document "{self.document.name}" has no template' - ) +from .logging import LoggingMixin +from .page import PDFBakerPage +from .pdf import ( + combine_pdfs, + compress_pdf, +) - # Resolve any templates in the config - self.config = resolve_config(self.config) +DEFAULT_DOCUMENT_CONFIG = { + # Default to directories relative to the config file + "directories": { + "pages": "pages", + "templates": "templates", + "images": "images", + }, +} +DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" - output_filename = f"{self.config['filename']}_{self.number:03}" - svg_path = self.document.build_dir / f"{output_filename}.svg" - pdf_path = self.document.build_dir / f"{output_filename}.pdf" +__all__ = ["PDFBakerDocument"] - template = self.document.jinja_env.get_template(self.config["template"]) - template_context = prepare_template_context( - self.config, images_dir=self.document.doc_dir / "images" - ) - template_context["page_number"] = self.number - with open(svg_path, "w", encoding="utf-8") as f: - f.write(template.render(**template_context)) +class PDFBakerDocument(LoggingMixin): + """A document being processed.""" - svg2pdf_backend = self.config.get("svg2pdf_backend", "cairosvg") - try: - return convert_svg_to_pdf( - svg_path, - pdf_path, - backend=svg2pdf_backend, - ) - except SVGConversionError as exc: - self.document.baker.error( - "Failed to convert page %d (%s): %s", - self.number, - self.name, - exc, + class Configuration(PDFBakerConfiguration): + """PDFBaker document-specific configuration.""" + + def __init__( + self, + document: "PDFBakerDocument", + base_config: "PDFBakerConfiguration", # type: ignore # noqa: F821 + config_path: Path, + ) -> None: + """Initialize document configuration. + + Args: + base_config: The PDFBaker configuration to merge with + config_file: The document configuration (YAML file) + """ + self.document = document + + if config_path.is_dir(): + self.name = config_path.name + config_path = config_path / DEFAULT_DOCUMENT_CONFIG_FILE + else: + self.name = config_path.stem + + base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG) + + self.document.log_trace_section( + "Loading document configuration: %s", config_path ) - raise + super().__init__(base_config, config_path) + self.document.log_trace(self.pretty()) + self.bake_path = self["directories"]["config"] / "bake.py" + self.build_dir = self["directories"]["build"] / self.name + self.dist_dir = self["directories"]["dist"] / self.name -class PDFBakerDocument: - """A document being processed.""" + if "pages" not in self: + raise ConfigurationError( + 'Document "{document.name}" is missing key "pages"' + ) + self.pages = [] + for page_spec in self["pages"]: + if isinstance(page_spec, dict) and "path" in page_spec: + # Path was specified: relative to the config file + page = self.resolve_path( + page_spec["path"], directory=self["directories"]["config"] + ) + else: + # Only name was specified: relative to the pages directory + page = self.resolve_path( + page_spec, directory=self["directories"]["pages"] + ) + if not page.suffix: + page = page.with_suffix(".yaml") + self.pages.append(page) def __init__( self, - name: str, - doc_dir: Path, - baker: "PDFBaker", # noqa: F821 - ) -> None: - """Initialize a document. - - Args: - name: Document name - doc_dir: Path to document directory - baker: PDFBaker instance that owns this document - """ - self.name = name - self.doc_dir = doc_dir + baker: "PDFBaker", # type: ignore # noqa: F821 + base_config: dict[str, Any], + config_path: Path, + ): + """Initialize a document.""" + super().__init__() self.baker = baker - self.config = self._load_config() - self.jinja_env = create_env(doc_dir / "templates") - self.build_dir = baker.build_dir / name - self.dist_dir = baker.dist_dir / name - - def _load_config(self) -> dict[str, Any]: - """Load and merge document configuration.""" - config_path = self.doc_dir / "config.yml" - try: - with open(config_path, encoding="utf-8") as f: - doc_config = yaml.safe_load(f) - return deep_merge(self.baker.config, doc_config) - except Exception as exc: - raise PDFBakeError(f"Failed to load config file: {exc}") from exc - - def setup_directories(self) -> None: - """Set up output directories.""" - self.build_dir.mkdir(parents=True, exist_ok=True) - self.dist_dir.mkdir(parents=True, exist_ok=True) - - # Clean existing files - for dir_path in [self.build_dir, self.dist_dir]: - for file in os.listdir(dir_path): - file_path = dir_path / file - if os.path.isfile(file_path): - os.remove(file_path) + self.config = self.Configuration( + document=self, + base_config=base_config, + config_path=config_path, + ) - def process_document(self) -> tuple[Path | None, str | None]: + def process_document(self) -> tuple[Path | list[Path] | None, str | None]: """Process the document - use custom bake module if it exists. Returns: - Tuple of (pdf_file, error_message) where: - - pdf_file is the path to the created PDF, or None if creation failed + Tuple of (pdf_files, error_message) where: + - pdf_files is a Path or list of Paths to the created PDF + files, or None if creation failed + FIXME: could have created SOME PDF files - error_message is a string describing the error, or None if successful """ - self.baker.info('Processing document "%s" from %s...', self.name, self.doc_dir) + self.log_info_section('Processing document "%s"...', self.config.name) - # Try to load custom bake module - bake_path = self.doc_dir / "bake.py" - if bake_path.exists(): - try: - self._process_with_custom_bake(bake_path) - return self.dist_dir / f"{self.config['filename']}.pdf", None - except PDFBakeError as exc: - return None, str(exc) - else: - try: - self.process() - return self.dist_dir / f"{self.config['filename']}.pdf", None - except (PDFBakeError, jinja2.exceptions.TemplateError) as exc: - return None, str(exc) + self.config.build_dir.mkdir(parents=True, exist_ok=True) + self.config.dist_dir.mkdir(parents=True, exist_ok=True) - def process(self) -> None: - """Process document using standard processing.""" - doc_config = self.config.copy() + try: + if self.config.bake_path.exists(): + return self._process_with_custom_bake(self.config.bake_path), None + return self.process(), None + except PDFBakerError as exc: + return None, str(exc) + def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: + """Process document using custom bake module.""" + try: + spec = importlib.util.spec_from_file_location( + f"documents.{self.config.name}.bake", bake_path + ) + if spec is None or spec.loader is None: + raise PDFBakerError( + f"Failed to load bake module for document {self.config.name}" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.process_document(document=self) + except Exception as exc: + raise PDFBakerError( + f"Failed to process document with custom bake: {exc}" + ) from exc + + def process(self) -> Path | list[Path]: + """Process document using standard processing.""" if "variants" in self.config: # Multiple PDF documents + pdf_files = [] for variant in self.config["variants"]: - self.baker.info('Processing variant "%s"...', variant["name"]) - variant_config = deep_merge(doc_config, variant) + self.log_info_subsection('Processing variant "%s"...', variant["name"]) + variant_config = deep_merge(self.config, variant) variant_config["variant"] = variant - variant_config = resolve_config(variant_config) - self._process_pages(variant_config) - else: - # Single PDF document - doc_config = resolve_config(doc_config) - self._process_pages(doc_config) + variant_config = render_config(variant_config) + page_pdfs = self._process_pages(variant_config) + pdf_files.append(self._finalize(page_pdfs, variant_config)) + return pdf_files - def _process_pages(self, config: dict[str, Any]) -> None: - """Process pages with given configuration.""" - pages = config.get("pages", []) - if not pages: - raise PDFBakeError("No pages defined in config") + # Single PDF document + doc_config = render_config(self.config) + page_pdfs = self._process_pages(doc_config) + return self._finalize(page_pdfs, doc_config) + def _process_pages(self, config: dict[str, Any]) -> list[Path]: + """Process pages with given configuration.""" pdf_files = [] - for page_num, page_name in enumerate(pages, start=1): + self.log_debug_subsection("Pages to process:") + self.log_debug(self.config.pages) + for page_num, page_config in enumerate(self.config.pages, start=1): page = PDFBakerPage( document=self, - name=page_name, - number=page_num, - config=config, + page_number=page_num, + base_config=config, + config_path=page_config, ) pdf_files.append(page.process()) - self._finalize(pdf_files, config) + return pdf_files - def _process_with_custom_bake(self, bake_path: Path) -> None: - """Process document using custom bake module.""" - try: - spec = importlib.util.spec_from_file_location( - f"documents.{self.name}.bake", bake_path - ) - if spec is None or spec.loader is None: - raise PDFBakeError( - f"Failed to load bake module for document {self.name}" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - module.process_document(document=self) - except Exception as exc: - raise PDFBakeError( - f"Failed to process document with custom bake: {exc}" - ) from exc - - def _finalize(self, pdf_files: list[Path], config: dict[str, Any]) -> None: - """Combine pages and handle compression.""" + def _finalize(self, pdf_files: list[Path], doc_config: dict[str, Any]) -> Path: + """Combine PDF pages and optionally compress.""" + self.log_debug_subsection("Finalizing document...") + self.log_debug("Combining PDF pages...") try: combined_pdf = combine_pdfs( pdf_files, - self.build_dir / f"{config['filename']}.pdf", + self.config.build_dir / f"{doc_config['filename']}.pdf", ) except PDFCombineError as exc: - raise PDFBakeError(f"Failed to combine PDFs: {exc}") from exc + raise PDFBakerError(f"Failed to combine PDFs: {exc}") from exc - output_path = self.dist_dir / f"{config['filename']}.pdf" + output_path = self.config.dist_dir / f"{doc_config['filename']}.pdf" - if config.get("compress_pdf", False): + if doc_config.get("compress_pdf", False): + self.log_debug("Compressing PDF document...") try: compress_pdf(combined_pdf, output_path) - self.baker.info("PDF compressed successfully") + self.log_info("PDF compressed successfully") except PDFCompressionError as exc: - self.baker.warning( - "Compression failed, using uncompressed version: %s", + self.log_warning( + "Compression failed, using uncompressed PDF: %s", exc, ) os.rename(combined_pdf, output_path) else: os.rename(combined_pdf, output_path) + + self.log_info("Created %s", output_path.name) + return output_path + + def teardown(self) -> None: + """Clean up build directory after processing.""" + self.log_debug_subsection( + "Tearing down build directory: %s", self.config.build_dir + ) + if self.config.build_dir.exists(): + self.log_debug("Removing files in build directory...") + for file_path in self.config.build_dir.iterdir(): + if file_path.is_file(): + file_path.unlink() + + try: + self.log_debug("Removing build directory...") + self.config.build_dir.rmdir() + except OSError: + self.log_warning("Build directory not empty - not removing") diff --git a/src/pdfbaker/errors.py b/src/pdfbaker/errors.py index a825078..7303fd3 100644 --- a/src/pdfbaker/errors.py +++ b/src/pdfbaker/errors.py @@ -3,26 +3,32 @@ from pathlib import Path __all__ = [ - "PDFBakeError", + "ConfigurationError", + "PDFBakerError", "PDFCombineError", "PDFCompressionError", "SVGConversionError", + "SVGTemplateError", ] -class PDFBakeError(Exception): +class PDFBakerError(Exception): """Base exception for PDF baking errors.""" -class PDFCombineError(PDFBakeError): +class ConfigurationError(PDFBakerError): + """Failed to load or parse configuration.""" + + +class PDFCombineError(PDFBakerError): """Failed to combine PDFs.""" -class PDFCompressionError(PDFBakeError): +class PDFCompressionError(PDFBakerError): """Failed to compress PDF.""" -class SVGConversionError(PDFBakeError): +class SVGConversionError(PDFBakerError): """Failed to convert SVG to PDF.""" def __init__( @@ -32,3 +38,7 @@ def __init__( self.backend = backend self.cause = cause super().__init__(f"Failed to convert {svg_path} using {backend}: {cause}") + + +class SVGTemplateError(PDFBakerError): + """Failed to load or render an SVG template.""" diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py new file mode 100644 index 0000000..759bda8 --- /dev/null +++ b/src/pdfbaker/logging.py @@ -0,0 +1,128 @@ +"""Logging mixin for pdfbaker classes.""" + +import logging +import sys +from typing import Any + +TRACE = 5 +logging.addLevelName(TRACE, "TRACE") + +__all__ = ["LoggingMixin", "setup_logging", "truncate_strings"] + + +class LoggingMixin: + """Mixin providing consistent logging functionality across pdfbaker classes.""" + + def __init__(self) -> None: + """Initialize logger for the class.""" + self.logger = logging.getLogger(self.__class__.__module__) + + def log_trace(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a trace message (more detailed than debug).""" + self.logger.log(TRACE, msg, *args, **kwargs) + + def log_trace_preview( + self, msg: str, *args: Any, max_chars: int = 500, **kwargs: Any + ) -> None: + """Log a trace preview of a potentially large message, truncating if needed.""" + self.logger.log( + TRACE, truncate_strings(msg, max_chars=max_chars), *args, **kwargs + ) + + def log_trace_section(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a trace message as a main section header.""" + self.logger.log(TRACE, f"──── {msg} ────", *args, **kwargs) + + def log_trace_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a trace message as a subsection header.""" + self.logger.log(TRACE, f" ── {msg} ──", *args, **kwargs) + + def log_debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a debug message.""" + self.logger.debug(msg, *args, **kwargs) + + def log_debug_section(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a debug message as a main section header.""" + self.logger.debug(f"──── {msg} ────", *args, **kwargs) + + def log_debug_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a debug message as a subsection header.""" + self.logger.debug(f" ── {msg} ──", *args, **kwargs) + + def log_info(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log an info message.""" + self.logger.info(msg, *args, **kwargs) + + def log_info_section(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log an info message as a main section header.""" + self.logger.info(f"──── {msg} ────", *args, **kwargs) + + def log_info_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log an info message as a subsection header.""" + self.logger.info(f" ── {msg} ──", *args, **kwargs) + + def log_warning(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a warning message.""" + self.logger.warning(msg, *args, **kwargs) + + def log_error(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log an error message.""" + self.logger.error(f"**** {msg} ****", *args, **kwargs) + + def log_critical(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a critical message.""" + self.logger.critical(msg, *args, **kwargs) + + +def setup_logging(quiet=False, trace=False, verbose=False) -> None: + """Set up logging for the application.""" + logger = logging.getLogger() + logger.setLevel(logging.INFO) + formatter = logging.Formatter("%(levelname)s: %(message)s") + + # stdout handler for TRACE/DEBUG/INFO + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + stdout_handler.setLevel(TRACE) + stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING) + + # stderr handler for WARNING and above + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setFormatter(formatter) + stderr_handler.setLevel(logging.WARNING) + + # Remove existing console handlers, add ours + for handler in logger.handlers[:]: + if isinstance(handler, logging.StreamHandler) and not isinstance( + handler, logging.FileHandler + ): + logger.removeHandler(handler) + logger.addHandler(stdout_handler) + logger.addHandler(stderr_handler) + + if quiet: + logger.setLevel(logging.ERROR) + elif trace: + logger.setLevel(TRACE) + elif verbose: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + +def truncate_strings(obj, max_chars: int) -> Any: + """Recursively truncate strings in nested structures.""" + if isinstance(obj, str): + return obj if len(obj) <= max_chars else obj[:max_chars] + "…" + if isinstance(obj, dict): + return { + truncate_strings(k, max_chars): truncate_strings(v, max_chars) + for k, v in obj.items() + } + if isinstance(obj, list): + return [truncate_strings(item, max_chars) for item in obj] + if isinstance(obj, tuple): + return tuple(truncate_strings(item, max_chars) for item in obj) + if isinstance(obj, set): + return {truncate_strings(item, max_chars) for item in obj} + return obj diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py new file mode 100644 index 0000000..1a446c1 --- /dev/null +++ b/src/pdfbaker/page.py @@ -0,0 +1,138 @@ +"""PDFBakerPage class. + +Individual page rendering and PDF conversion. + +Renders its SVG template with a fully merged configuration, +converts the result to PDF and returns the path of the new PDF file. +""" + +from pathlib import Path +from typing import Any + +from jinja2.exceptions import TemplateError, TemplateNotFound + +from .config import PDFBakerConfiguration +from .errors import ConfigurationError, SVGConversionError, SVGTemplateError +from .logging import TRACE, LoggingMixin +from .pdf import convert_svg_to_pdf +from .render import create_env, prepare_template_context + +__all__ = ["PDFBakerPage"] + + +# pylint: disable=too-few-public-methods +class PDFBakerPage(LoggingMixin): + """A single page of a document.""" + + class Configuration(PDFBakerConfiguration): + """PDFBakerPage configuration.""" + + def __init__( + self, + page: "PDFBakerPage", + base_config: dict[str, Any], + config_path: Path, + ) -> None: + """Initialize page configuration (needs a template).""" + self.page = page + + self.name = config_path.stem + + self.page.log_trace_section("Loading page configuration: %s", config_path) + super().__init__(base_config, config_path) + self.page.log_trace(self.pretty()) + + self.templates_dir = self["directories"]["templates"] + self.images_dir = self["directories"]["images"] + self.build_dir = page.document.config.build_dir + self.dist_dir = page.document.config.dist_dir + + if "template" not in self: + raise ConfigurationError( + f'Page "{self.name}" in document ' + f'"{self.page.document.config.name}" has no template' + ) + if isinstance(self["template"], dict) and "path" in self["template"]: + # Path was specified: relative to the config file + self.template = self.resolve_path( + self["template"]["path"], directory=self["directories"]["config"] + ).resolve() + else: + # Only name was specified: relative to the templates directory + self.template = self.resolve_path( + self["template"], directory=self.templates_dir + ).resolve() + + def __init__( + self, + document: "PDFBakerDocument", # type: ignore # noqa: F821 + page_number: int, + base_config: dict[str, Any], + config_path: Path | dict[str, Any], + ) -> None: + """Initialize a page.""" + super().__init__() + self.document = document + self.number = page_number + self.config = self.Configuration( + page=self, + base_config=base_config, + config_path=config_path, + ) + + def process(self) -> Path: + """Render SVG template and convert to PDF.""" + self.log_debug_subsection( + "Processing page %d: %s", self.number, self.config.name + ) + + self.log_debug("Loading template: %s", self.config.template) + if self.logger.isEnabledFor(TRACE): + with open(self.config.template, encoding="utf-8") as f: + self.log_trace_preview(f.read()) + + try: + jinja_env = create_env(self.config.template.parent) + template = jinja_env.get_template(self.config.template.name) + except TemplateNotFound as exc: + raise SVGTemplateError( + "Failed to load template for page " + f"{self.number} ({self.config.name}): {exc}" + ) from exc + + template_context = prepare_template_context( + self.config, + self.config.images_dir, + ) + + self.config.build_dir.mkdir(parents=True, exist_ok=True) + output_svg = self.config.build_dir / f"{self.config.name}_{self.number:03}.svg" + output_pdf = self.config.build_dir / f"{self.config.name}_{self.number:03}.pdf" + + self.log_debug("Rendering template...") + try: + rendered_template = template.render(**template_context) + with open(output_svg, "w", encoding="utf-8") as f: + f.write(rendered_template) + except TemplateError as exc: + raise SVGTemplateError( + f"Failed to render page {self.number} ({self.config.name}): {exc}" + ) from exc + self.log_trace_preview(rendered_template) + + self.log_debug("Converting SVG to PDF: %s", output_svg) + svg2pdf_backend = self.config.get("svg2pdf_backend", "cairosvg") + try: + return convert_svg_to_pdf( + output_svg, + output_pdf, + backend=svg2pdf_backend, + ) + except SVGConversionError as exc: + self.log_error( + "Failed to convert page %d (%s): %s", + self.number, + self.config.name, + exc, + ) + raise diff --git a/src/pdfbaker/common.py b/src/pdfbaker/pdf.py similarity index 62% rename from src/pdfbaker/common.py rename to src/pdfbaker/pdf.py index b008219..1f7c02a 100644 --- a/src/pdfbaker/common.py +++ b/src/pdfbaker/pdf.py @@ -1,4 +1,4 @@ -"""Common functionality for document generation.""" +"""PDF-related functions.""" import logging import os @@ -6,73 +6,28 @@ import subprocess from collections.abc import Sequence from pathlib import Path -from typing import Any import pypdf -import yaml from cairosvg import svg2pdf -from jinja2 import Template -from . import errors +from .errors import ( + PDFCombineError, + PDFCompressionError, + SVGConversionError, +) __all__ = [ "combine_pdfs", "compress_pdf", "convert_svg_to_pdf", - "deep_merge", - "resolve_config", ] logger = logging.getLogger(__name__) -def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: - """Recursively merge two dictionaries. - - Values in update will override those in base, except for dictionaries - which will be merged recursively. - """ - merged = base.copy() - for key, value in update.items(): - if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): - merged[key] = deep_merge(merged[key], value) - else: - merged[key] = value - return merged - - -def resolve_config(config: dict) -> dict: - """Resolve all template strings in config using its own values. - - Args: - config: Configuration dictionary with template strings - - Returns: - Resolved configuration dictionary - - Raises: - ValueError: If maximum number of iterations is reached - (likely due to circular references) - """ - max_iterations = 10 - for _ in range(max_iterations): - config_yaml = Template(yaml.dump(config)) - resolved_yaml = config_yaml.render(**config) - new_config = yaml.safe_load(resolved_yaml) - - if new_config == config: # No more changes - return new_config - config = new_config - - raise ValueError( - "Maximum number of iterations reached. " - "Check for circular references in your configuration." - ) - - def combine_pdfs( pdf_files: Sequence[Path], output_file: Path -) -> Path | errors.PDFCombineError: +) -> Path | PDFCombineError: """Combine multiple PDF files into a single PDF. Args: @@ -86,7 +41,7 @@ def combine_pdfs( PDFCombineError: If no PDF files provided or if combining fails """ if not pdf_files: - raise errors.PDFCombineError("No PDF files provided to combine") + raise PDFCombineError("No PDF files provided to combine") pdf_writer = pypdf.PdfWriter() @@ -110,9 +65,7 @@ def combine_pdfs( else: raise except Exception as exc: - raise errors.PDFCombineError( - f"Failed to combine PDFs: {exc}" - ) from exc + raise PDFCombineError(f"Failed to combine PDFs: {exc}") from exc pdf_writer.write(output_stream) return output_file @@ -169,7 +122,7 @@ def _run_subprocess_logged(cmd: list[str], env: dict[str, str] | None = None) -> def compress_pdf( input_pdf: Path, output_pdf: Path, dpi: int = 300 -) -> Path | errors.PDFCompressionError: +) -> Path | PDFCompressionError: """Compress a PDF file using Ghostscript. Args: @@ -199,17 +152,17 @@ def compress_pdf( ] ) return output_pdf + except FileNotFoundError as exc: + raise PDFCompressionError(f"Ghostscript not found: {exc}") from exc except subprocess.SubprocessError as exc: - raise errors.PDFCompressionError( - f"Ghostscript compression failed: {exc}" - ) from exc + raise PDFCompressionError(f"Ghostscript compression failed: {exc}") from exc def convert_svg_to_pdf( svg_path: Path, pdf_path: Path, backend: str = "cairosvg", -) -> Path | errors.SVGConversionError: +) -> Path | SVGConversionError: """Convert an SVG file to PDF. Args: @@ -224,25 +177,27 @@ def convert_svg_to_pdf( Raises: SVGConversionError: If SVG conversion fails, includes the backend used and cause """ - try: - if backend == "inkscape": - try: - _run_subprocess_logged( - [ - "inkscape", - f"--export-filename={pdf_path}", - str(svg_path), - ] - ) - except subprocess.SubprocessError as exc: - raise errors.SVGConversionError(svg_path, backend, str(exc)) from exc - else: - try: - with open(svg_path, "rb") as svg_file: - svg2pdf(file_obj=svg_file, write_to=str(pdf_path)) - except Exception as exc: - raise errors.SVGConversionError(svg_path, backend, str(exc)) from exc - - return pdf_path - except Exception as exc: - raise errors.SVGConversionError(svg_path, backend, str(exc)) from exc + if backend == "inkscape": + try: + _run_subprocess_logged( + [ + "inkscape", + f"--export-filename={pdf_path}", + str(svg_path), + ] + ) + except subprocess.SubprocessError as exc: + raise SVGConversionError(svg_path, backend, str(exc)) from exc + else: + if backend != "cairosvg": + logger.warning( + "Unknown svg2pdf backend: %s - falling back to cairosvg", + backend, + ) + try: + with open(svg_path, "rb") as svg_file: + svg2pdf(file_obj=svg_file, write_to=str(pdf_path)) + except Exception as exc: + raise SVGConversionError(svg_path, backend, str(exc)) from exc + + return pdf_path diff --git a/src/pdfbaker/render.py b/src/pdfbaker/render.py index e68b4d8..e707241 100644 --- a/src/pdfbaker/render.py +++ b/src/pdfbaker/render.py @@ -1,4 +1,4 @@ -"""Helper functions for rendering with Jinja""" +"""Classes and functions used for rendering with Jinja""" import base64 import re @@ -16,33 +16,6 @@ ] -def prepare_template_context( - config: dict[str], images_dir: Path | None = None -) -> dict[str]: - """Prepare config for template rendering by resolving styles and encoding images. - - Args: - config: Configuration with optional styles and images - images_dir: Directory containing images to encode - """ - context = config.copy() - - # Resolve style references to actual theme colors - if "style" in context and "theme" in context: - style = context["style"] - theme = context["theme"] - resolved_style: StyleDict = {} - for key, value in style.items(): - resolved_style[key] = theme[value] - context["style"] = resolved_style - - # Process image references - if context.get("images") is not None: - context["images"] = encode_images(context["images"], images_dir) - - return context - - class HighlightingTemplate(jinja2.Template): # pylint: disable=too-few-public-methods """A Jinja template that automatically applies highlighting to text. @@ -74,12 +47,40 @@ def create_env(templates_dir: Path | None = None) -> jinja2.Environment: env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(templates_dir)), autoescape=jinja2.select_autoescape(), + # FIXME: extensions configurable extensions=["jinja2.ext.do"], ) env.template_class = HighlightingTemplate return env +def prepare_template_context( + config: dict[str], images_dir: Path | None = None +) -> dict[str]: + """Prepare config for template rendering by resolving styles and encoding images. + + Args: + config: Configuration with optional styles and images + images_dir: Directory containing images to encode + """ + context = config.copy() + + # Resolve style references to actual theme colors + if "style" in context and "theme" in context: + style = context["style"] + theme = context["theme"] + resolved_style: StyleDict = {} + for key, value in style.items(): + resolved_style[key] = theme[value] + context["style"] = resolved_style + + # Process image references + if context.get("images") is not None: + context["images"] = encode_images(context["images"], images_dir) + + return context + + def encode_image(filename: str, images_dir: Path) -> str: """Encode an image file to a base64 data URI.""" image_path = images_dir / filename diff --git a/src/pdfbaker/types.py b/src/pdfbaker/types.py index df3b8e9..752e38f 100644 --- a/src/pdfbaker/types.py +++ b/src/pdfbaker/types.py @@ -4,11 +4,12 @@ __all__ = [ "ImageSpec", + "PathSpec", "StyleDict", ] -class ImageDict(TypedDict): +class _ImageDict(TypedDict): """Image specification.""" name: str @@ -16,10 +17,20 @@ class ImageDict(TypedDict): data: NotRequired[str] -ImageSpec = str | ImageDict +ImageSpec = str | _ImageDict class StyleDict(TypedDict): """Style configuration.""" highlight_color: NotRequired[str] + + +class _PathSpecDict(TypedDict): + """File/Directory location in YAML config.""" + + path: NotRequired[str] + name: NotRequired[str] + + +PathSpec = str | _PathSpecDict diff --git a/tests/test_baker.py b/tests/test_baker.py new file mode 100644 index 0000000..a1cbeb3 --- /dev/null +++ b/tests/test_baker.py @@ -0,0 +1,101 @@ +"""Tests for the PDFBaker class and related functionality.""" + +import logging +import shutil +from pathlib import Path + +import pytest + +from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.errors import ConfigurationError +from pdfbaker.logging import TRACE + + +# PDFBakerOptions tests +def test_baker_options_defaults() -> None: + """Test PDFBakerOptions default values.""" + options = PDFBakerOptions() + assert not options.quiet + assert not options.verbose + assert not options.trace + assert not options.keep_build + assert options.default_config_overrides is None + + +def test_baker_options_logging_levels() -> None: + """Test different logging level configurations.""" + test_cases = [ + (PDFBakerOptions(quiet=True), logging.ERROR), + (PDFBakerOptions(verbose=True), logging.DEBUG), + (PDFBakerOptions(trace=True), TRACE), + (PDFBakerOptions(), logging.INFO), # default + ] + + examples_config = Path(__file__).parent.parent / "examples" / "examples.yaml" + for options, expected_level in test_cases: + PDFBaker(examples_config, options=options) + assert logging.getLogger().level == expected_level + + +def test_baker_options_default_config_overrides(tmp_path: Path) -> None: + """Test PDFBakerOptions with default_config_overrides.""" + # Create a minimal valid config + config_file = tmp_path / "test.yaml" + config_file.write_text("documents: [test]") + + custom_dir = tmp_path / "custom" + options = PDFBakerOptions( + default_config_overrides={ + "directories": { + "documents": str(custom_dir), + } + } + ) + + baker = PDFBaker(config_file, options=options) + assert str(baker.config["directories"]["documents"]) == str(custom_dir) + + +# PDFBaker initialization tests +def test_baker_init_invalid_config(tmp_path: Path) -> None: + """Test PDFBaker initialization with invalid configuration.""" + # Create an invalid config file (missing 'documents' key) + config_file = tmp_path / "invalid.yaml" + config_file.write_text("title: test") + + with pytest.raises(ConfigurationError, match=".*documents.*missing.*"): + PDFBaker(config_file) + + +# PDFBaker functionality tests +def test_baker_examples() -> None: + """Test baking all examples.""" + test_dir = Path(__file__).parent + examples_config = test_dir.parent / "examples" / "examples.yaml" + + # Create test output directories + build_dir = test_dir / "build" + dist_dir = test_dir / "dist" + build_dir.mkdir(exist_ok=True) + dist_dir.mkdir(exist_ok=True) + + options = PDFBakerOptions( + quiet=True, + keep_build=True, + default_config_overrides={ + "directories": { + "build": str(build_dir), + "dist": str(dist_dir), + } + }, + ) + + try: + baker = PDFBaker(examples_config, options=options) + baker.bake() + finally: + # Clean up test directories + if build_dir.exists(): + shutil.rmtree(build_dir) + if dist_dir.exists(): + shutil.rmtree(dist_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..60611f9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,77 @@ +"""Tests for CLI functionality.""" + +from pathlib import Path + +from click.testing import CliRunner + +from pdfbaker.__main__ import cli + + +def test_cli_version() -> None: + """Test CLI version command.""" + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "version" in result.output.lower() + + +def test_cli_help() -> None: + """Test CLI help command.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +def test_cli_bake_help() -> None: + """Test CLI bake help command.""" + runner = CliRunner() + result = runner.invoke(cli, ["bake", "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +def test_cli_bake_missing_config(tmp_path: Path) -> None: + """Test CLI bake command with missing config file.""" + runner = CliRunner() + result = runner.invoke(cli, ["bake", str(tmp_path / "missing.yaml")]) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_cli_bake_invalid_config(tmp_path: Path) -> None: + """Test CLI bake command with invalid config file.""" + config_file = tmp_path / "invalid.yaml" + config_file.write_text("invalid: yaml: content") + + runner = CliRunner() + result = runner.invoke(cli, ["bake", str(config_file)]) + assert result.exit_code == 1 + assert "Invalid YAML" in result.output + + +def test_cli_bake_quiet_mode(tmp_path: Path) -> None: + """Test CLI bake command in quiet mode.""" + # Test case 1: Failure - should show errors + failing_config = tmp_path / "failing.yaml" + failing_config.write_text(""" +pages: [page1.yaml] +directories: + build: build +""") + + runner = CliRunner() + result = runner.invoke(cli, ["bake", "--quiet", str(failing_config)]) + assert result.exit_code == 1 # Will fail because page1.yaml doesn't exist + assert "error" in result.output.lower() # Should show error message + assert "info" not in result.output.lower() # Should not show info messages + + # Test case 2: Success - should be completely quiet + success_config = tmp_path / "success.yaml" + success_config.write_text(""" +documents: [] # Empty list of documents is valid +""") + + result = runner.invoke(cli, ["bake", "--quiet", str(success_config)]) + assert result.exit_code == 0 + assert not result.output # Should be completely quiet on success diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index 13be0f2..0000000 --- a/tests/test_common.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests for common functionality.""" - -import pytest - -from pdfbaker.common import deep_merge, resolve_config - - -def test_deep_merge_basic(): - """Test basic dictionary merging.""" - base = { - "title": "Document", - "style": { - "font": "Helvetica", - "size": 12, - }, - } - update = { - "title": "Updated Document", - "style": { - "size": 14, - }, - "author": "John Doe", - } - expected = { - "title": "Updated Document", - "style": { - "font": "Helvetica", - "size": 14, - }, - "author": "John Doe", - } - assert deep_merge(base, update) == expected - - -def test_deep_merge_nested(): - """Test merging of nested dictionaries.""" - base = { - "document": { - "title": "Main Document", - "meta": { - "author": "Jane Smith", - "date": "2024-01-01", - }, - }, - "style": { - "font": "Arial", - "colors": { - "text": "black", - "background": "white", - }, - }, - } - update = { - "document": { - "meta": { - "date": "2024-04-01", - "version": "1.0", - }, - }, - "style": { - "colors": { - "text": "navy", - }, - }, - } - expected = { - "document": { - "title": "Main Document", - "meta": { - "author": "Jane Smith", - "date": "2024-04-01", - "version": "1.0", - }, - }, - "style": { - "font": "Arial", - "colors": { - "text": "navy", - "background": "white", - }, - }, - } - assert deep_merge(base, update) == expected - - -def test_deep_merge_empty(): - """Test merging with empty dictionaries.""" - base = { - "title": "Document", - "style": { - "font": "Helvetica", - }, - } - update = {} - # Merging empty into non-empty should return non-empty - assert deep_merge(base, update) == base - # Merging non-empty into empty should return non-empty - # pylint: disable=arguments-out-of-order - assert deep_merge(update, base) == base - - -def test_resolve_config_basic(): - """Test basic template resolution.""" - config = { - "name": "test", - "title": "{{ name }} document", - } - expected = { - "name": "test", - "title": "test document", - } - assert resolve_config(config) == expected - - -def test_resolve_config_multiple_passes(): - """Test config that needs multiple passes to resolve.""" - config = { - "name": "test", - "title": "{{ name }} document", - "filename": "{{ title }}.pdf", - } - expected = { - "name": "test", - "title": "test document", - "filename": "test document.pdf", - } - assert resolve_config(config) == expected - - -def test_resolve_config_diamond_reference(): - """Test diamond-shaped reference pattern.""" - config = { - "name": "test", - "title": "{{ name }} document", - "subtitle": "{{ name }} details", - "header": "{{ title }} - {{ subtitle }}", - } - expected = { - "name": "test", - "title": "test document", - "subtitle": "test details", - "header": "test document - test details", - } - assert resolve_config(config) == expected - - -def test_resolve_config_circular(): - """Test circular reference handling.""" - config = { - "a": "{{ b }}", - "b": "{{ c }}", - "c": "{{ a }}", - } - with pytest.raises(ValueError, match="Maximum number of iterations reached"): - resolve_config(config) - - -def test_resolve_config_nested(): - """Test resolution in nested structures.""" - config = { - "name": "test", - "sections": [ - {"title": "{{ name }} section 1"}, - {"title": "{{ name }} section 2"}, - ], - "meta": { - "title": "{{ name }} document", - "description": "About {{ meta.title }}", - }, - } - expected = { - "name": "test", - "sections": [ - {"title": "test section 1"}, - {"title": "test section 2"}, - ], - "meta": { - "title": "test document", - "description": "About test document", - }, - } - assert resolve_config(config) == expected diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4fb8502 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,219 @@ +"""Tests for configuration functionality.""" + +from pathlib import Path + +import pytest +import yaml + +from pdfbaker.config import PDFBakerConfiguration, deep_merge, render_config +from pdfbaker.errors import ConfigurationError + + +# Dictionary merging tests +def test_deep_merge_basic() -> None: + """Test basic dictionary merging.""" + base = { + "title": "Document", + "style": { + "font": "Helvetica", + "size": 12, + }, + } + update = { + "title": "Updated Document", + "style": { + "size": 14, + }, + "author": "John Doe", + } + expected = { + "title": "Updated Document", + "style": { + "font": "Helvetica", + "size": 14, + }, + "author": "John Doe", + } + assert deep_merge(base, update) == expected + + +def test_deep_merge_nested() -> None: + """Test nested dictionary merging.""" + base = { + "document": { + "title": "Main Document", + "meta": { + "author": "Jane Smith", + "date": "2024-01-01", + }, + }, + "style": { + "font": "Arial", + "colors": { + "text": "black", + "background": "white", + }, + }, + } + update = { + "document": { + "meta": { + "date": "2024-04-01", + "version": "1.0", + }, + }, + "style": { + "colors": { + "text": "navy", + }, + }, + } + expected = { + "document": { + "title": "Main Document", + "meta": { + "author": "Jane Smith", + "date": "2024-04-01", + "version": "1.0", + }, + }, + "style": { + "font": "Arial", + "colors": { + "text": "navy", + "background": "white", + }, + }, + } + assert deep_merge(base, update) == expected + + +def test_deep_merge_empty() -> None: + """Test merging with empty dictionaries.""" + base = { + "title": "Document", + "style": { + "font": "Helvetica", + }, + } + update = {} + # Merging empty into non-empty should return non-empty + assert deep_merge(base, update) == base + # Merging non-empty into empty should return non-empty + # pylint: disable=arguments-out-of-order + assert deep_merge(update, base) == base + + +# Configuration initialization tests +def test_configuration_init_with_dict(tmp_path: Path) -> None: + """Test initializing Configuration with a dictionary.""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml.dump({"title": "Document"})) + + config = PDFBakerConfiguration({}, config_file) + assert config["title"] == "Document" + + +def test_configuration_init_with_path(tmp_path: Path) -> None: + """Test initializing Configuration with a file path.""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml.dump({"title": "Document"})) + + config = PDFBakerConfiguration({}, config_file) + assert config["title"] == "Document" + assert config["directories"]["config"] == tmp_path + + +def test_configuration_init_with_directory(tmp_path: Path) -> None: + """Test initializing Configuration with custom directory.""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml.dump({"title": "Document"})) + + config = PDFBakerConfiguration({}, config_file) + assert config["title"] == "Document" + assert config["directories"]["config"] == tmp_path + + +def test_configuration_init_invalid_yaml(tmp_path: Path) -> None: + """Test configuration with invalid YAML.""" + config_file = tmp_path / "invalid.yaml" + config_file.write_text("invalid: [yaml: content") + + with pytest.raises(ConfigurationError, match="Failed to load config file"): + PDFBakerConfiguration({}, config_file) + + +# Path resolution tests +def test_configuration_resolve_path(tmp_path: Path) -> None: + """Test path resolution.""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml.dump({"template": "test.yaml"})) + + config = PDFBakerConfiguration({}, config_file) + + # Test relative path + assert config.resolve_path("test.yaml") == tmp_path / "test.yaml" + + # Test absolute path + assert config.resolve_path({"path": "/absolute/path.yaml"}) == Path( + "/absolute/path.yaml" + ) + + # Test named path + assert config.resolve_path({"name": "test.yaml"}) == tmp_path / "test.yaml" + + +def test_configuration_resolve_path_invalid(tmp_path: Path) -> None: + """Test invalid path specification.""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml.dump({})) + + config = PDFBakerConfiguration({}, config_file) + with pytest.raises(ConfigurationError, match="Invalid path specification"): + config.resolve_path({}) + + +# Configuration rendering tests +def test_render_config_basic() -> None: + """Test basic template rendering in configuration.""" + config = { + "name": "test", + "title": "{{ name }} document", + "nested": { + "value": "{{ title }}", + }, + } + + rendered = render_config(config) + assert rendered["title"] == "test document" + assert rendered["nested"]["value"] == "test document" + + +def test_render_config_circular() -> None: + """Test detection of circular references in config rendering.""" + config = { + "a": "{{ b }}", + "b": "{{ a }}", + } + + with pytest.raises(ConfigurationError, match="(?i).*circular.*"): + render_config(config) + + +# Utility method tests +def test_configuration_pretty(tmp_path: Path) -> None: + """Test configuration pretty printing.""" + config_file = tmp_path / "test.yaml" + config_file.write_text( + yaml.dump( + { + "title": "Test", + "content": "A" * 100, # Long string that should be truncated + } + ) + ) + + config = PDFBakerConfiguration({}, config_file) + pretty = config.pretty(max_chars=20) + assert "…" in pretty # Should show truncation + assert "Test" in pretty diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..bb16f3e --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,215 @@ +"""Tests for document processing functionality.""" + +import logging +import shutil +from pathlib import Path + +import pytest + +from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.document import PDFBakerDocument +from pdfbaker.errors import ConfigurationError + + +@pytest.fixture(name="baker_config") +def fixture_baker_config(tmp_path: Path) -> Path: + """Create a baker configuration file.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" + documents: [test_doc] + """) + return config_file + + +@pytest.fixture(name="baker_options") +def fixture_baker_options(tmp_path: Path) -> PDFBakerOptions: + """Create baker options with test-specific build/dist directories.""" + return PDFBakerOptions( + default_config_overrides={ + "directories": { + "build": str(tmp_path / "build"), + "dist": str(tmp_path / "dist"), + } + } + ) + + +@pytest.fixture(name="doc_dir") +def fixture_doc_dir(tmp_path: Path) -> Path: + """Create a document directory with all necessary files.""" + doc_path = tmp_path / "test_doc" + doc_path.mkdir() + + # Create config file + config_file = doc_path / "config.yaml" + config_file.write_text(""" + pages: [page1.yaml] + directories: + build: build + """) + + # Create page config + pages_dir = doc_path / "pages" + pages_dir.mkdir() + page_file = pages_dir / "page1.yaml" + page_file.write_text("template: template.svg") + + # Create template + templates_dir = doc_path / "templates" + templates_dir.mkdir() + template_file = templates_dir / "template.svg" + template_file.write_text( + '' + ) + + yield doc_path + + # Cleanup + shutil.rmtree(doc_path, ignore_errors=True) + + +def test_document_init_with_dir( + baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path +) -> None: + """Test document initialization with directory.""" + baker = PDFBaker(config_file=baker_config, options=baker_options) + doc = PDFBakerDocument( + baker=baker, + base_config=baker.config, + config_path=doc_dir, # this will default to config.yaml in the directory + ) + assert doc.config.name == "test_doc" + assert len(doc.config.pages) > 0 + assert doc.config.pages[0].name == "page1.yaml" + + +def test_document_init_with_file( + tmp_path: Path, baker_config: Path, baker_options: PDFBakerOptions +) -> None: + """Test document initialization with config file.""" + # Create document config + config_file = tmp_path / "test_doc.yaml" + config_file.write_text(""" + pages: [page1.yaml] + directories: + build: build + """) + + # Create page config + pages_dir = tmp_path / "pages" + pages_dir.mkdir() + page_file = pages_dir / "page1.yaml" + page_file.write_text("template: template.svg") + + # Create template + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + template_file = templates_dir / "template.svg" + template_file.write_text( + '' + ) + + baker = PDFBaker(baker_config, options=baker_options) + doc = PDFBakerDocument(baker, baker.config, config_file) + assert doc.config.name == "test_doc" + assert doc.config["pages"] == ["page1.yaml"] + + +def test_document_init_missing_pages(tmp_path: Path, baker_config: Path) -> None: + """Test document initialization with missing pages key.""" + config_file = tmp_path / "test_doc.yaml" + config_file.write_text(""" + title: Test Document + directories: + build: build + """) + + baker = PDFBaker(baker_config) + with pytest.raises(ConfigurationError, match='missing key "pages"'): + PDFBakerDocument(baker, baker.config, config_file) + + +def test_document_custom_bake( + baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path +) -> None: + """Test document processing with custom bake module.""" + # Create custom bake module + bake_file = doc_dir / "bake.py" + bake_file.write_text(""" +def process_document(document): + return document.config.build_dir / "custom.pdf" +""") + + baker = PDFBaker(baker_config, options=baker_options) + doc = PDFBakerDocument(baker, baker.config, doc_dir) + assert doc.config.name == "test_doc" + assert doc.config["pages"] == ["page1.yaml"] + + +def test_document_custom_bake_error( + baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path +) -> None: + """Test document processing with invalid custom bake module.""" + # Create invalid bake module + bake_file = doc_dir / "bake.py" + bake_file.write_text("raise Exception('Test error')") + + baker = PDFBaker(baker_config, options=baker_options) + doc = PDFBakerDocument(baker, baker.config, doc_dir) + assert doc.config.name == "test_doc" + assert doc.config["pages"] == ["page1.yaml"] + + +def test_document_variants( + baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path +) -> None: + """Test document processing with variants.""" + # Update config file + config_file = doc_dir / "config.yaml" + config_file.write_text(""" + pages: [page1.yaml] + directories: + build: build + variants: + - name: variant1 + filename: variant1 + - name: variant2 + filename: variant2 + """) + + baker = PDFBaker(baker_config, options=baker_options) + doc = PDFBakerDocument(baker, baker.config, doc_dir) + assert doc.config.name == "test_doc" + assert doc.config["pages"] == ["page1.yaml"] + assert len(doc.config["variants"]) == 2 + + +def test_document_teardown( + baker_config: Path, + baker_options: PDFBakerOptions, + doc_dir: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test document teardown.""" + # Create build directory and some files + build_dir = doc_dir / "build" / "test_doc" + build_dir.mkdir(parents=True) + (build_dir / "file1.pdf").write_text("test") + (build_dir / "file2.pdf").write_text("test") + + # Set verbose mode to enable debug logging + # baker_options.verbose = True + baker = PDFBaker(baker_config, options=baker_options) + doc = PDFBakerDocument(baker, baker.config, doc_dir) + assert doc.config.name == "test_doc" + assert doc.config["pages"] == ["page1.yaml"] + + with caplog.at_level(logging.DEBUG): + # Manually reinstall caplog handler to the root logger + logging.getLogger().addHandler(caplog.handler) + doc.teardown() + + assert not build_dir.exists() + assert "Tearing down build directory" in caplog.text + assert "Removing files in build directory" in caplog.text + assert "Removing build directory" in caplog.text diff --git a/tests/test_pdf.py b/tests/test_pdf.py new file mode 100644 index 0000000..4c829be --- /dev/null +++ b/tests/test_pdf.py @@ -0,0 +1,238 @@ +"""Tests for PDF processing functionality.""" + +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdfbaker.pdf import ( + PDFCombineError, + PDFCompressionError, + SVGConversionError, + combine_pdfs, + compress_pdf, + convert_svg_to_pdf, +) + + +def test_combine_pdfs_empty_list(tmp_path: Path) -> None: + """Test combining empty list of PDFs.""" + output_file = tmp_path / "output.pdf" + with pytest.raises(PDFCombineError, match="No PDF files provided to combine"): + combine_pdfs([], output_file) + + +def test_combine_pdfs_single_file(tmp_path: Path) -> None: + """Test combining a single PDF file.""" + # Create a valid PDF file + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes( + b"%PDF-1.4\n" + b"1 0 obj\n" + b"<< /Type /Catalog /Pages 2 0 R >>\n" + b"endobj\n" + b"2 0 obj\n" + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n" + b"endobj\n" + b"3 0 obj\n" + b"<< /Type /Page /Parent 2 0 R /Resources <<>> /MediaBox [0 0 612 792] >>\n" + b"endobj\n" + b"xref\n" + b"0 4\n" + b"0000000000 65535 f\n" + b"0000000010 00000 n\n" + b"0000000056 00000 n\n" + b"0000000112 00000 n\n" + b"trailer\n" + b"<< /Size 4 /Root 1 0 R >>\n" + b"startxref\n" + b"164\n" + b"%%EOF\n" + ) + + output_file = tmp_path / "output.pdf" + combine_pdfs([pdf_file], output_file) + assert output_file.exists() + + +def test_combine_pdfs_multiple_files(tmp_path: Path) -> None: + """Test combining multiple PDF files.""" + # Create two valid PDF files + pdf1 = tmp_path / "test1.pdf" + pdf1.write_bytes( + b"%PDF-1.4\n" + b"1 0 obj\n" + b"<< /Type /Catalog /Pages 2 0 R >>\n" + b"endobj\n" + b"2 0 obj\n" + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n" + b"endobj\n" + b"3 0 obj\n" + b"<< /Type /Page /Parent 2 0 R /Resources <<>> /MediaBox [0 0 612 792] >>\n" + b"endobj\n" + b"xref\n" + b"0 4\n" + b"0000000000 65535 f\n" + b"0000000010 00000 n\n" + b"0000000056 00000 n\n" + b"0000000112 00000 n\n" + b"trailer\n" + b"<< /Size 4 /Root 1 0 R >>\n" + b"startxref\n" + b"164\n" + b"%%EOF\n" + ) + + pdf2 = tmp_path / "test2.pdf" + pdf2.write_bytes( + b"%PDF-1.4\n" + b"1 0 obj\n" + b"<< /Type /Catalog /Pages 2 0 R >>\n" + b"endobj\n" + b"2 0 obj\n" + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n" + b"endobj\n" + b"3 0 obj\n" + b"<< /Type /Page /Parent 2 0 R /Resources <<>> /MediaBox [0 0 612 792] >>\n" + b"endobj\n" + b"xref\n" + b"0 4\n" + b"0000000000 65535 f\n" + b"0000000010 00000 n\n" + b"0000000056 00000 n\n" + b"0000000112 00000 n\n" + b"trailer\n" + b"<< /Size 4 /Root 1 0 R >>\n" + b"startxref\n" + b"164\n" + b"%%EOF\n" + ) + + output_file = tmp_path / "output.pdf" + combine_pdfs([pdf1, pdf2], output_file) + assert output_file.exists() + + +def test_combine_pdfs_broken_annotations( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test combining PDFs with broken annotations.""" + # Create PDF with annotation missing a required /Subtype field + # This will trigger the specific KeyError("'/Subtype'") exception + pdf_file = tmp_path / "test.pdf" + + # The key part is creating an annotation without a Subtype + pdf_file.write_bytes( + b"%PDF-1.4\n" + b"1 0 obj\n" + b"<< /Type /Catalog /Pages 2 0 R >>\n" + b"endobj\n" + b"2 0 obj\n" + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n" + b"endobj\n" + b"3 0 obj\n" + b"<< /Type /Page /Parent 2 0 R /Resources <<>> /MediaBox [0 0 612 792] " + b"/Annots [4 0 R]>>\n" + b"endobj\n" + b"4 0 obj\n" + b"<< /Type /Annot /Rect [0 0 0 0] >>\n" # Missing /Subtype field + b"endobj\n" + b"xref\n" + b"0 5\n" + b"0000000000 65535 f\n" + b"0000000010 00000 n\n" + b"0000000056 00000 n\n" + b"0000000112 00000 n\n" + b"0000000168 00000 n\n" + b"trailer\n" + b"<< /Size 5 /Root 1 0 R >>\n" + b"startxref\n" + b"220\n" + b"%%EOF\n" + ) + + output_file = tmp_path / "output.pdf" + with caplog.at_level(logging.WARNING): + # Manually reinstall caplog handler to the root logger + logging.getLogger().addHandler(caplog.handler) + combine_pdfs([pdf_file], output_file) + + # Check for our specific broken annotations warning + assert "Broken annotations in PDF" in caplog.text + assert "Falling back to page-by-page method" in caplog.text + assert output_file.exists() + + +def test_combine_pdfs_invalid_file(tmp_path: Path) -> None: + """Test combining invalid PDF files.""" + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes(b"Not a PDF file") + + output_file = tmp_path / "output.pdf" + with pytest.raises(PDFCombineError, match="Failed to combine PDFs"): + combine_pdfs([pdf_file], output_file) + + +def test_convert_svg_to_pdf_cairosvg(tmp_path: Path) -> None: + """Test SVG to PDF conversion using cairosvg.""" + # Create a valid SVG file + svg_file = tmp_path / "test.svg" + svg_file.write_text( + '' + '' + ) + + output_file = tmp_path / "output.pdf" + convert_svg_to_pdf(svg_file, output_file, backend="cairosvg") + assert output_file.exists() + + +def test_convert_svg_to_pdf_unknown_backend( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test SVG to PDF conversion with unknown backend.""" + svg_file = tmp_path / "test.svg" + svg_file.write_text( + '' + '' + ) + + output_file = tmp_path / "output.pdf" + with caplog.at_level(logging.WARNING): + # Manually reinstall caplog handler to the root logger + logging.getLogger().addHandler(caplog.handler) + convert_svg_to_pdf(svg_file, output_file, backend="unknown") + assert "Unknown svg2pdf backend: unknown - falling back to cairosvg" in caplog.text + + +def test_convert_svg_to_pdf_invalid_svg(tmp_path: Path) -> None: + """Test SVG to PDF conversion with invalid SVG.""" + # Create an invalid SVG file + svg_file = tmp_path / "test.svg" + svg_file.write_text("Not an SVG file") + + output_file = tmp_path / "output.pdf" + with pytest.raises(SVGConversionError) as exc_info: + convert_svg_to_pdf(svg_file, output_file) + + # Check for the specific error + assert "syntax error: line 1, column 0" in str(exc_info.value) + + +def test_compress_pdf_missing_ghostscript(tmp_path: Path) -> None: + """Test PDF compression with missing Ghostscript.""" + pdf_file = tmp_path / "test.pdf" + pdf_file.write_bytes(b"%PDF-1.4\n%%EOF\n") + + output_file = tmp_path / "output.pdf" + # Use a realistic error message that would occur when gs is not found + with patch( + "pdfbaker.pdf._run_subprocess_logged", + side_effect=FileNotFoundError("gs: command not found"), + ): + with pytest.raises(PDFCompressionError) as exc_info: + compress_pdf(pdf_file, output_file) + + # Check that our error message is included in the PDFCompressionError + assert "Ghostscript not found" in str(exc_info.value) diff --git a/tests/test_render.py b/tests/test_render.py index 6a5e9c2..184692a 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,15 +1,83 @@ """Tests for rendering functionality.""" import base64 +from pathlib import Path +import jinja2 import pytest -from pdfbaker.render import encode_image +from pdfbaker.render import ( + HighlightingTemplate, + create_env, + encode_image, + encode_images, + prepare_template_context, +) -def test_encode_image(tmp_path): - """Test encoding an image to base64.""" - # Create a test image file +# Template environment tests +def test_create_env(tmp_path: Path) -> None: + """Test creating Jinja environment.""" + env = create_env(tmp_path) + assert isinstance(env, jinja2.Environment) + assert env.template_class == HighlightingTemplate + assert isinstance(env.loader, jinja2.FileSystemLoader) + + +def test_create_env_no_directory() -> None: + """Test create_env with no directory.""" + with pytest.raises(ValueError, match="templates_dir is required"): + create_env(None) + + +# Template rendering tests +def test_highlighting_template() -> None: + """Test highlighting template functionality.""" + template = HighlightingTemplate("test") + result = template.render(style={"highlight_color": "red"}) + assert result == 'test' + + +def test_highlighting_template_no_highlight() -> None: + """Test highlighting template with no highlight color.""" + template = HighlightingTemplate("test") + result = template.render() # No style provided + assert result == "test" + + +# Context preparation tests +def test_prepare_template_context_styles() -> None: + """Test style resolution in template context.""" + config = { + "style": {"color": "primary"}, + "theme": {"primary": "#ff0000"}, + } + context = prepare_template_context(config) + assert context["style"]["color"] == "#ff0000" + + +def test_prepare_template_context_images(tmp_path: Path) -> None: + """Test image processing in template context.""" + # Create test image + images_dir = tmp_path / "images" + images_dir.mkdir() + image_path = images_dir / "test.png" + image_path.write_bytes(b"fake image data") + + config = { + "images": [ + {"name": "test.png"}, + ] + } + context = prepare_template_context(config, images_dir) + assert context["images"][0]["type"] == "default" # Default type added + assert context["images"][0]["data"].startswith("data:image/png;base64,") + + +# Image encoding tests +def test_encode_image(tmp_path: Path) -> None: + """Test encoding a single image to base64.""" + # Create test image file image_path = tmp_path / "test.png" image_path.write_bytes(b"fake image data") @@ -23,7 +91,33 @@ def test_encode_image(tmp_path): assert decoded_data == b"fake image data" -def test_encode_image_not_found(tmp_path): +def test_encode_image_not_found(tmp_path: Path) -> None: """Test handling of missing image file.""" with pytest.raises(FileNotFoundError): encode_image("missing.png", tmp_path) + + +def test_encode_images(tmp_path: Path) -> None: + """Test encoding multiple images.""" + # Create test images + images_dir = tmp_path / "images" + images_dir.mkdir() + (images_dir / "test1.png").write_bytes(b"image1") + (images_dir / "test2.png").write_bytes(b"image2") + + images = [ + {"name": "test1.png"}, + {"name": "test2.png", "type": "custom"}, + ] + + result = encode_images(images, images_dir) + assert len(result) == 2 + assert result[0]["type"] == "default" # Default type added + assert result[1]["type"] == "custom" # Existing type preserved + assert all(img["data"].startswith("data:image/png;base64,") for img in result) + + +def test_encode_images_no_directory() -> None: + """Test encode_images with no directory.""" + with pytest.raises(ValueError, match="images_dir is required"): + encode_images([{"name": "test.png"}], None)