diff --git a/src/core/metadata_parser.js b/src/core/metadata_parser.js
index 9541dae4d91cd..66a8b92148b15 100644
--- a/src/core/metadata_parser.js
+++ b/src/core/metadata_parser.js
@@ -21,7 +21,10 @@ class MetadataParser {
data = this._repair(data);
// Convert the string to an XML document.
- const parser = new SimpleXMLParser({ lowerCaseName: true });
+ const parser = new SimpleXMLParser({
+ lowerCaseName: true,
+ hasAttributes: true,
+ });
const xmlDocument = parser.parseFromString(data);
this._metadataMap = new Map();
@@ -100,6 +103,35 @@ class MetadataParser {
);
}
+ _parseLangAlt(entry) {
+ if (!entry.hasChildNodes()) {
+ return;
+ }
+ const altNode = entry.childNodes.find(node => node.nodeName !== "#text");
+ if (!altNode) {
+ this._metadataMap.set(entry.nodeName, entry.textContent.trim());
+ return;
+ }
+ const list = this._getSequence(altNode);
+ if (!list || list.length === 0) {
+ // Fallback: no rdf:Alt container, use textContent directly
+ this._metadataMap.set(entry.nodeName, entry.textContent.trim());
+ return;
+ }
+ // Find x-default entry, otherwise use first entry
+ let selectedEntry = list[0];
+ for (const node of list) {
+ const langAttr = node.attributes?.find(
+ attr => attr.name.toLowerCase() === "xml:lang"
+ );
+ if (langAttr?.value === "x-default") {
+ selectedEntry = node;
+ break;
+ }
+ }
+ this._metadataMap.set(entry.nodeName, selectedEntry.textContent.trim());
+ }
+
_parse(xmlDocument) {
let rdf = xmlDocument.documentElement;
@@ -129,6 +161,10 @@ class MetadataParser {
case "dc:subject":
this._parseArray(entry);
continue;
+ case "dc:title":
+ case "dc:description":
+ this._parseLangAlt(entry);
+ continue;
}
this._metadataMap.set(name, entry.textContent.trim());
}
diff --git a/test/unit/metadata_spec.js b/test/unit/metadata_spec.js
index 5360f8ab0cec0..09b50901d828e 100644
--- a/test/unit/metadata_spec.js
+++ b/test/unit/metadata_spec.js
@@ -215,6 +215,72 @@ describe("metadata", function () {
expect([...metadata]).toEqual([]);
});
+ it("should handle multiple language titles in dc:title (issue 20801)", function () {
+ const data =
+ "" +
+ "" +
+ "" +
+ "" +
+ 'Hello World' +
+ 'Hello World' +
+ "";
+ const metadata = createMetadata(data);
+
+ // Should return just one title, not concatenated "Hello WorldHello World"
+ expect(metadata.get("dc:title")).toEqual("Hello World");
+ });
+
+ it("should handle multiple language descriptions without x-default (issue 20801)", function () {
+ const data =
+ "" +
+ "" +
+ "" +
+ "" +
+ 'English Desc' +
+ 'French Desc' +
+ "";
+ const metadata = createMetadata(data);
+
+ // Should return first entry when no x-default
+ expect(metadata.get("dc:description")).toEqual("English Desc");
+ });
+
+ it("should handle whitespace before rdf:Alt in dc:title", function () {
+ const data =
+ "" +
+ "" +
+ "" +
+ "\n " +
+ 'Whitespace Title' +
+ 'English Title' +
+ "";
+ const metadata = createMetadata(data);
+
+ expect(metadata.get("dc:title")).toEqual("Whitespace Title");
+ });
+
+ it("should handle empty dc:creator element", function () {
+ const data =
+ "" +
+ "" +
+ "" +
+ "" +
+ "";
+ const metadata = createMetadata(data);
+ expect(metadata.get("dc:creator")).toEqual(null);
+ });
+
+ it("should handle empty dc:title element", function () {
+ const data =
+ "" +
+ "" +
+ "" +
+ "" +
+ "";
+ const metadata = createMetadata(data);
+ expect(metadata.get("dc:title")).toEqual(null);
+ });
+
it("should not be vulnerable to the billion laughs attack", function () {
const data =
'' +