dropflow
Version:
A small CSS2 document renderer built from specifications
1,436 lines • 91.5 kB
JavaScript
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