xref: /plugin/totext/AGENTS.md (revision fd4f7c47d290ec79c2b911626d1d262c1e3d161e)
1# AGENTS.md
2
3This file provides guidance to AI agents when working with code in this repository.
4
5**Always update this file automatically when you learn new things about the code base!**
6
7## Project Overview
8
9This is the totext DokuWiki plugin. It extracts plain text from various file
10formats using **PHP only** — no shell-outs and no external binaries.
11
12It exposes two entry points:
13
141. **CLI component** (`cli.php`, class `cli_plugin_totext`) —
15   `php bin/plugin.php totext <file>` prints the body text followed by the
16   metadata (`Key: value` lines, separated by a blank line) to STDOUT. `--text`
17   (`-t`) prints only the body text; `--meta` (`-m`) prints only the metadata;
18   the two are mutually exclusive "only this" switches. If a requested half fails
19   but the other is salvaged, it warns on STDERR and still prints the salvaged
20   half (exit 0); if *nothing* usable came through for what was requested it
21   re-throws, so the CLI exception handler reports it and exits non-zero.
222. **Helper component** (`helper.php`, class `helper_plugin_totext`) — gives other
23   plugins the same functionality via `plugin_load('helper', 'totext')`:
24   `extract($path): ExtractionResult` (both outputs; never throws for a *partial*
25   failure — inspect `->textError`/`->metadataError`), `extractMetadata($path):
26   array` (throws if metadata failed), and `extractText($path): string` (throws
27   if text failed — preserves the throw-on-failure contract the docsearch plugin
28   relies on to fall back to its own converters).
29
30### Architecture
31
32- `Extractor/ExtractorInterface.php` — the contract every extractor implements.
33  It is deliberately just `extract(): ExtractionResult`: extractors are dumb
34  workers and do not know their own extensions. All extension knowledge lives in
35  the factory.
36- `Extractor/ExtractionResult.php` — the `final` readonly value object every
37  extractor returns: `string $text` (body text), `array<string,string>
38  $metadata` (canonical key → value map), and `?\Throwable $textError` /
39  `?\Throwable $metadataError` recording a per-half failure (plus `isComplete()`).
40  Produced from **one** parse of the file. See *Metadata* below for the
41  vocabulary and *Failure model* below for the error semantics.
42- `Extractor/ExtractorFactory.php` — the sole routing authority (`forFile()`,
43  `extract()` → `ExtractionResult`, `supportedExtensions()`). Its `EXTRACTORS`
44  constant (extension → extractor class) is the single source of truth:
45  `forFile()` looks the file's extension up in it and `supportedExtensions()`
46  returns its keys. Add a new format with one new extractor class plus one entry
47  in that map — extensions are never written down anywhere else.
48- `Extractor/AbstractZipXmlExtractor.php` — shared base for all ZIP-of-XML formats
49  (OOXML *and* OpenDocument). `extract()` does one unzip — **opening the archive
50  is the total-failure gate**: if it can't be unpacked, nothing is recoverable
51  and it throws. Once open, the abstract `extractText()` and `extractMetadata()`
52  run as independent halves; whichever throws has its error recorded on the
53  result (normalised to an `ExtractionException` via `ExtractionException::wrap()`,
54  the shared wrap-a-caught-error helper) while the other half is still returned
55  (see *Failure model*). Provides
56  `readPart()`, `listParts()`, temp-dir handling, two streaming `XMLReader` text
57  walkers — `extractTextFromXml()` (text in a wrapper element, used by OOXML) and
58  `extractAllTextFromXml()` (text as character data, used by OpenDocument) — and
59  the generic `mapMetadataFromXml($xml, $map, $multiValueKeys)` primitive (walks
60  a metadata part, matching element **local names** to canonical keys, dropping
61  empty values, accumulating multi-value keys). The unpack temp dir is created in
62  DokuWiki's own temp dir (`$conf['tmpdir']`) via core's `io_mktmpdir()` and
63  removed with core's `io_rmdir($dir, true)` — never the system temp dir.
64  `extract()` removes it promptly in a `finally` block; the class `__destruct()`
65  repeats the cleanup as a safety net so the dir is gone even if the process
66  dies (e.g. a fatal error) before `finally` runs.
67- `Extractor/AbstractOoxmlExtractor.php` / `Extractor/AbstractOdfExtractor.php` —
68  intermediary classes between the ZIP base and the concrete extractors. They
69  exist solely to declare each family's metadata source **once**: OOXML reads
70  `docProps/core.xml` (`CORE_META_MAP`) + `docProps/app.xml` (`APP_META_MAP`);
71  ODF reads `meta.xml` (`META_MAP`, with `meta:keyword` accumulating into
72  `Keywords`). There is no family autodetection — each concrete ZIP extractor
73  simply `extends` the matching intermediary and carries no metadata code.
74- Concrete extractors: `DocxExtractor`/`XlsxExtractor`/`PptxExtractor` (extend
75  `AbstractOoxmlExtractor`), `OdtExtractor`/`OdsExtractor`/`OdpExtractor` (extend
76  `AbstractOdfExtractor`), `PdfExtractor`, `TextExtractor` (txt/csv/md/log/...),
77  `ImageExtractor` (EXIF/IPTC metadata of jpg/tiff — metadata only, not OCR).
78- `Exception/ExtractionException.php` and `Exception/UnsupportedFormatException.php`
79  (the latter extends the former). The helper and factory **throw**; the CLI does
80  not catch, relying on `splitbrain\phpcli\CLI` to print the message and exit non-zero.
81
82### Metadata
83
84`extract()` returns a metadata map keyed by a **single canonical vocabulary**,
85identical across all formats so consumers never special-case the source:
86
87`Title, Author, Subject, Keywords, Description, Created, Modified, Language,
88Producer` (all formats) plus `Copyright` (**image-only**). Values are non-empty
89UTF-8 strings; empty values are dropped (never stored as blank keys).
90
91- **`Producer`** is "what produced this file" — the authoring application for
92  office/PDF (PDF: `Producer`, else `Creator`) **and** the camera/software for an
93  image (`Simple.Camera` / EXIF `Model`). There is intentionally no separate
94  `Camera` key.
95- **`Copyright` is image-only by spec, not by accident.** OOXML
96  `CT_CoreProperties` (ECMA-376 / ISO 29500) has no `dc:rights`; ODF
97  `<office:meta>` has no `dc:rights` either; the PDF Info dictionary has no
98  copyright key (it lives only in an XMP stream prinsfrank does not surface).
99  Only IPTC/EXIF have dedicated copyright fields, which `ImageExtractor` reads.
100- Each family's source map is declared **once** in its intermediary class
101  (`AbstractOoxmlExtractor`, `AbstractOdfExtractor`); `PdfExtractor` and
102  `ImageExtractor` map their own native fields. The generic
103  `mapMetadataFromXml()` matches by element **local name** (namespace-agnostic) —
104  safe because OOXML core/app and ODF meta use distinct local names.
105- **Image behavior change:** `extractText()` on an image now returns `''` (its
106  descriptive fields moved into `metadata`), not the old `"Caption: …\nAuthor:
107  …"` text block. `ImageExtractor`'s field-map keys were renamed to the canonical
108  vocabulary (`Caption`→`Description`, `Date`→`Created`, `Camera`→`Producer`).
109
110### Failure model
111
112Text and metadata are extracted **independently**, so one half can fail while
113the other is salvaged. Each extractor has a single **total-failure gate** — the
114step that, if it fails, leaves *nothing* recoverable (the container won't unzip,
115the PDF won't parse, the file can't be read). The gate throws an
116`ExtractionException`. Everything after the gate is a per-half best-effort:
117
118- A half that throws has its error recorded in `ExtractionResult::$textError` /
119  `$metadataError`; its output is left empty (`''` / `[]`) and the *other* half
120  is still returned. `extract()` itself does **not** throw for a partial failure.
121- An output that is empty **by design** is not a failure and leaves its error
122  `null`: images have no body text (`text === ''`, `textError === null`), plain
123  text has no metadata (`metadata === []`, `metadataError === null`). Likewise a
124  document that simply carries no metadata yields `[]` with no error — only an
125  actual read/parse exception sets `metadataError`.
126- Single-output formats fold into this cleanly: their one product *is* the gate.
127  `TextExtractor` (text-only) and `ImageExtractor` (metadata-only) throw on
128  failure and never set the error fields.
129- The helper's `extractText()` / `extractMetadata()` **re-throw** the relevant
130  half's recorded error, so a single-output caller keeps its throw-on-failure
131  contract even when the other half was salvaged. The CLI warns on STDERR about
132  a failed half but still prints the salvaged half (exit 0); only when *nothing*
133  usable came through for what was requested does it re-throw (non-zero exit).
134
135### Dependencies
136
137- `prinsfrank/pdfparser` (MIT, zero PHP dependencies — pulls only
138  `prinsfrank/glyph-lists`) is bundled in the committed `vendor/` directory (pulled
139  via the plugin's own `composer.json`). We track the **cosmocode fork's `dev`
140  branch** (`dev-dev`, VCS repo `github.com/cosmocode/prinsfrank-pdfparser`) rather
141  than an upstream tag: it carries fixes not yet released upstream (native UTF-16BE
142  Info-string decoding and Form-XObject text extraction, both noted below).
143  DokuWiki core auto-requires
144  `lib/plugins/totext/vendor/autoload.php` for enabled plugins. `PdfExtractor`
145  parses once via `(new PrinsFrank\PdfParser\PdfParser())->parseFile($path)`, takes
146  the body from `->getText()` and reads the Info dictionary
147  (`getInformationDictionary()`) best-effort for metadata — the default in-memory
148  mode, which benchmarked both faster and far lighter than the previous
149  smalot/cosmocode fork (no `setRetainImageContent` tuning needed). It requires
150  `ext-gd`/`ext-iconv`/`ext-zlib`, enforced transitively by the package.
151- **UTF-16BE Info strings** decode natively on the cosmocode `dev` fork, so
152  `extractMetadata()` just `trim()`s the values — no shim. (`tika-sample.pdf`'s
153  Author `Bertrand Delacrétaz` exercises this: the é comes through correctly.)
154  Stock upstream ≤ v3.1.0 needed a `normalizePdfString()` shim here (mojibake `þÿ`
155  → ISO-8859-1 → `iconv` UTF-16BE→UTF-8); it was removed when we moved to the fork.
156- **Form-XObject text** (page content painted with the `Do` operator — common in
157  Quartz/macOS, Firefox and Chrome PDFs, including `_test/data/tika-sample.pdf`) is
158  extracted by the cosmocode `dev` fork; `PdfExtractorTest::testExtractsText` and the
159  factory roundtrip's `pdf` case rely on it. Stock upstream ≤ v3.1.0 does **not**
160  extract this text (metadata was unaffected either way, since the Info dictionary is
161  read independently of `getText()`).
162- `splitbrain\PHPArchive\Zip` is **not** bundled — core provides it globally.
163- Text encoding defers to core's `dokuwiki\Utf8\Clean` / `dokuwiki\Utf8\Conversion`;
164  JPEG metadata uses core's `JpegMeta`; TIFF metadata uses the `exif` extension.
165
166Legacy binary Office formats (`.doc`/`.xls`/`.ppt`) are intentionally unsupported.
167
168## Automated Testing
169
170Tests run via DokuWiki's PHPUnit-based testing framework. The calls MUST be made from within the plugin's repository root using a relative path to the `bin/plugin.php` script!
171
172```bash
173# Tests must be run from repository root
174../../../bin/plugin.php dev test
175
176# run individual test file
177../../../bin/plugin.php dev test _test/GeneralTest.php
178
179# create a new test file
180../../../bin/plugin.php dev addTest MyClass
181```
182
183DokuWiki provides useful helper methods for testing:
184
185* `DokuWikiTest::getInaccessibleProperty()` to access private/protected properties
186* `DokuWikiTest::callInaccessibleMethod` to execute private/protected methods
187* read `../../../_test/core/DokuWikiTest.php` for more helper methods
188* use `../../../_test/TestRequest.php` to simulate HTTP requests for integration tests
189* use `../../../_test/phpQuery-onefile.php` if you need to parse HTML in tests
190
191Each test run will provide a fresh DokuWiki instance in a temporary directory via the default setupBeforeClass methods.
192
193### Test fixtures
194
195**All tests run against real files — never hand-built containers, never
196synthetic content.** A hand-authored fixture only encodes our *belief* about a
197format; if that belief is wrong the extractor is written to match and both agree
198on a fiction. So every sample is a genuine in-the-wild document, taken verbatim
199from the **Apache Tika test corpus** (Apache-2.0) and committed under
200`_test/data`. `_test` is `export-ignore`d, so these binaries never ship in
201release tarballs — only the git repo holds them.
202
203* **Provenance** — `_test/data/README.md` records, for every committed file, its
204  original Tika name + upstream path + license. Keep it accurate when files
205  change: committing third-party binaries here is fine *only* with that record.
206* **Refresh** — `_test/data/download.sh` re-downloads each file from its exact
207  upstream Tika path (needs only `curl`). The committed copies are authoritative;
208  the script just refreshes them and must reproduce them byte-for-byte. Every
209  committed file keeps a `tika-` filename prefix to mark its origin.
210* **What the samples cover** — `tika-sample.docx` (titles, headings, nested
211  tables, hyperlinks, custom style, header/footer); `tika-various.docx` (lists,
212  footnotes, Japanese + four-byte Gothic = multibyte UTF-8); `tika-sample.xlsx`
213  (three named sheets, tab-separated cells); `tika-sample.pptx` (three ordered
214  slides); `tika-various.pptx` (speaker notes); `tika-sample.odt` (one
215  paragraph); `tika-sample.ods` (numeric grid); `tika-sample.odp` (two slides);
216  `tika-sample.pdf` (the Tika homepage, full prose); `tika-meta.jpg` (Photoshop
217  IPTC caption/by-line/keywords + EXIF camera — IPTC is non-UTF-8, so only
218  ASCII-safe substrings are asserted); `tika-meta.tiff` (EXIF ImageDescription);
219  `tika-plain.jpg` (no metadata → empty string); `tika-sample.txt` (multilingual
220  UTF-8 pangram).
221* **Error-path tests** — derived from real files, not fabricated: `Samples.php`
222  exposes `withoutPart()` (unpack a real container minus one ZIP member and repack
223  it via php-archive, e.g. a DOCX missing `word/document.xml`) and `corrupt()`
224  (non-archive bytes); both manage their own temp files, and `path()` resolves a
225  committed sample. There is no fixture *builder*. Note the failure model (above):
226  `corrupt()` trips the total-failure gate so `extract()` **throws**, whereas
227  `withoutPart()` removing only the body part (`word/document.xml`, `content.xml`)
228  leaves the container openable, so `extract()` returns a **partial result** —
229  `textError` set, the independent metadata (`docProps/*`, `meta.xml`) salvaged.
230  `HelperTest` separately locks the `extractText()` re-throw contract.
231* **Edges no clean real file covers are not tested** — deliberately dropped when
232  the corpus had no document that targets them without inventing structure: XLSX
233  custom sheet-name↔file *re*ordering (only normal multi-sheet order is tested),
234  ODS merged/covered cells + multi-paragraph cells, and the Windows XP UTF-16LE
235  EXIF tag. Asserting these would mean asserting against invented inputs.
236
237### Format-specific gotchas learned
238
239* **XLSX sheet order/names** must be resolved through `xl/workbook.xml` +
240  `xl/_rels/workbook.xml.rels` (name → r:id → worksheet file). Worksheet file
241  numbering does NOT have to match tab order, so pairing sorted filenames with
242  names positionally mismatches them. `XlsxExtractor` follows the relationships
243  and only falls back to positional/`SheetN` naming when the workbook or its
244  rels are absent.
245* **EXIF "XP" tags** (TIFF): PHP's `exif_read_data` already decodes the Windows
246  XP tags and exposes them in a `WINXP` section under the short names
247  `Title`/`Comment`/`Author`/`Keywords`/`Subject` (UTF-8) — NOT as `XPTitle`
248  etc. `ImageExtractor::EXIF_FIELDS` lists the short names first and keeps the
249  `XP*` raw names (decoded from UTF-16LE by `normaliseExifValue()`) as a
250  cross-version fallback. This path still exists but is **no longer covered by a
251  fixture** — no real image in the corpus carries Windows XP tags.
252
253**Important:** Test classes that need the plugin must set `protected $pluginsEnabled = ['totext'];` to enable it in the test environment.
254
255**Important:** `setUp()` and `tearDown()` methods must be `public` (not `protected`) to match the `DokuWikiTest` base class.
256
257
258## Caching
259
260DokuWiki may cache JavaScript, CSS and rendered output. To reset the cache just touch the config file
261
262```bash
263touch ../../../conf/local.php
264```
265
266## Linting, Formatting and Conventions
267
268Adhere to PSR-12 coding standards. Always add proper docblocks with descriptions, parameter types, and return types to all classes, methods and functions.
269
270```bash
271# Lint PHP files using PHP_CodeSniffer (must be run from repo root)
272../../../bin/plugin.php dev check
273
274# Auto-Fix formatting issues using PHP_CBF and Rector (must be run from repo root)
275../../../bin/plugin.php dev fix
276```
277
278## Plugin Architecture
279
280Inspect the base plugin classes in `../../../inc/Extension/` to learn about the plugin system architecture.
281
282```bash
283# add new plugin components (must be run from repo root)
284../../../bin/plugin.php dev addComponent <type>
285# e.g.
286../../../bin/plugin.php dev addComponent action
287# if multiple of the same type are needed, give a name:
288../../../bin/plugin.php dev addComponent action foobar
289# -> creates action/foobar.php
290```
291
292Additional classes are autoloaded when using the `dokuwiki\plugin\totext` namespace.
293