3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vivliostyle CLI v11.4.0+のPDF/X機能で入稿データを作成する

3
Last updated at Posted at 2026-10-26

Vivliostyle CLI v11.4.0では、PDF/X-1a:2003またはPDF/X-4準拠と判定されるためのデータを補う機能が追加されました(私が実装しました!) これは出力をPDF/X準拠に変換する機能ではありません。PDF/X準拠の範囲で作成されている文書について、実際にPDF/X準拠にするための機能です。

この機能はCMYK機能と同様にChromium / SkiaのPDF生成内容を前提として実装されており、他のブラウザでは十分にテストされていません。この記事では、厳密にはSkiaに実装されている動作もChromiumの動作として述べることがあります。

使用例①:CMYKで作成した文書をPDF/X-1a準拠にする

次の文書は、全要素がPDF/X-1a相当で出力されるように記述しています。device-cmyk()はCSS Color Module Level 5で定義されている関数です。crop-marks-line-colorはトンボの色を変えるVivliostyleの独自機能1で、すべての版に出力するためdevice-cmyk(1 1 1 1)とします。device-cmyk()はSVG内では使えない2ため、reserveMapでCMYKとの対応を表現します。replaceImageではWebブラウザで表示できるPNGと印刷用のCMYK TIFFとの対応を表現しています。置換先のcmyk.tiffは白背景を引いており透過画素はありません。図版を生成するスクリプトは記事の末尾に掲載しています。

manuscript.md
---
lang: ja
---

<style>
@page {
  size: A5;
  bleed: 3mm;
  marks: crop cross;
  crop-marks-line-color: device-cmyk(1 1 1 1);
}
* { color: device-cmyk(0 0 0 1); }
.c { color: device-cmyk(1 0 0 0); }
.m { color: device-cmyk(0 1 0 0); }
.y { color: device-cmyk(0 0 1 0); }
img { max-width: 100%; }
</style>

この文書は<span class="c">C</span><span class="m">M</span><span class="y">Y</span>Kで作成されており、適切に設定すればPDF/X-1aとして出力できます。

![ベクター画像のCMYK対応](cmyk.svg)

![ラスター画像の置換](cmyk.png)
vivliostyle.config.ts
import { defineConfig } from '@vivliostyle/cli';

export default defineConfig({
  entry: ["manuscript.md"],
  pdfPostprocess: {
    cmyk: {
      reserveMap: [
        ["#00ffff", { c: 10000, m: 0, y: 0, k: 0 }],
        ["#ff00ff", { c: 0, m: 10000, y: 0, k: 0 }],
        ["#ffff00", { c: 0, m: 0, y: 10000, k: 0 }],
        ["#000000", { c: 0, m: 0, y: 0, k: 10000 }],
        ["#0000ff", { c: 10000, m: 10000, y: 0, k: 0 }],
        ["#ff0000", { c: 0, m: 10000, y: 10000, k: 0 }],
        ["#00ff00", { c: 10000, m: 0, y: 10000, k: 0 }],
        ["#000001", { c: 10000, m: 10000, y: 10000, k: 0 }],
      ],
    },
    replaceImage: [
      { source: "cmyk.png", replacement: "cmyk.tiff" },
    ],
  },
});
$ docker run --rm --interactive --tty --volume .:/data ghcr.io/vivliostyle/cli:11.4.0 build

しかしAdobe AcrobatのプリフライトではPDF/X-1aに準拠していないと判定されました。

次のように設定を追加することで、不足しているデータが補われ、PDF/X-1aに準拠した文書となります。outputIntentにはICCプロファイルへの相対パスを指定してください。JapanColor2011 ICCプロファイルはhttps://japancolor.jp/icc.htmlで配布されています。再配布は禁止されているためVivliostyleには同梱していません。

vivliostyle.config.ts
 import { defineConfig } from '@vivliostyle/cli';

 export default defineConfig({
   entry: ["manuscript.md"],
   pdfPostprocess: {
     cmyk: {
       reserveMap: [
         ["#00ffff", { c: 10000, m: 0, y: 0, k: 0 }],
         ["#ff00ff", { c: 0, m: 10000, y: 0, k: 0 }],
         ["#ffff00", { c: 0, m: 0, y: 10000, k: 0 }],
         ["#000000", { c: 0, m: 0, y: 0, k: 10000 }],
         ["#0000ff", { c: 10000, m: 10000, y: 0, k: 0 }],
         ["#ff0000", { c: 0, m: 10000, y: 10000, k: 0 }],
         ["#00ff00", { c: 10000, m: 0, y: 10000, k: 0 }],
         ["#000001", { c: 10000, m: 10000, y: 10000, k: 0 }],
       ],
     },
     replaceImage: [
       { source: "cmyk.png", replacement: "cmyk.tiff" },
     ],
+    pdfx: {
+      version: "X-1a:2003",
+      outputIntent: "JapanColor2011Coated.icc",
+    },
   },
 });

使用例②:RGBで作成した文書をPDF/X-4準拠にする

次の文書はRGBで作成されており、rgb.pngには透過画素を用いています。PDF/X-1aには準拠できませんが、PDF/X-4に準拠できます。

manuscript.md
---
lang: ja
---

<style>
@page {
  size: A5;
  bleed: 3mm;
  marks: crop cross;
}
.r { color: rgb(100% 0% 0%); }
.g { color: rgb(0% 100% 0%); }
.b { color: rgb(0% 0% 100%); }
img { max-width: 100%; }
</style>

この文書は<span class="r">R</span><span class="g">G</span><span class="b">B</span>で作成されており、適切に設定すればPDF/X-4として出力できます。

![ベクター画像](rgb.svg)

![ラスター画像](rgb.png)

ChromiumはDeviceRGBを使用したPDFを出力します。PDF/X-4でDeviceRGBを使用するためにはdefaultRgbProfileの設定が必要です。ChromiumはsRGBの値をDeviceRGBに反映するので、通常は、defaultRgbProfileにはsRGBプロファイル(https://registry.color.org/rgb-registry/srgbprofiles)を指定します。replaceImageで置換されていないラスター画像にはChromium内蔵のカラープロファイルが適用され、defaultRgbProfileの影響を受けません。

vivliostyle.config.ts
import { defineConfig } from '@vivliostyle/cli';

export default defineConfig({
  entry: ["manuscript.md"],
  pdfPostprocess: {
    pdfx: {
      version: "X-4",
      outputIntent: "JapanColor2011Coated.icc",
    },
    defaultRgbProfile: "sRGB_v4_ICC_preference.icc",
  },
});

なおPDF/X-4はCMYKとRGBの混在も許容しますが、VivliostyleのCMYK機能は現時点ではCMYKとRGBを混在させることを想定していないため、「文字だけK100」のような指定は期待通りには動作しません3。

press-readyとの違い

Vivliostyle CLIには以前からpreflight: 'press-ready'によるPDF/X-1aへの変換機能が用意されていました。press-readyはVivliostyle CLIとは別に開発されているOSSで、実態はGhostscriptのラッパーです。文書中のRGB要素は同梱のJapan Color 2001 CoatedプロファイルでCMYKまたはグレースケールに変換されます。またType 3フォントを検出した場合は全フォントがアウトライン化されます4。現時点でpreflight: 'press-ready'の前提であるrenderMode: 'docker'は非推奨になっています(参照)。preflight: 'press-ready-local'ではシステムにインストールされているGhostscriptとpoppler-utilsを使用します。

準拠していない文書を準拠させる変換は、一般には成立しません。たとえば透過を含む画像を白背景で合成すると、背景が白でない箇所で破綻します。また、RGBの黒をK100にすべきか4色ベタにすべきかは、RGBの値からは区別できません。従来の機能はこうした判断をGhostscriptの変換処理に委ねてきました。VivliostyleのCMYK機能とPDF/X機能によって、これらの判断を作成者が指定できるようになりました。現在のVivliostyleには、press-readyによる変換を必要としない表現力があると私は認識しています5。


繰り返しになりますが、VivliostyleのPDF/X機能は、すでにPDF/X相当のPDFがプリフライトでも準拠と判定されるように補う機能であり、PDF/Xに準拠するように変換したり準拠状況をチェックしたりする機能ではありません。出力したPDFはAdobe Acrobatのプリフライトなど業界標準のチェックを通し、最終的には入稿先のハウスルールに従ってください。それらが困難である場合や基準がない場合、そもそもPDF/Xが必要かという点もよく検討してください。構造をよく理解していないデータを入稿するより、時間の余裕を確保して十分な申し送りを行うほうが確実なケースもあります。


図版を生成するスクリプト
#!/usr/bin/env -S uv run --script
#
# /// script
# dependencies = ["pillow"]
# ///

import math
from pathlib import Path

from PIL import Image, ImageDraw

SUPERSAMPLE = 4

RADIUS = 100
TRIANGLE_SIDE = 120
GAP_TO_K = 40

TRIANGLE_HEIGHT = TRIANGLE_SIDE * math.sqrt(3) / 2
CMY_RIGHT = 2 * RADIUS + TRIANGLE_SIDE
HEIGHT = 2 * RADIUS + TRIANGLE_HEIGHT
CIRCLE_CENTERS = {
    "C": (RADIUS + TRIANGLE_SIDE / 2, RADIUS),
    "M": (RADIUS, RADIUS + TRIANGLE_HEIGHT),
    "Y": (RADIUS + TRIANGLE_SIDE, RADIUS + TRIANGLE_HEIGHT),
    "K": (CMY_RIGHT + GAP_TO_K + RADIUS, HEIGHT / 2),
}
SIZE = (math.ceil(CMY_RIGHT + GAP_TO_K + 2 * RADIUS), math.ceil(HEIGHT))

OUT_DIR = Path(__file__).resolve().parent
OUT_TIFF = OUT_DIR / "cmyk.tiff"
OUT_PREVIEW = OUT_DIR / "cmyk.png"


def scaled(*coords):
    return [v * SUPERSAMPLE for v in coords]


def new_plate():
    return Image.new("L", tuple(scaled(*SIZE)), 0)


def draw_circle(draw, center):
    cx, cy = center
    draw.ellipse(scaled(cx - RADIUS, cy - RADIUS, cx + RADIUS, cy + RADIUS), fill=255)


def build_plates():
    plates = []
    for ink in "CMYK":
        plate = new_plate()
        draw_circle(ImageDraw.Draw(plate), CIRCLE_CENTERS[ink])
        plates.append(plate.resize(SIZE, Image.Resampling.BOX))
    return plates


def main():
    image = Image.merge("CMYK", build_plates())
    image.save(OUT_TIFF)
    image.convert("RGB").save(OUT_PREVIEW)
    print(OUT_TIFF, image.mode, image.size)
    print(OUT_PREVIEW)


if __name__ == "__main__":
    main()
#!/usr/bin/env -S uv run --script
#
# /// script
# dependencies = []
# ///

import math
from itertools import combinations
from pathlib import Path
from xml.etree import ElementTree as ET

RADIUS = 100
TRIANGLE_SIDE = 120
GAP_TO_K = 40

TRIANGLE_HEIGHT = TRIANGLE_SIDE * math.sqrt(3) / 2
CMY_RIGHT = 2 * RADIUS + TRIANGLE_SIDE
HEIGHT = 2 * RADIUS + TRIANGLE_HEIGHT
CIRCLE_CENTERS = {
    "C": (RADIUS + TRIANGLE_SIDE / 2, RADIUS),
    "M": (RADIUS, RADIUS + TRIANGLE_HEIGHT),
    "Y": (RADIUS + TRIANGLE_SIDE, RADIUS + TRIANGLE_HEIGHT),
    "K": (CMY_RIGHT + GAP_TO_K + RADIUS, HEIGHT / 2),
}
SIZE = (math.ceil(CMY_RIGHT + GAP_TO_K + 2 * RADIUS), math.ceil(HEIGHT))

INK_CMYK = {
    "C": (1, 0, 0, 0),
    "M": (0, 1, 0, 0),
    "Y": (0, 0, 1, 0),
    "K": (0, 0, 0, 1),
}

COLOR_OVERRIDES = {frozenset("CMY"): "#000001"}

OUT_DIR = Path(__file__).resolve().parent
OUT_SVG = OUT_DIR / "cmyk.svg"


def number(value):
    return format(value, ".10g")


def cmyk_to_rgb(cyan, magenta, yellow, black):
    def channel(ink):
        return 1 - min(1, ink * (1 - black) + black)

    return channel(cyan), channel(magenta), channel(yellow)


def ink_color(inks):
    if frozenset(inks) in COLOR_OVERRIDES:
        return COLOR_OVERRIDES[frozenset(inks)]
    cmyk = [min(1, sum(INK_CMYK[ink][i] for ink in inks)) for i in range(4)]
    return "#" + "".join(f"{round(v * 255):02x}" for v in cmyk_to_rgb(*cmyk))


def circles_overlap(inks):
    return all(
        math.dist(CIRCLE_CENTERS[a], CIRCLE_CENTERS[b]) < 2 * RADIUS
        for a, b in combinations(inks, 2)
    )


def circle_element(ink, **attrs):
    cx, cy = CIRCLE_CENTERS[ink]
    return ET.Element("circle", cx=number(cx), cy=number(cy), r=number(RADIUS), **attrs)


def clipped_circle(inks):
    *clipping, painted = inks
    element = circle_element(painted, fill=ink_color(inks))
    for ink in reversed(clipping):
        group = ET.Element("g", {"clip-path": f"url(#clip-{ink})"})
        group.append(element)
        element = group
    return element


def build_svg():
    width, height = SIZE
    svg = ET.Element(
        "svg",
        xmlns="http://www.w3.org/2000/svg",
        width=str(width),
        height=str(height),
        viewBox=f"0 0 {width} {height}",
    )
    defs = ET.SubElement(svg, "defs")
    for ink in INK_CMYK:
        ET.SubElement(defs, "clipPath", id=f"clip-{ink}").append(circle_element(ink))
    for count in range(1, len(INK_CMYK) + 1):
        for inks in combinations(INK_CMYK, count):
            if circles_overlap(inks):
                svg.append(clipped_circle(inks))
    return svg


def main():
    svg = build_svg()
    ET.indent(svg)
    ET.ElementTree(svg).write(OUT_SVG, encoding="utf-8", xml_declaration=True)
    print(OUT_SVG, SIZE)


if __name__ == "__main__":
    main()
#!/usr/bin/env -S uv run --script
#
# /// script
# dependencies = ["pillow"]
# ///

import math
from pathlib import Path

from PIL import Image, ImageChops, ImageDraw

SUPERSAMPLE = 4

RADIUS = 100
TRIANGLE_SIDE = 120
RIGHT_MARGIN = 240

TRIANGLE_HEIGHT = TRIANGLE_SIDE * math.sqrt(3) / 2
HEIGHT = 2 * RADIUS + TRIANGLE_HEIGHT
CIRCLE_CENTERS = {
    "R": (RADIUS + TRIANGLE_SIDE / 2, RADIUS),
    "G": (RADIUS, RADIUS + TRIANGLE_HEIGHT),
    "B": (RADIUS + TRIANGLE_SIDE, RADIUS + TRIANGLE_HEIGHT),
}
SIZE = (math.ceil(2 * RADIUS + TRIANGLE_SIDE + RIGHT_MARGIN), math.ceil(HEIGHT))

OUT_DIR = Path(__file__).resolve().parent
OUT_PNG = OUT_DIR / "rgb.png"


def scaled(*coords):
    return [v * SUPERSAMPLE for v in coords]


def new_channel():
    return Image.new("L", tuple(scaled(*SIZE)), 0)


def draw_circle(draw, center):
    cx, cy = center
    draw.ellipse(scaled(cx - RADIUS, cy - RADIUS, cx + RADIUS, cy + RADIUS), fill=255)


def build_channels():
    channels = []
    for light in "RGB":
        channel = new_channel()
        draw_circle(ImageDraw.Draw(channel), CIRCLE_CENTERS[light])
        channels.append(channel)
    return channels


def main():
    channels = build_channels()
    alpha = ImageChops.lighter(ImageChops.lighter(*channels[:2]), channels[2])
    image = Image.merge("RGBA", [*channels, alpha]).resize(SIZE, Image.Resampling.BOX)
    image.save(OUT_PNG)
    print(OUT_PNG, image.mode, image.size)


if __name__ == "__main__":
    main()
#!/usr/bin/env -S uv run --script
#
# /// script
# dependencies = []
# ///

import math
from itertools import combinations
from pathlib import Path
from xml.etree import ElementTree as ET

RADIUS = 100
TRIANGLE_SIDE = 120
RIGHT_MARGIN = 240

TRIANGLE_HEIGHT = TRIANGLE_SIDE * math.sqrt(3) / 2
HEIGHT = 2 * RADIUS + TRIANGLE_HEIGHT
CIRCLE_CENTERS = {
    "R": (RADIUS + TRIANGLE_SIDE / 2, RADIUS),
    "G": (RADIUS, RADIUS + TRIANGLE_HEIGHT),
    "B": (RADIUS + TRIANGLE_SIDE, RADIUS + TRIANGLE_HEIGHT),
}
SIZE = (math.ceil(2 * RADIUS + TRIANGLE_SIDE + RIGHT_MARGIN), math.ceil(HEIGHT))

LIGHT_RGB = {
    "R": (1, 0, 0),
    "G": (0, 1, 0),
    "B": (0, 0, 1),
}

OUT_DIR = Path(__file__).resolve().parent
OUT_SVG = OUT_DIR / "rgb.svg"


def number(value):
    return format(value, ".10g")


def light_color(lights):
    rgb = [min(1, sum(LIGHT_RGB[light][i] for light in lights)) for i in range(3)]
    return "#" + "".join(f"{round(v * 255):02x}" for v in rgb)


def circles_overlap(lights):
    return all(
        math.dist(CIRCLE_CENTERS[a], CIRCLE_CENTERS[b]) < 2 * RADIUS
        for a, b in combinations(lights, 2)
    )


def circle_element(light, **attrs):
    cx, cy = CIRCLE_CENTERS[light]
    return ET.Element("circle", cx=number(cx), cy=number(cy), r=number(RADIUS), **attrs)


def clipped_circle(lights):
    *clipping, painted = lights
    element = circle_element(painted, fill=light_color(lights))
    for light in reversed(clipping):
        group = ET.Element("g", {"clip-path": f"url(#clip-{light})"})
        group.append(element)
        element = group
    return element


def build_svg():
    width, height = SIZE
    svg = ET.Element(
        "svg",
        xmlns="http://www.w3.org/2000/svg",
        width=str(width),
        height=str(height),
        viewBox=f"0 0 {width} {height}",
    )
    defs = ET.SubElement(svg, "defs")
    for light in LIGHT_RGB:
        ET.SubElement(defs, "clipPath", id=f"clip-{light}").append(circle_element(light))
    for count in range(1, len(LIGHT_RGB) + 1):
        for lights in combinations(LIGHT_RGB, count):
            if circles_overlap(lights):
                svg.append(clipped_circle(lights))
    return svg


def main():
    svg = build_svg()
    ET.indent(svg)
    ET.ElementTree(svg).write(OUT_SVG, encoding="utf-8", xml_declaration=True)
    print(OUT_SVG, SIZE)


if __name__ == "__main__":
    main()
  1. vivliostyle/vivliostyle.js#1505 AH Formatterには-ah-printer-marks-line-color、Princeにはprince-mark-colorという同様の機能があるようです。 ↩

  2. SVGが許容していないというより、CMYKのSVGを出力する編集ソフトがないのでVivliostyle側で実装してもあまり意味がないため対応していません。 ↩

  3. 使用色次第ですが、SVGで使用しているRGB黒が意図せずK100になるなどの結果が考えられます。device-cmyk()のブラウザ上での表示色やreserveMapの指定は、その色がRGBとして使用されているかを考慮しません。 ↩

  4. PDF/XはType 3フォントを禁止していません。使用例①ではChromiumがType 3フォントを埋め込んでいますが、問題なくPDF/X-1a準拠と判定されています。一方で、入稿先からType 3フォントを避けるように指示されることはしばしばあります。ChromiumがType 3フォントを埋め込む条件と、それを避けてType 0フォントとして埋め込ませる方法は別の記事で述べました。 ↩

  5. press-readyで対応できていたがVivliostyleのCMYK機能では未対応の要素として、グラデーション(シェーディング)があります。現在のCSS仕様にはインク量で補間するグラデーションを表現する方法がなく(w3c/csswg-drafts#13934)、何らかの拡張が必要です。グラデーションをラスター画像として用意してreplaceImageで置換すれば表現可能ですが、機械的な変換は依然困難です。 ↩

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?