diff --git a/.gitattributes b/.gitattributes
index 9bbc00b..8e8743e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a32ae18..dfb484f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -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.
@@ -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
@@ -262,6 +267,39 @@ 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.
+ - name: The documents are valid Office Open XML
+ run: vendor/bin/pest --testsuite=ooxml
+
# Static analysis on SonarCloud.
#
# Its own job rather than a step in the test matrix, so that a finding and a
@@ -316,9 +354,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
@@ -407,7 +450,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
diff --git a/.gitignore b/.gitignore
index 602868f..f7f1d05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
@@ -44,3 +49,6 @@
# Parallel agent worktrees
.worktrees/
+
+# The patch lock is this repository's install state, not the package's.
+/patches.lock.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74332a8..ef5a2b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,99 @@ 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-30
+
+### 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.
+
+### 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.
+
## [0.3.0] - 2026-10-30
### Added
@@ -1176,6 +1269,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
diff --git a/README.md b/README.md
index 3fdd8f1..b9b54a1 100644
--- a/README.md
+++ b/README.md
@@ -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].
@@ -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
@@ -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
@@ -1959,6 +1982,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/
diff --git a/composer.json b/composer.json
index 937e2b0..b1e99bc 100644
--- a/composer.json
+++ b/composer.json
@@ -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": {
@@ -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/"
@@ -50,6 +62,7 @@
"config": {
"sort-packages": true,
"allow-plugins": {
+ "cweagans/composer-patches": true,
"pestphp/pest-plugin": true
}
},
@@ -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",
diff --git a/composer.lock b/composer.lock
index b9ee406..4c64a7c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,131 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fab2692dc0051d7de26e79611416a476",
+ "content-hash": "821ee431bf54fcf6880c642662896637",
"packages": [
+ {
+ "name": "cweagans/composer-configurable-plugin",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cweagans/composer-configurable-plugin.git",
+ "reference": "15433906511a108a1806710e988629fd24b89974"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cweagans/composer-configurable-plugin/zipball/15433906511a108a1806710e988629fd24b89974",
+ "reference": "15433906511a108a1806710e988629fd24b89974",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "require-dev": {
+ "codeception/codeception": "~4.0",
+ "codeception/module-asserts": "^2.0",
+ "composer/composer": "~2.0",
+ "php-coveralls/php-coveralls": "~2.0",
+ "php-parallel-lint/php-parallel-lint": "^1.0.0",
+ "phpro/grumphp": "^1.8.0",
+ "sebastian/phpcpd": "^6.0",
+ "squizlabs/php_codesniffer": "^3.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "cweagans\\Composer\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Cameron Eagans",
+ "email": "me@cweagans.net"
+ }
+ ],
+ "description": "Provides a lightweight configuration system for Composer plugins.",
+ "support": {
+ "issues": "https://github.com/cweagans/composer-configurable-plugin/issues",
+ "source": "https://github.com/cweagans/composer-configurable-plugin/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/cweagans",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-12T04:58:58+00:00"
+ },
+ {
+ "name": "cweagans/composer-patches",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cweagans/composer-patches.git",
+ "reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/bfa6018a5f864653d9ed899b902ea72f858a2cf7",
+ "reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0",
+ "cweagans/composer-configurable-plugin": "^2.0",
+ "ext-json": "*",
+ "php": ">=8.0.0"
+ },
+ "require-dev": {
+ "codeception/codeception": "~4.0",
+ "codeception/module-asserts": "^2.0",
+ "codeception/module-cli": "^2.0",
+ "codeception/module-filesystem": "^2.0",
+ "composer/composer": "~2.0",
+ "php-coveralls/php-coveralls": "~2.0",
+ "php-parallel-lint/php-parallel-lint": "^1.0.0",
+ "phpro/grumphp": "^1.8.0",
+ "sebastian/phpcpd": "^6.0",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "_": "The following two lines ensure that composer-patches is loaded as early as possible.",
+ "class": "cweagans\\Composer\\Plugin\\Patches",
+ "plugin-modifies-downloads": true,
+ "plugin-modifies-install-path": true
+ },
+ "autoload": {
+ "psr-4": {
+ "cweagans\\Composer\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Cameron Eagans",
+ "email": "me@cweagans.net"
+ }
+ ],
+ "description": "Provides a way to patch Composer packages.",
+ "support": {
+ "issues": "https://github.com/cweagans/composer-patches/issues",
+ "source": "https://github.com/cweagans/composer-patches/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/cweagans",
+ "type": "github"
+ }
+ ],
+ "time": "2025-10-30T23:44:22+00:00"
+ },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
diff --git a/examples/README.md b/examples/README.md
index 51222b9..b204b44 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -20,6 +20,10 @@ Previews need LibreOffice; without it the documents still build and the script s
so rather than failing. Where a stem has more than one format behind it, the format
goes into the preview's name as well.
+`--no-previews` builds the documents and leaves the pictures alone. Drawing them
+takes longer than everything else the build does, and the gate that reads the
+documents wants the documents.
+
`out/` is emptied at the start of every build. It holds nothing that is not made
here, and a build that only writes what it still names leaves behind every name it
has stopped naming — which is how a template written in the `${…}` dialect that has
diff --git a/examples/build.php b/examples/build.php
index 809442c..ae56865 100644
--- a/examples/build.php
+++ b/examples/build.php
@@ -151,6 +151,15 @@ final class BadPackage extends RuntimeException
exit(0);
}
+// The previews are a picture of each document's first page, and drawing them takes
+// longer than everything else this script does. A build that wants only the
+// documents says so.
+//
+// The previews are still emptied with the rest of `out/`: a preview of a document
+// that is no longer built is a picture of nothing, which is the same thing the
+// directory is emptied for.
+$withPreviews = !in_array('--no-previews', $argv ?? [], true);
+
$missing = array_keys(array_filter($templatePaths, static fn (string $t): bool => !is_file($t)));
if ($missing !== []) {
@@ -640,7 +649,9 @@ function markdownOf(string $root, string $name): string
echo "\n";
}
-renderPreviews($built, $previews);
+if ($withPreviews) {
+ renderPreviews($built, $previews);
+}
/**
* The report template's own styles, pointed at by name.
diff --git a/examples/templates/invoice.dotx b/examples/templates/invoice.dotx
index 1cf42e7..b97b4a5 100644
Binary files a/examples/templates/invoice.dotx and b/examples/templates/invoice.dotx differ
diff --git a/examples/templates/meridian-qbr.dotx b/examples/templates/meridian-qbr.dotx
index 0f72de0..fa73c81 100644
Binary files a/examples/templates/meridian-qbr.dotx and b/examples/templates/meridian-qbr.dotx differ
diff --git a/examples/templates/plain-letterhead.dotx b/examples/templates/plain-letterhead.dotx
index 2eae20d..52b349f 100644
Binary files a/examples/templates/plain-letterhead.dotx and b/examples/templates/plain-letterhead.dotx differ
diff --git a/patches.lock.json b/patches.lock.json
new file mode 100644
index 0000000..06c2e84
--- /dev/null
+++ b/patches.lock.json
@@ -0,0 +1,17 @@
+{
+ "_hash": "1eefc9bb6c43887cafd5dd308c486f8f4792f8738624b148ddc66dfac57b677d",
+ "patches": {
+ "phpoffice/phpword": [
+ {
+ "package": "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",
+ "depth": 1,
+ "extra": {
+ "provenance": "root"
+ }
+ }
+ ]
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index c167bab..4e3c821 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -32,6 +32,15 @@
tests/Readme
+
+
+ tests/Ooxml
+
diff --git a/src/Writer/HyperlinkPass.php b/src/Writer/HyperlinkPass.php
index 9da2cf8..eb8cf05 100644
--- a/src/Writer/HyperlinkPass.php
+++ b/src/Writer/HyperlinkPass.php
@@ -182,24 +182,36 @@ private function buildRun(\DOMDocument $dom, string $text, array $style): \DOMEl
}
/**
+ * The properties of a run, in the order the schema names them.
+ *
+ * `w:rPr` is the sequence `EG_RPrBase` in ECMA-376's `wml.xsd` and not a set,
+ * so a reader walks the 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 — a validator, a
+ * converter — does not.
+ *
* @param array $style
*/
private function buildRunProperties(\DOMDocument $dom, array $style): \DOMElement
{
$properties = $dom->createElementNS(self::W_NS, 'w:rPr');
+ if (!empty($style['name'])) {
+ $fonts = $dom->createElementNS(self::W_NS, 'w:rFonts');
+ $fonts->setAttributeNS(self::W_NS, 'w:ascii', (string) $style['name']);
+ $fonts->setAttributeNS(self::W_NS, 'w:hAnsi', (string) $style['name']);
+ $properties->appendChild($fonts);
+ }
+
foreach (['bold' => 'b', 'italic' => 'i', 'strikethrough' => 'strike'] as $key => $element) {
if (!empty($style[$key])) {
$properties->appendChild($dom->createElementNS(self::W_NS, 'w:' . $element));
}
}
- if (isset($style['underline']) && $style['underline'] !== 'none') {
- $underline = $dom->createElementNS(self::W_NS, 'w:u');
- $underline->setAttributeNS(self::W_NS, 'w:val', (string) $style['underline']);
- $properties->appendChild($underline);
- }
-
if (!empty($style['color'])) {
$color = $dom->createElementNS(self::W_NS, 'w:color');
$color->setAttributeNS(self::W_NS, 'w:val', ltrim((string) $style['color'], '#'));
@@ -212,11 +224,10 @@ private function buildRunProperties(\DOMDocument $dom, array $style): \DOMElemen
$properties->appendChild($size);
}
- if (!empty($style['name'])) {
- $fonts = $dom->createElementNS(self::W_NS, 'w:rFonts');
- $fonts->setAttributeNS(self::W_NS, 'w:ascii', (string) $style['name']);
- $fonts->setAttributeNS(self::W_NS, 'w:hAnsi', (string) $style['name']);
- $properties->appendChild($fonts);
+ if (isset($style['underline']) && $style['underline'] !== 'none') {
+ $underline = $dom->createElementNS(self::W_NS, 'w:u');
+ $underline->setAttributeNS(self::W_NS, 'w:val', (string) $style['underline']);
+ $properties->appendChild($underline);
}
return $properties;
diff --git a/tests/Ooxml/gate.php b/tests/Ooxml/gate.php
new file mode 100644
index 0000000..a7318bf
--- /dev/null
+++ b/tests/Ooxml/gate.php
@@ -0,0 +1,233 @@
+beforeEach(function (): void {
+ $missing = whyTheValidatorCannotRun();
+
+ if ($missing !== '') {
+ test()->markTestSkipped($missing);
+ }
+});
+
+it('writes documents the schema accepts', function () {
+ $corpus = theCorpus();
+ $answer = runTheValidator($corpus);
+
+ expect($answer['code'])->toBe(0, sprintf(
+ "The validator rejected %d of the %d documents the build wrote:%s%s",
+ countDocumentsWithErrors($answer['out']),
+ count($corpus),
+ PHP_EOL,
+ $answer['out'],
+ ));
+});
+
+it('finds an error in a document that has one', function () {
+ // The gate is worth nothing if it cannot fail. A validator that answered "no
+ // errors" about every document would pass the test above, so this one asks it
+ // about a document with an error in it and expects to be told.
+ //
+ // The error is an element no schema names, and the document is otherwise one
+ // this library wrote: it still opens, so what refuses it is the schema and not
+ // a broken archive.
+ $document = Scratch::path('broken', '.docx');
+
+ file_put_contents($document, toDocx("# Title\n\nA paragraph.\n"));
+
+ TemplateFactory::addRawPart(
+ $document,
+ 'word/document.xml',
+ str_replace('', '', TemplateFactory::xmlOf($document)),
+ );
+
+ $answer = runTheValidator([$document]);
+
+ expect($answer['code'])->toBe(1, 'The validator did not refuse the document:' . PHP_EOL . $answer['out'])
+ ->and($answer['out'])->toContain('notAnElement');
+});
+
+/**
+ * The documents the examples build.
+ *
+ * The build is run here rather than the corpus being read off the disk, because a
+ * corpus left over from an earlier commit is a corpus of documents the code under
+ * test did not write, and a gate over that measures the past.
+ *
+ * The previews are skipped: drawing them takes longer than everything else the
+ * build does, and they are pictures of the documents rather than the documents.
+ *
+ * @return list
+ */
+function theCorpus(): array
+{
+ static $corpus = null;
+
+ if ($corpus !== null) {
+ return $corpus;
+ }
+
+ $root = dirname(__DIR__, 2);
+
+ $build = runACommand(sprintf(
+ '%s %s --no-previews 2>&1',
+ escapeshellarg(PHP_BINARY),
+ escapeshellarg($root . '/examples/build.php'),
+ ));
+
+ if ($build['code'] !== 0) {
+ throw new RuntimeException('The examples did not build:' . PHP_EOL . $build['out']);
+ }
+
+ $documents = glob($root . '/examples/out/documents/*.docx') ?: [];
+
+ sort($documents);
+
+ if ($documents === []) {
+ throw new RuntimeException('The build wrote no documents:' . PHP_EOL . $build['out']);
+ }
+
+ return $corpus = $documents;
+}
+
+/**
+ * What the validator says about a set of files.
+ *
+ * The exit code is the answer: 0 when every file is clean, 1 when one of them is
+ * not, and 2 when there was nothing to check. The output says which file and why,
+ * which is what a fix has to be pointed at.
+ *
+ * @param list $files
+ *
+ * @return array{code: int, out: string}
+ */
+function runTheValidator(array $files): array
+{
+ return runACommand(sprintf(
+ '%s run --project %s --no-build -- %s 2>&1',
+ escapeshellarg((string) commandOnPath('dotnet')),
+ escapeshellarg(theBuiltValidator()),
+ implode(' ', array_map('escapeshellarg', $files)),
+ ));
+}
+
+/**
+ * The validator, built and ready.
+ *
+ * The build restores the package it depends on from the network, so it is done
+ * once rather than before every document: a gate that took half a minute to start
+ * would be a gate nobody runs.
+ *
+ * @return string The path to the project `dotnet run` is given.
+ */
+function theBuiltValidator(): string
+{
+ static $project = null;
+
+ if ($project !== null) {
+ return $project;
+ }
+
+ $project = dirname(__DIR__, 2) . '/tools/ooxml-validator';
+
+ $build = runACommand(sprintf(
+ '%s build %s --nologo --verbosity quiet 2>&1',
+ escapeshellarg((string) commandOnPath('dotnet')),
+ escapeshellarg($project),
+ ));
+
+ if ($build['code'] !== 0) {
+ throw new RuntimeException('The OOXML validator did not build:' . PHP_EOL . $build['out']);
+ }
+
+ return $project;
+}
+
+/**
+ * Why the validator cannot run on this machine, or an empty string when it can.
+ *
+ * The SDK is not on every machine, and a test that fails where it cannot run says
+ * nothing about the code — so the test skips itself and says what is missing
+ * instead.
+ */
+function whyTheValidatorCannotRun(): string
+{
+ static $answer = null;
+
+ if ($answer !== null) {
+ return $answer;
+ }
+
+ $dotnet = commandOnPath('dotnet');
+
+ if ($dotnet === null) {
+ return $answer = 'The .NET SDK is not on this machine, so the OOXML validator cannot be run.';
+ }
+
+ $lines = [];
+ $status = 0;
+
+ exec(escapeshellarg($dotnet) . ' --list-sdks 2>&1', $lines, $status);
+
+ if ($status !== 0 || $lines === []) {
+ return $answer = 'This machine has the .NET runtime but no .NET SDK, so the validator cannot be built.';
+ }
+
+ return $answer = '';
+}
+
+/**
+ * How many documents the validator found errors in.
+ *
+ * Counted from the report rather than from the exit code, which says only that
+ * something is wrong: the number is what tells a reader how much of the corpus
+ * is affected. The line that names the count in total ends in `error(s)` as
+ * well, and it is not a document.
+ */
+function countDocumentsWithErrors(string $report): int
+{
+ $found = 0;
+
+ foreach (explode(PHP_EOL, $report) as $line) {
+ if (preg_match('#^.*\.docx: [1-9][0-9]* error\(s\)$#', $line) === 1) {
+ $found++;
+ }
+ }
+
+ return $found;
+}
+
+/**
+ * What a command did, and what it said.
+ *
+ * @return array{code: int, out: string}
+ */
+function runACommand(string $command): array
+{
+ $lines = [];
+ $code = 0;
+
+ exec($command, $lines, $code);
+
+ return ['code' => $code, 'out' => implode(PHP_EOL, $lines)];
+}
diff --git a/tests/Pest.php b/tests/Pest.php
index 82949d7..108b045 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -313,6 +313,40 @@ function toDocx(string $markdown, ?Configuration $config = null): string
);
}
+/**
+ * The command on this machine's PATH, or nothing.
+ *
+ * The PATH is walked rather than the shell asked, because `command -v` is a POSIX
+ * command and a Windows command prompt does not have it. The path is returned
+ * rather than a yes or a no, because a caller that has it wants to run it.
+ */
+function commandOnPath(string $command): ?string
+{
+ $path = getenv('PATH');
+
+ if ($path === false) {
+ return null;
+ }
+
+ foreach (explode(PATH_SEPARATOR, $path) as $directory) {
+ if ($directory === '') {
+ continue;
+ }
+
+ $candidate = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $command;
+
+ if (PHP_OS_FAMILY === 'Windows') {
+ $candidate .= '.exe';
+ }
+
+ if (is_file($candidate) && is_executable($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+}
+
// `tmp/pest` is emptied after every test, failure or not, so a run leaves
// nothing behind. The hook goes through `pest()`: in Pest 5 a bare `afterEach()`
// written here binds to this file, which is not a test file, so it would never
diff --git a/tests/Readme/examples-tables.php b/tests/Readme/examples-tables.php
index b6937e8..7881c73 100644
--- a/tests/Readme/examples-tables.php
+++ b/tests/Readme/examples-tables.php
@@ -87,7 +87,12 @@ function firstTableHeaderCell(string $xml): string
// every page the table spills onto. It is the whole of what a row can be told:
// the slot is handed a `RowStyle`, which has no appearance of its own — an
// earlier version of this example asked it for a shading and it was dropped.
- expect(tableExample('16-styled-tables'))->toMatch('##');
+ //
+ // The value is `on` and not `1`. Both are legal in the file — ECMA-376's
+ // `ST_OnOff` allows `true`, `false`, `on`, `off`, `0` and `1` — but the Open
+ // XML SDK reads this element with an enumeration of `on` and `off` alone, and
+ // refuses the others.
+ expect(tableExample('16-styled-tables'))->toMatch('##');
});
it('takes the cell typography from the cell slot', function () {
diff --git a/tests/Unit/dead-code.php b/tests/Unit/dead-code.php
index 39185ed..c03cf50 100644
--- a/tests/Unit/dead-code.php
+++ b/tests/Unit/dead-code.php
@@ -117,6 +117,7 @@ function spelledOut(string $source): string
'code block shading goes through a paragraph style, not a Font' => 'used for code block shading',
'the round trip loses what Word does not record' => 'the round trip is exact',
'a link wrapping a bare image is marked by a flag nothing read' => 'imageLabel',
+ 'a document with its run properties out of order is one Word refuses to open' => 'run properties are out of that order is one Word refuses to open',
'the version is written down in only one place' => 'The one place the version is written down',
'shipping the lock file makes an install reproducible' => 'a reproducible install is worth',
'one list of style keys covers every slot' => 'The keys a style slot\'s array form understands',
diff --git a/tests/Unit/dist-archive.php b/tests/Unit/dist-archive.php
index 441b9fc..55d46d5 100644
--- a/tests/Unit/dist-archive.php
+++ b/tests/Unit/dist-archive.php
@@ -102,6 +102,13 @@ function distArchiveFiles(): array
expect(distArchiveFiles())->not->toContain('composer.lock');
});
+it('does not ship the patch lock, which records one machine\'s install', function () {
+ // The patch lock says which patch was applied, at which checksum and at what
+ // depth. It is this repository's install state, not the package's, and a
+ // consumer's install never reads it.
+ expect(distArchiveFiles())->not->toContain('patches.lock.json');
+});
+
it('keeps the lock file in the repository, which this repository\'s installs pin to', function () {
// Both rules are the reason. No consumer ever reads the lock, which is what the
// export-ignore is for, but this repository's own installs are meant to be the
diff --git a/tests/Unit/hyperlink-run-properties.php b/tests/Unit/hyperlink-run-properties.php
new file mode 100644
index 0000000..7e21ecc
--- /dev/null
+++ b/tests/Unit/hyperlink-run-properties.php
@@ -0,0 +1,113 @@
+
+ */
+function hyperlinkRunPropertyNames(string $markdown, ?Configuration $config = null): array
+{
+ $path = Scratch::path('hyperlink-run-properties');
+
+ saveDocument($markdown, $path, $config);
+
+ $dom = new DOMDocument();
+ libxml_use_internal_errors(true);
+ $dom->loadXML(TemplateFactory::xmlOf($path), LIBXML_NOCDATA);
+ libxml_clear_errors();
+
+ $xpath = new DOMXPath($dom);
+ $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
+
+ $hyperlink = $xpath->query('//w:hyperlink')?->item(0);
+
+ if (!$hyperlink instanceof DOMElement) {
+ throw new RuntimeException('The document has no hyperlink.');
+ }
+
+ $names = [];
+
+ foreach ($xpath->query('./w:r[1]/w:rPr/*', $hyperlink) ?: [] as $child) {
+ if ($child instanceof DOMElement) {
+ $names[] = $child->localName;
+ }
+ }
+
+ return $names;
+}
+
+/**
+ * A link font carrying every property the writer puts in a run, so the whole of
+ * what the document says about the run is the order of its properties.
+ */
+function hyperlinkRunPropertiesConfig(): Configuration
+{
+ return (new Configuration())
+ ->withOptions(['deferredHyperlinks' => true])
+ ->withStyles([
+ Styles::LINK_FONT => [
+ 'name' => 'Consolas',
+ 'size' => 11,
+ 'color' => 'B22222',
+ 'bold' => true,
+ 'italic' => true,
+ 'strikethrough' => true,
+ 'underline' => 'single',
+ ],
+ ]);
+}
+
+it('writes the run properties of a link in the order the schema names them', function () {
+ $properties = hyperlinkRunPropertyNames(
+ '[label](https://example.com)',
+ hyperlinkRunPropertiesConfig(),
+ );
+
+ expect($properties)->toBe(['rFonts', 'b', 'i', 'strike', 'color', 'sz', 'u']);
+});
+
+it('writes the run properties of a link with emphasis in the order the schema names them', function () {
+ // The path a label with emphasis takes on its own: the label cannot be one
+ // plain string, so the runs are recorded and written afterwards.
+ $properties = hyperlinkRunPropertyNames('[*label*](https://example.com)');
+
+ expect($properties)->toBe(['i', 'color', 'u']);
+});
+
+it('writes the typeface of a link before the properties that follow it', function () {
+ // `w:rFonts` is the second element the schema names, and a run here carries
+ // nothing before it, so the typeface is written first.
+ $properties = hyperlinkRunPropertyNames('[label](https://example.com)', hyperlinkRunPropertiesConfig());
+
+ expect($properties[0])->toBe('rFonts');
+});
+
+it('writes the underline of a link after the size of its run', function () {
+ // `w:u` is named after the size and before the effect, and it is the last of
+ // the properties a link carries, so it is written last.
+ $properties = hyperlinkRunPropertyNames('[label](https://example.com)', hyperlinkRunPropertiesConfig());
+
+ expect($properties[array_key_last($properties)])->toBe('u');
+});
diff --git a/tests/Unit/render-tools.php b/tests/Unit/render-tools.php
index d20b1e8..a9d66d9 100644
--- a/tests/Unit/render-tools.php
+++ b/tests/Unit/render-tools.php
@@ -110,17 +110,6 @@ function noTools(): string
return toolsHolding();
}
-/**
- * Whether a command is on the path.
- */
-function commandOnPath(string $command): bool
-{
- $status = 0;
- exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null', $ignored, $status);
-
- return $status === 0;
-}
-
/**
* A directory that stands for an installed application.
*
@@ -404,7 +393,7 @@ function packageAsDocument(string $source, string $target): array
test()->markTestSkipped('Microsoft Word is only here on macOS, so the render cannot be checked.');
}
- if (! commandOnPath('osascript')) {
+ if (commandOnPath('osascript') === null) {
test()->markTestSkipped('AppleScript is not here, so Word cannot be driven.');
}
@@ -437,7 +426,7 @@ function packageAsDocument(string $source, string $target): array
test()->markTestSkipped('Microsoft Word is only here on macOS, so the render cannot be checked.');
}
- if (! commandOnPath('osascript')) {
+ if (commandOnPath('osascript') === null) {
test()->markTestSkipped('AppleScript is not here, so Word cannot be driven.');
}
diff --git a/tools/ooxml-validator/Program.cs b/tools/ooxml-validator/Program.cs
new file mode 100644
index 0000000..bbfdb15
--- /dev/null
+++ b/tools/ooxml-validator/Program.cs
@@ -0,0 +1,114 @@
+// Validates Office Open XML files against the schema, with Microsoft's own
+// validator — the one the Open XML SDK Productivity Tool uses.
+//
+// The exit code is the answer: 0 when every file is clean, 1 when one of them is
+// not, 2 when the tool was asked to do nothing it could do. A gate built on
+// anything else would be a gate on this suite's own opinion of the format rather
+// than on the format.
+
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Validation;
+
+if (args.Length == 0)
+{
+ Console.Error.WriteLine("usage: ooxml-validator [--version ] ...");
+ return 2;
+}
+
+// Office2019 rather than the oldest version the SDK knows: the SDK validates
+// against the schema of the version it is given, and a document written for a
+// modern Word should be read as a modern Word would read it. Every file this
+// suite writes has been checked against every version the SDK knows, and the
+// errors are the same at all of them.
+var version = FileFormatVersions.Office2019;
+var files = new List();
+
+// The files are gathered while the options are read, because the value of
+// `--version` is an option's value and not the name of a file.
+for (var i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--version" && i + 1 < args.Length)
+ {
+ var value = args[++i];
+ if (!Enum.TryParse(value, ignoreCase: true, out version))
+ {
+ Console.Error.WriteLine($"not a version the SDK knows: {value}");
+ return 2;
+ }
+
+ continue;
+ }
+
+ if (args[i].StartsWith("--"))
+ {
+ Console.Error.WriteLine($"unknown option: {args[i]}");
+ return 2;
+ }
+
+ files.Add(args[i]);
+}
+
+if (files.Count == 0)
+{
+ Console.Error.WriteLine("no files to validate");
+ return 2;
+}
+
+var errors = 0;
+
+foreach (var file in files)
+{
+ if (!File.Exists(file))
+ {
+ Console.Error.WriteLine($"{file}: no such file");
+ errors++;
+ continue;
+ }
+
+ var extension = Path.GetExtension(file).ToLowerInvariant();
+
+ try
+ {
+ errors += extension switch
+ {
+ ".docx" or ".docm" or ".dotx" or ".dotm" => Validate(file, WordprocessingDocument.Open(file, false), version),
+ ".pptx" or ".pptm" or ".potx" or ".potm" => Validate(file, PresentationDocument.Open(file, false), version),
+ _ => Unknown(file),
+ };
+ }
+ catch (Exception exception)
+ {
+ // A file the SDK cannot open is a file the gate cannot pass, so it counts
+ // as an error rather than as a reason to give up.
+ Console.Error.WriteLine($"{file}: could not be opened: {exception.Message}");
+ errors++;
+ }
+}
+
+Console.WriteLine(errors == 0
+ ? $"{files.Count} file(s) checked against {version}: no errors"
+ : $"{files.Count} file(s) checked against {version}: {errors} error(s)");
+
+return errors == 0 ? 0 : 1;
+
+static int Validate(string file, OpenXmlPackage package, FileFormatVersions version)
+{
+ var found = new OpenXmlValidator(version).Validate(package).ToList();
+
+ Console.WriteLine($"{file}: {found.Count} error(s)");
+
+ foreach (var error in found)
+ {
+ Console.WriteLine($" [{error.ErrorType}] {error.Description}");
+ Console.WriteLine($" part={error.Part?.Uri} path={error.Path?.XPath}");
+ }
+
+ return found.Count;
+}
+
+static int Unknown(string file)
+{
+ Console.Error.WriteLine($"{file}: not a Word or PowerPoint file");
+ return 1;
+}
diff --git a/tools/ooxml-validator/README.md b/tools/ooxml-validator/README.md
new file mode 100644
index 0000000..e78c508
--- /dev/null
+++ b/tools/ooxml-validator/README.md
@@ -0,0 +1,38 @@
+# The OOXML validator
+
+A console program that opens a `.docx` or a `.pptx` and reports what Microsoft's
+Open XML SDK makes of it. It is the oracle the gate in this repository is measured
+against, so it is not a dependency of the library — nothing the library ships
+depends on it, and the library's own suite does not need it to run.
+
+## Running it
+
+```bash
+dotnet run --project tools/ooxml-validator -- path/to/document.docx
+dotnet run --project tools/ooxml-validator -- --version Office2007 path/to/document.docx
+```
+
+The exit code is the answer: `0` when every file is clean, `1` when one of them is
+not, `2` when there was nothing to check. The errors are printed one per line with
+the part they are in and the path to them, which is what a fix needs to be pointed
+at.
+
+## Why this and not the schema
+
+The schema in ECMA-376 is the definition of the format, and a document that breaks
+it is a document that is not what it claims to be. The SDK reads the same schema,
+and it is the reader the Open XML SDK Productivity Tool — the tool Microsoft's own
+documentation points at — uses. A gate built on it is a gate on the format rather
+than on this suite's opinion of it.
+
+The SDK is also stricter than the schema in a few places, and those places are
+named in the pull requests rather than papered over: `w:tblHeader` and
+`w:bidiVisual` carry an on/off value the SDK reads as `on` or `off` alone, where
+the schema's `ST_OnOff` also allows `1` and `0`.
+
+## Where it runs
+
+The gate that uses it is a job in `.github/workflows/tests.yml`, which installs the
+.NET SDK with `actions/setup-dotnet` before the suite runs. A machine without the
+.NET SDK can still run the rest of the suite; the test that uses this tool skips
+itself there and says why.
diff --git a/tools/ooxml-validator/ooxml-validator.csproj b/tools/ooxml-validator/ooxml-validator.csproj
new file mode 100644
index 0000000..963b2cb
--- /dev/null
+++ b/tools/ooxml-validator/ooxml-validator.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+
+ net8.0
+
+ Major
+ enable
+ enable
+ OoxmlValidator
+ ooxml-validator
+
+
+
+
+
+
+
+