Reading in Colour

TL;DR: Try pressing this button to see if it helps your reading speed. (Tested in Chrome and Safari.)

I've always been a particularly slow reader. I don't think that I have dyslexia or anything like that. Although I have definitely noticed that it has been getting a lot worse. Maybe due to me not reading anywhere near as much as I used to.

It's become especially apparent at work. Staring at larger and larger blocks of code, my eyes just sort of bug out. Like a wall of text is just a huge pain to read.

Now code is definitely much worse to read than a novel. There's no way to tell if your eyes accidentally skipped a line. Splitting a sentence in the middle and splicing on a random part of will hopefully give you a hint that you missed something, since it simply won't make sense. But with code that you are unfamiliar with, or are trying to read so that you can understand what does, just about anything goes. You hope that it made sense to the guy that wrote it, when they wrote it, but who knows what I was thinking, lol.

But this difficulty in reading is what got me thinking about a study or something that I had seen a while back. I can't remember the specifics of it, but it went something along the lines of: colouring the ends of the lines of text different colours, so that the end of one line was the same colour as the beginning of the next line, made it easier for your eyes to track.

I mean, I'm not sure how effective that is for code. I can try that later. But for now, simply making it work for this fairly simple website to see if it helps seems like it would be a good place to start.

So what would it take to implement something like that for a website?

Well I started by simply grabbing all of the text in a paragraph tag <p>, wrapping all of the words in <span>s and colouring each word based on its relative x position in the line.

const p = document.querySelector("p");
const words = p.innerText.split(" ");
p.innerHTML = `<span>${words.join("</span> <span>")}</span>`;
const width = p.getBoundingClientRect().width;
let line    = 0;
let prevTop = 0;

const red    = [221, 126, 126];  // #dd7e7e
const blue   = [128, 174, 221];  // #80aedd
const purple = [220, 204, 243];  // #dcccf3

[...p.children].forEach(element => {
    const bounds = element.getBoundingClientRect();
    const top = bounds.top;
    if (top !== prevTop) {
        prevTop = top;
        ++line;
    }

    let offset = (bounds.left + bounds.right) / width;
    const colors = (line & 1) ? [red, blue] : [blue, red];
    let r, g, b;
    if (offset > 1) {
        offset = 2 - offset;
        r = colors[0][0] + (purple[0] - col[0][0]) * offset;
        g = colors[0][1] + (purple[1] - col[0][1]) * offset;
        b = colors[0][2] + (purple[2] - col[0][2]) * offset;
    } else {
        r = colors[1][0] + (purple[0] - col[1][0]) * offset;
        g = colors[1][1] + (purple[1] - col[1][1]) * offset;
        b = colors[1][2] + (purple[2] - col[1][2]) * offset;
    }
    element.style.color = `rgb(${r}, ${g}, ${b})`;
});

That worked alright. I guess. Though I really didn't like the look.

I think I had 2 major issues with it. First, the abrupt colour change from word to word, particularly longer words as they had a larger change from their neighbours. Second, alternating red start + blue end, and blue start + red end, meant that they always had the same colour middle which still made it too easy for my eyes to get lost in the middle of a line.

The second can be fixed by adding a third colour. (Note that I used a third purple colour in the above example only to prevent the the text from tending to get too dark as it was lerped from red to blue.) By colouring the lines A/B, B/C, C/A ... the middle colours will be more different.

As for the hard colour changes, I needed to look up a way to do proper text gradients. Searching on Google lead me to the article on CSS Gradient which I found very helpful, with the code and an example.

Basically it comes down to using the following CSS styling.

background: linear-gradient(to right, #000, #fff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

But I was still having one more issue with the above code -- one on which I was not willing to settle.

Go ahead, take a look back at the code above and see if you can spot the problem. I'll wait.

 

 

 

...

 

 

 

Still there?

Alright.

Well the issue is as follows: in the above code, we use the innerText of each paragraph. This completely ignores any other tags that we use! Forget about them, they are never coming back. I simply couldn't allow all of those useful tags like helpful links, images, or custom styling to be removed that easily.

So a different tactic needed to be used. One that takes into account existing elements. But I really didn't want to write an HTML parser. There's no way I could do that faster (especially in javascript) than the multitude of brilliant programmers who make the browser could do in native machine code.

After various little tests, I settled on using element.childNodes. The list of child nodes contains both the text, as well as the HTML elements. We can then check the type of each node, to handle them differently. So the text stays text and the tags stay tags.

After a little more massaging, I finally ended up with the following code. It's not perfect. But feel free to copy and paste this into the web dev console of various sites and see what you think.

[...document.querySelectorAll("p")].forEach(p => {
    const words    = [];
    const sections = [];
    [...p.childNodes].forEach(element => {
        if (element instanceof Text) {
            words.push(...element.textContent.trim().split(" "));
            sections.push(element.textContent.split(" ").map(s => `<span>${s}</span>`).join(" "));
        } else {
            words.push(`<span>${element.outerHTML}</span>`);
            sections.push(`<span>${element.outerHTML}</span>`);
        }
    });
    p.innerHTML = sections.join("");

    let prevTop  = 0;
    let addSpace = false;
    p.innerHTML  = [...p.childNodes].reduce((prev, element) => {
        if (element instanceof Text) {
            addSpace = true;
            return prev;
        }
        const bounds = element.getBoundingClientRect();
        const top    = bounds.top;
        if (top !== prevTop) {
            prevTop = top;
            if (prev) {
                    prev += "</span> ";
            }
            prev += "<span>" + element.innerHTML;
        } else {
            if (addSpace) {
                    prev += " ";
            }
            prev += element.innerHTML;
        }

        addSpace = false;
        return prev;
    }, "") + "</span>";

    const colors = [
        "#80aedd",
        "#dcccf3",
        "#dd7e7e",
        "#dcccf3",
        "#80aedd",
    ];
    [...p.children].forEach((element, i) => {
        i = i & 3;
        element.setAttribute("style", `background:linear-gradient(to right,${colors[i]},${colors[i+1]});-webkit-background-clip:text;-webkit-text-fill-color:transparent;`);

        [...element.children].forEach(child => {
            child.setAttribute("style", "-webkit-text-fill-color:currentColor;");
        });
    });
});