Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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) | Per-image `ConvertToCmyk` runs but is a no-op for PNG (PNG cannot carry CMYK). Print-ready CMYK is applied by the Ghostscript post-process pass (`Mode=PdfCmykPostProcess`, #632) on the final PDFs, not per-image. |
| 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,139 @@
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 BuildPdfxDefPostscript_uses_single_brace_pdfmark_object_names()
{
var ps = PdfCmykPostProcessor.BuildPdfxDefPostscript(@"C:\profiles\SWOP.icc");

// Regression pin (#641 review): "{{" in a NON-interpolated C# string segment is a
// literal double brace; GS parses {{icc_PDFX}} as a nested procedure and aborts with
// a typecheck error on /_objdef (empirically reproduced with GS 10.07.1 → exit 1,
// zero OutputIntent written). pdfmark named objects must be single-brace.
ps.Should().NotContain("{{");
ps.Should().NotContain("}}");
ps.Should().Contain("[/_objdef {icc_PDFX} /type /stream /OBJ pdfmark");
ps.Should().Contain("/DestOutputProfile {icc_PDFX}");
ps.Should().Contain("[{Catalog} <</OutputIntents [ {OutputIntent_PDFX} ]>> /PUT pdfmark");
}

[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