From c3a81deaf98fd40ada32dd3485ae8f13202a011c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 16:23:28 +0100 Subject: [PATCH 01/36] The reference tables are checked against the code The tables that list a vocabulary are written by hand, and they had drifted: the site said four themes shipped when five did, its error page said it listed every exception the package throws and left five of them out, and the Word options table was on two pages and the two copies already differed. Every vocabulary is now read out of the library that owns it and compared with the page that carries it, in both directions. A page says which of its blocks is the list of what by marking it, and a second copy of a list is a failure as well: two copies drift apart and the one nobody reads is the one that is wrong. The drifted claims are corrected, and the tables that were in two places are in one now. --- .gitignore | 1 + composer.json | 2 +- content/general/errors.md | 5 +- content/presentation/directives.md | 46 ++++- content/presentation/errors.md | 17 +- content/presentation/index.md | 73 ++------ content/presentation/themes.md | 44 +++-- content/word/configuration.md | 45 ++++- content/word/directives.md | 7 + content/word/errors.md | 2 + content/word/index.md | 82 +-------- src/Reference/Block.php | 27 +++ src/Reference/Pages.php | 257 +++++++++++++++++++++++++++ src/Reference/Vocabularies.php | 272 +++++++++++++++++++++++++++++ src/Reference/Vocabulary.php | 78 +++++++++ tools/check-reference.php | 257 +++++++++++++++++++++++++++ 16 files changed, 1054 insertions(+), 161 deletions(-) create mode 100644 src/Reference/Block.php create mode 100644 src/Reference/Pages.php create mode 100644 src/Reference/Vocabularies.php create mode 100644 src/Reference/Vocabulary.php create mode 100644 tools/check-reference.php 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/composer.json b/composer.json index 47fe781..c45b603 100644 --- a/composer.json +++ b/composer.json @@ -76,10 +76,10 @@ "examples": "@php tools/render-examples.php", "build": "npm run build", "serve": "npm run serve", - "test": "@php tools/check-command-line.php ../mdoffice", "check": [ "@php tools/render-examples.php --strict", "@php tools/check-command-line.php ../mdoffice", + "@php tools/check-reference.php", "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice" ], "check:site": "@php tools/check-site-links.php", diff --git a/content/general/errors.md b/content/general/errors.md index 0166c0e..9f8bfd2 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` diff --git a/content/presentation/directives.md b/content/presentation/directives.md index b7ace34..d013145 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -113,8 +113,41 @@ 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. + +## 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 twelve of them and no others — a name outside them +is refused with a message that lists all twelve. + + + +| 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 | + +**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 @@ -176,19 +209,20 @@ splits here the way it does there. Every key of every slide's frontmatter must be one of these seven: + + | 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** | -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. diff --git a/content/presentation/errors.md b/content/presentation/errors.md index 6d02e50..b239f84 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -12,22 +12,33 @@ 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 | | `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 | | `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 | | `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. diff --git a/content/presentation/index.md b/content/presentation/index.md index 6216d38..6970b69 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -59,20 +59,8 @@ 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`. +seven**, and anything outside it fails the build with a message naming what would have worked. The +seven are listed with the [directives](/presentation/directives/#frontmatter-directives). {% example "Every frontmatter key on a deck" %} ````md presentation @@ -92,47 +80,15 @@ 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. 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. ### Extended: directives @@ -301,6 +257,8 @@ PowerPoint open the deck as `[Repaired]`. ### Ceilings + + | | | | --- | --- | | `MAX_SLIDES` | 1000 | @@ -331,8 +289,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..500d28d 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 --- @@ -20,21 +20,28 @@ themes/corporate-dark/ ## `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 +49,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 | @@ -69,6 +83,8 @@ subtle ink `lt2` — which is why the same theme can be dark without being a sec Eight, and only these: + + ```text title content two-column section blank image-text image-title image-slide ``` @@ -78,7 +94,11 @@ 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 +## The ones that ship + +Five themes ship, and a deck names one with `theme:`: + + | Theme | Margin | Block | Body | Headings | Leading | Weight | Ink | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -86,14 +106,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 +128,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..56b87df 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -55,6 +55,8 @@ can carry `title:` or `author:` for a pipeline downstream that cares and be none ## Every option + + | Key | Type | Default | Values | | --- | --- | --- | --- | | `softBreak` | string | `space` | `space`, `lineBreak`, `paragraph` | @@ -82,15 +84,29 @@ Booleans may be written as `true`/`false`/`yes`/`no`/`on`/`off`, in any case. 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 +129,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 +147,23 @@ 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. + +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, 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. diff --git a/content/word/directives.md b/content/word/directives.md index 3a75b31..4ba9f5c 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -10,6 +10,13 @@ order: 1 Markdown Word draws **two** directives. Both 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 | + ## `::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 diff --git a/content/word/errors.md b/content/word/errors.md index 55d29a2..c12b73b 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 | diff --git a/content/word/index.md b/content/word/index.md index f2b166b..b9d6e67 100644 --- a/content/word/index.md +++ b/content/word/index.md @@ -88,87 +88,15 @@ The `theme:` key names a look that ships with the library. Everything under `opt ```` {% endexample %} -#### Every frontmatter key - -| 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 | +#### The keys, the options and the slots + +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, so a key +that is added to the library is a key the page has. 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. 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/Pages.php b/src/Reference/Pages.php new file mode 100644 index 0000000..12f3af9 --- /dev/null +++ b/src/Reference/Pages.php @@ -0,0 +1,257 @@ + + * + * 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 = '/^$/'; + + /** + * Every block under a directory: a table or a fenced list, with the names in it. + * + * @return list + */ + public static function blocks(string $directory): array + { + $blocks = []; + + foreach (self::files($directory) as $file) { + $lines = \explode("\n", (string) \file_get_contents($file)); + + for ($number = 0; $number < \count($lines); $number++) { + $line = \trim($lines[$number]); + + if ($line === '' || \preg_match(self::MARKER, $line) === 1) { + continue; + } + + if (\str_starts_with($line, '|')) { + $table = self::table($lines, $number); + $blocks[] = new Block($file, $number + 1, $table['names'], $table['values']); + + continue; + } + + if (\str_starts_with($line, '```')) { + $blocks[] = new Block($file, $number + 1, self::fence($lines, $number)); + } + } + } + + return $blocks; + } + + /** + * The blocks that carry a vocabulary, keyed by the vocabulary they carry. + * + * 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> + */ + public static function marked(string $directory): array + { + $marked = []; + + foreach (self::files($directory) as $file) { + $lines = \explode("\n", (string) \file_get_contents($file)); + + for ($number = 0; $number < \count($lines); $number++) { + if (\preg_match(self::MARKER, \trim($lines[$number]), $match) !== 1) { + continue; + } + + $block = self::blockAfter($lines, $number, $file); + + $marked[$match[1]][] = $block ?? new Block($file, $number + 1, [], []); + } + } + + \ksort($marked); + + return $marked; + } + + /** + * The block that follows a marker, and the names in it. + * + * Two shapes are read: a table, whose names are the backticked words in the first + * cell of each row, and a fenced list, whose names are the words in it. A page + * carries whichever of the two fits what it has to say. + * + * 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(array $lines, int $number, string $file): ?Block + { + for ($next = $number + 1; $next < \count($lines); $next++) { + $trimmed = \trim($lines[$next]); + + if ($trimmed === '') { + continue; + } + + if (\preg_match(self::MARKER, $trimmed) === 1) { + continue; + } + + if (\str_starts_with($trimmed, '|')) { + $table = self::table($lines, $next); + + return new Block($file, $next + 1, $table['names'], $table['values']); + } + + if (\str_starts_with($trimmed, '```')) { + return new Block($file, $next + 1, self::fence($lines, $next)); + } + + return null; + } + + return null; + } + + /** + * 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} + */ + private static function table(array $lines, int $number): array + { + $names = []; + $values = []; + + for ($next = $number; $next < \count($lines); $next++) { + $line = \trim($lines[$next]); + + if (!\str_starts_with($line, '|')) { + break; + } + + $cells = self::cells($line); + + // The header and the rule under it are not rows. + if (\count($cells) < 2) { + continue; + } + + if ($next === $number + 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]; + } + + /** + * The names of a fenced list: the words in it. + * + * @param list $lines + * + * @return list + */ + private static function fence(array $lines, int $number): array + { + $names = []; + + for ($next = $number + 1; $next < \count($lines); $next++) { + $line = \trim($lines[$next]); + + if (\str_starts_with($line, '```')) { + break; + } + + foreach (\preg_split('/\s+/', $line) ?: [] as $word) { + if ($word !== '') { + $names[] = $word; + } + } + } + + \sort($names); + + return \array_values(\array_unique($names)); + } + + /** + * The cells of a table row. + * + * @return list + */ + private 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 + */ + private static function codeIn(string $cell): array + { + \preg_match_all('/`([^`]+)`/', $cell, $matches); + + return $matches[1]; + } + + /** + * The Markdown files under a directory. + * + * @return list + */ + private static function files(string $directory): array + { + if (!\is_dir($directory)) { + return []; + } + + $found = []; + + foreach (\glob($directory . '/**/*.md') ?: [] as $path) { + $found[] = $path; + } + + foreach (\glob($directory . '/*.md') ?: [] 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..b79a781 --- /dev/null +++ b/src/Reference/Vocabularies.php @@ -0,0 +1,272 @@ + + */ + public static function all(): array + { + 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), + ]), + 'presentation.themes' => new Vocabulary([ + 'themes/' => self::themes(PresentationRenderer::class), + ]), + 'presentation.ceilings' => new Vocabulary( + [ + 'PresentationRenderer::MAX_SLIDES, BlockTranslator::MAX_STEP, ImageConverter::MAX_PIXELS' + => ['MAX_SLIDES', 'MAX_STEP', 'MAX_PIXELS'], + ], + [ + 'MAX_SLIDES' => PresentationRenderer::MAX_SLIDES, + 'MAX_STEP' => BlockTranslator::MAX_STEP, + 'MAX_PIXELS' => ImageConverter::MAX_PIXELS, + ], + ), + 'word.directives' => new Vocabulary([ + 'MarkdownToWord' => self::wordDirectives(), + ]), + 'word.exceptions' => new Vocabulary([ + 'MarkdownWord\\Exception' => self::exceptions(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(), + ]), + ]; + } + + /** + * The spellings a theme file may use instead of DrawingML's own. + * + * The list is a private constant of the mapper, and there is no accessor for it: a + * theme file is read into a `Theme`, and the names the file may use are not part of + * the theme. The constant is read the way the mapper reads it — as the code's own + * answer — rather than written down here, because a copy of it here would be the + * thing that drifts. + * + * @return list + */ + public static function themeAliases(): array + { + $constant = (new \ReflectionClass(ThemeMapping::class))->getReflectionConstant('ALIASES'); + + if ($constant === false) { + return []; + } + + return \array_keys((array) $constant->getValue()); + } + + /** + * 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 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. + * + * `MarkdownToWord` wires them into a `Directives` registry with two calls whose first + * argument is the name, and the registry offers no accessor for what it holds — so the + * names are read out of that call and out of nowhere else. + * + * The file is tokenised rather than matched with a pattern: a name is a string + * literal, and the tokens say which literals are arguments to those calls and which + * are something else entirely. + * + * @return list + */ + public static function wordDirectives(): array + { + $file = (string) (new \ReflectionClass(MarkdownToWord::class))->getFileName(); + $tokens = \token_get_all((string) \file_get_contents($file)); + $names = []; + + for ($index = 0; $index < \count($tokens); $index++) { + $token = $tokens[$index]; + + if (!\is_array($token) || $token[0] !== \T_STRING) { + continue; + } + + if (!\in_array($token[1], ['withBlock', 'withInline'], true)) { + continue; + } + + $name = self::literalAfter($tokens, $index); + + if ($name !== null) { + $names[] = $name; + } + } + + \sort($names); + + return $names; + } + + /** + * The string literal the call at `$index` is given, or null where there is none. + * + * @param list $tokens + */ + private static function literalAfter(array $tokens, int $index): ?string + { + for ($next = $index + 1; $next < \count($tokens); $next++) { + if ($tokens[$next] === '(') { + $argument = $tokens[$next + 1] ?? null; + + if (\is_array($argument) && $argument[0] === \T_CONSTANT_ENCAPSED_STRING) { + return \trim($argument[1], "'\""); + } + + return null; + } + } + + return null; + } + + /** + * 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 new \RuntimeException(\sprintf( + 'There is no composer.json above %s, so the package it belongs to cannot be found.', + $class, + )); + } + + $directory = $parent; + } + + return $directory; + } +} diff --git a/src/Reference/Vocabulary.php b/src/Reference/Vocabulary.php new file mode 100644 index 0000000..3d58358 --- /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/tools/check-reference.php b/tools/check-reference.php new file mode 100644 index 0000000..14bfae7 --- /dev/null +++ b/tools/check-reference.php @@ -0,0 +1,257 @@ +#!/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); +} + +$root = \dirname(__DIR__); + +if (!\is_file($root . '/vendor/autoload.php')) { + \fwrite(\STDERR, "There is no vendor/autoload.php. Run `composer install` in documentation/.\n"); + exit(1); +} + +require_once $root . '/vendor/autoload.php'; + +use MarkdownOffice\Documentation\Reference\Block; +use MarkdownOffice\Documentation\Reference\Pages; +use MarkdownOffice\Documentation\Reference\Vocabulary; +use MarkdownOffice\Documentation\Reference\Vocabularies; + +$content = $root . '/content'; + +if (!\is_dir($content)) { + \fwrite(\STDERR, \sprintf("There is no content directory at %s.\n", $content)); + exit(1); +} + +$failures = []; + +/** + * A thing that is wrong, said once. + */ +function fail(string $message): void +{ + global $failures; + + $failures[] = $message; +} + +/** + * 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(); +$marked = Pages::marked($content); +$blocks = Pages::blocks($content); + +foreach ($marked as $name => $carried) { + if (!isset($vocabularies[$name])) { + 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 === []) { + 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) { + 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 === []) { + 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)) { + fail(\sprintf( + "%s does not list \"%s\", which the code has.", + where($block), + $one, + )); + } + } + + foreach ($listed as $one) { + if (!\in_array($one, $expected, true)) { + fail(\sprintf( + "%s lists \"%s\", which the code does not have.", + where($block), + $one, + )); + } + } + + foreach ($vocabulary->disagreeing() as $source => $other) { + 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; + } + + if (\array_diff($expected, $other->names) !== []) { + continue; + } + + 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) { + fail(\sprintf("%s does not give \"%s\" a value, which the code has.", where($block), $one)); + + continue; + } + + if (!\str_contains($cell, (string) $value) && !\str_contains($cell, \number_format($value, 0, '.', ','))) { + fail(\sprintf( + "%s says \"%s\" is \"%s\"; the code says %d.", + where($block), + $one, + $cell, + $value, + )); + } + } + + $checked++; + $counted += \count($expected); +} + +if ($checked === 0) { + fail('No vocabulary was checked, so there is nothing to say about the reference tables.'); +} + +if ($failures !== []) { + \fwrite(\STDERR, \sprintf("%d problem(s) with the reference tables:\n\n", \count($failures))); + + foreach ($failures as $failure) { + \fwrite(\STDERR, ' ' . \str_replace("\n", "\n ", $failure) . "\n\n"); + } + + 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 ?? ''), + ); +} From 0a73eddeaddf7c1cb04893421ee4d5008d7dc15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 16:23:32 +0100 Subject: [PATCH 02/36] The claims the pages make are checked A table can be checked against the code and a sentence cannot: the counts, the claims of completeness and the rules a page states are prose, and a prose claim is correct only until the code moves. The suite in tests/ asks the code how many there are and the page how many it says, checks a page that claims to list every exception against the classes the package throws, and checks the rules against the library that has to behave that way. The phrases that were wrong and have been corrected go into the FORBIDDEN map, which now covers the whole site and not only the command-line pages: a sentence that was wrong is wrong wherever it is written, and the pages that drifted most were the ones nothing was checking. --- README.md | 52 ++++++++++++++- composer.json | 4 +- phpunit.xml.dist | 19 ++++++ tests/Claims/behaviour.php | 63 ++++++++++++++++++ tests/Claims/completeness.php | 62 ++++++++++++++++++ tests/Claims/counts.php | 61 +++++++++++++++++ tests/Pest.php | 30 +++++++++ tests/Support/Deck.php | 34 ++++++++++ tests/Support/Site.php | 120 ++++++++++++++++++++++++++++++++++ tools/check-command-line.php | 65 +++++++++++++----- 10 files changed, 493 insertions(+), 17 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Claims/behaviour.php create mode 100644 tests/Claims/completeness.php create mode 100644 tests/Claims/counts.php create mode 100644 tests/Pest.php create mode 100644 tests/Support/Deck.php create mode 100644 tests/Support/Site.php diff --git a/README.md b/README.md index 7d34b33..723f9ec 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,49 @@ 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 Word options and the Word +style slots — 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. + +Three rules make the lists stay lists: + +- **One list, one home.** A second copy of a table is a failure, because 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. +- **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. + +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 @@ -178,7 +226,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 c45b603..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": "pest", "check": [ "@php tools/render-examples.php --strict", "@php tools/check-command-line.php ../mdoffice", "@php tools/check-reference.php", - "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice" + "@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/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/tests/Claims/behaviour.php b/tests/Claims/behaviour.php new file mode 100644 index 0000000..ffcd14f --- /dev/null +++ b/tests/Claims/behaviour.php @@ -0,0 +1,63 @@ +toContain('last thing on the slide'); +})->todo('the library says "that nothing closes" until its message names the rule'); + +it('says that attributes are separated by a space', function () { + says('presentation/directives.md', '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'); +})->todo('the library names the value it read until its message names the separator'); + +it('says that a theme is found beside the deck', function () { + says('presentation/themes.md', 'beside the deck'); + says('presentation/index.md', 'beside the deck'); +}); + +it('finds a theme that sits beside the deck', function () { + $root = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)); + + \mkdir($root . '/themes/mine', 0o777, true); + \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n"); + \file_put_contents( + $root . '/themes/mine/layouts.yml', + "layouts:\n title: Title Slide\n content: Title and Content\n", + ); + + try { + $deck = (new DeckParser())->parse("---\ntheme: mine\n---\n\n# One\n"); + + (new PresentationRenderer($root)) + ->render($deck, (string) \tempnam(\sys_get_temp_dir(), 'deck') . '.pptx'); + } finally { + \exec(\sprintf('rm -rf %s', \escapeshellarg($root))); + } + + expect(true)->toBeTrue(); +})->todo('the resolver is rooted at the working directory until the library roots it at the deck'); diff --git a/tests/Claims/completeness.php b/tests/Claims/completeness.php new file mode 100644 index 0000000..7cefa13 --- /dev/null +++ b/tests/Claims/completeness.php @@ -0,0 +1,62 @@ + + */ +function listed(string $vocabulary): array +{ + $marked = Pages::marked(Site::root() . '/content')[$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++; + } + } + + says('presentation/errors.md', words($beside) . ' sit beside `InvalidInput`'); + says('presentation/index.md', words($beside) . ' sit beside `InvalidInput`'); + says('general/errors.md', words($beside) . ' sit beside `InvalidInput`'); +}); diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php new file mode 100644 index 0000000..0f41a2f --- /dev/null +++ b/tests/Claims/counts.php @@ -0,0 +1,61 @@ +names(); + + says('presentation/themes.md', words(\count($aliases)) . ' read-friendly aliases are accepted'); +}); + +it('counts the frontmatter keys as the code does', function () { + $count = \count(PresentationRenderer::DIRECTIVES); + + says('presentation/index.md', 'a **closed set of ' . words($count) . '**'); + says('presentation/directives.md', 'one of these ' . words($count)); +}); + +it('counts the themes that ship as the code does', function () { + $themes = Vocabularies::all()['presentation.themes']->names(); + + says('presentation/themes.md', 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('word/directives.md', 'draws **' . words(\count($directives)) . '** directives'); +}); 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/Deck.php b/tests/Support/Deck.php new file mode 100644 index 0000000..c5cca00 --- /dev/null +++ b/tests/Support/Deck.php @@ -0,0 +1,34 @@ +parse($markdown); + + (new PresentationRenderer($directory)) + ->render($deck, (string) \tempnam(\sys_get_temp_dir(), 'deck') . '.pptx'); + } catch (\Throwable $error) { + return $error->getMessage(); + } + + return ''; + } +} diff --git a/tests/Support/Site.php b/tests/Support/Site.php new file mode 100644 index 0000000..8544f62 --- /dev/null +++ b/tests/Support/Site.php @@ -0,0 +1,120 @@ + + */ + public static function pages(): array + { + $pages = []; + + foreach (self::files(self::root() . '/content') as $file) { + $pages[\str_replace(self::root() . '/content/', '', $file)] = (string) \file_get_contents($file); + } + + \ksort($pages); + + return $pages; + } + + /** + * 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; + } + + /** + * The Markdown files under a directory. + * + * @return list + */ + private static function files(string $directory): array + { + $found = []; + + foreach (\glob($directory . '/**/*.md') ?: [] as $path) { + $found[] = $path; + } + + foreach (\glob($directory . '/*.md') ?: [] as $path) { + $found[] = $path; + } + + \sort($found); + + return \array_values(\array_unique($found)); + } +} diff --git a/tools/check-command-line.php b/tools/check-command-line.php index f034cb5..1746eaf 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,7 +34,10 @@ * 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); @@ -55,13 +59,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,8 +78,40 @@ '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 Markdown files under a directory. + * + * @return list + */ +function markdownFiles(string $directory): array +{ + $files = []; + + foreach (\glob($directory . '/**/*.md') ?: [] as $path) { + $files[] = $path; + } + + foreach (\glob($directory . '/*.md') ?: [] as $path) { + $files[] = $path; + } + + \sort($files); + + return \array_values(\array_unique($files)); +} + /** * Runs the command line and returns what it printed. * @@ -528,18 +568,13 @@ 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 (markdownFiles($root . '/content') as $file) { + $pages[\str_replace($root . '/content/', '', $file)] = (string) \file_get_contents($file); +} foreach (FORBIDDEN as $claim => $phrase) { foreach ($pages as $name => $markdown) { @@ -550,7 +585,7 @@ function fail(string $message): void } if ($failures !== []) { - \fwrite(\STDERR, \sprintf("%d problem(s) with the command-line pages:\n\n", \count($failures))); + \fwrite(\STDERR, \sprintf("%d problem(s) with the pages:\n\n", \count($failures))); foreach ($failures as $failure) { \fwrite(\STDERR, ' ' . \str_replace("\n", "\n ", $failure) . "\n\n"); From 6d596204659f71f2137d2b5c5aa22cbeb14949a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 16:23:36 +0100 Subject: [PATCH 03/36] The build fails when an example does not build The workflow built the site with npm run build, which renders "This example did not build" into the page and succeeds anyway: a broken example was a green build. The strict build is the one that is asked for now, and the two checks that keep the pages true of the code run beside it. --- .github/workflows/pages.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7f2f15c..e0cbb5b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -155,9 +155,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. From 98b95517b7b59530088029fafb368561b4c2d513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 16:24:09 +0100 Subject: [PATCH 04/36] The forbidden phrases are looked for in the templates too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index of the site is a template, and a template carries a page's summary in the page it builds from — so a phrase that was wrong is looked for in the Markdown and in the templates that render it. --- tools/check-command-line.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tools/check-command-line.php b/tools/check-command-line.php index 1746eaf..f9abebe 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -91,20 +91,22 @@ ]; /** - * The Markdown files under a directory. + * The pages of the site: the Markdown and the templates they are built from. * * @return list */ -function markdownFiles(string $directory): array +function pageFiles(string $directory): array { $files = []; - foreach (\glob($directory . '/**/*.md') ?: [] as $path) { - $files[] = $path; - } + foreach (['md', 'njk'] as $extension) { + foreach (\glob($directory . '/**/*.' . $extension) ?: [] as $path) { + $files[] = $path; + } - foreach (\glob($directory . '/*.md') ?: [] as $path) { - $files[] = $path; + foreach (\glob($directory . '/*.' . $extension) ?: [] as $path) { + $files[] = $path; + } } \sort($files); @@ -572,7 +574,7 @@ function fail(string $message): void */ $pages = []; -foreach (markdownFiles($root . '/content') as $file) { +foreach (pageFiles($root . '/content') as $file) { $pages[\str_replace($root . '/content/', '', $file)] = (string) \file_get_contents($file); } From c39ba322e703510a81c2d288f11dd635dd1eac4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 16:24:56 +0100 Subject: [PATCH 05/36] The Word page says what is checked and what is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontmatter keys of the Word half are open-ended — the library validates two of them and reads the rest — so there is no list of them to check a page against. The options, the style slots and the themes are. --- content/word/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/word/index.md b/content/word/index.md index b9d6e67..32be111 100644 --- a/content/word/index.md +++ b/content/word/index.md @@ -91,8 +91,8 @@ The `theme:` key names a look that ships with the library. Everything under `opt #### The keys, the options and the slots 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, so a key -that is added to the library is a key the page has. +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. From f8d819f851c06b5d3b574fba61213f66e95e296c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 17:15:41 +0100 Subject: [PATCH 06/36] The reference tables are read of the code, and the pages carry what the code has --- content/general/directives.md | 3 +- content/presentation/errors.md | 2 + content/presentation/index.md | 6 ++- content/word/configuration.md | 7 +++- content/word/directives.md | 7 ++-- content/word/index.md | 8 +++- src/Reference/Vocabularies.php | 76 +++++----------------------------- 7 files changed, 33 insertions(+), 76 deletions(-) diff --git a/content/general/directives.md b/content/general/directives.md index 8b8e954..bfd3ae0 100644 --- a/content/general/directives.md +++ b/content/general/directives.md @@ -28,7 +28,8 @@ 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 +**Markdown Word is permissive.** It draws exactly three directives — `::toc`, `:footnote` and +`:style` — 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. diff --git a/content/presentation/errors.md b/content/presentation/errors.md index b239f84..8edd8a0 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -32,6 +32,8 @@ is about both libraries, is on [Errors in general](/errors/). | `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 | | `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 | diff --git a/content/presentation/index.md b/content/presentation/index.md index 6970b69..4318815 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -83,8 +83,10 @@ description: What changed, what it cost, and what happens next. #### 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. The keys it takes, the colour slots, the -geometry and the themes that ship are all on [Themes and layout](/presentation/themes/). +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 diff --git a/content/word/configuration.md b/content/word/configuration.md index 56b87df..62a66f9 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -151,7 +151,8 @@ 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. +optional `styles.yml` holding the same slot names as above. 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:`: @@ -164,6 +165,8 @@ Four themes ship, and a document names one with `theme:`: | `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, then `/themes/`, then the package's own `themes/`, and +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 4ba9f5c..03d8929 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -2,13 +2,13 @@ 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 and :style are drawn. 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 -up, which is the reason they exist at all. +Markdown Word draws **three** directives. They are real Word features rather than Markdown +dressed up, which is the reason they exist at all. @@ -16,6 +16,7 @@ up, which is the reason they exist at all. | --- | --- | --- | | `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 | ## `::toc` — a table of contents Word can build diff --git a/content/word/index.md b/content/word/index.md index 32be111..dc97cfc 100644 --- a/content/word/index.md +++ b/content/word/index.md @@ -99,12 +99,11 @@ can carry `title:` for a pipeline downstream that cares and be none the worse. ### Extended: directives -Two directives, and both are real Word features rather than Markdown dressed up. +Three 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 | @@ -139,6 +138,11 @@ rather than parsed as Markdown. There are no attributes. > the body. This page says so rather than describing the intended behaviour, because the build on > this site checks the difference. +**`:style[…]`** — a word in one of the theme's named styles. The text in the brackets is the +word, and the style is named by the directive's own name: `:brand[Bakery]` is "Bakery" in the +theme's `brand` style. Attributes on the directive override the style's own properties for that +word alone. + **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 diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index b79a781..1e55d05 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -18,6 +18,7 @@ use MarkdownWord\Configuration\Styles; use MarkdownWord\Exception\Exception as WordException; use MarkdownWord\MarkdownToWord; +use MarkdownWord\Render\Directives; /** * The vocabularies the site lists, and where the code keeps each one. @@ -96,23 +97,15 @@ public static function all(): array /** * The spellings a theme file may use instead of DrawingML's own. * - * The list is a private constant of the mapper, and there is no accessor for it: a - * theme file is read into a `Theme`, and the names the file may use are not part of - * the theme. The constant is read the way the mapper reads it — as the code's own - * answer — rather than written down here, because a copy of it here would be the - * thing that drifts. + * 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 { - $constant = (new \ReflectionClass(ThemeMapping::class))->getReflectionConstant('ALIASES'); - - if ($constant === false) { - return []; - } - - return \array_keys((array) $constant->getValue()); + return \array_keys(ThemeMapping::aliases()); } /** @@ -184,65 +177,16 @@ public static function themes(string $class): array /** * The directives the Word half draws. * - * `MarkdownToWord` wires them into a `Directives` registry with two calls whose first - * argument is the name, and the registry offers no accessor for what it holds — so the - * names are read out of that call and out of nowhere else. - * - * The file is tokenised rather than matched with a pattern: a name is a string - * literal, and the tokens say which literals are arguments to those calls and which - * are something else entirely. + * 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 { - $file = (string) (new \ReflectionClass(MarkdownToWord::class))->getFileName(); - $tokens = \token_get_all((string) \file_get_contents($file)); - $names = []; - - for ($index = 0; $index < \count($tokens); $index++) { - $token = $tokens[$index]; - - if (!\is_array($token) || $token[0] !== \T_STRING) { - continue; - } - - if (!\in_array($token[1], ['withBlock', 'withInline'], true)) { - continue; - } - - $name = self::literalAfter($tokens, $index); - - if ($name !== null) { - $names[] = $name; - } - } - - \sort($names); - - return $names; - } - - /** - * The string literal the call at `$index` is given, or null where there is none. - * - * @param list $tokens - */ - private static function literalAfter(array $tokens, int $index): ?string - { - for ($next = $index + 1; $next < \count($tokens); $next++) { - if ($tokens[$next] === '(') { - $argument = $tokens[$next + 1] ?? null; - - if (\is_array($argument) && $argument[0] === \T_CONSTANT_ENCAPSED_STRING) { - return \trim($argument[1], "'\""); - } - - return null; - } - } - - return null; + return Directives::names(); } /** From de18b334a08e804a1a436be65fe3599bad215061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 17:50:00 +0100 Subject: [PATCH 07/36] The claims the library now makes true are checked, not pending --- tests/Claims/behaviour.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php index ffcd14f..dd14fe4 100644 --- a/tests/Claims/behaviour.php +++ b/tests/Claims/behaviour.php @@ -23,7 +23,7 @@ $message = Deck::refusal("# One\n\n::note\nHello.\n::\n\n## After\n"); expect($message)->toContain('last thing on the slide'); -})->todo('the library says "that nothing closes" until its message names the rule'); +}); it('says that attributes are separated by a space', function () { says('presentation/directives.md', 'Attributes are separated by a space'); @@ -33,7 +33,7 @@ $message = Deck::refusal("---\nlayout: blank\n---\n\n::rectangle{fill=red,stroke=blue}\n"); expect($message)->toContain('separated by a space'); -})->todo('the library names the value it read until its message names the separator'); +}); it('says that a theme is found beside the deck', function () { says('presentation/themes.md', 'beside the deck'); @@ -60,4 +60,4 @@ } expect(true)->toBeTrue(); -})->todo('the resolver is rooted at the working directory until the library roots it at the deck'); +}); From 8d5606272fdcec08ce31ef7437ae0c7fcb8ffbbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:18:19 +0100 Subject: [PATCH 08/36] The shared page says what an inline style is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves can write a word in a style of its own, and the syntax and the attributes are the same in the two. The page that explains how a directive works is where the shared vocabulary belongs, and the eight attributes are a vocabulary the reference check reads out of both halves and holds to one list — so a half that grew a ninth attribute is caught rather than documented. --- content/general/directives.md | 63 ++++++++++++++++++++++-- src/Reference/Vocabularies.php | 6 +++ tests/Claims/text-styles.php | 61 +++++++++++++++++++++++ tests/Support/Deck.php | 35 +++++++++++++ tests/Support/Document.php | 89 ++++++++++++++++++++++++++++++++++ tests/Support/Package.php | 31 ++++++++++++ tests/Support/Theme.php | 79 ++++++++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 tests/Claims/text-styles.php create mode 100644 tests/Support/Document.php create mode 100644 tests/Support/Package.php create mode 100644 tests/Support/Theme.php diff --git a/content/general/directives.md b/content/general/directives.md index bfd3ae0..1539816 100644 --- a/content/general/directives.md +++ b/content/general/directives.md @@ -29,21 +29,74 @@ 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 three directives — `::toc`, `:footnote` and -`:style` — 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. +`:style` — 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`, and the rest rendered as + text. - [Presentation directives](/presentation/directives/) — `::click`, `:::click`, `::column`, `::note`, and the refusals. - [The syntax itself](/directives/syntax/) — every rule, including the ones that surprise people. +## 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/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 1e55d05..3b05d91 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -12,10 +12,12 @@ use MarkdownPresentation\Theme\LayoutRole; use MarkdownPresentation\Theme\StyleMapping; use MarkdownPresentation\Theme\StyleSlots; +use MarkdownPresentation\Theme\TextStyle; use MarkdownPresentation\Theme\Theme; use MarkdownPresentation\Theme\ThemeMapping; use MarkdownWord\Configuration\Options; use MarkdownWord\Configuration\Styles; +use MarkdownWord\Configuration\TextStyles; use MarkdownWord\Exception\Exception as WordException; use MarkdownWord\MarkdownToWord; use MarkdownWord\Render\Directives; @@ -91,6 +93,10 @@ public static function all(): array '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), + ]), ]; } diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php new file mode 100644 index 0000000..3fc93a9 --- /dev/null +++ b/tests/Claims/text-styles.php @@ -0,0 +1,61 @@ +toContain('reserved word'); +}); + +it('refuses a Word theme that names a style "style"', function () { + says('general/directives.md', 'a theme may not name a style `style`'); + + $root = Theme::word("textStyles:\n style:\n color: \"FF0000\"\n"); + + try { + $message = Document::refusal("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); + } finally { + Theme::remove($root); + } + + expect($message)->toContain('cannot be defined'); +}); + +it('refuses the raw form with no attributes in a deck', function () { + says('general/directives.md', '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('general/directives.md', 'is not honoured as emphasis'); + + $message = Deck::refusal("A :style[**Bakery**]{color=00B4D8} B\n"); + + expect($message)->toContain('which is Markdown'); +}); diff --git a/tests/Support/Deck.php b/tests/Support/Deck.php index c5cca00..f736f62 100644 --- a/tests/Support/Deck.php +++ b/tests/Support/Deck.php @@ -31,4 +31,39 @@ public static function refusal(string $markdown, string $directory = '.'): strin return ''; } + + /** + * 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 + { + $deck = (new DeckParser())->parse($markdown); + $path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx'; + + (new PresentationRenderer($directory))->render($deck, $path); + + return Package::part($path, 'ppt/slides/slide1.xml'); + } + + /** + * The run of a slide that holds the given text, as the XML it holds. + * + * The run rather than the slide, because a slide carries the words around the one a + * claim is about, and those are the ones that did not change. + */ + public static function run(string $slide, string $text): string + { + \preg_match_all('#.*?#s', $slide, $matches); + + foreach ($matches[0] as $run) { + if (\preg_match('#(?:)?#', $run) === 1) { + return $run; + } + } + + return ''; + } } diff --git a/tests/Support/Document.php b/tests/Support/Document.php new file mode 100644 index 0000000..d703004 --- /dev/null +++ b/tests/Support/Document.php @@ -0,0 +1,89 @@ +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. + * + * The run rather than the body, because a body carries the words around the one a claim + * is about, and those are the ones that did not change. + */ + public static function run(string $body, string $text): string + { + \preg_match_all('#]*)?>.*?#s', $body, $matches); + + foreach ($matches[0] as $run) { + if (\preg_match('#]*)?>' . \preg_quote($text, '#') . '#', $run) === 1) { + return $run; + } + } + + return ''; + } + + /** + * The message the library gives for a document it refuses. + * + * An empty string where the document was not refused at all, so a test that expects a + * message fails on the absence of one rather than on something else. + */ + public static function refusal(string $markdown, ?string $directory = null): string + { + try { + self::path($markdown, $directory); + } catch (\Throwable $error) { + return $error->getMessage(); + } + + return ''; + } + + /** + * 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/Package.php b/tests/Support/Package.php new file mode 100644 index 0000000..e1bb823 --- /dev/null +++ b/tests/Support/Package.php @@ -0,0 +1,31 @@ +open($path) !== true) { + throw new \RuntimeException(\sprintf('The package at "%s" could not be opened.', $path)); + } + + $part = (string) $zip->getFromName($name); + $zip->close(); + + return $part; + } +} diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php new file mode 100644 index 0000000..aff4e1c --- /dev/null +++ b/tests/Support/Theme.php @@ -0,0 +1,79 @@ + Date: Tue, 27 Oct 2026 18:18:37 +0100 Subject: [PATCH 09/36] The slides half refuses a name its theme does not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline directive in a deck is a text style, so every one of them is either a name the theme has or a build that stops with the styles it has named. The page says so, and the claims check it: the refusal, the message, the theme's file, and the three forms the raw one takes. The example is the raw form, because no theme that ships carries named styles yet — the named one is proved by a theme of the test's own. --- content/presentation/directives.md | 38 ++++++++++++ content/presentation/themes.md | 26 ++++++++ tests/Claims/text-styles.php | 96 ++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/content/presentation/directives.md b/content/presentation/directives.md index d013145..d453354 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 | @@ -117,6 +120,41 @@ Case-insensitive and whitespace-tolerant. A note has to be the last thing on 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 diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 500d28d..8bcbaa9 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -15,6 +15,7 @@ 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 ``` @@ -79,6 +80,31 @@ 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: diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index 3fc93a9..de06377 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -5,6 +5,7 @@ use MarkdownOffice\Documentation\Tests\Support\Deck; use MarkdownOffice\Documentation\Tests\Support\Document; use MarkdownOffice\Documentation\Tests\Support\Theme; +use MarkdownPresentation\Theme\ThemeLoader; /** * The claims the pages make about the inline styles. @@ -59,3 +60,98 @@ expect($message)->toContain('which is Markdown'); }); + +it('reads the named styles of a theme from the file the page names', function () { + says('presentation/themes.md', '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('presentation/directives.md', 'the message names the styles the theme does have'); + + $root = Theme::slides("brand:\n color: FF0000\n bold: true\nmuted:\n color: accent1\n size: 10\n"); + + try { + $message = Deck::refusal("---\ntheme: mine\n---\n\nResilience :bakery[Bakery] is open.\n", $root); + } finally { + Theme::remove($root); + } + + expect($message)->toContain('The styles are: brand, muted.'); +}); + +it('refuses an inline directive in a deck that names no style', function () { + says('presentation/directives.md', '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('presentation/themes.md', 'a theme without one has no named styles'); + + $root = Theme::slides(); + + try { + $message = Deck::refusal("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); + } finally { + Theme::remove($root); + } + + expect($message)->toContain('The theme has no text styles'); +}); + +it('refuses a highlight in a deck, by name', function () { + says('presentation/directives.md', '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('presentation/directives.md', '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 () { + $root = Theme::slides("brand:\n color: FF0000\n bold: true\n"); + + try { + $slide = Deck::slide("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); + } finally { + Theme::remove($root); + } + + expect(Deck::run($slide, 'Bakery'))->toContain('toContain('not->toContain('not->toContain('b="1"'); +}); From d181c03438b02520b850ed4d3227150502c48626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:18:53 +0100 Subject: [PATCH 10/36] The Word half writes what it cannot draw as its own text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name the theme does not have, a label that holds Markdown, an attribute nobody reads — each of them reaches the document as the characters the author typed, because a sentence and a parser nobody asked to change should not change the syntax of every document converted after it. The round trip keeps the word and loses the style, and the page says so: a style is written as direct formatting, and the reader reads bold, italic and strike and no colour. --- content/word/configuration.md | 4 ++- content/word/directives.md | 36 +++++++++++++++++++++++ content/word/round-trip.md | 11 +++++-- tests/Claims/text-styles.php | 55 +++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/content/word/configuration.md b/content/word/configuration.md index 62a66f9..308a2ba 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -151,7 +151,9 @@ 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. A `theme:` may also name the +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:`: diff --git a/content/word/directives.md b/content/word/directives.md index 03d8929..0a41806 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -112,6 +112,42 @@ Word's own footnote syntax is not what is parsed here. `:footnote[…]` is this - An empty body — `:footnote` or `:footnote[]` — draws the source characters instead. - Only `.docx` carries it; `.odt` and `.rtf` get the text back. +## 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 diff --git a/content/word/round-trip.md b/content/word/round-trip.md index a44fded..8d68c2d 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 --- @@ -48,6 +48,7 @@ Checked, rather than assumed: | --- | --- | | `::toc{depth=2}` | `Press F9 to build the table of contents.` | | `:footnote[Source: the ledger.]` | `:footnote[Source: the ledger.]` | +| `:brand[Bakery]` | `Bakery` | 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 @@ -57,8 +58,12 @@ The second round trips only by accident: `:footnote[…]` does not currently pro `footnotes.xml` at all, so what the reader finds is the source text that was always in the body. 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. + +**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 diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index de06377..bbf9621 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -6,6 +6,8 @@ use MarkdownOffice\Documentation\Tests\Support\Document; use MarkdownOffice\Documentation\Tests\Support\Theme; use MarkdownPresentation\Theme\ThemeLoader; +use MarkdownWord\Configuration\TextStyles; +use MarkdownWord\WordToMarkdown; /** * The claims the pages make about the inline styles. @@ -155,3 +157,56 @@ expect(Deck::run($slide, 'Bakery'))->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('word/directives.md', '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('word/directives.md', '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('word/round-trip.md', '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'); +}); From e61e261dcf06132f3d311b1e87510b2bb242abd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:19:04 +0100 Subject: [PATCH 11/36] The pages say what the dialect has now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three directives and not two, three methods on the renderer and not two, and the exception a theme naming a face there is no table for — each of them a count the code is asked about, and each of them now checked. The index pages carry the inline directive as well, because a deck's dialect now has one and the index is where the dialect is listed. --- content/presentation/errors.md | 1 + content/presentation/index.md | 12 ++++++++---- content/word/index.md | 10 +++++----- tests/Claims/counts.php | 24 ++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/content/presentation/errors.md b/content/presentation/errors.md index 8edd8a0..bfa5228 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -34,6 +34,7 @@ is about both libraries, is on [Errors in general](/errors/). | `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 | diff --git a/content/presentation/index.md b/content/presentation/index.md index 4318815..3e3e612 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -19,9 +19,9 @@ 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 +in again under a different theme. `PresentationRenderer` has three methods, `render()`, +`overflows()` and `unknownFonts()`; 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). --- @@ -94,7 +94,7 @@ do. ### 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 | @@ -107,6 +107,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 diff --git a/content/word/index.md b/content/word/index.md index dc97cfc..798d666 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 three directives — and back out again. section: Markdown Word order: 0 --- @@ -138,10 +138,10 @@ rather than parsed as Markdown. There are no attributes. > the body. This page says so rather than describing the intended behaviour, because the build on > this site checks the difference. -**`:style[…]`** — a word in one of the theme's named styles. The text in the brackets is the -word, and the style is named by the directive's own name: `:brand[Bakery]` is "Bakery" in the -theme's `brand` style. Attributes on the directive override the style's own properties for that -word alone. +**`: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. **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 diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index 0f41a2f..17dd1c8 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -3,6 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Reference\Vocabularies; +use MarkdownOffice\Documentation\Tests\Support\Site; use MarkdownPresentation\Render\PresentationRenderer; use MarkdownPresentation\Theme\LayoutRole; use MarkdownPresentation\Theme\StyleMapping; @@ -58,4 +59,27 @@ $directives = Vocabularies::all()['word.directives']->names(); says('word/directives.md', 'draws **' . words(\count($directives)) . '** directives'); + says('word/index.md', words(\count($directives)) . ' directives'); + says('general/directives.md', words(\count($directives)) . ' directives'); +}); + +it('counts the inline style attributes as the code does', function () { + $attributes = Vocabularies::all()['text-style-attributes']->names(); + + says('general/directives.md', words(\count($attributes)) . ' attributes'); +}); + +it('counts the things that do not survive as the page lists them', function () { + $sections = (int) \preg_match_all('/^### /m', Site::page('word/round-trip.md')); + + says('word/round-trip.md', words($sections) . ' 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('presentation/index.md', words(\count($methods)) . ' methods'); }); From 0e3b56d60a124651cfde99048c2d485f3270b8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:19:08 +0100 Subject: [PATCH 12/36] The site does not say again what was corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sentences were wrong and have been corrected: the round-trip page's count of what does not survive, the Word index's count of the directives, and the presentation index's count of the renderer's methods. A count that was wrong once tends to come back, because the code moved on and the sentence did not — so the phrases are here, and a page that says one again is a failure. --- tools/check-command-line.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/check-command-line.php b/tools/check-command-line.php index f9abebe..e62579a 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -88,6 +88,9 @@ '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', ]; /** From 49b1c67d8d63fe9257aa919607685229817e95e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:19:08 +0100 Subject: [PATCH 13/36] The README says what the site checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference tables are read out of the libraries and compared with the pages that carry them, and 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. --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 723f9ec..ba69961 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,15 @@ So the tables are checked against the code, and a table says which vocabulary it `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 Word options and the Word -style slots — 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. +exceptions, the themes that ship, the ceilings, the Word directives, 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: From 51847d082f74ce5050b428ceb71d29ffea88ac26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 18:21:54 +0100 Subject: [PATCH 14/36] The presentation index names what the round trip does not keep A word in a named style comes back as the word, without the style and without the colour it was drawn in, and the package's own list of what does not travel now says so. The index summarised that list, and the summary was one item short. --- content/presentation/index.md | 2 +- tools/check-command-line.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/content/presentation/index.md b/content/presentation/index.md index 3e3e612..e0dcb25 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -248,7 +248,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 diff --git a/tools/check-command-line.php b/tools/check-command-line.php index e62579a..792fd08 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -91,6 +91,7 @@ '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', ]; /** From ea4f28d8378fbef6fa1bf43196500e1c34256dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 22:33:35 +0100 Subject: [PATCH 15/36] The slides half says what a template is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A template is the furniture of a deck — the wordmark, 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 whose frontmatter names a template and nothing else, and it 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. Four things decide what one is, and the pages now say them: the body holds chrome and nothing else, a slide may name several templates, `layout:` and `template:` are independent, and the deck's own templates override the imported ones of the same name. A template may also live in a file beside the deck, named with `chrome:` in the deck's own frontmatter. `::slide-number` is a shape like any other, and `of=true` writes the number of the deck after the number of the slide. A rule is a sentence rather than a name, so each is checked against the library that has to behave that way: a definition that is drawn, a template that holds a heading, a chrome file that is not there, an imported template the deck overrides, and a page number that is the number of the deck. --- content/presentation/directives.md | 125 +++++++++++++++++++++++++++-- content/presentation/errors.md | 6 ++ content/presentation/index.md | 19 +++-- content/presentation/themes.md | 20 +++++ tests/Claims/counts.php | 6 ++ tests/Claims/templates.php | 115 ++++++++++++++++++++++++++ tests/Support/Chrome.php | 36 +++++++++ tests/Support/Deck.php | 54 +++++++++++++ 8 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 tests/Claims/templates.php create mode 100644 tests/Support/Chrome.php diff --git a/content/presentation/directives.md b/content/presentation/directives.md index d453354..a9f39f5 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -159,8 +159,8 @@ one, so the attribute is refused by name rather than accepted and quietly droppe 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 twelve of them and no others — a name outside them -is refused with a message that lists all twelve. +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. @@ -178,6 +178,7 @@ is refused with a message that lists all twelve. | `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 @@ -245,7 +246,7 @@ 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: @@ -258,15 +259,18 @@ Every key of every slide's frontmatter must be one of these seven: | `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 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 @@ -292,6 +296,117 @@ Lead with the renewal number. It is the one people remember. ```` {% endexample %} +## Slide templates + +The furniture of a deck — the wordmark, 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 bfa5228..f7e5a3c 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -20,6 +20,9 @@ is about both libraries, is on [Errors in general](/errors/). | `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 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 | | `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 | @@ -45,3 +48,6 @@ is about both libraries, is on [Errors in general](/errors/). 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 e0dcb25..e1b0753 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -21,8 +21,10 @@ PHP 8.2 or newer, with `ext-dom`, `ext-gd` and `ext-zip`. `ext-imagick` is optio 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 three methods, `render()`, `overflows()` and `unknownFonts()`; 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). +`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,10 +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. The -seven are listed with the [directives](/presentation/directives/#frontmatter-directives). +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 "Every frontmatter key on a deck" %} +{% example "The four keys that describe a deck" %} ````md presentation --- theme: corporate-dark @@ -92,6 +94,13 @@ Resolution is a path, then `themes/` beside the deck, then the package's o 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, 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 markers, and the set is closed — anything outside it is refused by name rather than drawn as a diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 8bcbaa9..b74d8db 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -120,6 +120,26 @@ 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. +## 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:`: diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index 17dd1c8..cfceec3 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -43,6 +43,12 @@ says('presentation/directives.md', 'one of these ' . words($count)); }); +it('counts the free shapes as the code does', function () { + $shapes = Vocabularies::all()['presentation.free-shapes']->names(); + + says('presentation/directives.md', 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(); diff --git a/tests/Claims/templates.php b/tests/Claims/templates.php new file mode 100644 index 0000000..939202f --- /dev/null +++ b/tests/Claims/templates.php @@ -0,0 +1,115 @@ +toHaveCount(1); +}); + +it('says that a template holds chrome and nothing else, and refuses ordinary Markdown in one', function () { + says('presentation/directives.md', '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('presentation/directives.md', '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('presentation/directives.md', '`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 () { + $root = Chrome::beside("---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n"); + + try { + $deck = "---\nchrome: ./chrome.md\n---\n# One\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n"; + + $shapes = Deck::shapes($deck, $root); + } finally { + Chrome::remove($root); + } + + 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 () { + $root = Chrome::beside("---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n"); + + try { + $deck = "---\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"; + + $shapes = Deck::shapes($deck, $root); + } finally { + Chrome::remove($root); + } + + 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('presentation/directives.md', '`::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/Support/Chrome.php b/tests/Support/Chrome.php new file mode 100644 index 0000000..9208dd9 --- /dev/null +++ b/tests/Support/Chrome.php @@ -0,0 +1,36 @@ + + */ + public static function slides(string $markdown, string $directory = '.'): array + { + $path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx'; + + (new PresentationRenderer($directory))->render((new DeckParser())->parse($markdown), $path); + + $zip = new \ZipArchive(); + $zip->open($path); + + $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('# Date: Tue, 27 Oct 2026 22:33:44 +0100 Subject: [PATCH 16/36] The Word half says what a document may hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven directives place objects in a document: a picture, a caption, a page break, a section, a floating box, a shape and a form field. The Word half never refuses — a directive a writer cannot express is given the author's own characters back and the loss is reported — so the pages say which format carries what, and the round trip says what comes back of each. The reference table carries the ten names the library draws, and a new vocabulary carries the four features a format cannot hold for them, read out of the class that names them. A caller that builds its own registry draws fewer than ten, and the page says so: the handlers that are not given are not drawn, and a conversion through `MarkdownToWord` hands over all of them. --- content/general/directives.md | 29 ++++-- content/general/errors.md | 7 +- content/word/configuration.md | 7 ++ content/word/directives.md | 182 +++++++++++++++++++++++++++++++-- content/word/index.md | 12 ++- content/word/round-trip.md | 19 ++++ src/Reference/Vocabularies.php | 13 +++ tests/Claims/objects.php | 139 +++++++++++++++++++++++++ tests/Support/Document.php | 61 ++++++++++- 9 files changed, 450 insertions(+), 19 deletions(-) create mode 100644 tests/Claims/objects.php diff --git a/content/general/directives.md b/content/general/directives.md index 1539816..5398896 100644 --- a/content/general/directives.md +++ b/content/general/directives.md @@ -28,10 +28,10 @@ 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 three directives — `::toc`, `:footnote` and -`:style` — 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 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 @@ -40,12 +40,27 @@ thing on it is worse than a deck that did not build. An inline directive in a de Both then agree on one thing: an unrecognised directive is never silently dropped. -- [Word directives](/word/directives/) — `::toc`, `:footnote`, `:style`, 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 diff --git a/content/general/errors.md b/content/general/errors.md index 9f8bfd2..d86b636 100644 --- a/content/general/errors.md +++ b/content/general/errors.md @@ -69,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/word/configuration.md b/content/word/configuration.md index 308a2ba..8ba552f 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -79,6 +79,13 @@ 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 diff --git a/content/word/directives.md b/content/word/directives.md index 0a41806..00cc501 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -2,13 +2,13 @@ layout: layouts/base.njk permalink: /word/directives/ title: Word directives -summary: ::toc, :footnote and :style 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 **three** directives. They are real Word features rather than Markdown -dressed up, which is the reason they exist at all. +Markdown Word draws **ten** directives. They are real Word features rather than Markdown dressed +up, which is the reason they exist at all. @@ -17,6 +17,18 @@ dressed up, which is the reason they exist at all. | `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 @@ -112,6 +124,164 @@ Word's own footnote syntax is not what is parsed here. `:footnote[…]` is this - 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 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 in it is protected for forms. A +field that cannot be answered is a field that is not there. + +| 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: @@ -165,9 +335,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/index.md b/content/word/index.md index 798d666..88139cc 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 three 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 --- @@ -99,7 +99,7 @@ can carry `title:` for a pipeline downstream that cares and be none the worse. ### Extended: directives -Three directives, and all of them 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 @@ -143,11 +143,19 @@ style: `:brand[Bakery]` is "Bakery" in the theme's `brand` style, and `:style[Ba 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, and the columns of a section are written by `.odt` but not by +`.rtf`; the other two formats are given the author's own characters back and the loss is reported. + More, including what a `:::toc` container does: [Word directives](/word/directives/). --- diff --git a/content/word/round-trip.md b/content/word/round-trip.md index 8d68c2d..c9083ae 100644 --- a/content/word/round-trip.md +++ b/content/word/round-trip.md @@ -49,6 +49,13 @@ Checked, rather than assumed: | `::toc{depth=2}` | `Press F9 to build the table of contents.` | | `:footnote[Source: the ledger.]` | `:footnote[Source: the ledger.]` | | `: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 @@ -62,6 +69,18 @@ The third keeps the word and loses the style: `:brand[Bakery]` is written as dir 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. diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 3b05d91..02268e9 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -19,6 +19,7 @@ use MarkdownWord\Configuration\Styles; use MarkdownWord\Configuration\TextStyles; use MarkdownWord\Exception\Exception as WordException; +use MarkdownWord\Format; use MarkdownWord\MarkdownToWord; use MarkdownWord\Render\Directives; @@ -80,6 +81,18 @@ public static function all(): array 'word.directives' => new Vocabulary([ 'MarkdownToWord' => self::wordDirectives(), ]), + '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), ]), diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php new file mode 100644 index 0000000..42baa24 --- /dev/null +++ b/tests/Claims/objects.php @@ -0,0 +1,139 @@ + + */ +function listedIn(string $sentence): array +{ + \preg_match_all('/`([^`]+)`/', $sentence, $matches); + + return $matches[1]; +} + +it('says that the Word half never refuses, and gives a text box back to the author', function () { + says('word/directives.md', 'The Word half never refuses'); + + $markdown = "::textbox[A note]{width=6 height=2}\n"; + + expect(Document::written($markdown, Format::Odt))->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('word/round-trip.md', '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('word/round-trip.md', '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('word/round-trip.md', 'a page break and the columns of a section come back as nothing'); + + $back = (new WordToMarkdown(Document::path("Before.\n\n::pagebreak\n\nAfter.\n")))->convert(); + + expect($back)->toBe("Before.\n\nAfter."); +}); + +it('draws every shape type the page names, and writes one it does not draw as the source text', function () { + \preg_match('/The type is one of (.+?)\./', sectionOf('word/directives.md', '`::shape` — a shape'), $match); + + $types = listedIn($match[1] ?? ''); + + expect($types)->not->toBe([]); + + foreach ($types as $type) { + expect(Document::body("::shape{type=$type width=4 height=2}"))->not->toContain('::shape'); + } + + expect(Document::body('::shape{type=triangle width=4 height=2}'))->toContain('::shape'); +}); + +it('draws every field type the page names, and writes one it does not draw as the source text', function () { + \preg_match('/The type is one of (.+?)\./', sectionOf('word/directives.md', '`::field` — a form field'), $match); + + $types = listedIn($match[1] ?? ''); + + expect($types)->not->toBe([]); + + foreach ($types as $type) { + expect(Document::body("::field{name=x type=$type}"))->not->toContain('::field'); + } + + expect(Document::body('::field{name=x type=date}'))->toContain('::field'); +}); + +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/Support/Document.php b/tests/Support/Document.php index d703004..7300ffa 100644 --- a/tests/Support/Document.php +++ b/tests/Support/Document.php @@ -7,6 +7,8 @@ use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; use MarkdownOffice\CommonMark\Directive\GenericDirectiveExtension; +use MarkdownWord\Format; +use MarkdownWord\Loss; use MarkdownWord\MarkdownToWord; use MarkdownWord\Parser\CommonMarkParser; @@ -22,7 +24,7 @@ public static function path(string $markdown, ?string $directory = null): string { $path = \sys_get_temp_dir() . '/markdown-office-document-' . \bin2hex(\random_bytes(6)) . '.docx'; - (new MarkdownToWord($markdown, parser: self::parser(), directory: $directory))->save($path); + self::converter($markdown, $directory)->save($path); return $path; } @@ -71,6 +73,63 @@ public static function refusal(string $markdown, ?string $directory = null): str return ''; } + /** + * 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 + { + $converter = self::converter($markdown, $directory); + + $converter->convertTo($format, (string) \tempnam(\sys_get_temp_dir(), 'document') . $format->extension()); + + return \array_map( + static fn (Loss $loss): string => $loss->feature, + $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. * From 8a36ff97ebef4c05a9ff74b609e0dce79617e34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 22:33:52 +0100 Subject: [PATCH 17/36] The site does not say again what was corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten sentences were wrong and have been corrected: the counts of the frontmatter keys, the free shapes and the Word directives, the two examples that claimed to use every frontmatter key, the claim that the container form of a drawn name is claimed rather than drawn, and the snippet that read a loss by two methods it does not have. A count that was wrong once tends to come back, because the code moved on and the sentence did not — so the phrases are here, and a page that says one again is a failure. --- tools/check-command-line.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/check-command-line.php b/tools/check-command-line.php index 792fd08..32ad985 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -92,6 +92,16 @@ '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()', ]; /** From a7288ad12cf89e2b09a4bb7c3fb7cce638b67010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 22:33:52 +0100 Subject: [PATCH 18/36] The README says what the site checks The reference tables are read out of the libraries and compared with the pages that carry them. A vocabulary joins them: the features a Word format cannot carry, which the pages list beside the directives they belong to. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba69961..a6a64f4 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ So the tables are checked against the code, and a table says which vocabulary it `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 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. +exceptions, the themes that ship, the ceilings, the Word directives, 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 From 61358c01afd3d4674040ba7f38837a6323a79d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 23:36:38 +0100 Subject: [PATCH 19/36] The pages say what a theme scaffolded from a deck is, and what a diff finds --- README.md | 2 +- content/command-line/basic.md | 30 +++++++-- content/command-line/presentations.md | 65 +++++++++++++++++- content/command-line/theme.md | 96 +++++++++++++++++++++++++++ content/command-line/word.md | 5 +- content/index.md | 4 +- 6 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 content/command-line/theme.md diff --git a/README.md b/README.md index a6a64f4..063d915 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,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 ``` 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/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
From 9ade76a70edc8c68a71b265c7f129f1592d8101c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 23:36:41 +0100 Subject: [PATCH 20/36] The command line is checked in one place, and the claims are read in one place --- src/Reference/Pages.php | 205 ++++++++++++++++++-------------- tests/Claims/behaviour.php | 23 +--- tests/Claims/completeness.php | 2 +- tests/Claims/objects.php | 87 ++++---------- tests/Claims/templates.php | 26 ++--- tests/Claims/text-styles.php | 76 +++++------- tests/Support/Chrome.php | 17 ++- tests/Support/Deck.php | 52 +++------ tests/Support/Directory.php | 54 +++++++++ tests/Support/Document.php | 24 +--- tests/Support/Refusal.php | 28 +++++ tests/Support/Run.php | 37 ++++++ tests/Support/Site.php | 67 +++++------ tests/Support/Theme.php | 42 +++++-- tests/Support/Types.php | 44 +++++++ tools/check-command-line.php | 214 +++++++++++++++++----------------- tools/check-readme-links.php | 12 +- tools/check-reference.php | 63 ++++------ tools/check-site-links.php | 12 +- tools/lib/Cli.php | 60 ++++++++++ tools/lib/Report.php | 50 ++++++++ tools/make-input-document.php | 21 +--- tools/render-examples.php | 28 ++--- 23 files changed, 699 insertions(+), 545 deletions(-) create mode 100644 tests/Support/Directory.php create mode 100644 tests/Support/Refusal.php create mode 100644 tests/Support/Run.php create mode 100644 tests/Support/Types.php create mode 100644 tools/lib/Cli.php create mode 100644 tools/lib/Report.php diff --git a/src/Reference/Pages.php b/src/Reference/Pages.php index 12f3af9..fb6e2e4 100644 --- a/src/Reference/Pages.php +++ b/src/Reference/Pages.php @@ -24,110 +24,129 @@ final class Pages private const MARKER = '/^$/'; /** - * Every block under a directory: a table or a fenced list, with the names in it. + * The blocks of the pages under a directory, and the ones that carry a vocabulary. * - * @return list + * 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 blocks(string $directory): array + public static function of(string $directory): array { $blocks = []; + $marked = []; foreach (self::files($directory) as $file) { - $lines = \explode("\n", (string) \file_get_contents($file)); + $walk = self::walk($file, \explode("\n", (string) \file_get_contents($file))); - for ($number = 0; $number < \count($lines); $number++) { - $line = \trim($lines[$number]); + $blocks = [...$blocks, ...$walk['blocks']]; - if ($line === '' || \preg_match(self::MARKER, $line) === 1) { - continue; - } - - if (\str_starts_with($line, '|')) { - $table = self::table($lines, $number); - $blocks[] = new Block($file, $number + 1, $table['names'], $table['values']); - - continue; - } - - if (\str_starts_with($line, '```')) { - $blocks[] = new Block($file, $number + 1, self::fence($lines, $number)); - } + foreach ($walk['markers'] as $marker) { + $marked[$marker['name']][] = $marker['block']; } } - return $blocks; + \ksort($marked); + + return ['blocks' => $blocks, 'marked' => $marked]; } /** - * The blocks that carry a vocabulary, keyed by the vocabulary they carry. + * The blocks of a file and the markers that name the vocabulary they carry. * - * 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. + * @param list $lines * - * @return array> + * @return array{ + * blocks: list, + * markers: list + * } */ - public static function marked(string $directory): array + private static function walk(string $file, array $lines): array { - $marked = []; + $blocks = []; + $markers = []; - foreach (self::files($directory) as $file) { - $lines = \explode("\n", (string) \file_get_contents($file)); + for ($number = 0; $number < \count($lines); $number++) { + $line = \trim($lines[$number]); - for ($number = 0; $number < \count($lines); $number++) { - if (\preg_match(self::MARKER, \trim($lines[$number]), $match) !== 1) { - continue; - } + if (\preg_match(self::MARKER, $line, $match) === 1) { + $markers[] = [ + 'name' => $match[1], + 'block' => self::blockAfter($file, $lines, $number), + ]; + + continue; + } + + if ($line === '') { + continue; + } - $block = self::blockAfter($lines, $number, $file); + $block = self::blockAt($file, $lines, $number); - $marked[$match[1]][] = $block ?? new Block($file, $number + 1, [], []); + if ($block !== null) { + $blocks[] = $block; } } - \ksort($marked); - - return $marked; + return ['blocks' => $blocks, 'markers' => $markers]; } /** - * The block that follows a marker, and the names in it. + * The block that starts at a line, if one does. * - * Two shapes are read: a table, whose names are the backticked words in the first - * cell of each row, and a fenced list, whose names are the words in it. A page - * carries whichever of the two fits what it has to say. + * 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 + */ + private static function blockAt(string $file, array $lines, int $number): ?Block + { + $line = \trim($lines[$number]); + + if (\str_starts_with($line, '|')) { + $table = self::table($lines, $number); + + return new Block($file, $number + 1, $table['names'], $table['values']); + } + + if (\str_starts_with($line, '```')) { + return new Block($file, $number + 1, self::fence($lines, $number), []); + } + + 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(array $lines, int $number, string $file): ?Block + 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 === '') { - continue; - } - - if (\preg_match(self::MARKER, $trimmed) === 1) { + if ($trimmed === '' || \preg_match(self::MARKER, $trimmed) === 1) { continue; } - if (\str_starts_with($trimmed, '|')) { - $table = self::table($lines, $next); - - return new Block($file, $next + 1, $table['names'], $table['values']); - } + $block = self::blockAt($file, $lines, $next); - if (\str_starts_with($trimmed, '```')) { - return new Block($file, $next + 1, self::fence($lines, $next)); + if ($block === null) { + break; } - return null; + return $block; } - return null; + return new Block($file, $number + 1, [], []); } /** @@ -145,21 +164,11 @@ private static function table(array $lines, int $number): array $names = []; $values = []; - for ($next = $number; $next < \count($lines); $next++) { - $line = \trim($lines[$next]); - - if (!\str_starts_with($line, '|')) { - break; - } - + foreach (self::blockLines($lines, $number, static fn (string $line): bool => !\str_starts_with($line, '|')) as $offset => $line) { $cells = self::cells($line); // The header and the rule under it are not rows. - if (\count($cells) < 2) { - continue; - } - - if ($next === $number + 1) { + if (\count($cells) < 2 || $offset === 1) { continue; } @@ -185,13 +194,7 @@ private static function fence(array $lines, int $number): array { $names = []; - for ($next = $number + 1; $next < \count($lines); $next++) { - $line = \trim($lines[$next]); - - if (\str_starts_with($line, '```')) { - break; - } - + foreach (self::blockLines($lines, $number + 1, static fn (string $line): bool => \str_starts_with($line, '```')) as $line) { foreach (\preg_split('/\s+/', $line) ?: [] as $word) { if ($word !== '') { $names[] = $word; @@ -204,12 +207,37 @@ private static function fence(array $lines, int $number): array return \array_values(\array_unique($names)); } + /** + * The lines of a block, from the line it starts at. + * + * @param list $lines + * @param callable(string): bool $ends Whether a line ends the block. + * + * @return list + */ + 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)) { + break; + } + + $found[] = $line; + } + + return $found; + } + /** * The cells of a table row. * * @return list */ - private static function cells(string $line): array + public static function cells(string $line): array { return \array_map('trim', \explode('|', \trim($line, " \t|"))); } @@ -222,7 +250,7 @@ private static function cells(string $line): array * * @return list */ - private static function codeIn(string $cell): array + public static function codeIn(string $cell): array { \preg_match_all('/`([^`]+)`/', $cell, $matches); @@ -230,11 +258,16 @@ private static function codeIn(string $cell): array } /** - * The Markdown files under a directory. + * 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 */ - private static function files(string $directory): array + public static function files(string $directory, array $extensions = ['md']): array { if (!\is_dir($directory)) { return []; @@ -242,12 +275,12 @@ private static function files(string $directory): array $found = []; - foreach (\glob($directory . '/**/*.md') ?: [] as $path) { - $found[] = $path; - } - - foreach (\glob($directory . '/*.md') ?: [] as $path) { - $found[] = $path; + foreach ($extensions as $extension) { + foreach (['/**/*.', '/*.'] as $pattern) { + foreach (\glob($directory . $pattern . $extension) ?: [] as $path) { + $found[] = $path; + } + } } \sort($found); diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php index dd14fe4..a65b69a 100644 --- a/tests/Claims/behaviour.php +++ b/tests/Claims/behaviour.php @@ -3,8 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Tests\Support\Deck; -use MarkdownPresentation\Document\DeckParser; -use MarkdownPresentation\Render\PresentationRenderer; +use MarkdownOffice\Documentation\Tests\Support\Theme; /** * The rules the pages state about what the libraries do. @@ -41,23 +40,7 @@ }); it('finds a theme that sits beside the deck', function () { - $root = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)); + $message = Theme::slidesRefusal("---\ntheme: mine\n---\n\n# One\n"); - \mkdir($root . '/themes/mine', 0o777, true); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n"); - \file_put_contents( - $root . '/themes/mine/layouts.yml', - "layouts:\n title: Title Slide\n content: Title and Content\n", - ); - - try { - $deck = (new DeckParser())->parse("---\ntheme: mine\n---\n\n# One\n"); - - (new PresentationRenderer($root)) - ->render($deck, (string) \tempnam(\sys_get_temp_dir(), 'deck') . '.pptx'); - } finally { - \exec(\sprintf('rm -rf %s', \escapeshellarg($root))); - } - - expect(true)->toBeTrue(); + expect($message)->toBe(''); }); diff --git a/tests/Claims/completeness.php b/tests/Claims/completeness.php index 7cefa13..43916f2 100644 --- a/tests/Claims/completeness.php +++ b/tests/Claims/completeness.php @@ -24,7 +24,7 @@ */ function listed(string $vocabulary): array { - $marked = Pages::marked(Site::root() . '/content')[$vocabulary] ?? []; + $marked = Pages::of(Site::root() . '/content')['marked'][$vocabulary] ?? []; return $marked === [] ? [] : $marked[0]->names; } diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php index 42baa24..dea5077 100644 --- a/tests/Claims/objects.php +++ b/tests/Claims/objects.php @@ -3,7 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Tests\Support\Document; -use MarkdownOffice\Documentation\Tests\Support\Site; +use MarkdownOffice\Documentation\Tests\Support\Types; use MarkdownWord\Format; use MarkdownWord\WordToMarkdown; @@ -16,45 +16,6 @@ * do it — the sentence the page states beside the behaviour it is about. */ -/** - * The body of a section of a page, as the page wrote it. - * - * The section rather than the page, because a page carries several lists and a claim is - * about one of them. The line breaks are gone, because the prose is wrapped and a sentence - * a page states is broken across two lines. - */ -function sectionOf(string $path, string $heading): string -{ - $body = []; - $inside = false; - - foreach (\explode("\n", Site::page($path)) as $line) { - if (\str_starts_with($line, '## ')) { - $inside = \trim($line) === '## ' . $heading; - - continue; - } - - if ($inside) { - $body[] = $line; - } - } - - return (string) \preg_replace('/\s+/', ' ', \implode("\n", $body)); -} - -/** - * The names a sentence lists, in code font. - * - * @return list - */ -function listedIn(string $sentence): array -{ - \preg_match_all('/`([^`]+)`/', $sentence, $matches); - - return $matches[1]; -} - it('says that the Word half never refuses, and gives a text box back to the author', function () { says('word/directives.md', 'The Word half never refuses'); @@ -103,33 +64,31 @@ function listedIn(string $sentence): array expect($back)->toBe("Before.\n\nAfter."); }); -it('draws every shape type the page names, and writes one it does not draw as the source text', function () { - \preg_match('/The type is one of (.+?)\./', sectionOf('word/directives.md', '`::shape` — a shape'), $match); - - $types = listedIn($match[1] ?? ''); +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( + 'word/directives.md', + $heading, + $marker, + static fn (string $type): string => Document::body(\sprintf($directive, $type)), + ); expect($types)->not->toBe([]); - foreach ($types as $type) { - expect(Document::body("::shape{type=$type width=4 height=2}"))->not->toContain('::shape'); - } - - expect(Document::body('::shape{type=triangle width=4 height=2}'))->toContain('::shape'); -}); - -it('draws every field type the page names, and writes one it does not draw as the source text', function () { - \preg_match('/The type is one of (.+?)\./', sectionOf('word/directives.md', '`::field` — a form field'), $match); - - $types = listedIn($match[1] ?? ''); - - expect($types)->not->toBe([]); - - foreach ($types as $type) { - expect(Document::body("::field{name=x type=$type}"))->not->toContain('::field'); - } - - expect(Document::body('::field{name=x type=date}'))->toContain('::field'); -}); + 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('gives a shape back to the author in a format that cannot carry it', function () { $markdown = "::shape{type=rect width=4 height=2}\n"; diff --git a/tests/Claims/templates.php b/tests/Claims/templates.php index 939202f..5d98572 100644 --- a/tests/Claims/templates.php +++ b/tests/Claims/templates.php @@ -62,15 +62,10 @@ }); it('reads a chrome file from beside the deck', function () { - $root = Chrome::beside("---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n"); - - try { - $deck = "---\nchrome: ./chrome.md\n---\n# One\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n"; - - $shapes = Deck::shapes($deck, $root); - } finally { - Chrome::remove($root); - } + $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'); }); @@ -82,15 +77,10 @@ }); it('lets the deck\'s own template override an imported one of the same name', function () { - $root = Chrome::beside("---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n"); - - try { - $deck = "---\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"; - - $shapes = Deck::shapes($deck, $root); - } finally { - Chrome::remove($root); - } + $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'); diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index bbf9621..dfef524 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -22,13 +22,10 @@ it('refuses a theme that names a style "style"', function () { says('general/directives.md', 'a theme may not name a style `style`'); - $root = Theme::slides("style:\n color: FF0000\n"); - - try { - $message = Deck::refusal("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); - } finally { - Theme::remove($root); - } + $message = Theme::slidesRefusal( + "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + "style:\n color: FF0000\n", + ); expect($message)->toContain('reserved word'); }); @@ -36,13 +33,10 @@ it('refuses a Word theme that names a style "style"', function () { says('general/directives.md', 'a theme may not name a style `style`'); - $root = Theme::word("textStyles:\n style:\n color: \"FF0000\"\n"); - - try { - $message = Document::refusal("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); - } finally { - Theme::remove($root); - } + $message = Theme::wordRefusal( + "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + "textStyles:\n style:\n color: \"FF0000\"\n", + ); expect($message)->toContain('cannot be defined'); }); @@ -72,13 +66,10 @@ it('refuses a text style the theme does not have, and names the styles it has', function () { says('presentation/directives.md', 'the message names the styles the theme does have'); - $root = Theme::slides("brand:\n color: FF0000\n bold: true\nmuted:\n color: accent1\n size: 10\n"); - - try { - $message = Deck::refusal("---\ntheme: mine\n---\n\nResilience :bakery[Bakery] is open.\n", $root); - } finally { - Theme::remove($root); - } + $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.'); }); @@ -94,13 +85,9 @@ it('says that a theme without named styles has none', function () { says('presentation/themes.md', 'a theme without one has no named styles'); - $root = Theme::slides(); - - try { - $message = Deck::refusal("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); - } finally { - Theme::remove($root); - } + $message = Theme::slidesRefusal( + "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + ); expect($message)->toContain('The theme has no text styles'); }); @@ -122,38 +109,29 @@ }); it('draws a word in a named style, in the colour the theme gives it', function () { - $root = Theme::slides("brand:\n color: FF0000\n bold: true\n"); - - try { - $slide = Deck::slide("---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", $root); - } finally { - Theme::remove($root); - } + $slide = Theme::slidesSlide( + "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + "brand:\n color: FF0000\n bold: true\n", + ); expect(Deck::run($slide, 'Bakery'))->toContain('toContain('not->toContain('not->toContain('b="1"'); }); diff --git a/tests/Support/Chrome.php b/tests/Support/Chrome.php index 9208dd9..d051640 100644 --- a/tests/Support/Chrome.php +++ b/tests/Support/Chrome.php @@ -18,19 +18,26 @@ final class Chrome */ public static function beside(string $markdown): string { - $root = \sys_get_temp_dir() . '/markdown-office-chrome-' . \bin2hex(\random_bytes(6)); + $root = Directory::make('chrome'); - \mkdir($root, 0o777, true); \file_put_contents($root . '/chrome.md', $markdown); return $root; } /** - * A directory of a deck's own, removed again. + * The shapes of a deck drawn against a chrome file. + * + * The chrome is written beside the deck and taken away again, whatever the deck says: + * a file left behind is a file the next test draws against. + * + * @return list> */ - public static function remove(string $root): void + public static function shapes(string $markdown, string $chrome): array { - \exec(\sprintf('rm -rf %s', \escapeshellarg($root))); + 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 index d10fa10..be270a2 100644 --- a/tests/Support/Deck.php +++ b/tests/Support/Deck.php @@ -14,22 +14,10 @@ final class Deck { /** * The message the library gives for a deck it refuses. - * - * An empty string where the deck was not refused at all, so a test that expects a - * message fails on the absence of one rather than on something else. */ public static function refusal(string $markdown, string $directory = '.'): string { - try { - $deck = (new DeckParser())->parse($markdown); - - (new PresentationRenderer($directory)) - ->render($deck, (string) \tempnam(\sys_get_temp_dir(), 'deck') . '.pptx'); - } catch (\Throwable $error) { - return $error->getMessage(); - } - - return ''; + return Refusal::of(static fn (): string => self::render($markdown, $directory)); } /** @@ -40,31 +28,15 @@ public static function refusal(string $markdown, string $directory = '.'): strin */ public static function slide(string $markdown, string $directory = '.'): string { - $deck = (new DeckParser())->parse($markdown); - $path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx'; - - (new PresentationRenderer($directory))->render($deck, $path); - - return Package::part($path, 'ppt/slides/slide1.xml'); + 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. - * - * The run rather than the slide, because a slide carries the words around the one a - * claim is about, and those are the ones that did not change. */ public static function run(string $slide, string $text): string { - \preg_match_all('#.*?#s', $slide, $matches); - - foreach ($matches[0] as $run) { - if (\preg_match('#(?:)?#', $run) === 1) { - return $run; - } - } - - return ''; + return Run::of($slide, $text, 'a'); } /** @@ -77,12 +49,8 @@ public static function run(string $slide, string $text): string */ public static function slides(string $markdown, string $directory = '.'): array { - $path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx'; - - (new PresentationRenderer($directory))->render((new DeckParser())->parse($markdown), $path); - $zip = new \ZipArchive(); - $zip->open($path); + $zip->open(self::render($markdown, $directory)); $slides = []; @@ -120,4 +88,16 @@ public static function shapes(string $markdown, string $directory = '.'): array return $names; } + + /** + * The deck written out, and the path it was written to. + */ + private static function render(string $markdown, string $directory): string + { + $path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx'; + + (new PresentationRenderer($directory))->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 @@ +]*)?>.*?#s', $body, $matches); - - foreach ($matches[0] as $run) { - if (\preg_match('#]*)?>' . \preg_quote($text, '#') . '#', $run) === 1) { - return $run; - } - } - - return ''; + return Run::of($body, $text, 'w'); } /** * The message the library gives for a document it refuses. - * - * An empty string where the document was not refused at all, so a test that expects a - * message fails on the absence of one rather than on something else. */ public static function refusal(string $markdown, ?string $directory = null): string { - try { - self::path($markdown, $directory); - } catch (\Throwable $error) { - return $error->getMessage(); - } - - return ''; + return Refusal::of(static fn (): string => self::path($markdown, $directory)); } /** diff --git a/tests/Support/Refusal.php b/tests/Support/Refusal.php new file mode 100644 index 0000000..ca0c19c --- /dev/null +++ b/tests/Support/Refusal.php @@ -0,0 +1,28 @@ +getMessage(); + } + + return ''; + } +} 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 @@ +]*)?>.*?#s', $xml, $matches); + + foreach ($matches[0] as $run) { + $pattern = '#<' . $letter . ':t(?: [^>]*)?>(?:)?#'; + + if (\preg_match($pattern, $run) === 1) { + return $run; + } + } + + return ''; + } +} diff --git a/tests/Support/Site.php b/tests/Support/Site.php index 8544f62..c0efb2f 100644 --- a/tests/Support/Site.php +++ b/tests/Support/Site.php @@ -32,33 +32,42 @@ public static function page(string $path): string } /** - * A page of the site with its line breaks gone. + * The body of a section of a page, as the page wrote it. * - * 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. + * The section rather than the page, because a page carries several lists and a claim is + * about one of them. The line breaks are gone, because the prose is wrapped and a sentence + * a page states is broken across two lines. */ - public static function flat(string $page): string + public static function section(string $path, string $heading): string { - return (string) \preg_replace('/\s+/', ' ', $page); + $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 (string) \preg_replace('/\s+/', ' ', \implode("\n", $body)); } /** - * Every page of the site, keyed by its path under `content/`. + * A page of the site with its line breaks gone. * - * @return array + * 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 pages(): array + public static function flat(string $page): string { - $pages = []; - - foreach (self::files(self::root() . '/content') as $file) { - $pages[\str_replace(self::root() . '/content/', '', $file)] = (string) \file_get_contents($file); - } - - \ksort($pages); - - return $pages; + return (string) \preg_replace('/\s+/', ' ', $page); } /** @@ -95,26 +104,4 @@ public static function words(int $number): string return $words[$number] ?? (string) $number; } - - /** - * The Markdown files under a directory. - * - * @return list - */ - private static function files(string $directory): array - { - $found = []; - - foreach (\glob($directory . '/**/*.md') ?: [] as $path) { - $found[] = $path; - } - - foreach (\glob($directory . '/*.md') ?: [] as $path) { - $found[] = $path; - } - - \sort($found); - - return \array_values(\array_unique($found)); - } } diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php index aff4e1c..beb4235 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -25,7 +25,6 @@ public static function slides(?string $textStyles = null): string { $root = self::directory(); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n"); \file_put_contents( $root . '/themes/mine/layouts.yml', "layouts:\n content: Title and Content\n", @@ -38,6 +37,31 @@ public static function slides(?string $textStyles = null): string return $root; } + /** + * The message the library gives for a deck drawn against a theme of the slides half. + * + * The theme is written, the deck is drawn against it and the theme is taken away + * again, whatever the deck says: a theme left behind is a theme the next test finds. + */ + public static function slidesRefusal(string $markdown, ?string $textStyles = null): string + { + return Directory::during( + self::slides($textStyles), + static fn (string $root): string => 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. * @@ -48,8 +72,6 @@ public static function word(?string $styles = null): string { $root = self::directory(); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n"); - if ($styles !== null) { \file_put_contents($root . '/themes/mine/styles.yml', $styles); } @@ -58,21 +80,25 @@ public static function word(?string $styles = null): string } /** - * A directory of a theme, removed again. + * The message the library gives for a document drawn against a theme of the Word half. */ - public static function remove(string $root): void + public static function wordRefusal(string $markdown, string $styles): string { - \exec(\sprintf('rm -rf %s', \escapeshellarg($root))); + return Directory::during( + self::word($styles), + static fn (string $root): string => Document::refusal($markdown, $root), + ); } /** - * An empty directory of themes, and the directory it is in. + * A directory holding a theme of the slides half's shape, and the directory it is in. */ private static function directory(): string { - $root = \sys_get_temp_dir() . '/markdown-office-theme-' . \bin2hex(\random_bytes(6)); + $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..f79c0de --- /dev/null +++ b/tests/Support/Types.php @@ -0,0 +1,44 @@ + + */ + public static function drawn(string $page, string $heading, string $marker, callable $directive): array + { + \preg_match('/The type is one of (.+?)\./', Site::section($page, $heading), $match); + + $listed = Pages::codeIn($match[1] ?? ''); + + if ($listed === []) { + throw new \RuntimeException(\sprintf('The page "%s" lists no type at all.', $page)); + } + + foreach ($listed as $type) { + expect($directive($type))->not->toContain($marker); + } + + return $listed; + } +} diff --git a/tools/check-command-line.php b/tools/check-command-line.php index 32ad985..8cc1966 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -40,15 +40,16 @@ * 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; -$root = \dirname(__DIR__); +Cli::guard('The command-line pages are checked from the command line.'); +Cli::autoload(); + +$root = Cli::root(); $checkout = $argv[1] ?? \dirname($root) . '/mdoffice'; $binary = $checkout . '/bin/mdoffice'; @@ -104,30 +105,6 @@ 'the errors page called a loss by a method it does not have' => '$loss->feature()', ]; -/** - * The pages of the site: the Markdown and the templates they are built from. - * - * @return list - */ -function pageFiles(string $directory): array -{ - $files = []; - - foreach (['md', 'njk'] as $extension) { - foreach (\glob($directory . '/**/*.' . $extension) ?: [] as $path) { - $files[] = $path; - } - - foreach (\glob($directory . '/*.' . $extension) ?: [] as $path) { - $files[] = $path; - } - } - - \sort($files); - - return \array_values(\array_unique($files)); -} - /** * Runs the command line and returns what it printed. * @@ -152,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. @@ -200,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; @@ -349,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. * @@ -369,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'])); @@ -391,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 (\explode("\n", $basic) as $line) { - if (\preg_match('/^\| `mdoffice (\w+) …` \| (.*) \|$/', \trim($line), $match) === 1) { - $documentedParts[$match[1]] = \str_replace('`', '', $match[2]); +foreach (tableRows($basic) as $cells) { + if (\preg_match('/^`mdoffice (\w+) …`$/', $cells[0] ?? '', $match) === 1) { + $documentedParts[$match[1]] = \str_replace('`', '', $cells[1] ?? ''); + + 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], @@ -424,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)); } } @@ -447,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, @@ -457,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, @@ -478,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]) { @@ -512,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, @@ -538,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; @@ -551,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, @@ -574,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)), @@ -588,24 +592,20 @@ function fail(string $message): void */ $pages = []; -foreach (pageFiles($root . '/content') as $file) { +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 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..cc3d7ec 100644 --- a/tools/check-readme-links.php +++ b/tools/check-readme-links.php @@ -21,15 +21,13 @@ * 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; + +Cli::guard('The README links are checked from the command line.'); -$root = \dirname(__DIR__); +$root = Cli::root(); $paths = \array_slice($argv, 1); if ($paths === []) { diff --git a/tools/check-reference.php b/tools/check-reference.php index 14bfae7..c8b5230 100644 --- a/tools/check-reference.php +++ b/tools/check-reference.php @@ -58,20 +58,18 @@ exit(1); } -$root = \dirname(__DIR__); - -if (!\is_file($root . '/vendor/autoload.php')) { - \fwrite(\STDERR, "There is no vendor/autoload.php. Run `composer install` in documentation/.\n"); - exit(1); -} - -require_once $root . '/vendor/autoload.php'; +require_once __DIR__ . '/lib/Autoload.php'; use MarkdownOffice\Documentation\Reference\Block; use MarkdownOffice\Documentation\Reference\Pages; -use MarkdownOffice\Documentation\Reference\Vocabulary; 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)) { @@ -79,17 +77,7 @@ exit(1); } -$failures = []; - -/** - * A thing that is wrong, said once. - */ -function fail(string $message): void -{ - global $failures; - - $failures[] = $message; -} +$report = new Report(); /** * The page a block is on, as the site's own path. @@ -100,12 +88,13 @@ function where(Block $block): string } $vocabularies = Vocabularies::all(); -$marked = Pages::marked($content); -$blocks = Pages::blocks($content); +$pages = Pages::of($content); +$marked = $pages['marked']; +$blocks = $pages['blocks']; foreach ($marked as $name => $carried) { if (!isset($vocabularies[$name])) { - fail(\sprintf( + $report->fail(\sprintf( "%s marks a vocabulary \"%s\", which no code holds.", where($carried[0]), $name, @@ -120,7 +109,7 @@ function where(Block $block): string $carried = $marked[$name] ?? []; if ($carried === []) { - fail(\sprintf( + $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()), @@ -131,7 +120,7 @@ function where(Block $block): string if (\count($carried) > 1) { foreach (\array_slice($carried, 1) as $block) { - fail(\sprintf( + $report->fail(\sprintf( "The vocabulary \"%s\" is carried by %s as well as by %s. One list, one home.", $name, where($block), @@ -147,7 +136,7 @@ function where(Block $block): string $listed = $block->names; if ($listed === []) { - fail(\sprintf( + $report->fail(\sprintf( "%s marks the vocabulary \"%s\" and there is no table or list under it.", where($block), $name, @@ -158,7 +147,7 @@ function where(Block $block): string foreach ($expected as $one) { if (!\in_array($one, $listed, true)) { - fail(\sprintf( + $report->fail(\sprintf( "%s does not list \"%s\", which the code has.", where($block), $one, @@ -168,7 +157,7 @@ function where(Block $block): string foreach ($listed as $one) { if (!\in_array($one, $expected, true)) { - fail(\sprintf( + $report->fail(\sprintf( "%s lists \"%s\", which the code does not have.", where($block), $one, @@ -177,7 +166,7 @@ function where(Block $block): string } foreach ($vocabulary->disagreeing() as $source => $other) { - fail(\sprintf( + $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, @@ -195,7 +184,7 @@ function where(Block $block): string continue; } - fail(\sprintf( + $report->fail(\sprintf( "%s carries a second copy of \"%s\", which %s is the home of. One list, one home.", where($other), $name, @@ -207,13 +196,13 @@ function where(Block $block): string $cell = $block->values[$one] ?? null; if ($cell === null) { - fail(\sprintf("%s does not give \"%s\" a value, which the code has.", where($block), $one)); + $report->fail(\sprintf("%s does not give \"%s\" a value, which the code has.", where($block), $one)); continue; } if (!\str_contains($cell, (string) $value) && !\str_contains($cell, \number_format($value, 0, '.', ','))) { - fail(\sprintf( + $report->fail(\sprintf( "%s says \"%s\" is \"%s\"; the code says %d.", where($block), $one, @@ -228,15 +217,11 @@ function where(Block $block): string } if ($checked === 0) { - fail('No vocabulary was checked, so there is nothing to say about the reference tables.'); + $report->fail('No vocabulary was checked, so there is nothing to say about the reference tables.'); } -if ($failures !== []) { - \fwrite(\STDERR, \sprintf("%d problem(s) with the reference tables:\n\n", \count($failures))); - - foreach ($failures as $failure) { - \fwrite(\STDERR, ' ' . \str_replace("\n", "\n ", $failure) . "\n\n"); - } +if ($report->failed()) { + $report->print('reference tables'); exit(1); } diff --git a/tools/check-site-links.php b/tools/check-site-links.php index 4ac9cab..3e9378a 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; + +Cli::guard("The site's links are checked from the command line."); -$root = \dirname(__DIR__); +$root = Cli::root(); $site = $argv[1] ?? $root . '/_site'; /** 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/make-input-document.php b/tools/make-input-document.php index 20b7d96..a341bcd 100644 --- a/tools/make-input-document.php +++ b/tools/make-input-document.php @@ -22,24 +22,15 @@ * the reader makes of it has nothing to compare against. */ -\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 document is written from the command line.\n"); - exit(1); -} - -$root = \dirname(__DIR__); - -if (! \is_file($root . '/vendor/autoload.php')) { - \fwrite(\STDERR, "Missing \"vendor/autoload.php\". Run `composer install` in documentation/.\n"); - exit(1); -} +use MarkdownOffice\Documentation\Tool\Cli; +use PhpOffice\PhpWord\PhpWord; -require_once $root . '/vendor/autoload.php'; +Cli::guard('The document is written from the command line.'); +Cli::autoload(); -use PhpOffice\PhpWord\PhpWord; +$root = Cli::root(); $word = new PhpWord(); $section = $word->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'; From 21d8c3b8bc840eac09b3171cfeb4425eac81bd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 23:38:12 +0100 Subject: [PATCH 21/36] The build checks the libraries out at the branch the repository names --- .github/workflows/pages.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e0cbb5b..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 From 52a67adc80010d285060670a71ebc10e0ef17efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 27 Oct 2026 23:52:32 +0100 Subject: [PATCH 22/36] A failure says what failed, in a class of its own Four places threw a generic exception, and a reader of the failure had to work out from the message what had gone wrong. Each has its own class now: the page of the site that is not there, the package that could not be opened, the page that lists no type at all, and the package a class belongs to. The rule that is not one of them is caught by name, which is what a catch is for. --- src/Reference/PackageNotFound.php | 23 ++++++++++++++++++++ src/Reference/Vocabularies.php | 5 +---- tests/Support/NoTypesListed.php | 20 +++++++++++++++++ tests/Support/Package.php | 2 +- tests/Support/PackageNotReadable.php | 19 +++++++++++++++++ tests/Support/PageNotFound.php | 20 +++++++++++++++++ tests/Support/Site.php | 32 +++++++++++++++++++++++++++- tests/Support/Types.php | 2 +- tools/check-readme-links.php | 11 +++++----- tools/lib/UnreadableSite.php | 17 +++++++++++++++ 10 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 src/Reference/PackageNotFound.php create mode 100644 tests/Support/NoTypesListed.php create mode 100644 tests/Support/PackageNotReadable.php create mode 100644 tests/Support/PageNotFound.php create mode 100644 tools/lib/UnreadableSite.php 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 @@ +open($path) !== true) { - throw new \RuntimeException(\sprintf('The package at "%s" could not be opened.', $path)); + throw PackageNotReadable::at($path); } $part = (string) $zip->getFromName($name); 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() . "\n"); exit(1); } 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 @@ + Date: Tue, 27 Oct 2026 23:52:35 +0100 Subject: [PATCH 23/36] A page is named once, and the deck a claim draws is named once The path of a page was written out in every claim that read it, and the deck that names a theme and writes a word in a named style was written out four times over. Each is named once now: the pages have constants in the class that knows the site, and the deck and the theme that go with it have constants in the class that holds the themes. A claim says which page it is about rather than spelling the path, and the page a claim is about is a thing that has a name. --- tests/Claims/behaviour.php | 9 ++++---- tests/Claims/completeness.php | 8 ++++--- tests/Claims/counts.php | 28 +++++++++++------------ tests/Claims/objects.php | 11 +++++---- tests/Claims/templates.php | 11 +++++---- tests/Claims/text-styles.php | 43 ++++++++++++++++++----------------- tests/Support/Theme.php | 16 +++++++++++++ tools/check-site-links.php | 14 +++++++++--- 8 files changed, 85 insertions(+), 55 deletions(-) diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php index a65b69a..2853e2d 100644 --- a/tests/Claims/behaviour.php +++ b/tests/Claims/behaviour.php @@ -4,6 +4,7 @@ use MarkdownOffice\Documentation\Tests\Support\Deck; use MarkdownOffice\Documentation\Tests\Support\Theme; +use MarkdownOffice\Documentation\Tests\Support\Site; /** * The rules the pages state about what the libraries do. @@ -15,7 +16,7 @@ */ it('says that a note has to be the last thing on the slide', function () { - says('presentation/directives.md', 'a note has to be the last thing on the slide'); + says(Site::PRESENTATION_DIRECTIVES, 'a note has to be the last thing on the slide'); }); it('refuses a note that is closed but is not last, and says why', function () { @@ -25,7 +26,7 @@ }); it('says that attributes are separated by a space', function () { - says('presentation/directives.md', 'Attributes are separated by a space'); + 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 () { @@ -35,8 +36,8 @@ }); it('says that a theme is found beside the deck', function () { - says('presentation/themes.md', 'beside the deck'); - says('presentation/index.md', 'beside the deck'); + says(Site::PRESENTATION_THEMES, 'beside the deck'); + says(Site::PRESENTATION_INDEX, 'beside the deck'); }); it('finds a theme that sits beside the deck', function () { diff --git a/tests/Claims/completeness.php b/tests/Claims/completeness.php index 43916f2..de6457a 100644 --- a/tests/Claims/completeness.php +++ b/tests/Claims/completeness.php @@ -56,7 +56,9 @@ function listed(string $vocabulary): array } } - says('presentation/errors.md', words($beside) . ' sit beside `InvalidInput`'); - says('presentation/index.md', words($beside) . ' sit beside `InvalidInput`'); - says('general/errors.md', words($beside) . ' sit beside `InvalidInput`'); + $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 index cfceec3..9433a52 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -19,40 +19,40 @@ */ it('counts the colour slots as the code does', function () { - says('presentation/themes.md', 'The ' . words(\count(Theme::COLOURS)) . ' colour slots'); + says(Site::PRESENTATION_THEMES, 'The ' . words(\count(Theme::COLOURS)) . ' colour slots'); }); it('counts the style keys as the code does', function () { - says('presentation/themes.md', 'the ' . words(\count(StyleMapping::KEYS)) . ' style keys'); + says(Site::PRESENTATION_THEMES, 'the ' . words(\count(StyleMapping::KEYS)) . ' style keys'); }); it('counts the layout roles as the code does', function () { - says('presentation/themes.md', words(\count(LayoutRole::names())) . ', and only these'); + says(Site::PRESENTATION_THEMES, words(\count(LayoutRole::names())) . ', and only these'); }); it('counts the colour aliases as the code does', function () { $aliases = Vocabularies::all()['presentation.theme-aliases']->names(); - says('presentation/themes.md', words(\count($aliases)) . ' read-friendly aliases are accepted'); + 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('presentation/index.md', 'a **closed set of ' . words($count) . '**'); - says('presentation/directives.md', 'one of these ' . words($count)); + 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('presentation/directives.md', words(\count($shapes)) . ' of them and no others'); + 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('presentation/themes.md', words(\count($themes)) . ' themes ship'); + says(Site::PRESENTATION_THEMES, words(\count($themes)) . ' themes ship'); }); it('counts the Word themes that ship as the code does', function () { @@ -64,21 +64,21 @@ it('counts the Word directives as the code does', function () { $directives = Vocabularies::all()['word.directives']->names(); - says('word/directives.md', 'draws **' . words(\count($directives)) . '** directives'); + says(Site::WORD_DIRECTIVES, 'draws **' . words(\count($directives)) . '** directives'); says('word/index.md', words(\count($directives)) . ' directives'); - says('general/directives.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('general/directives.md', words(\count($attributes)) . ' attributes'); + says(Site::GENERAL_DIRECTIVES, words(\count($attributes)) . ' attributes'); }); it('counts the things that do not survive as the page lists them', function () { - $sections = (int) \preg_match_all('/^### /m', Site::page('word/round-trip.md')); + $sections = (int) \preg_match_all('/^### /m', Site::page(Site::WORD_ROUND_TRIP)); - says('word/round-trip.md', words($sections) . ' things that do not'); + says(Site::WORD_ROUND_TRIP, words($sections) . ' things that do not'); }); it('counts the renderer\'s methods as the code does', function () { @@ -87,5 +87,5 @@ static fn (\ReflectionMethod $method): bool => $method->getName() !== '__construct', ); - says('presentation/index.md', words(\count($methods)) . ' methods'); + says(Site::PRESENTATION_INDEX, words(\count($methods)) . ' methods'); }); diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php index dea5077..0b1f55f 100644 --- a/tests/Claims/objects.php +++ b/tests/Claims/objects.php @@ -6,6 +6,7 @@ use MarkdownOffice\Documentation\Tests\Support\Types; use MarkdownWord\Format; use MarkdownWord\WordToMarkdown; +use MarkdownOffice\Documentation\Tests\Support\Site; /** * The claims the pages make about the objects a Word document may hold. @@ -17,7 +18,7 @@ */ it('says that the Word half never refuses, and gives a text box back to the author', function () { - says('word/directives.md', 'The Word half never refuses'); + says(Site::WORD_DIRECTIVES, 'The Word half never refuses'); $markdown = "::textbox[A note]{width=6 height=2}\n"; @@ -41,7 +42,7 @@ }); it('says that a text box comes back without its text', function () { - says('word/round-trip.md', 'a text box comes back without its text'); + 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(); @@ -49,7 +50,7 @@ }); it('says that a form field comes back as the blank field Word draws', function () { - says('word/round-trip.md', 'a form field comes back as the blank field Word draws'); + 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(); @@ -57,7 +58,7 @@ }); it('says that a page break and the columns of a section come back as nothing', function () { - says('word/round-trip.md', 'a page break and the columns of a section come back as nothing'); + says(Site::WORD_ROUND_TRIP, 'a page break and the columns of a section come back as nothing'); $back = (new WordToMarkdown(Document::path("Before.\n\n::pagebreak\n\nAfter.\n")))->convert(); @@ -66,7 +67,7 @@ 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( - 'word/directives.md', + Site::WORD_DIRECTIVES, $heading, $marker, static fn (string $type): string => Document::body(\sprintf($directive, $type)), diff --git a/tests/Claims/templates.php b/tests/Claims/templates.php index 5d98572..4b8e27b 100644 --- a/tests/Claims/templates.php +++ b/tests/Claims/templates.php @@ -4,6 +4,7 @@ use MarkdownOffice\Documentation\Tests\Support\Chrome; use MarkdownOffice\Documentation\Tests\Support\Deck; +use MarkdownOffice\Documentation\Tests\Support\Site; /** * The claims the pages make about the slide templates. @@ -15,7 +16,7 @@ */ it('says that a template definition is not a slide of the deck, and draws none', function () { - says('presentation/directives.md', 'A definition is not a slide of the deck'); + says(Site::PRESENTATION_DIRECTIVES, 'A definition is not a slide of the deck'); $deck = "---\ntemplate: brand\n---\n::line{left=5% top=72% width=30% height=0.2%}\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n"; @@ -23,7 +24,7 @@ }); it('says that a template holds chrome and nothing else, and refuses ordinary Markdown in one', function () { - says('presentation/directives.md', 'A template holds chrome and nothing else'); + says(Site::PRESENTATION_DIRECTIVES, 'A template holds chrome and nothing else'); $message = Deck::refusal("---\ntemplate: brand\n---\n# A heading\n"); @@ -32,7 +33,7 @@ }); it('says that a slide may name several templates, and draws the chrome of each', function () { - says('presentation/directives.md', 'A slide may name several templates'); + 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"; @@ -43,7 +44,7 @@ }); it('says that layout and template are independent, and draws both on one slide', function () { - says('presentation/directives.md', '`layout:` and `template:` are independent'); + 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"; @@ -87,7 +88,7 @@ }); it('says that a slide number is a shape, and writes the number of the slide', function () { - says('presentation/directives.md', '`::slide-number` is a shape like any other'); + 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"; diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index dfef524..9a12598 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -8,6 +8,7 @@ use MarkdownPresentation\Theme\ThemeLoader; use MarkdownWord\Configuration\TextStyles; use MarkdownWord\WordToMarkdown; +use MarkdownOffice\Documentation\Tests\Support\Site; /** * The claims the pages make about the inline styles. @@ -20,10 +21,10 @@ */ it('refuses a theme that names a style "style"', function () { - says('general/directives.md', 'a theme may not name a style `style`'); + says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`'); $message = Theme::slidesRefusal( - "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + Theme::DECK, "style:\n color: FF0000\n", ); @@ -31,10 +32,10 @@ }); it('refuses a Word theme that names a style "style"', function () { - says('general/directives.md', 'a theme may not name a style `style`'); + says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`'); $message = Theme::wordRefusal( - "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + Theme::DECK, "textStyles:\n style:\n color: \"FF0000\"\n", ); @@ -42,7 +43,7 @@ }); it('refuses the raw form with no attributes in a deck', function () { - says('general/directives.md', 'a style with neither a named style nor a property says nothing'); + says(Site::GENERAL_DIRECTIVES, 'a style with neither a named style nor a property says nothing'); $message = Deck::refusal("A :style[Bakery] B\n"); @@ -50,7 +51,7 @@ }); it('does not honour a label that holds Markdown', function () { - says('general/directives.md', 'is not honoured as emphasis'); + says(Site::GENERAL_DIRECTIVES, 'is not honoured as emphasis'); $message = Deck::refusal("A :style[**Bakery**]{color=00B4D8} B\n"); @@ -58,13 +59,13 @@ }); it('reads the named styles of a theme from the file the page names', function () { - says('presentation/themes.md', 'text-styles.yml'); + 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('presentation/directives.md', 'the message names the styles the theme does have'); + 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", @@ -75,7 +76,7 @@ }); it('refuses an inline directive in a deck that names no style', function () { - says('presentation/directives.md', 'fails the build rather than reaching the slide as text'); + says(Site::PRESENTATION_DIRECTIVES, 'fails the build rather than reaching the slide as text'); $message = Deck::refusal("Status :green[go] before lunch.\n"); @@ -83,17 +84,17 @@ }); it('says that a theme without named styles has none', function () { - says('presentation/themes.md', 'a theme without one has no named styles'); + says(Site::PRESENTATION_THEMES, 'a theme without one has no named styles'); $message = Theme::slidesRefusal( - "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", + Theme::DECK, ); expect($message)->toContain('The theme has no text styles'); }); it('refuses a highlight in a deck, by name', function () { - says('presentation/directives.md', 'refused by name rather than accepted and quietly dropped'); + says(Site::PRESENTATION_DIRECTIVES, 'refused by name rather than accepted and quietly dropped'); $message = Deck::refusal("A :style[Bakery]{highlight=00B4D8} B\n"); @@ -101,7 +102,7 @@ }); it('refuses a label that holds Markdown in a deck', function () { - says('presentation/directives.md', 'a complete Markdown construct in one is refused'); + says(Site::PRESENTATION_DIRECTIVES, 'a complete Markdown construct in one is refused'); $message = Deck::refusal("A :style[**Bakery**]{color=00B4D8} B\n"); @@ -110,8 +111,8 @@ it('draws a word in a named style, in the colour the theme gives it', function () { $slide = Theme::slidesSlide( - "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n", - "brand:\n color: FF0000\n bold: true\n", + Theme::DECK, + Theme::BRAND, ); expect(Deck::run($slide, 'Bakery'))->toContain('toContain('not->toContain('b="1"'); @@ -143,7 +144,7 @@ }); it('draws a word in a named style in a Word document', function () { - says('word/directives.md', 'the named styles come from the theme'); + says(Site::WORD_DIRECTIVES, 'the named styles come from the theme'); $body = Document::body("---\ntheme: corporate\n---\n\nResilience :brand[Bakery] is open.\n"); @@ -164,7 +165,7 @@ }); it('writes a name the theme does not have as its own source text', function () { - says('word/directives.md', 'writes the directive out as its own source text'); + says(Site::WORD_DIRECTIVES, 'writes the directive out as its own source text'); $body = Document::body("Resilience :brand[Bakery] is open.\n"); @@ -172,7 +173,7 @@ }); it('writes a label that holds Markdown as the source text of a Word document', function () { - says('word/directives.md', 'a complete Markdown construct in one is drawn as the source text as well'); + 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"); @@ -180,7 +181,7 @@ }); it('reads a word in a text style back as the word', function () { - says('word/round-trip.md', 'the style is not, and neither is the name it was written under'); + 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(); diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php index beb4235..a77f6a0 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -12,6 +12,22 @@ */ final class Theme { + /** + * A deck that names a theme of its own and writes a word in a named style. + * + * The deck every claim about a named style is drawn from: the theme is named in the + * deck's own front matter, and the word is written in the style the theme gives. + */ + public const DECK = "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n"; + + /** + * A theme's named styles, with one style in them. + * + * The style is the one the deck above writes in, and it is red and bold, so that a + * claim can look for a colour and a weight and find both. + */ + public const BRAND = "brand:\n color: FF0000\n bold: true\n"; + /** * A directory holding a theme of the slides half's shape, and the directory it is in. * diff --git a/tools/check-site-links.php b/tools/check-site-links.php index 3e9378a..f476319 100644 --- a/tools/check-site-links.php +++ b/tools/check-site-links.php @@ -85,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. * @@ -93,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; @@ -148,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; From 438fa5cfa571277cbb6d5a62ea30604cacc42efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:06:47 +0100 Subject: [PATCH 24/36] The Word pages say what the library does A dropdown with no entries protects nothing, a footnote needs the whitespace in front of it to be a directive at all, and an RTF keeps the section break and loses the columns. The pages said the opposite in all three places, and the example wrote `::toc{depth=2, title="Contents"}`, which the parser reads as the depth `2,` and therefore as no depth at all. --- content/word/configuration.md | 13 +++++++++- content/word/directives.md | 27 +++++++++++--------- content/word/index.md | 47 ++++++++++++++--------------------- content/word/round-trip.md | 8 +++--- 4 files changed, 50 insertions(+), 45 deletions(-) diff --git a/content/word/configuration.md b/content/word/configuration.md index 8ba552f..c990141 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; diff --git a/content/word/directives.md b/content/word/directives.md index 00cc501..ebfe132 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -46,9 +46,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 @@ -77,7 +81,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 @@ -111,13 +115,11 @@ 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. @@ -238,9 +240,10 @@ because an element no reader knows is not a shape. 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 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 in it is protected for forms. A -field that cannot be answered is a field that is not there. +**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 | | --- | --- | diff --git a/content/word/index.md b/content/word/index.md index 88139cc..6ce83d3 100644 --- a/content/word/index.md +++ b/content/word/index.md @@ -103,18 +103,14 @@ Ten directives, and all of them are real Word features rather than Markdown dres **`::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 @@ -132,11 +128,10 @@ 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}` @@ -153,8 +148,9 @@ opposite of the presentation half's policy, and deliberate — a parser nobody a 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, and the columns of a section are written by `.odt` but not by -`.rtf`; the other two formats are given the author's own characters back and the loss is reported. +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/). @@ -241,29 +237,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 c9083ae..caa9d3f 100644 --- a/content/word/round-trip.md +++ b/content/word/round-trip.md @@ -47,7 +47,7 @@ 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 | @@ -61,9 +61,9 @@ The first is **not** a directive and never was on the way back — the document 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/). 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 From 3a168101cf30d1dc2ba9994184e56e75cca06aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:11:42 +0100 Subject: [PATCH 25/36] The suite asks the library what does not survive, and reads the whole list of types The count of the things that do not survive came from the page's own headings, so the page agreed with itself and nothing else; the page break and the columns of a section were named by one test and exercised by another; and the list of types a page names was cut off at the first full stop, so a type nobody draws could be written down after it. --- tests/Claims/counts.php | 11 +++++- tests/Claims/objects.php | 15 +++++++- tests/Support/RoundTrip.php | 75 +++++++++++++++++++++++++++++++++++++ tests/Support/Site.php | 56 ++++++++++++++++++++++++++- tests/Support/Types.php | 36 ++++++++++++++++-- 5 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tests/Support/RoundTrip.php diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index 9433a52..eb9d59c 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -3,6 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Reference\Vocabularies; +use MarkdownOffice\Documentation\Tests\Support\RoundTrip; use MarkdownOffice\Documentation\Tests\Support\Site; use MarkdownPresentation\Render\PresentationRenderer; use MarkdownPresentation\Theme\LayoutRole; @@ -76,9 +77,15 @@ }); it('counts the things that do not survive as the page lists them', function () { - $sections = (int) \preg_match_all('/^### /m', Site::page(Site::WORD_ROUND_TRIP)); + $lost = RoundTrip::losses(); - says(Site::WORD_ROUND_TRIP, words($sections) . ' things that do not'); + expect(\count($lost))->toBe((int) \preg_match_all('/^### /m', Site::page(Site::WORD_ROUND_TRIP))); + + says(Site::WORD_ROUND_TRIP, words(\count($lost)) . ' things that do not'); + + foreach ($lost as $heading) { + says(Site::WORD_ROUND_TRIP, $heading); + } }); it('counts the renderer\'s methods as the code does', function () { diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php index 0b1f55f..4d5180b 100644 --- a/tests/Claims/objects.php +++ b/tests/Claims/objects.php @@ -60,9 +60,11 @@ 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'); - $back = (new WordToMarkdown(Document::path("Before.\n\n::pagebreak\n\nAfter.\n")))->convert(); + $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($back)->toBe("Before.\n\nAfter."); + 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) { @@ -91,6 +93,15 @@ ], ]); +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('gives a shape back to the author in a format that cannot carry it', function () { $markdown = "::shape{type=rect width=4 height=2}\n"; diff --git a/tests/Support/RoundTrip.php b/tests/Support/RoundTrip.php new file mode 100644 index 0000000..eeb91ed --- /dev/null +++ b/tests/Support/RoundTrip.php @@ -0,0 +1,75 @@ + + */ + 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/Site.php b/tests/Support/Site.php index 74db148..d04ad16 100644 --- a/tests/Support/Site.php +++ b/tests/Support/Site.php @@ -69,6 +69,50 @@ public static function page(string $path): string * a page states is broken across two lines. */ public static function section(string $path, string $heading): string + { + return self::flattened(self::sectionLines($path, $heading)); + } + + /** + * The paragraphs of a section of a page, as the page wrote them. + * + * A paragraph rather than a section, because a section carries a table and a fence as + * well as prose, and the names in code font are the ones the prose lists: a table's + * names are its own. + * + * @return list + */ + 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; @@ -85,7 +129,17 @@ public static function section(string $path, string $heading): string } } - return (string) \preg_replace('/\s+/', ' ', \implode("\n", $body)); + 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)); } /** diff --git a/tests/Support/Types.php b/tests/Support/Types.php index 23e5a7b..718bd35 100644 --- a/tests/Support/Types.php +++ b/tests/Support/Types.php @@ -27,9 +27,7 @@ final class Types */ public static function drawn(string $page, string $heading, string $marker, callable $directive): array { - \preg_match('/The type is one of (.+?)\./', Site::section($page, $heading), $match); - - $listed = Pages::codeIn($match[1] ?? ''); + $listed = self::listed($page, $heading); if ($listed === []) { throw NoTypesListed::on($page); @@ -41,4 +39,36 @@ public static function drawn(string $page, string $heading, string $marker, call 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 after 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 []; + } } From e940b6f08c6db7f98f2f5e95fd72a8c6c75839b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:18:10 +0100 Subject: [PATCH 26/36] The lists of attributes and of frontmatter keys are read out of the library The attributes a directive reads and the keys a document's frontmatter may carry were written down on the pages and checked against nothing, so a library that stopped reading one of them was a page that stayed wrong. They are read out of the code now, and a table that lists part of a list is a second copy of it, so the images page keeps no copy of three options and the command line is on the page that has it. --- content/word/configuration.md | 16 +- content/word/directives.md | 10 ++ content/word/images.md | 9 +- content/word/index.md | 28 +--- content/word/round-trip.md | 2 + src/Reference/Code.php | 291 +++++++++++++++++++++++++++++++++ src/Reference/Pages.php | 62 ++++--- src/Reference/Vocabularies.php | 24 +++ tools/check-reference.php | 5 +- 9 files changed, 389 insertions(+), 58 deletions(-) create mode 100644 src/Reference/Code.php diff --git a/content/word/configuration.md b/content/word/configuration.md index c990141..59e9cd7 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -51,15 +51,17 @@ 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. diff --git a/content/word/directives.md b/content/word/directives.md index ebfe132..7433e6f 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -39,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 | @@ -142,6 +144,8 @@ the picture gets: the cap is what happens to a picture that says nothing about i 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 | @@ -206,6 +210,8 @@ it — from the left margin and from the top of the paragraph it stands in — a 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 | @@ -224,6 +230,8 @@ The type is one of the shapes a Word document can draw: `arc`, `curve`, `line`, 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 | @@ -245,6 +253,8 @@ while the document is protected for forms, so a document with a dropdown that of 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` | diff --git a/content/word/images.md b/content/word/images.md index b45332f..3851999 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 which of the three ways +Word holds a picture the document takes, `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 6ce83d3..db32f3f 100644 --- a/content/word/index.md +++ b/content/word/index.md @@ -170,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: diff --git a/content/word/round-trip.md b/content/word/round-trip.md index caa9d3f..980ef28 100644 --- a/content/word/round-trip.md +++ b/content/word/round-trip.md @@ -155,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/src/Reference/Code.php b/src/Reference/Code.php new file mode 100644 index 0000000..c786a1b --- /dev/null +++ b/src/Reference/Code.php @@ -0,0 +1,291 @@ +> + */ + 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 two that are always + * drawn, and the handlers hold the rest — and 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. + * + * @param \ReflectionType|null $type + * + * @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 that + * draws one directive and is called for it — in the docblock of the class. A + * docblock that names no directive draws none: the inline styles are one, and they + * read the attributes of a style rather than 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 method that reads an attribute in a helper of its own reads it for the caller + * 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 body 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 the two sections it hands + * over are keys 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/Pages.php b/src/Reference/Pages.php index fb6e2e4..59581cb 100644 --- a/src/Reference/Pages.php +++ b/src/Reference/Pages.php @@ -85,36 +85,52 @@ private static function walk(string $file, array $lines): array continue; } - $block = self::blockAt($file, $lines, $number); + $found = self::blockAt($file, $lines, $number); - if ($block !== null) { - $blocks[] = $block; + if ($found === null) { + continue; } + + $blocks[] = $found['block']; + + // Past the block, so that its rows are not read as blocks of their own: a + // table is one table, and a row of it is not a second copy of anything. + $number = $found['end']; } return ['blocks' => $blocks, 'markers' => $markers]; } /** - * The block that starts at a line, if one does. + * 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): ?Block + 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 new Block($file, $number + 1, $table['names'], $table['values']); + return [ + 'block' => new Block($file, $number + 1, $table['names'], $table['values']), + 'end' => $table['end'], + ]; } if (\str_starts_with($line, '```')) { - return new Block($file, $number + 1, self::fence($lines, $number), []); + $fence = self::fence($lines, $number); + + return [ + 'block' => new Block($file, $number + 1, $fence['names'], []), + 'end' => $fence['end'], + ]; } return null; @@ -137,13 +153,13 @@ private static function blockAfter(string $file, array $lines, int $number): Blo continue; } - $block = self::blockAt($file, $lines, $next); + $found = self::blockAt($file, $lines, $next); - if ($block === null) { + if ($found === null) { break; } - return $block; + return $found['block']; } return new Block($file, $number + 1, [], []); @@ -157,14 +173,15 @@ private static function blockAfter(string $file, array $lines, int $number): Blo * * @param list $lines * - * @return array{names: list, values: array} + * @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 (self::blockLines($lines, $number, static fn (string $line): bool => !\str_starts_with($line, '|')) as $offset => $line) { + foreach ($block['lines'] as $offset => $line) { $cells = self::cells($line); // The header and the rule under it are not rows. @@ -180,7 +197,11 @@ private static function table(array $lines, int $number): array \sort($names); - return ['names' => \array_values(\array_unique($names)), 'values' => $values]; + return [ + 'names' => \array_values(\array_unique($names)), + 'values' => $values, + 'end' => $block['end'], + ]; } /** @@ -188,13 +209,14 @@ private static function table(array $lines, int $number): array * * @param list $lines * - * @return list + * @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 (self::blockLines($lines, $number + 1, static fn (string $line): bool => \str_starts_with($line, '```')) as $line) { + foreach ($block['lines'] as $line) { foreach (\preg_split('/\s+/', $line) ?: [] as $word) { if ($word !== '') { $names[] = $word; @@ -204,16 +226,16 @@ private static function fence(array $lines, int $number): array \sort($names); - return \array_values(\array_unique($names)); + return ['names' => \array_values(\array_unique($names)), 'end' => $block['end']]; } /** - * The lines of a block, from the line it starts at. + * 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 list + * @return array{lines: list, end: int} */ private static function blockLines(array $lines, int $number, callable $ends): array { @@ -223,13 +245,13 @@ private static function blockLines(array $lines, int $number, callable $ends): a $line = \trim($lines[$next]); if ($ends($line)) { - break; + return ['lines' => $found, 'end' => $next]; } $found[] = $line; } - return $found; + return ['lines' => $found, 'end' => \count($lines)]; } /** diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 119d1ae..3028bea 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -22,6 +22,7 @@ use MarkdownWord\Format; use MarkdownWord\MarkdownToWord; use MarkdownWord\Render\Directives; +use MarkdownWord\Reverse\Options as ReverseOptions; /** * The vocabularies the site lists, and where the code keeps each one. @@ -39,6 +40,8 @@ final class Vocabularies */ public static function all(): array { + $attributes = Code::wordDirectiveAttributes(); + return [ 'presentation.frontmatter' => new Vocabulary([ 'PresentationRenderer::DIRECTIVES' => PresentationRenderer::DIRECTIVES, @@ -81,6 +84,27 @@ public static function all(): array '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 diff --git a/tools/check-reference.php b/tools/check-reference.php index c8b5230..27d4a2c 100644 --- a/tools/check-reference.php +++ b/tools/check-reference.php @@ -180,7 +180,10 @@ function where(Block $block): string continue; } - if (\array_diff($expected, $other->names) !== []) { + // 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 — and the copy nobody reads is the one that is wrong. + if ($other->names === [] || \array_diff($other->names, $expected) !== []) { continue; } From ccadbea40993f9f3b8a891f3c36fab888848658d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:18:14 +0100 Subject: [PATCH 27/36] The page quotes the sentences the library prints for a loss A loss is reported in a sentence the library writes, and the page quoted four of them verbatim without anything checking that they were the sentences the library still writes. --- tests/Claims/objects.php | 18 ++++++++++++++++++ tests/Support/Document.php | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php index 4d5180b..42e0568 100644 --- a/tests/Claims/objects.php +++ b/tests/Claims/objects.php @@ -102,6 +102,24 @@ ->toBe(['arc', 'curve', 'line', 'polyline', 'rect', 'oval']); }); +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"; diff --git a/tests/Support/Document.php b/tests/Support/Document.php index d9e94ea..d90b512 100644 --- a/tests/Support/Document.php +++ b/tests/Support/Document.php @@ -63,15 +63,25 @@ public static function refusal(string $markdown, ?string $directory = null): str * @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 \array_map( - static fn (Loss $loss): string => $loss->feature, - $converter->pendingLosses(), - ); + return $converter->pendingLosses(); } /** From 220f887e934d167935ddd75677b5c0908a252985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:18:24 +0100 Subject: [PATCH 28/36] The errors page names the two exceptions the library throws now The slides half reads a chrome file with a ceiling and bounds the shapes on a slide, and the page that lists every exception it throws did not list either of them. --- content/presentation/errors.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/presentation/errors.md b/content/presentation/errors.md index f7e5a3c..085019b 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -23,11 +23,13 @@ is about both libraries, is on [Errors in general](/errors/). | `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 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` | a theme file is not shaped like one | From 3af85bbf6f285ae9e595b898d9cd3815e2d26c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:20:58 +0100 Subject: [PATCH 29/36] The exceptions that sit beside InvalidInput are checked against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The names in the first column of the errors tables were checked and the second was not, though the code says which of its exceptions sit beside InvalidInput rather than under it — and a page that calls one of them an InvalidInput says the caller can fix it. --- content/word/errors.md | 4 +-- src/Reference/Vocabularies.php | 45 +++++++++++++++++++++++++++++----- src/Reference/Vocabulary.php | 2 +- tools/check-reference.php | 4 +-- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/content/word/errors.md b/content/word/errors.md index c12b73b..4d4b8d3 100644 --- a/content/word/errors.md +++ b/content/word/errors.md @@ -24,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/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 3028bea..bd5ea00 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -64,9 +64,12 @@ public static function all(): array 'presentation.style-keys' => new Vocabulary([ 'StyleMapping::KEYS' => StyleMapping::KEYS, ]), - 'presentation.exceptions' => new Vocabulary([ - 'MarkdownPresentation\\Exception' => self::exceptions(PresentationException::class), - ]), + 'presentation.exceptions' => new Vocabulary( + [ + 'MarkdownPresentation\\Exception' => self::exceptions(PresentationException::class), + ], + self::besideInput(PresentationException::class), + ), 'presentation.themes' => new Vocabulary([ 'themes/' => self::themes(PresentationRenderer::class), ]), @@ -117,9 +120,12 @@ public static function all(): array Format::COLUMNS, ], ]), - 'word.exceptions' => new Vocabulary([ - 'MarkdownWord\\Exception' => self::exceptions(WordException::class), - ]), + '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()), ]), @@ -191,6 +197,33 @@ public static function exceptions(string $base): array return $names; } + /** + * The exceptions that sit beside `InvalidInput` rather than under it. + * + * The phrase is the one the pages write, and the exceptions it is written for are the + * ones the code does not put under `InvalidInput`: 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/`. * diff --git a/src/Reference/Vocabulary.php b/src/Reference/Vocabulary.php index 3d58358..2e86b19 100644 --- a/src/Reference/Vocabulary.php +++ b/src/Reference/Vocabulary.php @@ -16,7 +16,7 @@ final class Vocabulary { /** * @param array> $lists The code's lists, keyed by where each is kept. - * @param array $values A value per name, where the code has one. + * @param array $values A value per name, where the code has one. */ public function __construct( public readonly array $lists, diff --git a/tools/check-reference.php b/tools/check-reference.php index 27d4a2c..d575281 100644 --- a/tools/check-reference.php +++ b/tools/check-reference.php @@ -204,9 +204,9 @@ function where(Block $block): string continue; } - if (!\str_contains($cell, (string) $value) && !\str_contains($cell, \number_format($value, 0, '.', ','))) { + if (!\str_contains($cell, (string) $value) && !\str_contains($cell, \number_format((float) $value, 0, '.', ','))) { $report->fail(\sprintf( - "%s says \"%s\" is \"%s\"; the code says %d.", + "%s says \"%s\" is \"%s\"; the code says %s.", where($block), $one, $cell, From fb0ba82c44214f1eb429eb133dfa014ae9790315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:23:11 +0100 Subject: [PATCH 30/36] The page says which formats carry an object and which lose it The carried and lost columns were prose nothing checked, and the code says which of the formats carries each of the four objects and which drops it. --- content/word/images.md | 4 ++-- src/Reference/Code.php | 8 +++++--- tests/Claims/objects.php | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/content/word/images.md b/content/word/images.md index 3851999..cf20d65 100644 --- a/content/word/images.md +++ b/content/word/images.md @@ -14,8 +14,8 @@ the deck's, and are on [Images](/images/). ## The options The three options that decide what happens to a picture are on -[Configuration](/word/configuration/#every-option) — `images`, which says which of the three ways -Word holds a picture the document takes, `imageMaxWidth`, the cap on a picture that says nothing +[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 diff --git a/src/Reference/Code.php b/src/Reference/Code.php index c786a1b..41b325e 100644 --- a/src/Reference/Code.php +++ b/src/Reference/Code.php @@ -18,8 +18,8 @@ * The code is read rather than asked, because the library holds no list of the names it * reads. A directive reads the attributes it needs one accessor at a time, and the name * is in the call; a frontmatter key is read the same way. A read that moves to another - * class takes the name with it, and the list here follows — or the check fails, which - * is the answer a reader of this class wants. + * class leaves the name out of the list here, and the check says so — which is the + * answer a reader of this class wants. */ final class Code { @@ -207,7 +207,9 @@ private static function reached(string $class, string $method): array } /** - * The body of a method, without its comments. + * The source of a method, without its comments. + * + * A comment names things in prose, and only a call says what the code reads. */ private static function bodyOf(string $class, string $method): string { diff --git a/tests/Claims/objects.php b/tests/Claims/objects.php index 42e0568..907a3ba 100644 --- a/tests/Claims/objects.php +++ b/tests/Claims/objects.php @@ -102,6 +102,27 @@ ->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]; From fea8f5e4494cc21ad32e1f1486b304bbc97dbc4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:25:59 +0100 Subject: [PATCH 31/36] A value the code holds is checked in the form the code writes it The number in a cell was compared with the number in the code and with the number as a reader writes it, and a phrase with a number that happened to be in the cell passed; the page's list of what does not survive is now the list the library answers with. --- src/Reference/Code.php | 10 +++++----- src/Reference/Vocabularies.php | 20 ++++++++++---------- tests/Claims/counts.php | 13 ++++++++----- tools/check-reference.php | 18 +++++++++++++++++- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/Reference/Code.php b/src/Reference/Code.php index 41b325e..39d2cd2 100644 --- a/src/Reference/Code.php +++ b/src/Reference/Code.php @@ -69,7 +69,7 @@ public static function wordFrontmatterKeys(): array /** * The classes that draw the Word half's directives. * - * Asked of the registry that holds them: the factory takes the two that are always + * Asked of the registry that holds them: the factory takes the ones that are always * drawn, and the handlers hold the rest — and the type of a handler's property is * the class that draws it. * @@ -119,8 +119,8 @@ private static function namedTypeOf(?\ReflectionType $type): ?string * * The name is in the docblock of the method that draws it, or — for a class that * draws one directive and is called for it — in the docblock of the class. A - * docblock that names no directive draws none: the inline styles are one, and they - * read the attributes of a style rather than those of a directive. + * docblock that names no directive draws none: the inline styles read the + * attributes of a style rather than those of a directive. */ private static function directiveOf( \ReflectionClass $class, @@ -228,8 +228,8 @@ private static function bodyOf(string $class, string $method): string * 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 the two sections it hands - * over are keys of the 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. diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index bd5ea00..3e42144 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -199,16 +199,16 @@ public static function exceptions(string $base): array /** * The exceptions that sit beside `InvalidInput` rather than under it. - * - * The phrase is the one the pages write, and the exceptions it is written for are the - * ones the code does not put under `InvalidInput`: 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 + * + * The phrase is the one the pages write, and the exceptions it is written for are the + * ones the code does not put under `InvalidInput`: 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 = []; diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index eb9d59c..2a40296 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -79,13 +79,16 @@ it('counts the things that do not survive as the page lists them', function () { $lost = RoundTrip::losses(); - expect(\count($lost))->toBe((int) \preg_match_all('/^### /m', Site::page(Site::WORD_ROUND_TRIP))); + \preg_match_all('/^### (.+)$/m', Site::page(Site::WORD_ROUND_TRIP), $matches, \PREG_SET_ORDER); - says(Site::WORD_ROUND_TRIP, words(\count($lost)) . ' things that do not'); + $listed = \array_map(static fn (array $match): string => $match[1], $matches); + + \sort($lost); + \sort($listed); - foreach ($lost as $heading) { - says(Site::WORD_ROUND_TRIP, $heading); - } + 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 () { diff --git a/tools/check-reference.php b/tools/check-reference.php index d575281..6967e2a 100644 --- a/tools/check-reference.php +++ b/tools/check-reference.php @@ -204,7 +204,23 @@ function where(Block $block): string continue; } - if (!\str_contains($cell, (string) $value) && !\str_contains($cell, \number_format((float) $value, 0, '.', ','))) { + // 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), From d56b75228a384091b149280de696f63bc4eac57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:26:43 +0100 Subject: [PATCH 32/36] The README says which lists the check holds to Seven vocabularies were added and the rule about a second copy now covers a copy of part of a list, so the page that explains the mechanism says what the mechanism does. --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 063d915..ab7d893 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,12 @@ So the tables are checked against the code, and a table says which vocabulary it `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 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. +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 @@ -75,14 +76,19 @@ 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, because 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. +- **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. + 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 From ccec13ca9634ea881964e45f991473720bb23486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:36:45 +0100 Subject: [PATCH 33/36] The page names the ceilings the library has The slides half grew three: the size of a chrome file, the shapes one slide may carry, and the size of one part of a theme's template. The table listed three of the six. --- content/presentation/index.md | 3 +++ src/Reference/Vocabularies.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/content/presentation/index.md b/content/presentation/index.md index e1b0753..e029f88 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -279,6 +279,9 @@ PowerPoint open the deck as `[Repaired]`. | `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 diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 3e42144..a703520 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -7,8 +7,11 @@ use MarkdownPresentation\Exception\Exception as PresentationException; use MarkdownPresentation\Render\CommonMark\BlockTranslator; use MarkdownPresentation\Render\Free\FreeShapes; +use MarkdownPresentation\Render\Furniture; use MarkdownPresentation\Render\ImageConverter; use MarkdownPresentation\Render\PresentationRenderer; +use MarkdownPresentation\Render\TemplateGeometry; +use MarkdownPresentation\Template\ChromeFile; use MarkdownPresentation\Theme\LayoutRole; use MarkdownPresentation\Theme\StyleMapping; use MarkdownPresentation\Theme\StyleSlots; @@ -75,13 +78,24 @@ public static function all(): array ]), 'presentation.ceilings' => new Vocabulary( [ - 'PresentationRenderer::MAX_SLIDES, BlockTranslator::MAX_STEP, ImageConverter::MAX_PIXELS' - => ['MAX_SLIDES', 'MAX_STEP', 'MAX_PIXELS'], + '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([ From 25ec50028b5f52ebcc74ee2142bd510190c5e77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 18:51:19 +0100 Subject: [PATCH 34/36] The comments say what the code cannot Every comment in the code this session added was read again by the rules the documentation policy sets: what restates the code, what counts a thing that rots, and what is only padding. The reasons stay. --- src/Reference/Code.php | 32 ++++++++++++-------------------- src/Reference/Pages.php | 2 +- src/Reference/Vocabularies.php | 3 +-- tests/Support/RoundTrip.php | 3 +-- tests/Support/Site.php | 3 +-- tests/Support/Types.php | 2 +- tools/check-reference.php | 4 ++-- 7 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/Reference/Code.php b/src/Reference/Code.php index 39d2cd2..17ef444 100644 --- a/src/Reference/Code.php +++ b/src/Reference/Code.php @@ -12,14 +12,11 @@ * The names the Word half reads out of what it is given. * * The lists the site checks are read out of the library's own code rather than written - * down here: a name written down here is a second copy of the thing this checks, and a - * second copy is what this whole mechanism exists to remove. + * down here: a name written down here is a second copy of the thing this checks. * * The code is read rather than asked, because the library holds no list of the names it - * reads. A directive reads the attributes it needs one accessor at a time, and the name - * is in the call; a frontmatter key is read the same way. A read that moves to another - * class leaves the name out of the list here, and the check says so — which is the - * answer a reader of this class wants. + * reads — the name is in the call that reads it. A read that moves to another class + * leaves the name out of the list here, and the check says so. */ final class Code { @@ -70,8 +67,8 @@ public static function wordFrontmatterKeys(): array * 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 — and the type of a handler's property is - * the class that draws it. + * drawn, and the handlers hold the rest — the type of a handler's property is the + * class that draws it. * * @return list */ @@ -101,8 +98,6 @@ private static function handlers(): array /** * The class a type names, or null when it names no class. * - * @param \ReflectionType|null $type - * * @return class-string|null */ private static function namedTypeOf(?\ReflectionType $type): ?string @@ -117,10 +112,10 @@ private static function namedTypeOf(?\ReflectionType $type): ?string /** * 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 that - * draws one directive and is called for it — in the docblock of the class. A - * docblock that names no directive draws none: the inline styles read the - * attributes of a style rather than those of a directive. + * 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, @@ -173,9 +168,8 @@ private static function attributesRead(string $class, string $method): array /** * The methods of a class a method reaches, itself included. * - * A method that reads an attribute in a helper of its own reads it for the caller - * too: the table of contents asks for its depth in a helper, and the depth is an - * attribute of the directive. + * 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 */ @@ -208,8 +202,6 @@ private static function reached(string $class, string $method): array /** * The source of a method, without its comments. - * - * A comment names things in prose, and only a call says what the code reads. */ private static function bodyOf(string $class, string $method): string { @@ -232,7 +224,7 @@ private static function bodyOf(string $class, string $method): string * 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. + * reader asks for; the name the parser keeps the block under is not one of them. * * @return list */ diff --git a/src/Reference/Pages.php b/src/Reference/Pages.php index 59581cb..a606014 100644 --- a/src/Reference/Pages.php +++ b/src/Reference/Pages.php @@ -94,7 +94,7 @@ private static function walk(string $file, array $lines): array $blocks[] = $found['block']; // Past the block, so that its rows are not read as blocks of their own: a - // table is one table, and a row of it is not a second copy of anything. + // row of a table is not a second copy of the list. $number = $found['end']; } diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index a703520..57d8dd6 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -214,8 +214,7 @@ public static function exceptions(string $base): array /** * The exceptions that sit beside `InvalidInput` rather than under it. * - * The phrase is the one the pages write, and the exceptions it is written for are the - * ones the code does not put under `InvalidInput`: a page that calls one of them an + * 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 diff --git a/tests/Support/RoundTrip.php b/tests/Support/RoundTrip.php index eeb91ed..d0aec55 100644 --- a/tests/Support/RoundTrip.php +++ b/tests/Support/RoundTrip.php @@ -11,8 +11,7 @@ * * The reader is asked, and its answer is what the page has to say: a thing that comes * back whole is not a thing that does not survive. The page's count of them is the - * number of answers that say so — which is what the library knows, and not what the - * page lists. + * number of answers that say so, not what the page lists. */ final class RoundTrip { diff --git a/tests/Support/Site.php b/tests/Support/Site.php index d04ad16..72c3849 100644 --- a/tests/Support/Site.php +++ b/tests/Support/Site.php @@ -77,8 +77,7 @@ public static function section(string $path, string $heading): string * The paragraphs of a section of a page, as the page wrote them. * * A paragraph rather than a section, because a section carries a table and a fence as - * well as prose, and the names in code font are the ones the prose lists: a table's - * names are its own. + * well as prose, and a table's names in code font are its own. * * @return list */ diff --git a/tests/Support/Types.php b/tests/Support/Types.php index 718bd35..d19a0b7 100644 --- a/tests/Support/Types.php +++ b/tests/Support/Types.php @@ -53,7 +53,7 @@ public static function listed(string $page, string $heading): array /** * The types the paragraphs name, in the paragraph that names them. * - * The paragraph rather than the sentence: a list that goes on after the first full + * 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. * diff --git a/tools/check-reference.php b/tools/check-reference.php index 6967e2a..8d597fa 100644 --- a/tools/check-reference.php +++ b/tools/check-reference.php @@ -180,9 +180,9 @@ function where(Block $block): string continue; } - // A copy of the list, or of part of it. A table that lists nothing but names + // 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 — and the copy nobody reads is the one that is wrong. + // meant to have one. if ($other->names === [] || \array_diff($other->names, $expected) !== []) { continue; } From de126091c77719ce3c913492a391eaca7a859823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 19:32:32 +0100 Subject: [PATCH 35/36] The walk reads a page without a counter it moves itself The loop updated its counter inside its own body; the walk now advances once, at the end of a line, and a block still moves past its own rows. --- src/Reference/Pages.php | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/Reference/Pages.php b/src/Reference/Pages.php index a606014..078a679 100644 --- a/src/Reference/Pages.php +++ b/src/Reference/Pages.php @@ -68,8 +68,10 @@ private static function walk(string $file, array $lines): array { $blocks = []; $markers = []; + $number = 0; + $count = \count($lines); - for ($number = 0; $number < \count($lines); $number++) { + while ($number < $count) { $line = \trim($lines[$number]); if (\preg_match(self::MARKER, $line, $match) === 1) { @@ -77,25 +79,19 @@ private static function walk(string $file, array $lines): array 'name' => $match[1], 'block' => self::blockAfter($file, $lines, $number), ]; + } elseif ($line !== '') { + $found = self::blockAt($file, $lines, $number); - continue; - } - - if ($line === '') { - continue; - } + if ($found !== null) { + $blocks[] = $found['block']; - $found = self::blockAt($file, $lines, $number); - - if ($found === null) { - continue; + // 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']; + } } - $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]; From 5014186eddbbd4bd1521bef044296677f326b482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 28 Oct 2026 19:52:32 +0100 Subject: [PATCH 36/36] The page says what a wordmark is The word was used before it was defined, and a reader who met it in the prose had to guess. --- content/presentation/directives.md | 3 ++- content/presentation/index.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/content/presentation/directives.md b/content/presentation/directives.md index a9f39f5..2d1a806 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -298,7 +298,8 @@ Lead with the renewal number. It is the one people remember. ## Slide templates -The furniture of a deck — the wordmark, the rule, the page number — is the same on every slide, and +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. diff --git a/content/presentation/index.md b/content/presentation/index.md index e029f88..3ef602d 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -96,7 +96,8 @@ do. #### A template -A **template** is the furniture of a deck — the wordmark, the rule, the page number — written +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).