diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 7f2f15c..72f2ba3 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -29,6 +29,26 @@ jobs:
# site is mounted under, so this one setting decides both the canonical addresses and
# the links in the built pages.
MARKDOWN_OFFICE_CANONICAL: https://markdown-office.github.io
+
+ # Which branch of each library and of the command line the site is built against.
+ #
+ # `main` is the library as it is released, and it is the right answer for almost every
+ # run. A pull request that documents a feature the library does not have on `main` yet
+ # cannot be built against `main` — the site renders its own examples with the library,
+ # so an example that uses a feature `main` does not have does not build — and saying
+ # which branch is wanted in the repository's settings is better than a build that fails
+ # on a feature the page is about.
+ #
+ # The variables are read at the point of use and default to `main` when they are not
+ # set, so nothing has to be done to them for an ordinary run, and setting them back to
+ # `main` — or removing them — puts the repository back as it was.
+ #
+ # Only a pull request reads them: the site published from `main` is always built
+ # against the libraries' own `main`, so the published site never depends on a branch
+ # of a library that may be deleted.
+ MARKDOWN_WORD_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_WORD_REF || 'main' }}
+ MARKDOWN_PRESENTATION_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_PRESENTATION_REF || 'main' }}
+ MARKDOWN_OFFICE_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_OFFICE_REF || 'main' }}
steps:
# This repository with the three libraries beside it, because the manifest's `path`
# entries resolve by directory and `../markdown-word` means nothing in any other
@@ -43,14 +63,14 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: markdown-office/markdown-word
- ref: main
+ ref: ${{ env.MARKDOWN_WORD_REF }}
path: markdown-word
- name: Check out markdown-presentation
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: markdown-office/markdown-presentation
- ref: main
+ ref: ${{ env.MARKDOWN_PRESENTATION_REF }}
path: markdown-presentation
- name: Check out commonmark-generic-directives
@@ -72,7 +92,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: markdown-office/mdoffice
- ref: main
+ ref: ${{ env.MARKDOWN_OFFICE_REF }}
path: mdoffice
- name: Set up PHP
@@ -155,9 +175,27 @@ jobs:
"$RUNNER_TEMP/bin/wordcraft-cli" --version
"$RUNNER_TEMP/bin/deckcraft-cli" --version
+ # The strict build, not the plain one: an example that does not build is rendered
+ # into the page as "This example did not build", and the build succeeds anyway.
+ # A green build with a broken example on it is the failure this step exists to
+ # catch, so the strict flag is the one that is asked for.
- name: Build the site
working-directory: documentation
- run: npm run build
+ run: npm run build:strict
+
+ # The reference tables are written by hand, and a hand-written list is correct
+ # only until the code moves. Each vocabulary is read out of the library that
+ # owns it and the page that carries it is read for the names it lists, and the
+ # two are compared in both directions.
+ - name: The reference tables list what the libraries have
+ working-directory: documentation
+ run: php tools/check-reference.php
+
+ # The claims the pages make in prose — the counts, the completeness, the rules —
+ # which a table cannot carry and a name cannot say.
+ - name: The claims the pages make
+ working-directory: documentation
+ run: vendor/bin/pest
# A site whose links 404 is worse than no site, and nothing else says so: the build
# succeeds, the pages are there, and the reader is the one who finds out.
diff --git a/.gitignore b/.gitignore
index 6ee5b91..f909723 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,5 @@
/_data/examples.json
/patches.lock.json
/.worktrees/
+/.phpunit.cache/
.DS_Store
diff --git a/README.md b/README.md
index 7d34b33..ab7d893 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,11 @@ npm run build # build the examples, then the pages, into _site
npm run serve # the same, with a watcher
```
+```bash
+composer check # the examples, the command-line pages, the reference tables, the claims
+composer test # the claims on their own
+```
+
```bash
npm run build:strict # ...and exit non-zero if an example does not build
```
@@ -41,6 +46,60 @@ fails the build rather than the reader's afternoon.
That is not decoration — it caught three real errors in these pages while they were being written,
including one where an example claimed a Word footnote that the converter did not produce.
+## The reference tables
+
+The tables are claims too, and they drift: the site said four themes shipped when five did, and
+its error page said it listed every exception the package throws and left five of them out.
+
+So the tables are checked against the code, and a table says which vocabulary it carries:
+
+```markdown
+
+
+| Role | Title box |
+| --- | --- |
+| `title` | yes |
+```
+
+`tools/check-reference.php` reads every vocabulary out of the library that owns it — the frontmatter
+keys, the layout roles, the free shapes, the colour slots, the theme keys, the style keys, the
+exceptions, the themes that ship, the ceilings, the Word directives, the attributes each of the
+Word directives reads, the keys a Word document's frontmatter may carry, the options the reader of
+a Word document trusts, the features a Word format cannot carry, the Word options, the Word style
+slots and the inline style attributes — and reads the page that carries it for the names it lists.
+The two are compared in both directions: a name the code does not have is a page offering something
+that does not work, and a name the code has and the page does not is a feature nobody can find.
+
+A vocabulary the two halves share is read out of both of them and held to one list, so the
+attributes an inline style takes are the same eight in the deck and in the document — and the
+check says so when they are not.
+
+Three rules make the lists stay lists:
+
+- **One list, one home.** A second copy of a table is a failure, and so is a copy of part of one:
+ two copies drift apart and the one nobody reads is the one that is wrong. Every table that lists
+ names is marked, and the marked one is the only one.
+- **The code is the only source.** The lists are read out of the libraries, not written down in the
+ site, so there is no second copy for the check to compare against. Where a library holds no list
+ of its own — the attributes a directive reads, the keys a frontmatter may carry — the code is
+ read for the names, and a name the code stops reading is a name the page may not list.
+- **A vocabulary with no home is a failure.** A check that passes because it found nothing to check
+ is worse than no check at all.
+
+A table may also carry a value the code holds, and then the value is checked too: the numbers in
+the table of ceilings, and the exceptions that sit beside `InvalidInput` rather than under it.
+
+What a table cannot hold is prose — what a key does, what an exception means — and a count written
+out in a sentence ("Eight, and only these"). Those are claims too, and they are checked by the
+suite in `tests/`: the counts against the code, the claims of completeness against the code, and the
+rules against the library that has to behave that way. A sentence that was wrong and has been
+corrected goes into the `FORBIDDEN` map of `tools/check-command-line.php`, and a page that says it
+again is a failure.
+
+The tables are not generated. Their other columns are prose that no code holds, and moving that
+prose into a generator would make a second copy of the documentation in a file that is not a page —
+the same drift, one repository further away.
+
## Writing a page
```text
@@ -49,7 +108,7 @@ content/
├── word/ the dialect, the directives, the configuration, the round trips, the images, the errors
├── presentation/ the dialect, the directives, the themes, the errors
├── general/ how directives work, the syntax, the images, the errors
-├── command-line/ the installation, the basic use, the options of each half
+├── command-line/ the installation, the basic use, the options of each part
├── suite/ the other packages
└── llms/ llms.txt.njk, pages.json.njk
```
@@ -178,7 +237,9 @@ to it, and the site is published at the GitHub Pages address until then.
content/ the pages, one directory per chapter
_includes/ the layout, the stylesheet, the wordmark
_documents/ the documents the examples read. Committed: an input must not change.
-tools/ render-examples.php, make-input-document.php, the two link checks, and lib/
+src/ the reference tables, read out of the libraries and compared with the pages
+tests/ the claims the pages make in prose
+tools/ render-examples.php, make-input-document.php, the three checks, and lib/
_examples/ built artifacts, published under the site's own path. Not committed.
_data/ examples.json, written by the example builder. Not committed.
_site/ the built site. Not committed.
diff --git a/composer.json b/composer.json
index 47fe781..63e1a17 100644
--- a/composer.json
+++ b/composer.json
@@ -76,11 +76,13 @@
"examples": "@php tools/render-examples.php",
"build": "npm run build",
"serve": "npm run serve",
- "test": "@php tools/check-command-line.php ../mdoffice",
+ "test": "pest",
"check": [
"@php tools/render-examples.php --strict",
"@php tools/check-command-line.php ../mdoffice",
- "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice"
+ "@php tools/check-reference.php",
+ "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice",
+ "@test"
],
"check:site": "@php tools/check-site-links.php",
"check:links": "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice"
diff --git a/content/command-line/basic.md b/content/command-line/basic.md
index 7bae966..6d9da9b 100644
--- a/content/command-line/basic.md
+++ b/content/command-line/basic.md
@@ -11,23 +11,26 @@ order: 1
[`markdown-office/mdoffice`](https://github.com/markdown-office/mdoffice), which requires both
converter packages and calls them — so what it does is what those libraries do.
-The options of each half are on a page of their own: [For word](/command-line/word/) and [For
-presentations](/command-line/presentations/). How to get the tool is on [Installation](/command-line/installation/).
+The options of each part are on a page of their own: [For word](/command-line/word/), [For
+presentations](/command-line/presentations/) and [For themes](/command-line/theme/). How to get the
+tool is on [Installation](/command-line/installation/).
```bash
mdoffice word README.md # writes README.docx
mdoffice word README.docx # writes README.md
mdoffice slides deck.md # writes deck.pptx
+mdoffice theme deck.pptx # writes deck/, a theme to edit
```
## Parts
-The first argument says which converter the run is for.
+The first argument says which part the run is for.
| Part | Does |
| --- | --- |
| `mdoffice word …` | Markdown to `.docx`, `.odt` and `.rtf`, and `.docx` back to Markdown |
| `mdoffice slides …` | Markdown to `.pptx`, and back |
+| `mdoffice theme …` | A theme scaffolded from a deck |
**A bare file is not a run.** `mdoffice notes.md` is refused, and the parts are listed:
@@ -38,6 +41,7 @@ mdoffice: Unknown part "notes.md".
word Markdown to .docx, .odt and .rtf, and .docx back to Markdown
slides Markdown to .pptx, and back
+ theme A theme scaffolded from a deck
```
The Word half decides the direction from the file's first bytes, but with more than one converter
@@ -48,6 +52,9 @@ Only a `.docx` is read back. An `.odt` is a zip archive as well, so it is read a
document and refused — the reader knows the Word document format and not the OpenDocument one.
The two are written, not read.
+The theme part is the one that converts nothing: it reads a deck and writes a theme directory,
+which is a first draft of a theme and is meant to be edited.
+
## Commands
| Part | Command | Does |
@@ -58,15 +65,23 @@ The two are written, not read.
| `word` | `to-markdown` | a Word document to Markdown |
| `slides` | `to-pptx` | Markdown to `.pptx` |
| `slides` | `to-markdown` | a deck to Markdown |
+| `slides` | `diff` | two decks, and what changed between them |
+| `theme` | `init` | a theme scaffolded from a deck |
In the Word part, with no command, the direction is detected. `--to` overrides it: `docx` or
`word` for a Word document, `odt` for an OpenDocument Text, `rtf` for Rich Text Format, and
`markdown` or `md` for Markdown. Those are the spellings it takes; `opendocument` and `rich
text` are refused, because the command that runs says which format it reads.
-The slides part has two commands, and the first is the default: `mdoffice slides deck.md` is
+The slides part has three commands, and the first is the default: `mdoffice slides deck.md` is
the same run as `mdoffice slides to-pptx deck.md`. The second reads a deck back, and it is
-named — a deck is a `.pptx`, and the part does not work the direction out for itself.
+named — a deck is a `.pptx`, and the part does not work the direction out for itself. The third
+compares two decks and says what changed between them; its options are on [For
+presentations](/command-line/presentations/).
+
+The theme part has one command, and it is the default: `mdoffice theme deck.pptx` is the same
+run as `mdoffice theme init deck.pptx`. Its options are on [For
+themes](/command-line/theme/).
A command named on the command line decides the direction itself, and `--to` is then only allowed
to agree with it:
@@ -95,6 +110,7 @@ mdoffice help
mdoffice word help
mdoffice word to-docx --help
mdoffice slides to-pptx --help
+mdoffice theme help
```
## Exit codes
@@ -112,6 +128,10 @@ A defect is printed with its class and the line it happened on, because it is no
somebody's to see: `mdoffice word: : `, and then the file and the line
underneath it.
+**A diff that finds differences has done what it was asked.** It exits `0`, because comparing two
+decks and reporting the difference is the whole of the work; `--exit-code` is what turns the
+difference into a failure, for a build to act on.
+
## Things it is easy to get wrong
- `--output=x`, `--output x` and `-o x` are the same. Leading dashes are stripped before the
diff --git a/content/command-line/presentations.md b/content/command-line/presentations.md
index a586002..b5ab985 100644
--- a/content/command-line/presentations.md
+++ b/content/command-line/presentations.md
@@ -7,9 +7,10 @@ section: The command line
order: 2
---
-The slides part has two commands. `mdoffice slides deck.md` is the same run as `mdoffice slides
-to-pptx deck.md`, and the second reads a deck back. The options each takes are here; the
-options the Word part takes are on [For word](/command-line/word/).
+The slides part has three commands. `mdoffice slides deck.md` is the same run as `mdoffice slides
+to-pptx deck.md`, the second reads a deck back, and the third compares two decks. The options
+each takes are here; the options the Word part takes are on [For word](/command-line/word/), and
+the theme part is on [For themes](/command-line/theme/).
## Options for `to-pptx`
@@ -39,6 +40,64 @@ A line ending may also be written `unix`, `n`, `dos`, `windows` or `r`.
The Markdown that comes back is the Markdown this tool writes decks in, so a deck can be read
out, edited as text and put in again through `to-pptx` with a different theme.
+## Options for `diff`
+
+| Option | Value | Default |
+| --- | --- | --- |
+| `--exit-code` | flag | off |
+| `-h, --help` | flag | off |
+
+A diff takes two decks and says what changed between them:
+
+```bash
+mdoffice slides diff before.pptx after.pptx
+```
+
+**The bytes are never compared.** A `.pptx` carries a timestamp in every part it holds and
+another in the document's own properties, so two correct renderings of the same deck differ most
+of the time; comparing the bytes would report every deck as changed.
+
+**The slides are paired by their id**, because an id survives an edit and a reorder of the deck
+and a position does not: a slide inserted at the front moves every slide after it, and pairing by
+position would report the whole deck as changed. The shapes of a slide are paired by their id
+first, then by their name, and then by their place. A slide or a shape that is left over was
+added or removed, and the report says which.
+
+**What is compared** is the text of every shape, the position and the size of every shape that
+states them, and the colour of every fill and every run. A colour that names a slot of the theme
+is compared as the colour that slot holds, so a change in the theme or in the master's colour
+map is a change of colour. Positions and sizes are in millimetres, from the top left of the
+slide, and two lengths are the same length when they agree to a hundredth of a millimetre.
+
+**What is not compared** is said as well, because a diff that quietly skips what it could not
+compare leaves a reader worse off than one that says what it looked at: the notes, the layouts,
+the masters, the theme's own fonts, the pictures, the document's own properties, a fill that is
+not a single colour, the frame of a group of shapes, and the position or the size of a shape
+that takes it from the layout.
+
+The report goes to standard error, so a pipe gets the difference and not the report of it. It
+ends with what was looked at:
+
+```text
+Compared the text of every shape, the position and the size of every shape that states them, and the colour of every fill and every run, on the 3 slides the two decks have in common. A colour that names a slot of the theme is compared as the colour that slot holds, so a change in the theme or in the master's colour map is a change of colour.
+Positions and sizes are in millimetres, from the top left of the slide. Slides are numbered as in the first deck.
+Not compared: the notes, the layouts, the masters, the theme's own fonts, the pictures, the document's own properties, a fill that is not a single colour, the frame of a group of shapes, and the position or the size of a shape that takes it from the layout.
+```
+
+**A diff that finds differences has done what it was asked**, so it exits `0`. `--exit-code` is
+what turns the difference into a failure, for a build to act on:
+
+```bash
+mdoffice slides diff before.pptx after.pptx --exit-code || echo "the decks differ"
+```
+
+A diff needs two decks, and one is refused:
+
+```text
+mdoffice slides: Name the two decks to compare.
+mdoffice slides: A diff takes two: the deck before and the deck after.
+```
+
**There is no `--to` in this part.** The command says which way the deck goes, and the deck
is a `.pptx` either way:
diff --git a/content/command-line/theme.md b/content/command-line/theme.md
new file mode 100644
index 0000000..e3c05af
--- /dev/null
+++ b/content/command-line/theme.md
@@ -0,0 +1,96 @@
+---
+layout: layouts/base.njk
+permalink: /command-line/theme/
+title: For themes
+summary: What `mdoffice theme init` reads out of a deck, what it leaves to the library, and what it writes.
+section: The command line
+order: 4
+---
+
+The theme part converts nothing. It reads a deck and writes a theme directory — a first draft of
+a theme, meant to be edited. The other parts are on [For word](/command-line/word/) and [For
+presentations](/command-line/presentations/).
+
+```bash
+mdoffice theme init deck.pptx # writes deck/
+mdoffice theme deck.pptx # the same run: init is the default
+```
+
+## Options for `init`
+
+| Option | Value | Default |
+| --- | --- | --- |
+| `-o, --output ` | path | beside the deck |
+| `--force` | flag | off |
+| `-h, --help` | flag | off |
+
+The directory is the deck's own name without its extension, so `deck.pptx` gives `deck/` and
+`talks/talk.pptx` gives `talks/talk/`.
+
+**A theme that is already there is not replaced without being asked.** A theme is a directory of
+files somebody has edited, and replacing one is how an afternoon of work goes missing:
+
+```text
+mdoffice theme: There is already a theme in "deck".
+mdoffice theme: Pass --force to replace it.
+```
+
+## What is written
+
+Three files go into the directory.
+
+| File | Holds |
+| --- | --- |
+| `theme.yml` | the colours, the fonts and the type scale |
+| `layouts.yml` | the layout roles, each pointed at a layout of the deck |
+| `template.pptx` | the deck's own layouts, without its slides |
+
+**The theme is read back before the command reports success.** The library's own reader reads it
+and the library's own geometry reads the boxes of the template, so a theme this command cannot
+read is a failure here rather than a theme nobody can use.
+
+## What is read out of the deck
+
+**The colours** come from the deck's own theme part where the deck is drawn in that theme, and
+from the deck's slides where it is not. A deck made in a tool that does not do themes carries
+Office's palette without ever drawing in it, and that palette describes nothing on the page.
+
+**The type scale** is read off the deck's text, because a deck records a size for every
+paragraph rather than a scale. The title is the size most slides are headed in, the cover the
+largest type on the first slide, the body the size the prose is written at, and the heading
+scale the sizes below the title.
+
+**The layouts** are mapped by the placeholders they hold, not by their names: a deck calls its
+layouts whatever it likes, and the same layout is called different things in two offices. A role
+the deck has no layout for is reported rather than pointed at a layout that is something else.
+
+**The template** is the deck's layouts without its slides, and without the pictures on them. A
+template places, and the slides of the deck it came from are not a placement.
+
+## What is left to the library
+
+The spacing and the monospace face are the library's own. A deck records where its boxes are,
+not the rhythm it draws with, and it says nothing about a face it never uses. Every value the
+deck did not answer is left at the library's default and named in the report, so the numbers in
+the file can be told apart from the numbers somebody chose.
+
+## The report
+
+The command says what it read and where it read it from:
+
+```text
+mdoffice theme: wrote the theme for "deck.pptx" into "deck".
+
+ theme.yml the colours, the fonts and the type scale
+ layouts.yml 1 of the 8 layout roles mapped
+ template.pptx the deck's 1 layouts, without its 3 slides
+
+ colours
+ dk1 #000000 the type
+ lt2 #000000 a filled block
+```
+
+The report goes to standard output, and the theme it writes is a directory the slides part
+reads: put it in `themes//` beside a deck and name it with `theme:`. The theme format is
+the [presentation library's](/presentation/themes/), and the [layout roles](/presentation/themes/#layout-roles)
+are the ones it defines.
diff --git a/content/command-line/word.md b/content/command-line/word.md
index a9bc8e0..3778ed2 100644
--- a/content/command-line/word.md
+++ b/content/command-line/word.md
@@ -8,8 +8,9 @@ order: 3
---
The Word part decides the direction from the file's first bytes, and `--to` overrides it. The
-options it takes are here; the slides part has two commands and two sets of options, on [For
-presentations](/command-line/presentations/).
+options it takes are here; the slides part has three commands and three sets of options, on [For
+presentations](/command-line/presentations/), and the theme part is on [For
+themes](/command-line/theme/).
## Options for `to-docx`, `to-odt` and `to-rtf`
diff --git a/content/general/directives.md b/content/general/directives.md
index 8b8e954..5398896 100644
--- a/content/general/directives.md
+++ b/content/general/directives.md
@@ -28,21 +28,90 @@ and `{…}` is a modifier available on all three shapes rather than a shape of i
Here the two packages make opposite decisions, and both are defensible.
-**Markdown Word is permissive.** It draws exactly two directives — `::toc` and `:footnote` — and
-renders every other name as its own source text. A document nobody intended as a set of
-directives keeps working, and the text an author wrote is what a reader sees.
+**Markdown Word is permissive.** It draws exactly ten directives — `::toc`, `:footnote`, `:style`
+and the seven that place objects in a document — and every named style the theme brings, and
+renders every other name as its own source text. A document nobody intended as a set of directives
+keeps working, and the text an author wrote is what a reader sees.
**Markdown Presentation has a closed set.** Anything outside it fails the build with a message
naming what would have worked. A deck is a thing a person presents, and a slide with the wrong
-thing on it is worse than a deck that did not build.
+thing on it is worse than a deck that did not build. An inline directive in a deck is a
+[text style](#a-word-in-a-style), so a name the theme does not have is refused there too.
Both then agree on one thing: an unrecognised directive is never silently dropped.
-- [Word directives](/word/directives/) — `::toc`, `:footnote`, and the rest rendered as text.
+- [Word directives](/word/directives/) — `::toc`, `:footnote`, `:style`, the seven that place
+ objects, and the rest rendered as text.
- [Presentation directives](/presentation/directives/) — `::click`, `:::click`, `::column`,
- `::note`, and the refusals.
+ `::note`, the slide templates, and the refusals.
- [The syntax itself](/directives/syntax/) — every rule, including the ones that surprise people.
+## What a permissive half does with a directive it cannot draw
+
+Markdown Word draws a directive by handing it to a writer, and a writer is a program with a set of
+things it can express. A directive that names something the writer has no element for is not
+refused: it is **given back to the author as the characters they wrote**, and the loss is
+reported.
+
+That is the same answer an unrecognised name gets, and it is the reason the policy is safe: a
+document nobody intended as a set of directives keeps working, and a document that *did* mean it
+still reads as the thing that was lost rather than as a document with a hole in it.
+
+The three that only a `.docx` can carry — a text box, a shape and a form field — and the columns
+of a section, which only an `.rtf` cannot, are on
+[Word directives](/word/directives/#what-a-format-cannot-carry).
+
+## A word in a style
+
+Both halves can write one word in a style of its own — a colour, a size, a weight — inside a
+sentence that is otherwise one colour and one size. The syntax is the same in both, and the set of
+attributes is the same in both: eight attributes, closed.
+
+
+
+| Attribute | Carries |
+| --- | --- |
+| `color` | the ink |
+| `size` | the size of the type, in points |
+| `font` | the family |
+| `bold` | the weight |
+| `italic` | the slant |
+| `underline` | the underline |
+| `strike` | the strike |
+| `highlight` | a colour behind the word |
+
+Two of the eight are not the same in both halves. A `color` is a role, a theme slot or a hex value
+in Markdown Presentation, and six hexadecimal digits in Markdown Word. A `highlight` is drawn by
+Markdown Word and **refused by name** by Markdown Presentation, which has no way to write one.
+
+Three forms, one grammar:
+
+| Written | Means |
+| --- | --- |
+| `:accent[brand]` | the word "brand", in the style the theme names `accent` |
+| `:accent[brand]{color=FF0000}` | the same word, with the attributes laid over the style's |
+| `:style[brand]{color=FF0000}` | the word "brand", in the attributes, with no theme style behind it |
+
+`style` is reserved: a theme may not name a style `style`, and the raw form needs at least one
+attribute — a style with neither a named style nor a property says nothing.
+
+**An attribute overrides the style for the property it names, and for that property only.** A
+property neither of them names is not set: the run takes it from the block it is in, and nothing is
+reset to a default. A property set to `false` is not set either — it means "not on this run" rather
+than "off", because neither format can write a weight of none.
+
+**The label is plain text.** A directive's label is a string rather than Markdown, because
+CommonMark's inline engine has no way for a parser to say "the rest of this run is mine". So a
+complete Markdown construct in a label — `**text**`, `*text*`, `_text_`, `__text__` or `` `code` ``
+— is not honoured as emphasis. A link cannot be one: a label holds no brackets at all, so there is
+no way to write one. A bare `*` or `_` is a character rather than an intent, so `2 * 3` and
+`snake_case` are ordinary text.
+
+What the two halves do with a name they do not know, and with a label they cannot honour, is where
+they part: [Markdown Presentation](/presentation/directives/#a-word-in-a-style) refuses both and
+names what would have worked, and [Markdown Word](/word/directives/#a-word-in-a-style) writes the
+directive out as its own source text.
+
## The exact grammar
Names are a letter followed by letters, digits, `_` and `-`. There are no namespace prefixes in
diff --git a/content/general/errors.md b/content/general/errors.md
index 0166c0e..d86b636 100644
--- a/content/general/errors.md
+++ b/content/general/errors.md
@@ -32,8 +32,9 @@ try {
so one `catch` does not also catch the failures of the libraries underneath. The failures a deck
author can fix by naming something else extend `Exception\InvalidInput` below it.
-One sits beside those two rather than under `InvalidInput`: `Exception\UnreadablePackage`. Nothing
-a deck names fixes a package that is not a package.
+Five sit beside `InvalidInput` rather than under it, and nothing a deck names fixes any of them: a
+file that cannot be read, a target that cannot be written, a conversion with nothing to convert, or
+a dependency installed without the fixes the library needs.
## The two `UnknownDirective`
@@ -68,10 +69,11 @@ $converter = (new MarkdownToWord('notes.md'));
$converter->save('notes.docx');
foreach ($converter->pendingLosses() as $loss) {
- echo $loss->feature(), ': ', $loss->explain(), "\n";
+ echo $loss->feature, ': ', $loss->message, "\n";
}
```
`.docx` carries everything, so `pendingLosses()` is empty. `.odt` and `.rtf` drop named features —
-every list item left out of an RTF, an SVG flattened to a raster, a `::toc` left as its text — and
-the command line prints them to standard error rather than letting the document quietly lose them.
+every list item left out of an RTF, an SVG flattened to a raster, a `::toc` left as its text, a text
+box left out of the document — and the command line prints them to standard error rather than
+letting the document quietly lose them.
diff --git a/content/index.md b/content/index.md
index c723fb4..1e046c3 100644
--- a/content/index.md
+++ b/content/index.md
@@ -110,8 +110,8 @@ parser extension, for your own directives if you want one.
mdoffice
The command line for both of them: mdoffice word and mdoffice
-slides. Requires both converters and calls them — a program, not a facade, so neither
-package depends on it.
+slides, and mdoffice theme to scaffold a theme from a deck. Requires both
+converters and calls them — a program, not a facade, so neither package depends on it.
markdown-office/mdoffice
diff --git a/content/presentation/directives.md b/content/presentation/directives.md
index b7ace34..2d1a806 100644
--- a/content/presentation/directives.md
+++ b/content/presentation/directives.md
@@ -11,6 +11,9 @@ Markdown Presentation splits its vocabulary in two: **frontmatter keys**, which
and **in-body colon markers**, which are instructions. Both are closed sets, and both fail the
build with a message naming what would have worked.
+The one marker whose names are not closed is the inline one: a name in a deck is a
+[text style](#a-word-in-a-style), and the names are the theme's own.
+
## In-body markers
| Written | Shape | What it does |
@@ -113,8 +116,77 @@ Read this out loud.
::
```
-Case-insensitive, whitespace-tolerant, and the closer is anchored to the end of the slide, so two
-`::note` blocks on one slide become **one** note carrying both. An unclosed note is refused.
+Case-insensitive and whitespace-tolerant. A note has to be the last thing on the slide: the
+closer is anchored to the end of it, so two `::note` blocks on one slide become **one** note
+carrying both. An unclosed note is refused.
+
+## A word in a style
+
+A box carries one colour, one size and one face, and every word in it takes them or does not. A
+**text style** is a name the theme gives to a set of those, and a word written in one is drawn in
+it.
+
+The three forms and the eight attributes are the suite's, and are on
+[How directives work](/directives/#a-word-in-a-style). The named styles come from the theme's
+`text-styles.yml` — see [Text styles](/presentation/themes/#text-styles).
+
+**What this half does with a name it does not know is the opposite of the Word half's: it refuses
+it**, and the message names the styles the theme does have. Every inline directive in a deck is a
+text style, so `Status :green[go]` — which reads like a status — names a style the theme does not
+have, and it fails the build rather than reaching the slide as text.
+
+A label is **plain text** — the directives package stores a label as a string rather than parsing
+it as Markdown — so a complete Markdown construct in one is refused, and the message names the two
+ways out: give the style the weight, or write the emphasis in the paragraph around it. A bare `*`
+or `_` is a character and not an intent, so `2 * 3` and `snake_case` are drawn as they are.
+
+{% example "A word in a colour of its own, inside a box" %}
+````md presentation
+---
+layout: blank
+---
+
+:::content{left=8% top=40% width=60% height=20% fill=none font-size=20}
+Resilience :style[Bakery]{color=00B4D8} is open.
+:::
+````
+{% endexample %}
+
+The `highlight` attribute is **not drawn by this half**. PHPPresentation's font has no setter for
+one, so the attribute is refused by name rather than accepted and quietly dropped.
+
+## Free shapes
+
+A **free shape** is one you place yourself, in pixels or in fractions of the slide, rather than one
+the layout puts where it wants it. It is written in the body of a slide with the same two-colon
+directives the step markers use, and there are thirteen of them and no others — a name outside them
+is refused with a message that lists all thirteen.
+
+
+
+| Shape | Written | Draws |
+| --- | --- | --- |
+| `rectangle` | `::rectangle` | a rectangle |
+| `roundrect` | `::roundrect` | a rounded one, with `border-radius` |
+| `ellipse` | `::ellipse` | an ellipse |
+| `circle` | `::circle` | an ellipse that is as wide as it is tall |
+| `triangle` | `::triangle` | an isosceles triangle |
+| `diamond` | `::diamond` | a diamond |
+| `chevron` | `::chevron` | a chevron |
+| `hexagon` | `::hexagon` | a hexagon |
+| `arrow` | `::arrow` | a right-pointing arrow |
+| `line` | `::line` | a hairline rule as thick as its `stroke-width` |
+| `image` | `::image` | a picture |
+| `content` | `:::content` … `:::` | Markdown, in a box you sized |
+| `slide-number` | `::slide-number` | the number of the slide it is drawn on |
+
+**Attributes are separated by a space.** `::circle{left=8% top=10% width=6% height=6%}` is one
+attribute after another, not a list with commas in it — a comma is part of the value, so
+`{fill=red,stroke=blue}` is one attribute called `fill` whose value is `red,stroke=blue`.
+
+`layout: blank` is what most of them want: a slide whose whole content is free shapes has no title
+to place them around. Every attribute each of them reads is in the package's own
+[README](https://github.com/markdown-office/markdown-presentation#free-shapes).
## Slidev, and where it differs
@@ -174,27 +246,31 @@ splits here the way it does there.
## Frontmatter directives
-Every key of every slide's frontmatter must be one of these seven:
+Every key of every slide's frontmatter must be one of these nine:
+
+
| Key | Type | Default | Slide |
| --- | --- | --- | --- |
| `title` | string | `null` | any |
| `name` | string | `null` | any — a fallback for `title` |
| `level` | integer ≥ 1 | the heading's own depth | any |
-| `layout` | one of eight roles | `content` | any |
+| `layout` | a role name | `content` | any |
| `theme` | string | `default` | **first only** |
| `author` | string | the theme's, else `markdown-presentation` | **first only** |
| `description` | string | `null` | **first only** |
+| `template` | string, or a list of them | none | any |
+| `chrome` | string | none | **first only** |
-The eight layout roles: `title`, `content`, `two-column`, `section`, `blank`, `image-text`,
-`image-title`, `image-slide`. Anything else — including Slidev's own `two-cols` — is refused with
-the list of what would have worked.
+The layout roles are listed with the [themes](/presentation/themes/#layout-roles). Anything else —
+including Slidev's own `two-cols` — is refused with the list of what would have worked.
A deck directive on a later slide is refused by name, because it is nearly always a slide that
-was meant to be the first one.
+was meant to be the first one. The deck's own frontmatter **is the first slide's**, so `theme:`,
+`author:`, `description:` and `chrome:` belong on the slide that opens the deck.
{% example %}
-````md presentation "Every frontmatter key, on a deck that uses all seven"
+````md presentation "The four keys that describe a deck"
---
theme: corporate-dark
title: The quarter in three slides
@@ -220,6 +296,118 @@ Lead with the renewal number. It is the one people remember.
````
{% endexample %}
+## Slide templates
+
+The furniture of a deck — the wordmark (a company's name on a slide, with its mark), the rule, the
+page number — is the same on every slide, and
+writing it out on every slide means changing it on every slide. A **template** is that furniture
+written once.
+
+A template is a slide of the deck's own Markdown whose frontmatter names a template and **nothing
+else**:
+
+```markdown
+---
+template: brand
+---
+:::content{left=4.5% top=3.913% width=29.996% height=4.969% fill=none padding=0 color=FFFFFF font-size=11 font-family=Calibri align=left vertical-align=middle}
+Resilience
+:::
+
+::line{left=5% top=72.889% width=30% height=0.185% stroke=00B4D8 stroke-width=1}
+```
+
+and every slide that wants that furniture names it:
+
+```markdown
+---
+layout: blank
+template: brand
+---
+```
+
+{% example "A deck whose wordmark is written once" %}
+````md presentation
+---
+template: brand
+---
+:::content{left=4.5% top=3.913% width=29.996% height=4.969% fill=none padding=0 color=FFFFFF font-size=11 font-family=Calibri align=left vertical-align=middle}
+Resilience
+:::
+
+::line{left=5% top=72.889% width=30% height=0.185% stroke=00B4D8 stroke-width=1}
+
+---
+layout: blank
+template: brand
+---
+
+# A deck with a wordmark
+````
+{% endexample %}
+
+Four things decide what a template is.
+
+**A definition is not a slide of the deck.** It is not drawn, it has no number, and it does not
+count towards the number of slides — so one definition and many slides add up to one slide each,
+and the deck is numbered as though the definition were not there.
+
+**A template holds chrome and nothing else.** The free shapes it draws, and the words a
+`:::content` block carries. Ordinary Markdown in a template is refused by name, because a template
+is drawn on every slide that names it, and the words of a slide written into one would be on every
+one of them.
+
+**A slide may name several templates**, and the chrome of each of them is drawn on it:
+
+```markdown
+---
+layout: blank
+template: [brand, page]
+---
+```
+
+**`layout:` and `template:` are independent.** `layout:` chooses the geometry — the role the
+theme's `layouts.yml` maps onto a box on the page — and `template:` adds furniture on top of it.
+`layout: blank` with `template: brand` is a slide with no furniture of its own and the wordmark
+drawn where the template puts it.
+
+The chrome is drawn **over** what the slide placed itself, so a full-bleed picture is behind the
+wordmark and not over it.
+
+### The chrome file
+
+A deck may keep its templates in a file beside it and name it in the deck's frontmatter:
+
+```markdown
+---
+theme: ./theme.yml
+chrome: ./chrome.md
+---
+```
+
+`chrome.md` is a Markdown file of template definitions in the same syntax as a deck, and it is
+resolved against the deck's own directory — so a deck and the furniture it shares travel together.
+A file that is not there is a failure, and so is a file outside the directories the render may
+read: a path a deck wrote is held to the same scope as a picture in it. A file that holds a slide
+rather than a definition is a failure too.
+
+**The deck's own templates override the imported ones of the same name** — the same rule the theme
+resolver applies by search order, and the reason a deck can change one word of a house style
+without changing the house style.
+
+## `::slide-number` — the number of the slide
+
+`::slide-number` is a shape like any other: it takes the same placement attributes as every other
+shape and is in the layer order with them. The renderer fills in the number of the slide it is
+drawn on, and `of=true` writes the number of the deck after it.
+
+```markdown
+::slide-number{left=94% top=92% width=5% height=6% color=94A3B8 font-size=10 font-family=Calibri align=right vertical-align=middle}
+```
+
+"3 / 28" is the third slide of a deck of twenty-eight, and the twenty-eight is the number of the
+slides: a template definition is not one of them.
+
## What is refused, and why
The splitter is a **port** of Slidev's `parseSync`, not a clone of it: a deck that splits the way
diff --git a/content/presentation/errors.md b/content/presentation/errors.md
index 6d02e50..085019b 100644
--- a/content/presentation/errors.md
+++ b/content/presentation/errors.md
@@ -12,22 +12,44 @@ is about both libraries, is on [Errors in general](/errors/).
## Presentation
+
+
| Exception | Means |
| --- | --- |
| `InvalidInput` | the base for everything a deck author can fix |
| `UnknownDirective` | a key or marker outside the closed set — the message names the set |
| `UnknownMarker` | `::click=0`, backwards, over 500, unclosed, or a stray closer |
-| `UnknownLayoutRole` | `layout:` is not one of the eight |
+| `UnknownLayoutRole` | `layout:` is not one of the layout roles |
+| `UnknownTemplate` | a slide names a template the deck does not have — the message names the templates |
+| `MalformedChrome` | a template holds ordinary Markdown, or a chrome file holds a slide |
+| `ChromeNotFound` | the chrome file a deck names is not there, or is outside the roots |
+| `ChromeTooLarge` | the chrome file a deck names is over the ceiling |
| `UnsupportedColumn` | more `::column` markers than the layout has columns |
| `UnsupportedImageFormat` | not an image, no decoder, or over the ceiling |
| `UnsupportedLink` | `javascript:`, `data:`, `phar://`, `file://`, a UNC share |
| `ContentOverflow` | a block does not fit and `strict` is on |
-| `TooManySlides` | more than 1000 |
+| `TooManySlides` | more slides than the ceiling allows |
+| `TooManyShapes` | more shapes on a slide than the ceiling allows |
| `MalformedFrontmatter` | not a mapping, or a value of the wrong type |
| `FencedFrontmatter` | a ```` ```yaml ```` block at the top of a slide |
-| `MalformedTheme` / `ThemeNotFound` | as above |
+| `MalformedTheme` | a theme file is not shaped like one |
+| `ThemeNotFound` | a theme name resolves to nothing — the message lists the ones that do |
| `UnknownStyleSlot` | `styles.yml` names a slot that does not exist |
| `MalformedTemplate` | a `template.pptx` is not one |
+| `MalformedShape` | a free shape has an attribute it does not read, or a value it cannot be |
+| `UnknownTextStyle` | an inline style names a style the theme does not have — the message names the styles |
+| `MalformedTextStyle` | a text style has a property it does not have, or a value it cannot be |
+| `UnknownFont` | the theme names a face there is no table of advance widths for |
| `ImageNotFound` | a path, a URL, or something outside the roots |
| `UnreadablePackage` | **not** `InvalidInput`; nothing a deck names fixes it |
+| `UnreadableDeck` | **not** `InvalidInput`; the file is not a package this PHP can read |
+| `FileNotWritable` | **not** `InvalidInput`; the target cannot be written |
+| `NothingToConvert` | **not** `InvalidInput`; there was no deck to convert |
+| `UnpatchedDependency` | **not** `InvalidInput`; the installed PHPPresentation lacks the fixes |
+
+Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
+be read, a target that cannot be written, a conversion with nothing to convert, or a dependency
+that was installed without the fixes the library needs.
+The three that carry a template are about the deck rather than about a file on disk: a name
+nothing is under, a body that holds the wrong thing, and a file that is not there.
diff --git a/content/presentation/index.md b/content/presentation/index.md
index 6216d38..3ef602d 100644
--- a/content/presentation/index.md
+++ b/content/presentation/index.md
@@ -19,10 +19,12 @@ PHP 8.2 or newer, with `ext-dom`, `ext-gd` and `ext-zip`. `ext-imagick` is optio
**Both directions.** Markdown to `.pptx`, and a `.pptx` back to Markdown — what comes back is
the Markdown this package writes decks in, so a deck can be taken apart, edited as text and put
-in again under a different theme. `PresentationRenderer` has two methods, `render()` and
-`overflows()`; the reader is `PowerPointToMarkdown`, with `convert()`, `save()`, `toMarkdown()`
-and `read()`. What the round trip does not keep is written down in
-[Reading a deck back](#reading-a-deck-back).
+in again under a different theme. `PresentationRenderer` has three methods, `render()`,
+`overflows()` and `unknownFonts()`; the reader is `PowerPointToMarkdown`, with `convert()`,
+`save()`, `toMarkdown()` and `read()`. The two that report rather than write are
+`overflows()` — every block that had to be made smaller than it asked for — and `unknownFonts()`
+— every face the deck named that there is no table of advance widths for. What the round trip
+does not keep is written down in [Reading a deck back](#reading-a-deck-back).
---
@@ -59,22 +61,10 @@ slides and **cannot be a thematic break** — use `***`.
### Extended: frontmatter and themes
The deck's own configuration goes in the frontmatter of slide 1. Every key is in a **closed set of
-seven**, and anything outside it fails the build with a message naming what would have worked.
-
-| Key | Type | Default | |
-| --- | --- | --- | --- |
-| `title` | string | `null` | the slide's title |
-| `name` | string | `null` | a fallback for `title` |
-| `level` | integer ≥ 1 | the heading's own depth | the depth a title is drawn at |
-| `layout` | one of eight roles | `content` | which layout to use |
-| `theme` | string | `default` | **slide 1 only** |
-| `author` | string | the theme's | **slide 1 only** |
-| `description` | string | `null` | **slide 1 only** |
-
-The eight layout roles: `title`, `content`, `two-column`, `section`, `blank`, `image-text`,
-`image-title`, `image-slide`.
-
-{% example "Every frontmatter key on a deck" %}
+nine**, and anything outside it fails the build with a message naming what would have worked. The
+nine are listed with the [directives](/presentation/directives/#frontmatter-directives).
+
+{% example "The four keys that describe a deck" %}
````md presentation
---
theme: corporate-dark
@@ -92,51 +82,29 @@ description: What changed, what it cost, and what happens next.
````
{% endexample %}
-#### The whole of `theme.yml`
-
-| Key | Type | Default |
-| --- | --- | --- |
-| `name` | string | **required**, non-empty |
-| `description` | string | `null` |
-| `author` | string | `null` |
-| `dark` | boolean | `false` — boolean only; `dark: yes` is refused |
-| `colors:` | mapping | the twelve slots below |
-| `fonts:` | mapping | Calibri / Calibri / Consolas |
-| `style:` | mapping | the geometry below |
-
-The twelve colour slots — `dk1` `lt1` `dk2` `lt2` `accent1`…`accent6` `hlink` `folHlink` — with
-four readable aliases accepted: `dark1`, `light1`, `dark2`, `light2`, `hyperlink`,
-`followedHyperlink`.
-
-#### The whole of `style:`
-
-| Key | Type | Default | Minimum |
-| --- | --- | --- | --- |
-| `margin` | int, px | 40 | 1 |
-| `gutter` | int, px | 20 | 1 |
-| `block` | int, px | 12 | 1 |
-| `rule` | int, px | 2 | 1 |
-| `body` | int, pt | 16 | 1 |
-| `title` | int, pt | 28 | 1 |
-| `cover` | int, pt | 40 | 1 |
-| `section` | int, pt | 44 | 1 |
-| `headings` | list of int, pt | `[22, 20, 18, 16, 14]` | each ≥ 1 |
-| `leading` | number | 1.2 | 1.0 |
-| `heading-weight` | `bold` or `regular` | `bold` | |
-| `heading-ink` | `ink` or `accent` | `ink` | |
-
-Four themes ship: `default`, `corporate-dark`, `minimal`, `dense`. `minimal` has more margin and
-less ink; `dense` has less margin and smaller type — those are the two axes a deck is usually
-fighting over, and they are separate keys so you can move one.
-
-Resolution is a path, then `/themes/`, then the package's own. **No silent fallback to
-`default`**: a name that does not resolve raises `ThemeNotFound` listing the ones that do.
-
-More, including `layouts.yml` and every built-in theme's numbers: [Themes and layout](/presentation/themes/).
+#### A theme
+
+A theme is a directory, and `theme.yml` is what it has to hold: the name, an optional description
+and author, the colours, the fonts and the geometry. A `theme:` may also name the `theme.yml`
+itself, in which case the theme's other files are the ones beside it. The keys it takes, the
+colour slots, the geometry and the themes that ship are all on
+[Themes and layout](/presentation/themes/).
+
+Resolution is a path, then `themes/` beside the deck, then the package's own. **No silent
+fallback to `default`**: a name that does not resolve raises `ThemeNotFound` listing the ones that
+do.
+
+#### A template
+
+A **template** is the furniture of a deck — the wordmark (a company's name on a slide, with its
+mark), the rule, the page number — written
+once and drawn on every slide that names it. It is a slide of the deck's own Markdown, marked by
+its own frontmatter, and it is not a slide of the deck: it does not appear and it does not count
+towards the number of slides. See [Presentation directives](/presentation/directives/#slide-templates).
### Extended: directives
-Four, and the set is closed — anything outside it is refused by name rather than drawn as a
+Four markers, and the set is closed — anything outside it is refused by name rather than drawn as a
paragraph.
| Written | Does |
@@ -149,6 +117,10 @@ paragraph.
All must be alone on their line. Names are case-insensitive.
+**`:name[…]`** is the inline one — a word in one of the theme's named styles, in the middle of a
+sentence. It is the only inline directive a deck has, and a name the theme does not have is
+refused by name like any other marker: [the text styles](/presentation/directives/#a-word-in-a-style).
+
{% example "A slide that builds up, with a speaker note" %}
````md presentation
## Three things
@@ -286,7 +258,7 @@ deck. A `.pptx` records what is on a slide, not how it was drawn.
What the round trip does not keep is written down in the package's own
[README](https://github.com/markdown-office/markdown-presentation#reading-a-deck-back): the
-design, the layout, the underline, a link inside a run, and a chart.
+design, the layout, the underline, a text style, a link inside a run, and a chart.
### The rendering pipeline
@@ -301,11 +273,16 @@ PowerPoint open the deck as `[Repaired]`.
### Ceilings
+
+
| | |
| --- | --- |
| `MAX_SLIDES` | 1000 |
| `MAX_STEP` | 500 |
| `MAX_PIXELS` | 16,777,216 — 4096 × 4096, enforced on the file header |
+| `MAX_BYTES` | 1,048,576 — the size of a chrome file |
+| `MAX_SHAPES` | 100 — the shapes one slide may carry |
+| `MAX_PART_BYTES` | 4,194,304 — one part of a theme's template |
The pixel ceiling is worth a moment. The obvious implementation of it is wrong: measuring a
raster means decoding it, and decoding is what allocates the memory you were trying to bound. Both
@@ -331,8 +308,9 @@ try {
}
```
-One sits beside those two rather than under `InvalidInput`: `Exception\UnreadablePackage`. Nothing
-a deck names fixes a package that is not a package.
+Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
+be read, a target that cannot be written, a conversion with nothing to convert, or a dependency
+installed without the fixes the library needs.
The whole catalogue is on [Errors](/presentation/errors/).
diff --git a/content/presentation/themes.md b/content/presentation/themes.md
index a68efd7..b74d8db 100644
--- a/content/presentation/themes.md
+++ b/content/presentation/themes.md
@@ -2,7 +2,7 @@
layout: layouts/base.njk
permalink: /presentation/themes/
title: Themes and layout
-summary: The twelve colour slots, the thirteen style keys, the eight layout roles, and the four themes that ship with the presentation package.
+summary: The twelve colour slots, the twelve style keys, the eight layout roles, and the themes that ship with the presentation package.
section: Markdown Presentation
order: 2
---
@@ -15,26 +15,34 @@ themes/corporate-dark/
├── theme.yml required
├── styles.yml the Word half's vocabulary — the renderer does not read it
├── layouts.yml which PowerPoint layout each role maps to
+├── text-styles.yml the names a deck writes words in — see [Text styles](#text-styles)
└── template.pptx optional, a path
```
## `theme.yml`
+
+
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | — | **required**, non-empty |
| `description` | string | `null` | |
| `author` | string | `null` | |
| `dark` | boolean | `false` | boolean only — `dark: yes` is refused |
-| `colors:` | mapping | see below | `#rrggbb`, stored upper-cased |
-| `fonts:` | mapping | Calibri / Calibri / Consolas | `heading`, `body`, `mono` |
-| `style:` | mapping | see below | the geometry |
+| `colors` | mapping | see below | `#rrggbb`, stored upper-cased |
+| `fonts` | mapping | Calibri / Calibri / Consolas | `heading`, `body`, `mono` |
+| `style` | mapping | see below | the geometry |
+
+The last three hold a mapping. A key is written with a colon in the file whatever it holds,
+and the table names the keys as they are — the colon is YAML's and not theirs.
`dark: yes` being refused is not pedantry. YAML 1.1 truthy strings are what make a theme written
by hand behave differently from one written by a machine, and this one is for machines.
## The twelve colour slots
+
+
```text
dk1 lt1 dk2 lt2 accent1 accent2 accent3 accent4 accent5 accent6 hlink folHlink
```
@@ -42,14 +50,21 @@ dk1 lt1 dk2 lt2 accent1 accent2 accent3 accent4 accent5 accent6 hlink folHlink
Defaults: `#000000` `#FFFFFF` `#44546A` `#E7E6E6` `#4472C4` `#ED7D31` `#A5A5A5` `#FFC000`
`#5B9BD5` `#70AD47` `#0563C1` `#954F72`.
-Four read-friendly aliases are accepted: `dark1`, `light1`, `dark2`, `light2`, `hyperlink`,
-`followedHyperlink`.
+Six read-friendly aliases are accepted — the spellings the concept draft used:
+
+
+
+```text
+dark1 light1 dark2 light2 hyperlink followedHyperlink
+```
`dark: true` swaps four of them — the page becomes `dk1`, the ink `lt1`, the panel `dk2`, the
subtle ink `lt2` — which is why the same theme can be dark without being a second theme.
## `style:` — the geometry
+
+
| Key | Type | Default | Minimum |
| --- | --- | --- | --- |
| `margin` | int, px | 40 | 1 |
@@ -65,10 +80,37 @@ subtle ink `lt2` — which is why the same theme can be dark without being a sec
| `heading-weight` | `bold` or `regular` | `bold` | |
| `heading-ink` | `ink` or `accent` | `ink` | |
+## Text styles
+
+A theme may name the styles a deck writes words in. `text-styles.yml` is optional: a theme
+without one has no named styles, and every named form written against it is a name the theme does
+not have.
+
+```yaml
+# themes/mine/text-styles.yml
+brand:
+ color: accent1
+ bold: true
+muted:
+ color: lt2
+ size: 10
+```
+
+The properties are the [eight attributes](/directives/#a-word-in-a-style) an inline style takes, and
+a size is in points as it is everywhere else. A colour is the same one a shape's fill is written
+in: a role, a theme slot or a hex value.
+
+When the theme is read, a style that names an attribute that does not exist, a style that carries
+no property at all, a style called `style` — the word the raw form is written with — and a
+`highlight`, which this half does not draw, are each refused. A typo in the file is never rendered
+as silence.
+
## Layout roles
Eight, and only these:
+
+
```text
title content two-column section blank image-text image-title image-slide
```
@@ -78,7 +120,31 @@ master — the file may be a bare mapping or nested under a single `layouts:` ke
Only the roles a deck actually uses are built, so a ten-slide deck does not carry eight masters.
-## The four that ship
+## Templates
+
+A **template** is not a theme, and it does not live beside one.
+
+| | Theme | Template |
+| --- | --- | --- |
+| Where it lives | a directory, `themes//` | a slide of the deck, or a file beside the deck |
+| What it is | `theme.yml` and its friends | chrome: the free shapes it draws, and the words in them |
+| What names it | `theme:` | `template:` |
+| What it decides | the colours, the type, the grid | the furniture on the slide |
+
+A theme's own `template.pptx` is a **third thing** and shares only the name: it is the PowerPoint
+file that says where the theme's layouts put their boxes, and it says nothing about the furniture
+on a slide. A template that says where the furniture goes is written in Markdown, beside the deck
+or in it — `chrome: ./chrome.md` resolves against the deck's own directory, so a deck and the
+furniture it shares travel together.
+
+The rules a template follows, and the four things that decide what one is, are on
+[Presentation directives](/presentation/directives/#slide-templates).
+
+## The ones that ship
+
+Five themes ship, and a deck names one with `theme:`:
+
+
| Theme | Margin | Block | Body | Headings | Leading | Weight | Ink |
| --- | --- | --- | --- | --- | --- | --- | --- |
@@ -86,14 +152,18 @@ Only the roles a deck actually uses are built, so a ten-slide deck does not carr
| `corporate-dark` | 48 | 16 | 18pt | 26/23/21/19/17 | 1.32 | bold | **accent** |
| `minimal` | 64 | 14 | 17pt | 23/22/21/20/19 | 1.12 | **regular** | ink |
| `dense` | 28 | 8 | 13pt | 17/16/15/14/13 | 1.1 | bold | ink |
+| `pitch` | 48 | 10 | 12pt | 24/20/16/14/12 | 1.25 | bold | ink |
`minimal` has more margin and less ink; `dense` has less margin and smaller type. Those are the
two axes a deck is usually fighting over, and they are separate keys so you can move one.
+`pitch` is the one for a proposal: a near-black ink rather than a true black, and accents that carry
+greys as well as colours.
+
## Resolution order
1. an explicit path — anything containing `/`
-2. `/themes/`
+2. `themes/` beside the deck
3. the package's own `themes/`
There is **no silent fallback to `default`**. A name that does not resolve raises `ThemeNotFound`
@@ -104,8 +174,8 @@ asked for is worse than a deck that did not build.
The Word half has themes too, and they are the other shape: a directory with `theme.yml` —
`name`, `description`, `author`, and `name` required — and an optional `styles.yml` holding the
-same slot names the [Word configuration](/word/configuration/) page lists. Four ship:
-`default`, `minimal`, `corporate`, `report`.
+same slot names the [Word configuration](/word/configuration/) page lists. They are listed
+[there](/word/configuration/#themes).
A theme is a **starting point**, not a replacement: your own `styles:` sits on top of it, and
which keys you wrote is remembered so a partial override does not silently discard the rest.
diff --git a/content/word/configuration.md b/content/word/configuration.md
index 5f516c3..59e9cd7 100644
--- a/content/word/configuration.md
+++ b/content/word/configuration.md
@@ -17,7 +17,18 @@ carry ignored.
`withAllExtensions()` is not the answer either: it does read frontmatter, but it leaves directives
out, so `::toc` and `:footnote` stop being directives and become the text an author wrote.
-So both, named:
+So both, named — `withFrontMatter()` is the default flavour and the front matter, and nothing else:
+
+```php
+use MarkdownWord\MarkdownToWord;
+use MarkdownWord\Parser\CommonMarkParser;
+
+$parser = CommonMarkParser::withFrontMatter();
+
+(new MarkdownToWord($markdown, parser: $parser))->save('out.docx');
+```
+
+The same two things by hand are the extension list the constructor takes:
```php
use League\CommonMark\Extension\FrontMatter\FrontMatterExtension;
@@ -40,21 +51,25 @@ flavour — which is why this catches people writing PHP and not people using th
## Top-level keys
+
+
| Key | Type | Default | What it does |
| --- | --- | --- | --- |
-| `options:` | mapping | absent | handed to `Options::fromArray()` |
-| `styles:` | mapping | absent | handed to `new Styles(...)` |
-| `theme:` | string | absent | a named look, or a path to one |
-| `template_file:` | string | absent | a `.docx` or `.dotx` to render into |
-| `fields:` | mapping | `{}` | values for the template's `MERGEFIELD`s |
-| `header:` | mapping | empty | the running head |
-| `footer:` | mapping | empty | the running foot |
+| `options` | mapping | absent | handed to `Options::fromArray()` |
+| `styles` | mapping | absent | handed to `new Styles(...)` |
+| `theme` | string | absent | a named look, or a path to one |
+| `template_file` | string | absent | a `.docx` or `.dotx` to render into |
+| `fields` | mapping | `{}` | values for the template's `MERGEFIELD`s |
+| `header` | mapping | empty | the running head |
+| `footer` | mapping | empty | the running foot |
Only `options:` and `styles:` are validated. Every other key is open-ended metadata, so a document
can carry `title:` or `author:` for a pipeline downstream that cares and be none the worse.
## Every option
+
+
| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `softBreak` | string | `space` | `space`, `lineBreak`, `paragraph` |
@@ -77,20 +92,41 @@ can carry `title:` or `author:` for a pipeline downstream that cares and be none
Booleans may be written as `true`/`false`/`yes`/`no`/`on`/`off`, in any case.
+The objects a document may place — `::image`, `::caption`, `::pagebreak`, `::section`, `::textbox`,
+`::shape` and `::field` — take no options of their own. The one that reads an option is `::image`:
+its `src` is resolved against `imageBasePath`, and its size is in the centimetres `imageMaxWidth`
+is in — though a size the document names is the size the picture gets, and the cap is what happens
+to a picture that says nothing about its own size. See
+[Word directives](/word/directives/#image--a-picture-of-a-stated-size).
+
## Style slots
Each slot holds a **style id** (a string), an **inline style** (a mapping), or `null`. A mapping
merges recursively into the default; a style id or `null` replaces the slot whole.
+
+
| Slot | Default |
| --- | --- |
-| `heading.1` … `heading.6` | `Heading1`…`Heading6`, bold, 16/13/12/11/11/11pt, `2F5496` |
+| `heading.1` | `Heading1`, bold, 16pt, `2F5496` |
+| `heading.2` | `Heading2`, bold, 13pt, `2F5496` |
+| `heading.3` | `Heading3`, bold, 12pt, `1F3763` |
+| `heading.4` | `Heading4`, bold and italic, 11pt, `2F5496` |
+| `heading.5` | `Heading5`, bold, 11pt, `2F5496` |
+| `heading.6` | `Heading6`, italic, 11pt, `1F3763` |
| `paragraph` | 6pt after, 1.15 line height |
| `blockQuote` | `IntenseQuote`, italic, `404040`, 720 twips either side |
| `codeBlock` | 360 twips left, no space above or below |
+| `thematicBreak` | nothing |
+| `htmlFallback` | nothing |
| `listParagraph` | 3pt after |
| `codeFont` | Consolas 9pt `A31515` |
| `linkFont` | `0563C1`, underlined |
+| `bulletList` | `MarkdownWord-Bullet` |
+| `orderedList` | `MarkdownWord-Ordered` |
+| `table` | nothing — a table carries its own |
+| `tableHeaderRow` | nothing — a row carries its own |
+| `tableCell` | nothing — a cell carries its own |
Font properties are `name`, `size`, `color`, `bold`, `italic`, `strikethrough`, `underline`.
Paragraph properties are `indentation`, `space`, `shading`, `keepNext`, `alignment`, `styleName`,
@@ -113,10 +149,9 @@ it.
## Headers and footers
Both take `body`, `align` (`left`/`center`/`right`), `border` (`top`/`bottom`/`both`), and the
-switches `page`, `total`, `chapter`, `filename`, `date`, `firstPage`, `even`.
-
-A `body` may contain `{page}`, `{total}`, `{filename}`, `{chapter}` and `{date}`. With
-`dateFormat` set, `{date}` becomes a Word `DATE` field with that picture rather than a fixed date.
+switches `page`, `total`, `chapter`, `filename`, `date`, `firstPage`, `even`. A `body` may contain
+`{page}`, `{total}`, `{filename}`, `{chapter}` and `{date}`. With `dateFormat` set, `{date}` becomes
+a Word `DATE` field with that picture rather than a fixed date.
```yaml
footer:
@@ -132,3 +167,28 @@ footer:
`template_file:` names a `.dotx`. `fields:` fills its merge fields, with `true` and `false`
spelled as strings because that is what Word stores. The command line adds `--require-control`,
which refuses a template with no content control instead of appending to it.
+
+## Themes
+
+A theme is a directory: `theme.yml` — `name` required, plus `description` and `author` — and an
+optional `styles.yml` holding the same slot names as above. One key of that file is not a slot:
+`textStyles` holds the named styles a document may write a word in — see
+[Word directives](/word/directives/#a-word-in-a-style). A `theme:` may also name the
+`theme.yml` itself, in which case the `styles.yml` beside it is the theme's.
+
+Four themes ship, and a document names one with `theme:`:
+
+
+
+| Theme | What it is |
+| --- | --- |
+| `default` | the built-in look — blue headings, Consolas code on a light grey panel |
+| `minimal` | monochrome and quiet — headings in the ink, code on the page, no colour anywhere |
+| `corporate` | serif headings in navy, wide spacing, ruled tables |
+| `report` | serif body on an open leading, understated headings — for something meant to be read to the end |
+
+Resolution is a path — written from beside the document, the way a relative image path is —
+then `themes/` beside the document, then `themes/` beside the working directory,
+then the package's own `themes/`, and
+**there is no silent fallback** — a name that does not resolve raises `ThemeNotFound` listing the
+ones that do.
diff --git a/content/word/directives.md b/content/word/directives.md
index 3a75b31..7433e6f 100644
--- a/content/word/directives.md
+++ b/content/word/directives.md
@@ -2,14 +2,34 @@
layout: layouts/base.njk
permalink: /word/directives/
title: Word directives
-summary: ::toc and :footnote are drawn. Everything else is written into the document exactly as the author typed it.
+summary: ::toc, :footnote, :style and seven that place objects in a document. Everything else is written into the document exactly as the author typed it.
section: Markdown Word
order: 1
---
-Markdown Word draws **two** directives. Both are real Word features rather than Markdown dressed
+Markdown Word draws **ten** directives. They are real Word features rather than Markdown dressed
up, which is the reason they exist at all.
+
+
+| Directive | Shape | Draws |
+| --- | --- | --- |
+| `toc` | block | a table of contents Word can build |
+| `footnote` | inline | a real Word footnote |
+| `style` | inline | a word in one of the theme's named styles |
+| `image` | block | a picture of a stated size |
+| `caption` | block | a caption under a picture |
+| `pagebreak` | block | a page break |
+| `section` | block | a section break, and the columns of the section it starts |
+| `textbox` | block | a floating box with text in it |
+| `shape` | block | a shape |
+| `field` | block | a form field |
+
+**A caller that builds its own registry draws fewer.** `Directives::standard()` takes a handler for
+each name, and the handlers that are not given are not drawn — a caller with no images to place has
+no use for a handler that places them. A conversion through `MarkdownToWord` hands over all of
+them, so all ten are drawn.
+
## `::toc` — a table of contents Word can build
A block directive. Word's `TOC` element cannot draw one of these: it writes a `PAGEREF` for every
@@ -19,6 +39,8 @@ so the document carries a field that starts nowhere. The field is therefore buil
### Attributes
+
+
| Attribute | Type | Default | Notes |
| --- | --- | --- | --- |
| `depth` | integer | `maxHeadingLevel`, which is `6` | clamped to 1–6 |
@@ -26,9 +48,13 @@ so the document carries a field that starts nowhere. The field is therefore buil
| `title` | string | none | a heading drawn above the field |
```markdown
-::toc{depth=2, title="Contents"}
+::toc{depth=2 title="Contents"}
```
+**Attributes are separated by a space.** A comma is part of the value rather than a separator, so
+`{depth=2, title="Contents"}` reads the depth as `2,` — which is not a number, and the depth falls
+back to the default.
+
Names are matched case-insensitively and trimmed, so `::TOC` and `:: toc` both work.
### What comes out
@@ -57,7 +83,7 @@ and with `numbers=false`, a `\n` before the `\h`.
Contents below are built by Word on open.
-::toc{depth=2, title="Contents"}
+::toc{depth=2 title="Contents"}
## Highlights
@@ -91,19 +117,220 @@ The figure was provisional[^1] and has since been confirmed.
Word's own footnote syntax is not what is parsed here. `:footnote[…]` is this suite's spelling.
-> **Measured, and it does not currently reach a footnote.** Building
-> `Body with a note:footnote[Source: the ledger.]` with the library's own default parser produced
-> a `.docx` with **no `word/footnotes.xml` in it at all**, and the literal text
-> `:footnote[Source: the ledger.]` in the body. The same happened with an explicit parser
-> carrying `GenericDirectiveExtension`. This page says so rather than describing the intended
-> behaviour, because the build on this site checks the difference and would fail the example
-> otherwise.
+> **A directive needs the whitespace in front of it.** `Body with a note:footnote[Source: the
+> ledger.]` is prose — a name that follows a word is not a directive — and the `.docx` carries the
+> literal text `:footnote[Source: the ledger.]` in the body. Written
+> `Body with a note :footnote[Source: the ledger.]`, it is a real Word footnote: the `.docx`
+> carries `word/footnotes.xml`, and a `w:footnoteReference` stands where the note was written.
- The mark sits in the run the directive stood in, so no post-pass is needed.
- The note is set in the `paragraph` slot's character half, not in whatever heading the mark sits in.
- An empty body — `:footnote` or `:footnote[]` — draws the source characters instead.
- Only `.docx` carries it; `.odt` and `.rtf` get the text back.
+## `::image` — a picture of a stated size
+
+```markdown
+::image[Revenue by quarter]{src="chart.png" width=12 height=8 align=center}
+```
+
+The label is the alternative text, and a file that is not there falls back to it — the same answer
+a Markdown image gives. The file is resolved against `imageBasePath` the way a Markdown image's
+URL is.
+
+**The size is in centimetres**, the unit `imageMaxWidth` is in, so a document that names a size and
+a document that leaves it to the option are in the same unit. A size that is asked for is the size
+the picture gets: the cap is what happens to a picture that says nothing about its own size, and
+shrinking a picture to a limit the author did not know about is a quiet change this library does not
+make. One of the two sizes leaves the other to the picture's own proportions.
+
+
+
+| Attribute | Carries |
+| --- | --- |
+| `src` | the file |
+| `width`, `height` | the size in centimetres |
+| `align` | `left`, `center`, `right` |
+
+A directive with no `src` is written out as its own source text.
+
+## `::caption` — a caption under a picture
+
+```markdown
+::caption[Figure 1: Revenue by quarter]{}
+```
+
+A caption is a paragraph in the document's own look, with its text in italics.
+
+**A caption is not numbered.** PHPWord has no `SEQ` field, which is the field Word numbers a
+figure with, so a caption that numbered itself would be a number written out at conversion time —
+wrong the moment a figure is inserted above it. The number is in the caption, where a reader can
+see it and an author can change it.
+
+An empty caption is written out as its own source text.
+
+## `::pagebreak` — a page break
+
+```markdown
+::pagebreak
+```
+
+A page break belongs to a section, and a directive that stands where there is no page to break — in
+a table cell — is written out as the characters the author wrote, the same answer an unclaimed
+directive gets.
+
+## `::section{columns=…}` — a section break, and its columns
+
+```markdown
+::section{columns=2}
+```
+
+Everything after the directive belongs to the new section, and the columns are its own. The new
+section keeps the page setup of the one it follows: a section is a page, and a document whose
+second half is on a different page size from its first is a document nobody meant to write.
+
+A section break is a break between two parts of a document, and a part of a document that is a
+quote or a list item is not one — so a `::section` inside either is written out as the characters
+the author wrote.
+
+**The columns are carried by `.docx` and `.odt`, and not by `.rtf`.** RTF has no columns at all, so
+the section break survives and the columns do not. That is reported the way every other loss is.
+
+## `::textbox` — a floating box with text in it
+
+```markdown
+::textbox[A floating note]{width=6 height=2 left=1 top=1 border=00B4D8}
+```
+
+The label is the text the box holds.
+
+**The box floats**, and the text flows around it: a box that sat in the flow would be a paragraph
+with a border round it, and a paragraph is what a document can already say. `left` and `top` place
+it — from the left margin and from the top of the paragraph it stands in — and both are in
+centimetres, like every other size here. A box is given a border, because a box with nothing round
+it is a paragraph.
+
+
+
+| Attribute | Carries |
+| --- | --- |
+| `width`, `height` | the size in centimetres |
+| `left`, `top` | where it floats, in centimetres |
+| `border` | the colour of the line round it, six hexadecimal digits without a `#` |
+
+A box with no size is written out as its own source text.
+
+## `::shape` — a shape
+
+```markdown
+::shape{type=rect width=4 height=2 fill=E7E6E6 line=4472C4}
+```
+
+The type is one of the shapes a Word document can draw: `arc`, `curve`, `line`, `polyline`, `rect`
+and `oval`. A type that is not one of them is written out as the characters the author wrote,
+because an element no reader knows is not a shape.
+
+
+
+| Attribute | Carries |
+| --- | --- |
+| `type` | the shape to draw |
+| `width`, `height` | the size in centimetres |
+| `fill` | the colour inside it, six hexadecimal digits without a `#` |
+| `line` | the colour of its outline, six hexadecimal digits without a `#` |
+
+## `::field` — a form field
+
+```markdown
+::field{name="Customer" type=textinput value="Northwind"}
+```
+
+The type is one of `textinput`, `checkbox` or `dropdown`, and `textinput` is the default. A dropdown
+carries the choices it offers, separated by commas.
+
+**A dropdown with entries turns the document into a form.** Word only lets a reader choose from one
+while the document is protected for forms, so a document with a dropdown that offers choices is
+protected for forms. A dropdown without entries is written as a field with nothing to choose from,
+and nothing is protected: there is nothing to choose.
+
+
+
+| Attribute | Carries |
+| --- | --- |
+| `type` | `textinput`, `checkbox` or `dropdown` |
+| `name` | the name of the field |
+| `entries` | the choices a dropdown offers, separated by commas |
+| `value` | what a text field starts with |
+| `checked` | `true` leaves a checkbox ticked |
+
+## What a format cannot carry
+
+**The Word half never refuses.** A directive a writer cannot express is given the author's own
+characters back, and the loss is reported — never a refusal, and never a silent one.
+
+
+
+| Feature | `.docx` | `.odt` | `.rtf` |
+| --- | --- | --- | --- |
+| `text-box` | carried | lost | lost |
+| `shape` | carried | lost | lost |
+| `form-field` | carried | lost | lost |
+| `columns` | carried | carried | lost |
+
+A text box, a shape and a form field are written as VML and as Word's own `FORMTEXT`,
+`FORMCHECKBOX` and `FORMDROPDOWN` fields, and neither the ODF writer nor the RTF writer has an
+element for any of them. What the other two formats are given instead is the source form, and the
+loss is reported rather than suffered in silence:
+
+```text
+a ::textbox is left out of the document
+a ::shape is left out of the document
+a ::field is left out of the document
+the columns of a ::section are lost
+```
+
+The last of those is the RTF's alone. RTF has no columns at all, so the section break survives and
+the columns do not.
+
+`pendingLosses()` is where those sentences come from, and the command line prints them rather than
+letting the document quietly lose the thing.
+
+## A word in a style
+
+A word in a style, in the middle of a sentence:
+
+```markdown
+Resilience :brand[Bakery] is open.
+```
+
+The three forms and the eight attributes are the suite's, and are on
+[How directives work](/directives/#a-word-in-a-style). The named styles come from the theme: the
+`textStyles` key of its `styles.yml`, beside the slots — see [Themes](/word/configuration/#themes).
+
+**What this half does with a name it does not know is the opposite of the presentation half's: it
+writes the directive out as its own source text.**
+
+| Written | In the document |
+| --- | --- |
+| `:brand[Bakery]` where the theme has no `brand` | `:brand[Bakery]` |
+| `:style[Bakery]` with no attributes | `:style[Bakery]` |
+| `:brand[Bakery]{weight=900}` | `:brand[Bakery]{weight="900"}` |
+| `:brand[**Bakery**]` | `:brand[**Bakery**]` |
+
+The label is plain text, so a complete Markdown construct in one is drawn as the source text as
+well. A bare `*` or `_` is not Markdown: `2 * 3` and `snake_case` are ordinary text, and the word
+is written in the style as it stands.
+
+{% example "A word in the theme's own named style" %}
+````md word
+---
+theme: corporate
+---
+
+Resilience :brand[Bakery] is open. It has been since :muted[2019].
+````
+{% endexample %}
+
## Everything else
Any other name parses and is **written out as its own source text**. That is the policy, and it is
@@ -121,9 +348,9 @@ opt-in became on by default, which was a behaviour change and is written down as
Two consequences worth knowing:
-- **The container form of a drawn name is also claimed.** `:::toc` … `:::` matches the block map,
- the handler returns `true`, and the body is **silently dropped**. There is no test pinning this
- either way, so treat `::toc` as the only supported spelling.
+- **The container form of a drawn name draws its body.** `:::toc` … `:::` is not a table of
+ contents with fences written around it: the body is drawn as ordinary Markdown and the fence is
+ gone. Write `::toc` on a line of its own.
- **Block and inline are separate maps.** `::footnote` is not drawn — footnotes are inline — and
comes out as the text the author wrote.
diff --git a/content/word/errors.md b/content/word/errors.md
index 55d29a2..4d4b8d3 100644
--- a/content/word/errors.md
+++ b/content/word/errors.md
@@ -12,6 +12,8 @@ about both libraries, is on [Errors in general](/errors/).
## Word
+
+
| Exception | Means |
| --- | --- |
| `InvalidConfiguration` | the key half; the base for the next one |
@@ -22,12 +24,12 @@ about both libraries, is on [Errors in general](/errors/).
| `UnreadableFile` | the input is not a file |
| `UnreadableDocument` | the bytes are not a Word document |
| `MalformedDocument` | it is one, and it is broken |
-| `FileNotWritable` | the target cannot be written |
+| `FileNotWritable` | **not** `InvalidInput`; the target cannot be written |
| `TemplateNotFound` | `template_file:` names nothing |
| `NoContentControl` | `--require-control`, and the template has none |
| `MalformedTheme` | a theme file is not shaped like one |
| `ThemeNotFound` | a theme name resolves to nothing — the message lists the ones that do |
| `UnknownStyleSlot` | `styles:` names a slot that does not exist |
-| `UnsupportedElement` | something in the document cannot be drawn |
+| `UnsupportedElement` | **not** `InvalidInput`; something in the document cannot be drawn |
| `UnsupportedImageFormat` | see [Images](/images/) |
diff --git a/content/word/images.md b/content/word/images.md
index b45332f..cf20d65 100644
--- a/content/word/images.md
+++ b/content/word/images.md
@@ -13,11 +13,10 @@ the deck's, and are on [Images](/images/).
## The options
-| Option | Default | |
-| --- | --- | --- |
-| `images` | `embed` | `embed`, `placeholder`, `skip` |
-| `imageMaxWidth` | `15.0` | centimetres |
-| `svgFallback` | `raster` | `none` for readers that can take the vector |
+The three options that decide what happens to a picture are on
+[Configuration](/word/configuration/#every-option): `images`, which says whether the picture is
+embedded, left out or replaced by its name, `imageMaxWidth`, the cap on a picture that says nothing
+about its own size, and `svgFallback`, which says what happens to a vector.
`pendingImageConversions()` reports what was converted, and `pendingLosses()` says when the vector
was written with no raster beside it — which means a reader that does not prefer the vector draws
diff --git a/content/word/index.md b/content/word/index.md
index f2b166b..db32f3f 100644
--- a/content/word/index.md
+++ b/content/word/index.md
@@ -2,7 +2,7 @@
layout: layouts/base.njk
permalink: /word/
title: Markdown Word
-summary: CommonMark and GitHub Flavored Markdown into .docx, .odt and .rtf — extended with frontmatter and themes, and with two directives — and back out again.
+summary: CommonMark and GitHub Flavored Markdown into .docx, .odt and .rtf — extended with frontmatter and themes, and with ten directives — and back out again.
section: Markdown Word
order: 0
---
@@ -88,106 +88,29 @@ The `theme:` key names a look that ships with the library. Everything under `opt
````
{% endexample %}
-#### Every frontmatter key
+#### The keys, the options and the slots
-| Key | Type | Default | |
-| --- | --- | --- | --- |
-| `options:` | mapping | absent | handed to `Options::fromArray()` |
-| `styles:` | mapping | absent | handed to `new Styles(...)` |
-| `theme:` | string | absent | a named look, or a path to one |
-| `template_file:` | string | absent | a `.docx` or `.dotx` to render into |
-| `fields:` | mapping | `{}` | values for the template's `MERGEFIELD`s |
-| `header:` | mapping | empty | the running head |
-| `footer:` | mapping | empty | the running foot |
+Every frontmatter key, every option, every style slot and its default, the headers and footers and
+the themes that ship are listed in [Configuration](/word/configuration/) — one list each, and the
+options, the slots and the themes are checked against the library that holds them.
Only `options:` and `styles:` are validated. Every other key is open-ended metadata, so a document
can carry `title:` for a pipeline downstream that cares and be none the worse.
-#### Every option
-
-| Key | Type | Default | Values |
-| --- | --- | --- | --- |
-| `softBreak` | string | `space` | `space`, `lineBreak`, `paragraph` |
-| `hardBreak` | string | `line` | `remove`, `line`, `paragraph` |
-| `html` | string | `strip` | `strip`, `preserve`, `drop` |
-| `images` | string | `embed` | `embed`, `placeholder`, `skip` |
-| `imageBasePath` | string | `null` | where relative paths resolve from |
-| `imageMaxWidth` | number | `15.0` | centimetres, ≥ 0 |
-| `svgFallback` | string | `raster` | `raster`, `none` |
-| `maxHeadingLevel` | integer | `6` | clamped to 1–6 |
-| `orderedListFormat` | string | `decimal` | any OOXML `w:numFmt` |
-| `orderedListSuffix` | string | `tab` | `tab`, `space`, `nothing` |
-| `tableBorders` | boolean | `true` | |
-| `tableHeaderBold` | boolean | `true` | off makes a round trip honest about headers |
-| `tableWidth` | integer | `5000` | fiftieths of a percent, 0–5000 |
-| `codeBlockShading` | boolean | `true` | |
-| `linkTarget` | string | `_blank` | |
-| `thematicBreak` | string | `border` | `border`, `text` |
-| `deferredHyperlinks` | boolean | `false` | |
-
-#### Style slots
-
-Each slot holds a **style id** (a string), an **inline style** (a mapping), or `null`. A mapping
-merges recursively into the default; a style id or `null` replaces the slot whole.
-
-| Slot | Default |
-| --- | --- |
-| `heading.1` … `heading.6` | `Heading1`…`Heading6`, bold, 16/13/12/11/11/11pt, `2F5496` |
-| `paragraph` | 6pt after, 1.15 line height |
-| `blockQuote` | `IntenseQuote`, italic, `404040`, 720 twips either side |
-| `codeBlock` | 360 twips left, no space above or below |
-| `listParagraph` | 3pt after |
-| `codeFont` | Consolas 9pt `A31515` |
-| `linkFont` | `0563C1`, underlined |
-
-Font properties are `name`, `size`, `color`, `bold`, `italic`, `strikethrough`, `underline`.
-Paragraph properties are `indentation`, `space`, `shading`, `keepNext`, `alignment`, `styleName`,
-`lineHeight`.
-
-A colour is **six hex digits with no `#`** and no colour name. A size is a number ≥ 0.001, and `0`
-is refused with the reason: it is a size in points, and 0 makes the text invisible in Word.
-
-#### Headers and footers
-
-Both take `body`, `align` (`left`/`center`/`right`), `border` (`top`/`bottom`/`both`), and the
-switches `page`, `total`, `chapter`, `filename`, `date`, `firstPage`, `even`. A `body` may contain
-`{page}`, `{total}`, `{filename}`, `{chapter}` and `{date}`.
-
-```yaml
-footer:
- align: center
- border: top
- body: "Page {page} of {total}"
- page: true
- total: true
-```
-
-#### Themes
-
-A theme is a directory: `theme.yml` — `name` required, plus `description` and `author` — and an
-optional `styles.yml`. Four ship: `default`, `minimal`, `corporate`, `report`. Resolution is a
-path, then `/themes/`, then the package's own `themes/`, and **there is no silent
-fallback** — a name that does not resolve raises `ThemeNotFound` listing the ones that do.
-
### Extended: directives
-Two directives, and both are real Word features rather than Markdown dressed up.
+Ten directives, and all of them are real Word features rather than Markdown dressed up.
**`::toc`** — a table of contents Word can build. The field goes out empty and marked dirty, with
`w:updateFields` set, because PHPWord's cached page numbers are all `1` and a reader who never
-updates a field shows them as they are.
-
-| Attribute | Type | Default |
-| --- | --- | --- |
-| `depth` | integer | `maxHeadingLevel`, which is `6`, clamped to 1–6 |
-| `numbers` | boolean | `true`; `false` is Word's `\n` |
-| `title` | string | none — a heading drawn above the field |
+updates a field shows them as they are. The attributes it takes are on
+[Word directives](/word/directives/#toc--a-table-of-contents-word-can-build).
{% example "A table of contents Word can build" %}
````md word
# Quarterly report
-::toc{depth=2, title="Contents"}
+::toc{depth=2 title="Contents"}
## Highlights
@@ -205,17 +128,30 @@ The field instruction it writes is exactly `TOC \o "1-2" \h \z \u`, and with `nu
**`:footnote[…]`** — a Word footnote. The text in the brackets is the note, inserted as text
rather than parsed as Markdown. There are no attributes.
-> **Measured, and it does not currently reach a footnote.** Building
-> `Body with a note:footnote[Source: the ledger.]` produced a `.docx` with **no
-> `word/footnotes.xml` in it at all**, and the literal text `:footnote[Source: the ledger.]` in
-> the body. This page says so rather than describing the intended behaviour, because the build on
-> this site checks the difference.
+> **A directive needs the whitespace in front of it.** `Body with a note:footnote[Source: the
+> ledger.]` is prose — a name that follows a word is not a directive — and the `.docx` carries the
+> literal text in the body. Written `Body with a note :footnote[Source: the ledger.]`, it is a real
+> Word footnote, and the `.docx` carries `word/footnotes.xml`.
+
+**`:style[…]`** — a word in a style. The text in the brackets is the word, and the name is the
+style: `:brand[Bakery]` is "Bakery" in the theme's `brand` style, and `:style[Bakery]{color=00B4D8}`
+is "Bakery" in teal with no theme style behind it. Attributes on a directive override the style's
+own properties for that word alone.
+
+**The seven that place objects in a document** — `::image`, `::caption`, `::pagebreak`,
+`::section`, `::textbox`, `::shape` and `::field` — draw a picture, a caption, a page break, a
+section, a floating box, a shape and a form field.
**Everything else** parses and is written into the document as the text the author typed.
`Status :green[go] before lunch.` parses, and reaches the document exactly as written. That is the
opposite of the presentation half's policy, and deliberate — a parser nobody asked to change
should not change the syntax of every document converted after it.
+**A directive a format cannot carry is not refused either.** A text box, a shape and a form field
+are written by `.docx` alone: the other two formats are given the author's own characters back, and
+the loss is reported. The columns of a section are written by `.docx` and `.odt`, and not by
+`.rtf` — the section break survives there, and the columns do not.
+
More, including what a `:::toc` container does: [Word directives](/word/directives/).
---
@@ -234,31 +170,9 @@ mdoffice word README.docx # writes README.md
It decides which way the data has to go **from the content, not the name**: a Word document is a
zip and Markdown is text, and the four bytes that say which is which are part of the format.
-| Command | Does |
-| --- | --- |
-| `to-docx` | Markdown to `.docx` |
-| `to-odt` | Markdown to `.odt` |
-| `to-rtf` | Markdown to `.rtf` |
-| `to-markdown` | a Word document to Markdown |
-
-| Option | Value | Default |
-| --- | --- | --- |
-| `-o, --output ` | path | beside the input; `-` for standard output |
-| `-c, --config ` | path | none |
-| `--theme ` | name or path | the frontmatter's `theme:` |
-| `-t, --template ` | `.docx` or `.dotx` | none |
-| `--require-control` | flag | off |
-| `--images ` | `embed`, `placeholder` or `alt`, `skip` or `none` | `embed` |
-| `--no-images` | flag | shorthand for `--images skip` |
-| `--image-base ` | path | the input's directory; the working directory for standard input |
-| `--svg-fallback ` | `raster`, `none` | `raster` |
-| `--table-width ` | fiftieths of a percent; 0–5000 | `5000` |
-| `--plain` | flag | off |
-| `--to ` | `docx`, `word` for `to-docx`; `odt` for `to-odt`; `rtf` for `to-rtf` | the command names the direction |
-| `-h, --help` | flag | off |
-
-`to-markdown` takes `--media `, `--line-ending `, `--setext`, `--no-fence` and
-`--no-header` instead.
+The commands are `to-docx`, `to-odt`, `to-rtf` and `to-markdown` — they are listed with the
+others on [Basic use](/command-line/basic/#commands) — and the options each of them takes are on
+[For word](/command-line/word/).
A filter, not a command that needs a filename:
@@ -301,29 +215,24 @@ $converter->convertTo(Format::Rtf, 'notes.rtf');
### The parser, if you want frontmatter
+`withFrontMatter()` is the one — the default flavour and the front matter, and nothing else:
+
```php
-use League\CommonMark\Extension\FrontMatter\FrontMatterExtension;
-use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
-use MarkdownOffice\CommonMark\Directive\GenericDirectiveExtension;
use MarkdownWord\MarkdownToWord;
use MarkdownWord\Parser\CommonMarkParser;
-$parser = new CommonMarkParser([
- GithubFlavoredMarkdownExtension::class,
- GenericDirectiveExtension::class,
- FrontMatterExtension::class,
-]);
+$parser = CommonMarkParser::withFrontMatter();
(new MarkdownToWord($markdown, parser: $parser))->save('out.docx');
```
-Both, named. `withAllExtensions()` is not the answer either — it reads frontmatter and drops
-directives, so `::toc` and `:footnote` stop being directives and become the text an author wrote.
+`withAllExtensions()` is not the answer either — it reads frontmatter and drops directives, so
+`::toc` and `:footnote` stop being directives and become the text an author wrote.
-The available flavours are `commonMarkOnly()`, `extended()`, `directives()` (the default) and
-`withAllExtensions()`. Strict mode — a `DirectiveRegistry` that refuses unknown names — is **not
-reachable** from `CommonMarkParser`, because the extension list is a list of class names and each
-is built with `new`.
+The available flavours are `commonMarkOnly()`, `extended()`, `directives()` (the default),
+`withAllExtensions()` and `withFrontMatter()`. Strict mode — a `DirectiveRegistry` that refuses
+unknown names — is **not reachable** from `CommonMarkParser`, because the extension list is a list
+of class names and each is built with `new`.
### Back the other way
diff --git a/content/word/round-trip.md b/content/word/round-trip.md
index a44fded..980ef28 100644
--- a/content/word/round-trip.md
+++ b/content/word/round-trip.md
@@ -2,7 +2,7 @@
layout: layouts/base.njk
permalink: /word/round-trip/
title: Round trips
-summary: Markdown to a document and back. What comes out the same, and the five things that do not.
+summary: Markdown to a document and back. What comes out the same, and the six things that do not.
section: Markdown Word
order: 3
---
@@ -47,18 +47,42 @@ Checked, rather than assumed:
| Written | Comes back as |
| --- | --- |
| `::toc{depth=2}` | `Press F9 to build the table of contents.` |
-| `:footnote[Source: the ledger.]` | `:footnote[Source: the ledger.]` |
+| `:footnote[Source: the ledger.]` | nothing |
+| `:brand[Bakery]` | `Bakery` |
+| `::image[Revenue by quarter]{src="chart.png" width=4 height=2}` | a Markdown image with the alt text — the picture, without the size it was placed at |
+| `::caption[Figure 1: Revenue by quarter]{}` | `*Figure 1: Revenue by quarter*` — the text, in italics |
+| `::pagebreak` | nothing |
+| `::section{columns=2}` | nothing |
+| `::textbox[A floating note]{width=6 height=2}` | nothing |
+| `::shape{type=rect width=4 height=2}` | nothing |
+| `::field{name="Customer" type=textinput}` | thirty spaces |
The first is **not** a directive and never was on the way back — the document really does contain
that sentence, inside the table-of-contents field, waiting for Word to build it. The reader reads
the field's content and finds a sentence.
-The second round trips only by accident: `:footnote[…]` does not currently produce a
-`footnotes.xml` at all, so what the reader finds is the source text that was always in the body.
-See [Word directives](/word/directives/).
+The second is a real Word footnote, and none of it comes back: the reader reads the body and not
+the footnotes, so neither the mark nor the note is in what it finds. See
+[Word directives](/word/directives/).
-**Neither is a round trip you should build on.** A pipeline that edits documents in Word and reads
-them back should treat directives as one-way.
+The third keeps the word and loses the style: `:brand[Bakery]` is written as direct formatting — a
+colour, a weight, a size — and the reader reads bold, italic and strike and no colour. The word is
+there; the style is not, and neither is the name it was written under.
+
+The seven that place objects in a document keep none of their own syntax: **a text box comes back
+without its text**, because the text of a box is in a `w:txbxContent` inside the run that holds it
+and the reader reads the paragraphs of the body and not the boxes inside them — and a shape has no
+text at all, so it comes back as nothing. **A form field comes back as the blank field Word
+draws**: a `FORMTEXT` is a field with a run of thirty spaces in it, and that is what the reader
+finds. **A page break and the columns of a section come back as nothing**: both are in the file —
+`w:br` and `w:cols` — and the reader reads neither.
+
+A picture and a caption are the two that keep what they carried — the picture and the text. The
+size a picture was placed at and the alignment it was given do not come back, and a caption comes
+back as the italic paragraph it was drawn as.
+
+**None of them is a round trip you should build on.** A pipeline that edits documents in Word and
+reads them back should treat directives as one-way.
## What else does not survive
@@ -131,6 +155,8 @@ document, an existing path is read and must start with it, and anything else is
`Reverse\Options` is the whole vocabulary. It is worth knowing because two of its defaults are
decisions rather than facts:
+
+
| Key | Default | Notes |
| --- | --- | --- |
| `headingStyles` | `['Heading', 'Title']` | case-insensitive, optional trailing level |
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..adfc253
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+
+ tests/Claims
+
+
+
diff --git a/src/Reference/Block.php b/src/Reference/Block.php
new file mode 100644
index 0000000..61c7f33
--- /dev/null
+++ b/src/Reference/Block.php
@@ -0,0 +1,27 @@
+ $names The names the block lists.
+ * @param array $values The second cell of each row, where the block is a table.
+ */
+ public function __construct(
+ public readonly string $file,
+ public readonly int $line,
+ public readonly array $names,
+ public readonly array $values = [],
+ ) {
+ }
+}
diff --git a/src/Reference/Code.php b/src/Reference/Code.php
new file mode 100644
index 0000000..17ef444
--- /dev/null
+++ b/src/Reference/Code.php
@@ -0,0 +1,285 @@
+>
+ */
+ public static function wordDirectiveAttributes(): array
+ {
+ $attributes = [];
+
+ foreach (self::handlers() as $class) {
+ $reflection = new \ReflectionClass($class);
+ $invokable = $reflection->hasMethod('__invoke');
+
+ foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($invokable && $method->getName() !== '__invoke') {
+ continue;
+ }
+
+ $directive = self::directiveOf($reflection, $method, $invokable);
+
+ if ($directive === null) {
+ continue;
+ }
+
+ $attributes[$directive] = self::attributesRead($class, $method->getName());
+ }
+ }
+
+ \ksort($attributes);
+
+ return $attributes;
+ }
+
+ /**
+ * The keys the Word half reads out of a document's frontmatter.
+ *
+ * @return list
+ */
+ public static function wordFrontmatterKeys(): array
+ {
+ return self::keysRead(Frontmatter::class);
+ }
+
+ /**
+ * The classes that draw the Word half's directives.
+ *
+ * Asked of the registry that holds them: the factory takes the ones that are always
+ * drawn, and the handlers hold the rest — the type of a handler's property is the
+ * class that draws it.
+ *
+ * @return list
+ */
+ private static function handlers(): array
+ {
+ $classes = [];
+
+ foreach ((new \ReflectionMethod(Directives::class, 'standard'))->getParameters() as $parameter) {
+ $class = self::namedTypeOf($parameter->getType());
+
+ if ($class !== null) {
+ $classes[] = $class;
+ }
+ }
+
+ foreach ((new \ReflectionClass(Handlers::class))->getProperties() as $property) {
+ $class = self::namedTypeOf($property->getType());
+
+ if ($class !== null) {
+ $classes[] = $class;
+ }
+ }
+
+ return $classes;
+ }
+
+ /**
+ * The class a type names, or null when it names no class.
+ *
+ * @return class-string|null
+ */
+ private static function namedTypeOf(?\ReflectionType $type): ?string
+ {
+ if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
+ return null;
+ }
+
+ return $type->getName();
+ }
+
+ /**
+ * The directive a method draws, as the code names it.
+ *
+ * The name is in the docblock of the method that draws it, or — for a class drawn
+ * through `__invoke` — in the docblock of the class. A docblock that names no
+ * directive draws none: the inline styles read a style's attributes, not those of
+ * a directive.
+ */
+ private static function directiveOf(
+ \ReflectionClass $class,
+ \ReflectionMethod $method,
+ bool $invokable,
+ ): ?string {
+ $docblocks = [$method->getDocComment()];
+
+ if ($invokable) {
+ $docblocks[] = $class->getDocComment();
+ }
+
+ foreach ($docblocks as $docblock) {
+ if ($docblock === false) {
+ continue;
+ }
+
+ if (\preg_match('/`::([a-z]+)/', $docblock, $match) === 1) {
+ return $match[1];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The attribute names a method reads, and the ones the methods it calls read.
+ *
+ * @return list
+ */
+ private static function attributesRead(string $class, string $method): array
+ {
+ $names = [];
+
+ foreach (self::reached($class, $method) as $reached) {
+ \preg_match_all(
+ '/->(?:getList|getString|getFloat|getInt|getBool|get|has)\(\s*\'([^\']+)\'/',
+ self::bodyOf($class, $reached),
+ $matches,
+ );
+
+ foreach ($matches[1] as $name) {
+ $names[$name] = true;
+ }
+ }
+
+ return \array_keys($names);
+ }
+
+ /**
+ * The methods of a class a method reaches, itself included.
+ *
+ * A helper's reads are the caller's too: the table of contents asks for its depth
+ * in a helper, and the depth is an attribute of the directive.
+ *
+ * @return list
+ */
+ private static function reached(string $class, string $method): array
+ {
+ $reached = [$method => true];
+ $queue = [$method];
+
+ while ($queue !== []) {
+ $current = \array_shift($queue);
+
+ \preg_match_all(
+ '/(?:\$this->|self::|static::)([a-zA-Z_]\w*)\s*\(/',
+ self::bodyOf($class, $current),
+ $matches,
+ );
+
+ foreach ($matches[1] as $called) {
+ if (isset($reached[$called]) || !\method_exists($class, $called)) {
+ continue;
+ }
+
+ $reached[$called] = true;
+ $queue[] = $called;
+ }
+ }
+
+ return \array_keys($reached);
+ }
+
+ /**
+ * The source of a method, without its comments.
+ */
+ private static function bodyOf(string $class, string $method): string
+ {
+ $reflection = new \ReflectionMethod($class, $method);
+ $lines = \file((string) $reflection->getFileName()) ?: [];
+ $body = \implode('', \array_slice(
+ $lines,
+ $reflection->getStartLine() - 1,
+ $reflection->getEndLine() - $reflection->getStartLine() + 1,
+ ));
+
+ return self::withoutComments($body);
+ }
+
+ /**
+ * The keys a class reads out of a mapping.
+ *
+ * A key is read by name, or by a loop over a list of them: the frontmatter block
+ * hands the sections it understands over as a list, and each of them is a key of
+ * the block.
+ *
+ * Read through the class itself, because a key of the block is one the block's own
+ * reader asks for; the name the parser keeps the block under is not one of them.
+ *
+ * @return list
+ */
+ private static function keysRead(string $class): array
+ {
+ $source = self::withoutComments(
+ (string) \file_get_contents((string) (new \ReflectionClass($class))->getFileName()),
+ );
+
+ $keys = [];
+
+ \preg_match_all('/\$this->(?:getArray|getString|getInt|getBool|get|has)\(\s*\'([^\']+)\'/', $source, $matches);
+
+ foreach ($matches[1] as $key) {
+ $keys[$key] = true;
+ }
+
+ \preg_match_all('/foreach \(\s*\[([^\]]*)\]/', $source, $matches);
+
+ foreach ($matches[1] as $list) {
+ \preg_match_all('/\'([^\']+)\'/', $list, $names);
+
+ foreach ($names[1] as $name) {
+ $keys[$name] = true;
+ }
+ }
+
+ return \array_keys($keys);
+ }
+
+ /**
+ * The code with its comments taken out.
+ *
+ * A comment names things in prose, and a name in prose is not a name the code uses:
+ * the frontmatter block's own docblock names a `theme_file`, which the code does
+ * not read.
+ */
+ private static function withoutComments(string $code): string
+ {
+ $kept = '';
+
+ foreach (\token_get_all($code) as $token) {
+ if (!\is_array($token)) {
+ $kept .= $token;
+
+ continue;
+ }
+
+ if ($token[0] === \T_COMMENT || $token[0] === \T_DOC_COMMENT) {
+ continue;
+ }
+
+ $kept .= $token[1];
+ }
+
+ return $kept;
+ }
+}
diff --git a/src/Reference/PackageNotFound.php b/src/Reference/PackageNotFound.php
new file mode 100644
index 0000000..8544017
--- /dev/null
+++ b/src/Reference/PackageNotFound.php
@@ -0,0 +1,23 @@
+
+ *
+ * The marker is an HTML comment, so it survives Markdown untouched and says nothing to a
+ * reader. It is a name rather than a shape, because the pages are hand-written and a
+ * table whose first column happens to hold names is not a table the code can be checked
+ * against: the frontmatter keys and the exceptions are both two-column tables, and only
+ * the page knows which is which. A marker also gives every vocabulary an address, so a
+ * vocabulary no page carries is a failure of its own rather than a silence.
+ */
+final class Pages
+{
+ /** The marker that names the vocabulary a block carries. */
+ private const MARKER = '/^$/';
+
+ /**
+ * The blocks of the pages under a directory, and the ones that carry a vocabulary.
+ *
+ * One walk for both, because a check that reads the pages twice reads two copies of
+ * the site and compares them.
+ *
+ * A vocabulary with two blocks is two homes for one list, and both of them are about
+ * to drift: the caller is told about it rather than being handed one of them.
+ *
+ * @return array{blocks: list, marked: array>}
+ */
+ public static function of(string $directory): array
+ {
+ $blocks = [];
+ $marked = [];
+
+ foreach (self::files($directory) as $file) {
+ $walk = self::walk($file, \explode("\n", (string) \file_get_contents($file)));
+
+ $blocks = [...$blocks, ...$walk['blocks']];
+
+ foreach ($walk['markers'] as $marker) {
+ $marked[$marker['name']][] = $marker['block'];
+ }
+ }
+
+ \ksort($marked);
+
+ return ['blocks' => $blocks, 'marked' => $marked];
+ }
+
+ /**
+ * The blocks of a file and the markers that name the vocabulary they carry.
+ *
+ * @param list $lines
+ *
+ * @return array{
+ * blocks: list,
+ * markers: list
+ * }
+ */
+ private static function walk(string $file, array $lines): array
+ {
+ $blocks = [];
+ $markers = [];
+ $number = 0;
+ $count = \count($lines);
+
+ while ($number < $count) {
+ $line = \trim($lines[$number]);
+
+ if (\preg_match(self::MARKER, $line, $match) === 1) {
+ $markers[] = [
+ 'name' => $match[1],
+ 'block' => self::blockAfter($file, $lines, $number),
+ ];
+ } elseif ($line !== '') {
+ $found = self::blockAt($file, $lines, $number);
+
+ if ($found !== null) {
+ $blocks[] = $found['block'];
+
+ // Past the block, so that its rows are not read as blocks of their
+ // own: a row of a table is not a second copy of the list.
+ $number = $found['end'];
+ }
+ }
+
+ $number++;
+ }
+
+ return ['blocks' => $blocks, 'markers' => $markers];
+ }
+
+ /**
+ * The block that starts at a line, if one does, and the line it ends at.
+ *
+ * A table and a fenced list are the two shapes a page carries a vocabulary in, and
+ * both of them are read here, so that a page cannot grow a third.
+ *
+ * @param list $lines
+ *
+ * @return array{block: Block, end: int}|null
+ */
+ private static function blockAt(string $file, array $lines, int $number): ?array
+ {
+ $line = \trim($lines[$number]);
+
+ if (\str_starts_with($line, '|')) {
+ $table = self::table($lines, $number);
+
+ return [
+ 'block' => new Block($file, $number + 1, $table['names'], $table['values']),
+ 'end' => $table['end'],
+ ];
+ }
+
+ if (\str_starts_with($line, '```')) {
+ $fence = self::fence($lines, $number);
+
+ return [
+ 'block' => new Block($file, $number + 1, $fence['names'], []),
+ 'end' => $fence['end'],
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * The block that follows a marker.
+ *
+ * Another marker is stepped over rather than ending the search, so that a marker
+ * written twice is two homes for one list rather than one home and a silence.
+ *
+ * @param list $lines
+ */
+ private static function blockAfter(string $file, array $lines, int $number): Block
+ {
+ for ($next = $number + 1; $next < \count($lines); $next++) {
+ $trimmed = \trim($lines[$next]);
+
+ if ($trimmed === '' || \preg_match(self::MARKER, $trimmed) === 1) {
+ continue;
+ }
+
+ $found = self::blockAt($file, $lines, $next);
+
+ if ($found === null) {
+ break;
+ }
+
+ return $found['block'];
+ }
+
+ return new Block($file, $number + 1, [], []);
+ }
+
+ /**
+ * The names of a table: the backticked words in the first cell of each of its rows.
+ *
+ * The second cell is kept as well, because a table of ceilings is a table of names
+ * and numbers, and the number is as much a claim as the name is.
+ *
+ * @param list $lines
+ *
+ * @return array{names: list, values: array, end: int}
+ */
+ private static function table(array $lines, int $number): array
+ {
+ $names = [];
+ $values = [];
+ $block = self::blockLines($lines, $number, static fn (string $line): bool => !\str_starts_with($line, '|'));
+
+ foreach ($block['lines'] as $offset => $line) {
+ $cells = self::cells($line);
+
+ // The header and the rule under it are not rows.
+ if (\count($cells) < 2 || $offset === 1) {
+ continue;
+ }
+
+ foreach (self::codeIn($cells[0]) as $name) {
+ $names[] = $name;
+ $values[$name] = $cells[1];
+ }
+ }
+
+ \sort($names);
+
+ return [
+ 'names' => \array_values(\array_unique($names)),
+ 'values' => $values,
+ 'end' => $block['end'],
+ ];
+ }
+
+ /**
+ * The names of a fenced list: the words in it.
+ *
+ * @param list $lines
+ *
+ * @return array{names: list, end: int}
+ */
+ private static function fence(array $lines, int $number): array
+ {
+ $names = [];
+ $block = self::blockLines($lines, $number + 1, static fn (string $line): bool => \str_starts_with($line, '```'));
+
+ foreach ($block['lines'] as $line) {
+ foreach (\preg_split('/\s+/', $line) ?: [] as $word) {
+ if ($word !== '') {
+ $names[] = $word;
+ }
+ }
+ }
+
+ \sort($names);
+
+ return ['names' => \array_values(\array_unique($names)), 'end' => $block['end']];
+ }
+
+ /**
+ * The lines of a block, from the line it starts at, and the line it ends at.
+ *
+ * @param list $lines
+ * @param callable(string): bool $ends Whether a line ends the block.
+ *
+ * @return array{lines: list, end: int}
+ */
+ private static function blockLines(array $lines, int $number, callable $ends): array
+ {
+ $found = [];
+
+ for ($next = $number; $next < \count($lines); $next++) {
+ $line = \trim($lines[$next]);
+
+ if ($ends($line)) {
+ return ['lines' => $found, 'end' => $next];
+ }
+
+ $found[] = $line;
+ }
+
+ return ['lines' => $found, 'end' => \count($lines)];
+ }
+
+ /**
+ * The cells of a table row.
+ *
+ * @return list
+ */
+ public static function cells(string $line): array
+ {
+ return \array_map('trim', \explode('|', \trim($line, " \t|")));
+ }
+
+ /**
+ * The words in a cell that are written in code font.
+ *
+ * A name is a name in code font, and a cell that carries prose carries no name — so
+ * the code font is what tells a name from a description.
+ *
+ * @return list
+ */
+ public static function codeIn(string $cell): array
+ {
+ \preg_match_all('/`([^`]+)`/', $cell, $matches);
+
+ return $matches[1];
+ }
+
+ /**
+ * The files of a directory, of the kinds asked for.
+ *
+ * The extension is a parameter because the pages and the templates they are built
+ * from are read by different checks, and both are the same walk over one directory.
+ *
+ * @param list $extensions
+ *
+ * @return list
+ */
+ public static function files(string $directory, array $extensions = ['md']): array
+ {
+ if (!\is_dir($directory)) {
+ return [];
+ }
+
+ $found = [];
+
+ foreach ($extensions as $extension) {
+ foreach (['/**/*.', '/*.'] as $pattern) {
+ foreach (\glob($directory . $pattern . $extension) ?: [] as $path) {
+ $found[] = $path;
+ }
+ }
+ }
+
+ \sort($found);
+
+ return \array_values(\array_unique($found));
+ }
+}
diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php
new file mode 100644
index 0000000..57d8dd6
--- /dev/null
+++ b/src/Reference/Vocabularies.php
@@ -0,0 +1,302 @@
+
+ */
+ public static function all(): array
+ {
+ $attributes = Code::wordDirectiveAttributes();
+
+ return [
+ 'presentation.frontmatter' => new Vocabulary([
+ 'PresentationRenderer::DIRECTIVES' => PresentationRenderer::DIRECTIVES,
+ ]),
+ 'presentation.layout-roles' => new Vocabulary([
+ 'LayoutRole::names()' => LayoutRole::names(),
+ ]),
+ 'presentation.free-shapes' => new Vocabulary([
+ 'FreeShapes::names()' => FreeShapes::names(),
+ ]),
+ 'presentation.theme-colours' => new Vocabulary([
+ 'Theme::COLOURS' => Theme::COLOURS,
+ ]),
+ 'presentation.theme-keys' => new Vocabulary([
+ 'ThemeMapping::KEYS' => ThemeMapping::KEYS,
+ ]),
+ 'presentation.theme-aliases' => new Vocabulary([
+ 'ThemeMapping::ALIASES' => self::themeAliases(),
+ ]),
+ 'presentation.style-keys' => new Vocabulary([
+ 'StyleMapping::KEYS' => StyleMapping::KEYS,
+ ]),
+ 'presentation.exceptions' => new Vocabulary(
+ [
+ 'MarkdownPresentation\\Exception' => self::exceptions(PresentationException::class),
+ ],
+ self::besideInput(PresentationException::class),
+ ),
+ 'presentation.themes' => new Vocabulary([
+ 'themes/' => self::themes(PresentationRenderer::class),
+ ]),
+ 'presentation.ceilings' => new Vocabulary(
+ [
+ 'PresentationRenderer::MAX_SLIDES, BlockTranslator::MAX_STEP, ImageConverter::MAX_PIXELS, '
+ . 'ChromeFile::MAX_BYTES, Furniture::MAX_SHAPES, TemplateGeometry::MAX_PART_BYTES'
+ => [
+ 'MAX_SLIDES',
+ 'MAX_STEP',
+ 'MAX_PIXELS',
+ 'MAX_BYTES',
+ 'MAX_SHAPES',
+ 'MAX_PART_BYTES',
+ ],
+ ],
+ [
+ 'MAX_SLIDES' => PresentationRenderer::MAX_SLIDES,
+ 'MAX_STEP' => BlockTranslator::MAX_STEP,
+ 'MAX_PIXELS' => ImageConverter::MAX_PIXELS,
+ 'MAX_BYTES' => ChromeFile::MAX_BYTES,
+ 'MAX_SHAPES' => Furniture::MAX_SHAPES,
+ 'MAX_PART_BYTES' => TemplateGeometry::MAX_PART_BYTES,
+ ],
+ ),
+ 'word.directives' => new Vocabulary([
+ 'MarkdownToWord' => self::wordDirectives(),
+ ]),
+ 'word.attributes.toc' => new Vocabulary([
+ 'Toc::__invoke()' => $attributes['toc'] ?? [],
+ ]),
+ 'word.attributes.image' => new Vocabulary([
+ 'Image::__invoke()' => $attributes['image'] ?? [],
+ ]),
+ 'word.attributes.textbox' => new Vocabulary([
+ 'DocxOnly::drawTextBox()' => $attributes['textbox'] ?? [],
+ ]),
+ 'word.attributes.shape' => new Vocabulary([
+ 'DocxOnly::drawShape()' => $attributes['shape'] ?? [],
+ ]),
+ 'word.attributes.field' => new Vocabulary([
+ 'DocxOnly::drawField()' => $attributes['field'] ?? [],
+ ]),
+ 'word.frontmatter' => new Vocabulary([
+ 'MarkdownWord\\Document\\Frontmatter' => Code::wordFrontmatterKeys(),
+ ]),
+ 'word.reverse-options' => new Vocabulary([
+ 'Reverse\\Options::toArray()' => \array_keys((new ReverseOptions())->toArray()),
+ ]),
+ 'word.object-features' => new Vocabulary([
+ // The four features the objects in a document cost a writer that cannot carry
+ // them: a text box, a shape, a form field and the columns of a section. They
+ // are named by the format itself, so a fifth one is a constant the class
+ // grows and this list has to grow with it.
+ 'Format::TEXT_BOX, Format::SHAPE, Format::FORM_FIELD, Format::COLUMNS' => [
+ Format::TEXT_BOX,
+ Format::SHAPE,
+ Format::FORM_FIELD,
+ Format::COLUMNS,
+ ],
+ ]),
+ 'word.exceptions' => new Vocabulary(
+ [
+ 'MarkdownWord\\Exception' => self::exceptions(WordException::class),
+ ],
+ self::besideInput(WordException::class),
+ ),
+ 'word.options' => new Vocabulary([
+ 'Options::toArray()' => \array_keys((new Options())->toArray()),
+ ]),
+ 'word.themes' => new Vocabulary([
+ 'themes/' => self::themes(MarkdownToWord::class),
+ ]),
+ 'style-slots' => new Vocabulary([
+ 'Styles::defaults()' => \array_keys(Styles::defaults()),
+ 'StyleSlots::names()' => StyleSlots::names(),
+ ]),
+ 'text-style-attributes' => new Vocabulary([
+ 'MarkdownPresentation\\Theme\\TextStyle::ATTRIBUTES' => TextStyle::ATTRIBUTES,
+ 'MarkdownWord\\Configuration\\TextStyles::ATTRIBUTES' => \array_keys(TextStyles::ATTRIBUTES),
+ ]),
+ ];
+ }
+
+ /**
+ * The spellings a theme file may use instead of DrawingML's own.
+ *
+ * Asked of the mapper, which owns them, rather than read out of its internals: a
+ * private constant read by reflection breaks the day it is renamed, and a copy
+ * written down here is the thing that drifts.
+ *
+ * @return list
+ */
+ public static function themeAliases(): array
+ {
+ return \array_keys(ThemeMapping::aliases());
+ }
+
+ /**
+ * The concrete exceptions a package throws.
+ *
+ * The classes in the package's own `Exception` directory, minus the base every one of
+ * them extends. The directory is found through the base class rather than written
+ * down, so the list is the package's own and not this class's.
+ *
+ * @param class-string $base
+ *
+ * @return list
+ */
+ public static function exceptions(string $base): array
+ {
+ $directory = \dirname((string) (new \ReflectionClass($base))->getFileName());
+ $namespace = \substr($base, 0, (int) \strrpos($base, '\\'));
+ $names = [];
+
+ foreach (\glob($directory . '/*.php') ?: [] as $path) {
+ $short = \basename($path, '.php');
+
+ if ($short === 'Exception') {
+ continue;
+ }
+
+ $class = $namespace . '\\' . $short;
+
+ // The directory is the package's, but a file in it that is not a class of the
+ // package's own would be counted as one, so each is asked of the autoloader.
+ if (!\class_exists($class) || !\is_subclass_of($class, $base)) {
+ continue;
+ }
+
+ $names[] = $short;
+ }
+
+ \sort($names);
+
+ return $names;
+ }
+
+ /**
+ * The exceptions that sit beside `InvalidInput` rather than under it.
+ *
+ * The value is the phrase the pages write: a page that calls one of them an
+ * `InvalidInput` says the caller can fix it, and there are failures nobody can.
+ *
+ * @param class-string $base
+ *
+ * @return array
+ */
+ public static function besideInput(string $base): array
+ {
+ $namespace = \substr($base, 0, (int) \strrpos($base, '\\'));
+ $beside = [];
+
+ foreach (self::exceptions($base) as $name) {
+ if ($name === 'InvalidInput' || \is_subclass_of($namespace . '\\' . $name, $namespace . '\\InvalidInput')) {
+ continue;
+ }
+
+ $beside[$name] = '**not** `InvalidInput`';
+ }
+
+ return $beside;
+ }
+
+ /**
+ * The themes a package ships: the directories under its own `themes/`.
+ *
+ * A directory without a `theme.yml` is not a theme, which is the rule the library's
+ * own resolver follows when it lists the names that resolve.
+ *
+ * @param class-string $class A class of the package.
+ *
+ * @return list
+ */
+ public static function themes(string $class): array
+ {
+ $directory = self::packageRoot($class) . '/themes';
+ $names = [];
+
+ foreach (\scandir($directory) ?: [] as $entry) {
+ if (\is_dir($directory . '/' . $entry) && \is_file($directory . '/' . $entry . '/theme.yml')) {
+ $names[] = $entry;
+ }
+ }
+
+ \sort($names);
+
+ return $names;
+ }
+
+ /**
+ * The directives the Word half draws.
+ *
+ * Asked of the registry, which owns the names. The registry used to be read by
+ * tokenising `MarkdownToWord.php` and looking for the literals its two calls were
+ * given — which finds nothing, and passes, the day the registration moves to
+ * another file. A check that checks nothing is worse than no check.
+ *
+ * @return list
+ */
+ public static function wordDirectives(): array
+ {
+ return Directives::names();
+ }
+
+ /**
+ * The root of the package a class belongs to: the directory its `composer.json` is in.
+ *
+ * @param class-string $class
+ */
+ private static function packageRoot(string $class): string
+ {
+ $directory = \dirname((string) (new \ReflectionClass($class))->getFileName());
+
+ while (!\is_file($directory . '/composer.json')) {
+ $parent = \dirname($directory);
+
+ if ($parent === $directory) {
+ throw PackageNotFound::for($class);
+ }
+
+ $directory = $parent;
+ }
+
+ return $directory;
+ }
+}
diff --git a/src/Reference/Vocabulary.php b/src/Reference/Vocabulary.php
new file mode 100644
index 0000000..2e86b19
--- /dev/null
+++ b/src/Reference/Vocabulary.php
@@ -0,0 +1,78 @@
+> $lists The code's lists, keyed by where each is kept.
+ * @param array $values A value per name, where the code has one.
+ */
+ public function __construct(
+ public readonly array $lists,
+ public readonly array $values = [],
+ ) {
+ }
+
+ /**
+ * The names the page has to list.
+ *
+ * The first list is the one the others are held to, so it is the list the page is
+ * compared with.
+ *
+ * @return list
+ */
+ public function names(): array
+ {
+ foreach ($this->lists as $names) {
+ return $names;
+ }
+
+ return [];
+ }
+
+ /**
+ * The code's other lists, and the ones of them that do not agree with the first.
+ *
+ * The order the names are in is not part of the agreement: the two halves keep the
+ * same slots in a different order, and a list is a set of names rather than a
+ * sequence of them.
+ *
+ * @return array>
+ */
+ public function disagreeing(): array
+ {
+ $first = $this->sorted($this->names());
+ $disagreeing = [];
+
+ foreach ($this->lists as $where => $names) {
+ if ($this->sorted($names) !== $first) {
+ $disagreeing[$where] = $names;
+ }
+ }
+
+ return $disagreeing;
+ }
+
+ /**
+ * @param list $names
+ *
+ * @return list
+ */
+ private function sorted(array $names): array
+ {
+ \sort($names);
+
+ return $names;
+ }
+}
diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php
new file mode 100644
index 0000000..2853e2d
--- /dev/null
+++ b/tests/Claims/behaviour.php
@@ -0,0 +1,47 @@
+toContain('last thing on the slide');
+});
+
+it('says that attributes are separated by a space', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'Attributes are separated by a space');
+});
+
+it('says how the attributes of a shape are separated when one of them cannot be read', function () {
+ $message = Deck::refusal("---\nlayout: blank\n---\n\n::rectangle{fill=red,stroke=blue}\n");
+
+ expect($message)->toContain('separated by a space');
+});
+
+it('says that a theme is found beside the deck', function () {
+ says(Site::PRESENTATION_THEMES, 'beside the deck');
+ says(Site::PRESENTATION_INDEX, 'beside the deck');
+});
+
+it('finds a theme that sits beside the deck', function () {
+ $message = Theme::slidesRefusal("---\ntheme: mine\n---\n\n# One\n");
+
+ expect($message)->toBe('');
+});
diff --git a/tests/Claims/completeness.php b/tests/Claims/completeness.php
new file mode 100644
index 0000000..de6457a
--- /dev/null
+++ b/tests/Claims/completeness.php
@@ -0,0 +1,64 @@
+
+ */
+function listed(string $vocabulary): array
+{
+ $marked = Pages::of(Site::root() . '/content')['marked'][$vocabulary] ?? [];
+
+ return $marked === [] ? [] : $marked[0]->names;
+}
+
+it('lists every exception the presentation half throws', function () {
+ says('presentation/errors.md', 'Every exception Markdown Presentation throws, in one place');
+
+ expect(\count(listed('presentation.exceptions')))
+ ->toBe(\count(Vocabularies::exceptions(PresentationException::class)));
+});
+
+it('lists every exception the Word half throws', function () {
+ says('word/errors.md', 'Every exception Markdown Word throws, in one place');
+
+ expect(\count(listed('word.exceptions')))->toBe(\count(Vocabularies::exceptions(WordException::class)));
+});
+
+it('says how many exceptions sit beside InvalidInput, as the code does', function () {
+ $beside = 0;
+
+ foreach (Vocabularies::exceptions(PresentationException::class) as $name) {
+ // `InvalidInput` is the base itself rather than a failure beside it.
+ if ($name === 'InvalidInput') {
+ continue;
+ }
+
+ if (!\is_subclass_of('MarkdownPresentation\\Exception\\' . $name, InvalidInput::class)) {
+ $beside++;
+ }
+ }
+
+ $phrase = words($beside) . ' sit beside `InvalidInput`';
+
+ says('presentation/errors.md', $phrase);
+ says(Site::PRESENTATION_INDEX, $phrase);
+ says('general/errors.md', $phrase);
+});
diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php
new file mode 100644
index 0000000..2a40296
--- /dev/null
+++ b/tests/Claims/counts.php
@@ -0,0 +1,101 @@
+names();
+
+ says(Site::PRESENTATION_THEMES, words(\count($aliases)) . ' read-friendly aliases are accepted');
+});
+
+it('counts the frontmatter keys as the code does', function () {
+ $count = \count(PresentationRenderer::DIRECTIVES);
+
+ says(Site::PRESENTATION_INDEX, 'a **closed set of ' . words($count) . '**');
+ says(Site::PRESENTATION_DIRECTIVES, 'one of these ' . words($count));
+});
+
+it('counts the free shapes as the code does', function () {
+ $shapes = Vocabularies::all()['presentation.free-shapes']->names();
+
+ says(Site::PRESENTATION_DIRECTIVES, words(\count($shapes)) . ' of them and no others');
+});
+
+it('counts the themes that ship as the code does', function () {
+ $themes = Vocabularies::all()['presentation.themes']->names();
+
+ says(Site::PRESENTATION_THEMES, words(\count($themes)) . ' themes ship');
+});
+
+it('counts the Word themes that ship as the code does', function () {
+ $themes = Vocabularies::all()['word.themes']->names();
+
+ says('word/configuration.md', words(\count($themes)) . ' themes ship');
+});
+
+it('counts the Word directives as the code does', function () {
+ $directives = Vocabularies::all()['word.directives']->names();
+
+ says(Site::WORD_DIRECTIVES, 'draws **' . words(\count($directives)) . '** directives');
+ says('word/index.md', words(\count($directives)) . ' directives');
+ says(Site::GENERAL_DIRECTIVES, words(\count($directives)) . ' directives');
+});
+
+it('counts the inline style attributes as the code does', function () {
+ $attributes = Vocabularies::all()['text-style-attributes']->names();
+
+ says(Site::GENERAL_DIRECTIVES, words(\count($attributes)) . ' attributes');
+});
+
+it('counts the things that do not survive as the page lists them', function () {
+ $lost = RoundTrip::losses();
+
+ \preg_match_all('/^### (.+)$/m', Site::page(Site::WORD_ROUND_TRIP), $matches, \PREG_SET_ORDER);
+
+ $listed = \array_map(static fn (array $match): string => $match[1], $matches);
+
+ \sort($lost);
+ \sort($listed);
+
+ expect($listed)->toBe($lost);
+
+ says(Site::WORD_ROUND_TRIP, words(\count($lost)) . ' things that do not');
+});
+
+it('counts the renderer\'s methods as the code does', function () {
+ $methods = \array_filter(
+ (new \ReflectionClass(PresentationRenderer::class))->getMethods(\ReflectionMethod::IS_PUBLIC),
+ static fn (\ReflectionMethod $method): bool => $method->getName() !== '__construct',
+ );
+
+ says(Site::PRESENTATION_INDEX, words(\count($methods)) . ' methods');
+});
diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php
new file mode 100644
index 0000000..907a3ba
--- /dev/null
+++ b/tests/Claims/objects.php
@@ -0,0 +1,149 @@
+toContain('::textbox');
+ expect(Document::losses($markdown, Format::Odt))->toContain('text-box');
+});
+
+it('reports the loss of a text box, a shape and a form field in every format but docx', function () {
+ $markdown = "::textbox[A note]{width=6 height=2}\n\n::shape{type=rect width=4 height=2}\n\n::field{name=x}\n";
+
+ expect(Document::losses($markdown, Format::Docx))->toBe([]);
+ expect(Document::losses($markdown, Format::Odt))->toBe(['text-box', 'shape', 'form-field']);
+ expect(Document::losses($markdown, Format::Rtf))->toBe(['text-box', 'shape', 'form-field']);
+});
+
+it('loses the columns of a section in an rtf and keeps them in an odt', function () {
+ $markdown = "::section{columns=2}\n";
+
+ expect(Document::losses($markdown, Format::Odt))->toBe([]);
+ expect(Document::losses($markdown, Format::Rtf))->toBe(['columns']);
+});
+
+it('says that a text box comes back without its text', function () {
+ says(Site::WORD_ROUND_TRIP, 'a text box comes back without its text');
+
+ $back = (new WordToMarkdown(Document::path("::textbox[A note]{width=6 height=2}\n")))->convert();
+
+ expect($back)->not->toContain('A note');
+});
+
+it('says that a form field comes back as the blank field Word draws', function () {
+ says(Site::WORD_ROUND_TRIP, 'a form field comes back as the blank field Word draws');
+
+ $back = (new WordToMarkdown(Document::path("::field{name=\"Customer\"}\n")))->convert();
+
+ expect($back)->toContain(\str_repeat(' ', 30));
+});
+
+it('says that a page break and the columns of a section come back as nothing', function () {
+ says(Site::WORD_ROUND_TRIP, 'a page break and the columns of a section come back as nothing');
+
+ $break = (new WordToMarkdown(Document::path("Before.\n\n::pagebreak\n\nAfter.\n")))->convert();
+ $section = (new WordToMarkdown(Document::path("Before.\n\n::section{columns=2}\n\nAfter.\n")))->convert();
+
+ expect($break)->toBe("Before.\n\nAfter.");
+ expect($section)->toBe("Before.\n\nAfter.");
+});
+
+it('draws every type the page names, and writes one it does not draw as the source text', function (string $heading, string $marker, string $directive, string $unknown) {
+ $types = Types::drawn(
+ Site::WORD_DIRECTIVES,
+ $heading,
+ $marker,
+ static fn (string $type): string => Document::body(\sprintf($directive, $type)),
+ );
+
+ expect($types)->not->toBe([]);
+
+ expect(Document::body($unknown))->toContain($marker);
+})->with([
+ 'shapes' => [
+ '`::shape` — a shape',
+ '::shape',
+ '::shape{type=%s width=4 height=2}',
+ '::shape{type=triangle width=4 height=2}',
+ ],
+ 'form fields' => [
+ '`::field` — a form field',
+ '::field',
+ '::field{name=x type=%s}',
+ '::field{name=x type=date}',
+ ],
+]);
+
+it('names every type the page lists, and not only the ones its first sentence does', function () {
+ expect(Types::listedIn([
+ 'The type is one of `arc`. `oval` and `rect` are shapes as well.',
+ ]))->toBe(['arc', 'oval', 'rect']);
+
+ expect(Types::listed(Site::WORD_DIRECTIVES, '`::shape` — a shape'))
+ ->toBe(['arc', 'curve', 'line', 'polyline', 'rect', 'oval']);
+});
+
+it('says which formats carry each of the objects and which lose them', function () {
+ says(Site::WORD_DIRECTIVES, '| Feature | `.docx` | `.odt` | `.rtf` |');
+
+ $features = [
+ Format::TEXT_BOX => 'text-box',
+ Format::SHAPE => 'shape',
+ Format::FORM_FIELD => 'form-field',
+ Format::COLUMNS => 'columns',
+ ];
+
+ foreach ($features as $feature => $name) {
+ $carried = [];
+
+ foreach ([Format::Docx, Format::Odt, Format::Rtf] as $format) {
+ $carried[] = \in_array($feature, $format->drops(), true) ? 'lost' : 'carried';
+ }
+
+ says(Site::WORD_DIRECTIVES, \sprintf('`%s` | %s', $name, \implode(' | ', $carried)));
+ }
+});
+
+it('quotes the sentences the library prints for the objects a format cannot carry', function () {
+ $markdown = "::textbox[A note]{width=6 height=2}\n\n::shape{type=rect width=4 height=2}\n\n::field{name=x}\n\n::section{columns=2}\n";
+ $features = [Format::TEXT_BOX, Format::SHAPE, Format::FORM_FIELD, Format::COLUMNS];
+ $quoted = 0;
+
+ foreach (Document::pendingLosses($markdown, Format::Rtf) as $loss) {
+ if (!\in_array($loss->feature, $features, true)) {
+ continue;
+ }
+
+ says(Site::WORD_DIRECTIVES, $loss->message);
+
+ $quoted++;
+ }
+
+ expect($quoted)->toBe(\count($features));
+});
+
+it('gives a shape back to the author in a format that cannot carry it', function () {
+ $markdown = "::shape{type=rect width=4 height=2}\n";
+
+ expect(Document::written($markdown, Format::Rtf))->toContain('::shape');
+ expect(Document::losses($markdown, Format::Rtf))->toContain('shape');
+});
diff --git a/tests/Claims/templates.php b/tests/Claims/templates.php
new file mode 100644
index 0000000..4b8e27b
--- /dev/null
+++ b/tests/Claims/templates.php
@@ -0,0 +1,106 @@
+toHaveCount(1);
+});
+
+it('says that a template holds chrome and nothing else, and refuses ordinary Markdown in one', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'A template holds chrome and nothing else');
+
+ $message = Deck::refusal("---\ntemplate: brand\n---\n# A heading\n");
+
+ expect($message)->toContain('The template "brand" holds a heading');
+ expect($message)->toContain('chrome, not content');
+});
+
+it('says that a slide may name several templates, and draws the chrome of each', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'A slide may name several templates');
+
+ $deck = "---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n\n---\ntemplate: page\n---\n::line{left=5% top=72% width=30% height=0.2%}\n\n---\nlayout: blank\ntemplate: [brand, page]\n---\n\nWords.\n";
+
+ $shapes = Deck::shapes($deck)[0];
+
+ expect($shapes)->toContain('rectangle');
+ expect($shapes)->toContain('line');
+});
+
+it('says that layout and template are independent, and draws both on one slide', function () {
+ says(Site::PRESENTATION_DIRECTIVES, '`layout:` and `template:` are independent');
+
+ $deck = "---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n\n---\nlayout: content\ntemplate: brand\n---\n\n# Words.\n";
+
+ $shapes = Deck::shapes($deck)[0];
+
+ expect($shapes)->toContain('title');
+ expect($shapes)->toContain('rectangle');
+});
+
+it('draws the chrome over what the slide placed itself', function () {
+ $deck = "---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n\n---\nlayout: blank\ntemplate: brand\n---\n\n::circle{left=1% top=1% width=5% height=5%}\n";
+
+ $shapes = Deck::shapes($deck)[0];
+
+ expect(\array_search('circle', $shapes, true))->toBeLessThan(\array_search('rectangle', $shapes, true));
+});
+
+it('reads a chrome file from beside the deck', function () {
+ $shapes = Chrome::shapes(
+ "---\nchrome: ./chrome.md\n---\n# One\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
+ "---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n",
+ );
+
+ expect($shapes[1])->toContain('rectangle');
+});
+
+it('refuses a chrome file that is not there', function () {
+ $message = Deck::refusal("---\nchrome: ./nowhere.md\n---\n# One\n", \sys_get_temp_dir());
+
+ expect($message)->toContain('There is no chrome file at');
+});
+
+it('lets the deck\'s own template override an imported one of the same name', function () {
+ $shapes = Chrome::shapes(
+ "---\nchrome: ./chrome.md\n---\n# One\n\n---\ntemplate: brand\n---\n::circle{left=1% top=1% width=5% height=5%}\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
+ "---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n",
+ );
+
+ expect($shapes[1])->toContain('circle');
+ expect($shapes[1])->not->toContain('rectangle');
+});
+
+it('says that a slide number is a shape, and writes the number of the slide', function () {
+ says(Site::PRESENTATION_DIRECTIVES, '`::slide-number` is a shape like any other');
+
+ $deck = "---\nlayout: blank\n---\n\n::slide-number{left=94% top=92% width=5% height=6%}\n\n---\nlayout: blank\n---\n\nTwo.\n";
+
+ $slide = Deck::slides($deck)[0];
+
+ expect(Deck::run($slide, '1'))->not->toBe('');
+});
+
+it('writes the number of the deck after the number with of=true', function () {
+ $deck = "---\nlayout: blank\n---\n\n::slide-number{left=94% top=92% width=5% height=6% of=true}\n\n---\nlayout: blank\n---\n\nTwo.\n";
+
+ $slide = Deck::slides($deck)[0];
+
+ expect(Deck::run($slide, '1 / 2'))->not->toBe('');
+});
diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php
new file mode 100644
index 0000000..9a12598
--- /dev/null
+++ b/tests/Claims/text-styles.php
@@ -0,0 +1,191 @@
+toContain('reserved word');
+});
+
+it('refuses a Word theme that names a style "style"', function () {
+ says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`');
+
+ $message = Theme::wordRefusal(
+ Theme::DECK,
+ "textStyles:\n style:\n color: \"FF0000\"\n",
+ );
+
+ expect($message)->toContain('cannot be defined');
+});
+
+it('refuses the raw form with no attributes in a deck', function () {
+ says(Site::GENERAL_DIRECTIVES, 'a style with neither a named style nor a property says nothing');
+
+ $message = Deck::refusal("A :style[Bakery] B\n");
+
+ expect($message)->toContain('which says nothing');
+});
+
+it('does not honour a label that holds Markdown', function () {
+ says(Site::GENERAL_DIRECTIVES, 'is not honoured as emphasis');
+
+ $message = Deck::refusal("A :style[**Bakery**]{color=00B4D8} B\n");
+
+ expect($message)->toContain('which is Markdown');
+});
+
+it('reads the named styles of a theme from the file the page names', function () {
+ says(Site::PRESENTATION_THEMES, 'text-styles.yml');
+
+ expect(ThemeLoader::TEXT_STYLES_FILE)->toBe('text-styles.yml');
+});
+
+it('refuses a text style the theme does not have, and names the styles it has', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'the message names the styles the theme does have');
+
+ $message = Theme::slidesRefusal(
+ "---\ntheme: mine\n---\n\nResilience :bakery[Bakery] is open.\n",
+ "brand:\n color: FF0000\n bold: true\nmuted:\n color: accent1\n size: 10\n",
+ );
+
+ expect($message)->toContain('The styles are: brand, muted.');
+});
+
+it('refuses an inline directive in a deck that names no style', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'fails the build rather than reaching the slide as text');
+
+ $message = Deck::refusal("Status :green[go] before lunch.\n");
+
+ expect($message)->toContain('which is not a style this theme has');
+});
+
+it('says that a theme without named styles has none', function () {
+ says(Site::PRESENTATION_THEMES, 'a theme without one has no named styles');
+
+ $message = Theme::slidesRefusal(
+ Theme::DECK,
+ );
+
+ expect($message)->toContain('The theme has no text styles');
+});
+
+it('refuses a highlight in a deck, by name', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'refused by name rather than accepted and quietly dropped');
+
+ $message = Deck::refusal("A :style[Bakery]{highlight=00B4D8} B\n");
+
+ expect($message)->toContain('which the slides half does not draw');
+});
+
+it('refuses a label that holds Markdown in a deck', function () {
+ says(Site::PRESENTATION_DIRECTIVES, 'a complete Markdown construct in one is refused');
+
+ $message = Deck::refusal("A :style[**Bakery**]{color=00B4D8} B\n");
+
+ expect($message)->toContain('which is Markdown');
+});
+
+it('draws a word in a named style, in the colour the theme gives it', function () {
+ $slide = Theme::slidesSlide(
+ Theme::DECK,
+ Theme::BRAND,
+ );
+
+ expect(Deck::run($slide, 'Bakery'))->toContain('toContain('not->toContain('not->toContain('b="1"');
+});
+
+it('reads the named styles of a Word theme from the key the page names', function () {
+ says('word/configuration.md', '`textStyles`');
+
+ expect(TextStyles::KEY)->toBe('textStyles');
+});
+
+it('draws a word in a named style in a Word document', function () {
+ says(Site::WORD_DIRECTIVES, 'the named styles come from the theme');
+
+ $body = Document::body("---\ntheme: corporate\n---\n\nResilience :brand[Bakery] is open.\n");
+
+ expect(Document::run($body, 'Bakery'))->not->toBe('');
+ expect($body)->not->toContain(':brand[Bakery]');
+});
+
+it('overrides a named style with the attributes on the directive in a Word document', function () {
+ $body = Document::body("---\ntheme: corporate\n---\n\nA :brand[Bakery]{color=FF0000} B\n");
+
+ expect(Document::run($body, 'Bakery'))->toContain('');
+});
+
+it('leaves a property off the run of a Word document when an attribute is false', function () {
+ $body = Document::body("---\ntheme: corporate\n---\n\nA :brand[Bakery]{bold=false} B\n");
+
+ expect(Document::run($body, 'Bakery'))->not->toContain('toContain(':brand[Bakery]');
+});
+
+it('writes a label that holds Markdown as the source text of a Word document', function () {
+ says(Site::WORD_DIRECTIVES, 'a complete Markdown construct in one is drawn as the source text as well');
+
+ $body = Document::body("---\ntheme: corporate\n---\n\nA :brand[**Bakery**] B\n");
+
+ expect($body)->toContain(':brand[**Bakery**]');
+});
+
+it('reads a word in a text style back as the word', function () {
+ says(Site::WORD_ROUND_TRIP, 'the style is not, and neither is the name it was written under');
+
+ $path = Document::path("---\ntheme: corporate\n---\n\nResilience :brand[Bakery] is open.\n");
+ $markdown = (new WordToMarkdown($path))->convert();
+
+ expect($markdown)->toContain('Bakery');
+ expect($markdown)->not->toContain(':brand');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..e6c23a7
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,30 @@
+ $found])->toBe([$phrase => true]);
+}
+
+/**
+ * A number as the English word a page writes it in.
+ */
+function words(int $number): string
+{
+ return Site::words($number);
+}
diff --git a/tests/Support/Chrome.php b/tests/Support/Chrome.php
new file mode 100644
index 0000000..d051640
--- /dev/null
+++ b/tests/Support/Chrome.php
@@ -0,0 +1,43 @@
+>
+ */
+ public static function shapes(string $markdown, string $chrome): array
+ {
+ return Directory::during(
+ self::beside($chrome),
+ static fn (string $root): array => Deck::shapes($markdown, $root),
+ );
+ }
+}
diff --git a/tests/Support/Deck.php b/tests/Support/Deck.php
new file mode 100644
index 0000000..be270a2
--- /dev/null
+++ b/tests/Support/Deck.php
@@ -0,0 +1,103 @@
+ self::render($markdown, $directory));
+ }
+
+ /**
+ * The slide a deck was written as, as the XML it holds.
+ *
+ * The XML rather than the file, because what a claim about a deck is about is what was
+ * written into it: a colour, a weight, a size.
+ */
+ public static function slide(string $markdown, string $directory = '.'): string
+ {
+ return Package::part(self::render($markdown, $directory), 'ppt/slides/slide1.xml');
+ }
+
+ /**
+ * The run of a slide that holds the given text, as the XML it holds.
+ */
+ public static function run(string $slide, string $text): string
+ {
+ return Run::of($slide, $text, 'a');
+ }
+
+ /**
+ * The slides of a deck, as the XML each of them holds.
+ *
+ * The slides rather than the deck, because a claim about a deck is usually a claim
+ * about one of them: which shapes it carries, and in which order.
+ *
+ * @return list
+ */
+ public static function slides(string $markdown, string $directory = '.'): array
+ {
+ $zip = new \ZipArchive();
+ $zip->open(self::render($markdown, $directory));
+
+ $slides = [];
+
+ for ($number = 1; $number <= $zip->numFiles; $number++) {
+ $name = $zip->getNameIndex($number - 1);
+
+ if (\preg_match('#^ppt/slides/slide(\d+)\.xml$#', $name, $match) === 1) {
+ $slides[(int) $match[1]] = (string) $zip->getFromName($name);
+ }
+ }
+
+ \ksort($slides);
+
+ return \array_values($slides);
+ }
+
+ /**
+ * The names of the shapes on every slide of a deck, in the order they are drawn.
+ *
+ * The names rather than the XML, because what a claim is about is which shapes a
+ * slide carries and in which order — the chrome of a template is drawn over what the
+ * slide placed itself, and that is an order.
+ *
+ * @return list>
+ */
+ public static function shapes(string $markdown, string $directory = '.'): array
+ {
+ $names = [];
+
+ foreach (self::slides($markdown, $directory) as $slide) {
+ \preg_match_all('#render((new DeckParser())->parse($markdown), $path);
+
+ return $path;
+ }
+}
diff --git a/tests/Support/Directory.php b/tests/Support/Directory.php
new file mode 100644
index 0000000..37b87e2
--- /dev/null
+++ b/tests/Support/Directory.php
@@ -0,0 +1,54 @@
+save($path);
+
+ return $path;
+ }
+
+ /**
+ * The body of a document, as the XML it was written as.
+ */
+ public static function body(string $markdown, ?string $directory = null): string
+ {
+ return Package::part(self::path($markdown, $directory), 'word/document.xml');
+ }
+
+ /**
+ * The run of a document that holds the given text, as the XML it holds.
+ */
+ public static function run(string $body, string $text): string
+ {
+ return Run::of($body, $text, 'w');
+ }
+
+ /**
+ * The message the library gives for a document it refuses.
+ */
+ public static function refusal(string $markdown, ?string $directory = null): string
+ {
+ return Refusal::of(static fn (): string => self::path($markdown, $directory));
+ }
+
+ /**
+ * The losses a conversion into a format reports, as the feature keys.
+ *
+ * The keys rather than the sentences, because a claim about a loss is a claim about
+ * which feature was lost: the sentence is prose, and the key is what the library
+ * and the page both name.
+ *
+ * @return list
+ */
+ public static function losses(string $markdown, Format $format, ?string $directory = null): array
+ {
+ return \array_map(
+ static fn (Loss $loss): string => $loss->feature,
+ self::pendingLosses($markdown, $format, $directory),
+ );
+ }
+
+ /**
+ * The losses a conversion into a format reports, as the library reports them.
+ *
+ * @return list
+ */
+ public static function pendingLosses(string $markdown, Format $format, ?string $directory = null): array
+ {
+ $converter = self::converter($markdown, $directory);
+
+ $converter->convertTo($format, (string) \tempnam(\sys_get_temp_dir(), 'document') . $format->extension());
+
+ return $converter->pendingLosses();
+ }
+
+ /**
+ * The text a document written in a format holds, as the file holds it.
+ *
+ * The part that holds the text rather than the whole package: an `.odt` is a zip and
+ * its text is deflated into `content.xml`, and a `.rtf` is a plain file whose text is
+ * in it. What a claim is about is whether the words are there.
+ */
+ public static function written(string $markdown, Format $format, ?string $directory = null): string
+ {
+ $path = (string) \tempnam(\sys_get_temp_dir(), 'document') . $format->extension();
+
+ self::converter($markdown, $directory)->convertTo($format, $path);
+
+ if ($format === Format::Rtf) {
+ return (string) \file_get_contents($path);
+ }
+
+ $zip = new \ZipArchive();
+ $zip->open($path);
+
+ return (string) $zip->getFromName($format === Format::Odt ? 'content.xml' : 'word/document.xml');
+ }
+
+ /**
+ * A converter for the Markdown.
+ *
+ * The directory is named only where there is one: a document that has no file of its
+ * own beside it has nothing to resolve a path against.
+ */
+ private static function converter(string $markdown, ?string $directory): MarkdownToWord
+ {
+ return $directory === null
+ ? new MarkdownToWord($markdown, parser: self::parser())
+ : new MarkdownToWord($markdown, parser: self::parser(), directory: $directory);
+ }
+
+ /**
+ * The parser the site builds its examples with.
+ *
+ * The frontmatter is read because the default parser does not read it, and a document
+ * that names a theme in its frontmatter would then be drawn in no theme at all. The
+ * directives are read because the frontmatter alone would leave them out of it.
+ */
+ private static function parser(): CommonMarkParser
+ {
+ return new CommonMarkParser([
+ GithubFlavoredMarkdownExtension::class,
+ GenericDirectiveExtension::class,
+ FrontMatterExtension::class,
+ ]);
+ }
+}
diff --git a/tests/Support/NoTypesListed.php b/tests/Support/NoTypesListed.php
new file mode 100644
index 0000000..956cb72
--- /dev/null
+++ b/tests/Support/NoTypesListed.php
@@ -0,0 +1,20 @@
+open($path) !== true) {
+ throw PackageNotReadable::at($path);
+ }
+
+ $part = (string) $zip->getFromName($name);
+ $zip->close();
+
+ return $part;
+ }
+}
diff --git a/tests/Support/PackageNotReadable.php b/tests/Support/PackageNotReadable.php
new file mode 100644
index 0000000..def4b54
--- /dev/null
+++ b/tests/Support/PackageNotReadable.php
@@ -0,0 +1,19 @@
+getMessage();
+ }
+
+ return '';
+ }
+}
diff --git a/tests/Support/RoundTrip.php b/tests/Support/RoundTrip.php
new file mode 100644
index 0000000..d0aec55
--- /dev/null
+++ b/tests/Support/RoundTrip.php
@@ -0,0 +1,74 @@
+
+ */
+ public static function losses(): array
+ {
+ $lost = [];
+
+ foreach (self::documents() as $heading => [$document, $found]) {
+ $back = (new WordToMarkdown(Document::path($document)))->convert();
+
+ expect($back)->toBe($found);
+
+ $lost[] = $heading;
+ }
+
+ return $lost;
+ }
+
+ /**
+ * The documents that show each of the losses, and the Markdown the reader makes of
+ * them.
+ *
+ * @return array
+ */
+ private static function documents(): array
+ {
+ return [
+ "A table's header row comes back bold" => [
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n",
+ "| **A** | **B** |\n| :-- | :-- |\n| 1 | 2 |",
+ ],
+ 'A fenced code block comes back without its language' => [
+ "```php\n\$x = 1;\n\$y = 2;\n```\n",
+ "```\n\$x = 1;\n\$y = 2;\n```",
+ ],
+ 'A one-line code block comes back as inline code' => [
+ "```php\n\$x = 1;\n```\n",
+ '`$x = 1;`',
+ ],
+ 'A quote configured as indentation comes back as a paragraph' => [
+ "---\nstyles:\n blockQuote: null\n---\n\n> A quote.\n",
+ 'A quote.',
+ ],
+ 'The last line has no newline' => [
+ "# Title\n",
+ '# Title',
+ ],
+ 'A delimiter row is rewritten' => [
+ "| A | B |\n| :--- | ---: |\n| 1 | 2 |\n",
+ "| **A** | **B** |\n| :-- | --: |\n| 1 | 2 |",
+ ],
+ ];
+ }
+}
diff --git a/tests/Support/Run.php b/tests/Support/Run.php
new file mode 100644
index 0000000..a70662c
--- /dev/null
+++ b/tests/Support/Run.php
@@ -0,0 +1,37 @@
+]*)?>.*?' . $letter . ':r>#s', $xml, $matches);
+
+ foreach ($matches[0] as $run) {
+ $pattern = '#<' . $letter . ':t(?: [^>]*)?>(?:)?' . $letter . ':t>#';
+
+ if (\preg_match($pattern, $run) === 1) {
+ return $run;
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/tests/Support/Site.php b/tests/Support/Site.php
new file mode 100644
index 0000000..72c3849
--- /dev/null
+++ b/tests/Support/Site.php
@@ -0,0 +1,190 @@
+
+ */
+ public static function paragraphs(string $path, string $heading): array
+ {
+ $paragraphs = [];
+ $current = [];
+
+ foreach (self::sectionLines($path, $heading) as $line) {
+ if (\trim($line) !== '') {
+ $current[] = $line;
+
+ continue;
+ }
+
+ if ($current !== []) {
+ $paragraphs[] = self::flattened($current);
+ $current = [];
+ }
+ }
+
+ if ($current !== []) {
+ $paragraphs[] = self::flattened($current);
+ }
+
+ return $paragraphs;
+ }
+
+ /**
+ * The lines of a section of a page, as the page wrote them.
+ *
+ * @return list
+ */
+ private static function sectionLines(string $path, string $heading): array
+ {
+ $body = [];
+ $inside = false;
+
+ foreach (\explode("\n", self::page($path)) as $line) {
+ if (\str_starts_with($line, '## ')) {
+ $inside = \trim($line) === '## ' . $heading;
+
+ continue;
+ }
+
+ if ($inside) {
+ $body[] = $line;
+ }
+ }
+
+ return $body;
+ }
+
+ /**
+ * A piece of a page with its line breaks gone.
+ *
+ * @param list $lines
+ */
+ private static function flattened(array $lines): string
+ {
+ return self::flat(\implode("\n", $lines));
+ }
+
+ /**
+ * A page of the site with its line breaks gone.
+ *
+ * Prose is wrapped, so a sentence a page states is broken across two lines and a test
+ * that looks for it as one string finds nothing. The words are what the page says,
+ * and where it breaks them is the typesetter's business.
+ */
+ public static function flat(string $page): string
+ {
+ return (string) \preg_replace('/\s+/', ' ', $page);
+ }
+
+ /**
+ * A number as the English word a page writes it in.
+ *
+ * The counts a page states are claims about the code, and a claim written as a digit
+ * is read as a number while a claim written as a word is read as a sentence: the
+ * word is what the tests look for, and the number behind it is what they check.
+ */
+ public static function words(int $number): string
+ {
+ $words = [
+ 1 => 'one',
+ 2 => 'two',
+ 3 => 'three',
+ 4 => 'four',
+ 5 => 'five',
+ 6 => 'six',
+ 7 => 'seven',
+ 8 => 'eight',
+ 9 => 'nine',
+ 10 => 'ten',
+ 11 => 'eleven',
+ 12 => 'twelve',
+ 13 => 'thirteen',
+ 14 => 'fourteen',
+ 15 => 'fifteen',
+ 16 => 'sixteen',
+ 17 => 'seventeen',
+ 18 => 'eighteen',
+ 19 => 'nineteen',
+ 20 => 'twenty',
+ ];
+
+ return $words[$number] ?? (string) $number;
+ }
+}
diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php
new file mode 100644
index 0000000..a77f6a0
--- /dev/null
+++ b/tests/Support/Theme.php
@@ -0,0 +1,121 @@
+ Deck::refusal($markdown, $root),
+ );
+ }
+
+ /**
+ * The slide a deck is drawn as, against a theme of the slides half.
+ */
+ public static function slidesSlide(string $markdown, string $textStyles): string
+ {
+ return Directory::during(
+ self::slides($textStyles),
+ static fn (string $root): string => Deck::slide($markdown, $root),
+ );
+ }
+
+ /**
+ * A directory holding a theme of the Word half's shape, and the directory it is in.
+ *
+ * `$styles` is the theme's `styles.yml`, written as YAML because the file is what the
+ * library reads.
+ */
+ public static function word(?string $styles = null): string
+ {
+ $root = self::directory();
+
+ if ($styles !== null) {
+ \file_put_contents($root . '/themes/mine/styles.yml', $styles);
+ }
+
+ return $root;
+ }
+
+ /**
+ * The message the library gives for a document drawn against a theme of the Word half.
+ */
+ public static function wordRefusal(string $markdown, string $styles): string
+ {
+ return Directory::during(
+ self::word($styles),
+ static fn (string $root): string => Document::refusal($markdown, $root),
+ );
+ }
+
+ /**
+ * A directory holding a theme of the slides half's shape, and the directory it is in.
+ */
+ private static function directory(): string
+ {
+ $root = Directory::make('theme');
+
+ \mkdir($root . '/themes/mine', 0o777, true);
+ \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n");
+
+ return $root;
+ }
+}
diff --git a/tests/Support/Types.php b/tests/Support/Types.php
new file mode 100644
index 0000000..d19a0b7
--- /dev/null
+++ b/tests/Support/Types.php
@@ -0,0 +1,74 @@
+
+ */
+ public static function drawn(string $page, string $heading, string $marker, callable $directive): array
+ {
+ $listed = self::listed($page, $heading);
+
+ if ($listed === []) {
+ throw NoTypesListed::on($page);
+ }
+
+ foreach ($listed as $type) {
+ expect($directive($type))->not->toContain($marker);
+ }
+
+ return $listed;
+ }
+
+ /**
+ * The types a page names, in the paragraph that names them.
+ *
+ * @return list
+ */
+ public static function listed(string $page, string $heading): array
+ {
+ return self::listedIn(Site::paragraphs($page, $heading));
+ }
+
+ /**
+ * The types the paragraphs name, in the paragraph that names them.
+ *
+ * The paragraph rather than the sentence: a list that goes on past the first full
+ * stop is the list the page names all the same, and a sentence that ends at the
+ * first period is how a type nobody draws slips in unlooked-at.
+ *
+ * @param list $paragraphs
+ *
+ * @return list
+ */
+ public static function listedIn(array $paragraphs): array
+ {
+ foreach ($paragraphs as $paragraph) {
+ if (\str_contains($paragraph, 'The type is one of')) {
+ return \array_values(\array_unique(Pages::codeIn($paragraph)));
+ }
+ }
+
+ return [];
+ }
+}
diff --git a/tools/check-command-line.php b/tools/check-command-line.php
index f034cb5..8cc1966 100644
--- a/tools/check-command-line.php
+++ b/tools/check-command-line.php
@@ -4,7 +4,8 @@
declare(strict_types=1);
/**
- * Checks that the command-line pages say what the command line does.
+ * Checks that the command-line pages say what the command line does, and that the
+ * whole site does not say again what it has been corrected on.
*
* php tools/check-command-line.php [path-to-mdoffice]
*
@@ -33,18 +34,22 @@
* the command takes is named
* - the quoted error is the error the program prints
*
- * and what it refuses to let the pages say, which is in {@see FORBIDDEN} below.
+ * and what it refuses to let the site say, which is in {@see FORBIDDEN} below. That
+ * list covers every page and not only the command-line ones: a sentence that was
+ * wrong is wrong wherever it is written, and the pages that drifted most were the
+ * ones nothing was checking.
*/
-\error_reporting(\E_ALL & ~\E_DEPRECATED & ~\E_USER_DEPRECATED);
-\ini_set('display_errors', 'stderr');
+require_once __DIR__ . '/lib/Autoload.php';
-if (\PHP_SAPI !== 'cli') {
- \fwrite(\STDERR, "The command-line pages are checked from the command line.\n");
- exit(1);
-}
+use MarkdownOffice\Documentation\Reference\Pages;
+use MarkdownOffice\Documentation\Tool\Cli;
+use MarkdownOffice\Documentation\Tool\Report;
+
+Cli::guard('The command-line pages are checked from the command line.');
+Cli::autoload();
-$root = \dirname(__DIR__);
+$root = Cli::root();
$checkout = $argv[1] ?? \dirname($root) . '/mdoffice';
$binary = $checkout . '/bin/mdoffice';
@@ -55,13 +60,17 @@
}
/**
- * The claims the pages must not make, and the page each was made on.
+ * The claims the site must not make, and the page each was made on.
*
* These are claims that were made, and were wrong, and were found. A claim that
* is corrected once tends to come back, because the code moved on and the
* sentence did not — so the phrase is here, and a page that says it again is a
* failure.
*
+ * The list is the whole site's and not the command-line pages': a wrong sentence is
+ * wrong wherever it is written, and the pages that drifted most were the ones
+ * nothing was checking.
+ *
* @var array
*/
const FORBIDDEN = [
@@ -70,6 +79,30 @@
'the reader class does not exist' => 'There is no reader, no `.pptx` back to Markdown',
'the release workflow does not exist' => 'there is no release job',
'the package is on Packagist' => 'It is on Packagist as `markdown-office/mdoffice`',
+ 'the presentation page said four themes ship' => 'Four themes ship: `default`, `corporate-dark`, `minimal`, `dense`.',
+ 'the themes page called them the four that ship' => 'The four that ship',
+ 'the Word page said four themes ship' => 'Four ship: `default`, `minimal`, `corporate`, `report`.',
+ 'the themes page counted the colour aliases wrongly' => 'four readable aliases accepted',
+ 'the themes page counted the colour aliases wrongly in capitals' => 'Four read-friendly aliases are accepted',
+ 'the themes page counted the style keys wrongly' => 'the thirteen style keys',
+ 'the themes page found a theme under the working directory' => '`/themes/`',
+ 'the presentation page listed the layout roles twice' => 'The eight layout roles:',
+ 'the frontmatter table named a number of roles' => 'one of eight roles',
+ 'the presentation page said one exception sat beside InvalidInput' => 'One sits beside those two rather than under `InvalidInput`',
+ 'the round-trip page said five things do not survive' => 'the five things that do not',
+ 'the Word index said two directives' => 'with two directives',
+ 'the presentation index said two methods' => 'has two methods',
+ 'the presentation index left a text style out of the round trip' => 'the underline, a link inside a run, and a chart',
+ 'the Word directives page said three directives' => 'draws **three** directives',
+ 'the general page said three directives' => 'exactly three directives',
+ 'the Word index said three directives' => 'with three directives',
+ 'the directives page said seven frontmatter keys' => 'one of these seven',
+ 'the presentation index said seven frontmatter keys' => 'closed set of seven',
+ 'the directives page said twelve free shapes' => 'twelve of them and no others',
+ 'the presentation example claimed to use every frontmatter key' => 'a deck that uses all seven',
+ 'the presentation index claimed to use every frontmatter key' => 'Every frontmatter key on a deck',
+ 'the Word page said the container form of a drawn name is claimed' => 'the body is **silently dropped**',
+ 'the errors page called a loss by a method it does not have' => '$loss->feature()',
];
/**
@@ -96,26 +129,40 @@ function runCommandLine(string $binary, array $arguments): string
}
/**
- * The parts and what each says it does, out of `mdoffice help`.
+ * The lines of a section of the help, from its heading onwards.
*
- * @return array
+ * @return list
*/
-function partsOf(string $help): array
+function sectionOf(string $help, string $heading): array
{
- $parts = [];
- $inParts = false;
+ $lines = [];
+ $inside = false;
foreach (\explode("\n", $help) as $line) {
- if (\preg_match('/^PARTS\s*$/', $line) === 1) {
- $inParts = true;
+ if (\preg_match('/^' . $heading . '\s*$/', $line) === 1) {
+ $inside = true;
continue;
}
- if (!$inParts) {
- continue;
+ if ($inside) {
+ $lines[] = $line;
}
+ }
+
+ return $lines;
+}
+
+/**
+ * The parts and what each says it does, out of `mdoffice help`.
+ *
+ * @return array
+ */
+function partsOf(string $help): array
+{
+ $parts = [];
+ foreach (sectionOf($help, 'PARTS') as $line) {
// A blank line ends the section, and so does anything that is not a
// part: the list is padded to a fixed width, so the name and the
// summary are separated by more than one space.
@@ -144,19 +191,8 @@ function partsOf(string $help): array
function commandsOf(string $help, string $part): array
{
$commands = [];
- $inUsage = false;
-
- foreach (\explode("\n", $help) as $line) {
- if (\preg_match('/^USAGE\s*$/', $line) === 1) {
- $inUsage = true;
-
- continue;
- }
-
- if (!$inUsage) {
- continue;
- }
+ foreach (sectionOf($help, 'USAGE') as $line) {
// The usage block ends at the first blank line.
if (\trim($line) === '') {
break;
@@ -293,6 +329,26 @@ function rowsOf(string $markdown, string $heading): array
return $rows;
}
+/**
+ * The rows of a table, without the header and the rule under it.
+ *
+ * @return list>
+ */
+function optionRows(string $markdown, string $heading): array
+{
+ $rows = [];
+
+ foreach (rowsOf($markdown, $heading) as $row) {
+ if (\count($row) < 2 || \str_starts_with($row[0], '---')) {
+ continue;
+ }
+
+ $rows[] = $row;
+ }
+
+ return $rows;
+}
+
/**
* The option spellings a cell names, both the long ones and the short ones.
*
@@ -313,17 +369,7 @@ function optionsIn(string $cell): array
return \array_values(\array_unique($found));
}
-$failures = [];
-
-/**
- * A thing that is wrong, said once.
- */
-function fail(string $message): void
-{
- global $failures;
-
- $failures[] = $message;
-}
+$report = new Report();
$parts = partsOf(runCommandLine($binary, ['help']));
@@ -335,29 +381,61 @@ function fail(string $message): void
$basic = page($root, 'basic.md');
$word = page($root, 'word.md');
$presentations = page($root, 'presentations.md');
+$theme = page($root, 'theme.md');
/**
- * The parts table, as the part's name and what the page says it does.
+ * The rows of the tables of a page, as their cells.
*
- * @return array
+ * @return list>
+ */
+function tableRows(string $markdown): array
+{
+ $rows = [];
+
+ foreach (\explode("\n", $markdown) as $line) {
+ if (!\str_starts_with(\trim($line), '|')) {
+ continue;
+ }
+
+ $rows[] = Pages::cells($line);
+ }
+
+ return $rows;
+}
+
+/**
+ * The parts table and the commands table, as the page writes them.
*/
$documentedParts = [];
+$documentedCommands = [];
+
+foreach (tableRows($basic) as $cells) {
+ if (\preg_match('/^`mdoffice (\w+) …`$/', $cells[0] ?? '', $match) === 1) {
+ $documentedParts[$match[1]] = \str_replace('`', '', $cells[1] ?? '');
-foreach (\explode("\n", $basic) as $line) {
- if (\preg_match('/^\| `mdoffice (\w+) …` \| (.*) \|$/', \trim($line), $match) === 1) {
- $documentedParts[$match[1]] = \str_replace('`', '', $match[2]);
+ continue;
}
+
+ if (\preg_match('/^`(\w+)`$/', $cells[0] ?? '', $match) !== 1) {
+ continue;
+ }
+
+ if (\preg_match('/^`([\w-]+)`$/', $cells[1] ?? '', $command) !== 1) {
+ continue;
+ }
+
+ $documentedCommands[$match[1]][] = $command[1];
}
foreach ($parts as $name => $summary) {
if (!isset($documentedParts[$name])) {
- fail(\sprintf("The parts table does not list the part \"%s\".", $name));
+ $report->fail(\sprintf("The parts table does not list the part \"%s\".", $name));
continue;
}
if ($documentedParts[$name] !== $summary) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"The parts table says the part \"%s\" does \"%s\"; the program says \"%s\".",
$name,
$documentedParts[$name],
@@ -368,20 +446,7 @@ function fail(string $message): void
foreach (\array_keys($documentedParts) as $name) {
if (!isset($parts[$name])) {
- fail(\sprintf("The parts table lists the part \"%s\", which the program does not have.", $name));
- }
-}
-
-/**
- * The commands table, as the part's name and the command's name.
- *
- * @return array>
- */
-$documentedCommands = [];
-
-foreach (\explode("\n", $basic) as $line) {
- if (\preg_match('/^\| `(\w+)` \| `([\w-]+)` \|/', \trim($line), $match) === 1) {
- $documentedCommands[$match[1]][] = $match[2];
+ $report->fail(\sprintf("The parts table lists the part \"%s\", which the program does not have.", $name));
}
}
@@ -391,7 +456,7 @@ function fail(string $message): void
foreach ($commands as $command) {
if (!\in_array($command, $documented, true)) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"The commands table does not list the command \"%s %s\", which the program has.",
$part,
$command,
@@ -401,7 +466,7 @@ function fail(string $message): void
foreach ($documented as $command) {
if (!\in_array($command, $commands, true)) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"The commands table lists the command \"%s %s\", which the program does not have.",
$part,
$command,
@@ -422,6 +487,7 @@ function fail(string $message): void
$tables = [
'word.md' => ['word', $word],
'presentations.md' => ['slides', $presentations],
+ 'theme.md' => ['theme', $theme],
];
foreach ($tables as $file => [$part, $markdown]) {
@@ -456,14 +522,12 @@ function fail(string $message): void
$taken = \array_values(\array_unique($taken));
- foreach (rowsOf($markdown, 'Options for ' . $heading) as $row) {
- if (\count($row) < 2 || \str_starts_with($row[0], '---')) {
- continue;
- }
+ $rows = optionRows($markdown, 'Options for ' . $heading);
+ foreach ($rows as $row) {
foreach (optionsIn($row[0]) as $option) {
if (!\in_array($option, $taken, true)) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"%s documents the option \"%s\" for \"%s\", which the command does not take.",
$file,
$option,
@@ -482,11 +546,7 @@ function fail(string $message): void
$named = false;
- foreach (rowsOf($markdown, 'Options for ' . $heading) as $row) {
- if (\count($row) < 2 || \str_starts_with($row[0], '---')) {
- continue;
- }
-
+ foreach ($rows as $row) {
if (\in_array($option, optionsIn($row[0]), true)) {
$named = true;
@@ -495,7 +555,7 @@ function fail(string $message): void
}
if (!$named) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"%s does not document the option \"%s\", which \"%s\" takes.",
$file,
$option,
@@ -518,9 +578,9 @@ function fail(string $message): void
$printed = runCommandLine($binary, ['notes.md']);
if ($quoted === null) {
- fail("The basic page does not quote the error for a file that is not a part.");
+ $report->fail("The basic page does not quote the error for a file that is not a part.");
} elseif (\rtrim($quoted) !== \rtrim($printed)) {
- fail(\sprintf(
+ $report->fail(\sprintf(
"The basic page quotes an error the program does not print.\n the page says: %s\n the program says: %s",
\str_replace("\n", "\n ", \rtrim($quoted)),
\str_replace("\n", "\n ", \rtrim($printed)),
@@ -528,33 +588,24 @@ function fail(string $message): void
}
/**
- * The claims the pages must not make.
+ * The claims the site must not make.
*/
-$pages = [
- 'basic.md' => $basic,
- 'word.md' => $word,
- 'presentations.md' => $presentations,
- 'index.md' => (string) \file_get_contents($root . '/content/index.md'),
- 'presentation/index.md' => (string) \file_get_contents($root . '/content/presentation/index.md'),
- 'word/round-trip.md' => (string) \file_get_contents($root . '/content/word/round-trip.md'),
- 'suite/index.md' => (string) \file_get_contents($root . '/content/suite/index.md'),
- 'llms/llms.txt.njk' => (string) \file_get_contents($root . '/content/llms/llms.txt.njk'),
-];
+$pages = [];
+
+foreach (Pages::files($root . '/content', ['md', 'njk']) as $file) {
+ $pages[\str_replace($root . '/content/', '', $file)] = (string) \file_get_contents($file);
+}
foreach (FORBIDDEN as $claim => $phrase) {
foreach ($pages as $name => $markdown) {
if (\stripos($markdown, $phrase) !== false) {
- fail(\sprintf("The page \"%s\" says again what was corrected: \"%s\".", $name, $claim));
+ $report->fail(\sprintf("The page \"%s\" says again what was corrected: \"%s\".", $name, $claim));
}
}
}
-if ($failures !== []) {
- \fwrite(\STDERR, \sprintf("%d problem(s) with the command-line pages:\n\n", \count($failures)));
-
- foreach ($failures as $failure) {
- \fwrite(\STDERR, ' ' . \str_replace("\n", "\n ", $failure) . "\n\n");
- }
+if ($report->failed()) {
+ $report->print('pages');
exit(1);
}
diff --git a/tools/check-readme-links.php b/tools/check-readme-links.php
index 05ae13e..6457d4b 100644
--- a/tools/check-readme-links.php
+++ b/tools/check-readme-links.php
@@ -21,15 +21,14 @@
* because it found nothing to check is worse than no link check at all.
*/
-\error_reporting(\E_ALL & ~\E_DEPRECATED & ~\E_USER_DEPRECATED);
-\ini_set('display_errors', 'stderr');
+require_once __DIR__ . '/lib/Autoload.php';
-if (\PHP_SAPI !== 'cli') {
- \fwrite(\STDERR, "The README links are checked from the command line.\n");
- exit(1);
-}
+use MarkdownOffice\Documentation\Tool\Cli;
+use MarkdownOffice\Documentation\Tool\UnreadableSite;
+
+Cli::guard('The README links are checked from the command line.');
-$root = \dirname(__DIR__);
+$root = Cli::root();
$paths = \array_slice($argv, 1);
if ($paths === []) {
@@ -49,13 +48,13 @@ function commandLineUrl(string $root): string
$page = $root . '/content/command-line/basic.md';
if (! \is_file($page)) {
- throw new RuntimeException(\sprintf('The page "%s" is not there.', $page));
+ throw new UnreadableSite(\sprintf('The page "%s" is not there.', $page));
}
$read = (string) \file_get_contents($page);
if (! \preg_match('/^permalink:\s*(\S+)\s*$/m', $read, $permalink)) {
- throw new RuntimeException(\sprintf('The page "%s" has no permalink.', $page));
+ throw new UnreadableSite(\sprintf('The page "%s" has no permalink.', $page));
}
$host = \getenv('MARKDOWN_OFFICE_CANONICAL');
@@ -64,14 +63,14 @@ function commandLineUrl(string $root): string
$config = $root . '/eleventy.config.js';
if (! \is_file($config)) {
- throw new RuntimeException(\sprintf('The file "%s" is not there.', $config));
+ throw new UnreadableSite(\sprintf('The file "%s" is not there.', $config));
}
$source = (string) \file_get_contents($config);
// The default the site falls back to, which is the one the site publishes under.
if (! \preg_match('/MARKDOWN_OFFICE_CANONICAL\s*\?\?\s*"([^"]+)"/', $source, $published)) {
- throw new RuntimeException(\sprintf('The file "%s" does not say what the canonical host is.', $config));
+ throw new UnreadableSite(\sprintf('The file "%s" does not say what the canonical host is.', $config));
}
$host = $published[1];
@@ -146,7 +145,7 @@ function links(string $markdown): array
try {
$target = commandLineUrl($root);
-} catch (RuntimeException $e) {
+} catch (UnreadableSite $e) {
\fwrite(\STDERR, ' fail ' . $e->getMessage() . "\n");
exit(1);
}
diff --git a/tools/check-reference.php b/tools/check-reference.php
new file mode 100644
index 0000000..8d597fa
--- /dev/null
+++ b/tools/check-reference.php
@@ -0,0 +1,261 @@
+#!/usr/bin/env php
+
+ *
+ * A marker rather than a shape, because the pages are hand-written and a table whose
+ * first column holds names is not something the code can be checked against: the
+ * frontmatter keys and the exceptions are both two-column tables, and only the page
+ * knows which is which. A marker also gives every vocabulary an address, so a
+ * vocabulary no page carries is a failure of its own rather than a silence.
+ *
+ * A second copy of a list is a failure as well, and the reason the Word options table
+ * was on two pages: two copies drift apart, and the copy nobody reads is the one that is
+ * wrong. One list, one home.
+ *
+ * The lists are read out of the code rather than written down here, because a list
+ * written down here would be a second copy of the thing this checks. The tables
+ * themselves are not generated, because their other columns are prose that no code
+ * holds: what a key does, what an exception means. Prose is checked by the claims in
+ * `tests/`.
+ *
+ * What it checks:
+ *
+ * - every vocabulary has exactly one page that carries it
+ * - every marker names a vocabulary that exists
+ * - the page lists every name the code has, and no name the code does not have
+ * - no other page carries a second copy of the same list
+ * - where the code keeps the same list in two places, the two agree
+ * - the numbers in the table of ceilings are the numbers in the code
+ */
+
+\error_reporting(\E_ALL & ~\E_DEPRECATED & ~\E_USER_DEPRECATED);
+\ini_set('display_errors', 'stderr');
+
+if (\PHP_SAPI !== 'cli') {
+ \fwrite(\STDERR, "The reference tables are checked from the command line.\n");
+ exit(1);
+}
+
+require_once __DIR__ . '/lib/Autoload.php';
+
+use MarkdownOffice\Documentation\Reference\Block;
+use MarkdownOffice\Documentation\Reference\Pages;
+use MarkdownOffice\Documentation\Reference\Vocabularies;
+use MarkdownOffice\Documentation\Tool\Cli;
+use MarkdownOffice\Documentation\Tool\Report;
+
+Cli::guard('The reference tables are checked from the command line.');
+Cli::autoload();
+
+$root = Cli::root();
+$content = $root . '/content';
+
+if (!\is_dir($content)) {
+ \fwrite(\STDERR, \sprintf("There is no content directory at %s.\n", $content));
+ exit(1);
+}
+
+$report = new Report();
+
+/**
+ * The page a block is on, as the site's own path.
+ */
+function where(Block $block): string
+{
+ return \str_replace(\dirname(__DIR__) . '/', '', $block->file) . ':' . $block->line;
+}
+
+$vocabularies = Vocabularies::all();
+$pages = Pages::of($content);
+$marked = $pages['marked'];
+$blocks = $pages['blocks'];
+
+foreach ($marked as $name => $carried) {
+ if (!isset($vocabularies[$name])) {
+ $report->fail(\sprintf(
+ "%s marks a vocabulary \"%s\", which no code holds.",
+ where($carried[0]),
+ $name,
+ ));
+ }
+}
+
+$checked = 0;
+$counted = 0;
+
+foreach ($vocabularies as $name => $vocabulary) {
+ $carried = $marked[$name] ?? [];
+
+ if ($carried === []) {
+ $report->fail(\sprintf(
+ "No page carries the vocabulary \"%s\". The code has %d name(s) for it and the site lists none.",
+ $name,
+ \count($vocabulary->names()),
+ ));
+
+ continue;
+ }
+
+ if (\count($carried) > 1) {
+ foreach (\array_slice($carried, 1) as $block) {
+ $report->fail(\sprintf(
+ "The vocabulary \"%s\" is carried by %s as well as by %s. One list, one home.",
+ $name,
+ where($block),
+ where($carried[0]),
+ ));
+ }
+
+ continue;
+ }
+
+ $block = $carried[0];
+ $expected = $vocabulary->names();
+ $listed = $block->names;
+
+ if ($listed === []) {
+ $report->fail(\sprintf(
+ "%s marks the vocabulary \"%s\" and there is no table or list under it.",
+ where($block),
+ $name,
+ ));
+
+ continue;
+ }
+
+ foreach ($expected as $one) {
+ if (!\in_array($one, $listed, true)) {
+ $report->fail(\sprintf(
+ "%s does not list \"%s\", which the code has.",
+ where($block),
+ $one,
+ ));
+ }
+ }
+
+ foreach ($listed as $one) {
+ if (!\in_array($one, $expected, true)) {
+ $report->fail(\sprintf(
+ "%s lists \"%s\", which the code does not have.",
+ where($block),
+ $one,
+ ));
+ }
+ }
+
+ foreach ($vocabulary->disagreeing() as $source => $other) {
+ $report->fail(\sprintf(
+ "The code keeps two lists of \"%s\" and they disagree: %s has %d name(s), the first list has %d.",
+ $name,
+ $source,
+ \count($other),
+ \count($expected),
+ ));
+ }
+
+ foreach ($blocks as $other) {
+ if ($other->file === $block->file && $other->line === $block->line) {
+ continue;
+ }
+
+ // A copy of the list, or of part of it: a table that lists nothing but names
+ // of the list is a copy of it, and a copy is a second home for a list that is
+ // meant to have one.
+ if ($other->names === [] || \array_diff($other->names, $expected) !== []) {
+ continue;
+ }
+
+ $report->fail(\sprintf(
+ "%s carries a second copy of \"%s\", which %s is the home of. One list, one home.",
+ where($other),
+ $name,
+ where($block),
+ ));
+ }
+
+ foreach ($vocabulary->values as $one => $value) {
+ $cell = $block->values[$one] ?? null;
+
+ if ($cell === null) {
+ $report->fail(\sprintf("%s does not give \"%s\" a value, which the code has.", where($block), $one));
+
+ continue;
+ }
+
+ // A number is written the way the code writes it and the way a reader writes
+ // it: `16777216` and `16,777,216` are the same number.
+ $forms = \is_int($value) || \is_float($value)
+ ? [(string) $value, \number_format((float) $value, 0, '.', ',')]
+ : [(string) $value];
+
+ $found = false;
+
+ foreach ($forms as $form) {
+ if (\str_contains($cell, $form)) {
+ $found = true;
+
+ break;
+ }
+ }
+
+ if (!$found) {
+ $report->fail(\sprintf(
+ "%s says \"%s\" is \"%s\"; the code says %s.",
+ where($block),
+ $one,
+ $cell,
+ $value,
+ ));
+ }
+ }
+
+ $checked++;
+ $counted += \count($expected);
+}
+
+if ($checked === 0) {
+ $report->fail('No vocabulary was checked, so there is nothing to say about the reference tables.');
+}
+
+if ($report->failed()) {
+ $report->print('reference tables');
+
+ exit(1);
+}
+
+\printf(
+ "%d vocabulary(ies) and %d name(s) checked against the pages.\n",
+ $checked,
+ $counted,
+);
+
+foreach ($vocabularies as $name => $vocabulary) {
+ \printf(
+ " %-28s %2d name(s) %s\n",
+ $name,
+ \count($vocabulary->names()),
+ \str_replace(\dirname(__DIR__) . '/', '', ($marked[$name][0] ?? null)?->file ?? ''),
+ );
+}
diff --git a/tools/check-site-links.php b/tools/check-site-links.php
index 4ac9cab..f476319 100644
--- a/tools/check-site-links.php
+++ b/tools/check-site-links.php
@@ -21,15 +21,13 @@
* from, and the built site has to agree with it.
*/
-\error_reporting(\E_ALL & ~\E_DEPRECATED & ~\E_USER_DEPRECATED);
-\ini_set('display_errors', 'stderr');
+require_once __DIR__ . '/lib/Autoload.php';
-if (\PHP_SAPI !== 'cli') {
- \fwrite(\STDERR, "The site's links are checked from the command line.\n");
- exit(1);
-}
+use MarkdownOffice\Documentation\Tool\Cli;
-$root = \dirname(__DIR__);
+Cli::guard("The site's links are checked from the command line.");
+
+$root = Cli::root();
$site = $argv[1] ?? $root . '/_site';
/**
@@ -87,6 +85,14 @@ function addressesOf(string $file): array
return $found;
}
+/**
+ * The file that serves a directory.
+ *
+ * A directory is served by a file of this name and everything else by itself, so the
+ * address of a page depends on it.
+ */
+const DIRECTORY_INDEX = 'index.html';
+
/**
* The address a page is served at, from where it sits in the built site.
*
@@ -95,8 +101,8 @@ function addressesOf(string $file): array
*/
function addressOf(string $relative, string $mount): string
{
- $path = \str_ends_with($relative, 'index.html')
- ? \substr($relative, 0, -\strlen('index.html'))
+ $path = \str_ends_with($relative, DIRECTORY_INDEX)
+ ? \substr($relative, 0, -\strlen(DIRECTORY_INDEX))
: $relative;
return $mount . $path;
@@ -150,7 +156,7 @@ function resolve(string $address, string $host, string $mount, string $directory
function fileFor(string $site, string $relative): string
{
if ($relative === '' || \str_ends_with($relative, '/')) {
- $relative .= 'index.html';
+ $relative .= DIRECTORY_INDEX;
}
return $site . '/' . $relative;
diff --git a/tools/lib/Cli.php b/tools/lib/Cli.php
new file mode 100644
index 0000000..63b15b4
--- /dev/null
+++ b/tools/lib/Cli.php
@@ -0,0 +1,60 @@
+
+ */
+ private array $failures = [];
+
+ /**
+ * A thing that is wrong.
+ */
+ public function fail(string $message): void
+ {
+ $this->failures[] = $message;
+ }
+
+ /**
+ * Whether anything is wrong.
+ */
+ public function failed(): bool
+ {
+ return $this->failures !== [];
+ }
+
+ /**
+ * What was found, on standard error, one problem at a time.
+ *
+ * @param string $subject What was checked, for the line the problems are counted under.
+ */
+ public function print(string $subject): void
+ {
+ \fwrite(\STDERR, \sprintf("%d problem(s) with the %s:\n\n", \count($this->failures), $subject));
+
+ foreach ($this->failures as $failure) {
+ \fwrite(\STDERR, ' ' . \str_replace("\n", "\n ", $failure) . "\n\n");
+ }
+ }
+}
diff --git a/tools/lib/UnreadableSite.php b/tools/lib/UnreadableSite.php
new file mode 100644
index 0000000..f983c5d
--- /dev/null
+++ b/tools/lib/UnreadableSite.php
@@ -0,0 +1,17 @@
+addSection();
diff --git a/tools/render-examples.php b/tools/render-examples.php
index cab5e94..9110165 100644
--- a/tools/render-examples.php
+++ b/tools/render-examples.php
@@ -36,31 +36,17 @@
* source, so an unchanged block is not rebuilt and a changed one always is.
*/
-\error_reporting(\E_ALL & ~\E_DEPRECATED & ~\E_USER_DEPRECATED);
-\ini_set('display_errors', 'stderr');
-
-if (\PHP_SAPI !== 'cli') {
- \fwrite(\STDERR, "The examples are built from the command line.\n");
- exit(1);
-}
-
-$root = \dirname(__DIR__);
-
-foreach ([
- $root . '/vendor/autoload.php',
- __DIR__ . '/lib/Autoload.php',
-] as $required) {
- if (! \is_file($required)) {
- \fwrite(\STDERR, \sprintf('Missing "%s". Run `composer install` in documentation/.' . "\n", $required));
- exit(1);
- }
-
- require_once $required;
-}
+require_once __DIR__ . '/lib/Autoload.php';
+use MarkdownOffice\Documentation\Tool\Cli;
use MarkdownOffice\Documentation\Tool\Preview;
use MarkdownOffice\Documentation\Tool\Runner;
+Cli::guard('The examples are built from the command line.');
+Cli::autoload();
+
+$root = Cli::root();
+
$contentDirectory = $root . '/content';
$artifacts = $root . '/_examples';
$manifest = $root . '/_data/examples.json';