From afa05503becbfaf8bbefd4955420d8c401d088e3 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 13 Apr 2025 15:53:46 +1200 Subject: [PATCH 01/36] Return don't just call process() --- examples/custom_processing/bake.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/custom_processing/bake.py b/examples/custom_processing/bake.py index 98dc66f..10a1cbe 100644 --- a/examples/custom_processing/bake.py +++ b/examples/custom_processing/bake.py @@ -32,6 +32,6 @@ def process_document(document: PDFBakerDocument) -> None: } # Process as usual - document.process() + return document.process() except Exception as exc: raise PDFBakeError(f"Failed to process XKCD example: {exc}") from exc From d6382c0870965a3a0ec701efdd4e7e6f4d87587a Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 13 Apr 2025 15:54:23 +1200 Subject: [PATCH 02/36] Simplify docs for custom_processing --- docs/custom_processing.md | 82 +++++---------------------------------- 1 file changed, 9 insertions(+), 73 deletions(-) diff --git a/docs/custom_processing.md b/docs/custom_processing.md index 6665ce8..48f5c58 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 + return document.process() ``` -## Document Object +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. -The `document` parameter provides access to: +See `examples/custom_processing/bake.py` for a simple example of how to do this. -- 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() - - # Update document content - document.config['content'].update({ - 'latest_data': data, - 'generated_at': datetime.now().isoformat() - }) - - # Process as usual - document.process() -``` +If you need to fully customise the processing, make sure that your function returns a +list of Path objects (the PDF files that were created) as that is the expected type of +return value for logging. From 3dcafe3a5af72e99252104f4e507bdf33b992cf1 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 15 Apr 2025 23:45:06 +1200 Subject: [PATCH 03/36] Standardise YAML file extension from .yml to .yaml --- .../{pre-commit.yml => pre-commit.yaml} | 0 docs/configuration.md | 16 ++++++++-------- docs/custom_processing.md | 6 +++--- docs/overview.md | 17 +++++++++-------- docs/variants.md | 2 +- .../{config.yml => config.yaml} | 0 .../pages/{main.yml => main.yaml} | 0 examples/{examples.yml => examples.yaml} | 1 + examples/minimal/{config.yml => config.yaml} | 1 + examples/minimal/pages/{main.yml => main.yaml} | 0 examples/regular/{config.yml => config.yaml} | 0 .../pages/{benefits.yml => benefits.yaml} | 0 .../pages/{features.yml => features.yaml} | 0 .../regular/pages/{intro.yml => intro.yaml} | 0 examples/variants/{config.yml => config.yaml} | 0 examples/variants/pages/{main.yml => main.yaml} | 0 16 files changed, 23 insertions(+), 20 deletions(-) rename .github/workflows/{pre-commit.yml => pre-commit.yaml} (100%) rename examples/custom_processing/{config.yml => config.yaml} (100%) rename examples/custom_processing/pages/{main.yml => main.yaml} (100%) rename examples/{examples.yml => examples.yaml} (63%) rename examples/minimal/{config.yml => config.yaml} (74%) rename examples/minimal/pages/{main.yml => main.yaml} (100%) rename examples/regular/{config.yml => config.yaml} (100%) rename examples/regular/pages/{benefits.yml => benefits.yaml} (100%) rename examples/regular/pages/{features.yml => features.yaml} (100%) rename examples/regular/pages/{intro.yml => intro.yaml} (100%) rename examples/variants/{config.yml => config.yaml} (100%) rename examples/variants/pages/{main.yml => main.yaml} (100%) 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/docs/configuration.md b/docs/configuration.md index 09509d9..1702b90 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,14 +4,14 @@ ``` 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/ @@ -21,7 +21,7 @@ project/ | 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 +33,7 @@ highlighted in the color specified by the `highlight_color` in your `style`. Example: ```yaml -# kiwipycon.yml +# kiwipycon.yaml documents: - prospectus @@ -74,13 +74,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 +105,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 48f5c58..265b66d 100644 --- a/docs/custom_processing.md +++ b/docs/custom_processing.md @@ -17,7 +17,7 @@ from pdfbaker.document import PDFBakerDocument def process_document(document: PDFBakerDocument) -> None: # Custom processing logic here - return document.process() + document.process() ``` You will usually just manipulate the data for your templates, and then call `.process()` @@ -27,5 +27,5 @@ the PDF as configured. See `examples/custom_processing/bake.py` for a simple example of how to do this. If you need to fully customise the processing, make sure that your function returns a -list of Path objects (the PDF files that were created) as that is the expected type of -return value for logging. +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..cbc7760 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -25,25 +25,26 @@ 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_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 100% rename from examples/regular/config.yml rename to examples/regular/config.yaml 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 From 9b252bf95faa17667413e6c5ce60a66620c16bb2 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 15 Apr 2025 23:52:39 +1200 Subject: [PATCH 04/36] Add Cursor/VSCode config --- .gitignore | 3 --- .vscode/launch.json | 22 +++++++++++++++++++ .vscode/tasks.json | 52 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json 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..756e623 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "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"], + "console": "integratedTerminal" + } + ] + } + \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..bb70aa6 --- /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" + } + ] +} From 93c59de6d7379182d30b3dd104e2e4cf9e818ec2 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 15 Apr 2025 23:53:47 +1200 Subject: [PATCH 05/36] WIP Refactor for clean structure to support custom locations --- src/pdfbaker/__init__.py | 3 +- src/pdfbaker/__main__.py | 29 ++- src/pdfbaker/baker.py | 204 ++++++++--------- src/pdfbaker/config.py | 125 +++++++++++ src/pdfbaker/document.py | 337 +++++++++++++---------------- src/pdfbaker/errors.py | 15 +- src/pdfbaker/page.py | 114 ++++++++++ src/pdfbaker/{common.py => pdf.py} | 79 ++----- src/pdfbaker/render.py | 57 ++--- src/pdfbaker/types.py | 15 +- 10 files changed, 569 insertions(+), 409 deletions(-) create mode 100644 src/pdfbaker/config.py create mode 100644 src/pdfbaker/page.py rename src/pdfbaker/{common.py => pdf.py} (70%) 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..65cfee8 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -8,9 +8,8 @@ from pdfbaker import __version__ from pdfbaker.baker import PDFBaker -from pdfbaker.errors import PDFBakeError +from pdfbaker.errors import PDFBakerError -logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -25,27 +24,27 @@ 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("--keep-build", is_flag=True, help="Keep build artifacts") @click.option( - "--debug", is_flag=True, help="Debug mode (implies --verbose, keeps build files)" + "--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)" ) -@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: +def bake( + config_file: Path, quiet: bool, verbose: 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) + baker = PDFBaker( + config_file, quiet=quiet, verbose=verbose, keep_build=keep_build + ) + baker.bake() return 0 - except PDFBakeError as exc: + except PDFBakerError as exc: logger.error(str(exc)) return 1 diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 932db4f..e7fb2f7 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -1,34 +1,75 @@ -"""Main PDF baker class.""" +"""PDFBaker class. + +Overall orchestration and logging. + +Is given a configuration file and sets up logging. +bake() delegates to its documents and reports back the end result. +""" import logging from pathlib import Path from typing import Any -import yaml - -from . import errors +from .config import PDFBakerConfiguration from .document import PDFBakerDocument -from .errors import PDFBakeError +from .errors import ConfigurationError __all__ = ["PDFBaker"] +DEFAULT_CONFIG = { + "documents_dir": ".", + "pages_dir": "pages", + "templates_dir": "templates", + "images_dir": "images", + "build_dir": "build", + "dist_dir": "dist", +} + + class PDFBaker: """Main class for PDF document generation.""" - def __init__(self, config_file: Path) -> None: + class Configuration(PDFBakerConfiguration): + """PDFBaker configuration.""" + + def __init__(self, base_config: dict[str, Any], config_file: Path) -> None: + """Initialize baker configuration (needs documents).""" + super().__init__(base_config, config_file) + if "documents" not in self: + raise ConfigurationError( + 'Key "documents" missing - is this the main configuration file?' + ) + self.documents = [ + self.resolve_path(doc_spec) for doc_spec in self["documents"] + ] + + def __init__( + self, + config_file: Path, + quiet: bool = False, + verbose: bool = False, + keep_build: bool = False, + ) -> None: """Initialize PDFBaker with config file path. Args: config_file: Path to config file, document directory is its parent """ + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") 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) + if quiet: + logging.getLogger().setLevel(logging.ERROR) + elif verbose: + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.INFO) + self.keep_build = keep_build + self.config = self.Configuration( + base_config=DEFAULT_CONFIG, + config_file=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) @@ -37,125 +78,68 @@ def info(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log an info message.""" self.logger.info(msg, *args, **kwargs) + def 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 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 warning(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a warning message.""" self.logger.warning(msg, *args, **kwargs) def error(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log an error message.""" - self.logger.error(msg, *args, **kwargs) + self.logger.error(f"**** {msg} ****", *args, **kwargs) def critical(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a critical message.""" self.logger.critical(msg, *args, **kwargs) - def bake(self, debug: bool = False) -> None: - """Generate PDFs from configuration. - - Args: - debug: If True, keep build files for debugging - """ - document_paths = self._get_document_paths(self.config.get("documents", [])) - pdfs_created: list[str] = [] + def bake(self) -> None: + """Generate PDFs from documents.""" + pdfs_created: list[Path] = [] failed_docs: list[tuple[str, str]] = [] - for doc_name, doc_path in document_paths.items(): + self.debug("Main configuration:") + self.debug(self.config.pprint()) + self.debug("Documents to process:") + self.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=doc_config, ) - doc.setup_directories() - pdf_file, error_message = doc.process_document() - if pdf_file is None: + pdf_files, error_message = doc.process_document() + if pdf_files is None: self.error( - "Failed to process document '%s': %s", doc_name, error_message + "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 not debug: - self._teardown_build_directories(list(document_paths.keys())) + if isinstance(pdf_files, Path): + pdf_files = [pdf_files] + pdfs_created.extend(pdf_files) + if not self.keep_build: + doc.teardown() - self.info("Done.") if pdfs_created: - self.info("PDF files created in %s", self.dist_dir.resolve()) + self.info("Created PDFs:") + for pdf in pdfs_created: + self.info(" %s", pdf) else: - self.warning("No PDF files 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. - - Args: - documents: List of document names or dicts with name/path, - or None if no documents specified + self.warning("No PDFs were created.") - 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(): - try: - self.build_dir.rmdir() - except OSError: - # Directory not empty - self.logger.warning( - "Build directory not empty, keeping %s", self.build_dir - ) + if failed_docs: + self.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.error(" %s: %s", doc_name, error) diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py new file mode 100644 index 0000000..0c6a681 --- /dev/null +++ b/src/pdfbaker/config.py @@ -0,0 +1,125 @@ +"""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 .types import PathSpec + +__all__ = ["PDFBakerConfiguration"] + +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 + + +def _truncate_strings(obj, max_length: int) -> Any: + """Recursively truncate strings in nested structures.""" + if isinstance(obj, str): + return obj if len(obj) <= max_length else obj[:max_length] + "…" + if isinstance(obj, dict): + return { + _truncate_strings(k, max_length): _truncate_strings(v, max_length) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_truncate_strings(item, max_length) for item in obj] + if isinstance(obj, tuple): + return tuple(_truncate_strings(item, max_length) for item in obj) + if isinstance(obj, set): + return {_truncate_strings(item, max_length) for item in obj} + return obj + + +class PDFBakerConfiguration(dict): + """Base class for handling config loading/merging/parsing.""" + + def __init__( + self, + base_config: dict[str, Any], + config: Path, + ) -> None: + """Initialize configuration from a file. + + Args: + base_config: Existing base configuration + config: Path to YAML file to merge with base_config + """ + self.directory = config.parent + super().__init__(deep_merge(base_config, self._load_config(config))) + + def _load_config(self, config_file: Path) -> dict[str, Any]: + """Load configuration from a file.""" + try: + with open(config_file, encoding="utf-8") as f: + return yaml.safe_load(f) + except Exception as exc: + raise ConfigurationError(f"Failed to load config file: {exc}") from exc + + 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.directory + 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 render(self) -> dict[str, Any]: + """Resolve all template strings in config using its own values. + + This allows the use of "{{ variant }}" in the "filename" etc. + + Returns: + Resolved configuration dictionary + + Raises: + ConfigurationError: If maximum number of iterations is reached + (circular references) + """ + max_iterations = 10 + config = self + 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 ConfigurationError( + "Maximum number of iterations reached. " + "Check for circular references in your configuration." + ) + + def pprint(self, max_string=60) -> str: + """Pretty print a configuration dictionary (for debugging).""" + truncated = _truncate_strings(self, max_string) + return pprint.pformat(truncated, indent=2) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index a05b831..2a49deb 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -1,4 +1,10 @@ -"""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 @@ -6,242 +12,189 @@ 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, ) 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' - ) - - # Resolve any templates in the config - self.config = resolve_config(self.config) - - 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" +from .page import PDFBakerPage +from .pdf import ( + combine_pdfs, + compress_pdf, +) - 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 +DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" - with open(svg_path, "w", encoding="utf-8") as f: - f.write(template.render(**template_context)) - - 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, - ) - raise +__all__ = ["PDFBakerDocument"] class PDFBakerDocument: """A document being processed.""" + class Configuration(PDFBakerConfiguration): + """PDFBaker document-specific configuration.""" + + def __init__( + self, + base_config: "PDFBakerConfiguration", # type: ignore # noqa: F821 + config: Path, + document: "PDFBakerDocument", + ) -> None: + """Initialize document configuration. + + Args: + base_config: The PDFBaker configuration to merge with + config_file: The document configuration (YAML file) + """ + if config.is_dir(): + self.name = config.name + config = config / DEFAULT_DOCUMENT_CONFIG_FILE + else: + self.name = config.stem + super().__init__(base_config, config) + self.document = document + if "pages" not in self: + raise ConfigurationError( + 'Document "{document.name}" is missing key "pages"' + ) + self.directory = config.parent + self.pages_dir = self.resolve_path(self["pages_dir"]) + self.pages = [] + for page_spec in self["pages"]: + page = self.resolve_path(page_spec, directory=self.pages_dir) + if not page.suffix: + page = page.with_suffix(".yaml") + self.pages.append(page) + self.build_dir = self.resolve_path(self["build_dir"]) + self.dist_dir = self.resolve_path(self["dist_dir"]) + self.document.baker.debug("Document config for %s:", self.name) + self.document.baker.debug(self.pprint()) + 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, + ): + """Initialize a document.""" 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 + self.config = self.Configuration(base_config, config, document=self) - 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) - - 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.baker.info_section( + 'Processing document "%s"...', self.config.directory.name + ) + + self.config.build_dir.mkdir(parents=True, exist_ok=True) + self.config.dist_dir.mkdir(parents=True, exist_ok=True) - # Try to load custom bake module - bake_path = self.doc_dir / "bake.py" + bake_path = self.config.directory / "bake.py" if bake_path.exists(): + # Custom (pre-)processing try: - self._process_with_custom_bake(bake_path) - return self.dist_dir / f"{self.config['filename']}.pdf", None - except PDFBakeError as exc: + return self._process_with_custom_bake(bake_path), None + except PDFBakerError as exc: return None, str(exc) else: + # Standard processing try: - self.process() - return self.dist_dir / f"{self.config['filename']}.pdf", None - except (PDFBakeError, jinja2.exceptions.TemplateError) as exc: + return self.process(), None + except (PDFBakerError, jinja2.exceptions.TemplateError) as exc: return None, str(exc) - def process(self) -> None: - """Process document using standard processing.""" + 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. + + FIXME: don't mix up who gets what - there's still a page config + PDFBaker and PDFBakerDocument load and merge their config + file upong initialization, but a PDFBakerPage is initialized with + an already merged config, so that we can provide different + configs for different variants. + """ doc_config = self.config.copy() if "variants" in self.config: # Multiple PDF documents + pdf_files = [] for variant in self.config["variants"]: - self.baker.info('Processing variant "%s"...', variant["name"]) + self.baker.info_subsection( + 'Processing variant "%s"...', variant["name"] + ) variant_config = deep_merge(doc_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 = deep_merge(variant_config, self.config) + page_pdfs = self._process_pages(variant_config) + pdf_files.append(self._combine_and_compress(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 + page_pdfs = self._process_pages(doc_config) + # doc_config = doc_config.render() + return self._combine_and_compress(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): + for page_num, page in enumerate(self.config.pages, start=1): + # FIXME: just call with config - already merged page = PDFBakerPage( document=self, - name=page_name, - number=page_num, - config=config, + page_number=page_num, + base_config=config, + config=page, ) 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 _combine_and_compress( + self, pdf_files: list[Path], doc_config: dict[str, Any] + ) -> Path: + """Combine PDF pages and optionally compress.""" 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): try: compress_pdf(combined_pdf, output_path) self.baker.info("PDF compressed successfully") @@ -253,3 +206,21 @@ def _finalize(self, pdf_files: list[Path], config: dict[str, Any]) -> None: os.rename(combined_pdf, output_path) else: os.rename(combined_pdf, output_path) + + self.baker.info("Created PDF: %s", output_path) + return output_path + + def teardown(self) -> None: + """Clean up build directory after successful processing.""" + if self.config.build_dir.exists(): + # Remove all files in the build directory + for file_path in self.config.build_dir.iterdir(): + if file_path.is_file(): + file_path.unlink() + + # Try to remove the build directory + try: + self.config.build_dir.rmdir() + except OSError: + # Directory not empty - this is expected if we have subdirectories + pass diff --git a/src/pdfbaker/errors.py b/src/pdfbaker/errors.py index a825078..09135df 100644 --- a/src/pdfbaker/errors.py +++ b/src/pdfbaker/errors.py @@ -3,26 +3,31 @@ from pathlib import Path __all__ = [ - "PDFBakeError", + "ConfigurationError", + "PDFBakerError", "PDFCombineError", "PDFCompressionError", "SVGConversionError", ] -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__( diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py new file mode 100644 index 0000000..cdd4b7b --- /dev/null +++ b/src/pdfbaker/page.py @@ -0,0 +1,114 @@ +"""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 + +from .config import PDFBakerConfiguration +from .errors import ConfigurationError, SVGConversionError +from .pdf import convert_svg_to_pdf +from .render import create_env, prepare_template_context + +__all__ = ["PDFBakerPage"] + + +class PDFBakerPage: + """A single page of a document.""" + + class Configuration(PDFBakerConfiguration): + """PDFBakerPage configuration.""" + def __init__( + self, + base_config: dict[str, Any], + config: Path, + page: "PDFBakerPage", + ) -> None: + """Initialize page configuration (needs a template).""" + self.page = page + # FIXME: config is usually pages/mypage.yaml + self.name = "TBC" + super().__init__(base_config, config) + self.page.document.baker.debug("Page config for %s:", self.name) + self.page.document.baker.debug(self.pprint()) + if "template" not in self: + raise ConfigurationError( + f'Page "{self.name}" in document ' + f'"{self.page.document.config.name}" has no template' + ) + self.templates_dir = self.resolve_path( + self["templates_dir"], + directory=self.page.document.config.directory, + ) + self.template = self.resolve_path( + self["template"], + directory=self.templates_dir, + ) + self.images_dir = self.resolve_path( + self["images_dir"], + directory=self.page.document.config.directory, + ) + self.build_dir = self.resolve_path(self["build_dir"]) + + def __init__( + self, + document: "PDFBakerDocument", # type: ignore # noqa: F821 + page_number: int, + base_config: dict[str, Any], + config: Path | dict[str, Any], + ) -> None: + """Initialize a page.""" + self.document = document + self.number = page_number + self.config = self.Configuration( + base_config=base_config, + config=config, + page=self, + ) + + def process(self) -> Path: + """Render SVG template and convert to PDF.""" + 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" + + jinja_env = create_env(self.config.template.parent) + template = jinja_env.get_template(self.config["template"]) + template_context = prepare_template_context( + self.config, + self.config.images_dir, + ) + + try: + with open(output_svg, "w", encoding="utf-8") as f: + f.write(template.render(**template_context)) + except TemplateError as exc: + self.document.baker.error( + "Failed to render page %d (%s): %s", + self.number, + self.config.name, + exc, + ) + raise + + 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.document.baker.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 70% rename from src/pdfbaker/common.py rename to src/pdfbaker/pdf.py index b008219..0bcdacb 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: @@ -200,16 +153,14 @@ def compress_pdf( ) return output_pdf 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: @@ -235,14 +186,14 @@ def convert_svg_to_pdf( ] ) except subprocess.SubprocessError as exc: - raise errors.SVGConversionError(svg_path, backend, str(exc)) from exc + raise 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 + raise 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 + raise SVGConversionError(svg_path, backend, str(exc)) from exc 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 From 821cefb183a2ed2b6cabced6e8d0590f7d7d8127 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 15 Apr 2025 23:54:59 +1200 Subject: [PATCH 06/36] Update tests - test build of all examples --- tests/examples.yaml | 6 ++ tests/test_baker.py | 32 +++++++ tests/{test_common.py => test_config.py} | 113 ++++++++--------------- 3 files changed, 75 insertions(+), 76 deletions(-) create mode 100644 tests/examples.yaml create mode 100644 tests/test_baker.py rename tests/{test_common.py => test_config.py} (50%) diff --git a/tests/examples.yaml b/tests/examples.yaml new file mode 100644 index 0000000..d182401 --- /dev/null +++ b/tests/examples.yaml @@ -0,0 +1,6 @@ +documents: + - minimal + - regular + - variants + - "./custom_locations/your_directory" + - custom_processing diff --git a/tests/test_baker.py b/tests/test_baker.py new file mode 100644 index 0000000..01de22f --- /dev/null +++ b/tests/test_baker.py @@ -0,0 +1,32 @@ +import shutil +from pathlib import Path +from pdfbaker.baker import PDFBaker + + +def test_examples() -> None: + """Test all examples.""" + examples_dir = Path(__file__).parent.parent / "examples" + test_dir = Path(__file__).parent + + # Create build and dist directories + build_dir = test_dir / "build" + dist_dir = test_dir / "dist" + build_dir.mkdir(exist_ok=True) + dist_dir.mkdir(exist_ok=True) + + # Copy and modify examples config + config = examples_dir / "examples.yaml" + test_config = test_dir / "examples.yaml" + shutil.copy(config, test_config) + + # Modify paths in config + with open(test_config) as f: + content = f.read() + content = content.replace("build_dir: build", f"build_dir: {build_dir}") + content = content.replace("dist_dir: dist", f"dist_dir: {dist_dir}") + with open(test_config, "w") as f: + f.write(content) + + # Run baker + baker = PDFBaker(test_config, quiet=True, keep_build=True) + baker.bake() diff --git a/tests/test_common.py b/tests/test_config.py similarity index 50% rename from tests/test_common.py rename to tests/test_config.py index 13be0f2..5aa7bc8 100644 --- a/tests/test_common.py +++ b/tests/test_config.py @@ -1,8 +1,10 @@ """Tests for common functionality.""" +from pathlib import Path + import pytest -from pdfbaker.common import deep_merge, resolve_config +from pdfbaker.config import PDFBakerConfiguration, deep_merge def test_deep_merge_basic(): @@ -99,84 +101,43 @@ def test_deep_merge_empty(): 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_configuration_init_with_dict(): + """Test initializing Configuration with a dictionary.""" + config = PDFBakerConfiguration({}, {"title": "Document"}) + assert config["title"] == "Document" -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_configuration_init_with_path(tmp_path): + """Test initializing Configuration with a file path.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("title: Document") + config = PDFBakerConfiguration({}, config_file) + assert config["title"] == "Document" + assert config.directory == tmp_path -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_configuration_init_with_directory(tmp_path): + """Test initializing Configuration with custom directory.""" + config = PDFBakerConfiguration({}, {"title": "Document"}, directory=tmp_path) + assert config["title"] == "Document" + assert config.directory == tmp_path -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 + +def test_configuration_resolve_path(): + """Test path resolution.""" + config = PDFBakerConfiguration( + {}, {"template": "test.yaml"}, directory=Path("/base") + ) + assert config.resolve_path("test.yaml") == Path("/base/test.yaml") + assert config.resolve_path({"path": "/absolute/path.yaml"}) == Path( + "/absolute/path.yaml" + ) + assert config.resolve_path({"name": "test.yaml"}) == Path("/base/test.yaml") + + +def test_configuration_resolve_path_invalid(): + """Test invalid path specification.""" + config = PDFBakerConfiguration({}, {}) + with pytest.raises(ValueError, match="Invalid path specification"): + config.resolve_path({}) From 5cfb964889d4c158e1a8bab6b5109f7b190f502f Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 15 Apr 2025 23:55:32 +1200 Subject: [PATCH 07/36] Update examples - add example of custom locations --- README.md | 12 ++++++++++-- .../other_pages/custom_page.yaml | 3 +++ .../other_templates/custom_page.svg.j2 | 5 +++++ .../custom_locations/your_directory/config.yaml | 9 +++++++++ .../your_directory/pages/content.yaml | 2 ++ .../your_directory/pages/intro.yaml | 4 ++++ .../your_directory/pages/standard_page.yaml | 3 +++ .../templates/standard_page.svg.j2 | 5 +++++ .../your_directory/your_images/pythonnz.png | Bin 0 -> 24069 bytes examples/custom_processing/bake.py | 9 ++++----- 10 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 examples/custom_locations/other_pages/custom_page.yaml create mode 100644 examples/custom_locations/other_templates/custom_page.svg.j2 create mode 100644 examples/custom_locations/your_directory/config.yaml create mode 100644 examples/custom_locations/your_directory/pages/content.yaml create mode 100644 examples/custom_locations/your_directory/pages/intro.yaml create mode 100644 examples/custom_locations/your_directory/pages/standard_page.yaml create mode 100644 examples/custom_locations/your_directory/templates/standard_page.svg.j2 create mode 100644 examples/custom_locations/your_directory/your_images/pythonnz.png diff --git a/README.md b/README.md index 518e7ff..db94d3f 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,18 @@ kept if you specify `--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 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..52a2173 --- /dev/null +++ b/examples/custom_locations/other_pages/custom_page.yaml @@ -0,0 +1,3 @@ +title: "Custom Location Example" +description: "This page uses custom directory structure" +template: "../other_templates/custom_page.svg.j2" \ No newline at end of file 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..21772b8 --- /dev/null +++ b/examples/custom_locations/other_templates/custom_page.svg.j2 @@ -0,0 +1,5 @@ + + + {{ title }} + {{ description }} + \ No newline at end of file diff --git a/examples/custom_locations/your_directory/config.yaml b/examples/custom_locations/your_directory/config.yaml new file mode 100644 index 0000000..fbc1815 --- /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 + - "../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..fafcf19 --- /dev/null +++ b/examples/custom_locations/your_directory/pages/content.yaml @@ -0,0 +1,2 @@ +title: "Content Page" +description: "This document is located in a nested directory structure, demonstrating how pdfbaker can handle documents located anywhere in your filesystem." \ No newline at end of file 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..a091b3d --- /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) \ No newline at end of file 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..216d762 --- /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" \ No newline at end of file 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..9c39031 --- /dev/null +++ b/examples/custom_locations/your_directory/templates/standard_page.svg.j2 @@ -0,0 +1,5 @@ + + + {{ title }} + {{ subtitle }} + \ No newline at end of file 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 0000000000000000000000000000000000000000..8b67974ce6370a509a454ef37c01dd10c4a3cfc5 GIT binary patch literal 24069 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 literal 0 HcmV?d00001 diff --git a/examples/custom_processing/bake.py b/examples/custom_processing/bake.py index 10a1cbe..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 - return 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() From eba6a3ba6837d3c941cd1a4f8e0e2cafbfc3a6c3 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Wed, 16 Apr 2025 00:04:55 +1200 Subject: [PATCH 08/36] Linting --- .vscode/launch.json | 41 ++++--- .vscode/tasks.json | 100 +++++++++--------- .../other_pages/custom_page.yaml | 2 +- .../other_templates/custom_page.svg.j2 | 2 +- .../your_directory/config.yaml | 2 +- .../your_directory/pages/content.yaml | 4 +- .../your_directory/pages/intro.yaml | 2 +- .../your_directory/pages/standard_page.yaml | 2 +- .../templates/standard_page.svg.j2 | 2 +- src/pdfbaker/page.py | 12 ++- tests/test_baker.py | 17 +-- tests/test_config.py | 9 +- 12 files changed, 101 insertions(+), 94 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 756e623..7e1ec03 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,22 +1,21 @@ { - "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"], - "console": "integratedTerminal" - } - ] - } - \ No newline at end of file + "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"], + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bb70aa6..4094739 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,52 +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" - } - ] + "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/examples/custom_locations/other_pages/custom_page.yaml b/examples/custom_locations/other_pages/custom_page.yaml index 52a2173..9eac0b9 100644 --- a/examples/custom_locations/other_pages/custom_page.yaml +++ b/examples/custom_locations/other_pages/custom_page.yaml @@ -1,3 +1,3 @@ title: "Custom Location Example" description: "This page uses custom directory structure" -template: "../other_templates/custom_page.svg.j2" \ No newline at end of file +template: "../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 index 21772b8..83bac48 100644 --- a/examples/custom_locations/other_templates/custom_page.svg.j2 +++ b/examples/custom_locations/other_templates/custom_page.svg.j2 @@ -2,4 +2,4 @@ {{ title }} {{ description }} - \ No newline at end of file + diff --git a/examples/custom_locations/your_directory/config.yaml b/examples/custom_locations/your_directory/config.yaml index fbc1815..588698e 100644 --- a/examples/custom_locations/your_directory/config.yaml +++ b/examples/custom_locations/your_directory/config.yaml @@ -6,4 +6,4 @@ pages: # Path notation - uses custom location - "../other_pages/custom_page.yaml" # Custom images directory name and location -images_dir: "../your_images" +images_dir: "../your_images" diff --git a/examples/custom_locations/your_directory/pages/content.yaml b/examples/custom_locations/your_directory/pages/content.yaml index fafcf19..ca92c23 100644 --- a/examples/custom_locations/your_directory/pages/content.yaml +++ b/examples/custom_locations/your_directory/pages/content.yaml @@ -1,2 +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." \ No newline at end of file +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 index a091b3d..4d7bdf5 100644 --- a/examples/custom_locations/your_directory/pages/intro.yaml +++ b/examples/custom_locations/your_directory/pages/intro.yaml @@ -1,4 +1,4 @@ title: "Custom Locations Example" subtitle: "Mixing conventional and custom locations" template: "templates/intro.svg.j2" -# Uses conventional location (templates/intro.svg.j2) \ No newline at end of file +# 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 index 216d762..9c3cb7c 100644 --- a/examples/custom_locations/your_directory/pages/standard_page.yaml +++ b/examples/custom_locations/your_directory/pages/standard_page.yaml @@ -1,3 +1,3 @@ title: "Standard Location Example" subtitle: "This page uses conventional directory structure" -template: "standard_page.svg.j2" \ No newline at end of file +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 index 9c39031..603c847 100644 --- a/examples/custom_locations/your_directory/templates/standard_page.svg.j2 +++ b/examples/custom_locations/your_directory/templates/standard_page.svg.j2 @@ -2,4 +2,4 @@ {{ title }} {{ subtitle }} - \ No newline at end of file + diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index cdd4b7b..26fa7db 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -19,17 +19,19 @@ __all__ = ["PDFBakerPage"] +# pylint: disable=too-few-public-methods class PDFBakerPage: """A single page of a document.""" class Configuration(PDFBakerConfiguration): """PDFBakerPage configuration.""" + def __init__( - self, - base_config: dict[str, Any], - config: Path, - page: "PDFBakerPage", - ) -> None: + self, + base_config: dict[str, Any], + config: Path, + page: "PDFBakerPage", + ) -> None: """Initialize page configuration (needs a template).""" self.page = page # FIXME: config is usually pages/mypage.yaml diff --git a/tests/test_baker.py b/tests/test_baker.py index 01de22f..941610a 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -1,5 +1,8 @@ +"""Tests for the PDFBaker class and related functionality.""" + import shutil from pathlib import Path + from pdfbaker.baker import PDFBaker @@ -7,26 +10,26 @@ def test_examples() -> None: """Test all examples.""" examples_dir = Path(__file__).parent.parent / "examples" test_dir = Path(__file__).parent - + # Create build and dist directories build_dir = test_dir / "build" dist_dir = test_dir / "dist" build_dir.mkdir(exist_ok=True) dist_dir.mkdir(exist_ok=True) - + # Copy and modify examples config config = examples_dir / "examples.yaml" test_config = test_dir / "examples.yaml" shutil.copy(config, test_config) - + # Modify paths in config - with open(test_config) as f: + with open(test_config, encoding="utf-8") as f: content = f.read() content = content.replace("build_dir: build", f"build_dir: {build_dir}") content = content.replace("dist_dir: dist", f"dist_dir: {dist_dir}") - with open(test_config, "w") as f: + with open(test_config, "w", encoding="utf-8") as f: f.write(content) - + # Run baker baker = PDFBaker(test_config, quiet=True, keep_build=True) - baker.bake() + baker.bake() diff --git a/tests/test_config.py b/tests/test_config.py index 5aa7bc8..61d5a53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -119,16 +119,17 @@ def test_configuration_init_with_path(tmp_path): def test_configuration_init_with_directory(tmp_path): """Test initializing Configuration with custom directory.""" - config = PDFBakerConfiguration({}, {"title": "Document"}, directory=tmp_path) + config_file = tmp_path / "test.yaml" + config_file.write_text('{"title": "Document"}') + config = PDFBakerConfiguration({}, config_file) assert config["title"] == "Document" assert config.directory == tmp_path def test_configuration_resolve_path(): """Test path resolution.""" - config = PDFBakerConfiguration( - {}, {"template": "test.yaml"}, directory=Path("/base") - ) + config = PDFBakerConfiguration({}, {"template": "test.yaml"}) + config.directory = Path("/base") # Set directory explicitly for testing assert config.resolve_path("test.yaml") == Path("/base/test.yaml") assert config.resolve_path({"path": "/absolute/path.yaml"}) == Path( "/absolute/path.yaml" From fa1c01f1c3343f0bb1a52c208834c0295dad0feb Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 09:22:22 +1200 Subject: [PATCH 09/36] Factor out logging into separate mixin class No more `self.page.document.baker.debug()` However, configuration instances log through their "parent", not directly (so PDFBakerPage uses `self.page.debug()`) --- src/pdfbaker/__main__.py | 19 +++++++++-- src/pdfbaker/baker.py | 68 +++++++++++++++------------------------- src/pdfbaker/document.py | 32 ++++++++++--------- src/pdfbaker/logging.py | 62 ++++++++++++++++++++++++++++++++++++ src/pdfbaker/page.py | 15 ++++++--- 5 files changed, 133 insertions(+), 63 deletions(-) create mode 100644 src/pdfbaker/logging.py diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 65cfee8..e7372fc 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -26,12 +26,23 @@ def cli() -> None: ) @click.option("-q", "--quiet", is_flag=True, help="Show errors only") @click.option("-v", "--verbose", is_flag=True, help="Show debug information") +@click.option( + "-t", + "--trace", + is_flag=True, + help="Show trace information (even more detailed than debug)", +) @click.option("--keep-build", is_flag=True, help="Keep build artifacts") @click.option( "--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)" ) def bake( - config_file: Path, quiet: bool, verbose: bool, keep_build: bool, debug: bool + config_file: Path, + quiet: bool, + verbose: bool, + trace: bool, + keep_build: bool, + debug: bool, ) -> int: """Parse config file and bake PDFs.""" if debug: @@ -40,7 +51,11 @@ def bake( try: baker = PDFBaker( - config_file, quiet=quiet, verbose=verbose, keep_build=keep_build + config_file, + quiet=quiet, + verbose=verbose, + trace=trace, + keep_build=keep_build, ) baker.bake() return 0 diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index e7fb2f7..9e25859 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -13,6 +13,7 @@ from .config import PDFBakerConfiguration from .document import PDFBakerDocument from .errors import ConfigurationError +from .logging import TRACE, LoggingMixin __all__ = ["PDFBaker"] @@ -27,15 +28,20 @@ } -class PDFBaker: +class PDFBaker(LoggingMixin): """Main class for PDF document generation.""" class Configuration(PDFBakerConfiguration): """PDFBaker configuration.""" - def __init__(self, base_config: dict[str, Any], config_file: Path) -> None: + 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.pprint()) if "documents" not in self: raise ConfigurationError( 'Key "documents" missing - is this the main configuration file?' @@ -49,64 +55,42 @@ def __init__( config_file: Path, quiet: bool = False, verbose: bool = False, + trace: bool = False, keep_build: bool = False, ) -> None: - """Initialize PDFBaker with config file path. + """Initialize PDFBaker with config file path. Set logging level. Args: config_file: Path to config file, document directory is its parent + quiet: Show errors only + verbose: Show debug information + trace: Show trace information (even more detailed than debug) + keep_build: Keep build artifacts """ + super().__init__() logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - self.logger = logging.getLogger(__name__) if quiet: logging.getLogger().setLevel(logging.ERROR) + elif trace: + logging.getLogger().setLevel(TRACE) elif verbose: logging.getLogger().setLevel(logging.DEBUG) else: logging.getLogger().setLevel(logging.INFO) self.keep_build = keep_build self.config = self.Configuration( + baker=self, base_config=DEFAULT_CONFIG, config_file=config_file, ) - def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a debug message.""" - self.logger.debug(msg, *args, **kwargs) - - def info(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log an info message.""" - self.logger.info(msg, *args, **kwargs) - - def 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 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 warning(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a warning message.""" - self.logger.warning(msg, *args, **kwargs) - - def error(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log an error message.""" - self.logger.error(f"**** {msg} ****", *args, **kwargs) - - def critical(self, msg: str, *args: Any, **kwargs: Any) -> None: - """Log a critical message.""" - self.logger.critical(msg, *args, **kwargs) - def bake(self) -> None: """Generate PDFs from documents.""" pdfs_created: list[Path] = [] failed_docs: list[tuple[str, str]] = [] - self.debug("Main configuration:") - self.debug(self.config.pprint()) - self.debug("Documents to process:") - self.debug(self.config.documents) + self.log_debug_subsection("Documents to process:") + self.log_debug(self.config.documents) for doc_config in self.config.documents: doc = PDFBakerDocument( baker=self, @@ -115,7 +99,7 @@ def bake(self) -> None: ) pdf_files, error_message = doc.process_document() if pdf_files is None: - self.error( + self.log_error( "Failed to process document '%s': %s", doc.config.name, error_message, @@ -129,17 +113,17 @@ def bake(self) -> None: doc.teardown() if pdfs_created: - self.info("Created PDFs:") + self.log_info("Created PDFs:") for pdf in pdfs_created: - self.info(" %s", pdf) + self.log_info(" %s", pdf) else: - self.warning("No PDFs were created.") + self.log_warning("No PDFs were created.") if failed_docs: - self.warning( + 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.error(" %s: %s", doc_name, error) + self.log_error(" %s: %s", doc_name, error) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index 2a49deb..c858ec1 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -23,6 +23,7 @@ PDFCombineError, PDFCompressionError, ) +from .logging import LoggingMixin from .page import PDFBakerPage from .pdf import ( combine_pdfs, @@ -34,7 +35,7 @@ __all__ = ["PDFBakerDocument"] -class PDFBakerDocument: +class PDFBakerDocument(LoggingMixin): """A document being processed.""" class Configuration(PDFBakerConfiguration): @@ -52,18 +53,25 @@ def __init__( base_config: The PDFBaker configuration to merge with config_file: The document configuration (YAML file) """ + self.document = document + self.document.log_debug_subsection("Parsing document config: %s", config) if config.is_dir(): self.name = config.name config = config / DEFAULT_DOCUMENT_CONFIG_FILE else: self.name = config.stem + self.directory = config.parent + self.document.log_trace(self.pprint()) + self.document.log_debug_section( + 'Merging document config for "%s"...', self.name + ) super().__init__(base_config, config) - self.document = document + self.document.log_trace(self.pprint()) + self.document.log_debug_subsection("Document config for %s:", self.name) if "pages" not in self: raise ConfigurationError( 'Document "{document.name}" is missing key "pages"' ) - self.directory = config.parent self.pages_dir = self.resolve_path(self["pages_dir"]) self.pages = [] for page_spec in self["pages"]: @@ -73,8 +81,7 @@ def __init__( self.pages.append(page) self.build_dir = self.resolve_path(self["build_dir"]) self.dist_dir = self.resolve_path(self["dist_dir"]) - self.document.baker.debug("Document config for %s:", self.name) - self.document.baker.debug(self.pprint()) + self.document.log_trace(self.pprint()) def __init__( self, @@ -83,6 +90,7 @@ def __init__( config: Path, ): """Initialize a document.""" + super().__init__() self.baker = baker self.config = self.Configuration(base_config, config, document=self) @@ -96,9 +104,7 @@ def process_document(self) -> tuple[Path | list[Path] | None, str | None]: FIXME: could have created SOME PDF files - error_message is a string describing the error, or None if successful """ - self.baker.info_section( - 'Processing document "%s"...', self.config.directory.name - ) + self.log_info_section('Processing document "%s"...', self.config.directory.name) self.config.build_dir.mkdir(parents=True, exist_ok=True) self.config.dist_dir.mkdir(parents=True, exist_ok=True) @@ -150,9 +156,7 @@ def process(self) -> Path | list[Path]: # Multiple PDF documents pdf_files = [] for variant in self.config["variants"]: - self.baker.info_subsection( - 'Processing variant "%s"...', variant["name"] - ) + self.log_info_subsection('Processing variant "%s"...', variant["name"]) variant_config = deep_merge(doc_config, variant) variant_config["variant"] = variant # variant_config = deep_merge(variant_config, self.config) @@ -197,9 +201,9 @@ def _combine_and_compress( if doc_config.get("compress_pdf", False): 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( + self.log_warning( "Compression failed, using uncompressed version: %s", exc, ) @@ -207,7 +211,7 @@ def _combine_and_compress( else: os.rename(combined_pdf, output_path) - self.baker.info("Created PDF: %s", output_path) + self.log_info("Created PDF: %s", output_path) return output_path def teardown(self) -> None: diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py new file mode 100644 index 0000000..3c0826b --- /dev/null +++ b/src/pdfbaker/logging.py @@ -0,0 +1,62 @@ +"""Logging mixin for pdfbaker classes.""" + +import logging +from typing import Any + +TRACE = 5 +logging.addLevelName(TRACE, 'TRACE') + +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_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) diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index 26fa7db..9257cb1 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -13,6 +13,7 @@ from .config import PDFBakerConfiguration from .errors import ConfigurationError, SVGConversionError +from .logging import LoggingMixin from .pdf import convert_svg_to_pdf from .render import create_env, prepare_template_context @@ -20,7 +21,7 @@ # pylint: disable=too-few-public-methods -class PDFBakerPage: +class PDFBakerPage(LoggingMixin): """A single page of a document.""" class Configuration(PDFBakerConfiguration): @@ -34,11 +35,14 @@ def __init__( ) -> None: """Initialize page configuration (needs a template).""" self.page = page + self.page.log_debug_subsection("Loading page config: %s", config) + self.page.log_trace(self.pprint()) # FIXME: config is usually pages/mypage.yaml self.name = "TBC" + self.page.log_debug_section('Initializing document "%s"...', self.name) + self.page.log_debug_subsection("Merging config for %s:", self.name) super().__init__(base_config, config) - self.page.document.baker.debug("Page config for %s:", self.name) - self.page.document.baker.debug(self.pprint()) + self.page.log_trace(self.pprint()) if "template" not in self: raise ConfigurationError( f'Page "{self.name}" in document ' @@ -66,6 +70,7 @@ def __init__( config: Path | dict[str, Any], ) -> None: """Initialize a page.""" + super().__init__() self.document = document self.number = page_number self.config = self.Configuration( @@ -91,7 +96,7 @@ def process(self) -> Path: with open(output_svg, "w", encoding="utf-8") as f: f.write(template.render(**template_context)) except TemplateError as exc: - self.document.baker.error( + self.log_error( "Failed to render page %d (%s): %s", self.number, self.config.name, @@ -107,7 +112,7 @@ def process(self) -> Path: backend=svg2pdf_backend, ) except SVGConversionError as exc: - self.document.baker.error( + self.log_error( "Failed to convert page %d (%s): %s", self.number, self.config.name, From 6630561e7b85a3a6f8e294605e739812b8630e6c Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 09:36:14 +1200 Subject: [PATCH 10/36] Make PDFBaker options its own (data)class --- src/pdfbaker/__main__.py | 7 ++++--- src/pdfbaker/baker.py | 42 ++++++++++++++++++++++++++-------------- src/pdfbaker/logging.py | 3 ++- tests/test_baker.py | 5 +++-- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index e7372fc..5fbbb99 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -7,7 +7,7 @@ import click from pdfbaker import __version__ -from pdfbaker.baker import PDFBaker +from pdfbaker.baker import PDFBaker, PDFBakerOptions from pdfbaker.errors import PDFBakerError logger = logging.getLogger(__name__) @@ -36,6 +36,7 @@ def cli() -> None: @click.option( "--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)" ) +# pylint: disable=too-many-arguments,too-many-positional-arguments def bake( config_file: Path, quiet: bool, @@ -50,13 +51,13 @@ def bake( keep_build = True try: - baker = PDFBaker( - config_file, + options = PDFBakerOptions( quiet=quiet, verbose=verbose, trace=trace, keep_build=keep_build, ) + baker = PDFBaker(config_file, options=options) baker.bake() return 0 except PDFBakerError as exc: diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 9e25859..dd75b30 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -7,6 +7,7 @@ """ import logging +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -15,7 +16,7 @@ from .errors import ConfigurationError from .logging import TRACE, LoggingMixin -__all__ = ["PDFBaker"] +__all__ = ["PDFBaker", "PDFBakerOptions"] DEFAULT_CONFIG = { @@ -28,6 +29,23 @@ } +@dataclass +class PDFBakerOptions: + """Options for controlling PDFBaker behavior. + + 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 + """ + + quiet: bool = False + verbose: bool = False + trace: bool = False + keep_build: bool = False + + class PDFBaker(LoggingMixin): """Main class for PDF document generation.""" @@ -53,31 +71,27 @@ def __init__( def __init__( self, config_file: Path, - quiet: bool = False, - verbose: bool = False, - trace: bool = False, - keep_build: bool = False, + options: PDFBakerOptions | None = None, ) -> None: """Initialize PDFBaker with config file path. Set logging level. Args: - config_file: Path to config file, document directory is its parent - quiet: Show errors only - verbose: Show debug information - trace: Show trace information (even more detailed than debug) - keep_build: Keep build artifacts + config_file: Path to config file + options: Optional options for logging and build behavior """ super().__init__() + options = options or PDFBakerOptions() + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - if quiet: + if options.quiet: logging.getLogger().setLevel(logging.ERROR) - elif trace: + elif options.trace: logging.getLogger().setLevel(TRACE) - elif verbose: + elif options.verbose: logging.getLogger().setLevel(logging.DEBUG) else: logging.getLogger().setLevel(logging.INFO) - self.keep_build = keep_build + self.keep_build = options.keep_build self.config = self.Configuration( baker=self, base_config=DEFAULT_CONFIG, diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py index 3c0826b..1eb6d88 100644 --- a/src/pdfbaker/logging.py +++ b/src/pdfbaker/logging.py @@ -4,7 +4,8 @@ from typing import Any TRACE = 5 -logging.addLevelName(TRACE, 'TRACE') +logging.addLevelName(TRACE, "TRACE") + class LoggingMixin: """Mixin providing consistent logging functionality across pdfbaker classes.""" diff --git a/tests/test_baker.py b/tests/test_baker.py index 941610a..1d68d2d 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -3,7 +3,7 @@ import shutil from pathlib import Path -from pdfbaker.baker import PDFBaker +from pdfbaker.baker import PDFBaker, PDFBakerOptions def test_examples() -> None: @@ -31,5 +31,6 @@ def test_examples() -> None: f.write(content) # Run baker - baker = PDFBaker(test_config, quiet=True, keep_build=True) + options = PDFBakerOptions(quiet=True, keep_build=True) + baker = PDFBaker(test_config, options=options) baker.bake() From e35f3f4bc99099437e17ef3908fb46f79d58deb9 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 11:08:32 +1200 Subject: [PATCH 11/36] Minor tidy-up --- src/pdfbaker/baker.py | 3 ++- src/pdfbaker/config.py | 2 +- src/pdfbaker/document.py | 6 +++--- src/pdfbaker/page.py | 4 ++-- src/pdfbaker/pdf.py | 46 +++++++++++++++++++++------------------- 5 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index dd75b30..6a6efb0 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -20,6 +20,7 @@ DEFAULT_CONFIG = { + # Default to directories relative to the config file "documents_dir": ".", "pages_dir": "pages", "templates_dir": "templates", @@ -59,7 +60,7 @@ def __init__( 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.pprint()) + self.baker.log_trace(self.pretty()) if "documents" not in self: raise ConfigurationError( 'Key "documents" missing - is this the main configuration file?' diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index 0c6a681..d469068 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -119,7 +119,7 @@ def render(self) -> dict[str, Any]: "Check for circular references in your configuration." ) - def pprint(self, max_string=60) -> str: + def pretty(self, max_string=60) -> str: """Pretty print a configuration dictionary (for debugging).""" truncated = _truncate_strings(self, max_string) return pprint.pformat(truncated, indent=2) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index c858ec1..c375219 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -61,12 +61,12 @@ def __init__( else: self.name = config.stem self.directory = config.parent - self.document.log_trace(self.pprint()) + self.document.log_trace(self.pretty()) self.document.log_debug_section( 'Merging document config for "%s"...', self.name ) super().__init__(base_config, config) - self.document.log_trace(self.pprint()) + self.document.log_trace(self.pretty()) self.document.log_debug_subsection("Document config for %s:", self.name) if "pages" not in self: raise ConfigurationError( @@ -81,7 +81,7 @@ def __init__( self.pages.append(page) self.build_dir = self.resolve_path(self["build_dir"]) self.dist_dir = self.resolve_path(self["dist_dir"]) - self.document.log_trace(self.pprint()) + self.document.log_trace(self.pretty()) def __init__( self, diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index 9257cb1..eb0687f 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -36,13 +36,13 @@ def __init__( """Initialize page configuration (needs a template).""" self.page = page self.page.log_debug_subsection("Loading page config: %s", config) - self.page.log_trace(self.pprint()) + self.page.log_trace(self.pretty()) # FIXME: config is usually pages/mypage.yaml self.name = "TBC" self.page.log_debug_section('Initializing document "%s"...', self.name) self.page.log_debug_subsection("Merging config for %s:", self.name) super().__init__(base_config, config) - self.page.log_trace(self.pprint()) + self.page.log_trace(self.pretty()) if "template" not in self: raise ConfigurationError( f'Page "{self.name}" in document ' diff --git a/src/pdfbaker/pdf.py b/src/pdfbaker/pdf.py index 0bcdacb..b1d68d0 100644 --- a/src/pdfbaker/pdf.py +++ b/src/pdfbaker/pdf.py @@ -175,25 +175,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 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 SVGConversionError(svg_path, backend, str(exc)) from exc - - return pdf_path - except Exception as exc: - raise 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 From bce23a311f5f97339ad944158975f6cca9e9686c Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 11:09:51 +1200 Subject: [PATCH 12/36] Better docstring --- src/pdfbaker/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index d469068..ee7bfc6 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -120,6 +120,6 @@ def render(self) -> dict[str, Any]: ) def pretty(self, max_string=60) -> str: - """Pretty print a configuration dictionary (for debugging).""" + """Return readable presentation (for debugging).""" truncated = _truncate_strings(self, max_string) return pprint.pformat(truncated, indent=2) From 86b2c781b24c507f22aca04216fb6b16496023df Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 11:16:35 +1200 Subject: [PATCH 13/36] Add link to github repo back When did that get lost --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index db94d3f..a472d96 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ pdfbaker bake examples/examples.yaml ## 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. From 71a6db68b30bfae479a567a859212fe21e5be45a Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 17 Apr 2025 11:18:01 +1200 Subject: [PATCH 14/36] Fix capitalisation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a472d96..27acf06 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ pdfbaker bake examples/examples.yaml ## Development -All source code is [on github](https://github.com/pythonnz/pdfbaker). +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. From d5863f2007d2b010a4e4fbd22393d8435916f8d2 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 18 Apr 2025 10:29:12 +1200 Subject: [PATCH 15/36] Clarify README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27acf06..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,7 +55,7 @@ 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 From a10e05ea1c096f5f8eeb6d2d8463fcb3d847dce3 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 02:41:13 +1200 Subject: [PATCH 16/36] Refactor to handle custom locations properly --- .../other_pages/custom_page.yaml | 5 +- .../your_directory/config.yaml | 10 +- src/pdfbaker/baker.py | 24 ++-- src/pdfbaker/config.py | 121 ++++++++++++------ src/pdfbaker/document.py | 110 ++++++++-------- src/pdfbaker/errors.py | 5 + src/pdfbaker/page.py | 77 +++++------ 7 files changed, 210 insertions(+), 142 deletions(-) diff --git a/examples/custom_locations/other_pages/custom_page.yaml b/examples/custom_locations/other_pages/custom_page.yaml index 9eac0b9..646784a 100644 --- a/examples/custom_locations/other_pages/custom_page.yaml +++ b/examples/custom_locations/other_pages/custom_page.yaml @@ -1,3 +1,6 @@ title: "Custom Location Example" description: "This page uses custom directory structure" -template: "../other_templates/custom_page.svg.j2" +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/your_directory/config.yaml b/examples/custom_locations/your_directory/config.yaml index 588698e..a79e9ce 100644 --- a/examples/custom_locations/your_directory/config.yaml +++ b/examples/custom_locations/your_directory/config.yaml @@ -1,9 +1,9 @@ -title: "Custom Locations Example" -filename: "custom_locations_custom" +title: Custom Locations Example +filename: custom_locations_custom pages: # Simple notation - uses conventional location (pages/standard_page.yaml) - - "standard_page" + - standard_page # Path notation - uses custom location - - "../other_pages/custom_page.yaml" + - path: ../other_pages/custom_page.yaml # Custom images directory name and location -images_dir: "../your_images" +images_dir: ../your_images diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 6a6efb0..e524752 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -19,14 +19,13 @@ __all__ = ["PDFBaker", "PDFBakerOptions"] -DEFAULT_CONFIG = { +DEFAULT_BAKER_CONFIG = { # Default to directories relative to the config file - "documents_dir": ".", - "pages_dir": "pages", - "templates_dir": "templates", - "images_dir": "images", - "build_dir": "build", - "dist_dir": "dist", + "directories": { + "documents": ".", + "build": "build", + "dist": "dist", + }, } @@ -58,15 +57,16 @@ def __init__( ) -> 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_section("Main configuration: %s", 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.documents = [ - self.resolve_path(doc_spec) for doc_spec in self["documents"] + self.resolve_path(doc_spec, directory=self["directories"]["documents"]) + for doc_spec in self["documents"] ] def __init__( @@ -93,9 +93,11 @@ def __init__( else: logging.getLogger().setLevel(logging.INFO) self.keep_build = options.keep_build + base_config = DEFAULT_BAKER_CONFIG.copy() + base_config["directories"]["config"] = config_file.parent.resolve() self.config = self.Configuration( baker=self, - base_config=DEFAULT_CONFIG, + base_config=base_config, config_file=config_file, ) @@ -110,7 +112,7 @@ def bake(self) -> None: doc = PDFBakerDocument( baker=self, base_config=self.config, - config=doc_config, + config_path=doc_config, ) pdf_files, error_message = doc.process_document() if pdf_files is None: diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index ee7bfc6..da2cbd9 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -11,7 +11,7 @@ from .errors import ConfigurationError from .types import PathSpec -__all__ = ["PDFBakerConfiguration"] +__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ class PDFBakerConfiguration(dict): def __init__( self, base_config: dict[str, Any], - config: Path, + config_file: Path, ) -> None: """Initialize configuration from a file. @@ -59,17 +59,36 @@ def __init__( base_config: Existing base configuration config: Path to YAML file to merge with base_config """ - self.directory = config.parent - super().__init__(deep_merge(base_config, self._load_config(config))) - - def _load_config(self, config_file: Path) -> dict[str, Any]: - """Load configuration from a file.""" try: with open(config_file, encoding="utf-8") as f: - return yaml.safe_load(f) + config = yaml.safe_load(f) except Exception as exc: raise ConfigurationError(f"Failed to load config file: {exc}") from exc + # Determine all relevant 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 + directories[directory] = self.resolve_path( + config["directories"][directory] + ) + elif directory in base_config.get("directories", {}): + # Inherited or not yet relevant/mentioned + directories[directory] = self.resolve_path( + str(base_config["directories"][directory]), + directory=base_config["directories"]["config"], + ) + 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. @@ -79,7 +98,7 @@ def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: Returns: Resolved Path object """ - directory = directory or self.directory + directory = directory or self["directories"]["config"] if isinstance(spec, str): return directory / spec @@ -91,35 +110,63 @@ def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: return directory / spec["name"] - def render(self) -> dict[str, Any]: - """Resolve all template strings in config using its own values. - - This allows the use of "{{ variant }}" in the "filename" etc. - - Returns: - Resolved configuration dictionary - - Raises: - ConfigurationError: If maximum number of iterations is reached - (circular references) - """ - max_iterations = 10 - config = self - 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 ConfigurationError( - "Maximum number of iterations reached. " - "Check for circular references in your configuration." - ) - def pretty(self, max_string=60) -> str: """Return readable presentation (for debugging).""" truncated = _truncate_strings(self, max_string) 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) + + 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 c375219..dc3dcc6 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -11,11 +11,10 @@ from pathlib import Path from typing import Any -import jinja2 - from .config import ( PDFBakerConfiguration, deep_merge, + render_config, ) from .errors import ( ConfigurationError, @@ -30,6 +29,14 @@ compress_pdf, ) +DEFAULT_DOCUMENT_CONFIG = { + # Default to directories relative to the config file + "directories": { + "pages": "pages", + "templates": "templates", + "images": "images", + }, +} DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" __all__ = ["PDFBakerDocument"] @@ -43,9 +50,9 @@ class Configuration(PDFBakerConfiguration): def __init__( self, - base_config: "PDFBakerConfiguration", # type: ignore # noqa: F821 - config: Path, document: "PDFBakerDocument", + base_config: "PDFBakerConfiguration", # type: ignore # noqa: F821 + config_path: Path, ) -> None: """Initialize document configuration. @@ -54,45 +61,58 @@ def __init__( config_file: The document configuration (YAML file) """ self.document = document - self.document.log_debug_subsection("Parsing document config: %s", config) - if config.is_dir(): - self.name = config.name - config = config / DEFAULT_DOCUMENT_CONFIG_FILE + + if config_path.is_dir(): + self.name = config_path.name + config_path = config_path / DEFAULT_DOCUMENT_CONFIG_FILE else: - self.name = config.stem - self.directory = config.parent - self.document.log_trace(self.pretty()) - self.document.log_debug_section( - 'Merging document config for "%s"...', self.name - ) - super().__init__(base_config, config) + self.name = config_path.stem + + base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG) + base_config["directories"]["config"] = config_path.parent.resolve() + + super().__init__(base_config, config_path) + self.document.log_trace_section("Document configuration: %s", config_path) self.document.log_trace(self.pretty()) - self.document.log_debug_subsection("Document config for %s:", self.name) + + self.bake_path = self["directories"]["config"] / "bake.py" + self.build_dir = self["directories"]["build"] + self.dist_dir = self["directories"]["dist"] + if "pages" not in self: raise ConfigurationError( 'Document "{document.name}" is missing key "pages"' ) - self.pages_dir = self.resolve_path(self["pages_dir"]) self.pages = [] for page_spec in self["pages"]: - page = self.resolve_path(page_spec, directory=self.pages_dir) + 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) - self.build_dir = self.resolve_path(self["build_dir"]) - self.dist_dir = self.resolve_path(self["dist_dir"]) - self.document.log_trace(self.pretty()) def __init__( self, baker: "PDFBaker", # type: ignore # noqa: F821 base_config: dict[str, Any], - config: Path, + config_path: Path, ): """Initialize a document.""" super().__init__() self.baker = baker - self.config = self.Configuration(base_config, config, document=self) + self.config = self.Configuration( + document=self, + base_config=base_config, + config_path=config_path, + ) def process_document(self) -> tuple[Path | list[Path] | None, str | None]: """Process the document - use custom bake module if it exists. @@ -104,24 +124,17 @@ def process_document(self) -> tuple[Path | list[Path] | None, str | None]: FIXME: could have created SOME PDF files - error_message is a string describing the error, or None if successful """ - self.log_info_section('Processing document "%s"...', self.config.directory.name) + self.log_info_section('Processing document "%s"...', self.config.name) self.config.build_dir.mkdir(parents=True, exist_ok=True) self.config.dist_dir.mkdir(parents=True, exist_ok=True) - bake_path = self.config.directory / "bake.py" - if bake_path.exists(): - # Custom (pre-)processing - try: - return self._process_with_custom_bake(bake_path), None - except PDFBakerError as exc: - return None, str(exc) - else: - # Standard processing - try: - return self.process(), None - except (PDFBakerError, jinja2.exceptions.TemplateError) as exc: - return None, str(exc) + 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.""" @@ -142,43 +155,36 @@ def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: ) from exc def process(self) -> Path | list[Path]: - """Process document using standard processing. - - FIXME: don't mix up who gets what - there's still a page config - PDFBaker and PDFBakerDocument load and merge their config - file upong initialization, but a PDFBakerPage is initialized with - an already merged config, so that we can provide different - configs for different variants. - """ - doc_config = self.config.copy() - + """Process document using standard processing.""" if "variants" in self.config: # Multiple PDF documents pdf_files = [] for variant in self.config["variants"]: self.log_info_subsection('Processing variant "%s"...', variant["name"]) - variant_config = deep_merge(doc_config, variant) + variant_config = deep_merge(self.config, variant) variant_config["variant"] = variant - # variant_config = deep_merge(variant_config, self.config) + variant_config = render_config(variant_config) page_pdfs = self._process_pages(variant_config) pdf_files.append(self._combine_and_compress(page_pdfs, variant_config)) return pdf_files # Single PDF document + doc_config = render_config(self.config) page_pdfs = self._process_pages(doc_config) - # doc_config = doc_config.render() return self._combine_and_compress(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 in enumerate(self.config.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): # FIXME: just call with config - already merged page = PDFBakerPage( document=self, page_number=page_num, base_config=config, - config=page, + config_path=page_config, ) pdf_files.append(page.process()) diff --git a/src/pdfbaker/errors.py b/src/pdfbaker/errors.py index 09135df..7303fd3 100644 --- a/src/pdfbaker/errors.py +++ b/src/pdfbaker/errors.py @@ -8,6 +8,7 @@ "PDFCombineError", "PDFCompressionError", "SVGConversionError", + "SVGTemplateError", ] @@ -37,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/page.py b/src/pdfbaker/page.py index eb0687f..f9df1e4 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -9,10 +9,10 @@ from pathlib import Path from typing import Any -from jinja2.exceptions import TemplateError +from jinja2.exceptions import TemplateError, TemplateNotFound from .config import PDFBakerConfiguration -from .errors import ConfigurationError, SVGConversionError +from .errors import ConfigurationError, SVGConversionError, SVGTemplateError from .logging import LoggingMixin from .pdf import convert_svg_to_pdf from .render import create_env, prepare_template_context @@ -29,54 +29,56 @@ class Configuration(PDFBakerConfiguration): def __init__( self, - base_config: dict[str, Any], - config: Path, page: "PDFBakerPage", + base_config: dict[str, Any], + config_path: Path, ) -> None: """Initialize page configuration (needs a template).""" self.page = page - self.page.log_debug_subsection("Loading page config: %s", config) - self.page.log_trace(self.pretty()) - # FIXME: config is usually pages/mypage.yaml - self.name = "TBC" - self.page.log_debug_section('Initializing document "%s"...', self.name) - self.page.log_debug_subsection("Merging config for %s:", self.name) - super().__init__(base_config, config) + + self.name = config_path.stem + base_config["directories"]["config"] = config_path.parent.resolve() + + super().__init__(base_config, config_path) + self.page.log_trace_section("Page configuration: %s", config_path) self.page.log_trace(self.pretty()) + + self.templates_dir = self["directories"]["templates"] + self.images_dir = self["directories"]["images"] + self.build_dir = self["directories"]["build"] + self.dist_dir = self["directories"]["dist"] + if "template" not in self: raise ConfigurationError( f'Page "{self.name}" in document ' f'"{self.page.document.config.name}" has no template' ) - self.templates_dir = self.resolve_path( - self["templates_dir"], - directory=self.page.document.config.directory, - ) - self.template = self.resolve_path( - self["template"], - directory=self.templates_dir, - ) - self.images_dir = self.resolve_path( - self["images_dir"], - directory=self.page.document.config.directory, - ) - self.build_dir = self.resolve_path(self["build_dir"]) + 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 | 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( - base_config=base_config, - config=config, page=self, + base_config=base_config, + config_path=config_path, ) def process(self) -> Path: @@ -85,8 +87,15 @@ def process(self) -> Path: 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" - jinja_env = create_env(self.config.template.parent) - template = jinja_env.get_template(self.config["template"]) + 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, @@ -96,13 +105,9 @@ def process(self) -> Path: with open(output_svg, "w", encoding="utf-8") as f: f.write(template.render(**template_context)) except TemplateError as exc: - self.log_error( - "Failed to render page %d (%s): %s", - self.number, - self.config.name, - exc, - ) - raise + raise SVGTemplateError( + f"Failed to render page {self.number} ({self.config.name}): {exc}" + ) from exc svg2pdf_backend = self.config.get("svg2pdf_backend", "cairosvg") try: From 5d47a9fca84109e67f1d95e5629ddeec15ebce47 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 03:05:12 +1200 Subject: [PATCH 17/36] Tear down build correctly Just missing the top level now --- src/pdfbaker/document.py | 49 +++++++++++++++++++++++++--------------- src/pdfbaker/page.py | 4 ++-- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index dc3dcc6..3783bd0 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -76,8 +76,8 @@ def __init__( self.document.log_trace(self.pretty()) self.bake_path = self["directories"]["config"] / "bake.py" - self.build_dir = self["directories"]["build"] - self.dist_dir = self["directories"]["dist"] + self.build_dir = self["directories"]["build"] / self.name + self.dist_dir = self["directories"]["dist"] / self.name if "pages" not in self: raise ConfigurationError( @@ -156,22 +156,32 @@ def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: 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.log_info_subsection('Processing variant "%s"...', variant["name"]) - variant_config = deep_merge(self.config, variant) - variant_config["variant"] = variant - variant_config = render_config(variant_config) - page_pdfs = self._process_pages(variant_config) - pdf_files.append(self._combine_and_compress(page_pdfs, variant_config)) - return pdf_files - - # Single PDF document - doc_config = render_config(self.config) - page_pdfs = self._process_pages(doc_config) - return self._combine_and_compress(page_pdfs, doc_config) + try: + if "variants" in self.config: + # Multiple PDF documents + pdf_files = [] + for variant in self.config["variants"]: + self.log_info_subsection( + 'Processing variant "%s"...', variant["name"] + ) + variant_config = deep_merge(self.config, variant) + variant_config["variant"] = variant + variant_config = render_config(variant_config) + page_pdfs = self._process_pages(variant_config) + pdf_files.append( + self._combine_and_compress(page_pdfs, variant_config) + ) + return pdf_files + + # Single PDF document + doc_config = render_config(self.config) + page_pdfs = self._process_pages(doc_config) + return self._combine_and_compress(page_pdfs, doc_config) + except Exception: + # Ensure build directory is cleaned up if processing fails + if not self.baker.keep_build: + self.teardown() + raise def _process_pages(self, config: dict[str, Any]) -> list[Path]: """Process pages with given configuration.""" @@ -222,6 +232,9 @@ def _combine_and_compress( def teardown(self) -> None: """Clean up build directory after successful processing.""" + self.log_debug_subsection( + "Tearing down build directory: %s", self.config.build_dir + ) if self.config.build_dir.exists(): # Remove all files in the build directory for file_path in self.config.build_dir.iterdir(): diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index f9df1e4..51e4d66 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -45,8 +45,8 @@ def __init__( self.templates_dir = self["directories"]["templates"] self.images_dir = self["directories"]["images"] - self.build_dir = self["directories"]["build"] - self.dist_dir = self["directories"]["dist"] + self.build_dir = page.document.config.build_dir + self.dist_dir = page.document.config.dist_dir if "template" not in self: raise ConfigurationError( From 415b72f9ca5d0f4ee7d055dd118860727950ab64 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 17:20:38 +1200 Subject: [PATCH 18/36] Add diagrams for some workflow visualization --- docs/configuration.md | 10 ++++++++++ docs/overview.md | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 1702b90..9dcb75a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,16 @@ project/ └── templates/ ``` +## Configuration Workflow + +```mermaid +graph TD + Main[Main config] -->|merge| Document[Document config] + Document -->|merge| Page[Page config] + Template[SVG Template] -.- Page + Template -->|render| SVG[SVG Page] +``` + ## Main Configuration File | Option | Type | Default | Description | diff --git a/docs/overview.md b/docs/overview.md index cbc7760..fefdea9 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,6 +13,25 @@ documents with minimal effort. - **Custom Processing**: Extend the processing workflow with Python - **PDF Compression**: Optional compression of final PDFs +```mermaid +graph TD + Main[Main config] -->|merge| Document[Document config] + Document -->|merge| Page[Page config] + Template[SVG Template] -.- Page + Template -->|render| SVG[SVG Page] + SVG -->|convert| PDF[PDF Page] + +``` + +```mermaid +graph LR + Page1[PDF Page 1] -->|combine| Document[PDF Document] + Page2[PDF Page 2] -->|combine| Document + PageN[PDF Page ...] -->|combine| Document + Document -.->|compress| Compressed[PDF Document compressed] + linkStyle 3 stroke-dasharray: 5 5 +``` + ## Documentation - [Configuration](configuration.md) - How to set up your documents From 47d690387aa012c8fcdfe7229f9e771e8bf017f2 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 17:22:32 +1200 Subject: [PATCH 19/36] Various improvements * Allow override of defaults (for library use, incl. our tests) * Improved logging for all log levels (incl. template rendering preview for --trace) * Teardown of top-level build directory * Minor tidy-ups/clarifications --- src/pdfbaker/__main__.py | 6 ++---- src/pdfbaker/baker.py | 35 ++++++++++++++++++++++++++++++----- src/pdfbaker/config.py | 30 ++++++++++-------------------- src/pdfbaker/document.py | 29 ++++++++++++++--------------- src/pdfbaker/logging.py | 26 ++++++++++++++++++++++++++ src/pdfbaker/page.py | 25 +++++++++++++++++++------ 6 files changed, 101 insertions(+), 50 deletions(-) diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 5fbbb99..4690c70 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -30,12 +30,10 @@ def cli() -> None: "-t", "--trace", is_flag=True, - help="Show trace information (even more detailed than debug)", + help="Show trace information (even more detailed than --verbose)", ) @click.option("--keep-build", is_flag=True, help="Keep build artifacts") -@click.option( - "--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)" -) +@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, diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index e524752..d597369 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from .config import PDFBakerConfiguration +from .config import PDFBakerConfiguration, deep_merge from .document import PDFBakerDocument from .errors import ConfigurationError from .logging import TRACE, LoggingMixin @@ -38,12 +38,15 @@ class PDFBakerOptions: 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 """ quiet: bool = False verbose: bool = False trace: bool = False keep_build: bool = False + default_config_overrides: dict[str, Any] | None = None class PDFBaker(LoggingMixin): @@ -57,13 +60,14 @@ def __init__( ) -> 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_section("Main configuration: %s", 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"] @@ -93,7 +97,13 @@ def __init__( else: logging.getLogger().setLevel(logging.INFO) self.keep_build = options.keep_build + + # Start with defaults and apply any overrides base_config = DEFAULT_BAKER_CONFIG.copy() + if options and options.default_config_overrides: + base_config = deep_merge(base_config, options.default_config_overrides) + + # Set config directory and initialize base_config["directories"]["config"] = config_file.parent.resolve() self.config = self.Configuration( baker=self, @@ -102,7 +112,7 @@ def __init__( ) def bake(self) -> None: - """Generate PDFs from documents.""" + """Create PDFs for all documents.""" pdfs_created: list[Path] = [] failed_docs: list[tuple[str, str]] = [] @@ -115,7 +125,7 @@ def bake(self) -> None: config_path=doc_config, ) pdf_files, error_message = doc.process_document() - if pdf_files is None: + if error_message: self.log_error( "Failed to process document '%s': %s", doc.config.name, @@ -130,7 +140,7 @@ def bake(self) -> None: doc.teardown() if pdfs_created: - self.log_info("Created PDFs:") + self.log_info("Successfully created PDFs:") for pdf in pdfs_created: self.log_info(" %s", pdf) else: @@ -144,3 +154,18 @@ def bake(self) -> None: ) for doc_name, error in failed_docs: self.log_error(" %s: %s", doc_name, error) + + if not self.keep_build: + self.teardown() + + 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.log_debug("Removing top-level build directory...") + self.config.build_dir.rmdir() + except OSError: + self.log_warning("Top-level build directory not empty - not removing") diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index da2cbd9..1f0b0bd 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -9,6 +9,7 @@ from jinja2 import Template from .errors import ConfigurationError +from .logging import truncate_strings from .types import PathSpec __all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] @@ -27,24 +28,6 @@ def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: return result -def _truncate_strings(obj, max_length: int) -> Any: - """Recursively truncate strings in nested structures.""" - if isinstance(obj, str): - return obj if len(obj) <= max_length else obj[:max_length] + "…" - if isinstance(obj, dict): - return { - _truncate_strings(k, max_length): _truncate_strings(v, max_length) - for k, v in obj.items() - } - if isinstance(obj, list): - return [_truncate_strings(item, max_length) for item in obj] - if isinstance(obj, tuple): - return tuple(_truncate_strings(item, max_length) for item in obj) - if isinstance(obj, set): - return {_truncate_strings(item, max_length) for item in obj} - return obj - - class PDFBakerConfiguration(dict): """Base class for handling config loading/merging/parsing.""" @@ -110,9 +93,9 @@ def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: return directory / spec["name"] - def pretty(self, max_string=60) -> str: + def pretty(self, max_chars: int = 60) -> str: """Return readable presentation (for debugging).""" - truncated = _truncate_strings(self, max_string) + truncated = truncate_strings(self, max_chars=max_chars) return pprint.pformat(truncated, indent=2) @@ -162,6 +145,13 @@ def render_config(config: dict[str, Any]) -> dict[str, Any]: 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 diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index 3783bd0..a492c91 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -71,8 +71,10 @@ def __init__( base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG) base_config["directories"]["config"] = config_path.parent.resolve() + self.document.log_trace_section( + "Loading document configuration: %s", config_path + ) super().__init__(base_config, config_path) - self.document.log_trace_section("Document configuration: %s", config_path) self.document.log_trace(self.pretty()) self.bake_path = self["directories"]["config"] / "bake.py" @@ -168,15 +170,13 @@ def process(self) -> Path | list[Path]: variant_config["variant"] = variant variant_config = render_config(variant_config) page_pdfs = self._process_pages(variant_config) - pdf_files.append( - self._combine_and_compress(page_pdfs, variant_config) - ) + pdf_files.append(self._finalize(page_pdfs, variant_config)) return pdf_files # Single PDF document doc_config = render_config(self.config) page_pdfs = self._process_pages(doc_config) - return self._combine_and_compress(page_pdfs, doc_config) + return self._finalize(page_pdfs, doc_config) except Exception: # Ensure build directory is cleaned up if processing fails if not self.baker.keep_build: @@ -189,7 +189,6 @@ def _process_pages(self, config: dict[str, Any]) -> list[Path]: 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): - # FIXME: just call with config - already merged page = PDFBakerPage( document=self, page_number=page_num, @@ -200,10 +199,10 @@ def _process_pages(self, config: dict[str, Any]) -> list[Path]: return pdf_files - def _combine_and_compress( - self, pdf_files: list[Path], doc_config: dict[str, Any] - ) -> Path: + 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, @@ -215,6 +214,7 @@ def _combine_and_compress( output_path = self.config.dist_dir / f"{doc_config['filename']}.pdf" if doc_config.get("compress_pdf", False): + self.log_debug("Compressing PDF document...") try: compress_pdf(combined_pdf, output_path) self.log_info("PDF compressed successfully") @@ -227,23 +227,22 @@ def _combine_and_compress( else: os.rename(combined_pdf, output_path) - self.log_info("Created PDF: %s", output_path) + self.log_info("Created %s", output_path.name) return output_path def teardown(self) -> None: - """Clean up build directory after successful processing.""" + """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(): - # Remove all files in the build directory + 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 to remove the build directory try: + self.log_debug("Removing build directory...") self.config.build_dir.rmdir() except OSError: - # Directory not empty - this is expected if we have subdirectories - pass + self.log_warning("Build directory not empty - not removing") diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py index 1eb6d88..932844a 100644 --- a/src/pdfbaker/logging.py +++ b/src/pdfbaker/logging.py @@ -18,6 +18,14 @@ 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) @@ -61,3 +69,21 @@ def log_error(self, msg: str, *args: Any, **kwargs: Any) -> None: def log_critical(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a critical message.""" self.logger.critical(msg, *args, **kwargs) + + +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 index 51e4d66..f616a7a 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -13,7 +13,7 @@ from .config import PDFBakerConfiguration from .errors import ConfigurationError, SVGConversionError, SVGTemplateError -from .logging import LoggingMixin +from .logging import TRACE, LoggingMixin from .pdf import convert_svg_to_pdf from .render import create_env, prepare_template_context @@ -39,8 +39,8 @@ def __init__( self.name = config_path.stem base_config["directories"]["config"] = config_path.parent.resolve() + self.page.log_trace_section("Loading page configuration: %s", config_path) super().__init__(base_config, config_path) - self.page.log_trace_section("Page configuration: %s", config_path) self.page.log_trace(self.pretty()) self.templates_dir = self["directories"]["templates"] @@ -83,9 +83,14 @@ def __init__( def process(self) -> Path: """Render SVG template and convert to PDF.""" - 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_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) @@ -101,14 +106,22 @@ def process(self) -> Path: 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(template.render(**template_context)) + 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( From 34c85ec3ba3ce972417fc412e6b997fcf18c7d0a Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 20:20:03 +1200 Subject: [PATCH 20/36] Fix test debugging pytest-cov and debugpy don't play --- .vscode/launch.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 7e1ec03..cfb9046 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,6 +15,8 @@ "request": "launch", "module": "pytest", "args": ["-v", "tests"], + "justMyCode": false, + "env": {"PYTEST_ADDOPTS": "--no-cov"}, "console": "integratedTerminal" } ] From f9765f93f60c2aae5c3457e340ee0afc86fe82b4 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 21:35:35 +1200 Subject: [PATCH 21/36] Improve use of diagrams --- docs/configuration.md | 20 ++++++++--- docs/overview.md | 81 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9dcb75a..f8e1a50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,12 +19,22 @@ project/ ## 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 -graph TD - Main[Main config] -->|merge| Document[Document config] - Document -->|merge| Page[Page config] - Template[SVG Template] -.- Page - Template -->|render| SVG[SVG Page] +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 diff --git a/docs/overview.md b/docs/overview.md index fefdea9..97aa692 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,23 +13,76 @@ 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 -graph TD - Main[Main config] -->|merge| Document[Document config] - Document -->|merge| Page[Page config] - Template[SVG Template] -.- Page - Template -->|render| SVG[SVG Page] - SVG -->|convert| PDF[PDF Page] +flowchart TD + Main[YAML Main Config] -->|document 1| Doc1[Document Processing] + Main -->|document 2| Doc2[Document Processing] + + subgraph Document1[Document] + Doc1 -->|page 1| Page1[Page Processing] + Doc1 -->|page 2| Page2[Page Processing] + Doc1 -->|page n| 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 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 may optionally get compressed for a nice end result. + ```mermaid -graph LR - Page1[PDF Page 1] -->|combine| Document[PDF Document] - Page2[PDF Page 2] -->|combine| Document - PageN[PDF Page ...] -->|combine| Document - Document -.->|compress| Compressed[PDF Document compressed] - linkStyle 3 stroke-dasharray: 5 5 +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 @@ -38,10 +91,6 @@ graph LR - [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: From 21b5a396c883ed7561501a654440bf8906a9c5b1 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 21:39:14 +1200 Subject: [PATCH 22/36] Minor clarification --- docs/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/overview.md b/docs/overview.md index 97aa692..11ae032 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -28,7 +28,7 @@ flowchart TD Main[YAML Main Config] -->|document 1| Doc1[Document Processing] Main -->|document 2| Doc2[Document Processing] - subgraph Document1[Document] + subgraph Document[YAML Document Config] Doc1 -->|page 1| Page1[Page Processing] Doc1 -->|page 2| Page2[Page Processing] Doc1 -->|page n| PageN[Page Processing] From daba607953f37b94c2abc02727143e8887dc4c43 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 21:40:37 +1200 Subject: [PATCH 23/36] Minor clarification --- docs/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/overview.md b/docs/overview.md index 11ae032..dd036bf 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -31,7 +31,7 @@ flowchart TD subgraph Document[YAML Document Config] Doc1 -->|page 1| Page1[Page Processing] Doc1 -->|page 2| Page2[Page Processing] - Doc1 -->|page n| PageN[Page Processing] + Doc1 -->|page ...| PageN[Page Processing] Page1 --> PDF1[PDF File Page 1] Page2 --> PDF2[PDF File Page 2] From 409a846883ce4ee7705f2e6c708b33356101c084 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 21:41:19 +1200 Subject: [PATCH 24/36] Linting --- docs/configuration.md | 4 +++- docs/overview.md | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8e1a50..72477f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,7 +19,9 @@ project/ ## 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. +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 diff --git a/docs/overview.md b/docs/overview.md index dd036bf..73c90c0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -21,7 +21,8 @@ For a quick introduction, see the [README](../README.md). ### From configuration to PDF documents -Your main configuration defines which documents to create.
Each document configuration defines which pages make up the document. +Your main configuration defines which documents to create.
Each document +configuration defines which pages make up the document. ```mermaid flowchart TD @@ -32,7 +33,7 @@ flowchart TD 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 ...] @@ -52,7 +53,10 @@ flowchart TD ### 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 page is only responsible for layout/design. +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 page is only responsible +for layout/design. ```mermaid flowchart TD @@ -72,7 +76,9 @@ flowchart TD ### 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 may optionally get compressed for a nice end result. +After each page template was rendered and the resulting SVG file converted to PDF, these +page PDFs are combined to create the document.
This may optionally get compressed for +a nice end result. ```mermaid flowchart LR From 63f4c33a22913607147225b17301af63c8a57f4e Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 21:48:01 +1200 Subject: [PATCH 25/36] Minor clarification --- docs/overview.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/overview.md b/docs/overview.md index 73c90c0..10e8eba 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -55,8 +55,8 @@ flowchart TD 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 page is only responsible -for layout/design. +hold page-specific settings/content, so that the template of the page is only +responsible for layout/design. ```mermaid flowchart TD @@ -77,8 +77,8 @@ flowchart TD ### 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 may optionally get compressed for -a nice end result. +page PDFs are combined to create the document.
This PDF document may optionally get +compressed for a nice end result. ```mermaid flowchart LR From 8cafbd73900c58d0a0c43ba6b3c666e12f05918d Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 22:37:28 +1200 Subject: [PATCH 26/36] Fix directories calculation --- src/pdfbaker/baker.py | 4 +--- src/pdfbaker/config.py | 2 +- src/pdfbaker/document.py | 1 - src/pdfbaker/page.py | 1 - 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index d597369..51e5535 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -98,13 +98,11 @@ def __init__( logging.getLogger().setLevel(logging.INFO) self.keep_build = options.keep_build - # Start with defaults and apply any overrides base_config = DEFAULT_BAKER_CONFIG.copy() if options and options.default_config_overrides: base_config = deep_merge(base_config, options.default_config_overrides) - - # Set config directory and initialize base_config["directories"]["config"] = config_file.parent.resolve() + self.config = self.Configuration( baker=self, base_config=base_config, diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index 1f0b0bd..207363d 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -49,7 +49,7 @@ def __init__( raise ConfigurationError(f"Failed to load config file: {exc}") from exc # Determine all relevant directories - directories = {"config": config_file.parent.resolve()} + self["directories"] = directories = {"config": config_file.parent.resolve()} for directory in ( "documents", "pages", diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index a492c91..2686c49 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -69,7 +69,6 @@ def __init__( self.name = config_path.stem base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG) - base_config["directories"]["config"] = config_path.parent.resolve() self.document.log_trace_section( "Loading document configuration: %s", config_path diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index f616a7a..1a446c1 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -37,7 +37,6 @@ def __init__( self.page = page self.name = config_path.stem - base_config["directories"]["config"] = config_path.parent.resolve() self.page.log_trace_section("Loading page configuration: %s", config_path) super().__init__(base_config, config_path) From af97e515b0c77c674b3184fda828f540af60caba Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 22:58:04 +1200 Subject: [PATCH 27/36] Catch pyyaml ScannerError for more meaningful error message --- src/pdfbaker/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index 207363d..610725c 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -45,6 +45,10 @@ def __init__( 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 From 6e417485434e81fd4c23b227dbf9efa2a34837a1 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 19 Apr 2025 23:30:51 +1200 Subject: [PATCH 28/36] Fix CLI return code Click does the work, and doesn't care about return values. Also, we don't stop processing when one document fails, so we can see all problems not just one. So we need to error out if any of the documents failed. --- src/pdfbaker/__main__.py | 8 ++++---- src/pdfbaker/baker.py | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 4690c70..a875573 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -56,12 +56,12 @@ def bake( keep_build=keep_build, ) baker = PDFBaker(config_file, options=options) - baker.bake() - return 0 + 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 51e5535..7e9c8d7 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -110,7 +110,11 @@ def __init__( ) def bake(self) -> None: - """Create PDFs for all documents.""" + """Create PDFs for all documents. + + Returns: + bool: True if all documents were processed successfully, False if any failed + """ pdfs_created: list[Path] = [] failed_docs: list[tuple[str, str]] = [] @@ -156,6 +160,8 @@ def bake(self) -> None: if not self.keep_build: self.teardown() + return not failed_docs + def teardown(self) -> None: """Clean up (top-level) build directory after processing.""" self.log_debug_subsection( From 9aac8cae2a46ed2d53511f012bf5a58a927c80c8 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 00:39:29 +1200 Subject: [PATCH 29/36] Fix timing of teardown We just tear down the document build dir at the end, no matter what. The way it was before it would tear down, _then_ show an error message if there was an error. Now the output is more logical. (sure, same result) --- src/pdfbaker/baker.py | 4 ++-- src/pdfbaker/document.py | 40 ++++++++++++++++------------------------ 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 7e9c8d7..516562c 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -138,8 +138,8 @@ def bake(self) -> None: if isinstance(pdf_files, Path): pdf_files = [pdf_files] pdfs_created.extend(pdf_files) - if not self.keep_build: - doc.teardown() + if not self.keep_build: + doc.teardown() if pdfs_created: self.log_info("Successfully created PDFs:") diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index 2686c49..5c0ace4 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -157,30 +157,22 @@ def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: def process(self) -> Path | list[Path]: """Process document using standard processing.""" - try: - if "variants" in self.config: - # Multiple PDF documents - pdf_files = [] - for variant in self.config["variants"]: - self.log_info_subsection( - 'Processing variant "%s"...', variant["name"] - ) - variant_config = deep_merge(self.config, variant) - variant_config["variant"] = variant - 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 - - # Single PDF document - doc_config = render_config(self.config) - page_pdfs = self._process_pages(doc_config) - return self._finalize(page_pdfs, doc_config) - except Exception: - # Ensure build directory is cleaned up if processing fails - if not self.baker.keep_build: - self.teardown() - raise + if "variants" in self.config: + # Multiple PDF documents + pdf_files = [] + for variant in self.config["variants"]: + self.log_info_subsection('Processing variant "%s"...', variant["name"]) + variant_config = deep_merge(self.config, variant) + variant_config["variant"] = variant + 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 + + # 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.""" From 06c2c7630dae7e3dc13160b37312539b945b3d76 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 01:34:47 +1200 Subject: [PATCH 30/36] Fix settings inheritance This looks correct now. A new document will start with a default config that includes e.g. the "pages" directory to be "pages". So this is in its base config, not in its own config - but it still is relative to its own config. --- src/pdfbaker/config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index 610725c..ac5bfc8 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -63,15 +63,14 @@ def __init__( "dist", ): if directory in config.get("directories", {}): - # Set in this config file + # 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 or not yet relevant/mentioned + # Inherited (absolute) or default (relative to _this_ config) directories[directory] = self.resolve_path( - str(base_config["directories"][directory]), - directory=base_config["directories"]["config"], + str(base_config["directories"][directory]) ) super().__init__(deep_merge(base_config, config)) self["directories"] = directories From 651ce60e7ea3d400a6cfcabe3c76c20433b41e92 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 02:20:04 +1200 Subject: [PATCH 31/36] Improve logging * Proper logging setup instead of basicConfig() * INFO and below to stdout, WARNING and above to stderr --- src/pdfbaker/baker.py | 14 ++------------ src/pdfbaker/logging.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 516562c..16a7ad7 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -6,7 +6,6 @@ bake() delegates to its documents and reports back the end result. """ -import logging from dataclasses import dataclass from pathlib import Path from typing import Any @@ -14,7 +13,7 @@ from .config import PDFBakerConfiguration, deep_merge from .document import PDFBakerDocument from .errors import ConfigurationError -from .logging import TRACE, LoggingMixin +from .logging import LoggingMixin, setup_logging __all__ = ["PDFBaker", "PDFBakerOptions"] @@ -86,16 +85,7 @@ def __init__( """ super().__init__() options = options or PDFBakerOptions() - - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - if options.quiet: - logging.getLogger().setLevel(logging.ERROR) - elif options.trace: - logging.getLogger().setLevel(TRACE) - elif options.verbose: - logging.getLogger().setLevel(logging.DEBUG) - else: - logging.getLogger().setLevel(logging.INFO) + setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose) self.keep_build = options.keep_build base_config = DEFAULT_BAKER_CONFIG.copy() diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py index 932844a..c56cb7b 100644 --- a/src/pdfbaker/logging.py +++ b/src/pdfbaker/logging.py @@ -1,11 +1,14 @@ """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.""" @@ -71,6 +74,37 @@ def log_critical(self, msg: str, *args: Any, **kwargs: Any) -> None: 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) + + logger.handlers.clear() + 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): From 4c1b8c871f62edc634ab71d40057cb30c2b1c928 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 04:49:24 +1200 Subject: [PATCH 32/36] Enable compression in the regular example If Ghostscript is not installed, will fall back to uncompressed. So it's ok to always give it a go. --- examples/regular/config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/regular/config.yaml b/examples/regular/config.yaml index f546ede..a339870 100644 --- a/examples/regular/config.yaml +++ b/examples/regular/config.yaml @@ -1,4 +1,5 @@ filename: regular_example +compress_pdf: true pages: - intro - features From a49929abf1e830e421269f70d9bffbe37f3922f8 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 04:50:55 +1200 Subject: [PATCH 33/36] Adjust error message --- src/pdfbaker/document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index 5c0ace4..5922d69 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -211,7 +211,7 @@ def _finalize(self, pdf_files: list[Path], doc_config: dict[str, Any]) -> Path: self.log_info("PDF compressed successfully") except PDFCompressionError as exc: self.log_warning( - "Compression failed, using uncompressed version: %s", + "Compression failed, using uncompressed PDF: %s", exc, ) os.rename(combined_pdf, output_path) From 0a74d991e16f4c026e1ae5d107f1ef7df6c1558d Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 04:51:29 +1200 Subject: [PATCH 34/36] Remove only other console log handlers, not all --- src/pdfbaker/logging.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py index c56cb7b..759bda8 100644 --- a/src/pdfbaker/logging.py +++ b/src/pdfbaker/logging.py @@ -91,7 +91,12 @@ def setup_logging(quiet=False, trace=False, verbose=False) -> None: stderr_handler.setFormatter(formatter) stderr_handler.setLevel(logging.WARNING) - logger.handlers.clear() + # 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) From 6562adf851461bc124f67c6612422097f780f0e7 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 04:52:17 +1200 Subject: [PATCH 35/36] Handle "gs not found" specifically with own error message --- src/pdfbaker/pdf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pdfbaker/pdf.py b/src/pdfbaker/pdf.py index b1d68d0..1f7c02a 100644 --- a/src/pdfbaker/pdf.py +++ b/src/pdfbaker/pdf.py @@ -152,6 +152,8 @@ 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 PDFCompressionError(f"Ghostscript compression failed: {exc}") from exc From 1d8f65a1b7f6e4c5278774b3c01282409c122a46 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sun, 20 Apr 2025 04:53:04 +1200 Subject: [PATCH 36/36] Complete test suite Not the greatest of tests, but having 49 tests with a total test coverage of 91% is a good starting point. --- tests/examples.yaml | 6 -- tests/test_baker.py | 107 ++++++++++++++---- tests/test_cli.py | 77 +++++++++++++ tests/test_config.py | 119 +++++++++++++++++---- tests/test_document.py | 215 +++++++++++++++++++++++++++++++++++++ tests/test_pdf.py | 238 +++++++++++++++++++++++++++++++++++++++++ tests/test_render.py | 104 +++++++++++++++++- 7 files changed, 812 insertions(+), 54 deletions(-) delete mode 100644 tests/examples.yaml create mode 100644 tests/test_cli.py create mode 100644 tests/test_document.py create mode 100644 tests/test_pdf.py diff --git a/tests/examples.yaml b/tests/examples.yaml deleted file mode 100644 index d182401..0000000 --- a/tests/examples.yaml +++ /dev/null @@ -1,6 +0,0 @@ -documents: - - minimal - - regular - - variants - - "./custom_locations/your_directory" - - custom_processing diff --git a/tests/test_baker.py b/tests/test_baker.py index 1d68d2d..a1cbeb3 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -1,36 +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]") -def test_examples() -> None: - """Test all examples.""" - examples_dir = Path(__file__).parent.parent / "examples" + 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 build and dist directories + # 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) - # Copy and modify examples config - config = examples_dir / "examples.yaml" - test_config = test_dir / "examples.yaml" - shutil.copy(config, test_config) - - # Modify paths in config - with open(test_config, encoding="utf-8") as f: - content = f.read() - content = content.replace("build_dir: build", f"build_dir: {build_dir}") - content = content.replace("dist_dir: dist", f"dist_dir: {dist_dir}") - with open(test_config, "w", encoding="utf-8") as f: - f.write(content) - - # Run baker - options = PDFBakerOptions(quiet=True, keep_build=True) - baker = PDFBaker(test_config, options=options) - baker.bake() + 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_config.py b/tests/test_config.py index 61d5a53..4fb8502 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,13 +1,16 @@ -"""Tests for common functionality.""" +"""Tests for configuration functionality.""" from pathlib import Path import pytest +import yaml -from pdfbaker.config import PDFBakerConfiguration, deep_merge +from pdfbaker.config import PDFBakerConfiguration, deep_merge, render_config +from pdfbaker.errors import ConfigurationError -def test_deep_merge_basic(): +# Dictionary merging tests +def test_deep_merge_basic() -> None: """Test basic dictionary merging.""" base = { "title": "Document", @@ -34,8 +37,8 @@ def test_deep_merge_basic(): assert deep_merge(base, update) == expected -def test_deep_merge_nested(): - """Test merging of nested dictionaries.""" +def test_deep_merge_nested() -> None: + """Test nested dictionary merging.""" base = { "document": { "title": "Main Document", @@ -85,7 +88,7 @@ def test_deep_merge_nested(): assert deep_merge(base, update) == expected -def test_deep_merge_empty(): +def test_deep_merge_empty() -> None: """Test merging with empty dictionaries.""" base = { "title": "Document", @@ -101,44 +104,116 @@ def test_deep_merge_empty(): assert deep_merge(update, base) == base -def test_configuration_init_with_dict(): +# Configuration initialization tests +def test_configuration_init_with_dict(tmp_path: Path) -> None: """Test initializing Configuration with a dictionary.""" - config = PDFBakerConfiguration({}, {"title": "Document"}) + 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): +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("title: Document") + config_file.write_text(yaml.dump({"title": "Document"})) config = PDFBakerConfiguration({}, config_file) assert config["title"] == "Document" - assert config.directory == tmp_path + assert config["directories"]["config"] == tmp_path -def test_configuration_init_with_directory(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('{"title": "Document"}') + config_file.write_text(yaml.dump({"title": "Document"})) + config = PDFBakerConfiguration({}, config_file) assert config["title"] == "Document" - assert config.directory == tmp_path + assert config["directories"]["config"] == tmp_path -def test_configuration_resolve_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 = PDFBakerConfiguration({}, {"template": "test.yaml"}) - config.directory = Path("/base") # Set directory explicitly for testing - assert config.resolve_path("test.yaml") == Path("/base/test.yaml") + 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" ) - assert config.resolve_path({"name": "test.yaml"}) == Path("/base/test.yaml") + # Test named path + assert config.resolve_path({"name": "test.yaml"}) == tmp_path / "test.yaml" -def test_configuration_resolve_path_invalid(): + +def test_configuration_resolve_path_invalid(tmp_path: Path) -> None: """Test invalid path specification.""" - config = PDFBakerConfiguration({}, {}) - with pytest.raises(ValueError, match="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)