Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,17 @@ The pipeline uses `UseDebugParams` / `UseReleaseParams` (in `AssetConverterConfi
| Aspect | Debug (`dotnet run`) | Release (`-c Release`) |
|--------|----------------------|------------------------|
| Print&Play image format | JPEG Q=85 (~71 MB Tarot) | PNG lossless (~222 MB) |
| CMYK conversion | Disabled (RGB) | Enabled |
| Per-image CMYK conversion | Disabled (RGB) | Enabled (but see oxymore below) |
| CardPen source | Local IIS (`UseLocalCardpen=true`) | GitHub Pages URL |
| Template paths | `JsonFilePathDebug` | `JsonFilePathRelease` |
| Harvest output | Debug density directory | Release density directory |
| **PDF CMYK+OutputIntent post-process** (`PdfCmykPostProcess`, #632) | **OFF** | **ON** (Ghostscript post-pass on final PDFs) |

**⚠️ CMYK oxymore (resolved by #632)**: the per-image `ConvertToCmyk` (`DocumentCardSet.cs`) runs under Release, but the image is then written as **PNG** which cannot carry CMYK — Magick re-encodes to RGB on the write, so the per-image conversion is effectively a no-op for the PNG path. The bundle therefore ships **RGB-300-lossless** (FlateDecode, 0 DeviceCMYK — verified via `pdfimages -list`). The **authoritative CMYK path is the Ghostscript post-process** (`PdfCmykPostProcess`, new flag `ConverterMode.PdfCmykPostProcess = 1<<15`): it converts the final PDF to DeviceCMYK and embeds the SWOP OutputIntent. See `PdfCmykPostProcess/README.md`.

**Override**: Set `ForceReleaseParams = true` in JSON config to use Release params in Debug builds.

**Key files with Debug/Release pairs**: `DocumentCardSet.cs` (CMYK), `PdfManager.cs` (JPEG), `WebBasedGeneratorConfig.cs` (CardPen URL, template paths), `MindMapDocumentConfig.cs` (paths), `HarvestManager.cs` (URLs).
**Key files with Debug/Release pairs**: `DocumentCardSet.cs` (CMYK), `PdfManager.cs` (JPEG), `WebBasedGeneratorConfig.cs` (CardPen URL, template paths), `MindMapDocumentConfig.cs` (paths), `HarvestManager.cs` (URLs), `PdfCmykPostProcessConfig.cs` (GS post-process enable, #632).

### Known Fragile Areas
1. SVG disambiguation in mind map generation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.IO;
using System.Threading.Tasks;
using Argumentum.AssetConverter;
using Argumentum.AssetConverter.PdfCmykPostProcess;
using FluentAssertions;
using ImageMagick;
using Xunit;

namespace Argumentum.AssetConverter.Tests.PdfCmykPostProcess
{
/// <summary>
/// Contract pin for the Ghostscript CMYK + OutputIntent post-process (#632).
///
/// These tests do NOT require Ghostscript to be installed: they verify the gating
/// (Release-only by default, master toggle), the exact Ghostscript argument contract
/// (ai-01 POC-validated — see issue #632), the PDFX_def.ps generation, and the ICC
/// profile extraction from Magick.NET. The graceful-skip-when-GS-absent behavior is
/// covered by <see cref="ResolveGhostscript_returns_null_when_binary_absent"/>.
/// </summary>
public class PdfCmykPostProcessTests
{
[Fact]
public async Task Apply_IsEnabled_false_skips_without_requiring_ghostscript()
{
// A disabled stage must short-circuit before touching the filesystem or GS.
var masterConfig = new AssetConverterConfig();
var config = new PdfCmykPostProcessConfig { IsEnabled = false, GhostscriptPath = "definitely-not-real-gs" };

Func<Task> act = () => PdfCmykPostProcessConfig.Apply(masterConfig, config);

await act.Should().NotThrowAsync();
}

[Fact]
public void GetEnabled_is_release_only_by_default()
{
var config = new PdfCmykPostProcessConfig();
var debugConfig = new AssetConverterConfig(); // isInDebugMode=true under Debug tests
var releaseConfig = new AssetConverterConfig { ForceReleaseParams = true };

// Default pair: EnabledDebug=false, EnabledRelease=true → OFF in Debug, ON in Release.
config.GetEnabled(debugConfig).Should().BeFalse("Debug is preview-only by default");
config.GetEnabled(releaseConfig).Should().BeTrue("Release is printer-quality by default");
}

[Fact]
public void GetEnabled_respects_master_toggle_and_explicit_overrides()
{
var releaseConfig = new AssetConverterConfig { ForceReleaseParams = true };

var offEverywhere = new PdfCmykPostProcessConfig { EnabledRelease = false };
offEverywhere.GetEnabled(releaseConfig).Should().BeFalse("EnabledRelease=false overrides build mode");

var onInDebug = new PdfCmykPostProcessConfig { EnabledDebug = true };
var debugConfig = new AssetConverterConfig();
onInDebug.GetEnabled(debugConfig).Should().BeTrue("EnabledDebug=true enables it in Debug");
}

[Fact]
public void ResolveGhostscript_returns_null_when_binary_absent()
{
// A bogus name not on PATH and not absolute-existing must resolve to null, not throw.
var processor = new PdfCmykPostProcessor(
new PdfCmykPostProcessConfig { GhostscriptPath = "definitely-not-a-real-gs-binary-xyz123" });

var resolved = processor.ResolveGhostscript();

resolved.Should().BeNull("an absent GS binary must skip the stage gracefully (#632 spec)");
}

[Fact]
public void BuildGhostscriptArguments_matches_poc_validated_command()
{
var args = PdfCmykPostProcessor.BuildGhostscriptArguments(
iccPath: @"C:\icc\USWebCoatedSWOP.icc",
outputPdf: @"C:\out\doc-cmyk.pdf",
pdfxDefPath: @"C:\tmp\PDFX_def.ps",
inputPdf: @"C:\docs\doc.pdf");

// POC-validated invariants (issue #632): PDFX, CMYK strategy+model, Flate lossless,
// no downsample, ICC permit-read, then -o, PDFX_def.ps, input in order.
args.Should().Contain("-dPDFX");
args.Should().Contain("-sColorConversionStrategy=CMYK");
args.Should().Contain("-dProcessColorModel=/DeviceCMYK");
args.Should().Contain("-dColorImageFilter=/FlateEncode");
args.Should().Contain("-dGrayImageFilter=/FlateEncode");
args.Should().Contain("-dDownsampleColorImages=false");
args.Should().Contain("--permit-file-read=\"C:\\icc\\USWebCoatedSWOP.icc\"");
args.Should().Contain("-o \"C:\\out\\doc-cmyk.pdf\"");
args.Should().Contain("\"C:\\tmp\\PDFX_def.ps\"");
args.Should().Contain("\"C:\\docs\\doc.pdf\"");
}

[Fact]
public void BuildPdfxDefPostscript_embeds_icc_path_and_output_intent_markers()
{
var ps = PdfCmykPostProcessor.BuildPdfxDefPostscript(@"C:\profiles\SWOP.icc");

// ICC path injected (forward-slash normalized for Postscript).
ps.Should().Contain("/ICCProfile (C:/profiles/SWOP.icc) def");
// PDF/X OutputIntent contract: N=4 CMYK, GTS_PDFX type, SWOP registry identifiers.
ps.Should().Contain("<</N 4>>");
ps.Should().Contain("/S /GTS_PDFX");
ps.Should().Contain("/Type /OutputIntent");
ps.Should().Contain("(CGATS TR 001)");
ps.Should().Contain("(U.S. Web Coated \\(SWOP\\) v2)");
ps.Should().Contain("/RegistryName (http://www.color.org)");
// Catalog wiring.
ps.Should().Contain("/OutputIntents");
}

[Fact]
public void Magick_icc_profile_extracts_nonempty_swop_bytes()
{
// The ICC profile used both by the per-image ConvertToCmyk conversion AND by this
// GS OutputIntent — verifies the extraction API works and yields the ~557 KB SWOP profile.
var bytes = ColorProfiles.USWebCoatedSWOP.ToByteArray();

bytes.Should().NotBeNull();
bytes.Length.Should().BeGreaterThan(500_000, "USWebCoatedSWOP ICC is ~557 KB per ai-01 POC (#632)");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,13 @@ public async Task<bool> Apply()
await Task.WhenAll(tasks);
}


// #632: Ghostscript CMYK+OutputIntent post-pass. Runs AFTER all other stages
// (incl. PDF generation) so it operates on already-written PDFs — can be run
// standalone on an existing bundle (Mode=PdfCmykPostProcess) without re-harvest.
if (Mode.HasFlag(ConverterMode.PdfCmykPostProcess))
{
await global::Argumentum.AssetConverter.PdfCmykPostProcess.PdfCmykPostProcessConfig.Apply(this);
}

// Handling for None or unrecognized values
if (Mode == ConverterMode.None)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ public enum ConverterMode
QuestPdfGeneration = 1 << 12, // 4096
PdfAuditor = 1 << 13, // 8192
GSheetSync = 1 << 14, // 16384
PdfCmykPostProcess = 1 << 15, // 32768 — Ghostscript CMYK+OutputIntent post-process on generated PDFs (#632)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Threading.Tasks;

namespace Argumentum.AssetConverter.PdfCmykPostProcess
{
/// <summary>
/// Configuration for the Ghostscript CMYK + OutputIntent post-process (#632).
///
/// Converts the RGB-300-lossless (FlateDecode) PDFs produced by QuestPDF into
/// DeviceCMYK + OutputIntent (CGATS TR 001 / SWOP) print-ready PDFs via a
/// Ghostscript post-pass. Runs as a standalone stage AFTER PDF generation
/// (dispatched after Task.WhenAll in AssetConverterConfig.Apply), so it can be
/// run on an existing bundle WITHOUT re-harvesting.
///
/// Scope honesty (#632 spec): the output targets CMYK colorspace + an
/// OutputIntent profile, NOT formal PDF/X-3 certification (no trim/bleed boxes).
/// </summary>
public class PdfCmykPostProcessConfig
{
/// <summary>Master toggle. When false the stage is skipped entirely.</summary>
public bool IsEnabled { get; set; } = true;

/// <summary>Enable in Debug builds (default OFF — Debug is preview-only).</summary>
public bool EnabledDebug { get; set; } = false;

/// <summary>Enable in Release builds (default ON — printer quality).</summary>
public bool EnabledRelease { get; set; } = true;

/// <summary>Resolved enable flag: Release-only by default (see <see cref="AssetConverterConfig.UseReleaseParams"/>).</summary>
public bool GetEnabled(AssetConverterConfig config)
=> config.UseReleaseParams ? EnabledRelease : EnabledDebug;

/// <summary>
/// Ghostscript executable name/path. Defaults to "gswin64c" (Windows GS console,
/// resolves via PATH). Override with an absolute path (e.g. a conda-forge user-scope
/// install) when GS is not on PATH. If the binary cannot be found, the stage
/// skips every PDF with a warning rather than crashing.
/// </summary>
public string GhostscriptPath { get; set; } = "gswin64c";

/// <summary>
/// Optional absolute path to a custom ICC profile. When null/empty, the profile is
/// extracted at runtime from <c>ImageMagick.ColorProfiles.USWebCoatedSWOP</c> — the
/// SAME profile used by the per-image <c>ConvertToCmyk</c> pixel conversion, so the
/// GS OutputIntent is color-consistent with the source pipeline (zero new licensing).
/// </summary>
public string IccProfilePath { get; set; }

/// <summary>Per-PDF Ghostscript timeout in seconds (default 180).</summary>
public int TimeoutSeconds { get; set; } = 180;

/// <summary>
/// Entry point dispatched from <see cref="AssetConverterConfig.Apply"/> when
/// <see cref="ConverterMode.PdfCmykPostProcess"/> is set. Gated by
/// <see cref="GetEnabled"/> (Release-only by default) and the master toggle.
/// </summary>
public static async Task Apply(AssetConverterConfig masterConfig)
{
var config = new PdfCmykPostProcessConfig();
await Apply(masterConfig, config);
}

/// <summary>Overload accepting an explicit config instance (testable).</summary>
public static async Task Apply(AssetConverterConfig masterConfig, PdfCmykPostProcessConfig config)
{
if (!config.IsEnabled)
{
Logger.Log("PDF CMYK post-process is disabled (IsEnabled=false). Skipping.");
return;
}

if (!config.GetEnabled(masterConfig))
{
Logger.Log("PDF CMYK post-process is OFF for this build configuration. Skipping.");
return;
}

var processor = new PdfCmykPostProcessor(config);
await processor.ProcessAllAsync(masterConfig);
}
}
}
Loading
Loading