2849 lines
86 KiB
JavaScript
2849 lines
86 KiB
JavaScript
import { isNil, last, repeat, reverse, dropLast as dropLast$2, adjust, compose } from '@react-pdf/fns';
|
|
import bidiFactory from 'bidi-js';
|
|
import unicode from 'unicode-properties';
|
|
import hyphen from 'hyphen';
|
|
import pattern from 'hyphen/patterns/en-us.js';
|
|
|
|
/**
|
|
* Create attributed string from text fragments
|
|
*
|
|
* @param fragments - Fragments
|
|
* @returns Attributed string
|
|
*/
|
|
const fromFragments = (fragments) => {
|
|
let offset = 0;
|
|
let string = '';
|
|
const runs = [];
|
|
fragments.forEach((fragment) => {
|
|
string += fragment.string;
|
|
runs.push({
|
|
...fragment,
|
|
start: offset,
|
|
end: offset + fragment.string.length,
|
|
attributes: fragment.attributes || {},
|
|
});
|
|
offset += fragment.string.length;
|
|
});
|
|
return { string, runs };
|
|
};
|
|
|
|
/**
|
|
* Default word hyphenation engine used when no one provided.
|
|
* Does not perform word hyphenation at all
|
|
*
|
|
* @param word
|
|
* @returns Same word
|
|
*/
|
|
const defaultHyphenationEngine = (word) => [word];
|
|
/**
|
|
* Wrap words of attribute string
|
|
*
|
|
* @param engines layout engines
|
|
* @param options layout options
|
|
*/
|
|
const wrapWords = (engines = {}, options = {}) => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string including syllables
|
|
*/
|
|
return (attributedString) => {
|
|
const syllables = [];
|
|
const fragments = [];
|
|
const hyphenateWord = options.hyphenationCallback ||
|
|
engines.wordHyphenation?.() ||
|
|
defaultHyphenationEngine;
|
|
for (let i = 0; i < attributedString.runs.length; i += 1) {
|
|
let string = '';
|
|
const run = attributedString.runs[i];
|
|
const words = attributedString.string
|
|
.slice(run.start, run.end)
|
|
.split(/([ ]+)/g)
|
|
.filter(Boolean);
|
|
for (let j = 0; j < words.length; j += 1) {
|
|
const word = words[j];
|
|
const parts = hyphenateWord(word);
|
|
syllables.push(...parts);
|
|
string += parts.join('');
|
|
}
|
|
fragments.push({ ...run, string });
|
|
}
|
|
const result = { ...fromFragments(fragments), syllables };
|
|
return result;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Clone rect
|
|
*
|
|
* @param rect - Rect
|
|
* @returns Cloned rect
|
|
*/
|
|
const copy = (rect) => {
|
|
return Object.assign({}, rect);
|
|
};
|
|
|
|
/**
|
|
* Partition rect in two in the vertical direction
|
|
*
|
|
* @param rect - Rect
|
|
* @param height - Height
|
|
* @returns Partitioned rects
|
|
*/
|
|
const partition = (rect, height) => {
|
|
const a = Object.assign({}, rect, { height });
|
|
const b = Object.assign({}, rect, {
|
|
y: rect.y + height,
|
|
height: rect.height - height,
|
|
});
|
|
return [a, b];
|
|
};
|
|
|
|
/**
|
|
* Crop upper section of rect
|
|
*
|
|
* @param height - Height
|
|
* @param rect - Rect
|
|
* @returns Cropped rect
|
|
*/
|
|
const crop = (height, rect) => {
|
|
const [, result] = partition(rect, height);
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Get paragraph block height
|
|
*
|
|
* @param paragraph - Paragraph
|
|
* @returns Paragraph block height
|
|
*/
|
|
const height$2 = (paragraph) => {
|
|
return paragraph.reduce((acc, block) => acc + block.box.height, 0);
|
|
};
|
|
|
|
/**
|
|
* Calculate run scale
|
|
*
|
|
* @param run - Run
|
|
* @returns Scale
|
|
*/
|
|
const calculateScale = (run) => {
|
|
const attributes = run.attributes || {};
|
|
const fontSize = attributes.fontSize || 12;
|
|
const font = attributes.font;
|
|
const unitsPerEm = typeof font === 'string' ? null : font?.[0]?.unitsPerEm;
|
|
return unitsPerEm ? fontSize / unitsPerEm : 0;
|
|
};
|
|
/**
|
|
* Get run scale
|
|
*
|
|
* @param run
|
|
* @returns Scale
|
|
*/
|
|
const scale = (run) => {
|
|
return run.attributes?.scale || calculateScale(run);
|
|
};
|
|
|
|
/**
|
|
* Get ligature offset by index
|
|
*
|
|
* Ex. ffi ligature
|
|
*
|
|
* glyphs: l o f f i m
|
|
* glyphIndices: 0 1 2 2 2 3
|
|
* offset: 0 0 0 1 2 0
|
|
*
|
|
* @param index
|
|
* @param run - Run
|
|
* @returns Ligature offset
|
|
*/
|
|
const offset = (index, run) => {
|
|
if (!run)
|
|
return 0;
|
|
const glyphIndices = run.glyphIndices || [];
|
|
const value = glyphIndices[index];
|
|
return glyphIndices.slice(0, index).filter((i) => i === value).length;
|
|
};
|
|
|
|
/**
|
|
* Get run font
|
|
*
|
|
* @param run - Run
|
|
* @returns Font
|
|
*/
|
|
const getFont = (run) => {
|
|
return run.attributes?.font?.[0] || null;
|
|
};
|
|
|
|
/**
|
|
* Slice glyph between codePoints range
|
|
* Util for breaking ligatures
|
|
*
|
|
* @param start - Start code point index
|
|
* @param end - End code point index
|
|
* @param font - Font to generate new glyph
|
|
* @param glyph - Glyph to be sliced
|
|
* @returns Sliced glyph parts
|
|
*/
|
|
const slice$2 = (start, end, font, glyph) => {
|
|
if (!glyph)
|
|
return [];
|
|
if (start === end)
|
|
return [];
|
|
if (start === 0 && end === glyph.codePoints.length)
|
|
return [glyph];
|
|
const codePoints = glyph.codePoints.slice(start, end);
|
|
const string = String.fromCodePoint(...codePoints);
|
|
// passing LTR To force fontkit to not reverse the string
|
|
return font
|
|
? font.layout(string, undefined, undefined, undefined, 'ltr').glyphs
|
|
: [glyph];
|
|
};
|
|
|
|
/**
|
|
* Return glyph index at string index, if glyph indices present.
|
|
* Otherwise return string index
|
|
*
|
|
* @param index - Index
|
|
* @param run - Run
|
|
* @returns Glyph index
|
|
*/
|
|
const glyphIndexAt = (index, run) => {
|
|
const result = run?.glyphIndices?.[index];
|
|
return isNil(result) ? index : result;
|
|
};
|
|
|
|
/**
|
|
* Returns new array starting with zero, and keeping same relation between consecutive values
|
|
*
|
|
* @param array - List
|
|
* @returns Normalized array
|
|
*/
|
|
const normalize = (array) => {
|
|
const head = array[0];
|
|
return array.map((value) => value - head);
|
|
};
|
|
|
|
/**
|
|
* Slice run between glyph indices range
|
|
*
|
|
* @param start - Glyph index
|
|
* @param end - Glyph index
|
|
* @param run - Run
|
|
* @returns Sliced run
|
|
*/
|
|
const slice$1 = (start, end, run) => {
|
|
const runScale = scale(run);
|
|
const font = getFont(run);
|
|
// Get glyph start and end indices
|
|
const startIndex = glyphIndexAt(start, run);
|
|
const endIndex = glyphIndexAt(end, run);
|
|
// Get start and end glyph
|
|
const startGlyph = run.glyphs?.[startIndex];
|
|
const endGlyph = run.glyphs?.[endIndex];
|
|
// Get start ligature chunks (if any)
|
|
const startOffset = offset(start, run);
|
|
const startGlyphs = startOffset > 0 ? slice$2(startOffset, Infinity, font, startGlyph) : [];
|
|
// Get end ligature chunks (if any)
|
|
const endOffset = offset(end, run);
|
|
const endGlyphs = slice$2(0, endOffset, font, endGlyph);
|
|
// Compute new glyphs
|
|
const sliceStart = startIndex + Math.min(1, startOffset);
|
|
const glyphs = (run.glyphs || []).slice(sliceStart, endIndex);
|
|
// Compute new positions
|
|
const glyphPosition = (g) => ({
|
|
xAdvance: g.advanceWidth * runScale,
|
|
yAdvance: 0,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
});
|
|
const startPositions = startGlyphs.map(glyphPosition);
|
|
const positions = (run.positions || []).slice(sliceStart, endIndex);
|
|
const endPositions = endGlyphs.map(glyphPosition);
|
|
return Object.assign({}, run, {
|
|
start: run.start + start,
|
|
end: Math.min(run.end, run.start + end),
|
|
glyphIndices: normalize((run.glyphIndices || []).slice(start, end)),
|
|
glyphs: [startGlyphs, glyphs, endGlyphs].flat(),
|
|
positions: [startPositions, positions, endPositions].flat(),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get run index that contains passed index
|
|
*
|
|
* @param index - Index
|
|
* @param runs - Runs
|
|
* @returns Run index
|
|
*/
|
|
const runIndexAt$1 = (index, runs) => {
|
|
if (!runs)
|
|
return -1;
|
|
return runs.findIndex((run) => run.start <= index && index < run.end);
|
|
};
|
|
|
|
/**
|
|
* Filter runs contained between start and end
|
|
*
|
|
* @param start
|
|
* @param end
|
|
* @param runs
|
|
* @returns Filtered runs
|
|
*/
|
|
const filter = (start, end, runs) => {
|
|
const startIndex = runIndexAt$1(start, runs);
|
|
const endIndex = Math.max(runIndexAt$1(end - 1, runs), startIndex);
|
|
return runs.slice(startIndex, endIndex + 1);
|
|
};
|
|
|
|
/**
|
|
* Subtract scalar to run
|
|
*
|
|
* @param index - Scalar
|
|
* @param run - Run
|
|
* @returns Subtracted run
|
|
*/
|
|
const subtract = (index, run) => {
|
|
const start = run.start - index;
|
|
const end = run.end - index;
|
|
return Object.assign({}, run, { start, end });
|
|
};
|
|
|
|
/**
|
|
* Slice array of runs
|
|
*
|
|
* @param start - Offset
|
|
* @param end - Offset
|
|
* @param runs
|
|
* @returns Sliced runs
|
|
*/
|
|
const sliceRuns = (start, end, runs) => {
|
|
const sliceFirstRun = (a) => slice$1(start - a.start, end - a.start, a);
|
|
const sliceLastRun = (a) => slice$1(0, end - a.start, a);
|
|
return runs.map((run, i) => {
|
|
let result = run;
|
|
const isFirst = i === 0;
|
|
const isLast = !isFirst && i === runs.length - 1;
|
|
if (isFirst)
|
|
result = sliceFirstRun(run);
|
|
if (isLast)
|
|
result = sliceLastRun(run);
|
|
return subtract(start, result);
|
|
});
|
|
};
|
|
/**
|
|
* Slice attributed string between two indices
|
|
*
|
|
* @param start - Offset
|
|
* @param end - Offset
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
const slice = (start, end, attributedString) => {
|
|
if (attributedString.string.length === 0)
|
|
return attributedString;
|
|
const string = attributedString.string.slice(start, end);
|
|
const filteredRuns = filter(start, end, attributedString.runs);
|
|
const slicedRuns = sliceRuns(start, end, filteredRuns);
|
|
return Object.assign({}, attributedString, { string, runs: slicedRuns });
|
|
};
|
|
|
|
const findCharIndex = (string) => {
|
|
return string.search(/\S/g);
|
|
};
|
|
const findLastCharIndex = (string) => {
|
|
const match = string.match(/\S/g);
|
|
return match ? string.lastIndexOf(match[match.length - 1]) : -1;
|
|
};
|
|
/**
|
|
* Removes (strips) whitespace from both ends of the attributted string.
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
const trim = (attributedString) => {
|
|
const start = findCharIndex(attributedString.string);
|
|
const end = findLastCharIndex(attributedString.string);
|
|
return slice(start, end + 1, attributedString);
|
|
};
|
|
|
|
/**
|
|
* Returns empty run
|
|
*
|
|
* @returns Empty run
|
|
*/
|
|
const empty$1 = () => {
|
|
return {
|
|
start: 0,
|
|
end: 0,
|
|
glyphIndices: [],
|
|
glyphs: [],
|
|
positions: [],
|
|
attributes: {},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Check if value is a number
|
|
*
|
|
* @param value - Value to check
|
|
* @returns Whether value is a number
|
|
*/
|
|
const isNumber = (value) => {
|
|
return typeof value === 'number';
|
|
};
|
|
|
|
/**
|
|
* Append glyph indices with given length
|
|
*
|
|
* Ex. appendIndices(3, [0, 1, 2, 2]) => [0, 1, 2, 2, 3, 3, 3]
|
|
*
|
|
* @param length - Length
|
|
* @param indices - Glyph indices
|
|
* @returns Extended glyph indices
|
|
*/
|
|
const appendIndices = (length, indices) => {
|
|
const lastIndex = last(indices);
|
|
const value = isNil(lastIndex) ? 0 : lastIndex + 1;
|
|
const newIndices = Array(length).fill(value);
|
|
return indices.concat(newIndices);
|
|
};
|
|
|
|
/**
|
|
* Get glyph for a given code point
|
|
*
|
|
* @param value - CodePoint
|
|
* @param font - Font
|
|
* @returns Glyph
|
|
* */
|
|
const fromCodePoint = (value, font) => {
|
|
if (typeof font === 'string')
|
|
return null;
|
|
return font && value ? font.glyphForCodePoint(value) : null;
|
|
};
|
|
|
|
/**
|
|
* Append glyph to run
|
|
*
|
|
* @param glyph - Glyph
|
|
* @param run - Run
|
|
* @returns Run with glyph
|
|
*/
|
|
const appendGlyph = (glyph, run) => {
|
|
const glyphLength = glyph.codePoints?.length || 0;
|
|
const end = run.end + glyphLength;
|
|
const glyphs = run.glyphs.concat(glyph);
|
|
const glyphIndices = appendIndices(glyphLength, run.glyphIndices);
|
|
if (!run.positions)
|
|
return Object.assign({}, run, { end, glyphs, glyphIndices });
|
|
const positions = run.positions.concat({
|
|
xAdvance: glyph.advanceWidth * scale(run),
|
|
yAdvance: 0,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
});
|
|
return Object.assign({}, run, { end, glyphs, glyphIndices, positions });
|
|
};
|
|
/**
|
|
* Append glyph or code point to run
|
|
*
|
|
* @param value - Glyph or codePoint
|
|
* @param run - Run
|
|
* @returns Run with glyph
|
|
*/
|
|
const append$1 = (value, run) => {
|
|
if (!value)
|
|
return run;
|
|
const font = getFont(run);
|
|
const glyph = isNumber(value) ? fromCodePoint(value, font) : value;
|
|
return appendGlyph(glyph, run);
|
|
};
|
|
|
|
/**
|
|
* Get string from array of code points
|
|
*
|
|
* @param codePoints - Points
|
|
* @returns String
|
|
*/
|
|
const stringFromCodePoints = (codePoints) => {
|
|
return String.fromCodePoint(...(codePoints || []));
|
|
};
|
|
|
|
/**
|
|
* Append glyph into last run of attributed string
|
|
*
|
|
* @param glyph - Glyph or code point
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string with new glyph
|
|
*/
|
|
const append = (glyph, attributedString) => {
|
|
const codePoints = typeof glyph === 'number' ? [glyph] : glyph?.codePoints;
|
|
const codePointsString = stringFromCodePoints(codePoints || []);
|
|
const string = attributedString.string + codePointsString;
|
|
const firstRuns = attributedString.runs.slice(0, -1);
|
|
const lastRun = last(attributedString.runs) || empty$1();
|
|
const runs = firstRuns.concat(append$1(glyph, lastRun));
|
|
return Object.assign({}, attributedString, { string, runs });
|
|
};
|
|
|
|
const ELLIPSIS_UNICODE = 8230;
|
|
const ELLIPSIS_STRING = String.fromCharCode(ELLIPSIS_UNICODE);
|
|
/**
|
|
* Get ellipsis codepoint. This may be different in standard and embedded fonts
|
|
*
|
|
* @param font
|
|
* @returns Ellipsis codepoint
|
|
*/
|
|
const getEllipsisCodePoint = (font) => {
|
|
if (!font.encode)
|
|
return ELLIPSIS_UNICODE;
|
|
const [codePoints] = font.encode(ELLIPSIS_STRING);
|
|
return parseInt(codePoints[0], 16);
|
|
};
|
|
/**
|
|
* Trucante block with ellipsis
|
|
*
|
|
* @param paragraph - Paragraph
|
|
* @returns Sliced paragraph
|
|
*/
|
|
const truncate = (paragraph) => {
|
|
const runs = last(paragraph)?.runs || [];
|
|
const font = last(runs)?.attributes?.font[0];
|
|
if (font) {
|
|
const index = paragraph.length - 1;
|
|
const codePoint = getEllipsisCodePoint(font);
|
|
const glyph = font.glyphForCodePoint(codePoint);
|
|
const lastBlock = append(glyph, trim(paragraph[index]));
|
|
return Object.assign([], paragraph, { [index]: lastBlock });
|
|
}
|
|
return paragraph;
|
|
};
|
|
|
|
/**
|
|
* Omit attribute from run
|
|
*
|
|
* @param value - Attribute key
|
|
* @param run - Run
|
|
* @returns Run without ommited attribute
|
|
*/
|
|
const omit = (value, run) => {
|
|
const attributes = Object.assign({}, run.attributes);
|
|
delete attributes[value];
|
|
return Object.assign({}, run, { attributes });
|
|
};
|
|
|
|
/**
|
|
* Get run ascent
|
|
*
|
|
* @param run - Run
|
|
* @returns Ascent
|
|
*/
|
|
const ascent$1 = (run) => {
|
|
const { font, attachment } = run.attributes;
|
|
const attachmentHeight = attachment?.height || 0;
|
|
const fontAscent = typeof font === 'string' ? 0 : font?.[0]?.ascent || 0;
|
|
return Math.max(attachmentHeight, fontAscent * scale(run));
|
|
};
|
|
|
|
/**
|
|
* Get run descent
|
|
*
|
|
* @param run - Run
|
|
* @returns Descent
|
|
*/
|
|
const descent = (run) => {
|
|
const font = run.attributes?.font;
|
|
const fontDescent = typeof font === 'string' ? 0 : font?.[0]?.descent || 0;
|
|
return scale(run) * fontDescent;
|
|
};
|
|
|
|
/**
|
|
* Get run lineGap
|
|
*
|
|
* @param run - Run
|
|
* @returns LineGap
|
|
*/
|
|
const lineGap = (run) => {
|
|
const font = run.attributes?.font;
|
|
const lineGap = typeof font === 'string' ? 0 : font?.[0]?.lineGap || 0;
|
|
return lineGap * scale(run);
|
|
};
|
|
|
|
/**
|
|
* Get run height
|
|
*
|
|
* @param run - Run
|
|
* @returns Height
|
|
*/
|
|
const height$1 = (run) => {
|
|
const lineHeight = run.attributes?.lineHeight;
|
|
return lineHeight || lineGap(run) + ascent$1(run) - descent(run);
|
|
};
|
|
|
|
/**
|
|
* Returns attributed string height
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Height
|
|
*/
|
|
const height = (attributedString) => {
|
|
const reducer = (acc, run) => Math.max(acc, height$1(run));
|
|
return attributedString.runs.reduce(reducer, 0);
|
|
};
|
|
|
|
/**
|
|
* Checks if two rects intersect each other
|
|
*
|
|
* @param a - Rect A
|
|
* @param b - Rect B
|
|
* @returns Whether rects intersect
|
|
*/
|
|
const intersects = (a, b) => {
|
|
const x = Math.max(a.x, b.x);
|
|
const num1 = Math.min(a.x + a.width, b.x + b.width);
|
|
const y = Math.max(a.y, b.y);
|
|
const num2 = Math.min(a.y + a.height, b.y + b.height);
|
|
return num1 >= x && num2 >= y;
|
|
};
|
|
|
|
const getLineFragment = (lineRect, excludeRect) => {
|
|
if (!intersects(excludeRect, lineRect))
|
|
return [lineRect];
|
|
const eStart = excludeRect.x;
|
|
const eEnd = excludeRect.x + excludeRect.width;
|
|
const lStart = lineRect.x;
|
|
const lEnd = lineRect.x + lineRect.width;
|
|
const a = Object.assign({}, lineRect, { width: eStart - lStart });
|
|
const b = Object.assign({}, lineRect, { x: eEnd, width: lEnd - eEnd });
|
|
return [a, b].filter((r) => r.width > 0);
|
|
};
|
|
const getLineFragments = (rect, excludeRects) => {
|
|
let fragments = [rect];
|
|
for (let i = 0; i < excludeRects.length; i += 1) {
|
|
const excludeRect = excludeRects[i];
|
|
fragments = fragments.reduce((acc, fragment) => {
|
|
const pieces = getLineFragment(fragment, excludeRect);
|
|
return acc.concat(pieces);
|
|
}, []);
|
|
}
|
|
return fragments;
|
|
};
|
|
const generateLineRects = (container, height) => {
|
|
const { excludeRects, ...rect } = container;
|
|
if (!excludeRects)
|
|
return [rect];
|
|
const lineRects = [];
|
|
const maxY = Math.max(...excludeRects.map((r) => r.y + r.height));
|
|
let currentRect = rect;
|
|
while (currentRect.y < maxY) {
|
|
const [lineRect, rest] = partition(currentRect, height);
|
|
const lineRectFragments = getLineFragments(lineRect, excludeRects);
|
|
currentRect = rest;
|
|
lineRects.push(...lineRectFragments);
|
|
}
|
|
return [...lineRects, currentRect];
|
|
};
|
|
|
|
const ATTACHMENT_CODE$1 = '\ufffc'; // 65532
|
|
/**
|
|
* Remove attachment attribute if no char present
|
|
*
|
|
* @param line - Line
|
|
* @returns Line
|
|
*/
|
|
const purgeAttachments = (line) => {
|
|
const shouldPurge = !line.string.includes(ATTACHMENT_CODE$1);
|
|
if (!shouldPurge)
|
|
return line;
|
|
const runs = line.runs.map((run) => omit('attachment', run));
|
|
return Object.assign({}, line, { runs });
|
|
};
|
|
/**
|
|
* Layout paragraphs inside rectangle
|
|
*
|
|
* @param rects - Rects
|
|
* @param lines - Attributed strings
|
|
* @param indent
|
|
* @returns layout blocks
|
|
*/
|
|
const layoutLines = (rects, lines, indent) => {
|
|
let rect = rects.shift();
|
|
let currentY = rect.y;
|
|
return lines.map((line, i) => {
|
|
const lineIndent = i === 0 ? indent : 0;
|
|
const style = line.runs?.[0]?.attributes || {};
|
|
const height$1 = Math.max(height(line), style.lineHeight);
|
|
if (currentY + height$1 > rect.y + rect.height && rects.length > 0) {
|
|
rect = rects.shift();
|
|
currentY = rect.y;
|
|
}
|
|
const newLine = {
|
|
string: line.string,
|
|
runs: line.runs,
|
|
box: {
|
|
x: rect.x + lineIndent,
|
|
y: currentY,
|
|
width: rect.width - lineIndent,
|
|
height: height$1,
|
|
},
|
|
};
|
|
currentY += height$1;
|
|
return purgeAttachments(newLine);
|
|
});
|
|
};
|
|
/**
|
|
* Performs line breaking and layout
|
|
*
|
|
* @param engines - Engines
|
|
* @param options - Layout options
|
|
*/
|
|
const layoutParagraph = (engines, options = {}) => {
|
|
/**
|
|
* @param container - Container
|
|
* @param paragraph - Attributed string
|
|
* @returns Layout block
|
|
*/
|
|
return (container, paragraph) => {
|
|
const height$1 = height(paragraph);
|
|
const indent = paragraph.runs?.[0]?.attributes?.indent || 0;
|
|
const rects = generateLineRects(container, height$1);
|
|
const availableWidths = rects.map((r) => r.width);
|
|
availableWidths.unshift(availableWidths[0] - indent);
|
|
const lines = engines.linebreaker(options)(paragraph, availableWidths);
|
|
return layoutLines(rects, lines, indent);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Slice block at given height
|
|
*
|
|
* @param height - Height
|
|
* @param paragraph - Paragraph
|
|
* @returns Sliced paragraph
|
|
*/
|
|
const sliceAtHeight = (height, paragraph) => {
|
|
const newBlock = [];
|
|
let counter = 0;
|
|
for (let i = 0; i < paragraph.length; i += 1) {
|
|
const line = paragraph[i];
|
|
counter += line.box.height;
|
|
if (counter < height) {
|
|
newBlock.push(line);
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
return newBlock;
|
|
};
|
|
|
|
/**
|
|
* Layout paragraphs inside container until it does not
|
|
* fit anymore, performing line wrapping in the process.
|
|
*
|
|
* @param engines - Engines
|
|
* @param options - Layout options
|
|
* @param container - Container
|
|
*/
|
|
const typesetter = (engines, options, container) => {
|
|
/**
|
|
* @param attributedStrings - Attributed strings (paragraphs)
|
|
* @returns Paragraph blocks
|
|
*/
|
|
return (attributedStrings) => {
|
|
const result = [];
|
|
const paragraphs = [...attributedStrings];
|
|
const layout = layoutParagraph(engines, options);
|
|
const maxLines = isNil(container.maxLines) ? Infinity : container.maxLines;
|
|
const truncateEllipsis = container.truncateMode === 'ellipsis';
|
|
let linesCount = maxLines;
|
|
let paragraphRect = copy(container);
|
|
let nextParagraph = paragraphs.shift();
|
|
while (linesCount > 0 && nextParagraph) {
|
|
const paragraph = layout(paragraphRect, nextParagraph);
|
|
const slicedBlock = paragraph.slice(0, linesCount);
|
|
const linesHeight = height$2(slicedBlock);
|
|
const shouldTruncate = truncateEllipsis && paragraph.length !== slicedBlock.length;
|
|
linesCount -= slicedBlock.length;
|
|
if (paragraphRect.height >= linesHeight) {
|
|
result.push(shouldTruncate ? truncate(slicedBlock) : slicedBlock);
|
|
paragraphRect = crop(linesHeight, paragraphRect);
|
|
nextParagraph = paragraphs.shift();
|
|
}
|
|
else {
|
|
result.push(truncate(sliceAtHeight(paragraphRect.height, slicedBlock)));
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get attributed string start value
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Start
|
|
*/
|
|
const start = (attributedString) => {
|
|
const { runs } = attributedString;
|
|
return runs.length === 0 ? 0 : runs[0].start;
|
|
};
|
|
|
|
/**
|
|
* Get attributed string end value
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns End
|
|
*/
|
|
const end = (attributedString) => {
|
|
const { runs } = attributedString;
|
|
return runs.length === 0 ? 0 : last(runs).end;
|
|
};
|
|
|
|
/**
|
|
* Get attributed string length
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns End
|
|
*/
|
|
const length$1 = (attributedString) => {
|
|
return end(attributedString) - start(attributedString);
|
|
};
|
|
|
|
const bidi$2 = bidiFactory();
|
|
const getBidiLevels$1 = (runs) => {
|
|
return runs.reduce((acc, run) => {
|
|
const length = run.end - run.start;
|
|
const levels = repeat(run.attributes.bidiLevel, length);
|
|
return acc.concat(levels);
|
|
}, []);
|
|
};
|
|
const getReorderedIndices = (string, segments) => {
|
|
// Fill an array with indices
|
|
const indices = [];
|
|
for (let i = 0; i < string.length; i += 1) {
|
|
indices[i] = i;
|
|
}
|
|
// Reverse each segment in order
|
|
segments.forEach(([start, end]) => {
|
|
const slice = indices.slice(start, end + 1);
|
|
for (let i = slice.length - 1; i >= 0; i -= 1) {
|
|
indices[end - i] = slice[i];
|
|
}
|
|
});
|
|
return indices;
|
|
};
|
|
const getItemAtIndex = (runs, objectName, index) => {
|
|
for (let i = 0; i < runs.length; i += 1) {
|
|
const run = runs[i];
|
|
const updatedIndex = run.glyphIndices[index - run.start];
|
|
if (index >= run.start && index < run.end) {
|
|
return run[objectName][updatedIndex];
|
|
}
|
|
}
|
|
throw new Error(`index ${index} out of range`);
|
|
};
|
|
const reorderLine = (line) => {
|
|
const levels = getBidiLevels$1(line.runs);
|
|
const direction = line.runs[0]?.attributes.direction;
|
|
const level = direction === 'rtl' ? 1 : 0;
|
|
const end = length$1(line) - 1;
|
|
const paragraphs = [{ start: 0, end, level }];
|
|
const embeddingLevels = { paragraphs, levels };
|
|
const segments = bidi$2.getReorderSegments(line.string, embeddingLevels);
|
|
// No need for bidi reordering
|
|
if (segments.length === 0)
|
|
return line;
|
|
const indices = getReorderedIndices(line.string, segments);
|
|
const updatedString = bidi$2.getReorderedString(line.string, embeddingLevels);
|
|
const updatedRuns = line.runs.map((run) => {
|
|
const selectedIndices = indices.slice(run.start, run.end);
|
|
const updatedGlyphs = [];
|
|
const updatedPositions = [];
|
|
const addedGlyphs = new Set();
|
|
for (let i = 0; i < selectedIndices.length; i += 1) {
|
|
const index = selectedIndices[i];
|
|
const glyph = getItemAtIndex(line.runs, 'glyphs', index);
|
|
if (addedGlyphs.has(glyph.id))
|
|
continue;
|
|
updatedGlyphs.push(glyph);
|
|
updatedPositions.push(getItemAtIndex(line.runs, 'positions', index));
|
|
if (glyph.isLigature) {
|
|
addedGlyphs.add(glyph.id);
|
|
}
|
|
}
|
|
return {
|
|
...run,
|
|
glyphs: updatedGlyphs,
|
|
positions: updatedPositions,
|
|
};
|
|
});
|
|
return {
|
|
box: line.box,
|
|
runs: updatedRuns,
|
|
string: updatedString,
|
|
};
|
|
};
|
|
const reorderParagraph = (paragraph) => paragraph.map(reorderLine);
|
|
/**
|
|
* Perform bidi reordering
|
|
*
|
|
* @returns Reordered paragraphs
|
|
*/
|
|
const bidiReordering = () => {
|
|
/**
|
|
* @param paragraphs - Paragraphs
|
|
* @returns Reordered paragraphs
|
|
*/
|
|
return (paragraphs) => paragraphs.map(reorderParagraph);
|
|
};
|
|
|
|
const DUMMY_CODEPOINT = 123;
|
|
/**
|
|
* Resolve string indices based on glyphs code points
|
|
*
|
|
* @param glyphs
|
|
* @returns Glyph indices
|
|
*/
|
|
const resolve = (glyphs = []) => {
|
|
return glyphs.reduce((acc, glyph) => {
|
|
const codePoints = glyph?.codePoints || [DUMMY_CODEPOINT];
|
|
if (acc.length === 0)
|
|
return codePoints.map(() => 0);
|
|
const last = acc[acc.length - 1];
|
|
const next = codePoints.map(() => last + 1);
|
|
return [...acc, ...next];
|
|
}, []);
|
|
};
|
|
|
|
const getCharacterSpacing = (run) => {
|
|
return run.attributes?.characterSpacing || 0;
|
|
};
|
|
/**
|
|
* Scale run positions
|
|
*
|
|
* @param run
|
|
* @param positions
|
|
* @returns Scaled positions
|
|
*/
|
|
const scalePositions = (run, positions) => {
|
|
const runScale = scale(run);
|
|
const characterSpacing = getCharacterSpacing(run);
|
|
return positions.map((position, i) => {
|
|
const isLast = i === positions.length;
|
|
const xSpacing = isLast ? 0 : characterSpacing;
|
|
return Object.assign({}, position, {
|
|
xAdvance: position.xAdvance * runScale + xSpacing,
|
|
yAdvance: position.yAdvance * runScale,
|
|
xOffset: position.xOffset * runScale,
|
|
yOffset: position.yOffset * runScale,
|
|
});
|
|
});
|
|
};
|
|
/**
|
|
* Create glyph run
|
|
*
|
|
* @param string string
|
|
*/
|
|
const layoutRun = (string) => {
|
|
/**
|
|
* @param run - Run
|
|
* @returns Glyph run
|
|
*/
|
|
return (run) => {
|
|
const { start, end, attributes = {} } = run;
|
|
const { font } = attributes;
|
|
if (!font)
|
|
return { ...run, glyphs: [], glyphIndices: [], positions: [] };
|
|
const runString = string.slice(start, end);
|
|
if (typeof font === 'string')
|
|
throw new Error('Invalid font');
|
|
// passing LTR To force fontkit to not reverse the string
|
|
const glyphRun = font[0].layout(runString, undefined, undefined, undefined, 'ltr');
|
|
const positions = scalePositions(run, glyphRun.positions);
|
|
const glyphIndices = resolve(glyphRun.glyphs);
|
|
const result = {
|
|
...run,
|
|
positions,
|
|
glyphIndices,
|
|
glyphs: glyphRun.glyphs,
|
|
};
|
|
return result;
|
|
};
|
|
};
|
|
/**
|
|
* Generate glyphs for single attributed string
|
|
*/
|
|
const generateGlyphs = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string with glyphs
|
|
*/
|
|
return (attributedString) => {
|
|
const runs = attributedString.runs.map(layoutRun(attributedString.string));
|
|
const res = Object.assign({}, attributedString, { runs });
|
|
return res;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Resolves yOffset for run
|
|
*
|
|
* @param run - Run
|
|
* @returns Run
|
|
*/
|
|
const resolveRunYOffset = (run) => {
|
|
if (!run.positions)
|
|
return run;
|
|
const unitsPerEm = run.attributes?.font?.[0]?.unitsPerEm || 0;
|
|
const yOffset = (run.attributes?.yOffset || 0) * unitsPerEm;
|
|
const positions = run.positions.map((p) => Object.assign({}, p, { yOffset }));
|
|
return Object.assign({}, run, { positions });
|
|
};
|
|
/**
|
|
* Resolves yOffset for multiple paragraphs
|
|
*/
|
|
const resolveYOffset = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
const runs = attributedString.runs.map(resolveRunYOffset);
|
|
const res = Object.assign({}, attributedString, { runs });
|
|
return res;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Sort runs in ascending order
|
|
*
|
|
* @param runs
|
|
* @returns Sorted runs
|
|
*/
|
|
const sort = (runs) => {
|
|
return runs.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
};
|
|
|
|
/**
|
|
* Is run empty (start === end)
|
|
*
|
|
* @param run - Run
|
|
* @returns Is run empty
|
|
*/
|
|
const isEmpty = (run) => {
|
|
return run.start === run.end;
|
|
};
|
|
|
|
/**
|
|
* Sort points in ascending order
|
|
* @param a - First point
|
|
* @param b - Second point
|
|
* @returns Sort order
|
|
*/
|
|
const sortPoints = (a, b) => {
|
|
return a[1] - b[1] || a[3] - b[3];
|
|
};
|
|
/**
|
|
* @param runs
|
|
* @returns Points
|
|
*/
|
|
const generatePoints = (runs) => {
|
|
const result = runs.reduce((acc, run, i) => {
|
|
return acc.concat([
|
|
['start', run.start, run.attributes, i],
|
|
['end', run.end, run.attributes, i],
|
|
]);
|
|
}, []);
|
|
return result.sort(sortPoints);
|
|
};
|
|
/**
|
|
* @param runs
|
|
* @returns Merged runs
|
|
*/
|
|
const mergeRuns = (runs) => {
|
|
return runs.reduce((acc, run) => {
|
|
const attributes = Object.assign({}, acc.attributes, run.attributes);
|
|
return Object.assign({}, run, { attributes });
|
|
}, {});
|
|
};
|
|
/**
|
|
* @param runs
|
|
* @returns Grouped runs
|
|
*/
|
|
const groupEmptyRuns = (runs) => {
|
|
const groups = runs.reduce((acc, run) => {
|
|
if (!acc[run.start])
|
|
acc[run.start] = [];
|
|
acc[run.start].push(run);
|
|
return acc;
|
|
}, []);
|
|
return Object.values(groups);
|
|
};
|
|
/**
|
|
* @param runs
|
|
* @returns Flattened runs
|
|
*/
|
|
const flattenEmptyRuns = (runs) => {
|
|
return groupEmptyRuns(runs).map(mergeRuns);
|
|
};
|
|
/**
|
|
* @param runs
|
|
* @returns Flattened runs
|
|
*/
|
|
const flattenRegularRuns = (runs) => {
|
|
const res = [];
|
|
const points = generatePoints(runs);
|
|
let start = -1;
|
|
let attrs = {};
|
|
const stack = [];
|
|
for (let i = 0; i < points.length; i += 1) {
|
|
const [type, offset, attributes] = points[i];
|
|
if (start !== -1 && start < offset) {
|
|
res.push({
|
|
start,
|
|
end: offset,
|
|
attributes: attrs,
|
|
glyphIndices: [],
|
|
glyphs: [],
|
|
positions: [],
|
|
});
|
|
}
|
|
if (type === 'start') {
|
|
stack.push(attributes);
|
|
attrs = Object.assign({}, attrs, attributes);
|
|
}
|
|
else {
|
|
attrs = {};
|
|
for (let j = 0; j < stack.length; j += 1) {
|
|
if (stack[j] === attributes) {
|
|
stack.splice(j--, 1);
|
|
}
|
|
else {
|
|
attrs = Object.assign({}, attrs, stack[j]);
|
|
}
|
|
}
|
|
}
|
|
start = offset;
|
|
}
|
|
return res;
|
|
};
|
|
/**
|
|
* Flatten many runs
|
|
*
|
|
* @param runs
|
|
* @returns Flattened runs
|
|
*/
|
|
const flatten = (runs = []) => {
|
|
const emptyRuns = flattenEmptyRuns(runs.filter((run) => isEmpty(run)));
|
|
const regularRuns = flattenRegularRuns(runs.filter((run) => !isEmpty(run)));
|
|
return sort(emptyRuns.concat(regularRuns));
|
|
};
|
|
|
|
/**
|
|
* Returns empty attributed string
|
|
*
|
|
* @returns Empty attributed string
|
|
*/
|
|
const empty = () => ({ string: '', runs: [] });
|
|
|
|
/**
|
|
*
|
|
* @param attributedString
|
|
* @returns Attributed string without font
|
|
*/
|
|
const omitFont = (attributedString) => {
|
|
const runs = attributedString.runs.map((run) => omit('font', run));
|
|
return Object.assign({}, attributedString, { runs });
|
|
};
|
|
/**
|
|
* Performs font substitution and script itemization on attributed string
|
|
*
|
|
* @param engines - engines
|
|
*/
|
|
const preprocessRuns = (engines) => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Processed attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
if (isNil(attributedString))
|
|
return empty();
|
|
const { string } = attributedString;
|
|
const { fontSubstitution, scriptItemizer, bidi } = engines;
|
|
const { runs: omittedFontRuns } = omitFont(attributedString);
|
|
const { runs: itemizationRuns } = scriptItemizer()(attributedString);
|
|
const { runs: substitutedRuns } = fontSubstitution()(attributedString);
|
|
const { runs: bidiRuns } = bidi()(attributedString);
|
|
const runs = bidiRuns
|
|
.concat(substitutedRuns)
|
|
.concat(itemizationRuns)
|
|
.concat(omittedFontRuns);
|
|
return { string, runs: flatten(runs) };
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Breaks attributed string into paragraphs
|
|
*/
|
|
const splitParagraphs = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Paragraphs attributed strings
|
|
*/
|
|
return (attributedString) => {
|
|
const paragraphs = [];
|
|
let start = 0;
|
|
let breakPoint = attributedString.string.indexOf('\n') + 1;
|
|
while (breakPoint > 0) {
|
|
paragraphs.push(slice(start, breakPoint, attributedString));
|
|
start = breakPoint;
|
|
breakPoint = attributedString.string.indexOf('\n', breakPoint) + 1;
|
|
}
|
|
if (start === 0) {
|
|
paragraphs.push(attributedString);
|
|
}
|
|
else if (start < attributedString.string.length) {
|
|
paragraphs.push(slice(start, length$1(attributedString), attributedString));
|
|
}
|
|
return paragraphs;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Return positions advance width
|
|
*
|
|
* @param positions - Positions
|
|
* @returns {number} advance width
|
|
*/
|
|
const advanceWidth$2 = (positions) => {
|
|
return positions.reduce((acc, pos) => acc + (pos.xAdvance || 0), 0);
|
|
};
|
|
|
|
/**
|
|
* Return run advance width
|
|
*
|
|
* @param run - Run
|
|
* @returns Advance width
|
|
*/
|
|
const advanceWidth$1 = (run) => {
|
|
return advanceWidth$2(run.positions || []);
|
|
};
|
|
|
|
/**
|
|
* Returns attributed string advancewidth
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Advance width
|
|
*/
|
|
const advanceWidth = (attributedString) => {
|
|
const reducer = (acc, run) => acc + advanceWidth$1(run);
|
|
return attributedString.runs.reduce(reducer, 0);
|
|
};
|
|
|
|
const WHITE_SPACES_CODE = 32;
|
|
/**
|
|
* Check if glyph is white space
|
|
*
|
|
* @param glyph - Glyph
|
|
* @returns Whether glyph is white space
|
|
* */
|
|
const isWhiteSpace = (glyph) => {
|
|
const codePoints = glyph?.codePoints || [];
|
|
return codePoints.includes(WHITE_SPACES_CODE);
|
|
};
|
|
|
|
/**
|
|
* Get white space leading positions
|
|
*
|
|
* @param run - Run
|
|
* @returns White space leading positions
|
|
*/
|
|
const leadingPositions = (run) => {
|
|
const glyphs = run.glyphs || [];
|
|
const positions = run.positions || [];
|
|
const leadingWhitespaces = glyphs.findIndex((g) => !isWhiteSpace(g));
|
|
return positions.slice(0, leadingWhitespaces);
|
|
};
|
|
/**
|
|
* Get run leading white space offset
|
|
*
|
|
* @param run - Run
|
|
* @returns Leading white space offset
|
|
*/
|
|
const leadingOffset$1 = (run) => {
|
|
const positions = leadingPositions(run);
|
|
return positions.reduce((acc, pos) => acc + (pos.xAdvance || 0), 0);
|
|
};
|
|
|
|
/**
|
|
* Get attributed string leading white space offset
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Leading white space offset
|
|
*/
|
|
const leadingOffset = (attributedString) => {
|
|
const runs = attributedString.runs || [];
|
|
return leadingOffset$1(runs[0]);
|
|
};
|
|
|
|
/**
|
|
* Get white space trailing positions
|
|
*
|
|
* @param run run
|
|
* @returns White space trailing positions
|
|
*/
|
|
const trailingPositions = (run) => {
|
|
const glyphs = reverse(run.glyphs || []);
|
|
const positions = reverse(run.positions || []);
|
|
const leadingWhitespaces = glyphs.findIndex((g) => !isWhiteSpace(g));
|
|
return positions.slice(0, leadingWhitespaces);
|
|
};
|
|
/**
|
|
* Get run trailing white space offset
|
|
*
|
|
* @param run - Run
|
|
* @returns Trailing white space offset
|
|
*/
|
|
const trailingOffset$1 = (run) => {
|
|
const positions = trailingPositions(run);
|
|
return positions.reduce((acc, pos) => acc + (pos.xAdvance || 0), 0);
|
|
};
|
|
|
|
/**
|
|
* Get attributed string trailing white space offset
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Trailing white space offset
|
|
*/
|
|
const trailingOffset = (attributedString) => {
|
|
const runs = attributedString.runs || [];
|
|
return trailingOffset$1(last(runs));
|
|
};
|
|
|
|
/**
|
|
* Drop last char of run
|
|
*
|
|
* @param run - Run
|
|
* @returns Run without last char
|
|
*/
|
|
const dropLast$1 = (run) => {
|
|
return slice$1(0, run.end - run.start - 1, run);
|
|
};
|
|
|
|
/**
|
|
* Drop last glyph
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string with new glyph
|
|
*/
|
|
const dropLast = (attributedString) => {
|
|
const string = dropLast$2(attributedString.string);
|
|
const runs = adjust(-1, dropLast$1, attributedString.runs);
|
|
return Object.assign({}, attributedString, { string, runs });
|
|
};
|
|
|
|
const ALIGNMENT_FACTORS = { center: 0.5, right: 1 };
|
|
/**
|
|
* Remove new line char at the end of line if present
|
|
*
|
|
* @param line
|
|
* @returns Line
|
|
*/
|
|
const removeNewLine = (line) => {
|
|
return last(line.string) === '\n' ? dropLast(line) : line;
|
|
};
|
|
const getOverflowLeft = (line) => {
|
|
return leadingOffset(line) + (line.overflowLeft || 0);
|
|
};
|
|
const getOverflowRight = (line) => {
|
|
return trailingOffset(line) + (line.overflowRight || 0);
|
|
};
|
|
/**
|
|
* Ignore whitespace at the start and end of a line for alignment
|
|
*
|
|
* @param line
|
|
* @returns Line
|
|
*/
|
|
const adjustOverflow = (line) => {
|
|
const overflowLeft = getOverflowLeft(line);
|
|
const overflowRight = getOverflowRight(line);
|
|
const x = line.box.x - overflowLeft;
|
|
const width = line.box.width + overflowLeft + overflowRight;
|
|
const box = Object.assign({}, line.box, { x, width });
|
|
return Object.assign({}, line, { box, overflowLeft, overflowRight });
|
|
};
|
|
/**
|
|
* Performs line justification by calling appropiate engine
|
|
*
|
|
* @param engines - Engines
|
|
* @param options - Layout options
|
|
* @param align - Text align
|
|
*/
|
|
const justifyLine$1 = (engines, options, align) => {
|
|
/**
|
|
* @param line - Line
|
|
* @returns Line
|
|
*/
|
|
return (line) => {
|
|
const lineWidth = advanceWidth(line);
|
|
const alignFactor = ALIGNMENT_FACTORS[align] || 0;
|
|
const remainingWidth = Math.max(0, line.box.width - lineWidth);
|
|
const shouldJustify = align === 'justify' || lineWidth > line.box.width;
|
|
const x = line.box.x + remainingWidth * alignFactor;
|
|
const box = Object.assign({}, line.box, { x });
|
|
const newLine = Object.assign({}, line, { box });
|
|
return shouldJustify ? engines.justification(options)(newLine) : newLine;
|
|
};
|
|
};
|
|
const finalizeLine = (line) => {
|
|
let lineAscent = 0;
|
|
let lineDescent = 0;
|
|
let lineHeight = 0;
|
|
let lineXAdvance = 0;
|
|
const runs = line.runs.map((run) => {
|
|
const height = height$1(run);
|
|
const ascent = ascent$1(run);
|
|
const descent$1 = descent(run);
|
|
const xAdvance = advanceWidth$1(run);
|
|
lineHeight = Math.max(lineHeight, height);
|
|
lineAscent = Math.max(lineAscent, ascent);
|
|
lineDescent = Math.max(lineDescent, descent$1);
|
|
lineXAdvance += xAdvance;
|
|
return Object.assign({}, run, { height, ascent, descent: descent$1, xAdvance });
|
|
});
|
|
return Object.assign({}, line, {
|
|
runs,
|
|
height: lineHeight,
|
|
ascent: lineAscent,
|
|
descent: lineDescent,
|
|
xAdvance: lineXAdvance,
|
|
});
|
|
};
|
|
/**
|
|
* Finalize line by performing line justification
|
|
* and text decoration (using appropiate engines)
|
|
*
|
|
* @param engines - Engines
|
|
* @param options - Layout options
|
|
*/
|
|
const finalizeBlock = (engines, options) => {
|
|
/**
|
|
* @param line - Line
|
|
* @param i - Line index
|
|
* @param lines - Total lines
|
|
* @returns Line
|
|
*/
|
|
return (line, index, lines) => {
|
|
const isLastFragment = index === lines.length - 1;
|
|
const style = line.runs?.[0]?.attributes || {};
|
|
const align = isLastFragment ? style.alignLastLine : style.align;
|
|
return compose(finalizeLine, engines.textDecoration(), justifyLine$1(engines, options, align), adjustOverflow, removeNewLine)(line);
|
|
};
|
|
};
|
|
/**
|
|
* Finalize line block by performing line justification
|
|
* and text decoration (using appropiate engines)
|
|
*
|
|
* @param engines - Engines
|
|
* @param options - Layout options
|
|
*/
|
|
const finalizeFragments = (engines, options) => {
|
|
/**
|
|
* @param paragraphs - Paragraphs
|
|
* @returns Paragraphs
|
|
*/
|
|
return (paragraphs) => {
|
|
const blockFinalizer = finalizeBlock(engines, options);
|
|
return paragraphs.map((paragraph) => paragraph.map(blockFinalizer));
|
|
};
|
|
};
|
|
|
|
const ATTACHMENT_CODE = 0xfffc; // 65532
|
|
const isReplaceGlyph = (glyph) => glyph.codePoints.includes(ATTACHMENT_CODE);
|
|
/**
|
|
* Resolve attachments of run
|
|
*
|
|
* @param run
|
|
* @returns Run
|
|
*/
|
|
const resolveRunAttachments = (run) => {
|
|
if (!run.positions)
|
|
return run;
|
|
const glyphs = run.glyphs || [];
|
|
const attachment = run.attributes?.attachment;
|
|
if (!attachment)
|
|
return run;
|
|
const positions = run.positions.map((position, i) => {
|
|
const glyph = glyphs[i];
|
|
if (attachment.width && isReplaceGlyph(glyph)) {
|
|
return Object.assign({}, position, { xAdvance: attachment.width });
|
|
}
|
|
return Object.assign({}, position);
|
|
});
|
|
return Object.assign({}, run, { positions });
|
|
};
|
|
/**
|
|
* Resolve attachments for multiple paragraphs
|
|
*/
|
|
const resolveAttachments = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
const runs = attributedString.runs.map(resolveRunAttachments);
|
|
const res = Object.assign({}, attributedString, { runs });
|
|
return res;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* @param attributes - Attributes
|
|
* @returns Attributes with defaults
|
|
*/
|
|
const applyAttributes = (a) => {
|
|
return {
|
|
align: a.align || (a.direction === 'rtl' ? 'right' : 'left'),
|
|
alignLastLine: a.alignLastLine || (a.align === 'justify' ? 'left' : a.align || 'left'),
|
|
attachment: a.attachment || null,
|
|
backgroundColor: a.backgroundColor || null,
|
|
bullet: a.bullet || null,
|
|
characterSpacing: a.characterSpacing || 0,
|
|
color: a.color || 'black',
|
|
direction: a.direction || 'ltr',
|
|
features: a.features || [],
|
|
fill: a.fill !== false,
|
|
font: a.font || [],
|
|
fontSize: a.fontSize || 12,
|
|
hangingPunctuation: a.hangingPunctuation || false,
|
|
hyphenationFactor: a.hyphenationFactor || 0,
|
|
indent: a.indent || 0,
|
|
justificationFactor: a.justificationFactor || 1,
|
|
lineHeight: a.lineHeight || null,
|
|
lineSpacing: a.lineSpacing || 0,
|
|
link: a.link || null,
|
|
marginLeft: a.marginLeft || a.margin || 0,
|
|
marginRight: a.marginRight || a.margin || 0,
|
|
opacity: a.opacity,
|
|
paddingTop: a.paddingTop || a.padding || 0,
|
|
paragraphSpacing: a.paragraphSpacing || 0,
|
|
script: a.script || null,
|
|
shrinkFactor: a.shrinkFactor || 0,
|
|
strike: a.strike || false,
|
|
strikeColor: a.strikeColor || a.color || 'black',
|
|
strikeStyle: a.strikeStyle || 'solid',
|
|
stroke: a.stroke || false,
|
|
underline: a.underline || false,
|
|
underlineColor: a.underlineColor || a.color || 'black',
|
|
underlineStyle: a.underlineStyle || 'solid',
|
|
verticalAlign: a.verticalAlign || null,
|
|
wordSpacing: a.wordSpacing || 0,
|
|
yOffset: a.yOffset || 0,
|
|
};
|
|
};
|
|
/**
|
|
* Apply default style to run
|
|
*
|
|
* @param run - Run
|
|
* @returns Run with default styles
|
|
*/
|
|
const applyRunStyles = (run) => {
|
|
const attributes = applyAttributes(run.attributes);
|
|
return Object.assign({}, run, { attributes });
|
|
};
|
|
/**
|
|
* Apply default attributes for an attributed string
|
|
*/
|
|
const applyDefaultStyles = () => {
|
|
return (attributedString) => {
|
|
const string = attributedString.string || '';
|
|
const runs = (attributedString.runs || []).map(applyRunStyles);
|
|
return { string, runs };
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Apply scaling and yOffset for verticalAlign 'sub' and 'super'.
|
|
*/
|
|
const verticalAlignment = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
attributedString.runs.forEach((run) => {
|
|
const { attributes } = run;
|
|
const { verticalAlign } = attributes;
|
|
if (verticalAlign === 'sub') {
|
|
attributes.yOffset = -0.2;
|
|
}
|
|
else if (verticalAlign === 'super') {
|
|
attributes.yOffset = 0.4;
|
|
}
|
|
});
|
|
return attributedString;
|
|
};
|
|
};
|
|
|
|
const bidi$1 = bidiFactory();
|
|
/**
|
|
* @param runs
|
|
* @returns Bidi levels
|
|
*/
|
|
const getBidiLevels = (runs) => {
|
|
return runs.reduce((acc, run) => {
|
|
const length = run.end - run.start;
|
|
const levels = repeat(run.attributes.bidiLevel, length);
|
|
return acc.concat(levels);
|
|
}, []);
|
|
};
|
|
/**
|
|
* Perform bidi mirroring
|
|
*/
|
|
const mirrorString = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
const levels = getBidiLevels(attributedString.runs);
|
|
let updatedString = '';
|
|
attributedString.string.split('').forEach((char, index) => {
|
|
const isRTL = levels[index] % 2 === 1;
|
|
const mirroredChar = isRTL
|
|
? bidi$1.getMirroredCharacter(attributedString.string.charAt(index))
|
|
: null;
|
|
updatedString += mirroredChar || char;
|
|
});
|
|
const result = {
|
|
...attributedString,
|
|
string: updatedString,
|
|
};
|
|
return result;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A LayoutEngine is the main object that performs text layout.
|
|
* It accepts an AttributedString and a Container object
|
|
* to layout text into, and uses several helper objects to perform
|
|
* various layout tasks. These objects can be overridden to customize
|
|
* layout behavior.
|
|
*/
|
|
const layoutEngine = (engines) => {
|
|
return (attributedString, container, options = {}) => {
|
|
const processParagraph = compose(resolveYOffset(), resolveAttachments(), verticalAlignment(), wrapWords(engines, options), generateGlyphs(), mirrorString(), preprocessRuns(engines));
|
|
const processParagraphs = (paragraphs) => paragraphs.map(processParagraph);
|
|
return compose(finalizeFragments(engines, options), bidiReordering(), typesetter(engines, options, container), processParagraphs, splitParagraphs(), applyDefaultStyles())(attributedString);
|
|
};
|
|
};
|
|
|
|
const bidi = bidiFactory();
|
|
const bidiEngine = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
const { string } = attributedString;
|
|
const direction = attributedString.runs[0]?.attributes.direction;
|
|
const { levels } = bidi.getEmbeddingLevels(string, direction);
|
|
let lastLevel = null;
|
|
let lastIndex = 0;
|
|
let index = 0;
|
|
const runs = [];
|
|
for (let i = 0; i < levels.length; i += 1) {
|
|
const level = levels[i];
|
|
if (level !== lastLevel) {
|
|
if (lastLevel !== null) {
|
|
runs.push({
|
|
start: lastIndex,
|
|
end: index,
|
|
attributes: { bidiLevel: lastLevel },
|
|
});
|
|
}
|
|
lastIndex = index;
|
|
lastLevel = level;
|
|
}
|
|
index += 1;
|
|
}
|
|
if (lastIndex < string.length) {
|
|
runs.push({
|
|
start: lastIndex,
|
|
end: string.length,
|
|
attributes: { bidiLevel: lastLevel },
|
|
});
|
|
}
|
|
const result = { string, runs };
|
|
return result;
|
|
};
|
|
};
|
|
|
|
const INFINITY = 10000;
|
|
const getNextBreakpoint = (subnodes, widths, lineNumber) => {
|
|
let position = null;
|
|
let minimumBadness = Infinity;
|
|
const sum = { width: 0, stretch: 0, shrink: 0 };
|
|
const lineLength = widths[Math.min(lineNumber, widths.length - 1)];
|
|
const calculateRatio = (node) => {
|
|
const stretch = 'stretch' in node ? node.stretch : null;
|
|
if (sum.width < lineLength) {
|
|
if (!stretch)
|
|
return INFINITY;
|
|
return sum.stretch - stretch > 0
|
|
? (lineLength - sum.width) / sum.stretch
|
|
: INFINITY;
|
|
}
|
|
const shrink = 'shrink' in node ? node.shrink : null;
|
|
if (sum.width > lineLength) {
|
|
if (!shrink)
|
|
return INFINITY;
|
|
return sum.shrink - shrink > 0
|
|
? (lineLength - sum.width) / sum.shrink
|
|
: INFINITY;
|
|
}
|
|
return 0;
|
|
};
|
|
for (let i = 0; i < subnodes.length; i += 1) {
|
|
const node = subnodes[i];
|
|
if (node.type === 'box') {
|
|
sum.width += node.width;
|
|
}
|
|
if (node.type === 'glue') {
|
|
sum.width += node.width;
|
|
sum.stretch += node.stretch;
|
|
sum.shrink += node.shrink;
|
|
}
|
|
if (sum.width - sum.shrink > lineLength) {
|
|
if (position === null) {
|
|
let j = i === 0 ? i + 1 : i;
|
|
while (j < subnodes.length &&
|
|
(subnodes[j].type === 'glue' || subnodes[j].type === 'penalty')) {
|
|
j++;
|
|
}
|
|
position = j - 1;
|
|
}
|
|
break;
|
|
}
|
|
if (node.type === 'penalty' || node.type === 'glue') {
|
|
const ratio = calculateRatio(node);
|
|
const penalty = node.type === 'penalty' ? node.penalty : 0;
|
|
const badness = 100 * Math.abs(ratio) ** 3 + penalty;
|
|
if (minimumBadness >= badness) {
|
|
position = i;
|
|
minimumBadness = badness;
|
|
}
|
|
}
|
|
}
|
|
return sum.width - sum.shrink > lineLength ? position : null;
|
|
};
|
|
const applyBestFit = (nodes, widths) => {
|
|
let count = 0;
|
|
let lineNumber = 0;
|
|
let subnodes = nodes;
|
|
const breakpoints = [0];
|
|
while (subnodes.length > 0) {
|
|
const breakpoint = getNextBreakpoint(subnodes, widths, lineNumber);
|
|
if (breakpoint !== null) {
|
|
count += breakpoint;
|
|
breakpoints.push(count);
|
|
subnodes = subnodes.slice(breakpoint + 1, subnodes.length);
|
|
count++;
|
|
lineNumber++;
|
|
}
|
|
else {
|
|
subnodes = [];
|
|
}
|
|
}
|
|
return breakpoints;
|
|
};
|
|
|
|
/* eslint-disable max-classes-per-file */
|
|
class LinkedListNode {
|
|
data;
|
|
prev;
|
|
next;
|
|
constructor(data) {
|
|
this.data = data;
|
|
this.prev = null;
|
|
this.next = null;
|
|
}
|
|
}
|
|
class LinkedList {
|
|
static Node = LinkedListNode;
|
|
head;
|
|
tail;
|
|
listSize;
|
|
listLength;
|
|
constructor() {
|
|
this.head = null;
|
|
this.tail = null;
|
|
this.listSize = 0;
|
|
this.listLength = 0;
|
|
}
|
|
isLinked(node) {
|
|
return !((node &&
|
|
node.prev === null &&
|
|
node.next === null &&
|
|
this.tail !== node &&
|
|
this.head !== node) ||
|
|
this.isEmpty());
|
|
}
|
|
size() {
|
|
return this.listSize;
|
|
}
|
|
isEmpty() {
|
|
return this.listSize === 0;
|
|
}
|
|
first() {
|
|
return this.head;
|
|
}
|
|
last() {
|
|
return this.last;
|
|
}
|
|
forEach(callback) {
|
|
let node = this.head;
|
|
while (node !== null) {
|
|
callback(node);
|
|
node = node.next;
|
|
}
|
|
}
|
|
at(i) {
|
|
let node = this.head;
|
|
let index = 0;
|
|
if (i >= this.listLength || i < 0) {
|
|
return null;
|
|
}
|
|
while (node !== null) {
|
|
if (i === index) {
|
|
return node;
|
|
}
|
|
node = node.next;
|
|
index += 1;
|
|
}
|
|
return null;
|
|
}
|
|
insertAfter(node, newNode) {
|
|
if (!this.isLinked(node))
|
|
return this;
|
|
newNode.prev = node;
|
|
newNode.next = node.next;
|
|
if (node.next === null) {
|
|
this.tail = newNode;
|
|
}
|
|
else {
|
|
node.next.prev = newNode;
|
|
}
|
|
node.next = newNode;
|
|
this.listSize += 1;
|
|
return this;
|
|
}
|
|
insertBefore(node, newNode) {
|
|
if (!this.isLinked(node))
|
|
return this;
|
|
newNode.prev = node.prev;
|
|
newNode.next = node;
|
|
if (node.prev === null) {
|
|
this.head = newNode;
|
|
}
|
|
else {
|
|
node.prev.next = newNode;
|
|
}
|
|
node.prev = newNode;
|
|
this.listSize += 1;
|
|
return this;
|
|
}
|
|
push(node) {
|
|
if (this.head === null) {
|
|
this.unshift(node);
|
|
}
|
|
else {
|
|
this.insertAfter(this.tail, node);
|
|
}
|
|
return this;
|
|
}
|
|
unshift(node) {
|
|
if (this.head === null) {
|
|
this.head = node;
|
|
this.tail = node;
|
|
node.prev = null;
|
|
node.next = null;
|
|
this.listSize += 1;
|
|
}
|
|
else {
|
|
this.insertBefore(this.head, node);
|
|
}
|
|
return this;
|
|
}
|
|
remove(node) {
|
|
if (!this.isLinked(node))
|
|
return this;
|
|
if (node.prev === null) {
|
|
this.head = node.next;
|
|
}
|
|
else {
|
|
node.prev.next = node.next;
|
|
}
|
|
if (node.next === null) {
|
|
this.tail = node.prev;
|
|
}
|
|
else {
|
|
node.next.prev = node.prev;
|
|
}
|
|
this.listSize -= 1;
|
|
return this;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Licensed under the new BSD License.
|
|
* Copyright 2009-2010, Bram Stein
|
|
* All rights reserved.
|
|
*/
|
|
function breakpoint(position, demerits, line, fitnessClass, totals, previous) {
|
|
return {
|
|
position,
|
|
demerits,
|
|
line,
|
|
fitnessClass,
|
|
totals: totals || {
|
|
width: 0,
|
|
stretch: 0,
|
|
shrink: 0,
|
|
},
|
|
previous,
|
|
};
|
|
}
|
|
function computeCost(nodes, lineLengths, sum, end, active, currentLine) {
|
|
let width = sum.width - active.totals.width;
|
|
let stretch = 0;
|
|
let shrink = 0;
|
|
// If the current line index is within the list of linelengths, use it, otherwise use
|
|
// the last line length of the list.
|
|
const lineLength = currentLine < lineLengths.length
|
|
? lineLengths[currentLine - 1]
|
|
: lineLengths[lineLengths.length - 1];
|
|
if (nodes[end].type === 'penalty') {
|
|
width += nodes[end].width;
|
|
}
|
|
// Calculate the stretch ratio
|
|
if (width < lineLength) {
|
|
stretch = sum.stretch - active.totals.stretch;
|
|
if (stretch > 0) {
|
|
return (lineLength - width) / stretch;
|
|
}
|
|
return linebreak.infinity;
|
|
}
|
|
// Calculate the shrink ratio
|
|
if (width > lineLength) {
|
|
shrink = sum.shrink - active.totals.shrink;
|
|
if (shrink > 0) {
|
|
return (lineLength - width) / shrink;
|
|
}
|
|
return linebreak.infinity;
|
|
}
|
|
// perfect match
|
|
return 0;
|
|
}
|
|
// Add width, stretch and shrink values from the current
|
|
// break point up to the next box or forced penalty.
|
|
function computeSum(nodes, sum, breakPointIndex) {
|
|
const result = {
|
|
width: sum.width,
|
|
stretch: sum.stretch,
|
|
shrink: sum.shrink,
|
|
};
|
|
for (let i = breakPointIndex; i < nodes.length; i += 1) {
|
|
const node = nodes[i];
|
|
if (node.type === 'glue') {
|
|
result.width += node.width;
|
|
result.stretch += node.stretch;
|
|
result.shrink += node.shrink;
|
|
}
|
|
else if (node.type === 'box' ||
|
|
(node.type === 'penalty' &&
|
|
node.penalty === -linebreak.infinity &&
|
|
i > breakPointIndex)) {
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
function findBestBreakpoints(activeNodes) {
|
|
const breakpoints = [];
|
|
if (activeNodes.size() === 0)
|
|
return [];
|
|
let tmp = { data: { demerits: Infinity } };
|
|
// Find the best active node (the one with the least total demerits.)
|
|
activeNodes.forEach((node) => {
|
|
if (node.data.demerits < tmp.data.demerits) {
|
|
tmp = node;
|
|
}
|
|
});
|
|
while (tmp !== null) {
|
|
breakpoints.push(tmp.data.position);
|
|
tmp = tmp.data.previous;
|
|
}
|
|
return breakpoints.reverse();
|
|
}
|
|
/**
|
|
* @param nodes
|
|
* @param availableWidths
|
|
* @param tolerance
|
|
* @preserve Knuth and Plass line breaking algorithm in JavaScript
|
|
*/
|
|
const linebreak = (nodes, availableWidths, tolerance) => {
|
|
// Demerits are used as a way to penalize bad line breaks
|
|
// - line: applied to each line, depending on how much spaces need to stretch or shrink
|
|
// - flagged: applied when consecutive lines end in hyphenation
|
|
// - fitness: algorithm groups lines into fitness classes based on how loose or tight the spacing is.
|
|
// if a paragraph has consecutive lines from different fitness classes,
|
|
// a fitness demerit is applied to maintain visual consistency.
|
|
const options = {
|
|
demerits: { line: 10, flagged: 100, fitness: 3000 },
|
|
tolerance: tolerance || 3,
|
|
};
|
|
const activeNodes = new LinkedList();
|
|
const sum = { width: 0, stretch: 0, shrink: 0 };
|
|
const lineLengths = availableWidths;
|
|
// Add an active node for the start of the paragraph.
|
|
activeNodes.push(new LinkedList.Node(breakpoint(0, 0, 0, 0, undefined, null)));
|
|
// The main loop of the algorithm
|
|
function mainLoop(node, index, nodes) {
|
|
let active = activeNodes.first();
|
|
// The inner loop iterates through all the active nodes with line < currentLine and then
|
|
// breaks out to insert the new active node candidates before looking at the next active
|
|
// nodes for the next lines. The result of this is that the active node list is always
|
|
// sorted by line number.
|
|
while (active !== null) {
|
|
let currentLine = 0;
|
|
// Candidates fo each fitness class
|
|
const candidates = [
|
|
{ active: undefined, demerits: Infinity },
|
|
{ active: undefined, demerits: Infinity },
|
|
{ active: undefined, demerits: Infinity },
|
|
{ active: undefined, demerits: Infinity },
|
|
];
|
|
// Iterate through the linked list of active nodes to find new potential active nodes and deactivate current active nodes.
|
|
while (active !== null) {
|
|
currentLine = active.data.line + 1;
|
|
const ratio = computeCost(nodes, lineLengths, sum, index, active.data, currentLine);
|
|
// Deactive nodes when the distance between the current active node and the
|
|
// current node becomes too large (i.e. it exceeds the stretch limit and the stretch
|
|
// ratio becomes negative) or when the current node is a forced break (i.e. the end
|
|
// of the paragraph when we want to remove all active nodes, but possibly have a final
|
|
// candidate active node---if the paragraph can be set using the given tolerance value.)
|
|
if (ratio < -1 ||
|
|
(node.type === 'penalty' && node.penalty === -linebreak.infinity)) {
|
|
activeNodes.remove(active);
|
|
}
|
|
// If the ratio is within the valid range of -1 <= ratio <= tolerance calculate the
|
|
// total demerits and record a candidate active node.
|
|
if (ratio >= -1 && ratio <= options.tolerance) {
|
|
const badness = 100 * Math.pow(Math.abs(ratio), 3);
|
|
let demerits = 0;
|
|
// Positive penalty
|
|
if (node.type === 'penalty' && node.penalty >= 0) {
|
|
demerits =
|
|
Math.pow(options.demerits.line + badness, 2) +
|
|
Math.pow(node.penalty, 2);
|
|
// Negative penalty but not a forced break
|
|
}
|
|
else if (node.type === 'penalty' &&
|
|
node.penalty !== -linebreak.infinity) {
|
|
demerits =
|
|
Math.pow(options.demerits.line + badness, 2) -
|
|
Math.pow(node.penalty, 2);
|
|
// All other cases
|
|
}
|
|
else {
|
|
demerits = Math.pow(options.demerits.line + badness, 2);
|
|
}
|
|
if (node.type === 'penalty' &&
|
|
nodes[active.data.position].type === 'penalty') {
|
|
demerits +=
|
|
options.demerits.flagged *
|
|
node.flagged *
|
|
// @ts-expect-error node is penalty here
|
|
nodes[active.data.position].flagged;
|
|
}
|
|
// Calculate the fitness class for this candidate active node.
|
|
let currentClass;
|
|
if (ratio < -0.5) {
|
|
currentClass = 0;
|
|
}
|
|
else if (ratio <= 0.5) {
|
|
currentClass = 1;
|
|
}
|
|
else if (ratio <= 1) {
|
|
currentClass = 2;
|
|
}
|
|
else {
|
|
currentClass = 3;
|
|
}
|
|
// Add a fitness penalty to the demerits if the fitness classes of two adjacent lines differ too much.
|
|
if (Math.abs(currentClass - active.data.fitnessClass) > 1) {
|
|
demerits += options.demerits.fitness;
|
|
}
|
|
// Add the total demerits of the active node to get the total demerits of this candidate node.
|
|
demerits += active.data.demerits;
|
|
// Only store the best candidate for each fitness class
|
|
if (demerits < candidates[currentClass].demerits) {
|
|
candidates[currentClass] = { active, demerits };
|
|
}
|
|
}
|
|
active = active.next;
|
|
// Stop iterating through active nodes to insert new candidate active nodes in the active list
|
|
// before moving on to the active nodes for the next line.
|
|
// TODO: The Knuth and Plass paper suggests a conditional for currentLine < j0. This means paragraphs
|
|
// with identical line lengths will not be sorted by line number. Find out if that is a desirable outcome.
|
|
// For now I left this out, as it only adds minimal overhead to the algorithm and keeping the active node
|
|
// list sorted has a higher priority.
|
|
if (active !== null && active.data.line >= currentLine) {
|
|
break;
|
|
}
|
|
}
|
|
const tmpSum = computeSum(nodes, sum, index);
|
|
for (let fitnessClass = 0; fitnessClass < candidates.length; fitnessClass += 1) {
|
|
const candidate = candidates[fitnessClass];
|
|
if (candidate.demerits === Infinity)
|
|
continue;
|
|
const newNode = new LinkedList.Node(breakpoint(index, candidate.demerits, candidate.active.data.line + 1, fitnessClass, tmpSum, candidate.active));
|
|
if (active !== null) {
|
|
activeNodes.insertBefore(active, newNode);
|
|
}
|
|
else {
|
|
activeNodes.push(newNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
nodes.forEach((node, index, nodes) => {
|
|
if (node.type === 'box') {
|
|
sum.width += node.width;
|
|
return;
|
|
}
|
|
if (node.type === 'glue') {
|
|
const precedesBox = index > 0 && nodes[index - 1].type === 'box';
|
|
if (precedesBox)
|
|
mainLoop(node, index, nodes);
|
|
sum.width += node.width;
|
|
sum.stretch += node.stretch;
|
|
sum.shrink += node.shrink;
|
|
return;
|
|
}
|
|
if (node.type === 'penalty' && node.penalty !== linebreak.infinity) {
|
|
mainLoop(node, index, nodes);
|
|
}
|
|
});
|
|
return findBestBreakpoints(activeNodes);
|
|
};
|
|
linebreak.infinity = 10000;
|
|
linebreak.glue = (width, start, end, stretch, shrink) => ({
|
|
type: 'glue',
|
|
start,
|
|
end,
|
|
width,
|
|
stretch,
|
|
shrink,
|
|
});
|
|
linebreak.box = (width, start, end, hyphenated = false) => ({
|
|
type: 'box',
|
|
width,
|
|
start,
|
|
end,
|
|
hyphenated,
|
|
});
|
|
linebreak.penalty = (width, penalty, flagged) => ({
|
|
type: 'penalty',
|
|
width,
|
|
penalty,
|
|
flagged,
|
|
});
|
|
|
|
/**
|
|
* Add scalar to run
|
|
*
|
|
* @param index - Scalar
|
|
* @param run - Run
|
|
* @returns Added run
|
|
*/
|
|
const add = (index, run) => {
|
|
const start = run.start + index;
|
|
const end = run.end + index;
|
|
return Object.assign({}, run, { start, end });
|
|
};
|
|
|
|
/**
|
|
* Get run length
|
|
*
|
|
* @param run - Run
|
|
* @returns Length
|
|
*/
|
|
const length = (run) => {
|
|
return run.end - run.start;
|
|
};
|
|
|
|
/**
|
|
* Concats two runs into one
|
|
*
|
|
* @param runA - First run
|
|
* @param runB - Second run
|
|
* @returns Concatenated run
|
|
*/
|
|
const concat = (runA, runB) => {
|
|
const end = runA.end + length(runB);
|
|
const glyphs = (runA.glyphs || []).concat(runB.glyphs || []);
|
|
const positions = (runA.positions || []).concat(runB.positions || []);
|
|
const attributes = Object.assign({}, runA.attributes, runB.attributes);
|
|
const runAIndices = runA.glyphIndices || [];
|
|
const runALastIndex = last(runAIndices) || 0;
|
|
const runBIndices = (runB.glyphIndices || []).map((i) => i + runALastIndex + 1);
|
|
const glyphIndices = normalize(runAIndices.concat(runBIndices));
|
|
return Object.assign({}, runA, {
|
|
end,
|
|
glyphs,
|
|
positions,
|
|
attributes,
|
|
glyphIndices,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Insert glyph to run in the given index
|
|
*
|
|
* @param index - Index
|
|
* @param glyph - Glyph
|
|
* @param run - Run
|
|
* @returns Run with glyph
|
|
*/
|
|
const insertGlyph$1 = (index, glyph, run) => {
|
|
if (!glyph)
|
|
return run;
|
|
// Split resolves ligature splitting in case new glyph breaks some
|
|
const leadingRun = slice$1(0, index, run);
|
|
const trailingRun = slice$1(index, Infinity, run);
|
|
return concat(append$1(glyph, leadingRun), trailingRun);
|
|
};
|
|
/**
|
|
* Insert either glyph or code point to run in the given index
|
|
*
|
|
* @param index - Index
|
|
* @param value - Glyph or codePoint
|
|
* @param run - Run
|
|
* @returns Run with glyph
|
|
*/
|
|
const insert = (index, value, run) => {
|
|
const font = getFont(run);
|
|
const glyph = isNumber(value) ? fromCodePoint(value, font) : value;
|
|
return insertGlyph$1(index, glyph, run);
|
|
};
|
|
|
|
/**
|
|
* Get run index at char index
|
|
*
|
|
* @param index - Char index
|
|
* @param attributedString - Attributed string
|
|
* @returns Run index
|
|
*/
|
|
const runIndexAt = (index, attributedString) => {
|
|
return runIndexAt$1(index, attributedString.runs);
|
|
};
|
|
|
|
/**
|
|
* Insert glyph into attributed string
|
|
*
|
|
* @param index - Index
|
|
* @param glyph - Glyph or code point
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string with new glyph
|
|
*/
|
|
const insertGlyph = (index, glyph, attributedString) => {
|
|
const runIndex = runIndexAt(index, attributedString);
|
|
// Add glyph to the end if run index invalid
|
|
if (runIndex === -1)
|
|
return append(glyph, attributedString);
|
|
const codePoints = [glyph] ;
|
|
const string = attributedString.string.slice(0, index) +
|
|
stringFromCodePoints(codePoints) +
|
|
attributedString.string.slice(index);
|
|
const runs = attributedString.runs.map((run, i) => {
|
|
if (i === runIndex)
|
|
return insert(index - run.start, glyph, run);
|
|
if (i > runIndex)
|
|
return add(codePoints.length, run);
|
|
return run;
|
|
});
|
|
return Object.assign({}, attributedString, { string, runs });
|
|
};
|
|
|
|
/**
|
|
* Advance width between two string indices
|
|
*
|
|
* @param start - Glyph index
|
|
* @param end - Glyph index
|
|
* @param run - Run
|
|
* @returns Advanced width run
|
|
*/
|
|
const advanceWidthBetween$1 = (start, end, run) => {
|
|
const runStart = run.start || 0;
|
|
const glyphStartIndex = Math.max(0, glyphIndexAt(start - runStart, run));
|
|
const glyphEndIndex = Math.max(0, glyphIndexAt(end - runStart, run));
|
|
const positions = (run.positions || []).slice(glyphStartIndex, glyphEndIndex);
|
|
return advanceWidth$2(positions);
|
|
};
|
|
|
|
/**
|
|
* Advance width between start and end
|
|
* Does not consider ligature splitting for the moment.
|
|
* Check performance impact on supporting this
|
|
*
|
|
* @param start - Start offset
|
|
* @param end - End offset
|
|
* @param attributedString
|
|
* @returns Advance width
|
|
*/
|
|
const advanceWidthBetween = (start, end, attributedString) => {
|
|
const runs = filter(start, end, attributedString.runs);
|
|
return runs.reduce((acc, run) => acc + advanceWidthBetween$1(start, end, run), 0);
|
|
};
|
|
|
|
const HYPHEN = 0x002d;
|
|
const TOLERANCE_STEPS = 5;
|
|
const TOLERANCE_LIMIT = 50;
|
|
const opts = {
|
|
width: 3,
|
|
stretch: 6,
|
|
shrink: 9,
|
|
};
|
|
/**
|
|
* Slice attributed string to many lines
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @param nodes
|
|
* @param breaks
|
|
* @returns Attributed strings
|
|
*/
|
|
const breakLines = (attributedString, nodes, breaks) => {
|
|
let start = 0;
|
|
let end = null;
|
|
const lines = breaks.reduce((acc, breakPoint) => {
|
|
const node = nodes[breakPoint];
|
|
const prevNode = nodes[breakPoint - 1];
|
|
// Last breakpoint corresponds to K&P mandatory final glue
|
|
if (breakPoint === nodes.length - 1)
|
|
return acc;
|
|
let line;
|
|
if (node.type === 'penalty') {
|
|
// @ts-expect-error penalty node will always preceed box or glue node
|
|
end = prevNode.end;
|
|
line = slice(start, end, attributedString);
|
|
line = insertGlyph(line.string.length, HYPHEN, line);
|
|
}
|
|
else {
|
|
end = node.end;
|
|
line = slice(start, end, attributedString);
|
|
}
|
|
start = end;
|
|
return [...acc, line];
|
|
}, []);
|
|
// Last line
|
|
lines.push(slice(start, attributedString.string.length, attributedString));
|
|
return lines;
|
|
};
|
|
/**
|
|
* Return Knuth & Plass nodes based on line and previously calculated syllables
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @param attributes - Attributes
|
|
* @param options - Layout options
|
|
* @returns ?
|
|
*/
|
|
const getNodes = (attributedString, { align }, options) => {
|
|
let start = 0;
|
|
const hyphenWidth = 5;
|
|
const { syllables } = attributedString;
|
|
const hyphenPenalty = options.hyphenationPenalty || (align === 'justify' ? 100 : 600);
|
|
const result = syllables.reduce((acc, s, index) => {
|
|
const width = advanceWidthBetween(start, start + s.length, attributedString);
|
|
if (s.trim() === '') {
|
|
const stretch = (width * opts.width) / opts.stretch;
|
|
const shrink = (width * opts.width) / opts.shrink;
|
|
const end = start + s.length;
|
|
// Add glue node. Glue nodes are used to fill the space between words.
|
|
acc.push(linebreak.glue(width, start, end, stretch, shrink));
|
|
}
|
|
else {
|
|
const hyphenated = syllables[index + 1] !== ' ';
|
|
const end = start + s.length;
|
|
// Add box node. Box nodes are used to represent words.
|
|
acc.push(linebreak.box(width, start, end, hyphenated));
|
|
if (syllables[index + 1] && hyphenated) {
|
|
// Add penalty node. Penalty nodes are used to represent hyphenation points.
|
|
acc.push(linebreak.penalty(hyphenWidth, hyphenPenalty, 1));
|
|
}
|
|
}
|
|
start += s.length;
|
|
return acc;
|
|
}, []);
|
|
// Add mandatory final glue
|
|
result.push(linebreak.glue(0, start, start, linebreak.infinity, 0));
|
|
result.push(linebreak.penalty(0, -linebreak.infinity, 1));
|
|
return result;
|
|
};
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributes
|
|
*/
|
|
const getAttributes = (attributedString) => {
|
|
return attributedString.runs?.[0]?.attributes || {};
|
|
};
|
|
/**
|
|
* Performs Knuth & Plass line breaking algorithm
|
|
* Fallbacks to best fit algorithm if latter not successful
|
|
*
|
|
* @param options - Layout options
|
|
*/
|
|
const linebreaker = (options) => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @param availableWidths - Available widths
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString, availableWidths) => {
|
|
let tolerance = options.tolerance || 4;
|
|
const attributes = getAttributes(attributedString);
|
|
const nodes = getNodes(attributedString, attributes, options);
|
|
let breaks = linebreak(nodes, availableWidths, tolerance);
|
|
// Try again with a higher tolerance if the line breaking failed.
|
|
while (breaks.length === 0 && tolerance < TOLERANCE_LIMIT) {
|
|
tolerance += TOLERANCE_STEPS;
|
|
breaks = linebreak(nodes, availableWidths, tolerance);
|
|
}
|
|
if (breaks.length === 0 || (breaks.length === 1 && breaks[0] === 0)) {
|
|
breaks = applyBestFit(nodes, availableWidths);
|
|
}
|
|
return breakLines(attributedString, nodes, breaks.slice(1));
|
|
};
|
|
};
|
|
|
|
var Direction;
|
|
(function (Direction) {
|
|
Direction[Direction["GROW"] = 0] = "GROW";
|
|
Direction[Direction["SHRINK"] = 1] = "SHRINK";
|
|
})(Direction || (Direction = {}));
|
|
const WHITESPACE_PRIORITY = 1;
|
|
const LETTER_PRIORITY = 2;
|
|
const EXPAND_WHITESPACE_FACTOR = {
|
|
before: 0.5,
|
|
after: 0.5,
|
|
priority: WHITESPACE_PRIORITY,
|
|
unconstrained: false,
|
|
};
|
|
const EXPAND_CHAR_FACTOR = {
|
|
before: 0.14453125, // 37/256
|
|
after: 0.14453125,
|
|
priority: LETTER_PRIORITY,
|
|
unconstrained: false,
|
|
};
|
|
const SHRINK_WHITESPACE_FACTOR = {
|
|
before: -0.04296875, // -11/256
|
|
after: -0.04296875,
|
|
priority: WHITESPACE_PRIORITY,
|
|
unconstrained: false,
|
|
};
|
|
const SHRINK_CHAR_FACTOR = {
|
|
before: -0.04296875,
|
|
after: -0.04296875,
|
|
priority: LETTER_PRIORITY,
|
|
unconstrained: false,
|
|
};
|
|
const getCharFactor = (direction, options) => {
|
|
const expandCharFactor = options.expandCharFactor || {};
|
|
const shrinkCharFactor = options.shrinkCharFactor || {};
|
|
return direction === Direction.GROW
|
|
? Object.assign({}, EXPAND_CHAR_FACTOR, expandCharFactor)
|
|
: Object.assign({}, SHRINK_CHAR_FACTOR, shrinkCharFactor);
|
|
};
|
|
const getWhitespaceFactor = (direction, options) => {
|
|
const expandWhitespaceFactor = options.expandWhitespaceFactor || {};
|
|
const shrinkWhitespaceFactor = options.shrinkWhitespaceFactor || {};
|
|
return direction === Direction.GROW
|
|
? Object.assign({}, EXPAND_WHITESPACE_FACTOR, expandWhitespaceFactor)
|
|
: Object.assign({}, SHRINK_WHITESPACE_FACTOR, shrinkWhitespaceFactor);
|
|
};
|
|
const factor = (direction, options) => (glyphs) => {
|
|
const charFactor = getCharFactor(direction, options);
|
|
const whitespaceFactor = getWhitespaceFactor(direction, options);
|
|
const factors = [];
|
|
for (let index = 0; index < glyphs.length; index += 1) {
|
|
let f;
|
|
const glyph = glyphs[index];
|
|
if (isWhiteSpace(glyph)) {
|
|
f = Object.assign({}, whitespaceFactor);
|
|
if (index === glyphs.length - 1) {
|
|
f.before = 0;
|
|
if (index > 0) {
|
|
factors[index - 1].after = 0;
|
|
}
|
|
}
|
|
}
|
|
else if (glyph.isMark && index > 0) {
|
|
f = Object.assign({}, factors[index - 1]);
|
|
f.before = 0;
|
|
factors[index - 1].after = 0;
|
|
}
|
|
else {
|
|
f = Object.assign({}, charFactor);
|
|
}
|
|
factors.push(f);
|
|
}
|
|
return factors;
|
|
};
|
|
const getFactors = (gap, line, options) => {
|
|
const direction = gap > 0 ? Direction.GROW : Direction.SHRINK;
|
|
const getFactor = factor(direction, options);
|
|
const factors = line.runs.reduce((acc, run) => {
|
|
return acc.concat(getFactor(run.glyphs));
|
|
}, []);
|
|
factors[0].before = 0;
|
|
factors[factors.length - 1].after = 0;
|
|
return factors;
|
|
};
|
|
|
|
const KASHIDA_PRIORITY = 0;
|
|
const NULL_PRIORITY = 3;
|
|
const getDistances = (gap, factors) => {
|
|
let total = 0;
|
|
const priorities = [];
|
|
const unconstrained = [];
|
|
for (let priority = KASHIDA_PRIORITY; priority <= NULL_PRIORITY; priority += 1) {
|
|
priorities[priority] = unconstrained[priority] = 0;
|
|
}
|
|
// sum the factors at each priority
|
|
for (let j = 0; j < factors.length; j += 1) {
|
|
const f = factors[j];
|
|
const sum = f.before + f.after;
|
|
total += sum;
|
|
priorities[f.priority] += sum;
|
|
if (f.unconstrained) {
|
|
unconstrained[f.priority] += sum;
|
|
}
|
|
}
|
|
// choose the priorities that need to be applied
|
|
let highestPriority = -1;
|
|
let highestPrioritySum = 0;
|
|
let remainingGap = gap;
|
|
let priority;
|
|
for (priority = KASHIDA_PRIORITY; priority <= NULL_PRIORITY; priority += 1) {
|
|
const prioritySum = priorities[priority];
|
|
if (prioritySum !== 0) {
|
|
if (highestPriority === -1) {
|
|
highestPriority = priority;
|
|
highestPrioritySum = prioritySum;
|
|
}
|
|
// if this priority covers the remaining gap, we're done
|
|
if (Math.abs(remainingGap) <= Math.abs(prioritySum)) {
|
|
priorities[priority] = remainingGap / prioritySum;
|
|
unconstrained[priority] = 0;
|
|
remainingGap = 0;
|
|
break;
|
|
}
|
|
// mark that we need to use 100% of the adjustment from
|
|
// this priority, and subtract the space that it consumes
|
|
priorities[priority] = 1;
|
|
remainingGap -= prioritySum;
|
|
// if this priority has unconstrained glyphs, let them consume the remaining space
|
|
if (unconstrained[priority] !== 0) {
|
|
unconstrained[priority] = remainingGap / unconstrained[priority];
|
|
remainingGap = 0;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// zero out remaining priorities (if any)
|
|
for (let p = priority + 1; p <= NULL_PRIORITY; p += 1) {
|
|
priorities[p] = 0;
|
|
unconstrained[p] = 0;
|
|
}
|
|
// if there is still space left over, assign it to the highest priority that we saw.
|
|
// this violates their factors, but it only happens in extreme cases
|
|
if (remainingGap > 0 && highestPriority > -1) {
|
|
priorities[highestPriority] =
|
|
(highestPrioritySum + (gap - total)) / highestPrioritySum;
|
|
}
|
|
// create and return an array of distances to add to each glyph's advance
|
|
const distances = [];
|
|
for (let index = 0; index < factors.length; index += 1) {
|
|
// the distance to add to this glyph is the sum of the space to add
|
|
// after this glyph, and the space to add before the next glyph
|
|
const f = factors[index];
|
|
const next = factors[index + 1];
|
|
let dist = f.after * priorities[f.priority];
|
|
if (next) {
|
|
dist += next.before * priorities[next.priority];
|
|
}
|
|
// if this glyph is unconstrained, add the unconstrained distance as well
|
|
if (f.unconstrained) {
|
|
dist += f.after * unconstrained[f.priority];
|
|
if (next) {
|
|
dist += next.before * unconstrained[next.priority];
|
|
}
|
|
}
|
|
distances.push(dist);
|
|
}
|
|
return distances;
|
|
};
|
|
|
|
/**
|
|
* Adjust run positions by given distances
|
|
*
|
|
* @param distances
|
|
* @param line
|
|
* @returns Line
|
|
*/
|
|
const justifyLine = (distances, line) => {
|
|
let index = 0;
|
|
for (const run of line.runs) {
|
|
for (const position of run.positions) {
|
|
position.xAdvance += distances[index++];
|
|
}
|
|
}
|
|
return line;
|
|
};
|
|
/**
|
|
* A JustificationEngine is used by a Typesetter to perform line fragment
|
|
* justification. This implementation is based on a description of Apple's
|
|
* justification algorithm from a PDF in the Apple Font Tools package.
|
|
*
|
|
* @param options - Layout options
|
|
*/
|
|
const justification = (options) => {
|
|
/**
|
|
* @param line
|
|
* @returns Line
|
|
*/
|
|
return (line) => {
|
|
const gap = line.box.width - advanceWidth(line);
|
|
if (gap === 0)
|
|
return line; // Exact fit
|
|
const factors = getFactors(gap, line, options);
|
|
const distances = getDistances(gap, factors);
|
|
return justifyLine(distances, line);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Returns attributed string ascent
|
|
*
|
|
* @param attributedString - Attributed string
|
|
* @returns Ascent
|
|
*/
|
|
const ascent = (attributedString) => {
|
|
const reducer = (acc, run) => Math.max(acc, ascent$1(run));
|
|
return attributedString.runs.reduce(reducer, 0);
|
|
};
|
|
|
|
// The base font size used for calculating underline thickness.
|
|
const BASE_FONT_SIZE = 12;
|
|
/**
|
|
* A TextDecorationEngine is used by a Typesetter to generate
|
|
* DecorationLines for a line fragment, including underlines
|
|
* and strikes.
|
|
*/
|
|
const textDecoration = () => (line) => {
|
|
let x = line.overflowLeft || 0;
|
|
const overflowRight = line.overflowRight || 0;
|
|
const maxX = advanceWidth(line) - overflowRight;
|
|
line.decorationLines = [];
|
|
for (let i = 0; i < line.runs.length; i += 1) {
|
|
const run = line.runs[i];
|
|
const width = Math.min(maxX - x, advanceWidth$1(run));
|
|
const thickness = Math.max(0.5, Math.floor(run.attributes.fontSize / BASE_FONT_SIZE));
|
|
if (run.attributes.underline) {
|
|
const rect = {
|
|
x,
|
|
y: ascent(line) + thickness * 2,
|
|
width,
|
|
height: thickness,
|
|
};
|
|
const decorationLine = {
|
|
rect,
|
|
opacity: run.attributes.opacity,
|
|
color: run.attributes.underlineColor || 'black',
|
|
style: run.attributes.underlineStyle || 'solid',
|
|
};
|
|
line.decorationLines.push(decorationLine);
|
|
}
|
|
if (run.attributes.strike) {
|
|
const y = ascent(line) - ascent$1(run) / 3;
|
|
const rect = { x, y, width, height: thickness };
|
|
const decorationLine = {
|
|
rect,
|
|
opacity: run.attributes.opacity,
|
|
color: run.attributes.strikeColor || 'black',
|
|
style: run.attributes.strikeStyle || 'solid',
|
|
};
|
|
line.decorationLines.push(decorationLine);
|
|
}
|
|
x += width;
|
|
}
|
|
return line;
|
|
};
|
|
|
|
const ignoredScripts = ['Common', 'Inherited', 'Unknown'];
|
|
/**
|
|
* Resolves unicode script in runs, grouping equal runs together
|
|
*/
|
|
const scriptItemizer = () => {
|
|
/**
|
|
* @param attributedString - Attributed string
|
|
* @returns Attributed string
|
|
*/
|
|
return (attributedString) => {
|
|
const { string } = attributedString;
|
|
let lastScript = 'Unknown';
|
|
let lastIndex = 0;
|
|
let index = 0;
|
|
const runs = [];
|
|
if (!string)
|
|
return empty();
|
|
for (let i = 0; i < string.length; i += 1) {
|
|
const char = string[i];
|
|
const codePoint = char.codePointAt(0);
|
|
const script = unicode.getScript(codePoint);
|
|
if (script !== lastScript && !ignoredScripts.includes(script)) {
|
|
if (lastScript !== 'Unknown') {
|
|
runs.push({
|
|
start: lastIndex,
|
|
end: index,
|
|
attributes: { script: lastScript },
|
|
});
|
|
}
|
|
lastIndex = index;
|
|
lastScript = script;
|
|
}
|
|
index += char.length;
|
|
}
|
|
if (lastIndex < string.length) {
|
|
runs.push({
|
|
start: lastIndex,
|
|
end: string.length,
|
|
attributes: { script: lastScript },
|
|
});
|
|
}
|
|
const result = { string, runs: runs };
|
|
return result;
|
|
};
|
|
};
|
|
|
|
const SOFT_HYPHEN = '\u00ad';
|
|
const hyphenator = hyphen(pattern);
|
|
/**
|
|
* @param word
|
|
* @returns Word parts
|
|
*/
|
|
const splitHyphen = (word) => {
|
|
return word.split(SOFT_HYPHEN);
|
|
};
|
|
const cache = {};
|
|
/**
|
|
* @param word
|
|
* @returns Word parts
|
|
*/
|
|
const getParts = (word) => {
|
|
const base = word.includes(SOFT_HYPHEN) ? word : hyphenator(word);
|
|
return splitHyphen(base);
|
|
};
|
|
const wordHyphenation = () => {
|
|
/**
|
|
* @param word - Word
|
|
* @returns Word parts
|
|
*/
|
|
return (word) => {
|
|
const cacheKey = `_${word}`;
|
|
if (isNil(word))
|
|
return [];
|
|
if (cache[cacheKey])
|
|
return cache[cacheKey];
|
|
cache[cacheKey] = getParts(word);
|
|
return cache[cacheKey];
|
|
};
|
|
};
|
|
|
|
const IGNORED_CODE_POINTS = [173];
|
|
const getFontSize = (run) => run.attributes.fontSize || 12;
|
|
const pickFontFromFontStack = (codePoint, fontStack, lastFont) => {
|
|
const fontStackWithFallback = [...fontStack, lastFont];
|
|
for (let i = 0; i < fontStackWithFallback.length; i += 1) {
|
|
const font = fontStackWithFallback[i];
|
|
if (!IGNORED_CODE_POINTS.includes(codePoint) &&
|
|
font &&
|
|
font.hasGlyphForCodePoint &&
|
|
font.hasGlyphForCodePoint(codePoint)) {
|
|
return font;
|
|
}
|
|
}
|
|
return fontStack.at(-1);
|
|
};
|
|
const fontSubstitution = () => ({ string, runs }) => {
|
|
let lastFont = null;
|
|
let lastFontSize = null;
|
|
let lastIndex = 0;
|
|
let index = 0;
|
|
const res = [];
|
|
for (let i = 0; i < runs.length; i += 1) {
|
|
const run = runs[i];
|
|
if (string.length === 0) {
|
|
res.push({
|
|
start: 0,
|
|
end: 0,
|
|
attributes: { font: run.attributes.font },
|
|
});
|
|
break;
|
|
}
|
|
const chars = string.slice(run.start, run.end);
|
|
for (let j = 0; j < chars.length; j += 1) {
|
|
const char = chars[j];
|
|
const codePoint = char.codePointAt(0);
|
|
// If the default font does not have a glyph and the fallback font does, we use it
|
|
const font = pickFontFromFontStack(codePoint, run.attributes.font, lastFont);
|
|
const fontSize = getFontSize(run);
|
|
// If anything that would impact res has changed, update it
|
|
if (font !== lastFont ||
|
|
fontSize !== lastFontSize ||
|
|
font.unitsPerEm !== lastFont.unitsPerEm) {
|
|
if (lastFont) {
|
|
res.push({
|
|
start: lastIndex,
|
|
end: index,
|
|
attributes: {
|
|
font: [lastFont],
|
|
scale: lastFontSize / lastFont.unitsPerEm,
|
|
},
|
|
});
|
|
}
|
|
lastFont = font;
|
|
lastFontSize = fontSize;
|
|
lastIndex = index;
|
|
}
|
|
index += char.length;
|
|
}
|
|
}
|
|
if (lastIndex < string.length) {
|
|
const fontSize = getFontSize(last(runs));
|
|
res.push({
|
|
start: lastIndex,
|
|
end: string.length,
|
|
attributes: {
|
|
font: [lastFont],
|
|
scale: fontSize / lastFont.unitsPerEm,
|
|
},
|
|
});
|
|
}
|
|
return { string, runs: res };
|
|
};
|
|
|
|
export { bidiEngine as bidi, layoutEngine as default, fontSubstitution, fromFragments, justification, linebreaker, scriptItemizer, textDecoration, wordHyphenation };
|