Reputation: 24443
I have some text like this, displayed in HTML:
pīngpāngqiúpāi
How can I change the text color, such that the text is blue while the accents above are red?
Upvotes: 10
Views: 3893
Reputation: 1
This is a look back from the later question, for some search only shows this post instead of that one.
You can mark the accents in a colour different than the base letters, without the text-shadowing hack (demonstrated in other answers) in this way:
ñ
to n͏̃
) where the combining grapheme joiner (U+034F) is required after the base letter and before the combining diacritic marks.There is another workaround not as evil as text-shadowing, but will introduce some offsets. More on that down below.
This is 10 years after the quaestion was posed, but allow me to answer it.
I stepped into this problem on my journey through learning Sanskrit, as the Indic scripts mark the vowel on top. The fonts work same as Latin ones, with ते drawn as त and a े above.
Then there was the fiddle in the aforementioned post, of which the expected behaviour is with red diacritics and blue base letters as shown in the image. Happy after some tests, I decided to make a colourful grammar chart, and shared it with my mates. Later on I get complaints that no one other than me is able to open the correctly rendered chart.
Turns out that Chromium doesn't support this for some how. I tested the said fiddle on both my Win10 and Win11, for Chrome 131.0.6778.109, Edge 114.0.1823.86, Brave 1.73.97, Chromium 131.0.6778.108 and Opera 115.0.5322.77. All shown just blue. Only Firefox works.
As this other answer from the mentioned related post suggests, there is a workaround using another typeface/weight. The visual effect is worse than shadowing though.
Upvotes: 0
Reputation: 17316
All CSS-based approaches heavily rely on fixed "clip" heights or on reconstructing accented characters using joiners.
As an alternative you may also use a font parsing library like opentype.js to get the actual shapes to get a grouped and styleable <svg>
output.
This way we can actually access the font specific metrics and "sub-paths" – the shapes that represent diacritics.
// text to path params
let params = {
string: "gāiúpiÖiąậçấễệỗü",
fontSize: 100,
x: 0,
y: 0
};
(async() => {
let fontUrls = [
//cormorant
"https://fonts.gstatic.com/s/crimsonpro/v24/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZzm18PZE_VNWoyQ.woff2",
// Fira Sans
"https://fonts.gstatic.com/s/firasans/v17/va9E4kDNxMZdWfMOD5VflYLKSTbndQ.woff2",
//Noto Sans
"https://fonts.gstatic.com/s/notosans/v37/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99Y41P6zHtY.woff2"
];
fontUrls.forEach(async(fontUrl, i) => {
// load and parse font
let font = await loadFont(fontUrl);
// render text to paths
let svgContent = text2Path(font, params);
// add to svg
let svg = document.getElementById(`svg${i}`);
svg.insertAdjacentHTML("beforeend", svgContent);
// adjust SVG viewBox to text rendering
adjustViewBox(svg);
});
})();
function text2Path(font, params) {
// collect text SVG markup
let svgContent = "";
let options = {
//defaults
...{
string: "",
x: 0,
y: 0,
fontSize: 100
},
...params
};
let {
string,
x,
y,
fontSize,
decimals
} = options;
// detect x height
let {
unitsPerEm,
ascender,
descender
} = font;
let scale = fontSize / unitsPerEm;
// x-height as specified in the fonts
let xHeight = font.tables.os2.sxHeight;
//fontSize + (ascender*scale)
let xHeightRel = y - xHeight * scale;
let xHeightRelMid = xHeightRel / 3;
let baseLine = y;
// convert input text to glyphs
let glyphs = font.getPaths(string, x, y, fontSize, decimals);
let characters = string.split("");
glyphs.forEach((glyph, g) => {
let commands = glyph.commands;
//convert opentype.js command notation to pathdata
let pathData = OTCommandsToPathData(commands);
/**
* split to subPaths
* glyphs may have multiple sub paths
* e.g »o« but also
* diacritics introduce new sub paths
*/
let subPaths = splitSubpaths(pathData);
//calculate bounding boxes
subPaths.forEach((pathDataSub, i) => {
let xArr = [];
let yArr = [];
/**
* collect x/y coordinates
* from commands to
* approximate a mid point
*/
pathDataSub.forEach((com) => {
let {
type,
values
} = com;
//get a sloppy bbox based on control points
xArr.push(
...values.filter((val, i) => {
return i % 2 === 0;
})
);
yArr.push(
...values.filter((val, i) => {
return i % 2 !== 0;
})
);
});
let xMax = Math.max(...xArr);
let xMin = Math.min(...xArr);
let yMax = Math.max(...yArr);
let yMin = Math.min(...yArr);
let w = xMax - xMin;
let h = yMax - yMin;
let mid = {
x: xMin + w / 2,
y: yMin + h / 2
};
// save metrics to first M command
subPaths[i][0].mid = mid;
subPaths[i][0].bb = {
x: xMin,
y: yMin,
width: w,
height: w
};
});
// separate base shapes from diacritics
let basePaths = [];
let diacritics = [];
for (let i = 0; i < subPaths.length; i++) {
let sub = subPaths[i];
let {
bb,
mid
} = subPaths[i][0];
if (
(
// above xheight
(mid.y < xHeightRel) ||
// below baseline
(mid.y > baseLine)
// below mid x height
&&
bb.y > xHeightRelMid
)
) {
// is diacritic
diacritics.push(subPaths[i]);
} else {
// is base glyph shape
basePaths.push(subPaths[i]);
}
}
let glyphGroup = `<g class="glyph ${characters[g]}">
<path class="pathBase" d="${basePaths
.map((pathData) => {
return pathDataToD(pathData);
})
.join(" ")}"/>
<path class="pathDiacritics" d="${diacritics
.map((pathData) => {
return pathDataToD(pathData);
})
.join(" ")}"/>
</g>`;
svgContent += glyphGroup;
});
//adjust SVG viewBox
return svgContent;
}
function OTCommandsToPathData(commands) {
return commands.map((com) => {
let vals = Object.values(com);
return {
type: vals.slice(0, 1)[0],
values: vals.slice(1).map((val) => {
return +val.toFixed(3);
})
};
});
}
/**
* split compound paths into sub path data array
*/
function splitSubpaths(pathData) {
let SubpathIndices = pathData.reduce((acc, item, index) => {
if (item.type === "M") acc.push(index);
return acc;
}, []);
if (SubpathIndices.length === 1) return [pathData];
let subPathArr = [];
for (let i = 1, len = SubpathIndices.length; len && i <= len; i++) {
let index = SubpathIndices[i - 1];
let indexN = SubpathIndices[i];
subPathArr.push(pathData.slice(index, indexN));
}
return subPathArr;
}
function pathDataToD(pathData) {
return pathData
.map((com) => {
return `${com.type} ${com.values.join(" ")}`;
})
.join(" ");
}
/**
* adjjust viewBox
*/
function adjustViewBox(svg) {
let bb = svg.getBBox();
let [x, y, width, height] = [bb.x, bb.y, bb.width, bb.height].map((val) => {
return +val.toFixed(1);
});
svg.setAttribute("viewBox", [x, y, width, height].join(" "));
}
/**
* opentype.js helper
* Based on @yne's comment
* https://github.com/opentypejs/opentype.js/issues/183#issuecomment-1147228025
* will decompress woff2 files
*/
async function loadFont(src, options = {}) {
let buffer = {};
let font = {};
let ext = "woff2";
let url;
// 1. is file
if (src instanceof Object) {
// get file extension to skip woff2 decompression
let filename = src.name.split(".");
ext = filename[filename.length - 1];
buffer = await src.arrayBuffer();
}
// 2. is base64 data URI
else if (/^data/.test(src)) {
// is base64
let data = src.split(";");
ext = data[0].split("/")[1];
// create buffer from blob
let srcBlob = await (await fetch(src)).blob();
buffer = await srcBlob.arrayBuffer();
}
// 3. is url
else {
// if google font css - retrieve font src
if (/googleapis.com/.test(src)) {
ext = "woff2";
src = await getGoogleFontUrl(src, options);
}
// might be subset - no extension
let hasExt =
src.includes(".woff2") ||
src.includes(".woff") ||
src.includes(".ttf") ||
src.includes(".otf") ?
true :
false;
url = src.split(".");
ext = hasExt ? url[url.length - 1] : "woff2";
let fetchedSrc = await fetch(src);
buffer = await fetchedSrc.arrayBuffer();
}
// decompress woff2
if (ext === "woff2") {
buffer = Uint8Array.from(Module.decompress(buffer)).buffer;
}
// parse font
font = opentype.parse(buffer);
return font;
}
body {
font-family: sans-serif;
}
svg {
display: block;
outline: 1px solid #ccc;
overflow: visible;
}
.pathDiacritics {
fill: red;
}
.glyph:hover {
.pathDiacritics {
fill: green;
}
stroke: red;
stroke-width: 1;
}
<!-- necessary to decompress woff2 -->
<script src="https://unpkg.com/[email protected]/build/decompress_binding.js"></script>
<!-- parse fonts -->
<script src='https://cdn.jsdelivr.net/npm/opentype.js@latest/dist/opentype.min.js'></script>
<p>Hover to see glyph anatomy</p>
<h3>Cormorant</h3>
<svg id="svg0"></svg>
<h3>Fira Sans</h3>
<svg id="svg1"></svg>
<h3>Noto Sans</h3>
<svg id="svg2"></svg>
As you can see some fonts use merged diacritics (e.g the a ogonek or c cedilla) where others draw them as separate shapes.
Admittedly, this also has a lot of caveats but also some benefits.
However, with a suitable font we can get a pretty accurate rendering so this approach might be suitable for educational purposes (illustration for language classes etc).
Upvotes: 0
Reputation: 2900
Expanding the other solutions here, you can actually achieve a solution that doesn’t rely on diacritics to be placed only on the top part of the letter.
You can do this by simply overlapping the two versions (with and without diacritics) and using text-shadow
to cover the anti-alias bleed that obviously occurs in these situations.
Example of antialias color bleeding on the edges of text. Pay attention to the soft red halo / glow around black characters.
You can also just set the text with diacritics to a different opacity if you want them “greyed out”, therefore limiting the bleeding effect.
Some caveats apply:
If you want to color the dot of i
you need the U+0131 LATIN SMALL LETTER
DOTLESS I character (see «Spinal Tap» example)
All the solutions currently on this question rely on the fact that characters don’t change their metrics when diacritics are added or removed. This is not always true, for example with i
and ï
(see the last example).
Sometimes diacritics are attached to the main shape of the letter, such as ç
. This breaks our text-shadow
trick very badly.
Using text-shadow
is not a perfect solution. It requires the background to be at the same time a solid color and known at design time.
In the following snippet I also use CSS custom properties (aka CSS Variables) in order to not having to repeat the background color inside text-shadow
, and on hover you can see what happens when the text-shadow
is removed.
:root {
--body-bg: white;
}
body {
display: flex;
min-height: 100vh;
justify-content: center;
align-items: center;
font-family: sans-serif;
font-size: 30px;
flex-direction: column;
background: var(--body-bg);
}
.setting {
font-size: 14px;
display: flex;
align-items: baseline;
}
body>* {
padding: 10px;
}
[data-with-diacritics] {
display: inline-block;
position: relative;
text-shadow: -1px -1px 0px var(--body-bg), 1px -1px 0px var(--body-bg), -1px 1px 0px var(--body-bg), 1px 1px 0px var(--body-bg);
}
[data-with-diacritics]::before {
content: attr(data-with-diacritics);
position: absolute;
color: red;
}
[data-with-diacritics]>span {
position: relative;
}
[data-with-diacritics]:hover {
text-shadow: none;
outline: 1px solid red;
}
<span data-with-diacritics="Spın̈al Tap">
<span>Spınal Tap</span>
</span>
<span data-with-diacritics="España">
<span>Espana</span>
</span>
<span data-with-diacritics="Français">
<span>Francais</span>
</span>
<span data-with-diacritics="äëïiöü ãõ">
<span>aeııou ao</span>
</span>
Upvotes: 5
Reputation: 15619
CSS treats the letter as a whole and therefore it can only be 1 colour.
Here is a hacky version:
This will only work if the content will stay the same.
span{
font-size:48px;
color:blue;
position:relative;
}
span:after{
content:'pīngpāngqiúpāi';
position:absolute;
left:0;
height:18px;
overflow:hidden;
z-index:9999;
color:red;
top:-5px;
}
<span>pīngpāngqiúpāi</span>
Personally, I wouldn't use this. It's a horrible way of doing it.
Upvotes: 10
Reputation: 63367
I think you don't have any clean solution using pure CSS. As given by BeatAlex, it's a good idea for you to implement some script solving your problem beautifully. In fact I thought of using 2 overlays to create the effect you want, however the difference from the BeatAlex's idea is the top overlay will be the non-accented versions of accented letters. It requires us to convert accented letters to the corresponding non-accented letters. However I found that the idea of using overflow:hidden
applied in this case is really good. I would like to borrow this idea and implement a script fulfilling the solution (which is completely usable). The idea is you just need to find all the accented letters (in the original text), wrap each one with a span
element and then apply the style to these span elemnents. I would like to mention that I've not implemented the right code to be able to filter/detect all the possible accented letters, finally this is still a start for you to complete it. Code details:
CSS:
.distinct-accent {
font-size:30px;
color:blue;
}
.with-accent {
position:relative;
display:inline-block;
}
.with-accent:before {
content:attr(data-content);
position:absolute;
left:0;
top:0;
height:0.4em;
width:100%;
padding-right:0.1em;
color:red;
overflow:hidden;
}
JS:
$(".distinct-accent").html(function(i, oldhtml){
var newhtml = "";
for(var i = 0; i < oldhtml.length; i++){
var nextChar = "";
var code = oldhtml.charCodeAt(i);
if((code > 64 && code < 90) ||
(code > 96 && code < 123) || code == 32) nextChar = oldhtml[i];
else {
nextChar = "<span class='with-accent' data-content='" + oldhtml[i] + "'>" + oldhtml[i] + "</span>";
}
newhtml += nextChar;
}
return newhtml;
});
Update:
NOTE the above code did work well before in all webkit-based browsers (such as Chrome) but now it does not work for unknown reason (the content:attr
does not apply correctly with dynamically updated content via setting innerHtml
). It must be some change (which can be considered as bug) to Chrome causing that non-working. Especially you will see that the HTML output will render correctly after opening and closing the inspector pane).
To fix that issue I just added a simple hack (by removing the element and re-inserting back to force applying the CSS rule correctly). Almost the code above is kept unchanged.
Just append this script:
.each(function(){
$(this).replaceWith($(this).clone(true));
});
Upvotes: 6