# Documents & Printers
A **Document** holds structured content (an array/`iArray` body, plus meta/links/logs); a **Printer** turns a
document into its final serialized form (string, or bytes for xlsx). `Response::Json(...)`/`Response::Html(...)`/
etc. (see [server-http.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fserver-http.md)) wrap a document + the right printer together for you — most
application code builds a `Document` subclass and lets `Response::*` pick the printer, rather than calling
printers directly.
## Document subclasses
| Class | Printer | Use for |
|---|---|---|
| `Document` | none by default (`toArray()`/generic) | raw structured data |
| `JsonDocument` | `JsonPrinter` | JSON API responses |
| `XmlDocument` | `XmlPrinter` | XML output |
| `HtmlDocument` | `HtmlPrinter` | full HTML pages |
| `TextDocument` | `TextPrinter` (or `ShellPrinter` for ANSI-colored CLI output) | plain text / CLI |
| `xlsx\Document` | `XlsxPrinter` | spreadsheets (see below) |
Construct with `new JsonDocument(['key' => 'value'])`, or `Response::Json([...])` which does this for you.
`Document::Raw($body, $opts)` builds one with the `raw` option: as plain as its format allows — a JsonDocument
without its `meta`/`links`/`logs` envelope, an HtmlDocument without the server console, and without the CSS/JS a
`DocumentInit` listener (mmo.client's `Theme`) adds unless `isRaw()`: an e-mail's body, for example.
Common `Document` API: `addContent($data)` (merge array data in), `addMeta($data)`, `addLink($linkOrArray)`,
`getBody()`/`setBody()`, `render($opts = [])`, `setRaw()`/`isRaw()` (skip the configured printer's extra
formatting), `createStream(): Stream`.
## Options: typed, or an array
A document and a printer take options. Pass them typed, as named arguments: a misspelled one is then an error
("Unknown named parameter $escpae"), where a misspelled array key is silently ignored.
- `MMO\core\printers\PrintOptions` — what the printers read: `escape`, `dump`, `maxLevel`, `indent`, `separator`,
`precision`, `decimalSeparator`, `thousandSeparator`, `lang`, `basepath`, `ignoreTraces`, `tracesFilter`,
`fields`, `store`, `headers`, `eol`. Each printer reads those it knows (see the class's docblock).
- `MMO\core\documents\DocumentOptions` — a document's own: `raw`, `charset`, `printer`, `vars`, and `print` (a
`PrintOptions` for every value it renders).
```php fragment
$page = new HtmlDocument(null, new DocumentOptions(print: new PrintOptions(escape: true)));
echo TextPrinter::Print($order, new PrintOptions(indent: 2, maxLevel: 3));
$csv = CsvPrinter::Encode($rows, new PrintOptions(headers: ['Name', 'Age'], separator: ','));
```
They are accepted by `TextPrinter::Print()` (and `HtmlPrinter::Print()`...), `aExporter::Export()`,
`JsonPrinter::Encode()`/`EncodeFile()`, `CsvPrinter::Encode()`, `XlsxPrinter::ExportLines()`, `\MMO\idump()`, a
document's constructor, `Document::Raw()` and `renderValue()`. What takes an array only — `render()` (it is
`iRenderable`'s), a printer's `__invoke()` — takes `->toArray()`. An option a lib's own printer reads (pdo's
`bind`...) is not in `PrintOptions`: pass an array there.
## Templates
`$document->import($filename, $vars = [], $key = null)` `require()`s a PHP template file, capturing its output.
**`$vars` is NOT `extract()`-ed into local variables.** `Document::import()` only merges `getVars()` defaults into
the `$_VARS` array and then `require()`s the file, so the imported template has a `$_VARS` *array* in scope (plus
`$this`, bound to the `Document`, since `require()` runs inside `import()`'s own method body) — never individual
`$name`-style variables. (Its docblock used to claim otherwise; it has been corrected.) Real call sites agree:
`mmo.client`'s `src/templates/category.phtml` reads `$_VARS['content']`, not a bare `$content`. Write templates
against `$_VARS['key']`.
**`import()` does zero extension magic — it does a literal file lookup.** `addImportPath()` registers a search
directory, and `findImportPath()` resolves `$filename` by looking for that *exact literal string* in each
registered path (via `\MMO\filename($filename, $path)`). Nothing is appended or stripped, so whatever the real
file is named on disk is exactly what you pass — no more, no less. This trips people up in both directions:
passing a bare name when the real file has an extension fails just as surely as guessing an extension that
isn't there.
In `mmo.client` (`libs/client/`), every real template file keeps its `.phtml` extension, and every `import()`
call passes it explicitly — confirmed both by reading `src/controllers/PageController.php` (its templates are
`'category.phtml'` and `'entity.phtml'`) and by testing it directly: importing a template without its extension
fails with "cannot find import", while the real on-disk filename works.
```php
$doc = HtmlDocument::Raw(); // Raw() to skip the normal page-chrome init hook
$doc->addImportPath('/path/to/templates');
$doc->import('welcome.phtml', ['name' => 'Marc']); // looks for /path/to/templates/welcome.phtml, literally
```
`HtmlDocument::import()` additionally defaults `$key` to `'body'` (appends the rendered output into the page
body automatically) — the base `Document::import()` defaults to `null`, which appends it to the document's body
too; pass `false` to add it nowhere (`import()` always returns the capture, an `OutputCapture`, for the caller).
## HtmlDocument extras
`setTitle()`, `setMeta($name, $content, $key = 'name')` / `getMeta()`, `addStylesheet()`, `addScript()` /
`addInlineScript()`, `setScriptVariable($name, $value)` (a `JSVariable` — becomes a real JS variable assignment
in the rendered page), `setFavicon()`, `addBreadcrumb()`/`addNavigation()`/`addMenu()` (structured nav link
lists, not just arbitrary content).
`setTitle()` takes text: it holds it as an `XmlText`, escaped when printed (do not escape it first). The source
`addInlineScript()` adds is printed as it is, also by a document that escapes what it prints — see
[gotchas.md](https://mcp.mmoframework.fr/search/read?uri=mmoframework-skill%3A%2F%2Fmmo.core%2Fmmoframework%2Freferences%2Fgotchas.md#htmlprinter-does-not-escape-strings).
## Spreadsheets (`documents/xlsx/Document`)
Lazily autoloaded (see conventions.md). `xlsx\Document::File($filename)` opens an existing workbook;
`new xlsx\Document()` starts a blank one. Sheets/cells/styles are real, separate classes (`Sheet`, `Cell`,
`Column`, `RowPointer`, `styles\{Font,Fill,Border,Alignment,Color,Style}`) — this is a from-scratch OOXML
implementation, not a wrapper around another library. Useful statics: `Document::Cell($col, $row): string`
(→ `"B3"`-style reference), `Document::Index($cell): array` (reverse), `Document::Range(...)`/`LoopRange(...)`
for range strings. Render/save via the normal `Response::Xlsx($document)` or the document's own printer.
## Printers directly (rarely needed)
Each printer has a `Shared()` singleton and can be used standalone: `HtmlPrinter::Shared()->dump(...)` (debug
printing), `HtmlPrinter::Print($exception)` (render a throwable as HTML — used in error reporting/logs),
`HtmlPrinter::Print($value, new PrintOptions(escape: true))`.
An entity printed through `TextPrinter` or a printer extending it (`HtmlPrinter`, `XmlPrinter`, `ShellPrinter`: a
template's `$this->renderValue($user)`, a node holding it) is its `toArray()`, as `JsonPrinter` exports it: a `@hidden`
property is left out, even named in `fields`. A dump (`\MMO\dump($user)`) shows its properties, protected ones included,
but a hidden one as `[hidden]`: dump the value itself when you need it (`\MMO\dump($user['passhash'])`). Until 2026-09
an entity was printed property by property -- the password's hash of a user in any HTML or XML page printing it -- and
with its collection and repository (50 to 100 KB for one entity); a dump showed the hash too.
`JsonPrinter` exports an entity linked to another one in full (its own linked entities too), an entity met again as a
`$ref`, and **a list of stored entities inside a linked entity as their links** (`"<uid>title=..."`): until 2026-10 a
company's group listed every company of the group, each in full with its own group, and 50 employees with their
contract were 1.6 MB (400 KB since). The entity at the top still has its lists in full (`JsonPrinter::IsEntityList()`;
`tests/modules/TestEntitySaveFromClient.php`).
`CsvPrinter::Encode($rows, ...)` takes the rows themselves (an array, not a document): a plain `headers` list of
display titles is matched to the exported fields by position (one title per field, or it throws) — it used to
give a correct header row with blank data rows underneath (a real, fixed bug: see `CsvPrinter::Encode()`'s NOTE).