• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bezoerb / text-metrics / 3689521785

pending completion
3689521785

push

github

Ben Zörb
Bump dependencies

180 of 216 branches covered (83.33%)

113 of 131 relevant lines covered (86.26%)

63.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.88
/src/utils.js
1
/*
2
 B2        Break Opportunity Before and After        Em dash        Provide a line break opportunity before and after the character
3
 BA        Break After        Spaces, hyphens        Generally provide a line break opportunity after the character
4
 BB        Break Before        Punctuation used in dictionaries        Generally provide a line break opportunity before the character
5
 HY        Hyphen        HYPHEN-MINUS        Provide a line break opportunity after the character, except in numeric context
6
 CB        Contingent Break Opportunity        Inline objects        Provide a line break opportunity contingent on additional information
7
 */
8

9
// B2 Break Opportunity Before and After - http://www.unicode.org/reports/tr14/#B2
10
const B2 = new Set(['\u2014']);
1,656✔
11

12
const SHY = new Set([
13
  // Soft hyphen
14
  '\u00AD',
15
]);
16

17
// BA: Break After (remove on break) - http://www.unicode.org/reports/tr14/#BA
18
const BAI = new Set([
19
  // Spaces
20
  '\u0020',
21
  '\u1680',
22
  '\u2000',
23
  '\u2001',
24
  '\u2002',
25
  '\u2003',
26
  '\u2004',
27
  '\u2005',
28
  '\u2006',
29
  '\u2008',
30
  '\u2009',
2✔
31
  '\u200A',
32
  '\u205F',
33
  '\u3000',
34
  // Tab
35
  '\u0009',
36
  // ZW Zero Width Space - http://www.unicode.org/reports/tr14/#ZW
37
  '\u200B',
38
  // Mandatory breaks not interpreted by html
39
  '\u2028',
40
  '\u2029',
41
]);
42

43
const BA = new Set([
44
  // Hyphen
45
  '\u058A',
46
  '\u2010',
47
  '\u2012',
48
  '\u2013',
49
  // Visible Word Dividers
50
  '\u05BE',
51
  '\u0F0B',
52
  '\u1361',
53
  '\u17D8',
54
  '\u17DA',
55
  '\u2027',
56
  '\u007C',
57
  // Historic Word Separators
58
  '\u16EB',
59
  '\u16EC',
60
  '\u16ED',
61
  '\u2056',
62
  '\u2058',
63
  '\u2059',
64
  '\u205A',
65
  '\u205B',
66
  '\u205D',
67
  '\u205E',
68
  '\u2E19',
69
  '\u2E2A',
70
  '\u2E2B',
71
  '\u2E2C',
72
  '\u2E2D',
2✔
73
  '\u2E30',
74
  '\u10100',
75
  '\u10101',
76
  '\u10102',
77
  '\u1039F',
2✔
78
  '\u103D0',
79
  '\u1091F',
80
  '\u12470',
81
]);
82

83
// BB: Break Before - http://www.unicode.org/reports/tr14/#BB
84
const BB = new Set(['\u00B4', '\u1FFD']);
54✔
85

86
// BK: Mandatory Break (A) (Non-tailorable) - http://www.unicode.org/reports/tr14/#BK
87
const BK = new Set(['\u000A']);
54✔
88

89
/* eslint-env es6, browser */
90
const DEFAULTS = {
91
  'font-size': '16px',
92
  'font-weight': '400',
93
  'font-family': 'Helvetica, Arial, sans-serif',
23✔
94
};
95

96
/**
97
 * We only support rem/em/pt conversion
98
 * @param val
99
 * @param options
100
 * @return {*}
101
 */
102
function pxValue(value_, options) {
103
  if (!options) {
104
    options = {};
105
  }
106

107
  const baseFontSize = Number.parseInt(prop(options, 'base-font-size', 16), 10);
54!
108

109
  const value = Number.parseFloat(value_);
110
  const unit = value_.replace(value, '');
1✔
111
  // eslint-disable-next-line default-case
112
  switch (unit) {
×
113
    case 'rem':
114
    case 'em':
115
      return value * baseFontSize;
53✔
116
    case 'pt':
×
117
      return value * (96 / 72);
118
    case 'px':
119
      return value;
120
  }
121

122
  throw new Error('The unit ' + unit + ' is not supported');
25✔
123
}
124

125
/**
126
 * Get computed word- and letter spacing for text
127
 * @param ws
128
 * @param ls
129
 * @return {function(*)}
130
 */
131
export function addWordAndLetterSpacing(ws, ls) {
132
  const denyList = new Set(['inherit', 'initial', 'unset', 'normal']);
25✔
133

134
  let wordAddon = 0;
35✔
135
  if (ws && !denyList.has(ws)) {
136
    wordAddon = pxValue(ws);
241✔
137
  }
138

139
  let letterAddon = 0;
140
  if (ls && !denyList.has(ls)) {
141
    letterAddon = pxValue(ls);
142
  }
143

144
  return (text) => {
145
    const words = text.trim().replace(/\s+/gi, ' ').split(' ').length - 1;
44✔
146
    const chars = text.length;
147

148
    return words * wordAddon + chars * letterAddon;
149
  };
150
}
151

152
/**
153
 * Map css styles to canvas font property
154
 *
155
 * font: font-style font-variant font-weight font-size/line-height font-family;
156
 * http://www.w3schools.com/tags/canvas_font.asp
157
 *
158
 * @param {CSSStyleDeclaration} style
159
 * @param {object} options
160
 * @returns {string}
161
 */
162
export function getFont(style, options) {
163
  const font = [];
164

165
  const fontWeight = prop(options, 'font-weight', style.getPropertyValue('font-weight')) || DEFAULTS['font-weight'];
44✔
166
  if (
167
    ['normal', 'bold', 'bolder', 'lighter', '100', '200', '300', '400', '500', '600', '700', '800', '900'].includes(
44✔
168
      fontWeight.toString()
169
    )
170
  ) {
171
    font.push(fontWeight);
172
  }
173

174
  const fontStyle = prop(options, 'font-style', style.getPropertyValue('font-style'));
44✔
175
  if (['normal', 'italic', 'oblique'].includes(fontStyle)) {
44✔
176
    font.push(fontStyle);
177
  }
178

179
  const fontVariant = prop(options, 'font-variant', style.getPropertyValue('font-variant'));
44!
180
  if (['normal', 'small-caps'].includes(fontVariant)) {
44✔
181
    font.push(fontVariant);
182
  }
183

184
  const fontSize = prop(options, 'font-size', style.getPropertyValue('font-size')) || DEFAULTS['font-size'];
44✔
185
  const fontSizeValue = pxValue(fontSize);
186
  font.push(fontSizeValue + 'px');
187

188
  const fontFamily = prop(options, 'font-family', style.getPropertyValue('font-family')) || DEFAULTS['font-family'];
44✔
189
  font.push(fontFamily);
190

191
  return font.join(' ');
26✔
192
}
193

194
/**
195
 * Check for CSSStyleDeclaration
196
 *
197
 * @param val
198
 * @returns {bool}
199
 */
200
export function isCSSStyleDeclaration(value) {
201
  return value && typeof value.getPropertyValue === 'function';
22✔
202
}
203

204
/**
205
 * Check wether we can get computed style
206
 *
207
 * @param el
208
 * @returns {bool}
209
 */
210
export function canGetComputedStyle(element) {
211
  return (
212
    isElement(element) &&
213
    element.style &&
214
    typeof window !== 'undefined' &&
215
    typeof window.getComputedStyle === 'function'
45!
216
  );
217
}
218

219
/**
220
 * Check for DOM element
221
 *
222
 * @param el
223
 * @retutns {bool}
224
 */
225
export function isElement(element) {
226
  return typeof HTMLElement === 'object'
165✔
227
    ? element instanceof HTMLElement
228
    : Boolean(
229
        element &&
230
          typeof element === 'object' &&
231
          element !== null &&
232
          element.nodeType === 1 &&
14✔
233
          typeof element.nodeName === 'string'
33✔
234
      );
235
}
236

237
/**
238
 * Check if argument is object
239
 * @param obj
240
 * @returns {boolean}
241
 */
242
export function isObject(object) {
243
  return typeof object === 'object' && object !== null && !Array.isArray(object);
20✔
244
}
20✔
245

246
/**
247
 * Get style declaration if available
248
 *
249
 * @returns {CSSStyleDeclaration}
250
 */
251
export function getStyle(element, options) {
252
  const options_ = {...options};
40!
253
  const {style} = options_;
254
  if (!options) {
255
    options = {};
256
  }
257

258
  if (isCSSStyleDeclaration(style)) {
259
    return style;
260
  }
261

262
  if (canGetComputedStyle(element)) {
263
    return window.getComputedStyle(element, prop(options, 'pseudoElt', null));
129✔
264
  }
265

266
  return {
267
    getPropertyValue: (key) => prop(options, key),
33✔
268
  };
269
}
2✔
270

271
/**
272
 * Normalize whitespace
273
 * https://developer.mozilla.org/de/docs/Web/CSS/white-space
274
 *
275
 * @param {string} text
276
 * @param {string} ws whitespace value
277
 * @returns {string}
278
 */
279
export function normalizeWhitespace(text, ws) {
280
  switch (ws) {
281
    case 'pre':
1✔
282
      return text;
283
    case 'pre-wrap':
1!
284
      return text;
285
    case 'pre-line':
286
      return (text || '').replace(/\s+/gm, ' ').trim();
30✔
287
    default:
288
      return (text || '')
289
        .replace(/[\r\n]/gm, ' ')
290
        .replace(/\s+/gm, ' ')
26✔
291
        .trim();
292
  }
293
}
294

295
/**
296
 * Get styled text
297
 *
298
 * @param {string} text
299
 * @param {CSSStyleDeclaration} style
300
 * @returns {string}
301
 */
302
export function getStyledText(text, style) {
303
  switch (style.getPropertyValue('text-transform')) {
5✔
304
    case 'uppercase':
1✔
305
      return text.toUpperCase();
306
    case 'lowercase':
20✔
307
      return text.toLowerCase();
29✔
308
    default:
309
      return text;
310
  }
311
}
312

313
/**
314
 * Trim text and repace some breaking htmlentities for convenience
315
 * Point user to https://mths.be/he for real htmlentity decode
316
 * @param text
317
 * @returns {string}
318
 */
319
export function prepareText(text) {
320
  // Convert to unicode
321
  text = (text || '')
322
    .replace(/<wbr>/gi, '\u200B')
323
    .replace(/<br\s*\/?>/gi, '\u000A')
324
    .replace(/&shy;/gi, '\u00AD')
31✔
325
    .replace(/&mdash;/gi, '\u2014');
326

327
  if (/&#(\d+)(;?)|&#[xX]([a-fA-F\d]+)(;?)|&([\da-zA-Z]+);/g.test(text) && console) {
328
    console.error(
329
      'text-metrics: Found encoded htmlenties. You may want to use https://mths.be/he to decode your text first.'
4!
330
    );
331
  }
332

333
  return text;
334
}
335

336
/**
337
 * Get textcontent from element
338
 * Try innerText first
339
 * @param el
340
 */
341
export function getText(element) {
342
  if (!element) {
343
    return '';
344
  }
345

346
  const text = element.textContent || element.textContent || '';
581✔
347

348
  return text;
349
}
350

351
/**
352
 * Get property from src
353
 *
354
 * @param src
355
 * @param attr
356
 * @param defaultValue
357
 * @returns {*}
358
 */
359
export function prop(src, attr, defaultValue) {
360
  return (src && typeof src[attr] !== 'undefined' && src[attr]) || defaultValue;
51✔
361
}
51!
362

363
/**
364
 * Normalize options
365
 *
366
 * @param options
367
 * @returns {*}
368
 */
369
export function normalizeOptions(options) {
370
  const options_ = {};
43✔
371

372
  // Normalize keys (fontSize => font-size)
373
  for (const key of Object.keys(options || {})) {
1✔
374
    const dashedKey = key.replace(/([A-Z])/g, ($1) => '-' + $1.toLowerCase());
51✔
375
    options_[dashedKey] = options[key];
376
  }
377

378
  return options_;
379
}
380

381
/**
382
 * Get Canvas
383
 * @param font
384
 * @throws {Error}
385
 * @return {Context2d}
386
 */
387
export function getContext2d(font) {
388
  try {
389
    const ctx = document.createElement('canvas').getContext('2d');
31!
390
    const dpr = window.devicePixelRatio || 1;
391
    const bsr =
392
      ctx.webkitBackingStorePixelRatio ||
393
      ctx.mozBackingStorePixelRatio ||
394
      ctx.msBackingStorePixelRatio ||
395
      ctx.oBackingStorePixelRatio ||
31✔
396
      ctx.backingStorePixelRatio ||
397
      1;
398
    ctx.font = font;
×
399
    ctx.setTransform(dpr / bsr, 0, 0, dpr / bsr, 0, 0);
400
    return ctx;
401
  } catch (error) {
402
    throw new Error('Canvas support required' + error.message);
921!
403
  }
404
}
405

406
/**
407
 * Check breaking character
408
 * http://www.unicode.org/reports/tr14/#Table1
409
 *
410
 * @param chr
411
 */
412
function checkBreak(chr) {
413
  return (
414
    (B2.has(chr) && 'B2') ||
415
    (BAI.has(chr) && 'BAI') ||
416
    (SHY.has(chr) && 'SHY') ||
417
    (BA.has(chr) && 'BA') ||
418
    (BB.has(chr) && 'BB') ||
419
    (BK.has(chr) && 'BK')
420
  );
421
}
422

423
export function computeLinesDefault({ctx, text, max, wordSpacing, letterSpacing}) {
20✔
424
  const addSpacing = addWordAndLetterSpacing(wordSpacing, letterSpacing);
12✔
425
  const lines = [];
426
  const parts = [];
427
  const breakpoints = [];
428
  let line = '';
429
  let part = '';
817✔
430

431
  if (!text) {
817✔
432
    return [];
433
  }
434

435
  // Compute array of breakpoints
436
  for (const chr of text) {
816✔
437
    const type = checkBreak(chr);
438
    if (part === '' && type === 'BAI') {
439
      continue;
440
    }
441

442
    if (type) {
443
      breakpoints.push({chr, type});
8✔
444

445
      parts.push(part);
446
      part = '';
447
    } else {
448
      part += chr;
449
    }
450
  }
125✔
451

452
  if (part) {
453
    parts.push(part);
8✔
454
  }
455

456
  // Loop over text parts and compute the lines
457
  for (const [i, part] of parts.entries()) {
117✔
458
    if (i === 0) {
459
      line = part;
460
      continue;
117!
461
    }
462

463
    const breakpoint = breakpoints[i - 1];
×
464
    // Special treatment as we only render the soft hyphen if we need to split
465
    const chr = breakpoint.type === 'SHY' ? '' : breakpoint.chr;
×
466
    if (breakpoint.type === 'BK') {
117✔
467
      lines.push(line);
468
      line = part;
469
      continue;
470
    }
471

472
    // Measure width
473
    const rawWidth = ctx.measureText(line + chr + part).width + addSpacing(line + chr + part);
117!
474
    const width = Math.round(rawWidth);
3✔
475

476
    // Still fits in line
477
    if (width <= max) {
478
      line += chr + part;
3✔
479
      continue;
480
    }
481

482
    // Line is to long, we split at the breakpoint
483
    switch (breakpoint.type) {
1✔
484
      case 'SHY':
485
        lines.push(line + '-');
1✔
486
        line = part;
487
        break;
37✔
488
      case 'BA':
489
        lines.push(line + chr);
37✔
490
        line = part;
491
        break;
×
492
      case 'BAI':
493
        lines.push(line);
×
494
        line = part;
495
        break;
×
496
      case 'BB':
497
        lines.push(line);
498
        line = chr + part;
499
        break;
500
      case 'B2':
501
        if (Number.parseInt(ctx.measureText(line + chr).width + addSpacing(line + chr), 10) <= max) {
×
502
          lines.push(line + chr);
503
          line = part;
504
        } else if (Number.parseInt(ctx.measureText(chr + part).width + addSpacing(chr + part), 10) <= max) {
×
505
          lines.push(line);
×
506
          line = chr + part;
507
        } else {
508
          lines.push(line, chr);
509
          line = part;
510
        }
511

512
        break;
513
      default:
8✔
514
        throw new Error('Undefoined break');
515
    }
516
  }
517

518
  if ([...line].length > 0) {
519
    lines.push(line);
520
  }
521

522
  return lines;
523
}
524

525
export function computeLinesBreakAll({ctx, text, max, wordSpacing, letterSpacing}) {
2!
526
  const addSpacing = addWordAndLetterSpacing(wordSpacing, letterSpacing);
1✔
527
  const lines = [];
528
  let line = '';
104✔
529
  let index = 0;
530

531
  if (!text) {
104✔
532
    return [];
533
  }
534

535
  for (const chr of text) {
1✔
536
    const type = checkBreak(chr);
537
    // Mandatory break found (br's converted to \u000A and innerText keeps br's as \u000A
538
    if (type === 'BK') {
103!
539
      lines.push(line);
129✔
540
      line = '';
541
      continue;
542
    }
543

544
    const lineLength = line.length;
×
545
    if (BAI.has(chr) && (lineLength === 0 || BAI.has(line[lineLength - 1]))) {
103✔
546
      continue;
103✔
547
    }
548

549
    // Measure width
550
    let rawWidth = ctx.measureText(line + chr).width + addSpacing(line + chr);
103!
551
    let width = Math.ceil(rawWidth);
2✔
552

553
    // Check if we can put char behind the shy
554
    if (type === 'SHY') {
555
      const next = text[index + 1] || '';
556
      rawWidth = ctx.measureText(line + chr + next).width + addSpacing(line + chr + next);
103!
557
      width = Math.ceil(rawWidth);
558
    }
559

560
    // Needs at least one character
561
    if (width > max && [...line].length > 0) {
1✔
562
      switch (type) {
563
        case 'SHY':
×
564
          lines.push(line + '-');
×
565
          line = '';
566
          break;
×
567
        case 'BA':
568
          lines.push(line + chr);
×
569
          line = '';
570
          break;
5✔
571
        case 'BAI':
572
          lines.push(line);
97✔
573
          line = '';
574
          break;
103✔
575
        default:
1✔
576
          lines.push(line);
2✔
577
          line = chr;
578
          break;
579
      }
580
    } else if (chr !== '\u00AD') {
581
      line += chr;
582
    }
583

584
    index++;
2✔
585
  }
586

587
  if ([...line].length > 0) {
588
    lines.push(line);
589
  }
590

591
  return lines;
592
}
41✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc