This file provides guidance to AI agents when working with code in this repository.
Always update this file automatically when you learn new things about the code base!
This is the totext DokuWiki plugin. It extracts plain text from various file formats using PHP only — no shell-outs and no external binaries.
It exposes two entry points:
- CLI component (
cli.php, classcli_plugin_totext) —php bin/plugin.php totext <file>prints the body text followed by the metadata (Key: valuelines, separated by a blank line) to STDOUT.--text(-t) prints only the body text;--meta(-m) prints only the metadata; the two are mutually exclusive "only this" switches. If a requested half fails but the other is salvaged, it warns on STDERR and still prints the salvaged half (exit 0); if nothing usable came through for what was requested it re-throws, so the CLI exception handler reports it and exits non-zero. - Helper component (
helper.php, classhelper_plugin_totext) — gives other plugins the same functionality viaplugin_load('helper', 'totext'):extract($path): ExtractionResult(both outputs; never throws for a partial failure — inspect->textError/->metadataError),extractMetadata($path): array(throws if metadata failed), andextractText($path): string(throws if text failed — preserves the throw-on-failure contract the docsearch plugin relies on to fall back to its own converters).
Extractor/ExtractorInterface.php— the contract every extractor implements. It is deliberately justextract(): ExtractionResult: extractors are dumb workers and do not know their own extensions. All extension knowledge lives in the factory.Extractor/ExtractionResult.php— thefinalreadonly value object every extractor returns:string $text(body text),array<string,string> $metadata(canonical key → value map), and?\Throwable $textError/?\Throwable $metadataErrorrecording a per-half failure (plusisComplete()). Produced from one parse of the file. See Metadata below for the vocabulary and Failure model below for the error semantics.Extractor/ExtractorFactory.php— the sole routing authority (forFile(),extract()→ExtractionResult,supportedExtensions()). ItsEXTRACTORSconstant (extension → extractor class) is the single source of truth:forFile()looks the file's extension up in it andsupportedExtensions()returns its keys. Add a new format with one new extractor class plus one entry in that map — extensions are never written down anywhere else.Extractor/AbstractZipXmlExtractor.php— shared base for all ZIP-of-XML formats (OOXML and OpenDocument).extract()does one unzip — opening the archive is the total-failure gate: if it can't be unpacked, nothing is recoverable and it throws. Once open, the abstractextractText()andextractMetadata()run as independent halves; whichever throws has its error recorded on the result (normalised to anExtractionExceptionviaExtractionException::wrap(), the shared wrap-a-caught-error helper) while the other half is still returned (see Failure model). ProvidesreadPart(),listParts(), temp-dir handling, two streamingXMLReadertext walkers —extractTextFromXml()(text in a wrapper element, used by OOXML) andextractAllTextFromXml()(text as character data, used by OpenDocument) — and the genericmapMetadataFromXml($xml, $map, $multiValueKeys)primitive (walks a metadata part, matching element local names to canonical keys, dropping empty values, accumulating multi-value keys). The unpack temp dir is created in DokuWiki's own temp dir ($conf['tmpdir']) via core'sio_mktmpdir()and removed with core'sio_rmdir($dir, true)— never the system temp dir.extract()removes it promptly in afinallyblock; the class__destruct()repeats the cleanup as a safety net so the dir is gone even if the process dies (e.g. a fatal error) beforefinallyruns.Extractor/AbstractOoxmlExtractor.php/Extractor/AbstractOdfExtractor.php— intermediary classes between the ZIP base and the concrete extractors. They exist solely to declare each family's metadata source once: OOXML readsdocProps/core.xml(CORE_META_MAP) +docProps/app.xml(APP_META_MAP); ODF readsmeta.xml(META_MAP, withmeta:keywordaccumulating intoKeywords). There is no family autodetection — each concrete ZIP extractor simplyextendsthe matching intermediary and carries no metadata code.- Concrete extractors:
DocxExtractor/XlsxExtractor/PptxExtractor(extendAbstractOoxmlExtractor),OdtExtractor/OdsExtractor/OdpExtractor(extendAbstractOdfExtractor),PdfExtractor,TextExtractor(txt/csv/md/log/...),ImageExtractor(EXIF/IPTC metadata of jpg/tiff — metadata only, not OCR). Exception/ExtractionException.phpandException/UnsupportedFormatException.php(the latter extends the former). The helper and factory throw; the CLI does not catch, relying onsplitbrain\phpcli\CLIto print the message and exit non-zero.
extract() returns a metadata map keyed by a single canonical vocabulary,
identical across all formats so consumers never special-case the source:
Title, Author, Subject, Keywords, Description, Created, Modified, Language, Producer (all formats) plus Copyright (image-only). Values are non-empty
UTF-8 strings; empty values are dropped (never stored as blank keys).
Produceris "what produced this file" — the authoring application for office/PDF (PDF:Producer, elseCreator) and the camera/software for an image (Simple.Camera/ EXIFModel). There is intentionally no separateCamerakey.Copyrightis image-only by spec, not by accident. OOXMLCT_CoreProperties(ECMA-376 / ISO 29500) has nodc:rights; ODF<office:meta>has nodc:rightseither; the PDF Info dictionary has no copyright key (it lives only in an XMP stream prinsfrank does not surface). Only IPTC/EXIF have dedicated copyright fields, whichImageExtractorreads.- Each family's source map is declared once in its intermediary class
(
AbstractOoxmlExtractor,AbstractOdfExtractor);PdfExtractorandImageExtractormap their own native fields. The genericmapMetadataFromXml()matches by element local name (namespace-agnostic) — safe because OOXML core/app and ODF meta use distinct local names. - Image behavior change:
extractText()on an image now returns''(its descriptive fields moved intometadata), not the old"Caption: …\nAuthor: …"text block.ImageExtractor's field-map keys were renamed to the canonical vocabulary (Caption→Description,Date→Created,Camera→Producer).
Text and metadata are extracted independently, so one half can fail while
the other is salvaged. Each extractor has a single total-failure gate — the
step that, if it fails, leaves nothing recoverable (the container won't unzip,
the PDF won't parse, the file can't be read). The gate throws an
ExtractionException. Everything after the gate is a per-half best-effort:
- A half that throws has its error recorded in
ExtractionResult::$textError/$metadataError; its output is left empty (''/[]) and the other half is still returned.extract()itself does not throw for a partial failure. - An output that is empty by design is not a failure and leaves its error
null: images have no body text (text === '',textError === null), plain text has no metadata (metadata === [],metadataError === null). Likewise a document that simply carries no metadata yields[]with no error — only an actual read/parse exception setsmetadataError. - Single-output formats fold into this cleanly: their one product is the gate.
TextExtractor(text-only) andImageExtractor(metadata-only) throw on failure and never set the error fields. - The helper's
extractText()/extractMetadata()re-throw the relevant half's recorded error, so a single-output caller keeps its throw-on-failure contract even when the other half was salvaged. The CLI warns on STDERR about a failed half but still prints the salvaged half (exit 0); only when nothing usable came through for what was requested does it re-throw (non-zero exit).
prinsfrank/pdfparser(MIT, zero PHP dependencies — pulls onlyprinsfrank/glyph-lists) is bundled in the committedvendor/directory (pulled via the plugin's owncomposer.json). We track the cosmocode fork'sdevbranch (dev-dev, VCS repogithub.com/cosmocode/prinsfrank-pdfparser) rather than an upstream tag: it carries fixes not yet released upstream (native UTF-16BE Info-string decoding and Form-XObject text extraction, both noted below). DokuWiki core auto-requireslib/plugins/totext/vendor/autoload.phpfor enabled plugins.PdfExtractorparses once via(new PrinsFrank\PdfParser\PdfParser())->parseFile($path), takes the body from->getText()and reads the Info dictionary (getInformationDictionary()) best-effort for metadata — the default in-memory mode, which benchmarked both faster and far lighter than the previous smalot/cosmocode fork (nosetRetainImageContenttuning needed). It requiresext-gd/ext-iconv/ext-zlib, enforced transitively by the package.- UTF-16BE Info strings decode natively on the cosmocode
devfork, soextractMetadata()justtrim()s the values — no shim. (tika-sample.pdf's AuthorBertrand Delacrétazexercises this: the é comes through correctly.) Stock upstream ≤ v3.1.0 needed anormalizePdfString()shim here (mojibakeþÿ→ ISO-8859-1 →iconvUTF-16BE→UTF-8); it was removed when we moved to the fork. - Form-XObject text (page content painted with the
Dooperator — common in Quartz/macOS, Firefox and Chrome PDFs, including_test/data/tika-sample.pdf) is extracted by the cosmocodedevfork;PdfExtractorTest::testExtractsTextand the factory roundtrip'spdfcase rely on it. Stock upstream ≤ v3.1.0 does not extract this text (metadata was unaffected either way, since the Info dictionary is read independently ofgetText()). splitbrain\PHPArchive\Zipis not bundled — core provides it globally.- Text encoding defers to core's
dokuwiki\Utf8\Clean/dokuwiki\Utf8\Conversion; JPEG metadata uses core'sJpegMeta; TIFF metadata uses theexifextension.
Legacy binary Office formats (.doc/.xls/.ppt) are intentionally unsupported.
Tests 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!
# Tests must be run from repository root
../../../bin/plugin.php dev test
# run individual test file
../../../bin/plugin.php dev test _test/GeneralTest.php
# create a new test file
../../../bin/plugin.php dev addTest MyClassDokuWiki provides useful helper methods for testing:
DokuWikiTest::getInaccessibleProperty()to access private/protected propertiesDokuWikiTest::callInaccessibleMethodto execute private/protected methods- read
../../../_test/core/DokuWikiTest.phpfor more helper methods - use
../../../_test/TestRequest.phpto simulate HTTP requests for integration tests - use
../../../_test/phpQuery-onefile.phpif you need to parse HTML in tests
Each test run will provide a fresh DokuWiki instance in a temporary directory via the default setupBeforeClass methods.
All tests run against real files — never hand-built containers, never
synthetic content. A hand-authored fixture only encodes our belief about a
format; if that belief is wrong the extractor is written to match and both agree
on a fiction. So every sample is a genuine in-the-wild document, taken verbatim
from the Apache Tika test corpus (Apache-2.0) and committed under
_test/data. _test is export-ignored, so these binaries never ship in
release tarballs — only the git repo holds them.
- Provenance —
_test/data/README.mdrecords, for every committed file, its original Tika name + upstream path + license. Keep it accurate when files change: committing third-party binaries here is fine only with that record. - Refresh —
_test/data/download.shre-downloads each file from its exact upstream Tika path (needs onlycurl). The committed copies are authoritative; the script just refreshes them and must reproduce them byte-for-byte. Every committed file keeps atika-filename prefix to mark its origin. - What the samples cover —
tika-sample.docx(titles, headings, nested tables, hyperlinks, custom style, header/footer);tika-various.docx(lists, footnotes, Japanese + four-byte Gothic = multibyte UTF-8);tika-sample.xlsx(three named sheets, tab-separated cells);tika-sample.pptx(three ordered slides);tika-various.pptx(speaker notes);tika-sample.odt(one paragraph);tika-sample.ods(numeric grid);tika-sample.odp(two slides);tika-sample.pdf(the Tika homepage, full prose);tika-meta.jpg(Photoshop IPTC caption/by-line/keywords + EXIF camera — IPTC is non-UTF-8, so only ASCII-safe substrings are asserted);tika-meta.tiff(EXIF ImageDescription);tika-plain.jpg(no metadata → empty string);tika-sample.txt(multilingual UTF-8 pangram). - Error-path tests — derived from real files, not fabricated:
Samples.phpexposeswithoutPart()(unpack a real container minus one ZIP member and repack it via php-archive, e.g. a DOCX missingword/document.xml) andcorrupt()(non-archive bytes); both manage their own temp files, andpath()resolves a committed sample. There is no fixture builder. Note the failure model (above):corrupt()trips the total-failure gate soextract()throws, whereaswithoutPart()removing only the body part (word/document.xml,content.xml) leaves the container openable, soextract()returns a partial result —textErrorset, the independent metadata (docProps/*,meta.xml) salvaged.HelperTestseparately locks theextractText()re-throw contract. - Edges no clean real file covers are not tested — deliberately dropped when the corpus had no document that targets them without inventing structure: XLSX custom sheet-name↔file reordering (only normal multi-sheet order is tested), ODS merged/covered cells + multi-paragraph cells, and the Windows XP UTF-16LE EXIF tag. Asserting these would mean asserting against invented inputs.
- XLSX sheet order/names must be resolved through
xl/workbook.xml+xl/_rels/workbook.xml.rels(name → r:id → worksheet file). Worksheet file numbering does NOT have to match tab order, so pairing sorted filenames with names positionally mismatches them.XlsxExtractorfollows the relationships and only falls back to positional/SheetNnaming when the workbook or its rels are absent. - EXIF "XP" tags (TIFF): PHP's
exif_read_dataalready decodes the Windows XP tags and exposes them in aWINXPsection under the short namesTitle/Comment/Author/Keywords/Subject(UTF-8) — NOT asXPTitleetc.ImageExtractor::EXIF_FIELDSlists the short names first and keeps theXP*raw names (decoded from UTF-16LE bynormaliseExifValue()) as a cross-version fallback. This path still exists but is no longer covered by a fixture — no real image in the corpus carries Windows XP tags.
Important: Test classes that need the plugin must set protected $pluginsEnabled = ['totext']; to enable it in the test environment.
Important: setUp() and tearDown() methods must be public (not protected) to match the DokuWikiTest base class.
DokuWiki may cache JavaScript, CSS and rendered output. To reset the cache just touch the config file
touch ../../../conf/local.phpAdhere to PSR-12 coding standards. Always add proper docblocks with descriptions, parameter types, and return types to all classes, methods and functions.
# Lint PHP files using PHP_CodeSniffer (must be run from repo root)
../../../bin/plugin.php dev check
# Auto-Fix formatting issues using PHP_CBF and Rector (must be run from repo root)
../../../bin/plugin.php dev fixInspect the base plugin classes in ../../../inc/Extension/ to learn about the plugin system architecture.
# add new plugin components (must be run from repo root)
../../../bin/plugin.php dev addComponent <type>
# e.g.
../../../bin/plugin.php dev addComponent action
# if multiple of the same type are needed, give a name:
../../../bin/plugin.php dev addComponent action foobar
# -> creates action/foobar.phpAdditional classes are autoloaded when using the dokuwiki\plugin\totext namespace.