Skip to content

The documents this library writes are valid OOXML, a gate keeps them that way, and 0.3.1 carries it - #49

Merged
fabeat merged 24 commits into
mainfrom
feat/ooxml-0.3.1
Nov 1, 2026
Merged

fabeat merged 24 commits into
mainfrom
feat/ooxml-0.3.1

Conversation

@fabeat

@fabeat fabeat commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator

What this is

One pull request for the whole campaign: the documents this library writes are made
valid Office Open XML, a gate keeps them that way, and 0.3.1 carries it.

It supersedes #41,
#42,
#43,
#44 and
#48, and their commits are
in it.

The issue it carries is PHPOffice/PHPWord#2963,
"The documents this library writes are not valid OOXML", and the five pull requests that
fix it upstream are
#2964,
#2965,
#2966,
#2968 and
#2969 — all drafts, all waiting for
a merge by the maintainer.

The documents this library writes were not valid Office Open XML, and nothing in the
suite could see it: the files open, because Word is lenient where the schema is not. A
reader that walks the schema — a validator, a converter, an accessibility tool —
stops at the first child it did not expect.

Microsoft's Open XML SDK validator — the one the Open XML SDK Productivity Tool uses —
reported 1002 errors over the twenty-nine documents examples/build.php writes.
This pull request brings that to 0, and adds the gate that keeps it there.

The measure

errors kinds
main today 1002 24
with the patch, the templates and this library's own fix 0 0

The twenty-nine documents and the three templates were measured again on this branch:
29 documents, 0 errors. 3 templates, 0 errors.

1. The gate

tools/ooxml-validator/ is a small console program over the Open XML SDK: it opens a
document, validates it, and exits non-zero on any error. The SDK version is pinned,
because the validator is the oracle the gate is measured against and a gate whose
oracle moves is not a gate.

The test runs it over the whole corpus — the twenty-nine documents the build writes —
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 second test asks the validator about a document with an
error in it and insists it is found. Without that, a validator that said "no errors"
about everything would pass the gate.

On a machine without the .NET SDK the test skips itself and says what is missing. A
job of its own installs the SDK, so the gate cannot be skipped in CI — and the jobs
that run the rest of the suite leave it out, so it is one red cross rather than five.

examples/build.php --no-previews is new: drawing a preview of each document's first
page takes longer than everything else the build does, and the gate wants the
documents.

2. The patch

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 where the format does not allow them. Every document this
library writes is a document PHPWord writes.

The fixes are in five pull requests upstream. Until they are released the library
carries them as a patch — the same way it has carried PHPPresentation's fixes until
now.

The project that installs this one has to allow cweagans/composer-patches. The
plugin applies the patch, and Composer runs it only when the project allows it:

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

The README says so, and markdown-presentation says it the same way.

The patch is against the release, not against the branch. A patch of
1.4.0 → master would carry everything upstream has done since 1.4.0 to those files,
and one of those changes is a feature rather than a fix: upstream writes a style of
its own for a title depth used without addTitleStyle(), and its style id collides
with the one this library registers for its own headings. Two styles with one id is a
document Word repairs, and it breaks the reader here. So each file is merged instead —
the released file with our change applied on top of upstream's master as the common
ancestor — and the result is a patch that carries the fixes and one piece of upstream's
own: the outline level of a heading style, which upstream moved to the end of a
paragraph's properties at the same time as the fixes, and which cannot be carried
without it.

It applies to the released 1.4.0 with no conflict and no fuzz.

The version is held to the 1.4 line (~1.4.0, not ^1.4): the patch is against
1.4.0, and a later release of the line would carry a different writer.

3. The templates

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 — 44 of the 1002 errors 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.

4. The link's run properties

src/Writer/HyperlinkPass.php writes the w:rPr of a hyperlink's run by hand,
because PHPWord's Link element holds one plain string and cannot carry a label with
emphasis in it. The children were appended in the order the properties came to mind —
w:b, w:i, w:strike, w:u, w:color, w:sz, w:rFonts — and w:rPr is a
sequence in ECMA-376 and not a set, so the typeface was written last where the schema
names it second, and the underline was written before the colour where the schema names
it after the size. 19 errors, one for every styled link.

The order is the schema's now: w:rFonts, w:b, w:i, w:strike, w:color, w:sz,
w:u.

The two comments that said a document with the order wrong is one Word refuses to open
were corrected: Word opens it, because Word is lenient where the schema is not.

5. The template path is bounded like the read path

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 read path bounds both — Reverse\Options carries maxPartBytes (256 MiB),
maxEntries (4096) and maxStyleDepth (32) — and the write path read a template
without any of them. Template\TemplatePackage::of() opened the caller's archive
with ZipArchive::open() and nothing else, and read [Content_Types].xml whole.

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

They are checked against the archive's central directory, which costs one lookup per
entry and no decompression, and every entry is measured — the template is opened
again by PHPWord's own processor, and a part read there is read whole. The archive is
opened with CHECKCONS, which refuses one whose directory disagrees with its
contents.

A template that is over a bound is refused with a
MarkdownWord\Exception\UnreadableTemplate, which is an InvalidInput: the file is
the caller's to fix. A template that cannot be opened at all keeps the
FileNotWritable it has always had, so nothing that catches it stops working. A
caller who wants the bounds tighter says so:

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

The measure, on a .dotx of sixty-five kilobytes whose [Content_Types].xml
inflates to sixty-four MiB:

before after
the write path read it — the process peaked at 136 MB refused, with the part's size in the message

6. The message that says a document has too many parts reports zero parts

Reverse\Package closed the archive before it read the count, and closing an archive
resets numFiles to zero — so the message said 0 whatever the document held. The
count is read before the archive is closed now.

7. The examples build the way the analyser wants them to

Two findings the analyser raises on examples/build.php: the directory the Markdown
sources live in was named twenty times, and the autoloader was loaded with require
rather than require_once. Neither changed what the build writes.

The audit that came with it

Every place in the repository that builds a w:rPr or a w:pPr by hand was looked at
— BuiltInHeadingPass, WordFields, TocPass, NumberingMerger, ContentControl
and the example build — and every one of them is right.

The checks

composer check is green: the manifest, the audit, smoke.php, stress.php and
3960 tests, the gate among them. PHPStan at level 1 over src, tools, tests
and examples/build.php reports no errors — no ignoreErrors, no baseline, no
suppression.

The release

The changelog gains a heading for the version, which is what the release job reads the
release notes out of, and a link reference for it.

The tag is the maintainer's to cut:

git tag -a v0.3.1 -m v0.3.1
git push origin refs/tags/v0.3.1

This release carries the patch that puts the documents in the order the schema names
them, the templates written by the fixed library, the gate that keeps them there, the
link's run properties in the right order, and the bounds on what a template may hold.

fabeat added 20 commits October 30, 2026 16:57
The properties of a hyperlink's run were written by hand, because PHPWord's
Link element holds one plain string and cannot carry a label with emphasis
in it. 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 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, and a document that gets this wrong is one
Word refuses to open.

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. There are none of them now.
Two of them named a position in the schema's sequence, which is a number
that goes stale the moment the schema is read again, and one of them said
a run can hold every property, which is a claim about the schema rather
than about this library.
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 anyway, because Word
is lenient where the schema is not, and nothing else in the suite could see it.

The validator is Microsoft's own — the one the Open XML SDK Productivity Tool
uses — and it is run over the examples: the whole corpus of documents this
library writes, built by `examples/build.php`. A gate over a selection is a
gate over the selection.

The test skips itself where the .NET SDK is not, and says what is missing; the
job that installs the SDK runs it where it is, so the gate cannot be skipped
there. A second test asks the validator about a document with an error in it,
which is what proves that the gate can fail.
Drawing a preview of each document's first page takes longer than everything
else the build does, and the gate that reads the documents wants the documents.

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.
The gate is red when it lands: over the twenty-nine documents the build
writes, Microsoft's validator reports a thousand and two errors, and with the
four fixes upstream in PHPWord's writer it reports a hundred and fifty-one, of
which forty-four belong to the three committed templates and eighty-eight to
PHPWord itself.
…e suite

The runner image ships a .NET SDK, so the test does not skip itself there and
the gate would have failed the test matrix over a document that is nobody's
business but the gate's — and the analysis job needs the test job to have
passed. The gate has a job of its own, with the SDK version the validator is
built for, and the other jobs leave the suite out.
…elease/0.3.1

# Conflicts:
#	composer.json
#	patches.lock.json
@sonarqubecloud

Copy link
Copy Markdown

@fabeat
fabeat merged commit 65d3ecb into main Nov 1, 2026
9 checks passed
@fabeat
fabeat deleted the feat/ooxml-0.3.1 branch November 1, 2026 19:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant