diff --git a/.agents/plans/quiet-indigo-fire-transparency-groups-soft-masks-gradient-alpha.md b/.agents/plans/quiet-indigo-fire-transparency-groups-soft-masks-gradient-alpha.md new file mode 100644 index 0000000..4ba628a --- /dev/null +++ b/.agents/plans/quiet-indigo-fire-transparency-groups-soft-masks-gradient-alpha.md @@ -0,0 +1,299 @@ +--- +date: 2026-02-25 +title: Transparency Groups Soft Masks Gradient Alpha +--- + +## Problem Statement + +`@libpdf/core` supports parts of PDF transparency today: + +- constant opacity through ExtGState (`ca`/`CA`) +- blend mode through ExtGState (`BM`) +- image alpha through image XObject `SMask` + +But it does not expose the two reusable primitives that power PDF transparency as a whole: + +- **transparency groups** (`/Group` on form XObjects) +- **soft masks** (`/SMask` on ExtGState) + +This forces common tasks like gradient fade-out to rely on narrow behavior rather than the native PDF model. + +## Goals + +1. Add transparency-group authoring on form XObjects (`/Group` with isolation/knockout/color-space controls). +2. Add soft-mask authoring on ExtGState for both `Luminosity` and `Alpha` subtypes. +3. Add `opacity` to gradient color stops and map non-uniform stop opacity to soft-mask composition automatically. +4. Keep all transparency controls composable: soft mask + constant opacity + blend mode in one graphics state. +5. Preserve the two-layer API design: low-level COS control + high-level ergonomic defaults. + +## Non-Goals + +- `AIS` (alpha-is-shape) +- soft-mask transfer functions +- matte/premultiplied-alpha image-mask authoring +- parsing existing soft masks from loaded PDFs +- Canvas API integration (separate consumer) + +## Why This Matters + +- It aligns implementation with PDF 1.4+ transparency semantics instead of adding effect-specific branches. +- It unblocks multiple effects from one foundation: gradient alpha, vignette masks, text-as-mask, grouped compositing. +- It reduces future feature cost by making transparency behavior explicit in low-level objects. +- It preserves library consistency with existing composable drawing architecture. + +## Current Gap Analysis + +### Supported Today + +- ExtGState constant opacity (`ca`, `CA`) +- ExtGState blend modes (`BM`) +- image XObject `SMask` + +### Missing for Full Model + +- Form XObject `/Group` authoring for transparent compositing islands +- ExtGState `/SMask` dictionaries for arbitrary mask content +- Gradient-stop alpha channel semantics +- integrated draw-time orchestration when alpha varies spatially + +## Scope + +### In Scope + +- `FormXObjectOptions.group` for transparency-group dictionary emission +- `FormXObjectOptions.resources` to support self-contained group content +- `ExtGStateOptions.softMask` with `Luminosity`, `Alpha`, and explicit `None` +- `ColorStop.opacity` on gradient APIs (defaulting to opaque) +- automatic decomposition for varying-opacity gradients: + - color shading for chroma + - grayscale shading for opacity + - soft-mask wiring at draw time +- compatibility rules so `ca`/`CA` + `BM` + `SMask` can coexist + +### Out of Scope + +- parser exposure of soft masks in loaded PDFs +- new high-level helper methods that duplicate existing primitives without new capability +- renderer/canvas behavior modeling beyond PDF output generation + +## Success Scenarios + +### Scenario 1: Gradient Fade-Out (Common Case) + +```ts +const gradient = pdf.createLinearGradient({ + angle: 90, + length: 220, + stops: [ + { offset: 0, color: rgb(0.9, 0.2, 0.2), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.2, 0.2), opacity: 0 }, + ], +}); + +const pattern = pdf.createShadingPattern({ shading: gradient }); + +page.drawRectangle({ x: 72, y: 450, width: 240, height: 90, pattern }); +``` + +Expectation: users do not manually construct mask groups for this path. + +### Scenario 2: Reusable Soft Mask Built from Arbitrary Content + +```ts +const maskGroup = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 300, height: 100 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + /* text, paths, gradients, images */ + ], + resources: maskResources, +}); + +const maskedGs = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: maskGroup }, + fillOpacity: 0.85, + blendMode: "Multiply", +}); +``` + +Expectation: soft mask composes cleanly with existing opacity and blending. + +### Scenario 3: Transparency Group as Compositing Unit + +```ts +const groupForm = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 200, height: 200 }, + group: { colorSpace: "DeviceRGB", isolated: true, knockout: false }, + operators: [ + /* overlapping transparent marks */ + ], + resources: groupResources, +}); +``` + +Expectation: viewers composite inner content as a group and then composite group output onto backdrop. + +## API Direction + +### Low-Level Additions + +- Form XObject options include a transparency-group object and optional resources dictionary. +- ExtGState options include soft-mask definition (`Luminosity` | `Alpha` | `None`). +- Soft-mask source content is always a form XObject (the native PDF model). + +### High-Level Additions + +- `ColorStop.opacity?: number` on gradient APIs. +- Existing draw methods transparently choose the right path: + - opaque stops only: current path + - uniform non-1 opacity: constant opacity path + - varying opacity: soft-mask path + +### Explicit Non-Addition + +- No separate `createTransparencyGroup()` API if it would only wrap form XObject creation. +- No separate `createSoftMask()` API if it would only wrap ExtGState creation. + +Rationale: keep surface area minimal while exposing full capability. + +## Architectural Fit (Two-Layer Model) + +### Object/Document Layers + +- Own dictionary modeling/serialization for `/Group` and `/SMask` structures. +- Enforce structural validity at object construction boundaries. + +### Drawing Layer + +- Owns gradient-opacity analysis and decision routing (opaque/uniform/varying). +- Owns draw-time resource assembly where geometry/bounds are known. + +### High-Level API + +- Exposes intent (`opacity` on stops) with sensible defaults. +- Delegates to low-level primitives to preserve predictable PDF output. + +This follows existing architecture rather than introducing a parallel transparency subsystem. + +## Behavioral Model + +### Composition Semantics + +- `BM`, `ca`/`CA`, and `SMask` are independent ExtGState terms and must be allowed together. +- Effective source opacity uses multiplicative composition (constant opacity and mask opacity both matter). + +### Group-Execution Semantics + +- Group behavior follows PDF transparency-group semantics. +- Documentation should call out that entering a transparency group changes compositing context. + +### Fast Paths + +- All stops opaque => no new transparency objects. +- All stops share same non-1 opacity => constant opacity only. +- Stops vary => soft-mask path. + +## Validation and Guardrails + +- `ColorStop.opacity` normalized/clamped to `[0, 1]`. +- Luminosity masks require compatible color-space expectations on source group. +- Backdrop color (if present) must match group color-space component count. +- Alpha-mask guidance favors isolated groups for predictable behavior. +- Explicit `softMask: "None"` supported as state-reset mechanism. + +## Compatibility and Versioning + +- Transparency output requires PDF 1.4+ semantics. +- Plan decision: define whether writer should auto-bump minimum output version when transparency primitives are used. +- No behavior change for existing opaque gradients or non-transparency drawing calls. + +## Delivery Plan + +### Phase 1: Low-Level Transparency Primitives + +- Add form XObject transparency-group support and resources attachment. +- Add ExtGState soft-mask model (`Alpha`, `Luminosity`, `None`). +- Ensure serialization emits spec-correct dictionaries. + +### Phase 2: Gradient Opacity Model + +- Add `ColorStop.opacity` and default behavior. +- Add stop-opacity classification (opaque/uniform/varying). +- Preserve current gradient behavior for opaque-only input. + +### Phase 3: Draw-Time Composition + +- Wire varying-opacity gradients through soft-mask resources. +- Ensure one coherent graphics-state path that can include blend mode and constant opacity. +- Keep registration/resource behavior stable across repeated draw calls. + +### Phase 4: Validation and Docs + +- Add constructor-level validation and developer-facing errors. +- Add docs/examples for: + - gradient fade + - text/shape luminosity masks + - alpha masks + - compositional behavior with blend + opacity + +## Test Plan + +### Unit Tests + +- `/Group` dictionary emission for form XObjects with optional fields. +- `/SMask` dictionary emission for `Luminosity`, `Alpha`, and `None`. +- Validation failures for incompatible subtype/group/backdrop inputs. +- Stop-opacity normalization and path classification behavior. + +### Integration Tests + +- Draw calls with varying-opacity gradients produce expected transparency resources. +- Combined behavior: one graphics state can carry blend mode + constant opacity + soft mask. +- Fill/stroke pathways both validated when pattern-based alpha is involved. + +### Regression Tests + +- Opaque gradient output remains byte-structure compatible with current behavior where applicable. +- Existing opacity/blend-mode tests remain stable. + +### Visual Checks + +- Linear and radial fade-to-transparent fixtures. +- Text-as-luminosity-mask fixture. +- Alpha-subtype fixture driven by transparent source content. +- Backdrop color fixture. + +## Documentation Plan + +- Update transparency sections in API docs with a conceptual model: + - group compositing + - soft-mask subtypes + - gradient opacity decomposition +- Add cookbook examples for common effects (fade, vignette, masked image). +- Add explicit note about `softMask: "None"` for low-level state management. + +## Risks and Mitigations + +- **Resource growth**: varying-opacity gradients may add extra objects. + - Mitigation: preserve fast paths and add future caching hooks. +- **BBox sensitivity**: mask-group bounds control visible edges. + - Mitigation: clear bounding-box rules and integration tests around geometry. +- **Semantic confusion**: users may conflate group opacity with mask opacity. + - Mitigation: docs/examples showing composition model explicitly. +- **Version mismatch**: transparency in pre-1.4 outputs is invalid. + - Mitigation: enforce/auto-bump policy and test writer metadata. + +## Open Questions + +1. Should writer always auto-bump to at least 1.4 when transparency features are present? +2. Should we cache mask resources for repeated equivalent geometry in a first pass, or defer until profiling indicates need? +3. Should high-level API expose a convenience reset for soft masks, or keep reset solely low-level? + +## Acceptance Criteria + +- Users can author transparency groups through form XObject options. +- Users can author `Luminosity` and `Alpha` soft masks through ExtGState options. +- `ColorStop.opacity` enables gradient alpha behavior without manual mask plumbing. +- Soft mask, blend mode, and constant opacity compose on the same graphics state. +- Opaque-gradient and existing drawing behavior remain backward compatible. +- Generated files render correctly in major PDF viewers for covered fixtures. diff --git a/src/api/pdf-page.ts b/src/api/pdf-page.ts index 4929fd0..7e38f12 100644 --- a/src/api/pdf-page.ts +++ b/src/api/pdf-page.ts @@ -72,10 +72,11 @@ import { drawLineOps, drawRectangleOps, setFillColor, + wrapPathOps, } from "#src/drawing/operations"; import { PathBuilder } from "#src/drawing/path-builder"; -import type { PDFFormXObject, PDFPattern, PDFShading } from "#src/drawing/resources/index"; -import { PDFExtGState } from "#src/drawing/resources/index"; +import type { PDFPattern, PDFShading, PDFShadingPattern } from "#src/drawing/resources/index"; +import { PDFExtGState, PDFFormXObject } from "#src/drawing/resources/index"; import { serializeOperators } from "#src/drawing/serialize"; import { layoutJustifiedLine, layoutText, measureText } from "#src/drawing/text-layout"; import type { @@ -87,6 +88,7 @@ import type { DrawSvgPathOptions, DrawTextOptions, FontInput, + PathOptions, Rotation, } from "#src/drawing/types"; import { resolveRotationOrigin } from "#src/drawing/types"; @@ -116,6 +118,7 @@ import { showText, } from "#src/helpers/operators"; import * as operatorHelpers from "#src/helpers/operators"; +import { ensureCatalogMinVersion } from "#src/helpers/pdf-version"; import type { PDFImage } from "#src/images/pdf-image"; import { PdfArray } from "#src/objects/pdf-array"; import { PdfDict } from "#src/objects/pdf-dict"; @@ -218,6 +221,32 @@ export interface DrawFieldOptions { option?: string; } +type PatternOpacityMode = + | { kind: "none"; uniformOpacity: 1 } + | { kind: "uniform"; uniformOpacity: number } + | { kind: "varying"; uniformOpacity: 1; pattern: PDFShadingPattern }; + +interface SoftMaskPatternBinding { + name: string; + ref: PdfRef; +} + +interface PatternOpacityRoutingOptions { + /** + * If false, do not intercept when both patterns are fully opaque. + * Default behavior intercepts and emits draw ops. + */ + handleWhenNoPatternOpacity?: boolean; + fillPattern?: PDFPattern; + strokePattern?: PDFPattern; + fillOpacity?: number; + strokeOpacity?: number; + drawFill?: (options: { patternName?: string; graphicsStateName?: string }) => Operator[]; + drawStroke?: (options: { patternName?: string; graphicsStateName?: string }) => Operator[]; + createFillMaskOps?: (patternName: string) => Operator[]; + createStrokeMaskOps?: (patternName: string) => Operator[]; +} + /** * PDFPage wraps a page dictionary with convenient accessors. */ @@ -237,6 +266,9 @@ export class PDFPage { /** Resource cache for deduplication - maps object refs to resource names */ private _resourceCache: Map = new Map(); + /** Cache for generated grayscale opacity patterns keyed by source pattern ref */ + private _opacityPatternCache: Map = new Map(); + constructor(ref: PdfRef, dict: PdfDict, index: number, ctx: PDFContext) { this.ref = ref; this.dict = dict; @@ -850,21 +882,10 @@ export class PDFPage { * ``` */ drawRectangle(options: DrawRectangleOptions): void { - // Register graphics state for opacity if needed - let gsName: string | undefined; - - if (options.opacity !== undefined || options.borderOpacity !== undefined) { - gsName = this.registerGraphicsState({ - fillOpacity: options.opacity, - strokeOpacity: options.borderOpacity, - }); - } - - // Register patterns if provided - const fillPatternName = options.pattern ? this.registerPattern(options.pattern) : undefined; - const strokePatternName = options.borderPattern - ? this.registerPattern(options.borderPattern) - : undefined; + const fillPattern = options.color === undefined ? options.pattern : undefined; + const strokePattern = options.borderColor === undefined ? options.borderPattern : undefined; + const hasFill = options.color !== undefined || fillPattern !== undefined; + const hasStroke = options.borderColor !== undefined || strokePattern !== undefined; // Calculate rotation center if rotating let rotate: { angle: number; originX: number; originY: number } | undefined; @@ -876,24 +897,86 @@ export class PDFPage { rotate = { angle: options.rotate.angle, originX: origin.x, originY: origin.y }; } - const ops = drawRectangleOps({ - x: options.x, - y: options.y, - width: options.width, - height: options.height, - fillColor: options.color, - fillPatternName, - strokeColor: options.borderColor, - strokePatternName, - strokeWidth: options.borderWidth, - dashArray: options.borderDashArray, - dashPhase: options.borderDashPhase, - cornerRadius: options.cornerRadius, - graphicsStateName: gsName ? `/${gsName}` : undefined, - rotate, - }); + let drawFill: PatternOpacityRoutingOptions["drawFill"]; - this.appendOperators(ops); + if (hasFill) { + drawFill = ({ patternName, graphicsStateName }) => + drawRectangleOps({ + x: options.x, + y: options.y, + width: options.width, + height: options.height, + fillColor: options.color, + fillPatternName: patternName, + cornerRadius: options.cornerRadius, + graphicsStateName, + rotate, + }); + } + + let drawStroke: PatternOpacityRoutingOptions["drawStroke"]; + + if (hasStroke) { + drawStroke = ({ patternName, graphicsStateName }) => + drawRectangleOps({ + x: options.x, + y: options.y, + width: options.width, + height: options.height, + strokeColor: options.borderColor, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + dashArray: options.borderDashArray, + dashPhase: options.borderDashPhase, + cornerRadius: options.cornerRadius, + graphicsStateName, + rotate, + }); + } + + let createFillMaskOps: PatternOpacityRoutingOptions["createFillMaskOps"]; + + if (fillPattern) { + createFillMaskOps = patternName => + drawRectangleOps({ + x: options.x, + y: options.y, + width: options.width, + height: options.height, + fillPatternName: patternName, + cornerRadius: options.cornerRadius, + rotate, + }); + } + + let createStrokeMaskOps: PatternOpacityRoutingOptions["createStrokeMaskOps"]; + + if (strokePattern) { + createStrokeMaskOps = patternName => + drawRectangleOps({ + x: options.x, + y: options.y, + width: options.width, + height: options.height, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + dashArray: options.borderDashArray, + dashPhase: options.borderDashPhase, + cornerRadius: options.cornerRadius, + rotate, + }); + } + + this.drawWithPatternOpacityRouting({ + fillPattern, + strokePattern, + fillOpacity: options.opacity, + strokeOpacity: options.borderOpacity, + drawFill, + drawStroke, + createFillMaskOps, + createStrokeMaskOps, + }); } /** @@ -956,35 +1039,75 @@ export class PDFPage { * ``` */ drawCircle(options: DrawCircleOptions): void { - // Register graphics state for opacity if needed - let gsName: string | undefined; + const fillPattern = options.color === undefined ? options.pattern : undefined; + const strokePattern = options.borderColor === undefined ? options.borderPattern : undefined; + const hasFill = options.color !== undefined || fillPattern !== undefined; + const hasStroke = options.borderColor !== undefined || strokePattern !== undefined; - if (options.opacity !== undefined || options.borderOpacity !== undefined) { - gsName = this.registerGraphicsState({ - fillOpacity: options.opacity, - strokeOpacity: options.borderOpacity, - }); + let drawFill: PatternOpacityRoutingOptions["drawFill"]; + + if (hasFill) { + drawFill = ({ patternName, graphicsStateName }) => + drawCircleOps({ + cx: options.x, + cy: options.y, + radius: options.radius, + fillColor: options.color, + fillPatternName: patternName, + graphicsStateName, + }); } - // Register patterns if provided - const fillPatternName = options.pattern ? this.registerPattern(options.pattern) : undefined; - const strokePatternName = options.borderPattern - ? this.registerPattern(options.borderPattern) - : undefined; + let drawStroke: PatternOpacityRoutingOptions["drawStroke"]; - const ops = drawCircleOps({ - cx: options.x, - cy: options.y, - radius: options.radius, - fillColor: options.color, - fillPatternName, - strokeColor: options.borderColor, - strokePatternName, - strokeWidth: options.borderWidth, - graphicsStateName: gsName ? `/${gsName}` : undefined, + if (hasStroke) { + drawStroke = ({ patternName, graphicsStateName }) => + drawCircleOps({ + cx: options.x, + cy: options.y, + radius: options.radius, + strokeColor: options.borderColor, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + graphicsStateName, + }); + } + + let createFillMaskOps: PatternOpacityRoutingOptions["createFillMaskOps"]; + + if (fillPattern) { + createFillMaskOps = patternName => + drawCircleOps({ + cx: options.x, + cy: options.y, + radius: options.radius, + fillPatternName: patternName, + }); + } + + let createStrokeMaskOps: PatternOpacityRoutingOptions["createStrokeMaskOps"]; + + if (strokePattern) { + createStrokeMaskOps = patternName => + drawCircleOps({ + cx: options.x, + cy: options.y, + radius: options.radius, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + }); + } + + this.drawWithPatternOpacityRouting({ + fillPattern, + strokePattern, + fillOpacity: options.opacity, + strokeOpacity: options.borderOpacity, + drawFill, + drawStroke, + createFillMaskOps, + createStrokeMaskOps, }); - - this.appendOperators(ops); } /** @@ -1001,15 +1124,10 @@ export class PDFPage { * ``` */ drawEllipse(options: DrawEllipseOptions): void { - // Register graphics state for opacity if needed - let gsName: string | undefined; - - if (options.opacity !== undefined || options.borderOpacity !== undefined) { - gsName = this.registerGraphicsState({ - fillOpacity: options.opacity, - strokeOpacity: options.borderOpacity, - }); - } + const fillPattern = options.color === undefined ? options.pattern : undefined; + const strokePattern = options.borderColor === undefined ? options.borderPattern : undefined; + const hasFill = options.color !== undefined || fillPattern !== undefined; + const hasStroke = options.borderColor !== undefined || strokePattern !== undefined; // Calculate rotation center if rotating let rotate: { angle: number; originX: number; originY: number } | undefined; @@ -1027,27 +1145,78 @@ export class PDFPage { rotate = { angle: options.rotate.angle, originX: origin.x, originY: origin.y }; } - // Register patterns if provided - const fillPatternName = options.pattern ? this.registerPattern(options.pattern) : undefined; - const strokePatternName = options.borderPattern - ? this.registerPattern(options.borderPattern) - : undefined; + let drawFill: PatternOpacityRoutingOptions["drawFill"]; - const ops = drawEllipseOps({ - cx: options.x, - cy: options.y, - rx: options.xRadius, - ry: options.yRadius, - fillColor: options.color, - fillPatternName, - strokeColor: options.borderColor, - strokePatternName, - strokeWidth: options.borderWidth, - graphicsStateName: gsName ? `/${gsName}` : undefined, - rotate, - }); + if (hasFill) { + drawFill = ({ patternName, graphicsStateName }) => + drawEllipseOps({ + cx: options.x, + cy: options.y, + rx: options.xRadius, + ry: options.yRadius, + fillColor: options.color, + fillPatternName: patternName, + graphicsStateName, + rotate, + }); + } - this.appendOperators(ops); + let drawStroke: PatternOpacityRoutingOptions["drawStroke"]; + + if (hasStroke) { + drawStroke = ({ patternName, graphicsStateName }) => + drawEllipseOps({ + cx: options.x, + cy: options.y, + rx: options.xRadius, + ry: options.yRadius, + strokeColor: options.borderColor, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + graphicsStateName, + rotate, + }); + } + + let createFillMaskOps: PatternOpacityRoutingOptions["createFillMaskOps"]; + + if (fillPattern) { + createFillMaskOps = patternName => + drawEllipseOps({ + cx: options.x, + cy: options.y, + rx: options.xRadius, + ry: options.yRadius, + fillPatternName: patternName, + rotate, + }); + } + + let createStrokeMaskOps: PatternOpacityRoutingOptions["createStrokeMaskOps"]; + + if (strokePattern) { + createStrokeMaskOps = patternName => + drawEllipseOps({ + cx: options.x, + cy: options.y, + rx: options.xRadius, + ry: options.yRadius, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + rotate, + }); + } + + this.drawWithPatternOpacityRouting({ + fillPattern, + strokePattern, + fillOpacity: options.opacity, + strokeOpacity: options.borderOpacity, + drawFill, + drawStroke, + createFillMaskOps, + createStrokeMaskOps, + }); } // ───────────────────────────────────────────────────────────────────────────── @@ -1377,6 +1546,7 @@ export class PDFPage { }, shading => this.registerShading(shading), pattern => this.registerPattern(pattern), + (pathOps, options) => this.tryDrawPathWithPatternOpacity(pathOps, options), ); } @@ -2640,6 +2810,354 @@ export class PDFPage { }; } + private drawWithPatternOpacityRouting(options: PatternOpacityRoutingOptions): boolean { + const fillOpacityMode = this.getPatternOpacityMode(options.fillPattern); + const strokeOpacityMode = this.getPatternOpacityMode(options.strokePattern); + + const hasVaryingOpacity = + fillOpacityMode.kind === "varying" || strokeOpacityMode.kind === "varying"; + + const hasPatternOpacityRouting = + hasVaryingOpacity || + fillOpacityMode.uniformOpacity < 1 || + strokeOpacityMode.uniformOpacity < 1; + + if (!hasPatternOpacityRouting && options.handleWhenNoPatternOpacity === false) { + return false; + } + + const fillPatternName = options.fillPattern + ? this.registerPattern(options.fillPattern) + : undefined; + + const strokePatternName = options.strokePattern + ? this.registerPattern(options.strokePattern) + : undefined; + + const combinedOps: Operator[] = []; + + if (!hasVaryingOpacity) { + const fillOpacity = this.combineOpacity(options.fillOpacity, fillOpacityMode.uniformOpacity); + const strokeOpacity = this.combineOpacity( + options.strokeOpacity, + strokeOpacityMode.uniformOpacity, + ); + + let gsName: string | undefined; + + if (fillOpacity !== undefined || strokeOpacity !== undefined) { + gsName = this.registerGraphicsState({ fillOpacity, strokeOpacity }); + } + + const graphicsStateName = gsName ? `/${gsName}` : undefined; + + if (options.drawFill) { + combinedOps.push( + ...options.drawFill({ + patternName: fillPatternName, + graphicsStateName, + }), + ); + } + + if (options.drawStroke) { + combinedOps.push( + ...options.drawStroke({ + patternName: strokePatternName, + graphicsStateName, + }), + ); + } + + this.appendOperators(combinedOps); + + return true; + } + + if (options.drawFill) { + const fillOpacity = this.combineOpacity(options.fillOpacity, fillOpacityMode.uniformOpacity); + let fillGraphicsStateName: string | undefined; + + if (fillOpacityMode.kind === "varying") { + if (!options.createFillMaskOps) { + throw new Error("Internal error: missing fill mask ops builder"); + } + + const maskPattern = this.createOpacityPatternBinding(fillOpacityMode.pattern, "PmFill"); + const maskOps = options.createFillMaskOps(maskPattern.name); + + fillGraphicsStateName = this.registerSoftMaskGraphicsState({ + maskOps, + patternBindings: [maskPattern], + fillOpacity, + }); + } else if (fillOpacity !== undefined) { + fillGraphicsStateName = this.registerGraphicsState({ fillOpacity }); + } + + combinedOps.push( + ...options.drawFill({ + patternName: fillPatternName, + graphicsStateName: fillGraphicsStateName ? `/${fillGraphicsStateName}` : undefined, + }), + ); + } + + if (options.drawStroke) { + const strokeOpacity = this.combineOpacity( + options.strokeOpacity, + strokeOpacityMode.uniformOpacity, + ); + let strokeGraphicsStateName: string | undefined; + + if (strokeOpacityMode.kind === "varying") { + if (!options.createStrokeMaskOps) { + throw new Error("Internal error: missing stroke mask ops builder"); + } + + const maskPattern = this.createOpacityPatternBinding(strokeOpacityMode.pattern, "PmStroke"); + const maskOps = options.createStrokeMaskOps(maskPattern.name); + + strokeGraphicsStateName = this.registerSoftMaskGraphicsState({ + maskOps, + patternBindings: [maskPattern], + strokeOpacity, + }); + } else if (strokeOpacity !== undefined) { + strokeGraphicsStateName = this.registerGraphicsState({ strokeOpacity }); + } + + combinedOps.push( + ...options.drawStroke({ + patternName: strokePatternName, + graphicsStateName: strokeGraphicsStateName ? `/${strokeGraphicsStateName}` : undefined, + }), + ); + } + + this.appendOperators(combinedOps); + + return true; + } + + private tryDrawPathWithPatternOpacity(pathOps: Operator[], options: PathOptions): boolean { + const hasFill = options.pattern !== undefined || options.color !== undefined; + const hasStroke = options.borderPattern !== undefined || options.borderColor !== undefined; + + let drawFill: PatternOpacityRoutingOptions["drawFill"]; + + if (hasFill) { + drawFill = ({ patternName, graphicsStateName }) => + wrapPathOps(pathOps, { + fillColor: options.pattern ? undefined : options.color, + fillPatternName: patternName, + windingRule: options.windingRule, + graphicsStateName, + }); + } + + let drawStroke: PatternOpacityRoutingOptions["drawStroke"]; + + if (hasStroke) { + drawStroke = ({ patternName, graphicsStateName }) => + wrapPathOps(pathOps, { + strokeColor: options.borderPattern ? undefined : options.borderColor, + strokePatternName: patternName, + strokeWidth: options.borderWidth, + lineCap: options.lineCap, + lineJoin: options.lineJoin, + miterLimit: options.miterLimit, + dashArray: options.dashArray, + dashPhase: options.dashPhase, + graphicsStateName, + }); + } + + let createFillMaskOps: PatternOpacityRoutingOptions["createFillMaskOps"]; + + if (options.pattern) { + createFillMaskOps = patternName => + wrapPathOps(pathOps, { + fillPatternName: patternName, + windingRule: options.windingRule, + }); + } + + let createStrokeMaskOps: PatternOpacityRoutingOptions["createStrokeMaskOps"]; + + if (options.borderPattern) { + createStrokeMaskOps = patternName => + wrapPathOps(pathOps, { + strokePatternName: patternName, + strokeWidth: options.borderWidth, + lineCap: options.lineCap, + lineJoin: options.lineJoin, + miterLimit: options.miterLimit, + dashArray: options.dashArray, + dashPhase: options.dashPhase, + }); + } + + return this.drawWithPatternOpacityRouting({ + handleWhenNoPatternOpacity: false, + fillPattern: options.pattern, + strokePattern: options.borderPattern, + fillOpacity: options.opacity, + strokeOpacity: options.borderOpacity, + drawFill, + drawStroke, + createFillMaskOps, + createStrokeMaskOps, + }); + } + + private combineOpacity(opacity: number | undefined, multiplier: number): number | undefined { + const base = opacity ?? 1; + const combined = Math.max(0, Math.min(1, base * multiplier)); + + if (combined >= 1) { + return undefined; + } + + return combined; + } + + private getPatternOpacityMode(pattern: PDFPattern | undefined): PatternOpacityMode { + if (!pattern || pattern.patternType !== "shading") { + return { kind: "none", uniformOpacity: 1 }; + } + + const opacity = pattern.shading.getOpacityClassification(); + + if (opacity.mode === "opaque") { + return { kind: "none", uniformOpacity: 1 }; + } + + if (opacity.mode === "uniform") { + if (opacity.opacity >= 1) { + return { kind: "none", uniformOpacity: 1 }; + } + + return { kind: "uniform", uniformOpacity: opacity.opacity }; + } + + return { kind: "varying", uniformOpacity: 1, pattern }; + } + + private createOpacityPatternBinding( + pattern: PDFShadingPattern, + name: string, + ): SoftMaskPatternBinding { + return { + name, + ref: this.getOrCreateOpacityPattern(pattern), + }; + } + + private getOrCreateOpacityPattern(pattern: PDFShadingPattern): PdfRef { + const key = `${pattern.ref.objectNumber}:${pattern.ref.generation}`; + const cached = this._opacityPatternCache.get(key); + + if (cached) { + return cached; + } + + const opacityShading = pattern.shading.createOpacityMaskDict(); + const opacityShadingRef = this.ctx.register(opacityShading); + + const opacityPattern = PdfDict.of({ + Type: PdfName.of("Pattern"), + PatternType: PdfNumber.of(2), + Shading: opacityShadingRef, + }); + + if (pattern.matrix) { + const [a, b, c, d, e, f] = pattern.matrix; + opacityPattern.set( + "Matrix", + PdfArray.of( + PdfNumber.of(a), + PdfNumber.of(b), + PdfNumber.of(c), + PdfNumber.of(d), + PdfNumber.of(e), + PdfNumber.of(f), + ), + ); + } + + const opacityPatternRef = this.ctx.register(opacityPattern); + this._opacityPatternCache.set(key, opacityPatternRef); + + return opacityPatternRef; + } + + private registerSoftMaskGraphicsState(options: { + maskOps: Operator[]; + patternBindings: SoftMaskPatternBinding[]; + fillOpacity?: number; + strokeOpacity?: number; + }): string { + this.ensureMinimumVersion("1.4"); + + const patternResources = new PdfDict(); + + for (const binding of options.patternBindings) { + patternResources.set(binding.name, binding.ref); + } + + const resources = PdfDict.of({ + Pattern: patternResources, + }); + + const pageBox = this.getMediaBox(); + const maskBBox = { + x: pageBox.x, + y: pageBox.y, + width: pageBox.width - pageBox.x, + height: pageBox.height - pageBox.y, + }; + + const maskGroupOptions = { + colorSpace: "DeviceGray" as const, + isolated: true, + }; + + const maskStream = PDFFormXObject.createStream( + { + bbox: maskBBox, + operators: [], + resources, + group: maskGroupOptions, + }, + serializeOperators(options.maskOps), + ); + + const maskRef = this.ctx.register(maskStream); + const maskGroup = new PDFFormXObject(maskRef, maskBBox, maskGroupOptions); + + const extGState = PDFExtGState.createDict({ + fillOpacity: options.fillOpacity, + strokeOpacity: options.strokeOpacity, + softMask: { + subtype: "Luminosity", + group: maskGroup, + }, + }); + + const extGStateRef = this.ctx.register(extGState); + + return this.registerExtGState({ type: "extgstate", ref: extGStateRef }); + } + + private ensureMinimumVersion(version: string): void { + this.ctx.info.version = ensureCatalogMinVersion( + this.ctx.catalog.getDict(), + this.ctx.info.version, + version, + ); + } + /** * Create and register a graphics state, returning its resource name. * @@ -2647,6 +3165,8 @@ export class PDFPage { * For the public low-level API, use `pdf.createExtGState()` + `page.registerExtGState()`. */ private registerGraphicsState(options: { fillOpacity?: number; strokeOpacity?: number }): string { + this.ensureMinimumVersion("1.4"); + const dict = PDFExtGState.createDict(options); const ref = this.ctx.register(dict); diff --git a/src/api/pdf.ts b/src/api/pdf.ts index 1ac4296..fee3cb8 100644 --- a/src/api/pdf.ts +++ b/src/api/pdf.ts @@ -32,6 +32,7 @@ import { serializeOperators } from "#src/drawing/serialize"; import type { EmbeddedFont, EmbedFontOptions } from "#src/fonts/embedded-font"; import { formatPdfDate, parsePdfDate } from "#src/helpers/format"; import { resolvePageSize } from "#src/helpers/page-size"; +import { ensureCatalogMinVersion } from "#src/helpers/pdf-version"; import { checkIncrementalSaveBlocker, type IncrementalSaveBlocker } from "#src/helpers/save-utils"; import { isJpeg, parseJpegHeader } from "#src/images/jpeg"; import { PDFImage } from "#src/images/pdf-image"; @@ -698,26 +699,11 @@ export class PDF { * ``` */ upgradeVersion(version: string): void { - // Parse versions for comparison - const parseVersion = (v: string): number => { - const [major, minor] = v.split(".").map(Number); - return major * 10 + (minor || 0); - }; - - const currentVersion = parseVersion(this.ctx.info.version); - const targetVersion = parseVersion(version); - - // Only upgrade, never downgrade - if (targetVersion <= currentVersion) { - return; - } - - // Set the version in the catalog - const catalog = this.ctx.catalog.getDict(); - catalog.set("Version", PdfName.of(version)); - - // Update internal version tracking - this.ctx.info.version = version; + this.ctx.info.version = ensureCatalogMinVersion( + this.ctx.catalog.getDict(), + this.ctx.info.version, + version, + ); } /** Whether the document is encrypted */ @@ -2153,6 +2139,8 @@ export class PDF { // If there's alpha, create a soft mask if (alpha) { + this.upgradeVersion("1.4"); + const compressedAlpha = deflate(alpha); const smaskStream = PdfStream.fromDict( @@ -2204,8 +2192,9 @@ export class PDF { createAxialShading(options: AxialShadingOptions): PDFShading { const dict = PDFShading.createAxialDict(options); const ref = this.register(dict); + const definition = PDFShading.createDefinition(options); - return new PDFShading(ref, "axial"); + return new PDFShading(ref, "axial", definition); } /** @@ -2229,8 +2218,9 @@ export class PDF { createRadialShading(options: RadialShadingOptions): PDFShading { const dict = PDFShading.createRadialDict(options); const ref = this.register(dict); + const definition = PDFShading.createDefinition(options); - return new PDFShading(ref, "radial"); + return new PDFShading(ref, "radial", definition); } /** @@ -2254,10 +2244,12 @@ export class PDF { */ createLinearGradient(options: LinearGradientOptions): PDFShading { const coords = PDFShading.calculateAxialCoords(options.angle, options.length); - const dict = PDFShading.createAxialDict({ coords, stops: options.stops }); + const axialOptions: AxialShadingOptions = { coords, stops: options.stops }; + const dict = PDFShading.createAxialDict(axialOptions); const ref = this.register(dict); + const definition = PDFShading.createDefinition(axialOptions); - return new PDFShading(ref, "axial"); + return new PDFShading(ref, "axial", definition); } // ───────────────────────────────────────────────────────────────────────────── @@ -2388,7 +2380,7 @@ export class PDF { const dict = PDFShadingPattern.createDict(options); const ref = this.register(dict); - return new PDFShadingPattern(ref, options.shading); + return new PDFShadingPattern(ref, options.shading, options.matrix); } // ───────────────────────────────────────────────────────────────────────────── @@ -2412,6 +2404,15 @@ export class PDF { * ``` */ createExtGState(options: ExtGStateOptions): PDFExtGState { + if ( + options.fillOpacity !== undefined || + options.strokeOpacity !== undefined || + options.blendMode !== undefined || + options.softMask !== undefined + ) { + this.upgradeVersion("1.4"); + } + const dict = PDFExtGState.createDict(options); const ref = this.register(dict); @@ -2452,12 +2453,16 @@ export class PDF { * ``` */ createFormXObject(options: FormXObjectOptions): PDFFormXObject { + if (options.group) { + this.upgradeVersion("1.4"); + } + const contentBytes = serializeOperators(options.operators); const stream = PDFFormXObject.createStream(options, contentBytes); const ref = this.register(stream); - return new PDFFormXObject(ref, options.bbox); + return new PDFFormXObject(ref, options.bbox, options.group); } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/drawing/path-builder.ts b/src/drawing/path-builder.ts index 915f901..e3e4335 100644 --- a/src/drawing/path-builder.ts +++ b/src/drawing/path-builder.ts @@ -48,6 +48,12 @@ type ShadingRegistrar = (shading: PDFShading) => string; */ type PatternRegistrar = (pattern: PDFPattern) => string; +/** + * Callback type for handling varying pattern opacity with soft-mask composition. + * Return true when the callback handled emission and default paint should be skipped. + */ +type PatternOpacityPainter = (pathOps: Operator[], options: PathOptions) => boolean; + /** * PathBuilder provides a fluent interface for constructing PDF paths. * @@ -87,6 +93,7 @@ export class PathBuilder { private readonly registerGraphicsState: GraphicsStateRegistrar; private readonly registerShading?: ShadingRegistrar; private readonly registerPattern?: PatternRegistrar; + private readonly paintWithPatternOpacity?: PatternOpacityPainter; /** Current point for quadratic-to-cubic conversion */ private currentX = 0; @@ -101,11 +108,13 @@ export class PathBuilder { registerGraphicsState: GraphicsStateRegistrar, registerShading?: ShadingRegistrar, registerPattern?: PatternRegistrar, + paintWithPatternOpacity?: PatternOpacityPainter, ) { this.appendContent = appendContent; this.registerGraphicsState = registerGraphicsState; this.registerShading = registerShading; this.registerPattern = registerPattern; + this.paintWithPatternOpacity = paintWithPatternOpacity; } // ───────────────────────────────────────────────────────────────────────────── @@ -368,6 +377,10 @@ export class PathBuilder { * Paint the path with the given options. */ private paint(options: PathOptions): void { + if (this.paintWithPatternOpacity?.(this.pathOps, options)) { + return; + } + // Register graphics state for opacity if needed let gsName: string | null = null; diff --git a/src/drawing/resources/extgstate.test.ts b/src/drawing/resources/extgstate.test.ts new file mode 100644 index 0000000..03b63ff --- /dev/null +++ b/src/drawing/resources/extgstate.test.ts @@ -0,0 +1,79 @@ +import { PDFExtGState } from "#src/drawing/resources/extgstate"; +import { PDFFormXObject } from "#src/drawing/resources/form-xobject"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; +import { PdfRef } from "#src/objects/pdf-ref"; +import { describe, expect, it } from "vitest"; + +describe("PDFExtGState", () => { + it("serializes soft mask with alpha subtype", () => { + const group = new PDFFormXObject( + PdfRef.of(100, 0), + { x: 0, y: 0, width: 200, height: 50 }, + { colorSpace: "DeviceGray", isolated: true }, + ); + + const dict = PDFExtGState.createDict({ + fillOpacity: 0.8, + strokeOpacity: 0.6, + blendMode: "Multiply", + softMask: { + subtype: "Alpha", + group, + backdropColor: [0.2], + }, + }); + + expect(dict.getNumber("ca")?.value).toBe(0.8); + expect(dict.getNumber("CA")?.value).toBe(0.6); + expect(dict.getName("BM")).toEqual(PdfName.of("Multiply")); + + const smask = dict.getDict("SMask"); + expect(smask).toBeInstanceOf(PdfDict); + expect(smask?.getName("S")).toEqual(PdfName.of("Alpha")); + expect(smask?.getRef("G")).toEqual(group.ref); + expect(smask?.getArray("BC")?.length).toBe(1); + }); + + it("supports explicit soft-mask reset with /SMask /None", () => { + const dict = PDFExtGState.createDict({ softMask: "None" }); + + expect(dict.getName("SMask")).toEqual(PdfName.of("None")); + }); + + it("requires luminosity soft masks to use a transparency group color space", () => { + const group = new PDFFormXObject(PdfRef.of(101, 0), { + x: 0, + y: 0, + width: 20, + height: 20, + }); + + expect(() => + PDFExtGState.createDict({ + softMask: { + subtype: "Luminosity", + group, + }, + }), + ).toThrow(/Luminosity soft mask requires/); + }); + + it("validates backdrop component counts against group color space", () => { + const group = new PDFFormXObject( + PdfRef.of(102, 0), + { x: 0, y: 0, width: 20, height: 20 }, + { colorSpace: "DeviceRGB", isolated: true }, + ); + + expect(() => + PDFExtGState.createDict({ + softMask: { + subtype: "Alpha", + group, + backdropColor: [0.1], + }, + }), + ).toThrow(/must have 3 components/); + }); +}); diff --git a/src/drawing/resources/extgstate.ts b/src/drawing/resources/extgstate.ts index 8db251b..63f2487 100644 --- a/src/drawing/resources/extgstate.ts +++ b/src/drawing/resources/extgstate.ts @@ -2,11 +2,13 @@ * Extended graphics state resources. */ +import { PdfArray } from "#src/objects/pdf-array"; import { PdfDict } from "#src/objects/pdf-dict"; import { PdfName } from "#src/objects/pdf-name"; import { PdfNumber } from "#src/objects/pdf-number"; import type { PdfRef } from "#src/objects/pdf-ref"; +import type { PDFFormXObject, TransparencyGroupColorSpace } from "./form-xobject"; import type { BlendMode } from "./types"; // ───────────────────────────────────────────────────────────────────────────── @@ -23,8 +25,23 @@ export interface ExtGStateOptions { strokeOpacity?: number; /** Blend mode for compositing */ blendMode?: BlendMode; + /** Soft mask for alpha/luminosity compositing */ + softMask?: ExtGStateSoftMask; } +/** Soft mask using a transparency group source. */ +export interface SoftMaskOptions { + /** Soft mask subtype (/S) */ + subtype: "Alpha" | "Luminosity"; + /** Source transparency group form XObject (/G) */ + group: PDFFormXObject; + /** Optional backdrop color (/BC) in the group's blending color space */ + backdropColor?: number[]; +} + +/** ExtGState soft mask option. */ +export type ExtGStateSoftMask = SoftMaskOptions | "None"; + // ───────────────────────────────────────────────────────────────────────────── // Resource Class // ───────────────────────────────────────────────────────────────────────────── @@ -83,6 +100,71 @@ export class PDFExtGState { dict.set("BM", PdfName.of(options.blendMode)); } + if (options.softMask !== undefined) { + if (options.softMask === "None") { + dict.set("SMask", PdfName.of("None")); + } else { + validateSoftMaskOptions(options.softMask); + + const smask = PdfDict.of({ + S: PdfName.of(options.softMask.subtype), + G: options.softMask.group.ref, + }); + + if (options.softMask.backdropColor) { + smask.set( + "BC", + PdfArray.of(...options.softMask.backdropColor.map(value => PdfNumber.of(value))), + ); + } + + dict.set("SMask", smask); + } + } + return dict; } } + +function validateSoftMaskOptions(options: SoftMaskOptions): void { + if (options.subtype === "Luminosity") { + const groupColorSpace = options.group.group?.colorSpace; + + if (!groupColorSpace) { + throw new Error( + "Luminosity soft mask requires a form XObject transparency group with a colorSpace", + ); + } + } + + if (!options.backdropColor) { + return; + } + + const groupColorSpace = options.group.group?.colorSpace; + + if (!groupColorSpace) { + throw new Error( + "softMask.backdropColor requires the mask form XObject to define group.colorSpace", + ); + } + + const expectedComponents = getColorSpaceComponentCount(groupColorSpace); + + if (options.backdropColor.length !== expectedComponents) { + throw new Error( + `softMask.backdropColor must have ${expectedComponents} components for ${groupColorSpace}, got ${options.backdropColor.length}`, + ); + } +} + +function getColorSpaceComponentCount(colorSpace: TransparencyGroupColorSpace): number { + switch (colorSpace) { + case "DeviceGray": + return 1; + case "DeviceRGB": + return 3; + case "DeviceCMYK": + return 4; + } +} diff --git a/src/drawing/resources/form-xobject.test.ts b/src/drawing/resources/form-xobject.test.ts new file mode 100644 index 0000000..bededdf --- /dev/null +++ b/src/drawing/resources/form-xobject.test.ts @@ -0,0 +1,49 @@ +import { PDFFormXObject } from "#src/drawing/resources/form-xobject"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; +import { describe, expect, it } from "vitest"; + +describe("PDFFormXObject", () => { + it("emits Group and Resources dictionaries when configured", () => { + const resources = PdfDict.of({ + Font: new PdfDict(), + }); + + const stream = PDFFormXObject.createStream( + { + bbox: { x: 10, y: 20, width: 100, height: 50 }, + operators: [], + resources, + group: { + colorSpace: "DeviceRGB", + isolated: true, + knockout: false, + }, + }, + new Uint8Array([0x71, 0x0a]), + ); + + expect(stream.get("Resources")).toBe(resources); + + const group = stream.getDict("Group"); + expect(group).toBeDefined(); + expect(group?.getName("S")?.value).toBe("Transparency"); + expect(group?.getName("CS")?.value).toBe("DeviceRGB"); + expect(group?.getBool("I")?.value).toBe(true); + expect(group?.getBool("K")?.value).toBe(false); + }); + + it("keeps classic form dictionary when transparency options are absent", () => { + const stream = PDFFormXObject.createStream( + { + bbox: { x: 0, y: 0, width: 40, height: 20 }, + operators: [], + }, + new Uint8Array([0x51]), + ); + + expect(stream.get("Group")).toBeUndefined(); + expect(stream.get("Resources")).toBeUndefined(); + expect(stream.getName("Subtype")).toEqual(PdfName.of("Form")); + }); +}); diff --git a/src/drawing/resources/form-xobject.ts b/src/drawing/resources/form-xobject.ts index 572196a..865048c 100644 --- a/src/drawing/resources/form-xobject.ts +++ b/src/drawing/resources/form-xobject.ts @@ -4,6 +4,7 @@ import type { Operator } from "#src/content/operators"; import { PdfArray } from "#src/objects/pdf-array"; +import { PdfBool } from "#src/objects/pdf-bool"; import { PdfDict } from "#src/objects/pdf-dict"; import { PdfName } from "#src/objects/pdf-name"; import { PdfNumber } from "#src/objects/pdf-number"; @@ -59,6 +60,25 @@ export interface FormXObjectOptions { bbox: BBox; /** Operators that draw the form content */ operators: Operator[]; + /** Optional resources dictionary for self-contained form content */ + resources?: PdfDict; + /** Optional transparency group dictionary */ + group?: TransparencyGroupOptions; +} + +/** Supported transparency group color spaces. */ +export type TransparencyGroupColorSpace = "DeviceGray" | "DeviceRGB" | "DeviceCMYK"; + +/** + * Form XObject transparency group options. + */ +export interface TransparencyGroupOptions { + /** Group blending color space (/CS) */ + colorSpace: TransparencyGroupColorSpace; + /** Isolated group flag (/I) */ + isolated?: boolean; + /** Knockout group flag (/K) */ + knockout?: boolean; } // ───────────────────────────────────────────────────────────────────────────── @@ -102,10 +122,12 @@ export class PDFFormXObject { readonly type = "formxobject"; readonly ref: PdfRef; readonly bbox: BBox; + readonly group?: TransparencyGroupOptions; - constructor(ref: PdfRef, bbox: BBox) { + constructor(ref: PdfRef, bbox: BBox, group?: TransparencyGroupOptions) { this.ref = ref; this.bbox = bbox; + this.group = group; } /** @@ -127,6 +149,27 @@ export class PDFFormXObject { ]), }); + if (options.resources) { + dict.set("Resources", options.resources); + } + + if (options.group) { + const group = PdfDict.of({ + S: PdfName.of("Transparency"), + CS: PdfName.of(options.group.colorSpace), + }); + + if (options.group.isolated !== undefined) { + group.set("I", PdfBool.of(options.group.isolated)); + } + + if (options.group.knockout !== undefined) { + group.set("K", PdfBool.of(options.group.knockout)); + } + + dict.set("Group", group); + } + return new PdfStream(dict, contentBytes); } } diff --git a/src/drawing/resources/index.ts b/src/drawing/resources/index.ts index d7ff6ca..92047ae 100644 --- a/src/drawing/resources/index.ts +++ b/src/drawing/resources/index.ts @@ -27,9 +27,13 @@ export type { ImagePatternOptions, ShadingPatternOptions, TilingPatternOptions } export { PDFShadingPattern, PDFTilingPattern, type PDFPattern } from "./pattern"; // ExtGState -export type { ExtGStateOptions } from "./extgstate"; +export type { ExtGStateOptions, ExtGStateSoftMask, SoftMaskOptions } from "./extgstate"; export { PDFExtGState } from "./extgstate"; // Form XObject -export type { FormXObjectOptions } from "./form-xobject"; +export type { + FormXObjectOptions, + TransparencyGroupColorSpace, + TransparencyGroupOptions, +} from "./form-xobject"; export { PDFFormXObject } from "./form-xobject"; diff --git a/src/drawing/resources/pattern.ts b/src/drawing/resources/pattern.ts index 9dd6546..5706487 100644 --- a/src/drawing/resources/pattern.ts +++ b/src/drawing/resources/pattern.ts @@ -278,10 +278,12 @@ export class PDFShadingPattern { readonly ref: PdfRef; readonly patternType = "shading"; readonly shading: PDFShading; + readonly matrix?: PatternMatrix; - constructor(ref: PdfRef, shading: PDFShading) { + constructor(ref: PdfRef, shading: PDFShading, matrix?: PatternMatrix) { this.ref = ref; this.shading = shading; + this.matrix = matrix; } /** diff --git a/src/drawing/resources/shading.test.ts b/src/drawing/resources/shading.test.ts new file mode 100644 index 0000000..b41d6e3 --- /dev/null +++ b/src/drawing/resources/shading.test.ts @@ -0,0 +1,53 @@ +import { PDFShading } from "#src/drawing/resources/shading"; +import { rgb } from "#src/helpers/colors"; +import { PdfName } from "#src/objects/pdf-name"; +import { PdfRef } from "#src/objects/pdf-ref"; +import { describe, expect, it } from "vitest"; + +describe("PDFShading opacity model", () => { + it("classifies uniform stop opacity", () => { + const definition = PDFShading.createDefinition({ + coords: [0, 0, 100, 0], + stops: [ + { offset: 0, color: rgb(1, 0, 0), opacity: 0.5 }, + { offset: 1, color: rgb(0, 0, 1), opacity: 0.5 }, + ], + }); + + const shading = new PDFShading(PdfRef.of(300, 0), "axial", definition); + + expect(shading.getOpacityClassification()).toEqual({ mode: "uniform", opacity: 0.5 }); + expect(shading.getUniformOpacity()).toBe(0.5); + }); + + it("normalizes stop opacity into [0, 1]", () => { + const definition = PDFShading.createDefinition({ + coords: [0, 0, 100, 0], + stops: [ + { offset: 0, color: rgb(1, 0, 0), opacity: -2 }, + { offset: 1, color: rgb(0, 0, 1), opacity: 3 }, + ], + }); + + const shading = new PDFShading(PdfRef.of(301, 0), "axial", definition); + + expect(shading.getOpacityClassification()).toEqual({ mode: "varying" }); + }); + + it("creates grayscale opacity shading for soft-mask composition", () => { + const definition = PDFShading.createDefinition({ + coords: [0, 0, 0, 100, 100, 80], + stops: [ + { offset: 0, color: rgb(0.9, 0.2, 0.2), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.2, 0.2), opacity: 0 }, + ], + }); + + const shading = new PDFShading(PdfRef.of(302, 0), "radial", definition); + const maskDict = shading.createOpacityMaskDict(); + + expect(maskDict.getName("ColorSpace")).toEqual(PdfName.of("DeviceGray")); + expect(maskDict.getNumber("ShadingType")?.value).toBe(3); + expect(maskDict.get("Function")).toBeDefined(); + }); +}); diff --git a/src/drawing/resources/shading.ts b/src/drawing/resources/shading.ts index 53e7987..0ae114a 100644 --- a/src/drawing/resources/shading.ts +++ b/src/drawing/resources/shading.ts @@ -48,8 +48,16 @@ export interface ColorStop { offset: number; /** Color at this position */ color: Color; + /** Optional stop opacity (default: 1, clamped to [0, 1]) */ + opacity?: number; } +/** Opacity classification for gradient stops. */ +export type GradientOpacityClassification = + | { mode: "opaque" } + | { mode: "uniform"; opacity: number } + | { mode: "varying" }; + // ───────────────────────────────────────────────────────────────────────────── // Options // ───────────────────────────────────────────────────────────────────────────── @@ -159,6 +167,22 @@ export interface LinearGradientOptions { stops: ColorStop[]; } +type NormalizedColorStop = Omit & { opacity: number }; + +type ShadingDefinition = + | { + shadingType: "axial"; + coords: AxialCoords; + extend: [boolean, boolean]; + stops: NormalizedColorStop[]; + } + | { + shadingType: "radial"; + coords: RadialCoords; + extend: [boolean, boolean]; + stops: NormalizedColorStop[]; + }; + // ───────────────────────────────────────────────────────────────────────────── // Resource Class // ───────────────────────────────────────────────────────────────────────────── @@ -196,10 +220,115 @@ export class PDFShading { readonly type = "shading"; readonly ref: PdfRef; readonly shadingType: "axial" | "radial"; + private readonly definition?: ShadingDefinition; - constructor(ref: PdfRef, shadingType: "axial" | "radial") { + constructor(ref: PdfRef, shadingType: "axial" | "radial", definition?: ShadingDefinition) { this.ref = ref; this.shadingType = shadingType; + this.definition = definition; + } + + /** + * Classify gradient stop opacity as opaque, uniform, or varying. + */ + getOpacityClassification(): GradientOpacityClassification { + if (!this.definition) { + return { mode: "opaque" }; + } + + return classifyStopOpacity(this.definition.stops); + } + + /** + * Get uniform stop opacity when applicable. + */ + getUniformOpacity(): number { + const classification = this.getOpacityClassification(); + + if (classification.mode === "uniform") { + return classification.opacity; + } + + if (classification.mode === "opaque") { + return 1; + } + + return 1; + } + + /** + * Create a grayscale shading dictionary representing stop opacity. + */ + createOpacityMaskDict(): PdfDict { + if (!this.definition) { + throw new Error("Cannot create opacity mask shading without gradient stop metadata"); + } + + if (this.definition.shadingType === "axial") { + const [x0, y0, x1, y1] = this.definition.coords; + + return PdfDict.of({ + ShadingType: PdfNumber.of(2), + ColorSpace: PdfName.of("DeviceGray"), + Coords: new PdfArray([ + PdfNumber.of(x0), + PdfNumber.of(y0), + PdfNumber.of(x1), + PdfNumber.of(y1), + ]), + Function: createOpacityFunction(this.definition.stops), + Extend: new PdfArray([ + PdfBool.of(this.definition.extend[0]), + PdfBool.of(this.definition.extend[1]), + ]), + }); + } + + const [x0, y0, r0, x1, y1, r1] = this.definition.coords; + + return PdfDict.of({ + ShadingType: PdfNumber.of(3), + ColorSpace: PdfName.of("DeviceGray"), + Coords: new PdfArray([ + PdfNumber.of(x0), + PdfNumber.of(y0), + PdfNumber.of(r0), + PdfNumber.of(x1), + PdfNumber.of(y1), + PdfNumber.of(r1), + ]), + Function: createOpacityFunction(this.definition.stops), + Extend: new PdfArray([ + PdfBool.of(this.definition.extend[0]), + PdfBool.of(this.definition.extend[1]), + ]), + }); + } + + /** + * Build shading metadata from API options. + */ + static createDefinition(options: AxialShadingOptions): ShadingDefinition; + static createDefinition(options: RadialShadingOptions): ShadingDefinition; + static createDefinition(options: AxialShadingOptions | RadialShadingOptions): ShadingDefinition { + const stops = normalizeStops(options.stops); + const extend = options.extend ?? [true, true]; + + if (options.coords.length === 4) { + return { + shadingType: "axial", + coords: options.coords, + extend, + stops, + }; + } + + return { + shadingType: "radial", + coords: options.coords, + extend, + stops, + }; } /** @@ -276,17 +405,72 @@ export class PDFShading { * Create a gradient function from color stops. */ function createGradientFunction(stops: ColorStop[]): PdfDict { + const sortedStops = normalizeStops(stops); + + if (sortedStops.length === 2) { + return createExponentialFunction(sortedStops[0], sortedStops[1]); + } + + return createStitchingFunction(sortedStops); +} + +function createOpacityFunction(stops: ColorStop[]): PdfDict { + const sortedStops = normalizeStops(stops); + + if (sortedStops.length === 2) { + return createExponentialOpacityFunction(sortedStops[0], sortedStops[1]); + } + + return createStitchingOpacityFunction(sortedStops); +} + +function normalizeStops(stops: ColorStop[]): NormalizedColorStop[] { if (stops.length < 2) { throw new Error("Gradient requires at least 2 color stops"); } - const sortedStops = [...stops].sort((a, b) => a.offset - b.offset); + return [...stops] + .map(stop => ({ + ...stop, + opacity: normalizeOpacity(stop.opacity), + })) + .sort((a, b) => a.offset - b.offset); +} - if (sortedStops.length === 2) { - return createExponentialFunction(sortedStops[0], sortedStops[1]); +function normalizeOpacity(value: number | undefined): number { + if (value === undefined || Number.isNaN(value)) { + return 1; } - return createStitchingFunction(sortedStops); + return Math.max(0, Math.min(1, value)); +} + +function classifyStopOpacity(stops: ColorStop[]): GradientOpacityClassification { + const normalizedStops = normalizeStops(stops); + const firstOpacity = normalizedStops[0].opacity; + + let allSame = true; + let allOpaque = firstOpacity === 1; + + for (const stop of normalizedStops) { + if (stop.opacity !== firstOpacity) { + allSame = false; + } + + if (stop.opacity !== 1) { + allOpaque = false; + } + } + + if (allOpaque) { + return { mode: "opaque" }; + } + + if (allSame) { + return { mode: "uniform", opacity: firstOpacity }; + } + + return { mode: "varying" }; } function getRGB(color: Color): [number, number, number] { @@ -319,6 +503,19 @@ function createExponentialFunction(start: ColorStop, end: ColorStop): PdfDict { }); } +function createExponentialOpacityFunction(start: ColorStop, end: ColorStop): PdfDict { + const c0 = normalizeOpacity(start.opacity); + const c1 = normalizeOpacity(end.opacity); + + return PdfDict.of({ + FunctionType: PdfNumber.of(2), + Domain: new PdfArray([PdfNumber.of(0), PdfNumber.of(1)]), + C0: new PdfArray([PdfNumber.of(c0)]), + C1: new PdfArray([PdfNumber.of(c1)]), + N: PdfNumber.of(1), + }); +} + function createStitchingFunction(stops: ColorStop[]): PdfDict { const functions: PdfDict[] = []; const bounds: PdfNumber[] = []; @@ -343,3 +540,28 @@ function createStitchingFunction(stops: ColorStop[]): PdfDict { Encode: new PdfArray(encode), }); } + +function createStitchingOpacityFunction(stops: ColorStop[]): PdfDict { + const functions: PdfDict[] = []; + const bounds: PdfNumber[] = []; + const encode: PdfNumber[] = []; + + for (let i = 0; i < stops.length - 1; i++) { + functions.push(createExponentialOpacityFunction(stops[i], stops[i + 1])); + + if (i < stops.length - 2) { + bounds.push(PdfNumber.of(stops[i + 1].offset)); + } + + encode.push(PdfNumber.of(0)); + encode.push(PdfNumber.of(1)); + } + + return PdfDict.of({ + FunctionType: PdfNumber.of(3), + Domain: new PdfArray([PdfNumber.of(0), PdfNumber.of(1)]), + Functions: new PdfArray(functions), + Bounds: new PdfArray(bounds), + Encode: new PdfArray(encode), + }); +} diff --git a/src/helpers/pdf-version.test.ts b/src/helpers/pdf-version.test.ts new file mode 100644 index 0000000..a8f8de3 --- /dev/null +++ b/src/helpers/pdf-version.test.ts @@ -0,0 +1,29 @@ +import { ensureCatalogMinVersion, parsePdfVersion } from "#src/helpers/pdf-version"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; +import { describe, expect, it } from "vitest"; + +describe("pdf-version helpers", () => { + it("parses version strings for comparison", () => { + expect(parsePdfVersion("1.4")).toBe(14); + expect(parsePdfVersion("1.7")).toBe(17); + expect(parsePdfVersion("2.0")).toBe(20); + }); + + it("handles malformed versions safely", () => { + expect(parsePdfVersion("abc")).toBe(0); + expect(parsePdfVersion("1.x")).toBe(0); + }); + + it("upgrades catalog version only when target is higher", () => { + const catalog = new PdfDict(); + + const unchanged = ensureCatalogMinVersion(catalog, "1.7", "1.4"); + expect(unchanged).toBe("1.7"); + expect(catalog.get("Version")).toBeUndefined(); + + const upgraded = ensureCatalogMinVersion(catalog, "1.3", "1.4"); + expect(upgraded).toBe("1.4"); + expect(catalog.getName("Version")).toEqual(PdfName.of("1.4")); + }); +}); diff --git a/src/helpers/pdf-version.ts b/src/helpers/pdf-version.ts new file mode 100644 index 0000000..e0251d4 --- /dev/null +++ b/src/helpers/pdf-version.ts @@ -0,0 +1,45 @@ +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; + +/** + * Parse a PDF version string into a sortable numeric form. + * + * Examples: + * - "1.4" -> 14 + * - "1.7" -> 17 + * - "2.0" -> 20 + */ +export function parsePdfVersion(version: string): number { + const [majorRaw, minorRaw = "0"] = version.split("."); + + const major = Number(majorRaw); + const minor = Number(minorRaw); + + if (!Number.isFinite(major) || !Number.isFinite(minor)) { + return 0; + } + + return major * 10 + minor; +} + +/** + * Ensure the catalog /Version is at least the target version. + * + * Returns the resulting effective version string. + */ +export function ensureCatalogMinVersion( + catalog: PdfDict, + currentVersion: string, + targetVersion: string, +): string { + const current = parsePdfVersion(currentVersion); + const target = parsePdfVersion(targetVersion); + + if (target <= current) { + return currentVersion; + } + + catalog.set("Version", PdfName.of(targetVersion)); + + return targetVersion; +} diff --git a/src/index.ts b/src/index.ts index 497d5ca..910083c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -209,6 +209,7 @@ export type { BBox, BlendMode, ColorStop, + ExtGStateSoftMask, ExtGStateOptions, FormXObjectOptions, ImagePatternOptions, @@ -223,7 +224,10 @@ export type { RadialCoords, RadialShadingOptions, ShadingPatternOptions, + SoftMaskOptions, TilingPatternOptions, + TransparencyGroupColorSpace, + TransparencyGroupOptions, } from "./drawing/resources/index"; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/integration/drawing/low-level-soft-masks.test.ts b/src/integration/drawing/low-level-soft-masks.test.ts new file mode 100644 index 0000000..00595b2 --- /dev/null +++ b/src/integration/drawing/low-level-soft-masks.test.ts @@ -0,0 +1,1423 @@ +/** + * Integration tests for soft masks, transparency groups, and gradient stop opacity + * in the low-level drawing API. + * + * This suite generates visual PDF fixtures covering: + * - Gradient stop opacity (varying and uniform) + * - Luminosity soft masks (explicit) + * - Alpha soft masks (explicit) + * - Soft mask composability with blend modes and constant opacity + * - Radial gradient opacity + * - Multi-stop gradient opacity + * - Stroke pathway soft masks + * - SMask reset (/SMask /None) + * - Opaque gradient control (no transparency resources) + */ + +import { ops, PDF, PdfDict, PdfName, PdfRef, rgb } from "#src/index"; +import { saveTestOutput } from "#src/test-utils"; +import { beforeEach, describe, expect, it } from "vitest"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function resolveDict(pdf: PDF, entry: unknown): PdfDict | null { + if (entry instanceof PdfDict) { + return entry; + } + if (entry instanceof PdfRef) { + const resolved = pdf.getObject(entry); + return resolved instanceof PdfDict ? resolved : null; + } + return null; +} + +function toVersionNumber(version: string): number { + const [major, minor] = version.split(".").map(Number); + return major * 10 + (minor || 0); +} + +/** Draw text using low-level operators (requires registered fonts). */ +function textOps( + fontName: string, + size: number, + x: number, + y: number, + text: string, + gray = 0, +): ReturnType[] { + return [ + ops.beginText(), + ops.setFont(fontName, size), + ops.setNonStrokingGray(gray), + ops.moveText(x, y), + ops.showText(text), + ops.endText(), + ]; +} + +/** Draw a light background panel behind a demo area. */ +function panelOps( + x: number, + y: number, + width: number, + height: number, +): ReturnType[] { + return [ + ops.setNonStrokingRGB(0.96, 0.96, 0.98), + ops.rectangle(x, y, width, height), + ops.fill(), + ops.setStrokingRGB(0.82, 0.82, 0.88), + ops.setLineWidth(0.5), + ops.rectangle(x, y, width, height), + ops.stroke(), + ]; +} + +describe("Low-Level Drawing: Soft Masks & Gradient Opacity", () => { + let pdf: PDF; + + beforeEach(() => { + pdf = PDF.create(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Page 1: Gradient stop opacity + // ───────────────────────────────────────────────────────────────────────────── + + it("demonstrates gradient stop opacity", async () => { + const page = pdf.addPage({ width: 612, height: 792 }); + + const titleFont = page.registerFont("Helvetica-Bold"); + const bodyFont = page.registerFont("Helvetica"); + + // ── Title ──────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 22, 50, 740, "Gradient Stop Opacity"), + ...textOps( + bodyFont, + 10, + 50, + 722, + "ColorStop.opacity controls per-stop transparency via soft masks", + 0.3, + ), + ]); + + // ── Section 1: Varying opacity (fade to transparent) ──────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 694, "1. Varying stop opacity (fade to transparent)"), + ...textOps( + bodyFont, + 10, + 50, + 678, + "Left = fully opaque red, right = fully transparent red", + 0.35, + ), + ]); + + // Control: solid red (no opacity) for comparison + page.drawOperators([ + ...panelOps(50, 608, 120, 55), + ...textOps(bodyFont, 9, 55, 597, "Control: solid red", 0.4), + ]); + page.drawRectangle({ + x: 55, + y: 613, + width: 110, + height: 45, + color: rgb(0.9, 0.2, 0.2), + }); + + // Test: fade-to-transparent gradient + page.drawOperators([ + ...panelOps(190, 608, 220, 55), + ...textOps(bodyFont, 9, 195, 597, "Gradient: opacity 1 -> 0 (should fade right)", 0.4), + ]); + + const fadeGradient = pdf.createLinearGradient({ + angle: 90, + length: 210, + stops: [ + { offset: 0, color: rgb(0.9, 0.2, 0.2), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.2, 0.2), opacity: 0 }, + ], + }); + const fadePattern = pdf.createShadingPattern({ + shading: fadeGradient, + matrix: [1, 0, 0, 1, 195, 0], + }); + + page.drawRectangle({ + x: 195, + y: 613, + width: 210, + height: 45, + pattern: fadePattern, + }); + + // Test: reverse fade (transparent to opaque) + page.drawOperators([ + ...panelOps(430, 608, 160, 55), + ...textOps(bodyFont, 9, 435, 597, "Reverse: opacity 0 -> 1", 0.4), + ]); + + const reverseFade = pdf.createLinearGradient({ + angle: 90, + length: 150, + stops: [ + { offset: 0, color: rgb(0.2, 0.6, 0.9), opacity: 0 }, + { offset: 1, color: rgb(0.2, 0.6, 0.9), opacity: 1 }, + ], + }); + const reversePattern = pdf.createShadingPattern({ + shading: reverseFade, + matrix: [1, 0, 0, 1, 435, 0], + }); + + page.drawRectangle({ + x: 435, + y: 613, + width: 150, + height: 45, + pattern: reversePattern, + }); + + // ── Section 2: Uniform stop opacity ───────────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 570, "2. Uniform stop opacity (all stops same)"), + ...textOps( + bodyFont, + 10, + 50, + 554, + "Uses constant alpha (ca) only — no soft mask needed", + 0.35, + ), + ]); + + const uniformOpacities = [1.0, 0.75, 0.5, 0.25]; + const uniformLabels = ["100% (control)", "75% uniform", "50% uniform", "25% uniform"]; + const colX = [50, 190, 330, 470]; + + for (let i = 0; i < uniformOpacities.length; i++) { + const x = colX[i]; + page.drawOperators([ + ...panelOps(x, 482, 120, 55), + ...textOps(bodyFont, 9, x + 5, 471, uniformLabels[i], 0.4), + ]); + + const gradient = pdf.createLinearGradient({ + angle: 90, + length: 110, + stops: [ + { offset: 0, color: rgb(0.2, 0.7, 0.3), opacity: uniformOpacities[i] }, + { offset: 1, color: rgb(0.9, 0.8, 0.1), opacity: uniformOpacities[i] }, + ], + }); + const pattern = pdf.createShadingPattern({ + shading: gradient, + matrix: [1, 0, 0, 1, x + 5, 0], + }); + + page.drawRectangle({ + x: x + 5, + y: 487, + width: 110, + height: 45, + pattern, + }); + } + + // ── Section 3: Varying + draw opacity composition ─────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 444, "3. Gradient opacity + draw opacity composition"), + ...textOps( + bodyFont, + 10, + 50, + 428, + "Gradient opacity multiplied with drawRectangle opacity", + 0.35, + ), + ]); + + // Same gradient (opacity 1 -> 0.3) with varying draw opacity so the + // difference in overall opacity is clearly visible across all four panels. + const composeConfigs = [ + { drawOp: 1.0, label: "draw 100% (full)" }, + { drawOp: 0.7, label: "draw 70%" }, + { drawOp: 0.4, label: "draw 40%" }, + { drawOp: 0.15, label: "draw 15%" }, + ]; + + for (let i = 0; i < composeConfigs.length; i++) { + const x = colX[i]; + const { drawOp, label } = composeConfigs[i]; + page.drawOperators([ + ...panelOps(x, 356, 120, 55), + ...textOps(bodyFont, 8, x + 5, 345, label, 0.4), + ]); + + const gradient = pdf.createLinearGradient({ + angle: 90, + length: 110, + stops: [ + { offset: 0, color: rgb(0.8, 0.1, 0.5), opacity: 1 }, + { offset: 1, color: rgb(0.8, 0.1, 0.5), opacity: 0.3 }, + ], + }); + const pattern = pdf.createShadingPattern({ + shading: gradient, + matrix: [1, 0, 0, 1, x + 5, 0], + }); + + page.drawRectangle({ + x: x + 5, + y: 361, + width: 110, + height: 45, + pattern, + opacity: drawOp, + }); + } + + // ── Section 4: Multi-stop gradient opacity ────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 318, "4. Multi-stop gradient opacity"), + ...textOps(bodyFont, 10, 50, 302, "Three or more stops with different opacities", 0.35), + ]); + + // 3-stop: opaque-transparent-opaque + page.drawOperators([ + ...panelOps(50, 230, 240, 55), + ...textOps(bodyFont, 9, 55, 219, "3 stops: 1.0 -> 0.0 -> 1.0 (dip in center)", 0.4), + ]); + + const threeStop = pdf.createLinearGradient({ + angle: 90, + length: 230, + stops: [ + { offset: 0, color: rgb(0.1, 0.5, 0.9), opacity: 1 }, + { offset: 0.5, color: rgb(0.1, 0.5, 0.9), opacity: 0 }, + { offset: 1, color: rgb(0.1, 0.5, 0.9), opacity: 1 }, + ], + }); + const threeStopPattern = pdf.createShadingPattern({ + shading: threeStop, + matrix: [1, 0, 0, 1, 55, 0], + }); + + page.drawRectangle({ + x: 55, + y: 235, + width: 230, + height: 45, + pattern: threeStopPattern, + }); + + // 5-stop gradient: color changes with opacity changes + page.drawOperators([ + ...panelOps(310, 230, 280, 55), + ...textOps(bodyFont, 9, 315, 219, "5 stops: color + varying opacity per stop", 0.4), + ]); + + const fiveStop = pdf.createLinearGradient({ + angle: 90, + length: 270, + stops: [ + { offset: 0, color: rgb(1, 0, 0), opacity: 0.8 }, + { offset: 0.25, color: rgb(1, 0.5, 0), opacity: 0.2 }, + { offset: 0.5, color: rgb(0, 1, 0), opacity: 1 }, + { offset: 0.75, color: rgb(0, 0.5, 1), opacity: 0.3 }, + { offset: 1, color: rgb(0.5, 0, 1), opacity: 0.9 }, + ], + }); + const fiveStopPattern = pdf.createShadingPattern({ + shading: fiveStop, + matrix: [1, 0, 0, 1, 315, 0], + }); + + page.drawRectangle({ + x: 315, + y: 235, + width: 270, + height: 45, + pattern: fiveStopPattern, + }); + + // ── Section 5: Radial gradient with opacity ───────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 192, "5. Radial gradient with stop opacity"), + ...textOps(bodyFont, 10, 50, 176, "Radial gradients also support per-stop opacity", 0.35), + ]); + + // Control: opaque radial + page.drawOperators([ + ...panelOps(50, 80, 120, 80), + ...textOps(bodyFont, 9, 55, 69, "Control: opaque radial", 0.4), + ]); + + const opaqueRadial = pdf.createRadialShading({ + coords: [110, 120, 0, 110, 120, 55], + stops: [ + { offset: 0, color: rgb(1, 1, 0) }, + { offset: 1, color: rgb(0.8, 0.2, 0) }, + ], + }); + const opaqueRadialPattern = pdf.createShadingPattern({ shading: opaqueRadial }); + + page.drawRectangle({ + x: 55, + y: 85, + width: 110, + height: 70, + pattern: opaqueRadialPattern, + }); + + // Test: radial with edge fade + page.drawOperators([ + ...panelOps(190, 80, 120, 80), + ...textOps(bodyFont, 9, 195, 69, "Radial: edge fade out", 0.4), + ]); + + const radialFade = pdf.createRadialShading({ + coords: [250, 120, 0, 250, 120, 55], + stops: [ + { offset: 0, color: rgb(0.2, 0.5, 1), opacity: 1 }, + { offset: 0.7, color: rgb(0.2, 0.5, 1), opacity: 0.8 }, + { offset: 1, color: rgb(0.2, 0.5, 1), opacity: 0 }, + ], + }); + const radialFadePattern = pdf.createShadingPattern({ shading: radialFade }); + + page.drawRectangle({ + x: 195, + y: 85, + width: 110, + height: 70, + pattern: radialFadePattern, + }); + + // Test: radial with center transparent + page.drawOperators([ + ...panelOps(330, 80, 120, 80), + ...textOps(bodyFont, 9, 335, 69, "Radial: center hollow", 0.4), + ]); + + const radialHollow = pdf.createRadialShading({ + coords: [390, 120, 0, 390, 120, 55], + stops: [ + { offset: 0, color: rgb(0.9, 0.3, 0.5), opacity: 0 }, + { offset: 0.5, color: rgb(0.9, 0.3, 0.5), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.3, 0.5), opacity: 0.6 }, + ], + }); + const radialHollowPattern = pdf.createShadingPattern({ shading: radialHollow }); + + page.drawRectangle({ + x: 335, + y: 85, + width: 110, + height: 70, + pattern: radialHollowPattern, + }); + + // ── Footer ────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps( + bodyFont, + 9, + 50, + 45, + "Expected: opaque controls should be fully solid; fading gradients should show", + 0.3, + ), + ...textOps( + bodyFont, + 9, + 50, + 33, + "smooth transparency transitions; uniform-opacity versions use ca only (no SMask).", + 0.3, + ), + ]); + + const bytes = await pdf.save(); + await saveTestOutput("low-level-api/soft-masks-gradient-opacity.pdf", bytes); + expect(bytes).toBeDefined(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Page 2: Explicit soft masks (Luminosity & Alpha) + // ───────────────────────────────────────────────────────────────────────────── + + it("demonstrates explicit luminosity and alpha soft masks", async () => { + const page = pdf.addPage({ width: 612, height: 792 }); + + const titleFont = page.registerFont("Helvetica-Bold"); + const bodyFont = page.registerFont("Helvetica"); + + // ── Title ──────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 22, 50, 740, "Explicit Soft Masks"), + ...textOps( + bodyFont, + 10, + 50, + 722, + "Manual ExtGState SMask with Luminosity and Alpha subtypes", + 0.3, + ), + ]); + + // ── Section 1: Luminosity soft mask basics ────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 694, "1. Luminosity soft mask"), + ...textOps( + bodyFont, + 10, + 50, + 678, + "White mask pixels = visible, black mask pixels = hidden", + 0.35, + ), + ]); + + // Control: no mask + page.drawOperators([ + ...panelOps(50, 600, 120, 60), + ...textOps(bodyFont, 9, 55, 589, "Control: no mask", 0.4), + ]); + page.drawOperators([ + ops.setNonStrokingRGB(0.2, 0.5, 0.9), + ops.rectangle(55, 605, 110, 50), + ops.fill(), + ]); + + // Test: luminosity mask - left half white (visible), right half black (hidden) + page.drawOperators([ + ...panelOps(190, 600, 160, 60), + ...textOps(bodyFont, 9, 195, 589, "Luminosity: left visible", 0.4), + ]); + + const lumMask1 = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + // Black background (hidden) + ops.setNonStrokingGray(0), + ops.rectangle(0, 0, 612, 792), + ops.fill(), + // White left half of rectangle area (visible) + ops.setNonStrokingGray(1), + ops.rectangle(195, 605, 75, 50), + ops.fill(), + ], + }); + const lumGs1 = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: lumMask1 }, + }); + const lumGs1Name = page.registerExtGState(lumGs1); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(lumGs1Name), + ops.setNonStrokingRGB(0.2, 0.5, 0.9), + ops.rectangle(195, 605, 150, 50), + ops.fill(), + ops.popGraphicsState(), + ]); + + // Test: luminosity mask with gradient (smooth transition) + page.drawOperators([ + ...panelOps(370, 600, 210, 60), + ...textOps(bodyFont, 9, 375, 589, "Luminosity: gradient mask (smooth)", 0.4), + ]); + + // Create a gradient shading for the mask itself + const maskGradient = pdf.createAxialShading({ + coords: [375, 630, 575, 630], + stops: [ + { offset: 0, color: rgb(1, 1, 1) }, + { offset: 1, color: rgb(0, 0, 0) }, + ], + }); + const maskGradientName = "MaskSh"; + const maskResources = PdfDict.of({ + Shading: PdfDict.of({ + [maskGradientName]: maskGradient.ref, + }), + }); + + const lumMask2 = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceGray", isolated: true }, + resources: maskResources, + operators: [ + ops.pushGraphicsState(), + ops.rectangle(375, 605, 200, 50), + ops.clip(), + ops.endPath(), + ops.paintShading(maskGradientName), + ops.popGraphicsState(), + ], + }); + const lumGs2 = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: lumMask2 }, + }); + const lumGs2Name = page.registerExtGState(lumGs2); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(lumGs2Name), + ops.setNonStrokingRGB(0.9, 0.3, 0.1), + ops.rectangle(375, 605, 200, 50), + ops.fill(), + ops.popGraphicsState(), + ]); + + // ── Section 2: Alpha soft mask ────────────────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 562, "2. Alpha soft mask"), + ...textOps( + bodyFont, + 10, + 50, + 546, + "Mask group alpha channel controls visibility (not luminance)", + 0.35, + ), + ]); + + // Control: no mask + page.drawOperators([ + ...panelOps(50, 468, 120, 60), + ...textOps(bodyFont, 9, 55, 457, "Control: no mask", 0.4), + ]); + page.drawOperators([ + ops.setNonStrokingRGB(0.9, 0.5, 0.2), + ops.rectangle(55, 473, 110, 50), + ops.fill(), + ]); + + // Test: alpha mask with 45% opacity region + page.drawOperators([ + ...panelOps(190, 468, 160, 60), + ...textOps(bodyFont, 9, 195, 457, "Alpha: 45% opacity mask", 0.4), + ]); + + const alphaInnerGs = pdf.createExtGState({ fillOpacity: 0.45 }); + const alphaMaskResources = PdfDict.of({ + ExtGState: PdfDict.of({ + GSInner: alphaInnerGs.ref, + }), + }); + const alphaMask1 = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceRGB", isolated: true }, + resources: alphaMaskResources, + operators: [ + ops.pushGraphicsState(), + ops.setGraphicsState("GSInner"), + ops.setNonStrokingRGB(1, 1, 1), + ops.rectangle(195, 473, 150, 50), + ops.fill(), + ops.popGraphicsState(), + ], + }); + const alphaGs1 = pdf.createExtGState({ + softMask: { subtype: "Alpha", group: alphaMask1 }, + }); + const alphaGs1Name = page.registerExtGState(alphaGs1); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(alphaGs1Name), + ops.setNonStrokingRGB(0.9, 0.5, 0.2), + ops.rectangle(195, 473, 150, 50), + ops.fill(), + ops.popGraphicsState(), + ]); + + // Test: alpha mask — fully opaque comparison + page.drawOperators([ + ...panelOps(370, 468, 160, 60), + ...textOps(bodyFont, 9, 375, 457, "Alpha: 100% mask (= control)", 0.4), + ]); + + const alphaFullGs = pdf.createExtGState({ fillOpacity: 1 }); + const alphaFullMaskResources = PdfDict.of({ + ExtGState: PdfDict.of({ + GSFull: alphaFullGs.ref, + }), + }); + const alphaMask2 = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceRGB", isolated: true }, + resources: alphaFullMaskResources, + operators: [ + ops.pushGraphicsState(), + ops.setGraphicsState("GSFull"), + ops.setNonStrokingRGB(1, 1, 1), + ops.rectangle(375, 473, 150, 50), + ops.fill(), + ops.popGraphicsState(), + ], + }); + const alphaGs2 = pdf.createExtGState({ + softMask: { subtype: "Alpha", group: alphaMask2 }, + }); + const alphaGs2Name = page.registerExtGState(alphaGs2); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(alphaGs2Name), + ops.setNonStrokingRGB(0.9, 0.5, 0.2), + ops.rectangle(375, 473, 150, 50), + ops.fill(), + ops.popGraphicsState(), + ]); + + // ── Section 3: Composability — SMask + blend mode + constant opacity ──── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 430, "3. Composability: SMask + blend mode + constant opacity"), + ...textOps( + bodyFont, + 10, + 50, + 414, + "All three features applied simultaneously via a single ExtGState", + 0.35, + ), + ]); + + // Background for blend mode demos (need a colored base to see blending) + const blendConfigs = [ + { blend: "Multiply" as const, ca: 0.85, label: "Luminosity + Multiply + ca=0.85" }, + { blend: "Screen" as const, ca: 0.6, label: "Alpha + Screen + ca=0.6" }, + { blend: "Overlay" as const, ca: 0.9, label: "Luminosity + Overlay + ca=0.9" }, + ]; + const blendY = [340, 250, 160]; + + for (let i = 0; i < blendConfigs.length; i++) { + const { blend, ca, label } = blendConfigs[i]; + const y = blendY[i]; + + page.drawOperators([...textOps(bodyFont, 10, 55, y + 55, label, 0.2)]); + + // Dark background to show blending effect + page.drawOperators([...panelOps(50, y, 250, 48)]); + page.drawOperators([ + ops.setNonStrokingRGB(0.15, 0.2, 0.3), + ops.rectangle(55, y + 5, 240, 38), + ops.fill(), + ]); + + if (i === 1) { + // Alpha soft mask + const innerGs = pdf.createExtGState({ fillOpacity: 0.5 }); + const innerResources = PdfDict.of({ + ExtGState: PdfDict.of({ GSi: innerGs.ref }), + }); + const maskGroup = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceRGB", isolated: true }, + resources: innerResources, + operators: [ + ops.pushGraphicsState(), + ops.setGraphicsState("GSi"), + ops.setNonStrokingRGB(1, 1, 1), + ops.rectangle(55, y + 5, 240, 38), + ops.fill(), + ops.popGraphicsState(), + ], + }); + const gs = pdf.createExtGState({ + softMask: { subtype: "Alpha", group: maskGroup }, + fillOpacity: ca, + blendMode: blend, + }); + const gsName = page.registerExtGState(gs); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(gsName), + ops.setNonStrokingRGB(0.9, 0.6, 0.2), + ops.rectangle(55, y + 5, 240, 38), + ops.fill(), + ops.popGraphicsState(), + ]); + } else { + // Luminosity soft mask + const maskGroup = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + ops.setNonStrokingGray(0), + ops.rectangle(0, 0, 612, 792), + ops.fill(), + ops.setNonStrokingGray(1), + ops.rectangle(55, y + 5, 240, 38), + ops.fill(), + ], + }); + const gs = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: maskGroup }, + fillOpacity: ca, + blendMode: blend, + }); + const gsName = page.registerExtGState(gs); + + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(gsName), + ops.setNonStrokingRGB(0.3, 0.7, 0.9), + ops.rectangle(55, y + 5, 240, 38), + ops.fill(), + ops.popGraphicsState(), + ]); + } + + // Control: same color without mask/blend, at same ca + page.drawOperators([ + ...textOps(bodyFont, 9, 320, y + 55, `Control: no mask, no blend, ca=${ca}`, 0.4), + ...panelOps(315, y, 250, 48), + ]); + page.drawOperators([ + ops.setNonStrokingRGB(0.15, 0.2, 0.3), + ops.rectangle(320, y + 5, 240, 38), + ops.fill(), + ]); + + const controlGs = pdf.createExtGState({ fillOpacity: ca }); + const controlGsName = page.registerExtGState(controlGs); + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(controlGsName), + ops.setNonStrokingRGB(i === 1 ? 0.9 : 0.3, i === 1 ? 0.6 : 0.7, i === 1 ? 0.2 : 0.9), + ops.rectangle(320, y + 5, 240, 38), + ops.fill(), + ops.popGraphicsState(), + ]); + } + + // ── Footer ────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps( + bodyFont, + 9, + 50, + 130, + "Expected: Luminosity mask uses grayscale to control visibility;", + 0.3, + ), + ...textOps( + bodyFont, + 9, + 50, + 118, + "Alpha mask uses source alpha; composability stacks all three effects.", + 0.3, + ), + ...textOps( + bodyFont, + 9, + 50, + 106, + "Controls on the right should look like the blend-masked version minus the blend effect.", + 0.3, + ), + ]); + + const bytes = await pdf.save(); + await saveTestOutput("low-level-api/soft-masks-explicit.pdf", bytes); + expect(bytes).toBeDefined(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Page 3: Stroke pathway soft masks & SMask reset + // ───────────────────────────────────────────────────────────────────────────── + + it("demonstrates stroke pathway soft masks and SMask reset", async () => { + const page = pdf.addPage({ width: 612, height: 792 }); + + const titleFont = page.registerFont("Helvetica-Bold"); + const bodyFont = page.registerFont("Helvetica"); + + // ── Title ──────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 22, 50, 740, "Stroke Soft Masks & SMask Reset"), + ...textOps( + bodyFont, + 10, + 50, + 722, + "Opacity on stroke pathways and explicit SMask /None reset", + 0.3, + ), + ]); + + // ── Section 1: Stroke gradient with varying opacity ───────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 694, "1. Stroke with varying gradient opacity"), + ...textOps(bodyFont, 10, 50, 678, "borderPattern with per-stop opacity on stroke path", 0.35), + ]); + + // Control: opaque solid stroke + page.drawOperators([ + ...panelOps(50, 590, 240, 70), + ...textOps(bodyFont, 9, 55, 579, "Control: solid color stroke (14pt)", 0.4), + ]); + + // Light interior so stroke is visible + page.drawRectangle({ x: 60, y: 600, width: 220, height: 50, color: rgb(0.92, 0.96, 0.93) }); + page + .drawPath() + .rectangle(60, 600, 220, 50) + .stroke({ borderColor: rgb(0.2, 0.8, 0.4), borderWidth: 14 }); + + // Test: fade stroke (opaque left, transparent right) + page.drawOperators([ + ...panelOps(310, 590, 270, 70), + ...textOps(bodyFont, 9, 315, 579, "Fade stroke: opacity 1 -> 0 (14pt)", 0.4), + ]); + + page.drawRectangle({ x: 320, y: 600, width: 250, height: 50, color: rgb(0.92, 0.96, 0.93) }); + + const strokeFade = pdf.createLinearGradient({ + angle: 90, + length: 250, + stops: [ + { offset: 0, color: rgb(0.2, 0.8, 0.4), opacity: 1 }, + { offset: 1, color: rgb(0.2, 0.8, 0.4), opacity: 0 }, + ], + }); + const strokeFadePattern = pdf.createShadingPattern({ + shading: strokeFade, + matrix: [1, 0, 0, 1, 320, 0], + }); + + page + .drawPath() + .rectangle(320, 600, 250, 50) + .stroke({ borderPattern: strokeFadePattern, borderWidth: 14 }); + + // ── Section 2: Stroke + draw opacity composition ──────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 552, "2. Stroke opacity composition"), + ...textOps( + bodyFont, + 10, + 50, + 536, + "Varying gradient opacity * borderOpacity on stroke path", + 0.35, + ), + ]); + + // Use the same gradient (opacity 1 -> 0.2) with decreasing borderOpacity. + // The gradient doesn't fade to 0 so even at low borderOpacity values + // the stroke remains visible. + const strokeCompConfigs = [ + { borderOp: undefined, label: "borderOpacity = 1.0" }, + { borderOp: 0.5, label: "borderOpacity = 0.5" }, + { borderOp: 0.2, label: "borderOpacity = 0.2" }, + ]; + + for (let i = 0; i < strokeCompConfigs.length; i++) { + const x = 50 + i * 185; + const { borderOp, label } = strokeCompConfigs[i]; + + page.drawOperators([ + ...panelOps(x, 450, 170, 70), + ...textOps(bodyFont, 8, x + 5, 439, label, 0.4), + ]); + + // Colored interior so the fading stroke is visible against it + page.drawRectangle({ + x: x + 20, + y: 467, + width: 135, + height: 36, + color: rgb(0.95, 0.92, 0.9), + }); + + const gradient = pdf.createLinearGradient({ + angle: 90, + length: 155, + stops: [ + { offset: 0, color: rgb(0.9, 0.2, 0.1), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.2, 0.1), opacity: 0.2 }, + ], + }); + const pattern = pdf.createShadingPattern({ + shading: gradient, + matrix: [1, 0, 0, 1, x + 10, 0], + }); + + page + .drawPath() + .rectangle(x + 10, 460, 155, 50) + .stroke({ borderPattern: pattern, borderWidth: 12, borderOpacity: borderOp }); + } + + // ── Section 3: SMask reset (/SMask /None) ─────────────────────────────── + page.drawOperators([ + ...textOps(titleFont, 14, 50, 410, "3. SMask reset with /SMask /None"), + ...textOps( + bodyFont, + 10, + 50, + 394, + "After applying a soft mask, reset with SMask='None' to clear it", + 0.35, + ), + ]); + + // Row 1: Before mask, with mask, after mask (no reset) + // Wrapped in gsave/grestore so the mask doesn't leak into Row 2 + page.drawOperators([...textOps(bodyFont, 10, 55, 366, "Without reset — mask persists:", 0.2)]); + + // The mask only reveals a 100x50 region at (55, 290) — the "With mask" slot + const persistMask = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + ops.setNonStrokingGray(0), + ops.rectangle(0, 0, 612, 792), + ops.fill(), + ops.setNonStrokingGray(1), + ops.rectangle(180, 290, 100, 50), + ops.fill(), + ], + }); + const persistGs = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: persistMask }, + }); + const persistGsName = page.registerExtGState(persistGs); + const resetNoneGs = pdf.createExtGState({ softMask: "None" }); + const resetNoneGsName = page.registerExtGState(resetNoneGs); + + // Label positions + page.drawOperators([ + ...panelOps(50, 285, 110, 60), + ...textOps(bodyFont, 8, 55, 276, "Before mask (full)", 0.4), + + ...panelOps(175, 285, 110, 60), + ...textOps(bodyFont, 8, 180, 276, "With mask (visible)", 0.4), + + ...panelOps(300, 285, 110, 60), + ...textOps(bodyFont, 8, 305, 276, "No reset (hidden!)", 0.4), + ]); + + // Before: draw without mask — should be fully visible + page.drawOperators([ + ops.setNonStrokingRGB(0.2, 0.7, 0.4), + ops.rectangle(55, 290, 100, 50), + ops.fill(), + ]); + + // Apply mask, then draw two rectangles without resetting. + // Only the one inside the mask region should be visible. + // Use gsave/grestore to isolate this whole demo from Row 2. + page.drawOperators([ + ops.pushGraphicsState(), + ops.setGraphicsState(persistGsName), + // "With mask" — at (180, 290) which IS in the mask's white region + ops.setNonStrokingRGB(0.2, 0.7, 0.4), + ops.rectangle(180, 290, 100, 50), + ops.fill(), + // "No reset" — at (305, 290) which is NOT in the mask's white region + ops.setNonStrokingRGB(0.2, 0.7, 0.4), + ops.rectangle(305, 290, 100, 50), + ops.fill(), + ops.popGraphicsState(), + ]); + + // Row 2: With proper reset using /SMask /None + page.drawOperators([ + ...textOps(bodyFont, 10, 55, 256, "With reset — mask cleared after use:", 0.2), + ]); + + // Mask reveals only the "With mask" slot at (180, 180) + const resetMask = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 612, height: 792 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + ops.setNonStrokingGray(0), + ops.rectangle(0, 0, 612, 792), + ops.fill(), + ops.setNonStrokingGray(1), + ops.rectangle(180, 180, 100, 50), + ops.fill(), + ], + }); + const resetMaskGs = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: resetMask }, + }); + const resetMaskGsName = page.registerExtGState(resetMaskGs); + + page.drawOperators([ + ...panelOps(50, 175, 110, 60), + ...textOps(bodyFont, 8, 55, 166, "Before mask (full)", 0.4), + + ...panelOps(175, 175, 110, 60), + ...textOps(bodyFont, 8, 180, 166, "With mask (visible)", 0.4), + + ...panelOps(300, 175, 110, 60), + ...textOps(bodyFont, 8, 305, 166, "After reset (full!)", 0.4), + ]); + + page.drawOperators([ + // Before mask — no mask active, should be fully visible + ops.setNonStrokingRGB(0.8, 0.3, 0.5), + ops.rectangle(55, 180, 100, 50), + ops.fill(), + // Apply mask — only region at (180, 180) is revealed + ops.setGraphicsState(resetMaskGsName), + ops.setNonStrokingRGB(0.8, 0.3, 0.5), + ops.rectangle(180, 180, 100, 50), + ops.fill(), + // Reset mask with /SMask /None — should make subsequent draws fully visible + ops.setGraphicsState(resetNoneGsName), + ops.setNonStrokingRGB(0.8, 0.3, 0.5), + ops.rectangle(305, 180, 100, 50), + ops.fill(), + ]); + + // ── Footer ────────────────────────────────────────────────────────────── + page.drawOperators([ + ...textOps( + bodyFont, + 9, + 50, + 140, + "Expected: stroke fade should show gradient transparency on stroke paths;", + 0.3, + ), + ...textOps( + bodyFont, + 9, + 50, + 128, + "SMask persists across draws until reset; after /SMask /None, shapes render fully.", + 0.3, + ), + ]); + + const bytes = await pdf.save(); + await saveTestOutput("low-level-api/soft-masks-stroke-and-reset.pdf", bytes); + expect(bytes).toBeDefined(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Structural validation tests (round-trip parse) + // ───────────────────────────────────────────────────────────────────────────── + + it("validates varying-opacity gradient produces correct SMask structure", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 400, height: 300 }); + + const gradient = pdf.createLinearGradient({ + angle: 90, + length: 220, + stops: [ + { offset: 0, color: rgb(0.9, 0.2, 0.2), opacity: 1 }, + { offset: 1, color: rgb(0.9, 0.2, 0.2), opacity: 0 }, + ], + }); + const pattern = pdf.createShadingPattern({ shading: gradient }); + + page.drawRectangle({ + x: 50, + y: 120, + width: 240, + height: 80, + pattern, + opacity: 0.75, + }); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + const parsedPage = parsed.getPage(0)!; + const extGState = parsedPage.getResources().getDict("ExtGState"); + + expect(extGState).toBeDefined(); + + let foundMaskState = false; + + for (const [, value] of extGState ?? []) { + const gsDict = resolveDict(parsed, value); + if (!gsDict) { + continue; + } + + const smask = gsDict.getDict("SMask"); + if (!smask) { + continue; + } + + foundMaskState = true; + + // Verify Luminosity subtype + expect(smask.getName("S")).toEqual(PdfName.of("Luminosity")); + + // Verify draw opacity is applied + expect(gsDict.getNumber("ca")?.value).toBeCloseTo(0.75, 6); + + // Verify mask group + const maskGroupRef = smask.getRef("G"); + expect(maskGroupRef).toBeDefined(); + + if (maskGroupRef) { + const maskGroup = parsed.getObject(maskGroupRef); + expect(maskGroup).toBeInstanceOf(PdfDict); + + if (maskGroup instanceof PdfDict) { + const group = maskGroup.getDict("Group"); + expect(group?.getName("CS")).toEqual(PdfName.of("DeviceGray")); + expect(group?.getBool("I")?.value).toBe(true); + } + } + } + + expect(foundMaskState).toBe(true); + }); + + it("validates uniform stop opacity uses constant alpha without SMask", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 300, height: 200 }); + + const gradient = pdf.createLinearGradient({ + angle: 0, + length: 150, + stops: [ + { offset: 0, color: rgb(0.1, 0.6, 0.9), opacity: 0.5 }, + { offset: 1, color: rgb(0.9, 0.6, 0.1), opacity: 0.5 }, + ], + }); + const pattern = pdf.createShadingPattern({ shading: gradient }); + + page.drawRectangle({ + x: 20, + y: 60, + width: 180, + height: 70, + pattern, + opacity: 0.8, + }); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + const parsedPage = parsed.getPage(0)!; + const extGState = parsedPage.getResources().getDict("ExtGState"); + + expect(extGState).toBeDefined(); + + let foundCombinedOpacity = false; + + for (const [, value] of extGState ?? []) { + const gsDict = resolveDict(parsed, value); + if (!gsDict) { + continue; + } + + const ca = gsDict.getNumber("ca")?.value; + if (ca === undefined) { + continue; + } + + // 0.5 (uniform) * 0.8 (draw) = 0.4 + if (Math.abs(ca - 0.4) < 1e-8) { + foundCombinedOpacity = true; + expect(gsDict.get("SMask")).toBeUndefined(); + } + } + + expect(foundCombinedOpacity).toBe(true); + }); + + it("validates stroke pathway produces CA (not ca) in ExtGState", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 350, height: 250 }); + + const gradient = pdf.createLinearGradient({ + angle: 180, + length: 180, + stops: [ + { offset: 0, color: rgb(0.2, 0.8, 0.4), opacity: 0 }, + { offset: 1, color: rgb(0.2, 0.8, 0.4), opacity: 1 }, + ], + }); + const pattern = pdf.createShadingPattern({ shading: gradient }); + + page + .drawPath() + .rectangle(40, 60, 220, 120) + .stroke({ borderPattern: pattern, borderWidth: 14, borderOpacity: 0.5 }); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + const parsedPage = parsed.getPage(0)!; + const extGState = parsedPage.getResources().getDict("ExtGState"); + + expect(extGState).toBeDefined(); + + let foundStrokeMask = false; + + for (const [, value] of extGState ?? []) { + const gsDict = resolveDict(parsed, value); + if (!gsDict) { + continue; + } + + const smask = gsDict.getDict("SMask"); + if (!smask) { + continue; + } + + const strokeOpacity = gsDict.getNumber("CA")?.value; + if (strokeOpacity !== undefined && Math.abs(strokeOpacity - 0.5) < 1e-8) { + foundStrokeMask = true; + expect(smask.getName("S")).toEqual(PdfName.of("Luminosity")); + } + } + + expect(foundStrokeMask).toBe(true); + }); + + it("validates opaque gradient produces no ExtGState resources", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 320, height: 180 }); + + const opaqueGradient = pdf.createLinearGradient({ + angle: 45, + length: 180, + stops: [ + { offset: 0, color: rgb(0.2, 0.6, 0.9) }, + { offset: 1, color: rgb(0.9, 0.4, 0.2) }, + ], + }); + const opaquePattern = pdf.createShadingPattern({ shading: opaqueGradient }); + + page.drawRectangle({ x: 30, y: 40, width: 220, height: 80, pattern: opaquePattern }); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + const parsedPage = parsed.getPage(0)!; + const extGState = parsedPage.getResources().getDict("ExtGState"); + + expect(extGState).toBeUndefined(); + }); + + it("validates PDF version is >= 1.4 when transparency is used", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 300, height: 200 }); + + const gradient = pdf.createLinearGradient({ + angle: 90, + length: 200, + stops: [ + { offset: 0, color: rgb(1, 0, 0), opacity: 1 }, + { offset: 1, color: rgb(1, 0, 0), opacity: 0 }, + ], + }); + const pattern = pdf.createShadingPattern({ shading: gradient }); + + page.drawRectangle({ x: 10, y: 10, width: 200, height: 100, pattern }); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + + expect(toVersionNumber(parsed.version)).toBeGreaterThanOrEqual(14); + }); + + it("validates composable ExtGState with SMask + blend + opacity", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ width: 400, height: 300 }); + + const maskGroup = pdf.createFormXObject({ + bbox: { x: 0, y: 0, width: 400, height: 300 }, + group: { colorSpace: "DeviceGray", isolated: true }, + operators: [ + ops.setNonStrokingGray(0), + ops.rectangle(0, 0, 400, 300), + ops.fill(), + ops.setNonStrokingGray(1), + ops.rectangle(50, 50, 200, 100), + ops.fill(), + ], + }); + + const gs = pdf.createExtGState({ + softMask: { subtype: "Luminosity", group: maskGroup }, + fillOpacity: 0.85, + blendMode: "Multiply", + }); + const gsName = page.registerExtGState(gs); + + page.drawOperators([ + ops.setNonStrokingRGB(0.5, 0.5, 0.5), + ops.rectangle(0, 0, 400, 300), + ops.fill(), + ops.pushGraphicsState(), + ops.setGraphicsState(gsName), + ops.setNonStrokingRGB(0.2, 0.5, 0.9), + ops.rectangle(50, 50, 200, 100), + ops.fill(), + ops.popGraphicsState(), + ]); + + const bytes = await pdf.save(); + const parsed = await PDF.load(bytes); + const parsedPage = parsed.getPage(0)!; + const extGState = parsedPage.getResources().getDict("ExtGState"); + + expect(extGState).toBeDefined(); + + let found = false; + + for (const [, value] of extGState ?? []) { + const gsDict = resolveDict(parsed, value); + if (!gsDict) { + continue; + } + + const smask = gsDict.getDict("SMask"); + if (!smask) { + continue; + } + + const subtype = smask.getName("S")?.value; + const blendMode = gsDict.getName("BM")?.value; + const fillOpacity = gsDict.getNumber("ca")?.value; + + if ( + subtype === "Luminosity" && + blendMode === "Multiply" && + fillOpacity !== undefined && + Math.abs(fillOpacity - 0.85) < 1e-8 + ) { + found = true; + + // Verify mask group has DeviceGray colorSpace + const groupRef = smask.getRef("G"); + expect(groupRef).toBeDefined(); + + if (groupRef) { + const groupObj = parsed.getObject(groupRef); + if (groupObj instanceof PdfDict) { + const group = groupObj.getDict("Group"); + expect(group?.getName("CS")).toEqual(PdfName.of("DeviceGray")); + expect(group?.getBool("I")?.value).toBe(true); + } + } + } + } + + expect(found).toBe(true); + }); +});