我花了很多时间在 StackOverflow 中寻找类似情况的答案,但最终没有找到完美的答案。我最终做的是测量两种字体,然后调整第二种字体的上边距和比例以匹配第一种。 (通过使用缩放而不是改变字体大小,它可以让我们在调整大小后不需要重新计算文本度量)
我为后代整理了几支笔。这是第二个,它处理字体大小的规范化和两种字体之间的对齐:https://codepen.io/zacholas/pen/oNBPWga
第一个,只处理测量,是:https://codepen.io/zacholas/pen/ExZwJjx
我想我必须粘贴一些代码才能链接到 codepen,所以这里是所有比较代码:
HTML:
<h1>Welcome</h1>
<p>
<strong>What's all this about?</strong><br>
I've been working on the new version of the <a href="https://app.mason.ai/" target="_blank">Mason image editor app</a>, and in it, I need to compare multiple fonts to replace them with each other to have the final layout not look crappy due to spacing differences between fonts. (To allow users to customize fonts in templates and have them still look nice)
</p>
<p>The <a href="https://codepen.io/zacholas/pen/ExZwJjx" target="_blank">first pen</a> focused on getting all the measurements and ratios necessary.</p>
<p>This pen encompasses the second part, which is comparison and normalization of different fonts against a default.</p>
<p>
<strong>How it works</strong><br>
First we get the metrics for the two fonts and compare their top/bottom spacing. We then normalize them to align in a purdy vertically-centered way. And then we scale down the second font's container so that it matches the size of the first.
</p>
<p>Enjoy!</p>
<p><em><a href="https://zachswinehart.com" target="_blank">- Zach</a></em></p>
<hr>
<h3>Demo</h3>
<p>If all of my code is working correctly, the text in the "new font adjusted" box should look all purdy and be vertically and horizontally centered.</p>
<p><strong>NOTE:</strong><em> You'll need to make a change in the dropdown before the text in the "new font adjusted" box actually gets adjusted.</em></p>
<label for="font-picker">Choose a font to swap:</label>
<select id="font-picker" disabled>
<option value=""> — Template Default — </option>
</select>
<div >
<div id="image-box" class="flex-row">
<div>
<h6>Original:</h6>
<div class="reference-box">
<div class="text-background">
<div class="text-container text-utc-lander" id="original-text">
Hxy
</div>
</div>
</div>
</div>
<div>
<h6>New font unadjusted:</h6>
<div class="reference-box">
<div class="text-background">
<div class="text-container text-utc-lander" id="unadjusted-text">
Hxy
</div>
</div>
</div>
</div>
<div>
<h6>New font adjusted:</h6>
<div id="modified" class="reference-box">
<div class="text-background">
<div class="scaler" id="adjusted-text-scaler">
<div class="text-container text-utc-lander" id="adjusted-text">
Hxy
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<hr>
<h2>Canvases used for calculating</h2>
<div id="sample-output-container">
</div>
SCSS:
@import 'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css';
// The font size in the demo boxes
$test-font-size: 200px!default;
// $test-font-size: 100px;
body {
background: #eee;
padding: 10px;
font-family: Arial;
}
hr {
margin: 40px 0;
}
h6 {
font-size: 17px;
margin: 12px 0 5px 0;
}
//* In production, you should probably use code like this to position canvases off-screen:
// .test-canvas {
// position: fixed;
// top: -99999px;
// left: -99999px;
// display:none;
// }
.text-utc-lander { font-family: 'UTCLander-Regular'; }
.text-ar-bonnie { font-family: 'ARBONNIE'; }
.text-adam-cg { font-family: 'ADAMCGPRO'; }
.text-abolition { font-family: 'Abolition-Regular'; }
.text-avenir { font-family: 'AvenirNextLTPro-BoldItalic'; }
.text-agency { font-family: 'AgencyFB-Reg'; }
/* Testing a real life example with absolute CSS done to make a F-ed up font
like UTC lander look good, which we'll then need to modify positioning and
sizing for in order for it to look good with normal fonts */
.flex-row {
display: flex;
justify-content: space-between;
}
#image-box {
.reference-box {
background: url('https://mason-app-staging.herokuapp.com/images/sports_stadium_generic.jpg');
background-size: cover;
position: relative;
width: $test-font-size * 2;
height: $test-font-size * 1.2;
&:before, &:after {
content: '';
left: $test-font-size * .1;
right: $test-font-size * .1;
position: absolute;
height: 1px;
background: rgba(0,0,0,0.1);
z-index: 5;
}
&:before {
top: $test-font-size * 0.245;
}
&:after {
bottom: $test-font-size * 0.245;
}
.text-background {
position: absolute;
left: ($test-font-size * 0.1);
top: ($test-font-size * 0.1);
width: ($test-font-size * 1.8);
height: ($test-font-size * 1);
background:#39b510;
color: #fff;
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: center;
}
.text-container {
margin-top: -10px; // Will be overwritten anyway
text-align: center;
font-size: $test-font-size;
line-height: 1;
}
}
}
#comparison-output {
background: #fff;
padding: 20px;
margin-top: 40px;
flex: 1;
}
//* Debug output from the first example
#sample-output-container {
// * {
// line-height: 1;
// }
> div {
width: 700px;
background: #CCC;
margin-bottom: 20px;
position: relative;
height: 200px;
> .text-container {
background: #fff;
position: absolute;
display: flex;
height: 150px;
left: 25px;
width: 300px;
top: 25px;
align-items: center;
justify-content: center;
> span {
background: #edc79e;
}
}
> .info-box {
font-size: 12px;
font-family: Arial;
background: #fff;
position: absolute;
width: 300px;
top: 25px;
right: 25px;
padding: 10px;
}
}
}
/*
Webfonts
- Code from here down is just base 64'd webfonts.
- All are in "normal" font weight
- Families available:
- 'ARBONNIE';
- 'ADAMCGPRO';
- 'Abolition-Regular';
- 'AgencyFB-Reg';
- 'AvenirNextLTPro-BoldItalic';
- 'UTCLander-Regular';
*/
/* ***** SKIPPING BASE-64'D FONTS FOR STACKOVERFLOW */
JS:
import FontFaceObserver from "https://cdn.skypack.dev/fontfaceobserver@2.1.0";
// var FontFaceObserver = require('fontfaceobserver');
const TYPE_DEFAULT_FONT = 'defaultFont';
const TYPE_CURRENT_FONT = 'currentFont';
// debug output canvases
const removeCalculationCanvases = false;
const allAvailableFonts = [
{ label: 'AR Bonnie', value: 'ARBONNIE' },
{ label: 'Adam CG Pro', value: 'ADAMCGPRO' },
{ label: 'Abolition Regular', value: 'Abolition-Regular' },
{ label: 'Avenir Next LT Pro Bold Italic', value: 'AvenirNextLTPro-BoldItalic' },
{ label: 'Agency FB', value: 'AgencyFB-Reg' },
{ label: 'UTC Lander', value: 'UTCLander-Regular' },
]
const INITIAL_STATE = {
[TYPE_DEFAULT_FONT]: {
label: null,
fontFamily: null,
fontSize: null,
metrics: {},
},
[TYPE_CURRENT_FONT]: {
label: null,
fontFamily: null,
fontSize: null,
metrics: {},
postAdjustmentMetrics: {}
}
}
let state = {
...INITIAL_STATE
}
const _roundToTwo = num => {
return +(Math.round(Number(num) + "e+2") + "e-2");
}
const _roundToFive = num => {
return +(Math.round(Number(num) + "e+5") + "e-5");
}
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const getTextMetrics = async(fontFamily, fontSize, testtext = 'Sixty Handgloves ABC') => {
//* For now we'll just keep the test text hard-coded but maybe we'll pass in the element value at some point. (However, being that the text will be editable I don't think that's wise)
testtext = 'Hxy';
const fontSizePx = fontSize.split('px')[0];
//* Generate a hash from the font name for the canvas ID
const canvasId = Math.abs(fontFamily.split("").reduce(function(a,b){a=((a<<5)-a)+b.charCodeAt(0);return a&a},0));
console.log('waiting for font to load')
var font = new FontFaceObserver(fontFamily);
await font.load();
console.log('font loaded');
//* Initialize the test canvas so that we can measure stuff
const testCanvasWidth = 400;
const testCanvasHeight = 200;
const testCanvasPadding = 10;
// const canvasDrawingTextFontSize = 1000;
const canvasDrawingTextFontSize = fontSizePx;
const testCanvas = document.createElement('canvas');
testCanvas.id = (`cvs-${canvasId}-${Math.random().toString(36).substring(7)}`);
testCanvas.className = `test-canvas ${canvasId}`;
testCanvas.width = testCanvasWidth;
testCanvas.height = testCanvasHeight;
// document.body.appendChild(testCanvas);
var testCanvasCtx = testCanvas.getContext("2d");
testCanvas.style.font = `${canvasDrawingTextFontSize}px ${fontFamily}`;
testCanvasCtx.font = [`${canvasDrawingTextFontSize}px`, fontFamily].join(' ');
testCanvasCtx.clearRect(0, 0, testCanvasWidth, testCanvasHeight);
testCanvasCtx.fontFamily = fontFamily;
testCanvasCtx.fillStyle = "#fff";
testCanvasCtx.fillRect(0,0,testCanvas.width, testCanvas.height);
testCanvasCtx.fillStyle = "#333333";
testCanvasCtx.fillText(testtext, testCanvasPadding, testCanvasHeight);
// console.log('before timeout');
// await timeout(3000);
// console.log('timeout done');
document.body.appendChild(testCanvas);
//* Get Core Measurements
var xHeight = testCanvasCtx.measureText("x").height;
var capHeight = testCanvasCtx.measureText("H").height;
// var measuredTextMetrics = testCanvasCtx.measureText("Hxy");
var measuredTextMetrics = testCanvasCtx.measureText(testtext);
//* Make the measurements usable (cast to numbers to allow for nulls)
let metrics = {};
metrics.measured = {
actualBoundingBoxAscent: _roundToFive(measuredTextMetrics.actualBoundingBoxAscent),
actualBoundingBoxDescent: _roundToFive(measuredTextMetrics.actualBoundingBoxDescent),
actualBoundingBoxLeft: _roundToFive(measuredTextMetrics.actualBoundingBoxLeft),
actualBoundingBoxRight: _roundToFive(measuredTextMetrics.actualBoundingBoxRight),
fontBoundingBoxAscent: _roundToFive(measuredTextMetrics.fontBoundingBoxAscent),
fontBoundingBoxDescent: _roundToFive(measuredTextMetrics.fontBoundingBoxDescent),
width: _roundToFive(measuredTextMetrics.width)
};
const fontSizeMultiplicand = fontSizePx / canvasDrawingTextFontSize;
const {
actualBoundingBoxAscent,
// actualBoundingBoxDescent,
// actualBoundingBoxLeft,
// actualBoundingBoxRight,
fontBoundingBoxAscent,
fontBoundingBoxDescent,
} = metrics.measured;
metrics.calculated = {
gapAboveText: _roundToFive((fontBoundingBoxAscent - actualBoundingBoxAscent) * fontSizeMultiplicand),
gapBelowText: _roundToFive(fontBoundingBoxDescent * fontSizeMultiplicand),
textHeight: _roundToFive(actualBoundingBoxAscent * fontSizeMultiplicand),
totalHeight: _roundToFive((fontBoundingBoxAscent + fontBoundingBoxDescent) * fontSizeMultiplicand),
};
const {
gapBelowText, gapAboveText, textHeight, totalHeight
} = metrics.calculated;
metrics.calculated.gapBelowTextPercent = _roundToFive(gapBelowText / totalHeight);
metrics.calculated.gapAboveTextPercent = _roundToFive(gapAboveText / totalHeight);
metrics.calculated.gapTopBottomRatio = _roundToFive(gapAboveText / gapBelowText);
metrics.calculated.textHeightPercent = _roundToFive(textHeight / totalHeight);
metrics.calculated.baselineMarginTop = gapBelowText - gapAboveText;
if(removeCalculationCanvases === true){
testCanvas.remove(); // cleanup
}
return metrics;
};
const setFontState = async(fontFamily, fontSize, fontLabel, type = TYPE_CURRENT_FONT) => {
if(fontFamily){
console.log('about to get text metrics')
const metrics = await getTextMetrics(fontFamily, fontSize);
console.log('metrics received');
state[type] = {
label: fontLabel ? fontLabel : fontFamily,
fontFamily,
fontSize,
metrics
}
}
else {
state[type] = {
...INITIAL_STATE[type]
}
}
return true;
}
const watchForFontChange = async() => {
document.addEventListener('input', async(event) => {
if (event.target.id !== 'font-picker') return; // Only run on the font change menu
let label = null;
if(
event.target.options.length &&
typeof event.target.options[event.target.selectedIndex] !== 'undefined' &&
event.target.options[event.target.selectedIndex].text
) {
label = event.target.options[event.target.selectedIndex].text;
}
// For now just grab font size from the default font state, but probably will change later
const fontFamily = event.target.value;
const fontSize = state[TYPE_DEFAULT_FONT].fontSize;
await setFontState(fontFamily, fontSize, label);
console.log('font changed', state);
//* Set the font families in the display
if(fontFamily){
document.getElementById(`unadjusted-text`).style.fontFamily = fontFamily;
document.getElementById(`adjusted-text`).style.fontFamily = fontFamily;
}
else {
document.getElementById(`unadjusted-text`).style.fontFamily = null;
document.getElementById(`adjusted-text`).style.fontFamily = null;
}
//* Calculate the adjustments for the new font compared to the baseline
// const currentFontSize = parseInt(state.currentFont.fontSize,10);
const defaultFontMetrics = state.defaultFont.metrics;
const currentFontMetrics = state.currentFont.metrics;
// const fontSizeAdjustPx = defaultFontMetrics.calculated.textHeight - currentFontMetrics.calculated.textHeight;
// const fontSizeAdjustPcnt = _roundToFive(fontSizeAdjustPx / currentFontMetrics.calculated.textHeight);
//* Apply the adjustments
// const newFontSize = currentFontSize + (currentFontSize * fontSizeAdjustPcnt);
// console.log('newFontSize', newFontSize);
const textToAdjust = document.getElementById(`adjusted-text`);
// const fontSizeStr = `${newFontSize}px`;
textToAdjust.style.marginTop = `${currentFontMetrics.calculated.baselineMarginTop}px`;
const scaler = document.getElementById('adjusted-text-scaler');
const scale = _roundToTwo(defaultFontMetrics.calculated.textHeight / currentFontMetrics.calculated.textHeight);
scaler.style.transform = `scale(${scale})`;
}, false);
}
const addFontOptionsToDropdown = () => {
const parentSelect = document.getElementById(`font-picker`);
for(let i=0; i < allAvailableFonts.length; i++){
const thisOption = allAvailableFonts[i];
if(thisOption.value){
const label = thisOption.label ? thisOption.label : thisOption.value;
const thisOptionTag = document.createElement("option");
thisOptionTag.value = thisOption.value;
const thisOptionText = document.createTextNode(label);
thisOptionTag.appendChild(thisOptionText);
parentSelect.appendChild(thisOptionTag);
}
}
}
const parseDefaultFont = async() => {
const thisText = document.getElementById(`original-text`);
// We might need to do some special stuff for uppercase vs non-uppercase text
const thisTextStyle = window.getComputedStyle(thisText);
const textTransform = thisTextStyle.getPropertyValue('text-transform');
const marginTop = thisTextStyle.getPropertyValue('margin-top');
console.log('marginTop', marginTop);
const uppercase = textTransform === 'uppercase';
const fontFamily = thisTextStyle.getPropertyValue('font-family');
const fontSize = thisTextStyle.getPropertyValue('font-size');
console.log('fontSize', fontSize);
await setFontState(fontFamily, fontSize, null, TYPE_DEFAULT_FONT);
document.getElementById(`original-text`).style.marginTop = `${state.defaultFont.metrics.calculated.baselineMarginTop}px`;
return !! fontFamily;
}
const init = async() => {
console.log(' ');
console.log(' ');
console.log(' ');
console.log('initialized.');
const defaultFont = await parseDefaultFont();
if(defaultFont){
addFontOptionsToDropdown(); // Parse JSON object into the select html tag
await watchForFontChange();
}
else {
// Handle Error -- for some reason there wasn't a font family for the default text.
}
document.getElementById('font-picker').disabled = false;
console.log('state after init done', state);
}
//* Wait for all the base 64'd fonts to load before we run it
document.addEventListener("DOMContentLoaded", (ready => {
init();
// setTimeout(function(){ init(); }, 1000);
}));