dropflow
Version:
A small CSS2 document renderer built from specifications
1,292 lines • 58.6 kB
JavaScript
import { binarySearch } from './util.js';
import { HTMLElement, TextNode } from './dom.js';
import { createStyle, EMPTY_STYLE } from './style.js';
import { EmptyInlineMetrics, Run, collapseWhitespace, createEmptyParagraph, createParagraph, getFontMetrics } from './layout-text.js';
import { getImage } from './layout-image.js';
import { Box, FormattingBox, RenderItem } from './layout-box.js';
function assumePx(v) {
if (typeof v !== 'number') {
throw new TypeError('The value accessed here has not been reduced to a used value in a ' +
'context where a used value is expected. Make sure to perform any ' +
'needed layouts.');
}
}
function writingModeInlineAxis(el) {
if (el.style.writingMode === 'horizontal-tb') {
return 'horizontal';
}
else {
return 'vertical';
}
}
class MarginCollapseCollection {
positive;
negative;
constructor(initialMargin = 0) {
this.positive = 0;
this.negative = 0;
this.add(initialMargin);
}
add(margin) {
if (margin < 0) {
this.negative = Math.max(this.negative, -margin);
}
else {
this.positive = Math.max(this.positive, margin);
}
return this;
}
get() {
return this.positive - this.negative;
}
clone() {
const c = new MarginCollapseCollection();
c.positive = this.positive;
c.negative = this.negative;
return c;
}
}
const EMPTY_MAP = new Map();
export class BlockFormattingContext {
inlineSize;
fctx;
stack;
cbBlockStart;
cbLineLeft;
cbLineRight;
sizeStack;
offsetStack;
last;
level;
hypotheticals;
margin;
constructor(inlineSize) {
this.inlineSize = inlineSize;
this.stack = [];
this.cbBlockStart = 0;
this.cbLineLeft = 0;
this.cbLineRight = 0;
this.sizeStack = [0];
this.offsetStack = [0];
this.last = null;
this.level = 0;
this.margin = { level: 0, collection: new MarginCollapseCollection() };
this.hypotheticals = EMPTY_MAP;
}
collapseStart(box) {
const marginBlockStart = box.style.getMarginBlockStart(box);
let floatBottom = 0;
let clearance = 0;
assumePx(marginBlockStart);
if (this.fctx && (box.style.clear === 'left' || box.style.clear === 'both')) {
floatBottom = Math.max(floatBottom, this.fctx.getLeftBottom());
}
if (this.fctx && (box.style.clear === 'right' || box.style.clear === 'both')) {
floatBottom = Math.max(floatBottom, this.fctx.getRightBottom());
}
if (box.style.clear !== 'none') {
const hypo = this.margin.collection.clone().add(marginBlockStart).get();
clearance = Math.max(clearance, floatBottom - (this.cbBlockStart + hypo));
}
const adjoinsPrevious = clearance === 0;
if (adjoinsPrevious) {
this.margin.collection.add(marginBlockStart);
}
else {
this.positionBlockContainers();
const c = floatBottom - this.cbBlockStart;
this.margin = { level: this.level, collection: new MarginCollapseCollection(c) };
if (box.canCollapseThrough())
this.margin.clearanceAtLevel = this.level;
}
}
boxStart(box, ctx) {
const { lineLeft, lineRight, blockStart } = box.getContainingBlockToContent();
const paddingBlockStart = box.style.getPaddingBlockStart(box);
const borderBlockStartWidth = box.style.getBorderBlockStartWidth(box);
const adjoinsNext = paddingBlockStart === 0 && borderBlockStartWidth === 0;
this.collapseStart(box);
this.last = 'start';
this.level += 1;
this.cbLineLeft += lineLeft;
this.cbLineRight += lineRight;
this.stack.push(box);
if (box.isBlockContainerOfInlines()) {
this.cbBlockStart += blockStart + this.margin.collection.get();
}
this.fctx?.boxStart();
if (box.isBlockContainerOfInlines()) {
box.doTextLayout(ctx);
this.cbBlockStart -= blockStart + this.margin.collection.get();
}
if (!adjoinsNext) {
this.positionBlockContainers();
this.margin = { level: this.level, collection: new MarginCollapseCollection() };
}
}
boxEnd(box) {
const { lineLeft, lineRight } = box.getContainingBlockToContent();
const paddingBlockEnd = box.style.getPaddingBlockEnd(box);
const borderBlockEndWidth = box.style.getBorderBlockEndWidth(box);
const marginBlockEnd = box.style.getMarginBlockEnd(box);
let adjoins = paddingBlockEnd === 0
&& borderBlockEndWidth === 0
&& (this.margin.clearanceAtLevel == null || this.level > this.margin.clearanceAtLevel);
assumePx(marginBlockEnd);
if (adjoins) {
if (this.last === 'start') {
adjoins = box.canCollapseThrough();
}
else {
const blockSize = box.style.getBlockSize(box);
// Handle the end of a block box that was at the end of its parent
adjoins = blockSize === 'auto';
}
}
this.stack.push({ post: box });
this.level -= 1;
this.cbLineLeft -= lineLeft;
this.cbLineRight -= lineRight;
if (!adjoins) {
this.positionBlockContainers();
this.margin = { level: this.level, collection: new MarginCollapseCollection() };
}
// Collapsing through - need to find the hypothetical position
if (this.last === 'start') {
if (this.hypotheticals === EMPTY_MAP)
this.hypotheticals = new Map();
this.hypotheticals.set(box, this.margin.collection.get());
}
this.margin.collection.add(marginBlockEnd);
// When a box's end adjoins to the previous margin, move the "root" (the
// box which the margin will be placed adjacent to) to the highest-up box
// in the tree, since its siblings need to be shifted.
if (this.level < this.margin.level)
this.margin.level = this.level;
this.last = 'end';
}
boxAtomic(box) {
const marginBlockEnd = box.style.getMarginBlockEnd(box);
assumePx(marginBlockEnd);
this.collapseStart(box);
this.fctx?.boxStart();
this.positionBlockContainers();
box.setBlockPosition(this.cbBlockStart);
this.margin.collection = new MarginCollapseCollection();
this.margin.collection.add(marginBlockEnd);
this.last = 'end';
}
getLocalVacancyForLine(bfc, blockOffset, blockSize, vacancy) {
let leftInlineSpace = 0;
let rightInlineSpace = 0;
if (this.fctx) {
leftInlineSpace = this.fctx.leftFloats.getOccupiedSpace(blockOffset, blockSize, -this.cbLineLeft);
rightInlineSpace = this.fctx.rightFloats.getOccupiedSpace(blockOffset, blockSize, -this.cbLineRight);
}
vacancy.leftOffset = this.cbLineLeft + leftInlineSpace;
vacancy.rightOffset = this.cbLineRight + rightInlineSpace;
vacancy.inlineSize = this.inlineSize - vacancy.leftOffset - vacancy.rightOffset;
vacancy.blockOffset = blockOffset - bfc.cbBlockStart;
vacancy.leftOffset -= bfc.cbLineLeft;
vacancy.rightOffset -= bfc.cbLineRight;
}
ensureFloatContext(blockOffset) {
return this.fctx || (this.fctx = new FloatContext(this, blockOffset));
}
finalize(box) {
if (!box.isBfcRoot())
throw new Error('This is for bfc roots only');
const blockSize = box.style.getBlockSize(box);
this.positionBlockContainers();
if (blockSize === 'auto') {
let lineboxHeight = 0;
if (box.isBlockContainerOfInlines()) {
lineboxHeight = box.getContentArea().blockSize;
}
box.setBlockSize(Math.max(lineboxHeight, this.cbBlockStart, this.fctx?.getBothBottom() ?? 0));
}
}
positionBlockContainers() {
const sizeStack = this.sizeStack;
const offsetStack = this.offsetStack;
const margin = this.margin.collection.get();
let passedMarginLevel = this.margin.level === offsetStack.length - 1;
let levelNeedsPostOffset = offsetStack.length - 1;
sizeStack[this.margin.level] += margin;
this.cbBlockStart += margin;
for (const item of this.stack) {
const box = 'post' in item ? item.post : item;
if ('post' in item) {
const childSize = sizeStack.pop();
const offset = offsetStack.pop();
const level = sizeStack.length - 1;
const sBlockSize = box.style.getBlockSize(box);
if (sBlockSize === 'auto' && box.isBlockContainerOfBlockContainers() && !box.isBfcRoot()) {
box.setBlockSize(childSize);
}
const blockSize = box.getBorderArea().blockSize;
sizeStack[level] += blockSize;
this.cbBlockStart = offset + blockSize;
// Each time we go beneath a level that was created by the previous
// positionBlockContainers(), we have to put the margin on the "after"
// side of the block container. ("before" sides are covered at the top)
// ][[]]
if (level < levelNeedsPostOffset) {
--levelNeedsPostOffset;
this.cbBlockStart += margin;
}
}
else {
const hypothetical = this.hypotheticals.get(box);
const level = sizeStack.length - 1;
let blockOffset = sizeStack[level];
if (!passedMarginLevel) {
passedMarginLevel = this.margin.level === level;
}
if (!passedMarginLevel) {
blockOffset += margin;
}
if (hypothetical !== undefined) {
blockOffset -= margin - hypothetical;
}
box.setBlockPosition(blockOffset);
sizeStack.push(0);
offsetStack.push(this.cbBlockStart);
}
}
this.stack = [];
}
}
class FloatSide {
items;
// Moving shelf area (stretches to infinity in the block direction)
shelfBlockOffset;
shelfTrackIndex;
// Tracks
blockOffsets;
inlineSizes;
inlineOffsets;
floatCounts;
constructor(blockOffset) {
this.items = [];
this.shelfBlockOffset = blockOffset;
this.shelfTrackIndex = 0;
this.blockOffsets = [blockOffset];
this.inlineSizes = [0];
this.inlineOffsets = [0];
this.floatCounts = [0];
}
initialize(blockOffset) {
this.shelfBlockOffset = blockOffset;
this.blockOffsets = [blockOffset];
}
repr() {
let row1 = '', row2 = '';
for (let i = 0; i < this.blockOffsets.length; ++i) {
const blockOffset = this.blockOffsets[i];
const inlineOffset = this.inlineOffsets[i];
const size = this.inlineSizes[i];
const count = this.floatCounts[i];
const cell1 = `${blockOffset}`;
const cell2 = `| O:${inlineOffset} S:${size} N:${count} `;
const colSize = Math.max(cell1.length, cell2.length);
row1 += cell1 + ' '.repeat(colSize - cell1.length);
row2 += ' '.repeat(colSize - cell2.length) + cell2;
}
row1 += 'Inf';
row2 += '|';
return row1 + '\n' + row2;
}
getSizeOfTracks(start, end, inlineOffset) {
let max = 0;
for (let i = start; i < end; ++i) {
if (this.floatCounts[i] > 0) {
max = Math.max(max, inlineOffset + this.inlineSizes[i] + this.inlineOffsets[i]);
}
}
return max;
}
getOverflow() {
return this.getSizeOfTracks(0, this.inlineSizes.length, 0);
}
getFloatCountOfTracks(start, end) {
let max = 0;
for (let i = start; i < end; ++i)
max = Math.max(max, this.floatCounts[i]);
return max;
}
getEndTrack(start, blockOffset, blockSize) {
const blockPosition = blockOffset + blockSize;
let end = start + 1;
while (end < this.blockOffsets.length && this.blockOffsets[end] < blockPosition)
end++;
return end;
}
getTrackRange(blockOffset, blockSize = 0) {
let start = binarySearch(this.blockOffsets, blockOffset);
if (this.blockOffsets[start] !== blockOffset)
start -= 1;
return [start, this.getEndTrack(start, blockOffset, blockSize)];
}
getOccupiedSpace(blockOffset, blockSize, inlineOffset) {
if (this.items.length === 0)
return 0;
const [start, end] = this.getTrackRange(blockOffset, blockSize);
return this.getSizeOfTracks(start, end, inlineOffset);
}
boxStart(blockOffset) {
// This seems to violate rule 5 for blocks if the boxStart block has a
// negative margin, but it's what browsers do 🤷♂️
this.shelfBlockOffset = blockOffset;
[this.shelfTrackIndex] = this.getTrackRange(this.shelfBlockOffset);
}
dropShelf(blockOffset) {
if (blockOffset > this.shelfBlockOffset) {
this.shelfBlockOffset = blockOffset;
[this.shelfTrackIndex] = this.getTrackRange(this.shelfBlockOffset);
}
}
getNextTrackOffset() {
if (this.shelfTrackIndex + 1 < this.blockOffsets.length) {
return this.blockOffsets[this.shelfTrackIndex + 1];
}
else {
return this.blockOffsets[this.shelfTrackIndex];
}
}
getBottom() {
return this.blockOffsets[this.blockOffsets.length - 1];
}
splitTrack(trackIndex, blockOffset) {
const size = this.inlineSizes[trackIndex];
const offset = this.inlineOffsets[trackIndex];
const count = this.floatCounts[trackIndex];
this.blockOffsets.splice(trackIndex + 1, 0, blockOffset);
this.inlineSizes.splice(trackIndex, 0, size);
this.inlineOffsets.splice(trackIndex, 0, offset);
this.floatCounts.splice(trackIndex, 0, count);
}
splitIfShelfDropped() {
if (this.blockOffsets[this.shelfTrackIndex] !== this.shelfBlockOffset) {
this.splitTrack(this.shelfTrackIndex, this.shelfBlockOffset);
this.shelfTrackIndex += 1;
}
}
placeFloat(box, vacancy, cbLineLeft, cbLineRight) {
if (box.style.float === 'none') {
throw new Error('Tried to place float:none');
}
if (vacancy.blockOffset !== this.shelfBlockOffset) {
throw new Error('Assertion failed');
}
this.splitIfShelfDropped();
const borderArea = box.getBorderArea();
const startTrack = this.shelfTrackIndex;
const margins = box.getMarginsAutoIsZero();
const blockSize = borderArea.height + margins.blockStart + margins.blockEnd;
const blockEndOffset = this.shelfBlockOffset + blockSize;
let endTrack;
if (blockSize > 0) {
endTrack = this.getEndTrack(startTrack, this.shelfBlockOffset, blockSize);
if (this.blockOffsets[endTrack] !== blockEndOffset) {
this.splitTrack(endTrack - 1, blockEndOffset);
}
}
else {
endTrack = startTrack;
}
const cbOffset = box.style.float === 'left' ? vacancy.leftOffset : vacancy.rightOffset;
const cbLineSide = box.style.float === 'left' ? cbLineLeft : cbLineRight;
const marginOffset = box.style.float === 'left' ? margins.lineLeft : margins.lineRight;
const marginEnd = box.style.float === 'left' ? margins.lineRight : margins.lineLeft;
if (box.style.float === 'left') {
box.setInlinePosition(cbOffset - cbLineSide + marginOffset);
}
else {
const inlineSize = box.containingBlock.inlineSize;
const size = borderArea.inlineSize;
box.setInlinePosition(cbOffset - cbLineSide + inlineSize - marginOffset - size);
}
for (let track = startTrack; track < endTrack; track += 1) {
if (this.floatCounts[track] === 0) {
this.inlineOffsets[track] = cbOffset;
this.inlineSizes[track] = marginOffset + borderArea.width + marginEnd;
}
else {
this.inlineSizes[track] = cbOffset - this.inlineOffsets[track] + marginOffset + borderArea.width + marginEnd;
}
this.floatCounts[track] += 1;
}
this.items.push(box);
}
}
export class IfcVacancy {
leftOffset;
rightOffset;
inlineSize;
blockOffset;
leftFloatCount;
rightFloatCount;
static EPSILON = 1 / 64;
constructor(leftOffset, rightOffset, blockOffset, inlineSize, leftFloatCount, rightFloatCount) {
this.leftOffset = leftOffset;
this.rightOffset = rightOffset;
this.blockOffset = blockOffset;
this.inlineSize = inlineSize;
this.leftFloatCount = leftFloatCount;
this.rightFloatCount = rightFloatCount;
}
fits(inlineSize) {
return inlineSize - this.inlineSize < IfcVacancy.EPSILON;
}
hasFloats() {
return this.leftFloatCount > 0 || this.rightFloatCount > 0;
}
}
;
export class FloatContext {
bfc;
leftFloats;
rightFloats;
misfits;
constructor(bfc, blockOffset) {
this.bfc = bfc;
this.leftFloats = new FloatSide(blockOffset);
this.rightFloats = new FloatSide(blockOffset);
this.misfits = [];
}
boxStart() {
this.leftFloats.boxStart(this.bfc.cbBlockStart);
this.rightFloats.boxStart(this.bfc.cbBlockStart);
}
getVacancyForLine(blockOffset, blockSize) {
const leftInlineSpace = this.leftFloats.getOccupiedSpace(blockOffset, blockSize, -this.bfc.cbLineLeft);
const rightInlineSpace = this.rightFloats.getOccupiedSpace(blockOffset, blockSize, -this.bfc.cbLineRight);
const leftOffset = this.bfc.cbLineLeft + leftInlineSpace;
const rightOffset = this.bfc.cbLineRight + rightInlineSpace;
const inlineSize = this.bfc.inlineSize - leftOffset - rightOffset;
return new IfcVacancy(leftOffset, rightOffset, blockOffset, inlineSize, 0, 0);
}
getVacancyForBox(box, lineWidth) {
const float = box.style.float;
const floats = float === 'left' ? this.leftFloats : this.rightFloats;
const oppositeFloats = float === 'left' ? this.rightFloats : this.leftFloats;
const inlineOffset = float === 'left' ? -this.bfc.cbLineLeft : -this.bfc.cbLineRight;
const oppositeInlineOffset = float === 'left' ? -this.bfc.cbLineRight : -this.bfc.cbLineLeft;
const blockOffset = floats.shelfBlockOffset;
const blockSize = box.getBorderArea().height;
const startTrack = floats.shelfTrackIndex;
const endTrack = floats.getEndTrack(startTrack, blockOffset, blockSize);
const inlineSpace = floats.getSizeOfTracks(startTrack, endTrack, inlineOffset);
const [oppositeStartTrack, oppositeEndTrack] = oppositeFloats.getTrackRange(blockOffset, blockSize);
const oppositeInlineSpace = oppositeFloats.getSizeOfTracks(oppositeStartTrack, oppositeEndTrack, oppositeInlineOffset);
const leftOffset = this.bfc.cbLineLeft + (float === 'left' ? inlineSpace : oppositeInlineSpace);
const rightOffset = this.bfc.cbLineRight + (float === 'right' ? inlineSpace : oppositeInlineSpace);
const inlineSize = this.bfc.inlineSize - leftOffset - rightOffset - lineWidth;
const floatCount = floats.getFloatCountOfTracks(startTrack, endTrack);
const oppositeFloatCount = oppositeFloats.getFloatCountOfTracks(oppositeStartTrack, oppositeEndTrack);
const leftFloatCount = float === 'left' ? floatCount : oppositeFloatCount;
const rightFloatCount = float === 'left' ? oppositeFloatCount : floatCount;
return new IfcVacancy(leftOffset, rightOffset, blockOffset, inlineSize, leftFloatCount, rightFloatCount);
}
getLeftBottom() {
return this.leftFloats.getBottom();
}
getRightBottom() {
return this.rightFloats.getBottom();
}
getBothBottom() {
return Math.max(this.leftFloats.getBottom(), this.rightFloats.getBottom());
}
findLinePosition(blockOffset, blockSize, inlineSize) {
let [leftShelfIndex] = this.leftFloats.getTrackRange(blockOffset, blockSize);
let [rightShelfIndex] = this.rightFloats.getTrackRange(blockOffset, blockSize);
while (leftShelfIndex < this.leftFloats.inlineSizes.length ||
rightShelfIndex < this.rightFloats.inlineSizes.length) {
let leftOffset, rightOffset;
if (leftShelfIndex < this.leftFloats.inlineSizes.length) {
leftOffset = this.leftFloats.blockOffsets[leftShelfIndex];
}
else {
leftOffset = Infinity;
}
if (rightShelfIndex < this.rightFloats.inlineSizes.length) {
rightOffset = this.rightFloats.blockOffsets[rightShelfIndex];
}
else {
rightOffset = Infinity;
}
blockOffset = Math.max(blockOffset, Math.min(leftOffset, rightOffset));
const vacancy = this.getVacancyForLine(blockOffset, blockSize);
if (inlineSize <= vacancy.inlineSize)
return vacancy;
if (leftOffset <= rightOffset)
leftShelfIndex += 1;
if (rightOffset <= leftOffset)
rightShelfIndex += 1;
}
return this.getVacancyForLine(blockOffset, blockSize);
}
placeFloat(lineWidth, lineIsEmpty, box) {
if (box.style.float === 'none') {
throw new Error('Attempted to place float: none');
}
if (this.misfits.length) {
this.misfits.push(box);
}
else {
const side = box.style.float === 'left' ? this.leftFloats : this.rightFloats;
const oppositeSide = box.style.float === 'left' ? this.rightFloats : this.leftFloats;
if (box.style.clear === 'left' || box.style.clear === 'both') {
side.dropShelf(this.leftFloats.getBottom());
}
if (box.style.clear === 'right' || box.style.clear === 'both') {
side.dropShelf(this.rightFloats.getBottom());
}
const vacancy = this.getVacancyForBox(box, lineWidth);
const margins = box.getMarginsAutoIsZero();
const inlineSize = box.getBorderArea().width + margins.lineLeft + margins.lineRight;
if (vacancy.fits(inlineSize) || lineIsEmpty && !vacancy.hasFloats()) {
box.setBlockPosition(side.shelfBlockOffset + margins.blockStart - this.bfc.cbBlockStart);
side.placeFloat(box, vacancy, this.bfc.cbLineLeft, this.bfc.cbLineRight);
}
else {
const vacancy = this.getVacancyForBox(box, 0);
if (!vacancy.fits(inlineSize)) {
const count = box.style.float === 'left' ? vacancy.leftFloatCount : vacancy.rightFloatCount;
const oppositeCount = box.style.float === 'left' ? vacancy.rightFloatCount : vacancy.leftFloatCount;
if (count > 0) {
side.dropShelf(side.getNextTrackOffset());
}
else if (oppositeCount > 0) {
const [, trackIndex] = oppositeSide.getTrackRange(side.shelfBlockOffset);
if (trackIndex === oppositeSide.blockOffsets.length)
throw new Error('assertion failed');
side.dropShelf(oppositeSide.blockOffsets[trackIndex]);
} // else both counts are 0 so it will fit next time the line is empty
}
this.misfits.push(box);
}
}
}
consumeMisfits() {
while (this.misfits.length) {
const misfits = this.misfits;
this.misfits = [];
for (const box of misfits)
this.placeFloat(0, true, box);
}
}
dropShelf(blockOffset) {
this.leftFloats.dropShelf(blockOffset);
this.rightFloats.dropShelf(blockOffset);
}
postLine(line, didBreak) {
if (didBreak || this.misfits.length) {
this.dropShelf(this.bfc.cbBlockStart + line.blockOffset + line.height());
}
this.consumeMisfits();
}
// Float processing happens after every line, but some floats may be before
// all lines
preTextContent() {
this.consumeMisfits();
}
}
export class BlockContainer extends FormattingBox {
children;
static ATTRS = {
...FormattingBox.ATTRS,
isInline: Box.BITS.isInline,
isBfcRoot: Box.BITS.isBfcRoot
};
constructor(style, children, attrs) {
super(style, attrs);
this.children = children;
}
contribution(mode) {
const marginLineLeft = this.style.getMarginLineLeft(this);
const marginLineRight = this.style.getMarginLineRight(this);
const borderLineLeftWidth = this.style.getBorderLineLeftWidth(this);
const paddingLineLeft = this.style.getPaddingLineLeft(this);
const paddingLineRight = this.style.getPaddingLineRight(this);
const borderLineRightWidth = this.style.getBorderLineRightWidth(this);
let isize = this.style.getInlineSize(this);
let contribution = (marginLineLeft === 'auto' ? 0 : marginLineLeft)
+ borderLineLeftWidth
+ paddingLineLeft
+ paddingLineRight
+ borderLineRightWidth
+ (marginLineRight === 'auto' ? 0 : marginLineRight);
if (isize === 'auto') {
isize = 0;
if (this.isBlockContainerOfBlockContainers()) {
for (const child of this.children) {
isize = Math.max(isize, child.contribution(mode));
}
}
else if (this.isBlockContainerOfInlines()) {
const [ifc] = this.children;
if (ifc.shouldLayoutContent()) {
isize = ifc.paragraph.contribution(mode);
}
}
}
contribution += isize;
return contribution;
}
getLogSymbol() {
if (this.isFloat()) {
return '○︎';
}
else if (this.isInlineLevel()) {
return '▬';
}
else {
return '◼︎';
}
}
logName(log) {
if (this.isAnonymous())
log.dim();
if (this.isBfcRoot())
log.underline();
log.text(`Block ${this.id}`);
log.reset();
}
getContainingBlockToContent() {
const inlineSize = this.containingBlock.inlineSizeForPotentiallyOrthogonal(this);
const borderBlockStartWidth = this.style.getBorderBlockStartWidth(this);
const paddingBlockStart = this.style.getPaddingBlockStart(this);
const borderArea = this.getBorderArea();
const contentArea = this.getContentArea();
const bLineLeft = borderArea.lineLeft;
const blockStart = borderBlockStartWidth + paddingBlockStart;
const cInlineSize = contentArea.inlineSize;
const borderLineLeftWidth = this.style.getBorderLineLeftWidth(this);
const paddingLineLeft = this.style.getPaddingLineLeft(this);
const lineLeft = bLineLeft + borderLineLeftWidth + paddingLineLeft;
const lineRight = inlineSize - lineLeft - cInlineSize;
return { blockStart, lineLeft, lineRight };
}
getLastBaseline() {
const stack = [{ block: this, offset: 0 }];
while (stack.length) {
const { block, offset } = stack.pop();
if (block.isBlockContainerOfInlines()) {
const [ifc] = block.children;
const linebox = ifc.paragraph.lineboxes.at(-1);
if (linebox)
return offset + linebox.blockOffset + linebox.ascender;
}
if (block.isBlockContainerOfBlockContainers()) {
const parentOffset = offset;
for (const child of block.children) {
if (child.isBlockContainer()) {
const offset = parentOffset
+ child.getBorderArea().blockStart
+ child.style.getBorderBlockStartWidth(child);
+child.style.getPaddingBlockStart(child);
stack.push({ block: child, offset });
}
}
}
}
}
isBlockContainer() {
return true;
}
isInlineLevel() {
return Boolean(this.bitfield & Box.BITS.isInline);
}
isBfcRoot() {
return Boolean(this.bitfield & Box.BITS.isBfcRoot);
}
loggingEnabled() {
return Boolean(this.bitfield & Box.BITS.enableLogging);
}
isBlockContainerOfInlines() {
return Boolean(this.children.length && this.children[0].isIfcInline());
}
canCollapseThrough() {
const blockSize = this.style.getBlockSize(this);
if (blockSize !== 'auto' && blockSize !== 0)
return false;
if (this.isBlockContainerOfInlines()) {
const [ifc] = this.children;
return !ifc.hasText();
}
else {
return this.children.length === 0;
}
}
isBlockContainerOfBlockContainers() {
return !this.isBlockContainerOfInlines();
}
propagate(parent) {
super.propagate(parent);
if (this.isInlineLevel()) {
// TODO: and not absolutely positioned
parent.bitfield |= Box.BITS.hasInlineBlocks;
}
}
doTextLayout(ctx) {
if (!this.isBlockContainerOfInlines())
throw new Error('Children are block containers');
const [ifc] = this.children;
const blockSize = this.style.getBlockSize(this);
ifc.doTextLayout(ctx);
if (blockSize === 'auto')
this.setBlockSize(ifc.paragraph.height);
}
hasBackground() {
return this.style.hasPaint();
}
hasForeground() {
return false;
}
}
// §10.3.3
function doInlineBoxModelForBlockBox(box) {
const cInlineSize = box.containingBlock.inlineSizeForPotentiallyOrthogonal(box);
const inlineSize = box.getDefiniteInnerInlineSize();
let marginLineLeft = box.style.getMarginLineLeft(box);
let marginLineRight = box.style.getMarginLineRight(box);
// Paragraphs 2 and 3
if (inlineSize !== undefined) {
const borderLineLeftWidth = box.style.getBorderLineLeftWidth(box);
const paddingLineLeft = box.style.getPaddingLineLeft(box);
const paddingLineRight = box.style.getPaddingLineRight(box);
const borderLineRightWidth = box.style.getBorderLineRightWidth(box);
const specifiedInlineSize = inlineSize
+ borderLineLeftWidth
+ paddingLineLeft
+ paddingLineRight
+ borderLineRightWidth
+ (marginLineLeft === 'auto' ? 0 : marginLineLeft)
+ (marginLineRight === 'auto' ? 0 : marginLineRight);
// Paragraph 2: zero out auto margins if specified values sum to a length
// greater than the containing block's width.
if (specifiedInlineSize > cInlineSize) {
if (marginLineLeft === 'auto')
marginLineLeft = 0;
if (marginLineRight === 'auto')
marginLineRight = 0;
}
if (marginLineLeft !== 'auto' && marginLineRight !== 'auto') {
// Paragraph 3: check over-constrained values. This expands the right
// margin in LTR documents to fill space, or, if the above scenario was
// hit, it makes the right margin negative.
if (box.directionAsParticipant === 'ltr') {
marginLineRight = cInlineSize - (specifiedInlineSize - marginLineRight);
}
else {
marginLineLeft = cInlineSize - (specifiedInlineSize - marginLineRight);
}
}
else { // one or both of the margins is auto, specifiedWidth < cb width
if (marginLineLeft === 'auto' && marginLineRight !== 'auto') {
// Paragraph 4: only auto value is margin-left
marginLineLeft = cInlineSize - specifiedInlineSize;
}
else if (marginLineRight === 'auto' && marginLineLeft !== 'auto') {
// Paragraph 4: only auto value is margin-right
marginLineRight = cInlineSize - specifiedInlineSize;
}
else {
// Paragraph 6: two auto values, center the content
const margin = (cInlineSize - specifiedInlineSize) / 2;
marginLineLeft = marginLineRight = margin;
}
}
}
// Paragraph 5: auto width
if (inlineSize === undefined) {
if (marginLineLeft === 'auto')
marginLineLeft = 0;
if (marginLineRight === 'auto')
marginLineRight = 0;
}
assumePx(marginLineLeft);
assumePx(marginLineRight);
box.setInlinePosition(marginLineLeft);
box.setInlineOuterSize(cInlineSize - marginLineLeft - marginLineRight);
}
// §10.6.3
function doBlockBoxModelForBlockBox(box) {
const blockSize = box.style.getBlockSize(box);
if (blockSize === 'auto') {
if (box.children.length === 0) {
box.setBlockSize(0); // Case 4
}
else {
// Cases 1-4 should be handled by doBoxPositioning, where margin
// calculation happens. These bullet points seem to be re-phrasals of
// margin collapsing in CSS 2.2 § 8.3.1 at the very end. If I'm wrong,
// more might need to happen here.
}
}
else {
box.setBlockSize(blockSize);
}
}
function layoutBlockBoxInner(box, ctx) {
const containingBfc = ctx.bfc;
const cctx = { ...ctx };
let establishedBfc;
if (box.isBfcRoot()) {
const inlineSize = box.getContentArea().inlineSize;
cctx.bfc = new BlockFormattingContext(inlineSize);
establishedBfc = cctx.bfc;
}
containingBfc?.boxStart(box, cctx); // Assign block position if it's an IFC
// Child flow is now possible
if (box.isBlockContainerOfInlines()) {
if (containingBfc) {
// text layout happens in bfc.boxStart
}
else {
box.doTextLayout(cctx);
}
}
else if (box.isBlockContainerOfBlockContainers()) {
for (const child of box.children)
layoutBlockLevelBox(child, cctx);
}
else {
throw new Error(`Unknown box type: ${box.id}`);
}
if (establishedBfc) {
establishedBfc.finalize(box);
if (establishedBfc.fctx) {
if (box.loggingEnabled()) {
console.log('Left floats');
console.log(establishedBfc.fctx.leftFloats.repr());
console.log('Right floats');
console.log(establishedBfc.fctx.rightFloats.repr());
console.log();
}
}
}
containingBfc?.boxEnd(box);
}
function layoutBlockBox(box, ctx) {
box.fillAreas();
doInlineBoxModelForBlockBox(box);
doBlockBoxModelForBlockBox(box);
layoutBlockBoxInner(box, ctx);
}
function layoutReplacedBox(box, ctx) {
box.fillAreas();
doInlineBoxModelForBlockBox(box);
box.setBlockSize(box.getDefiniteInnerBlockSize());
ctx.bfc.boxAtomic(box);
}
export function layoutBlockLevelBox(box, ctx) {
if (box.isBlockContainer()) {
layoutBlockBox(box, ctx);
}
else {
layoutReplacedBox(box, ctx);
}
}
function doInlineBoxModelForFloatBox(box, inlineSize) {
box.setInlineOuterSize(inlineSize);
}
function doBlockBoxModelForFloatBox(box) {
const size = box.getDefiniteInnerBlockSize();
if (size !== undefined)
box.setBlockSize(size);
}
export function layoutFloatBox(box, ctx) {
const cctx = { ...ctx, bfc: undefined };
box.fillAreas();
let inlineSize = box.getDefiniteOuterInlineSize();
if (inlineSize === undefined) {
const minContent = box.contribution('min-content');
const maxContent = box.contribution('max-content');
const availableSpace = box.containingBlock.inlineSize;
const marginLineLeft = box.style.getMarginLineLeft(box);
const marginLineRight = box.style.getMarginLineRight(box);
inlineSize = Math.max(minContent, Math.min(maxContent, availableSpace));
if (marginLineLeft !== 'auto')
inlineSize -= marginLineLeft;
if (marginLineRight !== 'auto')
inlineSize -= marginLineRight;
}
doInlineBoxModelForFloatBox(box, inlineSize);
doBlockBoxModelForFloatBox(box);
if (box.isBlockContainer()) {
layoutBlockBoxInner(box, cctx);
}
else {
// replaced boxes have no layout. they were sized by doInline/Block above
}
}
export class Break extends RenderItem {
className = 'break';
isBreak() {
return true;
}
getLogSymbol() {
return '⏎';
}
logName(log) {
log.text('BR');
}
propagate(parent) {
parent.bitfield |= Box.BITS.hasBreakInlineOrReplaced;
}
}
export class Inline extends Box {
children;
nshaped;
metrics;
start;
end;
constructor(start, end, style, children, attrs) {
super(style, attrs);
this.start = start;
this.end = end;
this.children = children;
this.nshaped = 0;
this.metrics = EmptyInlineMetrics;
}
prelayoutPreorder(ctx) {
super.prelayoutPreorder(ctx);
this.nshaped = 0;
this.metrics = getFontMetrics(this);
}
propagate(parent) {
super.propagate(parent);
if (parent.isInline()) {
parent.bitfield |= Box.BITS.hasBreakInlineOrReplaced;
if (this.style.backgroundColor.a !== 0 || this.style.hasBorderArea()) {
parent.bitfield |= Box.BITS.hasPaintedInlines;
}
if (!parent.hasSizedInline() &&
(this.hasLineLeftGap() || this.hasLineRightGap())) {
parent.bitfield |= Box.BITS.hasSizedInline;
}
if (!parent.hasColoredInline() && (this.style.color.r !== parent.style.color.r ||
this.style.color.g !== parent.style.color.g ||
this.style.color.b !== parent.style.color.b ||
this.style.color.a !== parent.style.color.a)) {
parent.bitfield |= Box.BITS.hasColoredInline;
}
// Bits that propagate to Inline propagate again if the parent is Inline
parent.bitfield |= (this.bitfield & Box.PROPAGATES_TO_INLINE_BITS);
}
}
hasText() {
return this.bitfield & Box.BITS.hasText;
}
hasSoftWrap() {
return this.bitfield & Box.BITS.hasSoftWrap;
}
hasCollapsibleWs() {
return this.bitfield & Box.BITS.hasCollapsibleWs;
}
hasFloatOrReplaced() {
return this.bitfield & Box.BITS.hasFloatOrReplaced;
}
hasBreakOrInlineOrReplaced() {
return this.bitfield & Box.BITS.hasBreakInlineOrReplaced;
}
hasComplexText() {
return this.bitfield & Box.BITS.hasComplexText;
}
hasSoftHyphen() {
return this.bitfield & Box.BITS.hasSoftHyphen;
}
hasNewlines() {
return this.bitfield & Box.BITS.hasNewlines;
}
hasPaintedInlines() {
return this.bitfield & Box.BITS.hasPaintedInlines;
}
hasInlineBlocks() {
return this.bitfield & Box.BITS.hasInlineBlocks;
}
hasSizedInline() {
return this.bitfield & Box.BITS.hasSizedInline;
}
hasColoredInline() {
return this.bitfield & Box.BITS.hasColoredInline;
}
hasLineLeftGap() {
return this.style.hasLineLeftGap(this);
}
hasLineRightGap() {
return this.style.hasLineRightGap(this);
}
getInlineSideSize(side) {
if (this.directionAsParticipant === 'ltr' && side === 'pre' ||
this.directionAsParticipant === 'rtl' && side === 'post') {
const marginLineLeft = this.style.getMarginLineLeft(this);
return (marginLineLeft === 'auto' ? 0 : marginLineLeft)
+ this.style.getBorderLineLeftWidth(this)
+ this.style.getPaddingLineLeft(this);
}
else {
const marginLineRight = this.style.getMarginLineRight(this);
return (marginLineRight === 'auto' ? 0 : marginLineRight)
+ this.style.getBorderLineRightWidth(this)
+ this.style.getPaddingLineRight(this);
}
}
isInline() {
return true;
}
isInlineLevel() {
return true;
}
getLogSymbol() {
return '▭';
}
logName(log) {
if (this.isAnonymous())
log.dim();
if (this.isIfcInline())
log.underline();
log.text(`Inline ${this.id}`);
log.reset();
}
absolutify() {
// noop: inlines are painted in a different way than block containers
}
hasBackground() {
return false;
}
hasForeground() {
return this.style.hasPaint();
}
}
export class IfcInline extends Inline {
children;
text;
paragraph;
constructor(style, text, children, attrs) {
super(0, text.length, style, children, Box.ATTRS.isAnonymous | attrs);
this.children = children;
this.text = text;
this.paragraph = createEmptyParagraph(this);
}
isIfcInline() {
return true;
}
loggingEnabled() {
return Boolean(this.bitfield & Box.BITS.enableLogging);
}
prelayoutPostorder(ctx) {
if (this.shouldLayoutContent()) {
if (this.hasCollapsibleWs())
collapseWhitespace(this);
this.paragraph.destroy();
this.paragraph = createParagraph(this);
this.paragraph.shape();
}
}
positionItemsPostlayout() {
const inlineShifts = new Map();
const stack = [this];
let dx = 0;
let dy = 0;
let itemIndex = 0;
while (stack.length) {
const box = stack.pop();
if ('sentinel' in box) {
while (itemIndex < this.paragraph.items.length &&
this.paragraph.items[itemIndex].offset < box.sentinel.end) {
const item = this.paragraph.items[itemIndex];
item.x += this.containingBlock.x;
item.y += this.containingBlock.y;
if (item.end() > box.sentinel.start) {
item.x += dx;
item.y += dy;
}
itemIndex++;
}
if (box.sentinel.style.position === 'relative') {
dx -= box.sentinel.getRelativeHorizontalShift();
dy -= box.sentinel.getRelativeVerticalShift();
}
}
else if (box.isInline()) {
stack.push({ sentinel: box });
for (let i = box.children.length - 1; i >= 0; i--) {
stack.push(box.children[i]);
}
if (box.style.position === 'relative') {
dx += box.getRelativeHorizontalShift();
dy += box.getRelativeVerticalShift();
}
inlineShifts.set(box, { dx, dy });
}
else if (box.isFormattingBox()) {
const borderArea = box.getBorderArea();
// floats or inline-blocks
borderArea.x += dx;
borderArea.y += dy;
}
}
for (const [inline, backgrounds] of this.paragraph.backgroundBoxes) {
const { dx, dy } = inlineShifts.get(inline);
for (const background of backgrounds) {
background.blockOffset += this.containingBlock.y + dy;
background.start += this.containingBlock.x + dx;
background.end += this.containingBlock.x + dx;
}
}
}
postlayoutPreorder() {
this.paragraph.destroy();
if (this.shouldLayoutContent()) {
this.positionItemsPostlayout();
}
super.postlayoutPreorder();
}
shouldLayoutContent() {
return this.hasText()
|| this.hasSizedInline()
|| this.hasFloatOrReplaced()
|| this.hasInlineBlocks();
}
doTextLayout(ctx) {
if (this.shouldLayoutContent()) {
this.paragraph.createLineboxes(ctx);
this.paragraph.positionItems(ctx);
}
}
}
// So far this is always backed by an image (<img>) which, like browsers, always
// has a natural width and height and always has a ratio. In the browsers it's
// something like 20x20 and 1:1, but in dropflow, it's 0x0 and 1:1, since we
// prefer not to paint anything.
//
// If there is ever another kind of replaced element, the hard-coding should be
// replaced with an member that adheres to an interface.
export class ReplacedBox extends FormattingBox {
src;
constructor(style, src) {
super(style, 0);
this.src = src;
}
isReplacedBox() {
return true;
}
logName(log) {
log.text("Replaced " + this.id);
}
getLogSymbol() {
return "◼️";
}
hasBackground() {
return this.style.hasPaint();
}
hasForeground() {
return true;
}
getImage() {
return this.src === '' ? undefined : getImage(this.src);
}
getIntrinsicIsize() {
return (this.getImage()?.width ?? 0) * this.style.zoom;
}
getIntrinsicBsize() {
return (this.getImage()?.height ?? 0) * this.style.zoom;
}
getRatio() {
const image = this.getImage();
return image ? (image.width / image.height || 1) : 1;
}
propagate(parent) {
super.propagate(parent);
parent.bitfield |= Box.BITS.hasBreakInlineOrReplaced;
parent.bitfield |= Box.BITS.hasFloatOrReplaced;
}
contribution() {
const marginLineLeft = this.style.getMarginLineLeft(this);
const marginLineRight = this.style.getMarginLineLeft(this);
const borderLineLeftWidth = this.style.getBorderLineLeftWidth(this);
const paddingLineLeft = this.style.getPaddingLineLeft(this);
const paddingLineRight = this.style.getPaddingLineRight(this);
const borderLineRightWidth = this.style.getBorderLineRightWidth(this);
let isize = this.style.getInlineSize(this);
let contribution = (marginLineLeft === 'auto' ? 0 : marginLineLeft)
+ borderLineLeftWidth
+ paddingLineLeft
+ paddingLineRight
+ borderLineRightWidth
+ (marginLineRight === 'auto' ? 0 : marginLineRight);
if (isize === 'auto')
isize = this.getIntrinsicIsize();
contribution += isize;
return contribution;
}
getLastBaseline() {
return undefined;
}
getDefiniteInnerInlineSize() {
let isize = this.style.getInlineSize(this);
if (isize === 'auto') {
let bsize;
if ((bsize = this.style.getBlockSize(this)) !== 'auto') { // isize from bsize
return bsize * this.getRatio();
}
else {
return this.getIntrinsicIsize();
}
}
else {
return isize;
}
}
getDefiniteInnerBlockSize() {
const bsize = this.style.getBlockSize(this);
let isize;
if (bsize !== 'auto') {
return bsize;
}
else if ((isize = this.style.getInlineSize(this)) !== 'auto') { // bsize from isize
return isize / this.getRatio();
}
else {
return this.getIntrinsicBsize();
}
}
}
// break: an actual forced break; <br>.
//
// breakspot: the location in between spans at which to break if needed. for
// example, `abc </span><span>def ` would emit breakspot between the closing
// ("post") and opening ("pre") span
//
// breakop: a break opportunity introduced by an inline-block (these are unique
// compared to text break opportunities because they do not exist on character
// positions). one of thse comes before and one after an inline-block
export function createInlineIterator(inline) {
const stack = inline.children.slice().reverse();
const buffered = [];
let minlevel = 0;
let level = 0;
let bk = 0;
let shouldFlushBreakop = false;
function next() {
if (!buffered.length) {
while (stack.length) {
const item = stack.pop();
if ('post' in item) {
level -= 1;
buffered.push({ state: 'post', item: item.post });
if (level <= minlevel) {
bk = buffered.length;
minlevel = level;
}
}
else if (item.isInline()) {
level += 1;
buffered.push({ state: 'pre', item });
stack.push({ post: item });
for (let i = item.children.length - 1; i >= 0; --i)
stack.push(it