From 08beae7b834d05390eebe91ccb7e6d76eb5a15ee Mon Sep 17 00:00:00 2001 From: Endika Date: Wed, 29 Jul 2026 16:56:28 +0200 Subject: [PATCH 1/2] 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 7d3468d..83e0793 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 f25baa7..d3d71bd 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'; @@ -50,6 +50,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 ac41203583bdb32dfe508b13c02298aee69332d3 Mon Sep 17 00:00:00 2001 From: Endika Iglesias Date: Fri, 31 Jul 2026 11:27:13 +0200 Subject: [PATCH 2/2] 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 d3d71bd..d216076 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'; @@ -48,10 +49,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) {