`, `
` and ` ` still in them; escaped,
+ * they are drawn literally and every `
` line is a gap. See
+ * {@link cleanTextMarkup}.
* 4. **Every paragraph loses its leading whitespace.** What "段前空格" is: the
* full-width spaces a scraper indented with, which the client draws itself
* from the reader's own indent setting. Kept, they would be indent *added to*
@@ -42,6 +46,43 @@
* needed it most.
*/
+/**
+ * HTML tags a scraped page leaves in a TXT. Only real tag names are matched, so a
+ * `<` in prose (`a
`) is text and stays text.
+ */
+const BLOCK_TAG = /<\/?(?:br|p|div|h[1-6]|li|ul|ol|tr|table|tbody|section|article|blockquote|center|pre|hr|dd|dt|dl)\b[^<>]*>/gi;
+const INLINE_TAG = /<\/?(?:span|font|b|i|u|em|strong|small|big|a|img|sup|sub|ins|del|s|strike|tt|code|mark|abbr|cite|q|wbr|o:p)\b[^<>]*>/gi;
+const NAMED_ENTITIES: Record = {
+ nbsp: ' ', ensp: ' ', emsp: '\u3000', thinsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
+ ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', hellip: '…', mdash: '—', ndash: '–', middot: '·',
+};
+const ENTITY = /&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]{2,8}));/gi;
+/** A line holding only whitespace is a blank line: a reader cannot tell them apart. */
+const WHITESPACE_LINE = /^[ \t\u3000\u00a0]+$/gm;
+
+/**
+ * Removes the markup a web page leaves behind in a TXT, keeping its line structure.
+ *
+ * Block tags (`
`, ``, `
`, headings, list items) become line breaks, so
+ * the paragraphs they marked survive as the blank lines the rules below read;
+ * inline tags (`
`, ``) are dropped; common character entities become
+ * the characters they stand for. Idempotent, and a file without markup is returned
+ * with only its whitespace-only lines emptied. The result is still *text*: every
+ * consumer escapes it, so nothing here can turn into markup.
+ */
+export function cleanTextMarkup(text: string): string {
+ return text
+ .replace(LINE_ENDINGS, '\n')
+ .replace(BLOCK_TAG, '\n')
+ .replace(INLINE_TAG, '')
+ .replace(ENTITY, (all, decimal: string | undefined, hex: string | undefined, name: string | undefined) => {
+ if (name) return NAMED_ENTITIES[name.toLowerCase()] ?? all;
+ const code = decimal ? Number(decimal) : Number.parseInt(hex!, 16);
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all;
+ })
+ .replace(WHITESPACE_LINE, '');
+}
+
/** Paragraphs are separated by a blank line. */
const PARAGRAPH_BREAK = /\n{2,}/;
@@ -110,8 +151,7 @@ export interface TextParagraph {
* transport happened to deliver it.
*/
export function splitTextParagraphs(text: string): TextParagraph[] {
- return text
- .replace(LINE_ENDINGS, '\n')
+ return cleanTextMarkup(text)
.split(PARAGRAPH_BREAK)
.flatMap((block) => splitSentenceBreaks(block))
.map(toParagraph)
@@ -271,7 +311,7 @@ function isHeadingLine(line: string): boolean {
* the book rather than render it.
*/
export function splitChapterHeading(text: string): { heading: string; body: string } {
- const normalised = text.replace(LINE_ENDINGS, '\n');
+ const normalised = cleanTextMarkup(text);
const lines = normalised.split('\n');
const firstIndex = lines.findIndex((line) => line.trim().length > 0);
if (firstIndex === -1) return { heading: '', body: '' };
@@ -308,5 +348,5 @@ export function textToChapterHtml(text: string): string {
* in the middle of a novel: invisible in any test and obvious on the page.
*/
export function endsAtParagraphBreak(text: string): boolean {
- return /(?:\r\n?|\n)\s*$/.test(text);
+ return /\n\s*$/.test(cleanTextMarkup(text));
}
diff --git a/web/test/reader-view.test.ts b/web/test/reader-view.test.ts
index 415a4bb..dff9d5e 100644
--- a/web/test/reader-view.test.ts
+++ b/web/test/reader-view.test.ts
@@ -551,13 +551,15 @@ describe('plain-text chapters are typeset by the reader', () => {
// assertion is on the *absence* of an element, not on the presence of the words.
const container = document.createElement('div');
document.body.append(container);
- const view = make(container, txtDoc('
\n\n粗'));
+ // Known web-page tags are cleaned away (`cleanTextMarkup`); anything else stays text.
+ const view = make(container, txtDoc('
\n\n粗\n\n'));
await view.open(0, 0);
const shadow = shadowOf(container);
expect(shadow.querySelector('.txt-body img')).toBeNull();
expect(shadow.querySelector('.txt-body b')).toBeNull();
+ expect(shadow.querySelector('.txt-body script')).toBeNull();
const paragraphs = [...shadow.querySelectorAll('.txt-body > p')].map((p) => p.textContent);
- expect(paragraphs).toEqual(['
', '粗']);
+ expect(paragraphs).toEqual(['粗', '']);
});
it('re-typesets a chapter an older server sent as markup', async () => {
diff --git a/web/test/segments.test.ts b/web/test/segments.test.ts
index 380b72f..3dd474c 100644
--- a/web/test/segments.test.ts
+++ b/web/test/segments.test.ts
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
+ cleanTextMarkup,
endsAtParagraphBreak,
escapeHtml,
paragraphElement,
@@ -126,10 +127,14 @@ describe('the markup form', () => {
it('escapes a file that contains markup', () => {
// A TXT is user data: a novel with a `<` in it must not be able to produce an
// element, and `escapeHtml` is the one place that decides so.
- const html = textToParagraphHtml('
\n\n粗');
+ // Known page tags (`
`, ``) are cleaned away as web-page residue; any
+ // other markup is escaped. Neither path can produce an element.
+ const html = textToParagraphHtml('
\n\n粗\n\n');
expect(html).not.toContain('
');
- expect(html).toContain('<img src=x onerror=alert(1)>');
+ expect(html).not.toContain('