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

bezoerb / text-metrics / 9151001487

19 May 2024 10:34PM UTC coverage: 85.441% (-1.6%) from 87.023%
9151001487

Pull #42

github

bezoerb
lint
Pull Request #42: bump node

182 of 224 branches covered (81.25%)

26 of 27 new or added lines in 2 files covered. (96.3%)

33 existing lines in 2 files now uncovered.

223 of 261 relevant lines covered (85.44%)

42.11 hits per line

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

84.13
/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']);
2✔
11

12
const SHY = new Set([
2✔
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([
2✔
19
  // Spaces
20
  '\u0020',
21
  '\u1680',
22
  '\u2000',
23
  '\u2001',
24
  '\u2002',
25
  '\u2003',
26
  '\u2004',
27
  '\u2005',
28
  '\u2006',
29
  '\u2008',
30
  '\u2009',
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([
2✔
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',
73
  '\u2E30',
74
  '\u10100',
75
  '\u10101',
76
  '\u10102',
77
  '\u1039F',
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']);
2✔
85

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

89
/* eslint-env es6, browser */
90
const DEFAULTS = {
2✔
91
  'font-size': '16px',
92
  'font-weight': '400',
93
  'font-family': 'Helvetica, Arial, sans-serif',
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
  options ||= {};
54✔
104

105
  const baseFontSize = Number.parseInt(prop(options, 'base-font-size', 16), 10);
54✔
106

107
  const value = Number.parseFloat(value_);
54✔
108
  const unit = value_.replace(value, '');
54✔
109
  // eslint-disable-next-line default-case
110
  switch (unit) {
54!
111
    case 'rem':
112
    case 'em': {
113
      return value * baseFontSize;
1✔
114
    }
115

116
    case 'pt': {
UNCOV
117
      return value * (96 / 72);
×
118
    }
119

120
    case 'px': {
121
      return value;
53✔
122
    }
123
  }
124

UNCOV
125
  throw new Error('The unit ' + unit + ' is not supported');
×
126
}
127

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

137
  let wordAddon = 0;
25✔
138
  if (ws && !denyList.has(ws)) {
25✔
139
    wordAddon = pxValue(ws);
5✔
140
  }
141

142
  let letterAddon = 0;
25✔
143
  if (ls && !denyList.has(ls)) {
25✔
144
    letterAddon = pxValue(ls);
5✔
145
  }
146

147
  return (text) => {
25✔
148
    const words = text.trim().replace(/\s+/gi, ' ').split(' ').length - 1;
241✔
149
    const chars = text.length;
241✔
150

151
    return words * wordAddon + chars * letterAddon;
241✔
152
  };
153
}
154

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

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

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

182
  const fontVariant = prop(options, 'font-variant', style.getPropertyValue('font-variant'));
44✔
183
  if (['normal', 'small-caps'].includes(fontVariant)) {
44!
UNCOV
184
    font.push(fontVariant);
×
185
  }
186

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

191
  const fontFamily = prop(options, 'font-family', style.getPropertyValue('font-family')) || DEFAULTS['font-family'];
44✔
192
  font.push(fontFamily);
44✔
193

194
  return font.join(' ');
44✔
195
}
196

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

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

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

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

249
/**
250
 * Get style declaration if available
251
 *
252
 * @returns {CSSStyleDeclaration}
253
 */
254
export function getStyle(element, options) {
255
  const options_ = {...options};
20✔
256
  const {style} = options_;
20✔
257
  options ||= {};
20✔
258

259
  if (isCSSStyleDeclaration(style)) {
20!
UNCOV
260
    return style;
×
261
  }
262

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

267
  return {
8✔
268
    getPropertyValue: (key) => prop(options, key),
129✔
269
  };
270
}
271

272
/**
273
 * Normalize whitespace
274
 * https://developer.mozilla.org/de/docs/Web/CSS/white-space
275
 *
276
 * @param {string} text
277
 * @param {string} ws whitespace value
278
 * @returns {string}
279
 */
280
export function normalizeWhitespace(text, ws) {
281
  switch (ws) {
33✔
282
    case 'pre': {
283
      return text;
1✔
284
    }
285

286
    case 'pre-wrap': {
287
      return text;
1✔
288
    }
289

290
    case 'pre-line': {
291
      return (text || '').replace(/\s+/gm, ' ').trim();
1!
292
    }
293

294
    default: {
295
      return (text || '')
30✔
296
        .replace(/[\r\n]/gm, ' ')
297
        .replace(/\s+/gm, ' ')
298
        .trim();
299
    }
300
  }
301
}
302

303
/**
304
 * Get styled text
305
 *
306
 * @param {string} text
307
 * @param {CSSStyleDeclaration} style
308
 * @returns {string}
309
 */
310
export function getStyledText(text, style) {
311
  switch (style.getPropertyValue('text-transform')) {
26✔
312
    case 'uppercase': {
313
      return text.toUpperCase();
5✔
314
    }
315

316
    case 'lowercase': {
317
      return text.toLowerCase();
1✔
318
    }
319

320
    default: {
321
      return text;
20✔
322
    }
323
  }
324
}
325

326
/**
327
 * Trim text and repace some breaking htmlentities for convenience
328
 * Point user to https://mths.be/he for real htmlentity decode
329
 * @param text
330
 * @returns {string}
331
 */
332
export function prepareText(text) {
333
  // Convert to unicode
334
  text = (text || '')
29✔
335
    .replace(/<wbr>/gi, '\u200B')
336
    .replace(/<br\s*\/?>/gi, '\u000A')
337
    .replace(/&shy;/gi, '\u00AD')
338
    .replace(/&mdash;/gi, '\u2014');
339

340
  if (/&#(\d+)(;?)|&#[xX]([a-fA-F\d]+)(;?)|&([\da-zA-Z]+);/g.test(text) && console) {
29✔
341
    console.error(
1✔
342
      'text-metrics: Found encoded htmlenties. You may want to use https://mths.be/he to decode your text first.'
343
    );
344
  }
345

346
  return text;
29✔
347
}
348

349
/**
350
 * Get textcontent from element
351
 * Try innerText first
352
 * @param el
353
 */
354
export function getText(element) {
355
  if (!element) {
4!
UNCOV
356
    return '';
×
357
  }
358

359
  const text = element.textContent || element.textContent || '';
4!
360

361
  return text;
4✔
362
}
363

364
/**
365
 * Get property from src
366
 *
367
 * @param src
368
 * @param attr
369
 * @param defaultValue
370
 * @returns {*}
371
 */
372
export function prop(source, attribute, defaultValue) {
373
  return (source && source[attribute] !== undefined && source[attribute]) || defaultValue;
581✔
374
}
375

376
/**
377
 * Normalize options
378
 *
379
 * @param options
380
 * @returns {*}
381
 */
382
export function normalizeOptions(options) {
383
  const options_ = {};
51✔
384

385
  // Normalize keys (fontSize => font-size)
386
  for (const key of Object.keys(options || {})) {
51!
387
    const dashedKey = key.replace(/([A-Z])/g, ($1) => '-' + $1.toLowerCase());
43✔
388
    options_[dashedKey] = options[key];
43✔
389
  }
390

391
  return options_;
51✔
392
}
393

394
/**
395
 * Get Canvas
396
 * @param font
397
 * @throws {Error}
398
 * @return {Context2d}
399
 */
400
export function getContext2d(font) {
401
  try {
31✔
402
    const context = document.createElement('canvas').getContext('2d');
31✔
403
    const dpr = window.devicePixelRatio || 1;
31!
404
    const bsr =
405
      context.webkitBackingStorePixelRatio ||
31✔
406
      context.mozBackingStorePixelRatio ||
407
      context.msBackingStorePixelRatio ||
408
      context.oBackingStorePixelRatio ||
409
      context.backingStorePixelRatio ||
410
      1;
411
    context.font = font;
31✔
412
    context.setTransform(dpr / bsr, 0, 0, dpr / bsr, 0, 0);
31✔
413
    return context;
31✔
414
  } catch (error) {
UNCOV
415
    throw new Error('Canvas support required' + error.message);
×
416
  }
417
}
418

419
/**
420
 * Check breaking character
421
 * http://www.unicode.org/reports/tr14/#Table1
422
 *
423
 * @param chr
424
 */
425
function checkBreak(chr) {
426
  return (
921✔
427
    (B2.has(chr) && 'B2') ||
5,134!
428
    (BAI.has(chr) && 'BAI') ||
429
    (SHY.has(chr) && 'SHY') ||
430
    (BA.has(chr) && 'BA') ||
431
    (BB.has(chr) && 'BB') ||
432
    (BK.has(chr) && 'BK')
433
  );
434
}
435

436
export function computeLinesDefault({ctx, text, max, wordSpacing, letterSpacing}) {
437
  const addSpacing = addWordAndLetterSpacing(wordSpacing, letterSpacing);
10✔
438
  const lines = [];
10✔
439
  const parts = [];
10✔
440
  const breakpoints = [];
10✔
441
  let line = '';
10✔
442
  let part = '';
10✔
443

444
  if (!text) {
10✔
445
    return [];
2✔
446
  }
447

448
  // Compute array of breakpoints
449
  for (const chr of text) {
8✔
450
    const type = checkBreak(chr);
817✔
451
    if (part === '' && type === 'BAI') {
817✔
452
      continue;
1✔
453
    }
454

455
    if (type) {
816✔
456
      breakpoints.push({chr, type});
117✔
457

458
      parts.push(part);
117✔
459
      part = '';
117✔
460
    } else {
461
      part += chr;
699✔
462
    }
463
  }
464

465
  if (part) {
8!
466
    parts.push(part);
8✔
467
  }
468

469
  // Loop over text parts and compute the lines
470
  for (const [i, part] of parts.entries()) {
8✔
471
    if (i === 0) {
125✔
472
      line = part;
8✔
473
      continue;
8✔
474
    }
475

476
    const breakpoint = breakpoints[i - 1];
117✔
477
    // Special treatment as we only render the soft hyphen if we need to split
478
    const chr = breakpoint.type === 'SHY' ? '' : breakpoint.chr;
117✔
479
    if (breakpoint.type === 'BK') {
117!
UNCOV
480
      lines.push(line);
×
UNCOV
481
      line = part;
×
UNCOV
482
      continue;
×
483
    }
484

485
    // Measure width
486
    const rawWidth = ctx.measureText(line + chr + part).width + addSpacing(line + chr + part);
117✔
487
    const width = Math.round(rawWidth);
117✔
488

489
    // Still fits in line
490
    if (width <= max) {
117✔
491
      line += chr + part;
76✔
492
      continue;
76✔
493
    }
494

495
    // Line is to long, we split at the breakpoint
496
    switch (breakpoint.type) {
41!
497
      case 'SHY': {
498
        lines.push(line + '-');
3✔
499
        line = part;
3✔
500
        break;
3✔
501
      }
502

503
      case 'BA': {
504
        lines.push(line + chr);
1✔
505
        line = part;
1✔
506
        break;
1✔
507
      }
508

509
      case 'BAI': {
510
        lines.push(line);
37✔
511
        line = part;
37✔
512
        break;
37✔
513
      }
514

515
      case 'BB': {
UNCOV
516
        lines.push(line);
×
UNCOV
517
        line = chr + part;
×
UNCOV
518
        break;
×
519
      }
520

521
      case 'B2': {
UNCOV
522
        if (Number.parseInt(ctx.measureText(line + chr).width + addSpacing(line + chr), 10) <= max) {
×
UNCOV
523
          lines.push(line + chr);
×
UNCOV
524
          line = part;
×
525
        } else if (Number.parseInt(ctx.measureText(chr + part).width + addSpacing(chr + part), 10) <= max) {
×
526
          lines.push(line);
×
UNCOV
527
          line = chr + part;
×
528
        } else {
UNCOV
529
          lines.push(line, chr);
×
UNCOV
530
          line = part;
×
531
        }
532

UNCOV
533
        break;
×
534
      }
535

536
      default: {
UNCOV
537
        throw new Error('Undefoined break');
×
538
      }
539
    }
540
  }
541

542
  if ([...line].length > 0) {
8!
543
    lines.push(line);
8✔
544
  }
545

546
  return lines;
8✔
547
}
548

549
export function computeLinesBreakAll({ctx, text, max, wordSpacing, letterSpacing}) {
550
  const addSpacing = addWordAndLetterSpacing(wordSpacing, letterSpacing);
1✔
551
  const lines = [];
1✔
552
  let line = '';
1✔
553
  let index = 0;
1✔
554

555
  if (!text) {
1!
UNCOV
556
    return [];
×
557
  }
558

559
  for (const chr of text) {
1✔
560
    const type = checkBreak(chr);
104✔
561
    // Mandatory break found (br's converted to \u000A and innerText keeps br's as \u000A
562
    if (type === 'BK') {
104✔
563
      lines.push(line);
1✔
564
      line = '';
1✔
565
      continue;
1✔
566
    }
567

568
    const lineLength = line.length;
103✔
569
    if (BAI.has(chr) && (lineLength === 0 || BAI.has(line[lineLength - 1]))) {
103!
UNCOV
570
      continue;
×
571
    }
572

573
    // Measure width
574
    let rawWidth = ctx.measureText(line + chr).width + addSpacing(line + chr);
103✔
575
    let width = Math.ceil(rawWidth);
103✔
576

577
    // Check if we can put char behind the shy
578
    if (type === 'SHY') {
103✔
579
      const next = text[index + 1] || '';
2!
580
      rawWidth = ctx.measureText(line + chr + next).width + addSpacing(line + chr + next);
2✔
581
      width = Math.ceil(rawWidth);
2✔
582
    }
583

584
    // Needs at least one character
585
    if (width > max && [...line].length > 0) {
103✔
586
      switch (type) {
6!
587
        case 'SHY': {
588
          lines.push(line + '-');
1✔
589
          line = '';
1✔
590
          break;
1✔
591
        }
592

593
        case 'BA': {
594
          lines.push(line + chr);
×
UNCOV
595
          line = '';
×
UNCOV
596
          break;
×
597
        }
598

599
        case 'BAI': {
UNCOV
600
          lines.push(line);
×
UNCOV
601
          line = '';
×
UNCOV
602
          break;
×
603
        }
604

605
        default: {
606
          lines.push(line);
5✔
607
          line = chr;
5✔
608
          break;
5✔
609
        }
610
      }
611
    } else if (chr !== '\u00AD') {
97✔
612
      line += chr;
96✔
613
    }
614

615
    index++;
103✔
616
  }
617

618
  if ([...line].length > 0) {
1!
619
    lines.push(line);
1✔
620
  }
621

622
  return lines;
1✔
623
}
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