diff --git a/src/css/clip-path-types.ts b/src/css/clip-path-types.ts index 34b5004..f662184 100644 --- a/src/css/clip-path-types.ts +++ b/src/css/clip-path-types.ts @@ -10,4 +10,10 @@ export interface ClipPathPolygon { referenceBox?: ClipPathReferenceBox; } -export type ClipPath = ClipPathPolygon; +export interface ClipPathPath { + type: "path"; + commands: string; + referenceBox?: ClipPathReferenceBox; +} + +export type ClipPath = ClipPathPolygon | ClipPathPath; diff --git a/src/css/parsers/clip-path-parser.ts b/src/css/parsers/clip-path-parser.ts index ca01adb..ffaabf0 100644 --- a/src/css/parsers/clip-path-parser.ts +++ b/src/css/parsers/clip-path-parser.ts @@ -22,9 +22,31 @@ function parseClipPathValue(value: string): ClipPath | undefined { if (polygon) { return polygon; } + + const path = parsePath(normalized); + if (path) { + return path; + } + return undefined; } +function parsePath(input: string): ClipPath | undefined { + const match = /^path\s*\(\s*['"]?([^'"]+)['"]?\s*\)$/i.exec(input); + if (!match) { + return undefined; + } + const commands = match[1].trim(); + if (!commands) { + return undefined; + } + return { + type: "path", + commands, + referenceBox: "border-box", + }; +} + function parsePolygon(input: string): ClipPathPolygon | undefined { const match = /^polygon\s*\((.+)\)$/i.exec(input); if (!match) { diff --git a/src/layout/strategies/table.ts b/src/layout/strategies/table.ts index 19ad118..e363973 100644 --- a/src/layout/strategies/table.ts +++ b/src/layout/strategies/table.ts @@ -120,36 +120,41 @@ export class TableLayoutStrategy implements LayoutStrategy { const tableBorderLeft = numericBorder(node.style.borderLeft); // Collapse table border with outer cells + const processedTop = new Set(); + const processedBottom = new Set(); + const processedLeft = new Set(); + const processedRight = new Set(); + for (let c = 0; c < numCols; c++) { // Top edge cells collapse with table top border const topCell = grid[0][c]; - if (topCell && this.isOriginCell(topCell, 0, c)) { + if (topCell && !processedTop.has(topCell)) { const cellTop = numericBorder(topCell.style.borderTop); - const shared = Math.max(cellTop, tableBorderTop); - topCell.style.borderTop = shared; + topCell.style.borderTop = Math.max(cellTop, tableBorderTop); + processedTop.add(topCell); } // Bottom edge cells collapse with table bottom border const bottomCell = grid[numRows - 1][c]; - if (bottomCell && this.isOriginCell(bottomCell, numRows - 1, c)) { + if (bottomCell && !processedBottom.has(bottomCell) && this.isRowBoundary(bottomCell, numRows - 1)) { const cellBottom = numericBorder(bottomCell.style.borderBottom); - const shared = Math.max(cellBottom, tableBorderBottom); - bottomCell.style.borderBottom = shared; + bottomCell.style.borderBottom = Math.max(cellBottom, tableBorderBottom); + processedBottom.add(bottomCell); } } for (let r = 0; r < numRows; r++) { // Left edge cells collapse with table left border const leftCell = grid[r][0]; - if (leftCell && this.isOriginCell(leftCell, r, 0)) { + if (leftCell && !processedLeft.has(leftCell)) { const cellLeft = numericBorder(leftCell.style.borderLeft); - const shared = Math.max(cellLeft, tableBorderLeft); - leftCell.style.borderLeft = shared; + leftCell.style.borderLeft = Math.max(cellLeft, tableBorderLeft); + processedLeft.add(leftCell); } // Right edge cells collapse with table right border const rightCell = grid[r][numCols - 1]; - if (rightCell && this.isOriginCell(rightCell, r, numCols - 1)) { + if (rightCell && !processedRight.has(rightCell) && this.isColumnBoundary(rightCell, numCols - 1)) { const cellRight = numericBorder(rightCell.style.borderRight); - const shared = Math.max(cellRight, tableBorderRight); - rightCell.style.borderRight = shared; + rightCell.style.borderRight = Math.max(cellRight, tableBorderRight); + processedRight.add(rightCell); } } @@ -165,16 +170,25 @@ export class TableLayoutStrategy implements LayoutStrategy { const upper = grid[r][c]; const lower = grid[r + 1][c]; if (!upper || !lower) continue; - if (upper === lower) continue; + if (upper === lower) { + // Se as células são as mesmas (rowspan), não há borda compartilhada para processar AQUI + // mas precisamos garantir que a próxima linha saiba que esta célula continua. + continue; + } if (!this.isRowBoundary(upper, r)) continue; const upperBottom = numericBorder(upper.style.borderBottom); const lowerTop = numericBorder(lower.style.borderTop); const shared = Math.max(upperBottom, lowerTop); + + if (shared > 0) { + // "Winner" takes the color. If widths are equal, prefer the upper one (top-down bias) + if (upperBottom >= lowerTop && upper.style.borderColor) { + lower.style.borderColor = upper.style.borderColor; + } + } + lower.style.borderTop = shared; upper.style.borderBottom = 0; - if (lower.style.borderColor === undefined && upper.style.borderColor !== undefined) { - lower.style.borderColor = upper.style.borderColor; - } } } // Resolve horizontal shared borders between adjacent columns @@ -188,11 +202,16 @@ export class TableLayoutStrategy implements LayoutStrategy { const leftRight = numericBorder(left.style.borderRight); const rightLeft = numericBorder(right.style.borderLeft); const shared = Math.max(leftRight, rightLeft); + + if (shared > 0) { + // "Winner" takes the color. If widths are equal, prefer the left one (left-to-right bias) + if (leftRight >= rightLeft && left.style.borderColor) { + right.style.borderColor = left.style.borderColor; + } + } + right.style.borderLeft = shared; left.style.borderRight = 0; - if (right.style.borderColor === undefined && left.style.borderColor !== undefined) { - right.style.borderColor = left.style.borderColor; - } } } } @@ -470,11 +489,15 @@ export class TableLayoutStrategy implements LayoutStrategy { const minContentWidths = new Array(numCols).fill(0); + // First pass: resolve widths for cells with colspan = 1 for (let r = 0; r < grid.length; r++) { for (let c = 0; c < numCols; c++) { const cell = grid[r][c]; if (!cell || !this.isOriginCell(cell, r, c)) continue; + const colSpan = Math.min(this.cellColSpan(cell), numCols - c); + if (colSpan !== 1) continue; + let maxIntrinsicWidth = 0; if (cell.intrinsicInlineSize) { maxIntrinsicWidth = cell.intrinsicInlineSize; @@ -487,14 +510,43 @@ export class TableLayoutStrategy implements LayoutStrategy { const horizontalExtras = horizontalNonContent(cell, tableWidth); const cellMinWidth = maxIntrinsicWidth + horizontalExtras; + minContentWidths[c] = Math.max(minContentWidths[c], cellMinWidth); + } + } + + // Second pass: resolve widths for cells with colspan > 1 + for (let r = 0; r < grid.length; r++) { + for (let c = 0; c < numCols; c++) { + const cell = grid[r][c]; + if (!cell || !this.isOriginCell(cell, r, c)) continue; + const colSpan = Math.min(this.cellColSpan(cell), numCols - c); + if (colSpan <= 1) continue; - if (colSpan === 1) { - minContentWidths[c] = Math.max(minContentWidths[c], cellMinWidth); - } else { - const share = cellMinWidth / colSpan; - for (let offset = 0; offset < colSpan; offset++) { - minContentWidths[c + offset] = Math.max(minContentWidths[c + offset], share); + let maxIntrinsicWidth = 0; + if (cell.intrinsicInlineSize) { + maxIntrinsicWidth = cell.intrinsicInlineSize; + } + cell.walk((node) => { + if (node.intrinsicInlineSize !== undefined) { + maxIntrinsicWidth = Math.max(maxIntrinsicWidth, node.intrinsicInlineSize); + } + }); + + const horizontalExtras = horizontalNonContent(cell, tableWidth); + const cellMinWidth = maxIntrinsicWidth + horizontalExtras; + + // Check if current spanned columns already satisfy the min width + let currentSpanWidth = 0; + for (let i = 0; i < colSpan; i++) { + currentSpanWidth += minContentWidths[c + i]; + } + + if (cellMinWidth > currentSpanWidth) { + const extra = cellMinWidth - currentSpanWidth; + const share = extra / colSpan; + for (let i = 0; i < colSpan; i++) { + minContentWidths[c + i] += share; } } } diff --git a/src/pdf/layout-tree-builder.ts b/src/pdf/layout-tree-builder.ts index e166691..a959b4c 100644 --- a/src/pdf/layout-tree-builder.ts +++ b/src/pdf/layout-tree-builder.ts @@ -28,7 +28,7 @@ import { resolveTextGradientLayer, } from "./utils/background-layer-resolver.js"; import { resolveClipPath } from "./utils/clip-path-resolver.js"; -import { resolveMaskGradient } from "./utils/mask-resolver.js"; +import { resolveMaskGradients } from "./utils/mask-resolver.js"; import { parseTransform } from "../transform/css-parser.js"; import { buildNodeTextRuns } from "./utils/node-text-run-factory.js"; import type { FontResolver } from "../fonts/types.js"; @@ -148,7 +148,7 @@ function convertNode( const background = resolveBackgroundLayers(node, { borderBox, paddingBox, contentBox }); const backgroundClip = node.style.backgroundLayers?.some(l => l.clip === "text") ? "text" : undefined; - const maskGradient = resolveMaskGradient(node, { borderBox, paddingBox, contentBox }); + const maskLayers = resolveMaskGradients(node, { borderBox, paddingBox, contentBox }); const ownTextGradient = resolveTextGradientLayer(node, { borderBox, paddingBox, contentBox }); const textGradient = ownTextGradient ?? inheritedTextGradient; @@ -256,7 +256,8 @@ function convertNode( breakInside: node.style.breakInside, color: textColor, mask: node.style.mask, - maskGradient, + maskGradient: maskLayers.length > 0 ? maskLayers[0] : undefined, + maskLayers, background, backgroundClip, clipPath, diff --git a/src/pdf/renderer/box-painter.ts b/src/pdf/renderer/box-painter.ts index f127858..4b87bed 100644 --- a/src/pdf/renderer/box-painter.ts +++ b/src/pdf/renderer/box-painter.ts @@ -59,12 +59,17 @@ export async function paintBoxAtomic(painter: PagePainter, box: RenderBox): Prom } let clipCommands = buildClipPathCommands(box.clipPath); - if (!clipCommands && box.maskGradient && box.maskGradient.gradient.type === "radial") { - // MVP: Aproximação de máscara radial usando clipping path circular - clipCommands = buildCircularClipPath(box.maskGradient.rect); + if (!clipCommands && box.maskLayers && box.maskLayers.length > 0) { + const masks = box.maskLayers.filter(l => l.gradient.type === "radial"); + if (masks.length > 0) { + clipCommands = []; + for (const m of masks) { + clipCommands.push(...buildCircularClipPath(m.rect)); + } + } } - const hasClip = !!clipCommands; + const hasClip = !!clipCommands && clipCommands.length > 0; if (hasClip && clipCommands) { painter.beginClipPath(clipCommands); } @@ -320,7 +325,57 @@ function buildClipPathCommands(clipPath: RenderBox["clipPath"]): PathCommand[] | } function paintDropShadows(painter: PagePainter, box: RenderBox, shadows: import("../types.js").ShadowLayer[]): void { - // Usa o borderBox como base da sombra (aproximação para drop-shadow) + // Para drop-shadow em caminhos complexos (clip-path), precisamos de uma abordagem baseada em path + const clipCommands = buildClipPathCommands(box.clipPath); + + if (clipCommands && clipCommands.length > 0) { + for (const shadow of shadows) { + if (shadow.color.a <= 0) continue; + + const blur = Math.max(0, shadow.blur); + const steps = blur > 0 ? Math.max(3, Math.ceil(blur / 1.5)) : 1; + const baseAlpha = shadow.color.a; + + // Pintamos várias vezes com offsets variados para simular blur (distribuição radial) + for (let i = 0; i < steps; i++) { + const fraction = steps > 1 ? i / (steps - 1) : 0; + const angle = (i / steps) * Math.PI * 2; + const blurRadius = (blur * 0.5) * fraction; + const dx = shadow.offsetX + Math.cos(angle) * blurRadius; + const dy = shadow.offsetY + Math.sin(angle) * blurRadius; + + // Distribuição de opacidade: dividimos a opacidade total pelos steps + // mas damos um pouco mais de peso para as camadas internas. + const weight = (steps - i) / ((steps * (steps + 1)) / 2); + const opacity = baseAlpha * weight * (steps / 1.5); + + const offsetCommands = clipCommands.map(cmd => { + if (cmd.type === "moveTo" || cmd.type === "lineTo") { + return { ...cmd, x: cmd.x + dx, y: cmd.y + dy }; + } + if (cmd.type === "curveTo") { + return { + ...cmd, + x1: cmd.x1 + dx, + y1: cmd.y1 + dy, + x2: cmd.x2 + dx, + y2: cmd.y2 + dy, + x: cmd.x + dx, + y: cmd.y + dy + }; + } + return cmd; + }); + + painter.beginOpacityScope(Math.min(1, opacity)); + painter.fillPath(offsetCommands, shadow.color); + painter.endOpacityScope(Math.min(1, opacity)); + } + } + return; + } + + // Usa o borderBox como base da sombra (aproximação para drop-shadow retangular) const virtualBox: Partial = { borderBox: box.borderBox, borderRadius: box.borderRadius, diff --git a/src/pdf/types.ts b/src/pdf/types.ts index b9fb3fb..aa693ff 100644 --- a/src/pdf/types.ts +++ b/src/pdf/types.ts @@ -299,6 +299,7 @@ export interface RenderBox { color?: RGBA; mask?: string; maskGradient?: GradientBackground; + maskLayers?: GradientBackground[]; backgroundClip?: "border-box" | "padding-box" | "content-box" | "text"; transform?: TextMatrix; diff --git a/src/pdf/utils/clip-path-resolver.ts b/src/pdf/utils/clip-path-resolver.ts index b64b8b6..5d87076 100644 --- a/src/pdf/utils/clip-path-resolver.ts +++ b/src/pdf/utils/clip-path-resolver.ts @@ -2,10 +2,12 @@ import type { LayoutNode } from "../../dom/node.js"; import type { ClipPath, Rect } from "../types.js"; import type { BackgroundBoxes } from "./background-layer-resolver.js"; import type { ClipPath as CssClipPath, ClipPathLength, ClipPathReferenceBox } from "../../css/clip-path-types.js"; +import { parsePathData } from "../../svg/path-data.js"; +import type { PathCommand } from "../renderers/shape-renderer.js"; export function resolveClipPath(node: LayoutNode, boxes: BackgroundBoxes): ClipPath | undefined { const clip = node.style.clipPath as CssClipPath | undefined; - if (!clip || clip.type !== "polygon" || !clip.points.length) { + if (!clip) { return undefined; } @@ -14,20 +16,58 @@ export function resolveClipPath(node: LayoutNode, boxes: BackgroundBoxes): ClipP return undefined; } - const points = clip.points.map((point) => { - const x = resolveClipLength(point.x, referenceRect.width); - const y = resolveClipLength(point.y, referenceRect.height); - return { - x: referenceRect.x + x, - y: referenceRect.y + y, - }; - }); + if (clip.type === "polygon") { + if (!clip.points.length) return undefined; + const points = clip.points.map((point) => { + const x = resolveClipLength(point.x, referenceRect.width); + const y = resolveClipLength(point.y, referenceRect.height); + return { + x: referenceRect.x + x, + y: referenceRect.y + y, + }; + }); - if (points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) { - return undefined; + if (points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) { + return undefined; + } + + return { type: "polygon", points }; + } + + if (clip.type === "path") { + const segments = parsePathData(clip.commands); + if (!segments.length) return undefined; + + const commands: PathCommand[] = []; + for (const seg of segments) { + switch (seg.type) { + case "M": + commands.push({ type: "moveTo", x: referenceRect.x + seg.x, y: referenceRect.y + seg.y }); + break; + case "L": + commands.push({ type: "lineTo", x: referenceRect.x + seg.x, y: referenceRect.y + seg.y }); + break; + case "C": + commands.push({ + type: "curveTo", + x1: referenceRect.x + seg.x1, + y1: referenceRect.y + seg.y1, + x2: referenceRect.x + seg.x2, + y2: referenceRect.y + seg.y2, + x: referenceRect.x + seg.x, + y: referenceRect.y + seg.y + }); + break; + case "Z": + commands.push({ type: "closePath" }); + break; + } + } + + return commands.length > 0 ? { type: "path", commands } : undefined; } - return { type: "polygon", points }; + return undefined; } function selectReferenceRect(box: ClipPathReferenceBox | undefined, boxes: BackgroundBoxes): Rect { diff --git a/src/pdf/utils/mask-resolver.ts b/src/pdf/utils/mask-resolver.ts index b7100c1..13b0344 100644 --- a/src/pdf/utils/mask-resolver.ts +++ b/src/pdf/utils/mask-resolver.ts @@ -1,30 +1,37 @@ import type { LayoutNode } from "../../dom/node.js"; import type { Rect, GradientBackground } from "../types.js"; import { parseRadialGradient, parseLinearGradient } from "../../css/parsers/gradient-parser.js"; +import { splitCssCommaList } from "../../css/utils.js"; -export function resolveMaskGradient(node: LayoutNode, boxes: { borderBox: Rect; paddingBox: Rect; contentBox: Rect }): GradientBackground | undefined { +export function resolveMaskGradients(node: LayoutNode, boxes: { borderBox: Rect; paddingBox: Rect; contentBox: Rect }): GradientBackground[] { const mask = node.style.mask; - if (!mask) return undefined; + if (!mask) return []; - const radial = parseRadialGradient(mask); - if (radial) { - return { - gradient: radial, - rect: boxes.borderBox, - repeat: "no-repeat", - originRect: boxes.borderBox, - }; - } + const masks = splitCssCommaList(mask); + const result: GradientBackground[] = []; + + for (const m of masks) { + const radial = parseRadialGradient(m); + if (radial) { + result.push({ + gradient: radial, + rect: { ...boxes.borderBox }, + repeat: "no-repeat", + originRect: { ...boxes.borderBox }, + }); + continue; + } - const linear = parseLinearGradient(mask); - if (linear) { - return { - gradient: linear, - rect: boxes.borderBox, - repeat: "no-repeat", - originRect: boxes.borderBox, - }; + const linear = parseLinearGradient(m); + if (linear) { + result.push({ + gradient: linear, + rect: { ...boxes.borderBox }, + repeat: "no-repeat", + originRect: { ...boxes.borderBox }, + }); + } } - return undefined; + return result; } diff --git a/src/render/offset.ts b/src/render/offset.ts index c15fef2..1fb6aba 100644 --- a/src/render/offset.ts +++ b/src/render/offset.ts @@ -49,7 +49,12 @@ export function offsetRenderTree(root: RenderBox, dx: number, dy: number, _debug if (box.markerRect) { offsetRect(box.markerRect, dx, dy); } - if (box.maskGradient) { + if (box.maskLayers && box.maskLayers.length > 0) { + for (const layer of box.maskLayers) { + offsetRect(layer.rect, dx, dy); + offsetRect(layer.originRect, dx, dy); + } + } else if (box.maskGradient) { offsetRect(box.maskGradient.rect, dx, dy); offsetRect(box.maskGradient.originRect, dx, dy); } @@ -233,7 +238,12 @@ export function applyPageVerticalMarginsWithHf( if (box.markerRect) { adjustRect(box.markerRect); } - if (box.maskGradient) { + if (box.maskLayers && box.maskLayers.length > 0) { + for (const layer of box.maskLayers) { + adjustRect(layer.rect); + adjustRect(layer.originRect); + } + } else if (box.maskGradient) { adjustRect(box.maskGradient.rect); adjustRect(box.maskGradient.originRect); } diff --git a/verification/screenshots/vcomplex-final-1.png b/verification/screenshots/vcomplex-final-1.png new file mode 100644 index 0000000..2ad8838 Binary files /dev/null and b/verification/screenshots/vcomplex-final-1.png differ diff --git a/verification/screenshots/vcomplex-final-2.png b/verification/screenshots/vcomplex-final-2.png new file mode 100644 index 0000000..2f9676a Binary files /dev/null and b/verification/screenshots/vcomplex-final-2.png differ diff --git a/verification/screenshots/vcomplex-final-3.png b/verification/screenshots/vcomplex-final-3.png new file mode 100644 index 0000000..0e23923 Binary files /dev/null and b/verification/screenshots/vcomplex-final-3.png differ