diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7f2f15c..72f2ba3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -29,6 +29,26 @@ jobs: # site is mounted under, so this one setting decides both the canonical addresses and # the links in the built pages. MARKDOWN_OFFICE_CANONICAL: https://markdown-office.github.io + + # Which branch of each library and of the command line the site is built against. + # + # `main` is the library as it is released, and it is the right answer for almost every + # run. A pull request that documents a feature the library does not have on `main` yet + # cannot be built against `main` — the site renders its own examples with the library, + # so an example that uses a feature `main` does not have does not build — and saying + # which branch is wanted in the repository's settings is better than a build that fails + # on a feature the page is about. + # + # The variables are read at the point of use and default to `main` when they are not + # set, so nothing has to be done to them for an ordinary run, and setting them back to + # `main` — or removing them — puts the repository back as it was. + # + # Only a pull request reads them: the site published from `main` is always built + # against the libraries' own `main`, so the published site never depends on a branch + # of a library that may be deleted. + MARKDOWN_WORD_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_WORD_REF || 'main' }} + MARKDOWN_PRESENTATION_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_PRESENTATION_REF || 'main' }} + MARKDOWN_OFFICE_REF: ${{ github.event_name == 'pull_request' && vars.MARKDOWN_OFFICE_REF || 'main' }} steps: # This repository with the three libraries beside it, because the manifest's `path` # entries resolve by directory and `../markdown-word` means nothing in any other @@ -43,14 +63,14 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: markdown-office/markdown-word - ref: main + ref: ${{ env.MARKDOWN_WORD_REF }} path: markdown-word - name: Check out markdown-presentation uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: markdown-office/markdown-presentation - ref: main + ref: ${{ env.MARKDOWN_PRESENTATION_REF }} path: markdown-presentation - name: Check out commonmark-generic-directives @@ -72,7 +92,7 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: markdown-office/mdoffice - ref: main + ref: ${{ env.MARKDOWN_OFFICE_REF }} path: mdoffice - name: Set up PHP @@ -155,9 +175,27 @@ jobs: "$RUNNER_TEMP/bin/wordcraft-cli" --version "$RUNNER_TEMP/bin/deckcraft-cli" --version + # The strict build, not the plain one: an example that does not build is rendered + # into the page as "This example did not build", and the build succeeds anyway. + # A green build with a broken example on it is the failure this step exists to + # catch, so the strict flag is the one that is asked for. - name: Build the site working-directory: documentation - run: npm run build + run: npm run build:strict + + # The reference tables are written by hand, and a hand-written list is correct + # only until the code moves. Each vocabulary is read out of the library that + # owns it and the page that carries it is read for the names it lists, and the + # two are compared in both directions. + - name: The reference tables list what the libraries have + working-directory: documentation + run: php tools/check-reference.php + + # The claims the pages make in prose — the counts, the completeness, the rules — + # which a table cannot carry and a name cannot say. + - name: The claims the pages make + working-directory: documentation + run: vendor/bin/pest # A site whose links 404 is worse than no site, and nothing else says so: the build # succeeds, the pages are there, and the reader is the one who finds out. diff --git a/.gitignore b/.gitignore index 6ee5b91..f909723 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ /_data/examples.json /patches.lock.json /.worktrees/ +/.phpunit.cache/ .DS_Store diff --git a/README.md b/README.md index 7d34b33..ab7d893 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ npm run build # build the examples, then the pages, into _site npm run serve # the same, with a watcher ``` +```bash +composer check # the examples, the command-line pages, the reference tables, the claims +composer test # the claims on their own +``` + ```bash npm run build:strict # ...and exit non-zero if an example does not build ``` @@ -41,6 +46,60 @@ fails the build rather than the reader's afternoon. That is not decoration — it caught three real errors in these pages while they were being written, including one where an example claimed a Word footnote that the converter did not produce. +## The reference tables + +The tables are claims too, and they drift: the site said four themes shipped when five did, and +its error page said it listed every exception the package throws and left five of them out. + +So the tables are checked against the code, and a table says which vocabulary it carries: + +```markdown + + +| Role | Title box | +| --- | --- | +| `title` | yes | +``` + +`tools/check-reference.php` reads every vocabulary out of the library that owns it — the frontmatter +keys, the layout roles, the free shapes, the colour slots, the theme keys, the style keys, the +exceptions, the themes that ship, the ceilings, the Word directives, the attributes each of the +Word directives reads, the keys a Word document's frontmatter may carry, the options the reader of +a Word document trusts, the features a Word format cannot carry, the Word options, the Word style +slots and the inline style attributes — and reads the page that carries it for the names it lists. +The two are compared in both directions: a name the code does not have is a page offering something +that does not work, and a name the code has and the page does not is a feature nobody can find. + +A vocabulary the two halves share is read out of both of them and held to one list, so the +attributes an inline style takes are the same eight in the deck and in the document — and the +check says so when they are not. + +Three rules make the lists stay lists: + +- **One list, one home.** A second copy of a table is a failure, and so is a copy of part of one: + two copies drift apart and the one nobody reads is the one that is wrong. Every table that lists + names is marked, and the marked one is the only one. +- **The code is the only source.** The lists are read out of the libraries, not written down in the + site, so there is no second copy for the check to compare against. Where a library holds no list + of its own — the attributes a directive reads, the keys a frontmatter may carry — the code is + read for the names, and a name the code stops reading is a name the page may not list. +- **A vocabulary with no home is a failure.** A check that passes because it found nothing to check + is worse than no check at all. + +A table may also carry a value the code holds, and then the value is checked too: the numbers in +the table of ceilings, and the exceptions that sit beside `InvalidInput` rather than under it. + +What a table cannot hold is prose — what a key does, what an exception means — and a count written +out in a sentence ("Eight, and only these"). Those are claims too, and they are checked by the +suite in `tests/`: the counts against the code, the claims of completeness against the code, and the +rules against the library that has to behave that way. A sentence that was wrong and has been +corrected goes into the `FORBIDDEN` map of `tools/check-command-line.php`, and a page that says it +again is a failure. + +The tables are not generated. Their other columns are prose that no code holds, and moving that +prose into a generator would make a second copy of the documentation in a file that is not a page — +the same drift, one repository further away. + ## Writing a page ```text @@ -49,7 +108,7 @@ content/ ├── word/ the dialect, the directives, the configuration, the round trips, the images, the errors ├── presentation/ the dialect, the directives, the themes, the errors ├── general/ how directives work, the syntax, the images, the errors -├── command-line/ the installation, the basic use, the options of each half +├── command-line/ the installation, the basic use, the options of each part ├── suite/ the other packages └── llms/ llms.txt.njk, pages.json.njk ``` @@ -178,7 +237,9 @@ to it, and the site is published at the GitHub Pages address until then. content/ the pages, one directory per chapter _includes/ the layout, the stylesheet, the wordmark _documents/ the documents the examples read. Committed: an input must not change. -tools/ render-examples.php, make-input-document.php, the two link checks, and lib/ +src/ the reference tables, read out of the libraries and compared with the pages +tests/ the claims the pages make in prose +tools/ render-examples.php, make-input-document.php, the three checks, and lib/ _examples/ built artifacts, published under the site's own path. Not committed. _data/ examples.json, written by the example builder. Not committed. _site/ the built site. Not committed. diff --git a/composer.json b/composer.json index 47fe781..63e1a17 100644 --- a/composer.json +++ b/composer.json @@ -76,11 +76,13 @@ "examples": "@php tools/render-examples.php", "build": "npm run build", "serve": "npm run serve", - "test": "@php tools/check-command-line.php ../mdoffice", + "test": "pest", "check": [ "@php tools/render-examples.php --strict", "@php tools/check-command-line.php ../mdoffice", - "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice" + "@php tools/check-reference.php", + "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice", + "@test" ], "check:site": "@php tools/check-site-links.php", "check:links": "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice" diff --git a/content/command-line/basic.md b/content/command-line/basic.md index 7bae966..6d9da9b 100644 --- a/content/command-line/basic.md +++ b/content/command-line/basic.md @@ -11,23 +11,26 @@ order: 1 [`markdown-office/mdoffice`](https://github.com/markdown-office/mdoffice), which requires both converter packages and calls them — so what it does is what those libraries do. -The options of each half are on a page of their own: [For word](/command-line/word/) and [For -presentations](/command-line/presentations/). How to get the tool is on [Installation](/command-line/installation/). +The options of each part are on a page of their own: [For word](/command-line/word/), [For +presentations](/command-line/presentations/) and [For themes](/command-line/theme/). How to get the +tool is on [Installation](/command-line/installation/). ```bash mdoffice word README.md # writes README.docx mdoffice word README.docx # writes README.md mdoffice slides deck.md # writes deck.pptx +mdoffice theme deck.pptx # writes deck/, a theme to edit ``` ## Parts -The first argument says which converter the run is for. +The first argument says which part the run is for. | Part | Does | | --- | --- | | `mdoffice word …` | Markdown to `.docx`, `.odt` and `.rtf`, and `.docx` back to Markdown | | `mdoffice slides …` | Markdown to `.pptx`, and back | +| `mdoffice theme …` | A theme scaffolded from a deck | **A bare file is not a run.** `mdoffice notes.md` is refused, and the parts are listed: @@ -38,6 +41,7 @@ mdoffice: Unknown part "notes.md". word Markdown to .docx, .odt and .rtf, and .docx back to Markdown slides Markdown to .pptx, and back + theme A theme scaffolded from a deck ``` The Word half decides the direction from the file's first bytes, but with more than one converter @@ -48,6 +52,9 @@ Only a `.docx` is read back. An `.odt` is a zip archive as well, so it is read a document and refused — the reader knows the Word document format and not the OpenDocument one. The two are written, not read. +The theme part is the one that converts nothing: it reads a deck and writes a theme directory, +which is a first draft of a theme and is meant to be edited. + ## Commands | Part | Command | Does | @@ -58,15 +65,23 @@ The two are written, not read. | `word` | `to-markdown` | a Word document to Markdown | | `slides` | `to-pptx` | Markdown to `.pptx` | | `slides` | `to-markdown` | a deck to Markdown | +| `slides` | `diff` | two decks, and what changed between them | +| `theme` | `init` | a theme scaffolded from a deck | In the Word part, with no command, the direction is detected. `--to` overrides it: `docx` or `word` for a Word document, `odt` for an OpenDocument Text, `rtf` for Rich Text Format, and `markdown` or `md` for Markdown. Those are the spellings it takes; `opendocument` and `rich text` are refused, because the command that runs says which format it reads. -The slides part has two commands, and the first is the default: `mdoffice slides deck.md` is +The slides part has three commands, and the first is the default: `mdoffice slides deck.md` is the same run as `mdoffice slides to-pptx deck.md`. The second reads a deck back, and it is -named — a deck is a `.pptx`, and the part does not work the direction out for itself. +named — a deck is a `.pptx`, and the part does not work the direction out for itself. The third +compares two decks and says what changed between them; its options are on [For +presentations](/command-line/presentations/). + +The theme part has one command, and it is the default: `mdoffice theme deck.pptx` is the same +run as `mdoffice theme init deck.pptx`. Its options are on [For +themes](/command-line/theme/). A command named on the command line decides the direction itself, and `--to` is then only allowed to agree with it: @@ -95,6 +110,7 @@ mdoffice help mdoffice word help mdoffice word to-docx --help mdoffice slides to-pptx --help +mdoffice theme help ``` ## Exit codes @@ -112,6 +128,10 @@ A defect is printed with its class and the line it happened on, because it is no somebody's to see: `mdoffice word: : `, and then the file and the line underneath it. +**A diff that finds differences has done what it was asked.** It exits `0`, because comparing two +decks and reporting the difference is the whole of the work; `--exit-code` is what turns the +difference into a failure, for a build to act on. + ## Things it is easy to get wrong - `--output=x`, `--output x` and `-o x` are the same. Leading dashes are stripped before the diff --git a/content/command-line/presentations.md b/content/command-line/presentations.md index a586002..b5ab985 100644 --- a/content/command-line/presentations.md +++ b/content/command-line/presentations.md @@ -7,9 +7,10 @@ section: The command line order: 2 --- -The slides part has two commands. `mdoffice slides deck.md` is the same run as `mdoffice slides -to-pptx deck.md`, and the second reads a deck back. The options each takes are here; the -options the Word part takes are on [For word](/command-line/word/). +The slides part has three commands. `mdoffice slides deck.md` is the same run as `mdoffice slides +to-pptx deck.md`, the second reads a deck back, and the third compares two decks. The options +each takes are here; the options the Word part takes are on [For word](/command-line/word/), and +the theme part is on [For themes](/command-line/theme/). ## Options for `to-pptx` @@ -39,6 +40,64 @@ A line ending may also be written `unix`, `n`, `dos`, `windows` or `r`. The Markdown that comes back is the Markdown this tool writes decks in, so a deck can be read out, edited as text and put in again through `to-pptx` with a different theme. +## Options for `diff` + +| Option | Value | Default | +| --- | --- | --- | +| `--exit-code` | flag | off | +| `-h, --help` | flag | off | + +A diff takes two decks and says what changed between them: + +```bash +mdoffice slides diff before.pptx after.pptx +``` + +**The bytes are never compared.** A `.pptx` carries a timestamp in every part it holds and +another in the document's own properties, so two correct renderings of the same deck differ most +of the time; comparing the bytes would report every deck as changed. + +**The slides are paired by their id**, because an id survives an edit and a reorder of the deck +and a position does not: a slide inserted at the front moves every slide after it, and pairing by +position would report the whole deck as changed. The shapes of a slide are paired by their id +first, then by their name, and then by their place. A slide or a shape that is left over was +added or removed, and the report says which. + +**What is compared** is the text of every shape, the position and the size of every shape that +states them, and the colour of every fill and every run. A colour that names a slot of the theme +is compared as the colour that slot holds, so a change in the theme or in the master's colour +map is a change of colour. Positions and sizes are in millimetres, from the top left of the +slide, and two lengths are the same length when they agree to a hundredth of a millimetre. + +**What is not compared** is said as well, because a diff that quietly skips what it could not +compare leaves a reader worse off than one that says what it looked at: the notes, the layouts, +the masters, the theme's own fonts, the pictures, the document's own properties, a fill that is +not a single colour, the frame of a group of shapes, and the position or the size of a shape +that takes it from the layout. + +The report goes to standard error, so a pipe gets the difference and not the report of it. It +ends with what was looked at: + +```text +Compared the text of every shape, the position and the size of every shape that states them, and the colour of every fill and every run, on the 3 slides the two decks have in common. A colour that names a slot of the theme is compared as the colour that slot holds, so a change in the theme or in the master's colour map is a change of colour. +Positions and sizes are in millimetres, from the top left of the slide. Slides are numbered as in the first deck. +Not compared: the notes, the layouts, the masters, the theme's own fonts, the pictures, the document's own properties, a fill that is not a single colour, the frame of a group of shapes, and the position or the size of a shape that takes it from the layout. +``` + +**A diff that finds differences has done what it was asked**, so it exits `0`. `--exit-code` is +what turns the difference into a failure, for a build to act on: + +```bash +mdoffice slides diff before.pptx after.pptx --exit-code || echo "the decks differ" +``` + +A diff needs two decks, and one is refused: + +```text +mdoffice slides: Name the two decks to compare. +mdoffice slides: A diff takes two: the deck before and the deck after. +``` + **There is no `--to` in this part.** The command says which way the deck goes, and the deck is a `.pptx` either way: diff --git a/content/command-line/theme.md b/content/command-line/theme.md new file mode 100644 index 0000000..e3c05af --- /dev/null +++ b/content/command-line/theme.md @@ -0,0 +1,96 @@ +--- +layout: layouts/base.njk +permalink: /command-line/theme/ +title: For themes +summary: What `mdoffice theme init` reads out of a deck, what it leaves to the library, and what it writes. +section: The command line +order: 4 +--- + +The theme part converts nothing. It reads a deck and writes a theme directory — a first draft of +a theme, meant to be edited. The other parts are on [For word](/command-line/word/) and [For +presentations](/command-line/presentations/). + +```bash +mdoffice theme init deck.pptx # writes deck/ +mdoffice theme deck.pptx # the same run: init is the default +``` + +## Options for `init` + +| Option | Value | Default | +| --- | --- | --- | +| `-o, --output ` | path | beside the deck | +| `--force` | flag | off | +| `-h, --help` | flag | off | + +The directory is the deck's own name without its extension, so `deck.pptx` gives `deck/` and +`talks/talk.pptx` gives `talks/talk/`. + +**A theme that is already there is not replaced without being asked.** A theme is a directory of +files somebody has edited, and replacing one is how an afternoon of work goes missing: + +```text +mdoffice theme: There is already a theme in "deck". +mdoffice theme: Pass --force to replace it. +``` + +## What is written + +Three files go into the directory. + +| File | Holds | +| --- | --- | +| `theme.yml` | the colours, the fonts and the type scale | +| `layouts.yml` | the layout roles, each pointed at a layout of the deck | +| `template.pptx` | the deck's own layouts, without its slides | + +**The theme is read back before the command reports success.** The library's own reader reads it +and the library's own geometry reads the boxes of the template, so a theme this command cannot +read is a failure here rather than a theme nobody can use. + +## What is read out of the deck + +**The colours** come from the deck's own theme part where the deck is drawn in that theme, and +from the deck's slides where it is not. A deck made in a tool that does not do themes carries +Office's palette without ever drawing in it, and that palette describes nothing on the page. + +**The type scale** is read off the deck's text, because a deck records a size for every +paragraph rather than a scale. The title is the size most slides are headed in, the cover the +largest type on the first slide, the body the size the prose is written at, and the heading +scale the sizes below the title. + +**The layouts** are mapped by the placeholders they hold, not by their names: a deck calls its +layouts whatever it likes, and the same layout is called different things in two offices. A role +the deck has no layout for is reported rather than pointed at a layout that is something else. + +**The template** is the deck's layouts without its slides, and without the pictures on them. A +template places, and the slides of the deck it came from are not a placement. + +## What is left to the library + +The spacing and the monospace face are the library's own. A deck records where its boxes are, +not the rhythm it draws with, and it says nothing about a face it never uses. Every value the +deck did not answer is left at the library's default and named in the report, so the numbers in +the file can be told apart from the numbers somebody chose. + +## The report + +The command says what it read and where it read it from: + +```text +mdoffice theme: wrote the theme for "deck.pptx" into "deck". + + theme.yml the colours, the fonts and the type scale + layouts.yml 1 of the 8 layout roles mapped + template.pptx the deck's 1 layouts, without its 3 slides + + colours + dk1 #000000 the type + lt2 #000000 a filled block +``` + +The report goes to standard output, and the theme it writes is a directory the slides part +reads: put it in `themes//` beside a deck and name it with `theme:`. The theme format is +the [presentation library's](/presentation/themes/), and the [layout roles](/presentation/themes/#layout-roles) +are the ones it defines. diff --git a/content/command-line/word.md b/content/command-line/word.md index a9bc8e0..3778ed2 100644 --- a/content/command-line/word.md +++ b/content/command-line/word.md @@ -8,8 +8,9 @@ order: 3 --- The Word part decides the direction from the file's first bytes, and `--to` overrides it. The -options it takes are here; the slides part has two commands and two sets of options, on [For -presentations](/command-line/presentations/). +options it takes are here; the slides part has three commands and three sets of options, on [For +presentations](/command-line/presentations/), and the theme part is on [For +themes](/command-line/theme/). ## Options for `to-docx`, `to-odt` and `to-rtf` diff --git a/content/general/directives.md b/content/general/directives.md index 8b8e954..5398896 100644 --- a/content/general/directives.md +++ b/content/general/directives.md @@ -28,21 +28,90 @@ and `{…}` is a modifier available on all three shapes rather than a shape of i Here the two packages make opposite decisions, and both are defensible. -**Markdown Word is permissive.** It draws exactly two directives — `::toc` and `:footnote` — and -renders every other name as its own source text. A document nobody intended as a set of -directives keeps working, and the text an author wrote is what a reader sees. +**Markdown Word is permissive.** It draws exactly ten directives — `::toc`, `:footnote`, `:style` +and the seven that place objects in a document — and every named style the theme brings, and +renders every other name as its own source text. A document nobody intended as a set of directives +keeps working, and the text an author wrote is what a reader sees. **Markdown Presentation has a closed set.** Anything outside it fails the build with a message naming what would have worked. A deck is a thing a person presents, and a slide with the wrong -thing on it is worse than a deck that did not build. +thing on it is worse than a deck that did not build. An inline directive in a deck is a +[text style](#a-word-in-a-style), so a name the theme does not have is refused there too. Both then agree on one thing: an unrecognised directive is never silently dropped. -- [Word directives](/word/directives/) — `::toc`, `:footnote`, and the rest rendered as text. +- [Word directives](/word/directives/) — `::toc`, `:footnote`, `:style`, the seven that place + objects, and the rest rendered as text. - [Presentation directives](/presentation/directives/) — `::click`, `:::click`, `::column`, - `::note`, and the refusals. + `::note`, the slide templates, and the refusals. - [The syntax itself](/directives/syntax/) — every rule, including the ones that surprise people. +## What a permissive half does with a directive it cannot draw + +Markdown Word draws a directive by handing it to a writer, and a writer is a program with a set of +things it can express. A directive that names something the writer has no element for is not +refused: it is **given back to the author as the characters they wrote**, and the loss is +reported. + +That is the same answer an unrecognised name gets, and it is the reason the policy is safe: a +document nobody intended as a set of directives keeps working, and a document that *did* mean it +still reads as the thing that was lost rather than as a document with a hole in it. + +The three that only a `.docx` can carry — a text box, a shape and a form field — and the columns +of a section, which only an `.rtf` cannot, are on +[Word directives](/word/directives/#what-a-format-cannot-carry). + +## A word in a style + +Both halves can write one word in a style of its own — a colour, a size, a weight — inside a +sentence that is otherwise one colour and one size. The syntax is the same in both, and the set of +attributes is the same in both: eight attributes, closed. + + + +| Attribute | Carries | +| --- | --- | +| `color` | the ink | +| `size` | the size of the type, in points | +| `font` | the family | +| `bold` | the weight | +| `italic` | the slant | +| `underline` | the underline | +| `strike` | the strike | +| `highlight` | a colour behind the word | + +Two of the eight are not the same in both halves. A `color` is a role, a theme slot or a hex value +in Markdown Presentation, and six hexadecimal digits in Markdown Word. A `highlight` is drawn by +Markdown Word and **refused by name** by Markdown Presentation, which has no way to write one. + +Three forms, one grammar: + +| Written | Means | +| --- | --- | +| `:accent[brand]` | the word "brand", in the style the theme names `accent` | +| `:accent[brand]{color=FF0000}` | the same word, with the attributes laid over the style's | +| `:style[brand]{color=FF0000}` | the word "brand", in the attributes, with no theme style behind it | + +`style` is reserved: a theme may not name a style `style`, and the raw form needs at least one +attribute — a style with neither a named style nor a property says nothing. + +**An attribute overrides the style for the property it names, and for that property only.** A +property neither of them names is not set: the run takes it from the block it is in, and nothing is +reset to a default. A property set to `false` is not set either — it means "not on this run" rather +than "off", because neither format can write a weight of none. + +**The label is plain text.** A directive's label is a string rather than Markdown, because +CommonMark's inline engine has no way for a parser to say "the rest of this run is mine". So a +complete Markdown construct in a label — `**text**`, `*text*`, `_text_`, `__text__` or `` `code` `` +— is not honoured as emphasis. A link cannot be one: a label holds no brackets at all, so there is +no way to write one. A bare `*` or `_` is a character rather than an intent, so `2 * 3` and +`snake_case` are ordinary text. + +What the two halves do with a name they do not know, and with a label they cannot honour, is where +they part: [Markdown Presentation](/presentation/directives/#a-word-in-a-style) refuses both and +names what would have worked, and [Markdown Word](/word/directives/#a-word-in-a-style) writes the +directive out as its own source text. + ## The exact grammar Names are a letter followed by letters, digits, `_` and `-`. There are no namespace prefixes in diff --git a/content/general/errors.md b/content/general/errors.md index 0166c0e..d86b636 100644 --- a/content/general/errors.md +++ b/content/general/errors.md @@ -32,8 +32,9 @@ try { so one `catch` does not also catch the failures of the libraries underneath. The failures a deck author can fix by naming something else extend `Exception\InvalidInput` below it. -One sits beside those two rather than under `InvalidInput`: `Exception\UnreadablePackage`. Nothing -a deck names fixes a package that is not a package. +Five sit beside `InvalidInput` rather than under it, and nothing a deck names fixes any of them: a +file that cannot be read, a target that cannot be written, a conversion with nothing to convert, or +a dependency installed without the fixes the library needs. ## The two `UnknownDirective` @@ -68,10 +69,11 @@ $converter = (new MarkdownToWord('notes.md')); $converter->save('notes.docx'); foreach ($converter->pendingLosses() as $loss) { - echo $loss->feature(), ': ', $loss->explain(), "\n"; + echo $loss->feature, ': ', $loss->message, "\n"; } ``` `.docx` carries everything, so `pendingLosses()` is empty. `.odt` and `.rtf` drop named features — -every list item left out of an RTF, an SVG flattened to a raster, a `::toc` left as its text — and -the command line prints them to standard error rather than letting the document quietly lose them. +every list item left out of an RTF, an SVG flattened to a raster, a `::toc` left as its text, a text +box left out of the document — and the command line prints them to standard error rather than +letting the document quietly lose them. diff --git a/content/index.md b/content/index.md index c723fb4..1e046c3 100644 --- a/content/index.md +++ b/content/index.md @@ -110,8 +110,8 @@ parser extension, for your own directives if you want one.

mdoffice

The command line for both of them: mdoffice word and mdoffice -slides. Requires both converters and calls them — a program, not a facade, so neither -package depends on it.

+slides, and mdoffice theme to scaffold a theme from a deck. Requires both +converters and calls them — a program, not a facade, so neither package depends on it.

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