Public/Save-VellumPdfDocument.ps1
|
function Save-VellumPdfDocument { <# .SYNOPSIS Writes a VellumPdf document to a .pdf file and disposes it. .DESCRIPTION Wraps Document.Save(path) - or, when a signature has been staged with Set-VellumPdfSignature, SigningExtensions.Sign(document, stream, settings), which signs the document while writing it (PAdES). The document is IDisposable; this function disposes it after the save attempt (success or failure) because saving is the terminal step of a build pipeline, and marks it so later cmdlet calls against the stale document fail with a clear error. Use -KeepOpen to keep the document alive for inspection, in which case you are responsible for calling $doc.Dispose() yourself. Every document is single-use: saving it a second time is refused, and so is adding content to it (-KeepOpen is for inspecting a saved document, not for drawing more). After a FAILED save the document must be rebuilt (the engine can leave state from the failed attempt, so a retry could write a different file). With -WhatIf nothing is saved and the document is left open. After a successful save the engine's own diagnostics are reported: characters the base-14 fonts could not encode (written as '?') produce one warning, and a header or footer cut to fit the page produces one warning each. The same information is attached to the returned file object as the TextEncodingWarnings and BandTruncations properties. If the pipeline is aborted BEFORE this cmdlet runs (for example by an error in an earlier Add-VellumPdf* call, or -WarningAction Stop turning the encoding warning into a terminating error), the document is never saved or disposed - dispose it yourself in your catch block. The write is atomic: the PDF is rendered (and signed) to a temporary file beside -Path and only moved into place once it is complete, so a render or signing failure leaves any existing file at -Path untouched. On success an existing file at -Path is overwritten. .PARAMETER Document The live VellumPdf document to save. Accepts pipeline input. After saving, the document is disposed and stamped so subsequent cmdlet calls against the stale instance fail with a clear error. Use -KeepOpen to suppress disposal. .PARAMETER Path File system path for the output PDF file. The parent directory must already exist; an existing file at this path is overwritten. Mandatory and positional (position 0). .PARAMETER Validate Run the in-process preflight (see Test-VellumPdfConformance) on the rendered PDF against the document's own conformance level (PdfA2b/2u/2a/PdfUA1) and fail with the first findings - writing nothing - when it is not compliant. The document is then unusable, like after any failed save. Not available for encrypted documents. .PARAMETER Stream Write the PDF to this writable stream instead of a file (for example a response or blob stream). The PDF is rendered in memory first, so a failure never leaves a partial PDF in the stream. Nothing is returned. .PARAMETER AsByteArray Return the PDF as a byte[] instead of writing a file. .PARAMETER KeepOpen When specified, the document is not disposed after saving. The caller is responsible for calling $doc.Dispose() when finished. Useful when the same document object must be inspected after the file is written. The document cannot be saved a second time. .PARAMETER PassThru Return the live document instead of the written file, and keep it open (implies -KeepOpen). The caller disposes it. The document still cannot be saved a second time; use it to read TextEncodingWarnings or BandTruncations. .PARAMETER Force Create the parent directory of -Path when it does not exist (without it a missing directory is an error). .EXAMPLE $doc | Save-VellumPdfDocument -Path ./out.pdf .OUTPUTS System.IO.FileInfo for the written file (VellumPdf.Layout.Document with -PassThru), with extra TextEncodingWarnings (CodePoint, Character) and BandTruncations (Band, PageNumber, DrawnCharacters, ResolvedCharacters, DroppedCharacters) properties. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Path')] [OutputType([System.IO.FileInfo], ParameterSetName = 'Path')] [OutputType([byte[]], ParameterSetName = 'Bytes')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AsByteArray', Justification = 'Parameter-set selector: its presence picks the Bytes set; the value is never read.')] [OutputType([VellumPdf.Layout.Document], [VellumPdf.Document.PdfDocument])] param( [Parameter(Mandatory, ValueFromPipeline)] [object]$Document, [Parameter(Mandatory, Position = 0, ParameterSetName = 'Path')] [string]$Path, # Write the PDF to this writable stream instead of a file. [Parameter(Mandatory, ParameterSetName = 'Stream')] [System.IO.Stream]$Stream, # Return the PDF as a byte array instead of writing a file. [Parameter(Mandatory, ParameterSetName = 'Bytes')] [switch]$AsByteArray, # Run the in-process PDF/A / PDF/UA preflight on the rendered PDF and fail # (writing nothing) when it is not compliant. Needs -Conformance on the document. [switch]$Validate, # Keep the document open after saving (caller must Dispose it). [switch]$KeepOpen, # Return the live document instead of the file. Implies -KeepOpen. [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'Stream')] [switch]$PassThru, # Create the parent directory when it does not exist. [Parameter(ParameterSetName = 'Path')] [switch]$Force ) process { try { Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Save-VellumPdfDocument' # A Layout document is single-use: layout runs inside Save, a second Save # throws, and a failed Save can leave pages from the failed attempt so a # retry may write a wrong file (VellumPdf #530). Say so plainly. if ($Document.PSObject.Properties['PSVellumFailed']) { throw ('Save-VellumPdfDocument: a previous save of this document failed, which can leave it in an ' + 'inconsistent state (a retry could write a different file). Build a new document with ' + 'New-VellumPdfDocument.') } if ($Document.PSObject.Properties['PSVellumSaved']) { $factory = if ($Document -is [VellumPdf.Document.PdfDocument]) { 'New-VellumPdfPageDocument' } else { 'New-VellumPdfDocument' } throw ('Save-VellumPdfDocument: this document was already saved (-KeepOpen). A VellumPdf document ' + "can be written only once; build a new document with $factory.") } $validateProfile = $null if ($Validate) { $validateProfile = switch ($Document.Conformance) { ([VellumPdf.Document.PdfConformance]::PdfA2b) { 'PdfA2b' } ([VellumPdf.Document.PdfConformance]::PdfA2u) { 'PdfA2u' } ([VellumPdf.Document.PdfConformance]::PdfA2a) { 'PdfA2a' } ([VellumPdf.Document.PdfConformance]::PdfUA1) { 'PdfUA1' } default { $null } } if (-not $validateProfile) { throw ('Save-VellumPdfDocument: -Validate needs a document created with ' + 'New-VellumPdfDocument or New-VellumPdfPageDocument with -Conformance PdfA2b/PdfA2u/PdfA2a/PdfUA1.') } if ($Document.PSObject.Properties['PSVellumProtected']) { throw 'Save-VellumPdfDocument: -Validate cannot read an encrypted PDF; validate before Protect-VellumPdfDocument or run Test-VellumPdfConformance -Password afterwards.' } } $toMemory = $PSCmdlet.ParameterSetName -ne 'Path' $resolved = if ($toMemory) { if ($PSCmdlet.ParameterSetName -eq 'Stream') { if (-not $Stream.CanWrite) { throw 'Save-VellumPdfDocument: -Stream must be a writable stream.' } '<stream>' } else { '<bytes>' } } else { if ($Path.IndexOfAny([System.IO.Path]::GetInvalidPathChars()) -ge 0) { throw "Save-VellumPdfDocument: -Path contains a character that is not allowed in a file name (such as a NUL or control character)." } $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } # A signature staged by Set-VellumPdfSignature makes signing the write # step: VellumPdf signs at serialization time, so Sign() replaces Save(). $signature = $Document.PSObject.Properties['PSVellumSignature'] $action = if ($signature) { 'Save signed PDF' } else { 'Save PDF' } $attempted = $false try { # Everything below is skipped under -WhatIf (ShouldProcess returns # $false): nothing is written and the document is left open. The # directory check lives here too so a -WhatIf dry run never throws # or disposes the document. if ($PSCmdlet.ShouldProcess($resolved, $action)) { $attempted = $true # Renders (or signs) the document into an open stream, mapping engine # failures to messages with context for this operation. $render = { param([System.IO.Stream]$Target) try { # A page-based document accumulates its drawing in per-page canvases. if ($Document -is [VellumPdf.Document.PdfDocument]) { Complete-VellumPdfPageDocument -Document $Document if ($Document.Linearize -and $signature) { Write-Warning ('Save-VellumPdfDocument: signing ignores -Linearize; the signed file is not linearized.') } } if ($signature -and $signature.Value.ExternalSigner) { # An external signer only supports the asynchronous path. Layout then runs on # a thread-pool thread, which cannot execute Add-VellumPdfDrawing script blocks. if ($Document.PSObject.Properties['PSVellumHasDrawing']) { throw ('an external signer signs asynchronously, which cannot run the script blocks of ' + 'Add-VellumPdfDrawing elements in this document') } [void][VellumPdf.Signing.SigningExtensions]::SignAsync( $Document, $Target, $signature.Value, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() } elseif ($signature) { [VellumPdf.Signing.SigningExtensions]::Sign($Document, $Target, $signature.Value) } else { $Document.Save($Target) } } catch { # The engine may keep state from the failed attempt: never let a retry write a file. $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumFailed', $true)) # Surface failures with context specific to the operation # that failed instead of a bare library exception. $reason = $_.Exception.Message if ($_.Exception.InnerException) { $reason = $_.Exception.InnerException.Message } if ($signature) { $hint = 'Check that the signing certificate is valid and still holds its private key.' if ($signature.Value.TimestampClient) { $hint = 'Check that the signing certificate is valid and holds its private key, ' + 'and that the timestamp authority (-TimestampUrl) is reachable.' } if ($signature.Value.Level -ge [VellumPdf.Signing.PadesLevel]::B_LT) { $hint += ' B-LT/B-LTA also need the OCSP/CRL servers named in the certificate chain to be reachable;' + ' if the signature does not fit, raise -EstimatedSignatureSize.' } if ($signature.Value.SignaturePage -gt 0) { $hint += " -SignaturePage $($signature.Value.SignaturePage + 1) must not exceed the document's page count." } throw "Save-VellumPdfDocument: failed to sign '$resolved': $reason $hint" } # Messages the module raised itself (balance check) already say what is wrong. if ($reason -like 'Invoke-VellumPdfCanvas:*' -or $reason -like 'Add-VellumPdfDrawing:*') { throw "Save-VellumPdfDocument: failed to render '$resolved': $reason" } throw ("Save-VellumPdfDocument: failed to render '$resolved': $reason " + 'Check for extreme margin values or elements taller than the page.') } } # Preflight the rendered bytes; nothing reaches the target if they fail. $validateBytes = { param([byte[]]$Data) if (-not $validateProfile) { return } $check = Invoke-VellumPdfPreflight -Bytes $Data -Level $validateProfile -Source $resolved if (-not $check.IsCompliant) { $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumFailed', $true)) $top = @($check.Assertions | Where-Object Severity -EQ 'Error' | Select-Object -First 5 | ForEach-Object { " - $($_.RuleId): $($_.Message)" }) -join "`n" $more = if ($check.ErrorCount -gt 5) { "`n ... and $($check.ErrorCount - 5) more" } else { '' } throw (New-VellumPdfErrorRecord NotConformant ("Save-VellumPdfDocument: the rendered PDF is not $validateProfile compliant " + "($($check.ErrorCount) error(s)); nothing was written.`n$top$more")) } } $bytesResult = $null if ($toMemory) { # Render into memory first so a failure never leaves a partial # PDF in the caller's stream. $memory = [System.IO.MemoryStream]::new() try { & $render $memory if ($validateProfile) { & $validateBytes $memory.ToArray() } if ($PSCmdlet.ParameterSetName -eq 'Stream') { try { $memory.WriteTo($Stream); $Stream.Flush() } catch { throw "Save-VellumPdfDocument: rendered the PDF but could not write it to -Stream: $($_.Exception.Message)" } } else { $bytesResult = $memory.ToArray() } } finally { $memory.Dispose() } } else { $parent = [System.IO.Path]::GetDirectoryName($resolved) if ($parent -and $Force -and -not [System.IO.Directory]::Exists($parent)) { [void][System.IO.Directory]::CreateDirectory($parent) } if ($parent -and -not [System.IO.Directory]::Exists($parent)) { throw (New-VellumPdfErrorRecord FileNotFound "Save-VellumPdfDocument: directory not found: '$parent'. Create it first or pass a path in an existing directory.") } # Render/sign to a temporary file beside the target, then move # it into place only once it is complete. Writing straight to # -Path would truncate an existing good file the moment a # render/sign failure occurred. $temp = "$resolved.$([guid]::NewGuid().ToString('N')).tmp" try { $fileStream = [System.IO.File]::Create($temp) try { & $render $fileStream } finally { $fileStream.Dispose() } if ($validateProfile) { & $validateBytes ([System.IO.File]::ReadAllBytes($temp)) } # The rendered file is known good; replace the target now. try { [System.IO.File]::Move($temp, $resolved, $true) } catch { $reason = $_.Exception.Message throw ("Save-VellumPdfDocument: rendered the PDF but could not write it to '$resolved': $reason " + 'Check that -Path is a writable file location (not a directory) and is not locked by another process.') } } finally { # Clean up the temp file if a failure left it behind; on # success it has already been moved onto the target. if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumSaved', $true)) $diagnostics = Write-VellumPdfSaveDiagnostic -Document $Document if ($PassThru) { $Document } elseif ($PSCmdlet.ParameterSetName -eq 'Bytes') { , $bytesResult } elseif ($PSCmdlet.ParameterSetName -eq 'Stream') { # Nothing to return: the PDF is in the caller's stream. Diagnostics were warned above. } else { $file = Get-Item -LiteralPath $resolved $file | Add-Member -NotePropertyName TextEncodingWarnings -NotePropertyValue $diagnostics.TextEncodingWarnings $file | Add-Member -NotePropertyName BandTruncations -NotePropertyValue $diagnostics.BandTruncations $file } } } finally { # Dispose after any save attempt (success or failure), but leave the # document open under -WhatIf, where no attempt was made. The stamp # lets the other cmdlets reject stale use of this document (VellumPdf # itself accepts Add() on a disposed document without complaint). if ($attempted -and -not $KeepOpen -and -not $PassThru) { $Document.Dispose() if (-not $Document.PSObject.Properties['PSVellumDisposed']) { $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumDisposed', $true)) } # An HttpClient stashed by Set-VellumPdfSignature -TimestampUrl was # only needed for this signing write; dispose it with the document. foreach ($clientName in 'PSVellumTimestampHttpClient', 'PSVellumRevocationHttpClient') { $clientProp = $Document.PSObject.Properties[$clientName] if ($clientProp -and $clientProp.Value) { $clientProp.Value.Dispose() $clientProp.Value = $null } } } } } catch { $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Save-VellumPdfDocument')) } } } |