WebブラウザベースCSS組版はCMYK出力の夢を見るか？——PostCSSプラグインによるdevice-cmyk色指定の変換

[CSS組版 Advent Calendar 2024](https://adventar.org/calendars/10448) 17日目の記事です。昨日はhidarumaさんでした。明日はhidarumaさんです。この記事の内容はCSS組版というよりJavaScriptの領分です。

CSS組版で本格的な版下データを出力するには、CMYKへの対応が不可欠です。しかし、Vivliostyleをはじめとするレンダリングを既成のWebブラウザで行うCSS組版エンジンは、Webブラウザが対応しない限りCMYKカラーの出力ができません[^1]。印刷というWebとは異なるドメインに属するカラースペースであるCMYKへの対応を、Webブラウザ側が積極的に進める可能性は高くないと考えられます。

[^1]: 参考：https://print-css.rocks/lesson/lesson-cmyk

過去には、CSS側で疑似的にCMYKを指定した後、RGBに変換して組版を行い、さらにRGBからCMYKを復元する情報を出力することで、実質的にCMYKを指定する方法を検討しました。そして、先日公開した[`u1f992/postcss-device-cmyk`](https://github.com/u1f992/postcss-device-cmyk)により、当面の目標を達成できたと感じています。この記事では、`u1f992/postcss-device-cmyk`を使用したCMYK指定とPDF出力について紹介します。

## プロジェクト例

```
.
├── css
│   ├── JapanColor2001Coated.icc
│   ├── package.json
│   ├── postcss.config.js
│   └── src
│       └── style.css
├── manuscript.md
├── package.json
└── vivliostyle.config.js
```

<details>

```
> mkdir vivliostyle-cmyk-example && cd vivliostyle-cmyk-example
> npm init --yes
> npm pkg set type=module
> npm i --save-dev @vivliostyle/cli
> npx vivliostyle init
```

```package.json
{
  "name": "vivliostyle-cmyk-example",
  "type": "module",
  "scripts": {
    "build": "cd css && npm run build && cd .. && vivliostyle build",
    "preview": "node -e \"[['npm run watch','css'],['npx vivliostyle preview','']].map(([c,cwd])=>require('node:child_process').exec(c,{cwd})).forEach(p=>p.stdout.on('data',d=>process.stdout.write(d))||p.stderr.on('data',d=>process.stderr.write(d)))\""
  },
  "devDependencies": {
    "@vivliostyle/cli": "^8.17.1"
  }
}
```

```vivliostyle.config.js
// @ts-check
/** @type {import('@vivliostyle/cli').VivliostyleConfigSchema} */
const vivliostyleConfig = {
  title: "...",
  author: "...",
  language: "ja",
  theme: "./css",
  entry: ["manuscript.md"],
  output: ["./output.pdf"],
  workspaceDir: ".vivliostyle",
};

export default vivliostyleConfig;
```

```
> mkdir css && cd css
> echo '{"name":"css","type":"module","main":"dest/style.css","scripts":{"build":"postcss src --verbose --dir dest","watch":"postcss src --verbose --dir dest --watch"}}' > package.json
> npm install --save-dev postcss-cli @u1f992/postcss-device-cmyk
> cd ..
```

```css/postcss.config.js
// @ts-check

import fs from "node:fs";
import path from "node:path";

import deviceCMYK from "@u1f992/postcss-device-cmyk";

const outputDir = "dest";

/**
 * @param {string} dir
 * @returns {string[]}
 */
function collectFiles(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const filePath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      return collectFiles(filePath);
    }
    return entry.isFile() && filePath.endsWith(".css") ? [filePath] : [];
  });
}

export default (ctx) => ({
  plugins: [
    deviceCMYK({
      // https://www.adobe.com/support/downloads/iccprofiles/iccprofiles_win.html
      cmykProfilePath: "JapanColor2001Coated.icc",
      restoreJSONPath: path.join(outputDir, "device-cmyk.restore.json"),
      otherFiles: collectFiles(ctx.file.dirname),
    }),
  ],
});
```

</details>

`package.json`の`"scripts"`に記載されている通り、`$ npm run preview`や`$ npm run build`を実行することで、PostCSSを用いたCSSのビルドとVivliostyleによるビルドが一連の処理として実行されます。postcss-cliにはWatch機能があるため、`device-cmyk`を含むCSSファイルを変更するだけで、変換を意識せずにVivliostyleのプレビューを更新することが可能です。

## `device-cmyk`を使用した色指定

`u1f992/postcss-device-cmyk`は、CSS Color Module Level 5で定義されている[`device-cmyk`](https://www.w3.org/TR/css-color-5/#device-cmyk)による色指定をサポートするPostCSSプラグインです。関数を単独で使用する場合に限らず、`border: 1mm solid device-cmyk(0 1 0 0);`のように他のプロパティ値の一部として使用するケースにも対応しています。

```css/src/style.css
@page {
    size: A5;
}

h1 {
    color: device-cmyk(0 0 0 1);
    border: 1mm solid device-cmyk(0 1 0 0);
    border-radius: 5mm;
    padding: 10px;
    font-size: 7mm;
}

p {
    color: device-cmyk(0 0 0 1);
}

a {
    color: device-cmyk(1 0 0 0);
}

code {
    background-color: device-cmyk(0 0 0 25%);
}

strong {
    text-decoration: underline;
    text-decoration-color: device-cmyk(0, 0, 1, 0);
    text-decoration-thickness: 3px;
    text-underline-offset: 3px;
}
```

<details>
<summary>変換結果</summary>

```css/dest/style.css
@page {
    size: A5;
}

h1 {
    color: rgb(35 25 22);
    border: 1mm solid rgb(228 0 127);
    border-radius: 5mm;
    padding: 10px;
    font-size: 7mm;
}

p {
    color: rgb(35 25 22);
}

a {
    color: rgb(0 161 233);
}

code {
    background-color: rgb(211 211 212);
}

strong {
    text-decoration: underline;
    text-decoration-color: rgb(255 241 0);
    text-decoration-thickness: 3px;
    text-underline-offset: 3px;
}
```

</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/3b117eca-a904-ab50-0887-a9155edd5547.png)

同時に、`postcss.config.js`で指定したパスに復元情報を出力します。この復元情報は、RGBをキーとしてCMYKを関連付けた単純なJSONファイルです。キー自体がJSON形式である点に注意してください。JSONを処理できる環境であれば、キーのJSON文字列からオブジェクトを復元することもまた可能なはずであり、これは妥当な手段と考えています。

```css/dest/device-cmyk.restore.json
{
  "[35,25,22]": [0, 0, 0, 1],
  "[228,0,127]": [0, 1, 0, 0],
  "[0,161,233]": [1, 0, 0, 0],
  "[211,211,212]": [0, 0, 0, 0.25],
  "[255,241,0]": [0, 0, 1, 0]
}
```

## Scribusを使用したCMYKへの復元

さらなる調査と加筆が必要です。InDesignでも検証したい。

`$ npm run build`で生成されるPDFは、特に変換を施していないRGB形式のPDFです。このPDFをCMYK形式に復元するには、生成されたJSONをもとにInDesignやScribusを使用する必要があります。比較的信頼性の高い方法はInDesignですが、私が所持していないので、ここではScribusを使用する方法を紹介します。

<!-- https://www.klaasnotfound.com/2016/06/05/creating-cmyk-prepress-pdfs-with-inkscape-and-scribus/ -->

1. `ファイル > 設定 > カラーマネジメント > Document Options > Activate Color Management`を有効化。よしなに設定![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/05c3b248-ddc1-ad1d-9b2d-5577b3dc4365.png)
2. `ファイル > 開く`からPDFを開く。`Import Text As Vectors`で問題なし![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/3fc13d99-50df-95b1-9251-c7f4c4801fca.png)
3. `スクリプト > スクリプトを実行`で以下のPythonスクリプトを実行する
4. 適切な設定でエクスポートする

```restore_cmyk.py
import importlib
import json
import typing


class ScribusModule(typing.Protocol):
    def fileDialog(
        self,
        caption: str,
        filter: str = ...,
        defaultname: str = ...,
        haspreview: bool = ...,
        issave: bool = ...,
        isdir: bool = ...,
    ) -> str: ...
    def getColorNames(self) -> "list[str]": ...
    def getColorAsRGB(self, name: str) -> "tuple[int, int, int]": ...
    def changeColorCMYKFloat(
        self, name: str, c: float, m: float, y: float, k: float
    ) -> None: ...


scribus = typing.cast(ScribusModule, importlib.import_module("scribus"))


def main():
    json_path = scribus.fileDialog("Open", "JSON (*.json)")
    if json_path == "":
        # Cancel
        return
    with open(json_path, encoding="utf-8") as f:
        restoration_table = typing.cast(
            "dict[tuple[int, int, int], tuple[float, float, float, float]]",
            {tuple(json.loads(k)): tuple(v) for k, v in json.loads(f.read()).items()},
        )

    colors_from_pdf = {
        name: scribus.getColorAsRGB(name)
        for name in scribus.getColorNames()
        if name.startswith("FromPDF")
    }
    for name, color in colors_from_pdf.items():
        # FIXME:
        # try:
        c, m, y, k = restoration_table[color]
        # except KeyError:
        #     # It seems that Vivliostyle (or Chromium) internally uses rgb(0, 0, 0)
        #     if color == (0, 0, 0):
        #         c, m, y, k = 1, 1, 1, 1
        #     else:
        #         raise
        scribus.changeColorCMYKFloat(name, c * 100, m * 100, y * 100, k * 100)


if __name__ == "__main__":
    main()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/c88a11c4-ad7c-cb91-5710-30b8aaaeedc4.png)

## 補遺

言うまでもありませんが、このアイデアですべての場合に対応するのは困難です。例えばこんな問題があるかもしれません。

### 色数の制約

必ず元のCMYK値に復元できるよう、このPostCSSプラグインでは、どんなに近いCMYK値であっても異なるRGB値にマッピングします。たとえば、`device-cmyk(1 0 0 0)`、`device-cmyk(0.999 0 0 0)`、`device-cmyk(0.998 0 0 0)` ……のように使用すると、ビルド時間が延び、プレビュー時点での再現度も低下します。当然ながら、256^3色を超える指定はできません。ただし、手作業で指定する程度の個数であれば、まったく問題ありません。

### 図版

SVGで使用した色はScribus上で同様に列挙されているので、変換先のRGBのみを使用して図版を作成すればよいでしょう。

```css
/* SVGで使う色をCSSに含めておく */
:root {
    --cmyk-1-0-0-0: device-cmyk(1 0 0 0);
    --cmyk-0_8-0-0-0: device-cmyk(0.8 0 0 0);
    --cmyk-0_6-0-0-0: device-cmyk(0.6 0 0 0);
    --cmyk-0_4-0-0-0: device-cmyk(0.4 0 0 0);
    --cmyk-0_2-0-0-0: device-cmyk(0.2 0 0 0);

    --cmyk-0-0-0-1: device-cmyk(0 0 0 1);
    --cmyk-0-0-0-0_8: device-cmyk(0 0 0 0.8);
    --cmyk-0-0-0-0_6: device-cmyk(0 0 0 0.6);
    --cmyk-0-0-0-0_4: device-cmyk(0 0 0 0.4);
    --cmyk-0-0-0-0_2: device-cmyk(0 0 0 0.2);
}

/* 変換後のRGBカラーのみを使用してSVG図版を作成する */
:root {
    --cmyk-1-0-0-0: rgb(0 161 233);
    --cmyk-0_8-0-0-0: rgb(0 175 236);
    --cmyk-0_6-0-0-0: rgb(85 195 241);
    --cmyk-0_4-0-0-0: rgb(159 217 246);
    --cmyk-0_2-0-0-0: rgb(211 237 251);

    --cmyk-0-0-0-1: rgb(35 25 22);
    --cmyk-0-0-0-0_8: rgb(89 87 87);
    --cmyk-0-0-0-0_6: rgb(137 137 137);
    --cmyk-0-0-0-0_4: rgb(181 181 182);
    --cmyk-0-0-0-0_2: rgb(220 221 221);
}
```

ラスター画像は手動で貼り替える以外に良い方法が思いつきません。何らかのメタデータを含められたら自動で貼り替えられるかもしれません。

画面キャプチャや写真は、元画像がCMYKである可能性は低いため、4Cであれば自動変換で妥当な結果を得られるのではないでしょうか。1～3Cの場合は体感としてモノクロで掲載することが多いことから、こちらも自動変換で十分対応できるかもしれません。シンプルなイラストの類については、ベクター画像への変換を許容する方が実用的かもしれません。

### グラデーションなど

`linear-gradient`などで`device-cmyk`を使用すれば変換は行われますが、InDesignやScribus上でどのように取り込まれるのか、また操作可能なのかは不明です。仮に自動でCMYKに変換できたとしても、たとえば2色刷りで`device-cmyk(1 0 0 0)`→`device-cmyk(0 0 0 0)`を意図したグラデーションの中間色にシアン以外の色が含まれないとは限りません。結局、手動で貼り替える必要があるのではないでしょうか。

### トンボ

Vivliostyleのトンボは、すべての版に何らかが表示されるよう、あえて`black`ではなくなっているということです[^2]。

[^2]: https://github.com/vivliostyle/vivliostyle.js/issues/910

高度なトンボを付けたいなら、Vivliostyleからは「塗り足しあり・トンボなし」で出力して、InDesignやScribusでトンボを付けたほうがよさそうです。