You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Nuxt应用中外部字体导致文本换行计算错误的问题排查

问题解决:RobotMono字体下文本分行计算偏差导致换行异常

核心原因

  1. 字体加载时机不匹配:组件挂载后立即计算文本宽度,但此时RobotMono字体可能尚未加载完成,导致用系统 fallback 字体计算宽度,和实际渲染的宽度产生偏差。
  2. 动态span样式不完整:手动设置的span样式没有完全匹配#text-container的渲染上下文,添加到body后无法继承容器的样式环境,导致宽度计算基准不一致。
  3. 容器宽度获取时机:divideTextInLines中获取容器宽度时,DOM可能还未完成渲染,导致宽度值不准确。

修复方案

1. 等待字体加载完成再处理文本

利用document.fonts.ready确保RobotMono字体加载完毕后,再进行文本分割计算,避免用默认字体生成错误的宽度数据。

2. 让动态span继承容器样式

将动态创建的span添加到text-container内部,而非body,自动继承容器的所有字体相关样式,无需手动复刻所有属性。

3. 确保容器宽度准确获取

通过nextTick等待DOM更新完成后,再执行分行计算,保证容器宽度是最终渲染值。

修改后的完整代码

<template>
    <div class="typing-container">
        <div id="text-container">
            <div v-for="(line, lineId) in displayedLines" :key="lineId">
                <span v-for="(letter, letterId) in line.line" :key="letterId">
                    <span v-if="data.letterIndex === data.absoluteLetterIndexex[lineId+data.lineIndex][letterId]"  class="text-line"></span>
                    <span :style="{color: data.colors[data.absoluteLetterIndexex[lineId+data.lineIndex][letterId]]}">{{letter}}</span>
                </span>
            </div>
        </div>
        <font-awesome-icon :icon="['fas', 'rotate']" />
    </div>
</template>

<script setup lang="ts">
import { computed, reactive, onMounted, nextTick } from 'vue';
import { useTypingStore } from '../../store/typing/typingStore'
import { Word, Line } from './types';

const store = useTypingStore();
const { $axios } = useNuxtApp();

const data = reactive({
    text: "",
    letterIndex: 0,
    wordIndex:0,
    lineIndex: 0,
    colors: [] as string[],
    words: [] as Word[],
    lines: [] as Line[],
    absoluteLetterIndexex: [] as number[][] 
})

function keyPressed(event: KeyboardEvent){
    const { key } = event;
    const regex = /^[a-zA-Z'.,:?!;()-]$/;
    const currentWord = data.words[data.wordIndex];
    const lastWord = data.words[data.wordIndex-1] ?? null;

    if(regex.test(key)){
        if(key === data.text[data.letterIndex]){
            data.colors[data.letterIndex] = 'white'
            data.words[data.wordIndex].lettersLeft--;
            if(data.letterIndex === data.absoluteLetterIndexex[data.lineIndex+1][data.absoluteLetterIndexex[data.lineIndex+1].length-2]){
                data.lineIndex++;
            }
        }else{
            data.colors[data.letterIndex] = 'red';
        }
        data.words[data.wordIndex].lastWritedIndex = data.letterIndex +1;
        data.letterIndex+=1;
    }else{
        if(key === "Backspace"){
            if(data.letterIndex <= 0 ){
                return;   
            }
            if(data.wordIndex-1 >=0 && data.letterIndex === currentWord.start && lastWord.lettersLeft === 0){
                return;
            }
            if (currentWord.start > data.letterIndex - 1){
                data.letterIndex = lastWord.lastWritedIndex;
                data.words[data.wordIndex].lastWritedIndex =  data.words[data.wordIndex].start;
                data.wordIndex--;
            }else{
                data.letterIndex-=1;
                if(data.colors[data.letterIndex] === "white"){
                    data.words[data.wordIndex].lettersLeft++;
                    data.words[data.wordIndex].lastWritedIndex--;
                }
                data.colors[data.letterIndex] = "gray";
            }
        }
        if(key === ' ' && currentWord.start !== data.letterIndex){
            data.letterIndex = data.words[++data.wordIndex].start
        }
    }
}

function setData( text: string ){
    let newWords: Word[] = [];
    let length = 0;
    let word = "";
    let start=0,lastWritedIndex;

    for (var i = 0; i < text.length; i++) {
      if(text[i] !== ' '){
        length++;
        word+=text[i];
      }else{
        newWords.push({
          start,
          word,
          length,
          lastWritedIndex: start,
          index: newWords.length,
          lettersLeft: length
        });
        start = i+1;
        word = "";
        length= 0
      }
    }
    data.words = newWords;
    data.text = text
    data.colors = (new Array(data.text.length).fill('gray'))
    // 等待DOM更新后再计算分行
    nextTick(() => {
        data.lines = divideTextInLines();
    })
}

async function getRandomText(){
    // 等待字体加载完成再获取文本并处理
    await document.fonts.ready;
    const text = ((await $axios.get('http://metaphorpsum.com/paragraphs/6')).data.replace(/\n/g, ' '));
    setData(text)
}

function divideTextInLines() {
    let lines: Line[] = [];
    let currentLine = "";
    let cumulativeCharacters= 0;
    const textContainer = document.getElementById("text-container");
    if(!textContainer) return lines;
    const containerWidth = textContainer.offsetWidth;

    for (let i = 0; i < data.words.length; i++) {
        const word = data.words[i].word;
        const lineWithWord = currentLine ? currentLine + " " + word : word;
        const lineWidth = getTextWidth(lineWithWord, textContainer);

        if (lineWidth <= containerWidth) {
            currentLine = lineWithWord;
        } else {
            currentLine+= " ";
            lines.push({
                line: currentLine,
                cumulativeCharacters
            });
            
            let lineAbsoluteIndexes = [];
            for (let j = 0; j < currentLine.length; j++) {
                lineAbsoluteIndexes.push(cumulativeCharacters + j);
            }
            data.absoluteLetterIndexex.push(lineAbsoluteIndexes);
            cumulativeCharacters += currentLine.length;
            currentLine = word;
        }
    }

    if (currentLine) {
        currentLine+= " ";
        lines.push({
            line: currentLine,
            cumulativeCharacters
        });
        let lineAbsoluteIndexes = [];
        for (let j = 0; j < currentLine.length; j++) {
            lineAbsoluteIndexes.push(cumulativeCharacters + j);
        }
        data.absoluteLetterIndexex.push(lineAbsoluteIndexes);
    }

    return lines;
}

function getTextWidth(text: string, container: HTMLElement) {
    const span = document.createElement('span');
    // 继承容器的所有计算样式,确保宽度基准一致
    const computedStyle = window.getComputedStyle(container);
    span.style.fontSize = computedStyle.fontSize;
    span.style.fontFamily = computedStyle.fontFamily;
    span.style.fontWeight = computedStyle.fontWeight;
    span.style.letterSpacing = computedStyle.letterSpacing;
    span.style.whiteSpace = 'nowrap';
    span.style.visibility = 'hidden';
    span.style.position = 'absolute'; // 避免影响容器布局

    span.innerText = text;
    container.appendChild(span);
    const width = span.offsetWidth;
    container.removeChild(span);
    return width;
}

const colors = computed(()=>{
    return data.colors;
})

const displayedLines = computed(()=>{
    return data.lines.slice(data.lineIndex, data.lineIndex+3);
})

onMounted(()=>{
    window.addEventListener("keydown", keyPressed )
    getRandomText();
})
</script>

<style lang="css" scoped>
    #text-container{
        width: 80rem;
        max-width: 90%;
        font-size: 1.7rem;
        font-family: RobotMono;
        letter-spacing: normal;
        word-spacing: normal;
    }
    .text-line{
        padding: 2px 1px;
        background-color: red;
        font-size: 1.7rem;
        font-weight: bold;
    }

    .typing-container{
        display: flex;
        flex-direction: column;
        gap: 3rem;
        justify-content: center;
        align-items: center;
    }
</style>

内容的提问来源于stack exchange,提问作者Giuliano Maradei

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 11:24:56