dskripchenko / php-docx
The HTML layer for PHPWord pipelines and a standalone DOCX-HTML round-trip library: read arbitrary Word/Google Docs/LibreOffice/PHPWord documents to a typed AST and clean HTML, convert HTML to DOCX, detect MERGEFIELD/content-control/{{placeholder}} template variables. Pure PHP, zero dependencies.
Requires
- php: ^8.2
- ext-dom: *
- ext-mbstring: *
- ext-zip: *
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5
- tijsverkoyen/css-to-inline-styles: ^2.2
Suggests
- tijsverkoyen/css-to-inline-styles: Enables Converter::fromHtmlWithStyles() β inlines <style> blocks and CSS classes before HTML conversion (^2.2)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.18.0
- v1.17.0
- v1.16.0
- v1.15.0
- v1.14.1
- v1.14.0
- v1.13.3
- v1.13.2
- v1.13.1
- v1.13.0
- v1.12.0
- v1.11.0
- v1.10.0
- v1.9.0
- v1.8.2
- v1.8.1
- v1.8.0
- v1.7.0
- v1.6.0
- v1.5.0
- v1.4.1
- v1.4.0
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.2
- v1.0.1
- v1.0.0
- dev-release/1.18.0
- dev-merge/pr-3-cant-split-keep-lines
- dev-feat/import-fidelity
This package is auto-updated.
Last update: 2026-10-28 20:10:37 UTC
README
π English Β· Deutsch Β· Π ΡΡΡΠΊΠΈΠΉ Β· δΈζ
The HTML layer for your PHPWord pipeline β and a standalone
DOCX β HTML round-trip library. Pure-PHP: read arbitrary Word /
Google Docs / LibreOffice / PHPWord documents into a typed AST, turn
them into clean HTML, convert HTML into DOCX, detect template variables
(MERGEFIELD, content controls, {{placeholders}}). Works next to
PHPWord, not instead of it. No
external dependencies beyond standard PHP extensions.
Read this in other languages: English Β· Π ΡΡΡΠΊΠΈΠΉ Β· δΈζ Β· Deutsch
Table of contents
- Features
- php-docx and PHPWord
- Requirements
- Installation
- Quick start
- HTML β DOCX
- Programmatic builder API
- DOCX β HTML (Reader)
- Headers, footers & watermarks
- Sections
- Variable detection
- Length helpers
- AST overview
- Round-trip
- Architecture
- Development
- License
Features
- HTML β DOCX writer β full set of typical layout elements (paragraphs/headings/tables/lists/images/links/fields), inline-style resolution, custom heading registry.
- DOCX β HTML reader β parses arbitrary Word/Pages/LibreOffice documents into a typed AST, then serialises back to HTML with inline styles. Style cascade (docDefaults β named β direct), theme colors, numbering reconstruction, vMerge/gridSpan collapse, watermark detection (VML + DrawingML).
- Fluent programmatic builder β
DocumentBuilderwith closure scopes for nested structures (tables, lists, headers). - Variable detection β MERGEFIELD, SDT content controls, configurable
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+ β
readonlyvalue-objects, named arguments, constructor promotion, enums. - Zero composer dependencies.
Out of scope
Tracked changes, comments, embedded charts, OLE objects, footnotes/endnotes, SmartArt, math equations (OMML), form fields, custom XML parts.
Examples
Runnable scripts with committed output live in
examples/: HTML β DOCX with headers and
watermarks, the fluent builder, DOCX β HTML import, template-variable
detection and substitution, round-trip stability, CSS inlining, and the
PHPWord bridge.
php-docx and PHPWord
PHPWord is the established library for building Word documents in PHP. php-docx does not replace it β it adds the two things PHPWord is weakest at: reading arbitrary DOCX (with a full style cascade) and HTML in both directions. Use both, each for what it does best.
What php-docx adds next to PHPWord:
- reading real-world DOCX (Word, Google Docs, LibreOffice, PHPWord output) into a typed AST β verified continuously on an external corpus, see the reader-fidelity dashboard;
- DOCX β clean HTML with inline styles (style cascade, theme colours, numbering, merged cells);
- HTML β DOCX;
- template-variable detection (MERGEFIELD, SDT content controls,
{{x}}/${x}/%x%patterns).
What php-docx deliberately does not do (PHPWord or other tools do): ODF/RTF/PDF output, tracked changes, comments, footnotes/endnotes, charts, OMML math, form fields.
Zero-integration recipe β works today, no bridge required. PHPWord writes the file, php-docx reads the bytes:
use PhpOffice\PhpWord\PhpWord; use PhpOffice\PhpWord\IOFactory; use Dskripchenko\PhpDocx\Reader\DocxReader; use Dskripchenko\PhpDocx\Html\Serializer; $phpWord = new PhpWord; $section = $phpWord->addSection(); $section->addTitle('Quarterly report', 1); $section->addText('Built with PHPWord, exported to HTML by php-docx.'); $tmp = tempnam(sys_get_temp_dir(), 'docx'); IOFactory::createWriter($phpWord, 'Word2007')->save($tmp); $document = (new DocxReader)->read((string) file_get_contents($tmp)); $html = (new Serializer)->serialize($document)->bodyHtml;
A typed object-level bridge (PhpWordBridge::toHtml($phpWord), HTML
import into PHPWord objects, reading straight into PHPWord models) ships
separately as dskripchenko/php-docx-phpword.
Requirements
- PHP 8.2+
ext-zip,ext-dom,ext-mbstring
Installation
composer require dskripchenko/php-docx
Quick start
1. HTML β DOCX
use Dskripchenko\PhpDocx\Html\Converter; use Dskripchenko\PhpDocx\Writer\Word2007Writer; $html = <<<HTML <h1>Invoice #42</h1> <p>Total: <strong>500 USD</strong></p> <table> <tr><th>Item</th><th>Qty</th></tr> <tr><td>Widget</td><td>2</td></tr> </table> <p>Page <page-number/> of <page-total/></p> HTML; $doc = (new Converter)->fromHtml($html); file_put_contents('invoice.docx', (new Word2007Writer)->write($doc));
2. Programmatic builder
use Dskripchenko\PhpDocx\Build\DocumentBuilder; use Dskripchenko\PhpDocx\Element\ListFormat; DocumentBuilder::new() ->watermark('DRAFT') ->header(fn ($h) => $h->paragraph('Acme Inc.')) ->footer(fn ($f) => $f->paragraph(fn ($p) => $p ->text('Page ')->pageNumber()->text(' of ')->totalPages() )) ->heading(1, 'Invoice #42') ->paragraph(fn ($p) => $p ->text('Customer: ')->bold('Acme Co.') ->lineBreak() ->text('ID: ')->mergeField('CustomerID') ) ->table(fn ($t) => $t ->columns(fn ($c) => $c->widthCm(8), fn ($c) => $c->widthCm(3)) ->headerRow(['Item', 'Qty']) ->row(['Widget', '2']) ) ->orderedList(fn ($l) => $l ->format(ListFormat::LowerLetter) ->item('Net 30 terms') ->item('Free shipping') ) ->toFile('invoice.docx');
3. DOCX β HTML / AST
use Dskripchenko\PhpDocx\Reader\DocxReader; use Dskripchenko\PhpDocx\Reader\DocxPackageReader; use Dskripchenko\PhpDocx\Reader\VariableDetector; use Dskripchenko\PhpDocx\Html\Serializer; $bytes = file_get_contents('input.docx'); $document = (new DocxReader)->read($bytes); $pkg = (new DocxPackageReader)->read($bytes); $variables = (new VariableDetector)->detect($pkg); $imported = (new Serializer)->serialize($document, $variables); echo $imported->bodyHtml; echo $imported->headerHtml; echo $imported->footerHtml; echo $imported->watermarkText; $imported->pageSettings; $imported->variables; $imported->media;
HTML β DOCX
Input HTML must use inline styles only (no <style> blocks). Use a
CSS-inliner upstream if needed.
Supported elements
| Category | HTML tags |
|---|---|
| Text blocks | <p>, <h1..h6>, <div>, <pre>, <blockquote> |
| Inline marks | <strong>/<b>, <em>/<i>, <u>, <s>/<del>, <sup>, <sub>, <mark> |
| Code/teletype | <code>, <kbd>, <samp>, <var>, <cite>, <dfn>, <q>, <small> |
| Links | <a href> external, <a href="#anchor"> internal, <a id> bookmarks |
| Images | <img src="data:image/...;base64,..."> |
| Tables | <table>, <thead>/<tbody>, <tr>, <th>/<td>, <colgroup>/<col>, <caption>, colspan, rowspan |
| Lists | <ul>, <ol type="a/A/i/I" start="N">, <li value="N">, <dl>/<dt>/<dd>; Word numbering labels from .class::marker { content: β¦ counter(list-item) / counters(list-item, ".") β¦ } in a <style>; <li style="list-style-type:none"> holding only a nested list is a label-less parent (3. β 3.1.) |
| Custom tags | <page-number/>, <page-total/>, <current-date format="...">, <page-break> |
| Marker classes | class="page-number", class="page-total", class="page-break" β same fields, spelled so an HTML sanitizer keeps them |
| Layout | <hr>, <br>, <figure>/<figcaption> |
Inline styles
The converter understands style="β¦" properties:
- Run-level:
font-family,font-size,font-weight,font-style,text-decoration,color,background-color,letter-spacing - Paragraph-level:
text-align,margin,text-indent,line-height,border,padding,background-color - Table-level:
width,border,border-collapse - Cell-level:
width,padding,border,vertical-align,background-color
Custom tags
<p>Page <page-number/> of <page-total/></p> <p>Generated on <current-date format="dd.MM.yyyy"/></p>
These become OOXML field codes (<w:fldSimple w:instr="PAGE">).
Custom heading styles
use Dskripchenko\PhpDocx\Style\StyleRegistry; use Dskripchenko\PhpDocx\Style\RunStyle; use Dskripchenko\PhpDocx\Style\ParagraphStyle; use Dskripchenko\PhpDocx\Style\Alignment; $styles = (new StyleRegistry) ->heading(1, new RunStyle(sizeHalfPoints: 44, bold: true), new ParagraphStyle(alignment: Alignment::Center)) ->heading(2, new RunStyle(sizeHalfPoints: 28, bold: true)); $writer = new Word2007Writer($styles);
Stylesheets and classes
fromHtml() understands inline styles only. For HTML carrying
<style> blocks or class-based styling, use fromHtmlWithStyles() β
it first runs the document through an HtmlPreprocessor. The default
implementation inlines CSS via the optional
tijsverkoyen/css-to-inline-styles
package (composer require tijsverkoyen/css-to-inline-styles; php-docx
itself stays zero-dependency), and you can plug your own preprocessor:
use Dskripchenko\PhpDocx\Html\Converter; $doc = (new Converter)->fromHtmlWithStyles($htmlWithStyleBlocks); // or: new Converter(preprocessor: new MyPreprocessor)
Programmatic builder API
The Build namespace provides a fluent API for assembling DOCX
documents block by block, finalising to the same immutable AST that the
HTML pipeline produces.
DocumentBuilder
Entry point. Accumulates body, header/footer, watermark, page setup,
core properties (docProps/core.xml: title, author, dates).
use Dskripchenko\PhpDocx\Build\DocumentBuilder; use Dskripchenko\PhpDocx\Style\CoreProperties; use Dskripchenko\PhpDocx\Style\PageSetup; use Dskripchenko\PhpDocx\Style\PaperSize; use Dskripchenko\PhpDocx\Style\Orientation; $doc = DocumentBuilder::new() ->pageSetup(new PageSetup( paperSize: PaperSize::A4, orientation: Orientation::Portrait, )) ->watermark('CONFIDENTIAL') ->coreProperties(new CoreProperties(title: 'Report', creator: 'Acme Inc.')) ->heading(1, 'Report') ->paragraph('Body') ->build(); // β Document AST $bytes = DocumentBuilder::new()->paragraph('Hi')->toBytes(); $count = DocumentBuilder::new()->paragraph('Hi')->toFile('out.docx');
Output is byte-for-byte reproducible: every ZIP entry carries
CoreProperties::$modified (UTC), or 1980-01-01 00:00:00 when it is not set.
ParagraphBuilder
Inside ->paragraph(fn ($p) => β¦):
->paragraph(fn ($p) => $p ->text('plain ') ->bold('bold ') ->italic('italic ') ->underline('under ') ->strike('strike ') ->sup('super')->text('script ') ->sub('sub')->text('script ') ->styled('red', fn ($s) => $s->color('ff0000')->bold()) ->lineBreak() ->link('https://example.com', 'website') ->internalLink('section1', 'go to section 1') ->bookmark('anchor1', 'anchor target') ->pageNumber() ->totalPages() ->currentDate('yyyy-MM-dd') ->mergeField('CustomerName') ->image($img) ->imageFromFile('/path/to/logo.png', widthPx: 150, altText: 'Logo') )
Paragraph-level styling:
->paragraph(fn ($p) => $p ->alignCenter() // or alignRight()/alignJustify() ->styleId('Quote') // a style id from the StyleRegistry ->pageBreakBefore() // start on a new page; widowControl(false) turns widow control off ->indentMm(left: 20, firstLine: 10) ->spacingPt(before: 6, after: 12) ->text('Indented & spaced') )
styleId() takes effect only for a style the document defines: Heading1..6,
ListParagraph, Caption, or one registered with StyleRegistry::paragraph().
Word shows a paragraph with an unknown id as Normal.
TableBuilder
use Dskripchenko\PhpDocx\Build\{TableBuilder, TableRowBuilder, TableCellBuilder, ColumnBuilder}; ->table(fn (TableBuilder $t) => $t ->caption('Sales 2026') ->column(fn (ColumnBuilder $c) => $c->widthCm(6)) ->column(fn (ColumnBuilder $c) => $c->widthCm(3)) ->widthPercent(100) ->alignCenter() ->cellMarginsMm(2) ->headerRow(['Item', 'Price']) ->row(['Apple', '10 USD']) ->row(fn (TableRowBuilder $r) => $r ->cell('Banana') ->cell(fn (TableCellBuilder $c) => $c ->backgroundColor('ffeb3b') ->valignCenter() ->paragraph(fn ($p) => $p->bold('20 USD')) ) ) )
Spans and merges:
->row(fn ($r) => $r ->cell(fn ($c) => $c->gridSpan(2)->paragraph('Wide header')) ) ->row(fn ($r) => $r ->cell(fn ($c) => $c->rowSpan(2)->paragraph('Tall')) ->cell('right') )
ListBuilder
use Dskripchenko\PhpDocx\Build\ListBuilder; use Dskripchenko\PhpDocx\Element\ListFormat; ->bulletList(fn (ListBuilder $l) => $l ->item('First') ->item('Second', fn ($n) => $n ->item('Nested A') ->item('Nested B') ) ) ->orderedList(fn (ListBuilder $l) => $l ->format(ListFormat::LowerLetter) // a, b, c ->startAt(3) ->item('item starts at "c"') )
RunStyleBuilder
Used inside ->styled(text, fn (RunStyleBuilder) => β¦) or standalone via
RunStyleBuilder::new()->β¦->build().
RunStyleBuilder::new() ->bold() ->italic() ->underline() ->strike() ->color('ff0000') ->backgroundColor('eeeeee') ->highlight('yellow') ->fontFamily('Arial') ->fontFamilyEastAsia('SimSun') // w:eastAsia: the font for CJK text ->fontSizePt(14.5) ->build();
Length helpers
Convert common units to OOXML twips (1 twip = 1/20 pt). Used wherever a twip int is expected.
use Dskripchenko\PhpDocx\Build\Length; Length::pt(12); // 240 Length::mm(20); // 1134 Length::cm(2.5); // 1417 Length::inch(0.5); // 720 Length::px(100); // 1500 (CSS px @ 96 DPI)
Most builders expose unit-aware shortcuts:
- TableBuilder:
widthPt/widthMm/widthCm/widthInches,cellMarginsMm/cellMarginsPt,indent/indentMm/indentPt - TableCellBuilder:
widthPt/Mm/Cm/Inches,paddingMm/Pt/Cm/Inches - ColumnBuilder:
widthPt/Mm/Cm/Inches/Px - ParagraphBuilder:
indentMm/Cm/Pt/Inches,spacingPt/Mm - RunStyleBuilder:
fontSizePt
DOCX β HTML (Reader)
High-level: DocxReader
use Dskripchenko\PhpDocx\Reader\DocxReader; $document = (new DocxReader)->read(file_get_contents('input.docx')); // β Document (AST)
This runs the full pipeline: package unpack β styles resolve β body/header/footer parsing β vMerge/list reconstruction β image extraction β watermark detection β page setup.
Low-level: DocxPackageReader
If you need the raw OOXML parts:
use Dskripchenko\PhpDocx\Reader\DocxPackageReader; $pkg = (new DocxPackageReader)->read($bytes); $pkg->documentXml; // \DOMDocument $pkg->stylesXml; // ?\DOMDocument $pkg->numberingXml; // ?\DOMDocument $pkg->themeXml; // ?\DOMDocument $pkg->settingsXml; // ?\DOMDocument $pkg->headers; // array<path, \DOMDocument> $pkg->footers; // array<path, \DOMDocument> $pkg->media; // array<path, bytes> $pkg->documentRelationships(); // list<Relationship> $pkg->resolveDocumentRel('rId7'); // Relationship
Serializer: AST β HTML
use Dskripchenko\PhpDocx\Html\Serializer; $imported = (new Serializer)->serialize($document, $variables); // ImportedDocument: $imported->bodyHtml; // string $imported->headerHtml; // ?string $imported->footerHtml; // ?string $imported->watermarkText; // ?string $imported->pageSettings; // PageSetup $imported->variables; // list<DetectedVariable> $imported->media; // array<filename, bytes> $imported->css; // string β ::marker rules of Word's numbering labels
HTML output uses inline styles, plus css for the labels of Word's
multilevel numbering (3.1., 4.1.): ::marker rules on the classes of
the list items, with <ol start> where a list broken off by a paragraph
numbers on. Re-loadable into the same library via
Html\Converter::fromHtml('<style>'.$imported->css.'</style>'.$imported->bodyHtml);
an application that inlines CSS first keeps the labels with
Converter::markerStyleOf($html).
Headers, footers & watermarks
Three header/footer types are supported per section: default, first
(title page), even (even pages). Word automatically renders the right
one based on page number.
DocumentBuilder::new() ->header(fn ($h) => $h->paragraph('Default header')) ->firstHeader(fn ($h) => $h->paragraph('Cover page')) ->evenHeader(fn ($h) => $h->paragraph(fn ($p) => $p ->text('Page ')->pageNumber() )) ->footer(fn ($f) => $f->paragraph('Β© 2026 Acme')) ->firstFooter(fn ($f) => $f->paragraph('Confidential')) ->evenFooter(fn ($f) => $f->paragraph('Even footer')) ->paragraph('Body') ->toFile('with-headers.docx');
The writer automatically:
- emits
<w:titlePg/>insectPrwhen first-page header/footer is set - emits
word/settings.xmlwith<w:evenAndOddHeaders/>when even header/footer is set
Watermark
DocumentBuilder::new() ->watermark('DRAFT') ->paragraph('Body') ->toFile('with-watermark.docx');
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.
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.
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).DocxReaderreads 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
Scans an imported DOCX for three kinds of variables:
- MERGEFIELD β Word mail-merge native, both simple
<w:fldSimple>and complex<w:fldChar>form. - SDT content controls β
<w:sdt>with<w:tag w:val="...">. - Text patterns β configurable regexes (defaults:
{{name}},${name},%name%).
use Dskripchenko\PhpDocx\Reader\VariableDetector; $pkg = (new DocxPackageReader)->read($bytes); $detector = new VariableDetector; // defaults // Or with custom regexes: $detector = new VariableDetector(['/\[\[(\w+)\]\]/']); $variables = $detector->detect($pkg); foreach ($variables as $v) { echo "{$v->name} ({$v->source->value})"; echo " placeholder='{$v->placeholder}'"; echo " sample='{$v->sampleValue}'\n"; }
Detection runs across body + all headers + all footers. Results are
deduplicated by (source, name).
Length helpers
See Length helpers above. Conversion table:
| Unit | Twips | Pt | Notes |
|---|---|---|---|
| 1 twip | 1 | 0.05 | OOXML native |
| 1 pt | 20 | 1 | typography |
| 1 mm | ~57 | 2.83 | metric |
| 1 cm | ~567 | 28.35 | metric |
| 1 inch | 1440 | 72 | imperial |
| 1 px | 15 | 0.75 | CSS @ 96 DPI |
AST overview
All elements live under Dskripchenko\PhpDocx\Element namespace.
| Element | Type | Notes |
|---|---|---|
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, offsetYEmu, position: ?ImagePosition } |
Field |
InlineElement | { instruction: string, style: RunStyle } |
LineBreak, PageBreak, HorizontalRule |
both | marker elements |
Table |
BlockElement | { rows: TableRow[], style, caption, gridColumnsTwips } |
TableRow |
element | { cells: TableCell[], isHeader, heightTwips, cantSplit, heightRule } |
TableCell |
element | { children: BlockElement[], style: CellStyle } |
ListNode |
BlockElement | { items: ListItem[], ordered, format, startAt } |
ListItem |
element | { children: InlineElement[], nestedList: ?ListNode } |
Styles live under Dskripchenko\PhpDocx\Style:
RunStyleβ font (andfontFamilyEastAsiafor CJK text), weight, italic, color, size, highlight, β¦ParagraphStyleβ alignment, indents, spacing, borders, pagination (keepWithNext, keepLines, pageBreakBefore, widowControl)CellStyleβ width, padding, borders, valign, gridSpan, rowSpanTableStyleβ width, borders, alignment, cell margins, layout, indent (indentTwips)PageSetup,PaperSize,Orientation,Alignment,VerticalAlign,BorderStyle,Border,BorderSet
Round-trip
$bytes1 = file_get_contents('original.docx'); $doc = (new DocxReader)->read($bytes1); $bytes2 = (new Word2007Writer)->write($doc); file_put_contents('roundtrip.docx', $bytes2);
The library targets semantic round-trip safety, not byte equality β content, structure and styling survive, but XML ordering and whitespace may differ.
In-scope round-trip features:
- Paragraphs/headings with all run formatting
- Tables with
vMerge/gridSpanreconstruction - Lists (bullet/decimal/letter/roman) with arbitrary nesting
- Images with EMU sizes and alt text
- Hyperlinks (external + internal anchors) and bookmarks
- Headers/footers (default/first/even) and watermarks
- Field codes (PAGE, NUMPAGES, DATE, MERGEFIELD)
- Page setup (size, orientation, margins)
Out-of-scope features are silently dropped (footnotes, comments, equations, etc.).
Architecture
HTML (inline styles)
β
βΌ Html\Converter
Document (AST) βββββ DocumentBuilder (programmatic)
β
βΌ Writer\Word2007Writer
DOCX bytes
β²
β Reader\DocxReader
Document (AST)
β
βΌ Html\Serializer
ImportedDocument (bodyHtml, headerHtml, footerHtml, variables, media)
The same Document AST is shared by HTML conversion, programmatic
construction and DOCX reading β every entry/exit point operates on
typed value-objects.
Conformance
Every push validates the writer's output externally, not just against our own reader:
- ECMA-376 Transitional XSD β every WordprocessingML part (document, styles, numbering, headers, footers) is validated with xmllint against the official schemas (fetched from ecma-international.org, SHA-pinned);
- LibreOffice headless converts the reference document to PDF β a real-world consumer, not a mock;
- python-docx opens it and extracts the expected content β an independent reader implementation.
Reproduce locally:
bash scripts/fetch-ooxml-schemas.sh
php scripts/conformance/generate.php
bash scripts/conformance/xsd-check.sh
bash scripts/conformance/consumer-smoke.sh # needs LibreOffice + python-docx
Development
composer install composer test # phpunit suite (~340 tests) composer stan # phpstan level 8
License
MIT β see LICENSE.