|
659 | 659 | } |
660 | 660 |
|
661 | 661 | function hasOpaqueBackground(style) { |
662 | | - if (style.backgroundImage && style.backgroundImage !== "none") return true; |
| 662 | + if (style.backgroundImage && style.backgroundImage !== "none") { |
| 663 | + if (style.backgroundImage.includes("url(")) return true; |
| 664 | + // A gradient only occludes as much as the colours in it: a 4%-alpha |
| 665 | + // grid/scrim pattern must not count as an opaque occluder. |
| 666 | + if (gradientMaxAlpha(style.backgroundImage) > 0.6) return true; |
| 667 | + } |
663 | 668 | if (isTransparentColor(style.backgroundColor)) return false; |
664 | 669 | return colorAlpha(style.backgroundColor) > 0.6; |
665 | 670 | } |
666 | 671 |
|
| 672 | + function gradientMaxAlpha(backgroundImage) { |
| 673 | + const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g); |
| 674 | + if (!colors) return 1; // named colours we cannot parse — keep the old (opaque) behaviour |
| 675 | + let max = 0; |
| 676 | + for (const color of colors) { |
| 677 | + if (color === "transparent") continue; |
| 678 | + if (color.startsWith("#")) { |
| 679 | + const hex = color.slice(1); |
| 680 | + max = Math.max( |
| 681 | + max, |
| 682 | + hex.length === 4 |
| 683 | + ? parseInt(hex[3] + hex[3], 16) / 255 |
| 684 | + : hex.length === 8 |
| 685 | + ? parseInt(hex.slice(6), 16) / 255 |
| 686 | + : 1, |
| 687 | + ); |
| 688 | + } else { |
| 689 | + max = Math.max(max, colorAlpha(color)); |
| 690 | + } |
| 691 | + } |
| 692 | + return max; |
| 693 | + } |
| 694 | + |
667 | 695 | const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); |
668 | 696 | const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); |
669 | 697 |
|
|
754 | 782 | // reads very differently from a label buried under an overlay. Still |
755 | 783 | // returns the first opaque element found, for `containerSelector`. |
756 | 784 | function occlusionCoverage(element, textRect) { |
757 | | - let occluder = null; |
758 | | - let hits = 0; |
759 | | - for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { |
760 | | - const y = textRect.top + textRect.height * yFraction; |
761 | | - for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { |
762 | | - const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y); |
763 | | - if (!hit) continue; |
764 | | - hits += 1; |
765 | | - if (!occluder) occluder = hit; |
| 785 | + // pointer-events:none makes the element invisible to elementFromPoint, so |
| 786 | + // the probe would always "hit" whatever paints beneath it and misread |
| 787 | + // perfectly visible text as buried (decorative text commonly carries |
| 788 | + // pointer-events:none). Restore hit-testing for the duration of the probe. |
| 789 | + const restore = restoreHitTesting(element); |
| 790 | + try { |
| 791 | + let occluder = null; |
| 792 | + let hits = 0; |
| 793 | + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { |
| 794 | + const y = textRect.top + textRect.height * yFraction; |
| 795 | + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { |
| 796 | + const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y); |
| 797 | + if (!hit) continue; |
| 798 | + hits += 1; |
| 799 | + if (!occluder) occluder = hit; |
| 800 | + } |
766 | 801 | } |
| 802 | + return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) }; |
| 803 | + } finally { |
| 804 | + restore(); |
767 | 805 | } |
768 | | - return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) }; |
| 806 | + } |
| 807 | + |
| 808 | + function restoreHitTesting(element) { |
| 809 | + if (getComputedStyle(element).pointerEvents !== "none") return () => {}; |
| 810 | + const previous = element.style.getPropertyValue("pointer-events"); |
| 811 | + const priority = element.style.getPropertyPriority("pointer-events"); |
| 812 | + element.style.setProperty("pointer-events", "auto", "important"); |
| 813 | + return () => { |
| 814 | + if (previous) element.style.setProperty("pointer-events", previous, priority); |
| 815 | + else element.style.removeProperty("pointer-events"); |
| 816 | + }; |
| 817 | + } |
| 818 | + |
| 819 | + // The container can be fully visible while every text-bearing descendant is |
| 820 | + // still at opacity 0 (entrance not started yet) — there is no ink on screen |
| 821 | + // to occlude, so probing it only manufactures false "buried text" findings. |
| 822 | + function hasVisibleTextInk(element) { |
| 823 | + const nodes = [element, ...element.querySelectorAll("*")]; |
| 824 | + for (const node of nodes) { |
| 825 | + if (directTextNodes(node).length === 0) continue; |
| 826 | + if (opacityChain(node) >= 0.2) return true; |
| 827 | + } |
| 828 | + return false; |
769 | 829 | } |
770 | 830 |
|
771 | 831 | // Catches the blind spot the overflow checks miss: text that fits its box |
|
775 | 835 | // cover on a paragraph is usually a styling artifact, not a reading defect. |
776 | 836 | function occludedTextIssue(element, time) { |
777 | 837 | if (hasAllowOcclusionFlag(element)) return null; |
| 838 | + if (!hasVisibleTextInk(element)) return null; |
778 | 839 | const textRect = textRectFor(element); |
779 | 840 | if (!textRect) return null; |
780 | 841 | const text = textContentFor(element); |
|
0 commit comments