UNPKG

dropflow

Version:

A small CSS2 document renderer built from specifications

668 lines (667 loc) 26.4 kB
import { G_CL, G_AX, G_SZ } from './text-harfbuzz.js'; import { ShapedItem } from './layout-text.js'; import { binarySearchOf } from './util.js'; function getTextOffsetsForUncollapsedGlyphs(item) { const glyphs = item.glyphs; let glyphStart = 0; let glyphEnd = glyphs.length - G_SZ; while (glyphStart < glyphs.length && glyphs[glyphStart + G_AX] === 0) glyphStart += G_SZ; while (glyphEnd >= 0 && glyphs[glyphEnd + G_AX] === 0) glyphEnd -= G_SZ; if (glyphStart in glyphs && glyphEnd in glyphs) { let textStart, textEnd; if (item.attrs.level & 1) { textStart = glyphs[glyphEnd + G_CL]; if (glyphStart - G_SZ >= 0) { textEnd = glyphs[glyphStart - G_SZ + G_CL]; } else { textEnd = item.end(); } } else { textStart = glyphs[glyphStart + G_CL]; if (glyphEnd + G_SZ < glyphs.length) { textEnd = glyphs[glyphEnd + G_SZ + G_CL]; } else { textEnd = item.end(); } } return { textStart, textEnd }; } else { return { textStart: 0, textEnd: 0 }; } } function drawText(item, colors, b) { const style = item.attrs.style; const { textStart, textEnd } = getTextOffsetsForUncollapsedGlyphs(item); // Split the colors into spans so that colored diacritics can work. // Sadly this seems to only work in Firefox and only when the font doesn't do // any normalizination, so I could probably stop trying to support it // https://github.com/w3c/csswg-drafts/issues/699 const end = item.attrs.level & 1 ? item.colorsStart(colors) - 1 : item.colorsEnd(colors); let i = item.attrs.level & 1 ? item.colorsEnd(colors) - 1 : item.colorsStart(colors); let glyphIndex = 0; let tx = item.x; while (i !== end) { const [color, offset] = colors[i]; const colorStart = offset; const colorEnd = i + 1 < colors.length ? colors[i + 1][1] : textEnd; const start = Math.max(colorStart, textStart); const end = Math.min(colorEnd, textEnd); if (start < end) { // TODO: should really have isStartColorBoundary, isEndColorBoundary const isColorBoundary = start !== textStart && start === colorStart || end !== textEnd && end === colorEnd; let ax = 0; if (item.attrs.level & 1) { while (glyphIndex < item.glyphs.length && item.glyphs[glyphIndex + G_CL] >= start) { ax += item.glyphs[glyphIndex + G_AX]; glyphIndex += G_SZ; } } else { while (glyphIndex < item.glyphs.length && item.glyphs[glyphIndex + G_CL] < end) { ax += item.glyphs[glyphIndex + G_AX]; glyphIndex += G_SZ; } } b.fillColor = color; b.fontSize = style.fontSize; b.font = item.face; b.direction = item.attrs.level & 1 ? 'rtl' : 'ltr'; b.text(tx, item.y, item, start, end, isColorBoundary); tx += ax / item.face.hbface.upem * style.fontSize; } if (item.attrs.level & 1) { i -= 1; } else { i += 1; } } } /** * Paints the background and borders */ function paintFormattingBoxBackground(box, b, isRoot = false) { const style = box.style; const borderArea = box.getBorderArea(); if (!isRoot) { const paddingArea = box.getPaddingArea(); const contentArea = box.getContentArea(); const { backgroundColor, backgroundClip } = style; const area = backgroundClip === 'border-box' ? borderArea : backgroundClip === 'padding-box' ? paddingArea : contentArea; if (backgroundColor.a > 0) { b.fillColor = backgroundColor; b.rect(area.x, area.y, area.width, area.height); } } const work = [ ['top', style.borderTopWidth, style.borderTopColor], ['right', style.borderRightWidth, style.borderRightColor], ['bottom', style.borderBottomWidth, style.borderBottomColor], ['left', style.borderLeftWidth, style.borderLeftColor], ]; for (const [side, lineWidth, color] of work) { if (lineWidth === 0 || color.a === 0) continue; const length = side === 'top' || side === 'bottom' ? borderArea.width : borderArea.height; let x = side === 'right' ? borderArea.x + borderArea.width - lineWidth : borderArea.x; let y = side === 'bottom' ? borderArea.y + borderArea.height - lineWidth : borderArea.y; b.strokeColor = color; b.lineWidth = lineWidth; x += side === 'left' || side === 'right' ? lineWidth / 2 : 0; y += side === 'top' || side === 'bottom' ? lineWidth / 2 : 0; b.edge(x, y, length, side); } } function paintBackgroundDescendents(root, b) { const stack = [root]; const parents = []; while (stack.length) { const box = stack.pop(); if ('sentinel' in box) { const box = parents.pop(); if (box.isFormattingBox() && box.style.overflow === 'hidden' && box !== root) { b.popClip(); } } else { if (!box.isInline() && !box.isInlineLevel() && box !== root) { paintFormattingBoxBackground(box, b); } if (box.isBlockContainer() && box.hasBackgroundInLayerRoot()) { stack.push({ sentinel: true }); parents.push(box); if (box.isFormattingBox() && box.style.overflow === 'hidden' && box !== root) { const { x, y, width, height } = box.getPaddingArea(); b.pushClip(x, y, width, height); } for (let i = box.children.length - 1; i >= 0; i--) { const child = box.children[i]; if (child.isBox() && !child.isLayerRoot()) stack.push(child); } } } } } // TODO: since vertical padding is added above, hardware pixel snapping has // to happen here. But block containers are snapped during layout, so it'd // be more consistent to do it there. To be more consistent with the specs, // and hopefully clean up the code, I should start making "continuations" // (Firefox) of inlines, or create fragments out of them (Chrome) function snap(ox, oy, ow, oh) { const x = Math.round(ox); const y = Math.round(oy); const width = Math.round(ox + ow) - x; const height = Math.round(oy + oh) - y; return { x, y, width, height }; } function paintInlineBackground(background, inline, paragraph, b) { const ifc = paragraph.ifc; const direction = ifc.style.direction; const bgc = inline.style.backgroundColor; const clip = inline.style.backgroundClip; const { borderTopColor, borderRightColor, borderBottomColor, borderLeftColor } = inline.style; const { a: ta } = borderTopColor; const { a: ra } = borderRightColor; const { a: ba } = borderBottomColor; const { a: la } = borderLeftColor; const { start, end, blockOffset, ascender, descender, naturalStart, naturalEnd } = background; const paddingTop = inline.style.getPaddingBlockStart(ifc); const paddingRight = inline.style.getPaddingLineRight(ifc); const paddingBottom = inline.style.getPaddingBlockEnd(ifc); const paddingLeft = inline.style.getPaddingLineLeft(ifc); const paintLeft = naturalStart && direction === 'ltr' || naturalEnd && direction === 'rtl'; const paintRight = naturalEnd && direction === 'ltr' || naturalStart && direction === 'rtl'; const borderTopWidth = inline.style.getBorderBlockStartWidth(ifc); let borderRightWidth = inline.style.getBorderLineRightWidth(ifc); const borderBottomWidth = inline.style.getBorderBlockEndWidth(ifc); let borderLeftWidth = inline.style.getBorderLineLeftWidth(ifc); if (!paintLeft) borderLeftWidth = 0; if (!paintRight) borderRightWidth = 0; if (start !== end && bgc.a > 0) { let extraTop = 0; let extraBottom = 0; if (clip !== 'content-box') { extraTop += inline.style.getPaddingBlockStart(ifc); extraBottom += inline.style.getPaddingBlockEnd(ifc); } if (clip === 'border-box') { extraTop += borderTopWidth; extraBottom += borderBottomWidth; } b.fillColor = bgc; const { x, y, width, height } = snap(Math.min(start, end), blockOffset - ascender - extraTop, Math.abs(start - end), ascender + descender + extraTop + extraBottom); b.rect(x, y, width, height); } if (start !== end && (ta > 0 || ra > 0 || ba > 0 || la > 0)) { let extraLeft = 0; let extraRight = 0; if (paintLeft && clip === 'content-box') extraLeft += paddingLeft; if (paintLeft && clip !== 'border-box') extraLeft += borderLeftWidth; if (paintRight && clip === 'content-box') extraRight += paddingRight; if (paintRight && clip !== 'border-box') extraRight += borderRightWidth; const work = [ ['top', borderTopWidth, borderTopColor], ['right', borderRightWidth, borderRightColor], ['bottom', borderBottomWidth, borderBottomColor], ['left', borderLeftWidth, borderLeftColor] ]; // TODO there's a bug here: try // <span style="background-color:red; border-left: 2px solid yellow; border-top: 4px solid maroon;">red</span> for (const [side, lineWidth, color] of work) { if (lineWidth === 0) continue; const rect = snap(Math.min(start, end) - extraLeft, blockOffset - ascender - paddingTop - borderTopWidth, Math.abs(start - end) + extraLeft + extraRight, borderTopWidth + paddingTop + ascender + descender + paddingBottom + borderBottomWidth); const length = side === 'left' || side === 'right' ? rect.height : rect.width; let x = side === 'right' ? rect.x + rect.width : rect.x; let y = side === 'bottom' ? rect.y + rect.height : rect.y; x += side === 'left' ? lineWidth / 2 : side === 'right' ? -lineWidth / 2 : 0; y += side === 'top' ? lineWidth / 2 : side === 'bottom' ? -lineWidth / 2 : 0; b.lineWidth = lineWidth; b.strokeColor = color; b.edge(x, y, length, side); } } } function paintReplacedBox(box, b) { const image = box.getImage(); if (image?.status === 'loaded') { const { x, y, width, height } = box.getContentArea(); b.image(x, y, width, height, image); } } function paintInlines(root, ifc, b) { const colors = ifc.paragraph.getColors(); const lineboxes = ifc.paragraph.lineboxes; const painted = new Set(); let lineboxIndex = -1; let lineboxItem = null; for (const item of ifc.paragraph.treeItems) { let hasPositionedParent = false; if (lineboxItem) lineboxItem = lineboxItem.next; if (!lineboxItem) { // starting a new linebox lineboxItem = lineboxes[++lineboxIndex].head; painted.clear(); } for (const inline of item.inlines) { if (inline.isLayerRoot()) { hasPositionedParent = true; break; } else if (!painted.has(inline)) { const backgrounds = ifc.paragraph.backgroundBoxes.get(inline); if (backgrounds) { for (const background of backgrounds) { if (background.linebox === lineboxes[lineboxIndex]) { paintInlineBackground(background, inline, ifc.paragraph, b); } } } painted.add(inline); } } if (!hasPositionedParent) { if (item instanceof ShapedItem) { drawText(item, colors, b); } else if (item.box) { if (item.box.isReplacedBox()) { if (!item.box.isLayerRoot()) { paintFormattingBoxBackground(item.box, b); paintReplacedBox(item.box, b); } } else { const blockLayerRoot = root.inlineBlocks.get(item.box); paintBlockLayerRoot(blockLayerRoot, b); } } } } } function paintBlockForeground(root, b) { const stack = [root.box]; while (stack.length) { const box = stack.pop(); if ('sentinel' in box) { b.popClip(); } else if (box.isReplacedBox()) { // Belongs to this LayerRoot if (box === root.box || !box.isLayerRoot()) paintReplacedBox(box, b); } else if (box.isInline()) { paintInlines(root, box, b); } else { if ( // Belongs to this LayerRoot (box === root.box || !box.isLayerRoot()) && // Has something we should paint underneath it (box.hasForegroundInLayerRoot() || root.isInInlineBlockPath(box))) { if (box !== root.box && box.style.overflow === 'hidden') { const { x, y, width, height } = box.getPaddingArea(); b.pushClip(x, y, width, height); stack.push({ sentinel: true }); } for (let i = box.children.length - 1; i >= 0; i--) { stack.push(box.children[i]); } } } } } function paintInline(root, paragraph, b) { const colors = paragraph.getColors(); const treeItems = paragraph.treeItems; const stack = root.box.children.slice().reverse(); const ranges = []; let itemIndex = binarySearchOf(paragraph.treeItems, root.box.start, item => item.offset); function paintRanges() { while (ranges.length) { const [start, end] = ranges.shift(); while (treeItems[itemIndex]?.offset < start) itemIndex++; while (treeItems[itemIndex]?.end() <= end) { const item = treeItems[itemIndex]; let hasPositionedParent = false; for (let i = item.inlines.length - 1; i >= 0; i--) { if (item.inlines[i] === root.box) break; if (item.inlines[i].isLayerRoot()) { hasPositionedParent = true; break; } } if (!hasPositionedParent && item instanceof ShapedItem) { drawText(item, colors, b); } itemIndex++; } } } while (stack.length) { const box = stack.pop(); if (box.isRun()) { const range = ranges.at(-1); if (range?.[1] === box.start) { range[1] = box.end; } else { ranges.push([box.start, box.end]); } } else if (box.isBox() && !box.isPositioned()) { if (box.isInline()) { for (let i = box.children.length - 1; i >= 0; i--) { stack.push(box.children[i]); } } else if (box.isReplacedBox()) { paintFormattingBoxBackground(box, b); paintReplacedBox(box, b); } else { const layerRoot = root.inlineBlocks.get(box); paintRanges(); paintBlockLayerRoot(layerRoot, b); } } } paintRanges(); } class LayerRoot { box; parents; negativeRoots; floats; positionedRoots; positiveRoots; /** * Unlike the other child roots, inline-blocks are painted when text is * painted - after text that comes before them and before text that comes * after. The map allows lookup while walking the inline tree. */ inlineBlocks; constructor(box, parents) { this.box = box; this.parents = parents; this.negativeRoots = []; this.floats = []; this.positionedRoots = []; this.positiveRoots = []; this.inlineBlocks = new Map(); } get zIndex() { const zIndex = this.box.style.zIndex; return zIndex === 'auto' ? 0 : zIndex; } finalize(preorderScores) { this.negativeRoots.sort((a, b) => a.zIndex - b.zIndex); this.floats.sort((a, b) => preorderScores.get(a.box) - preorderScores.get(b.box)); this.positionedRoots.sort((a, b) => preorderScores.get(a.box) - preorderScores.get(b.box)); this.positiveRoots.sort((a, b) => a.zIndex - b.zIndex); } isEmpty() { return !this.box.hasBackground() && !this.box.hasForeground() && !this.box.hasBackgroundInLayerRoot() && !this.box.hasForegroundInLayerRoot() && this.negativeRoots.length === 0 && this.floats.length === 0 && this.positionedRoots.length === 0 && this.positiveRoots.length === 0 && this.inlineBlocks.size === 0; } /** * Returns true if the box belongs to this LayerRoot and is a parent of an * inline-block LayerRoot (which would be a direct child of this LayerRoot). * * The paint foreground algorithm normally only descends boxes with the * hasForegroundInLayerRoot bit set, for obvious reasons. However, since an * inline-block creates its own layer root, it does not contribute foreground. * This is used as an additional check next to hasForegroundInLayerRoot when * descending. */ isInInlineBlockPath(box) { if (this.inlineBlocks.size === 0) return false; if (box === this.box) return true; for (const root of this.inlineBlocks.values()) { if (root.parents.includes(box)) return true; } return false; } isBlockLayerRoot() { return false; } isInlineLayerRoot() { return false; } } class BlockLayerRoot extends LayerRoot { box; constructor(box, parents) { super(box, parents); this.box = box; } isBlockLayerRoot() { return true; } } class InlineLayerRoot extends LayerRoot { box; paragraph; constructor(box, parents, paragraph) { super(box, parents); this.box = box; this.paragraph = paragraph; } isInlineLayerRoot() { return true; } } function createLayerRoot(box) { const layerRoot = new BlockLayerRoot(box, []); const preorderIndices = new Map(); const parentRoots = [layerRoot]; const stack = box.children.slice().reverse(); const parents = []; let preorderIndex = 0; while (stack.length) { const box = stack.pop(); let layerRoot; if ('sentinel' in box) { const layerRoot = parentRoots.at(-1); const box = parents.pop(); if (layerRoot.box === box) { if (!layerRoot.isEmpty()) { let parentRootIndex = parentRoots.length - 2; let parentRoot = parentRoots[parentRootIndex]; if (box.isPositioned()) { const zIndex = box.style.zIndex; while (parentRootIndex > 0 && !parentRoots[parentRootIndex].box.isStackingContextRoot()) { parentRoot = parentRoots[--parentRootIndex]; } if (zIndex < 0) { parentRoot.negativeRoots.push(layerRoot); } else if (zIndex > 0) { parentRoot.positiveRoots.push(layerRoot); } else { parentRoot.positionedRoots.push(layerRoot); } } else if (box.isFormattingBox() && box.isFloat()) { parentRoot.floats.push(layerRoot); } layerRoot.finalize(preorderIndices); } parentRoots.pop(); } } else if (box.isBox()) { let parentRootIndex = parentRoots.length - 1; let parentRoot = parentRoots[parentRootIndex]; preorderIndices.set(box, preorderIndex++); if (box.isPositioned()) { while (parentRootIndex > 0 && !parentRoots[parentRootIndex].box.isStackingContextRoot()) { parentRoot = parentRoots[--parentRootIndex]; } const parentIndex = parents.findLastIndex(box => parentRoot.box === box); const paintRootParents = parents.slice(parentIndex + 1); let nearestParagraph; if (box.isInline()) { for (let i = parents.length - 1; i >= 0; i--) { const parent = parents[i]; if (parent.isIfcInline()) { nearestParagraph = parent.paragraph; break; } } } if (box.isInline()) { layerRoot = new InlineLayerRoot(box, paintRootParents, nearestParagraph); } else { layerRoot = new BlockLayerRoot(box, paintRootParents); } } else if (!box.isInline()) { if (box.isFloat() || box.isBlockContainer() && box.isInlineLevel()) { const parentIndex = parents.findLastIndex(box => parentRoot.box === box); const paintRootParents = parents.slice(parentIndex + 1); layerRoot = new BlockLayerRoot(box, paintRootParents); if (box.isBlockContainer() && box.isInlineLevel()) { parentRoot.inlineBlocks.set(box, layerRoot); } } } if (box.hasBackgroundInDescendent() || box.hasForegroundInDescendent() || box.hasBackground() || box.hasForeground()) { stack.push({ sentinel: true }); parents.push(box); if (layerRoot) parentRoots.push(layerRoot); if (box.isBlockContainer() || box.isInline()) { for (let i = box.children.length - 1; i >= 0; i--) { stack.push(box.children[i]); } } } } } layerRoot.finalize(preorderIndices); return layerRoot; } function paintInlineLayerRoot(root, b) { for (const r of root.negativeRoots) paintLayerRoot(r, b); if (root.box.hasBackgroundInLayerRoot()) { paintBackgroundDescendents(root.box, b); } for (const r of root.floats) paintLayerRoot(r, b); const backgrounds = root.paragraph.backgroundBoxes.get(root.box); if (backgrounds) { for (const background of backgrounds) { paintInlineBackground(background, root.box, root.paragraph, b); } } if (root.box.hasForeground() || root.box.hasForegroundInLayerRoot()) { paintInline(root, root.paragraph, b); } for (const r of root.positionedRoots) paintLayerRoot(r, b); for (const r of root.positiveRoots) paintLayerRoot(r, b); } function paintBlockLayerRoot(root, b, isRoot = false) { if (root.box.hasBackground() && !isRoot) paintFormattingBoxBackground(root.box, b); if (!isRoot && root.box.style.overflow === 'hidden') { const { x, y, width, height } = root.box.getPaddingArea(); b.pushClip(x, y, width, height); } for (const r of root.negativeRoots) paintLayerRoot(r, b); if (root.box.hasBackgroundInLayerRoot()) { paintBackgroundDescendents(root.box, b); } for (const r of root.floats) paintLayerRoot(r, b); if (root.box.hasForeground() || root.box.hasForegroundInLayerRoot() || root.inlineBlocks.size) { paintBlockForeground(root, b); } for (const r of root.positionedRoots) paintLayerRoot(r, b); for (const r of root.positiveRoots) paintLayerRoot(r, b); if (!isRoot && root.box.style.overflow === 'hidden') b.popClip(); } function paintLayerRoot(paintRoot, b) { for (const parent of paintRoot.parents) { if (parent.isBlockContainer() && parent.style.overflow === 'hidden') { const { x, y, width, height } = parent.getPaddingArea(); b.pushClip(x, y, width, height); } } if (paintRoot.isBlockLayerRoot()) { paintBlockLayerRoot(paintRoot, b); } else if (paintRoot.isInlineLayerRoot()) { paintInlineLayerRoot(paintRoot, b); } for (const parent of paintRoot.parents) { if (parent.isBlockContainer() && parent.style.overflow === 'hidden') { b.popClip(); } } } /** * Paint the root element * https://www.w3.org/TR/CSS22/zindex.html */ export default function paint(block, b) { const layerRoot = createLayerRoot(block); if (!layerRoot.isEmpty()) { // Propagate background color and overflow to the viewport if (block.style.backgroundColor.a > 0) { const area = block.containingBlock; b.fillColor = block.style.backgroundColor; b.rect(area.x, area.y, area.width, area.height); } if (block.style.overflow === 'hidden') { const { x, y, width, height } = block.containingBlock; b.pushClip(x, y, width, height); } paintBlockLayerRoot(layerRoot, b, true); if (block.style.overflow === 'hidden') b.popClip(); } }