From 40bff39b0ddb4e933c9e2b88622462172b6d6d4d Mon Sep 17 00:00:00 2001 From: Endika Date: Wed, 29 Jul 2026 16:56:28 +0200 Subject: [PATCH 1/7] implement position: absolute --- README.md | 2 +- src/api.ts | 3 +- src/layout-box.ts | 92 ++++++++- src/layout-flow.ts | 243 +++++++++++++++++++++- src/layout-text.ts | 19 +- src/style.ts | 57 ++++- test/ci.js | 1 + test/position-absolute.spec.js | 365 +++++++++++++++++++++++++++++++++ 8 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 test/position-absolute.spec.js diff --git a/README.md b/README.md index 2636ae8..8be3713 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Following are rules that work or will work soon. Shorthand properties are not li | margin | `em`, `px`, `%`, `cm` etc, `auto` | ✅‍ Works | | max-height, max-width,
min-height, min-width | `em`, `px`, `%`, `cm` etc, `auto` | 🚧‍ Planned | | padding | `em`, `px`, `%`, `cm` etc | ✅‍ Works | -| position | `absolute` | 🚧‍ Planned | +| position | `absolute` | ✅‍ Works | | position | `fixed` | 🚧‍ Planned | | position | `relative` | ✅‍ Works | | transform | | 🚧‍ Planned | diff --git a/src/api.ts b/src/api.ts index f8fbc14..a99da65 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,7 +2,7 @@ import '#register-default-environment'; import {HTMLElement, TextNode} from './dom.ts'; import {DeclaredStyle, getOriginStyle, computeElementStyle} from './style.ts'; import {fonts, FontFace, createFaceFromTables, createFaceFromTablesSync, onLoadWalkerTextNodeForFonts, onLoadWalkerElementForFonts} from './text-font.ts'; -import {generateBlockContainer, layoutBlockLevelBox} from './layout-flow.ts'; +import {generateBlockContainer, layoutBlockLevelBox, layoutAbsolutes} from './layout-flow.ts'; import HtmlPaintBackend from './paint-html.ts'; import SvgPaintBackend from './paint-svg.ts'; import CanvasPaintBackend from './paint-canvas.ts'; @@ -52,6 +52,7 @@ export function reflow(layout: Layout, width = 640, height = 480) { prelayout(layout, initialContainingBlock); layoutBlockLevelBox(layout, layout.root(), {}); + layoutAbsolutes(layout, {}); postlayout(layout); } diff --git a/src/layout-box.ts b/src/layout-box.ts index 91888b2..227b14b 100644 --- a/src/layout-box.ts +++ b/src/layout-box.ts @@ -419,6 +419,9 @@ export abstract class Box extends TreeNode { // TODO: Inlines don't use this yet. Get rid of paragraph's backgroundBoxes // and use normal inline areas instead, with fragmentation const borderArea = this.getBorderArea(); + if (this.style.position === 'absolute') { + shiftToStaticPosition(layout, this); + } if (this.style.position === 'relative') { const containingBlock = this.getContainingBlock(); borderArea.x += this.getRelativeHorizontalShift(containingBlock); @@ -555,7 +558,11 @@ export abstract class FormattingBox extends Box { } isOutOfFlow() { - return this.style.float !== 'none'; // TODO: or position === 'absolute' + return this.style.float !== 'none' || this.style.position === 'absolute'; + } + + isAbsolute() { + return this.style.position === 'absolute'; } propagate(parent: Box) { @@ -634,6 +641,21 @@ export class BoxArea { this.parent = p; } + blockSizeForPotentiallyOrthogonal(box: FormattingBox) { + if (!this.parent) return this.blockSize; // root area + if (!this.box.isBlockContainer()) return this.blockSize; // cannot be orthogonal + const cb1 = this.box.getContainingBlock(); + const cb2 = box.getContainingBlock(); + if ( + (this.box.getWritingModeAsParticipant(cb1) === 'horizontal-tb') !== + (box.getWritingModeAsParticipant(cb2) === 'horizontal-tb') + ) { + return this.inlineSize; + } else { + return this.blockSize; + } + } + inlineSizeForPotentiallyOrthogonal(box: FormattingBox) { if (!this.parent) return this.inlineSize; // root area if (!this.box.isBlockContainer()) return this.inlineSize; // cannot be orthogonal @@ -715,6 +737,49 @@ export class BoxArea { } } +/** + * An absolutely positioned box whose insets are `auto` on an axis sits at the + * position it would have had in flow, which is known in the axes of its in-flow + * parent, not of its containing block. Both of those are ancestors, so both are + * already absolute when postlayout reaches this box, and the offset can be + * mapped through physical coordinates. + */ +function shiftToStaticPosition(layout: Layout, box: Box) { + const staticPosition = layout.staticPositions.get(box); + if (!staticPosition) return; + const {area, blockOffset, inlineOffset, needsBlock, needsLineLeft} = staticPosition; + if (!needsBlock && !needsLineLeft) return; + const borderArea = box.getBorderArea(); + const containingBlock = borderArea.parent; + if (!containingBlock) throw new Error('Assertion failed'); + const parentWritingMode = area.getEstablishedWritingMode(); + let x, y; + + if (parentWritingMode === 'vertical-lr') { + x = area.x + blockOffset; + y = area.y + inlineOffset; + } else if (parentWritingMode === 'vertical-rl') { + x = area.x + area.width - blockOffset; + y = area.y + inlineOffset; + } else { // 'horizontal-tb' + x = area.x + inlineOffset; + y = area.y + blockOffset; + } + + const writingMode = containingBlock.getEstablishedWritingMode(); + + if (writingMode === 'vertical-lr') { + if (needsBlock) borderArea.blockStart += x - containingBlock.x; + if (needsLineLeft) borderArea.lineLeft += y - containingBlock.y; + } else if (writingMode === 'vertical-rl') { + if (needsBlock) borderArea.blockStart += containingBlock.x + containingBlock.width - x; + if (needsLineLeft) borderArea.lineLeft += y - containingBlock.y; + } else { // 'horizontal-tb' + if (needsBlock) borderArea.blockStart += y - containingBlock.y; + if (needsLineLeft) borderArea.lineLeft += x - containingBlock.x; + } +} + export function prelayout(layout: Layout, icb: BoxArea) { const parents: (BlockContainer | Inline)[] = []; const ifcs: BlockContainerOfInlines[] = []; @@ -836,14 +901,39 @@ export function log(layout: Layout, logger?: Logger, options?: TreeLogOptions) { logger.flush(); } +/** + * Where an absolutely positioned box would have been if it were in flow, in the + * logical axes of `area`, which is the content area of its in-flow parent. Only + * used when an inset on that axis is `auto` (CSS 2.2 § 10.3.7, § 10.6.4). + */ +export interface StaticPosition { + area: BoxArea; + blockOffset: number; + inlineOffset: number; + needsBlock: boolean; + needsLineLeft: boolean; +} + export class Layout { tree: InlineLevel[]; + staticPositions: Map; constructor(tree: InlineLevel[]) { this.tree = tree; + this.staticPositions = new Map(); } root() { return this.tree[0] as BlockContainer; } + + setStaticPosition(box: Box, area: BoxArea, blockOffset: number, inlineOffset: number) { + this.staticPositions.set(box, { + area, + blockOffset, + inlineOffset, + needsBlock: false, + needsLineLeft: false + }); + } } diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 217a1fb..db03a38 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -17,7 +17,7 @@ import {getImage} from './layout-image.ts'; import {Box, FormattingBox, TreeNode, Layout} from './layout-box.ts'; import type {InlineMetrics, ShapedItem, InlineFragment} from './layout-text.ts'; -import type {BoxArea, PrelayoutContext} from './layout-box.ts'; +import type {BoxArea, PrelayoutContext, StaticPosition} from './layout-box.ts'; import type {AllocatedUint16Array} from './text-harfbuzz.ts'; function assumePx(v: any): asserts v is number { @@ -813,7 +813,6 @@ export abstract class BlockContainerBase extends FormattingBox { super.propagate(parent); if (this.isInlineLevel()) { - // TODO: and not absolutely positioned parent.bitfield |= Box.BITS.hasInlineBlocks; } } @@ -1067,6 +1066,24 @@ function doBlockBoxModelForBlockBox(layout: Layout, box: BlockContainer) { } } +/** + * An inline formatting context with nothing to lay out still has to answer for + * the absolutely positioned boxes inside it, which would have started at its + * content edge (CSS 2.2 § 10.3.7, § 10.6.4). + */ +function setStaticPositionsWithoutLines(layout: Layout, box: BlockContainerOfInlines) { + const contentArea = box.getContentArea(); + const ltr = box.style.direction === 'ltr'; + + for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { + const item = layout.tree[i]; + if (item.isFormattingBox() && item.isAbsolute()) { + layout.setStaticPosition(item, contentArea, 0, ltr ? 0 : contentArea.inlineSize); + i = item.treeFinal; + } + } +} + function layoutBlockBoxInner( layout: Layout, box: BlockContainer, @@ -1086,7 +1103,11 @@ function layoutBlockBoxInner( // Child flow is now possible if (box.isBlockContainerOfInlines()) { - if (containingBfc) { + if (!box.shouldLayoutContent(layout)) { + // No lines will be built, so the boxes taken out of this flow would all + // have started at the content edge + setStaticPositionsWithoutLines(layout, box); + } else if (containingBfc) { // text layout happens in bfc.boxStart } else { box.doTextLayout(layout, cctx); @@ -1239,6 +1260,221 @@ export function layoutFloatBox( } } +/** + * Absolutely positioned boxes are laid out after the flow they were taken out + * of, because their containing block - the padding area of the nearest + * positioned ancestor, or the initial containing block - only has a size once + * that ancestor is done. Tree order means an ancestor is always resolved before + * a box it contains. + */ +export function layoutAbsolutes(layout: Layout, ctx: LayoutContext) { + for (let i = 1; i < layout.tree.length; i++) { + const item = layout.tree[i]; + if (item.isFormattingBox() && item.isAbsolute()) { + layoutAbsoluteBox(layout, item, ctx); + } + } +} + +function getShrinkToFitInlineSize( + layout: Layout, + box: BlockLevel, + available: number +) { + const minContent = layoutContribution(layout, box, 'min-content'); + const maxContent = layoutContribution(layout, box, 'max-content'); + return Math.max(minContent, Math.min(maxContent, available)); +} + +// § 10.3.7 +function doInlineBoxModelForAbsoluteBox( + layout: Layout, + box: BlockLevel, + staticPosition: StaticPosition | undefined +) { + const containingBlock = box.getContainingBlock(); + const cInlineSize = containingBlock.inlineSizeForPotentiallyOrthogonal(box); + const ltr = box.getDirectionAsParticipant(containingBlock) === 'ltr'; + const insetLineLeft = box.style.getInsetLineLeft(containingBlock); + const insetLineRight = box.style.getInsetLineRight(containingBlock); + const styleMarginLineLeft = box.style.getMarginLineLeft(containingBlock); + const styleMarginLineRight = box.style.getMarginLineRight(containingBlock); + const definiteInlineSize = box.getDefiniteOuterInlineSize(containingBlock); + let marginLineLeft = styleMarginLineLeft === 'auto' ? 0 : styleMarginLineLeft; + let marginLineRight = styleMarginLineRight === 'auto' ? 0 : styleMarginLineRight; + let sizedFromInsets = false; + let inlineSize; + + if (definiteInlineSize !== undefined) { + inlineSize = definiteInlineSize; + } else if (box.isReplacedBox()) { + inlineSize = box.getIntrinsicIsize(); + } else if (insetLineLeft !== 'auto' && insetLineRight !== 'auto') { + // Paragraph 3: both insets are given, so the size is what they leave behind + // and auto margins are zero + inlineSize = Math.max(0, cInlineSize + - insetLineLeft + - insetLineRight + - marginLineLeft + - marginLineRight); + sizedFromInsets = true; + } else { + // Paragraphs 1 and 5: shrink-to-fit over the space the insets leave behind + const available = cInlineSize + - (insetLineLeft === 'auto' ? 0 : insetLineLeft) + - (insetLineRight === 'auto' ? 0 : insetLineRight); + inlineSize = getShrinkToFitInlineSize(layout, box, available) + - marginLineLeft + - marginLineRight; + } + + if (!sizedFromInsets && insetLineLeft !== 'auto' && insetLineRight !== 'auto') { + // Paragraph 2: the equation is solvable, so what is left over goes to the + // margins that are auto + const rest = cInlineSize - insetLineLeft - insetLineRight - inlineSize; + + if (styleMarginLineLeft === 'auto' && styleMarginLineRight === 'auto') { + if (rest < 0) { + // Equal margins would be negative, so the line-start one is dropped + marginLineLeft = ltr ? 0 : rest; + marginLineRight = ltr ? rest : 0; + } else { + marginLineLeft = marginLineRight = rest / 2; + } + } else if (styleMarginLineLeft === 'auto') { + marginLineLeft = rest - marginLineRight; + } else if (styleMarginLineRight === 'auto') { + marginLineRight = rest - marginLineLeft; + } + // Otherwise the values are over-constrained. The line-right inset is the + // one ignored in ltr and the line-left one in rtl, which is what falls out + // of positioning against the retained side below + } + + box.setInlineOuterSize(containingBlock, inlineSize); + + if (insetLineLeft !== 'auto' && (insetLineRight === 'auto' || ltr)) { + box.setInlinePosition(insetLineLeft + marginLineLeft); + } else if (insetLineRight !== 'auto') { + box.setInlinePosition(cInlineSize - insetLineRight - marginLineRight - inlineSize); + } else if (staticPosition) { + // Paragraphs 1 and 4: both insets are auto, so the box stays where it would + // have been. In rtl it is the line-right margin edge that was recorded + staticPosition.needsLineLeft = true; + box.setInlinePosition(ltr ? marginLineLeft : -(inlineSize + marginLineRight)); + } else { + box.setInlinePosition(marginLineLeft); + } +} + +// § 10.6.4 +interface AbsoluteBlockAxis { + usesContentBlockSize: boolean; + blockSize: number | undefined; + insetBlockStart: number | 'auto'; + insetBlockEnd: number | 'auto'; + cBlockSize: number; +} + +function doBlockBoxModelForAbsoluteBox(box: BlockLevel): AbsoluteBlockAxis { + const containingBlock = box.getContainingBlock(); + const cBlockSize = containingBlock.blockSizeForPotentiallyOrthogonal(box); + const insetBlockStart = box.style.getInsetBlockStart(containingBlock); + const insetBlockEnd = box.style.getInsetBlockEnd(containingBlock); + const marginBlockStart = box.style.getMarginBlockStart(containingBlock); + const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); + let blockSize = box.getDefiniteInnerBlockSize(containingBlock); + let usesContentBlockSize = blockSize === undefined; + + if ( + blockSize === undefined && + insetBlockStart !== 'auto' && + insetBlockEnd !== 'auto' + ) { + // Paragraph 5: an auto size is what the two insets leave behind + const borderBlockStartWidth = box.style.getBorderBlockStartWidth(containingBlock); + const paddingBlockStart = box.style.getPaddingBlockStart(containingBlock); + const paddingBlockEnd = box.style.getPaddingBlockEnd(containingBlock); + const borderBlockEndWidth = box.style.getBorderBlockEndWidth(containingBlock); + + blockSize = Math.max(0, cBlockSize + - insetBlockStart + - insetBlockEnd + - (marginBlockStart === 'auto' ? 0 : marginBlockStart) + - (marginBlockEnd === 'auto' ? 0 : marginBlockEnd) + - borderBlockStartWidth + - paddingBlockStart + - paddingBlockEnd + - borderBlockEndWidth); + usesContentBlockSize = false; + } + + if (blockSize !== undefined) box.setBlockSize(containingBlock, blockSize); + + return {usesContentBlockSize, blockSize, insetBlockStart, insetBlockEnd, cBlockSize}; +} + +function setBlockPositionForAbsoluteBox( + box: BlockLevel, + axis: AbsoluteBlockAxis, + staticPosition: StaticPosition | undefined +) { + const containingBlock = box.getContainingBlock(); + const {insetBlockStart, insetBlockEnd, cBlockSize} = axis; + const outerBlockSize = box.getBorderArea().blockSize; + const styleMarginBlockStart = box.style.getMarginBlockStart(containingBlock); + const styleMarginBlockEnd = box.style.getMarginBlockEnd(containingBlock); + let marginBlockStart = styleMarginBlockStart === 'auto' ? 0 : styleMarginBlockStart; + let marginBlockEnd = styleMarginBlockEnd === 'auto' ? 0 : styleMarginBlockEnd; + + if (insetBlockStart !== 'auto' && insetBlockEnd !== 'auto') { + const rest = cBlockSize - insetBlockStart - insetBlockEnd - outerBlockSize; + + if (styleMarginBlockStart === 'auto' && styleMarginBlockEnd === 'auto') { + // Paragraph 6: equal margins center the box, negative values included + marginBlockStart = marginBlockEnd = rest / 2; + } else if (styleMarginBlockStart === 'auto') { + marginBlockStart = rest - marginBlockEnd; + } else if (styleMarginBlockEnd === 'auto') { + marginBlockEnd = rest - marginBlockStart; + } + // Otherwise over-constrained, and the block-end inset is the one ignored + } + + if (insetBlockStart !== 'auto') { + box.setBlockPosition(insetBlockStart + marginBlockStart); + } else if (insetBlockEnd !== 'auto') { + box.setBlockPosition(cBlockSize - insetBlockEnd - marginBlockEnd - outerBlockSize); + } else { + // Both insets are auto, so the box stays where it would have been + if (staticPosition) staticPosition.needsBlock = true; + box.setBlockPosition(marginBlockStart); + } +} + +function layoutAbsoluteBox(layout: Layout, box: BlockLevel, ctx: LayoutContext) { + const cctx: LayoutContext = {...ctx, bfc: undefined}; + const containingBlock = box.getContainingBlock(); + const staticPosition = layout.staticPositions.get(box); + + box.fillAreas(containingBlock); + doInlineBoxModelForAbsoluteBox(layout, box, staticPosition); + const axis = doBlockBoxModelForAbsoluteBox(box); + + if (box.isBlockContainer()) { + layoutBlockBoxInner(layout, box, cctx); + // The formatting context sizes an auto block size from the content, which is + // only the used value when an inset on that axis is auto + if (!axis.usesContentBlockSize && axis.blockSize !== undefined) { + box.setBlockSize(containingBlock, axis.blockSize); + } + } else if (axis.usesContentBlockSize) { + box.setBlockSize(containingBlock, box.getDefiniteInnerBlockSize()); + } + + setBlockPositionForAbsoluteBox(box, axis, staticPosition); +} + export class Break extends TreeNode { public className = 'break'; @@ -1830,6 +2066,7 @@ export function generateBlockContainer(tree: InlineLevel[], el: HTMLElement) { // generatesBreak, etc if ( el.style.float !== 'none' || + el.style.position === 'absolute' || el.style.overflow === 'hidden' || el.style.display.inner === 'flow-root' || el.parent && writingModeInlineAxis(el) !== writingModeInlineAxis(el.parent) diff --git a/src/layout-text.ts b/src/layout-text.ts index cd22443..45e8460 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -2030,8 +2030,9 @@ function createMarkIterator( inlineIteratorStateNext(inline); } - // Consume floats - if (inline.value?.state === 'box' && inline.value.item.isFloat() && inlineMark === mark.position) { + // Consume out-of-flow boxes: floats, which the line has to flow around, and + // absolutely positioned boxes, which only need the line they landed on + if (inline.value?.state === 'box' && inline.value.item.isOutOfFlow() && inlineMark === mark.position) { mark.box = inline.value.item; inlineIteratorStateNext(inline); return {done: false, value: mark}; @@ -2920,6 +2921,20 @@ export function createIfcLineboxes( } } + if (mark.box?.isAbsolute()) { + // The box is out of flow, so it contributes nothing to the line, but the + // line is where it would have been if it were in flow, which is the + // position it uses when its insets are auto (CSS 2.2 § 10.3.7, § 10.6.4) + const contentArea = ifc.block.getContentArea(); + const ltr = ifc.block.style.direction === 'ltr'; + layout.setStaticPosition( + mark.box, + contentArea, + ifc.vacancy.blockOffset, + ltr ? 0 : contentArea.inlineSize + ); + } + if (mark.inlinePost) { const inlineSpace = mark.inlinePost.getInlineEndSize(containingBlock); if (inlineSpace > 0) ifc.candidates.width.addInk(inlineSpace); diff --git a/src/style.ts b/src/style.ts index 83b704b..079f072 100644 --- a/src/style.ts +++ b/src/style.ts @@ -35,6 +35,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderRightStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderLeftStyle', rtl: 'borderRightStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderRightStyle', rtl: 'borderLeftStyle'}), + insetBlockStart: 'top', + insetBlockEnd: 'bottom', + insetLineLeft: 'left', + insetLineRight: 'right', blockSize: 'height', inlineSize: 'width' }), @@ -63,6 +67,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderBottomStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderTopStyle', rtl: 'borderBottomStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderBottomStyle', rtl: 'borderTopStyle'}), + insetBlockStart: 'left', + insetBlockEnd: 'right', + insetLineLeft: 'top', + insetLineRight: 'bottom', blockSize: 'width', inlineSize: 'height' }), @@ -91,6 +99,10 @@ const LogicalMaps = Object.freeze({ borderLineRightStyle: 'borderBottomStyle', borderInlineStartStyle: Object.freeze({ltr: 'borderTopStyle', rtl: 'borderBottomStyle'}), borderInlineEndStyle: Object.freeze({ltr: 'borderBottomStyle', rtl: 'borderTopStyle'}), + insetBlockStart: 'right', + insetBlockEnd: 'left', + insetLineLeft: 'top', + insetLineRight: 'bottom', blockSize: 'width', inlineSize: 'height' }) @@ -503,7 +515,7 @@ export class Style { } isOutOfFlow() { - return this.float !== 'none'; // TODO: or this.position === 'absolute' + return this.float !== 'none' || this.position === 'absolute'; } isWsCollapsible() { @@ -669,6 +681,44 @@ export class Style { return resolvePercent(containingBlock, cssWidthVal); } + /** + * `top`, `right`, `bottom` and `left` are physical, but layout is logical, so + * they get mapped like the rest of the box model. Percentages on the line + * axis resolve against the containing block's inline size and percentages on + * the block axis against its block size (CSS 2.2 § 10.3.7, § 10.6.4). + */ + private getInset( + key: 'insetBlockStart' | 'insetBlockEnd' | 'insetLineLeft' | 'insetLineRight', + containingBlock: BoxArea + ) { + const writingMode = containingBlock.box.style.writingMode; + const map = LogicalMaps[writingMode]; + const cssVal = this[map[key]]; + if (cssVal === 'auto') return cssVal; + if (typeof cssVal === 'object') { + const isBlockAxis = key === 'insetBlockStart' || key === 'insetBlockEnd'; + const size = containingBlock[isBlockAxis ? map.blockSize : map.inlineSize]; + return cssVal.value / 100 * size; + } + return cssVal; + } + + getInsetBlockStart(containingBlock: BoxArea) { + return this.getInset('insetBlockStart', containingBlock); + } + + getInsetBlockEnd(containingBlock: BoxArea) { + return this.getInset('insetBlockEnd', containingBlock); + } + + getInsetLineLeft(containingBlock: BoxArea) { + return this.getInset('insetLineLeft', containingBlock); + } + + getInsetLineRight(containingBlock: BoxArea) { + return this.getInset('insetLineRight', containingBlock); + } + getBlockSize(containingBlock: BoxArea) { const writingMode = containingBlock.box.style.writingMode; let cssVal = this[LogicalMaps[writingMode].blockSize]; @@ -1097,9 +1147,10 @@ function computeStyle(parentStyle: Style, cascadedStyle: DeclaredStyle) { const style = new Style(computed, parentStyle, cascadedStyle); - // Blockify floats (TODO: abspos too) (CSS Display §2.7). This drives what + // Blockify floats and absolutely positioned boxes (CSS Display §2.7). This + // drives what // type of box is created (-> not an inline), but otherwise has no effect. - if (computed.float !== 'none') style.blockify(); + if (computed.float !== 'none' || computed.position === 'absolute') style.blockify(); return style; } diff --git a/test/ci.js b/test/ci.js index cbdaf77..50ddd70 100644 --- a/test/ci.js +++ b/test/ci.js @@ -8,6 +8,7 @@ import './api.spec.js'; import './cascade.spec.js'; import './css.spec.js'; import './flow.spec.js'; +import './position-absolute.spec.js'; import './text.spec.js'; import './font.spec.js'; import './itemize.spec.js'; diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js new file mode 100644 index 0000000..6cf7313 --- /dev/null +++ b/test/position-absolute.spec.js @@ -0,0 +1,365 @@ +import '#register-default-environment'; +import {expect} from 'chai'; +import * as flow from 'dropflow'; +import parse from 'dropflow/parse.js'; +import {registerFontAsset, unregisterFontAsset} from '../assets/register.ts'; +import PaintSpy from './paint-spy.js'; +import paint from '../src/paint.ts'; + +const adaUrl = new URL(import.meta.resolve('#assets/images/ada.png')); + +describe('Absolute positioning', function () { + before(function () { + registerFontAsset('Arimo/Arimo-Regular.ttf'); + this.reflow = function (html, width = 300, height = 500) { + this.rootElement = parse(html); + flow.loadSync(this.rootElement); + this.layout = flow.layout(this.rootElement); + flow.reflow(this.layout, width, height); + this.get = selector => this.rootElement.query(selector)?.boxes[0]; + this.border = selector => { + const box = this.get(selector); + if (!box) throw new Error(`no box for ${selector}`); + const area = box.getBorderArea(); + return {x: area.x, y: area.y, width: area.width, height: area.height}; + }; + }; + }); + + after(function () { + unregisterFontAsset('Arimo/Arimo-Regular.ttf'); + }); + + it('places the border box against the containing block padding box', function () { + this.reflow(` +
+
+
+ `); + // The containing block is the parent's padding box, which starts inside the + // 3px border + expect(this.border('#t')).to.deep.equal({x: 10, y: 8, width: 40, height: 30}); + }); + + it('uses the nearest positioned ancestor, not the parent', function () { + this.reflow(` +
+
+
+
+
+
+ `); + // The insets are measured from the positioned ancestor, so neither the + // margin nor the padding of the boxes in between moves the box + expect(this.border('#t')).to.deep.equal({x: 4, y: 3, width: 10, height: 10}); + }); + + it('falls back to the initial containing block', function () { + this.reflow(` +
+
+
+
+ `, 300, 500); + // No positioned ancestor: the 300x500 initial containing block is used, so + // the box is at its line-right edge, not the 200px wide parent's + expect(this.border('#t')).to.deep.equal({x: 290, y: 0, width: 10, height: 10}); + }); + + it('takes the box out of flow', function () { + this.reflow(` +
+
+
+
+
+
+ `); + // The parent has no in-flow content, so it has no block size, and the + // sibling after it is not pushed down by the positioned box + expect(this.border('#p').height).to.equal(0); + expect(this.border('#after').y).to.equal(0); + }); + + it('does not add the box to the line it appears in', function () { + this.reflow(` +
aaa
+ `); + // One line of text: the positioned box neither widens the line nor makes the + // paragraph as tall as itself + expect(this.border('#p').height).to.equal(20); + }); + + it('sizes the box from a pair of insets', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 10, y: 10, width: 260, height: 170}); + }); + + it('subtracts border and padding from a size taken from insets', function () { + this.reflow(` +
+
+
+ `); + // The insets are measured to the margin edge, so the border box fills them + // and the padding and border are inside it + const t = this.get('#t'); + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 200, height: 200}); + expect(t.getContentArea().width).to.equal(200 - 2 * 8 - 2 * 2); + expect(t.getContentArea().height).to.equal(200 - 2 * 5 - 2 * 2); + }); + + it('shrink-to-fits an auto inline size', function () { + this.reflow(` +
+
aaa bbb ccc
+
aaa bbb ccc
+
+ `); + // The first box may use the whole 300px and keeps its text on one line; the + // second only has 35px left, so each word gets its own line + expect(this.border('#wide').height).to.equal(20); + expect(this.border('#narrow').height).to.equal(60); + expect(this.border('#narrow').width).to.be.at.most(35); + }); + + it('centers with auto margins on both axes', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 100, y: 75, width: 100, height: 50}); + }); + + it('solves for a single auto margin', function () { + this.reflow(` +
+
+
+
+ `); + // The containing block is the 240px padding box, so margin-left takes + // 240 - 50 - 20, measured from the padding box edge + expect(this.border('#t').x).to.equal(170); + expect(this.border('#t').y).to.equal(0); + }); + + it('ignores the line-right inset when over-constrained in ltr', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(10); + }); + + it('ignores the line-left inset when over-constrained in rtl', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(140); + }); + + it('ignores the block-end inset when over-constrained', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').y).to.equal(10); + }); + + it('resolves inset percentages against the containing block padding box', function () { + this.reflow(` +
+
+
+ `); + // The padding box is 220x120, so 25% is 55 and 50% is 60, both measured from + // the padding box origin + expect(this.border('#t')).to.deep.equal({x: 55, y: 60, width: 10, height: 10}); + }); + + it('resolves size percentages against the containing block padding box', function () { + this.reflow(` +
+
+
+
+ `); + // The padding box is 220x120, so the box is 110x30 at its origin, above the + // in-flow box that comes before it + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 110, height: 30}); + }); + + it('leaves the box at its static position after in-flow siblings', function () { + this.reflow(` +
+
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 0, y: 42, width: 10, height: 10}); + // and the parent stops at the in-flow content + expect(this.border('div').height).to.equal(42); + }); + + it('offsets the static position by the margins', function () { + this.reflow(` +
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 6, y: 19, width: 20, height: 20}); + expect(this.border('div').height).to.equal(15); + }); + + it('uses the static position of the line the box appears on', function () { + this.reflow(` +
aaa bbb ccc
+ `); + // Each word gets its own 20px line in 30px and the box comes after all of + // them, so it starts on the third line + expect(this.border('#t').y).to.equal(40); + }); + + it('takes the static position from the line-right edge in rtl', function () { + this.reflow(` +
+
+
+
+ `); + expect(this.border('#t')).to.deep.equal({x: 170, y: 20, width: 30, height: 10}); + expect(this.border('div').height).to.equal(20); + }); + + it('maps insets through a vertical-lr containing block', function () { + this.reflow(` +
+
+
+ `); + // The block axis runs left to right, so `left` is the block-start inset and + // `top` is the line-left one + expect(this.border('#t').x).to.equal(12); + expect(this.border('#t').y).to.equal(9); + }); + + it('maps insets through a vertical-rl containing block', function () { + this.reflow(` +
+
+
+ `); + // The block axis runs right to left, so `right` is the block-start inset + expect(this.border('#t').x).to.equal(200 - 12 - 20); + expect(this.border('#t').y).to.equal(9); + }); + + it('maps the static position through a vertical-rl containing block', function () { + this.reflow(` +
+
+
+
+ `); + // `top` is the line-left inset here, and the block axis still comes from the + // position the box would have had + expect(this.border('#t').x).to.equal(200 - 25 - 20); + expect(this.border('#t').y).to.equal(6); + }); + + it('uses the intrinsic size of a positioned replaced box', function () { + this.reflow(` +
+ +
+ `); + const t = this.border('#t'); + expect(t.x).to.equal(5); + expect(t.y).to.equal(5); + expect(t.width).to.be.greaterThan(0); + expect(t.height).to.be.greaterThan(0); + }); + + it('sizes a positioned replaced box from its style', function () { + this.reflow(` +
+
+ +
+ `); + expect(this.border('#t')).to.deep.equal({x: 3, y: 7, width: 40, height: 20}); + }); + + it('establishes a formatting context that contains its floats', function () { + this.reflow(` +
+
+
+
+
+ `); + expect(this.border('#t').height).to.equal(40); + }); + + it('blockifies an inline that is positioned', function () { + this.reflow(` +
+ aaa +
+ `); + // The span generates a block box, so it is not on a line and the paragraph + // has no line boxes at all + expect(this.border('#t')).to.deep.equal({x: 0, y: 0, width: 40, height: 40}); + expect(this.border('#p').height).to.equal(0); + }); + + it('paints a positioned box above in-flow content', function () { + this.reflow(` +
+
+
+
+ `); + const spy = new PaintSpy(); + paint(this.layout, spy); + const rects = spy.getCalls().filter(call => call.t === 'rect'); + const red = rects.findIndex(call => call.fillColor === '#f00'); + const blue = rects.findIndex(call => call.fillColor === '#00f'); + // The in-flow box is not pushed down by the positioned one + expect(rects[red].y).to.equal(0); + // Document order puts the positioned box first, but it paints last because + // it is in a later layer + expect(red).to.be.greaterThan(-1); + expect(blue).to.be.greaterThan(red); + }); +}); From a1bba40c48ac881fdce99496f2dca4e7102128dc Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 31 Jul 2026 11:27:13 +0200 Subject: [PATCH 2/7] keep static positions off of Layout --- src/api.ts | 10 ++++--- src/layout-box.ts | 65 +++++++++++++++++++++++++--------------------- src/layout-flow.ts | 31 ++++++++++++---------- src/layout-text.ts | 6 ++--- 4 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/api.ts b/src/api.ts index a99da65..3eac20e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,6 +9,7 @@ import CanvasPaintBackend from './paint-canvas.ts'; import paint from './paint.ts'; import {BoxArea, Layout, prelayout, postlayout} from './layout-box.ts'; +import type {Box, StaticPosition} from './layout-box.ts'; import {onLoadWalkerElementForImage} from './layout-image.ts'; import {id, uuid} from './util.ts'; @@ -50,10 +51,13 @@ export function layout(rootElement: HTMLElement): Layout { export function reflow(layout: Layout, width = 640, height = 480) { const initialContainingBlock = new BoxArea(layout.root(), 0, 0, width, height); + // Only alive for the length of this reflow, so nothing is retained on the Layout + const staticPositions = new Map(); + prelayout(layout, initialContainingBlock); - layoutBlockLevelBox(layout, layout.root(), {}); - layoutAbsolutes(layout, {}); - postlayout(layout); + layoutBlockLevelBox(layout, layout.root(), {staticPositions}); + layoutAbsolutes(layout, {staticPositions}); + postlayout(layout, staticPositions); } /** diff --git a/src/layout-box.ts b/src/layout-box.ts index 227b14b..1d53958 100644 --- a/src/layout-box.ts +++ b/src/layout-box.ts @@ -419,9 +419,6 @@ export abstract class Box extends TreeNode { // TODO: Inlines don't use this yet. Get rid of paragraph's backgroundBoxes // and use normal inline areas instead, with fragmentation const borderArea = this.getBorderArea(); - if (this.style.position === 'absolute') { - shiftToStaticPosition(layout, this); - } if (this.style.position === 'relative') { const containingBlock = this.getContainingBlock(); borderArea.x += this.getRelativeHorizontalShift(containingBlock); @@ -743,15 +740,22 @@ export class BoxArea { * parent, not of its containing block. Both of those are ancestors, so both are * already absolute when postlayout reaches this box, and the offset can be * mapped through physical coordinates. + * + * `area` is the content area of the in-flow parent, which is the nearest block + * container ancestor: the only boxes that can sit between them are inlines. */ -function shiftToStaticPosition(layout: Layout, box: Box) { - const staticPosition = layout.staticPositions.get(box); - if (!staticPosition) return; - const {area, blockOffset, inlineOffset, needsBlock, needsLineLeft} = staticPosition; - if (!needsBlock && !needsLineLeft) return; +function shiftToStaticPosition(box: Box, staticPosition: StaticPosition, area: BoxArea) { + const [blockOffset, inlineOffset] = staticPosition; const borderArea = box.getBorderArea(); const containingBlock = borderArea.parent; if (!containingBlock) throw new Error('Assertion failed'); + // Layout only leaves room for the static position when it had no inset to + // position against, which is the same question the box model asked + const needsBlock = box.style.getInsetBlockStart(containingBlock) === 'auto' && + box.style.getInsetBlockEnd(containingBlock) === 'auto'; + const needsLineLeft = box.style.getInsetLineLeft(containingBlock) === 'auto' && + box.style.getInsetLineRight(containingBlock) === 'auto'; + if (!needsBlock && !needsLineLeft) return; const parentWritingMode = area.getEstablishedWritingMode(); let x, y; @@ -837,11 +841,26 @@ export function prelayout(layout: Layout, icb: BoxArea) { } } -export function postlayout(layout: Layout) { +export function postlayout(layout: Layout, staticPositions: Map) { const parents: (BlockContainer | Inline)[] = []; for (let i = 0; i < layout.tree.length; i++) { const item = layout.tree[i]; + + if (item.isFormattingBox() && item.isAbsolute()) { + // The offset was recorded in the axes of the in-flow parent, which the + // preorder walk has already absolutified + const staticPosition = staticPositions.get(item); + if (staticPosition) { + let inflowParent; + for (let j = parents.length - 1; j >= 0 && !inflowParent; j--) { + if (parents[j].isBlockContainer()) inflowParent = parents[j]; + } + if (!inflowParent) throw new Error('Assertion failed'); + shiftToStaticPosition(item, staticPosition, inflowParent.getContentArea()); + } + } + item.postlayoutPreorder(layout); if (item.isBlockContainer() || item.isInline()) { parents.push(item); @@ -903,37 +922,23 @@ export function log(layout: Layout, logger?: Logger, options?: TreeLogOptions) { /** * Where an absolutely positioned box would have been if it were in flow, in the - * logical axes of `area`, which is the content area of its in-flow parent. Only - * used when an inset on that axis is `auto` (CSS 2.2 § 10.3.7, § 10.6.4). + * logical axes of the content area of its in-flow parent. Only used when both + * insets on that axis are `auto` (CSS 2.2 § 10.3.7, § 10.6.4). + * + * Only needed between line building and postlayout, so it is not stored on the + * `Layout`: it rides along on the layout context and is gone once `reflow` + * returns. */ -export interface StaticPosition { - area: BoxArea; - blockOffset: number; - inlineOffset: number; - needsBlock: boolean; - needsLineLeft: boolean; -} +export type StaticPosition = [blockOffset: number, inlineOffset: number]; export class Layout { tree: InlineLevel[]; - staticPositions: Map; constructor(tree: InlineLevel[]) { this.tree = tree; - this.staticPositions = new Map(); } root() { return this.tree[0] as BlockContainer; } - - setStaticPosition(box: Box, area: BoxArea, blockOffset: number, inlineOffset: number) { - this.staticPositions.set(box, { - area, - blockOffset, - inlineOffset, - needsBlock: false, - needsLineLeft: false - }); - } } diff --git a/src/layout-flow.ts b/src/layout-flow.ts index db03a38..02af535 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -44,6 +44,12 @@ export interface LayoutContext { * This is only undefined for the root box or when an element is out of flow. */ bfc?: BlockFormattingContext + /** + * Where the out-of-flow boxes seen so far would have been in flow, for the + * ones that need it. Read by the absolute layout pass and by postlayout, then + * dropped. + */ + staticPositions: Map } class MarginCollapseCollection { @@ -1071,14 +1077,18 @@ function doBlockBoxModelForBlockBox(layout: Layout, box: BlockContainer) { * the absolutely positioned boxes inside it, which would have started at its * content edge (CSS 2.2 § 10.3.7, § 10.6.4). */ -function setStaticPositionsWithoutLines(layout: Layout, box: BlockContainerOfInlines) { +function setStaticPositionsWithoutLines( + layout: Layout, + box: BlockContainerOfInlines, + ctx: LayoutContext +) { const contentArea = box.getContentArea(); const ltr = box.style.direction === 'ltr'; for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { const item = layout.tree[i]; if (item.isFormattingBox() && item.isAbsolute()) { - layout.setStaticPosition(item, contentArea, 0, ltr ? 0 : contentArea.inlineSize); + ctx.staticPositions.set(item, [0, ltr ? 0 : contentArea.inlineSize]); i = item.treeFinal; } } @@ -1106,7 +1116,7 @@ function layoutBlockBoxInner( if (!box.shouldLayoutContent(layout)) { // No lines will be built, so the boxes taken out of this flow would all // have started at the content edge - setStaticPositionsWithoutLines(layout, box); + setStaticPositionsWithoutLines(layout, box, cctx); } else if (containingBfc) { // text layout happens in bfc.boxStart } else { @@ -1360,7 +1370,6 @@ function doInlineBoxModelForAbsoluteBox( } else if (staticPosition) { // Paragraphs 1 and 4: both insets are auto, so the box stays where it would // have been. In rtl it is the line-right margin edge that was recorded - staticPosition.needsLineLeft = true; box.setInlinePosition(ltr ? marginLineLeft : -(inlineSize + marginLineRight)); } else { box.setInlinePosition(marginLineLeft); @@ -1414,11 +1423,7 @@ function doBlockBoxModelForAbsoluteBox(box: BlockLevel): AbsoluteBlockAxis { return {usesContentBlockSize, blockSize, insetBlockStart, insetBlockEnd, cBlockSize}; } -function setBlockPositionForAbsoluteBox( - box: BlockLevel, - axis: AbsoluteBlockAxis, - staticPosition: StaticPosition | undefined -) { +function setBlockPositionForAbsoluteBox(box: BlockLevel, axis: AbsoluteBlockAxis) { const containingBlock = box.getContainingBlock(); const {insetBlockStart, insetBlockEnd, cBlockSize} = axis; const outerBlockSize = box.getBorderArea().blockSize; @@ -1446,8 +1451,8 @@ function setBlockPositionForAbsoluteBox( } else if (insetBlockEnd !== 'auto') { box.setBlockPosition(cBlockSize - insetBlockEnd - marginBlockEnd - outerBlockSize); } else { - // Both insets are auto, so the box stays where it would have been - if (staticPosition) staticPosition.needsBlock = true; + // Both insets are auto, so the box stays where it would have been. Postlayout + // shifts it there once the in-flow parent has absolute coordinates box.setBlockPosition(marginBlockStart); } } @@ -1455,7 +1460,7 @@ function setBlockPositionForAbsoluteBox( function layoutAbsoluteBox(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const cctx: LayoutContext = {...ctx, bfc: undefined}; const containingBlock = box.getContainingBlock(); - const staticPosition = layout.staticPositions.get(box); + const staticPosition = ctx.staticPositions.get(box); box.fillAreas(containingBlock); doInlineBoxModelForAbsoluteBox(layout, box, staticPosition); @@ -1472,7 +1477,7 @@ function layoutAbsoluteBox(layout: Layout, box: BlockLevel, ctx: LayoutContext) box.setBlockSize(containingBlock, box.getDefiniteInnerBlockSize()); } - setBlockPositionForAbsoluteBox(box, axis, staticPosition); + setBlockPositionForAbsoluteBox(box, axis); } export class Break extends TreeNode { diff --git a/src/layout-text.ts b/src/layout-text.ts index 45e8460..cf9be11 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -2927,12 +2927,10 @@ export function createIfcLineboxes( // position it uses when its insets are auto (CSS 2.2 § 10.3.7, § 10.6.4) const contentArea = ifc.block.getContentArea(); const ltr = ifc.block.style.direction === 'ltr'; - layout.setStaticPosition( - mark.box, - contentArea, + ctx.staticPositions.set(mark.box, [ ifc.vacancy.blockOffset, ltr ? 0 : contentArea.inlineSize - ); + ]); } if (mark.inlinePost) { From 1d91a3c994444490d98cbb491435581194321feb Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 14 Aug 2026 11:11:57 +0200 Subject: [PATCH 3/7] lay out absolutes when their containing block is sized --- src/api.ts | 11 +- src/layout-box.ts | 157 +++++++++++++------------- src/layout-flow.ts | 276 +++++++++++++++++++++++---------------------- src/layout-text.ts | 9 +- 4 files changed, 228 insertions(+), 225 deletions(-) diff --git a/src/api.ts b/src/api.ts index 3eac20e..f8fbc14 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,14 +2,13 @@ import '#register-default-environment'; import {HTMLElement, TextNode} from './dom.ts'; import {DeclaredStyle, getOriginStyle, computeElementStyle} from './style.ts'; import {fonts, FontFace, createFaceFromTables, createFaceFromTablesSync, onLoadWalkerTextNodeForFonts, onLoadWalkerElementForFonts} from './text-font.ts'; -import {generateBlockContainer, layoutBlockLevelBox, layoutAbsolutes} from './layout-flow.ts'; +import {generateBlockContainer, layoutBlockLevelBox} from './layout-flow.ts'; import HtmlPaintBackend from './paint-html.ts'; import SvgPaintBackend from './paint-svg.ts'; import CanvasPaintBackend from './paint-canvas.ts'; import paint from './paint.ts'; import {BoxArea, Layout, prelayout, postlayout} from './layout-box.ts'; -import type {Box, StaticPosition} from './layout-box.ts'; import {onLoadWalkerElementForImage} from './layout-image.ts'; import {id, uuid} from './util.ts'; @@ -51,13 +50,9 @@ export function layout(rootElement: HTMLElement): Layout { export function reflow(layout: Layout, width = 640, height = 480) { const initialContainingBlock = new BoxArea(layout.root(), 0, 0, width, height); - // Only alive for the length of this reflow, so nothing is retained on the Layout - const staticPositions = new Map(); - prelayout(layout, initialContainingBlock); - layoutBlockLevelBox(layout, layout.root(), {staticPositions}); - layoutAbsolutes(layout, {staticPositions}); - postlayout(layout, staticPositions); + layoutBlockLevelBox(layout, layout.root(), {}); + postlayout(layout); } /** diff --git a/src/layout-box.ts b/src/layout-box.ts index 1d53958..f440ad3 100644 --- a/src/layout-box.ts +++ b/src/layout-box.ts @@ -163,7 +163,10 @@ export abstract class Box extends TreeNode { // 17..18: propagation bits: Inline <- FormattingBox hasFloatOrReplaced: 1 << 17, hasInlineBlocks: 1 << 18, - // 19..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS + // 19: the only bit that also propagates out of an Inline, up to the + // containing block: Inline, FormattingBox <- Inline, FormattingBox + hasAbsoluteInCb: 1 << 19, + // 20..31: if you take them, remove them from PROPAGATES_TO_INLINE_BITS }; /** @@ -415,6 +418,10 @@ export abstract class Box extends TreeNode { return Boolean(this.bitfield & Box.BITS.hasForegroundInDescendent); } + hasAbsoluteInCb() { + return Boolean(this.bitfield & Box.BITS.hasAbsoluteInCb); + } + postlayoutPreorder(layout: Layout) { // TODO: Inlines don't use this yet. Get rid of paragraph's backgroundBoxes // and use normal inline areas instead, with fragmentation @@ -568,11 +575,19 @@ export abstract class FormattingBox extends Box { if (this.isFloat()) { parent.bitfield |= Box.BITS.hasFloatOrReplaced; } + + if (this.isAbsolute() || this.hasAbsoluteInCb() && !this.isPositioned()) { + parent.bitfield |= Box.BITS.hasAbsoluteInCb; + } } isInlineLevel() { return this.style.display.outer === 'inline'; } + + isAbsoluteContainingBlock() { + return (this.isPositioned() || this.treeStart === 0) && this.hasAbsoluteInCb(); + } } export class BoxArea { @@ -610,6 +625,10 @@ export class BoxArea { return this.box.style.direction; } + getWritingModeAsParticipant() { + return this.parent ? this.parent.getEstablishedWritingMode() : 'horizontal-tb'; + } + get x() { return this.lineLeft; } @@ -634,6 +653,30 @@ export class BoxArea { return this.blockSize; } + getBlockEndInset() { + if (!this.parent) return 0; + if ( + (this.getWritingModeAsParticipant() === 'horizontal-tb') !== + (this.parent.getWritingModeAsParticipant() === 'horizontal-tb') + ) { + return this.parent.inlineSize - this.blockStart - this.blockSize; + } else { + return this.parent.blockSize - this.blockStart - this.blockSize; + } + } + + getLineRightInset() { + if (!this.parent) return 0; + if ( + (this.getWritingModeAsParticipant() === 'horizontal-tb') !== + (this.parent.getWritingModeAsParticipant() === 'horizontal-tb') + ) { + return this.parent.blockSize - this.lineLeft - this.inlineSize; + } else { + return this.parent.inlineSize - this.lineLeft - this.inlineSize; + } + } + setParent(p: BoxArea) { this.parent = p; } @@ -668,6 +711,40 @@ export class BoxArea { } } + getLineLeftOfAreaAgainstSelf(area: BoxArea) { + const thisCb = this.box.getContainingBlock(); + const thisWm = this.box.getWritingModeAsParticipant(thisCb); + const areaCb = area.box.getContainingBlock(); + const areaWm = area.box.getWritingModeAsParticipant(areaCb); + + if (thisWm === 'horizontal-tb') { + if (areaWm === 'vertical-rl') return area.getBlockEndInset(); + if (areaWm === 'vertical-lr') return area.blockStart; + } else { // 'vertical-rl', 'vertical-lr' + if (areaWm === 'horizontal-tb') return area.blockStart; + } + + return area.lineLeft; + } + + getBlockStartOfAreaAgainstSelf(area: BoxArea) { + const thisWm = this.getEstablishedWritingMode(); + const areaCb = area.box.getContainingBlock(); + const areaWm = area.box.getWritingModeAsParticipant(areaCb); + + if (thisWm === 'horizontal-tb') { + if (areaWm !== 'horizontal-tb') return area.lineLeft; + } else if (thisWm === 'vertical-rl') { + if (areaWm === 'horizontal-tb') return area.getLineRightInset(); + if (areaWm === 'vertical-lr') return area.getBlockEndInset(); + } else { // 'vertical-lr' + if (areaWm === 'horizontal-tb') return area.lineLeft; + if (areaWm === 'vertical-rl') return area.getBlockEndInset(); + } + + return area.blockStart; + } + absolutify() { let x, y, width, height; @@ -734,56 +811,6 @@ export class BoxArea { } } -/** - * An absolutely positioned box whose insets are `auto` on an axis sits at the - * position it would have had in flow, which is known in the axes of its in-flow - * parent, not of its containing block. Both of those are ancestors, so both are - * already absolute when postlayout reaches this box, and the offset can be - * mapped through physical coordinates. - * - * `area` is the content area of the in-flow parent, which is the nearest block - * container ancestor: the only boxes that can sit between them are inlines. - */ -function shiftToStaticPosition(box: Box, staticPosition: StaticPosition, area: BoxArea) { - const [blockOffset, inlineOffset] = staticPosition; - const borderArea = box.getBorderArea(); - const containingBlock = borderArea.parent; - if (!containingBlock) throw new Error('Assertion failed'); - // Layout only leaves room for the static position when it had no inset to - // position against, which is the same question the box model asked - const needsBlock = box.style.getInsetBlockStart(containingBlock) === 'auto' && - box.style.getInsetBlockEnd(containingBlock) === 'auto'; - const needsLineLeft = box.style.getInsetLineLeft(containingBlock) === 'auto' && - box.style.getInsetLineRight(containingBlock) === 'auto'; - if (!needsBlock && !needsLineLeft) return; - const parentWritingMode = area.getEstablishedWritingMode(); - let x, y; - - if (parentWritingMode === 'vertical-lr') { - x = area.x + blockOffset; - y = area.y + inlineOffset; - } else if (parentWritingMode === 'vertical-rl') { - x = area.x + area.width - blockOffset; - y = area.y + inlineOffset; - } else { // 'horizontal-tb' - x = area.x + inlineOffset; - y = area.y + blockOffset; - } - - const writingMode = containingBlock.getEstablishedWritingMode(); - - if (writingMode === 'vertical-lr') { - if (needsBlock) borderArea.blockStart += x - containingBlock.x; - if (needsLineLeft) borderArea.lineLeft += y - containingBlock.y; - } else if (writingMode === 'vertical-rl') { - if (needsBlock) borderArea.blockStart += containingBlock.x + containingBlock.width - x; - if (needsLineLeft) borderArea.lineLeft += y - containingBlock.y; - } else { // 'horizontal-tb' - if (needsBlock) borderArea.blockStart += y - containingBlock.y; - if (needsLineLeft) borderArea.lineLeft += x - containingBlock.x; - } -} - export function prelayout(layout: Layout, icb: BoxArea) { const parents: (BlockContainer | Inline)[] = []; const ifcs: BlockContainerOfInlines[] = []; @@ -841,26 +868,11 @@ export function prelayout(layout: Layout, icb: BoxArea) { } } -export function postlayout(layout: Layout, staticPositions: Map) { +export function postlayout(layout: Layout) { const parents: (BlockContainer | Inline)[] = []; for (let i = 0; i < layout.tree.length; i++) { const item = layout.tree[i]; - - if (item.isFormattingBox() && item.isAbsolute()) { - // The offset was recorded in the axes of the in-flow parent, which the - // preorder walk has already absolutified - const staticPosition = staticPositions.get(item); - if (staticPosition) { - let inflowParent; - for (let j = parents.length - 1; j >= 0 && !inflowParent; j--) { - if (parents[j].isBlockContainer()) inflowParent = parents[j]; - } - if (!inflowParent) throw new Error('Assertion failed'); - shiftToStaticPosition(item, staticPosition, inflowParent.getContentArea()); - } - } - item.postlayoutPreorder(layout); if (item.isBlockContainer() || item.isInline()) { parents.push(item); @@ -920,17 +932,6 @@ export function log(layout: Layout, logger?: Logger, options?: TreeLogOptions) { logger.flush(); } -/** - * Where an absolutely positioned box would have been if it were in flow, in the - * logical axes of the content area of its in-flow parent. Only used when both - * insets on that axis are `auto` (CSS 2.2 § 10.3.7, § 10.6.4). - * - * Only needed between line building and postlayout, so it is not stored on the - * `Layout`: it rides along on the layout context and is gone once `reflow` - * returns. - */ -export type StaticPosition = [blockOffset: number, inlineOffset: number]; - export class Layout { tree: InlineLevel[]; diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 02af535..4a0a79d 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -17,7 +17,7 @@ import {getImage} from './layout-image.ts'; import {Box, FormattingBox, TreeNode, Layout} from './layout-box.ts'; import type {InlineMetrics, ShapedItem, InlineFragment} from './layout-text.ts'; -import type {BoxArea, PrelayoutContext, StaticPosition} from './layout-box.ts'; +import type {BoxArea, PrelayoutContext} from './layout-box.ts'; import type {AllocatedUint16Array} from './text-harfbuzz.ts'; function assumePx(v: any): asserts v is number { @@ -44,12 +44,6 @@ export interface LayoutContext { * This is only undefined for the root box or when an element is out of flow. */ bfc?: BlockFormattingContext - /** - * Where the out-of-flow boxes seen so far would have been in flow, for the - * ones that need it. Read by the absolute layout pass and by postlayout, then - * dropped. - */ - staticPositions: Map } class MarginCollapseCollection { @@ -117,7 +111,7 @@ export class BlockFormattingContext { this.hypotheticals = EMPTY_MAP; } - collapseStart(layout: Layout, box: BlockLevel) { + collapseStart(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const marginBlockStart = box.style.getMarginBlockStart(containingBlock); let floatBottom = 0; @@ -143,7 +137,7 @@ export class BlockFormattingContext { if (adjoinsPrevious) { this.margin.collection.add(marginBlockStart); } else { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); const c = floatBottom - this.cbBlockStart; this.margin = {level: this.level, collection: new MarginCollapseCollection(c)}; if (box.canCollapseThrough(layout)) this.margin.clearanceAtLevel = this.level; @@ -157,7 +151,7 @@ export class BlockFormattingContext { const borderBlockStartWidth = box.style.getBorderBlockStartWidth(containingBlock); const adjoinsNext = paddingBlockStart === 0 && borderBlockStartWidth === 0; - this.collapseStart(layout, box); + this.collapseStart(layout, box, ctx); this.last = 'start'; this.level += 1; @@ -178,12 +172,12 @@ export class BlockFormattingContext { } if (!adjoinsNext) { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); this.margin = {level: this.level, collection: new MarginCollapseCollection()}; } } - boxEnd(layout: Layout, box: BlockContainer) { + boxEnd(layout: Layout, box: BlockContainer, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const {lineLeft, lineRight} = box.getContainingBlockToContent(containingBlock); const paddingBlockEnd = box.style.getPaddingBlockEnd(containingBlock); @@ -212,7 +206,7 @@ export class BlockFormattingContext { this.cbLineRight -= lineRight; if (!adjoins) { - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); this.margin = {level: this.level, collection: new MarginCollapseCollection()}; } @@ -231,13 +225,13 @@ export class BlockFormattingContext { this.last = 'end'; } - boxAtomic(layout: Layout, box: BlockLevel) { + boxAtomic(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); assumePx(marginBlockEnd); - this.collapseStart(layout, box); + this.collapseStart(layout, box, ctx); this.fctx?.boxStart(); - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); box.setBlockPosition(this.cbBlockStart); this.margin.collection = new MarginCollapseCollection(); this.margin.collection.add(marginBlockEnd); @@ -270,14 +264,21 @@ export class BlockFormattingContext { return this.fctx || (this.fctx = new FloatContext(this, blockOffset)); } - finalize(box: BlockContainer) { + finalize(layout: Layout, box: BlockContainer, ctx: LayoutContext) { if (!box.isBfcRoot()) throw new Error('This is for bfc roots only'); const containingBlock = box.getContainingBlock(); const blockSize = box.style.getBlockSize(containingBlock); + let insetBlockStart: number | 'auto' = 'auto'; + let insetBlockEnd: number | 'auto' = 'auto'; - this.positionBlockContainers(); + this.positionBlockContainers(layout, ctx); - if (blockSize === 'auto') { + if (box.isAbsolute()) { + insetBlockStart = box.style.getInsetBlockStart(containingBlock); + insetBlockEnd = box.style.getInsetBlockEnd(containingBlock); + } + + if (blockSize === 'auto' && !(insetBlockStart !== 'auto' && insetBlockEnd !== 'auto')) { let lineboxHeight = 0; if (box.isBlockContainerOfInlines()) { lineboxHeight = box.getContentArea().blockSize; @@ -285,9 +286,11 @@ export class BlockFormattingContext { const blockSize = Math.max(lineboxHeight, this.cbBlockStart, this.fctx?.getBothBottom() ?? 0); box.setBlockSize(containingBlock, blockSize); } + + finalizeBlockContainer(layout, box, ctx); } - positionBlockContainers() { + positionBlockContainers(layout: Layout, ctx: LayoutContext) { const sizeStack = this.sizeStack; const offsetStack = this.offsetStack; const margin = this.margin.collection.get(); @@ -311,6 +314,8 @@ export class BlockFormattingContext { box.setBlockSize(containingBlock, childSize); } + if (!box.isBfcRoot()) finalizeBlockContainer(layout, box, ctx); + const blockSize = box.getBorderArea().blockSize; sizeStack[level] += blockSize; @@ -960,7 +965,8 @@ export class BlockContainerOfInlines extends BlockContainerBase { return inline.hasText() || inline.hasSizedInline() || inline.hasFloatOrReplaced() - || inline.hasInlineBlocks(); + || inline.hasInlineBlocks() + || inline.hasAbsoluteInCb(); } doTextLayout(layout: Layout, ctx: LayoutContext) { @@ -1072,28 +1078,6 @@ function doBlockBoxModelForBlockBox(layout: Layout, box: BlockContainer) { } } -/** - * An inline formatting context with nothing to lay out still has to answer for - * the absolutely positioned boxes inside it, which would have started at its - * content edge (CSS 2.2 § 10.3.7, § 10.6.4). - */ -function setStaticPositionsWithoutLines( - layout: Layout, - box: BlockContainerOfInlines, - ctx: LayoutContext -) { - const contentArea = box.getContentArea(); - const ltr = box.style.direction === 'ltr'; - - for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { - const item = layout.tree[i]; - if (item.isFormattingBox() && item.isAbsolute()) { - ctx.staticPositions.set(item, [0, ltr ? 0 : contentArea.inlineSize]); - i = item.treeFinal; - } - } -} - function layoutBlockBoxInner( layout: Layout, box: BlockContainer, @@ -1113,11 +1097,7 @@ function layoutBlockBoxInner( // Child flow is now possible if (box.isBlockContainerOfInlines()) { - if (!box.shouldLayoutContent(layout)) { - // No lines will be built, so the boxes taken out of this flow would all - // have started at the content edge - setStaticPositionsWithoutLines(layout, box, cctx); - } else if (containingBfc) { + if (containingBfc) { // text layout happens in bfc.boxStart } else { box.doTextLayout(layout, cctx); @@ -1132,7 +1112,7 @@ function layoutBlockBoxInner( } if (establishedBfc) { - establishedBfc.finalize(box); + establishedBfc.finalize(layout, box, ctx); if (establishedBfc.fctx) { if (box.loggingEnabled()) { console.log('Left floats'); @@ -1144,7 +1124,7 @@ function layoutBlockBoxInner( } } - containingBfc?.boxEnd(layout, box); + containingBfc?.boxEnd(layout, box, ctx); } function layoutBlockBox( @@ -1168,7 +1148,7 @@ function layoutReplacedBox( box.fillAreas(containingBlock); doInlineBoxModelForBlockBox(box); box.setBlockSize(containingBlock, box.getDefiniteInnerBlockSize()); - ctx.bfc!.boxAtomic(layout, box); + ctx.bfc!.boxAtomic(layout, box, ctx); } export function layoutBlockLevelBox( @@ -1270,22 +1250,6 @@ export function layoutFloatBox( } } -/** - * Absolutely positioned boxes are laid out after the flow they were taken out - * of, because their containing block - the padding area of the nearest - * positioned ancestor, or the initial containing block - only has a size once - * that ancestor is done. Tree order means an ancestor is always resolved before - * a box it contains. - */ -export function layoutAbsolutes(layout: Layout, ctx: LayoutContext) { - for (let i = 1; i < layout.tree.length; i++) { - const item = layout.tree[i]; - if (item.isFormattingBox() && item.isAbsolute()) { - layoutAbsoluteBox(layout, item, ctx); - } - } -} - function getShrinkToFitInlineSize( layout: Layout, box: BlockLevel, @@ -1299,8 +1263,8 @@ function getShrinkToFitInlineSize( // § 10.3.7 function doInlineBoxModelForAbsoluteBox( layout: Layout, - box: BlockLevel, - staticPosition: StaticPosition | undefined + staticContainingBlock: BoxArea | null, + box: BlockLevel ) { const containingBlock = box.getContainingBlock(); const cInlineSize = containingBlock.inlineSizeForPotentiallyOrthogonal(box); @@ -1314,7 +1278,12 @@ function doInlineBoxModelForAbsoluteBox( let marginLineRight = styleMarginLineRight === 'auto' ? 0 : styleMarginLineRight; let sizedFromInsets = false; let inlineSize; + let lineLeft = insetLineLeft === 'auto' ? 0 : insetLineLeft; + // solve: + // left = px , width = auto, right = px + // left = px , width = auto, right = auto + // left = px , width = px , right = auto if (definiteInlineSize !== undefined) { inlineSize = definiteInlineSize; } else if (box.isReplacedBox()) { @@ -1338,6 +1307,8 @@ function doInlineBoxModelForAbsoluteBox( - marginLineRight; } + // solve: + // left = px , width = px , right = px if (!sizedFromInsets && insetLineLeft !== 'auto' && insetLineRight !== 'auto') { // Paragraph 2: the equation is solvable, so what is left over goes to the // margins that are auto @@ -1356,44 +1327,54 @@ function doInlineBoxModelForAbsoluteBox( } else if (styleMarginLineRight === 'auto') { marginLineRight = rest - marginLineLeft; } + // Otherwise the values are over-constrained. The line-right inset is the // one ignored in ltr and the line-left one in rtl, which is what falls out // of positioning against the retained side below + if (styleMarginLineLeft !== 'auto' && styleMarginLineRight !== 'auto' && !ltr) { + lineLeft = cInlineSize - insetLineRight - marginLineRight - inlineSize; + } else { + lineLeft += marginLineLeft; + } } - box.setInlineOuterSize(containingBlock, inlineSize); + // solve: + // left = auto, width = auto, right = auto + // left = auto, width = px , right = auto + if (insetLineLeft === 'auto' && insetLineRight === 'auto') { + lineLeft = box.getBorderArea().lineLeft; // against static cb + let area = staticContainingBlock; + while (area && area !== containingBlock) { + lineLeft += containingBlock.getLineLeftOfAreaAgainstSelf(area); + area = area.parent; + } + if (!ltr) lineLeft -= inlineSize; + } - if (insetLineLeft !== 'auto' && (insetLineRight === 'auto' || ltr)) { - box.setInlinePosition(insetLineLeft + marginLineLeft); - } else if (insetLineRight !== 'auto') { - box.setInlinePosition(cInlineSize - insetLineRight - marginLineRight - inlineSize); - } else if (staticPosition) { - // Paragraphs 1 and 4: both insets are auto, so the box stays where it would - // have been. In rtl it is the line-right margin edge that was recorded - box.setInlinePosition(ltr ? marginLineLeft : -(inlineSize + marginLineRight)); - } else { - box.setInlinePosition(marginLineLeft); + // solve: + // left = auto, width = auto, right = px + // left = auto, width = px , right = px + if (insetLineLeft === 'auto' && insetLineRight !== 'auto') { + lineLeft = cInlineSize - insetLineRight - marginLineRight - inlineSize; } -} -// § 10.6.4 -interface AbsoluteBlockAxis { - usesContentBlockSize: boolean; - blockSize: number | undefined; - insetBlockStart: number | 'auto'; - insetBlockEnd: number | 'auto'; - cBlockSize: number; + box.setInlineOuterSize(containingBlock, inlineSize); + box.setInlinePosition(lineLeft); } -function doBlockBoxModelForAbsoluteBox(box: BlockLevel): AbsoluteBlockAxis { +// § 10.6.4 +function doBlockBoxModelForAbsoluteBox( + staticContainingBlock: BoxArea | null, + box: BlockLevel +) { const containingBlock = box.getContainingBlock(); + const borderArea = box.getBorderArea(); const cBlockSize = containingBlock.blockSizeForPotentiallyOrthogonal(box); const insetBlockStart = box.style.getInsetBlockStart(containingBlock); const insetBlockEnd = box.style.getInsetBlockEnd(containingBlock); const marginBlockStart = box.style.getMarginBlockStart(containingBlock); const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); let blockSize = box.getDefiniteInnerBlockSize(containingBlock); - let usesContentBlockSize = blockSize === undefined; if ( blockSize === undefined && @@ -1415,69 +1396,96 @@ function doBlockBoxModelForAbsoluteBox(box: BlockLevel): AbsoluteBlockAxis { - paddingBlockStart - paddingBlockEnd - borderBlockEndWidth); - usesContentBlockSize = false; } - if (blockSize !== undefined) box.setBlockSize(containingBlock, blockSize); - - return {usesContentBlockSize, blockSize, insetBlockStart, insetBlockEnd, cBlockSize}; -} + if (blockSize !== undefined) { + box.setBlockSize(containingBlock, blockSize); + } -function setBlockPositionForAbsoluteBox(box: BlockLevel, axis: AbsoluteBlockAxis) { - const containingBlock = box.getContainingBlock(); - const {insetBlockStart, insetBlockEnd, cBlockSize} = axis; - const outerBlockSize = box.getBorderArea().blockSize; - const styleMarginBlockStart = box.style.getMarginBlockStart(containingBlock); - const styleMarginBlockEnd = box.style.getMarginBlockEnd(containingBlock); - let marginBlockStart = styleMarginBlockStart === 'auto' ? 0 : styleMarginBlockStart; - let marginBlockEnd = styleMarginBlockEnd === 'auto' ? 0 : styleMarginBlockEnd; + const outerBlockSize = borderArea.blockSize; + let usedMarginBlockStart = marginBlockStart === 'auto' ? 0 : marginBlockStart; + let usedMarginBlockEnd = marginBlockEnd === 'auto' ? 0 : marginBlockEnd; if (insetBlockStart !== 'auto' && insetBlockEnd !== 'auto') { const rest = cBlockSize - insetBlockStart - insetBlockEnd - outerBlockSize; - if (styleMarginBlockStart === 'auto' && styleMarginBlockEnd === 'auto') { + if (marginBlockStart === 'auto' && marginBlockEnd === 'auto') { // Paragraph 6: equal margins center the box, negative values included - marginBlockStart = marginBlockEnd = rest / 2; - } else if (styleMarginBlockStart === 'auto') { - marginBlockStart = rest - marginBlockEnd; - } else if (styleMarginBlockEnd === 'auto') { - marginBlockEnd = rest - marginBlockStart; + usedMarginBlockStart = usedMarginBlockEnd = rest / 2; + } else if (marginBlockStart === 'auto') { + usedMarginBlockStart = rest - usedMarginBlockEnd; + } else if (marginBlockEnd === 'auto') { + usedMarginBlockEnd = rest - marginBlockStart; } // Otherwise over-constrained, and the block-end inset is the one ignored } if (insetBlockStart !== 'auto') { - box.setBlockPosition(insetBlockStart + marginBlockStart); + box.setBlockPosition(insetBlockStart + usedMarginBlockStart); } else if (insetBlockEnd !== 'auto') { - box.setBlockPosition(cBlockSize - insetBlockEnd - marginBlockEnd - outerBlockSize); + box.setBlockPosition(cBlockSize - insetBlockEnd - usedMarginBlockEnd - outerBlockSize); } else { - // Both insets are auto, so the box stays where it would have been. Postlayout - // shifts it there once the in-flow parent has absolute coordinates - box.setBlockPosition(marginBlockStart); + let blockStart = borderArea.blockStart + usedMarginBlockStart; + let area = staticContainingBlock; + while (area && area !== containingBlock) { + blockStart += containingBlock.getBlockStartOfAreaAgainstSelf(area); + area = area.parent; + } + box.setBlockPosition(blockStart); } } -function layoutAbsoluteBox(layout: Layout, box: BlockLevel, ctx: LayoutContext) { +function layoutAbsoluteBox( + layout: Layout, + staticContainingBlock: BoxArea | null, + box: BlockLevel, + ctx: LayoutContext +) { const cctx: LayoutContext = {...ctx, bfc: undefined}; - const containingBlock = box.getContainingBlock(); - const staticPosition = ctx.staticPositions.get(box); - box.fillAreas(containingBlock); - doInlineBoxModelForAbsoluteBox(layout, box, staticPosition); - const axis = doBlockBoxModelForAbsoluteBox(box); + // NB: fillAreas was called in layoutStaticBox + doInlineBoxModelForAbsoluteBox(layout, staticContainingBlock, box); + doBlockBoxModelForAbsoluteBox(staticContainingBlock, box); - if (box.isBlockContainer()) { - layoutBlockBoxInner(layout, box, cctx); - // The formatting context sizes an auto block size from the content, which is - // only the used value when an inset on that axis is auto - if (!axis.usesContentBlockSize && axis.blockSize !== undefined) { - box.setBlockSize(containingBlock, axis.blockSize); + // A replaced box has no inner layout: the box model above sized it + if (box.isBlockContainer()) layoutBlockBoxInner(layout, box, cctx); +} + +function finalizeBlockContainer( + layout: Layout, + box: BlockContainer, + ctx: LayoutContext +) { + if (box.isAbsoluteContainingBlock()) { + const staticParents: FormattingBox[] = []; + + for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { + const item = layout.tree[i]; + + if (item.isFormattingBox()) { + if (!item.isPositioned()) staticParents.push(item); + if (item.isAbsolute()) { + const staticParent = staticParents[staticParents.length - 1]; + const staticContainingBlock = staticParent?.getContentArea() ?? null; + layoutAbsoluteBox(layout, staticContainingBlock, item, ctx); + } + } + + // A positioned descendant is the containing block for the absolutes + // inside it, so it finalizes them itself + if (item.isBox() && item.isPositioned()) i = item.treeFinal; + + while ( + staticParents.length && + i >= staticParents[staticParents.length - 1].treeFinal + ) staticParents.pop(); } - } else if (axis.usesContentBlockSize) { - box.setBlockSize(containingBlock, box.getDefiniteInnerBlockSize()); } +} - setBlockPositionForAbsoluteBox(box, axis); +export function layoutStaticBox(box: FormattingBox) { + const containingBlock = box.getContainingBlock(); + box.fillAreas(containingBlock); } export class Break extends TreeNode { @@ -1537,6 +1545,8 @@ export class Inline extends Box { // Bits that propagate to Inline propagate again if the parent is Inline parent.bitfield |= (this.bitfield & Box.PROPAGATES_TO_INLINE_BITS); + } else if (parent.isFormattingBox()) { + if (this.hasAbsoluteInCb()) parent.bitfield |= Box.BITS.hasAbsoluteInCb; } } @@ -1802,12 +1812,8 @@ export function inlineIteratorStateNext(state: InlineIteratorState) { } else if (item.isBreak()) { state.buffered.push({state: 'break', index: state.index}); } else { - if (item.isFloat()) { - state.buffered.push({state: 'box', item}); - } else { - state.buffered.push({state: 'box', item}); - state.isInlineBlock = true; - } + state.buffered.push({state: 'box', item}); + if (!item.isOutOfFlow()) state.isInlineBlock = true; state.index = item.treeFinal; } } diff --git a/src/layout-text.ts b/src/layout-text.ts index cf9be11..6731380 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -6,6 +6,7 @@ import { IfcVacancy, Inline, layoutFloatBox, + layoutStaticBox, layoutContribution, createInlineIteratorState, inlineIteratorStateNext, @@ -2926,11 +2927,11 @@ export function createIfcLineboxes( // line is where it would have been if it were in flow, which is the // position it uses when its insets are auto (CSS 2.2 § 10.3.7, § 10.6.4) const contentArea = ifc.block.getContentArea(); + const {lineLeft, lineRight} = mark.box.getMarginsAutoIsZero(containingBlock); const ltr = ifc.block.style.direction === 'ltr'; - ctx.staticPositions.set(mark.box, [ - ifc.vacancy.blockOffset, - ltr ? 0 : contentArea.inlineSize - ]); + layoutStaticBox(mark.box); + mark.box.setBlockPosition(ifc.vacancy.blockOffset); + mark.box.setInlinePosition(ltr ? lineLeft : contentArea.inlineSize - lineRight); } if (mark.inlinePost) { From 2bf869491de214872e2878ba3532e85475b8e564 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 14 Aug 2026 11:12:17 +0200 Subject: [PATCH 4/7] map an auto static position through the containing block's own writing mode --- src/layout-box.ts | 3 +-- test/position-absolute.spec.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/layout-box.ts b/src/layout-box.ts index f440ad3..9c4e005 100644 --- a/src/layout-box.ts +++ b/src/layout-box.ts @@ -712,8 +712,7 @@ export class BoxArea { } getLineLeftOfAreaAgainstSelf(area: BoxArea) { - const thisCb = this.box.getContainingBlock(); - const thisWm = this.box.getWritingModeAsParticipant(thisCb); + const thisWm = this.getEstablishedWritingMode(); const areaCb = area.box.getContainingBlock(); const areaWm = area.box.getWritingModeAsParticipant(areaCb); diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js index 6cf7313..0fba3f0 100644 --- a/test/position-absolute.spec.js +++ b/test/position-absolute.spec.js @@ -296,6 +296,20 @@ describe('Absolute positioning', function () { expect(this.border('#t').y).to.equal(6); }); + it('maps a fully auto static position through a vertical-rl containing block', function () { + this.reflow(` +
+
a
+
+
+ `); + // Both insets are auto on both axes, so the containing block's own + // writing mode is what maps the offset, not the one it participates in + expect(this.border('#t').x).to.equal(200 - 30 - 10); + expect(this.border('#t').y).to.equal(0); + }); + it('uses the intrinsic size of a positioned replaced box', function () { this.reflow(`
From 4d0d55e8f17bee070ebbf4409ba33600c31a9466 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 14 Aug 2026 11:51:19 +0200 Subject: [PATCH 5/7] position absolutes on the line they appear on --- src/layout-flow.ts | 3 +-- src/layout-text.ts | 28 ++++++++++++++++------------ test/position-absolute.spec.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 4a0a79d..183c91c 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -1471,8 +1471,7 @@ function finalizeBlockContainer( } } - // A positioned descendant is the containing block for the absolutes - // inside it, so it finalizes them itself + // A positioned descendant finalizes its own absolutes if (item.isBox() && item.isPositioned()) i = item.treeFinal; while ( diff --git a/src/layout-text.ts b/src/layout-text.ts index 6731380..a6359db 100644 --- a/src/layout-text.ts +++ b/src/layout-text.ts @@ -2272,6 +2272,7 @@ class InlineFormattingContext { lineHasWord: boolean; /** True when we should append the line */ lineIsDirty: boolean; + lineHasAbsolutes: boolean; /** Inlines to be fragmented; shared across finishLine calls */ inlines: Inline[]; @@ -2298,6 +2299,7 @@ class InlineFormattingContext { this.blockOffset = this.bfc.cbBlockStart; this.lineHasWord = false; this.lineIsDirty = false; + this.lineHasAbsolutes = false; this.inlines = []; } } @@ -2533,9 +2535,10 @@ function positionPhysicalLineItems( } } else { const box = layout.tree[item.treeIndex]; - if (box.isFormattingBox() && !box.isOutOfFlow()) { + if (box.isFormattingBox() && !box.isFloat()) { const {lineLeft} = box.getMarginsAutoIsZero(containingBlock); box.setInlinePosition(x + lineLeft); + if (box.isAbsolute()) box.setBlockPosition(line.blockOffset); } } x += item.inlineSpace + item.endSpace; @@ -2549,9 +2552,10 @@ function positionPhysicalLineItems( ifc.block.items[item.itemIndex].x = x; } else { const box = layout.tree[item.treeIndex]; - if (box.isFormattingBox() && !box.isOutOfFlow()) { + if (box.isFormattingBox() && !box.isFloat()) { const {lineLeft} = box.getMarginsAutoIsZero(containingBlock); box.setInlinePosition(x - lineLeft); + if (box.isAbsolute()) box.setBlockPosition(line.blockOffset); } } x -= item.endSpace; @@ -2923,15 +2927,10 @@ export function createIfcLineboxes( } if (mark.box?.isAbsolute()) { - // The box is out of flow, so it contributes nothing to the line, but the - // line is where it would have been if it were in flow, which is the - // position it uses when its insets are auto (CSS 2.2 § 10.3.7, § 10.6.4) - const contentArea = ifc.block.getContentArea(); - const {lineLeft, lineRight} = mark.box.getMarginsAutoIsZero(containingBlock); - const ltr = ifc.block.style.direction === 'ltr'; + // Rides the line with no width, to learn its static position (§ 10.3.7) layoutStaticBox(mark.box); - mark.box.setBlockPosition(ifc.vacancy.blockOffset); - mark.box.setInlinePosition(ltr ? lineLeft : contentArea.inlineSize - lineRight); + ifc.candidates.addBox(mark.box.treeStart, mark.box.treeFinal, 0); + ifc.lineHasAbsolutes = true; } if (mark.inlinePost) { @@ -3074,12 +3073,17 @@ export function createIfcLineboxes( // There could have been floats after the paragraph's final line break bfc.getLocalVacancyForLine(bfc, ifc.blockOffset, ifc.line.height(), ifc.vacancy); finishLine(ifc, ifc.line, true); - } else if (ifc.candidates.width.hasContent()) { + } else if (ifc.candidates.width.hasContent() || ifc.lineHasAbsolutes) { // We never hit a break opportunity because there is no non-whitespace // text and no inline-blocks, but there is some content on spans (border, - // padding, or margin). Add everything. + // padding, or margin), or an absolute still needs a static position. + // Add everything. + const forAbsolutesOnly = !ifc.candidates.width.hasContent(); + const blockOffset = ifc.blockOffset; ifc.line.concat(ifc.candidates); finishLine(ifc, ifc.line, true); + // Not a line box, so it adds no height + if (forAbsolutesOnly) ifc.blockOffset = blockOffset; } else { bfc.fctx?.consumeMisfits(); } diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js index 0fba3f0..38cf49b 100644 --- a/test/position-absolute.spec.js +++ b/test/position-absolute.spec.js @@ -246,6 +246,36 @@ describe('Absolute positioning', function () { expect(this.border('#t').y).to.equal(40); }); + it('uses the position on the line, not the start of it', function () { + this.reflow(` +
hello __is there anybody out there?
+ `); + // The zero-width inline-block is in flow at the same point on the line + expect(this.border('#t').x).to.equal(this.border('#ref').x); + expect(this.border('#t').x).to.be.greaterThan(30); + }); + + it('follows text-align on the line it appears on', function () { + this.reflow(` +
aaa_
+ `); + expect(this.border('#t').x).to.equal(this.border('#ref').x); + expect(this.border('#t').x).to.be.greaterThan(150); + }); + + it('moves to the line the box ends up on after a soft wrap', function () { + this.reflow(` +
aaa bbb ccc
+ `); + // "ccc" does not fit on the first line, and the box goes with it + expect(this.border('#t').y).to.equal(20); + }); + it('takes the static position from the line-right edge in rtl', function () { this.reflow(`
From ed15b039e398a8285c6d60ffaedac359f0964360 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 14 Aug 2026 11:54:47 +0200 Subject: [PATCH 6/7] let the block formatting context position absolutes among blocks --- src/layout-flow.ts | 35 +++++++++++++++++++++++++++++++--- test/position-absolute.spec.js | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 183c91c..6b040cb 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -82,7 +82,7 @@ const EMPTY_MAP = new Map(); export class BlockFormattingContext { public inlineSize: number; public fctx?: FloatContext; - public stack: (BlockContainer | {post: BlockContainer})[]; + public stack: (BlockContainer | {post: BlockContainer} | {abs: BlockLevel})[]; public cbBlockStart: number; public cbLineLeft: number; public cbLineRight: number; @@ -225,6 +225,11 @@ export class BlockFormattingContext { this.last = 'end'; } + boxOutOfFlow(box: BlockLevel) { + layoutStaticBox(box); + this.stack.push({abs: box}); + } + boxAtomic(layout: Layout, box: BlockLevel, ctx: LayoutContext) { const containingBlock = box.getContainingBlock(); const marginBlockEnd = box.style.getMarginBlockEnd(containingBlock); @@ -301,6 +306,18 @@ export class BlockFormattingContext { this.cbBlockStart += margin; for (const item of this.stack) { + if ('abs' in item) { + // Out of flow: it takes a static position but no space, and its margins + // collapse with nothing + const level = sizeStack.length - 1; + const atMarginLevel = passedMarginLevel || this.margin.level === level; + const containingBlock = item.abs.getContainingBlock(); + const {lineLeft} = item.abs.getMarginsAutoIsZero(containingBlock); + item.abs.setBlockPosition(sizeStack[level] + (atMarginLevel ? 0 : margin)); + item.abs.setInlinePosition(lineLeft); + continue; + } + const box = 'post' in item ? item.post : item; if ('post' in item) { @@ -1106,7 +1123,11 @@ function layoutBlockBoxInner( for (let i = box.treeStart + 1; i <= box.treeFinal; i++) { const child = layout.tree[i]; if (!child.isFormattingBox()) throw new Error('Assertion failed'); - layoutBlockLevelBox(layout, child, cctx); + if (child.isAbsolute()) { + cctx.bfc!.boxOutOfFlow(child); + } else { + layoutBlockLevelBox(layout, child, cctx); + } i = child.treeFinal; } } @@ -2109,7 +2130,15 @@ export function generateBlockContainer(tree: InlineLevel[], el: HTMLElement) { preBcInlineChild(tree, ctx); tree.push(new Break(child.style)); } else if (child.style.display.outer === 'block') { - if (child.style.isOutOfFlow()) { + if (child.style.position === 'absolute') { + // Joins whichever formatting context is open: the BFC positions it + // among blocks, the IFC on the line it would have been on + if (ctx.ifcIndex > -1) { + preBcInlineChild(tree, ctx); + } else { + preBcBlockChild(tree, ctx); + } + } else if (child.style.isOutOfFlow()) { preBcInlineChild(tree, ctx); } else { preBcBlockChild(tree, ctx); diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js index 38cf49b..fe30ba4 100644 --- a/test/position-absolute.spec.js +++ b/test/position-absolute.spec.js @@ -340,6 +340,41 @@ describe('Absolute positioning', function () { expect(this.border('#t').y).to.equal(0); }); + it('does not wrap a box among block-level content in an inline', function () { + this.reflow(`
:D
`); + const p = this.get('#p'); + expect(this.layout.tree[p.treeStart + 1]).to.equal(this.get('#t')); + }); + + it('is positioned by the block formatting context among blocks', function () { + this.reflow(`
`); + expect(this.border('#t')).to.deep.equal({x: 6, y: 19, width: 20, height: 20}); + }); + + it('does not collapse its margins with the ones around it', function () { + this.reflow(`
`); + // The siblings collapse to 30 as if the positioned box were not there + expect(this.border('#b').y).to.equal(40); + expect(this.border('#p').height).to.equal(50); + }); + + it('takes a static position from a static parent that already ended', function () { + this.reflow(`
x
`); + // The in-flow parent is the outer box, not the padded box that ended on the + // same tree index as the positioned box inside it + expect(this.border('#t').x).to.equal(0); + expect(this.border('#t').y).to.equal(30); + }); + it('uses the intrinsic size of a positioned replaced box', function () { this.reflow(`
From 98b240dce53d185d605a2ca4386964c2f37e8d41 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 14 Aug 2026 11:58:16 +0200 Subject: [PATCH 7/7] keep the line-left margin when positioning against the line-left inset --- src/layout-flow.ts | 41 ++++++++++++++++++---------------- test/position-absolute.spec.js | 11 +++++++++ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/layout-flow.ts b/src/layout-flow.ts index 6b040cb..0e2cf1c 100644 --- a/src/layout-flow.ts +++ b/src/layout-flow.ts @@ -1298,8 +1298,9 @@ function doInlineBoxModelForAbsoluteBox( let marginLineLeft = styleMarginLineLeft === 'auto' ? 0 : styleMarginLineLeft; let marginLineRight = styleMarginLineRight === 'auto' ? 0 : styleMarginLineRight; let sizedFromInsets = false; + let overConstrained = false; let inlineSize; - let lineLeft = insetLineLeft === 'auto' ? 0 : insetLineLeft; + let lineLeft; // solve: // left = px , width = auto, right = px @@ -1349,34 +1350,36 @@ function doInlineBoxModelForAbsoluteBox( marginLineRight = rest - marginLineLeft; } - // Otherwise the values are over-constrained. The line-right inset is the - // one ignored in ltr and the line-left one in rtl, which is what falls out - // of positioning against the retained side below - if (styleMarginLineLeft !== 'auto' && styleMarginLineRight !== 'auto' && !ltr) { - lineLeft = cInlineSize - insetLineRight - marginLineRight - inlineSize; - } else { - lineLeft += marginLineLeft; - } + // Otherwise the values are over-constrained + overConstrained = styleMarginLineLeft !== 'auto' && styleMarginLineRight !== 'auto'; } - // solve: - // left = auto, width = auto, right = auto - // left = auto, width = px , right = auto if (insetLineLeft === 'auto' && insetLineRight === 'auto') { - lineLeft = box.getBorderArea().lineLeft; // against static cb + // solve: + // left = auto, width = auto, right = auto + // left = auto, width = px , right = auto + lineLeft = box.getBorderArea().lineLeft; // against the static cb let area = staticContainingBlock; while (area && area !== containingBlock) { lineLeft += containingBlock.getLineLeftOfAreaAgainstSelf(area); area = area.parent; } if (!ltr) lineLeft -= inlineSize; - } - - // solve: - // left = auto, width = auto, right = px - // left = auto, width = px , right = px - if (insetLineLeft === 'auto' && insetLineRight !== 'auto') { + } else if (insetLineLeft !== 'auto' && (insetLineRight === 'auto' || ltr || !overConstrained)) { + // solve: + // left = px , width = auto, right = auto + // left = px , width = px , right = auto + // left = px , width = auto, right = px + // left = px , width = px , right = px , unless over-constrained in rtl + lineLeft = insetLineLeft + marginLineLeft; + } else if (insetLineRight !== 'auto') { + // solve: + // left = auto, width = auto, right = px + // left = auto, width = px , right = px + // left = px , width = px , right = px , over-constrained in rtl lineLeft = cInlineSize - insetLineRight - marginLineRight - inlineSize; + } else { + throw new Error('Assertion failed'); } box.setInlineOuterSize(containingBlock, inlineSize); diff --git a/test/position-absolute.spec.js b/test/position-absolute.spec.js index fe30ba4..e99b1cd 100644 --- a/test/position-absolute.spec.js +++ b/test/position-absolute.spec.js @@ -156,6 +156,17 @@ describe('Absolute positioning', function () { expect(this.border('#t').y).to.equal(0); }); + it('adds the line-left margin when the size came from both insets', function () { + this.reflow(` +
+
+
+ `); + expect(this.border('#t').x).to.equal(15); + expect(this.border('#t').width).to.equal(300 - 10 - 30 - 5); + }); + it('ignores the line-right inset when over-constrained in ltr', function () { this.reflow(`