diff --git a/src/css/style.ts b/src/css/style.ts index 2a9167a..02cdf99 100644 --- a/src/css/style.ts +++ b/src/css/style.ts @@ -126,6 +126,7 @@ export interface StyleAccumulator { right?: LengthLike; bottom?: LengthLike; left?: LengthLike; + opacity?: number; } export interface StyleProperties { @@ -210,6 +211,7 @@ export interface StyleProperties { breakInside: string; widows: number; orphans: number; + opacity: number; } const defaultStyle = BrowserDefaults.createBaseDefaults() as StyleProperties; @@ -296,6 +298,7 @@ export class ComputedStyle implements StyleProperties { breakInside: string; widows: number; orphans: number; + opacity: number; constructor(init?: Partial) { const data: StyleProperties = { @@ -386,6 +389,7 @@ export class ComputedStyle implements StyleProperties { this.breakInside = data.breakInside; this.widows = data.widows; this.orphans = data.orphans; + this.opacity = data.opacity; this.textAlign = init?.textAlign ?? undefined; this.verticalAlign = init?.verticalAlign ?? undefined; this.textDecorationLine = init?.textDecorationLine ?? defaultStyle.textDecorationLine; diff --git a/src/pdf/layout-tree-builder.ts b/src/pdf/layout-tree-builder.ts index 6441222..dc57205 100644 --- a/src/pdf/layout-tree-builder.ts +++ b/src/pdf/layout-tree-builder.ts @@ -366,9 +366,11 @@ function convertNode(node: LayoutNode, state: { counter: number }): RenderBox { // Handle background (colors, gradients, images) const background = handleBackground(node, borderBox, paddingBox, contentBox); + const position = node.style.position; const zIndex = typeof node.style.zIndex === "number" ? node.style.zIndex : 0; - const establishesStackingContext = - typeof node.style.zIndex === "number" && node.style.position !== Position.Static; + const opacity = typeof node.style.opacity === "number" ? node.style.opacity : 1; + const isPositioned = position !== Position.Static; + const establishesStackingContext = (isPositioned && typeof node.style.zIndex === "number") || opacity < 1; return { id, @@ -392,7 +394,7 @@ function convertNode(node: LayoutNode, state: { counter: number }): RenderBox { left: resolveLength(node.style.borderLeft, Math.max(node.box.contentWidth, 0), { auto: "zero" }), }, borderRadius, - opacity: 1, + opacity, overflow: mapOverflow(node.style.overflowX ?? OverflowMode.Visible), textRuns, decorations: decorations ?? {}, diff --git a/src/pdf/pagination.ts b/src/pdf/pagination.ts index 9a0fe7c..ddd1377 100644 --- a/src/pdf/pagination.ts +++ b/src/pdf/pagination.ts @@ -34,7 +34,13 @@ export function paginateTree(root: RenderBox, options: PaginationOptions): Layou const decorations: DecorationCommand[] = []; // Placeholder until decoration pagination is implemented + const pageRoot: RenderBox = { + ...root, + children: paintOrder, + }; + pages.push({ + root: pageRoot, paintOrder, floatLayerOrder: [], flowContentOrder, diff --git a/src/pdf/renderer/page-paint.ts b/src/pdf/renderer/page-paint.ts index 16efc37..16a0ecf 100644 --- a/src/pdf/renderer/page-paint.ts +++ b/src/pdf/renderer/page-paint.ts @@ -2,10 +2,11 @@ import { pickFooterVariant, pickHeaderVariant, paintHeaderFooter, type HeaderFoo import { PagePainter, type PainterResult } from "../page-painter.js"; import type { FontRegistry } from "../font/font-registry.js"; import { LayerMode, NodeKind } from "../types.js"; -import type { LayoutPageTree, PageSize, Radius, RenderBox, RGBA, Rect, TextPaintOptions } from "../types.js"; +import type { LayoutPageTree, PageSize, RenderBox, RGBA, Rect, TextPaintOptions } from "../types.js"; import { paintBoxShadows } from "./paint-box-shadows.js"; -import { clampRadiusComponent, shrinkRadius } from "./radius.js"; import { renderSvgBox } from "../svg/render-svg.js"; +import { sortBoxesByZIndex } from "./sort-by-z-index.js"; +import { paintSingleBackground, paintSingleBorder } from "./paint-utils.js"; export interface PagePaintInput { readonly pageTree: LayoutPageTree; @@ -22,6 +23,38 @@ export interface PagePaintInput { readonly pageBackground?: RGBA; } +function flattenBoxTree(root: RenderBox): RenderBox[] { + const result: RenderBox[] = []; + const queue = [root]; + while (queue.length > 0) { + const box = queue.shift(); + if (box) { + result.push(box); + queue.push(...box.children); + } + } + return result; +} + +async function paintBox(painter: PagePainter, box: RenderBox): Promise { + paintBoxShadows(painter, [box], false); // Inset shadows + paintSingleBackground(painter, box); + paintBoxShadows(painter, [box], true); // Outset shadows + paintSingleBorder(painter, box); + + if (box.kind === NodeKind.Svg || (box.tagName === "svg" && box.customData?.svg)) { + await renderSvgBox(painter, box); + } + + if (box.image) { + painter.drawImage(box.image, box.contentBox); + } + + for (const run of box.textRuns) { + await painter.drawTextRun(run); + } +} + export async function paintLayoutPage({ pageTree, pageNumber, @@ -47,13 +80,12 @@ export async function paintLayoutPage({ await paintHeaderFooter(painter, headerVariant, footerVariant, tokens, pageNumber, totalPages, headerFooterTextOptions, true); } - paintBoxShadows(painter, pageTree.paintOrder, false); - paintBackgrounds(painter, pageTree.paintOrder); - paintBoxShadows(painter, pageTree.paintOrder, true); - paintBorders(painter, pageTree.paintOrder); - await paintSvg(painter, pageTree.flowContentOrder); - paintImages(painter, pageTree.flowContentOrder); - await paintText(painter, pageTree.flowContentOrder); + const allBoxes = flattenBoxTree(pageTree.root); + const sortedBoxes = sortBoxesByZIndex(allBoxes); + + for (const box of sortedBoxes) { + await paintBox(painter, box); + } if (headerFooterLayout.layerMode === LayerMode.Over) { await paintHeaderFooter(painter, headerVariant, footerVariant, tokens, pageNumber, totalPages, headerFooterTextOptions, false); @@ -62,122 +94,6 @@ export async function paintLayoutPage({ return painter.result(); } -async function paintText(painter: PagePainter, boxes: RenderBox[]): Promise { - for (const box of boxes) { - for (const run of box.textRuns) { - await painter.drawTextRun(run); - } - } -} - -function paintBackgrounds(painter: PagePainter, boxes: RenderBox[]): void { - for (const box of boxes) { - const background = box.background; - if (!background) { - continue; - } - - const paintArea = determineBackgroundPaintArea(box); - if (!paintArea) { - continue; - } - - if (background.color) { - painter.fillRoundedRect(paintArea.rect, paintArea.radius, background.color); - } - - if (background.gradient) { - painter.fillRoundedRect(paintArea.rect, paintArea.radius, background.gradient as any); - } - - if (background.image) { - paintBackgroundImageLayer(painter, background.image, paintArea.rect, paintArea.radius); - } - } -} - -function paintBackgroundImageLayer( - painter: PagePainter, - layer: RenderBox["background"]["image"], - clipRect: Rect, - clipRadius: Radius, -): void { - if (!layer) { - return; - } - if (layer.repeat && layer.repeat !== "no-repeat") { - console.warn(`Background repeat mode "${layer.repeat}" is not fully supported yet. Rendering first tile only.`); - } - painter.drawBackgroundImage(layer.image, layer.rect, clipRect, clipRadius); -} - -function paintBorders(painter: PagePainter, boxes: RenderBox[]): void { - for (const box of boxes) { - const color = box.borderColor; - if (!color) { - continue; - } - const { border } = box; - if (!hasVisibleBorder(border)) { - continue; - } - const outerRect = box.borderBox; - const innerRect = { - x: outerRect.x + border.left, - y: outerRect.y + border.top, - width: Math.max(outerRect.width - border.left - border.right, 0), - height: Math.max(outerRect.height - border.top - border.bottom, 0), - }; - const innerRadius = shrinkRadius(box.borderRadius, border.top, border.right, border.bottom, border.left); - if (innerRect.width <= 0 || innerRect.height <= 0) { - painter.fillRoundedRect(outerRect, box.borderRadius, color); - } else { - painter.fillRoundedRectDifference(outerRect, box.borderRadius, innerRect, innerRadius, color); - } - } -} - -function determineBackgroundPaintArea(box: RenderBox): { rect: Rect; radius: Radius } | null { - const rect = box.borderBox ?? box.paddingBox ?? box.contentBox; - if (!rect) { - return null; - } - - if (rect === box.borderBox) { - return { rect, radius: box.borderRadius }; - } - - let radius = shrinkRadius(box.borderRadius, box.border.top, box.border.right, box.border.bottom, box.border.left); - if (rect === box.contentBox) { - radius = shrinkRadius(radius, box.padding.top, box.padding.right, box.padding.bottom, box.padding.left); - } - - return { rect, radius }; -} - -function hasVisibleBorder(border: RenderBox["border"]): boolean { - return border.top > 0 || border.right > 0 || border.bottom > 0 || border.left > 0; -} - -function paintImages(painter: PagePainter, boxes: RenderBox[]): void { - for (const box of boxes) { - if (box.image) { - painter.drawImage(box.image, box.contentBox); - } - } -} - -async function paintSvg(painter: PagePainter, boxes: RenderBox[]): Promise { - for (const box of boxes) { - if (box.kind === NodeKind.Svg || (box.tagName === "svg" && box.customData?.svg)) { - await renderSvgBox(painter, box); - } - if (box.children.length > 0) { - await paintSvg(painter, box.children); - } - } -} - function paintPageBackground(painter: PagePainter, color: RGBA | undefined, widthPx: number, heightPx: number, offsetY: number): void { if (!color) { return; diff --git a/src/pdf/renderer/paint-utils.ts b/src/pdf/renderer/paint-utils.ts new file mode 100644 index 0000000..e968d38 --- /dev/null +++ b/src/pdf/renderer/paint-utils.ts @@ -0,0 +1,77 @@ +import type { PagePainter } from "../page-painter.js"; +import type { RenderBox, Rect, Radius } from "../types.js"; +import { shrinkRadius } from "./radius.js"; + +function determineBackgroundPaintArea(box: RenderBox): { rect: Rect; radius: Radius } | null { + const rect = box.borderBox ?? box.paddingBox ?? box.contentBox; + if (!rect) { + return null; + } + + if (rect === box.borderBox) { + return { rect, radius: box.borderRadius }; + } + + let radius = shrinkRadius(box.borderRadius, box.border.top, box.border.right, box.border.bottom, box.border.left); + if (rect === box.contentBox) { + radius = shrinkRadius(radius, box.padding.top, box.padding.right, box.padding.bottom, box.padding.left); + } + + return { rect, radius }; +} + +export function paintSingleBackground(painter: PagePainter, box: RenderBox): void { + const background = box.background; + if (!background) { + return; + } + + const paintArea = determineBackgroundPaintArea(box); + if (!paintArea) { + return; + } + + if (background.color) { + painter.fillRoundedRect(paintArea.rect, paintArea.radius, background.color); + } + + if (background.gradient) { + painter.fillRoundedRect(paintArea.rect, paintArea.radius, background.gradient as any); + } + + if (background.image) { + const layer = background.image; + if (layer.repeat && layer.repeat !== "no-repeat") { + console.warn(`Background repeat mode "${layer.repeat}" is not fully supported yet. Rendering first tile only.`); + } + painter.drawBackgroundImage(layer.image, layer.rect, paintArea.rect, paintArea.radius); + } +} + +function hasVisibleBorder(border: RenderBox["border"]): boolean { + return border.top > 0 || border.right > 0 || border.bottom > 0 || border.left > 0; +} + +export function paintSingleBorder(painter: PagePainter, box: RenderBox): void { + const color = box.borderColor; + if (!color) { + return; + } + const { border } = box; + if (!hasVisibleBorder(border)) { + return; + } + const outerRect = box.borderBox; + const innerRect = { + x: outerRect.x + border.left, + y: outerRect.y + border.top, + width: Math.max(outerRect.width - border.left - border.right, 0), + height: Math.max(outerRect.height - border.top - border.bottom, 0), + }; + const innerRadius = shrinkRadius(box.borderRadius, border.top, border.right, border.bottom, border.left); + if (innerRect.width <= 0 || innerRect.height <= 0) { + painter.fillRoundedRect(outerRect, box.borderRadius, color); + } else { + painter.fillRoundedRectDifference(outerRect, box.borderRadius, innerRect, innerRadius, color); + } +} diff --git a/src/pdf/renderer/sort-by-z-index.ts b/src/pdf/renderer/sort-by-z-index.ts new file mode 100644 index 0000000..f980a65 --- /dev/null +++ b/src/pdf/renderer/sort-by-z-index.ts @@ -0,0 +1,72 @@ +import type { RenderBox } from "../types.js"; + +type StackingContext = { + boxes: RenderBox[]; + zIndex: number; +}; + +function getZIndex(box: RenderBox): number { + return box.zIndexComputed ?? 0; +} + +function createsStackingContext(box: RenderBox): boolean { + return box.establishesStackingContext ?? false; +} + +function processChildren(box: RenderBox): RenderBox[] { + if (createsStackingContext(box)) { + return sortBoxesByZIndex(box.children); + } + return box.children.flatMap(processChildren); +} + +export function sortBoxesByZIndex(boxes: ReadonlyArray): RenderBox[] { + const rootContext: StackingContext = { boxes: [], zIndex: 0 }; + const stackingContexts = new Map(); + + // Partition nodes into stacking contexts + for (const box of boxes) { + if (createsStackingContext(box)) { + const zIndex = getZIndex(box); + if (!stackingContexts.has(zIndex)) { + stackingContexts.set(zIndex, { boxes: [], zIndex }); + } + stackingContexts.get(zIndex)!.boxes.push(box); + } else { + rootContext.boxes.push(box); + } + } + + // Sort stacking contexts by z-index + const sortedContexts = Array.from(stackingContexts.values()).sort((a, b) => a.zIndex - b.zIndex); + + const result: RenderBox[] = []; + + // 1. Boxes with negative z-index + for (const context of sortedContexts.filter((c) => c.zIndex < 0)) { + for (const box of context.boxes) { + result.push(box, ...processChildren(box)); + } + } + + // 2. Boxes in the root stacking context + for (const box of rootContext.boxes) { + result.push(box, ...processChildren(box)); + } + + // 3. Boxes with z-index 0 + for (const context of sortedContexts.filter((c) => c.zIndex === 0)) { + for (const box of context.boxes) { + result.push(box, ...processChildren(box)); + } + } + + // 4. Boxes with positive z-index + for (const context of sortedContexts.filter((c) => c.zIndex > 0)) { + for (const box of context.boxes) { + result.push(box, ...processChildren(box)); + } + } + + return result; +} diff --git a/src/pdf/types.ts b/src/pdf/types.ts index c7f541d..30e6731 100644 --- a/src/pdf/types.ts +++ b/src/pdf/types.ts @@ -178,6 +178,7 @@ export interface Positioning { } export interface LayoutPageTree { + root: RenderBox; paintOrder: RenderBox[]; floatLayerOrder: RenderBox[]; flowContentOrder: RenderBox[];