From 11c8d4f893b8d882d6504ed605f65c1c3d25ab98 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 28 Oct 2026 14:15:43 -0700 Subject: [PATCH 1/3] feat(docx): sections per page setup, and layer pictures as floating pictures php-docx 1.18 writes several sections and floating pictures. - Each run of pages with the same size, margins, header, footer and layer pictures becomes its own Word section, so a later page keeps its setup and headers instead of taking the first page's. Pages that look alike stay one section joined by page breaks, so existing output is unchanged. No blank page at a section break, and a section without a header or footer no longer shows the previous section's. - Images, QR codes and SVG in a background or overlay layer float from the section's header at the positions the PDF draws them (checked against the PDF layout for widths, margins, alignment, anchors and percentages), so they repeat on every page. Other layer content is still dropped and now reported at its own path. - The page-size patch is gone: php-docx writes any page size. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/superpowers/RULINGS.md | 3 + .../2026-10-19-multi-format-export-design.md | 2 +- packages/docx/README.md | 8 +- packages/docx/composer.json | 2 +- packages/docx/src/DocxExporter.php | 140 ++++++++++++---- packages/docx/src/Package/DocxPatcher.php | 13 +- packages/docx/src/Package/PageSetupMapper.php | 13 +- packages/docx/src/Package/PatchSpec.php | 2 - packages/docx/src/Walk/DocxVisitor.php | 157 +++++++++++++++++- packages/docx/tests/FidelityTableTest.php | 6 +- .../docx/tests/Package/DocxPatcherTest.php | 4 +- packages/docx/tests/Package/PageSetupTest.php | 49 +++++- packages/docx/tests/Walk/LayersTest.php | 130 +++++++++++++++ packages/docx/tests/Walk/RegionsTest.php | 4 +- site/src/guide/word-export.md | 6 +- 16 files changed, 452 insertions(+), 89 deletions(-) create mode 100644 packages/docx/tests/Walk/LayersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 18697c5..659811f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Migration from v0.1: ### Added -- Word export (experimental): the new `papercut/docx` package. `$document->export(new DocxExporter())` writes a `.docx` that Word, LibreOffice and Google Docs open and paginate themselves: columns flow as paragraphs, rows become borderless tables at the PDF's widths, and boxes with padding, a border or a background become one-cell tables. Lists, tables (repeating headers, rows that don't split, `colspan`), links, headings, images, page numbers, headers and footers, metadata, `lang` and pagination rules carry over. One page setup from the first page applies to the whole document. Pictures Word can't take as they are are drawn at the PDF's size: WebP and AVIF are converted, image `cover` is cropped and opacity faded with `ext-gd`; QR codes are drawn at 300 dpi (styled ones with `ext-imagick` and Bacon, else plain); SVG is drawn at 300 dpi with `ext-imagick`, with nothing outside the file loaded. Opacity is drawn by blending each faded colour onto what lies beneath it, and text backgrounds are shaded in their own colour. Corner radius on boxes, background and overlay layers, and PDF pages and templates are reported as dropped, and the rest of what Word can't show exactly as approximated. The same input gives the same bytes. Built on `dskripchenko/php-docx` 1.17. +- Word export (experimental): the new `papercut/docx` package. `$document->export(new DocxExporter())` writes a `.docx` that Word, LibreOffice and Google Docs open and paginate themselves: columns flow as paragraphs, rows become borderless tables at the PDF's widths, and boxes with padding, a border or a background become one-cell tables. Lists, tables (repeating headers, rows that don't split, `colspan`), links, headings, images, page numbers, headers and footers, metadata, `lang` and pagination rules carry over. Each run of pages with the same size, margins, header, footer and layers becomes a Word section, so later pages keep their own setup and headers. Pictures Word can't take as they are are drawn at the PDF's size: WebP and AVIF are converted, image `cover` is cropped and opacity faded with `ext-gd`; QR codes are drawn at 300 dpi (styled ones with `ext-imagick` and Bacon, else plain); SVG is drawn at 300 dpi with `ext-imagick`, with nothing outside the file loaded. Opacity is drawn by blending each faded colour onto what lies beneath it, and text backgrounds are shaded in their own colour. Images, QR codes and SVG in background and overlay layers become floating pictures at the PDF's positions, repeated on every page through the header. Corner radius on boxes, other layer content, and PDF pages and templates are reported as dropped, and the rest of what Word can't show exactly as approximated. The same input gives the same bytes. Built on `dskripchenko/php-docx` 1.18. - Laravel: `->docx()` on a `PendingDocument` (needs `papercut/docx`, suggested; throws `MissingExporter` without it), and preview routes accept `?format=docx`. - PDF templates: `Page::template(PdfPage::file($path))` makes a page the size of a page of an existing PDF and draws that page behind everything else on it, for certificates and letterheads. `PdfPage::file()` and `PdfPage::bytes()` take a page number and a password, and also draw a PDF page anywhere in the content, sized like an image. The page is drawn as it is, with its text and vector artwork intact, stored once however many pages use it, and encrypted with the document. A missing file, bytes that aren't a PDF, a wrong password or a missing page throw `PdfUnreadable`. Backends opt in through a new `PdfPageLoader` interface. In Blade, `` (or `template-data`, with `template-page` and `template-password`) and ``. HTML exports leave PDF pages out and report them as dropped. - Placed content: `Page::overlay(Place::at($x, $y, ...))` puts content at a point on the page, over everything else, on every page. `x` and `y` are lengths from the top-left corner or percentages of the page, `anchor()` picks the point of the content placed there, and `width()` takes a length or a percentage of the page width. In Blade, `` in the page's `overlay` slot. HTML exports position places with CSS. diff --git a/docs/superpowers/RULINGS.md b/docs/superpowers/RULINGS.md index bb8f7c1..63ec471 100644 --- a/docs/superpowers/RULINGS.md +++ b/docs/superpowers/RULINGS.md @@ -181,3 +181,6 @@ Design: docs/superpowers/specs/2026-10-19-html-input-design.md. Each entry: what - Ruling (pictures): styled QR codes go through Bacon's Imagick backend rather than through the SVG and Imagick's SVG reader — no dependency on which SVG delegate Imagick was built with — cost if wrong: none - Ruling (small gaps): span backgrounds are written as run shading (`w:shd`) in their exact colour — php-docx writes a highlight value verbatim, so TextMapper writes a marker there and DocxPatcher turns it into `w:shd`, throwing if a marker is left — cost if wrong: none; a php-docx change fails loudly - Ruling (small gaps): opacity multiplies down the tree and each faded colour is blended onto what lies beneath it (the nearest fill, faded or not, else white), not reported — the PDF fades each thing a node draws, so text in a faded card lands on the card's faded fill — cost if wrong: a faded node over a page background layer or an image blends onto the nearest fill instead +- Ruling (php-docx 1.18, sections): a new Word section starts only where the page setup, header, footer or layer pictures change; pages that look alike stay one section joined by page breaks, as before — existing documents keep their output, and Word's section breaks are where page breaks go wrong most — cost if wrong: none visible; more sections would read the same +- Ruling (php-docx 1.18, layers): layer pictures float from the section's header, so they repeat on every page like Papercut's layers; Word draws header content behind the body, so an overlay picture sits behind body text, documented not reported — anchoring in the body would place it on one page only, since Word, not Papercut, paginates — cost if wrong: an overlay stamp over text shows beneath it in Word +- Ruling (php-docx 1.18, layers): a background converts its pictures until the first node that isn't one; everything from there is dropped and reported, since its height in Word is unknown — cost if wrong: a picture under a text block in a background is left out diff --git a/docs/superpowers/specs/2026-10-19-multi-format-export-design.md b/docs/superpowers/specs/2026-10-19-multi-format-export-design.md index a1402f8..599358b 100644 --- a/docs/superpowers/specs/2026-10-19-multi-format-export-design.md +++ b/docs/superpowers/specs/2026-10-19-multi-format-export-design.md @@ -384,5 +384,5 @@ v0.1 was never published to Packagist, so there are no deprecation shims. The ch - *Done as `Markup::html()` and ``, see `2026-10-19-html-input-design.md`:* **Raw HTML input** (``): convert HTML into Papercut nodes once, when the tree is built, using php-pdf's `HtmlParser` and CSS cascade as the front end plus a translator from its elements (`Paragraph`, `Run`, `Table`, `ListNode`) to `Text`/`Span`/`Table`. The result works in every format. It reverses v0.1's "no HTML parser" stance, so it needs its own spec. It would likely also bring a list node. - **Font embedding in DOCX** (upstream). - **An editable DOCX mode** (fewer layout tables, more native paragraphs), as a contribution. -- **Page background in DOCX** as an anchored shape behind the text. +- *Done for pictures (php-docx 1.18):* **Page background in DOCX** as an anchored shape behind the text. - *Done in 0.2 for PDF (DOCX and HTML anchors not yet):* **Bookmarks** (moved from v0.2 to v0.3), mapping naturally to PDF outlines, DOCX heading styles and HTML anchors. diff --git a/packages/docx/README.md b/packages/docx/README.md index f8bf86f..4a54ec3 100644 --- a/packages/docx/README.md +++ b/packages/docx/README.md @@ -44,14 +44,16 @@ Word, LibreOffice and Google Docs lay out the pages themselves, so page breaks c the PDF's. Columns flow as paragraphs, rows become borderless tables at the widths the PDF uses, and a box with padding, a border or a background becomes a one-cell table. -- One page setup for the whole document, taken from the first page; other pages' sizes, headers and footers are - reported as approximated. +- Each run of pages with the same size, margins, header, footer and layers becomes a Word section, so pages + keep their own setup and headers. +- Images, QR codes and SVG in a background or overlay layer become floating pictures at the place the PDF draws + them, on every page. Word draws them with the header, so an overlay sits behind the body's text. - Fonts are named, not embedded: the reader's machine supplies them. - Pictures Word can't take as they are are drawn at the size the PDF gives them: WebP and AVIF images, image `cover` and opacity, and plain QR codes need `ext-gd`; styled QR codes need `ext-imagick` and `bacon/bacon-qr-code`; SVG needs an `ext-imagick` that reads SVG. Without them, these are left out and reported. - Opacity is drawn by blending each faded colour onto what lies beneath it; text backgrounds keep their exact colour. -- Corner radius on boxes, background and overlay layers, PDF page templates +- Corner radius on boxes, other content in background and overlay layers, PDF page templates and embedded PDF pages are left out and reported (strict mode throws on them). Approximations and omissions are reported through `ExportOptions::onWarning` with the node's path, as with HTML diff --git a/packages/docx/composer.json b/packages/docx/composer.json index c9d67f4..612a7f5 100644 --- a/packages/docx/composer.json +++ b/packages/docx/composer.json @@ -37,7 +37,7 @@ "require": { "php": "^8.4", "ext-zip": "*", - "dskripchenko/php-docx": "^1.17", + "dskripchenko/php-docx": "^1.18", "papercut/core": "^0.1" }, "require-dev": { diff --git a/packages/docx/src/DocxExporter.php b/packages/docx/src/DocxExporter.php index 6d0e3e8..f10ab63 100644 --- a/packages/docx/src/DocxExporter.php +++ b/packages/docx/src/DocxExporter.php @@ -5,8 +5,9 @@ namespace Papercut\Docx; use Dskripchenko\PhpDocx\Document as DocxDocument; -use Dskripchenko\PhpDocx\Element\{BlockElement, PageBreak, Paragraph}; +use Dskripchenko\PhpDocx\Element\{BlockElement, Image as DocxImage, PageBreak, Paragraph}; use Dskripchenko\PhpDocx\Style\{CoreProperties, ParagraphStyle, RunStyle, StyleRegistry}; +use Papercut\Page; use Dskripchenko\PhpDocx\Section; use Dskripchenko\PhpDocx\Writer\Word2007Writer; use Papercut\Backend\DefaultBackend; @@ -19,7 +20,10 @@ use Papercut\Style\StyleResolver; use Papercut\Tree\TreeWalker; -/** Word (.docx) export: the document's pages as one flowing Word section that Word paginates. */ +/** + * Word (.docx) export: the document's pages as flowing Word sections that Word paginates, a new section wherever + * the page setup, header, footer or layer pictures change. + */ final class DocxExporter implements Exporter { public const string MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; @@ -37,12 +41,6 @@ public function export(Document $doc, ExportOptions $options): Output if ($page->templatePage() !== null) { $fidelity->dropped('PDF page template', $name); } - if ($page->backgroundNodes() !== []) { - $fidelity->dropped('background layer', $name . ' > background'); - } - if ($page->overlayPlaces() !== []) { - $fidelity->dropped('overlay layer', $name . ' > overlay'); - } } $boxes = new BoxMapper($fidelity); @@ -57,32 +55,12 @@ public function export(Document $doc, ExportOptions $options): Output new ImageMapper($fidelity, DefaultBackend::make(false), Raster::detect()), ); (new TreeWalker($styles))->walk($doc, $visitor); - $first = $visitor->pages()[0] ?? throw new \LogicException('A document needs a page'); - $page = $first['page']; - - foreach ($visitor->pages() as $i => $p) { - if ($i === 0) { - continue; - } - $other = $p['page']; - if (abs($other->width() - $page->width()) > 0.01 || abs($other->height() - $page->height()) > 0.01 || $other->margins() != $page->margins()) { - $fidelity->approximated("page setup other than the first page's", sprintf('page[%d]', $i)); - } - if ($other->headerNodes() !== [] || $other->footerNodes() !== []) { - $fidelity->approximated("header or footer other than the first page's", sprintf('page[%d]', $i)); - } - } - - $body = $this->body($visitor); - // Word needs the body to end in a paragraph; php-docx's own is default height and can spill onto a new page. - if ($body === [] || !end($body) instanceof Paragraph) { - $body[] = Flow::hairline(); - } - $section = new Section($body, $first['regions']['header'] ?? [], $first['regions']['footer'] ?? [], PageSetupMapper::setup($page)); + $sections = $this->sections($visitor); + $first = $visitor->pages()[0]['page']; $meta = $doc->meta(); $date = $meta->createdAt ?? new \DateTimeImmutable('2000-01-01T00:00:00Z'); - $docx = new DocxDocument($section, coreProperties: new CoreProperties( + $docx = new DocxDocument($sections[0], followingSections: array_slice($sections, 1), coreProperties: new CoreProperties( title: $meta->title, subject: $meta->subject, creator: $meta->author, @@ -98,20 +76,110 @@ public function export(Document $doc, ExportOptions $options): Output } $root = $styles->root(); - [$w, $h] = PageSetupMapper::pageSize($page); $bytes = DocxPatcher::patch( (new Word2007Writer($registry))->write($docx), - new PatchSpec($w, $h, $root->fontFamily, (int) round($root->fontSize * 2), $meta->lang, $date), + new PatchSpec($root->fontFamily, (int) round($root->fontSize * 2), $meta->lang, $date), ); return new Output($bytes, self::MIME, 'docx'); } - /** @return list every page's body, joined by page breaks */ - private function body(DocxVisitor $visitor): array + /** + * One section per run of pages that look alike: the same page setup, header, footer and layer pictures. + * + * @return non-empty-list
+ */ + private function sections(DocxVisitor $visitor): array + { + $groups = []; + foreach ($visitor->pages() as $p) { + $last = array_key_last($groups); + if ($last !== null && self::alike($groups[$last][0], $p)) { + $groups[$last][] = $p; + } else { + $groups[] = [$p]; + } + } + if ($groups === []) { + throw new \LogicException('A document needs a page'); + } + + $sections = []; + foreach ($groups as $g => $pages) { + $body = $this->body($pages); + if ($g > 0) { + $body = self::withoutLeadingBreak($body); + } + if ($g < count($groups) - 1 && end($body) instanceof PageBreak) { + // The next section starts on a new page by itself. + array_pop($body); + } + // Word needs a section to end in a paragraph; php-docx's own is default height and can spill onto a new page. + if ($body === [] || !end($body) instanceof Paragraph) { + $body[] = Flow::hairline(); + } + $header = $pages[0]['regions']['header'] ?? []; + if ($pages[0]['floats'] !== []) { + // Pictures placed on the page from the header repeat on every page of the section, as layers do. + $header = [new Paragraph($pages[0]['floats'], Flow::hairline()->style), ...$header]; + } + $footer = $pages[0]['regions']['footer'] ?? []; + // A section without a header or footer shows the previous section's in Word; an empty one stops that. + $previous = $sections[$g - 1] ?? null; + if ($previous !== null && $header === [] && $previous->header !== []) { + $header = [Flow::hairline()]; + } + if ($previous !== null && $footer === [] && $previous->footer !== []) { + $footer = [Flow::hairline()]; + } + $sections[] = new Section($body, $header, $footer, PageSetupMapper::setup($pages[0]['page'])); + } + + return $sections; + } + + /** + * @param array{page: Page, regions: array>, floats: list} $a + * @param array{page: Page, regions: array>, floats: list} $b + */ + private static function alike(array $a, array $b): bool + { + [$pa, $pb] = [$a['page'], $b['page']]; + + return abs($pa->width() - $pb->width()) <= 0.01 && abs($pa->height() - $pb->height()) <= 0.01 + && $pa->margins() == $pb->margins() + && ($a['regions']['header'] ?? []) == ($b['regions']['header'] ?? []) + && ($a['regions']['footer'] ?? []) == ($b['regions']['footer'] ?? []) + && $a['floats'] == $b['floats']; + } + + /** + * A section starts on a new page, so a later section's first paragraph needs no break of its own. + * + * @param list $body + * @return list + */ + private static function withoutLeadingBreak(array $body): array + { + $first = $body[0] ?? null; + if ($first instanceof Paragraph && $first->style->pageBreakBefore) { + $style = new ParagraphStyle(...array_merge(get_object_vars($first->style), ['pageBreakBefore' => false])); + $body[0] = new Paragraph($first->children, $style, $first->headingLevel, $first->styleId); + } + + return $body; + } + + /** + * Pages' bodies, joined by page breaks. + * + * @param list>, floats: list}> $pages + * @return list + */ + private function body(array $pages): array { $body = []; - foreach ($visitor->pages() as $i => $p) { + foreach ($pages as $i => $p) { $blocks = $p['regions']['body'] ?? []; $first = $blocks[0] ?? null; // The pages already break when one ends in a page break or the next starts on a new page. diff --git a/packages/docx/src/Package/DocxPatcher.php b/packages/docx/src/Package/DocxPatcher.php index 15a7846..b033a82 100644 --- a/packages/docx/src/Package/DocxPatcher.php +++ b/packages/docx/src/Package/DocxPatcher.php @@ -5,9 +5,8 @@ namespace Papercut\Docx\Package; /** - * Fixes php-docx 1.17's output where it can't express what Papercut needs: `left`/`right` alignment - * (Google Docs ignores `start`/`end`), run shading in any colour, any page size, the theme's default font, and the - * document language. + * Fixes php-docx's output where it can't express what Papercut needs: `left`/`right` alignment + * (Google Docs ignores `start`/`end`), run shading in any colour, the theme's default font, and the document language. * It edits the exact markup php-docx writes and throws when that markup changes, so an upgrade fails loudly. */ final class DocxPatcher @@ -37,14 +36,6 @@ public static function patch(string $bytes, PatchSpec $spec): string } } - $orient = $spec->pageWidthTwips > $spec->pageHeightTwips ? 'landscape' : 'portrait'; - $parts['word/document.xml'] = self::replaceOnce( - '~~', - sprintf('', $spec->pageWidthTwips, $spec->pageHeightTwips, $orient), - $parts['word/document.xml'] ?? '', - 'page size in word/document.xml', - ); - $font = self::attr($spec->fontFamily); $lang = $spec->lang === null ? '' : ''; $parts['word/styles.xml'] = self::replaceOnce( diff --git a/packages/docx/src/Package/PageSetupMapper.php b/packages/docx/src/Package/PageSetupMapper.php index 63e4387..b8312ea 100644 --- a/packages/docx/src/Package/PageSetupMapper.php +++ b/packages/docx/src/Package/PageSetupMapper.php @@ -8,10 +8,7 @@ use Papercut\Docx\Map\Units; use Papercut\Page; -/** - * A Papercut page as Word's section setup. php-docx only names five paper sizes, so any other size is - * written as A4 here and given its real size by DocxPatcher. - */ +/** A Papercut page as Word's section setup, at its exact size. The paper name is kept when one matches. */ final class PageSetupMapper { /** Points per paper size, portrait. */ @@ -45,12 +42,8 @@ public static function setup(Page $page): PageSetup marginLeftTwips: Units::tw($m->left->points), headerOffsetTwips: Units::tw($m->top->points), footerOffsetTwips: Units::tw($m->bottom->points), + widthTwips: Units::tw($page->width()), + heightTwips: Units::tw($page->height()), ); } - - /** @return array{int, int} width and height in twips, as the page is oriented */ - public static function pageSize(Page $page): array - { - return [Units::tw($page->width()), Units::tw($page->height())]; - } } diff --git a/packages/docx/src/Package/PatchSpec.php b/packages/docx/src/Package/PatchSpec.php index f9ae17c..1211303 100644 --- a/packages/docx/src/Package/PatchSpec.php +++ b/packages/docx/src/Package/PatchSpec.php @@ -8,8 +8,6 @@ final readonly class PatchSpec { public function __construct( - public int $pageWidthTwips, - public int $pageHeightTwips, public string $fontFamily, public int $fontSizeHalfPoints, public ?string $lang, diff --git a/packages/docx/src/Walk/DocxVisitor.php b/packages/docx/src/Walk/DocxVisitor.php index 612710b..c9a361d 100644 --- a/packages/docx/src/Walk/DocxVisitor.php +++ b/packages/docx/src/Walk/DocxVisitor.php @@ -5,9 +5,9 @@ namespace Papercut\Docx\Walk; use Dskripchenko\PhpDocx\Element\{BlockElement, Image as DocxImage, PageBreak as DocxPageBreak, Paragraph}; -use Dskripchenko\PhpDocx\Style\{Alignment, ParagraphStyle}; +use Dskripchenko\PhpDocx\Style\{Alignment, HorizontalAnchor, ImagePosition, ParagraphStyle, VerticalAnchor}; use Papercut\{Image, Link, ListItem, ListNode, Node, Page, PageBreak, PageNumber, PdfPage, Place, QrCode, Spacer, Svg, Table, TableCell, TableRow, Text, View}; -use Papercut\Docx\Map\{BoxMapper, Fade, Flow, ImageMapper, ListMapper, RowMapper, TableMapper, TextMapper}; +use Papercut\Docx\Map\{BoxMapper, Fade, Flow, ImageMapper, ListMapper, RowMapper, TableMapper, TextMapper, Units}; use Papercut\Export\Fidelity; use Papercut\Layout\WidthResolver; use Papercut\Style\{Align, ComputedStyle, Direction, Justify, TextAlign}; @@ -24,11 +24,21 @@ final class DocxVisitor implements TreeVisitor private array $stack = []; /** @var array> blocks per region of the current page */ private array $regions = []; - /** @var list>}> */ + /** @var list>, floats: list}> */ private array $pages = []; private ?Page $page = null; - /** True inside a background or overlay region, which Word can't draw (the exporter reports them). */ + /** True inside a background or overlay region: only its pictures reach Word, as floating pictures. */ private bool $skipping = false; + /** The background or overlay region being walked. */ + private ?Region $layer = null; + /** How far down the page the background's pictures have reached, in pt. */ + private float $layerY = 0.0; + /** Set once a background holds something other than a picture: what follows sits at a height Word can't know. */ + private bool $layerBlocked = false; + /** The overlay place being walked. */ + private ?Place $place = null; + /** @var list the current page's layer pictures, positioned on the page */ + private array $floats = []; /** @var list enclosing block link URIs, innermost last */ private array $links = []; @@ -44,7 +54,7 @@ public function __construct( ) { } - /** @return list>}> */ + /** @return list>, floats: list}> */ public function pages(): array { return $this->pages; @@ -54,16 +64,21 @@ public function enterPage(Page $page, int $index): void { $this->page = $page; $this->regions = []; + $this->floats = []; } public function leavePage(Page $page, int $index): void { - $this->pages[] = ['page' => $page, 'regions' => $this->regions]; + $this->pages[] = ['page' => $page, 'regions' => $this->regions, 'floats' => $this->floats]; } public function enterRegion(Region $region, ComputedStyle $root): void { $this->skipping = $region === Region::Background || $region === Region::Overlay; + $this->layer = $this->skipping ? $region : null; + $this->layerY = 0.0; + $this->layerBlocked = false; + $this->place = null; $page = $this->page; $width = $page === null ? 0.0 : $page->width() - $page->margins()->left->points - $page->margins()->right->points; $this->stack = [new Frame(null, $root, $width, $width)]; @@ -91,7 +106,7 @@ public function leaveRegion(Region $region): void public function enter(Node $node, ComputedStyle $style, Visit $at): bool { if ($this->skipping) { - return false; + return $this->layerNode($node, $style, $at); } $parent = $this->top(); // The opacity in effect here: this node's times its ancestors' (a faded style carries the product). @@ -300,4 +315,132 @@ private function drop(Node $n, Visit $at): array return []; } + + /** + * A node of a background or overlay. Pictures become floating pictures placed where the PDF draws them + * (Paginator: the background is a column the width of the page from its top-left corner; a place puts its + * box's anchor at its point). Anything else is dropped: Word floats only pictures. + */ + private function layerNode(Node $node, ComputedStyle $style, Visit $at): bool + { + $page = $this->page; + \assert($page !== null); + [$w, $h] = [$page->width(), $page->height()]; + $m = $style->margin; + + if ($this->layer === Region::Background) { + $image = $this->layerBlocked || $at->parent() !== null ? null : $this->picture($node, $style, $w, $at); + if ($image === null) { + if (!self::isPicture($node) || $this->layerBlocked) { + $this->fidelity->dropped('background layer', $at->path()); + } + $this->layerBlocked = true; + + return false; + } + [$pw, $ph] = [$image->widthEmu / 12700, $image->heightEmu / 12700]; + $x = match ($style->alignSelf) { + Align::Center => $m->left->points + ($w - $m->horizontal() - $pw) / 2, + Align::End => $w - $m->right->points - $pw, + default => $m->left->points, + }; + $this->float($image, $x, $this->layerY + $m->top->points, true); + $this->layerY += $m->vertical() + $ph; + + return false; + } + + if ($node instanceof Place) { + $this->place = $node; + if (count($node->children()) !== 1) { + $this->fidelity->dropped('overlay layer', $at->path()); + + return false; + } + + return true; + } + $place = $this->place; + \assert($place !== null); + $given = $place->boxWidth(); + $box = $given === null ? $w : max(0.0, self::resolve($given, $w)); + $image = $given === null && $style->width instanceof Percent + ? $this->shareOfNatural($node, $style, $style->width, $w, $at) + : $this->picture($node, $style, $box, $at); + if ($image === null) { + if (!self::isPicture($node)) { + $this->fidelity->dropped('overlay layer', $at->path()); + } + + return false; + } + [$pw, $ph] = [$image->widthEmu / 12700, $image->heightEmu / 12700]; + $boxW = $given === null ? $pw + $m->horizontal() : $box; + $anchor = $place->anchorPoint(); + $x = self::resolve($place->x(), $w) - $anchor->across() * $boxW + $m->left->points; + $y = self::resolve($place->y(), $h) - $anchor->down() * ($ph + $m->vertical()) + $m->top->points; + $this->float($image, $x, $y, false); + + return false; + } + + /** The picture a layer node draws in a column $space pt wide, at the size the PDF gives it; null if it isn't one or can't be drawn. */ + private function picture(Node $node, ComputedStyle $style, float $space, Visit $at): ?DocxImage + { + if (!self::isPicture($node)) { + return null; + } + $root = $this->stack[0]->style; + $width = $this->outerWidth($node, $style, new Frame(null, $root, $space, $space), $at); + $frame = new Frame($node, $style, $width, max(0.0, $width - $style->padding->horizontal() - $style->borderWidth->horizontal())); + $blocks = match (true) { + $node instanceof Image => $this->images->image($node, $style, $frame, $at->path()), + $node instanceof QrCode => $this->images->qr($node, $style, $frame, $at->path()), + $node instanceof Svg => $this->images->svg($node, $style, $frame, $at->path()), + default => [], + }; + $image = $blocks[0] ?? null; + + return $image instanceof DocxImage ? $image : null; + } + + /** + * A percentage width in a place without a width: the place is as wide as the picture's natural size (within + * the page), and the picture takes its share of that, as in the PDF. + */ + private function shareOfNatural(Node $node, ComputedStyle $style, Percent $share, float $page, Visit $at): ?DocxImage + { + $natural = $this->picture($node, new ComputedStyle(...[...get_object_vars($style), 'width' => Auto::instance()]), $page, $at); + if ($natural === null) { + return null; + } + $naturalW = $natural->widthEmu / 12700; + $content = min($page, $naturalW + $style->margin->horizontal()) - $style->margin->horizontal(); + $width = min($content, $content * $share->value / 100); + $scale = $naturalW > 0.0 ? $width / $naturalW : 0.0; + + return new DocxImage($natural->binary, $natural->format, Units::emu($width), (int) round($natural->heightEmu * $scale), $natural->altText); + } + + private function float(DocxImage $image, float $x, float $y, bool $behind): void + { + $this->floats[] = new DocxImage($image->binary, $image->format, $image->widthEmu, $image->heightEmu, $image->altText, position: new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + xEmu: Units::emu($x), + verticalFrom: VerticalAnchor::Page, + yEmu: Units::emu($y), + behindText: $behind, + zOrder: count($this->floats), + )); + } + + private static function isPicture(Node $node): bool + { + return $node instanceof Image || $node instanceof QrCode || $node instanceof Svg; + } + + private static function resolve(Length|Percent $v, float $basis): float + { + return $v instanceof Percent ? $v->resolve($basis) : $v->points; + } } diff --git a/packages/docx/tests/FidelityTableTest.php b/packages/docx/tests/FidelityTableTest.php index bfb80c7..f1a82d4 100644 --- a/packages/docx/tests/FidelityTableTest.php +++ b/packages/docx/tests/FidelityTableTest.php @@ -29,10 +29,8 @@ private static function pdf(): string public static function rows(): iterable { $body = 'page > body'; - yield 'later page setup' => [static fn () => Document::make(Page::a4()->body('a'), Page::a5()->body('b')), FidelityLevel::Approximated, "page setup other than the first page's", 'page[1]']; - yield 'later header' => [static fn () => Document::make(Page::a4()->body('a'), Page::a4()->header('h')->body('b')), FidelityLevel::Approximated, "header or footer other than the first page's", 'page[1]']; - yield 'background' => [static fn () => Page::a4()->background('bg')->body('x'), FidelityLevel::Dropped, 'background layer', 'page > background']; - yield 'overlay' => [static fn () => Page::a4()->body('x')->overlay(Place::at(pt(0), pt(0), 'o')), FidelityLevel::Dropped, 'overlay layer', 'page > overlay']; + yield 'background' => [static fn () => Page::a4()->background('bg')->body('x'), FidelityLevel::Dropped, 'background layer', 'page > background > text[0]']; + yield 'overlay' => [static fn () => Page::a4()->body('x')->overlay(Place::at(pt(0), pt(0), 'o')), FidelityLevel::Dropped, 'overlay layer', 'page > overlay > place[0] > text[0]']; yield 'pdf template' => [static fn () => Page::template(PdfPage::bytes(self::pdf()))->body('x'), FidelityLevel::Dropped, 'PDF page template', 'page']; yield 'min-lines' => [static fn () => Text::make('x')->minLines(3), FidelityLevel::Approximated, 'min-lines 3', "$body > text[0]"]; yield 'wrap' => [static fn () => Row::make(View::make('a')->width(pt(300)), View::make('b')->width(pt(300)))->wrap(), FidelityLevel::Approximated, 'wrap', "$body > row[0]"]; diff --git a/packages/docx/tests/Package/DocxPatcherTest.php b/packages/docx/tests/Package/DocxPatcherTest.php index 32cbb95..bd3cc0f 100644 --- a/packages/docx/tests/Package/DocxPatcherTest.php +++ b/packages/docx/tests/Package/DocxPatcherTest.php @@ -51,7 +51,7 @@ public function test_a_missing_anchor_fails_loudly(): void $this->expectException(\LogicException::class); $this->expectExceptionMessage('php-docx output changed: '); - DocxPatcher::patch($broken, new PatchSpec(11906, 16838, 'Inter', 20, null, new \DateTimeImmutable('2000-01-01'))); + DocxPatcher::patch($broken, new PatchSpec('Inter', 20, null, new \DateTimeImmutable('2000-01-01'))); } public function test_a_shading_marker_left_unpatched_fails_loudly(): void @@ -70,6 +70,6 @@ public function test_a_shading_marker_left_unpatched_fails_loudly(): void $this->expectException(\LogicException::class); $this->expectExceptionMessage('php-docx output changed: run highlight in word/document.xml not found'); - DocxPatcher::patch($changed, new PatchSpec(11906, 16838, 'Inter', 20, null, new \DateTimeImmutable('2000-01-01'))); + DocxPatcher::patch($changed, new PatchSpec('Inter', 20, null, new \DateTimeImmutable('2000-01-01'))); } } diff --git a/packages/docx/tests/Package/PageSetupTest.php b/packages/docx/tests/Package/PageSetupTest.php index c5f7fd4..3069790 100644 --- a/packages/docx/tests/Package/PageSetupTest.php +++ b/packages/docx/tests/Package/PageSetupTest.php @@ -40,18 +40,55 @@ public function test_margins_and_header_footer_distances(): void } } - public function test_pages_are_joined_by_page_breaks_and_later_setups_are_approximated(): void + public function test_a_page_with_another_setup_starts_a_section(): void { $d = self::export(Document::make( Page::a4()->body(Text::make('one')), Page::a5()->header(Text::make('h'))->body(Text::make('two')), )); - self::assertSame(1, $d->count("//w:br[@w:type='page']")); + self::assertSame(0, $d->count("//w:br[@w:type='page']")); self::assertSame(['one', 'two'], $d->values('/w:document/w:body/w:p/w:r/w:t[normalize-space()]')); - self::assertSame([ - "Approximated page setup other than the first page's @ page[1]", - "Approximated header or footer other than the first page's @ page[1]", - ], self::exportedReports()); + // The first section ends in its last paragraph, the second at the end of the body. + self::assertSame(['11906', '16838'], [$d->value('//w:p/w:pPr/w:sectPr/w:pgSz/@w:w'), $d->value('//w:p/w:pPr/w:sectPr/w:pgSz/@w:h')]); + self::assertSame(['8391', '11906'], [$d->value(self::SECT . '/w:pgSz/@w:w'), $d->value(self::SECT . '/w:pgSz/@w:h')]); + self::assertSame(0, $d->count('//w:p/w:pPr/w:sectPr/w:headerReference')); + self::assertSame(1, $d->count(self::SECT . '/w:headerReference')); + self::assertSame([], self::exportedReports()); + } + + public function test_pages_that_look_alike_share_a_section(): void + { + $d = self::export(Document::make( + Page::a4()->header(Text::make('h'))->body(Text::make('one')), + Page::a4()->header(Text::make('h'))->body(Text::make('two')), + )); + self::assertSame(1, $d->count("//w:br[@w:type='page']")); + self::assertSame(1, $d->count('//w:sectPr')); + } + + public function test_a_later_section_without_a_header_does_not_show_the_previous_one(): void + { + $d = self::export(Document::make( + Page::a4()->header(Text::make('h'))->footer(Text::make('f'))->body(Text::make('one')), + Page::a4()->body(Text::make('two')), + )); + self::assertSame(2, $d->count('//w:sectPr')); + $last = (string) $d->value(self::SECT . "/w:headerReference[@w:type='default']/@r:id"); + $target = $d->xpath('word/_rels/document.xml.rels')->evaluate("string(//*[@Id='$last']/@Target)"); + self::assertSame('', trim($d->bodyText('word/' . $target))); + self::assertSame(1, $d->count(self::SECT . "/w:footerReference[@w:type='default']")); + } + + public function test_no_blank_page_between_sections(): void + { + foreach ([ + Document::make(Page::a4()->body(Text::make('one')->breakAfter()), Page::a5()->body(Text::make('two'))), + Document::make(Page::a4()->body(Text::make('one')), Page::a5()->body(Text::make('two')->breakBefore())), + ] as $doc) { + $d = self::export($doc); + self::assertSame(0, $d->count("//w:br[@w:type='page']") + $d->count('//w:pageBreakBefore')); + self::assertSame(2, $d->count('//w:sectPr')); + } } public function test_core_properties(): void diff --git a/packages/docx/tests/Walk/LayersTest.php b/packages/docx/tests/Walk/LayersTest.php new file mode 100644 index 0000000..31d3d01 --- /dev/null +++ b/packages/docx/tests/Walk/LayersTest.php @@ -0,0 +1,130 @@ +value("$a/wp:positionH/wp:posOffset", 'word/header1.xml'), + (int) $d->value("$a/wp:positionV/wp:posOffset", 'word/header1.xml'), + (string) $d->value("$a/@behindDoc", 'word/header1.xml'), + (string) $d->value("$a/wp:positionH/@relativeFrom", 'word/header1.xml'), + (string) $d->value("$a/wp:positionV/@relativeFrom", 'word/header1.xml'), + ]; + } + + public function test_a_background_picture_floats_behind_the_text_from_the_header(): void + { + $d = self::export(Page::a4()->background(Image::bytes(self::png())->width(percent(100)))->body(Text::make('x'))); + self::assertSame(0, $d->count(self::ANCHOR)); + self::assertSame(1, $d->count(self::ANCHOR, 'word/header1.xml')); + self::assertSame([0, 0, '1', 'page', 'page'], self::placed($d)); + // The page's full width, at the image's aspect ratio. + self::assertSame((string) (int) round(595.28 * 12700), $d->value(self::ANCHOR . '/wp:extent/@cx', 'word/header1.xml')); + self::assertSame([], self::exportedReports()); + } + + public function test_background_pictures_stack_down_the_page(): void + { + $d = self::export(Page::a4()->background( + Image::bytes(self::png())->height(pt(10)), + QrCode::make('x')->width(mm(20))->margin(pt(5))->alignSelf(Align::Center), + )->body(Text::make('x'))); + self::assertSame(2, $d->count(self::ANCHOR, 'word/header1.xml')); + [, $y] = self::placed($d, 2); + self::assertSame((int) round(15 * 12700), $y); + [$x] = self::placed($d, 2); + self::assertSame((int) round((595.28 - mm(20)->points) / 2 * 12700), $x); + } + + public function test_a_background_with_text_keeps_the_pictures_above_it(): void + { + $d = self::export(Page::a4()->background(Image::bytes(self::png()), Text::make('t'), Image::bytes(self::png()))->body(Text::make('x'))); + self::assertSame(1, $d->count(self::ANCHOR, 'word/header1.xml')); + self::assertSame([ + 'Dropped background layer @ page > background > text[1]', + 'Dropped background layer @ page > background > image[2]', + ], self::exportedReports()); + } + + public function test_an_overlay_picture_sits_at_its_anchor_in_front(): void + { + $d = self::export(Page::a4()->body(Text::make('x'))->overlay( + Place::at(percent(50), mm(30), QrCode::make('x')->width(pt(40)))->anchor(Anchor::Center), + )); + self::assertSame([ + (int) round((595.28 / 2 - 20) * 12700), + (int) round((mm(30)->points - 20) * 12700), + '0', 'page', 'page', + ], self::placed($d)); + } + + public function test_an_overlay_place_with_a_width_anchors_its_box(): void + { + $d = self::export(Page::a4()->body(Text::make('x'))->overlay( + Place::at(pt(500), pt(100), Image::bytes(self::png()))->width(pt(100))->anchor(Anchor::TopRight), + )); + [$x, $y] = self::placed($d); + self::assertSame([400 * 12700, 100 * 12700], [$x, $y]); + } + + public function test_a_percentage_width_in_an_unsized_place_is_a_share_of_the_natural_size(): void + { + $d = self::export(Page::a5()->body(Text::make('x'))->overlay(Place::at(pt(10), pt(10), Image::bytes(self::png())->width(percent(50))))); + // 4 px is 3pt wide by nature, so half of it. + self::assertSame((string) (int) round(1.5 * 12700), $d->value(self::ANCHOR . '/wp:extent/@cx', 'word/header1.xml')); + } + + public function test_overlays_without_a_single_picture_are_dropped(): void + { + self::export(Page::a4()->body(Text::make('x'))->overlay( + Place::at(pt(0), pt(0), Image::bytes(self::png()), Text::make('caption')), + )); + self::assertSame(['Dropped overlay layer @ page > overlay > place[0]'], self::exportedReports()); + } + + public function test_layer_pictures_go_in_the_existing_header(): void + { + $d = self::export(Page::a4()->header(Text::make('Head'))->background(Image::bytes(self::png()))->body(Text::make('x'))); + self::assertSame(1, $d->count(self::ANCHOR, 'word/header1.xml')); + self::assertSame('Head', trim($d->bodyText('word/header1.xml'))); + } + + public function test_pages_with_different_layers_get_their_own_sections(): void + { + $d = self::export(Document::make( + Page::a4()->background(Image::bytes(self::png()))->body(Text::make('one')), + Page::a4()->body(Text::make('two')), + )); + self::assertSame(2, $d->count('//w:sectPr')); + self::assertSame(1, $d->count(self::ANCHOR, 'word/header1.xml')); + } +} diff --git a/packages/docx/tests/Walk/RegionsTest.php b/packages/docx/tests/Walk/RegionsTest.php index dcc67a1..a0189a8 100644 --- a/packages/docx/tests/Walk/RegionsTest.php +++ b/packages/docx/tests/Walk/RegionsTest.php @@ -64,10 +64,10 @@ public function test_header_and_footer_with_their_gaps(): void public function test_layers_and_pdf_only_nodes_are_dropped(): void { self::export(Page::a4()->background(Text::make('bg'))->body(Text::make('x'))); - self::assertSame(['Dropped background layer @ page > background'], self::exportedReports()); + self::assertSame(['Dropped background layer @ page > background > text[0]'], self::exportedReports()); self::export(Page::a4()->body(Text::make('x'))->overlay(Place::at(pt(0), pt(0), Text::make('o')))); - self::assertSame(['Dropped overlay layer @ page > overlay'], self::exportedReports()); + self::assertSame(['Dropped overlay layer @ page > overlay > place[0] > text[0]'], self::exportedReports()); $template = Document::make(Page::a4()->body('t'))->toPdf()->bytes(); self::export(Page::template(PdfPage::bytes($template))->body(Text::make('x'))); diff --git a/site/src/guide/word-export.md b/site/src/guide/word-export.md index bb64cdb..2076353 100644 --- a/site/src/guide/word-export.md +++ b/site/src/guide/word-export.md @@ -37,7 +37,7 @@ In Laravel, `->docx()` returns a `PendingOutput`, with the same `toString()`, `s Word, LibreOffice and Google Docs lay out the pages themselves, so a page break can land a line or two away from where it is in the PDF. -Papercut writes the layout in a form all three understand. Columns flow as paragraphs. Rows become tables with no borders, at the widths the PDF uses. A box with padding, a border or a background becomes a table with one cell. Lists, tables, links, headings, page numbers and pagination rules such as keep-together carry over. +Papercut writes the layout in a form all three understand. Columns flow as paragraphs. Rows become tables with no borders, at the widths the PDF uses. A box with padding, a border or a background becomes a table with one cell. Lists, tables, links, headings, page numbers and pagination rules such as keep-together carry over. Each run of pages with the same size, margins, header, footer and layers becomes a Word section of its own, so a landscape page or a page without the header comes out as it does in the PDF. Some things are approximated or left out. The ones in the table below are reported as a warning with the node's path, and [strict mode](./rich-text.md#strict-mode) fails on anything left out. The way the layout itself is written (rows as tables, boxes as one-cell tables, header and footer distances, fonts by name, colours with transparency blended onto white) applies to every document, so it isn't reported each time. @@ -45,14 +45,14 @@ Opacity carries over: Word has none, so each colour of a faded node is blended o | In the PDF | In Word | |---|---| -| A different page size, header or footer on a later page | ≈ the first page's setup applies to every page | | Font weights other than regular and bold | ≈ bold from 600, regular below | | Fixed heights, flexible spacers in a column, wrapping rows | ≈ | | `min-lines` above 2 | ≈ Word keeps 2 lines together | | Table cell `justify` other than start, center or end | ≈ top | | A block link around several paragraphs | ≈ a link on each paragraph's text | | Corner radius on a box | ✗ left out | -| Background and overlay layers, PDF page templates, embedded PDF pages | ✗ left out | +| Images, QR codes and SVG in a background or overlay layer | ≈ floating pictures at the same place on every page; Word draws them with the header, so an overlay sits behind the body's text | +| Anything else in a background or overlay layer, PDF page templates, embedded PDF pages | ✗ left out | | Styled QR codes without Imagick | ≈ plain squares | | SVG text | ≈ in the system's fonts | | Pictures without the PHP extension they need (below) | ✗ left out | From 5966bbbd6a5de0aad76c04c26462596f4a6dbb4b Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 28 Oct 2026 14:22:37 -0700 Subject: [PATCH 2/3] fix(docx): layer pictures sized and placed like the PDF in more cases From review of the floating-pictures change, each checked against the PDF layout: - Padding and border on a layer picture: the picture sits inside them, and the next background picture below the whole box. Word can't draw the border or background, so they are reported as dropped. - A picture aligns inside an overlay place that has a width. - An overlay place without a width is as wide as its picture asks (width, else natural size), so height-only, max-width and percentage widths come out as in the PDF. - A percentage width is of the space left after the margins, in the body as well as in layers. - An empty overlay place is ignored instead of reported (strict mode threw on it). - No blank page when a page opens with a page break, at a section break or within one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- packages/docx/README.md | 8 +- packages/docx/src/DocxExporter.php | 5 +- packages/docx/src/Map/ImageMapper.php | 18 +++ packages/docx/src/Walk/DocxVisitor.php | 133 ++++++++++-------- packages/docx/tests/Package/PageSetupTest.php | 9 +- packages/docx/tests/Walk/LayersTest.php | 40 ++++++ site/src/guide/word-export.md | 4 +- 8 files changed, 152 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 659811f..1563af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Migration from v0.1: ### Added -- Word export (experimental): the new `papercut/docx` package. `$document->export(new DocxExporter())` writes a `.docx` that Word, LibreOffice and Google Docs open and paginate themselves: columns flow as paragraphs, rows become borderless tables at the PDF's widths, and boxes with padding, a border or a background become one-cell tables. Lists, tables (repeating headers, rows that don't split, `colspan`), links, headings, images, page numbers, headers and footers, metadata, `lang` and pagination rules carry over. Each run of pages with the same size, margins, header, footer and layers becomes a Word section, so later pages keep their own setup and headers. Pictures Word can't take as they are are drawn at the PDF's size: WebP and AVIF are converted, image `cover` is cropped and opacity faded with `ext-gd`; QR codes are drawn at 300 dpi (styled ones with `ext-imagick` and Bacon, else plain); SVG is drawn at 300 dpi with `ext-imagick`, with nothing outside the file loaded. Opacity is drawn by blending each faded colour onto what lies beneath it, and text backgrounds are shaded in their own colour. Images, QR codes and SVG in background and overlay layers become floating pictures at the PDF's positions, repeated on every page through the header. Corner radius on boxes, other layer content, and PDF pages and templates are reported as dropped, and the rest of what Word can't show exactly as approximated. The same input gives the same bytes. Built on `dskripchenko/php-docx` 1.18. +- Word export (experimental): the new `papercut/docx` package. `$document->export(new DocxExporter())` writes a `.docx` that Word, LibreOffice and Google Docs open and paginate themselves: columns flow as paragraphs, rows become borderless tables at the PDF's widths, and boxes with padding, a border or a background become one-cell tables. Lists, tables (repeating headers, rows that don't split, `colspan`), links, headings, images, page numbers, headers and footers, metadata, `lang` and pagination rules carry over. Each run of pages with the same size, margins, header, footer and layers becomes a Word section, so later pages keep their own setup and headers. Pictures Word can't take as they are are drawn at the PDF's size: WebP and AVIF are converted, image `cover` is cropped and opacity faded with `ext-gd`; QR codes are drawn at 300 dpi (styled ones with `ext-imagick` and Bacon, else plain); SVG is drawn at 300 dpi with `ext-imagick`, with nothing outside the file loaded. Opacity is drawn by blending each faded colour onto what lies beneath it, and text backgrounds are shaded in their own colour. An image, QR code or SVG in a background (before anything else in it) or alone in an overlay place becomes a floating picture at the PDF's position, repeated on every page through the header. Corner radius on boxes, other layer content, a border or background on a layer picture, and PDF pages and templates are reported as dropped, and the rest of what Word can't show exactly as approximated. The same input gives the same bytes. Built on `dskripchenko/php-docx` 1.18. - Laravel: `->docx()` on a `PendingDocument` (needs `papercut/docx`, suggested; throws `MissingExporter` without it), and preview routes accept `?format=docx`. - PDF templates: `Page::template(PdfPage::file($path))` makes a page the size of a page of an existing PDF and draws that page behind everything else on it, for certificates and letterheads. `PdfPage::file()` and `PdfPage::bytes()` take a page number and a password, and also draw a PDF page anywhere in the content, sized like an image. The page is drawn as it is, with its text and vector artwork intact, stored once however many pages use it, and encrypted with the document. A missing file, bytes that aren't a PDF, a wrong password or a missing page throw `PdfUnreadable`. Backends opt in through a new `PdfPageLoader` interface. In Blade, `` (or `template-data`, with `template-page` and `template-password`) and ``. HTML exports leave PDF pages out and report them as dropped. - Placed content: `Page::overlay(Place::at($x, $y, ...))` puts content at a point on the page, over everything else, on every page. `x` and `y` are lengths from the top-left corner or percentages of the page, `anchor()` picks the point of the content placed there, and `width()` takes a length or a percentage of the page width. In Blade, `` in the page's `overlay` slot. HTML exports position places with CSS. diff --git a/packages/docx/README.md b/packages/docx/README.md index 4a54ec3..e3f55bb 100644 --- a/packages/docx/README.md +++ b/packages/docx/README.md @@ -46,14 +46,16 @@ padding, a border or a background becomes a one-cell table. - Each run of pages with the same size, margins, header, footer and layers becomes a Word section, so pages keep their own setup and headers. -- Images, QR codes and SVG in a background or overlay layer become floating pictures at the place the PDF draws - them, on every page. Word draws them with the header, so an overlay sits behind the body's text. +- An image, QR code or SVG in a background (before anything else in it), or alone in an overlay place, becomes a + floating picture at the place the PDF draws it, on every page. Word draws it with the header, so an overlay sits + behind the body's text. - Fonts are named, not embedded: the reader's machine supplies them. - Pictures Word can't take as they are are drawn at the size the PDF gives them: WebP and AVIF images, image `cover` and opacity, and plain QR codes need `ext-gd`; styled QR codes need `ext-imagick` and `bacon/bacon-qr-code`; SVG needs an `ext-imagick` that reads SVG. Without them, these are left out and reported. - Opacity is drawn by blending each faded colour onto what lies beneath it; text backgrounds keep their exact colour. -- Corner radius on boxes, other content in background and overlay layers, PDF page templates +- Corner radius on boxes, other content in background and overlay layers, a border or background on a layer + picture, PDF page templates and embedded PDF pages are left out and reported (strict mode throws on them). Approximations and omissions are reported through `ExportOptions::onWarning` with the node's path, as with HTML diff --git a/packages/docx/src/DocxExporter.php b/packages/docx/src/DocxExporter.php index f10ab63..57688b1 100644 --- a/packages/docx/src/DocxExporter.php +++ b/packages/docx/src/DocxExporter.php @@ -161,6 +161,9 @@ private static function alike(array $a, array $b): bool */ private static function withoutLeadingBreak(array $body): array { + while (($body[0] ?? null) instanceof PageBreak) { + array_shift($body); + } $first = $body[0] ?? null; if ($first instanceof Paragraph && $first->style->pageBreakBefore) { $style = new ParagraphStyle(...array_merge(get_object_vars($first->style), ['pageBreakBefore' => false])); @@ -183,7 +186,7 @@ private function body(array $pages): array $blocks = $p['regions']['body'] ?? []; $first = $blocks[0] ?? null; // The pages already break when one ends in a page break or the next starts on a new page. - if ($i > 0 && !end($body) instanceof PageBreak && !($first instanceof Paragraph && $first->style->pageBreakBefore)) { + if ($i > 0 && !end($body) instanceof PageBreak && !$first instanceof PageBreak && !($first instanceof Paragraph && $first->style->pageBreakBefore)) { $body[] = new PageBreak(); } foreach ($blocks as $b) { diff --git a/packages/docx/src/Map/ImageMapper.php b/packages/docx/src/Map/ImageMapper.php index f768cb2..00d28bc 100644 --- a/packages/docx/src/Map/ImageMapper.php +++ b/packages/docx/src/Map/ImageMapper.php @@ -119,6 +119,24 @@ public function svg(Svg $n, ComputedStyle $s, Frame $frame, string $path): array return [$this->picture($png, $s, $w, $h, $path, $doc->title === '' ? null : $doc->title)]; } + /** + * The width a picture asks for when nothing constrains it, as the PDF's layouters measure it: an explicit + * width, else the natural width plus padding and border (a QR code takes its height, else 25mm). + */ + public function intrinsicWidth(Image|QrCode|Svg $n, ComputedStyle $s): float + { + if ($s->width instanceof Length) { + return $s->width->points; + } + $decor = $s->padding->horizontal() + $s->borderWidth->horizontal(); + + return match (true) { + $n instanceof QrCode => $s->height instanceof Length ? $s->height->points : mm(25)->points, + $n instanceof Svg => $n->document()->widthPt + $decor, + default => $this->load($n)->widthPx * 0.75 + $decor, + }; + } + /** A PNG Papercut drew, with the node's opacity applied. */ private function picture(string $png, ComputedStyle $s, float $w, float $h, string $path, ?string $alt): DocxImage { diff --git a/packages/docx/src/Walk/DocxVisitor.php b/packages/docx/src/Walk/DocxVisitor.php index c9a361d..f05493c 100644 --- a/packages/docx/src/Walk/DocxVisitor.php +++ b/packages/docx/src/Walk/DocxVisitor.php @@ -14,6 +14,8 @@ use Papercut\Tree\{Region, TreeVisitor, Visit}; use Papercut\Value\{Auto, Edges, Length, Percent}; +use function Papercut\pt; + /** * Turns a tree walk into php-docx blocks (approach B2 of the multi-format spec): columns flow as paragraphs, * rows become borderless fixed-layout tables sized by WidthResolver, and a decorated box becomes a single-cell table. @@ -248,7 +250,8 @@ private function outerWidth(Node $node, ComputedStyle $style, Frame $parent, Vis $avail = max(0.0, $parent->content - $style->margin->horizontal()); $w = match (true) { $style->width instanceof Length => $style->width->points, - $style->width instanceof Percent => $parent->content * $style->width->value / 100, + // As in the PDF, a percentage is of the space left after the margins. + $style->width instanceof Percent => $avail * $style->width->value / 100, default => $avail, }; if ($style->maxWidth instanceof Length) { @@ -318,8 +321,9 @@ private function drop(Node $n, Visit $at): array /** * A node of a background or overlay. Pictures become floating pictures placed where the PDF draws them - * (Paginator: the background is a column the width of the page from its top-left corner; a place puts its - * box's anchor at its point). Anything else is dropped: Word floats only pictures. + * (Paginator: the background is a column the width of the page from its top-left corner; a place lays out its + * content in a box its width, or as wide as the content wants, and puts the box's anchor at its point). + * Anything else is dropped: Word floats only pictures. */ private function layerNode(Node $node, ComputedStyle $style, Visit $at): bool { @@ -329,8 +333,8 @@ private function layerNode(Node $node, ComputedStyle $style, Visit $at): bool $m = $style->margin; if ($this->layer === Region::Background) { - $image = $this->layerBlocked || $at->parent() !== null ? null : $this->picture($node, $style, $w, $at); - if ($image === null) { + $picture = $this->layerBlocked || $at->parent() !== null ? null : $this->layerPicture($node, $style, $w, $at); + if ($picture === null) { if (!self::isPicture($node) || $this->layerBlocked) { $this->fidelity->dropped('background layer', $at->path()); } @@ -338,92 +342,103 @@ private function layerNode(Node $node, ComputedStyle $style, Visit $at): bool return false; } - [$pw, $ph] = [$image->widthEmu / 12700, $image->heightEmu / 12700]; - $x = match ($style->alignSelf) { - Align::Center => $m->left->points + ($w - $m->horizontal() - $pw) / 2, - Align::End => $w - $m->right->points - $pw, - default => $m->left->points, - }; - $this->float($image, $x, $this->layerY + $m->top->points, true); - $this->layerY += $m->vertical() + $ph; + [$image, $boxW, $boxH] = $picture; + $x = self::aligned($style, $m->left->points, $w - $m->horizontal(), $boxW); + $this->float($image, $style, $x, $this->layerY + $m->top->points, true); + $this->layerY += $m->vertical() + $boxH; return false; } if ($node instanceof Place) { $this->place = $node; - if (count($node->children()) !== 1) { + $children = count($node->children()); + if ($children > 1) { $this->fidelity->dropped('overlay layer', $at->path()); - - return false; } - return true; + return $children === 1; } $place = $this->place; \assert($place !== null); - $given = $place->boxWidth(); - $box = $given === null ? $w : max(0.0, self::resolve($given, $w)); - $image = $given === null && $style->width instanceof Percent - ? $this->shareOfNatural($node, $style, $style->width, $w, $at) - : $this->picture($node, $style, $box, $at); - if ($image === null) { - if (!self::isPicture($node)) { - $this->fidelity->dropped('overlay layer', $at->path()); - } + if (!self::isPicture($node)) { + $this->fidelity->dropped('overlay layer', $at->path()); return false; } - [$pw, $ph] = [$image->widthEmu / 12700, $image->heightEmu / 12700]; - $boxW = $given === null ? $pw + $m->horizontal() : $box; + \assert($node instanceof Image || $node instanceof QrCode || $node instanceof Svg); + $given = $place->boxWidth(); + $box = $given === null + ? min($w, $this->images->intrinsicWidth($node, $style) + $m->horizontal()) + : max(0.0, self::resolve($given, $w)); + $picture = $this->layerPicture($node, $style, $box, $at); + if ($picture === null) { + return false; + } + [$image, $boxW, $boxH] = $picture; $anchor = $place->anchorPoint(); - $x = self::resolve($place->x(), $w) - $anchor->across() * $boxW + $m->left->points; - $y = self::resolve($place->y(), $h) - $anchor->down() * ($ph + $m->vertical()) + $m->top->points; - $this->float($image, $x, $y, false); + $x = self::resolve($place->x(), $w) - $anchor->across() * $box; + $y = self::resolve($place->y(), $h) - $anchor->down() * ($boxH + $m->vertical()); + $this->float($image, $style, self::aligned($style, $x + $m->left->points, $box - $m->horizontal(), $boxW), $y + $m->top->points, false); return false; } - /** The picture a layer node draws in a column $space pt wide, at the size the PDF gives it; null if it isn't one or can't be drawn. */ - private function picture(Node $node, ComputedStyle $style, float $space, Visit $at): ?DocxImage + /** + * The picture a layer node draws in a column $space pt wide, as the PDF sizes it, with its border box in pt; + * null if it isn't a picture or can't be drawn (the mapper reports why). + * + * @return array{DocxImage, float, float}|null + */ + private function layerPicture(Node $node, ComputedStyle $style, float $space, Visit $at): ?array { if (!self::isPicture($node)) { return null; } - $root = $this->stack[0]->style; - $width = $this->outerWidth($node, $style, new Frame(null, $root, $space, $space), $at); - $frame = new Frame($node, $style, $width, max(0.0, $width - $style->padding->horizontal() - $style->borderWidth->horizontal())); - $blocks = match (true) { - $node instanceof Image => $this->images->image($node, $style, $frame, $at->path()), - $node instanceof QrCode => $this->images->qr($node, $style, $frame, $at->path()), - $node instanceof Svg => $this->images->svg($node, $style, $frame, $at->path()), - default => [], - }; + $outer = $this->outerWidth($node, $style, new Frame(null, $this->stack[0]->style, $space, $space), $at); + $decor = $style->padding->horizontal() + $style->borderWidth->horizontal(); + $decorV = $style->padding->vertical() + $style->borderWidth->vertical(); + if ($node instanceof QrCode) { + // The mapper takes the QR code's padding and border off its side itself. + $blocks = $this->images->qr($node, $style, new Frame($node, $style, $outer, max(0.0, $outer - $decor)), $at->path()); + } else { + // Widths and heights are border-box sizes; the mapper sizes the picture inside them. + $inner = $style->height instanceof Length + ? new ComputedStyle(...[...get_object_vars($style), 'height' => pt(max(0.0, $style->height->points - $decorV))]) + : $style; + $content = max(0.0, $outer - $decor); + $frame = new Frame($node, $inner, $content, $content); + $blocks = $node instanceof Image + ? $this->images->image($node, $inner, $frame, $at->path()) + : ($node instanceof Svg ? $this->images->svg($node, $inner, $frame, $at->path()) : []); + } $image = $blocks[0] ?? null; + if (!$image instanceof DocxImage) { + return null; + } + if (($style->borderWidth->horizontal() + $style->borderWidth->vertical() > 0.0 && !$style->borderColor->isTransparent()) + || Units::fill($style->background) !== null) { + $this->fidelity->dropped('border or background on a layer picture', $at->path()); + } - return $image instanceof DocxImage ? $image : null; + return [$image, $image->widthEmu / 12700 + $decor, $image->heightEmu / 12700 + $decorV]; } - /** - * A percentage width in a place without a width: the place is as wide as the picture's natural size (within - * the page), and the picture takes its share of that, as in the PDF. - */ - private function shareOfNatural(Node $node, ComputedStyle $style, Percent $share, float $page, Visit $at): ?DocxImage + /** Where a box $boxW wide sits in a line $space wide from $left, by the node's align-self. */ + private static function aligned(ComputedStyle $style, float $left, float $space, float $boxW): float { - $natural = $this->picture($node, new ComputedStyle(...[...get_object_vars($style), 'width' => Auto::instance()]), $page, $at); - if ($natural === null) { - return null; - } - $naturalW = $natural->widthEmu / 12700; - $content = min($page, $naturalW + $style->margin->horizontal()) - $style->margin->horizontal(); - $width = min($content, $content * $share->value / 100); - $scale = $naturalW > 0.0 ? $width / $naturalW : 0.0; - - return new DocxImage($natural->binary, $natural->format, Units::emu($width), (int) round($natural->heightEmu * $scale), $natural->altText); + return match ($style->alignSelf) { + Align::Center => $left + ($space - $boxW) / 2, + Align::End => $left + $space - $boxW, + default => $left, + }; } - private function float(DocxImage $image, float $x, float $y, bool $behind): void + /** Floats $image on the page with its border box at $x, $y. */ + private function float(DocxImage $image, ComputedStyle $style, float $x, float $y, bool $behind): void { + $x += $style->padding->left->points + $style->borderWidth->left->points; + $y += $style->padding->top->points + $style->borderWidth->top->points; $this->floats[] = new DocxImage($image->binary, $image->format, $image->widthEmu, $image->heightEmu, $image->altText, position: new ImagePosition( horizontalFrom: HorizontalAnchor::Page, xEmu: Units::emu($x), diff --git a/packages/docx/tests/Package/PageSetupTest.php b/packages/docx/tests/Package/PageSetupTest.php index 3069790..200b9e5 100644 --- a/packages/docx/tests/Package/PageSetupTest.php +++ b/packages/docx/tests/Package/PageSetupTest.php @@ -4,7 +4,7 @@ namespace Papercut\Docx\Tests\Package; -use Papercut\{Document, Page, Text}; +use Papercut\{Document, Page, PageBreak, Text}; use Papercut\Docx\Tests\Support\Styles; use PHPUnit\Framework\TestCase; @@ -84,6 +84,7 @@ public function test_no_blank_page_between_sections(): void foreach ([ Document::make(Page::a4()->body(Text::make('one')->breakAfter()), Page::a5()->body(Text::make('two'))), Document::make(Page::a4()->body(Text::make('one')), Page::a5()->body(Text::make('two')->breakBefore())), + Document::make(Page::a4()->body(Text::make('one')), Page::a5()->body(PageBreak::make(), Text::make('two'))), ] as $doc) { $d = self::export($doc); self::assertSame(0, $d->count("//w:br[@w:type='page']") + $d->count('//w:pageBreakBefore')); @@ -91,6 +92,12 @@ public function test_no_blank_page_between_sections(): void } } + public function test_a_page_opening_with_a_page_break_breaks_once(): void + { + $d = self::export(Document::make(Page::a4()->body(Text::make('one')), Page::a4()->body(PageBreak::make(), Text::make('two')))); + self::assertSame(1, $d->count("//w:br[@w:type='page']")); + } + public function test_core_properties(): void { $doc = Document::make(Page::a4()->body(Text::make('x'))) diff --git a/packages/docx/tests/Walk/LayersTest.php b/packages/docx/tests/Walk/LayersTest.php index 31d3d01..e255618 100644 --- a/packages/docx/tests/Walk/LayersTest.php +++ b/packages/docx/tests/Walk/LayersTest.php @@ -5,6 +5,7 @@ namespace Papercut\Docx\Tests\Walk; use Papercut\{Document, Image, Page, Place, QrCode, Text}; +use Papercut\Export\FidelityLevel; use Papercut\Docx\Tests\Support\{DocxInspector, Styles}; use Papercut\Style\{Align, Anchor}; use PHPUnit\Framework\TestCase; @@ -103,6 +104,45 @@ public function test_a_percentage_width_in_an_unsized_place_is_a_share_of_the_na self::assertSame((string) (int) round(1.5 * 12700), $d->value(self::ANCHOR . '/wp:extent/@cx', 'word/header1.xml')); } + public function test_padding_and_border_size_the_picture_inside_its_box(): void + { + $d = self::export(Page::a4()->background( + Image::bytes(self::png())->width(pt(100))->padding(pt(10)), + Image::bytes(self::png())->width(pt(50))->border(pt(4), 'black'), + )->body(Text::make('x'))); + [$x, $y] = self::placed($d); + self::assertSame([10 * 12700, 10 * 12700], [$x, $y]); + self::assertSame((string) (80 * 12700), $d->value('(' . self::ANCHOR . ')[1]/wp:extent/@cx', 'word/header1.xml')); + // The second sits below the first's whole box, inside its own border. + [$x, $y] = self::placed($d, 2); + self::assertSame([4 * 12700, 64 * 12700], [$x, $y]); + // Word can't draw the border. + self::assertSame(['Dropped border or background on a layer picture @ page > background > image[1]'], self::exportedReports()); + } + + public function test_a_picture_aligns_inside_a_place_with_a_width(): void + { + $d = self::export(Page::a4()->body(Text::make('x'))->overlay( + Place::at(pt(100), pt(100), Image::bytes(self::png())->width(pt(50))->alignSelf(Align::Center))->width(pt(200)), + )); + self::assertSame(175 * 12700, self::placed($d)[0]); + } + + public function test_an_unsized_place_is_as_wide_as_its_picture_asks(): void + { + // A height alone doesn't widen the place, so the picture shrinks to its natural width, as in the PDF. + $d = self::export(Page::a4()->body(Text::make('x'))->overlay( + Place::at(pt(100), pt(100), Image::bytes(self::png())->height(pt(50)))->anchor(Anchor::BottomRight), + )); + self::assertSame([97 * 12700, (int) round(98.5 * 12700)], array_slice(self::placed($d), 0, 2)); + } + + public function test_an_empty_place_is_ignored(): void + { + self::export(Page::a4()->body(Text::make('x'))->overlay(Place::at(pt(0), pt(0))), strict: true); + self::assertSame([], self::exportedReports()); + } + public function test_overlays_without_a_single_picture_are_dropped(): void { self::export(Page::a4()->body(Text::make('x'))->overlay( diff --git a/site/src/guide/word-export.md b/site/src/guide/word-export.md index 2076353..66b2376 100644 --- a/site/src/guide/word-export.md +++ b/site/src/guide/word-export.md @@ -51,8 +51,8 @@ Opacity carries over: Word has none, so each colour of a faded node is blended o | Table cell `justify` other than start, center or end | ≈ top | | A block link around several paragraphs | ≈ a link on each paragraph's text | | Corner radius on a box | ✗ left out | -| Images, QR codes and SVG in a background or overlay layer | ≈ floating pictures at the same place on every page; Word draws them with the header, so an overlay sits behind the body's text | -| Anything else in a background or overlay layer, PDF page templates, embedded PDF pages | ✗ left out | +| An image, QR code or SVG in a background (before anything else in it) or alone in an overlay place | ≈ a floating picture at the same place on every page; Word draws it with the header, so an overlay sits behind the body's text | +| Anything else in a background or overlay layer, a border or background on a layer picture, PDF page templates, embedded PDF pages | ✗ left out | | Styled QR codes without Imagick | ≈ plain squares | | SVG text | ≈ in the system's fonts | | Pictures without the PHP extension they need (below) | ✗ left out | From 20533d654f269be3fa1bddb1c37d4357a67456a5 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 28 Oct 2026 14:17:34 -0700 Subject: [PATCH 3/3] chore: prepare 0.3.0, the first Packagist release - Core's branch alias is 0.3.x-dev; Laravel and DOCX require papercut/core ^0.3 (Laravel's dev and path pins for DOCX too). - CHANGELOG: the unreleased 0.2.0 section becomes 0.3.0, noting that 0.1 and 0.2 were never published. The date goes in at tag time. - READMEs and the site say 0.3 and drop "not yet published"; the Laravel README's path-repository install steps give way to composer require. Before 1.0, a minor version may change the API. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- CONTRIBUTING.md | 2 +- README.md | 4 ++-- packages/core/README.md | 2 +- packages/core/composer.json | 2 +- packages/docx/README.md | 4 +--- packages/docx/composer.json | 4 ++-- packages/laravel/README.md | 18 ++---------------- packages/laravel/composer.json | 8 ++++---- site/src/guide/README.md | 2 +- 10 files changed, 18 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1563af3..0746209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,10 @@ 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 +## [0.3.0] - unreleased -v0.1 was never published to Packagist, so there are no deprecation shims. +The first release on Packagist. 0.1 and 0.2 were never published, so there are no deprecation shims; the table +below maps v0.1 names for anyone who used the repository directly. ### Changed (breaking) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e675aee..9dbfe6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ composer --working-dir=packages/core install composer --working-dir=packages/laravel install ``` -The Laravel package's path repository pins core to `0.1.x-dev` (`options.versions` in its `composer.json`), so it +The Laravel and DOCX packages' path repositories pin core (and DOCX) to `0.3.x-dev` (`options.versions` in its `composer.json`), so it resolves on any branch or detached checkout. Bump that pin together with core's `branch-alias` when the minor version changes. diff --git a/README.md b/README.md index 030ad38..f79aee0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ is small, strict and ours, and a typo in an attribute is an error that names the **[Documentation](https://one-learning-community.github.io/papercut/)** · [Guide](https://one-learning-community.github.io/papercut/guide/) · [Laravel](https://one-learning-community.github.io/papercut/laravel/) · [Reference](https://one-learning-community.github.io/papercut/reference/blade-tags.html) · [Examples](https://one-learning-community.github.io/papercut/examples.html) -> **Status: 0.2, pre-release.** Not yet published to Packagist. See [Status](#status) below. +> **Status: 0.3, early release.** Before 1.0 a minor version (0.4) may change the API; patch versions won't. See [Status](#status) below. ## A Blade document @@ -117,7 +117,7 @@ The guide lives at [one-learning-community.github.io/papercut](https://one-learn ## Status -0.2, pre-release, not yet published to Packagist. No performance or compatibility benchmarks have been +0.3, the first release on Packagist. Before 1.0 a minor version may change the API; patch versions won't. No performance or compatibility benchmarks have been published; do not assume any. Requires PHP 8.4+ and Laravel 12 or 13 (CI tests against both). Not supported yet: HarfBuzz shaping (the backend exposes no glyph-level drawing API; its built-in shaping covers kerning diff --git a/packages/core/README.md b/packages/core/README.md index a7e5861..a81cb73 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,7 +11,7 @@ There is no browser and no CSS cascade. **Documentation:** [one-learning-community.github.io/papercut](https://one-learning-community.github.io/papercut/guide/) -> **Status: 0.2, pre-release.** Not yet published to Packagist. Using Laravel? See +> **Status: 0.3, early release.** Before 1.0 a minor version may change the API. Using Laravel? See > [`papercut/laravel`](../laravel) for the Blade front end of this same engine. ## Requirements diff --git a/packages/core/composer.json b/packages/core/composer.json index 9ab292c..070d434 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -62,7 +62,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } } } diff --git a/packages/docx/README.md b/packages/docx/README.md index e3f55bb..4b3799f 100644 --- a/packages/docx/README.md +++ b/packages/docx/README.md @@ -13,14 +13,12 @@ Word's paragraphs and tables; php-docx writes the file. Thank you. **Documentation:** [Word export](https://one-learning-community.github.io/papercut/guide/word-export.html) -> **Status: experimental, pre-release. Not yet published to Packagist.** Word, LibreOffice and Google Docs lay out +> **Status: experimental.** Word, LibreOffice and Google Docs lay out > the file themselves, so spacing and page breaks can differ slightly from the PDF, and the output may change between > minor versions while it settles. ## Install -Once published: - ``` composer require papercut/docx ``` diff --git a/packages/docx/composer.json b/packages/docx/composer.json index 612a7f5..9220c7f 100644 --- a/packages/docx/composer.json +++ b/packages/docx/composer.json @@ -29,7 +29,7 @@ "options": { "symlink": true, "versions": { - "papercut/core": "0.1.x-dev" + "papercut/core": "0.3.x-dev" } } } @@ -38,7 +38,7 @@ "php": "^8.4", "ext-zip": "*", "dskripchenko/php-docx": "^1.18", - "papercut/core": "^0.1" + "papercut/core": "^0.3" }, "require-dev": { "bacon/bacon-qr-code": "^3.1", diff --git a/packages/laravel/README.md b/packages/laravel/README.md index d9cd5f1..5a7dc96 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -9,29 +9,15 @@ Blade components for [Papercut](../../README.md): `` tags compile into **Documentation:** [one-learning-community.github.io/papercut/laravel](https://one-learning-community.github.io/papercut/laravel/) -> **Status: 0.2, pre-release. Not yet published to Packagist.** +> **Status: 0.3, early release.** Before 1.0 a minor version (0.4) may change the API; patch versions won't. ## Install -Once published: - ``` composer require papercut/laravel ``` -To try it now, clone the monorepo and add path repositories to your app's `composer.json`: - -```json -{ - "repositories": [ - { "type": "path", "url": "../php-pdf/packages/core" }, - { "type": "path", "url": "../php-pdf/packages/laravel" } - ], - "minimum-stability": "dev" -} -``` - -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. +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 diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index d59c687..f37e24b 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -22,7 +22,7 @@ "options": { "symlink": true, "versions": { - "papercut/core": "0.1.x-dev" + "papercut/core": "0.3.x-dev" } } }, @@ -32,7 +32,7 @@ "options": { "symlink": true, "versions": { - "papercut/docx": "0.1.x-dev" + "papercut/docx": "0.3.x-dev" } } } @@ -44,12 +44,12 @@ "illuminate/routing": "^12.0|^13.0", "illuminate/support": "^12.0|^13.0", "illuminate/view": "^12.0|^13.0", - "papercut/core": "^0.1" + "papercut/core": "^0.3" }, "require-dev": { "bacon/bacon-qr-code": "^3.1", "orchestra/testbench": "^11.3", - "papercut/docx": "^0.1", + "papercut/docx": "^0.3", "phpstan/phpstan": "^2.3", "phpunit/phpunit": "^13.4" }, diff --git a/site/src/guide/README.md b/site/src/guide/README.md index 2c4461e..2a11d97 100644 --- a/site/src/guide/README.md +++ b/site/src/guide/README.md @@ -32,7 +32,7 @@ Papercut does the layout; [php-pdf](https://github.com/dskripchenko/php-pdf), by ## Current limitations -Papercut is at 0.2, and a few things aren't supported yet: +Papercut is at 0.3, and a few things aren't supported yet: - complex text shaping (HarfBuzz), right-to-left and bidirectional text, and hyphenation - multi-column text flow