diff --git a/server/src/modules/opds/__tests__/opds.service.test.ts b/server/src/modules/opds/__tests__/opds.service.test.ts
index 0af201077..b6b0585d7 100644
--- a/server/src/modules/opds/__tests__/opds.service.test.ts
+++ b/server/src/modules/opds/__tests__/opds.service.test.ts
@@ -522,4 +522,40 @@ describe('OpdsService', () => {
expect(xml).toContain(''');
});
});
+
+ describe('book descriptions', () => {
+ function feedFor(description: string | null): string {
+ return makeService().generateAcquisitionFeed(
+ 'Catalog',
+ 'urn:bookorbit:catalog',
+ [sampleBook({ description })],
+ 1,
+ 1,
+ 50,
+ `${BASE}/catalog?page=1&size=50`,
+ 'test-token',
+ );
+ }
+
+ it('sends an HTML description as plain text with its paragraph and line breaks', () => {
+ const xml = feedFor('
The first book & its sequel.
Line one
line two
');
+
+ expect(xml).toContain('The first book & its sequel.\nLine one\nline two');
+ expect(xml).not.toContain('<p>');
+ expect(xml).not.toContain('<strong>');
+ });
+
+ it('keeps a plain-text description and its line breaks unchanged', () => {
+ const xml = feedFor('Part one.\n\nPart two.');
+
+ expect(xml).toContain('Part one.\n\nPart two.');
+ });
+
+ it.each([
+ ['no description', null],
+ ['a description holding only markup', '
'],
+ ])('sends empty content for %s', (_label, description) => {
+ expect(feedFor(description)).toContain('');
+ });
+ });
});
diff --git a/server/src/modules/opds/opds.service.ts b/server/src/modules/opds/opds.service.ts
index f40aaad75..8c2c694b9 100644
--- a/server/src/modules/opds/opds.service.ts
+++ b/server/src/modules/opds/opds.service.ts
@@ -1,6 +1,7 @@
import { Injectable, Optional } from '@nestjs/common';
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_ATOM, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
+import { htmlToPlainText } from '../../common/utils/html-to-text.utils';
import { DeliverySelectionService } from '../book/delivery-selection.service';
import type { OpdsRequestUser } from './opds-auth.guard';
import type { OpdsBookEntry } from './opds-book.service';
@@ -172,8 +173,12 @@ export class OpdsService {
lines.push(` ${xmlEl('name', author)}`);
}
- if (book.description) {
- lines.push(` ${esc(book.description)}`);
+ // Descriptions are stored as HTML. Clients that honour type="text" show markup literally,
+ // and type="html" would hand unsanitized markup from book files and providers to clients
+ // that render it, so the feed carries the text alone.
+ const description = book.description ? htmlToPlainText(book.description, { preserveLineBreaks: true }) : '';
+ if (description) {
+ lines.push(` ${esc(description)}`);
} else {
lines.push(' ');
}