Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ file. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Sections: `Document::$followingSections` (after the first section) and
`Document::sections()`, `Section::$start` (`SectionStart`: `NextPage`,
`Continuous`, `EvenPage`, `OddPage`), and `DocumentBuilder::newSection()`.
Each section has its own page setup and header/footer parts; every section
but the last ends with its `<w:sectPr>` in the pPr of its last paragraph
(one is added when it ends in a table). A section without a header or
footer of a kind shows the previous section's, as in Word.
- `PageSetup::$widthTwips` / `$heightTwips`: any page size, not only a
`PaperSize` (landscape when wider than tall), with `pageWidthTwips()`,
`pageHeightTwips()` and `effectiveOrientation()`.
- Floating pictures: `Image::$position` (`ImagePosition`) writes the picture
as `<wp:anchor>` instead of `<wp:inline>`. On each axis it sits at an
offset in EMU from what it is measured from (`HorizontalAnchor`: page,
margin, column, character; `VerticalAnchor`: page, margin, paragraph,
line) or is aligned there (`PositionAlign`); `behindText`, `wrap`
(`ImageWrap`: none, square, top and bottom) and `zOrder` set how it sits
with the text. A picture positioned on the page from a header or footer
repeats on every page. `Image::fromPx()` and the builder's
`imageFromBytes()`, `imageFromDataUrl()` and `imageFromFile()` take a
`position`.

### Changed
- `DocxReader` reads every section — body, page setup, start, headers and
footers — instead of the last section's page setup and the first section's
headers for the whole document. **Code that reads only `$document->section`
now sees the first section of a multi-section file;** use
`$document->sections()` for all of it. A page size no `PaperSize` matches is
kept as a custom size instead of becoming A4.
- The HTML serializer writes every section's body, with a page break before
each section that starts on a new page.
- An `Image` with a non-zero `offsetYEmu` and no `position` is now written
floating that far below the top of its paragraph, as the property always
described; the writer used to ignore it and write the picture inline.
- `DocxReader` reads an anchored picture's position into `Image::$position`
(it still fills `offsetYEmu`, which the HTML serializer uses), so writing a
read document keeps its floating pictures where they were.

## [1.17.0] — 2026-10-27

### Added
Expand Down
93 changes: 90 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ external dependencies beyond standard PHP extensions.
- [Programmatic builder API](#programmatic-builder-api)
- [DOCX → HTML (Reader)](#docx--html-reader)
- [Headers, footers & watermarks](#headers-footers--watermarks)
- [Sections](#sections)
- [Variable detection](#variable-detection)
- [Length helpers](#length-helpers)
- [AST overview](#ast-overview)
Expand All @@ -65,6 +66,12 @@ external dependencies beyond standard PHP extensions.
text patterns (`{{x}}`, `${x}`, `%x%`).
- **Multi-header/footer** — default / first-page / even-pages variants
with automatic `<w:titlePg/>` and `<w:evenAndOddHeaders/>` plumbing.
- **Sections** — several per document, each with its own page setup
(a preset paper size or any custom size), start (new page, continuous,
even or odd page) and headers/footers; read back section by section.
- **Floating pictures** — positioned on the page, margin or paragraph, in
front of or behind the text, with wrapping; from a header they repeat on
every page.
- **Round-trip safe** — read DOCX → AST → write DOCX produces a valid
document; bytes-level differences are limited to whitespace/ordering.
- **PHP 8.2+** — `readonly` value-objects, named arguments,
Expand Down Expand Up @@ -592,6 +599,86 @@ DocumentBuilder::new()

Renders as a 45°-rotated VML text shape on every page.

### Floating pictures

An `Image` with a `position` (an `ImagePosition`) floats (`<wp:anchor>`)
instead of sitting in the line of text. On each axis it is placed at an
offset in EMU from the page, the margin, the column or character
(horizontally), or the paragraph or line (vertically), or aligned there with
a `PositionAlign`. It is drawn in front of the text, or behind it with
`behindText`, and the text flows around it as `wrap` says (`None`, `Square`,
`TopAndBottom`); `zOrder` stacks floating pictures. The builder's
`imageFromBytes()`, `imageFromDataUrl()` and `imageFromFile()` take a
`position`.

```php
use Dskripchenko\PhpDocx\Element\ImageFormat;
use Dskripchenko\PhpDocx\Style\HorizontalAnchor;
use Dskripchenko\PhpDocx\Style\ImagePosition;
use Dskripchenko\PhpDocx\Style\ImageWrap;
use Dskripchenko\PhpDocx\Style\PositionAlign;
use Dskripchenko\PhpDocx\Style\VerticalAnchor;

DocumentBuilder::new()
// A logo in the top right corner of every page, behind the text.
->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition(
horizontalFrom: HorizontalAnchor::Page,
horizontalAlign: PositionAlign::Right,
verticalFrom: VerticalAnchor::Page,
yEmu: 360000, // 1 cm from the top
behindText: true,
))))
// A photo the text wraps around.
->paragraph(fn ($p) => $p
->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square))
->text('Text flows around the photo.'))
->toFile('floating.docx');
```

A picture placed on the page from a header or footer is drawn on every page
that header governs: letterheads, page backgrounds, stamps. An `offsetYEmu`
without a `position` floats the picture that far below the top of its
paragraph. `DocxReader` reads the position back.

---

## Sections

A document is one section unless the page setup or the headers change part
way through. `newSection()` ends the current section and starts the next:
the content, headers and footers added after it belong to the new one.

```php
use Dskripchenko\PhpDocx\Build\DocumentBuilder;
use Dskripchenko\PhpDocx\Style\Orientation;
use Dskripchenko\PhpDocx\Style\PageSetup;
use Dskripchenko\PhpDocx\Style\SectionStart;

DocumentBuilder::new()
->header(fn ($h) => $h->paragraph('Report'))
->paragraph('Portrait pages')
->newSection(new PageSetup(orientation: Orientation::Landscape))
->header(fn ($h) => $h->paragraph('Appendix'))
->paragraph('A wide table on landscape pages')
->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage)
->paragraph('A custom page size (A5 here), starting on an odd page')
->toFile('sections.docx');
```

- A new section keeps the page setup unless given one, and starts on a new
page unless given a `SectionStart` (`Continuous`, `EvenPage`, `OddPage`).
- A section shows the previous section's headers and footers until it sets
its own (Word's rule): an empty header list means "inherit", not "blank".
- `PageSetup(widthTwips:, heightTwips:)` sets any page size; the page is
landscape when it is wider than it is tall.
- Without the builder: `new Document($first, followingSections: [$second, …])`;
`Document::sections()` lists them all.
- Every section but the last ends with its `<w:sectPr>` in the properties
of its last paragraph (a paragraph is added when the section ends in a
table). `DocxReader` reads each section back with its own body, page
setup, start and headers; the HTML serializer puts the bodies one after
another, with a page break before each section that starts on a new page.

---

## Variable detection
Expand Down Expand Up @@ -646,13 +733,13 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace.

| Element | Type | Notes |
|---|---|---|
| `Document` | root | `{ section: Section, watermarkText: ?string }` |
| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` |
| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` |
| `Paragraph` | BlockElement | `{ children: InlineElement[], style: ParagraphStyle, headingLevel: ?int, styleId: ?string }` |
| `Run` | InlineElement | `{ text: string, style: RunStyle }` |
| `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children: InlineElement[] }` |
| `Bookmark` | InlineElement | `{ name: string, children: InlineElement[] }` |
| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText }` |
| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` |
| `Field` | InlineElement | `{ instruction: string, style: RunStyle }` |
| `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker elements |
| `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` |
Expand Down
94 changes: 91 additions & 3 deletions docs/de/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Abhängigkeiten ausser Standard-PHP-Erweiterungen.
- [Builder-API](#builder-api)
- [DOCX → HTML (Reader)](#docx--html-reader)
- [Kopf-/Fußzeilen & Wasserzeichen](#kopf-fußzeilen--wasserzeichen)
- [Abschnitte](#abschnitte)
- [Variablenerkennung](#variablenerkennung)
- [Längen-Helper](#längen-helper)
- [AST-Übersicht](#ast-übersicht)
Expand Down Expand Up @@ -61,6 +62,12 @@ Abhängigkeiten ausser Standard-PHP-Erweiterungen.
konfigurierbare Textmuster (`{{x}}`, `${x}`, `%x%`).
- **Multi-Header/Footer** — default / Titelseite / gerade Seiten,
inklusive automatischem `<w:titlePg/>` und `<w:evenAndOddHeaders/>`.
- **Abschnitte** — mehrere pro Dokument, jeder mit eigener Seiteneinrichtung
(Papierformat oder beliebige Größe), eigenem Beginn (neue Seite, fortlaufend,
gerade oder ungerade Seite) und eigenen Kopf-/Fußzeilen; werden Abschnitt für
Abschnitt zurückgelesen.
- **Schwebende Bilder** — auf Seite, Rand oder Absatz positioniert, vor oder
hinter dem Text, mit Umbruch; aus einer Kopfzeile auf jeder Seite.
- **Round-Trip-sicher** — DOCX lesen → AST → DOCX schreiben liefert ein
valides Dokument; byteweise Unterschiede beschränken sich auf
Whitespace und Reihenfolge.
Expand Down Expand Up @@ -514,6 +521,87 @@ DocumentBuilder::new()

Wird als um 45° gedrehte VML-Textform auf jeder Seite gerendert.

### Schwebende Bilder

Ein `Image` mit `position` (eine `ImagePosition`) schwebt (`<wp:anchor>`),
statt in der Textzeile zu stehen. Auf jeder Achse liegt es in einem Abstand
in EMU von Seite, Rand, Spalte oder Zeichen (waagerecht) bzw. Absatz oder
Zeile (senkrecht), oder es wird dort mit einer `PositionAlign` ausgerichtet.
Es liegt vor dem Text, mit `behindText` dahinter, und der Text fließt
darum, wie `wrap` es angibt (`None`, `Square`, `TopAndBottom`); `zOrder`
stapelt schwebende Bilder. `imageFromBytes()`, `imageFromDataUrl()` und
`imageFromFile()` des Builders nehmen eine `position`.

```php
use Dskripchenko\PhpDocx\Element\ImageFormat;
use Dskripchenko\PhpDocx\Style\HorizontalAnchor;
use Dskripchenko\PhpDocx\Style\ImagePosition;
use Dskripchenko\PhpDocx\Style\ImageWrap;
use Dskripchenko\PhpDocx\Style\PositionAlign;
use Dskripchenko\PhpDocx\Style\VerticalAnchor;

DocumentBuilder::new()
// A logo in the top right corner of every page, behind the text.
->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition(
horizontalFrom: HorizontalAnchor::Page,
horizontalAlign: PositionAlign::Right,
verticalFrom: VerticalAnchor::Page,
yEmu: 360000, // 1 cm from the top
behindText: true,
))))
// A photo the text wraps around.
->paragraph(fn ($p) => $p
->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square))
->text('Text flows around the photo.'))
->toFile('floating.docx');
```

Ein auf der Seite platziertes Bild aus einer Kopf- oder Fußzeile erscheint
auf jeder Seite, für die sie gilt: Briefköpfe, Seitenhintergründe, Stempel.
Ein `offsetYEmu` ohne `position` lässt das Bild so weit unter dem Anfang
seines Absatzes schweben. `DocxReader` liest die Position zurück.

---

## Abschnitte

Ein Dokument besteht aus einem Abschnitt, solange sich Seiteneinrichtung oder
Kopfzeilen nicht ändern. `newSection()` beendet den aktuellen Abschnitt und
beginnt den nächsten: Inhalte, Kopf- und Fußzeilen danach gehören zum neuen.

```php
use Dskripchenko\PhpDocx\Build\DocumentBuilder;
use Dskripchenko\PhpDocx\Style\Orientation;
use Dskripchenko\PhpDocx\Style\PageSetup;
use Dskripchenko\PhpDocx\Style\SectionStart;

DocumentBuilder::new()
->header(fn ($h) => $h->paragraph('Report'))
->paragraph('Portrait pages')
->newSection(new PageSetup(orientation: Orientation::Landscape))
->header(fn ($h) => $h->paragraph('Appendix'))
->paragraph('A wide table on landscape pages')
->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage)
->paragraph('A custom page size (A5 here), starting on an odd page')
->toFile('sections.docx');
```

- Ein neuer Abschnitt behält die Seiteneinrichtung, wenn keine angegeben ist,
und beginnt auf einer neuen Seite, wenn kein `SectionStart` angegeben ist
(`Continuous`, `EvenPage`, `OddPage`).
- Ein Abschnitt zeigt die Kopf- und Fußzeilen des vorherigen, bis er eigene
setzt (Regel von Word): eine leere Liste heißt „übernehmen“, nicht „leer“.
- `PageSetup(widthTwips:, heightTwips:)` setzt jede Seitengröße; die Seite ist
quer, wenn sie breiter als hoch ist.
- Ohne Builder: `new Document($first, followingSections: [$second, …])`;
`Document::sections()` liefert alle.
- Jeder Abschnitt außer dem letzten endet mit seinem `<w:sectPr>` in den
Eigenschaften seines letzten Absatzes (endet er mit einer Tabelle, wird ein
Absatz angefügt). `DocxReader` liest jeden Abschnitt mit eigenem Inhalt,
eigener Seiteneinrichtung, eigenem Beginn und eigenen Kopfzeilen zurück; der
HTML-Serializer setzt die Inhalte hintereinander, mit einem Seitenumbruch vor
jedem Abschnitt, der auf einer neuen Seite beginnt.

---

## Variablenerkennung
Expand Down Expand Up @@ -569,13 +657,13 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`.

| Element | Typ | Hinweise |
|---|---|---|
| `Document` | root | `{ section: Section, watermarkText: ?string }` |
| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` |
| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` |
| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` |
| `Run` | InlineElement | `{ text: string, style: RunStyle }` |
| `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` |
| `Bookmark` | InlineElement | `{ name: string, children }` |
| `Image` | beides | `{ binary, format, widthEmu, heightEmu, altText }` |
| `Image` | beides | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` |
| `Field` | InlineElement | `{ instruction: string, style: RunStyle }` |
| `LineBreak`, `PageBreak`, `HorizontalRule` | beides | Marker-Elemente |
| `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` |
Expand Down
Loading
Loading