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).

-Commit Activity +Commit Activity Total Downloads Latest Stable Version License

-## 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 &amp;) +``` + +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 &amp; + $this->assertStringContainsString('&', $result); + $this->assertStringNotContainsString('&amp;', $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'); + } +}