UNPKG

dropflow

Version:

A small CSS2 document renderer built from specifications

1,292 lines 58.6 kB
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