@khorsheed/dsh-reader 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29105 -6692
- package/lib/index.js +3500 -321
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +561 -33
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +81 -6
- package/lib/types/service.js +286 -65
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +90 -2
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +33 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* - {@link normalizeRichText} takes markup that is ALREADY known to be a body
|
|
15
15
|
* (a feed's `content:encoded`, or the block extraction selected) and puts it
|
|
16
16
|
* through a tag whitelist, so the renderer never sees third-party scripts,
|
|
17
|
-
* styles, forms or event handlers.
|
|
17
|
+
* styles, forms or event handlers. The one sanctioned exception: a content
|
|
18
|
+
* embed (`embedElement`) survives as a sandboxed iframe — `allow-scripts`
|
|
19
|
+
* without `allow-same-origin`, an opaque origin that cannot see the pane —
|
|
20
|
+
* or as a native controls-only media element.
|
|
18
21
|
*
|
|
19
22
|
* Everything runs in the browser because the host runtime has no DOM parser at
|
|
20
23
|
* all; nothing here touches the network.
|
|
@@ -55,16 +58,46 @@ const KEEP_TAGS = new Set([
|
|
|
55
58
|
*/
|
|
56
59
|
const DROP_TAGS = new Set([
|
|
57
60
|
'script', 'style', 'template', 'nav', 'header', 'footer', 'aside',
|
|
58
|
-
'form', '
|
|
59
|
-
'
|
|
61
|
+
'form', 'annotation-xml', 'button', 'select', 'input', 'textarea',
|
|
62
|
+
'canvas', 'embed', 'link', 'meta', 'base', 'title',
|
|
63
|
+
// iframe/video/audio are NOT here: the embed is content the page published,
|
|
64
|
+
// and normalizeNode keeps it (see embedElement) — a sandboxed opaque-origin
|
|
65
|
+
// frame for an iframe, a native controls player for media. Trackers
|
|
66
|
+
// (hidden, pixel-sized, untitled-and-unsized iframes) stay dropped there.
|
|
60
67
|
// svg is NOT here: capture-rendered pages carry their rescued figures as
|
|
61
68
|
// inline SVG, which normalizeSvg() whitelists per element/attribute. The
|
|
62
69
|
// strip's selector gains the canonical-case members separately (SVG names are
|
|
63
|
-
// case-sensitive in an HTML document).
|
|
64
|
-
'
|
|
70
|
+
// case-sensitive in an HTML document). `foreignObject` is not here either: its
|
|
71
|
+
// HTML is a figure's labels, and normalizeSvg() walks it through this file's
|
|
72
|
+
// own whitelist.
|
|
65
73
|
]);
|
|
66
74
|
/** The strip selector's exact-case members (SVG camelCase names in an HTML document). */
|
|
67
|
-
const DROP_STRIP_EXTRA = [
|
|
75
|
+
const DROP_STRIP_EXTRA = [];
|
|
76
|
+
/**
|
|
77
|
+
* Page furniture that sits beside an article in the same container and that
|
|
78
|
+
* no tag or score tells apart from it. Named by id, per site, only where the
|
|
79
|
+
* id is the platform's own and stable.
|
|
80
|
+
*
|
|
81
|
+
* WeChat (mp.weixin.qq.com): `#content_bottom_area` holds the 腾讯公益
|
|
82
|
+
* donation card (一起捐, the donors' avatars), the like/share bar and the
|
|
83
|
+
* album modal; `#js_tags_preview_toast` is 「预览时标签不可点」. A rendered
|
|
84
|
+
* fetch runs the page's scripts, which fill both in, and the winning container
|
|
85
|
+
* (`.rich_media_area_primary_inner`) holds them next to `#js_content`.
|
|
86
|
+
*/
|
|
87
|
+
const NOISE_SELECTORS = ['#content_bottom_area', '#js_tags_preview_toast'];
|
|
88
|
+
/**
|
|
89
|
+
* Article roots a platform names itself, taken as the body without scoring.
|
|
90
|
+
*
|
|
91
|
+
* WeChat's `#js_content` is exactly the article: the title, byline and the
|
|
92
|
+
* 「在小说阅读器读本章」 card sit beside it in `#img-content`, and a rendered
|
|
93
|
+
* fetch fills the page with modal layers (the font-size panel, the complaint
|
|
94
|
+
* dialog, the comment-identity popover, the a11y labels) that the scorer
|
|
95
|
+
* measured as one more container of text and let win. The pane renders the
|
|
96
|
+
* title itself, and the document `<title>` is the article's.
|
|
97
|
+
*/
|
|
98
|
+
const ARTICLE_ROOTS = ['#js_content'];
|
|
99
|
+
/** Below this much text a named root is not trusted (a lazy page not yet filled in). */
|
|
100
|
+
const ARTICLE_ROOT_MIN_TEXT = 120;
|
|
68
101
|
/** Inline elements that survive an inline-only pass. */
|
|
69
102
|
const INLINE_TAGS = new Set(['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'sub', 'sup', 'br']);
|
|
70
103
|
/**
|
|
@@ -143,6 +176,9 @@ const KEPT_ATTRIBUTES = {
|
|
|
143
176
|
// fallback), `display` marks block math (the pane scrolls it horizontally).
|
|
144
177
|
// Everything else — event handlers, `href` on <mi>, style hooks — is stripped.
|
|
145
178
|
math: ['alttext', 'display'],
|
|
179
|
+
// Set by settleListLabels(): the item writes its own label, so the pane
|
|
180
|
+
// hides the list's native marker for it.
|
|
181
|
+
li: ['data-labelled'],
|
|
146
182
|
};
|
|
147
183
|
/**
|
|
148
184
|
* Where lazy-loading scripts stash the real URL when `src` is a placeholder.
|
|
@@ -175,10 +211,15 @@ export function extractArticle(pageHtml, baseUrl) {
|
|
|
175
211
|
const docTitle = doc.title ?? '';
|
|
176
212
|
// Strip the noise subtrees BEFORE scoring, so every candidate is measured on
|
|
177
213
|
// the same terms (a sidebar full of links otherwise wins on raw text mass).
|
|
178
|
-
for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA].join(',')))) {
|
|
214
|
+
for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA, ...NOISE_SELECTORS].join(',')))) {
|
|
179
215
|
node.remove();
|
|
180
216
|
}
|
|
181
|
-
|
|
217
|
+
settleMathJaxTwins(doc.body);
|
|
218
|
+
settleListLabels(doc.body);
|
|
219
|
+
const named = ARTICLE_ROOTS
|
|
220
|
+
.map(selector => doc.querySelector(selector))
|
|
221
|
+
.find((element) => element !== null && textLengthOf(element) >= ARTICLE_ROOT_MIN_TEXT);
|
|
222
|
+
const scored = named === undefined ? scoreCandidates(doc) : { element: named, score: 0 };
|
|
182
223
|
if (scored === undefined) {
|
|
183
224
|
return { ok: false, error: 'no text-bearing block found' };
|
|
184
225
|
}
|
|
@@ -189,6 +230,9 @@ export function extractArticle(pageHtml, baseUrl) {
|
|
|
189
230
|
const title = articleTitleOf(scored.element, docTitle);
|
|
190
231
|
const excerpt = articleExcerptOf(scored.element);
|
|
191
232
|
trimLeadingChrome(scored.element, doc);
|
|
233
|
+
// …and the lead image comes in AFTER the trim: grafted first, it would
|
|
234
|
+
// stop the masthead walk at its own border and keep the junk behind it.
|
|
235
|
+
graftLeadingFigures(doc, scored.element, baseUrl);
|
|
192
236
|
// The title block is a custom element, so it has no tag of its own: the
|
|
193
237
|
// normalizer UNWRAPS it and its `<br>`s land at the front of the body. Four
|
|
194
238
|
// blank lines before the first paragraph is not content by any reading, and
|
|
@@ -284,7 +328,12 @@ function trimLeadingChrome(root, doc) {
|
|
|
284
328
|
const text = normalize(node.textContent ?? '');
|
|
285
329
|
if (ROOT_STOPS.has(tag))
|
|
286
330
|
break;
|
|
287
|
-
|
|
331
|
+
// A picture or an embed stops the walk whether it IS this node or sits
|
|
332
|
+
// inside it — a bare leading <img> has no img DESCENDANT, and dropping it
|
|
333
|
+
// as "masthead" is how a text-column-less cover used to vanish.
|
|
334
|
+
if (tag === 'img' || tag === 'picture' || tag === 'figure' || tag === 'video' || tag === 'iframe' || tag === 'audio')
|
|
335
|
+
break;
|
|
336
|
+
if (node.querySelector('img, picture, canvas, video, figure, iframe, audio') !== null)
|
|
288
337
|
break;
|
|
289
338
|
if (text.length >= 40)
|
|
290
339
|
break;
|
|
@@ -313,13 +362,98 @@ function trimLeadingChrome(root, doc) {
|
|
|
313
362
|
* a masthead hides in, and a wrapper is judged by what it holds instead.
|
|
314
363
|
*/
|
|
315
364
|
const ROOT_STOPS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'blockquote', 'pre', 'ul', 'ol']);
|
|
365
|
+
/**
|
|
366
|
+
* The most lead images one graft brings in. A hero is one picture; the second
|
|
367
|
+
* slot covers the two-image masthead (a logo plate beside the cover). Anything
|
|
368
|
+
* beyond that is a gallery the page put before its prose, not a lead.
|
|
369
|
+
*/
|
|
370
|
+
const LEAD_GRAFT_MAX = 2;
|
|
371
|
+
/**
|
|
372
|
+
* The declared width that separates a lead image from an icon. SSR'd hero
|
|
373
|
+
* markup carries the design width (Next.js writes `width`/`height` on every
|
|
374
|
+
* `<img>` it optimizes); an icon or avatar that omits it, or declares a small
|
|
375
|
+
* one, is not the article's cover by any reading.
|
|
376
|
+
*/
|
|
377
|
+
const LEAD_IMAGE_MIN_WIDTH = 480;
|
|
378
|
+
/**
|
|
379
|
+
* Bring the page's lead image into the body when the scorer left it outside.
|
|
380
|
+
*
|
|
381
|
+
* The scorer keeps ONE container — the text-heavy block — and on magazine-style
|
|
382
|
+
* templates (Anthropic's research posts, measured 2026-10-14) the cover art
|
|
383
|
+
* sits in a sibling wrapper BEFORE that block: inside the `<article>` but not
|
|
384
|
+
* inside the winner, so the extracted body came back with zero images on a
|
|
385
|
+
* page whose hero was the one picture it had.
|
|
386
|
+
*
|
|
387
|
+
* Only what PRECEDES the winner qualifies: what follows the text is a
|
|
388
|
+
* related-stories grid, and a figure already inside the winner needs nothing.
|
|
389
|
+
* A `<figure>`/`<picture>` qualifies by markup alone (a figure IS content); a
|
|
390
|
+
* bare `<img>` must also declare a hero-sized width, or every site's top-bar
|
|
391
|
+
* logo would ride in. Grafting MOVES the elements, so the images normalize
|
|
392
|
+
* through the same resolver as everything else the body carries.
|
|
393
|
+
*
|
|
394
|
+
* @param doc - the parsed document (already stripped of noise subtrees).
|
|
395
|
+
* @param winner - the container the scorer picked.
|
|
396
|
+
* @param baseUrl - the page URL, for resolving candidate sources.
|
|
397
|
+
*/
|
|
398
|
+
function graftLeadingFigures(doc, winner, baseUrl) {
|
|
399
|
+
// The search scope: the OUTERMOST article/main ancestor, not the nearest —
|
|
400
|
+
// templates nest them (`main > article > wrapper > article > text column`),
|
|
401
|
+
// and the lead image sits under the outer one, beside the wrapper.
|
|
402
|
+
let scope = null;
|
|
403
|
+
for (let el = winner.parentElement; el !== null && el !== doc.body; el = el.parentElement) {
|
|
404
|
+
if (el.localName === 'article' || el.localName === 'main')
|
|
405
|
+
scope = el;
|
|
406
|
+
}
|
|
407
|
+
scope ??= doc.body;
|
|
408
|
+
if (scope === null || scope === winner)
|
|
409
|
+
return;
|
|
410
|
+
const grafted = [];
|
|
411
|
+
for (const element of Array.from(scope.querySelectorAll('figure, picture, img'))) {
|
|
412
|
+
if (grafted.length >= LEAD_GRAFT_MAX)
|
|
413
|
+
break;
|
|
414
|
+
if (winner.contains(element))
|
|
415
|
+
continue;
|
|
416
|
+
// `winner` FOLLOWING the element means the element precedes the body.
|
|
417
|
+
if ((element.compareDocumentPosition(winner) & 4 /* Node.DOCUMENT_POSITION_FOLLOWING */) === 0)
|
|
418
|
+
continue;
|
|
419
|
+
const tag = element.localName;
|
|
420
|
+
if (tag === 'figure') {
|
|
421
|
+
const img = element.querySelector('img');
|
|
422
|
+
if (img === null || imageSrc(img, baseUrl) === undefined)
|
|
423
|
+
continue;
|
|
424
|
+
grafted.push(element);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (tag === 'picture') {
|
|
428
|
+
// A figure's own picture rides the figure (it came first in the walk).
|
|
429
|
+
if (element.closest('figure') !== null)
|
|
430
|
+
continue;
|
|
431
|
+
if (pictureImg(element, baseUrl).length === 0)
|
|
432
|
+
continue;
|
|
433
|
+
grafted.push(element);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
// A bare <img>: covered when a figure/picture ancestor carries it.
|
|
437
|
+
if (element.closest('figure, picture') !== null)
|
|
438
|
+
continue;
|
|
439
|
+
const width = dimensionAttr(element.getAttribute('width'));
|
|
440
|
+
if (width === undefined || Number(width) < LEAD_IMAGE_MIN_WIDTH)
|
|
441
|
+
continue;
|
|
442
|
+
if (imageSrc(element, baseUrl) === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
grafted.push(element);
|
|
445
|
+
}
|
|
446
|
+
for (const element of grafted.reverse())
|
|
447
|
+
winner.prepend(element);
|
|
448
|
+
}
|
|
316
449
|
/**
|
|
317
450
|
* Count the figures whose picture is not in the markup.
|
|
318
451
|
*
|
|
319
452
|
* "No picture" means no `<img>`, `<svg>`, `<canvas>`, `<video>` or `<picture>`
|
|
320
453
|
* anywhere inside: the page renders that illustration at runtime. A `<math>`
|
|
321
454
|
* subtree counts as content — a formula-only figure's picture IS its markup —
|
|
322
|
-
* and so does an image-typed `<object>` (the LaTeXML vector-figure shape)
|
|
455
|
+
* and so does an image-typed `<object>` (the LaTeXML vector-figure shape) and
|
|
456
|
+
* an iframe/audio embed, which normalizeNode turns into a link that opens it.
|
|
323
457
|
* A composite of styled containers carries its content as TEXT (a capture-
|
|
324
458
|
* rendered figure is a diagram plus property cards): enough non-caption words
|
|
325
459
|
* means the figure is here, even with no raster or vector island.
|
|
@@ -337,7 +471,7 @@ const ROOT_STOPS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'b
|
|
|
337
471
|
function countScriptFigures(root) {
|
|
338
472
|
let count = 0;
|
|
339
473
|
for (const figure of Array.from(root.querySelectorAll('figure'))) {
|
|
340
|
-
if (figure.querySelector('img, svg, canvas, video, picture, math, object[type^="image/"]') !== null)
|
|
474
|
+
if (figure.querySelector('img, svg, canvas, video, picture, math, object[type^="image/"], iframe[src], audio') !== null)
|
|
341
475
|
continue;
|
|
342
476
|
// A composite of styled containers CARRIES its content in the markup (a
|
|
343
477
|
// capture-rendered figure is boxes of text): what is not the caption is
|
|
@@ -543,7 +677,8 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
543
677
|
const src = imageSrc(element, baseUrl);
|
|
544
678
|
if (src === undefined)
|
|
545
679
|
return '';
|
|
546
|
-
|
|
680
|
+
const width = displayWidth(element);
|
|
681
|
+
return emitImg(src, element.getAttribute('alt'), width === undefined ? {} : { width });
|
|
547
682
|
}
|
|
548
683
|
// LaTeXML embeds vector figures as image-typed objects: an image in every
|
|
549
684
|
// sense that matters here. Any other object (a PDF, a movie) keeps the old
|
|
@@ -556,6 +691,13 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
556
691
|
// either duplicate the image or lose it.
|
|
557
692
|
if (tag === 'picture')
|
|
558
693
|
return pictureImg(element, baseUrl);
|
|
694
|
+
// A video/audio file or an embedded live frame (Anthropic's research posts
|
|
695
|
+
// carry their charts as sandboxed iframe artifacts — 16 of them on one page)
|
|
696
|
+
// cannot PLAY here, but it is content the page published at that spot:
|
|
697
|
+
// leave a link that opens it where it renders, or the section reads as if
|
|
698
|
+
// the picture it discusses never existed.
|
|
699
|
+
if (tag === 'iframe' || tag === 'video' || tag === 'audio')
|
|
700
|
+
return embedElement(tag, element, baseUrl);
|
|
559
701
|
// The standard no-JS fallback: a lazy-loading script hides the real image in
|
|
560
702
|
// a <noscript>, and a fetch runs no scripts — so the ONLY copy the static
|
|
561
703
|
// markup holds lives here. Recover that image; drop everything else a
|
|
@@ -564,14 +706,19 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
564
706
|
return noscriptImg(element, baseUrl);
|
|
565
707
|
// Capture-rendered pages carry their rescued figures as inline SVG with
|
|
566
708
|
// styles inlined onto the elements; the subset walker below keeps the chart
|
|
567
|
-
// and strips the injection-shaped members (script, foreignObject
|
|
709
|
+
// and strips the injection-shaped members (script, SMIL) and whitelists foreignObject labels.
|
|
568
710
|
if (tag === 'svg')
|
|
569
|
-
return normalizeSvg(element);
|
|
711
|
+
return isResponsiveAlternate(element) ? '' : normalizeSvg(element, baseUrl);
|
|
570
712
|
if (tag === 'br')
|
|
571
713
|
return '<br>';
|
|
572
714
|
if (tag === 'hr')
|
|
573
715
|
return '<hr>';
|
|
574
|
-
const
|
|
716
|
+
const latexml = tag === 'span' ? latexmlSpan(element) : undefined;
|
|
717
|
+
let inner = normalizeChildren(element, baseUrl, inFigure || tag === 'figure');
|
|
718
|
+
if (latexml?.bold === true && inner.trim().length > 0)
|
|
719
|
+
inner = `<strong>${inner}</strong>`;
|
|
720
|
+
if (latexml?.italic === true && inner.trim().length > 0)
|
|
721
|
+
inner = `<em>${inner}</em>`;
|
|
575
722
|
// A figure's composite layout IS its content (a capture-rendered figure is a
|
|
576
723
|
// diagram plus property cards built from styled containers), so inside one
|
|
577
724
|
// the div/span boxes survive as elements with their allowlisted style —
|
|
@@ -581,8 +728,16 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
581
728
|
// A kept container with neither text nor style is a shell, not a layout.
|
|
582
729
|
if (inner.trim().length === 0 && style === undefined)
|
|
583
730
|
return '';
|
|
584
|
-
|
|
731
|
+
// A bare span is no layout either: it renders exactly as its content.
|
|
732
|
+
if (tag === 'span' && style === undefined && latexml?.paragraph !== true)
|
|
733
|
+
return inner;
|
|
734
|
+
// A LaTeXML paragraph span is a block in the page's stylesheet: kept as a
|
|
735
|
+
// span, a figure label's lines would run together.
|
|
736
|
+
const kept = latexml?.paragraph === true ? 'div' : tag;
|
|
737
|
+
return `<${kept}${style === undefined ? '' : ` style="${escapeAttribute(style)}"`}>${inner}</${kept}>`;
|
|
585
738
|
}
|
|
739
|
+
if (latexml?.paragraph === true)
|
|
740
|
+
return `\n${inner}\n`;
|
|
586
741
|
if (!KEEP_TAGS.has(tag)) {
|
|
587
742
|
// Unwrapped block-ish containers leave a word boundary: capture's
|
|
588
743
|
// serialization carries no inter-tag whitespace, and two sibling cards
|
|
@@ -605,6 +760,89 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
605
760
|
.join('');
|
|
606
761
|
return `<${tag}${attributes}>${inner}</${tag}>`;
|
|
607
762
|
}
|
|
763
|
+
/**
|
|
764
|
+
* What a LaTeXML (arXiv HTML) span means through its classes alone.
|
|
765
|
+
*
|
|
766
|
+
* LaTeXML writes block paragraphs and font changes as classed spans and lets
|
|
767
|
+
* its stylesheet do the rest; the extraction carries no stylesheet, so the
|
|
768
|
+
* meaning is read here or lost.
|
|
769
|
+
*/
|
|
770
|
+
function latexmlSpan(element) {
|
|
771
|
+
const classes = element.getAttribute('class') ?? '';
|
|
772
|
+
if (!classes.includes('ltx_'))
|
|
773
|
+
return undefined;
|
|
774
|
+
return {
|
|
775
|
+
paragraph: /\bltx_p\b/.test(classes),
|
|
776
|
+
bold: /\bltx_font_bold\b/.test(classes),
|
|
777
|
+
italic: /\bltx_font_italic\b/.test(classes),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** A list item label the list's native disc already draws. */
|
|
781
|
+
const BULLET_LABEL = /^[•∙◦▪▫‣⁃·–—*-]$/;
|
|
782
|
+
/** An item label worth keeping: `1.`, `(2)`, `a)`, `(iv)`. */
|
|
783
|
+
const ITEM_LABEL = /^(?:\(?\d{1,3}[.)]|\(\d{1,3}\)|\(?[a-z]{1,2}[.)]|\([a-z]{1,2}\)|\(?[ivxlc]{1,6}[.)]|\([ivxlc]{1,6}\))$/i;
|
|
784
|
+
/**
|
|
785
|
+
* Settle list items that write their own label in front of a block.
|
|
786
|
+
*
|
|
787
|
+
* LaTeXML (arXiv HTML) turns the native marker off and writes each label as a
|
|
788
|
+
* text tag before the item's paragraph: `<li><span>•</span> <div><p>…`. Without
|
|
789
|
+
* the page's stylesheet the native disc comes back next to the written one and
|
|
790
|
+
* the paragraph drops to the line below. A bare bullet is removed (the disc
|
|
791
|
+
* stands for it); a real label (`(a)`, `2.`) moves into the first paragraph and
|
|
792
|
+
* the item is marked `data-labelled` so the pane hides its native marker.
|
|
793
|
+
*
|
|
794
|
+
* Matched by structure, not class, so it settles stored bodies too: the label
|
|
795
|
+
* is the item's whole leading text, and a paragraph block follows it.
|
|
796
|
+
*
|
|
797
|
+
* @param root - the subtree to settle in place.
|
|
798
|
+
* @returns true when anything changed.
|
|
799
|
+
*/
|
|
800
|
+
function settleListLabels(root) {
|
|
801
|
+
let changed = false;
|
|
802
|
+
for (const item of Array.from(root.querySelectorAll('li'))) {
|
|
803
|
+
const label = firstMeaningful(item.firstChild);
|
|
804
|
+
if (label === null)
|
|
805
|
+
continue;
|
|
806
|
+
if (label.nodeType === 1) {
|
|
807
|
+
const element = label;
|
|
808
|
+
if (element.localName.toLowerCase() !== 'span' || element.children.length > 0)
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
else if (label.nodeType !== 3) {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
const block = firstMeaningful(label.nextSibling);
|
|
815
|
+
if (block === null || block.nodeType !== 1)
|
|
816
|
+
continue;
|
|
817
|
+
const blockTag = block.localName.toLowerCase();
|
|
818
|
+
if (blockTag !== 'p' && blockTag !== 'div')
|
|
819
|
+
continue;
|
|
820
|
+
const paragraph = blockTag === 'p' ? block : block.querySelector('p');
|
|
821
|
+
if (paragraph === null)
|
|
822
|
+
continue;
|
|
823
|
+
const text = (label.textContent ?? '').trim();
|
|
824
|
+
if (BULLET_LABEL.test(text)) {
|
|
825
|
+
label.remove();
|
|
826
|
+
}
|
|
827
|
+
else if (ITEM_LABEL.test(text)) {
|
|
828
|
+
label.remove();
|
|
829
|
+
paragraph.prepend(`${text} `);
|
|
830
|
+
item.setAttribute('data-labelled', '');
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
changed = true;
|
|
836
|
+
}
|
|
837
|
+
return changed;
|
|
838
|
+
}
|
|
839
|
+
/** The node itself or the first following sibling that is not blank text. */
|
|
840
|
+
function firstMeaningful(node) {
|
|
841
|
+
let current = node;
|
|
842
|
+
while (current !== null && current.nodeType === 3 && (current.textContent ?? '').trim().length === 0)
|
|
843
|
+
current = current.nextSibling;
|
|
844
|
+
return current;
|
|
845
|
+
}
|
|
608
846
|
/**
|
|
609
847
|
* Serialize one normalized image.
|
|
610
848
|
*
|
|
@@ -617,6 +855,29 @@ function emitImg(src, alt, dimensions = {}) {
|
|
|
617
855
|
const height = dimensions.height === undefined ? '' : ` height="${dimensions.height}"`;
|
|
618
856
|
return `<img src="${escapeAttribute(src)}"${alt === null ? '' : ` alt="${escapeAttribute(alt)}"`}${width}${height} referrerpolicy="no-referrer">`;
|
|
619
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* The width the page shows an image at: its inline `width: Npx`.
|
|
860
|
+
*
|
|
861
|
+
* Without it every image renders at its natural size up to the column. A
|
|
862
|
+
* logo row is where that shows: claude.com ships a 132×128 PNG with
|
|
863
|
+
* `style="width:48.89px"`, and the reader drew it three times too large. Only
|
|
864
|
+
* a pixel width counts (`100%`, `auto` and `max-width` say nothing about the
|
|
865
|
+
* picture), and the column's `max-width: 100%` still caps it. The `width`
|
|
866
|
+
* attribute is not carried: it is usually the file's own size, which is what
|
|
867
|
+
* the image renders at anyway.
|
|
868
|
+
*
|
|
869
|
+
* @param element - the `<img>`.
|
|
870
|
+
* @returns the width as an integer string, or `undefined`.
|
|
871
|
+
*/
|
|
872
|
+
function displayWidth(element) {
|
|
873
|
+
const styled = /(?:^|;)\s*width\s*:\s*(\d+(?:\.\d+)?)px/i.exec(element.getAttribute('style') ?? '');
|
|
874
|
+
if (styled !== null) {
|
|
875
|
+
const px = Math.round(Number(styled[1]));
|
|
876
|
+
if (px >= 1 && px <= 99_999)
|
|
877
|
+
return String(px);
|
|
878
|
+
}
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
620
881
|
/** An integer dimension worth carrying onto the emitted image (nothing else is trusted). */
|
|
621
882
|
function dimensionAttr(value) {
|
|
622
883
|
if (value === null)
|
|
@@ -649,6 +910,77 @@ function objectImg(element, baseUrl) {
|
|
|
649
910
|
...(height === undefined ? {} : { height }),
|
|
650
911
|
});
|
|
651
912
|
}
|
|
913
|
+
/**
|
|
914
|
+
* An embed, kept: a sandboxed frame or a native media element.
|
|
915
|
+
*
|
|
916
|
+
* The pane used to drop iframes wholesale, then (2026-10-18, same report)
|
|
917
|
+
* left a placeholder link — the honest floor. But the framing check says the
|
|
918
|
+
* embeds readers actually hit (Anthropic's artifact charts, 16 on one page)
|
|
919
|
+
* carry no X-Frame-Options/frame-ancestors, so they CAN render here — the same
|
|
920
|
+
* way the source page renders them: sandboxed, `allow-scripts` and never
|
|
921
|
+
* `allow-same-origin`, so the frame's scripts run on an opaque origin with no
|
|
922
|
+
* sight of this page, no popups, no top navigation. The caption link stays as
|
|
923
|
+
* the way out (a frame that fails to load still names itself).
|
|
924
|
+
*
|
|
925
|
+
* Sizing comes from the page's own layout: the wrapper's aspect box
|
|
926
|
+
* (`padding-bottom: N%`, the Anthropic/ArtifactEmbed shape), else the frame's
|
|
927
|
+
* declared dimensions, else the pane's default height.
|
|
928
|
+
*
|
|
929
|
+
* The gate is what keeps trackers from ever GETTING a frame: a hidden or
|
|
930
|
+
* pixel-sized embed, or an iframe with no title AND no content-sized
|
|
931
|
+
* dimensions (a 200px floor — every measured content embed clears it, every
|
|
932
|
+
* measured tracker does not), stays dropped. Video/audio become native
|
|
933
|
+
* `controls` players with `preload="none"` — media elements run no scripts,
|
|
934
|
+
* and nothing downloads until the reader presses play.
|
|
935
|
+
*
|
|
936
|
+
* @param tag - the embed's tag (`iframe`, `video`, `audio`).
|
|
937
|
+
* @param element - the embed element.
|
|
938
|
+
* @param baseUrl - base for relative URL resolution.
|
|
939
|
+
* @returns the embed markup, or an empty string.
|
|
940
|
+
*/
|
|
941
|
+
function embedElement(tag, element, baseUrl) {
|
|
942
|
+
const style = element.getAttribute('style') ?? '';
|
|
943
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/i.test(style))
|
|
944
|
+
return '';
|
|
945
|
+
const width = dimensionAttr(element.getAttribute('width'));
|
|
946
|
+
const height = dimensionAttr(element.getAttribute('height'));
|
|
947
|
+
if ((width !== undefined && Number(width) <= 2) || (height !== undefined && Number(height) <= 2))
|
|
948
|
+
return '';
|
|
949
|
+
const src = absolutize(element.getAttribute('src'), baseUrl)
|
|
950
|
+
?? absolutize(element.querySelector('source')?.getAttribute('src') ?? null, baseUrl);
|
|
951
|
+
if (src === undefined)
|
|
952
|
+
return '';
|
|
953
|
+
const title = (element.getAttribute('title') ?? '').trim();
|
|
954
|
+
if (tag === 'iframe' && title.length === 0
|
|
955
|
+
&& (width === undefined || Number(width) < 200 || height === undefined || Number(height) < 200))
|
|
956
|
+
return '';
|
|
957
|
+
let label = title;
|
|
958
|
+
if (label.length === 0) {
|
|
959
|
+
try {
|
|
960
|
+
label = new URL(src).host;
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
return '';
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (tag === 'video' || tag === 'audio') {
|
|
967
|
+
const poster = tag === 'video' ? absolutize(element.getAttribute('poster'), baseUrl) : undefined;
|
|
968
|
+
return `<${tag} controls preload="none" src="${escapeAttribute(src)}"${poster === undefined ? '' : ` poster="${escapeAttribute(poster)}"`}></${tag}>`;
|
|
969
|
+
}
|
|
970
|
+
let sizing = '';
|
|
971
|
+
const wrapper = element.parentElement?.getAttribute('style') ?? '';
|
|
972
|
+
const aspect = /padding-bottom\s*:\s*([\d.]+)%/i.exec(wrapper);
|
|
973
|
+
if (aspect !== null) {
|
|
974
|
+
const padding = Number(aspect[1]);
|
|
975
|
+
if (padding > 5 && padding < 2000)
|
|
976
|
+
sizing = ` style="aspect-ratio: ${(100 / padding).toFixed(4)}"`;
|
|
977
|
+
}
|
|
978
|
+
else if (width !== undefined && height !== undefined) {
|
|
979
|
+
sizing = ` style="aspect-ratio: ${width} / ${height}"`;
|
|
980
|
+
}
|
|
981
|
+
return `<div data-reader-embed="iframe"><iframe src="${escapeAttribute(src)}" title="${escapeAttribute(label)}" sandbox="allow-scripts" loading="lazy" referrerpolicy="no-referrer"${sizing}></iframe>`
|
|
982
|
+
+ `<a href="${escapeAttribute(src)}" rel="noopener noreferrer" target="_blank">${escapeHtml(label)}</a></div>`;
|
|
983
|
+
}
|
|
652
984
|
/* --------------------------------------------------------- the SVG subset */
|
|
653
985
|
/**
|
|
654
986
|
* Inline SVG: the chart markup a capture-rendered page carries.
|
|
@@ -662,9 +994,9 @@ function objectImg(element, baseUrl) {
|
|
|
662
994
|
* `use`/`symbol`), looked up case-insensitively and emitted in the canonical
|
|
663
995
|
* SVG casing — the HTML parser's foreign-content adjustment already gave us
|
|
664
996
|
* that casing, and the emitted string is re-parsed as HTML downstream;
|
|
665
|
-
* - `<script
|
|
666
|
-
*
|
|
667
|
-
*
|
|
997
|
+
* - `<script>` and every SMIL element (`animate`, `set`, …) drop with their
|
|
998
|
+
* subtrees — a static figure needs none of them; `<foreignObject>` keeps
|
|
999
|
+
* only what the HTML whitelist keeps (diagram labels live there);
|
|
668
1000
|
* - attributes come from one whitelist (geometry, presentation, the inlined
|
|
669
1001
|
* `style`, paint-server/clip refs, and `id` — `url(#…)` and `href="#…"` point
|
|
670
1002
|
* at it), and any value containing `url(` must reference a same-document
|
|
@@ -684,7 +1016,7 @@ const SVG_TAGS = new Map([
|
|
|
684
1016
|
'linearGradient', 'radialGradient', 'stop', 'use', 'symbol', 'marker', 'desc',
|
|
685
1017
|
].map(name => [name.toLowerCase(), name]));
|
|
686
1018
|
/** Subtree-dropped inside SVG: script-capable or animated. (`title` collides with the HTML drop.) */
|
|
687
|
-
const SVG_DROP = new Set(['script', '
|
|
1019
|
+
const SVG_DROP = new Set(['script', 'animate', 'animatemotion', 'animatetransform', 'set']);
|
|
688
1020
|
/** Attributes kept on SVG elements; everything else (event handlers included) is stripped. */
|
|
689
1021
|
const SVG_ATTRIBUTES = [
|
|
690
1022
|
'id',
|
|
@@ -704,6 +1036,8 @@ const SVG_ATTRIBUTES = [
|
|
|
704
1036
|
'patternContentUnits', 'spreadMethod', 'preserveAspectRatio', 'startOffset',
|
|
705
1037
|
'markerWidth', 'markerHeight', 'markerUnits', 'orient', 'refX', 'refY',
|
|
706
1038
|
'marker-start', 'marker-mid', 'marker-end',
|
|
1039
|
+
// a foreignObject label box may spill past its nominal size (LaTeXML sets it)
|
|
1040
|
+
'overflow',
|
|
707
1041
|
];
|
|
708
1042
|
/** A kept value may only reference this same document: every `url(` must open a `#` fragment. */
|
|
709
1043
|
function svgValueSafe(value) {
|
|
@@ -719,29 +1053,215 @@ function svgAttributes(element) {
|
|
|
719
1053
|
const value = element.getAttribute(name);
|
|
720
1054
|
if (value === null || !svgValueSafe(value))
|
|
721
1055
|
continue;
|
|
722
|
-
parts.push(` ${name}="${escapeAttribute(value)}"`);
|
|
1056
|
+
parts.push(` ${name}="${escapeAttribute(resolveSvgVars(value))}"`);
|
|
723
1057
|
}
|
|
724
1058
|
return parts.join('');
|
|
725
1059
|
}
|
|
1060
|
+
/** Custom-property names that read as the page's own ground, not as ink. */
|
|
1061
|
+
const SVG_GROUND_VAR = /(^|-)(bg|background|ground|surface|paper|canvas|page)(-|$)/i;
|
|
1062
|
+
/**
|
|
1063
|
+
* Rewrite every `var(--…)` in a kept SVG value to something that paints here.
|
|
1064
|
+
*
|
|
1065
|
+
* A statically served chart colors itself through the PAGE's custom
|
|
1066
|
+
* properties (`style="stroke:var(--line)"`), defined in a stylesheet the
|
|
1067
|
+
* extraction never carries. Left as-is, each reference resolves against the
|
|
1068
|
+
* reader's document instead: an undefined one drops the stroke (an axis-less
|
|
1069
|
+
* scatter of black dots — claude.dev's Figure 1), and a name that collides
|
|
1070
|
+
* with a host token paints in a color nobody chose. So no reference survives:
|
|
1071
|
+
* a declared fallback wins, a ground-like name becomes `none` (callout discs
|
|
1072
|
+
* stop hiding what lies under them), and every other one becomes
|
|
1073
|
+
* `currentColor` — the article's ink, legible in both themes. A
|
|
1074
|
+
* capture-rendered page never reaches this: its styles arrive computed.
|
|
1075
|
+
*
|
|
1076
|
+
* @param value - one attribute value.
|
|
1077
|
+
* @returns the value with no custom-property reference left.
|
|
1078
|
+
*/
|
|
1079
|
+
export function resolveSvgVars(value) {
|
|
1080
|
+
let out = value;
|
|
1081
|
+
for (let guard = 0; guard < 32; guard++) {
|
|
1082
|
+
const start = out.search(/var\(/i);
|
|
1083
|
+
if (start < 0)
|
|
1084
|
+
return out;
|
|
1085
|
+
// The matching paren, and the first top-level comma inside it.
|
|
1086
|
+
let depth = 0;
|
|
1087
|
+
let comma = -1;
|
|
1088
|
+
let end = -1;
|
|
1089
|
+
for (let index = start + 3; index < out.length; index++) {
|
|
1090
|
+
const char = out[index];
|
|
1091
|
+
if (char === '(')
|
|
1092
|
+
depth++;
|
|
1093
|
+
else if (char === ')') {
|
|
1094
|
+
depth--;
|
|
1095
|
+
if (depth === 0) {
|
|
1096
|
+
end = index;
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else if (char === ',' && depth === 1 && comma < 0)
|
|
1101
|
+
comma = index;
|
|
1102
|
+
}
|
|
1103
|
+
if (end < 0)
|
|
1104
|
+
return out.slice(0, start);
|
|
1105
|
+
const name = out.slice(start + 4, comma < 0 ? end : comma).trim();
|
|
1106
|
+
const fallback = comma < 0 ? '' : out.slice(comma + 1, end).trim();
|
|
1107
|
+
const replacement = fallback.length > 0
|
|
1108
|
+
? fallback
|
|
1109
|
+
: SVG_GROUND_VAR.test(name.replace(/^--/, '')) ? 'none' : 'currentColor';
|
|
1110
|
+
out = `${out.slice(0, start)}${replacement}${out.slice(end + 1)}`;
|
|
1111
|
+
}
|
|
1112
|
+
return out;
|
|
1113
|
+
}
|
|
1114
|
+
/** Id/class tokens that mark an `<svg>` as the small-screen copy of a figure. */
|
|
1115
|
+
const SVG_ALTERNATE_TOKEN = /^(mobile|narrow|small|compact|sm|xs)$/i;
|
|
1116
|
+
/** Whether an element's id or class names it a small-screen variant. */
|
|
1117
|
+
function namesAlternate(element) {
|
|
1118
|
+
const names = `${element.getAttribute('id') ?? ''} ${element.getAttribute('class') ?? ''}`;
|
|
1119
|
+
return names.split(/[\s_-]+/).some(token => SVG_ALTERNATE_TOKEN.test(token));
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Whether this `<svg>` is the small-screen duplicate of a sibling figure.
|
|
1123
|
+
*
|
|
1124
|
+
* Responsive pages ship one chart twice — a wide drawing and a narrow one —
|
|
1125
|
+
* and let a media query show one. The extraction carries no stylesheet, so
|
|
1126
|
+
* both would render back to back. The narrow copy drops only when a sibling
|
|
1127
|
+
* `<svg>` NOT marked narrow is there to stand for the figure.
|
|
1128
|
+
*
|
|
1129
|
+
* @param element - a top-level `<svg>`.
|
|
1130
|
+
* @returns true when the element is the duplicate to drop.
|
|
1131
|
+
*/
|
|
1132
|
+
function isResponsiveAlternate(element) {
|
|
1133
|
+
if (!namesAlternate(element))
|
|
1134
|
+
return false;
|
|
1135
|
+
const parent = element.parentElement;
|
|
1136
|
+
if (parent === null)
|
|
1137
|
+
return false;
|
|
1138
|
+
return Array.from(parent.children).some(sibling => sibling !== element && sibling.localName.toLowerCase() === 'svg' && !namesAlternate(sibling));
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Bring a body stored by an older extractor up to the current rules.
|
|
1142
|
+
*
|
|
1143
|
+
* Bodies are extracted once and cached, so the fixes above that can be
|
|
1144
|
+
* replayed on whitelisted markup (`resolveSvgVars`, `isResponsiveAlternate`,
|
|
1145
|
+
* `settleMathJaxTwins`, `settleListLabels`) would otherwise reach only
|
|
1146
|
+
* articles fetched after they shipped — a cached claude.dev post kept drawing
|
|
1147
|
+
* its figure as bare dots until its TTL ran out. This replays them at display
|
|
1148
|
+
* time. A body with no `<svg>` or `<li>`, or one the current extractor wrote,
|
|
1149
|
+
* comes back as the same string. (What the old extractor DROPPED — a
|
|
1150
|
+
* foreignObject's labels — is not here to restore; that takes a refetch.)
|
|
1151
|
+
*
|
|
1152
|
+
* @param html - a stored (already whitelisted) article body.
|
|
1153
|
+
* @returns the body under the current rules.
|
|
1154
|
+
*/
|
|
1155
|
+
export function repairStoredBody(html) {
|
|
1156
|
+
if (!/<(?:svg|li)[\s>]/i.test(html) || typeof DOMParser === 'undefined')
|
|
1157
|
+
return html;
|
|
1158
|
+
let doc;
|
|
1159
|
+
try {
|
|
1160
|
+
doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
return html;
|
|
1164
|
+
}
|
|
1165
|
+
let changed = settleMathJaxTwins(doc.body);
|
|
1166
|
+
if (settleListLabels(doc.body))
|
|
1167
|
+
changed = true;
|
|
1168
|
+
for (const svg of Array.from(doc.body.querySelectorAll('svg'))) {
|
|
1169
|
+
// Nested drawings ride their top-level figure's pass.
|
|
1170
|
+
if ((svg.parentElement?.closest('svg') ?? null) !== null)
|
|
1171
|
+
continue;
|
|
1172
|
+
if (isResponsiveAlternate(svg)) {
|
|
1173
|
+
svg.remove();
|
|
1174
|
+
changed = true;
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
for (const element of [svg, ...Array.from(svg.querySelectorAll('*'))]) {
|
|
1178
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
1179
|
+
if (!/var\(/i.test(attribute.value))
|
|
1180
|
+
continue;
|
|
1181
|
+
element.setAttribute(attribute.name, resolveSvgVars(attribute.value));
|
|
1182
|
+
changed = true;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return changed ? doc.body.innerHTML : html;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Keep MathJax's MathML copy of a formula and drop its glyph drawing.
|
|
1190
|
+
*
|
|
1191
|
+
* MathJax 2's SVG output renders each formula twice: an `<svg>` assembled
|
|
1192
|
+
* from `<use href="#MJ…">` references into a page-global glyph table, then
|
|
1193
|
+
* the same formula as MathML in a visually-hidden "assistive" span. The
|
|
1194
|
+
* extraction never carries that glyph table, so the drawing degrades to its
|
|
1195
|
+
* fraction bars — stray lines and blank gaps — while the hidden MathML is
|
|
1196
|
+
* the one copy that can still render. The `<svg>` drops, the MathML takes its
|
|
1197
|
+
* place out of its 1px clip wrapper, and the TeX escapes MathJax would have
|
|
1198
|
+
* drawn as bare characters (`\#` → `#`) are undone in its text runs.
|
|
1199
|
+
*
|
|
1200
|
+
* Runs at extraction and again at display time over stored bodies, so it is
|
|
1201
|
+
* idempotent: a settled body has no glyph-reference `<svg>` left to match.
|
|
1202
|
+
*
|
|
1203
|
+
* @param root - the subtree to settle in place.
|
|
1204
|
+
* @returns true when anything changed.
|
|
1205
|
+
*/
|
|
1206
|
+
function settleMathJaxTwins(root) {
|
|
1207
|
+
let changed = false;
|
|
1208
|
+
for (const svg of Array.from(root.querySelectorAll('svg'))) {
|
|
1209
|
+
if (!drawsMathJaxGlyphs(svg))
|
|
1210
|
+
continue;
|
|
1211
|
+
const twin = svg.nextElementSibling;
|
|
1212
|
+
if (twin === null)
|
|
1213
|
+
continue;
|
|
1214
|
+
let math = null;
|
|
1215
|
+
if (twin.localName.toLowerCase() === 'math') {
|
|
1216
|
+
math = twin;
|
|
1217
|
+
}
|
|
1218
|
+
else if (twin.children.length === 1 && twin.children[0].localName.toLowerCase() === 'math') {
|
|
1219
|
+
math = twin.children[0];
|
|
1220
|
+
twin.replaceWith(math);
|
|
1221
|
+
}
|
|
1222
|
+
if (math === null)
|
|
1223
|
+
continue;
|
|
1224
|
+
svg.remove();
|
|
1225
|
+
for (const text of Array.from(math.querySelectorAll('mtext'))) {
|
|
1226
|
+
const value = text.textContent ?? '';
|
|
1227
|
+
const unescaped = value.replace(/\\([#%&$_{}])/g, '$1');
|
|
1228
|
+
if (unescaped !== value)
|
|
1229
|
+
text.textContent = unescaped;
|
|
1230
|
+
}
|
|
1231
|
+
changed = true;
|
|
1232
|
+
}
|
|
1233
|
+
return changed;
|
|
1234
|
+
}
|
|
1235
|
+
/** Whether an `<svg>` is drawn from MathJax's page-global glyph table. */
|
|
1236
|
+
function drawsMathJaxGlyphs(svg) {
|
|
1237
|
+
return Array.from(svg.querySelectorAll('use')).some(use => /^#MJ/.test(use.getAttribute('href') ?? use.getAttribute('xlink:href') ?? ''));
|
|
1238
|
+
}
|
|
726
1239
|
/** One SVG subtree → whitelisted markup (or empty). */
|
|
727
|
-
function normalizeSvg(element) {
|
|
1240
|
+
function normalizeSvg(element, baseUrl) {
|
|
728
1241
|
const name = element.localName.toLowerCase();
|
|
729
1242
|
if (SVG_DROP.has(name))
|
|
730
1243
|
return '';
|
|
1244
|
+
// A diagram's node labels (LaTeXML's TikZ output writes every one this way).
|
|
1245
|
+
// The HTML inside walks the body's own whitelist — the same subset any
|
|
1246
|
+
// article markup gets — so the injection vector it was dropped for is gone.
|
|
1247
|
+
if (name === 'foreignobject') {
|
|
1248
|
+
const inner = normalizeChildren(element, baseUrl, true);
|
|
1249
|
+
return inner.trim().length === 0 ? '' : `<foreignObject${svgAttributes(element)}>${inner}</foreignObject>`;
|
|
1250
|
+
}
|
|
731
1251
|
const canonical = SVG_TAGS.get(name);
|
|
732
1252
|
if (canonical === undefined)
|
|
733
|
-
return normalizeSvgChildren(element);
|
|
1253
|
+
return normalizeSvgChildren(element, baseUrl);
|
|
734
1254
|
if (canonical === 'use' || canonical === 'textPath') {
|
|
735
1255
|
// A fragment reference only: an external one drops the element outright.
|
|
736
1256
|
const href = (element.getAttribute('href') ?? element.getAttribute('xlink:href'))?.trim();
|
|
737
1257
|
if (href === undefined || !/^#\S+$/.test(href))
|
|
738
1258
|
return '';
|
|
739
|
-
return `<${canonical} href="${escapeAttribute(href)}"${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
1259
|
+
return `<${canonical} href="${escapeAttribute(href)}"${svgAttributes(element)}>${normalizeSvgChildren(element, baseUrl)}</${canonical}>`;
|
|
740
1260
|
}
|
|
741
|
-
return `<${canonical}${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
1261
|
+
return `<${canonical}${svgAttributes(element)}>${normalizeSvgChildren(element, baseUrl)}</${canonical}>`;
|
|
742
1262
|
}
|
|
743
1263
|
/** Every child of an SVG node: text is escaped, elements walk the subset. */
|
|
744
|
-
function normalizeSvgChildren(element) {
|
|
1264
|
+
function normalizeSvgChildren(element, baseUrl) {
|
|
745
1265
|
const parts = [];
|
|
746
1266
|
for (const child of Array.from(element.childNodes)) {
|
|
747
1267
|
if (child.nodeType === 3 /* text */) {
|
|
@@ -750,7 +1270,7 @@ function normalizeSvgChildren(element) {
|
|
|
750
1270
|
}
|
|
751
1271
|
if (child.nodeType !== 1 /* element */)
|
|
752
1272
|
continue;
|
|
753
|
-
parts.push(normalizeSvg(child));
|
|
1273
|
+
parts.push(normalizeSvg(child, baseUrl));
|
|
754
1274
|
}
|
|
755
1275
|
return parts.join('');
|
|
756
1276
|
}
|
|
@@ -817,7 +1337,10 @@ function imageSrc(element, baseUrl) {
|
|
|
817
1337
|
if (resolved !== undefined)
|
|
818
1338
|
return resolved;
|
|
819
1339
|
}
|
|
820
|
-
|
|
1340
|
+
// lazysizes-style markup keeps the candidate list in `data-srcset` and leaves
|
|
1341
|
+
// `srcset` to the script that never runs here: read both, the real one first.
|
|
1342
|
+
return bestSrcset(element.getAttribute('srcset'), baseUrl)
|
|
1343
|
+
?? bestSrcset(element.getAttribute('data-srcset'), baseUrl);
|
|
821
1344
|
}
|
|
822
1345
|
/**
|
|
823
1346
|
* The largest candidate of a `srcset` list.
|
|
@@ -875,17 +1398,21 @@ function bestSrcset(value, baseUrl) {
|
|
|
875
1398
|
function pictureImg(element, baseUrl) {
|
|
876
1399
|
const fallback = Array.from(element.children).find(child => child.localName === 'img');
|
|
877
1400
|
const alt = fallback?.getAttribute('alt') ?? null;
|
|
1401
|
+
// The picture's size is its <img>'s (claude.com's testimonial logos).
|
|
1402
|
+
const width = fallback === undefined ? undefined : displayWidth(fallback);
|
|
1403
|
+
const dimensions = width === undefined ? {} : { width };
|
|
878
1404
|
for (const source of Array.from(element.children)) {
|
|
879
1405
|
if (source.localName !== 'source')
|
|
880
1406
|
continue;
|
|
881
|
-
const url = bestSrcset(source.getAttribute('srcset'), baseUrl)
|
|
1407
|
+
const url = bestSrcset(source.getAttribute('srcset'), baseUrl)
|
|
1408
|
+
?? bestSrcset(source.getAttribute('data-srcset'), baseUrl);
|
|
882
1409
|
if (url !== undefined)
|
|
883
|
-
return emitImg(url, alt);
|
|
1410
|
+
return emitImg(url, alt, dimensions);
|
|
884
1411
|
}
|
|
885
1412
|
if (fallback !== undefined) {
|
|
886
1413
|
const url = imageSrc(fallback, baseUrl);
|
|
887
1414
|
if (url !== undefined)
|
|
888
|
-
return emitImg(url, alt);
|
|
1415
|
+
return emitImg(url, alt, dimensions);
|
|
889
1416
|
}
|
|
890
1417
|
return '';
|
|
891
1418
|
}
|
|
@@ -963,8 +1490,9 @@ function serializeInline(node) {
|
|
|
963
1490
|
continue;
|
|
964
1491
|
// A noscript's fallback image means nothing in an inline summary; the rest
|
|
965
1492
|
// of its content ("please enable JavaScript") was never content at all.
|
|
966
|
-
// An object's fallback text
|
|
967
|
-
|
|
1493
|
+
// An object's fallback text, a chart's text labels and an embed's
|
|
1494
|
+
// placeholder are the same chrome.
|
|
1495
|
+
if (tag === 'noscript' || tag === 'object' || tag === 'svg' || tag === 'iframe' || tag === 'video' || tag === 'audio')
|
|
968
1496
|
continue;
|
|
969
1497
|
if (tag === 'br') {
|
|
970
1498
|
out += ' ';
|