Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e56dc22
A link's run properties are written in the order the schema names them
fabeat Oct 30, 2026
89b6612
The comments in the new test say why, and not what
fabeat Oct 30, 2026
f314c28
The comment says what Word does with a document that gets the order w…
fabeat Oct 30, 2026
5016679
The changelog says what Word does with a document that gets the order…
fabeat Oct 30, 2026
8a19367
The documents this library writes are checked against the schema
fabeat Oct 30, 2026
8a63229
The examples can be built without their previews
fabeat Oct 30, 2026
62f933a
The README and the changelog say what the gate is
fabeat Oct 30, 2026
49d6e61
The gate runs in one job, and not in the jobs that run the rest of th…
fabeat Oct 30, 2026
204eda9
The PHPWord that writes the documents is patched, and the templates a…
fabeat Oct 30, 2026
dc8e781
The changelog says what the patch carries and what the templates were…
fabeat Oct 30, 2026
dc130b7
The 0.3.1 release
fabeat Oct 30, 2026
4575132
The consumer is told what the patch needs, and the changelog says wha…
fabeat Oct 30, 2026
db4e223
The patch is the one that carries the fixes of the second pull request
fabeat Oct 30, 2026
edaa06e
Merge remote-tracking branch 'origin/feat/phpword-ooxml-patch' into r…
fabeat Oct 30, 2026
809eb21
The lock file answers to the manifest
fabeat Oct 30, 2026
f9dcb2e
Merge remote-tracking branch 'origin/feat/phpword-ooxml-patch' into r…
fabeat Oct 30, 2026
f77e55b
A template is bounded before a part of it is read, and the message sa…
fabeat Oct 30, 2026
e9ae166
The version is held to the line the patch was made against
fabeat Oct 30, 2026
9a0c7ab
Merge remote-tracking branch 'origin/feat/phpword-ooxml-patch' into r…
fabeat Oct 30, 2026
1b69d1d
Merge remote-tracking branch 'origin/fix/template-bounds' into feat/o…
fabeat Oct 31, 2026
002d93e
The release is dated the day the tag is cut
fabeat Oct 31, 2026
2e69e1c
The install state is not part of the package, and the comments say less
fabeat Oct 31, 2026
1f3af75
The gate cannot pass without having run
fabeat Oct 31, 2026
65d351a
A document is compared by its parts and not by its bytes
fabeat Oct 31, 2026
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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@
# --strict` fails when the two disagree, and a build is meant to be the install
# the lock describes.
/composer.lock export-ignore

# The patch lock is this repository's install state — the patch, its checksum and
# the depth it applied at. A consumer's install never reads it, and it records
# one machine's install rather than the package's.
/patches.lock.json export-ignore
51 changes: 49 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,12 @@ jobs:
}
'

# The schema gate is not run here. The runner image ships a .NET SDK, so
# the test would not skip itself and would fail this job over a document
# that is nobody's business but the gate's — and the analysis job below
# needs this one to have passed. The gate has a job of its own.
- name: Test
run: vendor/bin/pest
run: vendor/bin/pest --exclude-testsuite=ooxml

# A coverage report on its own says little; this fails if the reported
# figure is a placeholder, or if the code stops being covered at all.
Expand All @@ -250,6 +254,7 @@ jobs:
if: matrix.php == '8.5'
run: |
vendor/bin/pest --coverage --min=91 \
--exclude-testsuite=ooxml \
--coverage-clover=build/coverage/clover.xml \
--coverage-html=build/coverage/html \
--log-junit build/coverage/junit.xml
Expand All @@ -262,6 +267,43 @@ jobs:
path: build/coverage
retention-days: 7

# The documents this library writes, checked against the schema.
#
# A job of its own because the gate needs the .NET SDK at a version this
# repository chooses. The runner image ships one, so the test would not skip
# itself there either; the jobs above leave the suite out rather than run the
# gate against whatever the image happens to carry, and this one installs the
# version the validator is built for.
ooxml:
name: ooxml
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.5'
coverage: none
tools: composer:v2

- name: Install
run: composer install --prefer-dist --no-interaction --no-progress

# The version the validator is built for. A machine with a newer runtime
# runs it unchanged, so this is the floor and not a ceiling.
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0'

# This one suite and not the whole of it: the other jobs leave it out, and
# this is where the gate runs.
#
# `--fail-on-skipped` because a skipped test is a green tick for a check that
# never ran. The job installs the SDK, so the test cannot skip here — and if
# that ever stops being true, the job says so instead of passing.
- name: The documents are valid Office Open XML
run: vendor/bin/pest --testsuite=ooxml --fail-on-skipped

# Static analysis on SonarCloud.
#
# Its own job rather than a step in the test matrix, so that a finding and a
Expand Down Expand Up @@ -316,9 +358,14 @@ jobs:
# Both reports the analysis reads, produced in one run rather than the
# second one being repeated. The HTML report is skipped; Sonar wants the
# machine-readable one.
#
# The schema gate is left out for the reason the test job gives: it has a
# job of its own, and the analysis is about the code rather than about the
# documents it writes.
- name: Coverage and test results for the analysis
run: |
vendor/bin/pest --coverage --min=91 \
--exclude-testsuite=ooxml \
--coverage-clover=build/coverage/clover.xml \
--log-junit build/coverage/junit.xml

Expand Down Expand Up @@ -407,7 +454,7 @@ jobs:
run: git --no-pager diff --stat composer.lock

- name: The suite still passes
run: vendor/bin/pest
run: vendor/bin/pest --exclude-testsuite=ooxml

# A runtime dependency that stops supporting the version the library claims
# is the failure worth catching, and the minimum job cannot see it while it
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
# The documents and previews examples/build.php writes.
/examples/out/

# What the OOXML validator's build produces. The validator is a .NET program and
# its compiler writes beside the sources.
/tools/ooxml-validator/bin/
/tools/ooxml-validator/obj/

# Scratch space for the tests and the checking scripts.
/tmp/

Expand Down Expand Up @@ -44,3 +49,6 @@

# Parallel agent worktrees
.worktrees/

# The patch lock is this repository's install state, not the package's.
/patches.lock.json
118 changes: 118 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,122 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.1] - 2026-10-31

### Added

- **The documents this library writes are checked against the schema.** A `.docx` is
Office Open XML, and a document that breaks the schema is a document that is not
what it claims to be — Word opens it because Word is lenient where the schema is
not, and nothing else in the suite could see it. The check is Microsoft's own
validator, the one the Open XML SDK Productivity Tool uses, and it lives in
`tools/ooxml-validator`.

It is run over the examples — the whole corpus of documents this library writes,
built by `examples/build.php` — rather than over documents the test writes
itself, because a gate over a selection is a gate over the selection.

It is a gate and not a report: the test fails on a document the validator
rejects, and a test that asks the validator about a document with an error in it
proves that it can fail. The test skips itself where the .NET SDK is not, and
says what is missing; a job of its own installs the SDK, so the gate cannot be
skipped there, and the jobs that run the rest of the suite leave it out.

**The gate lands green.** Over the twenty-nine documents the build writes,
Microsoft's validator reported a thousand and two errors. With the fixes upstream
in PHPWord's writer it reported a hundred and fifty-one: eighty-eight belonged to
PHPWord, forty-four to the three committed templates, and nineteen to this
library's own. **With all five upstream pull requests, the templates written by
the fixed library, and this library's own fix, it reports zero.**

The gate comes first, so that the fixes are proved to have landed rather than
believed to have.

- **`examples/build.php --no-previews` builds the documents and leaves the pictures
alone.** Drawing the previews takes longer than everything else the build does,
and the gate that reads the documents wants the documents.

- **The project that installs this one has to allow
[`cweagans/composer-patches`][composer-patches].** The fixes are in five pull
requests upstream —
[#2964](https://github.com/PHPOffice/PHPWord/pull/2964),
[#2965](https://github.com/PHPOffice/PHPWord/pull/2965),
[#2966](https://github.com/PHPOffice/PHPWord/pull/2966),
[#2968](https://github.com/PHPOffice/PHPWord/pull/2968) and
[#2969](https://github.com/PHPOffice/PHPWord/pull/2969), all against
[#2963](https://github.com/PHPOffice/PHPWord/issues/2963) — and until they are
released they reach this library as a patch applied to the installed package.
The plugin applies it, and Composer runs it only when the project allows it.
Without that line the documents are invalid Office Open XML again, silently.

- **A template is bounded before a part of it is read.** A template is somebody else's
file, and a zip says how its contents are laid out and nothing about how much room
they will take up: forty kilobytes of a part is forty megabytes of paragraph, and the
entry count is whatever the writer felt like. The write path read a template without
any of the limits the read path has, so a template of sixty-five kilobytes could
hold a part that inflates to sixty-four megabytes.

The number of parts and the size of any one of them are bounded now, with the read
path's numbers — 4096 parts, 256 MiB each — and a template that is over either is
refused with a `MarkdownWord\Exception\UnreadableTemplate` that says how far over
it is. A caller who wants them tighter says so, with
`MarkdownWord\Template\TemplateOptions`.

### Changed

- **The examples build the way the analyser wants them to.** The build script named
the directory the Markdown sources live in twenty times, and loaded the autoloader
with `require` rather than `require_once`. Neither changed what the build writes.

### Fixed

- **A link's run properties are written in the order the schema names them.** The
properties of a hyperlink's run were written by hand — PHPWord's `Link` element
holds one plain string and cannot carry a label with emphasis in it — and they
were written in the order they came to mind: the weight, the slant, the strike,
the underline, the colour, the size and the typeface.

`w:rPr` is a sequence in ECMA-376's `wml.xsd` and not a set, so a reader walks
its children in the order they are written and stops at the first one it did not
expect. The typeface comes before the weight, and the underline comes after the
size. Word opens a document that gets this wrong, because Word is lenient where
the schema is not; a reader that walks the sequence does not.

Microsoft's own validator reported `The element has unexpected child element
'w:color'` nineteen times across the example documents, every one of them a
link whose label was styled. The order is the schema's now: `w:rFonts`, `w:b`,
`w:i`, `w:strike`, `w:color`, `w:sz`, `w:u`.

- **The PHPWord that writes the documents is patched.** PHPWord 1.4.0 writes the
children of `w:rPr`, of `w:pPr`, of `w:tblPr`, of `w:sectPr` and of `w:style`
in an order of its own, writes a page size in twips as a fraction, and writes a
few elements the format does not allow where it puts them. A document it writes
is therefore not valid OOXML, and every document this library writes is a
document it writes.

The fixes are in five pull requests upstream, and until they are released the
library carries them as a patch — the same way it has carried PHPPresentation's
fixes until now. The patch touches eighteen files of the Word2007 writer and
nothing else: it is a patch against 1.4.0 and not against the branch, so it
carries the fixes and not the rest of what upstream has done since.

- **The three templates are written by the fixed library.** `invoice.dotx`,
`meridian-qbr.dotx` and `plain-letterhead.dotx` were written by PHPWord before
the fixes, so they carried four of its defects inside them — a page size in
twips with a fraction, a style whose properties were in the wrong order, the
settings in the wrong order and an inline picture in a header. Every document
rendered from a template inherited them, which is why forty-four of the errors
the gate found were in the templates and not in the code that writes them.

They are written by the fixed library now, and they score 0 errors each. **The
header logo of `meridian-qbr.dotx` and `plain-letterhead.dotx` is the picture the
examples carry today** — the old templates carried an earlier one, left behind
when the asset itself changed.

- **The message that says a document has too many parts reports zero parts.** The
archive was closed before the number was read, and closing an archive resets the
count to zero, so the message said `0` whatever the document held.

## [0.3.0] - 2026-10-30

### Added
Expand Down Expand Up @@ -1176,6 +1292,8 @@ on it:
`README.md` has the full list, and `Reverse\Options` is where each one is
configurable.

[0.3.1]: https://github.com/markdown-office/markdown-word/releases/tag/v0.3.1
[composer-patches]: https://github.com/cweagans/composer-patches
[0.2.2]: https://github.com/markdown-office/markdown-word/releases/tag/v0.2.2
[0.3.0]: https://github.com/markdown-office/markdown-word/releases/tag/v0.3.0
[0.2.1]: https://github.com/markdown-office/markdown-word/releases/tag/v0.2.1
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ install of this package puts no command in `vendor/bin`. The command line is a
separate program — [`mdoffice`][mdoffice] — whose commands and options are in
the [documentation][command-line].

**The project that installs this one has to allow [`cweagans/composer-patches`][composer-patches].**
The documents this library writes are Word and Open XML, and the released PHPWord writes
a few of them in an order the schema does not allow — the properties of a run, of a
paragraph, of a table, of a section and of a style, and a few elements that belong
somewhere else. The fixes are in pull requests upstream, and until they are released
they reach this library as a patch applied to the installed package. The plugin applies
that patch, and Composer runs it only when the project allows it:

```json
{
"config": {
"allow-plugins": {
"cweagans/composer-patches": true
}
}
}
```

Without that line the install stops with Composer's own message naming
`cweagans/composer-patches`. The documents are valid Office Open XML only with it.

See [Requirements](#requirements) for what PHP and which extensions it needs.
Where to get help: [the issue tracker][issues].

Expand Down Expand Up @@ -138,6 +159,7 @@ composer test:unit # the unit tests alone
composer test:spec # just the conformance suite
composer test:roundtrip # just the round-trip suite
composer test:readme # just the examples on this page
composer test:ooxml # just the schema gate over the documents
composer test:coverage # with a report in build/coverage
php smoke.php # does the library work on this version of PHP?
php stress.php # malformed input across every configuration
Expand All @@ -161,6 +183,7 @@ Tests are written with [Pest][pest] 5, against PHPUnit 13.
| Workflow | What it does |
| --- | --- |
| `tests` | `composer validate --strict`, `composer audit`, the README examples, then the suite on PHP 8.4 and 8.5 with a coverage report |
| `ooxml` (a job in `tests`) | the documents this library writes, checked against the OOXML schema by Microsoft's own validator, which needs the .NET SDK |
| `sonarcloud` (a job in `tests`) | static analysis on [SonarCloud][sonarcloud], failing the build when the quality gate is red |

PHP 8.2 is in the matrix too, but the test suite cannot run there: **Pest 5
Expand Down Expand Up @@ -989,6 +1012,35 @@ Normalising the type on the way in answers both at once, so the template on disk
never opened for writing — the copy the renderer works from is ours, and it is removed
when the renderer goes.

### What a template may hold

A template is somebody else's file, and a zip says how its contents are laid out and
nothing about how much room they will take up: forty kilobytes of a part is forty
megabytes of paragraph, and the entry count is whatever the writer felt like. So the
template is bounded before a part of it is read — the number of parts it may have, and
the size of any one of them once it is inflated.

The bounds are the read path's, so a document is as welcome as a template:

| | the bound |
| --- | --- |
| the number of parts in the archive | 4096 |
| the size of one part, inflated | 256 MiB |

A template that is over either is refused with an
`MarkdownWord\Exception\UnreadableTemplate`, and the message says how far over it
is. A caller who wants them tighter says so:

```php
use MarkdownWord\Template\MarkdownTemplate;
use MarkdownWord\Template\TemplateOptions;

$template = new MarkdownTemplate(
'report-template.dotx',
limits: (new TemplateOptions())->withMaxPartBytes(8 * 1024 * 1024),
);
```

### The template contract

A template says two things, and both are Word constructs:
Expand Down Expand Up @@ -1959,6 +2011,7 @@ LGPL-3.0.
[commonmark]: https://github.com/thephpleague/commonmark
[command-line]: https://markdown-office.github.io/command-line/basic/
[issues]: https://github.com/markdown-office/markdown-word/issues
[composer-patches]: https://github.com/cweagans/composer-patches
[mdoffice]: https://github.com/markdown-office/mdoffice
[sonarcloud]: https://sonarcloud.io/summary/new_code?id=markdown-office_markdown-word
[skill]: https://markdown-office.github.io/skills/
Expand Down
16 changes: 15 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@
],
"require": {
"php": "^8.2",
"cweagans/composer-patches": "^2.0",
"ext-dom": "*",
"ext-gd": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"league/commonmark": "^2.10",
"markdown-office/commonmark-generic-directives": "^0.3.0",
"phpoffice/phpword": "^1.4",
"phpoffice/phpword": "~1.4.0",
"symfony/yaml": "^7.4 || ^8.1"
},
"require-dev": {
Expand All @@ -37,6 +38,17 @@
"suggest": {
"ext-imagick": "Embeds SVG as a vector. Word holds an SVG only beside a raster of it, so something has to draw the raster; without this an SVG raises instead of going in."
},
"extra": {
"patches": {
"phpoffice/phpword": [
{
"description": "PHPWord 1.4.0 writes the children of `w:rPr`, of `w:pPr`, of `w:tblPr`, of `w:sectPr` and of `w:style` in an order of its own, and writes a few elements the format does not allow there. The patch puts them where ECMA-376 names them. The version is held to the 1.4 line rather than the 1 series: the patch is against 1.4.0, and a later release of the line would carry a different writer.",
"url": "https://raw.githubusercontent.com/markdown-office/PHPWord/62f68049d287e5a52575f2ae694c2e76f4a2002a/patches/phpoffice-phpword-1.4.0-ooxml.patch",
"sha256": "193163898e7690b6eaf51086d415809616b7e25ec4090b58ce7966f66ae1203a"
}
]
}
},
"autoload": {
"psr-4": {
"MarkdownWord\\": "src/"
Expand All @@ -50,6 +62,7 @@
"config": {
"sort-packages": true,
"allow-plugins": {
"cweagans/composer-patches": true,
"pestphp/pest-plugin": true
}
},
Expand All @@ -59,6 +72,7 @@
"test:spec": "pest --testsuite=conformance",
"test:roundtrip": "pest --testsuite=roundtrip",
"test:readme": "pest --testsuite=readme",
"test:ooxml": "pest --testsuite=ooxml",
"test:coverage": "pest --coverage --min=91 --coverage-clover=build/coverage/clover.xml --coverage-html=build/coverage/html",
"check": [
"@composer validate --strict",
Expand Down
Loading
Loading