// XML/XHTML parsing + serialization on top of linkedom. // // Replaces the browser DOMParser/XMLSerializer used by FilesPage.html. // safeSerialize is a verbatim port of source line 3997. // // --------------------------------------------------------------------------- // linkedom quirks measured on the installed version (probe results, not guesses). // EVERY port site must go through the shims below — never call linkedom's NS // APIs directly, they are wrong in ways that silently return nothing: // // 1. `getElementsByTagName('*')` returns an EMPTY NodeList. Unusable. // `querySelectorAll('*')` works and is what byLocalName uses instead. // 2. `getElementsByTagNameNS('*', name)` matches the *qualified* nodeName, so // `getElementsByTagNameNS('*','creator')` finds 0 elements in an OPF whose // metadata uses ``. This is the single most dangerous quirk — // it fails silently and would break title/author/identifier extraction. // 3. `element.localName` returns the QUALIFIED name including the prefix // ("dc:creator", "svg:svg"), not the local part. byLocalName strips it. // 4. `getAttributeNS(ns, local)` always returns null; attributes are keyed by // their qualified name. getAttrNS therefore tries getAttribute('local') // then getAttribute('prefix:local'). // 5. `querySelector('image[*|href]')` THROWS ("Namespaced attributes are not // yet supported by css-select"). Never use namespaced CSS selectors. // 6. `namespaceURI` is unreliable (an OPF reports the XHTML NS), so // createElementNS callers pass an explicit constant NS instead of reading // it off a neighbouring node. // 7. `doc.toString()` already emits the declaration and produces no // `xmlns:ns0` prefix noise, so safeSerialize's fix-ups are harmless no-ops // here. They are kept verbatim anyway per the port map. // 8. There is no `parsererror` element convention, and linkedom recovers from // mismatched/unclosed tags rather than failing. Only truly element-less // input yields a null documentElement. Detection is therefore: it threw, // documentElement is null, or documentElement is a . // --------------------------------------------------------------------------- import { DOMParser } from 'linkedom'; /** Strip a namespace prefix from a qualified name: "dc:creator" -> "creator". */ function localPart(name) { const n = name || ''; const i = n.indexOf(':'); return i === -1 ? n : n.slice(i + 1); } /** Prefix of a qualified name, or '' when unprefixed. */ function prefixPart(name) { const n = name || ''; const i = n.indexOf(':'); return i === -1 ? '' : n.slice(0, i); } /** The qualified tag name of an element, tolerating either linkedom accessor. */ function qualifiedName(el) { return el.localName || el.nodeName || ''; } function parse(text, mimeType) { try { const doc = new DOMParser().parseFromString(text, mimeType); if (!doc || !doc.documentElement) return { doc: null, ok: false }; if (localPart(qualifiedName(doc.documentElement)) === 'parsererror') { return { doc: null, ok: false }; } return { doc, ok: true }; } catch (e) { return { doc: null, ok: false }; } } /** Parse OPF/NCX/generic XML. Returns {doc, ok}; ok:false routes to regex fallbacks. */ export function parseXml(text) { return parse(text, 'text/xml'); } /** Parse XHTML content documents. Returns {doc, ok}. */ export function parseXhtml(text) { return parse(text, 'application/xhtml+xml'); } /** * Every element under `root` whose local name (prefix stripped) equals `name`. * Replaces every `getElementsByTagNameNS('*', name)` call in the source * (sites: 4021/4024/4028, 4057, 4082-4083, 4303/4323/4329, 3905-3906). * * When `root` is a Document the document element itself is included (matching * the source's document-level lookups for ); when `root` is an Element * only descendants are considered (matching `svg.getElementsByTagName('image')`). */ export function byLocalName(root, name) { if (!root) return []; let nodes; try { nodes = root.querySelectorAll('*'); } catch (e) { return []; } const out = []; for (const el of nodes) { if (localPart(qualifiedName(el)) === name) out.push(el); } return out; } /** * Read a namespaced attribute the way the source's fallback chains do * (3907-3920 role/opf:role, 4204-4206 and 4268 xlink:href). * * Tries the bare local name, then each candidate prefix, then any attribute * whose qualified name ends in `:localName`, then getAttributeNS in a * try/catch (which linkedom always answers with null, but a future/other * implementation may not). * * `preferPrefixed` flips the first two steps. The source's two chains differ in * precedence: the opf:role chain (3907-3920) reads the bare attribute first, * while the xlink:href chain (4204-4206, 4268) reads the namespaced one first * and only falls back to a bare `href`. Pass true at the xlink sites. */ export function getAttrNS(el, localName, nsUri, prefixes = [], preferPrefixed = false) { if (!el) return null; const tryGet = n => { try { const v = el.getAttribute(n); return v === null || v === undefined ? null : v; } catch (e) { return null; } }; const tryPrefixes = () => { for (const p of prefixes) { const v = tryGet(`${p}:${localName}`); if (v) return v; } return null; }; if (preferPrefixed) { const pre = tryPrefixes(); if (pre) return pre; const bare = tryGet(localName); if (bare) return bare; } else { const direct = tryGet(localName); if (direct) return direct; const pre = tryPrefixes(); if (pre) return pre; } // Any prefix at all — covers documents using a non-conventional prefix. try { for (const attr of el.attributes || []) { const n = attr.name || ''; if (prefixPart(n) && localPart(n) === localName) { if (attr.value) return attr.value; } } } catch (e) { /* ignore */ } if (nsUri) { try { const v = el.getAttributeNS(nsUri, localName); if (v) return v; } catch (e) { /* linkedom: always null, never throws — belt and braces */ } } return null; } /** * Serialize an XML doc back to string, preserving the original declaration * and cleaning up XMLSerializer namespace prefix noise (xmlns:ns0 etc). (3997) */ export function safeSerialize(doc, originalContent) { let result = doc.toString(); // Restore declaration if original had one if (originalContent && /^\s*<\?xml\b/.test(originalContent) && !/^\s*<\?xml\b/.test(result)) { const declMatch = originalContent.match(/^\s*(<\?xml[^?]*\?>)/); if (declMatch) result = declMatch[1] + '\n' + result; } // Clean up XMLSerializer namespace prefix noise (xmlns:ns0="..." ns0:attr="...") result = result.replace(/ xmlns:ns\d+="[^"]*"/g, ''); result = result.replace(/ ns\d+:/g, ' '); // linkedom-specific: unlike the browser XMLSerializer (which emits no // declaration at all, hence the restore above), linkedom INVENTS an // `` even for input that had none. // Drop it again so the contract matches the source's: the output carries a // declaration exactly when the input did. // // Note the encoding is deliberately NOT restored verbatim when the original // did have one. safeReadText decodes legacy encodings and everything is // re-encoded as UTF-8 on write, so linkedom's `utf-8` is the truthful label — // echoing back an original `encoding="windows-1252"` would mis-declare it. if (originalContent && !/^\s*<\?xml\b/.test(originalContent)) { result = result.replace(/^\s*<\?xml[^?]*\?>\s*/, ''); } return result; }