UNPKG

dropflow

Version:

A small CSS2 document renderer built from specifications

1,436 lines 91.5 kB
import { binarySearchTuple, basename, loggableText, Logger } from './util.js'; import { Box, RenderItem } from './layout-box.js'; import { IfcVacancy, createInlineIterator, createPreorderInlineIterator, layoutFloatBox } from './layout-flow.js'; import LineBreak, { HardBreaker } from './text-line-break.js'; import { nextGraphemeBreak, previousGraphemeBreak } from './text-grapheme-break.js'; import * as hb from './text-harfbuzz.js'; import { getLangCascade } from './text-font.js'; import { nameToTag } from '../gen/script-names.js'; import { createItemizeState, itemizeNext } from './text-itemize.js'; const lineFeedCharacter = 0x000a; const formFeedCharacter = 0x000c; const carriageReturnCharacter = 0x000d; const spaceCharacter = 0x0020; const zeroWidthSpaceCharacter = 0x200b; const objectReplacementCharacter = 0xfffc; const decoder = new TextDecoder('utf-16'); const NON_ASCII_MASK = 0b1111_1111_1000_0000; function isWsCollapsible(whiteSpace) { return whiteSpace === 'normal' || whiteSpace === 'nowrap' || whiteSpace === 'pre-line'; } function isNowrap(whiteSpace) { return whiteSpace === 'nowrap' || whiteSpace === 'pre'; } function isWsPreserved(whiteSpace) { return whiteSpace === 'pre' || whiteSpace === 'pre-wrap'; } function isSpaceOrTabOrNewline(c) { return c === ' ' || c === '\t' || c === '\n'; } function isSpaceOrTab(c) { return c === ' ' || c === '\t'; } function isNewline(c) { return c === '\n'; } function graphemeBoundaries(text, index) { const graphemeEnd = nextGraphemeBreak(text, index); const graphemeStart = previousGraphemeBreak(text, graphemeEnd); return { graphemeStart, graphemeEnd }; } export function nextGrapheme(text, index) { const { graphemeStart, graphemeEnd } = graphemeBoundaries(text, index); return graphemeStart < index ? graphemeEnd : index; } export function prevGrapheme(text, index) { const { graphemeStart } = graphemeBoundaries(text, index); return graphemeStart < index ? graphemeStart : index; } export class Run extends RenderItem { start; end; static TEXT_BITS = Box.BITS.hasText | Box.BITS.hasForegroundInLayer | Box.BITS.hasForegroundInDescendent; constructor(start, end, style) { super(style); this.start = start; this.end = end; } get length() { return this.end - this.start; } getLogSymbol() { return 'Ͳ'; } get wsCollapsible() { return isWsCollapsible(this.style.whiteSpace); } wrapsOverflowAnywhere(mode) { if (mode === 'min-content') { return this.style.overflowWrap === 'anywhere' || this.style.wordBreak === 'break-word'; } else { return this.style.overflowWrap === 'anywhere' || this.style.overflowWrap === 'break-word' || this.style.wordBreak === 'break-word'; } } isRun() { return true; } logName(log, options) { log.text(`${this.start},${this.end}`); if (options?.paragraphText) { log.text(` "${loggableText(options.paragraphText.slice(this.start, this.end))}"`); } } propagate(parent, paragraph) { if (!parent.isInline()) throw new Error('Assertion failed'); if (!isWsCollapsible(this.style.whiteSpace)) { parent.bitfield |= Run.TEXT_BITS; } for (let i = this.start; i < this.end; i++) { const code = paragraph.charCodeAt(i); if (code & NON_ASCII_MASK) { parent.bitfield |= Box.BITS.hasComplexText; } if (code === 0xad) { parent.bitfield |= Box.BITS.hasSoftHyphen; } else if (code === 0xa0) { parent.bitfield |= Box.BITS.hasNewlines; } if (!parent.hasText() && !isSpaceOrTabOrNewline(paragraph[i])) { parent.bitfield |= Run.TEXT_BITS; } } if (!isNowrap(this.style.whiteSpace)) { parent.bitfield |= Box.BITS.hasSoftWrap; } if (!isWsPreserved(this.style.whiteSpace)) { parent.bitfield |= Box.BITS.hasCollapsibleWs; } } } export function collapseWhitespace(ifc) { const stack = ifc.children.slice().reverse(); const parents = [ifc]; const str = new Uint16Array(ifc.text.length); let delta = 0; let stri = 0; let inWhitespace = false; while (stack.length) { const item = stack.pop(); if ('post' in item) { const inline = item.post; inline.end -= delta; parents.pop(); } else if (item.isInline()) { item.start -= delta; parents.push(item); stack.push({ post: item }); for (let i = item.children.length - 1; i >= 0; --i) stack.push(item.children[i]); } else if (item.isRun()) { const whiteSpace = item.style.whiteSpace; const originalStart = item.start; item.start -= delta; if (whiteSpace === 'normal' || whiteSpace === 'nowrap') { for (let i = originalStart; i < item.end; i++) { const isWhitespace = isSpaceOrTabOrNewline(ifc.text[i]); if (inWhitespace && isWhitespace) { delta += 1; } else { str[stri++] = isWhitespace ? spaceCharacter : ifc.text.charCodeAt(i); } inWhitespace = isWhitespace; } } else if (whiteSpace === 'pre-line') { for (let i = originalStart; i < item.end; i++) { const isWhitespace = isSpaceOrTabOrNewline(ifc.text[i]); if (isWhitespace) { let j = i + 1; let hasNewline = isNewline(ifc.text[i]); for (; j < item.end && isSpaceOrTabOrNewline(ifc.text[j]); j++) { hasNewline = hasNewline || isNewline(ifc.text[j]); } while (i < j) { if (isSpaceOrTab(ifc.text[i])) { if (inWhitespace || hasNewline) { delta += 1; } else { str[stri++] = spaceCharacter; } inWhitespace = true; } else { // newline str[stri++] = lineFeedCharacter; inWhitespace = false; } i++; } i = j - 1; } else { str[stri++] = ifc.text.charCodeAt(i); inWhitespace = false; } } } else { // pre inWhitespace = false; for (let i = originalStart; i < item.end; i++) { str[stri++] = ifc.text.charCodeAt(i); } } item.end -= delta; if (item.length === 0) { const parent = parents.at(-1); const i = parent.children.indexOf(item); if (i < 0) throw new Error('Assertion failed'); parent.children.splice(i, 1); } } else if (item.isBlockContainer() && !item.isFloat()) { // inline-block inWhitespace = false; } } ifc.text = decoder.decode(str.subarray(0, stri)); ifc.end = ifc.text.length; } const hyphenCache = new Map(); export function getFontMetrics(inline) { const strutCascade = getLangCascade(inline.style, 'en'); const [strutFace] = strutCascade; return getMetrics(inline.style, strutFace); } export const G_ID = 0; export const G_CL = 1; export const G_AX = 2; export const G_AY = 3; export const G_DX = 4; export const G_DY = 5; export const G_FL = 6; export const G_SZ = 7; const HyphenCodepointsToTry = '\u2010\u002d'; // HYPHEN, HYPHEN MINUS function createHyphenCacheKey(item) { return item.face.url.href; } function loadHyphen(item) { const key = createHyphenCacheKey(item); if (!hyphenCache.has(key)) { hyphenCache.set(key, new Int32Array(0)); for (const hyphen of HyphenCodepointsToTry) { const buf = hb.createBuffer(); buf.setClusterLevel(1); buf.addText(hyphen); buf.guessSegmentProperties(); hb.shape(item.face.hbfont, buf); const glyphs = buf.extractGlyphs(); buf.destroy(); if (glyphs[G_ID]) { hyphenCache.set(key, glyphs); break; } } } } function getHyphen(item) { return hyphenCache.get(createHyphenCacheKey(item)); } // Generated from pango-language.c // TODO: why isn't Han (Hant/Hans/Hani) in here? const LANG_FOR_SCRIPT = { Arabic: 'ar', Armenian: 'hy', Bengali: 'bn', Cherokee: 'chr', Coptic: 'cop', Cyrillic: 'ru', Devanagari: 'hi', Ethiopic: 'am', Georgian: 'ka', Greek: 'el', Gujarati: 'gu', Gurmukhi: 'pa', Hangul: 'ko', Hebrew: 'he', Hiragana: 'ja', Kannada: 'kn', Katakana: 'ja', Khmer: 'km', Lao: 'lo', Latin: 'en', Malayalam: 'ml', Mongolian: 'mn', Myanmar: 'my', Oriya: 'or', Sinhala: 'si', Syriac: 'syr', Tamil: 'ta', Telugu: 'te', Thaana: 'dv', Thai: 'th', Tibetan: 'bo', Canadian_Aboriginal: 'iu', Tagalog: 'tl', Hanunoo: 'hnn', Buhid: 'bku', Tagbanwa: 'tbw', Ugaritic: 'uga', Buginese: 'bug', Syloti_Nagri: 'syl', Old_Persian: 'peo', Nko: 'nqo' }; export function langForScript(script) { return LANG_FOR_SCRIPT[script] || 'xx'; } const metricsCache = new WeakMap(); // exported because used by html painter export function getMetrics(style, face) { let metrics = metricsCache.get(style)?.get(face.hbface); if (metrics) return metrics; const fontSize = style.fontSize; // now do CSS2 §10.8.1 const { ascender, xHeight, descender, lineGap } = face.hbfont.getMetrics('ltr'); // TODO vertical text const toPx = 1 / face.hbface.upem * fontSize; const pxHeight = (ascender - descender) * toPx; const lineHeight = style.lineHeight === 'normal' ? pxHeight + lineGap * toPx : style.lineHeight; const halfLeading = (lineHeight - pxHeight) / 2; const ascenderPx = ascender * toPx; const descenderPx = -descender * toPx; metrics = { ascenderBox: halfLeading + ascenderPx, ascender: ascenderPx, superscript: 0.34 * fontSize, // magic numbers come from Searchfox. xHeight: xHeight * toPx, subscript: 0.20 * fontSize, // all browsers use them instead of metrics descender: descenderPx, descenderBox: halfLeading + descenderPx }; let map1 = metricsCache.get(style); if (!map1) metricsCache.set(style, map1 = new WeakMap()); map1.set(face.hbface, metrics); return metrics; } export function nextCluster(glyphs, index) { const cl = glyphs[index + G_CL]; while ((index += G_SZ) < glyphs.length && cl == glyphs[index + G_CL]) ; return index; } export function prevCluster(glyphs, index) { const cl = glyphs[index + G_CL]; while ((index -= G_SZ) >= 0 && cl == glyphs[index + G_CL]) ; return index; } function createGlyphIteratorState(glyphs, level, textStart, textEnd) { const glyphIndex = level & 1 ? glyphs.length - G_SZ : 0; return { glyphIndex, clusterStart: textStart, clusterEnd: textStart, needsReshape: false, glyphs, level, textEnd, done: false }; } function nextGlyph(state) { state.needsReshape = false; if (state.level & 1) { if (state.glyphIndex < 0) { state.done = true; return; } state.clusterStart = state.clusterEnd; while (state.glyphIndex >= 0 && state.clusterEnd === state.glyphs[state.glyphIndex + G_CL]) { if (state.glyphs[state.glyphIndex + G_ID] === 0) state.needsReshape = true; state.glyphIndex -= G_SZ; } if (state.glyphIndex < 0) { state.clusterEnd = state.textEnd; } else { state.clusterEnd = state.glyphs[state.glyphIndex + G_CL]; } } else { if (state.glyphIndex === state.glyphs.length) { state.done = true; return; } state.clusterStart = state.clusterEnd; while (state.glyphIndex < state.glyphs.length && state.clusterEnd === state.glyphs[state.glyphIndex + G_CL]) { if (state.glyphs[state.glyphIndex + G_ID] === 0) state.needsReshape = true; state.glyphIndex += G_SZ; } if (state.glyphIndex === state.glyphs.length) { state.clusterEnd = state.textEnd; } else { state.clusterEnd = state.glyphs[state.glyphIndex + G_CL]; } } } function shiftGlyphs(glyphs, offset, dir) { if (dir === 'ltr') { for (let i = 0; i < glyphs.length; i += G_SZ) { if (glyphs[i + G_CL] >= offset) { return { leftGlyphs: glyphs.subarray(0, i), rightGlyphs: glyphs.subarray(i) }; } } } else { for (let i = glyphs.length - G_SZ; i >= 0; i -= G_SZ) { if (glyphs[i + G_CL] >= offset) { return { leftGlyphs: glyphs.subarray(i + G_SZ), rightGlyphs: glyphs.subarray(0, i + G_SZ) }; } } } return { leftGlyphs: glyphs, rightGlyphs: new Int32Array(0) }; } export class ShapedShim { offset; inlines; attrs; /** Defined when the shim is containing an inline-block */ block; constructor(offset, inlines, attrs, block) { this.offset = offset; this.inlines = inlines; this.attrs = attrs; this.block = block; } end() { return this.offset; } } export const EmptyInlineMetrics = Object.freeze({ ascenderBox: 0, ascender: 0, superscript: 0, xHeight: 0, subscript: 0, descender: 0, descenderBox: 0 }); export class ShapedItem { paragraph; face; glyphs; offset; length; attrs; inlines; x; y; constructor(paragraph, face, glyphs, offset, length, attrs) { this.paragraph = paragraph; this.face = face; this.glyphs = glyphs; this.offset = offset; this.length = length; this.attrs = attrs; this.inlines = []; this.x = 0; this.y = 0; } clone() { return new ShapedItem(this.paragraph, this.face, this.glyphs.slice(), this.offset, this.length, this.attrs); } split(offset) { const dir = this.attrs.level & 1 ? 'rtl' : 'ltr'; const { leftGlyphs, rightGlyphs } = shiftGlyphs(this.glyphs, this.offset + offset, dir); const needsReshape = Boolean(rightGlyphs[G_FL] & 1) || rightGlyphs[G_CL] !== this.offset + offset // cluster break || this.paragraph.isInsideGraphemeBoundary(this.offset + offset); const inlines = this.inlines; const right = new ShapedItem(this.paragraph, this.face, rightGlyphs, this.offset + offset, this.length - offset, this.attrs); this.glyphs = leftGlyphs; this.length = offset; this.inlines = inlines.filter(inline => { return inline.start < this.end() && inline.end > this.offset; }); right.inlines = inlines.filter(inline => { return inline.start < right.end() && inline.end > right.offset; }); for (const i of right.inlines) i.nshaped += 1; return { needsReshape, right }; } reshape(walkBackwards) { if (walkBackwards && !(this.attrs.level & 1) || !walkBackwards && this.attrs.level & 1) { let i = this.glyphs.length - G_SZ; while ((i = prevCluster(this.glyphs, i)) >= 0) { if (!(this.glyphs[i + G_FL] & 2) && !(this.glyphs[i + G_SZ + G_FL] & 2)) { const offset = this.attrs.level & 1 ? this.offset : this.glyphs[i + G_SZ + G_CL]; const length = this.attrs.level & 1 ? this.glyphs[i + G_CL] - offset : this.end() - offset; const newGlyphs = this.paragraph.shapePart(offset, length, this.face, this.attrs); if (!(newGlyphs[G_FL] & 2)) { const glyphs = new Int32Array(i + G_SZ + newGlyphs.length); glyphs.set(this.glyphs.subarray(0, i + G_SZ), 0); glyphs.set(newGlyphs, i + G_SZ); this.glyphs = glyphs; return; } } } } else { let i = 0; while ((i = nextCluster(this.glyphs, i)) < this.glyphs.length) { if (!(this.glyphs[i - G_SZ + G_FL] & 2) && !(this.glyphs[i + G_FL] & 2)) { const offset = this.attrs.level & 1 ? this.glyphs[i + G_CL] : this.offset; const length = this.attrs.level & 1 ? this.end() - offset : this.glyphs[i + G_CL] - this.offset; const newGlyphs = this.paragraph.shapePart(offset, length, this.face, this.attrs); if (!(newGlyphs.at(-G_SZ + G_FL) & 2)) { const glyphs = new Int32Array(this.glyphs.length - i + newGlyphs.length); glyphs.set(newGlyphs, 0); glyphs.set(this.glyphs.subarray(i), newGlyphs.length); this.glyphs = glyphs; return; } } } } this.glyphs = this.paragraph.shapePart(this.offset, this.length, this.face, this.attrs); } createMeasureState(direction = 1) { let glyphIndex; if (this.attrs.level & 1) { glyphIndex = direction === 1 ? this.glyphs.length - G_SZ : 0; } else { glyphIndex = direction === 1 ? 0 : this.glyphs.length - G_SZ; } return { glyphIndex, characterIndex: direction === 1 ? -1 : this.end(), clusterStart: this.glyphs[glyphIndex + G_CL], clusterEnd: this.glyphs[glyphIndex + G_CL], clusterAdvance: 0, isInk: false, done: false }; } nextCluster(direction, state) { const inc = this.attrs.level & 1 ? direction === 1 ? -G_SZ : G_SZ : direction === 1 ? G_SZ : -G_SZ; const g = this.glyphs; let glyphIndex = state.glyphIndex; if (glyphIndex in g) { const cl = g[glyphIndex + G_CL]; let w = 0; while (glyphIndex in g && cl == g[glyphIndex + G_CL]) { w += g[glyphIndex + G_AX]; glyphIndex += inc; } if (direction === 1) { state.clusterStart = state.clusterEnd; state.clusterEnd = glyphIndex in g ? g[glyphIndex + G_CL] : this.end(); } else { state.clusterEnd = state.clusterStart; state.clusterStart = cl; } state.glyphIndex = glyphIndex; state.clusterAdvance = w; state.isInk = isink(this.paragraph.string[cl]); } else { state.done = true; } } measureInsideCluster(state, ci) { const s = this.paragraph.string.slice(state.clusterStart, state.clusterEnd); const restrictedCi = Math.max(state.clusterStart, Math.min(ci, state.clusterEnd)); const numCharacters = Math.abs(restrictedCi - state.characterIndex); let w = 0; let numGraphemes = 0; for (let i = 0; i < s.length; i = nextGraphemeBreak(s, i)) { numGraphemes += 1; } if (numGraphemes > 1) { const clusterSize = state.clusterEnd - state.clusterStart; const cursor = Math.floor(numGraphemes * numCharacters / clusterSize); w += state.clusterAdvance * cursor / numGraphemes; } return w; } measure(ci = this.end(), direction = 1, state = this.createMeasureState(direction)) { const toPx = 1 / this.face.hbface.upem * this.attrs.style.fontSize; let advance = 0; let trailingWs = 0; if (state.characterIndex > state.clusterStart && state.characterIndex < state.clusterEnd) { advance += this.measureInsideCluster(state, ci); trailingWs = state.isInk ? 0 : trailingWs + state.clusterAdvance; if (ci > state.clusterStart && ci < state.clusterEnd) { state.characterIndex = ci; return { advance: advance * toPx, trailingWs: trailingWs * toPx }; } else { this.nextCluster(direction, state); } } while (!state.done && (direction === 1 ? ci >= state.clusterEnd : ci <= state.clusterStart)) { advance += state.clusterAdvance; trailingWs = state.isInk ? 0 : trailingWs + state.clusterAdvance; this.nextCluster(direction, state); } state.characterIndex = direction === 1 ? state.clusterStart : state.clusterEnd; if (ci > state.clusterStart && ci < state.clusterEnd) { advance += this.measureInsideCluster(state, ci); state.characterIndex = ci; } return { advance: advance * toPx, trailingWs: trailingWs * toPx }; } collapseWhitespace(at) { if (!isWsCollapsible(this.attrs.style.whiteSpace)) return true; if (at === 'start') { let index = 0; do { if (!isink(this.paragraph.string[this.glyphs[index + G_CL]])) { this.glyphs[index + G_AX] = 0; } else { return true; } } while ((index = nextCluster(this.glyphs, index)) < this.glyphs.length); } else { let index = this.glyphs.length - G_SZ; do { if (!isink(this.paragraph.string[this.glyphs[index + G_CL]])) { this.glyphs[index + G_AX] = 0; } else { return true; } } while ((index = prevCluster(this.glyphs, index)) >= 0); } } // used in shaping colorsStart(colors) { const s = binarySearchTuple(colors, this.offset); if (s === colors.length) return s - 1; if (colors[s][1] !== this.offset) return s - 1; return s; } // used in shaping colorsEnd(colors) { const s = binarySearchTuple(colors, this.end() - 1); if (s === colors.length) return s; if (colors[s][1] !== this.end() - 1) return s; return s + 1; } end() { return this.offset + this.length; } hasCharacterInside(ci) { return ci > this.offset && ci < this.end(); } // only use this in debugging or tests text() { return this.paragraph.string.slice(this.offset, this.offset + this.length); } } class LineItemLinkedList { head; tail; constructor() { this.head = null; this.tail = null; } clear() { this.head = null; this.tail = null; } transfer() { const ret = new LineItemLinkedList(); ret.concat(this); this.clear(); return ret; } concat(items) { if (!items.head) return; if (this.tail) { this.tail.next = items.head; items.head.previous = this.tail; this.tail = items.tail; } else { this.head = items.head; this.tail = items.tail; } } rconcat(items) { if (!items.tail) return; if (this.head) { items.tail.next = this.head; this.head.previous = items.tail; this.head = items.head; } else { this.head = items.head; this.tail = items.tail; } } push(value) { if (this.tail) { this.tail = this.tail.next = { value, next: null, previous: this.tail }; } else { this.head = this.tail = { value, next: null, previous: null }; } } unshift(value) { const item = { value, next: this.head, previous: null }; if (this.head) this.head.previous = item; this.head = item; if (!this.tail) this.tail = item; } reverse() { for (let n = this.head; n; n = n.previous) { [n.next, n.previous] = [n.previous, n.next]; } [this.head, this.tail] = [this.tail, this.head]; } } class LineWidthTracker { inkSeen; startWs; startWsC; ink; endWs; endWsC; hyphen; constructor() { this.inkSeen = false; this.startWs = 0; this.startWsC = 0; this.ink = 0; this.endWs = 0; this.endWsC = 0; this.hyphen = 0; } addInk(width) { this.ink += this.endWs + width; this.endWs = 0; this.endWsC = 0; this.hyphen = 0; if (width) this.inkSeen = true; } addWs(width, isCollapsible) { if (this.inkSeen) { this.endWs += width; this.endWsC += isCollapsible ? width : 0; } else { this.startWs += width; this.startWsC += isCollapsible ? width : 0; } this.hyphen = 0; } hasContent() { return this.inkSeen || this.startWs - this.startWsC > 0; } addHyphen(width) { this.hyphen = width; } concat(width) { if (this.inkSeen) { if (width.inkSeen) { this.ink += this.endWs + width.startWs + width.ink; this.endWs = width.endWs; this.endWsC = width.endWsC; } else { this.endWs += width.startWs; this.endWsC = width.startWsC + width.endWsC; } } else { this.startWs += width.startWs; this.startWsC += width.startWsC; this.ink = width.ink; this.endWs = width.endWs; this.endWsC = width.endWsC; this.inkSeen = width.inkSeen; } this.hyphen = width.hyphen; } forFloat() { return this.startWs - this.startWsC + this.ink + this.hyphen; } forWord() { return this.startWs - this.startWsC + this.ink + this.endWs; } asWord() { return this.startWs + this.ink + this.hyphen; } trimmed() { return this.startWs - this.startWsC + this.ink + this.endWs - this.endWsC + this.hyphen; } reset() { this.inkSeen = false; this.startWs = 0; this.startWsC = 0; this.ink = 0; this.endWs = 0; this.endWsC = 0; this.hyphen = 0; } } function baselineStep(parent, inline) { if (inline.style.verticalAlign === 'baseline') { return 0; } if (inline.style.verticalAlign === 'super') { return parent.metrics.superscript; } if (inline.style.verticalAlign === 'sub') { return -parent.metrics.subscript; } if (inline.style.verticalAlign === 'middle') { const midParent = parent.metrics.xHeight / 2; const midInline = (inline.metrics.ascender - inline.metrics.descender) / 2; return midParent - midInline; } if (inline.style.verticalAlign === 'text-top') { return parent.metrics.ascender - inline.metrics.ascenderBox; } if (inline.style.verticalAlign === 'text-bottom') { return inline.metrics.descenderBox - parent.metrics.descender; } if (typeof inline.style.verticalAlign === 'object') { return (inline.metrics.ascenderBox + inline.metrics.descenderBox) * inline.style.verticalAlign.value / 100; } if (typeof inline.style.verticalAlign === 'number') { return inline.style.verticalAlign; } return 0; } export function inlineBlockMetrics(block) { const { blockStart: marginBlockStart, blockEnd: marginBlockEnd } = block.getMarginsAutoIsZero(); const baseline = block.style.overflow === 'hidden' ? undefined : block.getLastBaseline(); let ascender, descender; if (baseline !== undefined) { const paddingBlockStart = block.style.getPaddingBlockStart(block); const paddingBlockEnd = block.style.getPaddingBlockEnd(block); const borderBlockStart = block.style.getBorderBlockStartWidth(block); const borderBlockEnd = block.style.getBorderBlockEndWidth(block); const blockSize = block.contentArea.blockSize; ascender = marginBlockStart + borderBlockStart + paddingBlockStart + baseline; descender = (blockSize - baseline) + paddingBlockEnd + borderBlockEnd + marginBlockEnd; } else { ascender = marginBlockStart + block.borderArea.blockSize + marginBlockEnd; descender = 0; } return { ascender, descender }; } function inlineBlockBaselineStep(parent, block) { if (block.style.overflow === 'hidden') { return 0; } if (block.style.verticalAlign === 'baseline') { return 0; } if (block.style.verticalAlign === 'super') { return parent.metrics.superscript; } if (block.style.verticalAlign === 'sub') { return -parent.metrics.subscript; } if (block.style.verticalAlign === 'middle') { const { ascender, descender } = inlineBlockMetrics(block); const midParent = parent.metrics.xHeight / 2; const midInline = (ascender - descender) / 2; return midParent - midInline; } if (block.style.verticalAlign === 'text-top') { const { ascender } = inlineBlockMetrics(block); return parent.metrics.ascender - ascender; } if (block.style.verticalAlign === 'text-bottom') { const { descender } = inlineBlockMetrics(block); return descender - parent.metrics.descender; } if (typeof block.style.verticalAlign === 'object') { const lineHeight = block.style.lineHeight; if (lineHeight === 'normal') { // TODO: is there a better/faster way to do this? currently struts only // exist if there is a paragraph, but I think spec is saying do this const [strutFace] = getLangCascade(block.style, 'en'); const metrics = getMetrics(block.style, strutFace); return (metrics.ascenderBox + metrics.descenderBox) * block.style.verticalAlign.value / 100; } else { return lineHeight * block.style.verticalAlign.value / 100; } } if (typeof block.style.verticalAlign === 'number') { return block.style.verticalAlign; } return 0; } class AlignmentContext { ascender; descender; baselineShift; constructor(arg) { if (arg instanceof AlignmentContext) { this.ascender = arg.ascender; this.descender = arg.descender; this.baselineShift = arg.baselineShift; } else { this.ascender = arg.ascenderBox; this.descender = arg.descenderBox; this.baselineShift = 0; } } stampMetrics(metrics) { const top = this.baselineShift + metrics.ascenderBox; const bottom = metrics.descenderBox - this.baselineShift; this.ascender = Math.max(this.ascender, top); this.descender = Math.max(this.descender, bottom); } stampBlock(block, parent) { const { ascender, descender } = inlineBlockMetrics(block); const baselineShift = this.baselineShift + inlineBlockBaselineStep(parent, block); const top = baselineShift + ascender; const bottom = descender - baselineShift; this.ascender = Math.max(this.ascender, top); this.descender = Math.max(this.descender, bottom); } extend(ctx) { this.ascender = Math.max(this.ascender, ctx.ascender); this.descender = Math.max(this.descender, ctx.descender); } stepIn(parent, inline) { this.baselineShift += baselineStep(parent, inline); } stepOut(parent, inline) { this.baselineShift -= baselineStep(parent, inline); } reset() { this.ascender = 0; this.descender = 0; this.baselineShift = 0; } } class LineCandidates extends LineItemLinkedList { width; height; constructor(ifc) { super(); this.width = new LineWidthTracker(); this.height = new LineHeightTracker(ifc); } clearContents() { this.width.reset(); this.height.clearContents(); this.clear(); } } ; const EMPTY_MAP = Object.freeze(new Map()); class LineHeightTracker { ifc; parents; contextStack; contextRoots; /** Inline blocks */ blocks; markedContextRoots; constructor(ifc) { const ctx = new AlignmentContext(ifc.metrics); this.ifc = ifc; this.parents = []; this.contextStack = [ctx]; this.contextRoots = EMPTY_MAP; this.blocks = []; this.markedContextRoots = []; } stampMetrics(metrics) { this.contextStack.at(-1).stampMetrics(metrics); } stampBlock(block, parent) { if (block.style.verticalAlign === 'top' || block.style.verticalAlign === 'bottom') { this.blocks.push(block); } else { this.contextStack.at(-1).stampBlock(block, parent); } } pushInline(inline) { const parent = this.parents.at(-1) || this.ifc; let ctx = this.contextStack.at(-1); this.parents.push(inline); if (inline.style.verticalAlign === 'top' || inline.style.verticalAlign === 'bottom') { if (this.contextRoots === EMPTY_MAP) this.contextRoots = new Map(); ctx = new AlignmentContext(inline.metrics); this.contextStack.push(ctx); this.contextRoots.set(inline, ctx); } else { ctx.stepIn(parent, inline); ctx.stampMetrics(inline.metrics); } } popInline() { const inline = this.parents.pop(); if (inline.style.verticalAlign === 'top' || inline.style.verticalAlign === 'bottom') { this.contextStack.pop(); this.markedContextRoots.push(inline); } else { const parent = this.parents.at(-1) || this.ifc; const ctx = this.contextStack.at(-1); ctx.stepOut(parent, inline); } } concat(height) { const thisCtx = this.contextStack[0]; const otherCtx = height.contextStack[0]; thisCtx.extend(otherCtx); if (height.contextRoots.size) { for (const [inline, ctx] of height.contextRoots) { const thisCtx = this.contextRoots.get(inline); if (thisCtx) { thisCtx.extend(ctx); } else { if (this.contextRoots === EMPTY_MAP) this.contextRoots = new Map(); this.contextRoots.set(inline, new AlignmentContext(ctx)); } } } for (const block of height.blocks) this.blocks.push(block); } align() { const rootCtx = this.contextStack[0]; if (this.contextRoots.size === 0 && this.blocks.length === 0) return rootCtx; const lineHeight = this.total(); let bottomsHeight = rootCtx.ascender + rootCtx.descender; for (const [inline, ctx] of this.contextRoots) { if (inline.style.verticalAlign === 'bottom') { bottomsHeight = Math.max(bottomsHeight, ctx.ascender + ctx.descender); } } for (const block of this.blocks) { if (block.style.verticalAlign === 'bottom') { const blockSize = block.borderArea.blockSize; const { blockStart, blockEnd } = block.getMarginsAutoIsZero(); bottomsHeight = Math.max(bottomsHeight, blockStart + blockSize + blockEnd); } } const ascender = bottomsHeight - rootCtx.descender; const descender = lineHeight - ascender; for (const [inline, ctx] of this.contextRoots) { if (inline.style.verticalAlign === 'top') { ctx.baselineShift = ascender - ctx.ascender; } else if (inline.style.verticalAlign === 'bottom') { ctx.baselineShift = ctx.descender - descender; } } return { ascender, descender }; } total() { let height = this.contextStack[0].ascender + this.contextStack[0].descender; if (this.contextRoots.size === 0 && this.blocks.length === 0) { return height; } else { for (const ctx of this.contextRoots.values()) { height = Math.max(height, ctx.ascender + ctx.descender); } for (const block of this.blocks) { const blockSize = block.borderArea.blockSize; const { blockStart, blockEnd } = block.getMarginsAutoIsZero(); height = Math.max(height, blockStart + blockSize + blockEnd); } return height; } } totalWith(height) { return Math.max(this.total(), height.total()); } reset() { const ctx = new AlignmentContext(this.ifc.metrics); this.parents = []; this.contextStack = [ctx]; this.contextRoots = EMPTY_MAP; this.blocks = []; this.markedContextRoots = []; } clearContents() { let parent = this.ifc; let inline = this.parents[0]; let i = 0; if (this.contextStack.length === 1 && // no vertical-align top or bottoms this.parents.length <= 1 // one non-top/bottom/baseline parent or none ) { const [ctx] = this.contextStack; ctx.reset(); ctx.stampMetrics(parent.metrics); if (inline) { ctx.stepIn(parent, inline); ctx.stampMetrics(inline.metrics); } } else { // slow path - this is the normative algorithm for (const ctx of this.contextStack) { ctx.reset(); while (inline) { if (inline.style.verticalAlign === 'top' || inline.style.verticalAlign === 'bottom') { parent = inline; inline = this.parents[++i]; break; } else { ctx.stepIn(parent, inline); ctx.stampMetrics(inline.metrics); parent = inline; inline = this.parents[++i]; } } } } for (const inline of this.markedContextRoots) this.contextRoots.delete(inline); this.markedContextRoots = []; this.blocks = []; } } export class Linebox extends LineItemLinkedList { startOffset; paragraph; ascender; descender; endOffset; blockOffset; inlineOffset; width; contextRoots; constructor(start, paragraph) { super(); this.startOffset = this.endOffset = start; this.paragraph = paragraph; this.ascender = 0; this.descender = 0; this.blockOffset = 0; this.inlineOffset = 0; this.width = 0; this.contextRoots = EMPTY_MAP; } addCandidates(candidates, endOffset) { this.concat(candidates); this.endOffset = endOffset; } hasContent() { if (this.endOffset > this.startOffset) { return true; } else { for (let n = this.head; n; n = n.next) { if (n.value instanceof ShapedShim && n.value.block) return true; } } return false; } hasAnything() { return this.head != null; } end() { return this.endOffset; } height() { return this.ascender + this.descender; } trimStart() { for (let n = this.head; n; n = n.next) { if (n.value instanceof ShapedShim) { if (n.value.block) return; } else if (n.value.collapseWhitespace('start')) { return; } } } trimEnd() { for (let n = this.tail; n; n = n.previous) { if (n.value instanceof ShapedShim) { if (n.value.block) return; } else if (n.value.collapseWhitespace('end')) { return; } } } reorderRange(start, length) { const ret = new LineItemLinkedList(); let minLevel = Infinity; for (let i = 0, n = start; n && i < length; ++i, n = n.next) { minLevel = Math.min(minLevel, n.value.attrs.level); } let levelStartIndex = 0; let levelStartNode = start; for (let i = 0, n = start; n && i < length; ++i, n = n.next) { if (n.value.attrs.level === minLevel) { if (minLevel & 1) { if (i > levelStartIndex) { ret.rconcat(this.reorderRange(levelStartNode, i - levelStartIndex)); } ret.unshift(n.value); } else { if (i > levelStartIndex) { ret.concat(this.reorderRange(levelStartNode, i - levelStartIndex)); } ret.push(n.value); } levelStartIndex = i + 1; levelStartNode = n.next; } } if (minLevel & 1) { if (levelStartIndex < length) { ret.rconcat(this.reorderRange(levelStartNode, length - levelStartIndex)); } } else { if (levelStartIndex < length) { ret.concat(this.reorderRange(levelStartNode, length - levelStartIndex)); } } return ret; } reorder() { let levelOr = 0; let levelAnd = 1; let length = 0; for (let n = this.head; n; n = n.next) { levelOr |= n.value.attrs.level; levelAnd &= n.value.attrs.level; length += 1; } // If none of the levels had the LSB set, all numbers were even const allEven = (levelOr & 1) === 0; // If all of the levels had the LSB set, all numbers were odd const allOdd = (levelAnd & 1) === 1; if (!allEven && !allOdd) { this.concat(this.reorderRange(this.transfer().head, length)); } else if (allOdd) { this.reverse(); } } postprocess(width, height, vacancy, textAlign) { const dir = this.paragraph.ifc.style.direction; const w = width.trimmed(); const { ascender, descender } = height.align(); this.width = w; if (height.contextRoots.size) this.contextRoots = new Map(height.contextRoots); this.blockOffset = vacancy.blockOffset; this.trimStart(); this.trimEnd(); this.reorder(); this.ascender = ascender; this.descender = descender; this.inlineOffset = dir === 'ltr' ? vacancy.leftOffset : vacancy.rightOffset; if (w < vacancy.inlineSize) { if (textAlign === 'right' && dir === 'ltr' || textAlign === 'left' && dir === 'rtl') { this.inlineOffset += vacancy.inlineSize - w; } else if (textAlign === 'center') { this.inlineOffset += (vacancy.inlineSize - w) / 2; } } } } class ContiguousBoxBuilder { opened; closed; constructor() { this.opened = new Map(); this.closed = new Map(); } open(inline, linebox, naturalStart, start, blockOffset) { const box = this.opened.get(inline); if (box) { box.end = start; } else { const end = start; const naturalEnd = false; const { ascender, descender } = inline.metrics; const box = { start, end, linebox, blockOffset, ascender, descender, naturalStart, naturalEnd }; this.opened.set(inline, box); // Make sure closed is in open order if (!this.closed.has(inline)) this.closed.set(inline, []); } } close(inline, naturalEnd, end) { const box = this.opened.get(inline); if (box) { const list = this.closed.get(inline); box.end = end; box.naturalEnd = naturalEnd; this.opened.delete(inline); list ? list.push(box) : this.closed.set(inline, [box]); } } closeAll(except, end) { for (const inline of this.opened.keys()) { if (!except.includes(inline)) this.close(inline, false, end); } } } function isink(c) { return c !== undefined && c !== ' ' && c !== '\t'; } function createIfcBuffer(text) { const allocation = hb.allocateUint16Array(text.length); const a = allocation.array; // Inspired by this diff in Chromium, which reveals the code that normalizes // the buffer passed to HarfBuzz before shaping: // https://chromium.googlesource.com/chromium/src.git/+/275c35fe82bd295a75c0d555db0e0b26fcdf980b%5E%21/#F18 // I removed the characters in the Default_Ignorables Unicode category since // HarfBuzz is configured to ignore them, and added newlines since currently // they get passed to HarfBuzz (they probably shouldn't because effects // should not happen across newlines) for (let i = 0; i < text.length; ++i) { const c = text.charCodeAt(i); if (c === formFeedCharacter || c === carriageReturnCharacter || c === lineFeedCharacter || c === objectReplacementCharacter) { a[i] = zeroWidthSpaceCharacter; } else { a[i] = c; } } return allocation; } const hbBuffer = hb.createBuffer(); hbBuffer.setClusterLevel(1); hbBuffer.setFlags(hb.HB_BUFFER_FLAG_PRODUCE_UNSAFE_TO_CONCAT); const wordCache = new Map(); let wordCacheSize = 0; // exported for testing, which should not measure with a prefilled cache export function clearWordCache() { wordCache.clear(); wordCacheSize = 0; } function wordCacheAdd(font, string, glyphs) { let stringCache = wordCache.get(font); if (!stringCache) wordCache.set(font, stringCache = new Map()); stringCache.set(string, glyphs); wordCacheSize += 1; } function wordCacheGet(font, string) { return wordCache.get(font)?.get(string); } export class Paragraph { ifc; string; buffer; brokenItems; wholeItems; treeItems; lineboxes; backgroundBoxes; height; constructor(ifc, buffer) { this.ifc = ifc; this.string = ifc.text; this.buffer = buffer; this.brokenItems = []; this.wholeItems = []; this.treeItems = []; this.lineboxes = []; this.backgroundBoxes = new Map(); this.height = 0; } destroy() { this.buffer.destroy(); this.buffer = EmptyBuffer; } slice(start, end) { return this.string.slice(start, end); } split(itemIndex, offset) { const left = this.brokenItems[itemIndex]; const { needsReshape, right } = left.split(offset - left.offset); if (needsReshape) { left.reshape(true); right.reshape(false); } this.brokenItems.splice(itemIndex + 1, 0, right); if (this.string[offset - 1] === '\u00ad' /* softHyphenCharacter */) { const hyphen = getHyphen(left); if (hyphen?.length) { const glyphs = new Int32Array(left.glyphs.length + hyphen.length); if (left.attrs.level & 1) { glyphs.set(hyphen, 0); glyphs.set(left.glyphs, hyphen.length); for (let i = G_CL; i < hyphen.length; i += G_SZ) { glyphs[i] = offset - 1; } } else { glyphs.set(left.glyphs, 0); glyphs.set(hyphen, left.glyphs.length); for (let i = left.glyphs.length + G_CL; i < glyphs.length; i += G_SZ) { glyphs[i] = offset - 1; } } left.glyphs = glyphs; } // TODO 1: this sucks, but it's probably still better than using a Uint16Array // and having to convert back to strings for the browser canvas backend // TODO 2: the hyphen character could also be HYPHEN MINUS this.string = this.string.slice(0, offset - 1) + /* U+2010 */ '‐' + this.string.slice(o