@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +29105 -6692
  7. package/lib/index.js +3500 -321
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +860 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +130 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +561 -33
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +81 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/document-fetch.d.ts +106 -0
  54. package/lib/types/document-fetch.js +100 -0
  55. package/lib/types/index.d.ts +15 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +118 -0
  70. package/lib/types/remote.js +151 -1
  71. package/lib/types/service.d.ts +81 -6
  72. package/lib/types/service.js +286 -65
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +90 -2
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +33 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. 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', 'iframe', 'annotation-xml', 'button', 'select', 'input', 'textarea', 'video',
59
- 'audio', 'canvas', 'embed', 'link', 'meta', 'base', 'title',
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
- 'foreignobject',
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 = ['foreignObject'];
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
- const scored = scoreCandidates(doc);
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
- if (node.querySelector('img, picture, canvas, video, figure') !== null)
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
- return emitImg(src, element.getAttribute('alt'));
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, SMIL).
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 inner = normalizeChildren(element, baseUrl, inFigure || tag === 'figure');
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
- return `<${tag}${style === undefined ? '' : ` style="${escapeAttribute(style)}"`}>${inner}</${tag}>`;
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>`, `<foreignObject>` (the HTML-injection vector inside SVG) and
666
- * every SMIL element (`animate`, `set`, …) drop with their subtrees — a
667
- * static figure needs none of them;
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', 'foreignobject', 'animate', 'animatemotion', 'animatetransform', 'set']);
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
- return bestSrcset(element.getAttribute('srcset'), baseUrl);
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 and a chart's text labels are the same chrome.
967
- if (tag === 'noscript' || tag === 'object' || tag === 'svg')
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 += ' ';