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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,57 @@ jobs:
- name: Test
run: vendor/bin/pest

# The decks the library writes, checked against the schema Microsoft's own validator
# reads.
#
# Its own job rather than a step in the test matrix, because the validator is a .NET
# program: the SDK it is built and run with is a second toolchain, and the job that
# installs it is the job that runs the gate.
#
# `--fail-on-skipped` because the test skips itself where the .NET SDK is missing, and
# a skipped test is a green tick for a check that never ran. The rasteriser is
# installed for the same reason: the deck carrying an SVG cannot be written without
# one, and a gate that checked one deck fewer than the gallery holds would be a gate
# that says less than it says.
conformance:
name: valid 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
extensions: imagick

- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'

# The delegate ImageMagick hands an SVG to. Without one that can draw a letter the
# picture in the deck is refused rather than drawn, and the deck is not written at
# all — which would leave the gate checking one deck fewer than the gallery holds.
- name: A rasteriser that can draw an SVG
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends librsvg2-bin

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

- name: What the runner has
run: |
php -r 'var_dump(extension_loaded("imagick"));'
php -r 'print_r(Imagick::queryFormats("*SVG*"));'
convert -list format 2>/dev/null | grep -i svg || echo "no convert"
convert -list delegate 2>/dev/null | grep -i svg || echo "no delegate"
fc-list 2>/dev/null | head -3 || echo "no fonts"

- name: The decks are valid OOXML
run: vendor/bin/pest --testsuite=conformance --fail-on-skipped

coverage:
name: coverage
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
# The OOXML schemas `tools/install-ooxml-schemas.php` fetches and checksums.
/tools/ooxml-schemas/

# The validator's own build output, which the .NET SDK writes beside the project.
/tools/ooxml-validator/bin/
/tools/ooxml-validator/obj/

# The fonts `tools/generate-font-metrics.php` fetches and checksums.
/tools/fonts/

Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ 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).

## [Unreleased]

### Added

- **Every deck the library writes is checked against the schema Microsoft's own validator
reads.** Nothing in the suite could see that before: the decks were checked against the
parts they carry, and a deck whose XML is outside the schema passes every one of those
checks and opens in PowerPoint as `[Repaired]`.

The validator is Microsoft's own — the one the Open XML SDK Productivity Tool uses — and
it is a program in `tools/ooxml-validator/` rather than a dependency: nothing that ships
with the library needs it. A machine without the .NET SDK runs the rest of the suite and
says what is missing, and the job that runs the gate in continuous integration installs
it, so the gate cannot skip itself there.

### Changed

- **The patch carries one more fix from upstream.** A chart's series is written in the
order the schema names it: the shape properties right after the title rather than after
the data points and the data labels, and the separator of the data labels of a doughnut
before the leader lines rather than after them. PowerPoint repairs a chart written the
other way round before it opens it
([#695](https://github.com/PHPOffice/PHPPresentation/issues/695),
[#1044](https://github.com/PHPOffice/PHPPresentation/pull/1044)).

This library writes no chart, so nothing it draws changes: the patch is upstream's
branch whole, and the fix belongs to that branch rather than to this one.

## [0.3.0] - 2026-10-30

### Added
Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ it would import the file through a lossy converter and report nothing about whet
is valid. PowerPoint is the only application on this machine that will tell the truth about
whether these decks open.

### The schema is checked by Microsoft's own validator

A deck whose XML is outside the schema opens in PowerPoint as `[Repaired]`, and the
checks written by hand here did not catch that. `tests/Conformance/ooxml.php` runs
Microsoft's Open XML validator — the one the Open XML SDK Productivity Tool uses — over
every deck the library writes, and the program that does it is in `tools/ooxml-validator/`.

It is a .NET program, so a machine without the SDK skips that test and says what is
missing; the job that runs it in continuous integration installs it, so the gate cannot
skip itself there.

## What a change has to pass

```
Expand Down
7 changes: 4 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
"patches": {
"phpoffice/phppresentation": [
{
"description": "PHPPresentation 1.2.0 brought up to upstream's master, with the fixes that give a picture the path, the bytes and the name it was read from, and that find the folder of a part of a package with the slashes of the package",
"url": "https://raw.githubusercontent.com/markdown-office/PHPPresentation/9aa3f4624f1cab54fc5d275397c2fc59cf6f276d/patches/phpoffice-phppresentation-1.2.0-to-master.patch",
"sha256": "0b8f7c150f7ba5d12671fb9e211c9f1a4db549d7609239f49c0cea707a190551"
"description": "PHPPresentation 1.2.0 brought up to upstream's master, with the fixes that give a picture the path, the bytes and the name it was read from, that find the folder of a part of a package with the slashes of the package, and that write a chart series in the order the schema names it",
"url": "https://raw.githubusercontent.com/markdown-office/PHPPresentation/92c2c64ca20a6b3febf6512e892b21a3c0ae4b56/patches/phpoffice-phppresentation-1.2.0-to-master.patch",
"sha256": "67ce1a44e76e4dc23639fefee365ec811ef7a763331343bde468ae781bf5427a"
}
]
}
Expand Down Expand Up @@ -69,6 +69,7 @@
"scripts": {
"test": "pest",
"test:unit": "pest --testsuite=unit",
"test:conformance": "pest --testsuite=conformance",
"test:readme": "pest --testsuite=readme",
"test:coverage": "php -d memory_limit=-1 vendor/bin/pest --coverage --min=100",
"analyse": "tools/phpstan.sh",
Expand Down
26 changes: 1 addition & 25 deletions examples/build.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,11 @@
*/

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/decks.php';

use MarkdownPresentation\Document\DeckParser;
use MarkdownPresentation\Render\PresentationRenderer;

/**
* The sources this script does not convert, and why.
*
* Keyed by the file's stem. A deck is here because converting it is the failure it
* exists to demonstrate, which is the same reason `markdown-word` does not build its
* `14-rejected.md`: the build would stop, or — worse — would not stop and the gallery
* would contain a deck that quietly came out wrong.
*/
const NOT_BUILT = [
'10-rejected' => 'converting it is the failure it exists to demonstrate; see examples/README.md',
];

/** Where the decks, their PDFs and their previews are written. */
const OUT = __DIR__ . '/out';

Expand All @@ -37,19 +26,6 @@
/** The directory a relative image path in a deck resolves against. */
const SOURCES = __DIR__ . '/markdown';

/**
* The sources written more than once, and the one line each copy differs in.
*
* A theme is a frontmatter key, so the only way to see the three palettes side by side is
* to write the same deck three times. Varying it here rather than in three files keeps
* the three decks identical apart from the one line, which is the point of the
* comparison.
*/
const VARIANTS = [
'03-themes' => ['theme' => ['default', 'corporate-dark', 'minimal']],
'11-type-scale' => ['theme' => ['default', 'dense']],
];

/**
* What each built deck is for, for the report at the end and for nothing else.
*/
Expand Down
41 changes: 41 additions & 0 deletions examples/decks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/*
* The decks the examples are written as.
*
* It is a file of its own because two things need it: the script that writes the decks,
* and the test that checks what it wrote against the schema. A list of its own in
* either of them would be a second answer to a question this file answers, and the two
* would drift — a deck the gallery no longer builds and the gate still checks, or the
* other way round, which is the worse of the two: a gate that says less than it says.
*
* Required rather than included, so that a second caller in the same run does not
* declare the constants again.
*/

/**
* The sources this script does not convert, and why.
*
* Keyed by the file's stem. A deck is here because converting it is the failure it
* exists to demonstrate, which is the same reason `markdown-word` does not build its
* `14-rejected.md`: the build would stop, or — worse — would not stop and the gallery
* would contain a deck that quietly came out wrong.
*/
const NOT_BUILT = [
'10-rejected' => 'converting it is the failure it exists to demonstrate; see examples/README.md',
];

/**
* The sources written more than once, and the one line each copy differs in.
*
* A theme is a frontmatter key, so the only way to see the three palettes side by side is
* to write the same deck three times. Varying it here rather than in three files keeps
* the three decks identical apart from the one line, which is the point of the
* comparison.
*/
const VARIANTS = [
'03-themes' => ['theme' => ['default', 'corporate-dark', 'minimal']],
'11-type-scale' => ['theme' => ['default', 'dense']],
];
8 changes: 8 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
<testsuite name="unit">
<directory suffix=".php">tests/Unit</directory>
</testsuite>
<!--
The decks the library writes, checked against the schema Microsoft's own
validator reads. It needs the .NET SDK, which a machine without it does not
have, so the test skips itself there.
-->
<testsuite name="conformance">
<directory suffix=".php">tests/Conformance</directory>
</testsuite>
<!--
Every fenced example in README.md, run. The mechanism is described where
it is written down — in tests/Support/Doctest.php.
Expand Down
149 changes: 149 additions & 0 deletions tests/Conformance/ooxml.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

declare(strict_types=1);

use MarkdownPresentation\Document\DeckParser;
use MarkdownPresentation\Render\PresentationRenderer;
use MarkdownPresentation\Render\SvgRasteriser;
use MarkdownPresentation\Tests\Support\Doctest;
use MarkdownPresentation\Tests\Support\Scratch;

require_once dirname(__DIR__, 2) . '/examples/decks.php';

/*
* Every deck this library writes, checked against the schema Microsoft's own validator
* reads — the one the Open XML SDK Productivity Tool uses.
*
* Nothing in this suite could see that before: the decks were checked against the parts
* they carry, and a deck whose XML is outside the schema passes every one of those
* checks and opens in PowerPoint as `[Repaired]`. The validator is the oracle, and it is
* a program in `tools/ooxml-validator/` rather than a library this package depends on:
* nothing that ships with the library needs it, and the suite runs without it.
*
* A machine with no .NET SDK says what is missing and skips the test. A green tick for a
* check that never ran is a check nobody made.
*/

it('writes decks that are valid OOXML', function () {
if (!hasDotnetSdk()) {
test()->markTestSkipped(
'The .NET SDK is not installed, so the Open XML validator cannot be run. '
. 'The job that runs this test in continuous integration installs it.',
);
}

$decks = writeExampleDecks();

[$status, $output] = validateWithOpenXml($decks);

// Every deck named, because a gate that checked nothing is not a gate. The validator
// prints one line per file and how many errors it holds, so a deck missing from the
// output is a deck that was not read — which is a failure, not a pass.
expect($decks)->not->toBe([]);

foreach ($decks as $deck) {
expect($output)->toContain(basename($deck) . ': 0 error(s)');
}

// The exit code is the tool's own answer, and it is what a caller that reads nothing
// else gets: 0 when every deck is clean, 1 when one of them is not, and 2 when the
// tool could not run at all. What the tool said is the message, because a failure
// here is a failure of the deck and the deck is what has to be looked at.
expect($status)->toBe(0, $output);
});

/**
* Whether this machine has the .NET SDK the validator is built with.
*
* The version rather than the path, because a machine with the runtime and not the SDK
* can run a program but not build one, and a path lookup would find the runtime and
* answer yes.
*/
function hasDotnetSdk(): bool
{
exec('dotnet --version 2>&1', $output, $status);

return $status === 0;
}

/**
* Every deck the examples are written as, written into the scratch directory.
*
* The plan of the decks is the one `examples/decks.php` holds, because that is the file
* the script that writes them holds it in: a list of its own here would be a second
* answer to a question that already has one, and the two would drift. A deck the
* gallery no longer builds and the gate still checks is the worse of the two — a gate
* that says less than it says.
*
* @return list<string> The decks written, as paths.
*/
function writeExampleDecks(): array
{
$sources = Doctest::root() . '/examples/markdown';
$directory = Scratch::directoryPath('decks');
$parser = new DeckParser();
$written = [];

foreach (glob($sources . '/*.md') ?: [] as $path) {
$stem = basename($path, '.md');

if (isset(NOT_BUILT[$stem])) {
continue;
}

$source = (string) file_get_contents($path);

// A deck carrying an SVG cannot be written by a build that cannot draw one, and
// the reason names the capability rather than the deck: the library refuses that
// picture in such a build, so there is no deck to be asked about.
if (str_contains($source, '.svg)') && !(new SvgRasteriser())->available()) {
test()->markTestSkipped(sprintf(
'This build cannot rasterise an SVG, and %s carries one to be drawn.',
$stem,
));
}

// A deck written more than once is written once per theme, which the renderer is
// told rather than the source: the theme in the source's own frontmatter is the
// one the deck asked for, and the copy in the gallery is the same deck in another
// theme.
$themes = VARIANTS[$stem]['theme'] ?? [null];

foreach ($themes as $theme) {
$renderer = new PresentationRenderer($sources);

if ($theme !== null) {
$renderer->withTheme($theme);
}

$name = $theme === null ? $stem : $stem . '-' . $theme;
$target = sprintf('%s/%s.pptx', $directory, $name);

$renderer->render($parser->parse($source), $target);

$written[] = $target;
}
}

return $written;
}

/**
* What Microsoft's validator makes of the given decks: the exit code and what it said.
*
* @param list<string> $decks
*
* @return array{0: int, 1: string}
*/
function validateWithOpenXml(array $decks): array
{
$command = sprintf(
'dotnet run --project %s -- %s 2>&1',
escapeshellarg(Doctest::root() . '/tools/ooxml-validator'),
implode(' ', array_map('escapeshellarg', $decks)),
);

exec($command, $output, $status);

return [$status, implode("\n", $output)];
}
Loading
Loading