feat: text measurements based on font metrics (#7693)

* Introduced vertical offset based on harcoded font metrics 

* Unified usage of alphabetic baseline for both canvas & svg export

* Removed baseline property

* Removed font-size rounding on Safari

* Removed artificial width offset
This commit is contained in:
Marcel Mraz 2024-03-05 19:33:27 +00:00 committed by GitHub
parent 160440b860
commit 7e471b55eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 83 additions and 154 deletions

View File

@ -27,6 +27,8 @@ Please add the latest change on the top under the correct section.
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render.
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
#### Bundler

View File

@ -49,7 +49,7 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height, baseline } = measureText(
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
@ -67,7 +67,6 @@ export const actionUnbindText = register({
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
x,
y,

View File

@ -224,7 +224,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id48",
@ -269,7 +268,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id48",
@ -373,7 +371,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id48",
"customData": undefined,
@ -472,7 +469,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id37",
"customData": undefined,
@ -643,7 +639,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id41",
"customData": undefined,
@ -683,7 +678,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id41",
@ -728,7 +722,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id41",
@ -1174,7 +1167,6 @@ exports[`Test Transform > should transform text element 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": null,
"customData": undefined,
@ -1214,7 +1206,6 @@ exports[`Test Transform > should transform text element 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": null,
"customData": undefined,
@ -1458,7 +1449,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id25",
"customData": undefined,
@ -1498,7 +1488,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id26",
"customData": undefined,
@ -1538,7 +1527,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id27",
"customData": undefined,
@ -1579,7 +1567,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id28",
"customData": undefined,
@ -1836,7 +1823,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id13",
"customData": undefined,
@ -1876,7 +1862,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id14",
"customData": undefined,
@ -1917,7 +1902,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id15",
"customData": undefined,
@ -1960,7 +1944,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id16",
"customData": undefined,
@ -2001,7 +1984,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id17",
"customData": undefined,
@ -2043,7 +2025,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id18",
"customData": undefined,

View File

@ -35,14 +35,13 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
@ -207,11 +206,6 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@ -222,7 +216,6 @@ const restoreElement = (
originalText: element.originalText || text,
lineHeight,
baseline,
});
// if empty text, mark as deleted. We keep in array

View File

@ -249,7 +249,6 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
lineHeight,
@ -268,13 +267,12 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
baseline: number;
} => {
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
const { width: nextWidth, height: nextHeight } = measureText(
nextText,
getFontString(element),
element.lineHeight,
);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@ -328,7 +326,6 @@ const getAdjustedDimensions = (
return {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
};

View File

@ -52,8 +52,6 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@ -213,8 +211,7 @@ const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number,
nextHeight: number,
): { size: number; baseline: number } | null => {
): { size: number } | null => {
// We only use width to scale font on resize
let width = element.width;
@ -229,14 +226,9 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
};
@ -309,12 +301,7 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const metrics = measureFontSizeFromWidth(
element,
elementsMap,
nextWidth,
nextHeight,
);
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
if (metrics === null) {
return;
}
@ -342,7 +329,6 @@ const resizeSingleTextElement = (
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
baseline: metrics.baseline,
x: nextElementX,
y: nextElementY,
});
@ -396,7 +382,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number; baseline?: number } = {};
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(element, elementsMap);
if (transformHandleDirection.includes("e")) {
@ -448,7 +434,6 @@ export const resizeSingleElement = (
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
}
if (shouldMaintainAspectRatio) {
@ -462,14 +447,12 @@ export const resizeSingleElement = (
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
getBoundTextMaxHeight(updatedElement, boundTextElement),
);
if (nextFont === null) {
return;
}
boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
};
} else {
const minWidth = getApproxMinLineWidth(
@ -638,7 +621,6 @@ export const resizeSingleElement = (
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline,
});
}
handleBindTextResize(
@ -769,7 +751,6 @@ export const resizeMultipleElements = (
> & {
points?: ExcalidrawLinearElement["points"];
fontSize?: ExcalidrawTextElement["fontSize"];
baseline?: ExcalidrawTextElement["baseline"];
scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
};
@ -844,17 +825,11 @@ export const resizeMultipleElements = (
}
if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(
orig,
elementsMap,
width,
height,
);
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
if (!metrics) {
return;
}
update.fontSize = metrics.size;
update.baseline = metrics.baseline;
}
const boundTextElement = originalElements.get(

View File

@ -18,7 +18,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
isSafari,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@ -62,7 +61,6 @@ export const redrawTextBoundingBox = (
text: textElement.text,
width: textElement.width,
height: textElement.height,
baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
@ -83,7 +81,6 @@ export const redrawTextBoundingBox = (
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
@ -188,7 +185,6 @@ export const handleBindTextResize = (
const maxWidth = getBoundTextMaxWidth(container, textElement);
const maxHeight = getBoundTextMaxHeight(container, textElement);
let containerHeight = container.height;
let nextBaseLine = textElement.baseline;
if (
shouldMaintainAspectRatio ||
(transformHandleType !== "n" && transformHandleType !== "s")
@ -207,7 +203,6 @@ export const handleBindTextResize = (
);
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
@ -235,7 +230,6 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
@ -285,8 +279,6 @@ export const computeBoundTextPosition = (
return { x, y };
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
font: FontString,
@ -301,59 +293,7 @@ export const measureText = (
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
const baseline = measureBaseline(text, font, lineHeight);
return { width, height, baseline };
};
export const measureBaseline = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
wrapInContainer?: boolean,
) => {
const container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (wrapInContainer) {
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.whiteSpace = "pre-wrap";
}
container.style.lineHeight = String(lineHeight);
container.innerText = text;
// Baseline is important for positioning text on canvas
document.body.appendChild(container);
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
container.appendChild(span);
let baseline = span.offsetTop + span.offsetHeight;
const height = container.offsetHeight;
if (isSafari) {
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
const fontSize = parseFloat(font);
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
// from the actual canvas height
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
if (canvasHeight > height) {
baseline += canvasHeight - domHeight;
}
if (height > canvasHeight) {
baseline -= domHeight - canvasHeight;
}
}
document.body.removeChild(container);
return baseline;
return { width, height };
};
/**
@ -378,6 +318,23 @@ export const getLineHeightInPx = (
return fontSize * lineHeight;
};
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily];
const fontSizeEm = fontSize / unitsPerEm;
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
@ -964,13 +921,40 @@ const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Virgil in WebKit and Blink.
// Gecko if all over the place.
// ~1.15 is the average for Helvetica in WebKit and Blink.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
type FontMetrics = {
unitsPerEm: number; // head.unitsPerEm
ascender: number; // sTypoAscender
descender: number; // sTypoDescender
};
/**
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
* For custom fonts, read these metrics on load and extend this object.
*/
const FONT_METRICS = {
[FONT_FAMILY.Virgil]: {
unitsPerEm: 1000,
ascender: 886,
descender: -374,
},
[FONT_FAMILY.Helvetica]: {
unitsPerEm: 2048,
ascender: 1577,
descender: -471,
},
[FONT_FAMILY.Cascadia]: {
unitsPerEm: 2048,
ascender: 1977,
descender: -480,
},
} as Record<number, FontMetrics>;
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily in DEFAULT_LINE_HEIGHT) {
return DEFAULT_LINE_HEIGHT[fontFamily];

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isSafari } from "../constants";
import { CLASSES } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -31,7 +31,6 @@ import {
getBoundTextMaxHeight,
getBoundTextMaxWidth,
computeContainerDimensionForBoundText,
detectLineHeight,
computeBoundTextPosition,
getBoundTextElement,
} from "./textElement";
@ -227,18 +226,6 @@ export const textWysiwyg = ({
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
}
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
if (isSafari) {
lineHeight = detectLineHeight({
...updatedTextElement,
fontSize: Math.round(updatedTextElement.fontSize),
});
}
// Make sure text editor height doesn't go beyond viewport
@ -247,7 +234,7 @@ export const textWysiwyg = ({
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
lineHeight: updatedTextElement.lineHeight,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,

View File

@ -176,7 +176,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;

View File

@ -50,6 +50,7 @@ import {
getLineHeightInPx,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
getVerticalOffset,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
@ -383,16 +384,23 @@ const drawElementOnCanvas = (
: element.textAlign === "right"
? element.width
: 0;
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const verticalOffset = element.height - element.baseline;
const verticalOffset = getVerticalOffset(
element.fontFamily,
element.fontSize,
lineHeightPx,
);
for (let index = 0; index < lines.length; index++) {
context.fillText(
lines[index],
horizontalOffset,
(index + 1) * lineHeightPx - verticalOffset,
index * lineHeightPx + verticalOffset,
);
}
context.restore();

View File

@ -17,6 +17,7 @@ import {
getBoundTextElement,
getContainerElement,
getLineHeightInPx,
getVerticalOffset,
} from "../element/textElement";
import {
isArrowElement,
@ -556,6 +557,11 @@ const renderElementToSvg = (
: element.textAlign === "right"
? element.width
: 0;
const verticalOffset = getVerticalOffset(
element.fontFamily,
element.fontSize,
lineHeightPx,
);
const direction = isRTL(element.text) ? "rtl" : "ltr";
const textAnchor =
element.textAlign === "center"
@ -567,14 +573,14 @@ const renderElementToSvg = (
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);
text.setAttribute("dominant-baseline", "text-before-edge");
text.setAttribute("dominant-baseline", "alphabetic");
node.appendChild(text);
}

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>

View File

@ -296,7 +296,6 @@ exports[`restoreElements > should restore text element correctly passing value f
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [],
"containerId": null,
"customData": undefined,
@ -338,7 +337,6 @@ exports[`restoreElements > should restore text element correctly with unknown fo
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [],
"containerId": null,
"customData": undefined,