20.6.2014

foto Tomáš Hofman

Tomáš Hofman
+420 777 634 660
tomas.hofman@hobrasoft.cz

Stalo se mi, že jsem jednoho krásného dne musel řešit to, čemu jsem se úspěšně leta vyhýbal – práci se složitějšími XML dokumenty v PHP. Konkrétně se jednalo o elektronické knihy ve formátu EPUB. Ve stručnosti bych uvedl, že takový EPUB soubor je poněkud podivně zabalený ZIP archiv plný (h)různých XML souborů z nichž nejdůležitější, ale zároveň nejsložitější na manipulaci je tzv. OPF soubor.

Proč je zrovna s tímto souborem složitá manipulace? Inu protože obsahuje více než jeden XML jmenný prostor. Kdo někdy zkoušel dělat něco s XML souborem s více než jedním jmenným prostorem v PHP, asi mi dá za pravdu, že to není žádný med.

Jak vlastně takový OPF soubor vypadá? Například takto:

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="2.0">
    <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
        <dc:identifier id="bookid" opf:scheme="ISBN">978-80-7252-467-9</dc:identifier>
        <dc:title>Padesát stínů Adama</dc:title>
        <dc:creator opf:role="aut">Rossela Calabro</dc:creator>
        <dc:language>cs</dc:language>
        <dc:date opf:event="create">2013-08-28</dc:date>
    </metadata>
    <manifest>
        <item href="toc.ncx" id="ncx" media-type="application/x-dtbncx+xml"/>
        <item href="Styles/template.css" id="css" media-type="text/css"/>
        <item href="Text/Adam_EPUB.html" id="Adam_EPUB" media-type="application/xhtml+xml"/>
        <item href="Text/Adam_EPUB-1.html" id="Adam_EPUB-1" media-type="application/xhtml+xml"/>
        <item href="Text/Adam_EPUB-2.html" id="Adam_EPUB-2" media-type="application/xhtml+xml"/>
        <item href="Text/Adam_EPUB-3.html" id="Adam_EPUB-3" media-type="application/xhtml+xml"/>
        ...
    </manifest>
    <spine toc="ncx">
        <itemref idref="Adam_EPUB"/>
        <itemref idref="Adam_EPUB-1"/>
        <itemref idref="Adam_EPUB-2"/>
        <itemref idref="Adam_EPUB-3"/>
        ...
    </spine>
    <guide>
        <reference href="Text/Adam_EPUB.html" title="Cover" type="cover"/>
    </guide>
</package>

(Na tento soubor se budu nadále odkazovat jako na content.opf)

Nelze tedy říci, že by to byl právě jednoduchá struktura, na druhou stranu to ale ani není nic extra komplikovaného. Za povšimnutí stojí především část uvnitř elementu <metadata>, kde je krásně vidět, že zde máme do sebe dvakrát zkřížené jmenné prostory. Ve jmenném prostoru OPF (celý dokument) jsou vloženy elementy ze jmenného prostoru DC, které obsahují atributy opět ze jmenného prostoru OPF. Uvedená struktura je ze skutečně existujícího EPUB souboru, nicméně je trochu očištěna o části, které situaci dále komplikují.

Bohužel jsem na začátku řešení problému zvolil cestu nejmenšího odporu skrze komponentu SimpleXML a po čase zjistil, že tudy cesta nevede. SimpleXML je opravdu velmi simple. Sice v zásadě umožňuje práci s takovým souborem, ale práce je to dost nejistá a vzhledem k tomu, že oblíbená finta s var_dump() SimpleXMLElementu zde dost dobře nefunguje (interně to není PHP objekt), podobá se práce s touto funkcionalitou PHP jízdě na kole se zavázanýma očima po minovém poli.

Proto jsem se po několika bezesných nocích rozhodl kousnout do kyselého jablka a přepracovat celou manipulaci s OPF soubory do mnohem sofistikovanějšího DOMu. Tedy ne že by to bylo bez problémů, neboť dokumentace je sice obsáhlá, ale v podstatě z ní nevyčtete ty nejdůležitější věci (obzvláště pokud vám stejně jako mě unikl krátký článek na Rootu od Jirky Koska o práci s DOMem v PHP :).

Z dokumentace se sice dozvíte, že existuje spousta tříd, které práci s DOMem zapouzdřují, ale že některé metody vracejí (za určitých okolností) jiné třídy než se v dokumentaci píše, se tam už nedočtete. Získání této znalosti se ponechává na čtenářově přirozené inteligenci, na experimentování, osvícení, či něčem podobném. Slovy nesmrtelného klasika: "Hledej Šmudlo!".

A tak jsem hledal a spoustu užitečných rad našel na StackOverflow. Smyslem tohoto zápisku je však shrnout práci s DOMem v PHP do několika praktických ukázek jak na to.

Načtení XML dokumentu je vcelku triviální záležitostí, kterou lze provést na několik způsobů. Nejjednodušší z nich je tento:

$dom = new DOMDocument();
$dom->load('content.opf');

Velmi prosté a účinné. Lze to ale udělat i jinak. Když už (stejně jako já) máte kus aplikace postavený nad SimpleXML, může přijít na řadu i jiný postup:

$sxe = new SimpleXMLElement(file_get_contents('content.opf'));
$domElement = dom_import_simplexml($sxe);
$dom = $domElement->ownerDocument;

Všimněte si, že v druhém případě funkce dom_import_simplexml() nevrací DOMDocument, ale DOMElement, ze kterého je potřeba teprve DOMDocument vypreparovat.

Při práci s dokumentem ve kterém je více jmenných prostorů je dobré znát jejich prefixy a k nim odpovídající URL těchto prostorů. V případě, že máme dokument načtený jako SimpleXMLElement je situace poměrně jednoduchá, neboť nám poskytuje metodu pro jejich získání:

$namespaces = $sxe->getNamespaces(TRUE);

Argument TRUE je zde proto, abychom donutili metodu rekurzivně prohledat celý XML dokument a vrátit všechny jmenné prostory. Po volání této metody nad výše uvedeným dokumentem dostaneme takovéto pole:

array(
    '' => 'http://www.idpf.org/2007/opf"',
    'dc' => 'http://purl.org/dc/elements/1.1/'
)

Kořenový jmenný prostor je OPF, čemuž odpovídá absence prefixu. To sice dokáže trochu komplikovat situaci, ale v závislosti na okolnostech je tento drobný nedostatek poměrně snadno řešitelný. Jakým způsobem jednoduše dostat jmenné prostory přímo z DOMu jsem příliš nezkoumal a podle toho co jsem viděl to žádným jednoduchým způsobem udělat nelze (přinejmenším ne bez znalosti prefixu).

Základní operací, kterou s takovým XML souborem obvykle děláme je jeho pohledávání. To lze provádět v zásadě dvěmi způsoby. Buďto hledáme obecně nějaké elementy, se kterými chceme pak dále pracovat a je nám jedno kde jsou. Pak postupujeme přibližně takto:

$items = $dom->getElementByTagName('item');
foreach ($items as $item) {
    echo $item->nodeValue;
}

případně

$firstItem = $dom->getElementByTagName('item')->item(0);

což je užitečné zejména pro masové zpracování některých elementů. Nebo můžeme s výhodou využít skvělého vynálezu jménem XPath, který nám umožní poměrně pohodlné dotazování na konkrétní elementy. Potíž spočívá v tom, že ačkoliv se v dokumentaci k XPath dočtete, že nějakým dotazem byste měli dostat určitý element, DOM vám jej nevrátí. Není to tím, že by byl v PHP špatně implementován, jen to často vyžaduje jistou dávku trpělivosti, zkoušení, googlení a uvažování v čem by asi tak mohl být zakopaný pes. Velmi jednoduchý XPath dotaz pak může vypadat například takto:

$xpath = new DOMXPath($dom);
$result = $xpath->query('//dc:title');
if ($result->length === 0) {
    // element dc:title se nepodařilo najít
}
$title = $result->item(0);

Testování zda se element podařilo najít nebo ne, je poměrně důležité, protože pokud se o to nebudete zajímat, bude program padat jak zralá hruška.

Složitější dotaz, který nám vrátí první referenční položku v sekci <spine> (což v EPUBech bývá titulní stránka) pak může vypadat například takto:

$xpath = new DOMXPath($dom);
$result = $xpath->query('/opf:package/opf:spine/opf:itemref[1]');
if ($result->length === 0) {
    // element titulní stránky se nepodařilo najít, takový OPF dokument nemůže být validní!
}
$coverReference = $result->item(0);

Nicméně lze dělat i komplikovanější věci, například lze hledat v konrétních částech dokumentu elementy, jejichž atribut nabývá nějaké hodnoty. XPath zná dva přístupy jak toho dosáhnout. Buďto budeme hledat obecně v celém dokumentu:

$result = $xpath->query('//opf:item[@id=Adam_EPUB]');

což ovšem ne vždy musí fungovat, nebo budeme hledat konkrétněji:

$result = $xpath->query("/opf:package/opf:manifest/opf:item[contains(@id, 'Adam_EPUB')]";

Jedna ze záludností dokumentace PHP DOMu je v tom, že se všude dozvíte, že kdejaka metoda vrací třídu DOMNode, jenže v závislosti na tom, kterého DOM uzlu se to týká vám jednou může vrátit jejího potomka DOMElement (to v případech kdy pracujete s elementy) a někdy třeba DOMAttr (v případě, že pracujete s atributem nějakého elementu). Proto je potřeba si při čtení dokumentace (a především při samotném vývoji :) dávat hodně velký pozor na to s čím vlastně pracujete (třeba pomocí operátoru instanceof).

Hodně často řešený problém je změna obsahu elementu. Sice to lze udělat i poměrně přímočaře takto:

$title->nodeValue = 'Jiný titulek';

nicméně v praxi potřebjeme často nahradit celý element něčím jiným, nebo zajistit aby se vyskytoval v dokumentu minimálně tento náš element. Pak to může vypadat například takto:

// nejdříve si vytvoříme nový element
$newCreator = $dom->createElementNS(
    $namespaces['dc'],
    'creator'
);
$newCreator = 'Rossela Calabró';
$newCreator->setAttribute(
    'opf:role',
    'aut'
);
$newCreator->setAttribute(
    'opf:file-as',
    'Callabró, Rossela'
);

// pak si najdeme již existující elementy dc:creator
$xpath = new DOMXPath($dom);
$result = $xpath->query('//dc:creator');
if ($result->length === 0) {
    // element dc:creator jsme nenašli
    // získáme tedy rodičovský element, do kterého chceme nový element připojit
    $parentNode = $dom->documentElement->getElementByTagName('metadata');
    // připojíme do rodičovského elementu nově vytvořený dc:creator
    $parentNode->appendChild($newCreator);
} else {
    // element dc:creator jsme našli
    // získáme na něj referenci
    $refCreator = $result->item(0);
    // získame z reference rodičovský element
    $parentNode = $refCreator->parentNode;
    // vyměníme původní element za nový
    $parentNode->replaceChild($newCreator, $refCreator);
}

Fantazii (a potřebám) se meze nekladou, takže lze bez větších problémů odstranit elementy určitého typu, které mají určité atributy nastavené na jisté hodnoty apod.

Poslední věc, která může být jistým oříškem je manipulace se samotnými jmennými prostory. Klasický problémem je, že jmenný prostor DC je nastaven pouze pro element <metadata> a my jej chceme z nějakého důvodu mít nastaven pro celý dokument. Toho lze dosáhnout odstraněním jmenného prostoru z elementu <metadata> a přidáním téhož do atributu <package>. Vyjádřeno v PHP to vypadá následovně:

// definujeme si XMLNS URL
define('XMLNS_URI', 'http://www.w3.org/2000/xmlns/');

$xpath = new DOMXPath($dom);

// najdeme element metadata
$result = $xpath->query('//opf:metadata');
$metadata = $result->item(0);

// pokud element metadata obsahuje namespace DC, tak jej odstranime
if ($metadata->hasAttributeNS(XMLNS_URI, 'dc')) {
    $metadata->removeAttributeNS($namespaces['dc'], 'dc');
}

// najdeme element package
$package = $dom->documentElement;

// pokud element package neobsahuje namespace DC, tak jej doplníme
if (!$package->hasAttributeNS(XMLNS_URI, 'dc')) {
    $package->setAttributeNS(XMLNS_URI, 'xmlns:dc', $namespaces['dc'])
}

Upozorňuji však na to, že manipulace se jmennými prostory v již existujícím dokumentu může být velmi zrádná. DOM totiž v tichosti odstraní prefixy daného jmenného prostoru z názvů již existujících atributů, čímž vznikne v XML dokumentu hotové boží dopuštění. Opravit jej sice je možné (ne vždy však spolehlivě), ale také velmi pracné. Proto vás důrazně varuji před podobnými nápady. Vždy si dobře rozmyslete, jestli tohle opravdu, ale opravdu máte zapotřebí.

Ukázali jsme si trochu pokročilejší techniky práce s DOM v PHP. Věřím, že zde nastíněné příklady vám ušetří spoustu času při průzkumu bojem. Sám jsem hledal nějaký ucelený text, kde bych tyto věci našel, nicméně jak se zdá, tak na celém internetu nikdo tyto věci buďto neřešil, nebo se o své zkušenosti nepodělil. Jedinou čestnou výjimkou z této informační mizérie je výše zmíněný StackOverflow, kterému děkuji za celou řadu informací.

Hobrasoft s.r.o. | Kontakt