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 };