无需手动字体渲染系统,即可对堆叠的变音符号进行精确着色

前端开发 2026-07-09

我正在尝试构建一个仅使用CSS的系统,供我的Anki卡片组使用。这个系统的目的是对大量使用声调的语言中的变音符号进行颜色高亮,并对带重音的转写进行标注,例如越南语。

const combiningMarks = {
  771: 126, // tilde
  769: 180, // acute accent
  768: 96, // grave accent
  803: 196, //dot below Ä (placeholder letter, not relevant)
  777: 214, //question mark Ö (placeholder letter, not relevant)
  795: 220, //horn Ü (placeholder letter, not relevant)
  770: 223, //circumflex ß (placeholder letter, not relevant)
  774: 222 //Breve Þ (placeholder letter, not relevant)

}

// const target = 'mè bố bẳ người bệ bế bể bễ bề ';
const target = "a á ạ à ả ã ă ắ ặ ằ ẳ ẵ â ấ ậ ầ ẩ ẫ e é ẹ è ẻ ẽ ê ế ệ ề ể ễ i í ị ì ỉ ĩ o ó ọ ò ỏ õ ô ố ộ ồ ổ ỗ ơ ớ ợ ờ ở ỡ u ú ụ ù ủ ũ ư ứ ự ừ ử ữ y ý ỵ ỳ ỷ ỹ đ"
const decomposedString = target.normalize("NFD")
const codepoints = [...decomposedString].map(c => c.codePointAt(0))
const charsWithFullMarks = codepoints.map(c => combiningMarks[c] || c)
var finalString = String.fromCodePoint(...charsWithFullMarks)

finalString = finalString.replaceAll("Ü", '<span class="diacritical-mark-generic">̛</span>');
finalString = finalString.replaceAll("ß", '<span class="diacritical-mark-generic">̂</span>');
finalString = finalString.replaceAll("Þ", '<span class="diacritical-mark-generic">̆</span>');
finalString = finalString.replaceAll("´", '<span class="diacritical-mark-green">́</span>');
finalString = finalString.replaceAll("`", '<span class="diacritical-mark-red">̀</span>');
finalString = finalString.replaceAll("~", '<span class="diacritical-mark-pink">̃</span>');
finalString = finalString.replaceAll("Ä", '<span class="diacritical-mark-blue">̣</span>');
finalString = finalString.replaceAll("Ö", '<span class="diacritical-mark-orange">̉</span>');

document.getElementById("exp").innerHTML = finalString
:root,
html,
body {
  font-family: sans-serif;
  /* This needs to be different from the diacritic font for the coloring to work */
}

span.diacritical-mark-generic {
  color: initial;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}

span.diacritical-mark-red {
  color: #ff3131;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}

span.diacritical-mark-green {
  color: #31ff61;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}

span.diacritical-mark-blue {
  color: #4081e9;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}

span.diacritical-mark-pink {
  color: #ff31cf;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}

span.diacritical-mark-orange {
  color: #ff9f04;
  font-family: "DejaVu Sans";
  /* font with the most accurate positioning */
}
<span id="exp"></span><br>
<p>
  a á ạ à ả ã ă ắ ặ ằ ẳ ẵ â ấ ậ ầ ẩ ẫ e é ẹ è ẻ ẽ ê ế ệ ề ể ễ i í ị ì ỉ ĩ o ó ọ ò ỏ õ ô ố ộ ồ ổ ỗ ơ ớ ợ ờ ở ỡ u ú ụ ù ủ ũ ư ứ ự ừ ử ữ y ý ỵ ỳ ỷ ỹ đ
</p>

这段代码将变音符从输入的字母中分离出来,并将它们作为占位符标记放在元音后面,之后这些占位符会被转换成组合变音符并包裹在一个用于样式的span中。

在我看来这似乎是一个相当hacky的解决方案,存在一些警告,比如span必须再应用另一种字体才能实现样式,且变音符的对齐可能时好时坏(很可能因为我们通过使用不同的span和字体来将重音与字母分离)。具体来说,像堆叠的上方组合重音以及重音相互覆盖,或者i 点没有被可用的重音所替代这样的特殊情况,是不可接受的。

我并不想编写一个完整的字体渲染系统,包含矢量和对变音符的手动定位,因此我想寻找一种聪明且简单的方法来克服这些缺点,并对可能有多重堆叠的变音符进行着色,颜色可指定。

解决方案

@herrstrietzel已经概述了将字符及其变音符分解时固有的问题。

因此我们放弃对结构进行分析的任何尝试,而是纯粹将字符及其变音符作为掩码使用。这可以揭示背景图像的某些部分,从而给出所需的颜色。

这段片段在垂直方向上有4 个区域,最上面两个用于(可能的)两个堆叠的变音符,中间的主体字符,最下面的点。如果你需要更多的堆叠,就增加区域的数量。大小写字母需要不同的区域大小。要知道这些尺寸似乎只能对任意特定字体用尺子来测量。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Diacrytics</title>
  <style>
    /* vowel */
    .v {
      display: inline-block;
      font-size: 5rem;
      background-image: linear-gradient(var(--c1) 0% 18%, var(--c2) 18% 35%, black 35% 85%, var(--c4) 85% 100%);
      /* Clip the background to the text */
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
      -webkit-text-fill-color: transparent;
      --c: transparent;
      /* colors in the 4 areas */
      --c1: transparent;
      --c2: var(--c);
      --c3: transparent;
      --c4: transparent;

      --ca: red;
      --cb: green;
      --cc: blue;
      --cg: cyan;
      --ch: yellow;
      --ct: magenta;
      --cd: orange;
    }

    /* colors for different diacritics */
    /* diacritics: none, acute, breve, circumflex, grave, hook, tilde, dot below  horn is not treated as a diacritic */

    /* acute */
    .a {
      --c: var(--ca);
    }

    /* breve */
    .b {
      --c: var(--cb);
    }

    /* circumflex */
    .c {
      --c: var(--cc);
    }

    /* grave */
    .g {
      --c: var(--cg);
    }

    /* hook */
    .h {
      --c: var(--ch);
    }

    /* tilde */
    .t {
      --c: var(--ct)
    }

    /* dot below */
    .d {
      --c4: var(--cd);
    }

    .a1 {
      --c1: var(--ca);
    }

    .b1 {
      --c1: var(--cb);
    }

    .c1 {
      --c1: var(--cc);
    }

    .g1 {
      --c1: var(--cg);
    }

    .h1 {
      --c1: var(--ch);
    }

    .t1 {
      --c1: var(--ct);
    }

    /* i has to be treated specially so its dot doesnt get colored as a diacritic */
    .v.i {
      --c1: black;
      --c2: black;
      background-image: linear-gradient(var(--c1) 0% 18%, var(--c2) 18% 37%, black 37% 85%, var(--c4) 85% 100%);
    }

    /* y has to be treated specially so its bottom dot can be seen alongside its descender */
    .v.y {
      background-image: linear-gradient(var(--c1) 0% 18%, var(--c2) 18% 37%, black 37% 100%);
    }

    .v.y.d {
      --c4: var(--cd);
      background-image: linear-gradient(var(--c4), var(--c4)), linear-gradient(var(--c1) 0% 18%, var(--c2) 18% 37%, black 37% 100%);
      background-size: 40% 30%, 100% 100%;
      background-position: right bottom, top left;
      background-repeat: no-repeat;
    }

    /* set up colors area 1 */
    .a1.b,
    .a1.c,
    .a1.g,
    .a1.h,
    .a1.t {
      --c1: var(--ca);
    }

    .b1.b,
    .b1.c,
    .b1.g,
    .b1.h,
    .b1.t {
      --c1: var(--cb);
    }

    .c1.b,
    .c1.a,
    .c1.g,
    .c1.h,
    .c1.t {
      --c1: var(--cc
      }

      ;
    }

    .g1.b, .g1.c, .g1.a, .g1.h, .g1.t {
      --c1: var(--cg);
    }

    .h1.b, .h1.c, .h1.g, .h1.a, .h1.t {
      --c1: var(--ch);
    }

    .t1.b, .t1.c, .t1.g, .t1.h, .t1.a {
      --c1: var(--ct);
    }
  </style>
</head>

<body>
  <div class="v d">a</div>
  <div class="v a">á</div>
  <div class="v d">ạ</div>
  <div class="v g">à</div>
  <div class="v a">ả</div>
  <div class="v t">ã</div>
  <div class="v b">ă</div>
  <div class="v a1 b">ắ</div>
  <div class="v b d">ặ</div>
  <div class="v g1 b">ằ</div>
  <div class="v h1 b">ẳ</div>
  <div class="v t1 b">ẵ</div>
  <div class="v c">â</div>
  <div class="v a1 c">ấ</div>
  <div class="v c d">ậ</div>
  <div class="v g1 c">ầ</div>
  <div class="v h1 c">ẩ</div>
  <div class="v t1 c">ẫ</div>
  <div class="v">e</div>
  <div class="v a">é</div>
  <div class="v d">ẹ</div>
  <div class="v g">è</div>
  <div class="v h">ẻ</div>
  <div class="v t">ẽ</div>
  <div class="v c">ê</div>
  <div class="v a1 c">ế</div>
  <div class="v c d">ệ</div>
  <div class="v g1 c">ề</div>
  <div class="v h1 c">ể</div>
  <div class="v t1 c">ễ</div>
  <div class="v i">i</div>
  <div class="v a">í</div>
  <div class="v d i">ị</div>
  <div class="v g">ì</div>
  <div class="v h">ỉ</div>
  <div class="v t">ĩ</div>
  <div class="v">o</div>
  <div class="v a">ó</div>
  <div class="v d">ọ</div>
  <div class="v a">ò</div>
  <div class="v h">ỏ</div>
  <div class="v t">õ</div>
  <div class="v c">ô</div>
  <div class="v a1 c">ố</div>
  <div class="v c d">ộ</div>
  <div class="v g1 c">ồ</div>
  <div class="v h1 c">ổ</div>
  <div class="v t1 c">ỗ</div>
  <div class="v">ơ</div>
  <div class="v h">ớ</div>
  <div class="v d">ợ</div>
  <div class="v g">ờ</div>
  <div class="v h">ở</div>
  <div class="v t">ỡ</div>
  <div class="v">u</div>
  <div class="v a">ú</div>
  <div class="v d">ụ</div>
  <div class="v g">ù</div>
  <div class="v h">ủ</div>
  <div class="v t">ũ</div>
  <div class="v">ư</div>
  <div class="v a">ứ</div>
  <div class="v d">ự</div>
  <div class="v g">ừ</div>
  <div class="v h">ử</div>
  <div class="v t">ữ</div>
  <div class="v y">y</div>
  <div class="v a y ">ý</div>
  <div class="v y d">ỵ</div>
  <div class="v y g">ỳ</div>
  <div class="v y h">ỷ</div>
  <div class="v y t">ỹ</div>
</body>

</html>

备选方案

简而言之:所有变通方案都存在局限性:
要运行示例,请参阅 A Haworth的 CSS-only方法 或向下滚动至JS的变通方法。

你当前的JS方法已经解决了一些必需的挑战,但按这种方式不会工作:
当前,你正在尝试把所有字符分解为它们的基础字符以及相应的 组合变音记号

不幸的是,这种方法有很多局限性:

  • 并非所有变音符都能被分解
  • 浏览器的渲染引擎会尝试将 [graphemes] 组合在一起——因此变音分量不能单独应用样式(这也是你在代码中不得不更改字体族的原因——显然这会破坏对齐)
  • 你可以尝试通过 [Combining grapheme joiner] 强制一个单独的渲染上下文,但在Chromium/Blink浏览器中会失败

CSS与变音符

仅靠CSS无法带来太多,因为我们无法访问例如变音符位置这样的字体特定属性值。我们也没有类似于 text-transform:no-diacritics(其作用类似于JS的 normalize("NFD"))的东西。因此,我们需要手动计算合适的偏移量,例如用于蒙版/裁剪的方法。

变音符选择的局限性

核心问题在于浏览器的形状处理和文本渲染引擎如何处理 [组合变音符号] 与 [字素连接符(grapheme joiners)] 的组合。

下面给出一个示例:

body {
  font-size: 10em;
  font-family: sans-serif;
}

h3 {
  font-size: 16px;
}

h3,
p {
  margin: 0;
}


.serif {
  font-family: serif;
}


.diacritic {}



.diacritic-highlight {

  .base {
    color: #ccc;
  }

  .diacritic {
    color: red;
  }

  /* no effect */
  .base::first-letter {
    color: red;
  }
}
<h3>Composed</h3>
<p class="diacritics">
  <span class="base">ä</span>
  <span class="base">Ä</span>
</p>

<h3>Decomposed - no Combining Grapheme Joiner</h3>
<p class="diacritics-decomposed diacritic-highlight">
  <span class="base">a</span><span class="diacritic">̈</span>
  <span class="base">A</span><span class="diacritic">̈</span>
</p>


<h3>Decomposed 2 - Combining Grapheme Joiner</h3>
<p class="diacritics-decomposed diacritic-highlight">
  <span class="base">a&#x034F;</span><span class="diacritic">̈</span>
  <span class="base">A&#x034F;</span><span class="diacritic">̈</span>
</p>

<h3>Decomposed 3 - Different font, no Combining Grapheme Joiner</h3>
<p class="diacritics-decomposed diacritic-highlight">
  <span class="base">a</span><span class="diacritic serif">̈</span>
  <span class="base">A</span><span class="diacritic serif">̈</span>
</p>


<h3>Decomposed 4 - Different font, Combining Grapheme Joiner</h3>
<p class="diacritics-decomposed diacritic-highlight">
  <span class="base">a&#x034F;</span><span class="diacritic serif">̈</span>
  <span class="base">A&#x034F;</span><span class="diacritic serif">̈</span>
</p>

如你所见,即使把它们分成两个独立的 <span> 元素,变音符也能与基础字形完美对齐。

然而,如果我们改变影响布局的样式属性,例如 font-familydisplayfont-size 等,我们就会失去对齐的正确上下文——变音符会完全错位。

另一方面,诸如 color 的属性——不影响布局——会被忽略。浏览器会采用基础字形的颜色。

在Firefox中,我们可以通过添加一个字素连接符来欺骗渲染器。正如前面所说——这在Chromium中不起作用。

Firefox
Firefox

Chromium
Chromium

当你看到带有变音符的字符时,通常看到的是独立成形的字形设计——而不是在运行时基于组件拼合的复合字形。

出于性能原因,浏览器通常也会在一个渲染上下文中尝试将基础字形与变音符号组合在一起(类似于字形连笔)。因此你通常看到的是独立成形的字形设计——而不是在运行时基于组件拼合的复合字形。

此外,字体设计师通常会为某些变音符调整位置或笔画宽度,以实现更和谐的视觉效果。

下面是越南语字符 »Ư« 的一个例子(上部带有逗号形状的重音符)。

Firefox
Firefox renderings

如果我们尝试使用前述技巧结合Combining Grapheme Joiner——在Firefox中,我们的变音符被高亮显示,但定位完全错位。

body {
  font-size: 10em;
  font-family: sans-serif;
}

svg {
  display: block;
  outline: 1px solid #ccc;
  overflow: visible;
}

h3 {
  font-size: 16px;
}

h3,
p {
  margin: 0;
}

.diacritic {
}

.serif {
  font-family: serif;
}

.diacritic-highlight {
  .base {
    position: relative;
    color: #ccc;
  }

  .diacritic {
    color: red;
  }

  /* no effect */
  .base::first-letter {
    color: red;
  }
}
<h3>Composed – overlapping diacritic</h3>
<p class="diacritics">
  <span class='base'>ư</span>
</p>

<h3>Decomposed 1 – overlapping diacritic, no Combining Grapheme Joiner</h3>
<p class="diacritics diacritic-highlight">
  <span class='base'>u</span><span class='diacritic'>̛</span>
</p>

<h3>Decomposed 2 – overlapping diacritic, Combining Grapheme Joiner</h3>
<p class="diacritics diacritic-highlight">
  <span class='base'>u&#x034F;</span><span class='diacritic'>̛</span>
</p>

JS变通方法:通过覆盖层实现伪高亮

由于我们不能依赖对变音符的分解,我们尝试通过以下方式来伪造:

  • 在前面添加一个带颜色的(原始)复合变音符字形副本,使用绝对定位和 z-index:-1
  • 通过JS [string.normalize("NFD")] 检测基础字形,并将其放在包裹元素的前景中

我们还可以通过比较基础字形与变音符的边界框来细化高亮——使用原生画布方法 [context.measureText()]
为了同时捕捉通过标记组合的变音符,我们可以使用原生 [Intl.Segmenter] 方法将字符串拆分成一个字素对象数组。

//add uppecase
let text = ` 
ạ à ả ã ă ắ ặ ằ ẳ ẵ â ấ ậ ầ ẩ ą í ị ì ỉ ĩ
  e é ẹ è ẻ ẽ ê ế ệ ề ể ễ ę ę̈
  ç
  ø o ó ọ ò ỏ õ ô ố ộ ồ ổ ỗ ơ ớ ợ ờ ở ỡ ǫ
  u ú ụ ù ủ ũ ư ứ ự ừ ử ữ ų
  y ý ỵ ỳ ỷ ỹ đ
`;

text+=text.toUpperCase();


let pDiacritics = document.querySelectorAll(".diacritics-wrp");
pDiacritics.forEach(p=>{
  //text = text + text.toUpperCase();
  let spans = diacriticToSpans(text);
  p.innerHTML = spans;  
})



function diacriticToSpans(str, fontFamily = "sans-serif") {
  let html = "";

  // Unicode grapheme segmentation
  const segmenter = new Intl.Segmenter(undefined, {
    granularity: "grapheme"
  });

  let chars = [...segmenter.segment(str.trim())].map((s) => s.segment);

  for (let i = 0; i < chars.length; i++) {
    let char = chars[i];

    // whitespace
    if (!char.trim()) {
      html += char;
      continue;
    }


    let nfd = char.normalize("NFD");

    // Split base + ALL combining marks
    let parts = [...nfd];
    let base = parts[0];

    // everything after first codepoint = combining sequence
    let diacritics = parts.slice(1);

    // exceptions
    ({ base, diacritics = [] } = handleExceptions(base, diacritics));

    let baseClass = `b-${base.toLowerCase()}`;

    // no diacritics
    if (!diacritics.length) {
      //console.log(char, base);
      html += `<span class="${baseClass} diacritic-none">${char}</span>`;
      continue;
    }

    let diacriticOverlay = "";

    let { isTop, isBottom, isLeft, isRight, hasOverlay, classes } = getPositions(char,base);

    // multiple combining marks supported
    let diacriticClasses = diacritics.map((d) => `d-${d.codePointAt(0)}`);
    diacriticClasses.push(...classes);

    // append overlay
    if (hasOverlay) {
      diacriticOverlay = `<span class="diacritic diacritic-overlay no-select" aria-hidden="true">${char}</span>`;
      diacriticClasses.push("diacritic-has-overlay");
    }

    html += `
      <span class="diacritic ${diacriticClasses.join(" ")} ${baseClass}">
        <span class="diacritic diacritic-inner">${char}</span>
        ${diacriticOverlay}
        <span class="diacritic diacritic-base no-select" aria-hidden="true">
          ${base}
        </span>
      </span>
    `;
  }

  return html;
}

/* get position */
function getPositions(char, base) {
  let isTop = false;
  let isBottom = false;
  let isLeft = false;
  let isRight = false;

  // compare bboxes to detect upper or lower positions
  let bb0 = getTextMetrics(base);
  let bb1 = getTextMetrics(char);

  let classes = [];
  let hasOverlay = false;

  if (bb0.y > bb1.y) {
    isTop = true;
    classes.push(`d-top`);
  }

  if (bb0.bottom < bb1.bottom) {
    isBottom = true;
    classes.push(`d-bottom`);
  }

  if (bb0.right < bb1.right) {
    isRight = true;
    classes.push(`d-right`);
  }

  if (bb0.left > bb1.left) {
    isLeft = true;
    classes.push(`d-left`);
  }

  if (isTop && isBottom) {
    hasOverlay = true;
  }

  // within bbbox
  if (!isTop && !isBottom && !isLeft && !isRight) {
    classes.push(`d-inside`);
  }

  if (isTop && isBottom && (isLeft || isRight) && base !== "ı" && base != "I") {
    hasOverlay = false;
    classes.push(`d-cross`);
  }

  return { isTop, isBottom, isLeft, isRight, hasOverlay, classes };
}

/* exceptions */
function handleExceptions(base = "", diacritics = []) {
  let baseLc = base.toLowerCase();
  let isLC = base === baseLc;

  if (baseLc === "i") {
    base = "ı";
  } else if (baseLc === "đ") {
    base = "d";
    diacritics = ["đ"];
  } else if (baseLc === "Ø") {
    base = "O";
    diacritics = ["Ø"];
  } else if (baseLc === "ø") {
    base = "o";
    diacritics = ["ø"];
  }

  // switch case for exceptions
  if (!isLC) {
    base = base.toUpperCase();
    diacritics = diacritics.map((d) => d.toUpperCase());
  }

  return { base, diacritics };
}

function getTextMetrics(
  text,
  fontFamily = "sans-serif",
  fontSize = 1000,
  fontWeight = 400,
  fontStyle = "normal"
) {
  //ignore whitespace
  text = text.trim();

  // create temporary canvas
  let canvas = document.createElement("canvas");
  let context = canvas.getContext("2d");
  // scale for more precise values
  let scale = fontSize < 100 ? 1000 / fontSize : 1;

  //render text
  context.font = `${fontWeight} ${fontStyle} ${
    fontSize * scale
  }px ${fontFamily}`;

  let {
    actualBoundingBoxLeft,
    actualBoundingBoxRight,
    actualBoundingBoxAscent,
    actualBoundingBoxDescent,
    fontBoundingBoxAscent,
    fontBoundingBoxDescent
  } = context.measureText(text);

  let left = actualBoundingBoxLeft;
  let right = actualBoundingBoxRight;
  let width = right + left;
  let ascender = actualBoundingBoxAscent;
  let descender = actualBoundingBoxDescent;
  let height = ascender + descender;
  let top = fontBoundingBoxAscent - ascender;

  let metrics = {
    x: -(left / scale),
    y: top / scale,
    left: -(left / scale),
    top: top / scale,
    right: right / scale,
    bottom: (top + height) / scale,
    width: width / scale,
    height: height / scale,
    ascender: ascender / scale,
    descender: descender / scale
  };

  canvas.remove();
  return metrics;
}
:root {
  --sideBearingLeft: 0;
  --diacriticColor: red;
  --color-base: #ccc;
  --color-top: blue;
  --color-bottom: green;
  --color-right: orange;
  --color-left: purple;
  --color-cross: magenta;
  --color-inside: cyan;
}

body {
  font-size: 5em;
  font-family: "Noto Sans", sans-serif;
}

h3,
p {
  margin: 0;
}

h3{
  font-size:24px
}


.serif{
  font-family: "Alegreya", serif;
}

.diacritic {
  position: relative;
  display: inline-block;
  color: var(--diacriticColor);
}

.diacritic-base {
  position: absolute;
  top:0;
  left: var(--sideBearingLeft);
  color: var(--color-base);

  /* prevent color-bleed */
  -webkit-text-stroke: 0.02em var(--color-bg, #fff);
  paint-order: stroke;
}

.no-select{
  user-select: none;
  pointer-events: none;
}

.diacritic-none {
  color: var(--color-base);
}

/* diacritic highlighting based on position */

.d-right {
  --diacriticColor: var(--color-right);
}
.d-left {
  --diacriticColor: var(--color-left);
}

.d-top {
  --diacriticColor: var(--color-top);
}
.d-bottom {
  --diacriticColor: var(--color-bottom);
}

.d-inside {
  --diacriticColor: var(--color-inside);
}

.d-cross {
  --diacriticColor: var(--color-cross);
}

.diacritic-has-overlay {

  .diacritic-inner {
    --diacriticColor: var(--color-top);
    position: relative;
    clip-path: inset(0 0 50% 0);

  }
}

.diacritic-overlay {
  position: absolute;
  inset: 0 0 0 0;
  color: var(--color-bottom);
  clip-path: inset(50% 0 0 0);
}

/* define exceptions if needed */
.d-803 {
  --sideBearingLeft: -0em;
}
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans:[email protected]&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Alegreya:[email protected]&display=swap">

<h3>Noto Sans</h3>
<p class="diacritics-wrp"> 
</p>

<h3>Alegreya</h3>
<p class="diacritics-wrp serif"> 
</p>

这实际上与这里 Pier Paolo Ramon:"How to display accents over words with different colors in HTML/CSS?" 所建议的核心原理相同。

这种覆盖方法的主要优点在于,我们可以将原始的“完整”变音符字形放在背景中,而不必担心对齐问题——除非基础字形度量与变音符复合体有显著差异。

挑战:

  • 我们需要确保覆盖层不能被复制/选中或读取——我们对基础字形覆盖应用 aria-hidden=true 属性以排除覆盖层,并使用 user-select:nonepointer-events:none 来防止选择
  • 为了避免颜色渗透/接缝,我们需要扩展覆盖层的轮廓——例如通过 text-stroketext-shadow

同样,必须使用能为所需语言/Unicode区间提供完整支持的字体——回退字体替换会破坏布局。在这方面,JS助手方法也相当与字体无关,并支持大小写字符串。

RTL(从右到左)支持?

上述辅助方法对阿拉伯语或希伯来语等书写系统并不起作用,因为它们大量使用上下文连字。不过,我们可以通过对基础检测进行更多的预定义异常来获得可接受的结果。

以下是一个初步的测试片段。免责声明:对这些字母表的拼写错误表示歉意——无法正确朗读。

let hebrew = `שָׁלוֹם אֱלֹהִים`;
let arabic = `السَّلَامُ`;
let mixed = "hẽllo السَّلَامُ wörlđ"
let hello_world_ar=` مرحبا العالم`;

let sampleTexts = [
  mixed,
  hebrew
]


let pDiacrticsRegular = document.querySelectorAll(".diacritics-regular");
let pDiacritics = document.querySelectorAll(".diacritics-wrp");

pDiacritics.forEach((p,i)=>{
  let text = sampleTexts[i];
  let spans = diacriticWordsToSpans(text);
  pDiacrticsRegular[i].textContent = text;
  p.innerHTML = spans;  
})



function diacriticWordsToSpans(str, fontFamily = "sans-serif") {

  const wordSegmenter = new Intl.Segmenter(undefined, {
    granularity: "word"
  });

  let segments = [...wordSegmenter.segment(str)];

  // check overall directions
  let directions = new Set([]);
   segments.forEach(seg=>{
    let txt = seg.segment.trim();
    if(txt){
      let rtl = isRTL(txt);
     seg.rtl = rtl;
     directions.add(rtl)
    }
  });

  //console.log(segments)

  // check if text is completetely RTL or LTR
  directions = Array.from(directions);

  let rtl_all = directions.length===1 && directions[0]===true;
  let hasMixedDir = directions.length>1;
  let rtl_class = rtl_all ? 'dir-rtl' : ''
  let html = `<span class="diacritic-text ${rtl_class}">`;

  //console.log('hasMixedDir', hasMixedDir, 'directions', directions)

  for (let seg of segments) {

    let {segment, isWordLike, rtl} = seg;

    // preserve whitespace exactly
    if (!isWordLike) {
      html += segment;
      continue;
    }


    let baseWord = segment
      .normalize("NFD")
      .replace(/\p{Mark}/gu, "");

    baseWord = fixBaseWord(baseWord);

    html += `
      <span
        class="word ${rtl ? 'word-rtl' : 'word-ltr'}"
        dir="${rtl ? 'rtl' : 'ltr'}"
      >
        <span class="word-original">
          ${segment}
        </span>

        <span
          class="word-base no-select"
          aria-hidden="true"
        >
          ${baseWord}
        </span>

      </span>
    `;
  }

  html += `</span>`;

  return html;
}




function isRTL(str) {
  return getWritingDirection(str) === 'rtl';
}


function getWritingDirection(str) {
  // RTL Unicode ranges: Arabic, Hebrew, Syriac, Thaana, NKo, etc.
  const rtlPattern = /[\u0590-\u08FF\u200E\u200F\uFB1D-\uFB4F]/;

  // LTR check (optional, for explicit detection)
  const ltrPattern = /[\u0041-\u005A\u0061-\u007A\u0391-\u03A9\u03B1-\u03C9\u0400-\u04FF]/;

  if (rtlPattern.test(str)) {
    return 'rtl';
  }

  if (ltrPattern.test(str)) {
    return 'ltr';
  }

  return 'ltr'; // Default
}


function fixBaseWord(word) {

  return word
    .replace(/i/g, "ı")
    .replace(/đ/g, "d")
    .replace(/Ø/g, "O")
    .replace(/ø/g, "o");
}
:root {
  --sideBearingLeft: 0;
  --diacriticColor: red;
  --color-base: #ccc;
  --color-top: blue;
  --color-bottom: green;
  --color-right: orange;
  --color-left: purple;
  --color-cross: magenta;
  --color-inside: cyan;
}

body {
  font-size: 5em;
  font-family: "Rubik", "Noto Sans", sans-serif;
  font-family: "Noto Sans", sans-serif;
}

h3,
p {
  margin: 0;
}

h3{
  font-size:24px
}

.word,
.diacritic {
  position: relative;
  display: inline-block;
  color: var(--diacriticColor);
}


.word-base,
.diacritic-base {
  position: absolute;
  top:0;
  left: var(--sideBearingLeft);
  color: var(--color-base);

  /* prevent color-bleed */
  -webkit-text-stroke: 0.02em var(--color-bg, #fff);
  paint-order: stroke;
}


.no-select{
  user-select: none;
  pointer-events: none;
}




/* writing direction isolation */
.word {
  position: relative;
  display: inline-block;
  unicode-bidi: isolate;
}


/* base glyphs layer */
.word-base {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* explicit writing directions */
.dir-rtl,
.word-rtl {
  direction: rtl;
  unicode-bidi: isolate;
}

.dir-ltr,
.word-ltr {
  direction: ltr;
}
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans:[email protected]&display=swap">


<p class="diacritics-wrp"> 
</p>

<p class="diacritics-regular"> 
</p>


<p class="diacritics-wrp"> 
</p>

<p class="diacritics-regular"> 
</p>

上述示例在不同浏览器的阿拉伯文文本上已经暴露出问题。理想情况下,一位母语者可以在此基础上改进并找到修复方法。

另请参阅 "How to display accents over words with different colors in HTML/CSS?"

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章