clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / frontend / dist / vendor / pretext / measurement.js
measurement.js 7.7K
1import { getSharedGraphemeSegmenter } from './analysis.js';2let measureContext = null;3const segmentMetricCaches = new Map();4let cachedEngineProfile = null;5// Safari's prefix-fit policy is useful for ordinary word-sized runs, but letting6// it measure every growing prefix of a giant segment recreates a pathological7// superlinear prepare-time path. Past this size, switch to the cheaper8// pair-context model and keep the public behavior linear.9const MAX_PREFIX_FIT_GRAPHEMES = 96;10const emojiPresentationRe = /\p{Emoji_Presentation}/u;11const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u;12const emojiCorrectionCache = new Map();13export function getMeasureContext() {14 if (measureContext !== null)15 return measureContext;16 if (typeof OffscreenCanvas !== 'undefined') {17 measureContext = new OffscreenCanvas(1, 1).getContext('2d');18 return measureContext;19 }20 if (typeof document !== 'undefined') {21 measureContext = document.createElement('canvas').getContext('2d');22 return measureContext;23 }24 throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.');25}26export function getSegmentMetricCache(font) {27 let cache = segmentMetricCaches.get(font);28 if (!cache) {29 cache = new Map();30 segmentMetricCaches.set(font, cache);31 }32 return cache;33}34export function getSegmentMetrics(seg, cache) {35 let metrics = cache.get(seg);36 if (metrics === undefined) {37 const ctx = getMeasureContext();38 metrics = {39 width: ctx.measureText(seg).width,40 };41 cache.set(seg, metrics);42 }43 return metrics;44}45export function getEngineProfile() {46 if (cachedEngineProfile !== null)47 return cachedEngineProfile;48 if (typeof navigator === 'undefined') {49 cachedEngineProfile = {50 geckoAsciiLineBreaks: false,51 lineFitEpsilon: 0.005,52 carryCJKAfterClosingQuote: false,53 breakKeepAllAfterPunctuation: true,54 preferPrefixWidthsForBreakableRuns: false,55 };56 return cachedEngineProfile;57 }58 const ua = navigator.userAgent;59 const vendor = navigator.vendor;60 const isSafari = vendor === 'Apple Computer, Inc.' &&61 ua.includes('Safari/') &&62 !ua.includes('Chrome/') &&63 !ua.includes('Chromium/') &&64 !ua.includes('CriOS/') &&65 !ua.includes('FxiOS/') &&66 !ua.includes('EdgiOS/');67 const isChromium = ua.includes('Chrome/') ||68 ua.includes('Chromium/') ||69 ua.includes('CriOS/') ||70 ua.includes('Edg/');71 const isGecko = ua.includes('Firefox/') && !ua.includes('FxiOS/');72 cachedEngineProfile = {73 geckoAsciiLineBreaks: isGecko,74 lineFitEpsilon: isSafari ? 1 / 64 : 0.005,75 carryCJKAfterClosingQuote: isChromium,76 breakKeepAllAfterPunctuation: !isSafari,77 preferPrefixWidthsForBreakableRuns: isSafari,78 };79 return cachedEngineProfile;80}81export function parseFontSize(font) {82 // A failed size can restart at the next digit run, not at every digit in it.83 const m = font.match(/(?:^|\D)(\d+(?:\.\d+)?)\s*px/);84 return m ? parseFloat(m[1]) : 16;85}86function isEmojiGrapheme(g) {87 return emojiPresentationRe.test(g) || g.includes('\uFE0F');88}89export function textMayContainEmoji(text) {90 return maybeEmojiRe.test(text);91}92function getEmojiCorrection(font) {93 let correction = emojiCorrectionCache.get(font);94 if (correction !== undefined)95 return correction;96 const fontSize = parseFontSize(font);97 const ctx = getMeasureContext();98 ctx.font = font;99 const canvasW = ctx.measureText('\u{1F600}').width;100 correction = 0;101 if (canvasW > fontSize + 0.5 &&102 typeof document !== 'undefined' &&103 document.body !== null) {104 const span = document.createElement('span');105 span.style.font = font;106 span.style.display = 'inline-block';107 span.style.visibility = 'hidden';108 span.style.position = 'absolute';109 span.textContent = '\u{1F600}';110 document.body.appendChild(span);111 const domW = span.getBoundingClientRect().width;112 document.body.removeChild(span);113 if (canvasW - domW > 0.5) {114 correction = canvasW - domW;115 }116 }117 emojiCorrectionCache.set(font, correction);118 return correction;119}120function countEmojiGraphemes(text) {121 let count = 0;122 const graphemeSegmenter = getSharedGraphemeSegmenter();123 for (const g of graphemeSegmenter.segment(text)) {124 if (isEmojiGrapheme(g.segment))125 count++;126 }127 return count;128}129function getEmojiCount(seg, metrics) {130 if (metrics.emojiCount === undefined) {131 metrics.emojiCount = countEmojiGraphemes(seg);132 }133 return metrics.emojiCount;134}135export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) {136 if (emojiCorrection === 0)137 return metrics.width;138 return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection;139}140export function getSegmentBreakableFitAdvances(seg, metrics, cache, emojiCorrection, mode) {141 if (metrics.breakableFitAdvances !== undefined && metrics.breakableFitMode === mode) {142 return metrics.breakableFitAdvances;143 }144 metrics.breakableFitMode = mode;145 const graphemeSegmenter = getSharedGraphemeSegmenter();146 const graphemes = [];147 for (const gs of graphemeSegmenter.segment(seg)) {148 graphemes.push(gs.segment);149 }150 if (graphemes.length <= 1) {151 metrics.breakableFitAdvances = null;152 return metrics.breakableFitAdvances;153 }154 if (mode === 'sum-graphemes') {155 const advances = [];156 for (const grapheme of graphemes) {157 const graphemeMetrics = getSegmentMetrics(grapheme, cache);158 advances.push(getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection));159 }160 metrics.breakableFitAdvances = advances;161 return metrics.breakableFitAdvances;162 }163 if (mode === 'pair-context' || graphemes.length > MAX_PREFIX_FIT_GRAPHEMES) {164 const advances = [];165 let previousGrapheme = null;166 let previousWidth = 0;167 for (const grapheme of graphemes) {168 const graphemeMetrics = getSegmentMetrics(grapheme, cache);169 const currentWidth = getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection);170 if (previousGrapheme === null) {171 advances.push(currentWidth);172 }173 else {174 const pair = previousGrapheme + grapheme;175 const pairMetrics = getSegmentMetrics(pair, cache);176 advances.push(getCorrectedSegmentWidth(pair, pairMetrics, emojiCorrection) - previousWidth);177 }178 previousGrapheme = grapheme;179 previousWidth = currentWidth;180 }181 metrics.breakableFitAdvances = advances;182 return metrics.breakableFitAdvances;183 }184 const advances = [];185 let prefix = '';186 let prefixWidth = 0;187 for (const grapheme of graphemes) {188 prefix += grapheme;189 const prefixMetrics = getSegmentMetrics(prefix, cache);190 const nextPrefixWidth = getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection);191 advances.push(nextPrefixWidth - prefixWidth);192 prefixWidth = nextPrefixWidth;193 }194 metrics.breakableFitAdvances = advances;195 return metrics.breakableFitAdvances;196}197export function getFontMeasurementState(font, needsEmojiCorrection) {198 const ctx = getMeasureContext();199 ctx.font = font;200 const cache = getSegmentMetricCache(font);201 const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(font) : 0;202 return { cache, emojiCorrection };203}204export function clearMeasurementCaches() {205 segmentMetricCaches.clear();206 emojiCorrectionCache.clear();207}