spora-ai / spora-plugin-office
Markdown <-> Office documents for Spora agents — Word (DOCX/ODT/RTF) and PowerPoint (PPTX) producers, a MIME refiner, media-library themes, and bundled skills.
Package info
github.com/spora-ai/spora-plugin-office
Type:spora-plugin
pkg:composer/spora-ai/spora-plugin-office
Requires
- php: ^8.4.1
- ext-dom: *
- ext-gd: *
- ext-mbstring: *
- ext-zip: *
- markdown-office/markdown-presentation: ^0.4
- markdown-office/markdown-word: ^0.4
- spora-ai/spora-core: >=0.30.0 <2.0.0
- symfony/yaml: ^7.4 || ^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- mockery/mockery: ^1.6
- pestphp/pest: ^5.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
Suggests
- spora-ai/spora-plugin-media-archive: Browse, filter, and download the DOCX and PPTX files this plugin renders.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 14:41:35 UTC
README
Markdown ⇄ Office documents for Spora agents — Word (.docx, .odt, .rtf)
and PowerPoint (.pptx). Backed by
markdown-office/markdown-word
and markdown-office/markdown-presentation,
and the media-archive producer / refiner registries in spora-core.
The plugin ships no LLM tool, no routes and no admin app. It contributes
five registrations to core's Media Archive and two skills, so the existing
media tool gains Office documents in both directions:
- Write —
media(action: "create_media")stores the Markdown, thenmedia(action: "create_derivative", format: "docx")renders it to a document the user downloads. - Read — a
.docxor a.pptxuploaded to a chat is extracted into anmdderivative, and that text is inlined into the agent's context. No tool call involved.
This package was renamed. It was
spora-ai/spora-plugin-word, and the GitHub repository is stillspora-ai/spora-plugin-worduntil the maintainer renames it. The old package stays published and is deprecated, not deleted:spora-fgrasslpinsspora-ai/spora-plugin-word: ^0.2.0, and a host that has not moved yet keeps working. Install the new name for everything new.Installing this package means removing the old one:
composer remove spora-ai/spora-plugin-wordThe two cannot both be installed. They register producers for the same (source MIME, format) pairs under different plugin and operation names, so the derivative's natural key —
(parent_id, format, producer_plugin, producer_operation)— splits in two. Core'sMediaDerivativeService::findProducer()returns the first registered match, and whichever plugin loses that race mints a second derivative for the same parent and format: a duplicate.docx, or a duplicate extract, beside the migrated one. The rename's data migration compounds it — it rewrites the old plugin's rows into the new plugin's identity, so the old plugin's own rows no longer match its natural key and it can only ever mint new ones.
Requires four PHP extensions
All four are hard requirements of the upstream packages — the plugin does
not degrade without them, and unlike ext-typst there is no
optional-extension path and no subset of the suite that runs without them.
| PHP | ^8.4.1 |
ext-zip |
* — a .docx is a zip archive; ZipArchive is the only way in |
ext-dom |
* — the renderer writes and rewrites word/document.xml |
ext-mbstring |
* — multibyte-safe string handling across the document model |
ext-gd |
* — rescaling an embedded image to imageMaxWidth; only reached when a readable local image is embedded |
| spora-core | >=0.30.0 — see the note below |
Check them before installing:
php -m | grep -E '^(zip|dom|mbstring|gd)$'
All four must be listed. On a shared host (cPanel / FTP) that does not already have them, the plugin is a hard install failure and there is no partial mode.
Escape hatch: --ignore-platform-req
Composer will refuse the install over any missing extension, so the useful question is what each one actually costs you if you skip it:
ext-zipandext-domare required in both directions. Without either, nothing works — no rendering and no reading back.ext-mbstringis required in both directions too: the reader usesmb_strtolowerwhen matching fonts and styles.ext-gdis the cheapest to skip. The reverse direction never calls into it, so DOCX→Markdown keeps working and the chat keeps the extracted text of every document it is given. Forward,gdis reached only when an embedded image is actually rescaled toimageMaxWidth, and the library catches the failure and falls back to the image's alt text. So ignoring it costs you rescaled embedded local images, not documents.
If that trade is acceptable — and on a host already running Spora, ext-dom is
present because spora-core requires it — install with:
composer require spora-ai/spora-plugin-office --ignore-platform-req=ext-gd
# or, to bypass all four at once:
composer require spora-ai/spora-plugin-office --ignore-platform-req=ext-zip --ignore-platform-req=ext-dom --ignore-platform-req=ext-mbstring --ignore-platform-req=ext-gd
Prefer enabling the extension. The flag exists for the host where you cannot.
The host has to allow cweagans/composer-patches
Both halves write a few things a released PHPPresentation or PHPWord cannot,
and get them from a patch applied to the installed package. Composer runs
those patches only when the host allows the plugin — allow-plugins is a
root-project setting, and spora-ai/installer routes spora-plugin packages
into the host's plugins/{name}/, so the host's tree is where the dependency
lands.
composer config allow-plugins.cweagans/composer-patches true
Without the entry the install stops with Composer's own message:
cweagans/composer-patches contains a Composer plugin which is blocked by your allow-plugins config.
With the entry set to false, or with --no-plugins, the install succeeds and
the two halves then fail differently. The slides half fails loudly, with
MarkdownPresentation\Exception\UnpatchedDependency — its message names the
plugin and says what to do, so the failure is diagnosable rather than
mysterious. The Word half raises nothing about the missing patch: the render
completes and writes a .docx that is not valid Office Open XML, and it stays
invalid until PHPWord releases the fixes the patch carries. The .odt and the
.rtf come out of other writers, which the patch does not touch.
A host that already runs the plugin has to rebuild its lock. The patches
that are applied are the ones in the host's own patches.lock.json, and that
file is written once and read from then on — a dependency that brings a new
patch does not rewrite it. A host whose lock predates the PHPWord patch
installs PHPWord unpatched, and the Word half then writes a .docx that is
not valid Office Open XML: the same silent failure as an install without the
plugin, and with no message at all. After the upgrade, rebuild the lock from
the packages that are installed now and apply it again:
composer patches-relock composer patches-repatch
A fresh install has no lock to be stale: it writes one from the packages' own declarations.
The patches carry PHPPresentation 1.2.0 and PHPWord 1.4.0 up to upstream's
master, so that the packages write the elements in the order ECMA-376 names
them and the parts of a package are found and read as they should be. When
those changes are released upstream, markdown-office drops the composer
plugin, the patches and the allow-plugins entry all at once. Until then the
entry is required.
spora-core version floor
The real requirement is the Spora\Services\MediaArchive\MediaMimeRefinerInterface
seam — the refiner registry below — which composer.json expresses as
spora-ai/spora-core >=0.30.0. Core v0.30.0 is tagged and indexed on
Packagist, so spora-core resolves from Packagist like every other plugin.
Install it the usual way:
composer require spora-ai/spora-plugin-office
A host running core older than v0.30.0 has no MediaMimeRefinerInterface and
must upgrade core.
OfficePlugin::onContainerBuilding() checks interface_exists() at boot and
throws PluginLoadFailedException naming the missing seam when the host's core
predates it, rather than registering two of the five contributions and leaving
a silently half-working plugin.
What's in the box
- 2 skills (
office-documents,office-presentations) — the two-call chain, the theme catalogue, the six documented round-trip losses, the image rules, the approval and idempotency rules. Each ships anexamples.mdbeside it and areference.md— the closed vocabulary of directives, keys and attributes — and every Markdown block in all six files is rendered by the test suite. - 5 media-archive registrations: four derivative producers and a MIME refiner (table below).
- 3 exception classes, mirroring the Typst plugin's shape.
- 1 plugin migration — the rows the rename touched (table below).
- No tools, no routes, no admin app, no frontend package — the download card
that renders a
.docxin chat is core'sMediaEmbed::fileCard(), so there is nospora-plugin-office-frontendto build or ship.
The data migration
The plugin's identity changed, and the rows that carry it carry two
vocabularies: pluginSlug() returns the Composer package name
(spora-plugin-word), not the manifest slug (word), and operationName()
returns the short slug (word.render). office_000001_rename_producer_identity.php
rewrites all of it:
| Column | Before | After |
|---|---|---|
media_derivatives.producer_plugin |
spora-plugin-word |
spora-plugin-office |
media_derivatives.producer_operation |
word.render / word.extract |
office.render / office.extract |
media_assets.plugin_slug |
spora-plugin-word |
spora-plugin-office |
media_assets.tool_name |
word.render / word.extract |
office.render / office.extract |
Both halves of each pair have to move together. The natural key of a derivative
is (parent_id, format, producer_plugin, producer_operation), and
MediaDerivativeService looks the row up by exactly that — a derivative whose
producer_plugin was rewritten while its producer_operation was not no
longer matches, so the service mints a second derivative instead of
refreshing the one that is there.
It is a plugin migration, in database/migrations/, because the plugin owns
its own identity and the rows are core's tables. Every file in that directory
has to start with the plugin's slug and an underscore — office_ — or
DatabaseSchemaInstaller::validateMigrationFilenames() refuses it.
The presentation.* operations are new and have no rows to rewrite.
The five registrations
OfficePlugin::onContainerBuilding() calls add() on two separate registries.
The producers share one seam — the round trip is two directions of the same
contract, so an md extract is a legal parent for a docx render — and the
refiner is the other. Registering only some of them leaves a plugin that
half-works rather than one that errors.
| Interface | Class | What it does |
|---|---|---|
MediaDerivativeProducerInterface |
Producers\MarkdownToOfficeProducer |
Markdown parent + format: "docx" / "odt" / "rtf" → document bytes as a new media_assets row. pluginSlug() = spora-plugin-office, operationName() = office.render — both are part of the idempotency natural key. |
MediaDerivativeProducerInterface |
Producers\OfficeToMarkdownProducer |
DOCX parent + format: "md" → GFM Markdown as a new media_assets row. operationName() = office.extract, distinct from the render half so the two rows never collapse onto one key. This is the half the chat reads. Its declared source formats are also what puts .docx on the upload allowlist — MediaAllowedTypesService unions every registered producer's source MIME, and nothing else in this plugin contributes an upload type. That union reaches allowedMimeTypes() only from core's md-derivative cut onwards; on an older core the entry came from the converter registry, which that cut deletes along with this PR. The two must land together — separated, the plugin boots fine and every .docx upload is a 415 at the gate. |
MediaDerivativeProducerInterface |
Producers\MarkdownToPresentationProducer |
Markdown deck parent + format: "pptx" → deck bytes. operationName() = presentation.render. The format is part of the natural key, so a deck and a document from the same parent are two rows and never one. |
MediaDerivativeProducerInterface |
Producers\PresentationToMarkdownProducer |
PPTX parent + format: "md" → the deck as Markdown. operationName() = presentation.extract. Its PPTX MIME is what puts .pptx on the upload allowlist. |
MediaMimeRefinerInterface |
Refiners\OfficeMimeRefiner |
Upgrades a coarse application/zip sniff to the DOCX or the PPTX MIME, but only when the archive actually contains word/document.xml or ppt/presentation.xml — so xlsx and epub are not mislabelled. Runs before the upload allowlist check. |
The refiner exists for a concrete reason: MimeSniffer feeds 4096 bytes to
finfo_buffer, which on most current libmagic builds reports a DOCX as its
proper OOXML type — but on older builds the same 4096 bytes come back
application/zip, and the upload is then rejected outright. PK\x03\x04
matches every zip, so the signature table cannot fix it; only opening the
archive can.
Themes in the media library
A theme is a top-level folder in the media library. Its name is the theme's name, and its files are the library's own:
Corporate/ ← the folder; "Corporate" is the theme's name
theme.yml ← the theme: its identity, its look, its styles
template.pptx ← the slides half's master (optional)
A theme is one file. The look a document is drawn in and the look a deck is
drawn in are two sections of the same theme.yml — styles: for the Word
half, dark: / colors: / fonts: / style: / layouts: for the slides
half — and what the two share is a theme's name, its description, its author
and its named text styles.
name: Corporate description: The house theme author: Ops styles: # the word half's slots heading.1: color: "1A2B3C" colors: # the slides half's palette accent1: "#4C1D95" textStyles: # named styles either half may write in brand: color: "0B5CAB" bold: true
The files of the old format — styles.yml, layouts.yml and
text-styles.yml — are refused when they sit beside a theme, each with a
message saying what to do: move the content into the section it became and
delete the file. A file that is quietly not read is a theme that looks like the
one its author wrote and is not, so the refusal is deliberate.
A document names one in its frontmatter — theme: corporate — and the folder's
UUID resolves as well, so a task can pin a theme by a value that survives a
rename. A media theme shadows a built-in of the same name, so the media
library's corporate is not the renderer shipped corporate.
The plugin reads the library through one class,
Finder\MediaLibraryFinder, which implements both upstream finder interfaces
(MarkdownWord\Files\FileFinder and MarkdownPresentation\Files\FileFinder)
and answers for one principal. A theme name is a folder name and never a
path, so theme: ../other-tenant names no folder of yours and resolves to
nothing.
Each half is served a projection of the manifest. Each half refuses a key
that is not its own, so one folder cannot hold one manifest unchanged for both:
the Word half reads styles: and the slides half reads colors: and the rest,
and each is handed the keys it reads. A key neither half reads is kept, so
the library refuses it with a sentence naming the whole vocabulary rather than
the manifest being quietly rewritten. The projection is the one lossy step in
the whole arrangement, and it is stated here, in one place, on purpose.
Media themes need a core that carries the folders feature — migration
0097, which arrives in core v0.33.0. The plugin's floor stays at
v0.30.0 because it degrades correctly: a core without media_folders has no
folders, so it has no media themes, and the library's own built-in themes are
the right answer. The media tool's list_folders operation arrives with
the same release, so on an older core there is nothing to call and the
built-in themes are the whole list.
The chain
Two calls on core's media tool. There is no office_document tool, by design.
// 1. store the Markdown { "action": "create_media", "content": "# Q3 Report\n\n…", "filename": "q3-report.md", "mime_type": "text/markdown" } // → data.asset_id // 2. render that asset to Word { "action": "create_derivative", "asset_id": "<asset_id from step 1>", "format": "docx" } // → data.derivative_id, data.asset_url
create_media is not idempotent — every call inserts a new row, so the
returned asset_id is the handle for everything downstream and a retry leaves a
duplicate in the user's library. create_derivative is idempotent on
(parent_id, format, producer_plugin, producer_operation), so re-rendering the
same parent in the same format is a safe retry and returns the same
derivative_id. It does rewrite the stored bytes: the existing derivative
is re-rendered in place.
The producer options are {"plain": true} (no decoration) and
{"theme": "<name>"} (a theme that outranks the document's own frontmatter).
plain is not purely cosmetic — see the round-trip note below.
Document assets render in chat as a download card rather than a bare markdown
link, emitted by core's MediaEmbed::fileCard() and styled by core's CSS. The
filename and the href are the entire contract.
Reading a .docx back
No tool call. The archive extracts a .docx into an md derivative — a real
media_assets row, reachable and re-derivable like any other — and the chat
inlines its text into the agent's context. "What does this contract say" is
answered from text the model can already see.
Behind the scenes the reverse direction is tightened for a chat attachment
rather than a file server: maxPartBytes is lowered from the library's 256 MiB
to 64 MiB, mediaDirectory stays null so no images are ever written to disk,
and the upstream zip-bomb / style-loop caps (maxEntries 4096,
maxStyleDepth 32) are kept. A corrupt or hostile .docx does not fail the
upload — the extraction failure is caught, logged at warning, and the chat
degrades to the asset's metadata block.
Round-trip limits
A .docx is a lower-fidelity form of the Markdown it came from. Six documented
losses, all silent: a table's header row returns bold; a fenced code block
returns without its language; a one-line fence returns as inline
code; a quote's own line breaks are joined into one line; the last line has
no trailing newline; and a word written in a named style (:brand[Bakery])
returns as the word — its weight kept, the style and the colour gone.
options: {"plain": true} loses the quote as well, because dropping the
renderer's quote style is exactly the case the reader cannot recognise — and
it also drops the code font, leaving fenced blocks as two bare prose paragraphs
with no fence at all. Nested bullet and ordered lists work at any depth, and
remote images are never fetched (the library has no HTTP client), so they fall
back to their alt text.
A .pptx is a package of positioned boxes. Reading a deck back gives the
words and their order; the type sizes, the colours of the text and the
geometry live in the theme and the master, and none of them travels. What does
travel is the theme — a deck records the theme it was drawn in, and a deck
read back names it — and a slide's background, which comes back as the
frontmatter that names it. The pictures do not travel
either: the reverse direction writes no media directory, so a picture in the
deck is left out of the extracted Markdown altogether — no reference to a file,
no alt text. The picture is still inside the .pptx; only the Markdown loses
it, and the Markdown is all the read-back gives you.
The agent-facing version of all of this, with the practical rules, is
skills/office-documents/SKILL.md and skills/office-presentations/SKILL.md.
Read them before changing how documents are produced.
Licensing
The plugin and the two upstream markdown-office
packages are all MIT. phpoffice/phpword, which does the actual Word
writing, is LGPL-3.0 — a copyleft licence, dynamically linked through
Composer, and not relicensed by this plugin's MIT. Nothing here vendors its
source.
Translations
This plugin ships a German catalogue at locales/de.json,
declared through translationsPaths() in src/OfficePlugin.php. The host merges
it under core's own catalogue, so a German install reads the plugin's operator
copy in German.
The key is the English source string, byte for byte — editing the English
in the source changes the key, so the old entry stops matching and the new
English has no entry. The gate is this repository's own
tests/Unit/LocaleCatalogTest.php: it fails on an empty translation, on a
translation identical to its key, and on a key the source no longer produces.
The catalogue holds one string: the plugin.json description. The plugin
ships no tool, no admin panel and no setting — the LLM drives both conversion
directions through core's own media tool — so that description is the only
operator copy it owns.
Never translated — these strings are read by the model, so the operator sees exactly what the model sees:
#[Tool].description#[ToolOperation].description#[ToolParameter].description- every
#[ToolSetting].labeland its option labels, including the labels of settings that carryexposeToLlm: true
See the plugin author guide → Translations.
Bootstrap
composer install composer test:parallel # Pest composer analyse # PHPStan level 5 composer format # PHP-CS-Fixer
The suite has no optional-extension skip: it needs all four extensions, so it
runs everywhere CI does. Binary fixtures are built with MarkdownToWord
rather than checked into the repository, the same approach as the Typst
plugin's tests/Support/TemplateFactory.php.
Local development
In spora-local, add the plugin as a path repo:
// spora-local/composer.json { "repositories": [ { "type": "path", "url": "../spora-plugin-office", "options": { "symlink": true } } ], "require": { "spora-ai/spora-plugin-office": "@dev" } }
Then composer update spora-ai/spora-plugin-office. Exercise the whole thing:
composer dev → ask the agent for a document → attach a .docx and ask a
question about it. The five registrations are added on every boot, so no cache
clear is needed between runs.
Layout
.
├── composer.json # spora-ai/spora-plugin-office + the two markdown-office packages + the four ext-*
├── plugin.json # manifest (class=Spora\Plugins\Office\OfficePlugin, slug=office, icon=file-text)
├── database/migrations/
│ └── office_000001_rename_producer_identity.php # the rows the rename touched
├── locales/
│ └── de.json # German catalogue, keyed by the English source string
├── src/
│ ├── OfficePlugin.php # entry point — binds DI, registers both discoveries
│ ├── Finder/
│ │ ├── LibraryHalf.php # which half of markdown-office a finder serves
│ │ ├── MediaLibraryFinder.php # both upstream FileFinder interfaces, one principal
│ │ ├── MediaLibrarySource.php # the seam the finder reads the library through
│ │ └── EloquentMediaLibrarySource.php # that seam over core's tables
│ ├── Producers/
│ │ ├── MarkdownToOfficeProducer.php # MediaDerivativeProducerInterface impl (md → docx/odt/rtf)
│ │ ├── OfficeToMarkdownProducer.php # MediaDerivativeProducerInterface impl (docx → md)
│ │ ├── MarkdownToPresentationProducer.php # MediaDerivativeProducerInterface impl (md → pptx)
│ │ └── PresentationToMarkdownProducer.php # MediaDerivativeProducerInterface impl (pptx → md)
│ ├── Refiners/
│ │ └── OfficeMimeRefiner.php # MediaMimeRefinerInterface impl (application/zip → DOCX/PPTX)
│ ├── Services/
│ │ ├── OfficeConversion.php # shared size caps, deprecation suppressor, exception map
│ │ └── OfficeSourceBytes.php # shared parent-asset read (data_url / local), AssetStorage map
│ └── Exceptions/
│ ├── OfficeDocumentException.php
│ ├── OfficeInvalidArgumentException.php
│ └── OfficeRuntimeException.php
├── skills/
│ ├── office-documents/ # the chain, the themes, the six round-trip losses
│ │ ├── SKILL.md
│ │ ├── examples.md # three worked exchanges
│ │ └── reference.md # the directives, their attributes, the text styles
│ └── office-presentations/ # the deck dialect, the themes, the deck's limits
│ ├── SKILL.md
│ ├── examples.md # five worked exchanges
│ └── reference.md # the layouts, the markers, the shapes, the colours
├── tests/
│ ├── Pest.php # resets all three discovery registries in afterEach
│ ├── bootstrap.php
│ ├── Unit/
│ │ ├── ClaimsTest.php # pins the claims a behaviour change would make false
│ │ └── Skills/ # renders every Markdown block the skills show
│ └── Feature/
└── .github/workflows/ci.yml # pest + phpstan + cs-fixer + skeleton boot + sonarcloud
CI
Five jobs (the standard Spora-plugin CI, plus the skeleton boot):
test— Pest onubuntu-latest, PHP 8.4 + 8.5. PHP 8.4 also writes the coverage report the SonarCloud job ingests.static-analysis— PHPStan level 5 (memory limit 512M).code-style—php-cs-fixerdry-run.integration— boots the plugin against a freshspora-corecheckout to prove the manifest parses, the DI bindings resolve and the five registrations hook in.sonarcloud— SonarCloud scan, gated ontest.
Every job installs all four extensions (zip, dom, mbstring, gd).
There is no --ignore-platform-req and no continue-on-error extension step —
unlike the Typst plugin, which skips ext-typst, the whole suite here depends on
them, and a silently missing gd would fail deep inside a test with a message
that says nothing about the environment.
The SonarCloud project this repo analyses into is
spora-ai_spora-plugin-word; it is not renamed with the package, because the
project key is bound to the GitHub repository and the maintainer performs that
rename. The header of sonar-project.properties says the same thing next to
the key itself.
Publishing
git tag v0.3.0 && git push --tags.- Configure Packagist to auto-pull from the GitHub repository.
The runtime reads the version from the git tag via
Composer\InstalledVersions::getPrettyVersion().
Authoring guidelines
Framework-level conventions — which classes are plugin-stable, what's framework-internal, schema versioning, deprecation policy — live in the Spora docs → Plugin system. Do not import framework-internal driver classes from a plugin.