diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e72f9b3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+# GitHub Pages (nur deploy-pages-Job)
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.3', '8.4']
+
+ name: PHPUnit (PHP ${{ matrix.php }})
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, mbstring, xml, zip
+ coverage: none
+
+ - name: Install Composer dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Run PHPUnit
+ run: vendor/bin/phpunit
+
+ docs:
+ runs-on: ubuntu-latest
+ name: MkDocs build
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install MkDocs
+ run: pip install -r docs/requirements.txt
+
+ - name: Build documentation
+ env:
+ NO_MKDOCS_2_WARNING: '1'
+ run: mkdocs build --strict
+
+ - name: Upload Pages artifact
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: site
+
+ deploy-pages:
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+ needs: docs
+ runs-on: ubuntu-latest
+ name: Deploy to GitHub Pages
+
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..17622e7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+site/
+/vendor/
+/.phpunit.cache/
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..c54055a
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,16 @@
+# Read the Docs configuration
+# https://docs.readthedocs.io/en/stable/config-file/v2.html
+
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+
+mkdocs:
+ configuration: packages/Exporter/mkdocs.yml
+
+python:
+ install:
+ - requirements: packages/Exporter/docs/requirements.txt
diff --git a/README.md b/README.md
index 0b992b8..f45f5ee 100644
--- a/README.md
+++ b/README.md
@@ -1,640 +1,34 @@
-# Word Template Exporter #
-Package for easy Word exports in Laravel on given Templates.
-This package is based on [phpoffice/phpword](https://github.com/PHPOffice/PHPWord).
+# Word Template Exporter
+
+Laravel package for exporting Word templates with placeholders as `.docx` or `.pdf`. Based on [phpoffice/phpword](https://github.com/PHPOffice/PHPWord).
-
+
-## Table of Contents
-- [Installation](#installation)
-- [Configuration](#configuration-optional)
-- [How to use with ExportClasses](#how-to-use-with-exportclasses)
- - [Usage](#usage)
- - [Creating a New Export](#creating-a-new-export)
- - [Example](#example)
- - [Charts](#charts)
- - [Images](#images)
- - [Tables](#tables)
-- [How to use in Query (deprecated)](#how-to-use-in-query)
- - [Basic Export](#basic-export)
- - [Export as PDF](#export-as-pdf)
- - [Autoloading Relations](#autoloading-relations)
- - [Variables](#variables)
- - [Template Variables/Blocks](#template-variablesblocks)
- - [Relation Variable with Condition](#relation-variable-with-condition)
-- [Template](#template)
-
## Installation
-Exporter is installed via [Composer](https://getcomposer.org/).
-To [add a dependency](https://getcomposer.org/doc/04-schema.md#package-links) to Exporter in your project, either
-
-Run the following to use the latest stable version
-```sh
- composer require santwer/exporter
-```
-or if you want the latest master version
-```sh
- composer require santwer/exporter:dev-master
+```bash
+composer require santwer/exporter
```
-You can of course also manually edit your composer.json file
-```json
-{
- "require": {
- "santwer/exporter": "v0.3.*"
- }
-}
-```
+For PDF export, LibreOffice must be installed. See the full documentation.
-### Configuration (optional)
-To use pdf export it is needed to install LibreOffice. WordTemplateExporter is using the soffice command to convert the docx file to pdf.
+## Full documentation
-```sh
- sudo apt-get install libreoffice
-```
-#### Windows
-Download and install LibreOffice from [here](https://www.libreoffice.org/download/download/)
-Also add the path to the soffice command to the system environment variables.
+**Full documentation:** [santwerexporter.readthedocs.io](https://santwerexporter.readthedocs.io/) · **Source:** [github.com/santwer/Exporter](https://github.com/santwer/Exporter). The docs cover installation, configuration, export classes, template syntax, charts, images, tables, and more.
-```sh
- export PATH=$PATH:/path/to/soffice
-```
+**GitHub Pages (optional mirror):** CI deploys the MkDocs site from `main`/`master` if the repo uses **Settings → Pages → Build and deployment → Source: GitHub Actions**. URL pattern: `https://.github.io//` (set `site_url` in `mkdocs.yml` to that base if you need canonical links).
-## How to use with ExportClasses
+## Quick example
-### Usage
-You can use the WordExporter Facade as follows. The format of the exported file is determined by the file extension. Supported formats are .docx and .pdf.
```php
-use WordExporter\Facades\WordExporter;
-
-// Download as a Word file
-WordExporter::download(new MyExportClass(), 'final-word.docx');
-
-// Store the exported file
-WordExporter::store(new MyExportClass(), 'path/to/save/export.docx');
-
-// Store the exported file with an certain filename
-WordExporter::storeAs(new MyExportClass(), 'path/to/save/', 'export.docx');
-
-// Store the exported file with an certain filename as a batch
-WordExporter::batchStore(
- new Exportable(new MyExportClass(), 'path/to/save/', 'export.docx'),
- new Exportable(new MyExportClass1(), 'path/to/save/', 'export1.docx'),
- new Exportable(new MyExportClass2(), 'path/to/save/', 'export2.pdf'),
- );
-
-// Queue it for later processing
-WordExporter::queue(new MyExportClass(), 'path/to/save/export.docx');
-```
-
-### Creating a New Export
-You can create a new export using the following Artisan command:
-```sh
- php artisan make:word {className}
-```
-Replace {className} with the name of the new export class.
-
-Interfaces
-
-The object $export can be implemented with the following interfaces:
-
-
-| Interface | Description | Example |
-|-------------------|-------------------------------------------------------------------------------------------|-------------------------------------------------|
-| `FromWordTemplate` | Required. Interface indicating the export is from a Word template. | `class MyExportClass implements FromWordTemplate` |
-| `GlobalTokens` | Interface for providing global tokens for replacement in Word template. | `class MyGlobalTokens implements GlobalTokens` |
-| `TokensFromArray` | Interface for providing tokens from an array for replacement in Word template. | `class MyArrayTokens implements TokensFromArray` |
-| `TokensFromCollection` | Interface for providing tokens from a collection for replacement in Word template. | `class MyCollectionTokens implements TokensFromCollection` |
-| `TokensArray` | Interface for providing tokens from an array without any block data | |
-| `TokensFromObject` | Interface for providing tokens from an object/class without any block data | |
-| `TokensFromModel` | Interface for prodiding tokens from a model without any block data | |
-| `WithCharts` | Interface that allows you to replace text charts as array | |
-| `WithCheckboxes` | Interfaces that allows you to replace text with Checkboxes, either checked or not checked | |
-| `WithImages` | Interface that allows you to replace text with Images | |
-
-Each of these interfaces defines methods that need to be implemented according to the specific requirements of the export process. These methods typically involve returning an array of key-value pairs where keys represent placeholders in the Word template and values are the data to replace those placeholders with.
-
-### Example
-
-Word file:
-```word
-${TownDateFormat}
-
-
-${customer}
- ${name}, ${email}
- ${deliveryAddress.street}, ${deliveryAddress.city} ${deliveryAddress.postcode}
-${/customer}
-```
-
-Controller:
-```php
-namespace App\Http\Controllers;
-
-use App\Http\Export\FirstExport;
use Santwer\Exporter\Facade\WordExport;
-class HomeController extends Controller
-{
- public function index()
- {
- return WordExport::download(new FirstExport(), 'myExport.docx');
- }
-}
-```
-
-Export Class:
-
-```php
-namespace App\Http\Export;
-
-use Santwer\Exporter\Concerns\FromWordTemplate;
-use Santwer\Exporter\Concerns\GlobalTokens;
-use Santwer\Exporter\Concerns\TokensFromCollection;
-use Illuminate\Support\Collection;
-
-class FirstExport implements FromWordTemplate, TokensFromCollection, GlobalTokens
-{
- public function items(): Collection
- {
- return collect([
- [
- 'name' => 'Jane Smith',
- 'email' => 'jane.smith@example.com',
- 'deliveryAddress' => [
- 'street' => 'Main Street',
- 'city' => 'Metropolis',
- 'postcode' => '543210',
- ],
- ],
- [
- 'name' => 'Alice Johnson',
- 'email' => 'alice.johnson@example.com',
- 'deliveryAddress' => [
- 'street' => 'Elm Street',
- 'city' => 'Springfield',
- 'postcode' => '987654',
- ],
- ],
- [
- 'name' => 'Bob Williams',
- 'email' => 'bob.williams@example.com',
- 'deliveryAddress' => [
- 'street' => 'Oak Avenue',
- 'city' => 'Townsville',
- 'postcode' => '135792',
- ],
- ],
- ]);
- }
-
- public function blockName():string
- {
- return 'customer';
- }
-
- public function values(): array
- {
- return [
- 'TownDateFormat' => 'Townsville, '. now()->format('Y-m-d'),
- ];
- }
-
- public function itemTokens($item) : array
- {
- return $item;
- }
-
- public function wordTemplateFile(): string
- {
- return 'uploads/myDocFile.docx';
- }
-}
-```
-
-### Charts
-To replace a chart in a Word template, you can use the `WithCharts` interface. This interface requires the implementation of the `charts` method, which should return an array of key-value pairs where keys represent placeholders in the Word template and values are the data to replace those placeholders with.
-You can find all infos about the charts [here](https://phpoffice.github.io/PHPWord/usage/template.html#setchartvalue)
-
-Possible types are `'pie', 'doughnut', 'line', 'bar', 'stacked_bar', 'percent_stacked_bar', 'column', 'stacked_column', 'percent_stacked_column', 'area', 'radar', 'scatter'`
-
-```php
-namespace App\Http\Export;
-use Santwer\Exporter\Concerns\FromWordTemplate;
-use Santwer\Exporter\Concerns\GlobalTokens;
-use Santwer\Exporter\Concerns\WithCharts;
-use Santwer\Exporter\Concerns\TokensFromCollection;
-use Illuminate\Support\Collection;
-
-class FirstExport implements FromWordTemplate, TokensFromCollection, GlobalTokens, WithCharts
-{
- public function charts(): array
- {
-
- return [
- 'radar' => function () {
- $categories = array('A', 'B', 'C', 'D', 'E');
- $series1 = [1, 3, 2, 5, 4];
-
- $chart = new Chart('radar', $categories, $series1,
- [
- 'width' => 1000000*5,
- 'height' => 1000000*5,
- 'showLegend' => true
-
- ],'Series 1');
- $chart->addSeries($categories, [3, 4, 5, 1, 2], 'Series 2');
- return $chart;
- },
- ];
- }
-
- public function items(): Collection
- {
- return collect([
-
- ]);
- }
-
- ...
-}
-
-```
-
-### Images
-To replace an image in a Word template, you can use the `WithImages` interface. This interface requires the implementation of the `images` method, which should return an array of key-value pairs where keys represent placeholders in the Word template and values are the data to replace those placeholders with.
-
-For more Details how to set Images you can find [here](https://phpoffice.github.io/PHPWord/usage/template.html#setimagevalue)
-
-```php
-namespace App\Http\Export;
-
-use Santwer\Exporter\Concerns\FromWordTemplate;
-use Santwer\Exporter\Concerns\GlobalTokens;
-use Santwer\Exporter\Concerns\WithImages;
-use Santwer\Exporter\Concerns\TokensFromCollection;
-use Illuminate\Support\Collection;
-
-class FirstExport implements FromWordTemplate, TokensFromCollection, GlobalTokens, WithImages
-{
- public function images(): array
- {
- return [
- 'CompanyLogo' => public_path('images/logo.jpg'),
- 'UserLogo' => 'path' => public_path('images/logo.jpg'), 'width' => 100, 'height' => 100, 'ratio' => false,
- 'image1' => function () {
- return [
- 'path' => public_path('images/image1.jpg'),
- 'width' => 100,
- 'height' => 100,
- 'ratio' => false,
- ];
- },
- ];
- }
-
- public function items(): Collection
- {
- return collect([
-
- ]);
- }
-
- ...
-}
-
-```
-
-### Tables
-
-To replace a table in a Word template, you can use the `WithTables` interface. This interface requires the implementation of the `tables` method, which should return an array of key-value pairs where keys represent placeholders in the Word template and values are the data to replace those placeholders with.
-
-Note: For export in pdf at least the headers need the width of the columns. Not settet column widths might not be shown.
-
-```php
-namespace App\Http\Export;
-
-use Santwer\Exporter\Concerns\FromWordTemplate;
-use Santwer\Exporter\Concerns\GlobalTokens;
-use Santwer\Exporter\Concerns\WithTables;
-use Santwer\Exporter\Concerns\TokensFromCollection;
-use Illuminate\Support\Collection;
-
-class FirstExport implements FromWordTemplate, TokensFromCollection, GlobalTokens, WithTables
-{
- public function tables(): array
- {
- return [
- 'table1' => function () {
- return [
- 'headers' => [['width' => 3000, 'text' => 'Name'], 'Email', 'Address'],
- 'rows' => [
- ['Jane Smith', 'jane@smith.com', 'Main Street'],
- ['Alice Johnson', 'alice@johnson.com', 'Elm Street'],
- ['Bob Williams', 'bob@williams.com', 'Oak Avenue'],
- ],
- 'style' => [
- 'borderSize' => 6,
- 'borderColor' => 'green',
- 'width' => 6000,
- ],
- ];
- },
- ];
- }
-
- public function items(): Collection
- {
- return collect([
-
- ]);
- }
-
- ...
-}
-
-```
-
-## How to use in Query
-
-> **Deprecated:** This usage is **deprecated** and will be removed in a future major version. Please use export classes (FromWordTemplate) and the WordExport facade instead.
-
-Add Trait *Exportable*
-```php
-use Santwer\Exporter\Exportable;
-
-class User {
- use Exportable;
- ...
-}
-```
-
-As default all Variables are available of the Model.
-Add a Concern for special export fields.
-```php
-use Santwer\Exporter\Exportable;
-use Santwer\Exporter\Concerns\HasTokens;
-...
-
-class User implements HasTokens {
- use Exportable;
- ...
- public function exportTokens(): array
- {
- return [
- 'name' => $this->name,
- 'email' => $this->email,
- ];
- }
-}
-```
-
-You can add a fixed Template for each Model.
-```php
-use Santwer\Exporter\Exportable;
-use Santwer\Exporter\Concerns\HasTemplate;
-...
-
-class User implements HasTemplate {
- use Exportable;
- ...
- public function exportTemplate(): string
- {
- return '/template.docx';
- }
-}
+WordExport::download(new MyExport(), 'export.docx');
```
-You can also define own Blocknames for the use of the Model in a template.
-```php
-use Santwer\Exporter\Exportable;
-...
-
-class User implements HasTemplate {
- use Exportable;
- ...
- protected $exportBlock = 'customer';
-}
-```
-
-### Basic Export
-
-export() gives download response back.
-```php
-return User::where(...)
- ->template('templatefile.docx')
- ->export();
-```
-
-If you defined export template in Model.
-```php
-return User::where(...)
- ->export();
-```
-
-
-```php
-return User::where(...)
- ->template('templatefile.docx')
- ->store('storage/path');
-```
-
-```php
-return User::where(...)
- ->template('templatefile.docx')
- ->storeAs('storage/path', 'name.docx');
-```
-It's also possible to set Export-Command after Executing the query or on a Model after Find-Command
-```php
-return User::where(...)
- ->first()
- ->export('filename.docx', ['template' =>' templatefile.docx']);
-```
-```php
-return User::find(1234)
- ->export('filename.docx', ['template' =>' templatefile.docx']);
-```
-
-### Export as PDF
-Generally with the option format = pdf it is possible to export pdf.
-It is important that libreOffice is installed for that actions.
-```php
-return User::where(...)
- ->template('templatefile.docx')
- ->export(['format' => 'pdf']);
-```
-For short terms it is possible to call Export functions as PDF
-```php
-return User::where(...)
- ->exportPdf();
-```
-```php
-return User::where(...)
- ->exportFirstPdf();
-```
-
-
-### Autoloading Relations
-
-Before exporting, the Package is checking for defined Relations.
-If there is no related Variable it will automatically remove unneeded relations.
-This behavior can be changed within the config. For that it is needed to set up a config File exporter.php in config/
-
-```php
-return [
- 'removeRelations' => false,
-]
-```
-
-Also is the Package checking for relations that are not loaded yet. It will automatically load the Relations before exporting.
-Therefore, it is possible to reduce the Export-Code from
-```php
-return User::with('posts')
- ->with('posts.comments')
- ->with('posts.comments.user')
- ->template('templatefile.docx')
- ->export();
-```
-to
-```php
-return User::template('templatefile.docx')->export();
-```
-If the Relation is already loaded it will not be affected.
-
-### Variables
-
-It is possible to set up variables which are not affected by the Model or Query.
-```php
-use Santwer\Exporter\Processor\GlobalVariables;
-...
- GlobalVariables::setVariable('Date', now()->format('d.m.Y'));
-```
-
-```php
-use Santwer\Exporter\Processor\GlobalVariables;
-...
- GlobalVariables::setVariables([
- 'Time' => now()->format('H:i'),
- 'Date' => now()->format('Y-m-d'),
- ]);
-```
-
-### Template Variables/Blocks
-
-In the template the package always looks for loops/Blocks (except for Global Variables).
-By Default the Blockname is the name of the table. It is also possible to use an own name for that.
-```php
-use Santwer\Exporter\Exportable;
-...
-
-class User implements HasTemplate {
- use Exportable;
- ...
- protected $exportBlock = 'customer';
-}
-```
-
-To export Customers with Name and e-mail addresses, it is needed to add the Block.
-```word
-${customer}
- ${name}, ${email}
-${/customer}
-```
-
-If there is a Relation within the customer.
-
-```php
-use Santwer\Exporter\Exportable;
-...
-
-class User implements HasTemplate {
- use Exportable;
- ...
- protected $exportBlock = 'customer';
-
- public function deliveryAddress()
- {
- return $this->hasOne(Address::class);
- }
-
-}
-```
-```word
-${customer}
- ${name}, ${email}
- ${deliveryAddress.street}, ${deliveryAddress.city} ${deliveryAddress.postcode}
-${/customer}
-```
-
-If there is a Relation with a collection of Entries.
-
-```php
-use Santwer\Exporter\Exportable;
-...
-
-class User implements HasTemplate {
- use Exportable;
- ...
- protected $exportBlock = 'customer';
-
- public function orders()
- {
- return $this->hasOne(Order::class);
- }
-
- public function deliveryAddress()
- {
- return $this->hasOne(Address::class);
- }
-
-}
-```
-```word
-${customer}
- ${name}, ${email}
- ${orders}
- ${orders.product_id} ${orders.order_date}
- ${deliveryAddress.street}, ${deliveryAddress.city} ${deliveryAddress.postcode}
- ${/orders}
-${/customer}
-```
-
-For each Relation it will add up its relation block name.
-
-
-### Relation Variable with Condition
-
-It is possible to define Variables which are Related to many Entries. Therefore, you can
-reduce it to one relation and get a certain Value in the relation.
-
-It will only select one entry.
-
-```word
-${customer}
- ${name}, ${email}
- Order 15: ${orders:15.product_id} ${orders:15.order_date}
-${/customer}
-```
-
-However, you can set up one where condition to get the entry.
-```word
-${customer}
- ${name}, ${email}
- Order 15: ${orders:product_id,=,4.product_id} ${orders:product_id,=,4.order_date}
-${/customer}
-```
-
-If the Entry is not found the Values of the Model will be null.
-
-## Template
-
-The Template should be DOCX or DOC. The File will be cloned and saved in the sys_temp_folder as long it has no store option.
-For PDF exports it is needed to use LibreOffice. Therefore, the soffice command needs to be executable.
-
-For the Templateprocessing it uses [phpoffice/phpword](https://github.com/PHPOffice/PHPWord)
-More Infos you can find [here](https://phpword.readthedocs.io/en/latest/templates-processing.html)
\ No newline at end of file
+Create an export class that implements `FromWordTemplate` (and optional concerns like `GlobalTokens`, `TokensFromCollection`). See the docs for export classes, template syntax, and configuration.
diff --git a/composer.json b/composer.json
index 6ad7cfb..5d58f01 100644
--- a/composer.json
+++ b/composer.json
@@ -4,9 +4,10 @@
"type": "library",
"license": "MIT",
"require": {
- "laravel/framework": "^9.0|^10|^11|^12",
- "php": "^8.0|^8.2|^8.3|^8.3",
- "phpoffice/phpword": "^v1.2"
+ "laravel/framework": "^10|^11|^12",
+ "php": "^8.3|^8.4",
+ "phpoffice/phpword": "^v1.4",
+ "symfony/process": "^6.0|^7.0"
},
"autoload": {
"psr-4": {
@@ -21,7 +22,7 @@
],
"minimum-stability": "dev",
"require-dev": {
- "orchestra/testbench": "^9.0|^10.0",
+ "orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^10.0|^11.0",
"mockery/mockery": "^1.6"
},
diff --git a/docs/batch-and-queue.md b/docs/batch-and-queue.md
new file mode 100644
index 0000000..0d629f4
--- /dev/null
+++ b/docs/batch-and-queue.md
@@ -0,0 +1,31 @@
+# Batch & Queue
+
+## batchStore
+
+Use `WordExport::batchStore()` to run multiple exports in one go. Each argument must be an `Santwer\Exporter\Exportables\Exportable` instance that wraps: the export class instance, the directory path, and the filename.
+
+```php
+use Santwer\Exporter\Facade\WordExport;
+use Santwer\Exporter\Exportables\Exportable;
+
+WordExport::batchStore(
+ new Exportable(new ReportExport(), 'exports/', 'report.docx'),
+ new Exportable(new InvoiceExport(), 'exports/', 'invoice.pdf'),
+);
+```
+
+The package processes each export (Word or PDF depending on file extension) and writes files to the given directory. Batch size is controlled by `config('exporter.batch_size')` (default 200).
+
+## queue
+
+Use `WordExport::queue()` to generate a file asynchronously via Laravel’s queue:
+
+```php
+WordExport::queue(new MyExport(), 'exports/report.docx');
+```
+
+A job is dispatched; when it runs, the file is created at the given path. Ensure your queue worker is running and that the export class and template are available in the worker environment.
+
+## Temp folder
+
+Exports use a temporary directory for intermediate files (e.g. before PDF conversion). Default is PHP’s `sys_get_temp_dir()`. Override with `config('exporter.temp_folder')` or the `EXPORTER_TEMP_FOLDER` environment variable. Set `temp_folder_relative` (or `EXPORTER_TEMP_FOLDER_RELATIVE`) if that path is relative. The temp folder is used only during processing; final files are written to the paths you pass to `store`, `storeAs`, or `batchStore`.
diff --git a/docs/charts.md b/docs/charts.md
new file mode 100644
index 0000000..cb2fc7b
--- /dev/null
+++ b/docs/charts.md
@@ -0,0 +1,58 @@
+# Charts
+
+Implement the `WithCharts` interface to replace chart placeholders in your Word template with data-driven charts.
+
+## Interface
+
+```php
+interface WithCharts
+{
+ public function charts(): array;
+}
+```
+
+Return an array mapping placeholder names to chart objects or closures that return chart objects. Keys are the placeholder names in the template; values are PHPWord chart instances or closures.
+
+## Supported chart types
+
+- `pie`, `doughnut`
+- `line`, `bar`, `stacked_bar`, `percent_stacked_bar`
+- `column`, `stacked_column`, `percent_stacked_column`
+- `area`, `radar`, `scatter`
+
+## Example
+
+```php
+use Santwer\Exporter\Concerns\FromWordTemplate;
+use Santwer\Exporter\Concerns\WithCharts;
+use PhpOffice\PhpWord\Element\Chart;
+
+class ReportExport implements FromWordTemplate, WithCharts
+{
+ public function wordTemplateFile(): string
+ {
+ return 'templates/report.docx';
+ }
+
+ public function charts(): array
+ {
+ return [
+ 'salesChart' => function () {
+ $categories = ['Q1', 'Q2', 'Q3', 'Q4'];
+ $series = [100, 150, 200, 180];
+ return new Chart('column', $categories, $series, [
+ 'width' => 1000000 * 5,
+ 'height' => 1000000 * 5,
+ 'showLegend' => true,
+ ], 'Sales');
+ },
+ ];
+ }
+}
+```
+
+In the template, use a placeholder with the same name as the key (e.g. `salesChart`). The package replaces it with the chart.
+
+## PHPWord reference
+
+For chart options and template usage, see [PHPWord template documentation](https://phpoffice.github.io/PHPWord/usage/template.html#setchartvalue).
diff --git a/docs/checkboxes.md b/docs/checkboxes.md
new file mode 100644
index 0000000..6f03f51
--- /dev/null
+++ b/docs/checkboxes.md
@@ -0,0 +1,39 @@
+# Checkboxes
+
+Implement the `WithCheckboxes` interface to replace text placeholders in your Word template with checked or unchecked checkboxes.
+
+## Interface
+
+```php
+interface WithCheckboxes
+{
+ public function checkboxes(): array;
+}
+```
+
+Return an array mapping placeholder names to boolean values. Keys are the placeholder names in the template; values are `true` (checked) or `false` (unchecked).
+
+## Example
+
+```php
+use Santwer\Exporter\Concerns\FromWordTemplate;
+use Santwer\Exporter\Concerns\WithCheckboxes;
+
+class FormExport implements FromWordTemplate, WithCheckboxes
+{
+ public function wordTemplateFile(): string
+ {
+ return 'templates/form.docx';
+ }
+
+ public function checkboxes(): array
+ {
+ return [
+ 'agreed' => true,
+ 'newsletter' => false,
+ ];
+ }
+}
+```
+
+In the template, use placeholders with the same names (e.g. `agreed`, `newsletter`). They are replaced with a checked or unchecked checkbox according to the boolean value.
diff --git a/docs/concerns.md b/docs/concerns.md
new file mode 100644
index 0000000..33c3612
--- /dev/null
+++ b/docs/concerns.md
@@ -0,0 +1,188 @@
+# Concerns & Interfaces
+
+Export classes implement interfaces (concerns) to provide data and features. Every export class must implement **FromWordTemplate**. All others are optional.
+
+## Required: FromWordTemplate
+
+```php
+interface FromWordTemplate
+{
+ public function wordTemplateFile(): string;
+}
+```
+
+- Return a valid file path (relative to `storage_path()` or absolute).
+- Supported formats: `.docx`, `.doc`.
+- See [Template Resolution](template-resolution.md) for resolution order.
+
+## Token interfaces
+
+### GlobalTokens
+
+Global placeholders (not inside blocks).
+
+```php
+interface GlobalTokens
+{
+ public function values(): array;
+}
+```
+
+- Return key-value pairs: keys match placeholders `${key}` in the template.
+- Values can be strings, numbers, or other primitives.
+
+Example:
+
+```php
+public function values(): array
+{
+ return [
+ 'Date' => now()->format('Y-m-d'),
+ 'CompanyName' => 'My Company',
+ ];
+}
+```
+
+### TokensFromCollection
+
+Collection-based blocks (loops).
+
+```php
+interface TokensFromCollection
+{
+ public function blockName(): string|array;
+ public function items(): Collection;
+ public function itemTokens($item): array;
+}
+```
+
+- `blockName()`: name of the block in the template (e.g. `'customer'` for `${customer}...${/customer}`). Can be an array for multiple blocks.
+- `items()`: collection of items to iterate.
+- `itemTokens($item)`: map each item to an array of placeholder values. Nested arrays are supported for nested blocks.
+
+Example:
+
+```php
+public function blockName(): string { return 'customer'; }
+
+public function items(): Collection
+{
+ return collect([
+ ['name' => 'Jane', 'email' => 'jane@example.com'],
+ ['name' => 'Bob', 'email' => 'bob@example.com'],
+ ]);
+}
+
+public function itemTokens($item): array { return $item; }
+```
+
+### TokensFromArray
+
+Same as TokensFromCollection but `items()` returns an array. Internally converted to a collection.
+
+```php
+interface TokensFromArray
+{
+ public function blockName(): string|array;
+ public function items(): array;
+ public function itemTokens($item): array;
+}
+```
+
+### TokensArray
+
+Simple key-value tokens without blocks.
+
+```php
+interface TokensArray
+{
+ public function tokens(): array;
+}
+```
+
+### TokensFromObject
+
+Single object converted to tokens (no blocks). Object is serialized to an array internally.
+
+```php
+interface TokensFromObject
+{
+ public function tokens(): object;
+}
+```
+
+### TokensFromModel
+
+Single Eloquent model converted to tokens (no blocks).
+
+```php
+interface TokensFromModel
+{
+ public function model(): Model;
+}
+```
+
+## Feature interfaces
+
+### WithCharts
+
+Replace chart placeholders. See [Charts](charts.md).
+
+```php
+interface WithCharts
+{
+ public function charts(): array;
+}
+```
+
+### WithImages
+
+Replace image placeholders. See [Images](images.md).
+
+```php
+interface WithImages
+{
+ public function images(): array;
+}
+```
+
+### WithTables
+
+Replace table placeholders. See [Tables](tables.md).
+
+```php
+interface WithTables
+{
+ public function tables(): array;
+}
+```
+
+### WithCheckboxes
+
+Replace text with checked/unchecked checkboxes. See [Checkboxes](checkboxes.md).
+
+```php
+interface WithCheckboxes
+{
+ public function checkboxes(): array;
+}
+```
+
+## Combining interfaces
+
+You can combine any of the optional concerns with `FromWordTemplate`:
+
+```php
+class InvoiceExport implements
+ FromWordTemplate,
+ GlobalTokens,
+ TokensFromCollection,
+ WithCharts,
+ WithImages,
+ WithTables
+{
+ // Implement all required methods
+}
+```
+
+`FromWordTemplate` is always required; the rest are optional.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..f410a96
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,32 @@
+# Configuration
+
+Publish and edit the config file:
+
+```bash
+php artisan vendor:publish --tag=exporter-config
+```
+
+Config file: `config/exporter.php`.
+
+## Options
+
+| Key | Type | Default | Description |
+|-----|------|--------|-------------|
+| `batch_size` | int | `200` | Number of exports per batch. Can be overridden via `EXPORTER_BATCH_SIZE`. |
+| `removeRelations` | bool | `true` | When using Exportable trait: remove relations not referenced in the template. |
+| `relationsFromTemplate` | bool | `false` | Derive which relations to load from template placeholders. |
+| `temp_folder` | string | `sys_get_temp_dir()` | Directory for temporary export files. Override with `EXPORTER_TEMP_FOLDER`. |
+| `temp_folder_relative` | bool | `false` | Whether `temp_folder` is relative. Override with `EXPORTER_TEMP_FOLDER_RELATIVE`. |
+| `word2pdf` | array | see below | LibreOffice PDF conversion. |
+
+## `word2pdf`
+
+```php
+'word2pdf' => [
+ 'soffice_prefix' => env('SOFFICE_PATH', ''),
+ 'command' => env('SOFFICE_COMMAND_PATH', 'soffice --convert-to pdf --outdir ? ? --headless'),
+],
+```
+
+- **`soffice_prefix`**: Prefix for the `soffice` command (e.g. full path on Windows). Set `SOFFICE_PATH` in `.env` if `soffice` is not on `PATH`.
+- **`command`**: Full command template; `?` placeholders are replaced with output directory and input file. Override with `SOFFICE_COMMAND_PATH` if needed.
diff --git a/docs/export-classes.md b/docs/export-classes.md
new file mode 100644
index 0000000..bbdb96d
--- /dev/null
+++ b/docs/export-classes.md
@@ -0,0 +1,79 @@
+# Export Classes
+
+Export classes are the main way to use the package. Each export class implements at least `FromWordTemplate` and optionally other concerns (GlobalTokens, TokensFromCollection, WithCharts, etc.).
+
+## Required: FromWordTemplate
+
+Every export class must implement `FromWordTemplate`, which defines the Word template file:
+
+```php
+public function wordTemplateFile(): string
+```
+
+Return a path relative to `storage_path()` or an absolute path. See [Template Resolution](template-resolution.md).
+
+## Creating an export class
+
+Use the Artisan command:
+
+```bash
+php artisan make:word {ClassName}
+```
+
+Replace `{ClassName}` with the name of your export class (e.g. `InvoiceExport`). The class is created in your application (e.g. `app/Http/Export/`).
+
+## WordExport facade
+
+Use the `WordExport` facade to run exports. The output format is determined by the file extension you pass.
+
+```php
+use Santwer\Exporter\Facade\WordExport;
+```
+
+### download($export, $fileName)
+
+Returns an HTTP response that triggers a file download.
+
+```php
+WordExport::download(new MyExport(), 'export.docx');
+WordExport::download(new MyExport(), 'export.pdf'); // requires LibreOffice
+```
+
+### store($export, $filePath)
+
+Saves the exported file to the given path. Filename is generated.
+
+```php
+WordExport::store(new MyExport(), 'exports/');
+```
+
+### storeAs($export, $filePath, $name)
+
+Saves with a specific filename.
+
+```php
+WordExport::storeAs(new MyExport(), 'exports/', 'invoice-2024.docx');
+```
+
+### queue($export, $filePath)
+
+Dispatches a job to generate the file asynchronously.
+
+```php
+WordExport::queue(new MyExport(), 'exports/report.docx');
+```
+
+### batchStore(...$exports)
+
+Stores multiple exports. Each argument must be an `Santwer\Exporter\Exportables\Exportable` instance wrapping the export class, path, and filename.
+
+```php
+use Santwer\Exporter\Exportables\Exportable;
+
+WordExport::batchStore(
+ new Exportable(new MyExport1(), 'exports/', 'one.docx'),
+ new Exportable(new MyExport2(), 'exports/', 'two.pdf'),
+);
+```
+
+See [Batch & Queue](batch-and-queue.md) for details.
diff --git a/docs/exportable-deprecated.md b/docs/exportable-deprecated.md
new file mode 100644
index 0000000..5bf7624
--- /dev/null
+++ b/docs/exportable-deprecated.md
@@ -0,0 +1,150 @@
+# Exportable (Deprecated)
+
+> **Deprecated.** This usage path (Exportable trait + Eloquent builder methods like `template()`, `export()`, `exportPdf()`) is **deprecated** and will be removed in a future major version. Use export classes (FromWordTemplate) and the `WordExport` facade instead.
+
+This page documents the deprecated model-based export flow for existing code. New code should use [Export Classes](export-classes.md) and the [WordExport facade](export-classes.md#wordexport-facade).
+
+## Exportable trait
+
+Add the trait to your Eloquent model:
+
+```php
+use Santwer\Exporter\Exportable;
+
+class User extends Model
+{
+ use Exportable;
+}
+```
+
+By default, all model attributes (and loaded relations) are available as template tokens (e.g. `${name}`, `${email}`).
+
+## HasTokens
+
+Implement `HasTokens` to define which attributes are exported:
+
+```php
+use Santwer\Exporter\Exportable;
+use Santwer\Exporter\Concerns\HasTokens;
+
+class User extends Model implements HasTokens
+{
+ use Exportable;
+
+ public function exportTokens(): array
+ {
+ return [
+ 'fullName' => $this->first_name . ' ' . $this->last_name,
+ 'email' => $this->email,
+ ];
+ }
+}
+```
+
+Only the keys returned by `exportTokens()` are available in the template.
+
+## HasTemplate
+
+Implement `HasTemplate` to set a default template for the model:
+
+```php
+use Santwer\Exporter\Exportable;
+use Santwer\Exporter\Concerns\HasTemplate;
+
+class User extends Model implements HasTemplate
+{
+ use Exportable;
+
+ public function exportTemplate(): string
+ {
+ return 'templates/user-template.docx';
+ }
+}
+```
+
+If you do not call `->template(...)` on the builder, this path is used.
+
+## Block name
+
+By default the block name is the table name (e.g. `users` for `${users}...${/users}`). Override with `$exportBlock`:
+
+```php
+protected $exportBlock = 'customer';
+```
+
+Then use `${customer}...${/customer}` in the template.
+
+## Builder methods
+
+- **template($path)**: Set the Word template path. Must be called before export methods.
+- **export()**: Returns a download response for all matching records.
+- **exportFirst()**: Exports only the first record.
+- **exportPdf()**: Exports as PDF (requires LibreOffice).
+- **exportFirstPdf()**: First record as PDF.
+- **store($path)**: Saves the file to storage; filename is generated.
+- **storeAs($path, $name)**: Saves with a specific filename.
+
+Example:
+
+```php
+User::where('active', true)
+ ->template('templates/users.docx')
+ ->export();
+
+User::find(1)->export('user.docx', ['template' => 'templates/user.docx']);
+```
+
+## Relation autoloading
+
+The package can infer which relations are needed from template placeholders and load them automatically. So instead of:
+
+```php
+User::with('posts')->with('posts.comments')->template('templates/users.docx')->export();
+```
+
+you can use:
+
+```php
+User::template('templates/users.docx')->export();
+```
+
+This behaviour can be tuned with `config('exporter.removeRelations')` and `config('exporter.relationsFromTemplate')`.
+
+## Condition-based relation variables
+
+In the template you can reference a single related record by ID or condition:
+
+- `${orders:15.product_id}` — order with ID 15.
+- `${orders:product_id,=,4.product_id}` — order where `product_id` equals 4.
+
+If no record matches, the value is null.
+
+## Migration to export classes
+
+**Before (deprecated):**
+
+```php
+class User extends Model { use Exportable; }
+User::where('active', true)->template('templates/users.docx')->export();
+```
+
+**After (recommended):**
+
+```php
+class UserExport implements FromWordTemplate, TokensFromCollection
+{
+ public function __construct(private Collection $users) {}
+
+ public function wordTemplateFile(): string { return 'templates/users.docx'; }
+ public function blockName(): string { return 'users'; }
+ public function items(): Collection { return $this->users; }
+ public function itemTokens($user): array { return $user->toArray(); }
+}
+
+WordExport::download(
+ new UserExport(User::where('active', true)->get()),
+ 'users.docx'
+);
+```
+
+Use [Export Classes](export-classes.md) and [Concerns](concerns.md) for all new exports.
diff --git a/docs/images.md b/docs/images.md
new file mode 100644
index 0000000..0e33b74
--- /dev/null
+++ b/docs/images.md
@@ -0,0 +1,64 @@
+# Images
+
+Implement the `WithImages` interface to replace image placeholders in your Word template with images.
+
+## Interface
+
+```php
+interface WithImages
+{
+ public function images(): array;
+}
+```
+
+Return an array mapping placeholder names to image data. Keys are the placeholder names in the template.
+
+## Value types
+
+Each value can be:
+
+1. **String**: Path to the image file (absolute or relative to `public_path()`).
+2. **Array**: `['path' => string, 'width' => int, 'height' => int, 'ratio' => bool]`. `ratio` controls aspect ratio preservation.
+3. **Closure**: A callable that returns a string path or an array as above.
+
+## Example
+
+```php
+use Santwer\Exporter\Concerns\FromWordTemplate;
+use Santwer\Exporter\Concerns\WithImages;
+
+class ReportExport implements FromWordTemplate, WithImages
+{
+ public function wordTemplateFile(): string
+ {
+ return 'templates/report.docx';
+ }
+
+ public function images(): array
+ {
+ return [
+ 'CompanyLogo' => public_path('images/logo.jpg'),
+ 'UserAvatar' => [
+ 'path' => public_path('images/avatar.jpg'),
+ 'width' => 100,
+ 'height' => 100,
+ 'ratio' => false,
+ ],
+ 'HeaderImage' => function () {
+ return [
+ 'path' => public_path('images/header.png'),
+ 'width' => 600,
+ 'height' => 200,
+ 'ratio' => true,
+ ];
+ },
+ ];
+ }
+}
+```
+
+In the template, use placeholders with the same names (e.g. `CompanyLogo`, `UserAvatar`, `HeaderImage`). They are replaced with the corresponding images.
+
+## PHPWord reference
+
+For more options, see [PHPWord template images](https://phpoffice.github.io/PHPWord/usage/template.html#setimagevalue).
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..3da1705
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,80 @@
+# Word Template Exporter
+
+A Laravel package for exporting Word templates with placeholders as `.docx` or `.pdf` files. Placeholders (e.g. `${variable}`, `${block}...${/block}`) are filled with data from export classes, Eloquent models, or collections.
+
+**Repository:** [github.com/santwer/Exporter](https://github.com/santwer/Exporter) · **Packagist:** [santwer/exporter](https://packagist.org/packages/santwer/exporter) · **Documentation:** [santwerexporter.readthedocs.io](https://santwerexporter.readthedocs.io/)
+
+## Features
+
+- **Word template processing**: Placeholders in Word templates are replaced with your data.
+- **Export formats**: `.docx` (Word) and `.pdf` (via LibreOffice).
+- **Data sources**: Export classes implementing concerns/interfaces, or Eloquent models with the Exportable trait.
+- **Extended features**: Charts, images, tables, checkboxes.
+- **Relations**: Automatic loading and processing of Eloquent relations based on template placeholders.
+- **Security**: Automatic XML escaping for all text values with UTF-8 support.
+
+## Tech Stack
+
+- **PHPWord** (`phpoffice/phpword`): Template processing and Word file generation.
+- **LibreOffice**: PDF conversion via the `soffice` command.
+- **Laravel**: Service provider, facades, Eloquent builder extensions, queue jobs.
+
+## Architecture
+
+### Data Flow
+
+The export class (or Eloquent model) provides the **template path** and the **data**. The processor loads the template, fills an internal Exporter with that data, then runs PHPWord’s TemplateProcessor to produce the file.
+
+**Export class path (recommended):**
+
+```
+Export class (wordTemplateFile() + concerns: values, items, charts, images, tables, …)
+ │
+ ▼
+WordTemplateExporter::processFile($export)
+ │ Resolves template path, creates Exporter(templatePath), fills it from export (setValues)
+ ▼
+Exporter (holds template path + values, blocks, charts, images, tables)
+ │
+ ▼
+Exporter::getProcessedFile() / getProcessedConvertedFile()
+ │ process() → TemplateProcessor (PHPWord) replaces placeholders, saveAs(.docx)
+ ▼
+.docx file
+ │ If format is PDF:
+ ▼
+PDFExporter::docxToPdf() (LibreOffice soffice)
+ │
+ ▼
+.pdf file
+```
+
+**Model path (deprecated):** Eloquent builder with Exportable → ModelProcessor builds data from the model → Exporter (same as above) → .docx or .pdf.
+
+1. **Export class** (or model) provides the template path and all data (tokens, blocks, charts, images, tables, checkboxes).
+2. **WordTemplateExporter** (or ModelProcessor) resolves the template file path, creates an **Exporter** instance with that path, and fills it from the export/model.
+3. **Exporter** holds the template path and data; when output is requested it runs **process()**, which uses **TemplateProcessor** (PHPWord) to replace placeholders and write the `.docx`.
+4. For PDF, **PDFExporter** converts the `.docx` to `.pdf` via LibreOffice.
+
+### Package Structure
+
+| Directory | Purpose |
+|-----------|---------|
+| `Concerns/` | Interfaces for export classes (FromWordTemplate, GlobalTokens, WithCharts, etc.) |
+| `Processor/` | WordTemplateExporter, ModelProcessor, TemplateProcessor, Exporter, PDFExporter |
+| `Eloquent/` | Builder extensions for models |
+| `Jobs/` | Batch export and Word-to-PDF queue jobs |
+| `Facade/` | WordExport facade |
+| `Traits/` | Exportable trait for models |
+| `Commands/` | `make:word` Artisan command |
+
+## Usage Paths
+
+1. **Export classes** (recommended): Implement `FromWordTemplate` and optional concerns; use the `WordExport` facade.
+2. **Exportable trait** (deprecated): Use the trait on models and call `->template()->export()` on the builder. See [Exportable (Deprecated)](exportable-deprecated.md).
+
+Start with [Installation](installation.md) and [Quick Start](quickstart.md).
+
+---
+
+[View on GitHub](https://github.com/santwer/Exporter) · [Report issue](https://github.com/santwer/Exporter/issues) · [Install from Packagist](https://packagist.org/packages/santwer/exporter)
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..6511b42
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,46 @@
+# Installation
+
+## Composer
+
+Install via [Composer](https://getcomposer.org/):
+
+```bash
+composer require santwer/exporter
+```
+
+Or add to your `composer.json`:
+
+```json
+{
+ "require": {
+ "santwer/exporter": "^0.5"
+ }
+}
+```
+
+Then run `composer update`.
+
+## LibreOffice (for PDF export)
+
+PDF export uses LibreOffice’s `soffice` command to convert `.docx` to `.pdf`. Install LibreOffice if you need PDF output.
+
+### Linux
+
+```bash
+sudo apt-get install libreoffice
+```
+
+### Windows
+
+1. Download and install [LibreOffice](https://www.libreoffice.org/download/download/).
+2. Add the directory containing `soffice` (or `soffice.exe`) to your system `PATH`, or set the `SOFFICE_PATH` environment variable (see [Configuration](configuration.md)).
+
+Example (Unix-style, adjust path as needed):
+
+```bash
+export PATH=$PATH:/path/to/libreoffice/program
+```
+
+## Security note
+
+PDF conversion runs the `soffice` command with file paths from your application. Only use templates from trusted sources and sanitize any user-provided template paths to avoid command injection.
diff --git a/docs/processors.md b/docs/processors.md
new file mode 100644
index 0000000..7acf62e
--- /dev/null
+++ b/docs/processors.md
@@ -0,0 +1,39 @@
+# Processors (Reference)
+
+Internal reference for the processor classes that drive template processing and export. You typically use the package via the [WordExport facade](export-classes.md#wordexport-facade) and export classes; these classes are what run under the hood.
+
+## WordTemplateExporter
+
+Main processor for export classes that implement concerns/interfaces. It detects which concerns are implemented, loads the template file, and builds an `Exporter` instance with values, blocks, charts, images, tables, and checkboxes. Key method: `processFile(object $export): Exporter`. Concern processing order: GlobalTokens / TokensArray / TokensFromObject / TokensFromModel, then TokensFromCollection / TokensFromArray, then WithCharts, WithImages, WithTables, WithCheckboxes.
+
+## ModelProcessor
+
+Used for Eloquent models with the Exportable trait (deprecated path). Checks for the Exportable trait, discovers model relations via reflection, and prepares export attributes. Key methods: `checkForExportable(?object $class): bool`, `getAllRelations(Model $model, $heritage = 'all'): array`. Relation results are cached.
+
+## TemplateProcessor
+
+Extends PHPWord's template processor. Handles placeholder replacement and recursive block cloning (loops). Key methods: `setValue()`, `replace()`, `cloneRecrusiveBlocks()`, `arrayListRecusive()`. Used by `Exporter` to fill the Word document.
+
+**XML Escaping:** The `replace()` method automatically escapes all text values for XML safety: `&`, `<`, `>`, `"`, `'` → XML entities. With `allowTags` parameter, only `&`, `"`, `'` are escaped while `<` and `>` are preserved. Supports UTF-8 and prevents double-escaping. See [XML Escaping & Security](xml-escaping.md).
+
+## Exporter
+
+Central handler that holds the template file and collected values, blocks, charts, images, and tables. Coordinates `TemplateProcessor` and triggers processing. Key methods: `setBlockValues()`, `setValue()` / `setArrayValues()`, `setChart()` / `setImage()` / `setTables()` / `setCheckbox()`, `process()`, `getProcessedFile()`, `getProcessedConvertedFile()` for PDF conversion.
+
+**Output Escaping:** Disables PHPWord's internal XML escaping (`Settings::setOutputEscapingEnabled(false)`) to prevent double-escaping, as the `TemplateProcessor::replace()` method handles all escaping.
+
+## PDFExporter
+
+Handles conversion of Word or HTML to PDF via LibreOffice's `soffice` command. Key methods: `docxToPdf($docX, $path = null)`, `html2Pdf(string $html, ?string $path = null)`. Uses `config('exporter.word2pdf')` for command and prefix.
+
+## BatchProcessor
+
+Trait used for processing multiple exports (e.g. batch store). Manages temp files and PDF conversion for batches. Key methods: `preProcess()`, `subProcess()`, `whenDone()` / `whenPDFDone()` for callbacks.
+
+## VariablesConditionProcessor
+
+Parses condition-based relation variables (e.g. `${relation:id.field}`, `${relation:field,=,value.field}`) and reduces them for relation detection. Key methods: `getReducedForRelations(array $variables)`, `getRelatedConditions(array $variables)`.
+
+## GlobalVariables
+
+Static helper for global placeholder values used across exports. Key methods: `setVariable()`, `setVariables()`, `getGlobalVariables()`. Used by the Exporter during `process()`.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..7b9915e
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,78 @@
+# Quick Start
+
+Minimal example: an export class with a Word template and global tokens, then download via the facade.
+
+## 1. Template
+
+Create a Word file (e.g. `storage/app/templates/hello.docx`) with:
+
+```
+${Title}
+
+${greeting}
+ ${name}, ${email}
+${/greeting}
+```
+
+## 2. Export class
+
+Create a class that implements `FromWordTemplate` and `GlobalTokens`. For a single block you can also use `TokensFromCollection`.
+
+```php
+namespace App\Http\Export;
+
+use Santwer\Exporter\Concerns\FromWordTemplate;
+use Santwer\Exporter\Concerns\GlobalTokens;
+use Santwer\Exporter\Concerns\TokensFromCollection;
+use Illuminate\Support\Collection;
+
+class HelloExport implements FromWordTemplate, GlobalTokens, TokensFromCollection
+{
+ public function wordTemplateFile(): string
+ {
+ return 'templates/hello.docx';
+ }
+
+ public function values(): array
+ {
+ return [
+ 'Title' => 'Quick Start Export',
+ ];
+ }
+
+ public function blockName(): string
+ {
+ return 'greeting';
+ }
+
+ public function items(): Collection
+ {
+ return collect([
+ ['name' => 'Jane', 'email' => 'jane@example.com'],
+ ['name' => 'Bob', 'email' => 'bob@example.com'],
+ ]);
+ }
+
+ public function itemTokens($item): array
+ {
+ return $item;
+ }
+}
+```
+
+## 3. Download
+
+In a controller or route:
+
+```php
+use Santwer\Exporter\Facade\WordExport;
+use App\Http\Export\HelloExport;
+
+return WordExport::download(new HelloExport(), 'hello.docx');
+```
+
+The response is a file download. Use `.pdf` as extension to get PDF (requires LibreOffice). See [Export Classes](export-classes.md) and [Template Syntax](template-syntax.md) for more.
+
+## Security Note
+
+All text values are automatically escaped for XML safety. Special characters like `&`, `<`, `>`, `"`, `'` are converted to XML entities. This prevents injection vulnerabilities. See [XML Escaping & Security](xml-escaping.md) for details on `allowTags` mode for formatted content.
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..1ff0e80
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,6 @@
+# Read the Docs / local: pip install -r docs/requirements.txt
+#
+# MkDocs 2.0 is incompatible with Material for MkDocs today; stay on MkDocs 1.x.
+# See: https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/
+mkdocs>=1.6,<2
+mkdocs-material>=9.7.5
diff --git a/docs/tables.md b/docs/tables.md
new file mode 100644
index 0000000..d173554
--- /dev/null
+++ b/docs/tables.md
@@ -0,0 +1,65 @@
+# Tables
+
+Implement the `WithTables` interface to replace table placeholders in your Word template with data tables.
+
+## Interface
+
+```php
+interface WithTables
+{
+ public function tables(): array;
+}
+```
+
+Return an array mapping placeholder names to table data or closures that return table data. Keys are the placeholder names in the template.
+
+## Table data structure
+
+Each value must be an array (or a closure returning one) with:
+
+- **headers**: Array of header cells. Each cell can be a string or `['width' => int, 'text' => string]`. For PDF export, set column widths on headers so columns render correctly.
+- **rows**: Array of rows; each row is an array of cell values (strings or `['width' => int, 'text' => string]`).
+- **style** (optional): Array with `borderSize`, `borderColor`, `width`, etc.
+
+## Example
+
+```php
+use Santwer\Exporter\Concerns\FromWordTemplate;
+use Santwer\Exporter\Concerns\WithTables;
+
+class InvoiceExport implements FromWordTemplate, WithTables
+{
+ public function wordTemplateFile(): string
+ {
+ return 'templates/invoice.docx';
+ }
+
+ public function tables(): array
+ {
+ return [
+ 'invoiceItems' => function () {
+ return [
+ 'headers' => [
+ ['width' => 3000, 'text' => 'Item'],
+ ['width' => 2000, 'text' => 'Quantity'],
+ ['width' => 2000, 'text' => 'Price'],
+ ],
+ 'rows' => [
+ ['Product A', '2', '100.00'],
+ ['Product B', '1', '50.00'],
+ ],
+ 'style' => [
+ 'borderSize' => 6,
+ 'borderColor' => '000000',
+ 'width' => 7000,
+ ],
+ ];
+ },
+ ];
+ }
+}
+```
+
+## PDF export
+
+For PDF output, at least the headers should have column widths set. Unset column widths may not display correctly in the generated PDF.
diff --git a/docs/template-resolution.md b/docs/template-resolution.md
new file mode 100644
index 0000000..a48121f
--- /dev/null
+++ b/docs/template-resolution.md
@@ -0,0 +1,28 @@
+# Template Resolution
+
+The package resolves the path returned by `wordTemplateFile()` in a fixed order. Use this when placing templates and when choosing relative vs absolute paths.
+
+## Resolution order
+
+1. **Absolute path**: If the returned string is an absolute path and the file exists, that file is used.
+2. **storage_path($path)**: The path is passed to Laravel’s `storage_path()` (e.g. `storage_path('templates/invoice.docx')`).
+3. **storage_path('app/'.$path)**: The path is tried under `storage/app/` (e.g. `storage_path('app/templates/invoice.docx')`).
+
+If no file is found after these steps, an exception is thrown.
+
+## Supported formats
+
+- `.docx` (Word 2007+)
+- `.doc` (legacy Word)
+
+## Examples
+
+| Return value | Resolved to (conceptually) |
+|--------------|----------------------------|
+| `'templates/invoice.docx'` | `storage_path('templates/invoice.docx')` or then `storage_path('app/templates/invoice.docx')` |
+| `storage_path('templates/invoice.docx')` | Used as-is if the file exists |
+| `'/var/www/storage/templates/invoice.docx'` | Used as-is if the file exists |
+
+## Best practice
+
+Use relative paths under `storage` (e.g. `templates/...`) so the same code works across environments. Put template files in `storage/app/templates/` or adjust your path so one of the resolution steps finds the file.
diff --git a/docs/template-syntax.md b/docs/template-syntax.md
new file mode 100644
index 0000000..b612f4c
--- /dev/null
+++ b/docs/template-syntax.md
@@ -0,0 +1,78 @@
+# Template Syntax
+
+Placeholders in Word templates use the `${...}` syntax. The package replaces them with values from your export class or model.
+
+## Simple variables
+
+```
+${variableName}
+```
+
+The key must match a token key from your export (e.g. from `GlobalTokens::values()` or `itemTokens()`).
+
+## Global variables
+
+Set via `GlobalTokens::values()` or `GlobalVariables::setVariable()` / `setVariables()`:
+
+```
+${Date}
+${Time}
+${CompanyName}
+```
+
+## Blocks (loops)
+
+Blocks repeat a section for each item in a collection:
+
+```
+${blockName}
+ ${field1}, ${field2}
+ ${nestedRelation.field}
+${/blockName}
+```
+
+- `blockName` must match the name returned by `blockName()` (e.g. `TokensFromCollection`).
+- Inside the block, use keys from `itemTokens($item)` (e.g. `field1`, `field2`).
+- Nested relations use dot notation: `nestedRelation.field`.
+
+Example template:
+
+```
+${customer}
+ ${name}, ${email}
+ ${deliveryAddress.street}, ${deliveryAddress.city} ${deliveryAddress.postcode}
+${/customer}
+```
+
+## Relation variables
+
+For Eloquent models (Exportable trait), access relations with dot notation:
+
+```
+${relationName.field}
+${parent.child.field}
+```
+
+## Condition-based relation variables
+
+When using the Exportable trait, you can reference a single related record by ID or by a condition:
+
+**By ID** (single record):
+
+```
+${orders:15.product_id} ${orders:15.order_date}
+```
+
+**By condition** (field, operator, value):
+
+```
+${orders:product_id,=,4.product_id} ${orders:product_id,=,4.order_date}
+```
+
+Supported operators: `=`, `!=`, `>`, `<`, `>=`, `<=`. If no record matches, the value is null.
+
+## Block naming conventions
+
+- Block names must match between template and code (e.g. `blockName()` and `${blockName}...${/blockName}`).
+- Use lowercase and underscores (e.g. `customer`, `order_items`).
+- No spaces or special characters in block names.
diff --git a/docs/xml-escaping.md b/docs/xml-escaping.md
new file mode 100644
index 0000000..5689e77
--- /dev/null
+++ b/docs/xml-escaping.md
@@ -0,0 +1,167 @@
+# XML Escaping and Security
+
+The package automatically escapes all text values to ensure XML-safe output and prevent injection vulnerabilities in generated Word documents.
+
+## Automatic Escaping
+
+All text values passed through the package are automatically escaped for XML safety:
+
+- **Ampersands** `&` → `&`
+- **Less than** `<` → `<`
+- **Greater than** `>` → `>`
+- **Double quotes** `"` → `"`
+- **Single quotes** `'` → `'`
+
+This applies to all text sources:
+
+- `GlobalTokens::values()`
+- `TokensFromCollection::itemTokens()`
+- `TokensFromArray::itemTokens()`
+- `TokensArray::tokens()` / `TokensFromObject::tokens()` / `TokensFromModel::model()`
+- `WithTables::tables()` (headers and rows)
+- `GlobalVariables::setVariable()` / `setVariables()`
+- Block values (`setBlockValues()`)
+
+### UTF-8 Support
+
+The package ensures proper UTF-8 encoding for all text values. International characters (Ä, Ö, Ü, ñ, €, etc.) are preserved correctly.
+
+**Implementation:** Uses `htmlspecialchars()` with `ENT_XML1 | ENT_QUOTES` flags and UTF-8 encoding, with `double_encode = false` to avoid double-escaping existing entities.
+
+## AllowTags Mode
+
+In some cases, you may want to include formatted text (bold, italic, etc.) in your placeholders. Use the `allowTags` parameter to preserve HTML/XML tags while still escaping quotes and ampersands.
+
+### Using AllowTags in setValue()
+
+```php
+$processor = $exporter->getTemplateProcessor();
+$processor->setValue('formatted', 'Bold text', allowTags: true);
+```
+
+When `allowTags` is enabled:
+- `<` and `>` are **preserved** (tags remain intact)
+- `&`, `"`, `'` are **still escaped** (for XML attribute safety)
+
+### Using AllowTags in Blocks
+
+For block replacements, pass an array with `[value, allowTags]`:
+
+```php
+use Santwer\Exporter\Concerns\TokensFromCollection;
+
+class MyExport implements FromWordTemplate, TokensFromCollection
+{
+ public function blockName(): string
+ {
+ return 'items';
+ }
+
+ public function items(): Collection
+ {
+ return collect([
+ ['name' => 'Item 1', 'formatted' => 'Bold text'],
+ ['name' => 'Item 2', 'formatted' => 'Italic text'],
+ ]);
+ }
+
+ public function itemTokens($item): array
+ {
+ return [
+ 'name' => $item['name'],
+ // Enable allowTags for this specific placeholder
+ 'formatted' => [$item['formatted'], true],
+ ];
+ }
+}
+```
+
+**Template:**
+```
+${items}
+ ${name}: ${formatted}
+${/items}
+```
+
+### Security Warning
+
+Only use `allowTags` with **trusted content**. If you pass user-generated content with `allowTags` enabled, you may expose your documents to XML injection vulnerabilities.
+
+**Safe:**
+```php
+// Content from your application code
+'formatted' => ['System Message', true]
+```
+
+**Unsafe:**
+```php
+// Content from user input - DO NOT use allowTags!
+'description' => [$request->input('description'), true] // ❌ Dangerous
+```
+
+## No Double-Escaping
+
+The package automatically prevents double-escaping of already-encoded entities:
+
+```php
+// Input: 'Already & encoded'
+// Output: 'Already & encoded' (not &)
+```
+
+This is achieved by:
+1. Using `double_encode = false` in `htmlspecialchars()`
+2. Disabling PHPWord's internal escaping via `Settings::setOutputEscapingEnabled(false)`
+
+## Chart Titles and Labels
+
+**Note:** Chart titles and labels are passed directly to PHPWord as part of chart objects. If you're using user-generated content in chart titles, ensure you escape it manually before creating the chart:
+
+```php
+use Santwer\Exporter\Concerns\WithCharts;
+
+class MyExport implements FromWordTemplate, WithCharts
+{
+ public function charts(): array
+ {
+ $userTitle = 'Sales & Revenue'; // May contain special chars
+
+ return [
+ 'salesChart' => function () use ($userTitle) {
+ // PHPWord will handle this, but be aware of the content source
+ return new Chart('column', $categories, $series, [
+ 'width' => 5000000,
+ 'height' => 5000000,
+ ], $userTitle);
+ },
+ ];
+ }
+}
+```
+
+For maximum safety with untrusted chart titles, manually escape before passing:
+
+```php
+$safeTitle = htmlspecialchars($userTitle, ENT_XML1 | ENT_QUOTES, 'UTF-8', false);
+```
+
+## Type Handling
+
+Non-string values are automatically converted:
+
+| Input Type | Output |
+|------------|--------|
+| `null` | `''` (empty string) |
+| `42` (int) | `'42'` |
+| `3.14` (float) | `'3.14'` |
+| `true` (bool) | `'1'` |
+| `false` (bool) | `''` |
+
+All conversions happen before XML escaping is applied.
+
+## Best Practices
+
+1. **Default to automatic escaping** - Let the package handle escaping for all user content
+2. **Use allowTags sparingly** - Only for trusted, application-generated formatted content
+3. **Never trust user input** - Always use automatic escaping (no allowTags) for user-generated content
+4. **Test with special characters** - Include `&`, `<`, `>`, `"`, `'` in your test data
+5. **UTF-8 everywhere** - Ensure your database and Laravel app use UTF-8 encoding
diff --git a/index.rst b/index.rst
deleted file mode 100644
index d74d5fd..0000000
--- a/index.rst
+++ /dev/null
@@ -1,260 +0,0 @@
-=====================
-Word Template Exporter
-=====================
-
-Package for easy Word exports in Laravel on given Templates.
-This package is based on `phpoffice/phpword `_.
-
-.. image:: https://img.shields.io/github/commit-activity/m/santwer/exporter
- :alt: Commit Activity
- :target: https://github.com/santwer/exporter
-
-.. image:: https://img.shields.io/packagist/dt/santwer/exporter
- :alt: Total Downloads
- :target: https://packagist.org/packages/santwer/exporter
-
-.. image:: https://img.shields.io/packagist/v/santwer/exporter
- :alt: Latest Stable Version
- :target: https://packagist.org/packages/santwer/exporter
-
-.. image:: https://img.shields.io/packagist/l/santwer/exporter
- :alt: License
- :target: https://packagist.org/packages/santwer/exporter
-
-.. contents:: Table of Contents
- :local:
-
-Installation
-============
-
-Exporter is installed via `Composer `_.
-To add a dependency to Exporter in your project, either
-
-Run the following to use the latest stable version
-
-.. code-block:: bash
-
- composer require santwer/exporter
-
-or if you want the latest master version
-
-.. code-block:: bash
-
- composer require santwer/exporter:dev-master
-
-You can of course also manually edit your composer.json file
-
-.. code-block:: json
-
- {
- "require": {
- "santwer/exporter": "v0.3.*"
- }
- }
-
-Configuration (optional)
-------------------------
-
-To use pdf export it is needed to install LibreOffice. WordTemplateExporter is using the soffice command to convert the docx file to pdf.
-
-.. code-block:: bash
-
- sudo apt-get install libreoffice
-
-Windows
-^^^^^^^
-
-Download and install LibreOffice from `here `_
-Also add the path to the soffice command to the system environment variables.
-
-.. code-block:: bash
-
- export PATH=$PATH:/path/to/soffice
-
-How to use with ExportClasses
-=============================
-
-Usage
------
-
-You can use the WordExporter Facade as follows. The format of the exported file is determined by the file extension. Supported formats are .docx and .pdf.
-
-.. code-block:: php
-
- use WordExporter\Facades\WordExporter;
-
- // Download as a Word file
- WordExporter::download(new MyExportClass(), 'final-word.docx');
-
- // Store the exported file
- WordExporter::store(new MyExportClass(), 'path/to/save/export.docx');
-
- // Store the exported file with an certain filename
- WordExporter::storeAs(new MyExportClass(), 'path/to/save/', 'export.docx');
-
- // Store the exported file with an certain filename as a batch
- WordExporter::batchStore(
- new Exportable(new MyExportClass(), 'path/to/save/', 'export.docx'),
- new Exportable(new MyExportClass1(), 'path/to/save/', 'export1.docx'),
- new Exportable(new MyExportClass2(), 'path/to/save/', 'export2.pdf'),
- );
-
- // Queue it for later processing
- WordExporter::queue(new MyExportClass(), 'path/to/save/export.docx');
-
-Creating a New Export
----------------------
-
-You can create a new export using the following Artisan command:
-
-.. code-block:: bash
-
- php artisan make:word {className}
-
-Replace {className} with the name of the new export class.
-
-Interfaces
-^^^^^^^^^^
-
-The object $export can be implemented with the following interfaces:
-
-.. list-table::
- :header-rows: 1
-
- * - Interface
- - Description
- - Example
- * - `FromWordTemplate`
- - Required. Interface indicating the export is from a Word template.
- - `class MyExportClass implements FromWordTemplate`
- * - `GlobalTokens`
- - Interface for providing global tokens for replacement in Word template.
- - `class MyGlobalTokens implements GlobalTokens`
- * - `TokensFromArray`
- - Interface for providing tokens from an array for replacement in Word template.
- - `class MyArrayTokens implements TokensFromArray`
- * - `TokensFromCollection`
- - Interface for providing tokens from a collection for replacement in Word template.
- - `class MyCollectionTokens implements TokensFromCollection`
- * - `TokensArray`
- - Interface for providing tokens from an array without any block data
- -
- * - `TokensFromObject`
- - Interface for providing tokens from an object/class without any block data
- -
- * - `TokensFromModel`
- - Interface for prodiding tokens from a model without any block data
- -
- * - `WithCharts`
- - Interface that allows you to replace text charts as array
- -
- * - `WithCheckboxes`
- - Interfaces that allows you to replace text with Checkboxes, either checked or not checked
- -
- * - `WithImages`
- - Interface that allows you to replace text with Images
- -
-
-Each of these interfaces defines methods that need to be implemented according to the specific requirements of the export process. These methods typically involve returning an array of key-value pairs where keys represent placeholders in the Word template and values are the data to replace those placeholders with.
-
-Example
--------
-
-Word file:
-
-.. code-block:: text
-
- ${TownDateFormat}
-
-
- ${customer}
- ${name}, ${email}
- ${deliveryAddress.street}, ${deliveryAddress.city} ${deliveryAddress.postcode}
- ${/customer}
-
-Controller:
-
-.. code-block:: php
-
- namespace App\Http\Controllers;
-
- use App\Http\Export\FirstExport;
- use Santwer\Exporter\Facade\WordExport;
-
- class HomeController extends Controller
- {
- public function index()
- {
- return WordExport::download(new FirstExport(), 'myExport.docx');
- }
- }
-
-Export Class:
-
-.. code-block:: php
-
- namespace App\Http\Export;
-
- use Santwer\Exporter\Concerns\FromWordTemplate;
- use Santwer\Exporter\Concerns\GlobalTokens;
- use Santwer\Exporter\Concerns\TokensFromCollection;
- use Illuminate\Support\Collection;
-
- class FirstExport implements FromWordTemplate, TokensFromCollection, GlobalTokens
- {
- public function items(): Collection
- {
- return collect([
- [
- 'name' => 'Jane Smith',
- 'email' => 'jane.smith@example.com',
- 'deliveryAddress' => [
- 'street' => 'Main Street',
- 'city' => 'Metropolis',
- 'postcode' => '543210',
- ],
- ],
- [
- 'name' => 'Alice Johnson',
- 'email' => 'alice.johnson@example.com',
- 'deliveryAddress' => [
- 'street' => 'Elm Street',
- 'city' => 'Springfield',
- 'postcode' => '987654',
- ],
- ],
- [
- 'name' => 'Bob Williams',
- 'email' => 'bob.williams@example.com',
- 'deliveryAddress' => [
- 'street' => 'Oak Avenue',
- 'city' => 'Townsville',
- 'postcode' => '135792',
- ],
- ],
- ]);
- }
-
- public function blockName():string
- {
- return 'customer';
- }
-
- public function values(): array
- {
- return [
- 'TownDateFormat' => 'Townsville, '. now()->format('Y-m-d'),
- ];
- }
-
- public function itemTokens($item) : array
- {
- return $item;
- }
-
- public function wordTemplateFile(): string
- {
- return 'uploads/myDocFile.docx';
- }
- }
-
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..00252aa
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,85 @@
+site_name: Word Template Exporter
+site_description: Laravel package for Word template exports (.docx / .pdf) with placeholders, based on PHPWord.
+# Canonical base URL (sitemap, meta). Use ONE primary host — not both RTD and GitHub Pages at once.
+# Read the Docs: https://santwerexporter.readthedocs.io/
+# GitHub Pages (if that is your main docs URL): https://santwer.github.io/Exporter/
+site_url: https://santwerexporter.readthedocs.io/
+site_author: santwer
+copyright: Copyright © santwer. Documentation licensed under CC BY 4.0.
+
+repo_url: https://github.com/santwer/Exporter
+repo_name: santwer/Exporter
+edit_uri: edit/master/docs/
+
+docs_dir: docs
+theme:
+ name: material
+ language: en
+ icon:
+ repo: fontawesome/brands/github
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: blue
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: blue
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+ features:
+ - navigation.instant
+ - navigation.tracking
+ - navigation.expand
+ - navigation.top
+ - search.suggest
+ - search.highlight
+ - search.share
+ - content.code.copy
+ - content.code.annotate
+ - toc.follow
+
+plugins:
+ - search
+
+markdown_extensions:
+ - tables
+ - toc:
+ permalink: true
+ toc_depth: 3
+ - admonition
+ - pymdownx.details
+ # Highlighting: Pygments erkennt php, bash, json, yaml, etc. automatisch — keine Sprachliste nötig.
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: true
+ pygments_lang_class: true
+ - pymdownx.superfences
+ - pymdownx.inlinehilite
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.tasklist:
+ custom_checkbox: true
+
+nav:
+ - Home: index.md
+ - Installation: installation.md
+ - Configuration: configuration.md
+ - Quick Start: quickstart.md
+ - Export Classes: export-classes.md
+ - Concerns & Interfaces: concerns.md
+ - Template Syntax: template-syntax.md
+ - Template Resolution: template-resolution.md
+ - XML Escaping & Security: xml-escaping.md
+ - Charts: charts.md
+ - Images: images.md
+ - Tables: tables.md
+ - Checkboxes: checkboxes.md
+ - Batch & Queue: batch-and-queue.md
+ - Exportable (Deprecated): exportable-deprecated.md
+ - Processors (Reference): processors.md
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..37b2a67
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,32 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+ src/Commands
+ src/Jobs
+ src/stubs
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php
index 80d72f6..3e4be7c 100644
--- a/src/Eloquent/Builder.php
+++ b/src/Eloquent/Builder.php
@@ -1,5 +1,7 @@
beginnProcess($options);
if($this::$exportdata === null && !empty($this->getModel()->getAttributes())) {
@@ -63,7 +65,7 @@ public function exportFirst($name = null, $columns = ['*'], array $options = [])
{
if(is_array($name)) {
$options = array_merge($name, $options);
- $name = isset($options['name']) ? $options['name'] : null;
+ $name = $options['name'] ?? null;
}
$this->beginnProcess($options);
$data = collect([$this->first($columns)]);
diff --git a/src/Exceptions/PDFConversionException.php b/src/Exceptions/PDFConversionException.php
new file mode 100644
index 0000000..d852609
--- /dev/null
+++ b/src/Exceptions/PDFConversionException.php
@@ -0,0 +1,11 @@
+commands([
MakeExportCommand::class,
]);
+ if ($this->app->runningInConsole()) {
+ $this->publishes([
+ __DIR__.'/../config/exporter.php' => config_path('exporter.php'),
+ ], 'exporter-config');
+ }
}
/**
* Register the service provider.
- *
- * @return void
*/
- public function register() : void
+ public function register(): void
{
$this->app->bind('wordexport', function ($app) {
return new ExportClassExporter(
@@ -41,8 +41,5 @@ public function register() : void
$this->app->alias('wordexport', WordExport::class);
$this->mergeConfigFrom(__DIR__.'/../config/exporter.php', 'exporter');
-
}
-
-
}
\ No newline at end of file
diff --git a/src/Helpers/ExportHelper.php b/src/Helpers/ExportHelper.php
index 479a3b1..0e8add3 100644
--- a/src/Helpers/ExportHelper.php
+++ b/src/Helpers/ExportHelper.php
@@ -1,5 +1,7 @@
*/
protected static array $garbage = [];
+
+ /** @var array */
protected static array $garbageFiles = [];
- public static function generateRandomString()
+
+ public static function resetBatchCounters(): void
+ {
+ self::$subBatch = 0;
+ self::$subBatchCalls = 0;
+ }
+
+ public static function resetGarbage(): void
+ {
+ self::$garbage = [];
+ self::$garbageFiles = [];
+ }
+
+ public static function generateRandomString(): string
{
return uniqid();
}
/**
- * @param string $fileName
- * @param string|null $writerType
- * @return string
* @throws \Exception
*/
- public static function getFormat(
- string $fileName,
- string $writerType = null
- ): string {
-
+ public static function getFormat(string $fileName, ?string $writerType = null): string
+ {
if ($writerType) {
if (!in_array(strtolower($writerType), Writer::formats())) {
return Writer::DOCX;
@@ -49,82 +62,85 @@ public static function getFormat(
return self::getFormat($fileName, $ext);
}
- public static function hasSupportedFormats(string $fileName) : bool
+ public static function hasSupportedFormats(string $fileName): bool
{
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
return in_array($ext, Writer::formats());
}
- public static function tempFile(?string $dir = null)
+ public static function tempFile(?string $dir = null): string
{
- if(config('exporter.temp_folder_relative')) {
+ if (config('exporter.temp_folder_relative')) {
$filename = 'php_we'.ExportHelper::generateRandomString().'.tmp';
if ($dir) {
return $dir.DIRECTORY_SEPARATOR.$filename;
}
return ExportHelper::tempDir().DIRECTORY_SEPARATOR.$filename;
-
- }
- if ($dir) {
- return tempnam($dir, "php_we");
}
- return tempnam(ExportHelper::tempDir(), "php_we");
- }
+ $baseDir = $dir ?? ExportHelper::tempDir();
- public static function isPathAbsolute(string $path) : bool
- {
- return Str::startsWith($path, ['/', '\\',
- 'C:', 'D:', 'E:', 'F:', 'G:', 'H:', 'I:', 'J:', 'K:', 'L:', 'M:', 'N:', 'O:', 'P:', 'Q:', 'R:', 'S:',
- 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:']);
+ return self::createUniqueTempFile($baseDir);
}
- public static function tempDir() : string
- {
- //create all folders in path if not exists
- $path = config('exporter.temp_folder');
- //explode String with DIRECTORY_SEPARATOR and / or \
- $pathParts = preg_split('/[\/\\\\]/', $path);
- $folderPath = '';
- foreach ( $pathParts as $folder) {
- if (empty($folder)) {
+ /**
+ * Create an empty temp file in $directory without tempnam() (avoids PHP deprecations
+ * when the directory lies under the system temp path).
+ */
+ private static function createUniqueTempFile(string $directory): string
+ {
+ for ($i = 0; $i < 50; $i++) {
+ $path = $directory.DIRECTORY_SEPARATOR.'php_we_'.bin2hex(random_bytes(8)).'.tmp';
+ if (file_exists($path)) {
continue;
}
- if (Str::contains($folder, ':')) {
- $folderPath = $folder;
- } elseif(empty($folderPath)) {
- $folderPath = $folder;
- } else {
- $folderPath = $folderPath.DIRECTORY_SEPARATOR.$folder;
+ if (false !== file_put_contents($path, '')) {
+ return $path;
}
+ }
+
+ throw new \RuntimeException('Could not create temp file in '.$directory);
+ }
+
+ public static function isPathAbsolute(string $path): bool
+ {
+ return Str::startsWith($path, [
+ '/', '\\',
+ 'C:', 'D:', 'E:', 'F:', 'G:', 'H:', 'I:', 'J:', 'K:', 'L:', 'M:',
+ 'N:', 'O:', 'P:', 'Q:', 'R:', 'S:', 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:'
+ ]);
+ }
- if (!is_dir($folderPath)) {
- if (!mkdir($folderPath, 0700)) {
- throw new TempFolderException('Folder couldn\'t be created');
- }
+ /**
+ * @throws TempFolderException
+ */
+ public static function tempDir(): string
+ {
+ $path = config('exporter.temp_folder');
+ if (!is_dir($path)) {
+ if (!mkdir($path, 0700, true) && !is_dir($path)) {
+ throw new TempFolderException('Folder couldn\'t be created');
}
}
return $path;
}
- public static function convertForRunningInConsole(string $path) : string
+ public static function convertForRunningInConsole(string $path): string
{
if (self::isPathAbsolute($path)) {
return $path;
}
- if(Str::startsWith($path, '/')) {
+ if (Str::startsWith($path, '/')) {
return $path;
}
- if(app()->runningInConsole()) {
+ if (app()->runningInConsole()) {
return $path;
}
return '..'.DIRECTORY_SEPARATOR.$path;
-
}
/**
- * @param string $prefix
- * @return string
+ * @return array{0: string, 1: string, 2: string}
* @throws TempFolderException
*/
public static function tempFileName(string $prefix = ''): array
@@ -133,7 +149,7 @@ public static function tempFileName(string $prefix = ''): array
//The converter can not handle big chunks, therefore the batch size gets reduced to 200
$tempDir = ExportHelper::tempDir();
self::$subBatchCalls++;
- if(self::$subBatchCalls > config('exporter.batch_size', 200)) {
+ if (self::$subBatchCalls > config('exporter.batch_size', 200)) {
self::$subBatch++;
self::$subBatchCalls = 1;
}
@@ -143,29 +159,28 @@ public static function tempFileName(string $prefix = ''): array
$newTempDir = $tempDir.DIRECTORY_SEPARATOR.$folderName;
$batchNameFolder = $newTempDir.DIRECTORY_SEPARATOR.$batchName;
if (!is_dir($newTempDir)) {
- if (!mkdir($newTempDir, 0700)) {
+ if (!mkdir($newTempDir, 0700, true) && !is_dir($newTempDir)) {
throw new TempFolderException('Folder couldn\'t be created');
}
}
if (!is_dir($batchNameFolder)) {
- if (!mkdir($batchNameFolder, 0700)) {
+ if (!mkdir($batchNameFolder, 0700, true) && !is_dir($batchNameFolder)) {
throw new TempFolderException('Folder couldn\'t be created');
}
}
- return [tempnam($batchNameFolder, "php_we"), $newTempDir, $batchName];
+ return [self::createUniqueTempFile($batchNameFolder), $newTempDir, $batchName];
}
/**
- * @param string $folder
- * @return array
+ * @return array
* @throws \Exception
*/
- public static function processWordToPdf(string $folder) : array
+ public static function processWordToPdf(string $folder): array
{
$files = [];
foreach (self::getSubDirs($folder) as $dir) {
- if(is_string($dir)) {
+ if (is_string($dir)) {
$files = array_merge($files, self::processWordToPdfFolder($dir));
} else {
$files = array_merge($files, self::processWordToPdfFolder($folder.DIRECTORY_SEPARATOR.$dir->getFilename()));
@@ -175,45 +190,52 @@ public static function processWordToPdf(string $folder) : array
return $files;
}
- public static function processWordToPdfFolder($subfolder)
+ /**
+ * @return array
+ */
+ public static function processWordToPdfFolder(string $subfolder): array
{
PDFExporter::docxToPdf($subfolder.DIRECTORY_SEPARATOR.'*', $subfolder);
- $subFiles = glob($subfolder .DIRECTORY_SEPARATOR. '*.pdf');
- if(false !== $subFiles) {
+ $subFiles = glob($subfolder.DIRECTORY_SEPARATOR.'*.pdf');
+ if (false !== $subFiles) {
return $subFiles;
}
return [];
}
- public static function getSubDirs(string $folder)
+ /**
+ * @return \Generator
+ */
+ public static function getSubDirs(string $folder): \Generator
{
$dirs = new DirectoryIterator($folder);
foreach ($dirs as $dir) {
- if($dir->isDot()) continue;
+ if ($dir->isDot()) {
+ continue;
+ }
if ($dir->isDir()) {
$subfolder = $folder.DIRECTORY_SEPARATOR.$dir->getFilename();
yield $subfolder;
}
}
- return [];
}
- public static function garbageCollector(string $folder)
+ public static function garbageCollector(string $folder): void
{
self::$garbage[] = $folder;
}
- public static function garbageCollectorFiles(string $file)
+
+ public static function garbageCollectorFiles(string $file): void
{
self::$garbage[] = $file;
}
- public static function cleanGarbage()
+ public static function cleanGarbage(): void
{
foreach (self::$garbage as $folder) {
try {
//try to delete to save disk space
File::deleteDirectory($folder);
-
} catch (\Exception $exception) {
Log::error($exception->getMessage());
//folder could not be deleted, not a throwable error since its temp folder
diff --git a/src/Interfaces/ExporterInterface.php b/src/Interfaces/ExporterInterface.php
index 7f4bc33..5e73f2b 100644
--- a/src/Interfaces/ExporterInterface.php
+++ b/src/Interfaces/ExporterInterface.php
@@ -9,43 +9,24 @@ interface ExporterInterface
{
public function __construct(string $wordfile);
- /**
- * @param array $values
- * @return void
- */
- public function setArrayValues(array $values);
+ public function setArrayValues(array $values): void;
- /**
- * @param string $block
- * @param array $values
- * @return void
- */
- public function setBlockValues(string $block, array $values);
+ public function setBlockValues(string $block, array $values): void;
- /**
- * @param $name
- * @param $value
- * @return void
- */
- public function setValue($name, $value);
+ public function setValue(string $name, mixed $value): void;
/**
- * @param string|null $savepath
- * @return array|false|string|string[]
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
- public function getProcessedFile(?string $savepath = null);
+ public function getProcessedFile(?string $savepath = null): string;
/**
- * @param string $format
- * @param string|null $savepath
- * @return array|false|string|string[]
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
- public function getProcessedConvertedFile(string $format, ?string $savepath = null);
+ public function getProcessedConvertedFile(string $format, ?string $savepath = null): string;
/**
* @return TemplateProcessor
@@ -54,10 +35,5 @@ public function getProcessedConvertedFile(string $format, ?string $savepath = nu
*/
public function process(): TemplateProcessor;
- /**
- * @param string|null $ext
- * @param bool $withoutPath
- * @return array|false|string|string[]
- */
- public function getTempFileName(string $ext = null, bool $withoutPath = false);
+ public function getTempFileName(?string $ext = null, bool $withoutPath = false): string;
}
\ No newline at end of file
diff --git a/src/Processor/ExportClassExporter.php b/src/Processor/ExportClassExporter.php
index a8e0281..cb190cb 100644
--- a/src/Processor/ExportClassExporter.php
+++ b/src/Processor/ExportClassExporter.php
@@ -24,18 +24,13 @@ public function __construct(WordTemplateExporter $exporter)
}
/**
- * @param object $export
- * @param string $fileName
- * @param string|null $writerType
- * @param array $headers
- * @return BinaryFileResponse
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
public function download(
object $export,
string $fileName,
- string $writerType = null,
+ ?string $writerType = null,
array $headers = []
): BinaryFileResponse {
$tmpfname = ExportHelper::tempFile();
@@ -59,22 +54,16 @@ public function download(
}
/**
- * @param object $export
- * @param string $folderPath
- * @param string|null $disk
- * @param string|null $writerType
- * @param array $diskOptions
- * @return false|string
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
public function store(
object $export,
string $folderPath,
- string $disk = null,
- string $writerType = null,
+ ?string $disk = null,
+ ?string $writerType = null,
array $diskOptions = []
- ) {
+ ): false|string {
if(ExportHelper::hasSupportedFormats($folderPath)) {
$fileName = pathinfo($folderPath, PATHINFO_FILENAME)
.'.'.pathinfo($folderPath, PATHINFO_EXTENSION);
@@ -107,10 +96,10 @@ public function storeAs(
object $export,
string $filePath,
string $name,
- string $disk = null,
- string $writerType = null,
+ ?string $disk = null,
+ ?string $writerType = null,
array $diskOptions = []
- ) {
+ ): false|string {
$format = ExportHelper::getFormat($name, $writerType);
$tmpfname = ExportHelper::tempFile();
$file = $this->exporter
@@ -146,10 +135,7 @@ public function batchStore(
return true;
}
- /**
- * @param Exportable ...$exports
- */
- public function batchQueue(Exportable ...$exports)
+ public function batchQueue(Exportable ...$exports): array
{
return array_map(fn($x) => $x->dispatch(), $this->batch(...$exports));
}
diff --git a/src/Processor/Exporter.php b/src/Processor/Exporter.php
index a8dc30c..21e9105 100644
--- a/src/Processor/Exporter.php
+++ b/src/Processor/Exporter.php
@@ -1,5 +1,7 @@
*/
protected array $values = [];
+ /** @var array>> */
protected array $blocks = [];
+ /** @var array */
protected array $checkboxes = [];
+ /** @var array */
protected array $charts = [];
+
+ /** @var array */
protected array $images = [];
- protected array $tables = [];
- /**
- * @var TemplateProcessor $templateProcessor
- */
- protected $templateProcessor;
+ /** @var array */
+ protected array $tables = [];
- /**
- * @var Builder $builder
- */
- protected $builder;
+ protected ?TemplateProcessor $templateProcessor = null;
+ protected ?Builder $builder = null;
- public function __construct(string $wordfile)
- {
- $this->wordfile = $wordfile;
- }
+ public function __construct(
+ protected readonly string $wordfile
+ ) {}
public function setTemplateProcessor(callable $templateProcessor): void
{
$templateProcessor($this->templateProcessor);
}
-
- public function getTemplateVariables()
+ /**
+ * @return array
+ */
+ public function getTemplateVariables(): array
{
return $this->getTemplateProcessor()->getVariables();
}
@@ -75,61 +77,54 @@ public function setArray(array $array, string $prefix = ''): void
}
/**
- * @param array $values
- * @return void
+ * @param array $values
*/
- public function setArrayValues(array $values)
+ public function setArrayValues(array $values): void
{
$this->values = array_merge($this->values, $values);
}
/**
- * @param string $block
- * @param array $values
- * @return void
+ * @param array> $values
*/
- public function setBlockValues(string $block, array $values)
+ public function setBlockValues(string $block, array $values): void
{
$this->blocks[$block] = $values;
}
- /**
- * @param $name
- * @param $value
- * @return void
- */
- public function setValue($name, $value)
+ public function setValue(string $name, mixed $value): void
{
$this->values[$name] = $value;
}
- public function setCheckbox(string $name, bool $value)
+ public function setCheckbox(string $name, bool $value): void
{
$this->checkboxes[$name] = $value;
}
- public function setChart(string $name,object $value)
+ public function setChart(string $name, object $value): void
{
$this->charts[$name] = $value;
}
- public function setImage(string $name, $value)
+
+ public function setImage(string $name, string|array $value): void
{
$this->images[$name] = $value;
}
- public function setTables(array $tables)
+ /**
+ * @param array $tables
+ */
+ public function setTables(array $tables): void
{
$this->tables = $tables;
}
-
/**
- * @param string|null $savepath
- * @return array|false|string|string[]
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
- public function getProcessedFile(?string $savepath = null)
+ public function getProcessedFile(?string $savepath = null): string
{
$templateProcessor = $this->process();
$savepath = $savepath ?? $this->getTempFileName();
@@ -139,13 +134,10 @@ public function getProcessedFile(?string $savepath = null)
}
/**
- * @param string $format
- * @param string|null $savepath
- * @return array|false|string|string[]
* @throws \PhpOffice\PhpWord\Exception\CopyFileException
* @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
*/
- public function getProcessedConvertedFile(string $format, ?string $savepath = null)
+ public function getProcessedConvertedFile(string $format, ?string $savepath = null): string
{
$templateProcessor = $this->process();
$savepath = $savepath ?? $this->getTempFileName('docx');
@@ -153,27 +145,23 @@ public function getProcessedConvertedFile(string $format, ?string $savepath = nu
$templateProcessor->saveAs(
ExportHelper::convertForRunningInConsole($savepath)
);
- if ($format === Writer::PDF) {
- return PDFExporter::docxToPdf($savepath,
- $savepath ? pathinfo($savepath,
- PATHINFO_DIRNAME) : null);
- }
- if ($format === 'html') {
- return PDFExporter::html2Pdf($savepath,
- $savepath ? pathinfo($savepath,
- PATHINFO_DIRNAME) : null);
- }
- return ExportHelper::convertForRunningInConsole($savepath);
+ return match ($format) {
+ Writer::PDF, 'pdf' => PDFExporter::docxToPdf(
+ $savepath,
+ $savepath ? pathinfo($savepath, PATHINFO_DIRNAME) : null
+ ),
+ 'html' => PDFExporter::html2Pdf(
+ $savepath,
+ $savepath ? pathinfo($savepath, PATHINFO_DIRNAME) : null
+ ),
+ default => ExportHelper::convertForRunningInConsole($savepath),
+ };
}
public function getTemplateProcessor(): TemplateProcessor
{
- if (null === $this->templateProcessor) {
- $this->templateProcessor = new TemplateProcessor($this->wordfile);
- }
-
- return $this->templateProcessor;
+ return $this->templateProcessor ??= new TemplateProcessor($this->wordfile);
}
/**
@@ -183,6 +171,10 @@ public function getTemplateProcessor(): TemplateProcessor
*/
public function process(): TemplateProcessor
{
+ // Disable PHPWord output escaping to avoid double-escaping
+ // (our TemplateProcessor::replace() handles all XML escaping)
+ \PhpOffice\PhpWord\Settings::setOutputEscapingEnabled(false);
+
$templateProcessor = $this->getTemplateProcessor();
$values = collect($this->values);
@@ -195,9 +187,8 @@ public function process(): TemplateProcessor
$templateProcessor->setValues($setValues);
}
- if ($this->builder) {
- $this->builder->checkForRelations($templateProcessor->getVariables());
- }
+ $this->builder?->checkForRelations($templateProcessor->getVariables());
+
$this->blocks = $this->addEmptyValues($this->blocks);
if (!empty($this->checkboxes)) {
foreach ($this->checkboxes as $checkbox => $value) {
@@ -215,68 +206,61 @@ public function process(): TemplateProcessor
}
}
- if(!empty($this->tables)) {
+ if (!empty($this->tables)) {
foreach ($this->tables as $table => $tableData) {
$templateProcessor->setComplexBlock($table, $this->tableDataToComplexBlock($tableData));
}
}
if (!empty($this->blocks)) {
-
foreach ($this->blocks as $block => $replacement) {
$replacements = collect($replacement)
- ->map(function ($y) {
- return collect($y)
- ->toArray();
- })->toArray();
-
- $templateProcessor->cloneRecrusiveBlocks($block, 0,
- true,
- false, $replacements);
-
+ ->map(fn ($y) => collect($y)->toArray())
+ ->toArray();
+
+ $templateProcessor->cloneRecursiveBlocks(
+ blockname: $block,
+ clones: 0,
+ replace: true,
+ indexVariables: false,
+ variableReplacements: $replacements
+ );
}
}
return $templateProcessor;
}
- /**
- * transform table data to complex block
- * @param $tableData
- * @return Table
- */
- private function tableDataToComplexBlock($tableData) : Table
+ private function tableDataToComplexBlock(array|callable $tableData): Table
{
-
- if(is_callable($tableData)) {
+ if (is_callable($tableData)) {
$tableData = $tableData();
}
- $style = isset($tableData['style']) ? $tableData['style'] : null;
+ $style = $tableData['style'] ?? null;
$table = new Table($style);
- if(isset($tableData['headers'])) {
+ if (isset($tableData['headers'])) {
$table->addRow();
foreach ($tableData['headers'] as $header) {
- if(is_array($header)) {
+ if (is_array($header)) {
$table->addCell(
- isset($header['width']) ? $header['width'] : null,
- isset($header['style']) ? $header['style'] : null
+ $header['width'] ?? null,
+ $header['style'] ?? null
)->addText($this->templateProcessor->replace($header['text']));
} else {
$table->addCell()->addText($this->templateProcessor->replace($header));
}
}
-
}
- if(isset($tableData['rows'])) {
+ if (isset($tableData['rows'])) {
foreach ($tableData['rows'] as $row) {
$table->addRow();
foreach ($row as $column) {
- if(is_array($column)) {
+ if (is_array($column)) {
$table->addCell(
- isset($column['width']) ? $column['width'] : null,
- isset($column['style']) ? $column['style'] : null
+ $column['width'] ?? null,
+ $column['style'] ?? null
)->addText($this->templateProcessor->replace($column['text']));
} else {
$table->addCell()->addText($this->templateProcessor->replace($column));
@@ -288,8 +272,11 @@ private function tableDataToComplexBlock($tableData) : Table
return $table;
}
-
- private function addEmptyValues($blocks)
+ /**
+ * @param array>> $blocks
+ * @return array>>
+ */
+ private function addEmptyValues(array $blocks): array
{
$variables = $this->getTemplateVariables();
foreach ($variables as $variable) {
@@ -297,11 +284,12 @@ private function addEmptyValues($blocks)
if (
in_array('/'.$sp, $variables)
- || Str::contains(':', $variable) || Str::startsWith($variable, '/')) {
+ || Str::contains(':', $variable) || Str::startsWith($variable, '/')
+ ) {
continue;
} else {
foreach ($blocks as $b => $block) {
- $blocks[$b] = $this->templateProcessor->arrayListRecusive($block);
+ $blocks[$b] = $this->templateProcessor->arrayListRecursive($block);
foreach ($block as $e => $entry) {
if (isset($entry[$variable])) {
continue;
@@ -315,12 +303,7 @@ private function addEmptyValues($blocks)
return $blocks;
}
- /**
- * @param string|null $ext
- * @param bool $withoutPath
- * @return array|false|string|string[]
- */
- public function getTempFileName(string $ext = null, bool $withoutPath = false)
+ public function getTempFileName(?string $ext = null, bool $withoutPath = false): string
{
if ($withoutPath) {
$temp = tempnam('', 'Exp');
@@ -334,5 +317,4 @@ public function getTempFileName(string $ext = null, bool $withoutPath = false)
return Str::replace('.tmp', '.'.$ext, $temp);
}
-
}
\ No newline at end of file
diff --git a/src/Processor/GlobalVariables.php b/src/Processor/GlobalVariables.php
index 366ebbc..c23cab6 100644
--- a/src/Processor/GlobalVariables.php
+++ b/src/Processor/GlobalVariables.php
@@ -1,37 +1,43 @@
['', true],
-
- ];
-
- return array_merge($vars, self::$globalVars);
- }
-
- /**
- * @param string $key
- * @param string $value
- * @return void
- */
- public static function setVariable(string $key, string $value)
- {
- self::$globalVars[$key] = $value;
- }
-
- public static function setVariables(array $values)
- {
- foreach($values as $key => $value) {
- self::setVariable($key, $value);
- }
- }
-
+ /** @var array */
+ protected static array $globalVars = [];
+
+ /**
+ * @return array
+ */
+ public static function getGlobalVariables(): array
+ {
+ $vars = [
+ __('new_page') => ['', true],
+ ];
+
+ return array_merge($vars, self::$globalVars);
+ }
+
+ public static function setVariable(string $key, mixed $value): void
+ {
+ self::$globalVars[$key] = $value;
+ }
+
+ /**
+ * @param array $values
+ */
+ public static function setVariables(array $values): void
+ {
+ foreach ($values as $key => $value) {
+ self::setVariable($key, $value);
+ }
+ }
+
+ public static function clear(): void
+ {
+ self::$globalVars = [];
+ }
}
\ No newline at end of file
diff --git a/src/Processor/PDFExporter.php b/src/Processor/PDFExporter.php
index 427e520..28311fc 100644
--- a/src/Processor/PDFExporter.php
+++ b/src/Processor/PDFExporter.php
@@ -1,128 +1,87 @@
setTimeout(120);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw PDFConversionException::fromProcess(
+ $process->getOutput(),
+ $process->getErrorOutput()
+ );
}
- if ($path !== null) {
- //get file extension
- $fileext = pathinfo($docX, PATHINFO_EXTENSION);
- if (empty($fileext)) {
- $file = $docX.'.pdf';
- } else {
- $file = Str::replace('.'.$fileext, '.pdf', $docX);
- }
- return ExportHelper::convertForRunningInConsole($file);
- }
- $file = $path.pathinfo($docX, PATHINFO_FILENAME).'.pdf';
+ $file = Str::replace('.tmp', '.pdf', $htmlfile);
return ExportHelper::convertForRunningInConsole($file);
}
- private static function cmdToString(array $array) : string
- {
- return implode(' ', array_map(function ($item) {
- if(Str::contains($item, ' ') && !Str::startsWith($item, '"')) {
- return '"'.$item.'"';
- } else
- return trim($item);
- },$array));
- }
-
- /**
- * @param $type
- * @param mixed ...$args
- * @return string
- */
- private static function getCommand($type, ...$args): string
- {
-
- $collection = collect(explode(' ', self::commands($type)));
- $partIndex = 0;
-
- foreach ($collection as $index => $commandPart) {
- if($index === 0 && !empty(config('exporter.word2pdf.soffice_prefix'))) {
- $collection[$index] = config('exporter.word2pdf.soffice_prefix').$commandPart;
- }
-
- if (Str::contains($commandPart, ['?','%s', '"%s"'])) {
- if (isset($args[$partIndex])) {
- $collection[$index] = Str::replace(['?','%s', '"%s"'], $args[$partIndex], $collection[$index]);
- }
- $partIndex++;
- }
- }
-
- return self::cmdToString($collection
- ->toArray());
- }
-
-
/**
- * @param $type
- * @return string
+ * @throws PDFConversionException
*/
- private static function commands($type)
+ public static function docxToPdf(string $docX, ?string $path = null): string
{
- switch ($type) {
- case 'docx2pdfPath':
- case 'html2pdfPath':
- return config('exporter.word2pdf.command');
- default:
- return $type;
+ $outDir = $path ?? pathinfo($docX, PATHINFO_DIRNAME);
+
+ $command = [
+ config('exporter.word2pdf.soffice_prefix').'soffice',
+ '--convert-to',
+ 'pdf',
+ '--outdir',
+ $outDir,
+ $docX,
+ '--headless'
+ ];
+
+ $process = new Process($command);
+ $process->setTimeout(120);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw PDFConversionException::fromProcess(
+ $process->getOutput(),
+ $process->getErrorOutput()
+ );
}
- }
-
- private static function checkReturnValue($value)
- {
- $checkConvert = explode(' ', $value);
- if (isset($checkConvert[3])) {
- self::$outPutFile = $checkConvert[3];
- }
- if ($checkConvert[0] === 'convert') {
- return true;
+ $fileext = pathinfo($docX, PATHINFO_EXTENSION);
+ if (empty($fileext)) {
+ $file = $docX.'.pdf';
} else {
- return false;
+ $file = Str::replace('.'.$fileext, '.pdf', $docX);
}
+
+ return ExportHelper::convertForRunningInConsole($file);
}
}
\ No newline at end of file
diff --git a/src/Processor/TemplateProcessor.php b/src/Processor/TemplateProcessor.php
index 92b021f..bd2ffea 100644
--- a/src/Processor/TemplateProcessor.php
+++ b/src/Processor/TemplateProcessor.php
@@ -1,5 +1,7 @@
replace($replace, $allowTags), $limit);
}
-
- public function replace($replace, bool $allowTags = false)
+ public function replace(mixed $replace, bool $allowTags = false): string
{
+ // Handle array format [value, allowTags]
if (is_array($replace)) {
[$replace, $allowTags] = array_pad($replace, 2, false);
}
- if(method_exists(Str::class, 'replaceMatches')) {
- $replace = Str::replaceMatches(['/&(?![a-zA-Z0-9]+;)/'], '&', $replace);
- } else {
- $replace = preg_replace('/&(?![a-zA-Z0-9]+;)/', '&', $replace);
+ // Convert non-string types to string
+ if ($replace === null) {
+ return '';
+ }
+ if (!is_string($replace)) {
+ $replace = (string) $replace;
}
+
+ // Normalize to UTF-8
+ $replace = static::ensureUtf8Encoded($replace);
+
+ // Escape XML-relevant characters
if (!$allowTags) {
- $replace = Str::replace(['<'], '<', $replace);
- $replace = Str::replace(['>'], '>', $replace);
+ // Full XML escaping: & < > " '
+ $replace = htmlspecialchars($replace, ENT_XML1 | ENT_QUOTES, 'UTF-8', false);
+ } else {
+ // Only escape & " ' (preserve < > for tags)
+ // First escape & (but not existing entities)
+ if (method_exists(Str::class, 'replaceMatches')) {
+ $replace = Str::replaceMatches(['/&(?![a-zA-Z0-9]+;)/'], '&', $replace);
+ } else {
+ $replace = preg_replace('/&(?![a-zA-Z0-9]+;)/', '&', $replace);
+ }
+ // Escape quotes
+ $replace = str_replace('"', '"', $replace);
+ $replace = str_replace("'", ''', $replace);
}
+
return $replace;
}
- /**
- * @param ?string $subject
- *
- * @return string
- */
- protected static function ensureUtf8Encoded($subject)
+ protected static function ensureUtf8Encoded(mixed $subject): string
{
return is_string($subject) || $subject ? Text::toUTF8($subject) : '';
}
@@ -50,27 +65,46 @@ protected static function ensureUtf8Encoded($subject)
/**
* Clone a block.
*
- * @param string $blockname
- * @param int $clones How many time the block should be cloned
- * @param bool $replace
- * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
- * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
+ * @param string $blockname
+ * @param int $clones How many time the block should be cloned
+ * @param bool $replace
+ * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
+ * @param array>|null $variableReplacements Array containing replacements for macros found inside the block to clone
*
* @return string|null
*/
+ public function cloneRecursiveBlocks(
+ string $blockname,
+ int $clones = 1,
+ bool $replace = true,
+ bool $indexVariables = false,
+ ?array $variableReplacements = null
+ ): ?string {
+ return $this->cloneRecursiveBlock(
+ blockname: $blockname,
+ clones: $clones,
+ replace: $replace,
+ indexVariables: $indexVariables,
+ variableReplacements: $variableReplacements,
+ refXmlBlock: $this->tempDocumentMainPart
+ );
+ }
+
+ /** @deprecated Use cloneRecursiveBlocks() */
public function cloneRecrusiveBlocks(
- $blockname,
- $clones = 1,
- $replace = true,
- $indexVariables = false,
- $variableReplacements = null
- ) {
- return $this->cloneRecrusiveBlock($blockname,
- $clones, $replace, $indexVariables, $variableReplacements,
- $this->tempDocumentMainPart);
+ string $blockname,
+ int $clones = 1,
+ bool $replace = true,
+ bool $indexVariables = false,
+ ?array $variableReplacements = null
+ ): ?string {
+ return $this->cloneRecursiveBlocks($blockname, $clones, $replace, $indexVariables, $variableReplacements);
}
- private function collectListRecusive(Collection $collection, $prekey = null): array
+ /**
+ * @return array
+ */
+ private function collectListRecursive(Collection $collection, ?string $prekey = null): array
{
return $collection->mapWithKeys(function ($value, $key) use ($prekey) {
$newkey = $prekey ? $prekey.'.'.$key : $key;
@@ -78,11 +112,15 @@ private function collectListRecusive(Collection $collection, $prekey = null): ar
return [$newkey => $value];
}
- return array_merge([$key => $value], $this->collectListRecusive(collect($value), $newkey));
+ return array_merge([$key => $value], $this->collectListRecursive(collect($value), $newkey));
})->toArray();
}
- public function arrayListRecusive(array $array): array
+ /**
+ * @param array $array
+ * @return array
+ */
+ public function arrayListRecursive(array $array): array
{
return array_map(function ($x) {
if (!is_array($x)) {
@@ -95,31 +133,37 @@ public function arrayListRecusive(array $array): array
return [$key => $value];
}
- return array_merge([$key => $value], $this->collectListRecusive(collect($value),$key));
+ return array_merge([$key => $value], $this->collectListRecursive(collect($value), $key));
})
->toArray();
}, $array);
}
+ /** @deprecated Use arrayListRecursive() */
+ public function arrayListRecusive(array $array): array
+ {
+ return $this->arrayListRecursive($array);
+ }
+
/**
* Clone a block.
*
- * @param string $blockname
- * @param int $clones How many time the block should be cloned
- * @param bool $replace
- * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
- * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
+ * @param string $blockname
+ * @param int $clones How many time the block should be cloned
+ * @param bool $replace
+ * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
+ * @param array>|null $variableReplacements Array containing replacements for macros found inside the block to clone
*
* @return string|null
*/
- public function cloneRecrusiveBlock(
- $blockname,
- $clones = 1,
- $replace = true,
- $indexVariables = false,
- $variableReplacements = null,
- &$refXmlBlock = null
- ) {
+ private function cloneRecursiveBlock(
+ string $blockname,
+ int $clones = 1,
+ bool $replace = true,
+ bool $indexVariables = false,
+ ?array $variableReplacements = null,
+ ?string &$refXmlBlock = null
+ ): ?string {
$xmlBlock = null;
$matches = [];
preg_match(
@@ -133,39 +177,24 @@ public function cloneRecrusiveBlock(
if ($indexVariables) {
$cloned = $this->indexClonedVariables($clones, $xmlBlock);
} elseif ($variableReplacements !== null && is_array($variableReplacements)) {
- $variableReplacementsFirst = array_map(function ($x) {
- return array_filter($x, function ($y) {
- if(is_array($y)) {
- if(isset($y[1]) && is_bool($y[1])) {
- return true;
- }
- return false;
- }
- return true;
- });
- return $x;
- }, $variableReplacements);
+ $variableReplacementsFirst = $this->filterVariableReplacements($variableReplacements);
$t = collect($variableReplacementsFirst)->map(fn ($a) => collect($a)->map(function ($x) {
- if(is_array($x)) {
- if(is_array($x)) {
- if(isset($x[1]) && is_bool($x[1])) {
- return $this->replace(...$x);
- }
- return null;
+ if (is_array($x)) {
+ if (isset($x[1]) && is_bool($x[1])) {
+ return $this->replace(...$x);
}
- return array_map(fn($y) => $this->replace($y), $x);
+ return null;
}
return $this->replace($x);
})->toArray())->toArray();
$cloned = $this->replaceClonedVariables($t, $xmlBlock);
- $variableReplacementsRecrusive = array_map(function ($x) {
- return array_filter($x, function ($y) {
- return is_array($y);
- });
- }, $variableReplacements);
+ $variableReplacementsRecrusive = array_map(
+ fn ($x) => array_filter($x, fn ($y) => is_array($y)),
+ $variableReplacements
+ );
foreach ($cloned as $index => $clone) {
if (!isset($variableReplacementsRecrusive[$index])) {
@@ -173,15 +202,16 @@ public function cloneRecrusiveBlock(
}
$clonedBlockVaribles = $variableReplacementsRecrusive[$index];
foreach ($clonedBlockVaribles as $block => $variableReplacementsR) {
- $this->cloneRecrusiveBlock($block,
- $clones,
- $replace,
- $indexVariables,
- $variableReplacementsR,
- $cloned[$index]);
+ $this->cloneRecursiveBlock(
+ blockname: $block,
+ clones: $clones,
+ replace: $replace,
+ indexVariables: $indexVariables,
+ variableReplacements: $variableReplacementsR,
+ refXmlBlock: $cloned[$index]
+ );
}
}
-
} else {
$cloned = [];
for ($i = 1; $i <= $clones; $i++) {
@@ -200,4 +230,19 @@ public function cloneRecrusiveBlock(
return $xmlBlock;
}
-}
\ No newline at end of file
+
+ /**
+ * @param array> $variableReplacements
+ * @return array>
+ */
+ private function filterVariableReplacements(array $variableReplacements): array
+ {
+ return array_map(
+ fn ($x) => array_filter(
+ $x,
+ fn ($y) => !is_array($y) || (isset($y[1]) && is_bool($y[1]))
+ ),
+ $variableReplacements
+ );
+ }
+}
diff --git a/src/Processor/VariablesConditionProcessor.php b/src/Processor/VariablesConditionProcessor.php
index e37deca..f7a4ebc 100644
--- a/src/Processor/VariablesConditionProcessor.php
+++ b/src/Processor/VariablesConditionProcessor.php
@@ -1,70 +1,84 @@
$variable) {
- if(is_string($variable) && Str::contains($variable, ':')) {
- $array = collect(explode('.', $variable))
- ->map(fn ($x) => Str::beforeLast($x,':'));
- if (!in_array($array->first(), $variables)) {
- $variables[] = $array->first();
- }
-
- $variables[$key] = $array->implode('.');
- }
- }
- return $variables;
- }
+ /**
+ * Adds Block Relations if needed
+ *
+ * @param array $variables
+ * @return array
+ */
+ public static function getReducedForRelations(array $variables): array
+ {
+ foreach ($variables as $key => $variable) {
+ if (is_string($variable) && Str::contains($variable, ':')) {
+ $array = collect(explode('.', $variable))
+ ->map(fn ($x) => Str::beforeLast($x, ':'));
+ if (!in_array($array->first(), $variables)) {
+ $variables[] = $array->first();
+ }
- public static function getRelatedConditions(array $variables) : array
- {
- $conditions = [];
- foreach ($variables as $key => $variable) {
- if(is_string($variable) && Str::contains($variable, ':')) {
- $condition = collect(explode('.', $variable))
- ->filter(fn ($x) => Str::contains($x, ':'))
- ->first();
- $cond = self::getConditions($condition);
- if($cond === null) continue;
- $conditions[Str::beforeLast($condition, ':')] = array_merge($cond, [$condition]);
- }
- }
- return $conditions;
- }
+ $variables[$key] = $array->implode('.');
+ }
+ }
+ return $variables;
+ }
+ /**
+ * @param array $variables
+ * @return array
+ */
+ public static function getRelatedConditions(array $variables): array
+ {
+ $conditions = [];
+ foreach ($variables as $key => $variable) {
+ if (is_string($variable) && Str::contains($variable, ':')) {
+ $condition = collect(explode('.', $variable))
+ ->filter(fn ($x) => Str::contains($x, ':'))
+ ->first();
+ $cond = self::getConditions($condition);
+ if ($cond === null) {
+ continue;
+ }
+ $conditions[Str::beforeLast($condition, ':')] = array_merge($cond, [$condition]);
+ }
+ }
+ return $conditions;
+ }
- private static function getConditions(string $condition): ?array
- {
- if(!is_string($condition) || empty($condition)) return null;
- [$relation, $cond] = explode(':', $condition);
- if(!is_string($cond) || empty($cond)) return null;
- $condArray = explode(',', $cond);
- if(!isset($condArray[1])) {
- $key = '$primary';
- $operator = '=';
- $value = $condArray[0];
- }
- else if(!isset($condArray[2])) {
- $key = $condArray[0];
- $operator = '=';
- $value = $condArray[1];
- } else {
- $key = $condArray[0];
- $operator = $condArray[1];
- $value = $condArray[2];
- }
+ /**
+ * @return array{0: string, 1: string, 2: string}|null
+ */
+ private static function getConditions(string $condition): ?array
+ {
+ if (!is_string($condition) || empty($condition)) {
+ return null;
+ }
+ [$relation, $cond] = explode(':', $condition);
+ if (!is_string($cond) || empty($cond)) {
+ return null;
+ }
+ $condArray = explode(',', $cond);
+ if (!isset($condArray[1])) {
+ $key = '$primary';
+ $operator = '=';
+ $value = $condArray[0];
+ } elseif (!isset($condArray[2])) {
+ $key = $condArray[0];
+ $operator = '=';
+ $value = $condArray[1];
+ } else {
+ $key = $condArray[0];
+ $operator = $condArray[1];
+ $value = $condArray[2];
+ }
- return [$key, $operator, $value];
- }
+ return [$key, $operator, $value];
+ }
}
\ No newline at end of file
diff --git a/src/Processor/WordTemplateExporter.php b/src/Processor/WordTemplateExporter.php
index 7cd3347..682c702 100644
--- a/src/Processor/WordTemplateExporter.php
+++ b/src/Processor/WordTemplateExporter.php
@@ -1,5 +1,7 @@
*/
protected array $concerns = [];
+ protected ?TemplatePathResolver $pathResolver = null;
+
public function processFile(object $export): Exporter
{
$this->export = $export;
- $this->implementsMinumum();
+ $this->implementsMinimum();
$file = $this->getFilePath();
$exporter = new Exporter($file);
@@ -36,27 +43,21 @@ public function processFile(object $export): Exporter
private function getFilePath(): string
{
- $file = $this->export->wordTemplateFile();
- if (!file_exists($file)) {
- $file = storage_path($file);
- }
- if (!file_exists($file)) {
- $file = storage_path('app/'.$this->export->wordTemplateFile());
- }
-
- return $file;
+ $this->pathResolver ??= new TemplatePathResolver();
+ return $this->pathResolver->resolve($this->export->wordTemplateFile());
}
private function setValues(Exporter $exporter): void
{
if ($this->hasConcern(TokensFromCollection::class) || $this->hasConcern(TokensFromArray::class)) {
$blockNames = $this->export->blockName();
- if(!is_array($blockNames)) {
+ if (!is_array($blockNames)) {
$blockNames = [$blockNames];
}
+ $formattedData = $this->formatData();
foreach ($blockNames as $blockName) {
- $data = isset($this->formatData()[$blockName]) ? $this->formatData()[$blockName] : $this->formatData();
- if($data instanceof Collection) {
+ $data = $formattedData[$blockName] ?? $formattedData;
+ if ($data instanceof Collection) {
$data = $data->toArray();
}
$exporter->setBlockValues($blockName, $data);
@@ -97,7 +98,7 @@ private function setCheckboxes(Exporter $exporter): void
{
if ($this->hasConcern(WithCheckboxes::class)) {
foreach ($this->export->checkboxes() as $key => $value) {
- $exporter->setCheckbox($key, (bool)$value);
+ $exporter->setCheckbox($key, (bool) $value);
}
}
}
@@ -106,7 +107,7 @@ private function setCharts(Exporter $exporter): void
{
if ($this->hasConcern(WithCharts::class)) {
foreach ($this->export->charts() as $key => $value) {
- if(is_callable($value)) {
+ if (is_callable($value)) {
$value = $value();
}
$exporter->setChart($key, $value);
@@ -130,7 +131,10 @@ private function setTables(Exporter $exporter): void
}
}
- private function formatData()
+ /**
+ * @return array
+ */
+ private function formatData(): array
{
if ($this->hasConcern(TokensFromCollection::class)) {
return $this->export->items()->map(fn ($x) => $this->export->itemTokens($x))->toArray();
@@ -141,7 +145,7 @@ private function formatData()
throw new MissingConcernException();
}
- private function implementsMinumum()
+ private function implementsMinimum(): void
{
$this->concerns = class_implements($this->export);
$implementsMissing = array_diff([FromWordTemplate::class], $this->concerns);
diff --git a/src/Services/TemplatePathResolver.php b/src/Services/TemplatePathResolver.php
new file mode 100644
index 0000000..02dae8c
--- /dev/null
+++ b/src/Services/TemplatePathResolver.php
@@ -0,0 +1,25 @@
+
+ */
+ public static function formats(): array
{
return [self::DOCX, self::HTML, self::PDF];
}
diff --git a/src/stubs/wordexport.stub b/src/stubs/wordexport.stub
index b0aa1e5..bc69339 100644
--- a/src/stubs/wordexport.stub
+++ b/src/stubs/wordexport.stub
@@ -37,4 +37,9 @@ class {{ class }} implements FromWordTemplate, TokensFromCollection, GlobalToken
{
return collect();
}
-}
\ No newline at end of file
+
+ public function values(): array
+ {
+ return [];
+ }
+}
diff --git a/tests/Integration/ExportClassExporterTest.php b/tests/Integration/ExportClassExporterTest.php
index b8ba1bd..d7e65cc 100644
--- a/tests/Integration/ExportClassExporterTest.php
+++ b/tests/Integration/ExportClassExporterTest.php
@@ -5,9 +5,11 @@
use Illuminate\Support\Facades\Storage;
use Santwer\Exporter\Concerns\FromWordTemplate;
use Santwer\Exporter\Concerns\GlobalTokens;
+use Santwer\Exporter\Exportables\Exportable;
use Santwer\Exporter\Processor\ExportClassExporter;
use Santwer\Exporter\Processor\WordTemplateExporter;
use Santwer\Exporter\Tests\TestCase;
+use Santwer\Exporter\Writer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ExportClassExporterTest extends TestCase
@@ -41,4 +43,79 @@ public function values(): array { return []; }
$this->assertNotFalse($result);
Storage::disk('local')->assertExists('exports/stored.docx');
}
+
+ public function test_download_pdf_sets_content_type(): void
+ {
+ if (!$this->hasSoffice()) {
+ $this->markTestSkipped('LibreOffice not available');
+ }
+
+ $templatePath = $this->createMinimalDocx();
+ $export = new class($templatePath) implements FromWordTemplate, GlobalTokens {
+ private string $path;
+ public function __construct(string $path) { $this->path = $path; }
+ public function wordTemplateFile(): string { return $this->path; }
+ public function values(): array { return []; }
+ };
+ $exporter = new ExportClassExporter(new WordTemplateExporter());
+ $response = $exporter->download($export, 'output.pdf');
+ $this->assertInstanceOf(BinaryFileResponse::class, $response);
+ }
+
+ public function test_store_with_filename_in_path_calls_store_as(): void
+ {
+ Storage::fake('local');
+ $templatePath = $this->createMinimalDocx();
+ $export = new class($templatePath) implements FromWordTemplate, GlobalTokens {
+ private string $path;
+ public function __construct(string $path) { $this->path = $path; }
+ public function wordTemplateFile(): string { return $this->path; }
+ public function values(): array { return []; }
+ };
+ $exporter = new ExportClassExporter(new WordTemplateExporter());
+ $result = $exporter->store($export, 'exports/file.docx', 'local');
+ $this->assertNotFalse($result);
+ Storage::disk('local')->assertExists('exports/file.docx');
+ }
+
+ public function test_store_with_folder_path(): void
+ {
+ Storage::fake('local');
+ $templatePath = $this->createMinimalDocx();
+ $export = new class($templatePath) implements FromWordTemplate, GlobalTokens {
+ private string $path;
+ public function __construct(string $path) { $this->path = $path; }
+ public function wordTemplateFile(): string { return $this->path; }
+ public function values(): array { return []; }
+ };
+ $exporter = new ExportClassExporter(new WordTemplateExporter());
+ $result = $exporter->store($export, 'exports', 'local');
+ $this->assertNotFalse($result);
+ }
+
+ public function test_download_with_pdf_writer_type(): void
+ {
+ if (!$this->hasSoffice()) {
+ $this->markTestSkipped('LibreOffice not available');
+ }
+
+ $templatePath = $this->createMinimalDocx();
+ $export = new class($templatePath) implements FromWordTemplate, GlobalTokens {
+ private string $path;
+ public function __construct(string $path) { $this->path = $path; }
+ public function wordTemplateFile(): string { return $this->path; }
+ public function values(): array { return []; }
+ };
+ $exporter = new ExportClassExporter(new WordTemplateExporter());
+ $response = $exporter->download($export, 'output.docx', Writer::PDF);
+ $this->assertInstanceOf(BinaryFileResponse::class, $response);
+ }
+
+ private function hasSoffice(): bool
+ {
+ $process = new \Symfony\Component\Process\Process(['soffice', '--version']);
+ $process->run();
+ return $process->isSuccessful();
+ }
}
+
diff --git a/tests/Integration/ExporterTest.php b/tests/Integration/ExporterTest.php
index 99319a0..7877203 100644
--- a/tests/Integration/ExporterTest.php
+++ b/tests/Integration/ExporterTest.php
@@ -52,4 +52,94 @@ public function test_get_processed_converted_file_docx(): void
$outPath = $exporter->getProcessedConvertedFile(Writer::DOCX);
$this->assertFileExists($outPath);
}
+
+ public function test_set_array_flattens_nested_arrays(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setArray(['user' => ['name' => 'John', 'email' => 'john@test.com']]);
+ $vars = $exporter->getTemplateVariables();
+ $this->assertIsArray($vars);
+ }
+
+ public function test_set_checkbox(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setCheckbox('agree', true);
+ $exporter->setCheckbox('optout', false);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_set_chart(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setChart('sales', (object)['data' => []]);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_set_image_with_string_path(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setImage('logo', __FILE__);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_set_tables(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setTables(['table1' => ['rows' => []]]);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_table_data_to_complex_block_with_callable(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setTables(['table1' => fn() => ['rows' => [['Col1', 'Col2']]]]);
+ $processor = $exporter->process();
+ $this->assertNotNull($processor);
+ }
+
+ public function test_table_data_with_headers_and_style(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $exporter->setTables(['table1' => [
+ 'style' => 'gridTable',
+ 'headers' => [
+ ['text' => 'Name', 'width' => 2000, 'style' => 'bold'],
+ 'Email'
+ ],
+ 'rows' => [
+ [['text' => 'John', 'width' => 2000], 'john@test.com']
+ ]
+ ]]);
+ $processor = $exporter->process();
+ $this->assertNotNull($processor);
+ }
+
+ public function test_process_with_combined_features(): void
+ {
+ $templatePath = $this->createMinimalDocx('${test}${block}${item}${/block}');
+ $exporter = new Exporter($templatePath);
+ $exporter->setValue('test', 'Value');
+ $exporter->setBlockValues('block', [['item' => 'A'], ['item' => 'B']]);
+ $exporter->setCheckbox('check', true);
+ $processor = $exporter->process();
+ $this->assertNotNull($processor);
+ }
+
+ public function test_get_template_processor_returns_same_instance(): void
+ {
+ $templatePath = $this->createMinimalDocx();
+ $exporter = new Exporter($templatePath);
+ $proc1 = $exporter->getTemplateProcessor();
+ $proc2 = $exporter->getTemplateProcessor();
+ $this->assertSame($proc1, $proc2);
+ }
}
+
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 699faa1..3bcc1f7 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -5,6 +5,8 @@
use Orchestra\Testbench\TestCase as Orchestra;
use PhpOffice\PhpWord\PhpWord;
use Santwer\Exporter\ExporterProvider;
+use Santwer\Exporter\Helpers\ExportHelper;
+use Santwer\Exporter\Processor\GlobalVariables;
abstract class TestCase extends Orchestra
{
@@ -22,6 +24,14 @@ protected function getEnvironmentSetUp($app): void
$app['config']->set('exporter.batch_size', 200);
}
+ protected function tearDown(): void
+ {
+ GlobalVariables::clear();
+ ExportHelper::resetBatchCounters();
+ ExportHelper::resetGarbage();
+ parent::tearDown();
+ }
+
protected function createMinimalDocx(string $placeholder = '${test}'): string
{
$phpWord = new PhpWord();
diff --git a/tests/Unit/Exceptions/PDFConversionExceptionTest.php b/tests/Unit/Exceptions/PDFConversionExceptionTest.php
new file mode 100644
index 0000000..be1fd84
--- /dev/null
+++ b/tests/Unit/Exceptions/PDFConversionExceptionTest.php
@@ -0,0 +1,24 @@
+assertStringContainsString('error text', $e->getMessage());
+ $this->assertStringContainsString('output text', $e->getMessage());
+ $this->assertStringContainsString('PDF conversion failed', $e->getMessage());
+ }
+
+ public function test_can_be_constructed_directly(): void
+ {
+ $e = new PDFConversionException('Direct message', 123);
+ $this->assertSame('Direct message', $e->getMessage());
+ $this->assertSame(123, $e->getCode());
+ }
+}
diff --git a/tests/Unit/ExporterProviderTest.php b/tests/Unit/ExporterProviderTest.php
index 2305a5e..2392ab0 100644
--- a/tests/Unit/ExporterProviderTest.php
+++ b/tests/Unit/ExporterProviderTest.php
@@ -19,4 +19,9 @@ public function test_exporter_config_is_merged(): void
$this->assertNotNull(config('exporter.temp_folder'));
$this->assertNotNull(config('exporter.batch_size'));
}
+
+ public function test_boot_registers_commands(): void
+ {
+ $this->assertTrue(class_exists(\Santwer\Exporter\Commands\MakeExportCommand::class));
+ }
}
diff --git a/tests/Unit/Processor/PDFExporterTest.php b/tests/Unit/Processor/PDFExporterTest.php
new file mode 100644
index 0000000..35231b2
--- /dev/null
+++ b/tests/Unit/Processor/PDFExporterTest.php
@@ -0,0 +1,50 @@
+markTestSkipped('Requires soffice binary or mock - integration test');
+ }
+
+ public function test_docx_to_pdf_returns_pdf_path_on_success(): void
+ {
+ $docxPath = $this->createMinimalDocx();
+ $dir = pathinfo($docxPath, PATHINFO_DIRNAME);
+
+ if (!$this->sofOfficeAvailable()) {
+ $this->markTestSkipped('LibreOffice not available');
+ }
+
+ $result = PDFExporter::docxToPdf($docxPath, $dir);
+ $this->assertIsString($result);
+ $this->assertStringEndsWith('.pdf', $result);
+ }
+
+ public function test_html_to_pdf_creates_temp_html_file(): void
+ {
+ if (!$this->sofOfficeAvailable()) {
+ $this->markTestSkipped('LibreOffice not available');
+ }
+
+ $html = 'Test';
+ $result = PDFExporter::html2Pdf($html);
+ $this->assertIsString($result);
+ $this->assertStringEndsWith('.pdf', $result);
+ }
+
+ private function sofOfficeAvailable(): bool
+ {
+ $process = new Process(['soffice', '--version']);
+ $process->run();
+ return $process->isSuccessful();
+ }
+}
diff --git a/tests/Unit/Processor/TemplateProcessorTest.php b/tests/Unit/Processor/TemplateProcessorTest.php
new file mode 100644
index 0000000..7e22944
--- /dev/null
+++ b/tests/Unit/Processor/TemplateProcessorTest.php
@@ -0,0 +1,158 @@
+createMinimalDocx('${test}');
+ $processor = new TemplateProcessor($docPath);
+ $processor->setValue('test', 'Replaced');
+ $variables = $processor->getVariables();
+ $this->assertIsArray($variables);
+ }
+
+ public function test_replace_escapes_html_by_default(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('');
+ $this->assertStringContainsString('<', $result);
+ $this->assertStringContainsString('>', $result);
+ }
+
+ public function test_replace_allows_tags_when_flag_set(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('test', true);
+ $this->assertStringContainsString('', $result);
+ }
+
+ public function test_replace_handles_ampersand_encoding(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('A & B');
+ $this->assertStringContainsString('&', $result);
+ }
+
+ public function test_replace_with_array_first_element_as_value(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace(['value', true]);
+ $this->assertSame('value', $result);
+ }
+
+ public function test_replace_escapes_double_quotes(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('Say "Hello"');
+ $this->assertStringContainsString('"', $result);
+ $this->assertStringNotContainsString('"', $result);
+ }
+
+ public function test_replace_escapes_single_quotes(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace("It's working");
+ // ENT_XML1 uses ' for single quotes
+ $this->assertStringContainsString(''', $result);
+ }
+
+ public function test_replace_escapes_quotes_with_allow_tags(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('Text\'s "value"
', true);
+ // Tags should be preserved
+ $this->assertStringContainsString('assertStringContainsString('
', $result);
+ // Quotes should be escaped (allowTags uses ' for single quotes)
+ $this->assertStringContainsString('"', $result);
+ $this->assertStringContainsString(''', $result);
+ }
+
+ public function test_replace_preserves_utf8_characters(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('Ä Ö Ü ä ö ü ß € ñ');
+ $this->assertStringContainsString('Ä', $result);
+ $this->assertStringContainsString('Ö', $result);
+ $this->assertStringContainsString('Ü', $result);
+ $this->assertStringContainsString('ä', $result);
+ $this->assertStringContainsString('ö', $result);
+ $this->assertStringContainsString('ü', $result);
+ $this->assertStringContainsString('ß', $result);
+ $this->assertStringContainsString('€', $result);
+ $this->assertStringContainsString('ñ', $result);
+ }
+
+ public function test_replace_handles_null_value(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace(null);
+ $this->assertSame('', $result);
+ }
+
+ public function test_replace_handles_numeric_values(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $resultInt = $processor->replace(42);
+ $this->assertSame('42', $resultInt);
+ $resultFloat = $processor->replace(3.14);
+ $this->assertSame('3.14', $resultFloat);
+ }
+
+ public function test_replace_does_not_double_encode_entities(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->replace('Already & encoded');
+ // Should not become &
+ $this->assertStringContainsString('&', $result);
+ $this->assertStringNotContainsString('&', $result);
+ }
+
+ public function test_clone_recursive_blocks_processes_block(): void
+ {
+ $docPath = $this->createMinimalDocx('${block}${name}${/block}');
+ $processor = new TemplateProcessor($docPath);
+ $processor->cloneRecursiveBlocks('block', 2, true, false, [
+ [['name' => 'First']],
+ [['name' => 'Second']]
+ ]);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_array_list_recursive_flattens_nested_arrays(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->arrayListRecursive([
+ ['user' => ['name' => 'John', 'email' => 'john@test.com']]
+ ]);
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey(0, $result);
+ }
+
+ public function test_array_list_recusive_deprecated_alias_works(): void
+ {
+ $docPath = $this->createMinimalDocx();
+ $processor = new TemplateProcessor($docPath);
+ $result = $processor->arrayListRecusive([
+ ['key' => 'value']
+ ]);
+ $this->assertIsArray($result);
+ }
+}
diff --git a/tests/Unit/Services/TemplatePathResolverTest.php b/tests/Unit/Services/TemplatePathResolverTest.php
new file mode 100644
index 0000000..019b9ae
--- /dev/null
+++ b/tests/Unit/Services/TemplatePathResolverTest.php
@@ -0,0 +1,59 @@
+cleanup as $file) {
+ @unlink($file);
+ }
+ parent::tearDown();
+ }
+
+ public function test_resolve_with_absolute_path_returns_path(): void
+ {
+ $path = $this->createMinimalDocx();
+ $resolver = new TemplatePathResolver();
+ $resolved = $resolver->resolve($path);
+ $this->assertSame($path, $resolved);
+ }
+
+ public function test_resolve_with_storage_path(): void
+ {
+ $fileName = 'template-'.uniqid().'.docx';
+ $path = storage_path($fileName);
+ file_put_contents($path, 'test');
+ $this->cleanup[] = $path;
+
+ $resolver = new TemplatePathResolver();
+ $resolved = $resolver->resolve($fileName);
+ $this->assertSame($path, $resolved);
+ }
+
+ public function test_resolve_with_storage_app_path(): void
+ {
+ $fileName = 'template-'.uniqid().'.docx';
+ $path = storage_path('app/'.$fileName);
+ file_put_contents($path, 'test');
+ $this->cleanup[] = $path;
+
+ $resolver = new TemplatePathResolver();
+ $resolved = $resolver->resolve($fileName);
+ $this->assertSame($path, $resolved);
+ }
+
+ public function test_resolve_throws_exception_when_not_found(): void
+ {
+ $resolver = new TemplatePathResolver();
+ $this->expectException(NoFileException::class);
+ $resolver->resolve('non-existent-'.uniqid().'.docx');
+ }
+}