Find screen location of a word in textbox2 stimulus

Dear all,

I display pages of a text to my participants using textbox2 and collect their gaze position using an eyetracker. Because I want to present another stimulus whenever a participant looks at a specific word on each page (the word will be different on each page), I need to find that word’s location on the screen.

Assuming that the text is stored in a string variable, I thought I simply sum the width of each individual word as rendered on the screen until I get to the target word (accounting for line breaks). But I can’t seem to find a way to get the rendered width of a word. Another idea was that I use a monospace font and simply multiply the number of letters and spaces to the target word with the rendered width of a letter. But again, I can’t seem to find out the rendered width of a letter.

Anyone having a hint for me?

Best
Alexs

Try using JavaScript to dynamically measure word positions by creating hidden elements with the text’s font properties and using`getBoundingClientRect() to get their dimensions. This method can help synchronize participant gaze with specific words on each page effectively.

Hi Sebastian,

Thanks for your suggestion. So, do you mean I convert everything to an online study using Javascript? Or is it possible to include Javascript elements into a local study?

Best
Alexs

1 Like

Is this something you thing could work in PsychoPy? eyetracking is generally done locally.

Personally I would use a fixed width font and calculate the position – I’ve done this previously for a dictation task.

I actually managed to create a demo by summing bounding boxes of individual words until I reach the target string (I take line breaks into account by checking whether the horizontal sum exceeds the wrap width). The problem is that I only managed to do this using visual.TextStim, as there is the convenient boundingbox property. However, I need double line spacing for the experiment, and if I’m not mistaken I need visual.TextBox2 for this. But I don’t know how to obtain bounding boxes of visual.TextBox2 stimuli.

Yes, I think this could work in PsychoPy.

You’re welcome! You can do either. Convert everything to an online study using JavaScript or include JavaScript elements in a local study by running the files on your computer through a web browser. Let me know if you need more details!

In the end, I chose to stick to a python implementation using visual.TextStim to display the text. Because I needed double spacing between lines, I implemented a function that estimates where the line breaks would be based on the bounding boxes of individual words (plus whitespace). At these locations ‘\n\n’ is inserted into the text. The function also sums up the bounding boxes until the target phrase for the gaze contingent part and returns that as well.

This might not be the smartest implementation, but it seems to work so far. If anyone is interested, please DM me.

1 Like

Hi Alexander ! How are you?
I’m interested in your implementation. I’ve been working with the same problem around here. Could you help me?

Best,
Juliana

Hi Juliana,

Sure, here is function I implemented. Feel free to ask questions, if anything is unclear. Also note that there might be smarter ways to do this, but at least it works for me.

Best
Alex

# Splits the text into individual lines that fit within
# the width of the screen. This is done because we
# need double spaced lines, and there is no parameter
# in visual.TextStim that sets line spacing. So we do 
# this manually.
# (it would be possible to use visual.TextBox2 for double 
# spacing, but as of now, the bounding box function does
# not seem to have been implemented yet)
# In addition, the function calculates the position 
# and size of the bounding box for a target phrase 
# in a text stimulus. Since the experiment uses gaze
# to trigger a probe, we need to locate the
# position of a target word/phrase on the screen
# and compare it with the current eye gaze.
def splitIntoLines(text, target, height, font, origin, wrap):
    line, result  = [], []
    rct = None
    wordbox = visual.TextStim(win,
                                                    text               = "",
                                                    height           = height,
                                                    font               = font,
                                                    pos                = origin,
                                                    color              = "white",
                                                    units              = 'norm',
                                                    alignText     = 'left',
                                                    anchorHoriz  = 'left',
                                                    anchorVert    = 'top',
                                                    wrapWidth   = wrap)
    
    # Split text into individual words in order to 
    # determine the sequence of words that fit
    # into a line
    wrds    = text.split()
    # Determine the index of the target phrase
    # so we can conveniently check its location 
    # while putting together the lines
    # UPDATE (5.9.24): Check if target phrase is None
    # which indicates that no trigger should be activated
    # on a given page
    if (target != 'None'):
        tarind  = len(text.split(target)[0].split())
    else:
        tarind = -1

    # Copy the list in order not to have side effects
    curpos = origin.copy()

    # Determine the width of a whitespace character
    # as this has to be added to each cursor position
    wordbox.setText(f" ")
    space = toNorm(wordbox)[0]
    
    # Determine the size of the bounding box for the
    # target phrase. The origin will be calculate below
    wordbox.setText(target)
    bounds = toNorm(wordbox)
        
    # 1. Go through each word and determine its
    # bounding box. 
    # 2. Sum bounding box widths until the sum 
    # exceeds the wrap width of a line.  
    # 3. If wrap width is exceeded, store the words 
    # until that position and begin a new line
    for index, word in enumerate(wrds):
        boxtext            = f"{word}"
        wordbox.setText(boxtext)
        worddim = toNorm(wordbox)

        # Check if target phrase reached
        # -> if yes, then determine bounding box
        if tarind > -1:
            if index == tarind: 
                rct = visual.Rect(win           = win,
                                             pos         = [curpos[0] + bounds[0]/2,
                                                               curpos[1] - bounds[1]/2],
                                             size        = bounds,
                                             lineColor = "red")

        # If width of the line exceeds wrap width,
        # then flatten list of words into a single string,
        # add the string to the results list,
        # start a new line with the last word 
        if (curpos[0] + abs(worddim[0])) > wrap/2:
            # Flatten the list of words
            addline = (' '.join(line)).rstrip()
            # Add the string to the results
            result.append(addline)
            # Start new line with last word
            line = []
            line.append(boxtext)
            # Update horizontal position by adding word width and space
            curpos[0]  = origin[0] + abs(worddim[0]) + space
            # Update vertical position by adding double word height
            curpos[1] -= 2*worddim[1]
        else:
            # Simply add the word to the current line and update
            # the horizontal position by adding word width and space
            line.append(boxtext)
            curpos[0] += abs(worddim[0]) + space

    # END FOR
    
    # In order not to forget the last few words
    # of the text, join them and add them to 
    # the results
    addline = ' '.join(line).rstrip()
    result.append(addline)
    
    # Return both the list of individual lines and
    # the rectangle indicating the bounding box
    # of the target phrase on the screen.
    result  ='\n\n'.join(result)
    return result, rct
2 Likes

Hi Alexander,
Thank you so much, it was very helpful!! I appreciate it.
I have a question, what is the toNorm() function?

Best,
Juliana

Hi Juliana,

sorry, I missed that:

def toNorm(wordbox):
    return [2*wordbox.boundingBox[0]/win.size[0], 
                 2*wordbox.boundingBox[1]/win.size[1]]

But it could be that psychopy’s builtin function does the same. I didn’t test it at the time I wrote this custom function.

Best
Alex

1 Like

Thanks, Alex!