diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index c9e4240..d37e2c1 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -69,9 +69,9 @@ jobs: coverage: none tools: composer:v2 - run: composer install --no-interaction --no-progress --no-dev --classmap-authoritative - - name: Benchmark (10-page table document, median of 5 runs after 1 warm-up) + - name: Benchmark (10-page table document, PDF and HTML, median of 5 runs after 1 warm-up) run: php -d opcache.enable_cli=1 bench/run.php | tee bench-result.json - - name: Compare with the committed baseline (fails if > 20% slower) + - name: Compare with the committed baseline (PDF and HTML each fail if > 20% slower) run: php bench/check.php bench-result.json - uses: actions/upload-artifact@v7 if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ceff8..0c69358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ All notable changes are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] - 0.2.0 + +v0.1 was never published to Packagist, so there are no deprecation shims. + +### Changed (breaking) + +Migration from v0.1: + +| v0.1 | 0.2 | +|---|---| +| `Pdf::view(...)` | `Papercut::view(...)->pdf()` | +| `` | `` | +| `PendingPdf` | `PendingOutput` | +| `PdfManager` | `PapercutManager` | +| `Document::render()` / `toString()` | `toPdf()->bytes()` | +| `Document::save($p)` / `stream()` | `toPdf()->save($p)` / `toPdf()->stream()` | +| `RenderOptions` | `PdfOptions` | +| `Image::handle()` | removed; use `Image::file()` / `Image::bytes()` | + +- The `Pdf` facade alias is now `Papercut`; `Papercut::pdfOptions()` replaces `Pdf::options()`. +- The Blade tag prefix is configurable through `papercut.prefix` (default `doc`). +- `PdfOptions` is not a drop-in rename of `RenderOptions`; the constructor changed shape: + - the positional order is now theme, fonts, onWarning, strict, then backend, overflow, cacheDir, so use named arguments; + - `fonts` takes a `FontSet`, not a `FontRegistry`; + - fallback families moved to `FontSet::withFallbacks()`; + - the bundled Inter is always registered. +- Save failures now read `Directory does not exist: …` or `Could not write output to "…": …` (previously `Could not write PDF to …`). +- In Laravel, all config errors (theme, styles, overflow, strict, fonts, including a missing or unreadable font file) now surface when the output is rendered, not at `view()`, `make()` or `Papercut::pdfOptions()`. +- `->html()` returns a `PendingHtml` that no longer extends `PendingOutput` and has no `toString()`, `save()`, `download()`, `inline()` or `output()`. Use `->html()->page()` (full page) or `->html()->fragment()` (embeddable fragment), each a `PendingOutput` named `document.html`. `PendingOutput` is now `final`. Returning `->html()` from a route still sends the full page with no `Content-Disposition`. +- `Output` and the export options expose methods, not properties: `$output->bytes()`, `mime()`, `extension()`; `$options->theme()`, `fonts()`, `onWarning()`, `strict()`, `overflow()`, `cacheDir()`. + +### Added + +- `Papercut::make($document)` and `PendingDocument` (`document()`, `pdf()`); `PendingOutput` takes its `Content-Type` and default filename from the `Output`. +- Core: `Exporter`/`Output` API, `PdfOptions`, `FontSet`, fidelity reporting (`Fidelity`, `FidelityReport`), strict mode (`UnsupportedFeature`) and `MissingExporter`. +- Config key `papercut.strict` (default `false`). +- HTML export: `Document::toHtml()` in core and `Papercut::view(...)->html()` in Laravel (`PendingHtml`: `(string)` and `{{ }}` give the fragment for embedding, `page()` and `fragment()` each return a `PendingOutput`, and a route response is the full page). +- Config key `papercut.html.fonts` (`none` default, `embed` or `link`), and `papercut.html.image_url` / `papercut.html.font_url`: invokable class names implementing `Papercut\Laravel\Html\ImageUrlResolver` / `FontUrlResolver`, built by the container (class names survive `config:cache`; closures are rejected). `link` needs `font_url`. Invalid `html.*` config is reported at the first `->html()` export. +- Laravel: `papercut.html.fonts = sources`, with `html.font_sources` (default `[GoogleFonts::class]`), `html.google_fonts` (`host`, `display`, `families`) and `html.local_fonts` (`mode`, `families`). The service provider binds `GoogleFonts` and `LocalFonts` from config. Checked lazily at the first `->html()` export, and only when used. +- Preview routes accept `?format=pdf|html`; any other value is a 404. +- `Papercut::exportOptions()` (shared theme, fonts, warnings, strict) and `Papercut::htmlOptions()`; `pdfOptions()` builds on the shared options. +- `HtmlOptions` (`fullPage`, `fontMode`, `imageUrl`, `fontUrl`), `FontMode` (`None`, `Embed`, `Link`) and the `imageUrl` / `fontUrl` callbacks: images embed as data URIs unless `imageUrl` returns a safe URL; fonts embed or link per `FontMode`. +- `onWarning` callbacks are arity-aware: a one-parameter callback gets the message only, a callback with a second parameter also gets the `FidelityReport`. +- A table cell outside a table (not in a row) is laid out as a column and warns `A table cell outside a table is laid out as a column`, in both PDF and HTML. +- HTML font sources: `FontMode::Sources` with `HtmlOptions::fontSources`, an ordered stack of `FontSource` objects or closures (`GoogleFonts` for Google Fonts and Bunny Fonts, `LocalFonts::embed()` / `link()` for registered files). Full pages get `` tags in ``; fragments get `@import` rules first in `

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/examples/html/render.php b/examples/html/render.php new file mode 100644 index 0000000..6ace7d4 --- /dev/null +++ b/examples/html/render.php @@ -0,0 +1,31 @@ +path) ?: $face->path); + // A face outside the repository has no relative URL that works elsewhere; null skips it. + if (!str_starts_with($path, $root . '/')) { + return null; + } + return '../../' . ltrim(substr($path, strlen($root)), '/'); +}; + +// The document files assign their own variables, so each is loaded inside a function scope. +$load = static function (string $name): Document { + return require __DIR__ . '/../core/documents/' . $name . '.php'; +}; + +foreach (['invoice', 'transcript', 'report'] as $name) { + $load($name)->toHtml(new HtmlOptions(fullPage: true, fontMode: FontMode::Link, fontUrl: $fontUrl))->save(__DIR__ . '/' . $name . '.html'); + echo 'Wrote examples/html/' . $name . ".html\n"; +} diff --git a/examples/html/report.html b/examples/html/report.html new file mode 100644 index 0000000..66cafdf --- /dev/null +++ b/examples/html/report.html @@ -0,0 +1,17 @@ +Designing Papercut

Papercut Design Report

Confidential

Designing Papercut

A short report on layout, pagination, and deterministic output

SECTIONS

4

plus a glossary appendix

WORDS OF PROSE

509

across 10 justified paragraphs

ATTRIBUTES

28

listed in the reference table

1. Overview

Papercut starts from a simple observation: most business documents are layouts, not drawings. An invoice is a column of blocks, a letterhead is a row with a flexible gap, and a transcript is a table that happens to be long. The library therefore models documents as trees of components and leaves the coordinate arithmetic to a layout engine.

Every node is immutable. A modifier such as padding or bold returns a new instance, so a fragment can be reused in a dozen places without one caller changing what another sees. This also makes documents safe to build once and render many times, which matters in long-running workers.

Layout follows a measure-then-place model. Each element reports the space it wants given the width on offer, and containers distribute the remainder using grow factors, gaps, and alignment. The vocabulary is deliberately close to flexbox, so anyone who has written modern CSS can predict the result.

2. Text and pagination

Text is shaped with real font metrics. Lines are broken at word boundaries, justified paragraphs stretch the gaps between words, and inline markup such as bold or italic spans is carried through the line breaker without losing its style. Widths come from the embedded font program, never from an estimate.

Pagination is a first-class concern rather than an afterthought. Blocks can ask to be kept together, a heading can insist on staying with the paragraph that follows it, and tables repeat their header on every page they cross. When nothing else works, the engine splits a block at a line boundary and keeps going.

Output is deterministic. Given the same input and a fixed creation date, two runs produce identical bytes. That property makes golden-file tests practical: a visual regression becomes a byte comparison, and a diff in the PDF points at a diff in the code.

3. Backend and failure modes

The backend is a thin seam. The layout engine produces a display list of rectangles, lines, text runs, and links in plain points with a top-left origin, and one adapter turns that list into PDF objects. Nothing above the adapter knows how a PDF is encoded, so the engine can be tested without parsing a single file.

Failure is explicit. If a block cannot fit on a page even after every permitted split, the engine throws by default and names the offending node. Callers who prefer degraded output can opt into clipping, which keeps the rest of the document flowing and reports every clipped node through a warning callback; silent corruption is never the default.

Styling is data. Colors resolve from a named palette, sizes accept points, millimetres, or percentages, and a theme supplies defaults for family, size, weight, and line height. Because every style property can also be set from a string attribute, templates written in markup and code written in PHP describe the same documents.

The result is a library that stays out of the way for simple documents and still holds together for hard ones: a forty-page ledger, a certificate with a decorative background, or a report whose tables must not be orphaned from their captions.

Rule of thumb: measure first, then place, and only then paint.

4. Style attributes

Attribute

Example

Effect

padding

12pt 6mm

Space inside the border; one to four values, like CSS.

margin

0 0 6pt

Space outside the border; one to four values.

gap

8pt

Space between the children of a row or column.

grow

1

Share of the free space a child takes along the main axis.

shrink

0

Share of the overflow a child gives back along the main axis.

basis

40%

Starting main size of a flex child before grow and shrink.

width

60mm

Border-box width: a length, a percentage, or auto.

height

120pt

Border-box height; a fixed height makes the box monolithic.

min-width / max-width

20mm

Clamps applied after the width is resolved.

align-items

center

Cross-axis placement of the children.

justify

space-between

Main-axis distribution of the free space.

background

slate-50

Fill colour: a palette name or a hex value.

border-width

1pt

Border width; one to four values.

border-color

slate-200

Border colour: a palette name or a hex value.

radius

8pt

Corner radius of the background and border.

opacity

0.5

Opacity of the box and everything inside it.

font

Inter

Font family, resolved through the font registry.

font-size

10pt

Font size.

weight

700

Font weight, such as 400 or 700.

color

slate-900

Text colour: a palette name or a hex value.

line-height

1.4

Line height as a multiple of the font size.

letter-spacing

0.5pt

Extra space after every character.

text-align

justify

Left, center, right or justify.

underline / strike

true

Text decorations drawn under or through the run.

keep-together

true

Moves the whole block to the next page rather than splitting it.

keep-with-next

true

Keeps a block on the same page as the block that follows it.

break-before / break-after

true

Forces a page break before or after the block.

min-lines

2

Fewest lines of a paragraph left on either side of a page break.

Appendix A: Glossary

Display list: The ordered drawing operations produced by layout.

Fragment: The part of a node that fits on one page.

Track: A column width definition in a table.

\ No newline at end of file diff --git a/examples/html/transcript.html b/examples/html/transcript.html new file mode 100644 index 0000000..49c8085 --- /dev/null +++ b/examples/html/transcript.html @@ -0,0 +1,17 @@ +Official Transcript

Official Transcript

One Learning Community

Ada Lovelace

Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019

CREDITS

210

GPA

3.20

STANDING

Good

COURSES

60

Course history

Code

Course

Term

Credits

Grade

CS 100

Introduction to Calculus

Term 1

3

A

CS 107

Introduction to Linear Algebra

Term 1

4

C+

CS 114

Introduction to Data Structures

Term 1

3

B-

CS 121

Introduction to Algorithms

Term 1

4

B-

CS 128

Introduction to Physics

Term 1

3

B

CS 135

Introduction to Statistics

Term 1

4

B+

CS 142

Introduction to Databases

Term 1

3

B+

CS 149

Introduction to Compilers

Term 1

4

A-

CS 156

Introduction to Networks

Term 2

3

A

CS 163

Introduction to Ethics

Term 2

4

A

CS 170

Introduction to Logic

Term 2

3

C+

CS 177

Introduction to Numerical Methods

Term 2

4

B-

CS 184

Applied Calculus

Term 2

3

B-

CS 191

Applied Linear Algebra

Term 2

4

B

CS 198

Applied Data Structures

Term 2

3

B+

CS 205

Applied Algorithms

Term 2

4

B+

CS 212

Applied Physics

Term 3

3

A-

CS 219

Applied Statistics

Term 3

4

A

CS 226

Applied Databases

Term 3

3

A

CS 233

Applied Compilers

Term 3

4

C+

CS 240

Applied Networks

Term 3

3

B-

CS 247

Applied Ethics

Term 3

4

B-

CS 254

Applied Logic

Term 3

3

B

CS 261

Applied Numerical Methods

Term 3

4

B+

CS 268

Topics in Calculus

Term 4

3

B+

CS 275

Topics in Linear Algebra

Term 4

4

A-

CS 282

Topics in Data Structures

Term 4

3

A

CS 289

Topics in Algorithms

Term 4

4

A

CS 296

Topics in Physics

Term 4

3

C+

CS 303

Topics in Statistics

Term 4

4

B-

CS 310

Topics in Databases

Term 4

3

B-

CS 317

Topics in Compilers

Term 4

4

B

CS 324

Topics in Networks

Term 5

3

B+

CS 331

Topics in Ethics

Term 5

4

B+

CS 338

Topics in Logic

Term 5

3

A-

CS 345

Topics in Numerical Methods

Term 5

4

A

CS 352

Advanced Calculus

Term 5

3

A

CS 359

Advanced Linear Algebra

Term 5

4

C+

CS 366

Advanced Data Structures

Term 5

3

B-

CS 373

Advanced Algorithms

Term 5

4

B-

CS 380

Advanced Physics

Term 6

3

B

CS 387

Advanced Statistics

Term 6

4

B+

CS 394

Advanced Databases

Term 6

3

B+

CS 401

Advanced Compilers

Term 6

4

A-

CS 408

Advanced Networks

Term 6

3

A

CS 415

Advanced Ethics

Term 6

4

A

CS 422

Advanced Logic

Term 6

3

C+

CS 429

Advanced Numerical Methods

Term 6

4

B-

CS 436

Seminar in Calculus

Term 7

3

B-

CS 443

Seminar in Linear Algebra

Term 7

4

B

CS 450

Seminar in Data Structures

Term 7

3

B+

CS 457

Seminar in Algorithms

Term 7

4

B+

CS 464

Seminar in Physics

Term 7

3

A-

CS 471

Seminar in Statistics

Term 7

4

A

CS 478

Seminar in Databases

Term 7

3

A

CS 485

Seminar in Compilers

Term 7

4

C+

CS 492

Seminar in Networks

Term 8

3

B-

CS 499

Seminar in Ethics

Term 8

4

B-

CS 106

Seminar in Logic

Term 8

3

B

CS 113

Seminar in Numerical Methods

Term 8

4

B+

Cumulative summary

210 credits attempted, 671.3 quality points.

GPA 3.20

\ No newline at end of file diff --git a/examples/laravel/README.md b/examples/laravel/README.md index ae0ddb8..84d2cce 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -1,12 +1,12 @@ # Papercut Blade examples Blade versions of the plain-PHP examples in `examples/core`: `invoice`, `transcript`, `report`. -`views/components/` holds two app components (``, ``) that wrap `` tags. +`views/components/` holds two app components (``, ``) that wrap `` tags. Sample data lives in `data.php`; the report's table is generated filler, not measurements. ``` cd packages/laravel && composer install -php examples/laravel/render.php # writes examples/laravel/{invoice,transcript,report}.pdf (git-ignored) +php examples/laravel/render.php # writes examples/laravel/{invoice,transcript,report}.{pdf,html} (the PDFs are git-ignored, the HTML is committed) ``` `render.php` boots a throwaway app with Orchestra Testbench (a dev dependency) and registers the provider by hand. diff --git a/examples/laravel/invoice.html b/examples/laravel/invoice.html new file mode 100644 index 0000000..c16b926 --- /dev/null +++ b/examples/laravel/invoice.html @@ -0,0 +1,13 @@ +Invoice INV-2026-0142

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/examples/laravel/render.php b/examples/laravel/render.php index 5a3d483..ac20d71 100644 --- a/examples/laravel/render.php +++ b/examples/laravel/render.php @@ -10,7 +10,7 @@ require __DIR__ . '/../../packages/laravel/vendor/autoload.php'; use Orchestra\Testbench\Foundation\Application as Testbench; -use Papercut\Laravel\Facades\Pdf; +use Papercut\Laravel\Facades\Papercut; use Papercut\Laravel\PapercutServiceProvider; $app = Testbench::create(); @@ -23,6 +23,8 @@ foreach ($data as $view => $viewData) { $path = __DIR__ . "/$view.pdf"; - Pdf::view($view, $viewData)->save($path); + Papercut::view($view, $viewData)->pdf()->save($path); echo "Wrote examples/laravel/$view.pdf\n"; + Papercut::view($view, $viewData)->html()->page()->save(__DIR__ . "/$view.html"); + echo "Wrote examples/laravel/$view.html\n"; } diff --git a/examples/laravel/report.html b/examples/laravel/report.html new file mode 100644 index 0000000..94e91d8 --- /dev/null +++ b/examples/laravel/report.html @@ -0,0 +1,13 @@ +Designing Papercut

Papercut Design Report

Confidential

Designing Papercut

A short report on layout, pagination, and deterministic output

SECTIONS

4

plus an appendix

TABLE ROWS

80

split across pages, header repeated

1. Overview

Papercut starts from a simple observation: most business documents are layouts, not drawings. An invoice is a column of blocks, a letterhead is a row with a flexible gap, and a transcript is a table that happens to be long. The library therefore models documents as trees of components and leaves the coordinate arithmetic to a layout engine.

Every node is immutable. A modifier such as padding or bold returns a new instance, so a fragment can be reused in a dozen places without one caller changing what another sees. This also makes documents safe to build once and render many times, which matters in long-running workers.

Layout follows a measure-then-place model. Each element reports the space it wants given the width on offer, and containers distribute the remainder using grow factors, gaps, and alignment. The vocabulary is deliberately close to flexbox, so anyone who has written modern CSS can predict the result.

2. Text and pagination

Text is shaped with real font metrics. Lines are broken at word boundaries, justified paragraphs stretch the gaps between words, and inline markup such as bold or italic spans is carried through the line breaker without losing its style. Widths come from the embedded font program, never from an estimate.

Pagination is a first-class concern rather than an afterthought. Blocks can ask to be kept together, a heading can insist on staying with the paragraph that follows it, and tables repeat their header on every page they cross. When nothing else works, the engine splits a block at a line boundary and keeps going.

Output is deterministic. Given the same input and a fixed creation date, two runs produce identical bytes. That property makes golden-file tests practical: a visual regression becomes a byte comparison, and a diff in the PDF points at a diff in the code.

3. Backend and failure modes

The backend is a thin seam. The layout engine produces a display list of rectangles, lines, text runs, and links in plain points with a top-left origin, and one adapter turns that list into PDF objects. Nothing above the adapter knows how a PDF is encoded, so the engine can be tested without parsing a single file.

Failure is explicit. If a block cannot fit on a page even after every permitted split, the engine throws by default and names the offending node. Callers who prefer degraded output can opt into clipping the overflowing element, but silent corruption is never the default.

Styling is data. Colors resolve from a named palette, sizes accept points, millimetres, or percentages, and a theme supplies defaults for family, size, weight, and line height. Because every modifier also accepts a string form, templates written in markup and code written in PHP describe the same documents.

The result is a library that stays out of the way for simple documents and still holds together for hard ones: a forty-page ledger, a certificate with a decorative background, or a report whose tables must not be orphaned from their captions.

Rule of thumb: measure first, then place, and only then paint.

4. Sample table

Illustrative data generated for this example; not measurements.

#

Scenario

Pages

Rows

1

Sample scenario 1: nested rows

2

47

2

Sample scenario 2: wide table

3

84

3

Sample scenario 3: justified prose

4

31

4

Sample scenario 4: single column text

5

68

5

Sample scenario 5: nested rows

6

15

6

Sample scenario 6: wide table

7

52

7

Sample scenario 7: justified prose

8

89

8

Sample scenario 8: single column text

9

36

9

Sample scenario 9: nested rows

1

73

10

Sample scenario 10: wide table

2

20

11

Sample scenario 11: justified prose

3

57

12

Sample scenario 12: single column text

4

94

13

Sample scenario 13: nested rows

5

41

14

Sample scenario 14: wide table

6

78

15

Sample scenario 15: justified prose

7

25

16

Sample scenario 16: single column text

8

62

17

Sample scenario 17: nested rows

9

99

18

Sample scenario 18: wide table

1

46

19

Sample scenario 19: justified prose

2

83

20

Sample scenario 20: single column text

3

30

21

Sample scenario 21: nested rows

4

67

22

Sample scenario 22: wide table

5

14

23

Sample scenario 23: justified prose

6

51

24

Sample scenario 24: single column text

7

88

25

Sample scenario 25: nested rows

8

35

26

Sample scenario 26: wide table

9

72

27

Sample scenario 27: justified prose

1

19

28

Sample scenario 28: single column text

2

56

29

Sample scenario 29: nested rows

3

93

30

Sample scenario 30: wide table

4

40

31

Sample scenario 31: justified prose

5

77

32

Sample scenario 32: single column text

6

24

33

Sample scenario 33: nested rows

7

61

34

Sample scenario 34: wide table

8

98

35

Sample scenario 35: justified prose

9

45

36

Sample scenario 36: single column text

1

82

37

Sample scenario 37: nested rows

2

29

38

Sample scenario 38: wide table

3

66

39

Sample scenario 39: justified prose

4

13

40

Sample scenario 40: single column text

5

50

41

Sample scenario 41: nested rows

6

87

42

Sample scenario 42: wide table

7

34

43

Sample scenario 43: justified prose

8

71

44

Sample scenario 44: single column text

9

18

45

Sample scenario 45: nested rows

1

55

46

Sample scenario 46: wide table

2

92

47

Sample scenario 47: justified prose

3

39

48

Sample scenario 48: single column text

4

76

49

Sample scenario 49: nested rows

5

23

50

Sample scenario 50: wide table

6

60

51

Sample scenario 51: justified prose

7

97

52

Sample scenario 52: single column text

8

44

53

Sample scenario 53: nested rows

9

81

54

Sample scenario 54: wide table

1

28

55

Sample scenario 55: justified prose

2

65

56

Sample scenario 56: single column text

3

12

57

Sample scenario 57: nested rows

4

49

58

Sample scenario 58: wide table

5

86

59

Sample scenario 59: justified prose

6

33

60

Sample scenario 60: single column text

7

70

61

Sample scenario 61: nested rows

8

17

62

Sample scenario 62: wide table

9

54

63

Sample scenario 63: justified prose

1

91

64

Sample scenario 64: single column text

2

38

65

Sample scenario 65: nested rows

3

75

66

Sample scenario 66: wide table

4

22

67

Sample scenario 67: justified prose

5

59

68

Sample scenario 68: single column text

6

96

69

Sample scenario 69: nested rows

7

43

70

Sample scenario 70: wide table

8

80

71

Sample scenario 71: justified prose

9

27

72

Sample scenario 72: single column text

1

64

73

Sample scenario 73: nested rows

2

11

74

Sample scenario 74: wide table

3

48

75

Sample scenario 75: justified prose

4

85

76

Sample scenario 76: single column text

5

32

77

Sample scenario 77: nested rows

6

69

78

Sample scenario 78: wide table

7

16

79

Sample scenario 79: justified prose

8

53

80

Sample scenario 80: single column text

9

90

Appendix A: Glossary

Display list: The ordered drawing operations produced by layout.

Fragment: The part of a node that fits on one page.

Track: A column width definition in a table.

\ No newline at end of file diff --git a/examples/laravel/transcript.html b/examples/laravel/transcript.html new file mode 100644 index 0000000..8564833 --- /dev/null +++ b/examples/laravel/transcript.html @@ -0,0 +1,13 @@ +Official Transcript

Official Transcript

One Learning Community

Ada Lovelace

Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019

CREDITS

210

GPA

3.20

STANDING

Good

COURSES

60

Course history

Code

Course

Term

Credits

Grade

CS 100

Calculus

Term 1

3

A

CS 107

Linear Algebra

Term 1

4

C+

CS 114

Data Structures

Term 1

3

B-

CS 121

Algorithms

Term 1

4

B-

CS 128

Physics

Term 1

3

B

CS 135

Statistics

Term 1

4

B+

CS 142

Databases

Term 1

3

B+

CS 149

Compilers

Term 1

4

A-

CS 156

Networks

Term 2

3

A

CS 163

Ethics

Term 2

4

A

CS 170

Logic

Term 2

3

C+

CS 177

Numerical Methods

Term 2

4

B-

CS 184

Calculus 2

Term 2

3

B-

CS 191

Linear Algebra 2

Term 2

4

B

CS 198

Data Structures 2

Term 2

3

B+

CS 205

Algorithms 2

Term 2

4

B+

CS 212

Physics 2

Term 3

3

A-

CS 219

Statistics 2

Term 3

4

A

CS 226

Databases 2

Term 3

3

A

CS 233

Compilers 2

Term 3

4

C+

CS 240

Networks 2

Term 3

3

B-

CS 247

Ethics 2

Term 3

4

B-

CS 254

Logic 2

Term 3

3

B

CS 261

Numerical Methods 2

Term 3

4

B+

CS 268

Calculus 3

Term 4

3

B+

CS 275

Linear Algebra 3

Term 4

4

A-

CS 282

Data Structures 3

Term 4

3

A

CS 289

Algorithms 3

Term 4

4

A

CS 296

Physics 3

Term 4

3

C+

CS 303

Statistics 3

Term 4

4

B-

CS 310

Databases 3

Term 4

3

B-

CS 317

Compilers 3

Term 4

4

B

CS 324

Networks 3

Term 5

3

B+

CS 331

Ethics 3

Term 5

4

B+

CS 338

Logic 3

Term 5

3

A-

CS 345

Numerical Methods 3

Term 5

4

A

CS 352

Calculus 4

Term 5

3

A

CS 359

Linear Algebra 4

Term 5

4

C+

CS 366

Data Structures 4

Term 5

3

B-

CS 373

Algorithms 4

Term 5

4

B-

CS 380

Physics 4

Term 6

3

B

CS 387

Statistics 4

Term 6

4

B+

CS 394

Databases 4

Term 6

3

B+

CS 401

Compilers 4

Term 6

4

A-

CS 408

Networks 4

Term 6

3

A

CS 415

Ethics 4

Term 6

4

A

CS 422

Logic 4

Term 6

3

C+

CS 429

Numerical Methods 4

Term 6

4

B-

CS 436

Calculus 5

Term 7

3

B-

CS 443

Linear Algebra 5

Term 7

4

B

CS 450

Data Structures 5

Term 7

3

B+

CS 457

Algorithms 5

Term 7

4

B+

CS 464

Physics 5

Term 7

3

A-

CS 471

Statistics 5

Term 7

4

A

CS 478

Databases 5

Term 7

3

A

CS 485

Compilers 5

Term 7

4

C+

CS 492

Networks 5

Term 8

3

B-

CS 499

Ethics 5

Term 8

4

B-

CS 106

Logic 5

Term 8

3

B

CS 113

Numerical Methods 5

Term 8

4

B+

Cumulative summary

210 credits attempted, 671.3 quality points.

GPA 3.20

\ No newline at end of file diff --git a/examples/laravel/views/components/line-item-row.blade.php b/examples/laravel/views/components/line-item-row.blade.php index 0e4d058..13cfa99 100644 --- a/examples/laravel/views/components/line-item-row.blade.php +++ b/examples/laravel/views/components/line-item-row.blade.php @@ -1,12 +1,12 @@ @props(['name', 'desc', 'hours', 'rate']) - - - - {{ $name }} - {{ $desc }} - - - {{ $hours }} - ${{ number_format($rate, 2) }} - ${{ number_format($hours * $rate, 2) }} - + + + + {{ $name }} + {{ $desc }} + + + {{ $hours }} + ${{ number_format($rate, 2) }} + ${{ number_format($hours * $rate, 2) }} + diff --git a/examples/laravel/views/components/stat-card.blade.php b/examples/laravel/views/components/stat-card.blade.php index 164cb03..c5d26a2 100644 --- a/examples/laravel/views/components/stat-card.blade.php +++ b/examples/laravel/views/components/stat-card.blade.php @@ -1,8 +1,8 @@ @props(['label', 'value', 'note' => null, 'color' => 'slate-900']) - - {{ $label }} - {{ $value }} + + {{ $label }} + {{ $value }} @if ($note) - {{ $note }} + {{ $note }} @endif - + diff --git a/examples/laravel/views/invoice.blade.php b/examples/laravel/views/invoice.blade.php index c9d7f4f..97c054a 100644 --- a/examples/laravel/views/invoice.blade.php +++ b/examples/laravel/views/invoice.blade.php @@ -1,84 +1,84 @@ - - - + + + {{-- Letterhead --}} - - - Northwind Studio - 48 Harbor Street, Suite 210 - Portland, OR 97204 - billing@northwind.example - - - - INVOICE - No. INV-2026-0142 - Issued 2026-03-02 / Due 2026-04-01 - - + + + Northwind Studio + 48 Harbor Street, Suite 210 + Portland, OR 97204 + billing@northwind.example + + + + INVOICE + No. INV-2026-0142 + Issued 2026-03-02 / Due 2026-04-01 + + {{-- Bill to / project --}} - - - BILL TO - Marlowe & Finch Publishing - Attn: Accounts Payable - 900 Meridian Avenue - Seattle, WA 98101 - - - PROJECT - Report Platform Rebuild - PO NUMBER - PO-77310 - - + + + BILL TO + Marlowe & Finch Publishing + Attn: Accounts Payable + 900 Meridian Avenue + Seattle, WA 98101 + + + PROJECT + Report Platform Rebuild + PO NUMBER + PO-77310 + + {{-- Line items --}} - - - - Description - Hours - Rate - Amount - - + + + + Description + Hours + Rate + Amount + + @foreach ($items as [$name, $desc, $hours, $rate]) @endforeach - + {{-- Totals --}} - - - - - Subtotal - - ${{ number_format($subtotal, 2) }} - - - Tax (8.25%) - - ${{ number_format($tax, 2) }} - - - Total due - - ${{ number_format($total, 2) }} - - - - + + + + + Subtotal + + ${{ number_format($subtotal, 2) }} + + + Tax (8.25%) + + ${{ number_format($tax, 2) }} + + + Total due + + ${{ number_format($total, 2) }} + + + + - - - Payment terms: net 30. Wire or ACH to Northwind Studio, account ending 4417. - - - + + + Payment terms: net 30. Wire or ACH to Northwind Studio, account ending 4417. + + + - - + + diff --git a/examples/laravel/views/report.blade.php b/examples/laravel/views/report.blade.php index 9ca436f..f9b4b6e 100644 --- a/examples/laravel/views/report.blade.php +++ b/examples/laravel/views/report.blade.php @@ -1,65 +1,65 @@ - - + + - - Papercut Design Report - - Confidential - + + Papercut Design Report + + Confidential + - - Designing Papercut - A short report on layout, pagination, and deterministic output + + Designing Papercut + A short report on layout, pagination, and deterministic output - + - + - 1. Overview + 1. Overview @foreach ($prose as $i => $paragraph) @if ($i === 3) - 2. Text and pagination + 2. Text and pagination @elseif ($i === 6) - 3. Backend and failure modes + 3. Backend and failure modes @endif - {{ $paragraph }} + {{ $paragraph }} @endforeach - Rule of thumb: measure first, then place, and only then paint. + Rule of thumb: measure first, then place, and only then paint. - 4. Sample table - Illustrative data generated for this example; not measurements. - - - - # - Scenario - Pages - Rows - - + 4. Sample table + Illustrative data generated for this example; not measurements. + + + + # + Scenario + Pages + Rows + + @foreach ($scenarios as [$n, $name, $pages, $rows]) - - {{ $n }} - {{ $name }} - {{ $pages }} - {{ $rows }} - + + {{ $n }} + {{ $name }} + {{ $pages }} + {{ $rows }} + @endforeach - + - + - Appendix A: Glossary + Appendix A: Glossary @foreach ($glossary as [$term, $definition]) - {{ $term }}: {{ $definition }} + {{ $term }}: {{ $definition }} @endforeach - + - + - - + + diff --git a/examples/laravel/views/transcript.blade.php b/examples/laravel/views/transcript.blade.php index 38fdd07..10ba6bb 100644 --- a/examples/laravel/views/transcript.blade.php +++ b/examples/laravel/views/transcript.blade.php @@ -1,61 +1,61 @@ - - + + - - Official Transcript - - One Learning Community - + + Official Transcript + + One Learning Community + - - - {{ $student }} - Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019 - + + + {{ $student }} + Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019 + @foreach ([['CREDITS', (string) $credits], ['GPA', number_format($gpa, 2)], ['STANDING', 'Good'], ['COURSES', (string) count($courses)]] as [$label, $value]) - - {{ $label }} - {{ $value }} - + + {{ $label }} + {{ $value }} + @endforeach - - + + - Course history + Course history - - - - Code - Course - Term - Credits - Grade - - + + + + Code + Course + Term + Credits + Grade + + @foreach ($courses as [$code, $title, $term, $cr, $grade]) - - {{ $code }} - {{ $title }} - {{ $term }} - {{ $cr }} - {{ $grade }} - + + {{ $code }} + {{ $title }} + {{ $term }} + {{ $cr }} + {{ $grade }} + @endforeach - + - - Cumulative summary - - {{ sprintf('%d credits attempted, %.1f quality points.', $credits, $points) }} - GPA {{ number_format($gpa, 2) }} - - - + + Cumulative summary + + {{ sprintf('%d credits attempted, %.1f quality points.', $credits, $points) }} + GPA {{ number_format($gpa, 2) }} + + + - + - - + + diff --git a/packages/core/README.md b/packages/core/README.md index 26b6674..4709689 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -15,7 +15,7 @@ PHP 8.4+, `ext-mbstring`, `ext-zlib`, `ext-dom`. The only Composer dependency is ## Quick start Every example below is a complete snippet that leaves a `Papercut\Document` in `$document` (and, where needed, a -`RenderOptions` in `$options`); the test suite runs each one (`tests/Feature/ReadmeTest.php`). Real code would end with `$document->save('out.pdf')`. +`PdfOptions` in `$options`); the test suite runs each one (`tests/Feature/ReadmeTest.php`). Real code would end with `$document->toPdf()->save('out.pdf')`. ```php use Papercut\{Column, Document, Page, Text}; @@ -31,7 +31,7 @@ $document = Document::make( )->title('Hello'); ``` -Output: `$document->save($path)`, `$document->render()` / `toString()` (a string of PDF bytes) or `stream()` +Output: `$document->toPdf()` returns an `Output`: `->save($path)`, `->bytes()` (a string of PDF bytes) or `->stream()` (writes to output, returns the byte count). ## Elements @@ -43,7 +43,7 @@ Output: `$document->save($path)`, `$document->render()` / `toString()` (a string | `Row::make(...)`, `Column::make(...)` | Flex containers (horizontal, vertical). | | `Spacer::flex()`, `Spacer::size($length)` | Flexible or fixed gap. | | `Text::make()`, `h1`-`h4`, `p`, `markup()`, `spans()` | Text, with inline spans. | -| `Image::file()`, `bytes()`, `handle()` | Raster images, `fit()` modes. | +| `Image::file()`, `bytes()` | Raster images, `fit()` modes. | | `Link::to($uri, ...$children)` | A clickable region around children. | | `Table::make()` | Page-aware table. | | `PageNumber::make('Page {page} of {total}')` | Page number and page count. | @@ -91,7 +91,8 @@ Colours are `Color` values or strings: hex (`'#336699'`) or the bundled Tailwind Named styles live in the `Theme`; apply one by name: ```php -use Papercut\{Column, Document, Page, RenderOptions, Text}; +use Papercut\{Column, Document, Page, Text}; +use Papercut\Export\PdfOptions; use Papercut\Style\{Style, Theme}; use Papercut\Value\Color; use function Papercut\{mm, pt}; @@ -106,10 +107,10 @@ $document = Document::make( )->gap(pt(6)), ), ); -$options = new RenderOptions(theme: $theme); // pass to save()/render() +$options = new PdfOptions(theme: $theme); // pass to toPdf() ``` -Pass `RenderOptions` as the second argument to `render()`, `toString()`, `save()` and `stream()`. +Pass `PdfOptions` to `toPdf()`. ## Text and inline markup @@ -233,21 +234,20 @@ $document = Document::make( ## Fonts Inter (Regular, Bold, Italic, BoldItalic) is bundled, subset and embedded; the 14 standard PDF fonts are never -used. Register your own families on a `FontRegistry`, pick them with `font()`, and list fallback families for glyphs +used. Add your own families with a `FontSet` of `FontFace`s, pick them with `font()`, and list fallback families for glyphs the primary font lacks. ```php -use Papercut\{Column, Document, Page, RenderOptions, Text}; -use Papercut\Backend\PhpPdf\PhpPdfBackend; -use Papercut\Text\{FontRegistry, FontStyle}; +use Papercut\{Column, Document, Page, Text}; +use Papercut\Export\PdfOptions; +use Papercut\Text\{FontFace, FontRegistry, FontSet}; use function Papercut\{mm, pt}; -$backend = new PhpPdfBackend(); -$fonts = FontRegistry::withDefaults($backend); -// Register your own family (path to a TTF), here reusing the bundled file under another name: +// Add your own family (paths to TTFs), here reusing the bundled files under another name: $inter = dirname((new ReflectionClass(FontRegistry::class))->getFileName(), 3) . '/resources/fonts/Inter'; -$fonts->register('Brand', $inter . '/Inter-Regular.ttf', 400, FontStyle::Normal); -$fonts->register('Brand', $inter . '/Inter-Bold.ttf', 700, FontStyle::Normal); +$fonts = (new FontSet()) + ->with(new FontFace('Brand', $inter . '/Inter-Regular.ttf', 400), new FontFace('Brand', $inter . '/Inter-Bold.ttf', 700)) + ->withFallbacks('Inter'); $document = Document::make( Page::a4()->margin(mm(20))->body( @@ -257,14 +257,58 @@ $document = Document::make( )->gap(pt(8)), ), ); -$options = new RenderOptions(backend: $backend, fonts: $fonts, fallbackFamilies: ['Inter']); -// $document->save('out.pdf', $options); +$options = new PdfOptions(fonts: $fonts); +// $document->toPdf($options)->save('out.pdf'); ``` The backend shapes and reorders text within a single drawn run (kerning, ligatures). Right-to-left and bidirectional paragraph layout (direction, reordering across line breaks, direction-aware alignment) is unsupported and untested in v0.1, and so is HarfBuzz shaping. +## Fonts in HTML + +`HtmlOptions::fontMode` decides how a browser gets the fonts of an HTML export: + +- `FontMode::None` (default): only the `font-family` stack is written. The browser uses what it has. +- `FontMode::Embed`: the registered files are embedded as `data:` URIs in `@font-face` rules. +- `FontMode::Link`: the registered files are linked; the `fontUrl` callback returns each face's URL. +- `FontMode::Sources`: an ordered stack of font sources decides, family by family, where the browser gets the font. Pass the stack as `fontSources`. A source is a `FontSource` object or a closure `fn (FontRequest $r): ?FontAnswer`; return `null` to pass a family to the next source. + +```php +use Papercut\{Document, Page, Text}; +use Papercut\Export\{FontMode, HtmlOptions}; +use Papercut\Html\{GoogleFonts, LocalFonts}; + +$document = Document::make(Page::a4()->body(Text::make('Hello'))); + +// Google Fonts +$google = new HtmlOptions(fullPage: true, fontMode: FontMode::Sources, fontSources: [new GoogleFonts()]); + +// Bunny Fonts: same API, another host +$bunny = new HtmlOptions(fullPage: true, fontMode: FontMode::Sources, fontSources: [new GoogleFonts(host: 'https://fonts.bunny.net')]); + +// Mixed: your own Acme font is embedded, everything else comes from Google +$mixed = new HtmlOptions( + fullPage: true, + fontMode: FontMode::Sources, + fontSources: [LocalFonts::embed(['Acme']), new GoogleFonts()], +); + +$html = $document->toHtml($google)->bytes(); +``` + +A full page gets one `` per stylesheet in ``. A fragment has no head, so the same stylesheets are written as `@import` rules at the very start of its `'; + + if (!$options->fullPage()) { + return new Output($style . $rootDiv, 'text/html; charset=UTF-8', 'html'); + } + + $head = '' . HtmlWriter::esc($meta->title ?? '') . ''; + foreach (['author' => $meta->author, 'description' => $meta->subject, 'keywords' => $meta->keywords] as $name => $value) { + if ($value !== null) { + $head .= ''; + } + } + + foreach ($stack?->links() ?? [] as $link) { + $head .= ''; + } + + return new Output( + '' . $head . $style . '' . $rootDiv . '', + 'text/html; charset=UTF-8', + 'html', + ); + } + + public function defaultOptions(): ExportOptions + { + return new HtmlOptions(); + } +} diff --git a/packages/core/src/Export/HtmlOptions.php b/packages/core/src/Export/HtmlOptions.php new file mode 100644 index 0000000..86b6dc9 --- /dev/null +++ b/packages/core/src/Export/HtmlOptions.php @@ -0,0 +1,81 @@ + */ + private array $fontSources; + + /** + * @param (\Closure(string, ?FidelityReport=): void)|null $onWarning + * @param (\Closure(Image): ?string)|null $imageUrl a null result embeds the image as a data URI + * @param (\Closure(FontFace): ?string)|null $fontUrl used by FontMode::Link; a null result skips that face + * @param list $fontSources used by FontMode::Sources, asked in order for each family; the first usable answer wins + */ + public function __construct( + ?Theme $theme = null, + ?FontSet $fonts = null, + ?\Closure $onWarning = null, + bool $strict = false, + private bool $fullPage = false, + private FontMode $fontMode = FontMode::None, + ?\Closure $imageUrl = null, + ?\Closure $fontUrl = null, + array $fontSources = [], + ) { + parent::__construct($theme, $fonts, $onWarning, $strict); + $this->imageUrl = $imageUrl; + $this->fontUrl = $fontUrl; + $this->fontSources = $fontSources; + } + + public function fullPage(): bool + { + return $this->fullPage; + } + + public function fontMode(): FontMode + { + return $this->fontMode; + } + + /** @return (\Closure(Image): ?string)|null */ + public function imageUrl(): ?\Closure + { + return $this->imageUrl; + } + + /** @return (\Closure(FontFace): ?string)|null */ + public function fontUrl(): ?\Closure + { + return $this->fontUrl; + } + + /** @return list */ + public function fontSources(): array + { + return $this->fontSources; + } + + public function withFullPage(bool $on): self + { + return new self($this->theme(), $this->fonts(), $this->onWarning(), $this->strict(), $on, $this->fontMode, $this->imageUrl, $this->fontUrl, $this->fontSources); + } + + public static function from(ExportOptions $o): self + { + return $o instanceof self ? $o : new self($o->theme(), $o->fonts(), $o->onWarning(), $o->strict()); + } +} diff --git a/packages/core/src/Export/LoadedImage.php b/packages/core/src/Export/LoadedImage.php new file mode 100644 index 0000000..c96db2e --- /dev/null +++ b/packages/core/src/Export/LoadedImage.php @@ -0,0 +1,49 @@ + $kind, 'value' => $value] = $image->source(); + if ($kind === 'file') { + $bytes = is_file($value) && is_readable($value) ? file_get_contents($value) : false; + if ($bytes === false) { + throw new ImageUnreadable(sprintf('Cannot read image "%s": file not found or not readable', $value)); + } + // Read first for the exact PDF wording on missing files; the backend then validates by path like PDF does. + $handle = $backend->loadImageFile($value); + $unsupported = sprintf('Cannot read image "%s": Unsupported image format (expected PNG or JPEG).', $value); + } else { + $bytes = $value; + $handle = $backend->loadImageBytes($bytes); + $unsupported = 'Cannot read image bytes: Unsupported image format (expected PNG or JPEG).'; + } + + // The unsupported arm only fires for non-default backends: PdfImage already rejects other formats. + $mime = match (true) { + str_starts_with($bytes, "\x89PNG\r\n\x1a\n") => 'image/png', + str_starts_with($bytes, "\xFF\xD8\xFF") => 'image/jpeg', + default => throw new ImageUnreadable($unsupported), + }; + + return new self($bytes, $mime, $handle->widthPx(), $handle->heightPx()); + } +} diff --git a/packages/core/src/Export/Output.php b/packages/core/src/Export/Output.php new file mode 100644 index 0000000..77c7d00 --- /dev/null +++ b/packages/core/src/Export/Output.php @@ -0,0 +1,60 @@ +bytes; + } + + public function mime(): string + { + return $this->mime; + } + + public function extension(): string + { + return $this->extension; + } + + public function save(string $path): void + { + $dir = dirname($path); + if (!is_dir($dir)) { + throw new \RuntimeException("Directory does not exist: $dir"); + } + error_clear_last(); + $written = @file_put_contents($path, $this->bytes); + if ($written === false || $written < strlen($this->bytes)) { + $error = error_get_last()['message'] ?? sprintf('wrote %d of %d bytes', (int) $written, strlen($this->bytes)); + throw new \RuntimeException(sprintf('Could not write output to "%s": %s', $path, $error)); + } + } + + /** Writes the bytes to php://output and returns the number of bytes written. */ + public function stream(): int + { + $out = fopen('php://output', 'wb'); + if ($out === false) { + throw new \RuntimeException('Could not open php://output'); + } + $written = fwrite($out, $this->bytes); + fclose($out); + if ($written === false || $written < strlen($this->bytes)) { + throw new \RuntimeException(sprintf('Could not write output to php://output: wrote %d of %d bytes', (int) $written, strlen($this->bytes))); + } + + return $written; + } +} diff --git a/packages/core/src/Export/PdfExporter.php b/packages/core/src/Export/PdfExporter.php new file mode 100644 index 0000000..07ae811 --- /dev/null +++ b/packages/core/src/Export/PdfExporter.php @@ -0,0 +1,40 @@ +backend(); + $fonts = $options->fonts(); + $ctx = new LayoutContext( + FontRegistry::fromSet($fonts, $backend), + $options->theme(), + $backend, + $options->overflow(), + $options->onWarning(), + $fonts->fallbacks, + ); + $placed = (new Paginator(new LayoutEngine($ctx), $ctx))->paginate($doc); + + return new Output($backend->render($doc->meta(), Painter::paint($placed)), 'application/pdf', 'pdf'); + } + + public function defaultOptions(): ExportOptions + { + return new PdfOptions(); + } +} diff --git a/packages/core/src/Export/PdfOptions.php b/packages/core/src/Export/PdfOptions.php new file mode 100644 index 0000000..008e9fd --- /dev/null +++ b/packages/core/src/Export/PdfOptions.php @@ -0,0 +1,58 @@ +, false = no disk cache); ignored when $backend is given + */ + public function __construct( + ?Theme $theme = null, + ?FontSet $fonts = null, + ?\Closure $onWarning = null, + bool $strict = false, + ?Backend $backend = null, + private OverflowMode $overflow = OverflowMode::Throw, + private string|false|null $cacheDir = null, + ) { + parent::__construct($theme, $fonts, $onWarning, $strict); + $this->backend = $backend; + } + + /** + * Returns the given backend, or a new PhpPdfBackend on each call. + * Callers should call this once and reuse the result. + */ + public function backend(): Backend + { + return $this->backend ?? DefaultBackend::make($this->cacheDir); + } + + public function overflow(): OverflowMode + { + return $this->overflow; + } + + public function cacheDir(): string|false|null + { + return $this->cacheDir; + } + + public static function from(ExportOptions $o): self + { + return $o instanceof self ? $o : new self($o->theme(), $o->fonts(), $o->onWarning(), $o->strict()); + } +} diff --git a/packages/core/src/Html/Css.php b/packages/core/src/Html/Css.php new file mode 100644 index 0000000..5715cf0 --- /dev/null +++ b/packages/core/src/Html/Css.php @@ -0,0 +1,247 @@ + self::pt($v->points), + $v instanceof Percent => self::num($v->value) . '%', + default => null, + }; + } + + public static function color(Color $c): string + { + if ($c->isOpaque()) { + return sprintf('#%02x%02x%02x', $c->r, $c->g, $c->b); + } + return sprintf('rgba(%d, %d, %d, %s)', $c->r, $c->g, $c->b, self::num($c->alpha)); + } + + /** A CSS string literal. `<` is escaped too, so `` can never appear inside a style block. */ + public static function string(string $s): string + { + $out = preg_replace_callback( + '/[\\\\"<\x00-\x1f\x7f]/', + static fn (array $m): string => match ($m[0]) { + '\\' => '\\\\', + '"' => '\\"', + default => '\\' . dechex(ord($m[0])) . ' ', + }, + $s, + ); + return '"' . ($out ?? '') . '"'; + } + + /** @param list $fallbacks families tried before the generic ones */ + public static function fontFamily(string $family, array $fallbacks = []): string + { + return implode(', ', [...array_map(self::string(...), [$family, ...$fallbacks]), 'system-ui', 'sans-serif']); + } + + /** @return array */ + public static function box(ComputedStyle $s): array + { + $d = []; + foreach (['width' => $s->width, 'height' => $s->height, 'min-width' => $s->minWidth, 'max-width' => $s->maxWidth, 'min-height' => $s->minHeight, 'max-height' => $s->maxHeight] as $k => $v) { + $css = $v === null ? null : self::length($v); + if ($css !== null) { + $d[$k] = $css; + } + } + if (!self::zero($s->padding)) { + $d['padding'] = self::four($s->padding); + } + if (!self::zero($s->margin)) { + $d['margin'] = self::four($s->margin); + } + if (!self::zero($s->borderWidth)) { + $d['border-width'] = self::four($s->borderWidth); + $d['border-style'] = $s->borderStyle->value; + $d['border-color'] = self::color($s->borderColor); + } + if ($s->radius !== 0.0) { + $d['border-radius'] = self::pt($s->radius); + } + if ($s->background !== null) { + $d['background'] = self::color($s->background); + } + if ($s->opacity !== 1.0) { + $d['opacity'] = self::num($s->opacity); + } + return $d; + } + + /** + * @param list $fallbacks + * @return array every property when $parent is null, otherwise those that differ + */ + public static function text(ComputedStyle $s, ?ComputedStyle $parent, array $fallbacks = []): array + { + $d = []; + if ($parent === null || $s->fontFamily !== $parent->fontFamily) { + $d['font-family'] = self::fontFamily($s->fontFamily, $fallbacks); + } + if ($parent === null || $s->fontSize !== $parent->fontSize) { + $d['font-size'] = self::pt($s->fontSize); + } + if ($parent === null || $s->fontWeight !== $parent->fontWeight) { + $d['font-weight'] = (string) $s->fontWeight; + } + if ($parent === null || $s->italic !== $parent->italic) { + $d['font-style'] = $s->italic ? 'italic' : 'normal'; + } + if ($parent === null || self::color($s->color) !== self::color($parent->color)) { + $d['color'] = self::color($s->color); + } + if ($parent === null || $s->lineHeight !== $parent->lineHeight) { + $d['line-height'] = self::num($s->lineHeight); + } + if ($parent === null || $s->letterSpacing !== $parent->letterSpacing) { + $d['letter-spacing'] = self::pt($s->letterSpacing); + } + if ($parent === null || $s->textAlign !== $parent->textAlign) { + $d['text-align'] = $s->textAlign->value; + } + if ($parent === null || $s->underline !== $parent->underline || $s->strike !== $parent->strike) { + $d['text-decoration'] = match (true) { + $s->underline && $s->strike => 'underline line-through', + $s->underline => 'underline', + $s->strike => 'line-through', + default => 'none', + }; + } + return $d; + } + + /** @return array */ + public static function flexContainer(ComputedStyle $s): array + { + $d = ['display' => 'flex', 'flex-direction' => $s->direction->value]; + if ($s->gap !== 0.0) { + $d['gap'] = self::pt($s->gap); + } + if ($s->justify !== Justify::Start) { + $d['justify-content'] = self::justify($s->justify); + } + if ($s->alignItems !== Align::Stretch) { + $d['align-items'] = self::align($s->alignItems); + } + if ($s->wrap) { + $d['flex-wrap'] = 'wrap'; + } + return $d; + } + + /** @return array */ + public static function flexItem(ComputedStyle $s): array + { + $d = []; + if ($s->grow !== 0.0 || $s->shrink !== 1.0 || !$s->basis instanceof Auto) { + $d['flex'] = self::num($s->grow) . ' ' . self::num($s->shrink) . ' ' . (self::length($s->basis) ?? 'auto'); + } + if ($s->alignSelf !== null) { + $d['align-self'] = self::align($s->alignSelf); + } + return $d; + } + + /** @return array */ + public static function pagination(ComputedStyle $s, ?ComputedStyle $parent = null): array + { + $d = []; + if ($s->breakBefore) { + $d['break-before'] = 'page'; + } + if ($s->breakAfter) { + $d['break-after'] = 'page'; + } + if ($s->keepTogether) { + $d['break-inside'] = 'avoid'; + } + if ($s->keepWithNext && !$s->breakAfter) { + $d['break-after'] = 'avoid'; + } + if ($s->minLines !== ($parent === null ? self::rootMinLines() : $parent->minLines)) { + $d['orphans'] = (string) $s->minLines; + $d['widows'] = (string) $s->minLines; + } + return $d; + } + + private static function rootMinLines(): int + { + static $v = null; + return $v ??= ComputedStyle::root(Theme::default())->minLines; + } + + /** @param array $decls */ + public static function attr(array $decls): string + { + $parts = []; + foreach ($decls as $k => $v) { + $parts[] = $k . ':' . $v; + } + return implode(';', $parts); + } + + /** Horizontal padding, border and margin of a box, in points. */ + public static function horizontalEdges(ComputedStyle $s): float + { + return $s->padding->left->points + $s->padding->right->points + + $s->borderWidth->left->points + $s->borderWidth->right->points + + $s->margin->left->points + $s->margin->right->points; + } + + private static function zero(Edges $e): bool + { + return $e->top->points === 0.0 && $e->right->points === 0.0 && $e->bottom->points === 0.0 && $e->left->points === 0.0; + } + + private static function four(Edges $e): string + { + return self::pt($e->top->points) . ' ' . self::pt($e->right->points) . ' ' . self::pt($e->bottom->points) . ' ' . self::pt($e->left->points); + } + + private static function justify(Justify $j): string + { + return match ($j) { + Justify::Start => 'flex-start', + Justify::End => 'flex-end', + Justify::Center => 'center', + Justify::SpaceBetween => 'space-between', + Justify::SpaceAround => 'space-around', + Justify::SpaceEvenly => 'space-evenly', + }; + } + + private static function align(Align $a): string + { + return match ($a) { + Align::Start => 'flex-start', + Align::End => 'flex-end', + Align::Center => 'center', + Align::Stretch => 'stretch', + }; + } +} diff --git a/packages/core/src/Html/FontAnswer.php b/packages/core/src/Html/FontAnswer.php new file mode 100644 index 0000000..ef6b790 --- /dev/null +++ b/packages/core/src/Html/FontAnswer.php @@ -0,0 +1,38 @@ + $faces */ + private function __construct(private ?string $url, private array $faces) {} + + public static function stylesheet(string $url): self + { + return new self($url, []); + } + + public static function faces(FontFaceSrc ...$faces): self + { + return new self(null, array_values($faces)); + } + + /** Set for stylesheet answers. */ + public function url(): ?string + { + return $this->url; + } + + /** + * Set for faces answers. May be empty; FontStack refuses that. + * + * @return list + */ + public function faceList(): array + { + return $this->faces; + } +} diff --git a/packages/core/src/Html/FontFaceSrc.php b/packages/core/src/Html/FontFaceSrc.php new file mode 100644 index 0000000..cd478c1 --- /dev/null +++ b/packages/core/src/Html/FontFaceSrc.php @@ -0,0 +1,13 @@ + $families families used by the document + * @param (\Closure(FontFace): ?string)|null $fontUrl + * @return string one rule per line, each ending in a newline; empty for FontMode::None + */ + public static function css(array $families, FontSet $set, FontMode $mode, ?\Closure $fontUrl): string + { + if ($mode === FontMode::None) { + return ''; + } + $used = array_map('strtolower', $families); + $out = ''; + foreach ($set->effectiveFaces() as $face) { + if (!in_array(strtolower($face->family), $used, true)) { + continue; + } + $src = $mode === FontMode::Embed ? self::dataUri($face) : self::link($face, $fontUrl); + if ($src === null) { + continue; + } + $out .= '@font-face{font-family:' . Css::string($face->family) . ';src:url(' . $src . ') format("truetype");font-weight:' . $face->weight + . ';font-style:' . ($face->style === FontStyle::Italic ? 'italic' : 'normal') . "}\n"; + } + + return $out; + } + + /** @throws FontNotFound when the font file cannot be read */ + public static function dataUri(FontFace $face): string + { + $bytes = is_file($face->path) && is_readable($face->path) ? file_get_contents($face->path) : false; + if ($bytes === false) { + throw new FontNotFound('Font file not found: ' . $face->path); + } + + return 'data:font/ttf;base64,' . base64_encode($bytes); + } + + /** @param (\Closure(FontFace): ?string)|null $fontUrl */ + private static function link(FontFace $face, ?\Closure $fontUrl): ?string + { + $url = $fontUrl === null ? null : $fontUrl($face); + + return $url === null ? null : Css::string($url); + } +} diff --git a/packages/core/src/Html/FontRequest.php b/packages/core/src/Html/FontRequest.php new file mode 100644 index 0000000..7c3afa7 --- /dev/null +++ b/packages/core/src/Html/FontRequest.php @@ -0,0 +1,33 @@ + normal before italic, then weight ascending; deduplicated */ + public array $faces; + + /** + * @param list $faces any order; sorted and deduplicated here so every source can rely on the order + * @param bool $registered whether the family is registered in $fonts + * @throws \InvalidArgumentException when $faces is empty + */ + public function __construct(public string $family, array $faces, public bool $registered, public FontSet $fonts) + { + $unique = []; + foreach ($faces as $face) { + $unique[($face->style === FontStyle::Italic ? 1 : 0) . ':' . str_pad((string) $face->weight, 6, '0', STR_PAD_LEFT)] = $face; + } + ksort($unique, SORT_STRING); + $sorted = array_values($unique); + if ($sorted === []) { + throw new \InvalidArgumentException("A font request needs at least one face: '$family'"); + } + $this->faces = $sorted; + } +} diff --git a/packages/core/src/Html/FontRequests.php b/packages/core/src/Html/FontRequests.php new file mode 100644 index 0000000..50d0419 --- /dev/null +++ b/packages/core/src/Html/FontRequests.php @@ -0,0 +1,55 @@ +, path: ?string}> $usage from HtmlWriter::fontUsage() + * @return list usage order, then the root family, then the fallbacks; one per family, case-insensitively + */ + public static function collect(array $usage, string $rootFamily, FontSet $fonts): array + { + $names = []; + foreach ($usage as $entry) { + $names[strtolower($entry['family'])] ??= $entry['family']; + } + foreach ([$rootFamily, ...$fonts->fallbacks] as $family) { + $names[strtolower($family)] ??= $family; + } + + $registered = []; + foreach ($fonts->effectiveFaces() as $face) { + $registered[strtolower($face->family)][] = new FontVariant($face->weight, $face->style); + } + + $all = []; + foreach ($usage as $entry) { + array_push($all, ...$entry['variants']); + } + if ($all === []) { + $all = [new FontVariant(400, FontStyle::Normal)]; + } + + $requests = []; + foreach ($names as $key => $family) { + $used = $usage[$key] ?? null; + if (isset($registered[$key])) { + $faces = $registered[$key]; + } else { + $faces = $used['variants'] ?? $all; + } + $requests[] = [ + 'request' => new FontRequest($family, $faces, isset($registered[$key]), $fonts), + 'path' => $used['path'] ?? 'document', + ]; + } + + return $requests; + } +} diff --git a/packages/core/src/Html/FontSource.php b/packages/core/src/Html/FontSource.php new file mode 100644 index 0000000..88e52c6 --- /dev/null +++ b/packages/core/src/Html/FontSource.php @@ -0,0 +1,17 @@ + $links */ + private function __construct(private array $links, private string $faceCss) {} + + /** + * Asks each source in order for each family; the first answer with safe URLs wins. + * + * @param list $requests + * @param list $sources + */ + public static function resolve(array $requests, array $sources, Fidelity $fidelity): self + { + $links = []; + $css = ''; + foreach ($requests as ['request' => $request, 'path' => $path]) { + $accepted = false; + foreach ($sources as $i => $source) { + $answer = $source($request); + if ($answer === null) { + continue; + } + if (!self::safe($answer)) { + $fidelity->warn('font source ' . $i . ' returned an unsafe URL for "' . $request->family . '"; trying the next source'); + continue; + } + $url = $answer->url(); + if ($url !== null) { + $links[$url] = $url; + } else { + foreach ($answer->faceList() as $face) { + $css .= self::rule($request->family, $face); + } + } + $accepted = true; + break; + } + if (!$accepted) { + $fidelity->approximated('font not linked', $path); + } + } + + return new self(array_values($links), $css); + } + + /** @return list stylesheet URLs, deduplicated, in family order */ + public function links(): array + { + return $this->links; + } + + /** @font-face rules, one per line. */ + public function faceCss(): string + { + return $this->faceCss; + } + + /** One @import per link, one per line; it must come first in a stylesheet. */ + public function importCss(): string + { + $out = ''; + foreach ($this->links as $link) { + $out .= '@import url(' . Css::string($link) . ");\n"; + } + + return $out; + } + + private static function safe(FontAnswer $answer): bool + { + $url = $answer->url(); + if ($url !== null) { + return Urls::safeStylesheet($url); + } + $faces = $answer->faceList(); + if ($faces === []) { + return false; + } + foreach ($faces as $face) { + if (!Urls::safeFontSrc($face->url)) { + return false; + } + } + + return true; + } + + private static function rule(string $family, FontFaceSrc $face): string + { + // Embedded fonts keep the bare data URI FontFaces writes; anything else is quoted. + $src = preg_match('~^data:font/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]*$~i', $face->url) === 1 ? $face->url : Css::string($face->url); + + return '@font-face{font-family:' . Css::string($family) . ';src:url(' . $src . ') format(' . Css::string($face->format) . ');font-weight:' . $face->weight + . ';font-style:' . ($face->style === FontStyle::Italic ? 'italic' : 'normal') . "}\n"; + } +} diff --git a/packages/core/src/Html/FontVariant.php b/packages/core/src/Html/FontVariant.php new file mode 100644 index 0000000..f8b89de --- /dev/null +++ b/packages/core/src/Html/FontVariant.php @@ -0,0 +1,13 @@ +|null lowercased */ + private ?array $families; + + /** + * @param list|null $families case-insensitive allowlist; null accepts every family + * @throws \InvalidArgumentException for a bad host or display + */ + public function __construct(string $host = 'https://fonts.googleapis.com', private string $display = 'swap', ?array $families = null) + { + if (!self::validHost($host)) { + throw new \InvalidArgumentException("GoogleFonts host must be an http or https URL with no query or fragment: '$host'"); + } + if (!in_array($display, self::DISPLAYS, true)) { + throw new \InvalidArgumentException("GoogleFonts display must be one of auto, block, swap, fallback, optional: '$display'"); + } + $this->host = rtrim($host, '/'); + $this->families = $families === null ? null : array_map('strtolower', $families); + } + + /** True for a clean http or https URL that has a host and no query, fragment, whitespace, quote, angle bracket or backslash. */ + public static function validHost(string $host): bool + { + if (!Urls::clean($host) || preg_match('/[\s"\'<>\\\\]/', $host) === 1 || str_contains($host, '?') || str_contains($host, '#')) { + return false; + } + $parts = parse_url($host); + + return $parts !== false + && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) + && ($parts['host'] ?? '') !== ''; + } + + public function __invoke(FontRequest $request): ?FontAnswer + { + if ($this->families !== null && !in_array(strtolower($request->family), $this->families, true)) { + return null; + } + + return FontAnswer::stylesheet( + $this->host . '/css2?family=' . str_replace('%20', '+', rawurlencode($request->family)) + . $this->axes($request->faces) . '&display=' . $this->display, + ); + } + + /** @param non-empty-list $faces sorted by FontRequest */ + private function axes(array $faces): string + { + $italic = false; + foreach ($faces as $face) { + $italic = $italic || $face->style === FontStyle::Italic; + } + if ($italic) { + // Google answers HTTP 400 unless tuples are sorted by ital, then weight. + return ':ital,wght@' . implode(';', array_map( + static fn (FontVariant $f): string => ($f->style === FontStyle::Italic ? 1 : 0) . ',' . $f->weight, + $faces, + )); + } + if (count($faces) === 1 && $faces[0]->weight === 400) { + return ''; + } + + return ':wght@' . implode(';', array_map(static fn (FontVariant $f): int => $f->weight, $faces)); + } +} diff --git a/packages/core/src/Html/HtmlWriter.php b/packages/core/src/Html/HtmlWriter.php new file mode 100644 index 0000000..17d557e --- /dev/null +++ b/packages/core/src/Html/HtmlWriter.php @@ -0,0 +1,468 @@ + */ + private array $styles = []; + /** @var list closing markup, one per open node */ + private array $closers = []; + /** @var list the open thead/tbody of each open table */ + private array $sections = []; + /** @var \WeakMap cells laid out as plain columns because no row holds them */ + private \WeakMap $strays; + /** @var array */ + private array $families = []; + /** @var array, path: ?string}> by lowercased family */ + private array $usage = []; + /** @var list horizontal padding, border and margin of each open node */ + private array $edges = []; + /** Content width of the current region's page, in points. */ + private float $regionWidth = 0.0; + /** Open block links and span-link-bearing paragraphs inside them; an inner link cannot be an anchor. */ + private int $linkDepth = 0; + /** @var array images already loaded this export, by source */ + private array $images = []; + + public function __construct( + Document $doc, + private readonly Fidelity $fidelity, + private readonly HtmlOptions $options, + private readonly Backend $backend, + ) { + $this->strays = new \WeakMap(); + $this->pageCount = count($doc->pages()); + } + + public static function esc(string $s): string + { + return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + } + + /** + * Per lowercased family: the first spelling seen, the distinct weight/style pairs in first-seen order, + * and the first node path that used it (null when only the root style did). + * + * @return array, path: ?string}> + */ + public function fontUsage(): array + { + return $this->usage; + } + + private function recordFont(ComputedStyle $s, ?string $path): void + { + $this->families[$s->fontFamily] = true; + $key = strtolower($s->fontFamily); + $variant = new FontVariant($s->fontWeight, $s->italic ? FontStyle::Italic : FontStyle::Normal); + if (!isset($this->usage[$key])) { + $this->usage[$key] = ['family' => $s->fontFamily, 'variants' => [], 'path' => $path]; + } + foreach ($this->usage[$key]['variants'] as $seen) { + if ($seen->weight === $variant->weight && $seen->style === $variant->style) { + return; + } + } + $this->usage[$key]['variants'][] = $variant; + } + + /** @return list font families of every style written, in first-seen order */ + public function usedFamilies(): array + { + return array_keys($this->families); + } + + public function html(): string + { + return $this->out; + } + + public function enterPage(Page $page, int $index): void + { + $this->page = $page; + $path = $this->pageCount > 1 ? sprintf('page[%d]', $index) : 'page'; + $this->fidelity->approximated('page box', $path); + if ($page->headerNodes() !== [] || $page->footerNodes() !== []) { + $this->fidelity->approximated('header and footer once per page', $path); + } + if ($page->backgroundNodes() !== []) { + $this->fidelity->approximated('background layer', $path); + } + $m = $page->margins(); + $this->out .= '
'; + } + + public function leavePage(Page $page, int $index): void + { + $this->out .= '
'; + } + + public function enterRegion(Region $region, ComputedStyle $root): void + { + $this->styles = [$root]; + $this->edges = []; + $m = $this->page?->margins(); + $this->regionWidth = $this->page === null || $m === null ? 0.0 : max(0.0, $this->page->width() - $m->left->points - $m->right->points); + $this->recordFont($root, null); + $style = match ($region) { + Region::Header => ' style="margin-bottom:' . Css::pt($this->page?->headerGapLength()->points ?? 0.0) . '"', + Region::Footer => ' style="margin-top:' . Css::pt($this->page?->footerGapLength()->points ?? 0.0) . '"', + default => '', + }; + $this->out .= '
'; + } + + public function leaveRegion(Region $region): void + { + $this->out .= '
'; + $this->styles = []; + } + + public function enter(Node $node, ComputedStyle $style, Visit $at): bool + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $closer = match (true) { + $node instanceof View => $this->view($style, $parent, $at), + $node instanceof Text => $this->text($node, $style, $parent, $at), + $node instanceof Link => $this->link($node, $style, $parent, $at), + $node instanceof Spacer => $this->spacer($node, $style), + $node instanceof PageBreak => $this->pageBreak(), + $node instanceof PageNumber => '', + $node instanceof Image => $this->image($node, $style, $at), + $node instanceof Table => $this->table($node, $style, $at), + $node instanceof TableRow => $this->tableRow($node, $style, $at), + $node instanceof TableCell => $this->tableCell($node, $style, $at), + default => throw new \LogicException('HTML export of ' . $node->debugName() . ' is not supported'), + }; + $this->styles[] = $style; + $this->edges[] = Css::horizontalEdges($style); + $this->closers[] = $closer; + + return true; + } + + public function leave(Node $node, ComputedStyle $style, Visit $at): void + { + array_pop($this->styles); + array_pop($this->edges); + if ($node instanceof Link) { + $this->linkDepth--; + } + if ($node instanceof Table) { + $open = array_pop($this->sections); + $this->out .= $open === null ? '' : ''; + } + $this->out .= (string) array_pop($this->closers); + } + + /** @return array */ + private function textCss(ComputedStyle $s, ?ComputedStyle $parent, string $path): array + { + $this->recordFont($s, $path); + + return Css::text($s, $parent, $this->options->fonts()->fallbacks); + } + + private function view(ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $this->checkDecoration($s, $parent, $at->path()); + $decls = [...Css::box($s), ...Css::flexContainer($s)]; + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + $decls = [...$decls, ...Css::pagination($s, $parent), ...$this->textCss($s, $parent, $at->path())]; + $this->out .= ''; + + return ''; + } + + private function text(Text $t, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $override = self::overridesDecoration($s, $parent); + $tag = in_array($t->preset(), ['h1', 'h2', 'h3', 'h4'], true) ? $t->preset() : 'p'; + $decls = [...Css::box($s), ...$this->textCss($s, $parent, $at->path()), ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::pagination($s, $parent)]; + $this->out .= '<' . $tag . self::styleAttr($decls) . '>'; + $nested = false; + foreach ($t->spanList() as $span) { + $override = $this->span($span, $s, $nested, $at->path()) || $override; + } + if ($nested) { + $this->fidelity->approximated('nested link', $at->path()); + } + if ($override) { + $this->fidelity->approximated('text decoration override', $at->path()); + } + + return ''; + } + + /** @return bool whether the span turns off a decoration the paragraph has on */ + private function span(\Papercut\Span $span, ComputedStyle $text, bool &$nested, string $path): bool + { + if ($span->lineBreak) { + $this->out .= '
'; + return false; + } + $style = $text->child($span->style); + $diff = $this->textCss($style, $text, $path); + $body = self::esc($span->text); + if ($diff !== []) { + $body = '' . $body . ''; + } + if ($span->href !== null) { + $href = self::href($span->href); + if ($this->linkDepth > 0) { + // An anchor inside an anchor is restructured by HTML parsers: keep the text and styles, lose the inner link. + $nested = true; + $body = $diff === [] ? '' . $body . '' : $body; + } else { + $body = '' . $body . ''; + } + } + $this->out .= $body; + + return self::overridesDecoration($style, $text); + } + + private function link(Link $l, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $href = self::href($l->uri()); + $this->checkDecoration($s, $parent, $at->path()); + $decls = [...Css::box($s), ...Css::flexContainer($s), 'flex-direction' => 'column', ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::pagination($s, $parent), ...$this->textCss($s, $parent, $at->path())]; + if ($this->linkDepth > 0) { + $this->fidelity->approximated('nested link', $at->path()); + $this->linkDepth++; + $this->out .= ''; + + return ''; + } + $this->linkDepth++; + $this->out .= ''; + + return ''; + } + + private function spacer(Spacer $sp, ComputedStyle $s): string + { + $fixed = $sp->fixedSize(); + $flex = $fixed !== null ? '0 0 ' . Css::pt($fixed->points) : Css::num($s->grow) . ' 0 0'; + $this->out .= ' $flex]) . '>'; + + return ''; + } + + private function pageBreak(): string + { + $this->out .= '
'; + + return ''; + } + + private function loadImage(Image $n): LoadedImage + { + ['kind' => $kind, 'value' => $value] = $n->source(); + $key = $kind === 'file' ? 'file:' . $value : 'bytes:' . sha1($value); + + return $this->images[$key] ??= LoadedImage::load($n, $this->backend); + } + + private function image(Image $n, ComputedStyle $s, Visit $at): string + { + $decls = Css::box($s); + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + $decls['object-fit'] = $n->fitMode()->value; + $this->out .= ''; + + return ''; + } + + private function table(Table $n, ComputedStyle $s, Visit $at): string + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $this->checkDecoration($s, $parent, $at->path()); + $tracks = WidthResolver::tracks($n); + $given = $n->trackList()?->count(); + if ($given !== null && $given !== count($tracks)) { + $this->fidelity->warn(sprintf('[%s] Table has %d columns but %d tracks', $at->path(), count($tracks), $given)); + } + $fixed = 0.0; + $percent = 0.0; + $frs = 0.0; + foreach ($tracks as $t) { + $fixed += $t instanceof Length ? $t->points : 0.0; + $percent += $t instanceof Percent ? $t->value : 0.0; + $frs += $t instanceof Fr ? $t->value : 0.0; + } + if ($frs > 0.0) { + $this->fidelity->approximated('fr column widths', $at->path()); + } + $est = max(0.0, $this->regionWidth - array_sum($this->edges) - Css::horizontalEdges($s)); + $hasAuto = in_array(true, array_map(static fn (object $t): bool => $t instanceof Auto, $tracks), true); + if ($hasAuto) { + $this->fidelity->approximated('auto column widths', $at->path()); + } + + $decls = [...Css::box($s), ...$this->textCss($s, $parent, $at->path())]; + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + if ($hasAuto) { + $decls['table-layout'] = 'auto'; + } + $this->out .= ''; + foreach ($tracks as $t) { + $width = match (true) { + $t instanceof Length => Css::pt($t->points), + $t instanceof Percent => Css::num($t->value) . '%', + $t instanceof Fr => $hasAuto ? null : self::frWidth($fixed, $percent, $t->value / $frs, $est), + default => null, + }; + $this->out .= ' $width]) . '>'; + } + $this->out .= ''; + $this->sections[] = null; + + return ''; + } + + /** A plain percentage of the table: browsers ignore a calc() mixing % and pt on a col, so fixed tracks are converted at the estimated width. */ + private static function frWidth(float $fixed, float $percent, float $share, float $estimate): string + { + $fixedPercent = $estimate > 0.0 ? $fixed / $estimate * 100 : 0.0; + + return Css::num($share * max(0.0, 100 - $percent - $fixedPercent)) . '%'; + } + + private function tableRow(TableRow $n, ComputedStyle $s, Visit $at): string + { + if (!$at->parent() instanceof Table) { + throw new \LogicException(sprintf('No layouter registered for %s at "%s".', TableRow::class, $at->path())); + } + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $this->checkDecoration($s, $parent, $at->path()); + $want = $at->inHeader() ? 'thead' : 'tbody'; + $open = end($this->sections); + if ($open !== $want) { + $this->out .= ($open === null ? '' : '') . '<' . $want . '>'; + array_pop($this->sections); + $this->sections[] = $want; + } + $box = Css::box($s); + if (isset($box['padding'])) { + unset($box['padding']); + $this->fidelity->approximated('row padding', $at->path()); + } + $this->out .= 'textCss($s, $parent, $at->path())]) . '>'; + + return ''; + } + + private function tableCell(TableCell $n, ComputedStyle $s, Visit $at): string + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + if (!$at->parent() instanceof TableRow) { + return $this->strayCell($n, $s, $parent, $at); + } + $this->checkDecoration($s, $parent, $at->path()); + $tag = $n->header() ? 'th' : 'td'; + $span = $n->span() > 1 ? ' colspan="' . $n->span() . '"' : ''; + $td = [...Css::box($s), ...$this->textCss($s, $parent, $at->path())]; + if ($s->justify === Justify::Center) { + $td['vertical-align'] = 'middle'; + } elseif ($s->justify === Justify::End) { + $td['vertical-align'] = 'bottom'; + } elseif ($s->justify === Justify::SpaceBetween || $s->justify === Justify::SpaceAround || $s->justify === Justify::SpaceEvenly) { + $this->fidelity->approximated('cell justify', $at->path()); + } + // The cell's children are laid out as a column; a td cannot be a flex container, so a div inside it is. + $column = array_diff_key(Css::flexContainer($s), ['display' => 1, 'flex-direction' => 1]); + $this->out .= '<' . $tag . $span . self::styleAttr($td) . '> 'flex', 'flex-direction' => 'column', ...$column]) . '>'; + + return ''; + } + + /** A cell outside a table is laid out as a column; it becomes a div and warns like the PDF layout does. */ + private function strayCell(TableCell $n, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + if (!isset($this->strays[$n])) { + $this->strays[$n] = true; + $this->fidelity->warn(sprintf('[%s] A table cell outside a table is laid out as a column', $at->path())); + } + $this->checkDecoration($s, $parent, $at->path()); + $decls = [...Css::box($s), ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::flexContainer($s), ...Css::pagination($s, $parent), ...$this->textCss($s, $parent, $at->path())]; + $this->out .= ''; + + return ''; + } + + /** Flex items only make sense inside flex containers: Views, Links and region roots. */ + private function parentIsFlex(Visit $at): bool + { + $p = $at->parent(); + + return $p === null || $p instanceof View || $p instanceof Link || $p instanceof TableCell; + } + + /** CSS cannot switch off a decoration an ancestor drew, so turning one off is only approximated. */ + private function checkDecoration(ComputedStyle $s, ComputedStyle $parent, string $path): void + { + if (self::overridesDecoration($s, $parent)) { + $this->fidelity->approximated('text decoration override', $path); + } + } + + private static function overridesDecoration(ComputedStyle $s, ComputedStyle $parent): bool + { + return ($parent->underline && !$s->underline) || ($parent->strike && !$s->strike); + } + + private static function href(string $uri): string + { + if (!\Papercut\Paint\Link::allowed($uri)) { + throw InvalidValue::for('link', $uri)->because('only http, https, mailto, tel and #fragment links are allowed'); + } + + return ltrim($uri); + } + + /** @param array $decls */ + private static function attr(array $decls): string + { + return self::esc(Css::attr($decls)); + } + + /** @param array $decls */ + private static function styleAttr(array $decls): string + { + return $decls === [] ? '' : ' style="' . self::attr($decls) . '"'; + } +} diff --git a/packages/core/src/Html/ImageSource.php b/packages/core/src/Html/ImageSource.php new file mode 100644 index 0000000..13565db --- /dev/null +++ b/packages/core/src/Html/ImageSource.php @@ -0,0 +1,46 @@ +mime . ';base64,' . base64_encode($loaded->bytes); + } + + /** An allowed link scheme, a data:image/ URI, or a relative path. Control characters and edge whitespace are refused: browsers drop them inside schemes. */ + private static function safe(string $url): bool + { + return Urls::clean($url) && ( + Link::allowed($url) + || preg_match('~^data:image/~i', $url) === 1 + || Urls::relative($url) + ); + } +} diff --git a/packages/core/src/Html/LocalFonts.php b/packages/core/src/Html/LocalFonts.php new file mode 100644 index 0000000..4d9cc3c --- /dev/null +++ b/packages/core/src/Html/LocalFonts.php @@ -0,0 +1,61 @@ +|null $allowed lowercased family allowlist; null allows all + * @param (\Closure(FontFace): ?string)|null $fontUrl null embeds + */ + private function __construct(private ?array $allowed, private ?\Closure $fontUrl) {} + + /** @param list|null $families families to serve, case-insensitive; null serves every registered family */ + public static function embed(?array $families = null): self + { + return new self(self::lower($families), null); + } + + /** + * @param \Closure(FontFace): ?string $fontUrl a URL for the face, or null to leave it out + * @param list|null $families families to serve, case-insensitive; null serves every registered family + */ + public static function link(\Closure $fontUrl, ?array $families = null): self + { + return new self(self::lower($families), $fontUrl); + } + + public function __invoke(FontRequest $request): ?FontAnswer + { + $family = strtolower($request->family); + if (!$request->registered || ($this->allowed !== null && !in_array($family, $this->allowed, true))) { + return null; + } + $out = []; + foreach ($request->fonts->effectiveFaces() as $face) { + if (strtolower($face->family) !== $family) { + continue; + } + $url = $this->fontUrl === null ? FontFaces::dataUri($face) : ($this->fontUrl)($face); + if ($url !== null) { + $out[] = new FontFaceSrc($face->weight, $face->style, $url, 'truetype'); + } + } + + return $out === [] ? null : FontAnswer::faces(...$out); + } + + /** + * @param list|null $families + * @return list|null + */ + private static function lower(?array $families): ?array + { + return $families === null ? null : array_map('strtolower', $families); + } +} diff --git a/packages/core/src/Html/Stylesheet.php b/packages/core/src/Html/Stylesheet.php new file mode 100644 index 0000000..a157bdb --- /dev/null +++ b/packages/core/src/Html/Stylesheet.php @@ -0,0 +1,42 @@ +pages()[0] ?? null; + $w = $page === null ? 595.28 : $page->width(); + $h = $page === null ? 841.89 : $page->height(); + $m = $page === null ? '0pt' : self::margins($page->margins()); + $pageRule = $o->fullPage() ? '@page{size:' . Css::pt($w) . ' ' . Css::pt($h) . ';margin:' . $m . '}' : null; + + return $fontFaces . implode("\n", [ + '.papercut{background:#e5e7eb;padding:24px 0}', + '.papercut *,.papercut *::before,.papercut *::after{box-sizing:border-box}', + '.papercut p,.papercut h1,.papercut h2,.papercut h3,.papercut h4{margin:0;font:inherit;overflow-wrap:anywhere}', + '.papercut a{color:inherit;text-decoration:inherit}', + '.papercut img{display:block;max-width:100%}', + '.papercut table{border-collapse:collapse;table-layout:fixed;width:100%}', + '.papercut td,.papercut th{vertical-align:top;text-align:inherit;font-weight:inherit}', + '.papercut .pc-page{position:relative;z-index:0;margin:0 auto 24px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.15)}', + '.papercut .pc-header,.papercut .pc-body,.papercut .pc-footer,.papercut .pc-background{display:flex;flex-direction:column;align-items:stretch}', + '.papercut .pc-background{position:absolute;inset:0;z-index:-1}', + '.papercut .pc-break{break-before:page}', + ...($pageRule === null ? [] : [$pageRule]), + '@media print{.papercut{background:none;padding:0}.papercut .pc-page{max-width:none;min-height:0;margin:0;padding:0;box-shadow:none;break-after:page}.papercut tr{break-inside:avoid}}', + ]); + } + + private static function margins(\Papercut\Value\Edges $e): string + { + return Css::pt($e->top->points) . ' ' . Css::pt($e->right->points) . ' ' . Css::pt($e->bottom->points) . ' ' . Css::pt($e->left->points); + } +} diff --git a/packages/core/src/Html/Urls.php b/packages/core/src/Html/Urls.php new file mode 100644 index 0000000..eb916e0 --- /dev/null +++ b/packages/core/src/Html/Urls.php @@ -0,0 +1,33 @@ +kind; return ['kind' => $kind, 'value' => $this->value]; } diff --git a/packages/core/src/Layout/BlockLayouter.php b/packages/core/src/Layout/BlockLayouter.php index 963ad2e..01b5f59 100644 --- a/packages/core/src/Layout/BlockLayouter.php +++ b/packages/core/src/Layout/BlockLayouter.php @@ -6,6 +6,7 @@ use Papercut\{Link, Node, PageBreak, Spacer, View}; use Papercut\Style\{Align, ComputedStyle, Direction}; +use Papercut\Tree\Expander; use Papercut\Value\Length; /** @@ -49,7 +50,7 @@ public function layout(Node $node, ComputedStyle $style, Constraints $c, ?BreakT $breakAt = null; for ($i = $start; $i < $count; $i++) { - $child = LayoutEngine::expand($children[$i]); + $child = Expander::expand($children[$i]); $resuming = $resumeToken !== null && $i === $start; if ($child instanceof PageBreak) { @@ -209,7 +210,7 @@ public function intrinsic(Node $node, ComputedStyle $style, LayoutEngine $engine $min = 0.0; $max = 0.0; foreach (self::children($node) as $i => $child) { - $child = LayoutEngine::expand($child); + $child = Expander::expand($child); if ($child instanceof Spacer || $child instanceof PageBreak) { continue; } @@ -248,7 +249,7 @@ private static function forcesBreakAfter(Node $node, ComputedStyle $style, Layou return false; } $i = count($children) - 1; - $last = LayoutEngine::expand($children[$i]); + $last = Expander::expand($children[$i]); if ($last instanceof PageBreak) { return true; } @@ -269,7 +270,7 @@ private static function endsWithKeepWithNext(Node $node, ComputedStyle $style, L return false; } $i = count($children) - 1; - $last = LayoutEngine::expand($children[$i]); + $last = Expander::expand($children[$i]); if ($last instanceof PageBreak) { return false; } @@ -316,7 +317,7 @@ private static function trailingKeepOffset(Node $node, ComputedStyle $style, Fra $m = count($frags); $j = $n - 1; while ($j >= 0 && $n - $j <= $m) { - $cs = $engine->styleOf(LayoutEngine::expand($children[$j]), $style, $j); + $cs = $engine->styleOf(Expander::expand($children[$j]), $style, $j); if (!$cs->keepWithNext) { break; } @@ -330,7 +331,7 @@ private static function trailingKeepOffset(Node $node, ComputedStyle $style, Fra if ($m === 0) { return null; } - $last = LayoutEngine::expand($children[$n - 1]); + $last = Expander::expand($children[$n - 1]); $inner = self::trailingKeepOffset($last, $engine->styleOf($last, $style, $n - 1), $frags[$m - 1]['fragment'], $engine); if ($inner === null) { // the last child is all chain: cut before it, unless it is all that was placed diff --git a/packages/core/src/Layout/FlexLayouter.php b/packages/core/src/Layout/FlexLayouter.php index 9cf7870..3257ad1 100644 --- a/packages/core/src/Layout/FlexLayouter.php +++ b/packages/core/src/Layout/FlexLayouter.php @@ -6,6 +6,7 @@ use Papercut\{Node, PageBreak, Spacer, View}; use Papercut\Style\{Align, ComputedStyle}; +use Papercut\Tree\Expander; use Papercut\Value\{Auto, Length}; /** @@ -121,7 +122,7 @@ public function intrinsic(Node $node, ComputedStyle $style, LayoutEngine $engine $max = 0.0; $n = 0; foreach (self::children($node) as $i => $child) { - $child = LayoutEngine::expand($child); + $child = Expander::expand($child); if ($child instanceof PageBreak) { continue; } @@ -140,6 +141,18 @@ public function intrinsic(Node $node, ComputedStyle $style, LayoutEngine $engine ]; } + /** + * Flex lines for a row whose border box is offered $available, without + * pagination: the same items and line resolution layout() uses. + * + * @return list> + */ + public function lines(Node $node, ComputedStyle $style, float $available, LayoutEngine $engine): array + { + $contentWidth = BoxGeometry::contentWidth($style, $available); + return self::resolveLines($this->items($node, $style, $contentWidth, $engine), $style, $contentWidth); + } + /** * Flex items with their hypothetical main sizes (border box) and clamps. * @@ -149,7 +162,7 @@ private function items(Node $node, ComputedStyle $style, float $width, LayoutEng { $items = []; foreach (self::children($node) as $i => $child) { - $child = LayoutEngine::expand($child); + $child = Expander::expand($child); if ($child instanceof PageBreak) { if ($warn) { $engine->context()->warn('PageBreak inside a row is ignored'); diff --git a/packages/core/src/Layout/FlexSlot.php b/packages/core/src/Layout/FlexSlot.php new file mode 100644 index 0000000..57c732f --- /dev/null +++ b/packages/core/src/Layout/FlexSlot.php @@ -0,0 +1,13 @@ +> - */ - private \WeakMap $styles; + public readonly StyleResolver $styles; public function __construct( public readonly FontRegistry $fonts, @@ -40,13 +33,13 @@ public function __construct( /** @var list */ private readonly array $fallbackFamilies = [], ) { - $this->styles = new \WeakMap(); + $this->styles = new StyleResolver($theme); } /** The theme's root style, computed once per layout run so memoized child styles are shared across pages. */ public function rootStyle(): ComputedStyle { - return $this->root ??= ComputedStyle::root($this->theme); + return $this->styles->root(); } public function itemizer(): Itemizer @@ -91,43 +84,14 @@ public function image(Image $node): ImageHandle { $source = $node->source(); $value = $source['value']; - if ($value instanceof ImageHandle) { - return $value; - } if ($source['kind'] === 'file') { return $this->images['file:' . $value] ??= $this->backend->loadImageFile($value); } return $this->images['bytes:' . sha1($value)] ??= $this->backend->loadImageBytes($value); } - /** - * Theme preset (Text h1-h4/p), then the named theme style, then the - * node's own style, all applied on top of the parent's computed style. - */ public function resolveStyle(Node $node, ComputedStyle $parent): ComputedStyle { - $byParent = $this->styles[$node] ?? null; - if ($byParent === null) { - /** @var \WeakMap $byParent */ - $byParent = new \WeakMap(); - $this->styles[$node] = $byParent; - } - return $byParent[$parent] ??= $this->computeStyle($node, $parent); - } - - private function computeStyle(Node $node, ComputedStyle $parent): ComputedStyle - { - $style = new Style(); - if ($node instanceof Text && $node->preset() !== null) { - $preset = $node->preset(); - $style = $style->merge($preset === 'p' ? $this->theme->paragraph() : $this->theme->heading((int) substr($preset, 1))); - } - if ($node instanceof Styled) { - $name = $node->styleName(); - if (is_string($name)) { - $style = $style->merge($this->theme->named($name)); - } - } - return $parent->child($style->merge($node->style())); + return $this->styles->resolve($node, $parent); } } diff --git a/packages/core/src/Layout/LayoutEngine.php b/packages/core/src/Layout/LayoutEngine.php index 402b646..f8f3695 100644 --- a/packages/core/src/Layout/LayoutEngine.php +++ b/packages/core/src/Layout/LayoutEngine.php @@ -7,6 +7,7 @@ use Papercut\{Component, Image, Link, Node, OverflowMode, PageBreak, PageNumber, Spacer, Table, TableCell, Text, View}; use Papercut\Exception\{InvalidValue, UnknownAttribute}; use Papercut\Style\{ComputedStyle, Direction}; +use Papercut\Tree\{Expander, NodePath}; /** * Dispatches nodes to their layouters. Expands Components, resolves the @@ -14,8 +15,6 @@ */ final class LayoutEngine { - private const int MAX_COMPONENT_DEPTH = 100; - /** @var array, Layouter> */ private array $layouters = []; @@ -30,6 +29,7 @@ final class LayoutEngine private readonly BlockLayouter $block; private readonly FlexLayouter $flex; + private readonly TableLayouter $table; public function __construct(private readonly LayoutContext $ctx) { @@ -40,14 +40,24 @@ public function __construct(private readonly LayoutContext $ctx) $this->layouters[Text::class] = new TextLayouter(); $this->layouters[Link::class] = new LinkLayouter($this->block); $this->layouters[Image::class] = new ImageLayouter(); - $table = new TableLayouter($this->block); - $this->layouters[Table::class] = $table; - $this->layouters[TableCell::class] = $table; + $this->table = new TableLayouter($this->block); + $this->layouters[Table::class] = $this->table; + $this->layouters[TableCell::class] = $this->table; $this->layouters[Spacer::class] = $leaf; $this->layouters[PageBreak::class] = $leaf; $this->layouters[PageNumber::class] = $leaf; } + public function flexLayouter(): FlexLayouter + { + return $this->flex; + } + + public function tableLayouter(): TableLayouter + { + return $this->table; + } + public function context(): LayoutContext { return $this->ctx; @@ -60,8 +70,8 @@ public function context(): LayoutContext */ public function layout(Node|Component $node, ComputedStyle $parent, Constraints $c, ?BreakToken $token = null, ?int $index = null, ?string $pathName = null): LayoutResult { - $node = self::expand($node); - $this->ctx->pushPath($pathName ?? self::pathName($node, null, $index)); + $node = Expander::expand($node); + $this->ctx->pushPath($pathName ?? NodePath::segment($node, null, $index)); try { $style = $this->enter($node, $parent, $index, $pathName); $layouter = $this->layouterFor($node, $style); @@ -123,12 +133,12 @@ private function recordFailure(): void /** @return array{min: float, max: float} */ public function intrinsic(Node|Component $node, ComputedStyle $parent, ?int $index = null): array { - $node = self::expand($node); + $node = Expander::expand($node); $byParent = $this->intrinsics[$node] ?? null; if ($byParent !== null && isset($byParent[$parent])) { return $byParent[$parent]; } - $this->ctx->pushPath(self::pathName($node, null, $index)); + $this->ctx->pushPath(NodePath::segment($node, null, $index)); try { $style = $this->enter($node, $parent, $index); $size = $this->layouterFor($node, $style)->intrinsic($node, $style, $this); @@ -147,7 +157,7 @@ public function intrinsic(Node|Component $node, ComputedStyle $parent, ?int $ind /** Computed style of a child node, with style errors reported at its path. */ public function styleOf(Node $node, ComputedStyle $parent, ?int $index = null): ComputedStyle { - $this->ctx->pushPath(self::pathName($node, null, $index)); + $this->ctx->pushPath(NodePath::segment($node, null, $index)); try { return $this->enter($node, $parent, $index); } finally { @@ -167,36 +177,10 @@ private function enter(Node $node, ComputedStyle $parent, ?int $index, ?string $ throw $e->within('at ' . $this->ctx->path()); } $this->ctx->popPath(); - $this->ctx->pushPath($pathName ?? self::pathName($node, $style, $index)); + $this->ctx->pushPath($pathName ?? NodePath::segment($node, $style, $index)); return $style; } - /** Renders Components down to a Node. */ - public static function expand(Node|Component $node): Node - { - $depth = 0; - while (!$node instanceof Node) { - if (++$depth > self::MAX_COMPONENT_DEPTH) { - throw new \LogicException(sprintf( - 'Component %s did not render to a node within %d levels; check for a component that renders itself.', - $node::class, - self::MAX_COMPONENT_DEPTH, - )); - } - $node = $node->render(); - } - return $node; - } - - /** Views are named by their computed direction; debugName() can be stale after attributes(). */ - private static function pathName(Node $node, ?ComputedStyle $style, ?int $index): string - { - $name = $node instanceof View && $style !== null - ? ($style->direction === Direction::Row ? 'row' : 'column') - : $node->debugName(); - return $index === null ? $name : sprintf('%s[%d]', $name, $index); - } - private function layouterFor(Node $node, ComputedStyle $style): Layouter { if ($node instanceof View) { diff --git a/packages/core/src/Layout/Paginator.php b/packages/core/src/Layout/Paginator.php index b8adc83..50b9d6f 100644 --- a/packages/core/src/Layout/Paginator.php +++ b/packages/core/src/Layout/Paginator.php @@ -51,8 +51,8 @@ private function paginatePage(Page $page, string $name, array &$out): void $contentWidth = $w - $left - $m->right->points; $contentHeight = $h - $top - $m->bottom->points; - $header = $this->slot($page->headerNodes(), $root, $contentWidth); - $footer = $this->slot($page->footerNodes(), $root, $contentWidth); + $header = $this->slot($page->headerNodes(), $root, $contentWidth, 'header'); + $footer = $this->slot($page->footerNodes(), $root, $contentWidth, 'footer'); $headerSpace = $header === null ? 0.0 : $header->height + $page->headerGapLength()->points; $footerSpace = $footer === null ? 0.0 : $footer->height + $page->footerGapLength()->points; @@ -77,7 +77,7 @@ private function paginatePage(Page $page, string $name, array &$out): void $background = null; if ($page->backgroundNodes() !== []) { - $background = $this->engine->layout(Column::make(...$page->backgroundNodes()), $root, new Constraints($w, null, true, $h))->fragment; + $background = $this->engine->layout(Column::make(...$page->backgroundNodes()), $root, new Constraints($w, null, true, $h), pathName: 'background')->fragment; } /** @var list $before */ @@ -141,11 +141,11 @@ private static function clipTo(Fragment $f, float $height): Fragment } /** @param list<\Papercut\Node|\Papercut\Component> $nodes */ - private function slot(array $nodes, ComputedStyle $root, float $width): ?Fragment + private function slot(array $nodes, ComputedStyle $root, float $width, string $pathName): ?Fragment { if ($nodes === []) { return null; } - return $this->engine->layout(Column::make(...$nodes), $root, new Constraints($width, null, true))->fragment; + return $this->engine->layout(Column::make(...$nodes), $root, new Constraints($width, null, true), pathName: $pathName)->fragment; } } diff --git a/packages/core/src/Layout/TableLayouter.php b/packages/core/src/Layout/TableLayouter.php index 752d042..9cb82d6 100644 --- a/packages/core/src/Layout/TableLayouter.php +++ b/packages/core/src/Layout/TableLayouter.php @@ -39,6 +39,10 @@ final class TableLayouter implements Layouter private \WeakMap $prepared; /** @var \WeakMap the block container each cell is laid out as */ private \WeakMap $columns; + /** @var \WeakMap prepared cells, which belong to a table */ + private \WeakMap $inTable; + /** @var \WeakMap bare cells already warned about */ + private \WeakMap $strayWarned; public function __construct(private readonly BlockLayouter $block) { @@ -47,11 +51,14 @@ public function __construct(private readonly BlockLayouter $block) $this->headerWarned = new \WeakMap(); $this->prepared = new \WeakMap(); $this->columns = new \WeakMap(); + $this->inTable = new \WeakMap(); + $this->strayWarned = new \WeakMap(); } public function layout(Node $node, ComputedStyle $style, Constraints $c, ?BreakToken $token, LayoutEngine $engine): LayoutResult { if ($node instanceof TableCell) { + $this->warnStray($node, $engine); return $this->block->layout($this->column($node), $style, $c, $token, $engine); } $table = self::table($node); @@ -154,6 +161,7 @@ public function layout(Node $node, ComputedStyle $style, Constraints $c, ?BreakT public function intrinsic(Node $node, ComputedStyle $style, LayoutEngine $engine): array { if ($node instanceof TableCell) { + $this->warnStray($node, $engine); return $this->block->intrinsic($this->column($node), $style, $engine); } if ($style->width instanceof Length) { @@ -161,7 +169,7 @@ public function intrinsic(Node $node, ComputedStyle $style, LayoutEngine $engine } $table = self::table($node); $columns = $this->columnContent($table, $style, $engine); - $tracks = self::tracks($table, count($columns)); + $tracks = self::trackList($table); $min = 0.0; $max = 0.0; foreach ($columns as $k => $size) { @@ -192,6 +200,17 @@ private function crowdsOutRow(TableRow $row, int $r, bool $resuming, float $head return $height > $room + self::EPSILON && $height <= $available + self::EPSILON; } + /** + * Column widths in pt for a table whose border box is offered $available, + * without pagination. + * + * @return list + */ + public function columns(Table $table, ComputedStyle $style, float $available, LayoutEngine $engine): array + { + return $this->grid($table, $style, BoxGeometry::contentWidth($style, $available), $engine)['widths']; + } + /** * Column widths and x offsets for content width $width, computed once per * table, style and width; column warnings are emitted only then. @@ -215,7 +234,7 @@ private function grid(Table $table, ComputedStyle $style, float $width, LayoutEn if ($given !== null && $given !== $n) { $engine->context()->warn(sprintf('Table has %d columns but %d tracks', $n, $given)); } - $tracks = self::tracks($table, $n); + $tracks = self::trackList($table); $widths = array_fill(0, $n, 0.0); $auto = []; @@ -321,11 +340,8 @@ private function columnContent(Table $table, ComputedStyle $style, LayoutEngine } $rows = self::allRows($table); - $n = 0; - foreach ($rows as $row) { - $n = max($n, array_sum(array_map(static fn (TableCell $cell): int => $cell->span(), $row->cells()))); - } - $tracks = self::tracks($table, $n); + $tracks = self::trackList($table); + $n = count($tracks); $columns = array_fill(0, $n, ['min' => 0.0, 'max' => 0.0]); /** @var list $spanning */ $spanning = []; @@ -550,12 +566,17 @@ private static function styleKey(ComputedStyle $style): string } /** - * The given tracks for $n columns: missing tracks are 1fr, extra ones dropped. + * One track per column: the column count is the widest row's total span; missing tracks + * are 1fr, extra ones ignored. * * @return list */ - private static function tracks(Table $table, int $n): array + public static function trackList(Table $table): array { + $n = 0; + foreach (self::allRows($table) as $row) { + $n = max($n, array_sum(array_map(static fn (TableCell $cell): int => $cell->span(), $row->cells()))); + } $given = $table->trackList()->tracks ?? []; $tracks = []; for ($k = 0; $k < $n; $k++) { @@ -570,11 +591,18 @@ private static function tracks(Table $table, int $n): array */ private function prepare(TableCell $cell, Theme $theme): TableCell { - return $this->prepared[$cell] ??= self::withThemeDefaults($cell, $theme); + if (!isset($this->prepared[$cell])) { + $prepared = self::withThemeDefaults($cell, $theme); + $this->prepared[$cell] = $prepared; + $this->inTable[$prepared] = true; // the engine lays this one out; it is not a stray cell + } + return $this->prepared[$cell]; } + // StyleResolver applies the same defaults as its lowest layer; applying them twice changes nothing. private static function withThemeDefaults(TableCell $cell, Theme $theme): TableCell { + $source = $cell; $own = $cell->style(); $named = new Style(); $name = $cell->styleName(); @@ -591,7 +619,8 @@ private static function withThemeDefaults(TableCell $cell, Theme $theme): TableC if ($cell->header() && $own->fontWeight === null && $named->fontWeight === null) { $cell = $cell->weight(700); } - return $cell; + // Always a distinct instance: the source cell is never marked inTable, so a bare reuse still warns. + return $cell === $source ? clone $cell : $cell; } /** @return list */ @@ -600,6 +629,14 @@ private static function allRows(Table $table): array return [...$table->headerRows(), ...$table->bodyRows()]; } + private function warnStray(TableCell $cell, LayoutEngine $engine): void + { + if (!isset($this->inTable[$cell]) && !isset($this->strayWarned[$cell])) { + $this->strayWarned[$cell] = true; + $engine->context()->warn('A table cell outside a table is laid out as a column'); + } + } + private function column(TableCell $cell): View { return $this->columns[$cell] ??= View::make(...$cell->children()); diff --git a/packages/core/src/Layout/WidthResolver.php b/packages/core/src/Layout/WidthResolver.php new file mode 100644 index 0000000..498a9e1 --- /dev/null +++ b/packages/core/src/Layout/WidthResolver.php @@ -0,0 +1,77 @@ +fonts(); + $ctx = new LayoutContext( + FontRegistry::fromSet($fonts, $backend), + $options->theme(), + $backend, + OverflowMode::Throw, + $options->onWarning(), + $fonts->fallbacks, + ); + + return new self(new LayoutEngine($ctx)); + } + + /** + * One list per flex line. $style is the row's own computed style; $available is the + * width offered to its border box. x is relative to the content box. + * + * @return list> + */ + public function flexLines(View $row, ComputedStyle $style, float $available): array + { + $lines = []; + foreach ($this->engine->flexLayouter()->lines($row, $style, $available, $this->engine) as $line) { + $lines[] = array_map(static fn (array $item): FlexSlot => new FlexSlot($item['index'], $item['x'], $item['main']), $line); + } + return $lines; + } + + /** + * One track per column as the PDF layout counts them. + * + * @return list<\Papercut\Value\Length|\Papercut\Value\Percent|\Papercut\Value\Fr|\Papercut\Value\Auto> + */ + public static function tracks(Table $table): array + { + return TableLayouter::trackList($table); + } + + /** + * Column widths in pt; $style is the table's computed style, $available as in flexLines(). + * + * @return list + */ + public function tableColumns(Table $table, ComputedStyle $style, float $available): array + { + return $this->engine->tableLayouter()->columns($table, $style, $available, $this->engine); + } +} diff --git a/packages/core/src/RenderOptions.php b/packages/core/src/RenderOptions.php deleted file mode 100644 index 14e5cae..0000000 --- a/packages/core/src/RenderOptions.php +++ /dev/null @@ -1,48 +0,0 @@ - $fallbackFamilies families tried, in order, for glyphs the primary font lacks - * @param string|false|null $cacheDir directory for the default backend's font kerning cache - * (null = sys_get_temp_dir()/papercut-cache-, false = no disk cache); ignored when $backend is given - */ - public function __construct( - private ?Backend $backend = null, - private ?FontRegistry $fonts = null, - private ?Theme $theme = null, - public OverflowMode $overflow = OverflowMode::Throw, - public ?\Closure $onWarning = null, - public array $fallbackFamilies = [], - public string|false|null $cacheDir = null, - ) { - } - - /** - * Returns the given backend, or a new PhpPdfBackend on each call. - * Callers (Document::render) should call this once and reuse the result. - */ - public function backend(): Backend - { - return $this->backend ?? new PhpPdfBackend($this->cacheDir); - } - - public function fonts(Backend $backend): FontRegistry - { - return $this->fonts ?? FontRegistry::withDefaults($backend); - } - - public function theme(): Theme - { - return $this->theme ?? Theme::default(); - } -} diff --git a/packages/core/src/Style/StyleResolver.php b/packages/core/src/Style/StyleResolver.php new file mode 100644 index 0000000..5a898ad --- /dev/null +++ b/packages/core/src/Style/StyleResolver.php @@ -0,0 +1,88 @@ +> + */ + private \WeakMap $styles; + /** @var \WeakMap> */ + private \WeakMap $cellStyles; + + public function __construct(private readonly Theme $theme) + { + $this->styles = new \WeakMap(); + $this->cellStyles = new \WeakMap(); + } + + /** The theme's root style, computed once so memoized child styles are shared across pages. */ + public function root(): ComputedStyle + { + return $this->root ??= ComputedStyle::root($this->theme); + } + + /** + * Theme preset (Text h1-h4/p), then the named theme style, then the + * node's own style, all applied on top of the parent's computed style. + */ + public function resolve(Node $node, ComputedStyle $parent): ComputedStyle + { + $byParent = $this->styles[$node] ?? null; + if ($byParent === null) { + /** @var \WeakMap $byParent */ + $byParent = new \WeakMap(); + $this->styles[$node] = $byParent; + } + return $byParent[$parent] ??= $this->compute($node, $parent); + } + + /** + * Like resolve(), for a cell under a table row: the theme's cell padding + * and bold th weight form the lowest layer. TreeWalker calls this for + * cells under a table row; layout applies the same defaults through + * TableLayouter::prepare(). A bare cell outside a table uses resolve(). + */ + public function resolveInTable(TableCell $cell, ComputedStyle $rowStyle): ComputedStyle + { + $byParent = $this->cellStyles[$cell] ?? null; + if ($byParent === null) { + /** @var \WeakMap $byParent */ + $byParent = new \WeakMap(); + $this->cellStyles[$cell] = $byParent; + } + return $byParent[$rowStyle] ??= $this->compute( + $cell, + $rowStyle, + new Style(padding: $this->theme->cellPadding, fontWeight: $cell->header() ? 700 : null), + ); + } + + private function compute(Node $node, ComputedStyle $parent, Style $style = new Style()): ComputedStyle + { + if ($node instanceof Text && $node->preset() !== null) { + $preset = $node->preset(); + $style = $style->merge($preset === 'p' ? $this->theme->paragraph() : $this->theme->heading((int) substr($preset, 1))); + } + if ($node instanceof Styled) { + $name = $node->styleName(); + if (is_string($name)) { + $style = $style->merge($this->theme->named($name)); + } + } + return $parent->child($style->merge($node->style())); + } +} diff --git a/packages/core/src/Text/FontFace.php b/packages/core/src/Text/FontFace.php new file mode 100644 index 0000000..5589686 --- /dev/null +++ b/packages/core/src/Text/FontFace.php @@ -0,0 +1,16 @@ +register('Inter', "$dir/Inter-Regular.ttf", 400) - ->register('Inter', "$dir/Inter-Italic.ttf", 400, FontStyle::Italic) - ->register('Inter', "$dir/Inter-Bold.ttf", 700) - ->register('Inter', "$dir/Inter-BoldItalic.ttf", 700, FontStyle::Italic); + $registry = new self($backend); + foreach (FontSet::bundled() as $face) { + $registry->register($face->family, $face->path, $face->weight, $face->style); + } + return $registry; + } + + public static function fromSet(FontSet $set, Backend $backend): self + { + $registry = self::withDefaults($backend); + foreach ($set->effectiveFaces() as $face) { + $registry->register($face->family, $face->path, $face->weight, $face->style); + } + return $registry; } public function register(string $family, string $path, int $weight = 400, FontStyle $style = FontStyle::Normal): self diff --git a/packages/core/src/Text/FontSet.php b/packages/core/src/Text/FontSet.php new file mode 100644 index 0000000..02a854f --- /dev/null +++ b/packages/core/src/Text/FontSet.php @@ -0,0 +1,57 @@ + $faces + * @param list $fallbacks families tried, in order, for glyphs the primary font lacks + */ + public function __construct( + public array $faces = [], + public array $fallbacks = [], + ) { + } + + /** @return list the bundled Inter faces, in registration order */ + public static function bundled(): array + { + $dir = __DIR__ . '/../../resources/fonts/Inter'; + return [ + new FontFace('Inter', "$dir/Inter-Regular.ttf", 400), + new FontFace('Inter', "$dir/Inter-Italic.ttf", 400, FontStyle::Italic), + new FontFace('Inter', "$dir/Inter-Bold.ttf", 700), + new FontFace('Inter', "$dir/Inter-BoldItalic.ttf", 700, FontStyle::Italic), + ]; + } + + /** + * Bundled then custom faces, one per family (case-insensitive), weight and style: the last + * registration wins, and survivors keep the order their key was first registered in. + * + * @return list + */ + public function effectiveFaces(): array + { + $byKey = []; + foreach ([...self::bundled(), ...$this->faces] as $face) { + $byKey[strtolower($face->family) . '|' . $face->weight . '|' . $face->style->value] = $face; + } + + return array_values($byKey); + } + + public function with(FontFace ...$faces): self + { + return new self([...$this->faces, ...array_values($faces)], $this->fallbacks); + } + + public function withFallbacks(string ...$families): self + { + return new self($this->faces, [...$this->fallbacks, ...array_values($families)]); + } +} diff --git a/packages/core/src/Tree/Expander.php b/packages/core/src/Tree/Expander.php new file mode 100644 index 0000000..d8d830b --- /dev/null +++ b/packages/core/src/Tree/Expander.php @@ -0,0 +1,29 @@ + self::MAX_DEPTH) { + throw new \LogicException(sprintf( + 'Component %s did not render to a node within %d levels; check for a component that renders itself.', + $node::class, + self::MAX_DEPTH, + )); + } + $node = $node->render(); + } + return $node; + } +} diff --git a/packages/core/src/Tree/NodePath.php b/packages/core/src/Tree/NodePath.php new file mode 100644 index 0000000..0d49799 --- /dev/null +++ b/packages/core/src/Tree/NodePath.php @@ -0,0 +1,20 @@ +direction === Direction::Row ? 'row' : 'column') + : $node->debugName(); + return $index === null ? $name : sprintf('%s[%d]', $name, $index); + } +} diff --git a/packages/core/src/Tree/Region.php b/packages/core/src/Tree/Region.php new file mode 100644 index 0000000..800385e --- /dev/null +++ b/packages/core/src/Tree/Region.php @@ -0,0 +1,13 @@ +pages(); + $root = $this->styles->root(); + foreach ($pages as $i => $page) { + $name = count($pages) > 1 ? sprintf('page[%d]', $i) : 'page'; + $visitor->enterPage($page, $i); + foreach ($this->regions($page) as [$region, $nodes]) { + if ($nodes === []) { + continue; + } + $visitor->enterRegion($region, $root); + foreach ($nodes as $j => $node) { + $this->visit(Expander::expand($node), $root, $name . ' > ' . $region->value, $j, $visitor, $region); + } + $visitor->leaveRegion($region); + } + $visitor->leavePage($page, $i); + } + } + + /** @return list}> */ + private function regions(Page $page): array + { + return [ + [Region::Background, $page->backgroundNodes()], + [Region::Header, $page->headerNodes()], + [Region::Body, $page->bodyNodes()], + [Region::Footer, $page->footerNodes()], + ]; + } + + private function visit(Node $node, ComputedStyle $parent, string $parentPath, int $index, TreeVisitor $visitor, Region $region, ?Node $parentNode = null, bool $inHeader = false, ?ComputedStyle $rowStyle = null): void + { + $provisional = $parentPath . ' > ' . NodePath::segment($node, null, $index); + try { + $style = $node instanceof TableCell && $rowStyle !== null + ? $this->styles->resolveInTable($node, $rowStyle) + : $this->styles->resolve($node, $parent); + } catch (UnknownAttribute | InvalidValue $e) { + throw $e->within('at ' . $provisional); + } + $path = $parentPath . ' > ' . NodePath::segment($node, $style, $index); + + $at = new Visit($path, $index, $parentNode, $inHeader, $region); + if ($visitor->enter($node, $style, $at)) { + $this->children($node, $style, $path, $visitor, $region, $inHeader); + } + $visitor->leave($node, $style, $at); + } + + private function children(Node $node, ComputedStyle $style, string $path, TreeVisitor $visitor, Region $region, bool $inHeader): void + { + if ($node instanceof View || $node instanceof Link || $node instanceof TableCell) { + foreach ($node->children() as $i => $child) { + $this->visit(Expander::expand($child), $style, $path, $i, $visitor, $region, $node, $inHeader); + } + } elseif ($node instanceof Table) { + $headers = count($node->headerRows()); + foreach ([...$node->headerRows(), ...$node->bodyRows()] as $r => $row) { + $this->visit($row, $style, $path, $r, $visitor, $region, $node, $r < $headers); + } + } elseif ($node instanceof TableRow) { + foreach ($node->cells() as $j => $cell) { + $this->visit($cell, $style, $path, $j, $visitor, $region, $node, $inHeader, $style); + } + } + } +} diff --git a/packages/core/src/Tree/Visit.php b/packages/core/src/Tree/Visit.php new file mode 100644 index 0000000..a275c76 --- /dev/null +++ b/packages/core/src/Tree/Visit.php @@ -0,0 +1,51 @@ +path; + } + + /** Sibling index in the parent's original child list (table rows: header rows, then body rows). */ + public function index(): int + { + return $this->index; + } + + /** Null for a region's top-level nodes. */ + public function parent(): ?Node + { + return $this->parent; + } + + /** True within the nearest table's header rows: a header TableRow and everything inside it. */ + public function inHeader(): bool + { + return $this->inHeader; + } + + public function region(): Region + { + return $this->region; + } +} diff --git a/packages/core/tests/ArchitectureTest.php b/packages/core/tests/ArchitectureTest.php index 3d2e33b..5c80749 100644 --- a/packages/core/tests/ArchitectureTest.php +++ b/packages/core/tests/ArchitectureTest.php @@ -28,4 +28,24 @@ public function test_only_phppdf_backend_imports_dskripchenko(): void } self::assertSame([], $offenders, 'Only src/Backend/PhpPdf may reference Dskripchenko\\*'); } + + public function test_tree_and_style_do_not_depend_on_layout_or_backend(): void + { + $src = realpath(__DIR__ . '/../src'); + self::assertIsString($src); + $offenders = []; + foreach (['Tree', 'Style'] as $dir) { + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($src . '/' . $dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + $code = (string) file_get_contents($file->getPathname()); + if (preg_match('/Papercut\\\\(Layout|Backend)\b/', $code) === 1) { + $offenders[] = substr($file->getPathname(), strlen($src) + 1); + } + } + } + self::assertSame([], $offenders, 'src/Tree and src/Style must not reference Papercut\\Layout or Papercut\\Backend'); + } } diff --git a/packages/core/tests/Backend/KerningCacheTest.php b/packages/core/tests/Backend/KerningCacheTest.php index 62eca80..b74f35c 100644 --- a/packages/core/tests/Backend/KerningCacheTest.php +++ b/packages/core/tests/Backend/KerningCacheTest.php @@ -4,7 +4,9 @@ namespace Papercut\Tests\Backend; -use Papercut\{Document, Page, RenderOptions, Text}; +use Papercut\{Document, Page, Text}; +use Papercut\Export\PdfOptions; +use Papercut\Text\{FontFace, FontSet}; use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; use Papercut\Backend\PhpPdf\{KerningCache, PhpPdfBackend}; use Papercut\Text\FontRegistry; @@ -140,9 +142,9 @@ public function test_an_unwritable_cache_dir_falls_back_silently(): void } } - public function test_render_options_pass_the_cache_dir_to_the_default_backend(): void + public function test_pdf_options_pass_the_cache_dir_to_the_default_backend(): void { - (new RenderOptions(cacheDir: $this->dir))->backend()->loadFont($this->font); + (new PdfOptions(cacheDir: $this->dir))->backend()->loadFont($this->font); self::assertCount(1, $this->cacheFiles()); } @@ -152,8 +154,8 @@ public function test_output_is_identical_uncached_cold_and_warm(): void ->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); $render = function (string|false $dir) use ($doc): string { $backend = new PhpPdfBackend(kerningCacheDir: $dir); - $fonts = (new FontRegistry($backend))->register('Copy', $this->font); - return $doc->render(new RenderOptions(backend: $backend, fonts: $fonts)); + $fonts = new FontSet([new FontFace('Copy', $this->font)]); + return $doc->toPdf(new PdfOptions(backend: $backend, fonts: $fonts))->bytes(); }; $uncached = $render(false); $this->forgetParsedFonts(); diff --git a/packages/core/tests/Exception/InvalidValueTest.php b/packages/core/tests/Exception/InvalidValueTest.php index 42981d2..cb0303a 100644 --- a/packages/core/tests/Exception/InvalidValueTest.php +++ b/packages/core/tests/Exception/InvalidValueTest.php @@ -43,9 +43,9 @@ public function test_with_context_returns_new_exception_with_context_in_message( public function test_context_and_location(): void { $base = InvalidValue::for('edges', '20 mm', '20mm'); - $e = $base->withContext('margin')->within('on '); - self::assertSame('Invalid edges "20 mm" for margin on . Did you mean "20mm"?', $e->getMessage()); - self::assertSame(['on '], $e->locations); + $e = $base->withContext('margin')->within('on '); + self::assertSame('Invalid edges "20 mm" for margin on . Did you mean "20mm"?', $e->getMessage()); + self::assertSame(['on '], $e->locations); self::assertSame('margin', $e->context); $mid = $e->getPrevious(); self::assertInstanceOf(InvalidValue::class, $mid); @@ -56,8 +56,8 @@ public function test_context_and_location(): void public function test_location_only_and_repeated_locations_append_left_to_right(): void { self::assertSame( - 'Invalid edges "20 mm" on .', - InvalidValue::for('edges', '20 mm')->within('on ')->getMessage(), + 'Invalid edges "20 mm" on .', + InvalidValue::for('edges', '20 mm')->within('on ')->getMessage(), ); $e = InvalidValue::for('edges', 'x')->within('on ')->within('in '); self::assertSame('Invalid edges "x" on in .', $e->getMessage()); diff --git a/packages/core/tests/Exception/NewExceptionsTest.php b/packages/core/tests/Exception/NewExceptionsTest.php new file mode 100644 index 0000000..9e19145 --- /dev/null +++ b/packages/core/tests/Exception/NewExceptionsTest.php @@ -0,0 +1,29 @@ +getMessage()); + self::assertInstanceOf(PapercutException::class, $e); + } + + public function test_unsupported_feature_carries_its_report(): void + { + $r = new FidelityReport(FidelityLevel::Dropped, 'docx', 'radius', 'page > row[2]'); + $e = UnsupportedFeature::from($r); + self::assertSame('Strict mode: docx cannot render radius at page > row[2].', $e->getMessage()); + self::assertSame($r, $e->report); + self::assertInstanceOf(PapercutException::class, $e); + } +} diff --git a/packages/core/tests/Exception/UnknownAttributeTest.php b/packages/core/tests/Exception/UnknownAttributeTest.php index 29d62a9..7d5e504 100644 --- a/packages/core/tests/Exception/UnknownAttributeTest.php +++ b/packages/core/tests/Exception/UnknownAttributeTest.php @@ -25,13 +25,13 @@ public function test_message_with_suggestion(): void public function test_message_with_context_and_suggestion(): void { $base = UnknownAttribute::for('paddng', 'padding'); - $e = $base->within('on '); + $e = $base->within('on '); self::assertSame( - 'Unknown attribute "paddng" on . Did you mean "padding"?', + 'Unknown attribute "paddng" on . Did you mean "padding"?', $e->getMessage(), ); self::assertSame($base, $e->getPrevious()); - self::assertSame(['on '], $e->locations); + self::assertSame(['on '], $e->locations); } public function test_repeated_within_appends_left_to_right(): void diff --git a/packages/core/tests/Export/FidelityTest.php b/packages/core/tests/Export/FidelityTest.php new file mode 100644 index 0000000..ba0541b --- /dev/null +++ b/packages/core/tests/Export/FidelityTest.php @@ -0,0 +1,91 @@ +approximated('fr column widths', 'page > body > table[0]'); + $f->dropped('radius', 'page > row[2] > column[0]'); + + self::assertCount(2, $f->reports()); + self::assertSame(FidelityLevel::Approximated, $f->reports()[0]->level); + self::assertSame(FidelityLevel::Dropped, $f->reports()[1]->level); + self::assertSame([ + 'docx: approximated fr column widths at page > body > table[0]', + 'docx: dropped radius at page > row[2] > column[0]', + ], $messages); + self::assertSame($f->reports(), $objects); + self::assertSame('docx: dropped radius at page > row[2] > column[0]', $f->reports()[1]->message()); + } + + public function test_works_without_a_warning_callback(): void + { + $f = new Fidelity('docx', new ExportOptions()); + $f->dropped('radius', 'p'); + self::assertCount(1, $f->reports()); + } + + public function test_strict_throws_on_dropped_only(): void + { + $called = 0; + $f = new Fidelity('docx', new ExportOptions(onWarning: function () use (&$called): void { + $called++; + }, strict: true)); + $f->approximated('fr column widths', 'page > body > table[0]'); + self::assertSame(1, $called); + try { + $f->dropped('radius', 'page > body > row[2] > column[0]'); + self::fail('expected UnsupportedFeature'); + } catch (UnsupportedFeature $e) { + self::assertSame('Strict mode: docx cannot render radius at page > body > row[2] > column[0].', $e->getMessage()); + self::assertSame('radius', $e->report->feature); + } + self::assertSame(1, $called); + self::assertCount(1, $f->reports()); + } + + public function test_one_argument_warning_closure_still_receives_layout_warnings(): void + { + $warnings = []; + $doc = Document::make(Page::a5()->body(Text::make('漢'))); + $doc->toPdf(new PdfOptions(onWarning: function (string $m) use (&$warnings): void { + $warnings[] = $m; + })); + self::assertNotSame([], $warnings); + self::assertStringContainsString('Missing glyphs', $warnings[0]); + } + + public function test_one_parameter_native_callable_gets_one_argument(): void + { + /** @var \Closure(string, ?FidelityReport=): void $strlen */ + $strlen = \Closure::fromCallable('strlen'); + $f = new Fidelity('html', new ExportOptions(onWarning: $strlen)); + $f->approximated('page box', 'page'); + self::assertCount(1, $f->reports()); + } + + public function test_two_parameter_closure_gets_the_report(): void + { + $seen = []; + $f = new Fidelity('html', new ExportOptions(onWarning: function (string $m, ?FidelityReport $r = null) use (&$seen): void { + $seen[] = $r; + })); + $f->approximated('page box', 'page'); + self::assertSame($f->reports(), $seen); + } +} diff --git a/packages/core/tests/Export/HtmlOptionsTest.php b/packages/core/tests/Export/HtmlOptionsTest.php new file mode 100644 index 0000000..4a8ba3f --- /dev/null +++ b/packages/core/tests/Export/HtmlOptionsTest.php @@ -0,0 +1,53 @@ +fullPage()); + self::assertSame(FontMode::None, $o->fontMode()); + self::assertNull($o->imageUrl()); + self::assertNull($o->fontUrl()); + self::assertFalse($o->strict()); + } + + public function test_from_copies_shared_fields_and_keeps_html_options(): void + { + $theme = Theme::default(); + $fonts = new FontSet(); + $warn = static function (string $m): void { + }; + $o = HtmlOptions::from(new ExportOptions($theme, $fonts, $warn, true)); + self::assertSame($theme, $o->theme()); + self::assertSame($fonts, $o->fonts()); + self::assertSame($warn, $o->onWarning()); + self::assertTrue($o->strict()); + self::assertFalse($o->fullPage()); + self::assertSame(FontMode::None, $o->fontMode()); + + $html = new HtmlOptions(fullPage: true, fontMode: FontMode::Embed); + self::assertSame($html, HtmlOptions::from($html)); + } + + public function test_with_full_page_copies_everything(): void + { + $img = static fn ($i): ?string => null; + $o = new HtmlOptions(strict: true, fontMode: FontMode::Link, imageUrl: $img); + $p = $o->withFullPage(true); + self::assertTrue($p->fullPage()); + self::assertFalse($o->fullPage()); + self::assertTrue($p->strict()); + self::assertSame(FontMode::Link, $p->fontMode()); + self::assertSame($img, $p->imageUrl()); + } +} diff --git a/packages/core/tests/Export/LoadedImageTest.php b/packages/core/tests/Export/LoadedImageTest.php new file mode 100644 index 0000000..c95a459 --- /dev/null +++ b/packages/core/tests/Export/LoadedImageTest.php @@ -0,0 +1,34 @@ +mime); + self::assertSame([40, 20], [$png->widthPx, $png->heightPx]); + + $jpeg = LoadedImage::load(Image::bytes(TestImages::jpeg(40, 20)), $backend); + self::assertSame('image/jpeg', $jpeg->mime); + self::assertSame([40, 20], [$jpeg->widthPx, $jpeg->heightPx]); + } + + public function test_missing_file_throws(): void + { + $this->expectException(ImageUnreadable::class); + $this->expectExceptionMessage('file not found or not readable'); + LoadedImage::load(Image::file('/nonexistent/x.png'), DefaultBackend::make(false)); + } +} diff --git a/packages/core/tests/Export/OutputTest.php b/packages/core/tests/Export/OutputTest.php new file mode 100644 index 0000000..502e834 --- /dev/null +++ b/packages/core/tests/Export/OutputTest.php @@ -0,0 +1,55 @@ +bytes()); + self::assertSame('text/html; charset=UTF-8', $out->mime()); + self::assertSame('html', $out->extension()); + } + + public function test_save_writes_bytes_and_missing_directory_throws(): void + { + $out = new Output('%PDF-abc', 'application/pdf', 'pdf'); + $path = sys_get_temp_dir() . '/papercut-output-' . getmypid() . '.pdf'; + try { + $out->save($path); + self::assertSame('%PDF-abc', file_get_contents($path)); + } finally { + @unlink($path); + } + $missing = sys_get_temp_dir() . '/papercut-no-such-dir-' . getmypid(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Directory does not exist: $missing"); + $out->save($missing . '/x.pdf'); + } + + public function test_save_failure_names_the_path(): void + { + $out = new Output('x', 'text/plain', 'txt'); + $dir = sys_get_temp_dir(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/^Could not write output to "' . preg_quote($dir, '/') . '": ./'); + $out->save($dir); + } + + public function test_stream_returns_byte_count(): void + { + $out = new Output('hello', 'text/plain', 'txt'); + ob_start(); + $n = $out->stream(); + $echoed = (string) ob_get_clean(); + self::assertSame(5, $n); + self::assertSame('hello', $echoed); + self::assertSame('hello', $out->bytes()); + } +} diff --git a/packages/core/tests/Export/PdfExporterTest.php b/packages/core/tests/Export/PdfExporterTest.php new file mode 100644 index 0000000..f8dc825 --- /dev/null +++ b/packages/core/tests/Export/PdfExporterTest.php @@ -0,0 +1,114 @@ +body(Text::make('Hi')))->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + } + + public function test_to_pdf_is_a_pdf_output(): void + { + $out = $this->doc()->toPdf(); + self::assertSame('application/pdf', $out->mime()); + self::assertSame('pdf', $out->extension()); + self::assertStringStartsWith('%PDF-', $out->bytes()); + } + + public function test_export_with_plain_export_options_uses_their_theme(): void + { + $doc = $this->doc(); + $themed = $doc->export(new PdfExporter(), new ExportOptions(theme: Theme::default()->with(fontSize: 20.0))); + self::assertNotSame($doc->export(new PdfExporter())->bytes(), $themed->bytes()); + self::assertSame($doc->toPdf()->bytes(), $doc->export(new PdfExporter())->bytes()); + } + + public function test_from_is_identity_for_pdf_options(): void + { + $o = new PdfOptions(overflow: OverflowMode::Clip); + self::assertSame($o, PdfOptions::from($o)); + self::assertSame(OverflowMode::Throw, PdfOptions::from(new ExportOptions())->overflow()); + } + + public function test_pdf_options_keep_overflow(): void + { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); + $doc = Document::make(Page::a5()->body(Image::bytes((string) $png)->width(mm(10))->height(mm(5000)))); + try { + $doc->toPdf(); + self::fail('expected LayoutOverflow'); + } catch (LayoutOverflow) { + self::addToAssertionCount(1); + } + $warnings = []; + $pdf = $doc->toPdf(new PdfOptions(onWarning: function (string $m) use (&$warnings): void { + $warnings[] = $m; + }, overflow: OverflowMode::Clip))->bytes(); + self::assertStringStartsWith('%PDF-', $pdf); + self::assertNotSame([], $warnings); + } + + public function test_cache_dir_controls_where_the_kerning_cache_is_written(): void + { + $base = (string) realpath(sys_get_temp_dir()) . '/papercut-cachedir-' . getmypid() . '-' . bin2hex(random_bytes(4)); + $fontDir = $base . '-fonts'; + $on = $base . '-on'; + mkdir($fontDir); + // Private font copies: php-pdf parses each path once per process, so earlier tests cannot have warmed them. + copy(__DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf', $fontDir . '/On.ttf'); + copy(__DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf', $fontDir . '/Off.ttf'); + $render = static fn (string $family, string $file, string|false $cacheDir): string => Document::make(Page::a5()->body(Text::make('AVATAR Wavy Tea')->font($family))) + ->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')) + ->toPdf(new PdfOptions(fonts: new FontSet([new FontFace($family, $fontDir . '/' . $file)]), cacheDir: $cacheDir)) + ->bytes(); + try { + $before = glob(KerningCache::defaultDir() . '/kern-*') ?: []; + $render('Off', 'Off.ttf', false); + self::assertSame($before, glob(KerningCache::defaultDir() . '/kern-*') ?: [], 'cacheDir: false must not fall back to the default directory'); + $render('On', 'On.ttf', $on); + self::assertNotSame([], glob($on . '/kern-*')); + } finally { + foreach ([$on] as $d) { + array_map('unlink', glob($d . '/*') ?: []); + @rmdir($d); + } + array_map('unlink', glob($fontDir . '/*') ?: []); + @rmdir($fontDir); + $cache = new \ReflectionProperty(TtfFile::class, 'cache'); + /** @var array $parsed */ + $parsed = $cache->getValue(); + $cache->setValue(null, array_filter($parsed, static fn (string $key): bool => !str_starts_with($key, $fontDir), ARRAY_FILTER_USE_KEY)); + gc_collect_cycles(); + } + } + + public function test_from_copies_every_shared_field(): void + { + $theme = Theme::default()->with(fontSize: 20.0); + $fonts = new FontSet(fallbacks: ['Inter']); + $onWarning = static function (string $m): void { + }; + $from = PdfOptions::from(new ExportOptions($theme, $fonts, $onWarning, true)); + self::assertSame($theme, $from->theme()); + self::assertSame($fonts, $from->fonts()); + self::assertSame($onWarning, $from->onWarning()); + self::assertTrue($from->strict()); + self::assertSame(OverflowMode::Throw, $from->overflow()); + self::assertNull($from->cacheDir()); + } +} diff --git a/packages/core/tests/Feature/BenchCheckTest.php b/packages/core/tests/Feature/BenchCheckTest.php index 0183bb4..d322d4e 100644 --- a/packages/core/tests/Feature/BenchCheckTest.php +++ b/packages/core/tests/Feature/BenchCheckTest.php @@ -52,9 +52,13 @@ private function writeBaseline(float $median): void file_put_contents($this->baseline, json_encode(['machines' => ['test' => ['median_ms' => $median]]])); } - private static function benchResult(float $median, int $pages = 10): string + private static function benchResult(float $median, int $pages = 10, ?float $html = null): string { - return (string) json_encode(['median_ms' => $median, 'runs_ms' => [$median], 'pages' => $pages, 'bytes' => 1, 'opcache' => true, 'php' => '8.4.0']); + $result = ['median_ms' => $median, 'runs_ms' => [$median], 'pages' => $pages, 'bytes' => 1, 'opcache' => true, 'php' => '8.4.0']; + if ($html !== null) { + $result['html_median_ms'] = $html; + } + return (string) json_encode($result); } public function test_missing_baseline_writes_an_entry_and_passes(): void @@ -83,6 +87,37 @@ public function test_more_than_20_percent_slower_fails(): void self::assertSame(0, $this->check(self::benchResult(119.0))['exit']); } + public function test_html_more_than_20_percent_slower_fails(): void + { + file_put_contents($this->baseline, json_encode(['machines' => ['test' => ['median_ms' => 100.0, 'html_median_ms' => 20.0]]])); + $run = $this->check(self::benchResult(100.0, html: 24.5)); + self::assertSame(1, $run['exit'], $run['out']); + self::assertStringContainsString('check: FAIL: html median 24.50 ms exceeds 24.00 ms (baseline 20.00 ms + 20%)', $run['out']); + self::assertSame(0, $this->check(self::benchResult(100.0, html: 23.0))['exit']); + } + + public function test_missing_html_baseline_is_added_to_the_entry(): void + { + $this->writeBaseline(100.0); + $run = $this->check(self::benchResult(100.0, html: 18.25)); + self::assertSame(0, $run['exit'], $run['out']); + self::assertStringContainsString('no html baseline', $run['out']); + $written = json_decode((string) file_get_contents($this->baseline), true); + self::assertIsArray($written); + self::assertSame(18.25, $written['machines']['test']['html_median_ms']); + self::assertEquals(100.0, $written['machines']['test']['median_ms']); + } + + public function test_result_without_html_key_is_unchanged_behavior(): void + { + $contents = json_encode(['machines' => ['test' => ['median_ms' => 100.0, 'html_median_ms' => 20.0]]]); + file_put_contents($this->baseline, $contents); + $run = $this->check(self::benchResult(100.0)); + self::assertSame(0, $run['exit'], $run['out']); + self::assertStringNotContainsString('html', $run['out']); + self::assertSame($contents, file_get_contents($this->baseline)); + } + public function test_a_document_that_is_not_ten_pages_fails(): void { $this->writeBaseline(100.0); diff --git a/packages/core/tests/Feature/BenchmarkDocumentTest.php b/packages/core/tests/Feature/BenchmarkDocumentTest.php index 0e6f1cc..045fad2 100644 --- a/packages/core/tests/Feature/BenchmarkDocumentTest.php +++ b/packages/core/tests/Feature/BenchmarkDocumentTest.php @@ -15,7 +15,7 @@ public function test_bench_document_has_exactly_ten_pages(): void { $doc = require __DIR__ . '/../../bench/transcript.php'; self::assertInstanceOf(Document::class, $doc); - $pdf = $doc->render(); + $pdf = $doc->toPdf()->bytes(); PdfInspector::check($pdf); self::assertSame('10', PdfInspector::info($pdf)['Pages']); $text = PdfInspector::text($pdf); diff --git a/packages/core/tests/Feature/ContentParityTest.php b/packages/core/tests/Feature/ContentParityTest.php new file mode 100644 index 0000000..3d5d043 --- /dev/null +++ b/packages/core/tests/Feature/ContentParityTest.php @@ -0,0 +1,165 @@ + */ + public static function documents(): iterable + { + foreach (['fixtures' => __DIR__ . '/../Visual/fixtures', 'examples' => __DIR__ . '/../../../../examples/core/documents'] as $kind => $dir) { + $files = glob("$dir/*.php") ?: []; + sort($files); + foreach ($files as $file) { + yield $kind . '-' . basename($file, '.php') => [$file]; + } + } + } + + protected function setUp(): void + { + exec('command -v pdftotext 2>/dev/null', $out, $code); + if ($code !== 0) { + self::markTestSkipped('pdftotext (poppler) is not installed.'); + } + } + + #[DataProvider('documents')] + public function test_pdf_and_html_have_the_same_word_multisets(string $file): void + { + $source = require $file; + self::assertInstanceOf(Document::class, $source); + $pages = array_map(static fn (Page $p): Page => Page::size(pt($p->width()), pt($p->height()))->margin($p->margins())->body(...$p->bodyNodes()), $source->pages()); + $document = Document::make(...$pages); + + $headerWords = $this->headerWords($document); + self::assertSame( + $this->words($this->pdfText($document), $headerWords), + $this->words($this->htmlText($document), $headerWords), + ); + } + + #[DataProvider('documents')] + public function test_html_export_does_not_change_pdf_bytes(string $file): void + { + $document = require $file; + self::assertInstanceOf(Document::class, $document); + $document = $document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $before = $document->toPdf()->bytes(); + $document->toHtml(); + $document->toHtml(new \Papercut\Export\HtmlOptions(fontMode: \Papercut\Export\FontMode::Embed)); + self::assertSame($before, $document->toPdf()->bytes()); + } + + private function pdfText(Document $document): string + { + $path = tempnam(sys_get_temp_dir(), 'pc') ?: self::fail('tempnam'); + try { + file_put_contents($path, $document->toPdf()->bytes()); + return (string) shell_exec('pdftotext -enc UTF-8 ' . escapeshellarg($path) . ' - 2>/dev/null'); + } finally { + @unlink($path); + } + } + + private function htmlText(Document $document): string + { + $dom = \Dom\HTMLDocument::createFromString($document->toHtml(new \Papercut\Export\HtmlOptions(fullPage: true))->bytes()); + return $dom->body === null ? '' : $this->textOf($dom->body); + } + + /** textContent with a space around every block-level element, as a browser would lay it out (only span and a are inline). */ + private function textOf(\Dom\Node $node): string + { + $text = ''; + foreach ($node->childNodes as $child) { + if ($child instanceof \Dom\Text) { + $text .= $child->data; + } elseif ($child instanceof \Dom\Element) { + $inline = in_array($child->localName, ['span', 'a'], true); + $text .= ($inline ? '' : ' ') . $this->textOf($child) . ($inline ? '' : ' '); + } + } + return $text; + } + + /** @return array every word in every table header row of the document */ + private function headerWords(Document $document): array + { + $collector = new class implements TreeVisitor { + /** @var array */ + public array $words = []; + + public function enterPage(Page $page, int $index): void + { + } + + public function leavePage(Page $page, int $index): void + { + } + + public function enterRegion(Region $region, ComputedStyle $root): void + { + } + + public function leaveRegion(Region $region): void + { + } + + public function enter(Node $node, ComputedStyle $style, Visit $at): bool + { + if ($node instanceof Text && $at->inHeader()) { + foreach ($node->spanList() as $span) { + foreach (preg_split('/\s+/u', trim(str_replace('-', '', $span->text)), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $w) { + $this->words[$w] = true; + } + } + } + + return true; + } + + public function leave(Node $node, ComputedStyle $style, Visit $at): void + { + } + }; + (new TreeWalker(new StyleResolver(Theme::default())))->walk($document, $collector); + + return $collector->words; + } + + /** + * @param array $skip words removed from the count + * @return array word => occurrences, sorted by word + */ + private function words(string $text, array $skip): array + { + // pdftotext dehyphenates a hyphenated word that wraps at the hyphen ("break-after" becomes "breakafter"), which + // the HTML text cannot show, so hyphens are ignored on both sides. + $text = str_replace('-', '', $text); + $words = preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $counts = []; + foreach ($words as $w) { + if (!isset($skip[$w])) { + $counts[$w] = ($counts[$w] ?? 0) + 1; + } + } + ksort($counts, SORT_STRING); + + return $counts; + } +} diff --git a/packages/core/tests/Feature/EndToEndTest.php b/packages/core/tests/Feature/EndToEndTest.php index c259fe4..f706255 100644 --- a/packages/core/tests/Feature/EndToEndTest.php +++ b/packages/core/tests/Feature/EndToEndTest.php @@ -4,10 +4,10 @@ namespace Papercut\Tests\Feature; -use Papercut\{Column, Document, Page, PageNumber, RenderOptions, Row, Spacer, Table, Text}; +use Papercut\{Column, Document, Page, PageNumber, Row, Spacer, Table, Text}; use Papercut\Backend\PhpPdf\PhpPdfBackend; +use Papercut\Export\PdfOptions; use Papercut\Style\Theme; -use Papercut\Text\FontRegistry; use Papercut\Tests\Support\PdfInspector; use PHPUnit\Framework\TestCase; @@ -32,7 +32,7 @@ private function transcript(int $rows): Document public function test_multi_page_transcript(): void { - $pdf = $this->transcript(120)->toString(); + $pdf = $this->transcript(120)->toPdf()->bytes(); PdfInspector::check($pdf); $pages = (int) PdfInspector::info($pdf)['Pages']; self::assertGreaterThanOrEqual(2, $pages); @@ -48,14 +48,14 @@ public function test_multi_page_transcript(): void public function test_deterministic(): void { - self::assertSame($this->transcript(30)->toString(), $this->transcript(30)->toString()); + self::assertSame($this->transcript(30)->toPdf()->bytes(), $this->transcript(30)->toPdf()->bytes()); } public function test_save(): void { $path = sys_get_temp_dir() . '/papercut-e2e-' . getmypid() . '.pdf'; try { - $this->transcript(5)->save($path); + $this->transcript(5)->toPdf()->save($path); self::assertFileExists($path); PdfInspector::check((string) file_get_contents($path)); } finally { @@ -67,48 +67,42 @@ public function test_save_missing_directory_throws(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Directory does not exist'); - $this->transcript(1)->save(sys_get_temp_dir() . '/papercut-no-such-dir-' . getmypid() . '/x.pdf'); + $this->transcript(1)->toPdf()->save(sys_get_temp_dir() . '/papercut-no-such-dir-' . getmypid() . '/x.pdf'); } public function test_stream_writes_output_and_returns_byte_count(): void { $doc = $this->transcript(5); - $expected = $doc->render(); + $expected = $doc->toPdf()->bytes(); ob_start(); - $n = $doc->stream(); + $n = $doc->toPdf()->stream(); $out = (string) ob_get_clean(); self::assertSame(strlen($expected), $n); self::assertSame($expected, $out); } - public function test_render_and_to_string_are_equivalent(): void - { - $doc = $this->transcript(3); - self::assertSame($doc->render(), $doc->toString()); - } - public function test_save_failure_names_the_path_and_the_error(): void { $path = sys_get_temp_dir(); // a directory: the write itself fails try { - $this->transcript(1)->save($path); + $this->transcript(1)->toPdf()->save($path); self::fail('saved'); } catch (\RuntimeException $e) { - self::assertStringStartsWith(sprintf('Could not write PDF to "%s": ', $path), $e->getMessage()); - self::assertGreaterThan(strlen(sprintf('Could not write PDF to "%s": ', $path)), strlen($e->getMessage())); + self::assertStringStartsWith(sprintf('Could not write output to "%s": ', $path), $e->getMessage()); + self::assertGreaterThan(strlen(sprintf('Could not write output to "%s": ', $path)), strlen($e->getMessage())); } } public function test_render_with_custom_options(): void { $doc = Document::make(Page::a5()->body(Text::make('Hello options')))->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); - $default = $doc->render(); - $themed = $doc->render(new RenderOptions(theme: Theme::default()->with(fontSize: 20.0))); + $default = $doc->toPdf()->bytes(); + $themed = $doc->toPdf(new PdfOptions(theme: Theme::default()->with(fontSize: 20.0)))->bytes(); PdfInspector::check($themed); self::assertNotSame($default, $themed); $backend = new PhpPdfBackend(); - $pdf = $doc->render(new RenderOptions(backend: $backend, fonts: FontRegistry::withDefaults($backend))); + $pdf = $doc->toPdf(new PdfOptions(backend: $backend))->bytes(); PdfInspector::check($pdf); self::assertStringContainsString('Hello options', PdfInspector::text($pdf)); } diff --git a/packages/core/tests/Feature/ExamplesTest.php b/packages/core/tests/Feature/ExamplesTest.php index 760aec2..2f5b3d7 100644 --- a/packages/core/tests/Feature/ExamplesTest.php +++ b/packages/core/tests/Feature/ExamplesTest.php @@ -39,7 +39,7 @@ public function test_example_produces_a_valid_pdf(string $name, int $minPages, i public function test_report_example_makes_no_unbacked_claims(): void { - $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/report.php'); + $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/documents/report.php'); foreach (['PAGES PER SECOND', '38 KB', 'Benchmark', 'shrinking', 'every modifier also accepts a string'] as $claim) { self::assertStringNotContainsString($claim, $source); } @@ -47,7 +47,7 @@ public function test_report_example_makes_no_unbacked_claims(): void public function test_transcript_heading_is_a_sibling_of_the_summary_and_courses_are_distinct(): void { - $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/transcript.php'); + $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/documents/transcript.php'); self::assertDoesNotMatchRegularExpression('/\$summary = Column::make\(\s*Text::h3/', $source); self::assertStringContainsString("Text::h3('Cumulative summary')->keepWithNext(), \$summary", $source); $root = dirname(__DIR__, 4); diff --git a/packages/core/tests/Feature/GoldenBytesTest.php b/packages/core/tests/Feature/GoldenBytesTest.php new file mode 100644 index 0000000..ced0f05 --- /dev/null +++ b/packages/core/tests/Feature/GoldenBytesTest.php @@ -0,0 +1,103 @@ + */ + private const array EXAMPLES = ['invoice', 'transcript', 'report']; + + /** @return iterable */ + public static function cases(): iterable + { + $files = glob(__DIR__ . '/../Visual/fixtures/*.php') ?: []; + sort($files); + foreach ($files as $file) { + $name = 'fixture:' . basename($file, '.php'); + yield $name => [$name]; + } + foreach (self::EXAMPLES as $example) { + yield "example:$example" => ["example:$example"]; + } + } + + #[DataProvider('cases')] + public function test_pdf_bytes_match_v01(string $name): void + { + $hash = hash('sha256', $this->bytesFor($name)); + $golden = self::golden(); + if (getenv('UPDATE_GOLDEN') === '1' && !array_key_exists($name, $golden)) { + self::recordGolden($name, $hash); + $golden[$name] = $hash; + } + self::assertArrayHasKey($name, $golden, "No golden hash for $name; this phase must not add cases"); + self::assertSame($golden[$name], $hash, "PDF bytes for $name changed; Phase 0 must keep PDF output byte-identical"); + } + + private function bytesFor(string $name): string + { + [$kind, $id] = explode(':', $name, 2); + if ($kind === 'fixture') { + $document = require __DIR__ . "/../Visual/fixtures/$id.php"; + self::assertInstanceOf(Document::class, $document); + + return $this->pdfBytes($document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z'))); + } + + $root = dirname(__DIR__, 4); + $pdfPath = "$root/examples/core/$id.pdf"; + @unlink($pdfPath); + exec(sprintf('cd %s && %s -d xdebug.mode=off examples/core/%s.php 2>&1', escapeshellarg($root), escapeshellarg(PHP_BINARY), $id), $output, $code); + self::assertSame(0, $code, implode("\n", $output)); + self::assertFileExists($pdfPath); + + return (string) file_get_contents($pdfPath); + } + + private function pdfBytes(Document $d): string + { + return $d->toPdf()->bytes(); + } + + public function test_golden_keys_match_the_cases_exactly(): void + { + $names = array_keys(iterator_to_array(self::cases())); + sort($names); + $keys = array_keys(self::golden()); + sort($keys); + self::assertSame($names, $keys, 'Golden file keys must equal the test cases exactly'); + } + + /** @return array */ + private static function golden(): array + { + if (!is_file(self::GOLDEN)) { + return []; + } + /** @var array $golden */ + $golden = json_decode((string) file_get_contents(self::GOLDEN), true, flags: JSON_THROW_ON_ERROR); + + return $golden; + } + + private static function recordGolden(string $name, string $hash): void + { + $golden = self::golden(); + $golden[$name] = $hash; + ksort($golden); + file_put_contents(self::GOLDEN, json_encode($golden, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); + } +} diff --git a/packages/core/tests/Feature/ReadmeTest.php b/packages/core/tests/Feature/ReadmeTest.php index 56d5f53..5f1a8ca 100644 --- a/packages/core/tests/Feature/ReadmeTest.php +++ b/packages/core/tests/Feature/ReadmeTest.php @@ -12,7 +12,7 @@ * Executes every fenced ```php block of packages/core/README.md so the guide cannot rot. * Convention: a block is a complete snippet (it may start with `use` lines) that leaves a * Papercut\Document in `$document` - * (and optionally a RenderOptions in `$options`); the harness saves it to a temp file and validates it. + * (and optionally a PdfOptions in `$options`); the harness saves it to a temp file and validates it. */ final class ReadmeTest extends TestCase { @@ -44,7 +44,7 @@ public function test_block_runs_and_renders_a_valid_pdf(string $code): void $pdfPath = $dir . '/out.pdf'; $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; file_put_contents($script, "save(" . var_export($pdfPath, true) . ", \$options ?? null);\n"); + . $code . "\n\$document->toPdf(\$options ?? null)->save(" . var_export($pdfPath, true) . ");\n"); $output = []; $exit = -1; diff --git a/packages/core/tests/Feature/golden/pdf-v0.1.json b/packages/core/tests/Feature/golden/pdf-v0.1.json new file mode 100644 index 0000000..4fb9015 --- /dev/null +++ b/packages/core/tests/Feature/golden/pdf-v0.1.json @@ -0,0 +1,12 @@ +{ + "example:invoice": "097360ed58c9143782e06fb9ef20071069708226d6d105e5bb3e567e9cdb463d", + "example:report": "be2d5b4d430e2b079516cc3200af17701b4d5bf040519545d459635f98ada5da", + "example:transcript": "17ed4133e66b9239bffd437ca1ef2590d4d9a65400b31f1e281fb84f869515ee", + "fixture:borders-radius-background": "4b418b3d1cbeb75c954cb6ebf93f622d934c5495b9c23838774aaee335ddd6c0", + "fixture:flex-justify": "672d9a9b6a934f240a98876469fd6063326d0c29e22349e7c16cc3c8feca64b3", + "fixture:image-fit": "e4e5ad313f14a52ba47abad9e3dd6ef97b1ad185914f6e115e959c8f6aec4dfe", + "fixture:keep-together": "c2b936f89e59fb693c28f03d47b0c3734fb8a1a52e41c6a7d9a2ec8e4153685d", + "fixture:page-numbers": "fac616762eddc0048ce05b75b19d9b2839c0b88732ce2f716da6937073539b3b", + "fixture:table-split-repeat-header": "55940d14957bb9f864cde294635e829bcd526dd101e582d7b5240a4824bf34b6", + "fixture:text-align-justify": "8efed3b69a1a144c235ca2ef7a2a4fc0e44becd3be9c50cdb4d8fcf0087ea6d3" +} diff --git a/packages/core/tests/Html/CssTest.php b/packages/core/tests/Html/CssTest.php new file mode 100644 index 0000000..cf03944 --- /dev/null +++ b/packages/core/tests/Html/CssTest.php @@ -0,0 +1,172 @@ +root())->child($style); + } + + public function test_num_formatting(): void + { + self::assertSame('12', Css::num(12.0)); + self::assertSame('0.333', Css::num(0.3333)); + self::assertSame('0', Css::num(-0.0)); + self::assertSame('0', Css::num(-0.0001)); + self::assertSame('1.5', Css::num(1.5)); + self::assertSame('12pt', Css::pt(12.0)); + } + + public function test_length(): void + { + self::assertSame('10pt', Css::length(Length::parse('10pt'))); + self::assertSame('50%', Css::length(Percent::of(50.0))); + self::assertNull(Css::length(Auto::instance())); + } + + public function test_color(): void + { + self::assertSame('#0a1b2c', Css::color(Color::rgb(10, 27, 44))); + self::assertSame('rgba(17, 34, 51, 0.502)', Css::color(Color::hex('#11223380'))); + self::assertSame('rgba(0, 0, 0, 0)', Css::color(Color::transparent())); + } + + public function test_string_escapes_quotes_backslashes_and_control_chars(): void + { + self::assertSame('"Inter"', Css::string('Inter')); + $evil = Css::string('Evil"; background:url(x)'); + self::assertSame('"Evil\\"; background:url(x)"', $evil); + self::assertSame('"a\\\\b"', Css::string('a\\b')); + self::assertSame('"a\\a b"', Css::string("a\nb")); + self::assertSame('"a\\d b"', Css::string("a\rb")); + self::assertSame('"\\0 "', Css::string("\0")); + self::assertSame('"\\3c /style>"', Css::string('')); + self::assertSame('"Zürich"', Css::string('Zürich')); + self::assertSame('"Evil\\"; background:url(x)", system-ui, sans-serif', Css::fontFamily('Evil"; background:url(x)')); + } + + public function test_box_omits_defaults_and_emits_borders_only_with_width(): void + { + self::assertSame([], Css::box($this->child(new Style()))); + + $s = $this->child(new Style( + width: Length::parse('100pt'), + maxWidth: Percent::of(80.0), + padding: Edges::of(Length::parse('1pt'), Length::parse('2pt')), + margin: Edges::all(Length::parse('3pt')), + radius: Length::parse('4pt'), + background: Color::hex('#ff0000'), + opacity: 0.5, + )); + self::assertSame([ + 'width' => '100pt', + 'max-width' => '80%', + 'padding' => '1pt 2pt 1pt 2pt', + 'margin' => '3pt 3pt 3pt 3pt', + 'border-radius' => '4pt', + 'background' => '#ff0000', + 'opacity' => '0.5', + ], Css::box($s)); + + $b = $this->child(new Style(borderWidth: Edges::of(Length::parse('1pt'), Length::zero()), borderColor: Color::hex('#00ff00'), borderStyle: BorderStyle::Dashed)); + $box = Css::box($b); + self::assertSame('1pt 0pt 1pt 0pt', $box['border-width']); + self::assertSame('dashed', $box['border-style']); + self::assertSame('#00ff00', $box['border-color']); + + $noWidth = Css::box($this->child(new Style(borderColor: Color::hex('#00ff00'), borderStyle: BorderStyle::Dashed))); + self::assertSame([], $noWidth); + } + + public function test_text_diffs_against_parent(): void + { + $parent = $this->root(); + $all = Css::text($parent, null); + foreach (['font-family', 'font-size', 'font-weight', 'font-style', 'color', 'line-height', 'letter-spacing', 'text-align', 'text-decoration'] as $k) { + self::assertArrayHasKey($k, $all); + } + self::assertSame('normal', $all['font-style']); + self::assertSame('none', $all['text-decoration']); + + $child = $this->child(new Style(color: Color::hex('#ff0000')), $parent); + self::assertSame(['color' => '#ff0000'], Css::text($child, $parent)); + + $rich = $this->child(new Style(italic: true, underline: true, strike: true, textAlign: TextAlign::Center, letterSpacing: Length::parse('1pt'), fontWeight: 700), $parent); + $d = Css::text($rich, $parent); + self::assertSame('italic', $d['font-style']); + self::assertSame('underline line-through', $d['text-decoration']); + self::assertSame('center', $d['text-align']); + self::assertSame('1pt', $d['letter-spacing']); + self::assertSame('700', $d['font-weight']); + self::assertSame(['text-decoration' => 'underline'], Css::text($this->child(new Style(underline: true), $parent), $parent)); + self::assertSame(['text-decoration' => 'line-through'], Css::text($this->child(new Style(strike: true), $parent), $parent)); + } + + public function test_flex_maps_enums(): void + { + self::assertSame(['display' => 'flex', 'flex-direction' => 'column'], Css::flexContainer($this->child(new Style()))); + + $s = $this->child(new Style(direction: Direction::Row, gap: Length::parse('6pt'), justify: Justify::SpaceBetween, alignItems: Align::Center, wrap: true)); + self::assertSame([ + 'display' => 'flex', + 'flex-direction' => 'row', + 'gap' => '6pt', + 'justify-content' => 'space-between', + 'align-items' => 'center', + 'flex-wrap' => 'wrap', + ], Css::flexContainer($s)); + + $map = [Justify::Start->value => 'flex-start', Justify::End->value => 'flex-end', Justify::Center->value => 'center', Justify::SpaceBetween->value => 'space-between', Justify::SpaceAround->value => 'space-around', Justify::SpaceEvenly->value => 'space-evenly']; + foreach (Justify::cases() as $j) { + if ($j === Justify::Start) { + continue; + } + $d = Css::flexContainer($this->child(new Style(justify: $j))); + self::assertSame($map[$j->value], $d['justify-content']); + } + self::assertSame('flex-start', Css::flexContainer($this->child(new Style(alignItems: Align::Start)))['align-items']); + self::assertSame('flex-end', Css::flexContainer($this->child(new Style(alignItems: Align::End)))['align-items']); + + self::assertSame([], Css::flexItem($this->child(new Style()))); + self::assertSame(['flex' => '1 1 auto'], Css::flexItem($this->child(new Style(grow: 1.0)))); + self::assertSame(['flex' => '0 0 30%', 'align-self' => 'flex-end'], Css::flexItem($this->child(new Style(shrink: 0.0, basis: Percent::of(30.0), alignSelf: Align::End)))); + self::assertSame(['align-self' => 'stretch'], Css::flexItem($this->child(new Style(alignSelf: Align::Stretch)))); + } + + public function test_pagination_maps_keep_and_breaks(): void + { + self::assertSame([], Css::pagination($this->child(new Style()))); + $s = $this->child(new Style(breakBefore: true, breakAfter: true, keepTogether: true, minLines: 5)); + self::assertSame([ + 'break-before' => 'page', + 'break-after' => 'page', + 'break-inside' => 'avoid', + 'orphans' => '5', + 'widows' => '5', + ], Css::pagination($s)); + + $parent = $this->child(new Style(minLines: 5)); + self::assertSame([], Css::pagination($this->child(new Style(), $parent), $parent)); + self::assertSame(['orphans' => '1', 'widows' => '1'], Css::pagination($this->child(new Style(minLines: 1), $parent), $parent)); + } + + public function test_attr(): void + { + self::assertSame('', Css::attr([])); + self::assertSame('a:1;b:2', Css::attr(['a' => '1', 'b' => '2'])); + } +} diff --git a/packages/core/tests/Html/FontRequestsTest.php b/packages/core/tests/Html/FontRequestsTest.php new file mode 100644 index 0000000..c59c6f9 --- /dev/null +++ b/packages/core/tests/Html/FontRequestsTest.php @@ -0,0 +1,133 @@ +theme())))->walk($doc, $writer); + + return $writer; + } + + /** @return list */ + private function collect(Document $doc, FontSet $fonts = new FontSet()): array + { + $writer = $this->walk($doc, new HtmlOptions(fonts: $fonts)); + + return FontRequests::collect($writer->fontUsage(), 'Inter', $fonts); + } + + /** + * @param list $faces + * @return list + */ + private static function names(array $faces): array + { + return array_map(static fn (FontVariant $f): string => $f->weight . ' ' . $f->style->value, $faces); + } + + public function test_registered_family_uses_registered_faces(): void + { + $requests = $this->collect(Document::make(Page::a4()->body(Text::make('a')))); + + self::assertCount(1, $requests); + self::assertSame('Inter', $requests[0]['request']->family); + self::assertTrue($requests[0]['request']->registered); + self::assertSame(['400 normal', '700 normal', '400 italic', '700 italic'], self::names($requests[0]['request']->faces)); + } + + public function test_unregistered_family_uses_document_usage(): void + { + $doc = Document::make(Page::a4()->body( + Text::make('a')->font('Roboto'), + Text::spans(Span::text('plain '), Span::text('loud', new Style(fontWeight: 700, italic: true)))->font('Roboto'), + )); + $roboto = array_values(array_filter($this->collect($doc), static fn (array $r): bool => $r['request']->family === 'Roboto')); + + self::assertCount(1, $roboto); + self::assertFalse($roboto[0]['request']->registered); + self::assertSame(['400 normal', '700 italic'], self::names($roboto[0]['request']->faces)); + } + + public function test_family_names_dedupe_case_insensitively(): void + { + $doc = Document::make(Page::a4()->body(Text::make('a')->font('roboto'), Text::make('b')->font('Roboto'))); + $requests = $this->collect($doc); + + self::assertSame(['Inter', 'roboto'], array_map(static fn (array $r): string => $r['request']->family, $requests)); + self::assertSame(['inter', 'roboto'], array_keys($this->walk($doc)->fontUsage())); + } + + public function test_fallback_without_usage_gets_the_union(): void + { + $doc = Document::make(Page::a4()->body(Text::make('a')->font('Roboto'), Text::spans(Span::text('b', new Style(fontWeight: 700)))->font('Roboto'))); + $requests = $this->collect($doc, (new FontSet())->withFallbacks('Noto Sans')); + $noto = array_values(array_filter($requests, static fn (array $r): bool => $r['request']->family === 'Noto Sans')); + + self::assertCount(1, $noto); + self::assertSame(['400 normal', '700 normal'], self::names($noto[0]['request']->faces)); + self::assertSame('document', $noto[0]['path']); + self::assertFalse($noto[0]['request']->registered); + } + + public function test_empty_usage_falls_back_to_regular(): void + { + $requests = FontRequests::collect([], 'Acme', (new FontSet())->withFallbacks('Noto Sans')); + + self::assertSame(['Acme', 'Noto Sans'], array_map(static fn (array $r): string => $r['request']->family, $requests)); + self::assertSame(['400 normal'], self::names($requests[0]['request']->faces)); + self::assertSame(['400 normal'], self::names($requests[1]['request']->faces)); + } + + public function test_registered_custom_family_in_fallbacks(): void + { + $face = new FontFace('Fall', __DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf', 300); + $fonts = (new FontSet([$face]))->withFallbacks('fall'); + $requests = $this->collect(Document::make(Page::a4()->body(Text::make('a'))), $fonts); + $fall = array_values(array_filter($requests, static fn (array $r): bool => strtolower($r['request']->family) === 'fall')); + + self::assertCount(1, $fall); + self::assertTrue($fall[0]['request']->registered); + self::assertSame(['300 normal'], self::names($fall[0]['request']->faces)); + } + + public function test_first_node_path_is_reported(): void + { + $doc = Document::make(Page::a4()->body(Column::make(Text::make('a'), Text::make('b')->font('Roboto')))); + $writer = $this->walk($doc); + $usage = $writer->fontUsage(); + + self::assertNull($usage['inter']['path']); + self::assertSame('page > body > column[0] > text[1]', $usage['roboto']['path']); + + $requests = FontRequests::collect($usage, 'Inter', new FontSet()); + self::assertSame(['document', 'page > body > column[0] > text[1]'], array_map(static fn (array $r): string => $r['path'], $requests)); + } + + public function test_used_families_unchanged(): void + { + foreach (['invoice', 'report', 'transcript'] as $name) { + $doc = require __DIR__ . "/../../../../examples/core/documents/$name.php"; + self::assertSame(['Inter'], $this->walk($doc)->usedFamilies(), $name); + } + + $mixed = Document::make(Page::a4()->body(Text::make('a')->font('Roboto'), Text::make('b')->font('roboto'), Text::make('c'))); + // Case-sensitive on purpose: FontFaces deduplicates, and the output is pinned. + self::assertSame(['Inter', 'Roboto', 'roboto'], $this->walk($mixed)->usedFamilies()); + } +} diff --git a/packages/core/tests/Html/FontSourcesNetworkTest.php b/packages/core/tests/Html/FontSourcesNetworkTest.php new file mode 100644 index 0000000..5ecbf05 --- /dev/null +++ b/packages/core/tests/Html/FontSourcesNetworkTest.php @@ -0,0 +1,45 @@ + */ + public static function cases(): iterable + { + foreach (['invoice', 'report', 'transcript'] as $example) { + foreach (['https://fonts.googleapis.com', 'https://fonts.bunny.net'] as $host) { + yield "$example @ $host" => [$example, $host]; + } + } + } + + #[DataProvider('cases')] + public function test_stylesheets_answer_200(string $example, string $host): void + { + if (getenv('PAPERCUT_NETWORK_TESTS') !== '1') { + self::markTestSkipped('Set PAPERCUT_NETWORK_TESTS=1 to run the live font host check.'); + } + $document = (static fn () => require __DIR__ . "/../../../../examples/core/documents/$example.php")(); + self::assertInstanceOf(Document::class, $document); + $html = $document->toHtml(new HtmlOptions(fullPage: true, fontMode: FontMode::Sources, fontSources: [new GoogleFonts(host: $host)]))->bytes(); + + self::assertGreaterThan(0, preg_match_all('/ ['ignore_errors' => true, 'timeout' => 15, 'header' => "User-Agent: Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/120 Safari/537.36\r\n"]]); + foreach ($m[1] as $href) { + $url = html_entity_decode($href); + @file_get_contents($url, false, $context); + $status = $http_response_header[0] ?? '(no response)'; + self::assertStringContainsString(' 200', $status, $url); + } + } +} diff --git a/packages/core/tests/Html/GoldenFontSourcesTest.php b/packages/core/tests/Html/GoldenFontSourcesTest.php new file mode 100644 index 0000000..21cb2c6 --- /dev/null +++ b/packages/core/tests/Html/GoldenFontSourcesTest.php @@ -0,0 +1,65 @@ +}> */ + public static function cases(): iterable + { + yield 'example-invoice-google' => ['example-invoice-google', 'invoice', true, [new GoogleFonts()]]; + yield 'example-invoice-fragment-google' => ['example-invoice-fragment-google', 'invoice', false, [new GoogleFonts()]]; + yield 'example-report-mixed' => ['example-report-mixed', 'report', true, [ + LocalFonts::link(static fn (FontFace $f): string => 'fonts/' . basename($f->path), ['Inter']), + new GoogleFonts(host: 'https://fonts.bunny.net'), + ]]; + yield 'mixed-fragment' => ['mixed-fragment', 'inline-mixed', false, [ + LocalFonts::link(static fn (FontFace $f): string => 'fonts/' . basename($f->path), ['Inter']), + new GoogleFonts(host: 'https://fonts.bunny.net'), + ]]; + } + + /** @param list $sources */ + #[DataProvider('cases')] + public function test_matches_golden(string $name, string $example, bool $full, array $sources): void + { + $document = $example === 'inline-mixed' + ? Document::make(Page::a4()->body(Text::make('Hello Inter'), Text::make('Hello Roboto')->font('Roboto'))) + : (static fn () => require __DIR__ . "/../../../../examples/core/documents/$example.php")(); + self::assertInstanceOf(Document::class, $document); + $document = $document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $html = $document->toHtml(new HtmlOptions(fullPage: $full, fontMode: FontMode::Sources, fontSources: $sources))->bytes(); + + // A fragment has no document around it; give the parser one so it does not report a missing doctype. + $canonical = $this->canonicalize($full ? $html : 'fragment' . $html . ''); + $golden = __DIR__ . "/golden/sources/$name.html"; + if (getenv('UPDATE_HTML_GOLDEN') === '1') { + @mkdir(dirname($golden), 0777, true); + file_put_contents($golden, $canonical); + } + self::assertFileExists($golden); + self::assertSame(file_get_contents($golden), $canonical); + } + + private function canonicalize(string $html): string + { + set_error_handler(static function (int $no, string $msg): never { + throw new \RuntimeException("HTML parse warning: $msg"); + }); + try { + return \Dom\HTMLDocument::createFromString($html)->saveHtml(); + } finally { + restore_error_handler(); + } + } +} diff --git a/packages/core/tests/Html/GoldenHtmlTest.php b/packages/core/tests/Html/GoldenHtmlTest.php new file mode 100644 index 0000000..22c7f27 --- /dev/null +++ b/packages/core/tests/Html/GoldenHtmlTest.php @@ -0,0 +1,59 @@ + */ + public static function documents(): iterable + { + foreach (['Visual/fixtures' => 'fixtures', '../../../examples/core/documents' => 'examples'] as $dir => $kind) { + $files = glob(__DIR__ . "/../$dir/*.php") ?: []; + sort($files); + foreach ($files as $file) { + yield $kind . '-' . basename($file, '.php') => [$file, $kind]; + } + } + } + + #[DataProvider('documents')] + public function test_matches_golden(string $file, string $kind): void + { + $document = require $file; + self::assertInstanceOf(Document::class, $document); + $document = $document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $html = $document->toHtml(new HtmlOptions(fullPage: true))->bytes(); + + $canonical = $this->canonicalize($html); + $name = ($kind === 'examples' ? 'example-' : '') . basename($file, '.php'); + $golden = __DIR__ . "/golden/$name.html"; + if (getenv('UPDATE_HTML_GOLDEN') === '1') { + file_put_contents($golden, $canonical); + } + self::assertFileExists($golden); + self::assertSame(file_get_contents($golden), $canonical); + } + + private function canonicalize(string $html): string + { + set_error_handler(static function (int $no, string $msg): never { + throw new \RuntimeException("HTML parse warning: $msg"); + }); + try { + return \Dom\HTMLDocument::createFromString($html)->saveHtml(); + } finally { + restore_error_handler(); + } + } +} diff --git a/packages/core/tests/Html/GoogleFontsTest.php b/packages/core/tests/Html/GoogleFontsTest.php new file mode 100644 index 0000000..d7f4d87 --- /dev/null +++ b/packages/core/tests/Html/GoogleFontsTest.php @@ -0,0 +1,134 @@ + $faces [ital, weight] pairs */ + private function request(string $family, array $faces): FontRequest + { + $variants = array_map( + static fn (array $f): FontVariant => new FontVariant($f[1], $f[0] === 1 ? FontStyle::Italic : FontStyle::Normal), + $faces, + ); + + return new FontRequest($family, $variants, true, new FontSet()); + } + + private function url(GoogleFonts $source, FontRequest $request): ?string + { + return $source($request)?->url(); + } + + public function test_regular_only_has_no_axes(): void + { + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Inter&display=swap', + $this->url(new GoogleFonts(), $this->request('Inter', [[0, 400]])), + ); + } + + public function test_weights_only(): void + { + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap', + $this->url(new GoogleFonts(), $this->request('Inter', [[0, 700], [0, 400]])), + ); + } + + public function test_italics_are_sorted_tuples(): void + { + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,700;1,400;1,700&display=swap', + $this->url(new GoogleFonts(), $this->request('Inter', [[1, 400], [0, 700], [0, 400], [1, 700]])), + ); + } + + public function test_italic_only(): void + { + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Inter:ital,wght@1,400&display=swap', + $this->url(new GoogleFonts(), $this->request('Inter', [[1, 400]])), + ); + } + + public function test_family_name_encoding(): void + { + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap', + $this->url(new GoogleFonts(), $this->request('Noto Sans JP', [[0, 400]])), + ); + $this->assertSame( + 'https://fonts.googleapis.com/css2?family=Cr%C3%A8me+%26+Co&display=swap', + $this->url(new GoogleFonts(), $this->request('Crème & Co', [[0, 400]])), + ); + } + + public function test_host_and_display(): void + { + $this->assertSame( + 'https://fonts.bunny.net/css2?family=Inter&display=optional', + $this->url(new GoogleFonts('https://fonts.bunny.net/', 'optional'), $this->request('Inter', [[0, 400]])), + ); + } + + public function test_allowlist_is_case_insensitive(): void + { + $source = new GoogleFonts(families: ['inter']); + + $this->assertNotNull($source($this->request('Inter', [[0, 400]]))); + $this->assertNull($source($this->request('Roboto', [[0, 400]]))); + } + + public function test_invalid_host_and_display_throw(): void + { + foreach (['ftp://x', 'https://x/?a=1', 'https://x/#f', 'fonts.googleapis.com', 'https://fonts.bunny.net ', "https://x\n", 'https://x/a b', 'https://x/"', "https://x/'", 'https://x/<', 'https://x/>', 'https://x/\\a', ' https://x', ''] as $host) { + try { + new GoogleFonts($host); + $this->fail("Host accepted: $host"); + } catch (\InvalidArgumentException $e) { + $this->assertSame("GoogleFonts host must be an http or https URL with no query or fragment: '$host'", $e->getMessage()); + } + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("GoogleFonts display must be one of auto, block, swap, fallback, optional: 'fast'"); + new GoogleFonts(display: 'fast'); + } + + public function test_font_request_sorts_dedupes_and_rejects_empty(): void + { + $request = new FontRequest('Inter', [ + new FontVariant(400, FontStyle::Italic), + new FontVariant(700, FontStyle::Normal), + new FontVariant(400, FontStyle::Normal), + new FontVariant(700, FontStyle::Normal), + ], false, new FontSet()); + + $this->assertSame( + [[400, 'normal'], [700, 'normal'], [400, 'italic']], + array_map(static fn (FontVariant $v): array => [$v->weight, $v->style->value], $request->faces), + ); + + $this->expectException(\InvalidArgumentException::class); + new FontRequest('Inter', [], false, new FontSet()); + } + + public function test_font_answer_kinds(): void + { + $sheet = FontAnswer::stylesheet('https://x/css'); + $this->assertSame('https://x/css', $sheet->url()); + $this->assertSame([], $sheet->faceList()); + + $face = new FontFaceSrc(400, FontStyle::Normal, 'https://x/a.woff2', 'woff2'); + $faces = FontAnswer::faces($face); + $this->assertNull($faces->url()); + $this->assertSame([$face], $faces->faceList()); + } +} diff --git a/packages/core/tests/Html/HtmlExporterTest.php b/packages/core/tests/Html/HtmlExporterTest.php new file mode 100644 index 0000000..adb4d4b --- /dev/null +++ b/packages/core/tests/Html/HtmlExporterTest.php @@ -0,0 +1,332 @@ +toHtml($o)->bytes(); + } + + private function parse(string $html): \Dom\HTMLDocument + { + if (!str_starts_with($html, 't' . $html . ''; + } + set_error_handler(static function (int $no, string $msg): never { + throw new \RuntimeException($msg); + }); + try { + return \Dom\HTMLDocument::createFromString($html); + } finally { + restore_error_handler(); + } + } + + /** @return list<\Dom\Node> */ + private function xpath(string $html, string $query): array + { + $nodes = []; + $xp = new \Dom\XPath($this->parse($html)); + $xp->registerNamespace('h', 'http://www.w3.org/1999/xhtml'); + $query = (string) preg_replace('~(?<=/)([a-z][a-z0-9]*)(?![a-z0-9(\-])~', 'h:$1', $query); + foreach ($xp->query($query) as $n) { + $nodes[] = $n; + } + return $nodes; + } + + private function one(string $html, string $query): \Dom\Element + { + $found = $this->xpath($html, $query); + self::assertCount(1, $found, $query); + self::assertInstanceOf(\Dom\Element::class, $found[0]); + return $found[0]; + } + + private function attr(\Dom\Node $e, string $name): string + { + self::assertInstanceOf(\Dom\Element::class, $e); + + return (string) $e->getAttribute($name); + } + + private function simple(): Document + { + return Document::make(Page::a4()->body(Text::make('Hello'))); + } + + public function test_fragment_and_full_page_shapes(): void + { + $doc = $this->simple()->title('T')->lang('fr'); + $out = $doc->toHtml(); + self::assertSame('text/html; charset=UTF-8', $out->mime()); + self::assertSame('html', $out->extension()); + self::assertStringStartsWith('
', $full); + self::assertSame('T', $this->one($full, '//head/title')->textContent); + self::assertCount(1, $this->xpath($full, '//head/style')); + self::assertCount(1, $this->xpath($full, '//body/div[@class="papercut"]/section[@class="pc-page"]')); + self::assertStringContainsString('', $this->html($this->simple(), new HtmlOptions(fullPage: true))); + } + + public function test_row_is_flex_row_with_gap_and_justify(): void + { + $doc = Document::make(Page::a4()->body(Row::make(Text::make('a'), Text::make('b'))->gap(pt(8))->justify(Justify::SpaceBetween))); + $row = $this->one($this->html($doc), '//div[@class="pc-body"]/div'); + $style = $this->attr($row, 'style'); + self::assertStringContainsString('display:flex', $style); + self::assertStringContainsString('flex-direction:row', $style); + self::assertStringContainsString('gap:8pt', $style); + self::assertStringContainsString('justify-content:space-between', $style); + } + + public function test_text_presets_map_to_headings_and_paragraphs(): void + { + $doc = Document::make(Page::a4()->body(Text::h1('A'), Text::h2('B'), Text::h3('C'), Text::h4('D'), Text::p('E'), Text::make('F'))); + $html = $this->html($doc); + foreach (['h1', 'h2', 'h3', 'h4'] as $tag) { + self::assertCount(1, $this->xpath($html, "//div[@class='pc-body']/$tag")); + } + self::assertCount(2, $this->xpath($html, "//div[@class='pc-body']/p")); + } + + public function test_spans_emit_diffs_links_and_breaks(): void + { + $doc = Document::make(Page::a4()->body(Text::spans( + Span::text('plain '), + Span::text('em', new Style(italic: true)), + Span::break(), + new Span('go', new Style(), 'https://example.com/?a=1&b=2'), + ))); + $html = $this->html($doc); + $p = $this->one($html, '//div[@class="pc-body"]/p'); + self::assertSame('plain emgo', $p->textContent); + self::assertStringContainsString('font-style:italic', $this->attr($this->one($html, '//p/span'), 'style')); + self::assertCount(1, $this->xpath($html, '//p/br')); + self::assertSame('https://example.com/?a=1&b=2', $this->attr($this->one($html, '//p/a'), 'href')); + self::assertCount(1, $this->xpath($html, '//p/text()[starts-with(., "plain")]')); + } + + public function test_block_link_and_spacers_and_flex_item(): void + { + $doc = Document::make(Page::a4()->body(Column::make( + Link::to('#top', Text::make('x')), + Spacer::size(pt(12)), + Spacer::flex(2), + Text::make('g')->grow(1), + ))); + $html = $this->html($doc); + $a = $this->one($html, '//a[@class="pc-link"]'); + self::assertSame('#top', $this->attr($a, 'href')); + self::assertStringStartsWith('display:flex;flex-direction:column', $this->attr($a, 'style')); + $spacers = $this->xpath($html, '//div[@class="pc-body"]/div/div'); + self::assertCount(2, $spacers); + self::assertSame('flex:0 0 12pt', $this->attr($spacers[0], 'style')); + self::assertSame('flex:2 0 0', $this->attr($spacers[1], 'style')); + self::assertStringContainsString('flex:1 1 auto', $this->attr($this->one($html, '//div[@class="pc-body"]/div/p'), 'style')); + } + + public function test_page_number_is_omitted_silently(): void + { + $reports = []; + $doc = Document::make(Page::a4()->body(Text::make('x'))->footer(PageNumber::make('Page {page}'))); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$reports): void { + $reports[] = $m; + })); + self::assertCount(0, $this->xpath($html, '//div[@class="pc-footer"]/*')); + self::assertSame('', $this->one($html, '//div[@class="pc-footer"]')->textContent); + self::assertStringNotContainsString('Page 1', (string) $this->parse($html)->body?->textContent); + foreach ($reports as $m) { + self::assertStringNotContainsString('page-number', $m); + } + } + + public function test_page_break_and_keep_rules_become_print_css(): void + { + $doc = Document::make(Page::a4()->body( + Text::make('a')->keepTogether()->breakBefore()->breakAfter()->minLines(3), + PageBreak::make(), + Text::make('b'), + )); + $html = $this->html($doc); + $style = $this->attr($this->one($html, '//div[@class="pc-body"]/p[1]'), 'style'); + foreach (['break-before:page', 'break-after:page', 'break-inside:avoid', 'orphans:3', 'widows:3'] as $d) { + self::assertStringContainsString($d, $style); + } + self::assertCount(1, $this->xpath($html, '//div[@class="pc-body"]/div[@class="pc-break"]')); + } + + public function test_header_footer_background_regions_and_reports(): void + { + $messages = []; + $doc = Document::make(Page::a4()->header(Text::make('H'))->footer(Text::make('F'))->background(Text::make('B'))->body(Text::make('x'))); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m, ?FidelityReport $r = null) use (&$messages): void { + $messages[] = $m; + })); + self::assertSame([ + 'html: approximated page box at page', + 'html: approximated header and footer once per page at page', + 'html: approximated background layer at page', + ], $messages); + $regions = array_map(static fn (\Dom\Node $n): string => $n instanceof \Dom\Element ? $n->getAttribute('class') ?? '' : '', $this->xpath($html, '//section/div')); + self::assertSame(['pc-background', 'pc-header', 'pc-body', 'pc-footer'], $regions); + self::assertStringContainsString('margin-bottom:', $this->attr($this->one($html, '//div[@class="pc-header"]'), 'style')); + self::assertStringContainsString('margin-top:', $this->attr($this->one($html, '//div[@class="pc-footer"]'), 'style')); + } + + public function test_page_paths_are_indexed_for_several_pages(): void + { + $messages = []; + $doc = Document::make(Page::a4()->body(Text::make('a')), Page::a4()->footer(Text::make('f'))->body(Text::make('b'))); + $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$messages): void { + $messages[] = $m; + })); + self::assertSame([ + 'html: approximated page box at page[0]', + 'html: approximated page box at page[1]', + 'html: approximated header and footer once per page at page[1]', + ], $messages); + } + + public function test_underline_override_is_approximated(): void + { + $messages = []; + $doc = Document::make(Page::a4()->body( + Column::make(Text::make('a'), Text::spans(Span::text('x', new Style(underline: false)), Span::text('y', new Style(underline: false))))->underline(), + )); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$messages): void { + $messages[] = $m; + })); + self::assertContains('html: approximated text decoration override at page > body > column[0] > text[1]', $messages); + self::assertCount(1, array_filter($messages, static fn (string $m): bool => str_contains($m, 'decoration'))); + self::assertCount(2, $this->xpath($html, '//span[contains(@style, "text-decoration:none")]')); + } + + public function test_strict_mode_never_throws_for_html(): void + { + $doc = Document::make(Page::a4()->header(Text::make('H'))->background(Text::make('B'))->body(Text::make('x'))->footer(PageNumber::make())); + self::assertNotSame('', $this->html($doc, new HtmlOptions(strict: true))); + } + + public function test_hostile_text_is_escaped(): void + { + $evil = '"\'&'; + $doc = Document::make(Page::a4()->body(Text::make($evil)))->title($evil)->author($evil)->subject($evil)->keywords($evil)->lang('en">', Text::make('t')))); + $html = $this->html($doc); + self::assertStringNotContainsString('', $this->attr($this->one($html, '//a'), 'href')); + } + + public function test_disallowed_href_throws_like_pdf(): void + { + foreach ([ + Text::spans(new Span('x', href: 'javascript:alert(1)')), + Link::to('javascript:alert(1)', Text::make('x')), + ] as $node) { + $doc = Document::make(Page::a4()->body($node)); + try { + $doc->toPdf(); + self::fail('expected InvalidValue'); + } catch (InvalidValue $pdfError) { + $pdf = $pdfError->getMessage(); + } + try { + $doc->toHtml(); + self::fail('expected InvalidValue'); + } catch (InvalidValue $htmlError) { + self::assertSame($pdf, $htmlError->getMessage()); + } + } + } + + public function test_hostile_font_family_stays_inert(): void + { + $doc = Document::make(Page::a4()->body(Text::make('x')->font('Evil"; background:url(x)'))); + $html = $this->html($doc); + self::assertCount(0, $this->xpath($html, '//b')); + self::assertCount(1, $this->xpath($html, '//style')); + $p = $this->one($html, '//div[@class="pc-body"]/p'); + self::assertStringNotContainsString('', $this->attr($p, 'style')); + self::assertCount(1, $this->xpath($html, '//div[@class="pc-body"]/p[@style]')); + self::assertSame('x', $p->textContent); + } + + public function test_fragment_root_has_no_lang_without_document_lang(): void + { + self::assertNull($this->one($this->html($this->simple()), '//div[@class="papercut"]')->getAttribute('lang')); + } + + public function test_entities_are_escaped_once(): void + { + $doc = Document::make(Page::a4()->body(Text::make('a & b < c'))); + self::assertStringContainsString('a &amp; b &lt; c', $this->html($doc)); + self::assertSame('a & b < c', $this->one($this->html($doc), '//p')->textContent); + } + + public function test_empty_body_and_header_only_pages_are_valid(): void + { + $html = $this->html(Document::make(Page::a4()), new HtmlOptions(fullPage: true)); + self::assertCount(1, $this->xpath($html, '//section[@class="pc-page"]')); + self::assertCount(0, $this->xpath($html, '//section/div')); + + $html = $this->html(Document::make(Page::a4()->header(Text::make('H')))); + self::assertCount(1, $this->xpath($html, '//section/div[@class="pc-header"]')); + self::assertCount(0, $this->xpath($html, '//div[@class="pc-body"]')); + + self::assertStringContainsString('
html(Document::make())); + } + + public function test_metadata_in_full_page_head(): void + { + $doc = $this->simple()->title('Title')->author('Au')->subject('Sub')->keywords('k1, k2'); + $html = $this->html($doc, new HtmlOptions(fullPage: true)); + self::assertSame('Au', $this->attr($this->one($html, '//head/meta[@name="author"]'), 'content')); + self::assertSame('Sub', $this->attr($this->one($html, '//head/meta[@name="description"]'), 'content')); + self::assertSame('k1, k2', $this->attr($this->one($html, '//head/meta[@name="keywords"]'), 'content')); + self::assertSame('width=device-width, initial-scale=1', $this->attr($this->one($html, '//head/meta[@name="viewport"]'), 'content')); + self::assertCount(0, $this->xpath($this->html($this->simple(), new HtmlOptions(fullPage: true)), '//meta[@name="author"]')); + } + + public function test_stylesheet_uses_first_page_box(): void + { + $html = $this->html($this->simple(), new HtmlOptions(fullPage: true)); + self::assertStringContainsString('@page{size:595.28pt 841.89pt;margin:56.693pt 56.693pt 56.693pt 56.693pt}', $html); + self::assertStringContainsString('max-width:595.28pt;min-height:841.89pt;padding:56.693pt', $html); + self::assertStringContainsString('.papercut tr{break-inside:avoid}', $html); + } + + public function test_fragment_style_has_no_page_rule(): void + { + self::assertStringNotContainsString('@page', $this->html($this->simple())); + } +} diff --git a/packages/core/tests/Html/HtmlFontSourcesTest.php b/packages/core/tests/Html/HtmlFontSourcesTest.php new file mode 100644 index 0000000..be1799b --- /dev/null +++ b/packages/core/tests/Html/HtmlFontSourcesTest.php @@ -0,0 +1,189 @@ + Text::make('Hello')->font($f), $families); + + return Document::make(Page::a4()->body(...$texts)); + } + + /** @param list $sources */ + private function html(Document $doc, array $sources, bool $full = false, ?\Closure $onWarning = null, bool $strict = false, ?FontSet $fonts = null): string + { + return $doc->toHtml(new HtmlOptions(fonts: $fonts, onWarning: $onWarning, strict: $strict, fullPage: $full, fontMode: FontMode::Sources, fontSources: $sources))->bytes(); + } + + public function test_full_page_links_in_head(): void + { + $html = $this->html($this->doc(), [new GoogleFonts()], true); + + self::assertStringContainsString('', $html); + self::assertLessThan(strpos($html, '

Plain border

Radius and background

Background only

Bottom rule

Half opacity

One

Two

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/example-invoice.html b/packages/core/tests/Html/golden/example-invoice.html new file mode 100644 index 0000000..2104559 --- /dev/null +++ b/packages/core/tests/Html/golden/example-invoice.html @@ -0,0 +1,13 @@ +Invoice INV-2026-0142

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/example-report.html b/packages/core/tests/Html/golden/example-report.html new file mode 100644 index 0000000..416720f --- /dev/null +++ b/packages/core/tests/Html/golden/example-report.html @@ -0,0 +1,13 @@ +Designing Papercut

Papercut Design Report

Confidential

Designing Papercut

A short report on layout, pagination, and deterministic output

SECTIONS

4

plus a glossary appendix

WORDS OF PROSE

509

across 10 justified paragraphs

ATTRIBUTES

28

listed in the reference table

1. Overview

Papercut starts from a simple observation: most business documents are layouts, not drawings. An invoice is a column of blocks, a letterhead is a row with a flexible gap, and a transcript is a table that happens to be long. The library therefore models documents as trees of components and leaves the coordinate arithmetic to a layout engine.

Every node is immutable. A modifier such as padding or bold returns a new instance, so a fragment can be reused in a dozen places without one caller changing what another sees. This also makes documents safe to build once and render many times, which matters in long-running workers.

Layout follows a measure-then-place model. Each element reports the space it wants given the width on offer, and containers distribute the remainder using grow factors, gaps, and alignment. The vocabulary is deliberately close to flexbox, so anyone who has written modern CSS can predict the result.

2. Text and pagination

Text is shaped with real font metrics. Lines are broken at word boundaries, justified paragraphs stretch the gaps between words, and inline markup such as bold or italic spans is carried through the line breaker without losing its style. Widths come from the embedded font program, never from an estimate.

Pagination is a first-class concern rather than an afterthought. Blocks can ask to be kept together, a heading can insist on staying with the paragraph that follows it, and tables repeat their header on every page they cross. When nothing else works, the engine splits a block at a line boundary and keeps going.

Output is deterministic. Given the same input and a fixed creation date, two runs produce identical bytes. That property makes golden-file tests practical: a visual regression becomes a byte comparison, and a diff in the PDF points at a diff in the code.

3. Backend and failure modes

The backend is a thin seam. The layout engine produces a display list of rectangles, lines, text runs, and links in plain points with a top-left origin, and one adapter turns that list into PDF objects. Nothing above the adapter knows how a PDF is encoded, so the engine can be tested without parsing a single file.

Failure is explicit. If a block cannot fit on a page even after every permitted split, the engine throws by default and names the offending node. Callers who prefer degraded output can opt into clipping, which keeps the rest of the document flowing and reports every clipped node through a warning callback; silent corruption is never the default.

Styling is data. Colors resolve from a named palette, sizes accept points, millimetres, or percentages, and a theme supplies defaults for family, size, weight, and line height. Because every style property can also be set from a string attribute, templates written in markup and code written in PHP describe the same documents.

The result is a library that stays out of the way for simple documents and still holds together for hard ones: a forty-page ledger, a certificate with a decorative background, or a report whose tables must not be orphaned from their captions.

Rule of thumb: measure first, then place, and only then paint.

4. Style attributes

Attribute

Example

Effect

padding

12pt 6mm

Space inside the border; one to four values, like CSS.

margin

0 0 6pt

Space outside the border; one to four values.

gap

8pt

Space between the children of a row or column.

grow

1

Share of the free space a child takes along the main axis.

shrink

0

Share of the overflow a child gives back along the main axis.

basis

40%

Starting main size of a flex child before grow and shrink.

width

60mm

Border-box width: a length, a percentage, or auto.

height

120pt

Border-box height; a fixed height makes the box monolithic.

min-width / max-width

20mm

Clamps applied after the width is resolved.

align-items

center

Cross-axis placement of the children.

justify

space-between

Main-axis distribution of the free space.

background

slate-50

Fill colour: a palette name or a hex value.

border-width

1pt

Border width; one to four values.

border-color

slate-200

Border colour: a palette name or a hex value.

radius

8pt

Corner radius of the background and border.

opacity

0.5

Opacity of the box and everything inside it.

font

Inter

Font family, resolved through the font registry.

font-size

10pt

Font size.

weight

700

Font weight, such as 400 or 700.

color

slate-900

Text colour: a palette name or a hex value.

line-height

1.4

Line height as a multiple of the font size.

letter-spacing

0.5pt

Extra space after every character.

text-align

justify

Left, center, right or justify.

underline / strike

true

Text decorations drawn under or through the run.

keep-together

true

Moves the whole block to the next page rather than splitting it.

keep-with-next

true

Keeps a block on the same page as the block that follows it.

break-before / break-after

true

Forces a page break before or after the block.

min-lines

2

Fewest lines of a paragraph left on either side of a page break.

Appendix A: Glossary

Display list: The ordered drawing operations produced by layout.

Fragment: The part of a node that fits on one page.

Track: A column width definition in a table.

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/example-transcript.html b/packages/core/tests/Html/golden/example-transcript.html new file mode 100644 index 0000000..f999643 --- /dev/null +++ b/packages/core/tests/Html/golden/example-transcript.html @@ -0,0 +1,13 @@ +Official Transcript

Official Transcript

One Learning Community

Ada Lovelace

Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019

CREDITS

210

GPA

3.20

STANDING

Good

COURSES

60

Course history

Code

Course

Term

Credits

Grade

CS 100

Introduction to Calculus

Term 1

3

A

CS 107

Introduction to Linear Algebra

Term 1

4

C+

CS 114

Introduction to Data Structures

Term 1

3

B-

CS 121

Introduction to Algorithms

Term 1

4

B-

CS 128

Introduction to Physics

Term 1

3

B

CS 135

Introduction to Statistics

Term 1

4

B+

CS 142

Introduction to Databases

Term 1

3

B+

CS 149

Introduction to Compilers

Term 1

4

A-

CS 156

Introduction to Networks

Term 2

3

A

CS 163

Introduction to Ethics

Term 2

4

A

CS 170

Introduction to Logic

Term 2

3

C+

CS 177

Introduction to Numerical Methods

Term 2

4

B-

CS 184

Applied Calculus

Term 2

3

B-

CS 191

Applied Linear Algebra

Term 2

4

B

CS 198

Applied Data Structures

Term 2

3

B+

CS 205

Applied Algorithms

Term 2

4

B+

CS 212

Applied Physics

Term 3

3

A-

CS 219

Applied Statistics

Term 3

4

A

CS 226

Applied Databases

Term 3

3

A

CS 233

Applied Compilers

Term 3

4

C+

CS 240

Applied Networks

Term 3

3

B-

CS 247

Applied Ethics

Term 3

4

B-

CS 254

Applied Logic

Term 3

3

B

CS 261

Applied Numerical Methods

Term 3

4

B+

CS 268

Topics in Calculus

Term 4

3

B+

CS 275

Topics in Linear Algebra

Term 4

4

A-

CS 282

Topics in Data Structures

Term 4

3

A

CS 289

Topics in Algorithms

Term 4

4

A

CS 296

Topics in Physics

Term 4

3

C+

CS 303

Topics in Statistics

Term 4

4

B-

CS 310

Topics in Databases

Term 4

3

B-

CS 317

Topics in Compilers

Term 4

4

B

CS 324

Topics in Networks

Term 5

3

B+

CS 331

Topics in Ethics

Term 5

4

B+

CS 338

Topics in Logic

Term 5

3

A-

CS 345

Topics in Numerical Methods

Term 5

4

A

CS 352

Advanced Calculus

Term 5

3

A

CS 359

Advanced Linear Algebra

Term 5

4

C+

CS 366

Advanced Data Structures

Term 5

3

B-

CS 373

Advanced Algorithms

Term 5

4

B-

CS 380

Advanced Physics

Term 6

3

B

CS 387

Advanced Statistics

Term 6

4

B+

CS 394

Advanced Databases

Term 6

3

B+

CS 401

Advanced Compilers

Term 6

4

A-

CS 408

Advanced Networks

Term 6

3

A

CS 415

Advanced Ethics

Term 6

4

A

CS 422

Advanced Logic

Term 6

3

C+

CS 429

Advanced Numerical Methods

Term 6

4

B-

CS 436

Seminar in Calculus

Term 7

3

B-

CS 443

Seminar in Linear Algebra

Term 7

4

B

CS 450

Seminar in Data Structures

Term 7

3

B+

CS 457

Seminar in Algorithms

Term 7

4

B+

CS 464

Seminar in Physics

Term 7

3

A-

CS 471

Seminar in Statistics

Term 7

4

A

CS 478

Seminar in Databases

Term 7

3

A

CS 485

Seminar in Compilers

Term 7

4

C+

CS 492

Seminar in Networks

Term 8

3

B-

CS 499

Seminar in Ethics

Term 8

4

B-

CS 106

Seminar in Logic

Term 8

3

B

CS 113

Seminar in Numerical Methods

Term 8

4

B+

Cumulative summary

210 credits attempted, 671.3 quality points.

GPA 3.20

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/flex-justify.html b/packages/core/tests/Html/golden/flex-justify.html new file mode 100644 index 0000000..fee0497 --- /dev/null +++ b/packages/core/tests/Html/golden/flex-justify.html @@ -0,0 +1,13 @@ +

start

A

B

C

end

A

B

C

center

A

B

C

space-between

A

B

C

space-around

A

B

C

space-evenly

A

B

C

left

right

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/image-fit.html b/packages/core/tests/Html/golden/image-fit.html new file mode 100644 index 0000000..aab3c71 --- /dev/null +++ b/packages/core/tests/Html/golden/image-fit.html @@ -0,0 +1,13 @@ +

contain

cover

fill

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/keep-together.html b/packages/core/tests/Html/golden/keep-together.html new file mode 100644 index 0000000..c587edc --- /dev/null +++ b/packages/core/tests/Html/golden/keep-together.html @@ -0,0 +1,13 @@ +

Filler line 1

Filler line 2

Filler line 3

Filler line 4

Filler line 5

Filler line 6

Filler line 7

Filler line 8

Filler line 9

Filler line 10

Filler line 11

Filler line 12

Filler line 13

Filler line 14

Filler line 15

Filler line 16

Filler line 17

Filler line 18

Filler line 19

Filler line 20

Filler line 21

Filler line 22

Kept together: heading

This block does not fit in the space left,

so it moves whole to page two.

Last line of the block.

Tail paragraph after the block.

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/page-numbers.html b/packages/core/tests/Html/golden/page-numbers.html new file mode 100644 index 0000000..712f936 --- /dev/null +++ b/packages/core/tests/Html/golden/page-numbers.html @@ -0,0 +1,13 @@ +

Header

First

Body of the first page.

Second

Body of the second page.

Third

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/sources/example-invoice-fragment-google.html b/packages/core/tests/Html/golden/sources/example-invoice-fragment-google.html new file mode 100644 index 0000000..a49a5b7 --- /dev/null +++ b/packages/core/tests/Html/golden/sources/example-invoice-fragment-google.html @@ -0,0 +1,13 @@ +fragment

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/sources/example-invoice-google.html b/packages/core/tests/Html/golden/sources/example-invoice-google.html new file mode 100644 index 0000000..0729dc3 --- /dev/null +++ b/packages/core/tests/Html/golden/sources/example-invoice-google.html @@ -0,0 +1,13 @@ +Invoice INV-2026-0142

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/sources/example-report-mixed.html b/packages/core/tests/Html/golden/sources/example-report-mixed.html new file mode 100644 index 0000000..88533c9 --- /dev/null +++ b/packages/core/tests/Html/golden/sources/example-report-mixed.html @@ -0,0 +1,17 @@ +Designing Papercut

Papercut Design Report

Confidential

Designing Papercut

A short report on layout, pagination, and deterministic output

SECTIONS

4

plus a glossary appendix

WORDS OF PROSE

509

across 10 justified paragraphs

ATTRIBUTES

28

listed in the reference table

1. Overview

Papercut starts from a simple observation: most business documents are layouts, not drawings. An invoice is a column of blocks, a letterhead is a row with a flexible gap, and a transcript is a table that happens to be long. The library therefore models documents as trees of components and leaves the coordinate arithmetic to a layout engine.

Every node is immutable. A modifier such as padding or bold returns a new instance, so a fragment can be reused in a dozen places without one caller changing what another sees. This also makes documents safe to build once and render many times, which matters in long-running workers.

Layout follows a measure-then-place model. Each element reports the space it wants given the width on offer, and containers distribute the remainder using grow factors, gaps, and alignment. The vocabulary is deliberately close to flexbox, so anyone who has written modern CSS can predict the result.

2. Text and pagination

Text is shaped with real font metrics. Lines are broken at word boundaries, justified paragraphs stretch the gaps between words, and inline markup such as bold or italic spans is carried through the line breaker without losing its style. Widths come from the embedded font program, never from an estimate.

Pagination is a first-class concern rather than an afterthought. Blocks can ask to be kept together, a heading can insist on staying with the paragraph that follows it, and tables repeat their header on every page they cross. When nothing else works, the engine splits a block at a line boundary and keeps going.

Output is deterministic. Given the same input and a fixed creation date, two runs produce identical bytes. That property makes golden-file tests practical: a visual regression becomes a byte comparison, and a diff in the PDF points at a diff in the code.

3. Backend and failure modes

The backend is a thin seam. The layout engine produces a display list of rectangles, lines, text runs, and links in plain points with a top-left origin, and one adapter turns that list into PDF objects. Nothing above the adapter knows how a PDF is encoded, so the engine can be tested without parsing a single file.

Failure is explicit. If a block cannot fit on a page even after every permitted split, the engine throws by default and names the offending node. Callers who prefer degraded output can opt into clipping, which keeps the rest of the document flowing and reports every clipped node through a warning callback; silent corruption is never the default.

Styling is data. Colors resolve from a named palette, sizes accept points, millimetres, or percentages, and a theme supplies defaults for family, size, weight, and line height. Because every style property can also be set from a string attribute, templates written in markup and code written in PHP describe the same documents.

The result is a library that stays out of the way for simple documents and still holds together for hard ones: a forty-page ledger, a certificate with a decorative background, or a report whose tables must not be orphaned from their captions.

Rule of thumb: measure first, then place, and only then paint.

4. Style attributes

Attribute

Example

Effect

padding

12pt 6mm

Space inside the border; one to four values, like CSS.

margin

0 0 6pt

Space outside the border; one to four values.

gap

8pt

Space between the children of a row or column.

grow

1

Share of the free space a child takes along the main axis.

shrink

0

Share of the overflow a child gives back along the main axis.

basis

40%

Starting main size of a flex child before grow and shrink.

width

60mm

Border-box width: a length, a percentage, or auto.

height

120pt

Border-box height; a fixed height makes the box monolithic.

min-width / max-width

20mm

Clamps applied after the width is resolved.

align-items

center

Cross-axis placement of the children.

justify

space-between

Main-axis distribution of the free space.

background

slate-50

Fill colour: a palette name or a hex value.

border-width

1pt

Border width; one to four values.

border-color

slate-200

Border colour: a palette name or a hex value.

radius

8pt

Corner radius of the background and border.

opacity

0.5

Opacity of the box and everything inside it.

font

Inter

Font family, resolved through the font registry.

font-size

10pt

Font size.

weight

700

Font weight, such as 400 or 700.

color

slate-900

Text colour: a palette name or a hex value.

line-height

1.4

Line height as a multiple of the font size.

letter-spacing

0.5pt

Extra space after every character.

text-align

justify

Left, center, right or justify.

underline / strike

true

Text decorations drawn under or through the run.

keep-together

true

Moves the whole block to the next page rather than splitting it.

keep-with-next

true

Keeps a block on the same page as the block that follows it.

break-before / break-after

true

Forces a page break before or after the block.

min-lines

2

Fewest lines of a paragraph left on either side of a page break.

Appendix A: Glossary

Display list: The ordered drawing operations produced by layout.

Fragment: The part of a node that fits on one page.

Track: A column width definition in a table.

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/sources/mixed-fragment.html b/packages/core/tests/Html/golden/sources/mixed-fragment.html new file mode 100644 index 0000000..9687b19 --- /dev/null +++ b/packages/core/tests/Html/golden/sources/mixed-fragment.html @@ -0,0 +1,17 @@ +fragment

Hello Inter

Hello Roboto

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/table-split-repeat-header.html b/packages/core/tests/Html/golden/table-split-repeat-header.html new file mode 100644 index 0000000..bc20644 --- /dev/null +++ b/packages/core/tests/Html/golden/table-split-repeat-header.html @@ -0,0 +1,13 @@ +

Split table

Name

Qty

Notes

Item 1

4

Note for row 1

Item 2

7

Note for row 2

Item 3

3

Note for row 3

Item 4

6

Note for row 4

Item 5

2

Note for row 5

Item 6

5

Note for row 6

Item 7

1

Note for row 7

Item 8

4

Note for row 8

Item 9

7

Note for row 9

Item 10

3

Note for row 10

Item 11

6

Note for row 11

Item 12

2

Note for row 12

Item 13

5

Note for row 13

Item 14

1

Note for row 14

Item 15

4

Note for row 15

Item 16

7

Note for row 16

Item 17

3

Note for row 17

Item 18

6

Note for row 18

Item 19

2

Note for row 19

Item 20

5

Note for row 20

Item 21

1

Note for row 21

Item 22

4

Note for row 22

Item 23

7

Note for row 23

Item 24

3

Note for row 24

Item 25

6

Note for row 25

Item 26

2

Note for row 26

Item 27

5

Note for row 27

Item 28

1

Note for row 28

Item 29

4

Note for row 29

Item 30

7

Note for row 30

Item 31

3

Note for row 31

Item 32

6

Note for row 32

Item 33

2

Note for row 33

Item 34

5

Note for row 34

Item 35

1

Note for row 35

Item 36

4

Note for row 36

Item 37

7

Note for row 37

Item 38

3

Note for row 38

Item 39

6

Note for row 39

Item 40

2

Note for row 40

\ No newline at end of file diff --git a/packages/core/tests/Html/golden/text-align-justify.html b/packages/core/tests/Html/golden/text-align-justify.html new file mode 100644 index 0000000..99b282d --- /dev/null +++ b/packages/core/tests/Html/golden/text-align-justify.html @@ -0,0 +1,13 @@ +

Justified

Layout engines break paragraphs into lines, and justified text stretches the gaps between words so that both edges of the column line up. A final line stays ragged, and a very long word such as supercalifragilisticexpialidocious must still fit somewhere sensible.

Left

Layout engines break paragraphs into lines, and justified text stretches the gaps between words so that both edges of the column line up. A final line stays ragged, and a very long word such as supercalifragilisticexpialidocious must still fit somewhere sensible.

Center

Layout engines break paragraphs into lines, and justified text stretches the gaps between words so that both edges of the column line up. A final line stays ragged, and a very long word such as supercalifragilisticexpialidocious must still fit somewhere sensible.

Right

Layout engines break paragraphs into lines, and justified text stretches the gaps between words so that both edges of the column line up. A final line stays ragged, and a very long word such as supercalifragilisticexpialidocious must still fit somewhere sensible.

\ No newline at end of file diff --git a/packages/core/tests/Layout/TableLayouterTest.php b/packages/core/tests/Layout/TableLayouterTest.php index 5a021a6..dbe7464 100644 --- a/packages/core/tests/Layout/TableLayouterTest.php +++ b/packages/core/tests/Layout/TableLayouterTest.php @@ -377,4 +377,39 @@ private static function cellFragments(Fragment $table): array } return $cells; } + + public function test_bare_cell_warns_once_and_bytes_are_unchanged(): void + { + $doc = fn () => \Papercut\Document::make(\Papercut\Page::a4()->body(\Papercut\View::make(TableCell::th('x')))); + $warnings = []; + $withHook = $doc()->toPdf(new \Papercut\Export\PdfOptions( + backend: new \Papercut\Tests\Support\FakeBackend(), + onWarning: function (string $m) use (&$warnings): void { $warnings[] = $m; }, + ))->bytes(); + $plain = $doc()->toPdf(new \Papercut\Export\PdfOptions(backend: new \Papercut\Tests\Support\FakeBackend()))->bytes(); + + $stray = array_values(array_filter($warnings, static fn (string $m): bool => str_ends_with($m, 'A table cell outside a table is laid out as a column'))); + self::assertCount(1, $stray); + self::assertStringStartsWith('[', $stray[0]); + self::assertStringContainsString('page > body', $stray[0]); + self::assertSame($plain, $withHook); + } + + public function test_reused_cell_warns_when_used_bare_after_a_table(): void + { + $cell = TableCell::make('x')->padding(pt(3)); // own padding: theme defaults change nothing + $doc = \Papercut\Document::make(\Papercut\Page::a4()->body( + Table::make()->columns('1fr')->row($cell), + \Papercut\View::make($cell), + )); + $warnings = []; + $doc->toPdf(new \Papercut\Export\PdfOptions( + backend: new \Papercut\Tests\Support\FakeBackend(), + onWarning: function (string $m) use (&$warnings): void { $warnings[] = $m; }, + )); + + $stray = array_values(array_filter($warnings, static fn (string $m): bool => str_ends_with($m, 'A table cell outside a table is laid out as a column'))); + self::assertCount(1, $stray); + self::assertStringContainsString('body > column[1] > td[0]]', $stray[0]); + } } diff --git a/packages/core/tests/Layout/WidthResolverTest.php b/packages/core/tests/Layout/WidthResolverTest.php new file mode 100644 index 0000000..2cca6bb --- /dev/null +++ b/packages/core/tests/Layout/WidthResolverTest.php @@ -0,0 +1,161 @@ +grow(1), + Text::make('fixed')->width(pt(50))->shrink(0), + Spacer::flex(), + Text::make('tail'), + )->gap(pt(6))->justify(Justify::SpaceBetween); + + $h = new LayoutHarness(); + $style = $h->engine->styleOf($row, ComputedStyle::root(Theme::default())); + $lines = (new WidthResolver($h->engine))->flexLines($row, $style, 400.0); + $frag = $h->layout($row, 400)->fragment; + self::assertNotNull($frag); + + self::assertCount(1, $lines); + self::assertCount(4, $lines[0]); + $ox = $style->borderWidth->left->points + $style->padding->left->points; + $children = $frag->children(); + foreach ($lines[0] as $k => $slot) { + self::assertSame($k, $slot->index); + self::assertEqualsWithDelta($children[$k]['x'], $ox + $slot->x, self::TOL); + self::assertEqualsWithDelta($children[$k]['fragment']->width, $slot->width, self::TOL); + } + } + + public function test_wrap_produces_one_list_per_line(): void + { + $row = Row::make( + Text::make('aaaaaaaaaa')->width(pt(90)), + PageBreak::make(), + Text::make('bbbbbbbbbb')->width(pt(90)), + Text::make('cccccccccc')->width(pt(90)), + )->wrap()->gap(pt(5)); + + $h = new LayoutHarness(); + $style = $h->engine->styleOf($row, ComputedStyle::root(Theme::default())); + $lines = (new WidthResolver($h->engine))->flexLines($row, $style, 200.0); + self::assertCount(2, $lines); + self::assertSame([0, 2], array_map(static fn ($s) => $s->index, $lines[0])); + self::assertSame([3], array_map(static fn ($s) => $s->index, $lines[1])); + self::assertSame([], $h->warnings); + + $frag = $h->layout($row, 200)->fragment; + self::assertNotNull($frag); + $children = $frag->children(); + $k = 0; + foreach ($lines as $line) { + foreach ($line as $slot) { + self::assertEqualsWithDelta($children[$k]['x'], $slot->x, self::TOL); + self::assertEqualsWithDelta($children[$k]['fragment']->width, $slot->width, self::TOL); + $k++; + } + } + self::assertSame(3, $k); + } + + public function test_row_inside_padded_bordered_column_uses_offered_width(): void + { + $row = Row::make(Text::make('a')->grow(1), Text::make('b')->grow(2))->gap(pt(4)); + $column = Column::make($row)->padding(pt(20))->border(pt(2)); + + $h = new LayoutHarness(); + $colStyle = $h->engine->styleOf($column, ComputedStyle::root(Theme::default())); + $rowStyle = $h->engine->styleOf($row, $colStyle); + $offered = 400.0 - 44.0; + $lines = (new WidthResolver($h->engine))->flexLines($row, $rowStyle, $offered); + + $outer = $h->layout($column, 400)->fragment; + self::assertNotNull($outer); + $rowFrag = $outer->children()[0]['fragment']; + self::assertEqualsWithDelta($offered, $rowFrag->width, self::TOL); + foreach ($lines[0] as $k => $slot) { + $child = $rowFrag->children()[$k]; + self::assertEqualsWithDelta($child['x'], $slot->x, self::TOL); + self::assertEqualsWithDelta($child['fragment']->width, $slot->width, self::TOL); + } + self::assertEqualsWithDelta($offered, $lines[0][1]->x + $lines[0][1]->width, self::TOL); + } + + public function test_table_columns_match_layout_cell_x(): void + { + $table = Table::make()->columns('30mm 2fr 1fr auto') + ->row('a', 'b', 'c', 'dd') + ->row(TableCell::make('wide')->colspan(2), 'x', 'y'); + + $h = new LayoutHarness(); + $style = $h->engine->styleOf($table, ComputedStyle::root(Theme::default())); + $widths = (new WidthResolver($h->engine))->tableColumns($table, $style, 400.0); + $frag = $h->layout($table, 400)->fragment; + self::assertNotNull($frag); + + self::assertCount(4, $widths); + self::assertEqualsWithDelta(mm(30)->points, $widths[0], self::TOL); + self::assertEqualsWithDelta(400.0, array_sum($widths), self::TOL); + + $offsets = [0.0, $widths[0], $widths[0] + $widths[1], $widths[0] + $widths[1] + $widths[2]]; + $rows = $frag->children(); + self::assertCount(2, $rows); + $first = array_map(static fn (array $c): array => [$c['x'], $c['fragment']->width], $rows[0]['fragment']->children()); + foreach ($first as $k => [$x, $w]) { + self::assertEqualsWithDelta($offsets[$k], $x, self::TOL); + self::assertEqualsWithDelta($widths[$k], $w, self::TOL); + } + $second = array_map(static fn (array $c): array => [$c['x'], $c['fragment']->width], $rows[1]['fragment']->children()); + self::assertCount(3, $second); + self::assertEqualsWithDelta(0.0, $second[0][0], self::TOL); + self::assertEqualsWithDelta($widths[0] + $widths[1], $second[0][1], self::TOL); + self::assertEqualsWithDelta($offsets[2], $second[1][0], self::TOL); + self::assertEqualsWithDelta($offsets[3], $second[2][0], self::TOL); + } + + public function test_auto_columns_use_font_metrics(): void + { + $widthOf = function (string $word): float { + $table = Table::make()->columns('auto 1fr')->row($word, 'x'); + $h = new LayoutHarness(); + $style = $h->engine->styleOf($table, ComputedStyle::root(Theme::default())); + return (new WidthResolver($h->engine))->tableColumns($table, $style, 400.0)[0]; + }; + + self::assertGreaterThan($widthOf('ab'), $widthOf('abcdefghijklmnop')); + } + + public function test_tracks_match_layout_for_spans_missing_and_extra_tracks(): void + { + $fr1 = \Papercut\Value\Fr::of(1); + $fr2 = \Papercut\Value\Fr::of(2); + + $missing = Table::make()->columns('30mm 2fr')->row('a', 'b', 'c'); + self::assertEquals([mm(30), $fr2, $fr1], WidthResolver::tracks($missing)); + + $spanned = Table::make()->columns('10pt 2fr')->row(TableCell::make('wide')->colspan(3)); + self::assertEquals([pt(10), $fr2, $fr1], WidthResolver::tracks($spanned)); + + $extra = Table::make()->columns('10pt 20pt 30pt 40pt 50pt')->row('a', 'b'); + self::assertEquals([pt(10), pt(20)], WidthResolver::tracks($extra)); + + $h = new LayoutHarness(); + $style = $h->engine->styleOf($missing, ComputedStyle::root(Theme::default())); + self::assertCount(count(WidthResolver::tracks($missing)), (new WidthResolver($h->engine))->tableColumns($missing, $style, 400.0)); + } +} diff --git a/packages/core/tests/Style/StyleResolverTest.php b/packages/core/tests/Style/StyleResolverTest.php new file mode 100644 index 0000000..ea4136b --- /dev/null +++ b/packages/core/tests/Style/StyleResolverTest.php @@ -0,0 +1,96 @@ +withStyle('loud', Style::make(fontSize: pt(30), color: Color::parse('red-500'))); + $r = new StyleResolver($theme); + $s = $r->resolve(Text::h1('x')->apply('loud')->color('blue-500'), $r->root()); + self::assertSame(30.0, $s->fontSize); + self::assertEquals(Color::parse('blue-500'), $s->color); + self::assertSame(700, $s->fontWeight, 'h1 preset weight survives under named and own'); + self::assertSame(400, $r->resolve(Text::make('x'), $r->root())->fontWeight); + } + + public function test_memoized_by_node_and_parent_identity(): void + { + $r = new StyleResolver(Theme::default()); + $node = Text::make('x'); + self::assertSame($r->resolve($node, $r->root()), $r->resolve($node, $r->root())); + self::assertSame($r->root(), $r->root()); + $other = $r->root()->child(Style::make(fontSize: pt(20))); + self::assertNotSame($r->resolve($node, $r->root()), $r->resolve($node, $other)); + } + + public function test_bare_cell_through_resolve_gets_no_table_defaults(): void + { + $r = new StyleResolver(Theme::default()); + $th = $r->resolve(TableCell::th('a'), $r->root()); + self::assertSame(400, $th->fontWeight); + self::assertEquals($r->root()->padding, $th->padding); + self::assertEquals($r->root()->padding, $r->resolve(TableCell::make('a'), $r->root())->padding); + } + + public function test_resolve_in_table_applies_cell_defaults(): void + { + $r = new StyleResolver(Theme::default()); + $td = $r->resolveInTable(TableCell::make('a'), $r->root()); + self::assertEquals(Theme::default()->cellPadding, $td->padding); + self::assertSame(400, $td->fontWeight); + self::assertSame(700, $r->resolveInTable(TableCell::th('a'), $r->root())->fontWeight); + self::assertSame(400, $r->resolveInTable(TableCell::th('a')->weight(400), $r->root())->fontWeight); + self::assertEquals(Edges::all(pt(1)), $r->resolveInTable(TableCell::make('a')->padding(pt(1)), $r->root())->padding); + } + + public function test_resolve_in_table_named_style_beats_defaults(): void + { + $theme = Theme::default()->withStyle('roomy', Style::make(padding: Edges::all(pt(9)), fontWeight: 300)); + $r = new StyleResolver($theme); + $s = $r->resolveInTable(TableCell::th('a')->apply('roomy'), $r->root()); + self::assertEquals(Edges::all(pt(9)), $s->padding); + self::assertSame(300, $s->fontWeight); + } + + public function test_resolve_in_table_has_its_own_memo(): void + { + $r = new StyleResolver(Theme::default()); + $cell = TableCell::th('a'); + self::assertSame($r->resolveInTable($cell, $r->root()), $r->resolveInTable($cell, $r->root())); + self::assertNotSame($r->resolve($cell, $r->root()), $r->resolveInTable($cell, $r->root())); + } + + public function test_resolve_in_table_matches_table_layouter_defaults(): void + { + $theme = Theme::default() + ->withStyle('roomy', Style::make(padding: Edges::all(pt(9)))) + ->withStyle('light', Style::make(fontWeight: 300)); + $r = new StyleResolver($theme); + $prepare = new \ReflectionMethod(\Papercut\Layout\TableLayouter::class, 'withThemeDefaults'); + $cells = [ + 'td' => TableCell::make('a'), + 'th' => TableCell::th('a'), + 'own padding' => TableCell::th('a')->padding(pt(1)), + 'own weight' => TableCell::th('a')->weight(400), + 'named padding' => TableCell::th('a')->apply('roomy'), + 'named weight' => TableCell::th('a')->apply('light'), + ]; + foreach ($cells as $label => $cell) { + $viaTable = $r->resolve($prepare->invoke(null, $cell, $theme), $r->root()); + $viaResolver = $r->resolveInTable($cell, $r->root()); + self::assertEquals($viaTable->padding, $viaResolver->padding, "$label padding"); + self::assertSame($viaTable->fontWeight, $viaResolver->fontWeight, "$label weight"); + } + } +} diff --git a/packages/core/tests/Support/TestImages.php b/packages/core/tests/Support/TestImages.php index 2dac7be..2b51890 100644 --- a/packages/core/tests/Support/TestImages.php +++ b/packages/core/tests/Support/TestImages.php @@ -23,4 +23,17 @@ public static function png(int $w = 40, int $h = 20, bool $alpha = false): strin imagepng($im); return (string) ob_get_clean(); } + + /** + * @param int<1, max> $w + * @param int<1, max> $h + */ + public static function jpeg(int $w = 40, int $h = 20): string + { + $im = imagecreatetruecolor($w, $h) ?: throw new \RuntimeException('gd failure'); + imagefill($im, 0, 0, (int) imagecolorallocate($im, 30, 90, 200)); + ob_start(); + imagejpeg($im); + return (string) ob_get_clean(); + } } diff --git a/packages/core/tests/Text/FontRegistryTest.php b/packages/core/tests/Text/FontRegistryTest.php index d9e260e..69ef258 100644 --- a/packages/core/tests/Text/FontRegistryTest.php +++ b/packages/core/tests/Text/FontRegistryTest.php @@ -133,4 +133,28 @@ public function test_register_rejects_bad_weight(): void $this->expectException(\Papercut\Exception\InvalidValue::class); (new FontRegistry(new PhpPdfBackend()))->register('X', self::DIR . '/Inter-Regular.ttf', 0); } + + public function test_registry_matches_effective_faces(): void + { + $dir = self::DIR; + $set = (new \Papercut\Text\FontSet())->with( + new \Papercut\Text\FontFace('Brand', "$dir/Inter-Italic.ttf", 400, FontStyle::Italic), + new \Papercut\Text\FontFace('inter', "$dir/Inter-Regular.ttf", 700), + ); + $map = static fn (array $rows): array => array_map(static fn (array $r): string => strtolower($r['family']) . '|' . $r['weight'] . '|' . $r['style']->value . '|' . $r['path'], $rows); + $expected = $map(array_map(static fn (\Papercut\Text\FontFace $f): array => [ + 'family' => $f->family, 'weight' => $f->weight, 'style' => $f->style, 'path' => realpath($f->path) ?: $f->path, + ], $set->effectiveFaces())); + $actual = $map(FontRegistry::fromSet($set, new PhpPdfBackend())->faces()); + sort($expected); + sort($actual); + $this->assertSame($expected, $actual); + + $doc = \Papercut\Document::make(\Papercut\Page::a4()->body( + \Papercut\Text::make('Plain bold italic')->weight(700), + \Papercut\Text::make('Brand italic')->font('Brand')->italic(), + ))->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $bytes = $doc->toPdf(new \Papercut\Export\PdfOptions(fonts: $set))->bytes(); + $this->assertSame('7c0493dc227e8b8c347a1d45a6b81cd88b026cf1', sha1($bytes)); + } } diff --git a/packages/core/tests/Text/FontSetTest.php b/packages/core/tests/Text/FontSetTest.php new file mode 100644 index 0000000..b31767f --- /dev/null +++ b/packages/core/tests/Text/FontSetTest.php @@ -0,0 +1,61 @@ +with(new FontFace('Brand', self::INTER, 700, FontStyle::Italic))->withFallbacks('Inter', 'Brand'); + self::assertSame(['Inter', 'Brand'], $set->fallbacks); + $registry = FontRegistry::fromSet($set, new FakeBackend()); + self::assertTrue($registry->has('Inter')); + self::assertTrue($registry->has('Brand')); + self::assertContains(['family' => 'Brand', 'weight' => 700, 'style' => FontStyle::Italic, 'path' => realpath(self::INTER)], $registry->faces()); + } + + public function test_set_is_immutable(): void + { + $a = new FontSet(); + $b = $a->with(new FontFace('X', self::INTER))->withFallbacks('X'); + self::assertSame([], $a->faces); + self::assertSame([], $a->fallbacks); + self::assertCount(1, $b->faces); + } + + public function test_bundled_lists_the_four_inter_faces(): void + { + $faces = FontSet::bundled(); + self::assertCount(4, $faces); + foreach ($faces as $face) { + self::assertSame('Inter', $face->family); + self::assertFileExists($face->path); + } + self::assertSame([400, 400, 700, 700], array_map(static fn (FontFace $f): int => $f->weight, $faces)); + self::assertSame([FontStyle::Normal, FontStyle::Italic, FontStyle::Normal, FontStyle::Italic], array_map(static fn (FontFace $f): FontStyle => $f->style, $faces)); + } + + public function test_effective_faces_dedupe_case_insensitively_last_wins(): void + { + $custom = __DIR__ . '/../../resources/fonts/Inter/Inter-Italic.ttf'; + $set = (new FontSet())->with( + new FontFace('Brand', self::INTER), + new FontFace('inter', $custom, 700), + ); + $faces = $set->effectiveFaces(); + + self::assertCount(5, $faces); + self::assertSame(['Inter', 'Inter', 'Inter', 'Inter', 'Brand'], array_map(static fn (FontFace $f): string => $f->family === 'inter' ? 'Inter' : $f->family, $faces)); + self::assertSame(['inter', 700, FontStyle::Normal, $custom], [$faces[2]->family, $faces[2]->weight, $faces[2]->style, $faces[2]->path]); + self::assertSame(FontSet::bundled()[0]->path, $faces[0]->path); + self::assertSame(self::INTER, $faces[4]->path); + } +} diff --git a/packages/core/tests/Tree/ExpanderTest.php b/packages/core/tests/Tree/ExpanderTest.php new file mode 100644 index 0000000..b6a961f --- /dev/null +++ b/packages/core/tests/Tree/ExpanderTest.php @@ -0,0 +1,73 @@ +n; + } + }; + $outer = new class ($inner) implements Component { + public function __construct(private readonly Component $c) {} + public function render(): Node|Component + { + return $this->c; + } + }; + self::assertSame($text, Expander::expand($outer)); + } + + public function test_self_rendering_component_throws_after_max_depth(): void + { + $loop = new class implements Component { + public function render(): Node|Component + { + return $this; + } + }; + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('did not render to a node within 100 levels'); + Expander::expand($loop); + } + + public function test_depth_limit_is_exactly_100_levels(): void + { + self::assertInstanceOf(Text::class, Expander::expand(self::nested(100))); + $this->expectException(\LogicException::class); + Expander::expand(self::nested(101)); + } + + private static function nested(int $levels): Component + { + $c = new class (Text::make('x')) implements Component { + public function __construct(private readonly Node $n) {} + public function render(): Node|Component + { + return $this->n; + } + }; + for ($i = 1; $i < $levels; $i++) { + $c = new class ($c) implements Component { + public function __construct(private readonly Component $c) {} + public function render(): Node|Component + { + return $this->c; + } + }; + } + return $c; + } +} diff --git a/packages/core/tests/Tree/TreeWalkerTest.php b/packages/core/tests/Tree/TreeWalkerTest.php new file mode 100644 index 0000000..bf0798f --- /dev/null +++ b/packages/core/tests/Tree/TreeWalkerTest.php @@ -0,0 +1,250 @@ +header(Text::make('H'))->body(Row::make(Text::make('a'), Text::make('b')))->footer(PageNumber::make())); + self::assertSame([ + 'page:0', + 'region:header', 'enter:page > header > text[0]', 'leave:page > header > text[0]', 'leaveRegion:header', + 'region:body', + 'enter:page > body > row[0]', + 'enter:page > body > row[0] > text[0]', 'leave:page > body > row[0] > text[0]', + 'enter:page > body > row[0] > text[1]', 'leave:page > body > row[0] > text[1]', + 'leave:page > body > row[0]', + 'leaveRegion:body', + 'region:footer', 'enter:page > footer > page-number[0]', 'leave:page > footer > page-number[0]', 'leaveRegion:footer', + 'leavePage:0', + ], $this->walk($doc)); + } + + public function test_background_comes_first_and_several_pages_are_indexed(): void + { + $doc = Document::make(Page::a4()->body(Text::make('a'))->background(Text::make('bg')), Page::a4()->body(Text::make('b'))); + self::assertSame([ + 'page:0', + 'region:background', 'enter:page[0] > background > text[0]', 'leave:page[0] > background > text[0]', 'leaveRegion:background', + 'region:body', 'enter:page[0] > body > text[0]', 'leave:page[0] > body > text[0]', 'leaveRegion:body', + 'leavePage:0', + 'page:1', + 'region:body', 'enter:page[1] > body > text[0]', 'leave:page[1] > body > text[0]', 'leaveRegion:body', + 'leavePage:1', + ], $this->walk($doc)); + } + + public function test_expands_components_in_header_and_table_cells(): void + { + $comp = new class implements Component { + public function render(): Node|Component + { + return Text::make('expanded'); + } + }; + $doc = Document::make(Page::a4()->header($comp)->body(Table::make()->row(TableCell::make($comp)))); + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + $texts = array_values(array_filter($visitor->log, fn ($l) => str_starts_with($l, 'enter:') && str_ends_with($l, 'text[0]'))); + self::assertSame([ + 'enter:page > header > text[0]', + 'enter:page > body > table[0] > tr[0] > td[0] > text[0]', + ], $texts); + self::assertSame(2, count(array_filter($visitor->nodes, fn ($n) => $n instanceof Text))); + } + + public function test_returning_false_skips_children(): void + { + $doc = Document::make(Page::a4()->body(Row::make(Text::make('a')), Text::make('b'))); + $visitor = $this->recorder(skip: 'row'); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + self::assertSame([ + 'enter:page > body > row[0]', 'leave:page > body > row[0]', + 'enter:page > body > text[1]', 'leave:page > body > text[1]', + ], array_values(array_filter($visitor->log, fn ($l) => str_starts_with($l, 'enter:') || str_starts_with($l, 'leave:')))); + } + + public function test_table_rows_and_cells_get_layout_paths(): void + { + $doc = Document::make(Page::a4()->body(Table::make()->head('H')->row('a', 'b')->row('c'))); + $paths = array_values(array_filter($this->walk($doc), fn ($l) => preg_match('/^enter:.* > (td|th)\[\d+\]$/', $l) === 1)); + self::assertSame([ + 'enter:page > body > table[0] > tr[0] > th[0]', + 'enter:page > body > table[0] > tr[1] > td[0]', + 'enter:page > body > table[0] > tr[1] > td[1]', + 'enter:page > body > table[0] > tr[2] > td[0]', + ], $paths); + } + + public function test_bad_named_style_in_cell_matches_pdf_error(): void + { + $doc = Document::make(Page::a4()->body(Table::make()->row(TableCell::make('x')->apply('nope')))); + $pdfMsg = self::messageOf(fn () => $doc->toPdf(new PdfOptions(backend: new FakeBackend()))); + $walkMsg = self::messageOf(fn () => $this->walk($doc)); + self::assertNotSame('', $pdfMsg); + self::assertSame($pdfMsg, $walkMsg); + } + + public function test_bad_named_style_in_body_view_matches_pdf_error(): void + { + $doc = Document::make(Page::a4()->body(Row::make(Text::make('x')->apply('nope')))); + $pdfMsg = self::messageOf(fn () => $doc->toPdf(new PdfOptions(backend: new FakeBackend()))); + self::assertNotSame('', $pdfMsg); + self::assertSame($pdfMsg, self::messageOf(fn () => $this->walk($doc))); + } + + public function test_bad_named_style_in_header_footer_and_background_matches_pdf_error(): void + { + $bad = fn () => Row::make(Text::make('x')->apply('nope')); + foreach (['header', 'footer', 'background'] as $region) { + $doc = Document::make(Page::a4()->body(Text::make('b'))->$region($bad())); + $pdfMsg = self::messageOf(fn () => $doc->toPdf(new PdfOptions(backend: new FakeBackend()))); + self::assertNotSame('', $pdfMsg, $region); + self::assertStringContainsString('page > ' . $region . ' > ', $pdfMsg, $region); + self::assertSame($pdfMsg, self::messageOf(fn () => $this->walk($doc)), $region); + } + } + + public function test_style_inherits_through_the_walk(): void + { + $doc = Document::make(Page::a4()->body(Row::make(Text::make('x'))->color(Color::hex('#ff0000')))); + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + $text = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof Text))[0]; + self::assertEquals(Color::hex('#ff0000'), $visitor->styles[spl_object_id($text)]->color); + } + + public function test_table_cells_get_table_defaults(): void + { + $theme = Theme::default(); + $doc = Document::make(Page::a4()->body(Table::make()->head('H')->row('d'))); + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver($theme)))->walk($doc, $visitor); + $cells = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof TableCell)); + self::assertCount(2, $cells); + [$th, $td] = $cells; + self::assertSame(700, $visitor->styles[spl_object_id($th)]->fontWeight); + self::assertEquals($theme->cellPadding, $visitor->styles[spl_object_id($th)]->padding); + self::assertSame(400, $visitor->styles[spl_object_id($td)]->fontWeight); + self::assertEquals($theme->cellPadding, $visitor->styles[spl_object_id($td)]->padding); + } + + public function test_visit_reports_index_parent_and_region(): void + { + $row = Row::make(Text::make('a'), Text::make('b')); + $doc = Document::make(Page::a4()->body($row)); + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + $texts = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof Text)); + $at = $visitor->visits[spl_object_id($texts[1])]; + self::assertSame(1, $at->index()); + self::assertSame($row, $at->parent()); + self::assertSame(Region::Body, $at->region()); + self::assertFalse($at->inHeader()); + self::assertSame('page > body > row[0] > text[1]', $at->path()); + $top = $visitor->visits[spl_object_id($row)]; + self::assertNull($top->parent()); + self::assertSame(0, $top->index()); + } + + public function test_header_rows_and_their_cells_are_in_header(): void + { + $doc = Document::make(Page::a4()->body(Table::make()->head('H')->row('a')->row('b'))); + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + $rows = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof TableRow)); + self::assertCount(3, $rows); + self::assertSame([true, false, false], array_map(fn ($r) => $visitor->visits[spl_object_id($r)]->inHeader(), $rows)); + self::assertSame([0, 1, 2], array_map(fn ($r) => $visitor->visits[spl_object_id($r)]->index(), $rows)); + $cells = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof TableCell)); + self::assertSame([true, false, false], array_map(fn ($c) => $visitor->visits[spl_object_id($c)]->inHeader(), $cells)); + self::assertSame($rows[0], $visitor->visits[spl_object_id($cells[0])]->parent()); + $texts = array_values(array_filter($visitor->nodes, fn ($n) => $n instanceof Text)); + self::assertTrue($visitor->visits[spl_object_id($texts[0])]->inHeader()); + self::assertFalse($visitor->visits[spl_object_id($texts[1])]->inHeader()); + } + + /** @return list */ + private function walk(Document $doc): array + { + $visitor = $this->recorder(); + (new TreeWalker(new StyleResolver(Theme::default())))->walk($doc, $visitor); + return $visitor->log; + } + + private static function messageOf(\Closure $fn): string + { + try { + $fn(); + } catch (\Throwable $e) { + return $e->getMessage(); + } + return ''; + } + + private function recorder(?string $skip = null): RecordingVisitor + { + return new RecordingVisitor($skip); + } +} + +final class RecordingVisitor implements TreeVisitor +{ + /** @var list */ + public array $log = []; + /** @var list */ + public array $nodes = []; + /** @var array */ + public array $styles = []; + /** @var array */ + public array $visits = []; + + public function __construct(private readonly ?string $skip) + { + } + + public function enterPage(Page $page, int $index): void + { + $this->log[] = 'page:' . $index; + } + + public function leavePage(Page $page, int $index): void + { + $this->log[] = 'leavePage:' . $index; + } + + public function enterRegion(Region $region, ComputedStyle $root): void + { + $this->log[] = 'region:' . $region->value; + } + + public function leaveRegion(Region $region): void + { + $this->log[] = 'leaveRegion:' . $region->value; + } + + public function enter(Node $node, ComputedStyle $style, Visit $at): bool + { + $this->log[] = 'enter:' . $at->path(); + $this->visits[spl_object_id($node)] = $at; + $this->nodes[] = $node; + $this->styles[spl_object_id($node)] = $style; + return $node->debugName() !== $this->skip; + } + + public function leave(Node $node, ComputedStyle $style, Visit $at): void + { + $this->log[] = 'leave:' . $at->path(); + } +} diff --git a/packages/core/tests/Visual/SnapshotTest.php b/packages/core/tests/Visual/SnapshotTest.php index 7933c02..d9e04ed 100644 --- a/packages/core/tests/Visual/SnapshotTest.php +++ b/packages/core/tests/Visual/SnapshotTest.php @@ -45,7 +45,7 @@ public function test_fixture_matches_snapshots(string $name): void { $document = require __DIR__ . "/fixtures/$name.php"; self::assertInstanceOf(Document::class, $document); - $pages = $this->rasterise($document->toString()); + $pages = $this->rasterise($document->toPdf()->bytes()); self::assertNotSame([], $pages, 'pdftoppm produced no pages'); $snapshotDir = __DIR__ . '/snapshots'; diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 9fa0a90..b32fa1e 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -1,6 +1,6 @@ # papercut/laravel -Blade components for [Papercut](../../README.md): `` tags compile into Papercut's layout engine and out to a PDF. Not HTML-to-PDF. +Blade components for [Papercut](../../README.md): `` tags compile into Papercut's layout engine and out to a PDF. Not HTML-to-PDF. > **Status: v0.1, pre-release. Not yet published to Packagist.** @@ -24,29 +24,29 @@ To try it now, clone the monorepo and add path repositories to your app's `compo } ``` -then `composer require papercut/laravel:@dev`. Requires PHP 8.4+, `ext-zlib`, `ext-mbstring`, `ext-dom` and Laravel 12 or 13. The service provider and the `Pdf` facade are auto-discovered. +then `composer require papercut/laravel:@dev`. Requires PHP 8.4+, `ext-zlib`, `ext-mbstring`, `ext-dom` and Laravel 12 or 13. The service provider and the `Papercut` facade are auto-discovered. ## Quick start ```blade {{-- resources/views/pdf/hello.blade.php --}} - - - Hello, {{ $name }} - Rendered by Papercut. - - + + + Hello, {{ $name }} + Rendered by Papercut. + + ``` ```php -use Papercut\Laravel\Facades\Pdf; +use Papercut\Laravel\Facades\Papercut; -Route::get('/hello', fn () => Pdf::view('pdf.hello', ['name' => 'Ada'])->download('hello.pdf')); +Route::get('/hello', fn () => Papercut::view('pdf.hello', ['name' => 'Ada'])->pdf()->download('hello.pdf')); ``` -A view must have exactly one `` root. `Pdf::view($view, $data)` returns a `PendingPdf` with `download($filename)`, `inline($filename)`, `save($path)` and `toString()`. A `PendingPdf` returned from a route renders inline. +A view must have exactly one `` root. `Papercut::view($view, $data)` returns a `PendingDocument`; `->document()` gives the core `Document` and `->pdf()` a `PendingOutput` with `download($filename)`, `inline($filename)`, `save($path)`, `toString()` and `output()`. The filename defaults to `document.pdf`. A `PendingOutput` returned from a route renders inline. `Papercut::make($document)` wraps a `Document` you built in PHP the same way. -Full examples: [`examples/laravel`](../../examples/laravel) (invoice, transcript, report). Content inside `` may also contain your own Blade components, which can themselves contain `` tags. +Full examples: [`examples/laravel`](../../examples/laravel) (invoice, transcript, report). Content inside `` may also contain your own Blade components, which can themselves contain `` tags. ## Configuration @@ -58,19 +58,111 @@ This publishes `config/papercut.php`: | Key | Default | Meaning | |---|---|---| -| `paper` | `'a4'` | Page size when `` has no `size`: `a3`, `a4`, `a5`, `a6`, `letter`, `legal` | -| `margin` | `'20mm'` | Page margin when `` has no `margin` | +| `prefix` | `'doc'` | Blade tag prefix (``): lowercase letters, digits and dashes. Read at boot | +| `paper` | `'a4'` | Page size when `` has no `size`: `a3`, `a4`, `a5`, `a6`, `letter`, `legal` | +| `margin` | `'20mm'` | Page margin when `` has no `margin` | | `fonts` | `[]` | Extra font families, see [Fonts](#fonts) | | `fallback_fonts` | `[]` | Family names tried, in order, for glyphs the primary font lacks | | `theme` | `[]` | Defaults: `font_family`, `font_size` (points, number), `color`, `line_height` (number) | | `styles` | `[]` | [Named styles](#named-styles) | | `overflow` | `null` | `'throw'`, `'clip'`, or `null` = throw in `local`/`testing`, clip elsewhere | -| `image_path` | `null` | Base directory for relative ``; `null` = `public_path()` | +| `strict` | `false` | `true` makes an export throw `UnsupportedFeature` for content a format cannot render, instead of dropping it with a warning. Consider `true` in tests | +| `html.fonts` | `'none'` | Fonts in HTML output: `'none'` (a `system-ui` fallback stack, small files), `'embed'` (base64 `@font-face`, large files), `'link'` (`@font-face` with URLs from `html.font_url`; requires it) or `'sources'` (see [Font sources](#font-sources)) | +| `html.image_url` | `null` | Class name implementing `Papercut\Laravel\Html\ImageUrlResolver`; `null` embeds images as data URIs (see [HTML URLs](#html-urls)) | +| `html.font_url` | `null` | Class name implementing `Papercut\Laravel\Html\FontUrlResolver`; required when `html.fonts` is `'link'` | +| `html.font_sources` | `[GoogleFonts::class]` | Used when `html.fonts` is `'sources'`: an ordered list of class names implementing `Papercut\Html\FontSource` | +| `html.google_fonts.host` | `'https://fonts.googleapis.com'` | Host of the `GoogleFonts` source; `'https://fonts.bunny.net'` for Bunny Fonts | +| `html.google_fonts.display` | `'swap'` | `font-display`: `auto`, `block`, `swap`, `fallback` or `optional` | +| `html.google_fonts.families` | `null` | `null` = every family, or a list of family names (allowlist) | +| `html.local_fonts.mode` | `'embed'` | `LocalFonts` source: `'embed'` (base64) or `'link'` (URLs from `html.font_url`; requires it) | +| `html.local_fonts.families` | `null` | `null` = every registered family, or a list of family names | +| `image_path` | `null` | Base directory for relative ``; `null` = `public_path()` | | `cache_dir` | `null` | Font kerning cache directory: `null` = `sys_get_temp_dir()/papercut-cache-`, `false` = no disk cache, or a path (see core's "Kerning cache") | | `preview.enabled` | `env('PAPERCUT_PREVIEW', false)` | Enable [preview routes](#preview-routes) | | `preview.path` | `'_papercut'` | URL prefix for preview routes | -> Apps that also use barryvdh/laravel-dompdf (which claims the same `Pdf` alias) should import the facade explicitly: `use Papercut\Laravel\Facades\Pdf;`. +## HTML output + +The same document exports to HTML. `->html()` shares the theme, fonts, named styles and strict setting with `->pdf()`. + +```php +// A route: a full HTML page (Content-Type text/html; charset=UTF-8) +return Papercut::view('pdf.invoice', $data)->html(); + +// Download or save +Papercut::view('pdf.invoice', $data)->html()->page()->download('invoice.html'); +Papercut::view('pdf.invoice', $data)->html()->page()->save(storage_path('invoice.html')); + +// The embeddable fragment as a string or file +$fragment = Papercut::view('pdf.invoice', $data)->html()->fragment()->toString(); +``` + +Inside another Blade view, `{{ $doc->html() }}`, `{!! $doc->html() !!}` and `(string) $doc->html()` all give the embeddable fragment (one `.papercut` element, no ``), where `$doc` is `Papercut::view(...)` or `Papercut::make($document)`. Returning `->html()` from a route sends the full page. `PendingHtml` has no `toString()`, `save()` or `download()` of its own, so a string never means two things: `->html()->page()` and `->html()->fragment()` each return a `PendingOutput` (`toString()`, `save()`, `download()`, `inline()`, default name `document.html`). HTML reports approximations through the same warning log as PDF. Every approximation is reported (for example fr column widths, the page box, and header and footer once per page), and HTML reports nothing as dropped, so `strict` does not throw for it. + +### HTML URLs + +`html.image_url` and `html.font_url` name invokable classes, built by the container (constructor dependencies work). Use class names, not closures: a class name survives `php artisan config:cache`, and a closure in config is rejected with an error naming the key. Return `null` to embed an image as a data URI, or to skip a font face. + +```php +namespace App\Pdf; + +use Papercut\Image; +use Papercut\Laravel\Html\ImageUrlResolver; + +final class CdnImageUrl implements ImageUrlResolver +{ + public function __invoke(Image $image): ?string + { + ['kind' => $kind, 'value' => $value] = $image->source(); + + return $kind === 'file' ? asset('img/' . basename($value)) : null; // null = embed + } +} + +// config/papercut.php +'html' => ['fonts' => 'link', 'image_url' => \App\Pdf\CdnImageUrl::class, 'font_url' => \App\Pdf\CdnFontUrl::class], +``` + +Invalid `html.*` config is reported at the first `->html()` export, not at boot. + +### Font sources + +`html.fonts = 'sources'` sends each font family to the first source in `html.font_sources` that accepts it. It works with no other setting: the default list is Google Fonts, so each family becomes a `` to `fonts.googleapis.com`. For how sources, answers and fallbacks work, see "Fonts in HTML" in the [core README](../core/README.md). + +```php +// Bunny Fonts instead of Google: a host change +'html' => ['fonts' => 'sources', 'google_fonts' => ['host' => 'https://fonts.bunny.net']], + +// Fonts registered in papercut.fonts embedded; anything else from Google +'html' => [ + 'fonts' => 'sources', + 'font_sources' => [\Papercut\Html\LocalFonts::class, \Papercut\Html\GoogleFonts::class], + 'local_fonts' => ['families' => ['Acme']], // null = every registered family +], +``` + +A custom source is a class implementing `Papercut\Html\FontSource`, built by the container, so constructor dependencies work: + +```php +use Papercut\Html\{FontAnswer, FontRequest, FontSource}; + +final class BrandFonts implements FontSource +{ + public function __invoke(FontRequest $request): ?FontAnswer + { + return $request->family === 'Acme' ? FontAnswer::stylesheet('https://brand.example/fonts.css') : null; // null = ask the next source + } +} + +'font_sources' => [\App\Pdf\BrandFonts::class, \Papercut\Html\GoogleFonts::class], +``` + +- **Order.** The first source that answers wins, so list specific sources first and catch-all hosts such as Google last. +- **Privacy.** With `GoogleFonts` the visitor's browser fetches the stylesheet and fonts from the host, and so sends it their IP address. A German court held this can breach data protection law without consent (LG München I, 3 O 17493/20). Bunny Fonts is a host that states it does not log IP addresses, but the request still reaches a third party and this is not a compliance guarantee. The fully private routes are `LocalFonts` (embed, or link to your own files) and the `'embed'` and `'link'` modes. +- **CSP.** If your site sends a Content-Security-Policy, allow: `style-src` for the stylesheet host (`fonts.googleapis.com` for Google, `fonts.bunny.net` for Bunny); `font-src` for the font domain (`fonts.gstatic.com` for Google, `fonts.bunny.net` for Bunny); and `font-src data:` for embedded faces (`local_fonts` in its default `embed` mode). +- **Config is checked lazily.** `font_sources` is read only when `html.fonts` is `'sources'`, and `google_fonts` and `local_fonts` only when their class is in the list, at the first `->html()` export. Class names only: closures are rejected naming the key and index, for example `papercut.html.font_sources.1`. + +Preview routes accept `?format=pdf` (default) or `?format=html`; any other value is a 404. ## Tags @@ -78,23 +170,23 @@ Style attributes (next section) work on every tag marked "style" below. Values a | Tag | Own attributes | Notes | |---|---|---| -| `` | `title`, `author`, `subject`, `keywords`, `lang` | Root. Children must be ``; with no page, content gets an implicit page. No style attributes. | -| `` | `size`, `orientation` (`portrait`/`landscape`), `margin`, `header-gap`, `footer-gap` | Slots: ``, ``, ``. No style attributes. | -| `` | `align` | Style. Generic container. | -| `` | `align` | Style. Lays children out horizontally. | -| `` | `align` | Style. Lays children out vertically. | -| `` | `align`, `size` | Style. Inline markup allowed: ``, ``, ``, ``, `
`, `
`, ``. | -| `` ... `` | `align`, `size` | Style. Heading presets. | -| `` | `align`, `size` | Style. Paragraph preset. | -| `` | `src`, `data`, `fit` (`contain`/`cover`/`fill`) | Style. Exactly one of `src` or `data` (raw bytes). Absolute and scheme paths (`/x.png`, `C:\x.png`, `file://`) are trusted as given; relative paths are contained to `papercut.image_path` (`..` cannot escape it). Remote URLs are not supported. Never pass untrusted input to `src`. | -| `` | `href` | Style. Required `href`. | -| `` | `columns`, `repeat-header` | Style. Children: one optional `` first, then ``. `columns` is a track list such as `"3fr 25mm 20mm"`. `repeat-header` defaults to on. | -| `` | none | Contains ``/`` cells (one header row) or `` rows. | -| `` | none | Style. Contains ``/``. | -| `` / `` | `colspan`, `align` (`left`/`right`/`center`/`justify`) | Style. `th` is a header cell. | -| `` | `size` | `size="10mm"` is a fixed gap; with no `size` it is flexible (`grow`, default 1). Only `grow` is accepted besides `size`/`style`. | -| `` | `format`, `align`, `size` | Style. `format` uses `{page}` and `{total}`; no content allowed. | -| `` | none | Forces a new page. | +| `` | `title`, `author`, `subject`, `keywords`, `lang` | Root. Children must be ``; with no page, content gets an implicit page. No style attributes. | +| `` | `size`, `orientation` (`portrait`/`landscape`), `margin`, `header-gap`, `footer-gap` | Slots: ``, ``, ``. No style attributes. | +| `` | `align` | Style. Generic container. | +| `` | `align` | Style. Lays children out horizontally. | +| `` | `align` | Style. Lays children out vertically. | +| `` | `align`, `size` | Style. Inline markup allowed: ``, ``, ``, ``, `
`, `
`, ``. | +| `` ... `` | `align`, `size` | Style. Heading presets. | +| `` | `align`, `size` | Style. Paragraph preset. | +| `` | `src`, `data`, `fit` (`contain`/`cover`/`fill`) | Style. Exactly one of `src` or `data` (raw bytes). Absolute and scheme paths (`/x.png`, `C:\x.png`, `file://`) are trusted as given; relative paths are contained to `papercut.image_path` (`..` cannot escape it). Remote URLs are not supported. Never pass untrusted input to `src`. | +| `` | `href` | Style. Required `href`. | +| `` | `columns`, `repeat-header` | Style. Children: one optional `` first, then ``. `columns` is a track list such as `"3fr 25mm 20mm"`. `repeat-header` defaults to on. | +| `` | none | Contains ``/`` cells (one header row) or `` rows. | +| `` | none | Style. Contains ``/``. | +| `` / `` | `colspan`, `align` (`left`/`right`/`center`/`justify`) | Style. `th` is a header cell. | +| `` | `size` | `size="10mm"` is a fixed gap; with no `size` it is flexible (`grow`, default 1). Only `grow` is accepted besides `size`/`style`. | +| `` | `format`, `align`, `size` | Style. `format` uses `{page}` and `{total}`; no content allowed. | +| `` | none | Forces a new page. | On `row`, `column` and `view`, `align` is a shorthand for `align-items`. On text tags, `align` is `text-align` and `size` is `font-size`. Every style-capable tag also accepts `style="name"` (a [named style](#named-styles)). @@ -141,7 +233,7 @@ Define styles in `config/papercut.php`, using the same keys as tag attributes: ``` ```blade -... +... ``` The named style is the base; attributes written on the tag override it for any property both set. @@ -168,15 +260,15 @@ Inter (regular, bold, italic, bold italic) is bundled and always available. Regi For fast iteration on a layout, register a view with sample data (for example in a service provider) and enable previews: ```php -Pdf::preview('pdf.invoice', fn () => ['invoice' => Invoice::factory()->make()]); +Papercut::preview('pdf.invoice', fn () => ['invoice' => Invoice::factory()->make()]); ``` Set `PAPERCUT_PREVIEW=true`. Only in the `local` environment, `GET /_papercut` lists the registered views and `GET /_papercut/{view}` renders one inline. ## Errors -- `Papercut\Exception\UnknownAttribute` and `Papercut\Exception\InvalidValue`: reported with the tag (`on `), the attribute and, where possible, a suggestion. -- `Papercut\Laravel\Exception\InvalidStructure`: misplaced content, such as a `` outside a table, a missing `` root, or an unknown page slot. +- `Papercut\Exception\UnknownAttribute` and `Papercut\Exception\InvalidValue`: reported with the tag (`on `), the attribute and, where possible, a suggestion. +- `Papercut\Laravel\Exception\InvalidStructure`: misplaced content, such as a `` outside a table, a missing `` root, or an unknown page slot. - `Papercut\Exception\ImageUnreadable`: the `src` file is missing or cannot be read as an image. - `Papercut\Exception\LayoutOverflow`: a block cannot fit on a page even after every permitted split. Controlled by `overflow`: throw (default in `local`/`testing`) or clip. diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index 7a957d5..5c5a506 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -56,10 +56,13 @@ "Papercut\\Laravel\\PapercutServiceProvider" ], "aliases": { - "Pdf": "Papercut\\Laravel\\Facades\\Pdf" + "Papercut": "Papercut\\Laravel\\Facades\\Papercut" } } }, + "scripts": { + "analyse": "phpstan analyse --memory-limit=1G" + }, "minimum-stability": "dev", "prefer-stable": true, "config": { diff --git a/packages/laravel/config/papercut.php b/packages/laravel/config/papercut.php index 39f5a68..22ab757 100644 --- a/packages/laravel/config/papercut.php +++ b/packages/laravel/config/papercut.php @@ -1,7 +1,10 @@ when no size is given: a3|a4|a5|a6|letter|legal + // Blade tag prefix: , ... Lowercase letters, digits and dashes. + // Read at boot; templates written with another prefix fail with Blade's "Unable to locate a class or view". + 'prefix' => 'doc', + // Default page size used by when no size is given: a3|a4|a5|a6|letter|legal 'paper' => 'a4', 'margin' => '20mm', // Font families: name => [weight => ['normal' => path, 'italic' => path]]. Inter is always available. @@ -13,10 +16,43 @@ 'styles' => [], // throw | clip | null (null = throw in local/testing, clip elsewhere) 'overflow' => null, - // Base directory for relative + // Strict export: throw instead of dropping content a format cannot render. Consider true in tests. + 'strict' => false, + // Base directory for relative 'image_path' => null, // null = public_path() // Kerning cache directory: null = sys_get_temp_dir()/papercut-cache-, false = disabled, string = path 'cache_dir' => null, + 'html' => [ + // Fonts in HTML output: none (system-ui fallback stack) | embed (base64 @font-face, large) + // | link (@font-face with URLs from font_url; needs font_url) + // | sources (each family goes to the first class in font_sources that accepts it; works as is, via Google) + 'fonts' => 'none', + // Class name implementing Papercut\Laravel\Html\ImageUrlResolver, built by the container. + // null embeds images as data URIs. Use a class, not a closure: class names survive `php artisan config:cache`. + 'image_url' => null, + // Class name implementing Papercut\Laravel\Html\FontUrlResolver, built by the container. Needed when fonts is link. + 'font_url' => null, + // Used when fonts is sources: an ordered list of classes implementing Papercut\Html\FontSource, built by the container. + // Each font family goes to the first source that accepts it: list specific sources first, catch-all hosts such as Google last. + // PRIVACY: with GoogleFonts the visitor's browser fetches the CSS and fonts from the host and sends it their IP address + // (see core's "Fonts in HTML"). Bunny Fonts (fonts.bunny.net) is a host that states it does not log IP addresses, but the + // request still reaches a third party. Only local_fonts (your own files, embedded or linked), or fonts = embed or link, + // keeps it all on your side. + // Check your CSP: style-src, font-src and, for embedded fonts, font-src data:. + // Class names only (they survive `php artisan config:cache`). Read only when fonts is sources. + 'font_sources' => [\Papercut\Html\GoogleFonts::class], + // Settings for the GoogleFonts source. Checked only when GoogleFonts is in font_sources. + 'google_fonts' => [ + 'host' => 'https://fonts.googleapis.com', // or https://fonts.bunny.net + 'display' => 'swap', // auto|block|swap|fallback|optional + 'families' => null, // null = every family; or a list of names (allowlist) + ], + // Settings for the LocalFonts source (families registered in papercut.fonts). Checked only when LocalFonts is in font_sources. + 'local_fonts' => [ + 'mode' => 'embed', // embed | link (link uses font_url) + 'families' => null, // null = every registered family; or a list of names + ], + ], 'preview' => [ 'enabled' => env('PAPERCUT_PREVIEW', false), 'path' => '_papercut', diff --git a/packages/laravel/src/Components/Column.php b/packages/laravel/src/Components/Column.php index 6008f26..4445ce0 100644 --- a/packages/laravel/src/Components/Column.php +++ b/packages/laravel/src/Components/Column.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class Column extends PdfComponent +final class Column extends PapercutComponent { protected function aliases(): array { diff --git a/packages/laravel/src/Components/Document.php b/packages/laravel/src/Components/Document.php index 386b11c..afac38b 100644 --- a/packages/laravel/src/Components/Document.php +++ b/packages/laravel/src/Components/Document.php @@ -4,10 +4,11 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\{InvalidValue, UnknownAttribute}; use Papercut\Laravel\Exception\InvalidStructure; -final class Document extends PdfComponent +final class Document extends PapercutComponent { protected function ownAttributes(): array { @@ -18,29 +19,29 @@ protected function build(array $data): object { ['own' => $own, 'style' => $style] = $this->attrs($data); foreach (array_keys($style) as $key) { - throw UnknownAttribute::for($key)->within('on '); + throw UnknownAttribute::for($key)->within('on ' . Tag::of('document')); } if (isset($own['style'])) { - throw UnknownAttribute::for('style')->within('on '); + throw UnknownAttribute::for('style')->within('on ' . Tag::of('document')); } $children = $this->rawChildren($data, []); $pages = array_values(array_filter($children, static fn (object $n): bool => $n instanceof \Papercut\Page)); if ($pages === []) { if ($children === []) { - throw new InvalidStructure(' must contain at least one '); + throw new InvalidStructure(Tag::of('document') . ' must contain at least one ' . Tag::of('page')); } /** @var list<\Papercut\Node> $children */ $pages = [Page::implicit($children)]; } elseif (count($pages) !== count($children)) { - throw new InvalidStructure(' children must all be ; put other content inside a page'); + throw new InvalidStructure(Tag::of('document') . ' children must all be ' . Tag::of('page') . '; put other content inside a page'); } $doc = \Papercut\Document::make(...$pages); foreach (['title', 'author', 'subject', 'keywords', 'lang'] as $key) { if (isset($own[$key])) { if (!is_scalar($own[$key])) { - throw InvalidValue::for($key, get_debug_type($own[$key]))->withContext($key)->within('on '); + throw InvalidValue::for($key, get_debug_type($own[$key]))->withContext($key)->within('on ' . Tag::of('document')); } $doc = $doc->$key((string) $own[$key]); } diff --git a/packages/laravel/src/Components/H1.php b/packages/laravel/src/Components/H1.php index e7cc68a..d33fe84 100644 --- a/packages/laravel/src/Components/H1.php +++ b/packages/laravel/src/Components/H1.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class H1 extends PdfComponent +final class H1 extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/H2.php b/packages/laravel/src/Components/H2.php index 9fae762..9806ea7 100644 --- a/packages/laravel/src/Components/H2.php +++ b/packages/laravel/src/Components/H2.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class H2 extends PdfComponent +final class H2 extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/H3.php b/packages/laravel/src/Components/H3.php index 336d96d..134baa9 100644 --- a/packages/laravel/src/Components/H3.php +++ b/packages/laravel/src/Components/H3.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class H3 extends PdfComponent +final class H3 extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/H4.php b/packages/laravel/src/Components/H4.php index d43ba74..34814e1 100644 --- a/packages/laravel/src/Components/H4.php +++ b/packages/laravel/src/Components/H4.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class H4 extends PdfComponent +final class H4 extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/HeaderRows.php b/packages/laravel/src/Components/HeaderRows.php index 208493e..41fcf08 100644 --- a/packages/laravel/src/Components/HeaderRows.php +++ b/packages/laravel/src/Components/HeaderRows.php @@ -6,7 +6,7 @@ use Papercut\TableRow; -/** Internal value object returned by ; only accepts it. */ +/** Internal value object returned by ; only accepts it. */ final readonly class HeaderRows { /** @param list $rows */ diff --git a/packages/laravel/src/Components/Image.php b/packages/laravel/src/Components/Image.php index e697eaa..3455d9b 100644 --- a/packages/laravel/src/Components/Image.php +++ b/packages/laravel/src/Components/Image.php @@ -4,11 +4,12 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\{ImageUnreadable, InvalidValue}; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\Style\ImageFit; -final class Image extends PdfComponent +final class Image extends PapercutComponent { protected function ownAttributes(): array { @@ -21,17 +22,17 @@ protected function build(array $data): object $src = $own['src'] ?? null; $bytes = $own['data'] ?? null; if (($src === null) === ($bytes === null)) { - throw new InvalidStructure(' requires exactly one of src or data'); + throw new InvalidStructure(Tag::of('image') . ' requires exactly one of src or data'); } if (($src !== null && !is_string($src)) || ($bytes !== null && !is_string($bytes))) { - throw new InvalidStructure(' src must be a string path'); + throw new InvalidStructure(Tag::of('image') . ' src must be a string path'); } if ($src !== null) { try { $node = \Papercut\Image::file(self::resolvePath($src)); } catch (ImageUnreadable $e) { - throw new ImageUnreadable($e->getMessage() . ' on ', 0, $e); + throw new ImageUnreadable($e->getMessage() . ' on ' . rtrim(Tag::of('image'), '>') . ' src="' . $src . '">', 0, $e); } } else { $node = \Papercut\Image::bytes((string) $bytes); @@ -39,7 +40,7 @@ protected function build(array $data): object if (isset($own['fit'])) { $fit = ImageFit::tryFrom((string) $own['fit']) - ?? throw InvalidValue::for('fit', (string) $own['fit'])->because('expected contain, cover or fill')->withContext('fit')->within('on '); + ?? throw InvalidValue::for('fit', (string) $own['fit'])->because('expected contain, cover or fill')->withContext('fit')->within('on ' . Tag::of('image')); $node = $node->fit($fit); } diff --git a/packages/laravel/src/Components/Link.php b/packages/laravel/src/Components/Link.php index 254951c..e24c6fe 100644 --- a/packages/laravel/src/Components/Link.php +++ b/packages/laravel/src/Components/Link.php @@ -4,9 +4,10 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Laravel\Exception\InvalidStructure; -final class Link extends PdfComponent +final class Link extends PapercutComponent { protected function ownAttributes(): array { @@ -20,7 +21,7 @@ protected function build(array $data): object $href = (string) $href; } if (!is_string($href) || $href === '') { - throw new InvalidStructure(' href must be a non-empty string'); + throw new InvalidStructure(Tag::of('link') . ' href must be a non-empty string'); } return $this->styled(\Papercut\Link::to($href, ...$this->children($data)), $data); diff --git a/packages/laravel/src/Components/P.php b/packages/laravel/src/Components/P.php index 9a6c9ed..3e56917 100644 --- a/packages/laravel/src/Components/P.php +++ b/packages/laravel/src/Components/P.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class P extends PdfComponent +final class P extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/Page.php b/packages/laravel/src/Components/Page.php index 2225e08..8c09b5e 100644 --- a/packages/laravel/src/Components/Page.php +++ b/packages/laravel/src/Components/Page.php @@ -4,13 +4,14 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\{InvalidValue, UnknownAttribute}; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\Laravel\Rendering\NodeRegistry; use Papercut\Laravel\Rendering\Segments; use Papercut\Value\{Edges, Length}; -final class Page extends PdfComponent +final class Page extends PapercutComponent { private const array SLOTS = ['header', 'footer', 'background']; @@ -22,7 +23,7 @@ protected function ownAttributes(): array protected function build(array $data): object { ['own' => $own, 'style' => $style] = $this->attrs($data); - $location = 'on '; + $location = 'on ' . Tag::of('page'); foreach (array_keys($style) as $key) { throw UnknownAttribute::for($key)->within($location); } @@ -33,7 +34,7 @@ protected function build(array $data): object $slots = $this->namedSlots($data); foreach (array_keys($slots) as $name) { if (!in_array($name, self::SLOTS, true)) { - throw new InvalidStructure("Unknown slot \"$name\" on ; allowed slots: " . implode(', ', self::SLOTS)); + throw new InvalidStructure("Unknown slot \"$name\" on " . Tag::of('page') . "; allowed slots: " . implode(', ', self::SLOTS)); } } @@ -64,7 +65,7 @@ public static function implicit(array $body): \Papercut\Page /** @param array $own */ private static function configured(array $own): \Papercut\Page { - $location = 'on '; + $location = 'on ' . Tag::of('page'); try { $size = $own['size'] ?? config('papercut.paper', 'a4'); if (!is_string($size)) { diff --git a/packages/laravel/src/Components/PageBreak.php b/packages/laravel/src/Components/PageBreak.php index 07f1907..cd6f94f 100644 --- a/packages/laravel/src/Components/PageBreak.php +++ b/packages/laravel/src/Components/PageBreak.php @@ -4,15 +4,16 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\UnknownAttribute; -final class PageBreak extends PdfComponent +final class PageBreak extends PapercutComponent { protected function build(array $data): object { $attrs = $this->attrs($data); foreach (array_keys([...$attrs['own'], ...$attrs['style']]) as $key) { - throw UnknownAttribute::for((string) $key)->within('on '); + throw UnknownAttribute::for((string) $key)->within('on ' . Tag::of('page-break')); } return \Papercut\PageBreak::make(); diff --git a/packages/laravel/src/Components/PageNumber.php b/packages/laravel/src/Components/PageNumber.php index 35fd5eb..4f2d837 100644 --- a/packages/laravel/src/Components/PageNumber.php +++ b/packages/laravel/src/Components/PageNumber.php @@ -4,10 +4,11 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\InvalidValue; use Papercut\Laravel\Exception\InvalidStructure; -final class PageNumber extends PdfComponent +final class PageNumber extends PapercutComponent { protected function ownAttributes(): array { @@ -17,11 +18,11 @@ protected function ownAttributes(): array protected function build(array $data): object { if (trim($this->text($data)) !== '') { - throw new InvalidStructure(' must not have content; use the format attribute'); + throw new InvalidStructure(Tag::of('page-number') . ' must not have content; use the format attribute'); } $format = $this->attrs($data)['own']['format'] ?? '{page}'; if (!is_string($format)) { - throw InvalidValue::for('format', is_scalar($format) ? (string) $format : get_debug_type($format))->withContext('format')->within('on '); + throw InvalidValue::for('format', is_scalar($format) ? (string) $format : get_debug_type($format))->withContext('format')->within('on ' . Tag::of('page-number')); } return $this->textStyled(\Papercut\PageNumber::make($format), $data); diff --git a/packages/laravel/src/Components/PdfComponent.php b/packages/laravel/src/Components/PapercutComponent.php similarity index 96% rename from packages/laravel/src/Components/PdfComponent.php rename to packages/laravel/src/Components/PapercutComponent.php index b4431e4..b815b33 100644 --- a/packages/laravel/src/Components/PdfComponent.php +++ b/packages/laravel/src/Components/PapercutComponent.php @@ -4,17 +4,18 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Closure; use Illuminate\View\Component; use Illuminate\View\ComponentSlot; use Papercut\Exception\InvalidValue; use Papercut\Style\Style; use Papercut\Exception\UnknownAttribute; -use Papercut\Laravel\PdfManager; +use Papercut\Laravel\PapercutManager; use Papercut\Laravel\Rendering\NodeRegistry; use Papercut\Laravel\Rendering\Segments; -abstract class PdfComponent extends Component +abstract class PapercutComponent extends Component { final public function render(): string { @@ -81,7 +82,7 @@ protected function aliases(): array */ protected function applyStyle(object $node, array $style, mixed $styleName = null): object { - $location = 'on tag() . '>'; + $location = 'on ' . Tag::of($this->tag()); $aliases = $this->aliases(); try { $plain = []; @@ -101,7 +102,7 @@ protected function applyStyle(object $node, array $style, mixed $styleName = nul $node = $node->attributes($plain); } if (is_string($styleName)) { - app(PdfManager::class)->options()->theme()->named($styleName); + app(PapercutManager::class)->pdfOptions()->theme()->named($styleName); $node = $node->apply($styleName); } elseif ($styleName instanceof Style) { $node = $node->apply($styleName); diff --git a/packages/laravel/src/Components/Row.php b/packages/laravel/src/Components/Row.php index 905a4b4..b3df10b 100644 --- a/packages/laravel/src/Components/Row.php +++ b/packages/laravel/src/Components/Row.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class Row extends PdfComponent +final class Row extends PapercutComponent { protected function aliases(): array { diff --git a/packages/laravel/src/Components/Spacer.php b/packages/laravel/src/Components/Spacer.php index 0ff9448..f156496 100644 --- a/packages/laravel/src/Components/Spacer.php +++ b/packages/laravel/src/Components/Spacer.php @@ -4,10 +4,11 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\{InvalidValue, UnknownAttribute}; use Papercut\Value\Length; -final class Spacer extends PdfComponent +final class Spacer extends PapercutComponent { protected function ownAttributes(): array { @@ -17,7 +18,7 @@ protected function ownAttributes(): array protected function build(array $data): object { ['own' => $own, 'style' => $style] = $this->attrs($data); - $location = 'on '; + $location = 'on ' . Tag::of('spacer'); foreach (array_keys($style) as $key) { if ($key !== 'grow') { throw UnknownAttribute::for($key)->within($location); @@ -25,7 +26,7 @@ protected function build(array $data): object } if (isset($own['size']) && array_key_exists('grow', $style)) { - throw new \Papercut\Laravel\Exception\InvalidStructure(' takes size or grow, not both'); + throw new \Papercut\Laravel\Exception\InvalidStructure(Tag::of('spacer') . ' takes size or grow, not both'); } if (isset($own['size'])) { diff --git a/packages/laravel/src/Components/Table.php b/packages/laravel/src/Components/Table.php index cb12561..8762557 100644 --- a/packages/laravel/src/Components/Table.php +++ b/packages/laravel/src/Components/Table.php @@ -4,11 +4,12 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\TableRow; use Papercut\Value\TrackList; -final class Table extends PdfComponent +final class Table extends PapercutComponent { protected function ownAttributes(): array { @@ -47,7 +48,7 @@ protected function build(array $data): object $table = $table->bodyRow($child); } else { $name = $child instanceof HeaderRows ? 'thead' : (method_exists($child, 'debugName') ? $child->debugName() : get_debug_type($child)); - throw new InvalidStructure(" can only contain (once, first) and , found <$name>"); + throw new InvalidStructure(Tag::of('table') . ' can only contain ' . Tag::of('thead') . ' (once, first) and ' . Tag::of('tr') . ", found <$name>"); } } diff --git a/packages/laravel/src/Components/Td.php b/packages/laravel/src/Components/Td.php index b51a0a0..54eab55 100644 --- a/packages/laravel/src/Components/Td.php +++ b/packages/laravel/src/Components/Td.php @@ -4,11 +4,12 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Exception\InvalidValue; use Papercut\Style\TextAlign; use Papercut\TableCell; -class Td extends PdfComponent +class Td extends PapercutComponent { protected function ownAttributes(): array { @@ -21,7 +22,7 @@ protected function cell(array $data, bool $header): TableCell ['own' => $own] = $this->attrs($data); $children = $this->children($data); $cell = $header ? TableCell::th(...$children) : TableCell::make(...$children); - $where = 'on tag() . '>'; + $where = 'on ' . Tag::of($this->tag()); if (isset($own['colspan'])) { $raw = $own['colspan']; diff --git a/packages/laravel/src/Components/Text.php b/packages/laravel/src/Components/Text.php index 3da1f11..28d2026 100644 --- a/packages/laravel/src/Components/Text.php +++ b/packages/laravel/src/Components/Text.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class Text extends PdfComponent +final class Text extends PapercutComponent { protected function ownAttributes(): array { diff --git a/packages/laravel/src/Components/Thead.php b/packages/laravel/src/Components/Thead.php index d34dbcf..f83c834 100644 --- a/packages/laravel/src/Components/Thead.php +++ b/packages/laravel/src/Components/Thead.php @@ -4,11 +4,12 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\TableCell; use Papercut\TableRow; -final class Thead extends PdfComponent +final class Thead extends PapercutComponent { protected function build(array $data): object { @@ -16,10 +17,10 @@ protected function build(array $data): object $cells = array_filter($children, fn ($c) => $c instanceof TableCell); $rows = array_filter($children, fn ($c) => $c instanceof TableRow); if ($children === []) { - throw new InvalidStructure(' must not be empty'); + throw new InvalidStructure(Tag::of('thead') . ' must not be empty'); } if (count($cells) + count($rows) !== count($children) || ($cells !== [] && $rows !== [])) { - throw new InvalidStructure(' can only contain / cells or rows, not both'); + throw new InvalidStructure(Tag::of('thead') . ' can only contain ' . Tag::of('th') . '/' . Tag::of('td') . ' cells or ' . Tag::of('tr') . ' rows, not both'); } if ($cells !== []) { /** @var list $list */ diff --git a/packages/laravel/src/Components/Tr.php b/packages/laravel/src/Components/Tr.php index e157071..707ad88 100644 --- a/packages/laravel/src/Components/Tr.php +++ b/packages/laravel/src/Components/Tr.php @@ -4,18 +4,19 @@ namespace Papercut\Laravel\Components; +use Papercut\Laravel\Tag; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\TableCell; use Papercut\TableRow; -final class Tr extends PdfComponent +final class Tr extends PapercutComponent { protected function build(array $data): object { $cells = []; foreach ($this->rawChildren($data, [TableCell::class]) as $child) { if (!$child instanceof TableCell) { - throw new InvalidStructure(' can only contain or '); + throw new InvalidStructure(Tag::of('tr') . ' can only contain ' . Tag::of('td') . ' or ' . Tag::of('th')); } $cells[] = $child; } diff --git a/packages/laravel/src/Components/View.php b/packages/laravel/src/Components/View.php index 9dd1254..4e52f7b 100644 --- a/packages/laravel/src/Components/View.php +++ b/packages/laravel/src/Components/View.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Components; -final class View extends PdfComponent +final class View extends PapercutComponent { protected function aliases(): array { diff --git a/packages/laravel/src/Facades/Papercut.php b/packages/laravel/src/Facades/Papercut.php new file mode 100644 index 0000000..7e93ca2 --- /dev/null +++ b/packages/laravel/src/Facades/Papercut.php @@ -0,0 +1,24 @@ + $data = []) + * @method static \Papercut\Laravel\PendingDocument make(\Papercut\Document $doc) + * @method static \Papercut\Export\PdfOptions pdfOptions() + * @method static void preview(string $view, \Closure|array $data = []) + * + * @see PapercutManager + */ +final class Papercut extends Facade +{ + protected static function getFacadeAccessor(): string + { + return PapercutManager::class; + } +} diff --git a/packages/laravel/src/Facades/Pdf.php b/packages/laravel/src/Facades/Pdf.php deleted file mode 100644 index f446dc5..0000000 --- a/packages/laravel/src/Facades/Pdf.php +++ /dev/null @@ -1,24 +0,0 @@ - $data = []) - * @method static \Papercut\RenderOptions options() - * @method static \Papercut\Laravel\PendingPdf make(\Papercut\Document $doc) - * @method static void preview(string $view, \Closure|array $data = []) - * - * @see PdfManager - */ -final class Pdf extends Facade -{ - protected static function getFacadeAccessor(): string - { - return PdfManager::class; - } -} diff --git a/packages/laravel/src/Html/FontUrlResolver.php b/packages/laravel/src/Html/FontUrlResolver.php new file mode 100644 index 0000000..f2e3525 --- /dev/null +++ b/packages/laravel/src/Html/FontUrlResolver.php @@ -0,0 +1,16 @@ + $data */ + public function preview(string $view, \Closure|array $data = []): void + { + $this->previews->register($view, $data); + } + + /** @param array $data */ + public function view(string $view, array $data = []): PendingDocument + { + $document = $this->registry->scope(function () use ($view, $data): object { + $html = view($view, $data)->render(); + $segments = array_values(array_filter( + $this->registry->segments($html), + static fn (object|string $s): bool => !is_string($s) || trim($s) !== '', + )); + + if (count($segments) !== 1 || !$segments[0] instanceof Document) { + throw new InvalidStructure('A Papercut view must have exactly one ' . Tag::of('document') . ' root; found ' . $this->describe($segments) . " in view [$view]"); + } + if ($this->registry->remaining() > 0) { + if (preg_match('/\[\[pc:[0-9a-f]{16}:\d+\]\]/i', $html) === 1) { + throw new InvalidStructure('A slot containing ' . Tag::any() . " tags was modified as a string (e.g. strtoupper(\$slot)) in view [$view]"); + } + throw new InvalidStructure('Some ' . Tag::any() . " elements were rendered outside the document in view [$view]"); + } + + return $segments[0]; + }, $view); + assert($document instanceof Document); + + return $this->make($document); + } + + public function make(Document $doc): PendingDocument + { + return new PendingDocument($doc, $this); + } + + /** Options shared by every format: theme, fonts, warning sink and strict mode. */ + public function exportOptions(): ExportOptions + { + return $this->export ??= $this->buildExportOptions(); + } + + public function pdfOptions(): PdfOptions + { + return $this->pdf ??= $this->buildPdfOptions(); + } + + public function htmlOptions(): HtmlOptions + { + return $this->html ??= $this->buildHtmlOptions(); + } + + private function buildExportOptions(): ExportOptions + { + $fonts = (new FontSet(array_map( + static fn (array $face): FontFace => new FontFace($face['family'], $face['path'], $face['weight'], $face['style']), + FontConfig::faces(), + )))->withFallbacks(...FontConfig::fallbacks()); + + $cfg = (array) config('papercut.theme', []); + try { + $color = isset($cfg['color']) ? Color::parse((string) $cfg['color']) : null; + } catch (InvalidValue $e) { + throw $e->within('in config papercut.theme.color'); + } + $theme = Theme::default()->with( + fontFamily: isset($cfg['font_family']) ? (string) $cfg['font_family'] : null, + fontSize: self::positive($cfg, 'font_size'), + color: $color, + lineHeight: self::positive($cfg, 'line_height'), + ); + /** @var array> $styles */ + $styles = (array) config('papercut.styles', []); + foreach ($styles as $name => $attrs) { + try { + $theme = $theme->withStyle((string) $name, Style::fromAttributes($attrs)); + } catch (InvalidValue|UnknownAttribute $e) { + throw $e->within("in config papercut.styles.$name"); + } + } + + return new ExportOptions( + fonts: $fonts, + theme: $theme, + onWarning: static function (string $msg, ?FidelityReport $report = null): void { + if ($report?->level === FidelityLevel::Approximated) { + logger()->debug('[papercut] ' . $msg); + } else { + logger()->warning('[papercut] ' . $msg); + } + }, + strict: self::strict(), + ); + } + + private function buildPdfOptions(): PdfOptions + { + $overflow = config('papercut.overflow'); + $key = is_string($overflow) ? strtolower($overflow) : $overflow; + if ($key !== null && $key !== 'throw' && $key !== 'clip') { + throw new \InvalidArgumentException('Invalid papercut.overflow: ' . (is_scalar($overflow) ? var_export($overflow, true) : get_debug_type($overflow)) . '; expected throw, clip or null'); + } + $mode = match ($key) { + 'throw' => OverflowMode::Throw, + 'clip' => OverflowMode::Clip, + default => app()->environment('local', 'testing') ? OverflowMode::Throw : OverflowMode::Clip, + }; + + $base = $this->exportOptions(); + + return new PdfOptions( + theme: $base->theme(), + fonts: $base->fonts(), + onWarning: $base->onWarning(), + strict: $base->strict(), + overflow: $mode, + cacheDir: self::cacheDir(), + ); + } + + private function buildHtmlOptions(): HtmlOptions + { + $fonts = config('papercut.html.fonts', 'none'); + $mode = match (is_string($fonts) ? strtolower($fonts) : null) { + 'none' => FontMode::None, + 'embed' => FontMode::Embed, + 'link' => FontMode::Link, + 'sources' => FontMode::Sources, + default => throw new \InvalidArgumentException('Invalid papercut.html.fonts: ' . (is_scalar($fonts) ? var_export($fonts, true) : get_debug_type($fonts)) . '; expected none, embed, link or sources'), + }; + + $imageUrl = self::resolver('image_url', Html\ImageUrlResolver::class); + $fontUrl = self::fontUrlResolver(); + if ($mode === FontMode::Link && $fontUrl === null) { + throw new \InvalidArgumentException('papercut.html.fonts is link, which needs papercut.html.font_url'); + } + + $sources = $mode === FontMode::Sources ? self::fontSources() : []; + + $base = $this->exportOptions(); + + return new HtmlOptions( + theme: $base->theme(), + fonts: $base->fonts(), + onWarning: $base->onWarning(), + strict: $base->strict(), + fontMode: $mode, + imageUrl: $imageUrl, + fontUrl: $fontUrl, + fontSources: $sources, + ); + } + + /** The `html.font_url` resolver, or null when unset. Also used by the LocalFonts binding. */ + public static function fontUrlResolver(): ?\Closure + { + return self::resolver('font_url', Html\FontUrlResolver::class); + } + + /** @return list */ + private static function fontSources(): array + { + $classes = config('papercut.html.font_sources', [\Papercut\Html\GoogleFonts::class]); + if (!is_array($classes) || !array_is_list($classes)) { + throw new \InvalidArgumentException('Invalid papercut.html.font_sources: ' . (is_scalar($classes) ? var_export($classes, true) : get_debug_type($classes)) . '; expected a list of class names implementing ' . FontSource::class); + } + if ($classes === []) { + throw new \InvalidArgumentException('papercut.html.fonts is sources, which needs at least one class in papercut.html.font_sources'); + } + $sources = []; + foreach ($classes as $i => $class) { + $key = "font_sources.$i"; + if (!is_string($class) || !is_subclass_of($class, FontSource::class)) { + throw new \InvalidArgumentException("Invalid papercut.html.$key: " . (is_scalar($class) ? var_export($class, true) : get_debug_type($class)) . '; expected an invokable class name implementing ' . FontSource::class); + } + /** @var FontSource $instance */ + $instance = self::build($key, $class); + $sources[] = $instance; + } + + return $sources; + } + + /** Builds a class through the container; a factory's own InvalidArgumentException passes through unchanged. */ + private static function build(string $key, string $class): object + { + try { + return app($class); + } catch (\Illuminate\Contracts\Container\BindingResolutionException $e) { + $inner = $e->getPrevious(); + if ($inner instanceof \InvalidArgumentException) { + throw $inner; + } + throw new \InvalidArgumentException("Cannot build papercut.html.$key resolver " . var_export($class, true) . ': ' . $e->getMessage(), 0, $e); + } + } + + /** + * @param class-string $interface + */ + private static function resolver(string $key, string $interface): ?\Closure + { + $class = config('papercut.html.' . $key); + if ($class === null) { + return null; + } + if (!is_string($class) || !is_subclass_of($class, $interface)) { + throw new \InvalidArgumentException("Invalid papercut.html.$key: " . (is_scalar($class) ? var_export($class, true) : get_debug_type($class)) . "; expected an invokable class name implementing $interface or null"); + } + /** @var Html\ImageUrlResolver|Html\FontUrlResolver $instance */ + $instance = self::build($key, $class); + + return $instance(...); + } + + private static function strict(): bool + { + $strict = config('papercut.strict', false); + if (!is_bool($strict)) { + throw new \InvalidArgumentException('Invalid papercut.strict: ' . (is_scalar($strict) ? var_export($strict, true) : get_debug_type($strict)) . '; expected true or false'); + } + + return $strict; + } + + private static function cacheDir(): string|false|null + { + $dir = config('papercut.cache_dir'); + if ($dir === null || $dir === false || (is_string($dir) && $dir !== '')) { + return $dir; + } + throw new \InvalidArgumentException('Invalid papercut.cache_dir: ' . (is_scalar($dir) ? var_export($dir, true) : get_debug_type($dir)) . '; expected a directory path, false or null'); + } + + /** @param array $cfg */ + private static function positive(array $cfg, string $key): ?float + { + if (!isset($cfg[$key])) { + return null; + } + $v = $cfg[$key]; + if (!is_numeric($v) || (float) $v <= 0) { + throw new \InvalidArgumentException("Invalid papercut.theme.$key: " . (is_scalar($v) ? var_export($v, true) : get_debug_type($v)) . '; expected a number greater than 0'); + } + + return (float) $v; + } + + /** @param list $segments */ + private function describe(array $segments): string + { + if ($segments === []) { + return 'nothing'; + } + if (count($segments) > 1) { + return count($segments) . ' nodes'; + } + $only = $segments[0]; + if (is_string($only)) { + return 'text'; + } + $short = substr((string) strrchr('\\' . $only::class, '\\'), 1); + + return 'a <' . strtolower((string) preg_replace('/(?'; + } +} diff --git a/packages/laravel/src/PapercutServiceProvider.php b/packages/laravel/src/PapercutServiceProvider.php index d43e7e4..402646e 100644 --- a/packages/laravel/src/PapercutServiceProvider.php +++ b/packages/laravel/src/PapercutServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Route; +use Papercut\Html\{GoogleFonts, LocalFonts}; use Papercut\Laravel\Console\FontsCommand; use Papercut\Laravel\Preview\{PreviewController, PreviewRegistry}; use Papercut\Laravel\Rendering\NodeRegistry; @@ -21,7 +22,38 @@ public function register(): void $this->app->singleton(PreviewRegistry::class); - $this->app->scoped(PdfManager::class); + $this->app->scoped(PapercutManager::class); + + // Config is read and checked when a source is first built (the first sources-mode export), not at boot. + $this->app->bind(GoogleFonts::class, static function (): GoogleFonts { + $host = config('papercut.html.google_fonts.host', 'https://fonts.googleapis.com'); + if (!is_string($host) || !GoogleFonts::validHost($host)) { + throw new \InvalidArgumentException('Invalid papercut.html.google_fonts.host: ' . self::show($host) . '; expected an http or https URL with no query or fragment'); + } + $display = config('papercut.html.google_fonts.display', 'swap'); + if (!is_string($display) || !in_array($display, GoogleFonts::DISPLAYS, true)) { + throw new \InvalidArgumentException('Invalid papercut.html.google_fonts.display: ' . self::show($display) . '; expected ' . self::joinOr(GoogleFonts::DISPLAYS)); + } + + return new GoogleFonts($host, $display, self::families('google_fonts')); + }); + + $this->app->bind(LocalFonts::class, static function (): LocalFonts { + $mode = config('papercut.html.local_fonts.mode', 'embed'); + if ($mode !== 'embed' && $mode !== 'link') { + throw new \InvalidArgumentException('Invalid papercut.html.local_fonts.mode: ' . self::show($mode) . '; expected embed or link'); + } + $families = self::families('local_fonts'); + if ($mode === 'embed') { + return LocalFonts::embed($families); + } + $fontUrl = PapercutManager::fontUrlResolver(); + if ($fontUrl === null) { + throw new \InvalidArgumentException('papercut.html.local_fonts.mode is link, which needs papercut.html.font_url'); + } + + return LocalFonts::link($fontUrl, $families); + }); } public function boot(): void @@ -34,7 +66,7 @@ public function boot(): void $this->commands([FontsCommand::class]); } - Blade::componentNamespace('Papercut\\Laravel\\Components', 'pdf'); + Blade::componentNamespace('Papercut\\Laravel\\Components', Tag::prefix()); if (config('papercut.preview.enabled') && $this->app->environment('local')) { Route::prefix((string) config('papercut.preview.path', '_papercut'))->group(static function (): void { @@ -43,4 +75,31 @@ public function boot(): void }); } } + + private static function show(mixed $v): string + { + return is_scalar($v) ? var_export($v, true) : get_debug_type($v); + } + + /** @param list $items */ + private static function joinOr(array $items): string + { + $last = array_pop($items); + + return implode(', ', $items) . ' or ' . $last; + } + + /** @return list|null */ + private static function families(string $block): ?array + { + $families = config("papercut.html.$block.families"); + if ($families === null) { + return null; + } + if (!is_array($families) || !array_is_list($families) || array_filter($families, static fn (mixed $f): bool => !is_string($f)) !== []) { + throw new \InvalidArgumentException("Invalid papercut.html.$block.families: " . self::show($families) . '; expected null or a list of family names'); + } + + return $families; + } } diff --git a/packages/laravel/src/PdfManager.php b/packages/laravel/src/PdfManager.php deleted file mode 100644 index 5fa90f5..0000000 --- a/packages/laravel/src/PdfManager.php +++ /dev/null @@ -1,164 +0,0 @@ - $data */ - public function preview(string $view, \Closure|array $data = []): void - { - $this->previews->register($view, $data); - } - - /** @param array $data */ - public function view(string $view, array $data = []): PendingPdf - { - $document = $this->registry->scope(function () use ($view, $data): object { - $html = view($view, $data)->render(); - $segments = array_values(array_filter( - $this->registry->segments($html), - static fn (object|string $s): bool => !is_string($s) || trim($s) !== '', - )); - - if (count($segments) !== 1 || !$segments[0] instanceof Document) { - throw new InvalidStructure('A Papercut view must have exactly one root; found ' . $this->describe($segments) . " in view [$view]"); - } - if ($this->registry->remaining() > 0) { - if (preg_match('/\[\[pc:[0-9a-f]{16}:\d+\]\]/i', $html) === 1) { - throw new InvalidStructure("A slot containing tags was modified as a string (e.g. strtoupper(\$slot)) in view [$view]"); - } - throw new InvalidStructure("Some elements were rendered outside the document in view [$view]"); - } - - return $segments[0]; - }, $view); - assert($document instanceof Document); - - return $this->make($document); - } - - public function make(Document $doc): PendingPdf - { - return new PendingPdf($doc, $this->options()); - } - - public function options(): RenderOptions - { - return $this->options ??= $this->buildOptions(); - } - - private function buildOptions(): RenderOptions - { - $backend = new PhpPdfBackend(self::cacheDir()); - $fonts = FontRegistry::withDefaults($backend); - foreach (FontConfig::faces() as $face) { - $fonts->register($face['family'], $face['path'], $face['weight'], $face['style']); - } - - $cfg = (array) config('papercut.theme', []); - try { - $color = isset($cfg['color']) ? Color::parse((string) $cfg['color']) : null; - } catch (InvalidValue $e) { - throw $e->within('in config papercut.theme.color'); - } - $theme = Theme::default()->with( - fontFamily: isset($cfg['font_family']) ? (string) $cfg['font_family'] : null, - fontSize: self::positive($cfg, 'font_size'), - color: $color, - lineHeight: self::positive($cfg, 'line_height'), - ); - /** @var array> $styles */ - $styles = (array) config('papercut.styles', []); - foreach ($styles as $name => $attrs) { - try { - $theme = $theme->withStyle((string) $name, Style::fromAttributes($attrs)); - } catch (InvalidValue|UnknownAttribute $e) { - throw $e->within("in config papercut.styles.$name"); - } - } - - $overflow = config('papercut.overflow'); - $key = is_string($overflow) ? strtolower($overflow) : $overflow; - if ($key !== null && $key !== 'throw' && $key !== 'clip') { - throw new \InvalidArgumentException('Invalid papercut.overflow: ' . (is_scalar($overflow) ? var_export($overflow, true) : get_debug_type($overflow)) . '; expected throw, clip or null'); - } - $mode = match ($key) { - 'throw' => OverflowMode::Throw, - 'clip' => OverflowMode::Clip, - default => app()->environment('local', 'testing') ? OverflowMode::Throw : OverflowMode::Clip, - }; - - return new RenderOptions( - backend: $backend, - fonts: $fonts, - theme: $theme, - overflow: $mode, - fallbackFamilies: FontConfig::fallbacks(), - cacheDir: self::cacheDir(), - onWarning: static function (string $msg): void { - logger()->warning('[papercut] ' . $msg); - }, - ); - } - - private static function cacheDir(): string|false|null - { - $dir = config('papercut.cache_dir'); - if ($dir === null || $dir === false || (is_string($dir) && $dir !== '')) { - return $dir; - } - throw new \InvalidArgumentException('Invalid papercut.cache_dir: ' . (is_scalar($dir) ? var_export($dir, true) : get_debug_type($dir)) . '; expected a directory path, false or null'); - } - - /** @param array $cfg */ - private static function positive(array $cfg, string $key): ?float - { - if (!isset($cfg[$key])) { - return null; - } - $v = $cfg[$key]; - if (!is_numeric($v) || (float) $v <= 0) { - throw new \InvalidArgumentException("Invalid papercut.theme.$key: " . (is_scalar($v) ? var_export($v, true) : get_debug_type($v)) . '; expected a number greater than 0'); - } - - return (float) $v; - } - - /** @param list $segments */ - private function describe(array $segments): string - { - if ($segments === []) { - return 'nothing'; - } - if (count($segments) > 1) { - return count($segments) . ' nodes'; - } - $only = $segments[0]; - if (is_string($only)) { - return 'text'; - } - $short = substr((string) strrchr('\\' . $only::class, '\\'), 1); - - return 'a <' . strtolower((string) preg_replace('/(?'; - } -} diff --git a/packages/laravel/src/PendingDocument.php b/packages/laravel/src/PendingDocument.php new file mode 100644 index 0000000..4d669de --- /dev/null +++ b/packages/laravel/src/PendingDocument.php @@ -0,0 +1,38 @@ +document; + } + + public function pdf(): PendingOutput + { + return $this->pdf ??= new PendingOutput( + fn (): \Papercut\Export\Output => $this->document->toPdf($this->manager->pdfOptions()), + ); + } + + public function html(): PendingHtml + { + return $this->html ??= new PendingHtml( + fn (): \Papercut\Export\Output => $this->document->toHtml($this->manager->htmlOptions()), + fn (): \Papercut\Export\Output => $this->document->toHtml($this->manager->htmlOptions()->withFullPage(true)), + ); + } +} diff --git a/packages/laravel/src/PendingHtml.php b/packages/laravel/src/PendingHtml.php new file mode 100644 index 0000000..81e192c --- /dev/null +++ b/packages/laravel/src/PendingHtml.php @@ -0,0 +1,63 @@ +pageOutput ??= new PendingOutput($this->page); + } + + /** The embeddable `.papercut` fragment. */ + public function fragment(): PendingOutput + { + return $this->fragmentOutput ??= new PendingOutput($this->fragment); + } + + /** The fragment, for `{!! $doc->html() !!}` inside another Blade view. */ + public function toHtml(): string + { + return $this->fragment()->toString(); + } + + /** The fragment; lets `{{ $doc->html() }}` and `(string) $doc->html()` print it. */ + public function __toString(): string + { + return $this->toHtml(); + } + + /** The full page as a bare 200 response, with no Content-Disposition. @param \Illuminate\Http\Request $request */ + public function toResponse($request): Response + { + $output = $this->page()->output(); + + return new Response($output->bytes(), 200, [ + 'Content-Type' => $output->mime(), + 'Content-Length' => (string) strlen($output->bytes()), + ]); + } +} diff --git a/packages/laravel/src/PendingOutput.php b/packages/laravel/src/PendingOutput.php new file mode 100644 index 0000000..5c30d83 --- /dev/null +++ b/packages/laravel/src/PendingOutput.php @@ -0,0 +1,74 @@ +output ??= ($this->render)(); + } + + public function toString(): string + { + return $this->output()->bytes(); + } + + public function save(string $path): static + { + $this->output()->save($path); + + return $this; + } + + public function download(?string $filename = null): Response + { + return $this->respond(HeaderUtils::DISPOSITION_ATTACHMENT, $filename); + } + + public function inline(?string $filename = null): Response + { + return $this->respond(HeaderUtils::DISPOSITION_INLINE, $filename); + } + + /** @param \Illuminate\Http\Request $request */ + public function toResponse($request): Response + { + return $this->inline(); + } + + private function respond(string $disposition, ?string $filename): Response + { + $output = $this->output(); + $default = $this->basename . '.' . $output->extension(); + $filename = str_replace(['/', '\\'], '-', $filename ?? $default); + + return new Response($output->bytes(), 200, [ + 'Content-Type' => $output->mime(), + 'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $filename, self::asciiFallback($filename, $default)), + 'Content-Length' => (string) strlen($output->bytes()), + ]); + } + + private static function asciiFallback(string $filename, string $default): string + { + $fallback = str_replace('%', '_', (string) preg_replace('/[^\x20-\x7e]/', '_', Str::ascii($filename))); + + return $fallback === '' ? $default : $fallback; + } +} diff --git a/packages/laravel/src/PendingPdf.php b/packages/laravel/src/PendingPdf.php deleted file mode 100644 index a2b1edc..0000000 --- a/packages/laravel/src/PendingPdf.php +++ /dev/null @@ -1,72 +0,0 @@ -rendered ??= $this->document->render($this->options); - } - - public function save(string $path): self - { - $bytes = $this->toString(); - $written = @file_put_contents($path, $bytes); - if ($written === false || $written !== strlen($bytes)) { - throw new \RuntimeException('Could not write PDF to "' . $path . '"'); - } - - return $this; - } - - public function download(string $filename = 'document.pdf'): Response - { - return $this->respond(HeaderUtils::DISPOSITION_ATTACHMENT, $filename); - } - - public function inline(string $filename = 'document.pdf'): Response - { - return $this->respond(HeaderUtils::DISPOSITION_INLINE, $filename); - } - - /** @param \Illuminate\Http\Request $request */ - public function toResponse($request): Response - { - return $this->inline(); - } - - private function respond(string $disposition, string $filename): Response - { - $body = $this->toString(); - $filename = str_replace(['/', '\\'], '-', $filename); - - return new Response($body, 200, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $filename, self::asciiFallback($filename)), - 'Content-Length' => (string) strlen($body), - ]); - } - - private static function asciiFallback(string $filename): string - { - $fallback = str_replace('%', '_', (string) preg_replace('/[^\x20-\x7e]/', '_', Str::ascii($filename))); - - return $fallback === '' ? 'document.pdf' : $fallback; - } -} diff --git a/packages/laravel/src/Preview/PreviewController.php b/packages/laravel/src/Preview/PreviewController.php index a83fe9d..0db2162 100644 --- a/packages/laravel/src/Preview/PreviewController.php +++ b/packages/laravel/src/Preview/PreviewController.php @@ -5,12 +5,12 @@ namespace Papercut\Laravel\Preview; use Illuminate\Http\Response as HttpResponse; -use Papercut\Laravel\PdfManager; +use Papercut\Laravel\PapercutManager; use Symfony\Component\HttpFoundation\Response; final class PreviewController { - public function __construct(private readonly PreviewRegistry $registry, private readonly PdfManager $pdf) + public function __construct(private readonly PreviewRegistry $registry, private readonly PapercutManager $papercut) { } @@ -37,6 +37,11 @@ public function show(string $view): Response abort_unless(app()->isLocal(), 404); abort_unless($this->registry->has($view), 404); - return $this->pdf->view($view, $this->registry->data($view))->inline(); + $format = request()->query('format', 'pdf'); + abort_unless($format === 'pdf' || $format === 'html', 404); + + $pending = $this->papercut->view($view, $this->registry->data($view)); + + return $format === 'html' ? $pending->html()->toResponse(request()) : $pending->pdf()->inline(); } } diff --git a/packages/laravel/src/Rendering/NodeRegistry.php b/packages/laravel/src/Rendering/NodeRegistry.php index 6a84bcf..1e54557 100644 --- a/packages/laravel/src/Rendering/NodeRegistry.php +++ b/packages/laravel/src/Rendering/NodeRegistry.php @@ -4,6 +4,7 @@ namespace Papercut\Laravel\Rendering; +use Papercut\Laravel\Tag; use Illuminate\Support\HtmlString; use Papercut\Laravel\Exception\InvalidStructure; @@ -26,7 +27,7 @@ public function register(object $node): HtmlString { $key = array_key_last($this->scopes); if ($key === null) { - throw new InvalidStructure(' components can only be rendered through Pdf::view().'); + throw new InvalidStructure(Tag::any() . ' components can only be rendered through Papercut::view().'); } $id = $this->scopes[$key]['next']++; $this->scopes[$key]['nodes'][$id] = $node; @@ -55,10 +56,10 @@ public function segments(string $html): array $exact = $m[1] === $this->scopes[$key]['nonce'] && str_starts_with($part, '[[pc:'); if (!$exact || !isset($this->scopes[$key]['nodes'][$id])) { $view = $this->scopes[$key]['view']; - throw new InvalidStructure('A slot containing tags was modified as a string (e.g. strtoupper($slot))' . ($view !== null ? " in view [$view]" : '')); + throw new InvalidStructure('A slot containing ' . Tag::any() . ' tags was modified as a string (e.g. strtoupper($slot))' . ($view !== null ? " in view [$view]" : '')); } if (isset($this->scopes[$key]['taken'][$id])) { - throw new InvalidStructure('A element was output twice (is {{ $slot }} echoed more than once?)'); + throw new InvalidStructure('A ' . Tag::any() . ' element was output twice (is {{ $slot }} echoed more than once?)'); } $this->scopes[$key]['taken'][$id] = true; $node = $this->scopes[$key]['nodes'][$id]; diff --git a/packages/laravel/src/Rendering/Segments.php b/packages/laravel/src/Rendering/Segments.php index 8c833cb..e6b8199 100644 --- a/packages/laravel/src/Rendering/Segments.php +++ b/packages/laravel/src/Rendering/Segments.php @@ -4,6 +4,7 @@ namespace Papercut\Laravel\Rendering; +use Papercut\Laravel\Tag; use Papercut\Laravel\Exception\InvalidStructure; use Papercut\Laravel\Components\HeaderRows; use Papercut\TableCell; @@ -34,7 +35,7 @@ public static function nodes(array $segs, string $tag, array $allow = []): array } else { foreach (self::PARENTS as $class => [$name, $parent]) { if ($seg instanceof $class && !in_array($class, $allow, true)) { - throw new InvalidStructure(" must be inside "); + throw new InvalidStructure(Tag::of($name) . ' must be inside ' . Tag::of($parent)); } } $out[] = $seg; @@ -50,7 +51,7 @@ public static function text(array $segs, string $tag): string $text = ''; foreach ($segs as $seg) { if (!is_string($seg)) { - throw new InvalidStructure(" can only contain text and inline tags (, , , ,
,
, )"); + throw new InvalidStructure(Tag::of($tag) . " can only contain text and inline tags (, , , ,
,
, )"); } $text .= $seg; } diff --git a/packages/laravel/src/Tag.php b/packages/laravel/src/Tag.php new file mode 100644 index 0000000..61e2785 --- /dev/null +++ b/packages/laravel/src/Tag.php @@ -0,0 +1,43 @@ +`) shown in error messages, from `papercut.prefix`. */ +final class Tag +{ + /** Last validated raw config value and its prefix, so `of()` does not run a regex per component. */ + private static mixed $raw = null; + private static ?string $valid = null; + + public static function prefix(): string + { + $prefix = config('papercut.prefix', 'doc'); + if (self::$valid !== null && self::$raw === $prefix) { + return self::$valid; + } + + if (!is_string($prefix) || preg_match('/^[a-z][a-z0-9-]*$/', $prefix) !== 1) { + throw new InvalidArgumentException( + 'Invalid papercut.prefix: ' . (is_string($prefix) ? $prefix : get_debug_type($prefix)) . '; expected lowercase letters, digits and dashes', + ); + } + + self::$raw = $prefix; + + return self::$valid = $prefix; + } + + public static function of(string $tag): string + { + return ''; + } + + public static function any(): string + { + return self::of('*'); + } +} diff --git a/packages/laravel/tests/Feature/BlockComponentsTest.php b/packages/laravel/tests/Feature/BlockComponentsTest.php index a17f2ea..99bf9d8 100644 --- a/packages/laravel/tests/Feature/BlockComponentsTest.php +++ b/packages/laravel/tests/Feature/BlockComponentsTest.php @@ -13,7 +13,7 @@ final class BlockComponentsTest extends TestCase { public function test_row_with_attributes_and_children(): void { - $node = $this->nodeFrom('A B '); + $node = $this->nodeFrom('A B '); self::assertInstanceOf(View::class, $node); self::assertSame(Direction::Row, $node->style()->direction); self::assertSame(Align::Center, $node->style()->alignItems); @@ -24,7 +24,7 @@ public function test_row_with_attributes_and_children(): void public function test_whitespace_between_blocks_is_dropped(): void { - $node = $this->nodeFrom("\n \n \n"); + $node = $this->nodeFrom("\n \n \n"); self::assertCount(2, $node->children()); self::assertInstanceOf(Spacer::class, $node->children()[0]); self::assertInstanceOf(PageBreak::class, $node->children()[1]); @@ -32,95 +32,95 @@ public function test_whitespace_between_blocks_is_dropped(): void public function test_bound_values_pass_through_typed(): void { - $node = $this->nodeFrom('', ['p' => \Papercut\pt(3)]); + $node = $this->nodeFrom('', ['p' => \Papercut\pt(3)]); self::assertSame(3.0, $node->style()->padding?->top->points); } public function test_unknown_attribute_names_the_tag(): void { $this->expectException(UnknownAttribute::class); - $this->expectExceptionMessage('Unknown attribute "paddng" on . Did you mean "padding"?'); - $this->nodeFrom(''); + $this->expectExceptionMessage('Unknown attribute "paddng" on . Did you mean "padding"?'); + $this->nodeFrom(''); } public function test_invalid_value_names_attribute_and_tag(): void { $this->expectException(InvalidValue::class); - $this->expectExceptionMessage('Invalid edges "20 mm" for margin on . Did you mean "20mm"?'); - $this->nodeFrom(''); + $this->expectExceptionMessage('Invalid edges "20 mm" for margin on . Did you mean "20mm"?'); + $this->nodeFrom(''); } public function test_link_requires_href(): void { - self::assertSame('https://x.test', $this->nodeFrom('go')->uri()); + self::assertSame('https://x.test', $this->nodeFrom('go')->uri()); $this->expectException(\Papercut\Laravel\Exception\InvalidStructure::class); - $this->nodeFrom('go'); + $this->nodeFrom('go'); } public function test_app_component_with_extra_attributes_nests_cleanly(): void { - $node = $this->nodeFrom('', ['n' => 'Ada']); + $node = $this->nodeFrom('', ['n' => 'Ada']); $card = $node->children()[0]; self::assertInstanceOf(View::class, $card); } public function test_spacer_grow_is_numeric_and_non_negative(): void { - self::assertSame(2.0, $this->nodeFrom('')->style()->grow); - self::assertSame(0.0, $this->nodeFrom('')->style()->grow); - self::assertSame(1.0, $this->nodeFrom('')->style()->grow); + self::assertSame(2.0, $this->nodeFrom('')->style()->grow); + self::assertSame(0.0, $this->nodeFrom('')->style()->grow); + self::assertSame(1.0, $this->nodeFrom('')->style()->grow); } /** @return iterable */ public static function badGrow(): iterable { - yield 'text' => ['']; - yield 'presence' => ['']; - yield 'negative' => ['']; - yield 'bound array' => ['']; + yield 'text' => ['']; + yield 'presence' => ['']; + yield 'negative' => ['']; + yield 'bound array' => ['']; } #[\PHPUnit\Framework\Attributes\DataProvider('badGrow')] public function test_spacer_rejects_invalid_grow(string $blade): void { $this->expectException(InvalidValue::class); - $this->expectExceptionMessage('for grow on '); + $this->expectExceptionMessage('for grow on '); $this->nodeFrom($blade); } public function test_alias_error_reports_the_users_key(): void { $this->expectException(InvalidValue::class); - $this->expectExceptionMessage('for align on '); - $this->nodeFrom(''); + $this->expectExceptionMessage('for align on '); + $this->nodeFrom(''); } public function test_align_alias_is_scoped_to_containers(): void { $this->expectException(UnknownAttribute::class); - $this->expectExceptionMessage('Unknown attribute "align" on '); - $this->nodeFrom('go'); + $this->expectExceptionMessage('Unknown attribute "align" on '); + $this->nodeFrom('go'); } public function test_named_style_and_style_object(): void { config(['papercut.styles' => ['card' => ['padding' => '12pt']]]); - self::assertSame('card', $this->nodeFrom('')->styleName()); - $node = $this->nodeFrom('', ['s' => \Papercut\Style\Style::fromAttributes(['padding' => '4pt'])]); + self::assertSame('card', $this->nodeFrom('')->styleName()); + $node = $this->nodeFrom('', ['s' => \Papercut\Style\Style::fromAttributes(['padding' => '4pt'])]); self::assertSame(4.0, $node->style()->padding?->top->points); } public function test_non_string_style_is_invalid(): void { $this->expectException(InvalidValue::class); - $this->expectExceptionMessage('Invalid style "5" for style on '); - $this->nodeFrom(''); + $this->expectExceptionMessage('Invalid style "5" for style on '); + $this->nodeFrom(''); } public function test_nested_file_component_errors_are_unwrapped(): void { $this->expectException(UnknownAttribute::class); - $this->expectExceptionMessage('Unknown attribute "paddng" on '); - $this->nodeFrom(''); + $this->expectExceptionMessage('Unknown attribute "paddng" on '); + $this->nodeFrom(''); } } diff --git a/packages/laravel/tests/Feature/ExamplesTest.php b/packages/laravel/tests/Feature/ExamplesTest.php index cb84d5d..17578f5 100644 --- a/packages/laravel/tests/Feature/ExamplesTest.php +++ b/packages/laravel/tests/Feature/ExamplesTest.php @@ -4,7 +4,7 @@ namespace Papercut\Laravel\Tests\Feature; -use Papercut\Laravel\Facades\Pdf; +use Papercut\Laravel\Facades\Papercut; use Papercut\Laravel\Tests\TestCase; final class ExamplesTest extends TestCase @@ -43,7 +43,7 @@ private static function data(string $name): array private function render(string $view): string { - $pdf = Pdf::view($view, self::data($view))->toString(); + $pdf = Papercut::view($view, self::data($view))->pdf()->toString(); $base = (string) tempnam(sys_get_temp_dir(), 'pc-ex'); $file = $base . '.pdf'; $this->temps[] = $base; diff --git a/packages/laravel/tests/Feature/FinalFixTest.php b/packages/laravel/tests/Feature/FinalFixTest.php index 9db5ac2..83ccc52 100644 --- a/packages/laravel/tests/Feature/FinalFixTest.php +++ b/packages/laravel/tests/Feature/FinalFixTest.php @@ -6,8 +6,8 @@ use Papercut\Exception\{ImageUnreadable, InvalidValue, UnknownAttribute}; use Papercut\Laravel\Exception\InvalidStructure; -use Papercut\Laravel\Facades\Pdf; -use Papercut\Laravel\PdfManager; +use Papercut\Laravel\Facades\Papercut; +use Papercut\Laravel\PapercutManager; use Papercut\Laravel\Rendering\NodeRegistry; use Papercut\Laravel\Tests\TestCase; use Papercut\Text; @@ -19,7 +19,7 @@ public function test_bad_named_style_names_config_key(): void config(['papercut.styles' => ['card' => ['bogus-attr' => 'x']]]); $this->expectException(UnknownAttribute::class); $this->expectExceptionMessage('in config papercut.styles.card'); - $this->app()->make(PdfManager::class)->options(); + $this->app()->make(PapercutManager::class)->pdfOptions(); } public function test_bad_theme_color_names_config_key(): void @@ -27,7 +27,7 @@ public function test_bad_theme_color_names_config_key(): void config(['papercut.theme' => ['color' => 'not-a-color']]); $this->expectException(InvalidValue::class); $this->expectExceptionMessage('in config papercut.theme.color'); - $this->app()->make(PdfManager::class)->options(); + $this->app()->make(PapercutManager::class)->pdfOptions(); } public function test_theme_numbers_must_be_positive_numeric(): void @@ -36,7 +36,7 @@ public function test_theme_numbers_must_be_positive_numeric(): void foreach ($bad as $value) { config(['papercut.theme' => [$key => $value]]); try { - (new PdfManager($this->app()->make(NodeRegistry::class), $this->app()->make(\Papercut\Laravel\Preview\PreviewRegistry::class)))->options(); + (new PapercutManager($this->app()->make(NodeRegistry::class), $this->app()->make(\Papercut\Laravel\Preview\PreviewRegistry::class)))->pdfOptions(); self::fail("accepted $key=" . var_export($value, true)); } catch (\InvalidArgumentException $e) { self::assertStringContainsString("Invalid papercut.theme.$key:", $e->getMessage()); @@ -48,7 +48,7 @@ public function test_theme_numbers_must_be_positive_numeric(): void public function test_altered_marker_in_slot_is_reported(): void { try { - Pdf::view('pdf.shouted')->toString(); + Papercut::view('pdf.shouted')->pdf()->toString(); self::fail('accepted'); } catch (\Illuminate\View\ViewException $e) { self::assertStringContainsString('modified as a string (e.g. strtoupper($slot)) in view [pdf.shouted]', $e->getMessage()); @@ -76,7 +76,7 @@ public function test_foreign_nonce_marker_stays_literal(): void public function test_output_twice_message(): void { try { - Pdf::view('pdf.twice')->toString(); + Papercut::view('pdf.twice')->pdf()->toString(); self::fail('accepted'); } catch (\Illuminate\View\ViewException $e) { self::assertStringContainsString('was output twice (is {{ $slot }} echoed more than once?)', $e->getMessage()); @@ -87,38 +87,38 @@ public function test_image_error_names_src(): void { config(['papercut.image_path' => sys_get_temp_dir()]); try { - $this->nodeFrom(''); + $this->nodeFrom(''); self::fail('accepted'); } catch (ImageUnreadable $e) { - self::assertStringEndsWith(' on ', $e->getMessage()); + self::assertStringEndsWith(' on ', $e->getMessage()); } } public function test_spacer_size_and_grow_conflict(): void { $this->expectException(InvalidStructure::class); - $this->expectExceptionMessage(' takes size or grow, not both'); - $this->nodeFrom(''); + $this->expectExceptionMessage(' takes size or grow, not both'); + $this->nodeFrom(''); } public function test_save_failure_throws(): void { - $pending = Pdf::make(\Papercut\Document::make(\Papercut\Page::named('a4')->body('hello'))); + $pending = Papercut::make(\Papercut\Document::make(\Papercut\Page::named('a4')->body('hello')))->pdf(); $path = sys_get_temp_dir() . '/papercut-no-such-dir-' . bin2hex(random_bytes(3)) . '/out.pdf'; $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Could not write PDF to "' . $path . '"'); + $this->expectExceptionMessage('Directory does not exist: ' . dirname($path)); @$pending->save($path); } public function test_app_component_forwards_merged_attributes(): void { - $col = $this->nodeFrom('a'); + $col = $this->nodeFrom('a'); self::assertInstanceOf(\Papercut\View::class, $col); $dump = print_r($col, true); self::assertMatchesRegularExpression('/\[background\] => Papercut.Value.Color.*\[r\] => 248/s', $dump, 'caller attribute forwarded'); self::assertMatchesRegularExpression('/\[padding\] => Papercut.Value.Edges.*\[points\] => 4\b/s', $dump, 'merged default padding'); - $plain = print_r($this->nodeFrom('a'), true); - $over = print_r($this->nodeFrom('a'), true); + $plain = print_r($this->nodeFrom('a'), true); + $over = print_r($this->nodeFrom('a'), true); self::assertNotSame($plain, $over, 'caller padding overrides the merged default'); self::assertNotSame($plain, $dump); } @@ -126,7 +126,7 @@ public function test_app_component_forwards_merged_attributes(): void public function test_extends_section_stack_layout_renders(): void { $file = tempnam(sys_get_temp_dir(), 'pc-ext') . '.pdf'; - file_put_contents($file, Pdf::view('pdf.extended')->toString()); + file_put_contents($file, Papercut::view('pdf.extended')->pdf()->toString()); exec('pdftotext ' . escapeshellarg($file) . ' - 2>/dev/null', $out); unlink($file); $text = implode("\n", $out); diff --git a/packages/laravel/tests/Feature/FontsTest.php b/packages/laravel/tests/Feature/FontsTest.php index 1adc7af..577ea35 100644 --- a/packages/laravel/tests/Feature/FontsTest.php +++ b/packages/laravel/tests/Feature/FontsTest.php @@ -5,7 +5,7 @@ namespace Papercut\Laravel\Tests\Feature; use Illuminate\Support\Facades\Artisan; -use Papercut\Laravel\Facades\Pdf; +use Papercut\Laravel\Facades\Papercut; use Papercut\Laravel\Tests\TestCase; final class FontsTest extends TestCase @@ -76,16 +76,16 @@ public function testOptionsRegisterConfiguredFonts(): void { $this->configure(['Brand' => [400 => ['normal' => self::INTER . '/Inter-Regular.ttf']]]); - $options = Pdf::options(); + $options = Papercut::pdfOptions(); - $this->assertTrue($options->fonts($options->backend())->has('Brand')); + $this->assertTrue(\Papercut\Text\FontRegistry::fromSet($options->fonts(), $options->backend())->has('Brand')); } public function testOptionsForwardFallbackFonts(): void { $this->configure([], ['Brand']); - $this->assertSame(['Brand'], Pdf::options()->fallbackFamilies); + $this->assertSame(['Brand'], Papercut::pdfOptions()->fonts()->fallbacks); } /** @return iterable, string}> */ @@ -109,7 +109,7 @@ public function testMalformedFontConfigThrows(array $fonts, string $message): vo $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid font config ' . $message); - Pdf::options(); + Papercut::pdfOptions(); } public function testBadFallbackEntryThrows(): void @@ -118,7 +118,7 @@ public function testBadFallbackEntryThrows(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('papercut.fallback_fonts.1'); - Pdf::options(); + Papercut::pdfOptions(); } public function testCommandWarnsAboutUnregisteredFallback(): void diff --git a/packages/laravel/tests/Feature/GoldenBytesTest.php b/packages/laravel/tests/Feature/GoldenBytesTest.php new file mode 100644 index 0000000..468cf6b --- /dev/null +++ b/packages/laravel/tests/Feature/GoldenBytesTest.php @@ -0,0 +1,90 @@ +set('view.paths', [self::EXAMPLES . '/views']); + } + + /** @return iterable */ + public static function cases(): iterable + { + foreach (['invoice', 'transcript', 'report'] as $view) { + yield $view => [$view]; + } + } + + #[DataProvider('cases')] + public function test_pdf_bytes_match_v01(string $name): void + { + /** @var array> $all */ + $all = require self::EXAMPLES . '/data.php'; + $hash = hash('sha256', $this->pdfBytes($name, $all[$name])); + $golden = self::golden(); + if (getenv('UPDATE_GOLDEN') === '1' && !array_key_exists($name, $golden)) { + self::recordGolden($name, $hash); + $golden[$name] = $hash; + } + self::assertArrayHasKey($name, $golden, "No golden hash for $name; this phase must not add cases"); + self::assertSame($golden[$name], $hash, "PDF bytes for $name changed; Phase 0 must keep PDF output byte-identical"); + } + + /** @param array $data */ + private function pdfBytes(string $view, array $data): string + { + // The example views set no createdAt, so the PDF would embed the current time. Pin it by rebuilding the + // resolved document with a fixed date; only the test does this, the examples stay as they are. + $document = Papercut::view($view, $data)->document()->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + + return Papercut::make($document)->pdf()->toString(); + } + + public function test_golden_keys_match_the_cases_exactly(): void + { + $names = array_keys(iterator_to_array(self::cases())); + sort($names); + $keys = array_keys(self::golden()); + sort($keys); + self::assertSame($names, $keys, 'Golden file keys must equal the test cases exactly'); + } + + /** @return array */ + private static function golden(): array + { + if (!is_file(self::GOLDEN)) { + return []; + } + /** @var array $golden */ + $golden = json_decode((string) file_get_contents(self::GOLDEN), true, flags: JSON_THROW_ON_ERROR); + + return $golden; + } + + private static function recordGolden(string $name, string $hash): void + { + $golden = self::golden(); + $golden[$name] = $hash; + ksort($golden); + file_put_contents(self::GOLDEN, json_encode($golden, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); + } +} diff --git a/packages/laravel/tests/Feature/HardeningTest.php b/packages/laravel/tests/Feature/HardeningTest.php index 4d9404d..b69b420 100644 --- a/packages/laravel/tests/Feature/HardeningTest.php +++ b/packages/laravel/tests/Feature/HardeningTest.php @@ -7,8 +7,8 @@ use Illuminate\Support\Facades\{Log, Route}; use Papercut\Exception\{ImageUnreadable, InvalidValue}; use Papercut\Laravel\Exception\InvalidStructure; -use Papercut\Laravel\Facades\Pdf; -use Papercut\Laravel\PdfManager; +use Papercut\Laravel\Facades\Papercut; +use Papercut\Laravel\PapercutManager; use Papercut\Laravel\Preview\PreviewController; use Papercut\Laravel\Tests\TestCase; use Papercut\OverflowMode; @@ -33,7 +33,7 @@ public function test_image_traversal_is_rejected(): void { $this->expectException(ImageUnreadable::class); $this->expectExceptionMessage('Image path escapes the image directory: ../secret.txt'); - $this->nodeFrom(''); + $this->nodeFrom(''); } public function test_image_sibling_prefix_directory_is_rejected(): void @@ -42,20 +42,20 @@ public function test_image_sibling_prefix_directory_is_rejected(): void file_put_contents($this->dir . '/img-evil/a.png', 'x'); $this->expectException(ImageUnreadable::class); $this->expectExceptionMessage('escapes the image directory'); - $this->nodeFrom(''); + $this->nodeFrom(''); } public function test_image_inner_dotdot_that_stays_inside_is_allowed(): void { mkdir($this->dir . '/img/sub'); - $img = $this->nodeFrom(''); + $img = $this->nodeFrom(''); self::assertInstanceOf(\Papercut\Image::class, $img); } public function test_image_symlinked_subdirectory_inside_base_is_allowed(): void { symlink($this->dir, $this->dir . '/img/link'); - $img = $this->nodeFrom(''); + $img = $this->nodeFrom(''); self::assertInstanceOf(\Papercut\Image::class, $img); } @@ -63,26 +63,26 @@ public function test_image_dotdot_climbing_above_base_after_descending_is_reject { $this->expectException(ImageUnreadable::class); $this->expectExceptionMessage('escapes the image directory'); - $this->nodeFrom(''); + $this->nodeFrom(''); } public function test_image_empty_dot_and_nul_src(): void { foreach (['', '.'] as $p) { try { - $this->nodeFrom('', ['p' => $p]); + $this->nodeFrom('', ['p' => $p]); self::fail('accepted'); } catch (ImageUnreadable $e) { self::assertStringContainsString('Image not found', $e->getMessage()); } } $this->expectException(ImageUnreadable::class); - $this->nodeFrom('', ['p' => "logo.png\0.txt"]); + $this->nodeFrom('', ['p' => "logo.png\0.txt"]); } public function test_image_absolute_path_is_trusted(): void { - $img = $this->nodeFrom('', ['p' => $this->dir . '/secret.txt']); + $img = $this->nodeFrom('', ['p' => $this->dir . '/secret.txt']); self::assertInstanceOf(\Papercut\Image::class, $img); } @@ -91,7 +91,7 @@ public function test_image_stream_wrapper_and_drive_paths_count_as_absolute(): v // Not resolved against the base, so reported as not found rather than joined under it. foreach (['file:///nonexistent/x.png', 'C:\\x.png', 'C:/x.png'] as $p) { try { - $this->nodeFrom('', ['p' => $p]); + $this->nodeFrom('', ['p' => $p]); self::fail("$p accepted"); } catch (ImageUnreadable $e) { self::assertStringContainsString("Image not found: $p", $e->getMessage()); @@ -101,16 +101,16 @@ public function test_image_stream_wrapper_and_drive_paths_count_as_absolute(): v public function test_image_presence_only_src_or_data_is_rejected(): void { - foreach (['', ''] as $blade) { + foreach (['', ''] as $blade) { try { $this->nodeFrom($blade); self::fail("$blade accepted"); } catch (InvalidStructure $e) { - self::assertStringContainsString(' src must be a string path', $e->getMessage()); + self::assertStringContainsString(' src must be a string path', $e->getMessage()); } } $this->expectException(InvalidStructure::class); - $this->nodeFrom('', ['p' => ['a']]); + $this->nodeFrom('', ['p' => ['a']]); } // ---- 2. preview @@ -133,7 +133,7 @@ public function test_preview_actions_abort_outside_local(): void public function test_preview_index_urlencodes_names(): void { $this->setEnv('local'); - Pdf::preview('pdf.a b?c'); + Papercut::preview('pdf.a b?c'); $html = (string) $this->laravel()->make(PreviewController::class)->index()->getContent(); self::assertStringContainsString('href="/_papercut/pdf.a%20b%3Fc"', $html); } @@ -141,7 +141,7 @@ public function test_preview_index_urlencodes_names(): void public function test_preview_closure_must_return_array(): void { $this->setEnv('local'); - Pdf::preview('pdf.hello', fn () => 'nope'); + Papercut::preview('pdf.hello', fn () => 'nope'); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Preview data for pdf.hello must be an array'); $this->laravel()->make(PreviewController::class)->show('pdf.hello'); @@ -153,10 +153,10 @@ public function test_unknown_named_style_has_context_suggestion_and_file(): void { config(['papercut.styles' => ['card' => ['padding' => '12pt']]]); try { - Pdf::view('pdf.bad-style')->toString(); + Papercut::view('pdf.bad-style')->pdf()->toString(); self::fail('accepted'); } catch (\Illuminate\View\ViewException $e) { - self::assertStringContainsString('Unknown style "crad" on ', $e->getMessage()); + self::assertStringContainsString('Unknown style "crad" on ', $e->getMessage()); self::assertStringContainsString('Did you mean "card"', $e->getMessage()); self::assertStringContainsString('bad-style.blade.php', $e->getMessage()); } @@ -165,9 +165,9 @@ public function test_unknown_named_style_has_context_suggestion_and_file(): void public function test_known_named_style_and_no_style_still_render(): void { config(['papercut.styles' => ['card' => ['padding' => '12pt']]]); - $v = $this->nodeFrom('x'); + $v = $this->nodeFrom('x'); self::assertInstanceOf(\Papercut\View::class, $v); - self::assertInstanceOf(\Papercut\View::class, $this->nodeFrom('x')); + self::assertInstanceOf(\Papercut\View::class, $this->nodeFrom('x')); } // ---- 4. root validation names the view @@ -176,7 +176,7 @@ public function test_root_errors_name_the_view(): void { foreach (['pdf.two-roots', 'pdf.no-root', 'pdf.leftover'] as $view) { try { - Pdf::view($view)->toString(); + Papercut::view($view)->pdf()->toString(); self::fail('accepted'); } catch (InvalidStructure $e) { self::assertStringEndsWith(" in view [$view]", $e->getMessage()); @@ -188,13 +188,13 @@ public function test_root_errors_name_the_view(): void public function test_download_filename_slashes_replaced(): void { - Route::get('/d1', fn () => Pdf::view('pdf.hello', ['name' => 'A'])->download('a/b.pdf')); + Route::get('/d1', fn () => Papercut::view('pdf.hello', ['name' => 'A'])->pdf()->download('a/b.pdf')); $this->get('/d1')->assertOk()->assertHeader('Content-Disposition', 'attachment; filename=a-b.pdf'); } public function test_download_filename_unicode_fallback(): void { - Route::get('/d2', fn () => Pdf::view('pdf.hello', ['name' => 'A'])->download('résumé.pdf')); + Route::get('/d2', fn () => Papercut::view('pdf.hello', ['name' => 'A'])->pdf()->download('résumé.pdf')); $h = (string) $this->get('/d2')->assertOk()->headers->get('Content-Disposition'); self::assertStringContainsString('filename=resume.pdf', $h); self::assertStringContainsString("filename*=utf-8''r%C3%A9sum%C3%A9.pdf", $h); @@ -205,21 +205,21 @@ public function test_download_filename_unicode_fallback(): void public function test_overflow_config_is_validated_case_insensitively(): void { config(['papercut.overflow' => 'CLIP']); - self::assertSame(OverflowMode::Clip, Pdf::options()->overflow); + self::assertSame(OverflowMode::Clip, Papercut::pdfOptions()->overflow()); } - public function test_cache_dir_config_reaches_render_options(): void + public function test_cache_dir_config_reaches_pdf_options(): void { - $options = static function (mixed $value): \Papercut\RenderOptions { + $options = static function (mixed $value): \Papercut\Export\PdfOptions { config(['papercut.cache_dir' => $value]); - app()->forgetInstance(PdfManager::class); - Pdf::clearResolvedInstances(); + app()->forgetInstance(PapercutManager::class); + Papercut::clearResolvedInstances(); - return Pdf::options(); + return Papercut::pdfOptions(); }; - self::assertNull($options(null)->cacheDir); - self::assertFalse($options(false)->cacheDir); - self::assertSame('/tmp/pc-x', $options('/tmp/pc-x')->cacheDir); + self::assertNull($options(null)->cacheDir()); + self::assertFalse($options(false)->cacheDir()); + self::assertSame('/tmp/pc-x', $options('/tmp/pc-x')->cacheDir()); } public function test_invalid_overflow_throws(): void @@ -227,38 +227,38 @@ public function test_invalid_overflow_throws(): void config(['papercut.overflow' => 'bogus']); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid papercut.overflow'); - Pdf::options(); + Papercut::pdfOptions(); } public function test_overflow_default_depends_on_environment(): void { config(['papercut.overflow' => null]); - self::assertSame(OverflowMode::Throw, Pdf::options()->overflow); - $this->laravel()->forgetInstance(PdfManager::class); - \Illuminate\Support\Facades\Facade::clearResolvedInstance(PdfManager::class); + self::assertSame(OverflowMode::Throw, Papercut::pdfOptions()->overflow()); + $this->laravel()->forgetInstance(PapercutManager::class); + \Illuminate\Support\Facades\Facade::clearResolvedInstance(PapercutManager::class); $this->setEnv('production'); - self::assertSame(OverflowMode::Clip, Pdf::options()->overflow); + self::assertSame(OverflowMode::Clip, Papercut::pdfOptions()->overflow()); } public function test_non_string_page_size_throws(): void { $this->expectException(InvalidValue::class); $this->expectExceptionMessage('page size'); - $this->nodeFrom('x', ['s' => ['a4']]); + $this->nodeFrom('x', ['s' => ['a4']]); } public function test_non_scalar_document_metadata_throws(): void { $this->expectException(InvalidValue::class); - $this->expectExceptionMessage('on '); - $this->nodeFrom('x', ['t' => ['a']]); + $this->expectExceptionMessage('on '); + $this->nodeFrom('x', ['t' => ['a']]); } public function test_non_string_page_number_format_throws(): void { $this->expectException(InvalidValue::class); $this->expectExceptionMessage('format'); - $this->nodeFrom('', ['f' => 5]); + $this->nodeFrom('', ['f' => 5]); } // ---- 7. small items @@ -266,14 +266,14 @@ public function test_non_string_page_number_format_throws(): void public function test_empty_thead_message(): void { $this->expectException(InvalidStructure::class); - $this->expectExceptionMessage(' must not be empty'); - $this->nodeFrom(''); + $this->expectExceptionMessage(' must not be empty'); + $this->nodeFrom(''); } public function test_facade_docblock_has_single_options_line(): void { - $src = (string) file_get_contents(__DIR__ . '/../../src/Facades/Pdf.php'); - self::assertSame(1, substr_count($src, 'static \Papercut\RenderOptions options()')); + $src = (string) file_get_contents(__DIR__ . '/../../src/Facades/Papercut.php'); + self::assertSame(1, substr_count($src, 'static \Papercut\Export\PdfOptions pdfOptions()')); } public function test_link_accepts_stringable_and_rejects_bad_href(): void @@ -284,17 +284,17 @@ public function __toString(): string return 'https://example.com'; } }; - self::assertInstanceOf(\Papercut\Link::class, $this->nodeFrom('x', ['u' => $url])); + self::assertInstanceOf(\Papercut\Link::class, $this->nodeFrom('x', ['u' => $url])); foreach ([[], 5, ''] as $bad) { try { - $this->nodeFrom('x', ['u' => $bad]); + $this->nodeFrom('x', ['u' => $bad]); self::fail('accepted'); } catch (InvalidStructure $e) { self::assertStringContainsString('href must be a non-empty string', $e->getMessage()); } } try { - $this->nodeFrom('x'); + $this->nodeFrom('x'); self::fail('accepted'); } catch (InvalidStructure $e) { self::assertStringContainsString('href must be a non-empty string', $e->getMessage()); @@ -305,7 +305,7 @@ public function __toString(): string public function test_styled_tr_reaches_core(): void { - $t = $this->nodeFrom('a'); + $t = $this->nodeFrom('a'); self::assertInstanceOf(\Papercut\Table::class, $t); $row = $t->bodyRows()[0]; self::assertNotNull($row->style()->background); @@ -314,7 +314,7 @@ public function test_styled_tr_reaches_core(): void public function test_blade_comment_between_foreach_rows_adds_no_child(): void { $t = $this->nodeFrom( - '@foreach ([1, 2] as $i){{-- c --}}{{ $i }}{{-- c --}}@endforeach', + '@foreach ([1, 2] as $i){{-- c --}}{{ $i }}{{-- c --}}@endforeach', ); self::assertInstanceOf(\Papercut\Table::class, $t); self::assertCount(2, $t->bodyRows()); @@ -351,7 +351,7 @@ public function test_scope_pops_after_exception(): void public function test_on_warning_writes_to_log(): void { $spy = Log::spy(); - $cb = Pdf::options()->onWarning; + $cb = Papercut::pdfOptions()->onWarning(); self::assertNotNull($cb); $cb('careful'); $spy->shouldHaveReceived('warning')->with('[papercut] careful')->once(); @@ -360,7 +360,7 @@ public function test_on_warning_writes_to_log(): void public function test_make_and_save(): void { $doc = \Papercut\Document::make(\Papercut\Page::named('a4')->body('hello')); - $pending = Pdf::make($doc); + $pending = Papercut::make($doc)->pdf(); $path = $this->dir . '/out.pdf'; self::assertSame($pending, $pending->save($path)); self::assertStringStartsWith('%PDF-', (string) file_get_contents($path)); @@ -369,7 +369,7 @@ public function test_make_and_save(): void public function test_implicit_page_uses_configured_paper_and_margin(): void { config(['papercut.paper' => 'letter', 'papercut.margin' => '10mm']); - $doc = $this->nodeFrom('x'); + $doc = $this->nodeFrom('x'); self::assertInstanceOf(\Papercut\Document::class, $doc); $page = $doc->pages()[0]; self::assertEqualsWithDelta(612.0, $page->width(), 0.5); diff --git a/packages/laravel/tests/Feature/HtmlFontSourcesTest.php b/packages/laravel/tests/Feature/HtmlFontSourcesTest.php new file mode 100644 index 0000000..ddd51e7 --- /dev/null +++ b/packages/laravel/tests/Feature/HtmlFontSourcesTest.php @@ -0,0 +1,178 @@ +app()->forgetInstance(PapercutManager::class); // @phpstan-ignore method.notFound + + return Papercut::view('pdf.hello', ['name' => 'Ann'])->html()->page()->toString(); + } + + /** @param array $html */ + private function html(array $html): void + { + foreach ($html as $key => $value) { + config(["papercut.html.$key" => $value]); + } + } + + private function acme(): void + { + config(['papercut.fonts' => ['Acme' => [400 => ['normal' => self::INTER . '/Inter-Regular.ttf']]], 'papercut.theme' => ['font_family' => 'Acme'], 'papercut.fallback_fonts' => ['Inter']]); + } + + public function test_sources_with_defaults_links_google(): void + { + $this->html(['fonts' => 'sources']); + + $this->assertStringContainsString('fonts.googleapis.com/css2?family=Inter', $this->page()); + } + + public function test_fragment_imports_first_and_has_no_link(): void + { + $this->html(['fonts' => 'sources']); + $this->app()->forgetInstance(PapercutManager::class); // @phpstan-ignore method.notFound + + $out = (string) Papercut::view('pdf.hello', ['name' => 'Ann'])->html(); + + $this->assertStringContainsString('