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..d216076 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -2,13 +2,14 @@ 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';
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,9 +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(), {});
- 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 91888b2..1d53958 100644
--- a/src/layout-box.ts
+++ b/src/layout-box.ts
@@ -555,7 +555,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 +638,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 +734,56 @@ 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[] = [];
@@ -772,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);
@@ -836,6 +920,17 @@ 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 217a1fb..02af535 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 {
@@ -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 {
@@ -813,7 +819,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 +1072,28 @@ 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,
@@ -1086,7 +1113,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, cctx);
+ } else if (containingBfc) {
// text layout happens in bfc.boxStart
} else {
box.doTextLayout(layout, cctx);
@@ -1239,6 +1270,216 @@ 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
+ 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) {
+ 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. Postlayout
+ // shifts it there once the in-flow parent has absolute coordinates
+ box.setBlockPosition(marginBlockStart);
+ }
+}
+
+function layoutAbsoluteBox(layout: Layout, 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);
+
+ 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);
+}
+
export class Break extends TreeNode {
public className = 'break';
@@ -1830,6 +2071,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..cf9be11 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,18 @@ 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';
+ ctx.staticPositions.set(mark.box, [
+ 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(`
+
+ `);
+ // 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(`
+
+ `);
+ // 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);
+ });
+});