UNPKG

@nrkn/text-layout

Version:

Wrapping and fitting styled runs of text

190 lines 8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.defaultFitterOptions = exports.fitnessUnder = exports.fitnessOver = exports.fitter = void 0; const words_js_1 = require("./words.js"); const scale_js_1 = require("./scale.js"); const wrap_js_1 = require("./wrap.js"); const lines_js_1 = require("./lines.js"); // pretty good, pretty fast fitter - adjusts scale, rewrapping text at each // new scale until either it finds a close fit to the height, or the widest // unbreakable word can't be scaled up any further. Optionally, only shrink the // text to fit but don't grow if it already fits. If a close fit isn't possible // it is detected by the lower and upper bounds converging to a small delta // and a best attempt is returned instead. const fitter = (bounds, options = {}) => { const { tolerance, scaleStep, maxIterations, minBoundsDelta, fitType, wrapper, cropToMetrics = false } = Object.assign((0, exports.defaultFitterOptions)(), options); assertOptions(tolerance, scaleStep); const wrap = wrapper(bounds.width); const closeW = bounds.width - tolerance; const closeH = bounds.height - tolerance; const fitBlock = (block) => { let scale = 1; let lowerBound = 0; let upperBound = 0; let iterations = 0; let wrapped; // try to either fit the longest unbreakable word, or to fit the bound // height, or return why it failed const attemptFit = (scale, during) => { iterations++; const scaledBlock = (0, scale_js_1.blockScaler)(scale)(block); wrapped = wrap(scaledBlock); let wrappedHeight = wrapped.height; if (wrapped.lines.length > 0 && cropToMetrics) { const firstLine = wrapped.lines[0]; const ascent = (0, lines_js_1.opticalLineAscent)(firstLine); if (ascent !== null) { const delta = firstLine.height - ascent; wrappedHeight -= delta; } } if (iterations > maxIterations) { throw Error(`Exceeded max iterations (${maxIterations})`); } const longestWord = (0, words_js_1.longestWordInBlock)(wrapped); // unbreakable word that exceeds bounds if (longestWord.width > bounds.width) { return exports.fitnessOver; } // the word has been reduced to fit the width, and the height is within // bounds, this is the best we can manage with such a long word if (longestWord.width >= closeW && wrappedHeight <= bounds.height) { return { wrapped, bounds: { width: bounds.width, height: bounds.height }, strategy: 'widest word', scale, iterations, foundDuring: during }; } // otherwise, we need to check the height if (wrappedHeight > bounds.height) { return exports.fitnessOver; } else if (wrappedHeight < closeH) { return exports.fitnessUnder; } // great - found a close fit return { wrapped, bounds: { width: bounds.width, height: bounds.height }, strategy: 'height', scale, iterations, foundDuring: during }; }; // Find initial bounds let fit = attemptFit(scale, 'initial'); // if fitType is shrink, only continue if we are over sized if (fitType === 'shrink' && fit === exports.fitnessUnder) return { wrapped: wrapped, bounds: { width: bounds.width, height: bounds.height }, strategy: 'shrink', scale, iterations, foundDuring: 'initial' }; // found it during the initial fit at scale 1 if (isFitResult(fit)) return fit; // estimate the starting scale const boundsArea = bounds.width * bounds.height; const wrappedArea = wrapped.width * wrapped.height; scale = Math.sqrt(boundsArea / wrappedArea); // Find estimated fit = attemptFit(scale, 'estimate'); // found it during the estimate if (isFitResult(fit)) return fit; // find approx upper and lower bounds - we need this because we have no // robust way to guess what they are, the user could easily pass in a text // that needs to be scaled outside of our guess if (fit === exports.fitnessUnder) { lowerBound = scale; do { scale *= scaleStep; fit = attemptFit(scale, 'upper bound search'); // found it while searching for the upper bound if (isFitResult(fit)) return fit; } while (fit !== exports.fitnessOver); upperBound = scale; } else { upperBound = scale; do { scale /= scaleStep; fit = attemptFit(scale, 'lower bound search'); // found it while searching for the lower bound if (isFitResult(fit)) return fit; } while (fit !== exports.fitnessUnder); lowerBound = scale; } let midScale = (lowerBound + upperBound) / 2; fit = attemptFit(midScale, 'mid scale'); // found it while setting the mid scale if (isFitResult(fit)) return fit; // binary search to find the right scale // while( true ) seems scary, but we will either find the fit and return, // or attemptFit will throw at max iterations while (true) { if (fit === exports.fitnessUnder) { lowerBound = midScale; const boundsDelta = upperBound - lowerBound; // ok - need to handle the case where the delta between upper and lower // is really small, it means that no close fit is possible - better for // it to be under if (boundsDelta < minBoundsDelta) { return { wrapped: wrapped, bounds: { width: bounds.width, height: bounds.height }, strategy: 'no close fit', scale, iterations, foundDuring: 'lower/upper delta check' }; } } else if (fit === exports.fitnessOver) { upperBound = midScale; } midScale = (lowerBound + upperBound) / 2; fit = attemptFit(midScale, 'binary search'); // found during binary search if (isFitResult(fit)) return fit; } }; return fitBlock; }; exports.fitter = fitter; exports.fitnessOver = 'over'; exports.fitnessUnder = 'under'; const defaultFitterOptions = () => ({ tolerance: 1, scaleStep: 2, maxIterations: 100, fitType: 'fit', minBoundsDelta: 1e-6, // you can override the soft wrapper // the idea being that we can provide a new wrapper later // that makes better use of font metrics wrapper: wrap_js_1.softWrapper }); exports.defaultFitterOptions = defaultFitterOptions; const isFitResult = (attempt) => typeof attempt !== 'string'; const assertOptions = (tolerance, scaleStep) => { const errors = []; if (tolerance <= 0) errors.push(`Tolerance must be > 0, saw ${tolerance}`); if (scaleStep <= 1) errors.push(`Scale step must be > 1, saw ${scaleStep}`); if (errors.length > 0) throw Error(errors.join(', ')); }; //# sourceMappingURL=fit.js.map