Skip to content

Commit debeb06

Browse files
authored
Add support for PDF generation (#54)
This pull request adds PDF generation support. New methods: - `PageInterface::pdf()` - `PageInterface::pdfContent()` New class: - `PdfOptions` as (optional) VO
1 parent 204ed13 commit debeb06

File tree

11 files changed

+896
-4
lines changed

11 files changed

+896
-4
lines changed

bin/lib/handlers.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class ContextHandler extends BaseHandler {
4040
cookies: async () => ({ cookies: await context.cookies(command.urls) }),
4141
storageState: async () => ({ storageState: await context.storageState(command.options) }),
4242
clipboardText: () => this.getClipboardText(context),
43+
close: () => this.closeContext(context, command.contextId),
4344
newPage: () => this.createNewPage(context, command)
4445
});
4546

@@ -77,6 +78,35 @@ class ContextHandler extends BaseHandler {
7778
return { pageId };
7879
}
7980

81+
async closeContext(context, contextId) {
82+
try {
83+
await context.close();
84+
} catch (error) {
85+
logger.error('Failed to close context', { contextId, error: error?.message });
86+
throw error;
87+
} finally {
88+
this.cleanupContextResources(contextId);
89+
}
90+
}
91+
92+
cleanupContextResources(contextId) {
93+
this.contexts.delete(contextId);
94+
this.contextThrottling?.delete?.(contextId);
95+
96+
for (const [pageId, mappedContextId] of this.pageContexts.entries()) {
97+
if (mappedContextId === contextId) {
98+
this.pageContexts.delete(pageId);
99+
this.pages.delete(pageId);
100+
}
101+
}
102+
103+
for (const [routeId, info] of this.routes.entries()) {
104+
if (info?.contextId === contextId) {
105+
this.routes.delete(routeId);
106+
}
107+
}
108+
}
109+
80110
async waitForPopup(context, command) {
81111
const timeout = command.timeout || 30000;
82112
const requestId = command.requestId || this.generateId('popup_req');
@@ -155,6 +185,7 @@ class PageHandler extends BaseHandler {
155185
waitForURL: () => page.waitForURL(command.url, command.options),
156186
waitForSelector: () => page.waitForSelector(command.selector, command.options),
157187
screenshot: () => PromiseUtils.wrapBinary(page.screenshot(command.options)),
188+
pdf: () => PromiseUtils.wrapBinary(page.pdf(command.options || {})),
158189
evaluateHandle: () => this.evaluateHandle(page, command),
159190
addScriptTag: () => page.addScriptTag(command.options),
160191
addStyleTag: () => page.addStyleTag(command.options).then(() => ({ success: true })),

bin/playwright-server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class PlaywrightServer extends BaseHandler {
3030
pageContexts: this.pageContexts, dialogs: this.dialogs, elementHandles: this.elementHandles,
3131
responses: this.responses, routes: this.routes, generateId: this.generateId.bind(this),
3232
extractRequestData: this.extractRequestData.bind(this), serializeResponse: this.serializeResponse.bind(this),
33-
sendFramedResponse, routeCounter: { value: this.counters.route },
33+
sendFramedResponse,
34+
routeCounter: { value: this.counters.route },
3435
setupPageEventListeners: this.setupPageEventListeners.bind(this)
3536
};
3637
this.contextHandler = new ContextHandler(deps);

docs/examples/pdf.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the community-maintained Playwright PHP project.
7+
* It is not affiliated with or endorsed by Microsoft.
8+
*
9+
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
require_once __DIR__.'/../../vendor/autoload.php';
16+
17+
use Playwright\Playwright;
18+
19+
$browser = Playwright::chromium();
20+
$page = $browser->newPage();
21+
22+
$page->goto('https://example.com');
23+
24+
$pdfPath = __DIR__.'/example.pdf';
25+
$page->pdf($pdfPath, ['format' => 'A4']);
26+
echo 'PDF saved to: '.$pdfPath."\n";
27+
28+
$pdfBytes = $page->pdfContent();
29+
echo 'Inline PDF bytes: '.strlen($pdfBytes)."\n";
30+
31+
$page->close();
32+
$browser->close();

docs/guide/getting-started.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ echo $page->title() . PHP_EOL; // Outputs: "Example Domain"
7070
// Take a screenshot and save it as 'screenshot.png'.
7171
$page->screenshot('screenshot.png');
7272

73+
// Export the page to PDF on disk.
74+
$page->pdf('invoice.pdf', ['format' => 'A4']);
75+
76+
// Or grab the PDF bytes directly without keeping files around.
77+
$pdfBytes = $page->pdfContent();
78+
file_put_contents('inline-invoice.pdf', $pdfBytes);
79+
7380
// Close the browser context and all its pages.
7481
$context->close();
7582
```

src/Page/Options/PdfOptions.php

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the community-maintained Playwright PHP project.
7+
* It is not affiliated with or endorsed by Microsoft.
8+
*
9+
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
namespace Playwright\Page\Options;
16+
17+
use Playwright\Exception\RuntimeException;
18+
19+
/**
20+
* @phpstan-type PdfMargins array{top?: string, right?: string, bottom?: string, left?: string}
21+
* @phpstan-type PdfOptionsArray array{
22+
* path?: string,
23+
* format?: string,
24+
* landscape?: bool,
25+
* scale?: float,
26+
* printBackground?: bool,
27+
* width?: string,
28+
* height?: string,
29+
* margin?: PdfMargins,
30+
* displayHeaderFooter?: bool,
31+
* footerTemplate?: string,
32+
* headerTemplate?: string,
33+
* outline?: bool,
34+
* pageRanges?: string,
35+
* preferCSSPageSize?: bool,
36+
* tagged?: bool
37+
* }
38+
*/
39+
final class PdfOptions
40+
{
41+
private const SCALE_MIN = 0.1;
42+
private const SCALE_MAX = 2.0;
43+
44+
private ?string $path;
45+
private ?string $format;
46+
private ?bool $landscape;
47+
private ?float $scale;
48+
private ?bool $printBackground;
49+
private ?string $width;
50+
private ?string $height;
51+
/** @var PdfMargins|null */
52+
private ?array $margin;
53+
private ?bool $displayHeaderFooter;
54+
private ?string $footerTemplate;
55+
private ?string $headerTemplate;
56+
private ?bool $outline;
57+
private ?string $pageRanges;
58+
private ?bool $preferCSSPageSize;
59+
private ?bool $tagged;
60+
61+
public function __construct(
62+
?string $path = null,
63+
?string $format = null,
64+
?bool $landscape = null,
65+
?float $scale = null,
66+
?bool $printBackground = null,
67+
?string $width = null,
68+
?string $height = null,
69+
mixed $margin = null,
70+
?bool $displayHeaderFooter = null,
71+
?string $footerTemplate = null,
72+
?string $headerTemplate = null,
73+
?bool $outline = null,
74+
?string $pageRanges = null,
75+
?bool $preferCSSPageSize = null,
76+
?bool $tagged = null,
77+
) {
78+
$this->path = self::normalizeNullableString($path);
79+
$this->format = self::normalizeNullableString($format);
80+
$this->landscape = $landscape;
81+
$this->scale = null;
82+
if (null !== $scale) {
83+
if ($scale < self::SCALE_MIN || $scale > self::SCALE_MAX) {
84+
throw new RuntimeException(sprintf('PDF scale must be between %.1f and %.1f.', self::SCALE_MIN, self::SCALE_MAX));
85+
}
86+
87+
$this->scale = round($scale, 2);
88+
}
89+
90+
$this->printBackground = $printBackground;
91+
$this->width = self::normalizeNullableString($width);
92+
$this->height = self::normalizeNullableString($height);
93+
$this->margin = self::normalizeMargin($margin);
94+
$this->displayHeaderFooter = $displayHeaderFooter;
95+
$this->footerTemplate = self::normalizeNullableString($footerTemplate);
96+
$this->headerTemplate = self::normalizeNullableString($headerTemplate);
97+
$this->outline = $outline;
98+
$this->pageRanges = self::normalizeNullableString($pageRanges);
99+
$this->preferCSSPageSize = $preferCSSPageSize;
100+
$this->tagged = $tagged;
101+
}
102+
103+
/**
104+
* @param array<string, mixed>|self $options
105+
*/
106+
public static function from(array|self $options): self
107+
{
108+
if ($options instanceof self) {
109+
return $options;
110+
}
111+
112+
return self::fromArray($options);
113+
}
114+
115+
/**
116+
* @param array<string, mixed> $options
117+
*/
118+
public static function fromArray(array $options): self
119+
{
120+
$scale = null;
121+
if (array_key_exists('scale', $options)) {
122+
if (!is_numeric($options['scale'])) {
123+
throw new RuntimeException('PDF option "scale" must be numeric.');
124+
}
125+
126+
$scale = (float) $options['scale'];
127+
}
128+
129+
return new self(
130+
path: isset($options['path']) ? (string) $options['path'] : null,
131+
format: isset($options['format']) ? (string) $options['format'] : null,
132+
landscape: isset($options['landscape']) ? (bool) $options['landscape'] : null,
133+
scale: $scale,
134+
printBackground: isset($options['printBackground']) ? (bool) $options['printBackground'] : null,
135+
width: isset($options['width']) ? (string) $options['width'] : null,
136+
height: isset($options['height']) ? (string) $options['height'] : null,
137+
margin: $options['margin'] ?? null,
138+
displayHeaderFooter: isset($options['displayHeaderFooter']) ? (bool) $options['displayHeaderFooter'] : null,
139+
footerTemplate: isset($options['footerTemplate']) ? (string) $options['footerTemplate'] : null,
140+
headerTemplate: isset($options['headerTemplate']) ? (string) $options['headerTemplate'] : null,
141+
outline: isset($options['outline']) ? (bool) $options['outline'] : null,
142+
pageRanges: isset($options['pageRanges']) ? (string) $options['pageRanges'] : null,
143+
preferCSSPageSize: isset($options['preferCSSPageSize']) ? (bool) $options['preferCSSPageSize'] : null,
144+
tagged: isset($options['tagged']) ? (bool) $options['tagged'] : null,
145+
);
146+
}
147+
148+
public function path(): ?string
149+
{
150+
return $this->path;
151+
}
152+
153+
public function withPath(?string $path): self
154+
{
155+
return new self(
156+
path: $path,
157+
format: $this->format,
158+
landscape: $this->landscape,
159+
scale: $this->scale,
160+
printBackground: $this->printBackground,
161+
width: $this->width,
162+
height: $this->height,
163+
margin: $this->margin,
164+
displayHeaderFooter: $this->displayHeaderFooter,
165+
footerTemplate: $this->footerTemplate,
166+
headerTemplate: $this->headerTemplate,
167+
outline: $this->outline,
168+
pageRanges: $this->pageRanges,
169+
preferCSSPageSize: $this->preferCSSPageSize,
170+
tagged: $this->tagged,
171+
);
172+
}
173+
174+
/**
175+
* @return PdfOptionsArray
176+
*/
177+
public function toArray(): array
178+
{
179+
$options = [];
180+
181+
if (null !== $this->path) {
182+
$options['path'] = $this->path;
183+
}
184+
if (null !== $this->format) {
185+
$options['format'] = $this->format;
186+
}
187+
if (null !== $this->landscape) {
188+
$options['landscape'] = $this->landscape;
189+
}
190+
if (null !== $this->scale) {
191+
$options['scale'] = $this->scale;
192+
}
193+
if (null !== $this->printBackground) {
194+
$options['printBackground'] = $this->printBackground;
195+
}
196+
if (null !== $this->width) {
197+
$options['width'] = $this->width;
198+
}
199+
if (null !== $this->height) {
200+
$options['height'] = $this->height;
201+
}
202+
if (null !== $this->margin) {
203+
$options['margin'] = $this->margin;
204+
}
205+
if (null !== $this->displayHeaderFooter) {
206+
$options['displayHeaderFooter'] = $this->displayHeaderFooter;
207+
}
208+
if (null !== $this->footerTemplate) {
209+
$options['footerTemplate'] = $this->footerTemplate;
210+
}
211+
if (null !== $this->headerTemplate) {
212+
$options['headerTemplate'] = $this->headerTemplate;
213+
}
214+
if (null !== $this->outline) {
215+
$options['outline'] = $this->outline;
216+
}
217+
if (null !== $this->pageRanges) {
218+
$options['pageRanges'] = $this->pageRanges;
219+
}
220+
if (null !== $this->preferCSSPageSize) {
221+
$options['preferCSSPageSize'] = $this->preferCSSPageSize;
222+
}
223+
if (null !== $this->tagged) {
224+
$options['tagged'] = $this->tagged;
225+
}
226+
227+
return $options;
228+
}
229+
230+
private static function normalizeNullableString(?string $value): ?string
231+
{
232+
if (null === $value) {
233+
return null;
234+
}
235+
236+
$trimmed = trim($value);
237+
238+
return '' === $trimmed ? null : $trimmed;
239+
}
240+
241+
/**
242+
* @return PdfMargins|null
243+
*/
244+
private static function normalizeMargin(mixed $margin): ?array
245+
{
246+
if (null === $margin) {
247+
return null;
248+
}
249+
250+
if (!is_array($margin)) {
251+
throw new RuntimeException('PDF option "margin" must be an array of edge => size.');
252+
}
253+
254+
$normalized = [];
255+
foreach (['top', 'right', 'bottom', 'left'] as $edge) {
256+
if (!array_key_exists($edge, $margin)) {
257+
continue;
258+
}
259+
260+
$value = $margin[$edge];
261+
if (null === $value) {
262+
continue;
263+
}
264+
265+
$normalizedValue = self::normalizeNullableString((string) $value);
266+
if (null !== $normalizedValue) {
267+
$normalized[$edge] = $normalizedValue;
268+
}
269+
}
270+
271+
return [] === $normalized ? null : $normalized;
272+
}
273+
}

0 commit comments

Comments
 (0)