From 407612f8046eec1dfb17f6663a58bff75301f5a0 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 24 Sep 2026 12:29:12 +1000 Subject: [PATCH 1/4] Convert black to the black ink alone rather than to all four inks at once rgb2cmyk() answered pure black with 100% of cyan, magenta, yellow and black: 400% total area coverage, past any press's limit, and a jump away from rgb(1, 1, 1), which already converted to 99.6% black alone. Black text and rules in a PDF/X-1a document, and anything converted by restrictColorSpace 3, now print from the black plate. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Color/ColorModeConverter.php | 11 ++-- tests/Mpdf/Color/ColorModeConverterTest.php | 56 +++++++++++++++++++++ tests/Mpdf/Image/CmykConversionTest.php | 2 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Color/ColorModeConverter.php b/src/Color/ColorModeConverter.php index 610cff413..cdb1d9b11 100644 --- a/src/Color/ColorModeConverter.php +++ b/src/Color/ColorModeConverter.php @@ -31,6 +31,10 @@ public function cmyk2gray($c) } /** + * Black is the black ink alone. Painting it as all four inks at once - registration black - is 400% + * total area coverage, past what any press allows, and would be a jump away from every near-black + * value: rgb(1, 1, 1) is already K 99.6% and nothing else. + * * @param float[] $c * * @return float[] @@ -44,13 +48,10 @@ public function rgb2cmyk($c) if ($min == 1) { if ($c[0] == 5) { - return [6, 100, 100, 100, 100, $c[4]]; + return [6, 0, 0, 0, 100, $c[4]]; } - return [4, 100, 100, 100, 100]; - // For K-Black - //if ($c[0]==5) { return array (6,0,0,0,100, $c[4]); } - //else { return array (4,0,0,0,100); } + return [4, 0, 0, 0, 100]; } $K = $min; diff --git a/tests/Mpdf/Color/ColorModeConverterTest.php b/tests/Mpdf/Color/ColorModeConverterTest.php index 0a26c4c60..151262cf4 100644 --- a/tests/Mpdf/Color/ColorModeConverterTest.php +++ b/tests/Mpdf/Color/ColorModeConverterTest.php @@ -128,6 +128,62 @@ public function rgb2cmykProvider() ]; } + /** + * A neutral colour is the black ink alone, black included: painting black as all four inks at once + * is 400% total area coverage, past what any press allows, and a jump away from every colour near it + * + * @dataProvider blackProvider + * + * @param int $grey The red, green and blue of a neutral colour + * @param float $black The percentage of black ink it is made of + */ + public function testANeutralColourIsTheBlackInkAlone($grey, $black) + { + $cmyk = $this->converter->rgb2cmyk([ColorConverter::MODE_RGB, $grey, $grey, $grey]); + + $this->assertSame(ColorConverter::MODE_CMYK, $cmyk[0]); + $this->assertEqualsWithDelta([0, 0, 0], [$cmyk[1], $cmyk[2], $cmyk[3]], 0.001, 'no cyan, magenta or yellow'); + $this->assertEqualsWithDelta($black, $cmyk[4], 0.05); + } + + /** + * @return array[] Neutral colours from black upwards, and the black ink each is made of + */ + public function blackProvider() + { + return [ + 'black' => [0, 100.0], + 'all but black' => [1, 99.6], + 'near black' => [8, 96.9], + 'mid grey' => [128, 49.8], + ]; + } + + /** + * Black is continuous with the colours beside it, rather than jumping from one ink to four + */ + public function testBlackIsContinuousWithTheColoursBesideIt() + { + $black = $this->converter->rgb2cmyk([ColorConverter::MODE_RGB, 0, 0, 0]); + $next = $this->converter->rgb2cmyk([ColorConverter::MODE_RGB, 1, 1, 1]); + + $this->assertEqualsWithDelta($next[1], $black[1], 0.5); + $this->assertEqualsWithDelta($next[2], $black[2], 0.5); + $this->assertEqualsWithDelta($next[3], $black[3], 0.5); + $this->assertEqualsWithDelta($next[4], $black[4], 0.5); + } + + /** + * Black with transparency keeps its alpha, and is the black ink alone + */ + public function testBlackWithTransparencyIsTheBlackInkAlone() + { + $this->assertSame( + [ColorConverter::MODE_CMYKA, 0, 0, 0, 100, 50], + $this->converter->rgb2cmyk([ColorConverter::MODE_RGBA, 0, 0, 0, 50]) + ); + } + /** * @dataProvider cmyk2rgbProvider * diff --git a/tests/Mpdf/Image/CmykConversionTest.php b/tests/Mpdf/Image/CmykConversionTest.php index 415ade3f6..09febf0fd 100644 --- a/tests/Mpdf/Image/CmykConversionTest.php +++ b/tests/Mpdf/Image/CmykConversionTest.php @@ -41,7 +41,7 @@ public function testSampleIsRounded($rgb, $cmyk) public function colours() { return [ - 'black' => [[0, 0, 0], 'ffffffff'], + 'black' => [[0, 0, 0], '000000ff'], 'red' => [[255, 0, 0], '00ffff00'], 'white' => [[255, 255, 255], '00000000'], ]; From 86a5cea73e5f94aacb92daf6b9a0dbc75bd431c5 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 24 Sep 2026 12:31:38 +1000 Subject: [PATCH 2/4] Write PDF/X-4, keeping the transparency, layers and colour PDF/X-1a strips, and make it PDFX's default PDFX is now a switch, as PDFA is, and PDFXversion names the version: '4' (the default) or '1a' for PDF/X-1a:2003, which PDFX => true gave before. Under PDF/X-4 mPDF writes PDF 1.6 and the pdfxid XMP schema, and keeps opacity, watermarks, rgba(), PNG alpha, layers and colour fonts. The output intent defaults to the bundled SWOP2006_Coated3v2 profile, identified as CGATS TR 003, and a PDF/X ICCProfile must be a printer (prtr) profile, as ISO 15930-7 asks. Colour follows the intent: RGB is written in an ICC-based sRGB space where the intent is not RGB, neutral RGB goes to DeviceGray under a CMYK intent, grey to an ICC-based grey space under an RGB one, and CMYK to RGB where the intent is not CMYK. A gradient's stops are written in the one colour space they all fit. Across PDF/X versions: JavaScript and attachments, SetAssociatedFiles() among them, are removed; active forms are drawn on the page under PDFXauto; links and annotations in the printed area are removed, and one kept outside it takes its colour in the intent's device space; an untitled document is titled after its file, or refused; a symbolic TrueType subset carries one cmap subtable; a page imported from a later PDF keeps the PDF/X version; and a greyscale PNG with alpha is drawn in PDF/X-1a rather than dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- composer.json | 1 + data/iccprofiles/Gray_sRGB_TRC.icc | Bin 0 -> 2440 bytes data/iccprofiles/NOTICE.md | 18 +- phpstan-baseline.neon | 12 - src/Color/ColorConverter.php | 57 + src/Color/ColorSpaceRestrictor.php | 52 +- src/Config/ConfigVariables.php | 16 +- src/Fonts/Color/ColorFormats.php | 5 +- src/Fonts/Color/FillsInColour.php | 2 +- src/Fonts/Color/GlyphResources.php | 15 + src/Fonts/Color/SvgRenderer.php | 4 +- src/Form.php | 16 +- src/FpdiTrait.php | 14 +- src/Gradient.php | 17 +- src/Image/Bmp.php | 2 +- src/Image/ImageProcessor.php | 12 +- src/Image/Svg.php | 56 +- src/Mpdf.php | 367 +++- src/Pdf/DocumentProfile.php | 13 +- src/ServiceFactory.php | 4 +- src/Tag/Input.php | 14 +- src/Tag/Select.php | 4 +- src/Tag/TextArea.php | 6 +- src/Writer/BackgroundWriter.php | 36 +- src/Writer/BaseWriter.php | 104 ++ src/Writer/ColorWriter.php | 30 +- src/Writer/FontWriter.php | 15 +- src/Writer/FormWriter.php | 5 +- src/Writer/ImageWriter.php | 29 +- src/Writer/MetadataWriter.php | 141 +- src/Writer/PageWriter.php | 95 +- src/Writer/ResourceWriter.php | 12 +- src/Writer/Type3FontWriter.php | 75 +- tests/Mpdf/AutoVisibilityTest.php | 4 +- tests/Mpdf/Color/ColorSpaceRestrictorTest.php | 2 +- tests/Mpdf/Color/GrayIccProfile.php | 108 ++ tests/Mpdf/Color/GrayIccProfileTest.php | 131 ++ tests/Mpdf/ColorFontTest.php | 6 +- tests/Mpdf/DeviceGrayMpdf.php | 21 + tests/Mpdf/Fonts/Color/RecordingResources.php | 16 + tests/Mpdf/FormAppearanceTest.php | 2 +- tests/Mpdf/Image/SvgColorAlphaTest.php | 2 +- tests/Mpdf/PDFA1Test.php | 2 +- tests/Mpdf/PDFA2Test.php | 5 +- tests/Mpdf/Pdf/DocumentProfileTest.php | 3 +- tests/Mpdf/PdfHeaderVersionTest.php | 2 +- tests/Mpdf/PdfX4Test.php | 1611 +++++++++++++++++ tests/Mpdf/PdfaOptionalContentTest.php | 2 +- tests/Mpdf/StaticListBoxTest.php | 2 +- .../Mpdf/Writer/CrossReferenceWriterTest.php | 2 +- .../PdfX4FlattenedFormSnapshotTest.php | 121 ++ .../Snapshots/PdfX4RgbAndGreySnapshotTest.php | 88 + tests/data/icc/README.md | 8 + tests/data/icc/rgb-printer-header.icc | Bin 0 -> 132 bytes tests/data/img/grey-gradient.jpg | Bin 0 -> 368 bytes tests/data/img/grey-gradient.png | Bin 0 -> 129 bytes tests/data/snapshots/pdfx4-flattened-form.pdf | Bin 0 -> 95181 bytes tests/data/snapshots/pdfx4-rgb-and-grey.pdf | Bin 0 -> 103453 bytes utils/grayprofile_update.php | 18 + 59 files changed, 3196 insertions(+), 209 deletions(-) create mode 100644 data/iccprofiles/Gray_sRGB_TRC.icc create mode 100644 tests/Mpdf/Color/GrayIccProfile.php create mode 100644 tests/Mpdf/Color/GrayIccProfileTest.php create mode 100644 tests/Mpdf/DeviceGrayMpdf.php create mode 100644 tests/Mpdf/PdfX4Test.php create mode 100644 tests/Snapshots/PdfX4FlattenedFormSnapshotTest.php create mode 100644 tests/Snapshots/PdfX4RgbAndGreySnapshotTest.php create mode 100644 tests/data/icc/README.md create mode 100644 tests/data/icc/rgb-printer-header.icc create mode 100644 tests/data/img/grey-gradient.jpg create mode 100644 tests/data/img/grey-gradient.png create mode 100644 tests/data/snapshots/pdfx4-flattened-form.pdf create mode 100644 tests/data/snapshots/pdfx4-rgb-and-grey.pdf create mode 100644 utils/grayprofile_update.php diff --git a/composer.json b/composer.json index b7293cda3..e4a1f8097 100644 --- a/composer.json +++ b/composer.json @@ -87,6 +87,7 @@ "arabicjoining:update": "@php utils/arabicjoining_update.php", "emoji:update": "@php utils/emoji_update.php", "fontcache:update": "@php utils/fontcache_update.php", + "grayprofile:update": "@php utils/grayprofile_update.php", "otlanguages:update": "@php utils/otlanguages_update.php", "otldump:update": "@php utils/otldump_update.php", "shaping:update": "@php utils/shaping_update.php", diff --git a/data/iccprofiles/Gray_sRGB_TRC.icc b/data/iccprofiles/Gray_sRGB_TRC.icc new file mode 100644 index 0000000000000000000000000000000000000000..3cf6415912af8469cafe29b268abbd3df649fb8d GIT binary patch literal 2440 zcmb_dXHb*d7Cqnhr58#PLWfX83rz?Cq?Zti6zQUZA#@^yj@S@U0YwoJ0Ywn03O0(^ z5ygTiC}6)qE&|F`yo#u(Jbb>H_v^l2Z|yl}uUUJ|oHhH;0RX=fECH4RAW@hq;`%v+ z1c!t&ux|jgRR2ZVb6Vm#0N_af@h{0hnJbFOGGL_maUB_{>xBXaKTVV_VCW?JIJ@fpGWQ0{~Yb6o?{H1(A$(S&UyVF``846B#@gCkETZlFeqBvP`TQTpv%Csi84zy)YpQ zfL}ZPo#nr~`jz#!hWgdefB+-`4afr(paFCM3zz~6U=8en3vdVCz#oKwHGmIdKq3%< z43Go1f_zX2_JRYT0#t)K&;(k+8PEwXf@|O=xCSi$UcnAwC$L{| zM4T$l6z7Ty#R+k_xH4Qb?lNu&H;G%oOX0QfR(NlGBt8pYjIYCYohWU@Bdo*YU}CGR0Ok*||qlD|k$B@88a5|I*{B`PI4B!(p3N#Z57BpoEzNM=ix zNuHIwFF7NHm(rGUl8TVpBvmPOUTRcoPMRuhBJC}mC|xAoEPY#gih`wRQ(P#Klsw8Y z%5};(6`^WSov3{3HfkO926cjlp)qMZS{$vAc9J$go0Xx+n8^gnWXe>^T#^yf5xO>= zM^B&^)6dXH=-*_OWgTP%vb$tkWrt+vC%7C>>MkSDI5+QRXTqDIZe4t~{ec zSFu-#Qz=!stTL@iRkc-(RV`J$qWXp*!*F0EFv=Odj9E1$H8-_1wHmbnwFPyidVqSa zdYk$S4T1(oBTA!Gqeo*_Q&rPTGe@&U^QjhIi=!2zbx^BMYhIhF9i+WOyHk6LDaYh7 zvzaZ-=Q?B^JDp^mTAfF_7+sETyzXJ$2YRrcxn7K3rQUsgSf8yQtAAL3&;V^LIV{_vK<2vJKCKMBQlU$Q-lfO;% zO!=mjrjM49m$@$6ysT^42Qz&$f!PtW$L3UXFY_JdJ?1~y7Hko_l|5~tX%TKwWiiU3 zaeO#MoLiP?OGnGimKQC*TCuHCtw?_}k)(dnu);>>j}a2{}> zxCFRVxV&^#ca3&!ah>Iwb2o6Wx}n`X-1fPR@|1aeUK4ND-NJpNd#?w{W2Hxh$16`g zPmyQ07wqNkwcqRc3au5w6`kI|o9DgX`-KnFC)wwMFUHr$x6=2upNU_NU*AgV%C#$- zSAO<)^e^^*7N8T57SIzY85kDW6gVH`98?k{4mJwj7<@NGAtW}WD-;_V7+N1Xx5{PJ zfmIV>=3)6^BdeLKGgseUqp&7n&84-HYa`a44M&Fug*S(PkMNGDjhI`r>CkN1vmh+j+y zN@z>OC59)S7gB`r!rt{N>oeC6Cg~^TCyA4-k`E-$iabR1DNxGll&)0T(r474rjxck zZ7kh3y)ykvMqtL-OzBKvW`CAm)~>9{Y}f4L8&Dhg8+vloa<=A-ZFJaJvkBU?Zd1=@ zjm>$RC$@06G;Sqqjo*4N*EqK%_tUnZZQXgwd0X@<(@A@2K91+8MiZ zVArx;hj#reh%C5MXk1uU_`Qfd!qIX6tjzu?8WRA?j6}@x3958x+J?~ ze81QJj#5TxLFuOhYY*HmGb^h;NH~~wP+aa=-gQXx(B4DeD`F~!D;+D_s+6jBRm~rc zJUn#7{zz-Ja&=+#x1+H~AJ=eeI%>6R%Z_1=r5}4;7g*PKoO8UXUZK9Q{zrqbLEPxq z*xO{$)YPohT-*Y;q_s?+2s`oMr1QzHR#t0G8@;XY6gZW3>doo!(<5g*&h(t+oNYa) zb*`$N+FsBBb!2tScE)syyF$7K&%2-R>9+1}zhHQw;iB5b%1bhriZ2r`=UraBl6__F zYSPu2Yth%nd)D?myB>V~QLk_BzzvTZw{E)Lyx!-~cjcDNt&6vPf`DwCnVPH>=)EyiIz$Fq8k6^j}r)bl#nNZ~wl3Hh6a8gXqKJ-$ftg zKQ??a`_wb%Gxzdy;^&3=f-mx48ozSB_I(TbHo1`T9s9lfhwhK=pB_J-FD5Q7{tHT# BY}o(+ literal 0 HcmV?d00001 diff --git a/data/iccprofiles/NOTICE.md b/data/iccprofiles/NOTICE.md index efa9a963f..696132af2 100644 --- a/data/iccprofiles/NOTICE.md +++ b/data/iccprofiles/NOTICE.md @@ -6,15 +6,19 @@ licence — they are aggregated data assets, not part of mPDF's source code, and are redistributed here under the terms their publishers grant. Do not alter these files; ship them byte-for-byte. -To use a different output condition (e.g. your house print profile), set the -`ICCProfile` config key to the path of your own `.icc` file — it overrides the -bundled default and nothing here is embedded. +To use a different output condition (e.g. your house print profile, or an RGB +output intent for PDF/X-4), set the `ICCProfile` config key to the path of your +own `.icc` file — it overrides the bundled default. A PDF/X output intent must be +an output device (`prtr`) profile. --- ## sRGB_IEC61966-2-1.icc -- **Colour space:** RGB (used as the PDF/A and default output-intent profile). +- **Colour space:** RGB, device class `mntr` (display). Used as the PDF/A + output-intent profile. Also the ICC-based colour space RGB is written in where + a PDF/X-4 document prints to a CMYK or grey output intent, which permits no + DeviceRGB. Being a display profile, it is not a PDF/X output intent. - **Publisher / copyright:** International Color Consortium. - **Embedded copyright string:** `Copyright International Color Consortium, 2009`. - **Terms:** Distributed by the ICC as a freely redistributable reference @@ -24,8 +28,10 @@ bundled default and nothing here is embedded. ## SWOP2006_Coated3v2.icc - **Colour space:** CMYK, device class `prtr` (printer), ICC v2. - A CMYK output intent: point `ICCProfile` at it, with `restrictColorSpace` set - to 3, to write a CMYK PDF/A document. Nothing embeds it unless asked. + The output intent a **PDF/X-4** document embeds where it names no + `ICCProfile`, identified by the CGATS TR 003 characterisation it is built on. + Point `ICCProfile` at it, with `restrictColorSpace` set to 3, to write a CMYK + PDF/A document. Represents U.S. web-coated (SWOP) publication printing, 2006, grade 3 coated. - **Publisher:** IDEAlliance, with permission of X-Rite, Inc. - **Copyright:** X-Rite, Inc. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a52cdef35..0f02ff884 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -150,12 +150,6 @@ parameters: count: 2 path: src/Image/ImageProcessor.php - - - message: '#^Variable \$info might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Image/ImageProcessor.php - - message: '#^Binary operation "\*" between 0\.3333333333333333 and string results in an error\.$#' identifier: binaryOp.invalid @@ -216,12 +210,6 @@ parameters: count: 1 path: src/Image/Svg.php - - - message: '#^Variable \$color_final might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Image/Svg.php - - message: '#^Variable \$inners might not be defined\.$#' identifier: variable.undefined diff --git a/src/Color/ColorConverter.php b/src/Color/ColorConverter.php index 6505c618a..4cde64256 100644 --- a/src/Color/ColorConverter.php +++ b/src/Color/ColorConverter.php @@ -203,6 +203,63 @@ public function colAtoString($c) return ''; } + /** + * A shading function gives every stop of a gradient the same number of components, so the stops are + * written in one colour space: grey where every stop is grey, CMYK where any is CMYK, keeping the + * inks the document names, and RGB otherwise + * + * @param string[] $colors Binary color strings + * + * @return string 'Gray', 'RGB' or 'CMYK' + */ + public function gradientColorSpace(array $colors) + { + $space = 'Gray'; + + foreach ($colors as $c) { + if ($c[0] == static::MODE_CMYK || $c[0] == static::MODE_CMYKA) { + return 'CMYK'; + } + + if ($c[0] != static::MODE_GRAYSCALE) { + $space = 'RGB'; + } + } + + return $space; + } + + /** + * @param string $c Binary color string + * @param string $space The colour space gradientColorSpace() settled on for the stops + * + * @return string The colour's components in that space, as a shading function writes them + */ + public function gradientComponents($c, $space) + { + if ($c[0] == static::MODE_CMYK || $c[0] == static::MODE_CMYKA) { + return sprintf('%.3F %.3F %.3F %.3F', ord($c[1]) / 100, ord($c[2]) / 100, ord($c[3]) / 100, ord($c[4]) / 100); + } + + if ($c[0] == static::MODE_GRAYSCALE) { + $gray = ord($c[1]) / 255; + + if ($space === 'CMYK') { + return sprintf('0.000 0.000 0.000 %.3F', 1 - $gray); + } + + return $space === 'RGB' ? sprintf('%.3F %.3F %.3F', $gray, $gray, $gray) : sprintf('%.3F', $gray); + } + + if ($space === 'CMYK') { + $cmyk = $this->colorModeConverter->rgb2cmyk([static::MODE_RGB, ord($c[1]), ord($c[2]), ord($c[3])]); + + return sprintf('%.3F %.3F %.3F %.3F', $cmyk[1] / 100, $cmyk[2] / 100, $cmyk[3] / 100, $cmyk[4] / 100); + } + + return sprintf('%.3F %.3F %.3F', ord($c[1]) / 255, ord($c[2]) / 255, ord($c[3]) / 255); + } + /** * @param string $color * @param string[] $PDFAXwarnings diff --git a/src/Color/ColorSpaceRestrictor.php b/src/Color/ColorSpaceRestrictor.php index 46289b568..ca86ebbd4 100644 --- a/src/Color/ColorSpaceRestrictor.php +++ b/src/Color/ColorSpaceRestrictor.php @@ -113,7 +113,20 @@ private function restrictSpotColorSpace($c, &$PDFAXwarnings = []) */ private function restrictRgbColorSpace($c, $color, &$PDFAXwarnings = []) { - if ($this->mpdf->PDFX || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace == 3)) { + // PDF/X-4 keeps RGB as it is: printing to an RGB condition it is DeviceRGB, and printing to any + // other it is written in the ICC-based sRGB colour space, so that the press converts the colours + // of the content and of the images together - see Mpdf::writesCalibratedRgb() + if ($this->mpdf->isPdfx4()) { + if ($this->mpdf->pdfxRgbIntent()) { + return $c; + } + + $gray = $this->neutralToGray($c); + + return $gray === null ? $c : $gray; + } + + if (($this->mpdf->PDFX && !$this->mpdf->pdfxRgbIntent()) || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace == 3)) { if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { $PDFAXwarnings[] = "RGB color specified '" . $color . "' (converted to CMYK)"; } @@ -136,8 +149,8 @@ private function restrictRgbColorSpace($c, $color, &$PDFAXwarnings = []) */ private function restrictCmykColorSpace($c, $color, &$PDFAXwarnings = []) { - if ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace != 3) { - if ($this->mpdf->PDFA && !$this->mpdf->PDFAauto) { + if (($this->mpdf->PDFA && $this->mpdf->restrictColorSpace != 3) || $this->mpdf->pdfxConvertsCmyk()) { + if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { $PDFAXwarnings[] = "CMYK color specified '" . $color . "' (converted to RGB)"; } $c = $this->colorModeConverter->cmyk2rgb($c); @@ -159,6 +172,11 @@ private function restrictCmykColorSpace($c, $color, &$PDFAXwarnings = []) */ private function restrictRgbaColorSpace($c, $color, &$PDFAXwarnings = []) { + // PDF/X-4 keeps the transparency, which the colour space it settles on carries + if ($this->mpdf->isPdfx4()) { + return $this->restrictRgbColorSpace($c, $color, $PDFAXwarnings); + } + if ($this->mpdf->PDFX || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace == 3)) { if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { $PDFAXwarnings[] = "RGB color with transparency specified '" . $color . "' (converted to CMYK" . ($this->mpdf->transparencyAllowed() ? ')' : ' without transparency)'); @@ -179,6 +197,30 @@ private function restrictRgbaColorSpace($c, $color, &$PDFAXwarnings = []) return $c; } + /** + * Tagging black sRGB would have the press make it out of all four inks, which fringes text where the + * plates are a hair out of register. A neutral colour goes to DeviceGray instead, which ISO 15930-7 + * permits where the output condition is CMYK or grey, and which such a condition takes as its black + * separation. A colour that is neutral but translucent keeps its RGB, since DeviceGray carries no + * alpha for mPDF to put it back into. + * + * @param float[] $c A colour in RGB or RGBA + * + * @return float[]|null That colour in DeviceGray, or null where it is not neutral and opaque + */ + private function neutralToGray($c) + { + if ($c[1] != $c[2] || $c[2] != $c[3]) { + return null; + } + + if ($c[0] == 5 && $c[4] < 100) { + return null; + } + + return [1, $c[1]]; + } + /** * @param mixed $c * @param string $color @@ -188,6 +230,10 @@ private function restrictRgbaColorSpace($c, $color, &$PDFAXwarnings = []) */ private function restrictCmykaColorSpace($c, $color, &$PDFAXwarnings = []) { + if ($this->mpdf->isPdfx4()) { + return $this->restrictCmykColorSpace($c, $color, $PDFAXwarnings); + } + if ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace != 3) { if (!$this->mpdf->PDFAauto) { $PDFAXwarnings[] = "CMYK color with transparency specified '" . $color . "' (converted to RGB" . ($this->mpdf->transparencyAllowed() ? ')' : ' without transparency)'); diff --git a/src/Config/ConfigVariables.php b/src/Config/ConfigVariables.php index 9e7c34cda..c2087bbd0 100644 --- a/src/Config/ConfigVariables.php +++ b/src/Config/ConfigVariables.php @@ -154,12 +154,19 @@ public function __construct() // 3 - allow CMYK / SPOT COLOR / Grayscale [convert RGB->CMYK] 'restrictColorSpace' => 0, - // PDFX/1-a Compliant files - // true=Forces compliance with PDFX-1a spec + // PDF/X Compliant files + // true=Forces compliance with the PDF/X version PDFXversion names // Cannot be used with 'restrictColorSpace' (i.e. no RGB) 'PDFX' => false, - // Overrides warnings making changes when possible to force PDFX1-a compliance + // Overrides warnings making changes when possible to force PDF/X compliance 'PDFXauto' => false, + // The PDF/X version to write + // 4 PDF/X-4 (ISO 15930-7, PDF 1.6). The general choice for print, and the version print workflows + // and the Ghent Workgroup specifications expect. Keeps transparency and layers, and prints to + // the CMYK, RGB or grey condition ICCProfile names (SWOP where it names none) + // 1a PDF/X-1a:2003 (ISO 15930-4, PDF 1.4). For printers and publications that accept only it: + // CMYK and spot colour only, and translucent content is drawn opaque + 'PDFXversion' => '4', // PDF/A Compliant files // true=Forces compliance with the PDF/A part and level PDFAversion names @@ -183,7 +190,8 @@ public function __construct() // Colour profile OutputIntent // sRGB_IEC61966-2-1 (=default if blank and PDFA), or other added .icc profile - // Must be CMYK for PDFX, or appropriate type for PDFA(RGB or CMYK) + // Must be CMYK for PDF/X-1a, or appropriate type for PDFA(RGB or CMYK) or PDF/X-4 + // PDF/X-4 embeds SWOP2006_Coated3v2 (CMYK) where blank. PDF/X takes a printer (prtr) profile only 'ICCProfile' => '', 'spotColors' => [], diff --git a/src/Fonts/Color/ColorFormats.php b/src/Fonts/Color/ColorFormats.php index ee40dc30a..fd1bbd977 100644 --- a/src/Fonts/Color/ColorFormats.php +++ b/src/Fonts/Color/ColorFormats.php @@ -42,7 +42,8 @@ public static function drawable(array $fontFormats) /** * Whether a document may draw colour fonts in colour: not where PDF/A-1b or PDF/X-1a forbid the * transparency every colour format is drawn with, nor where restrictColorSpace holds the document to - * colours an RGB bitmap is not. + * colours an RGB bitmap is not. PDF/X-4 allows the transparency, and its RGB is written calibrated + * where the output intent is not RGB - see Type3FontWriter. * * Asked whenever it matters rather than once, since each of those settings can be changed on the * document after the fonts it starts with are added. @@ -53,7 +54,7 @@ public static function drawable(array $fontFormats) */ public static function inColor(Mpdf $mpdf) { - return !$mpdf->PDFA && !$mpdf->PDFX && !$mpdf->restrictColorSpace; + return !$mpdf->PDFA && !$mpdf->isPdfx1a() && !$mpdf->restrictColorSpace; } /** diff --git a/src/Fonts/Color/FillsInColour.php b/src/Fonts/Color/FillsInColour.php index 61b5c6d07..ef6a213cb 100644 --- a/src/Fonts/Color/FillsInColour.php +++ b/src/Fonts/Color/FillsInColour.php @@ -29,7 +29,7 @@ private function filled(array $colour, $fill, GlyphResources $resources) $operators[] = $resources->alpha($alpha); } if ($rgb !== null) { - $operators[] = vsprintf('%.3F %.3F %.3F rg', $rgb); + $operators[] = $resources->rgb($rgb); } return $operators ? 'q ' . implode(' ', $operators) . "\n" . $fill . "\nQ\n" : $fill . "\n"; diff --git a/src/Fonts/Color/GlyphResources.php b/src/Fonts/Color/GlyphResources.php index ed1627ba9..a1f91d51d 100644 --- a/src/Fonts/Color/GlyphResources.php +++ b/src/Fonts/Color/GlyphResources.php @@ -21,6 +21,21 @@ interface GlyphResources */ public function image($data); + /** + * @param float[] $rgb [red, green, blue], from 0 to 1 + * @param bool $stroking Whether it is the colour strokes are painted in, rather than fills + * + * @return string Content setting that colour, e.g. '1.000 0.800 0.200 rg' + */ + public function rgb(array $rgb, $stroking = false); + + /** + * @param float $level From 0, black, to 1 + * + * @return string Content setting that grey as the fill colour, e.g. '0 g' + */ + public function gray($level); + /** * @param float $opacity From 0, transparent, to 1 * diff --git a/src/Fonts/Color/SvgRenderer.php b/src/Fonts/Color/SvgRenderer.php index a769b5279..cb61057e6 100644 --- a/src/Fonts/Color/SvgRenderer.php +++ b/src/Fonts/Color/SvgRenderer.php @@ -370,7 +370,7 @@ private function stroke($outline, array $style, $opacity) $set[] = sprintf('[%s] %s d', Geometry::numbers($dashes), Geometry::number($this->length($style['stroke-dashoffset'], 0))); } if ($rgb !== null) { - $set[] = vsprintf('%.3F %.3F %.3F RG', $rgb); + $set[] = $this->resources->rgb($rgb, true); } return 'q ' . implode(' ', $set) . "\n" . $outline . "S\nQ\n"; @@ -636,7 +636,7 @@ private function clip($url, DOMElement $element, array $matrix, $depth) return null; } - $mask = "0 g\n"; + $mask = $this->resources->gray(0) . "\n"; foreach ($pieces as $piece) { $mask .= $piece[0] . ($piece[1] ? 'f*' : 'f') . "\n"; } diff --git a/src/Form.php b/src/Form.php index 4a3db140d..50b9d9f3a 100644 --- a/src/Form.php +++ b/src/Form.php @@ -173,7 +173,7 @@ public function __construct(Mpdf $mpdf, Otl $otl, ColorConverter $colorConverter function print_ob_text($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) { // TEXT/PASSWORD INPUT - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; @@ -251,7 +251,7 @@ function print_ob_text($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) function print_ob_textarea($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) { // TEXTAREA - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = [self::FLAG_TEXTAREA]; @@ -352,7 +352,7 @@ function print_ob_select($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) // As in HTML, a select is a drop-down unless it is multiple or given a size of two or more rows $multiple = !empty($objattr['multiple']); $combo = !$multiple && (!isset($objattr['size']) || $objattr['size'] < 2); - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; if (!empty($objattr['disabled'])) { $flags[] = self::FLAG_READONLY; @@ -439,7 +439,7 @@ function print_ob_select($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) $save_currentfont = $this->mpdf->currentfontfamily; if ($this->mpdf->PDFA || $this->mpdf->PDFX) { if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { - $this->mpdf->PDFAXwarnings[] = 'Core Adobe font Zapfdingbats cannot be embedded in mPDF - used in Form element: Select - which is required for PDFA1-b or PDFX/1-a. (Different character/font will be substituted.)'; + $this->mpdf->PDFAXwarnings[] = 'Core Adobe font Zapfdingbats cannot be embedded in mPDF - used in Form element: Select - which is required for PDFA1-b or ' . $this->mpdf->pdfxVersionLabel() . '. (Different character/font will be substituted.)'; } $this->mpdf->SetFont('sans'); if ($this->mpdf->_charDefined($this->mpdf->CurrentFont['cw'], 9660)) { @@ -530,7 +530,7 @@ private function printListBox($objattr, $w, $h, $rtlalign, $k) function print_ob_imageinput($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir, $is_table) { // INPUT/BUTTON as IMAGE - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; if (!empty($objattr['disabled'])) { $flags[] = self::FLAG_READONLY; @@ -554,7 +554,7 @@ function print_ob_imageinput($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir, function print_ob_button($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) { // BUTTON - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; if (!empty($objattr['disabled'])) { $flags[] = self::FLAG_READONLY; @@ -626,7 +626,7 @@ function print_ob_button($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir) function print_ob_checkbox($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir, $x, $y) { // CHECKBOX - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; if (!empty($objattr['disabled'])) { $flags[] = self::FLAG_READONLY; @@ -669,7 +669,7 @@ function print_ob_checkbox($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir, $ function print_ob_radio($objattr, $w, $h, $texto, $rtlalign, $k, $blockdir, $x, $y) { // RADIO - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $flags = []; if (!empty($objattr['disabled'])) { $flags[] = self::FLAG_READONLY; diff --git a/src/FpdiTrait.php b/src/FpdiTrait.php index 645711f20..9685d9655 100644 --- a/src/FpdiTrait.php +++ b/src/FpdiTrait.php @@ -102,13 +102,23 @@ protected function setPageFormat($format, $orientation) /** * Set the minimal PDF version. * - * Not under PDF/A or PDF/X, which keep the version their standard is built on. + * Not under PDF/A, which keeps the version its standard is built on. PDF/X-1a:2003 is written as PDF 1.4 and + * PDF/X-4 as PDF 1.6, and neither may say more, so a page imported from a later version leaves the version + * where it is. * * @param string $pdfVersion */ protected function setMinPdfVersion($pdfVersion) { - if (!$this->PDFA && !$this->PDFX && \version_compare($pdfVersion, $this->pdf_version, '>')) { + if ($this->PDFX) { + $ceiling = $this->isPdfx4() ? '1.6' : '1.4'; + if (\version_compare($pdfVersion, $ceiling, '>')) { + $this->pdfaxWarning(sprintf('A page imported from a PDF %s file may use more than the PDF %s that %s is written as.', $pdfVersion, $ceiling, $this->pdfxVersionLabel())); + $pdfVersion = $ceiling; + } + } + + if (!$this->PDFA && \version_compare($pdfVersion, $this->pdf_version, '>')) { $this->pdf_version = $pdfVersion; } } diff --git a/src/Gradient.php b/src/Gradient.php index 0f66b02cb..8f5c4ffc9 100644 --- a/src/Gradient.php +++ b/src/Gradient.php @@ -165,6 +165,7 @@ private function percentToByte($percent) // (fx, fy) should be inside the circle, otherwise some areas will not be defined // $col = array(R,G,B/255); or array(G/255); or array(C,M,Y,K/100) // $stops = array('col'=>$col [, 'opacity'=>0-1] [, 'offset'=>0-1]) + // $colorspace is kept for callers, but the stops settle the colour space - see ColorConverter::gradientColorSpace() public function Gradient($x, $y, $w, $h, $type, $stops = [], $colorspace = 'RGB', $coords = '', $extend = '', $return = false, $is_mask = false) { if ($type && stripos($type, 'L') === 0) { @@ -173,9 +174,10 @@ public function Gradient($x, $y, $w, $h, $type, $stops = [], $colorspace = 'RGB' $type = self::TYPE_RADIAL; } - if ($colorspace !== 'CMYK' && $colorspace !== 'Gray') { - $colorspace = 'RGB'; - } + // The stops settle the colour space, which a parser names from one stop alone + $colorspace = $this->colorConverter->gradientColorSpace(array_map(function ($stop) { + return $stop['col']; + }, $stops)); $bboxw = $w; $bboxh = $h; @@ -545,14 +547,7 @@ public function Gradient($x, $y, $w, $h, $type, $stops = [], $colorspace = 'RGB' } for ($i = 0; $i < count($stops); $i++) { - // mPDF 5.3.74 - if ($colorspace === 'CMYK') { - $this->mpdf->gradients[$n]['stops'][$i]['col'] = sprintf('%.3F %.3F %.3F %.3F', ord($stops[$i]['col'][1]) / 100, ord($stops[$i]['col'][2]) / 100, ord($stops[$i]['col'][3]) / 100, ord($stops[$i]['col'][4]) / 100); - } elseif ($colorspace === 'Gray') { - $this->mpdf->gradients[$n]['stops'][$i]['col'] = sprintf('%.3F', ord($stops[$i]['col'][1]) / 255); - } else { - $this->mpdf->gradients[$n]['stops'][$i]['col'] = sprintf('%.3F %.3F %.3F', ord($stops[$i]['col'][1]) / 255, ord($stops[$i]['col'][2]) / 255, ord($stops[$i]['col'][3]) / 255); - } + $this->mpdf->gradients[$n]['stops'][$i]['col'] = $this->colorConverter->gradientComponents($stops[$i]['col'], $colorspace); if (!isset($stops[$i]['opacity'])) { $stops[$i]['opacity'] = 1; } elseif ($stops[$i]['opacity'] > 1 || $stops[$i]['opacity'] < 0) { diff --git a/src/Image/Bmp.php b/src/Image/Bmp.php index 8df9c342e..31b570523 100644 --- a/src/Image/Bmp.php +++ b/src/Image/Bmp.php @@ -47,7 +47,7 @@ public function _getBMPimage($data, $file) $info['bpc'] = 8; } - if ($this->mpdf->restrictColorSpace == 1 || $this->mpdf->PDFX || $this->mpdf->restrictColorSpace == 3) { + if ($this->mpdf->restrictColorSpace == 1 || $this->mpdf->isPdfx1a() || $this->mpdf->restrictColorSpace == 3) { if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { $this->mpdf->PDFAXwarnings[] = "Image cannot be converted to suitable colour space for PDFA or PDFX file - $file - (Image replaced by 'no-image'.)"; } diff --git a/src/Image/ImageProcessor.php b/src/Image/ImageProcessor.php index f1c7ef464..4c0513762 100644 --- a/src/Image/ImageProcessor.php +++ b/src/Image/ImageProcessor.php @@ -1077,14 +1077,14 @@ public function processJpg($data, $file, $firstTime, $interpolation) $channels = (int) $a[4]; - if ($a[2] === 'DeviceCMYK' && ($this->mpdf->restrictColorSpace === 2 || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace !== 3))) { + if ($a[2] === 'DeviceCMYK' && ($this->mpdf->restrictColorSpace === 2 || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace !== 3) || $this->mpdf->pdfxConvertsCmyk())) { // convert to RGB image if (!function_exists('gd_info')) { throw new \Mpdf\MpdfException(sprintf('JPG image may not use CMYK color space (%s).', $file)); } - if ($this->mpdf->PDFA && !$this->mpdf->PDFAauto) { + if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { $this->mpdf->PDFAXwarnings[] = sprintf('JPG image "%s" may not use CMYK color space. Image converted to RGB. The colour profile was altered', $file); } @@ -1120,7 +1120,7 @@ public function processJpg($data, $file, $firstTime, $interpolation) return $this->imageError($file, $firstTime, 'Error creating GD image file from JPG(CMYK) image'); } - if ($a[2] === 'DeviceRGB' && ($this->mpdf->PDFX || $this->mpdf->restrictColorSpace === 3)) { + if ($a[2] === 'DeviceRGB' && ($this->mpdf->isPdfx1a() || $this->mpdf->restrictColorSpace === 3)) { // Convert to CMYK image stream - nominally returned as type='png' $info = $this->convertImage($data, $a[2], 'DeviceCMYK', $a[0], $a[1], $ppUx, false); if (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto)) { @@ -1433,7 +1433,7 @@ public function processPng($data, $file, $firstTime, $interpolation) //$j = strpos($data,'cHRM'); // Chromaticity and Whitepoint // $firstTime added mPDF 6 so when PNG Grayscale with alpha using resrtictcolorspace to CMYK // the alpha channel is sent through as secondtime as Indexed and should not be converted to CMYK - if ($firstTime && ($colspace === 'DeviceRGB' || $colspace === 'Indexed') && ($this->mpdf->PDFX || $this->mpdf->restrictColorSpace === 3)) { + if ($firstTime && ($colspace === 'DeviceRGB' || $colspace === 'Indexed') && ($this->mpdf->isPdfx1a() || $this->mpdf->restrictColorSpace === 3)) { // Convert to CMYK image stream - nominally returned as type='png' $info = $this->convertImage($data, $colspace, 'DeviceCMYK', $w, $h, $ppUx, $pngalpha, $gamma, $ct); // mPDF 5.7.2 Gamma correction @@ -1448,7 +1448,7 @@ public function processPng($data, $file, $firstTime, $interpolation) // Convert to Grayscale image stream - nominally returned as type='png' $info = $this->convertImage($data, $colspace, 'DeviceGray', $w, $h, $ppUx, $pngalpha, $gamma, $ct); // mPDF 5.7.2 Gamma correction - } elseif (($this->mpdf->PDFA || $this->mpdf->PDFX) && $pngalpha) { + } elseif (($this->mpdf->PDFA || $this->mpdf->isPdfx1a()) && $pngalpha) { // Convert to the colour space the standard allows; convertImage() drops the alpha channel where it forbids transparency if ($this->mpdf->restrictColorSpace === 1) { // Grayscale @@ -1457,6 +1457,8 @@ public function processPng($data, $file, $firstTime, $interpolation) $info = $this->convertImage($data, $colspace, 'DeviceCMYK', $w, $h, $ppUx, $pngalpha, $gamma, $ct); // mPDF 5.7.2 Gamma correction } elseif ($this->mpdf->PDFA) { // RGB $info = $this->convertImage($data, $colspace, 'DeviceRGB', $w, $h, $ppUx, $pngalpha, $gamma, $ct); // mPDF 5.7.2 Gamma correction + } else { // PDF/X-1a, which permits grey and CMYK; RGB came through as CMYK above + $info = $this->convertImage($data, $colspace, $colspace === 'DeviceGray' ? 'DeviceGray' : 'DeviceCMYK', $w, $h, $ppUx, $pngalpha, $gamma, $ct); } if (!$this->mpdf->transparencyAllowed() && (($this->mpdf->PDFA && !$this->mpdf->PDFAauto) || ($this->mpdf->PDFX && !$this->mpdf->PDFXauto))) { $this->mpdf->PDFAXwarnings[] = sprintf('Transparency (alpha channel) not permitted in PDFA or PDFX files - %s - (Image converted to one without transparency.)', $file); diff --git a/src/Image/Svg.php b/src/Image/Svg.php index f17ec76d4..b012cbad8 100644 --- a/src/Image/Svg.php +++ b/src/Image/Svg.php @@ -371,6 +371,8 @@ function svgGradient($gradient_info, $attribs, $element) { $n = count($this->mpdf->gradients) + 1; + $gradient_info = $this->inOneColorSpace($gradient_info); + // Get bounding dimensions of element $w = 100; $h = 100; @@ -762,15 +764,6 @@ function svgGradient($gradient_info, $attribs, $element) $min = $gradient_info['color'][0]['offset']; for ($i = 0; $i < ($stops); $i++) { - if (!$gradient_info['color'][$i]['color']) { - if ($gradient_info['colorspace'] == 'RGB') { - $gradient_info['color'][$i]['color'] = '0 0 0'; - } elseif ($gradient_info['colorspace'] == 'Gray') { - $gradient_info['color'][$i]['color'] = '0'; - } elseif ($gradient_info['colorspace'] == 'CMYK') { - $gradient_info['color'][$i]['color'] = '1 1 1 1'; - } - } $offset = ($gradient_info['color'][$i]['offset'] - $min) / $range; $this->mpdf->gradients[$n]['stops'][] = [ 'col' => $gradient_info['color'][$i]['color'], @@ -991,15 +984,6 @@ function svgGradient($gradient_info, $attribs, $element) $min = $gradient_info['color'][0]['offset']; for ($i = 0; $i < ($stops); $i++) { - if (!$gradient_info['color'][$i]['color']) { - if ($gradient_info['colorspace'] == 'RGB') { - $gradient_info['color'][$i]['color'] = '0 0 0'; - } elseif ($gradient_info['colorspace'] == 'Gray') { - $gradient_info['color'][$i]['color'] = '0'; - } elseif ($gradient_info['colorspace'] == 'CMYK') { - $gradient_info['color'][$i]['color'] = '1 1 1 1'; - } - } $offset = ($gradient_info['color'][$i]['offset'] - $min) / $range; $this->mpdf->gradients[$n]['stops'][] = [ 'col' => $gradient_info['color'][$i]['color'], @@ -2968,6 +2952,29 @@ function svgDefineTxtStyle($critere_style) array_push($this->txt_style, $current_style); } + /** + * A shading function gives every stop the same number of components, so the stops are written in the + * one colour space they can all be written in, rather than the space of whichever stop came last + * + * @param array $gradient_info + * + * @return array The gradient, its stops written in that colour space + */ + private function inOneColorSpace(array $gradient_info) + { + $colors = []; + foreach ($gradient_info['color'] as $stop) { + $colors[] = $stop['col']; + } + + $gradient_info['colorspace'] = $this->colorConverter->gradientColorSpace($colors); + foreach ($gradient_info['color'] as $i => $stop) { + $gradient_info['color'][$i]['color'] = $this->colorConverter->gradientComponents($stop['col'], $gradient_info['colorspace']); + } + + return $gradient_info; + } + function svgAddGradient($id, $array_gradient) { @@ -3550,17 +3557,6 @@ function xml_svg2pdf_start($parser, $name, $attribs) $col = $this->colorConverter->convert('#000000', $this->mpdf->PDFAXwarnings); } // In case "transparent" or "inherit" returned - if ($col[0] == 3 || $col[0] == 5) { // RGB - $color_final = sprintf('%.3F %.3F %.3F', ord($col[1]) / 255, ord($col[2]) / 255, ord($col[3]) / 255); - $this->svg_gradient[$last_gradid]['colorspace'] = 'RGB'; - } elseif ($col[0] == 4 || $col[0] == 6) { // CMYK - $color_final = sprintf('%.3F %.3F %.3F %.3F', ord($col[1]) / 100, ord($col[2]) / 100, ord($col[3]) / 100, ord($col[4]) / 100); - $this->svg_gradient[$last_gradid]['colorspace'] = 'CMYK'; - } elseif ($col[0] == 1) { // Grayscale - $color_final = sprintf('%.3F', ord($col[1]) / 255); - $this->svg_gradient[$last_gradid]['colorspace'] = 'Gray'; - } - $stop_opacity = 1; if (isset($attribs['style']) and preg_match('/stop-opacity:\s*([0-9.]*)/i', $attribs['style'], $m)) { @@ -3572,7 +3568,7 @@ function xml_svg2pdf_start($parser, $name, $attribs) $stop_opacity = $this->withColorAlpha($col, $stop_opacity); $tmp_color = [ - 'color' => $color_final, + 'col' => $col, // written in the colour space every stop fits - see inOneColorSpace() 'offset' => (isset($attribs['offset']) ? $attribs['offset'] : ''), 'opacity' => $this->mpdf->allowedAlpha($stop_opacity) ]; diff --git a/src/Mpdf.php b/src/Mpdf.php index 9afd89f70..280ca36db 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -54,11 +54,22 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface const OBJECT_IDENTIFIER = "\xbb\xa4\xac"; + /** + * The name a document is sent under where Output() is given none + */ + const DEFAULT_OUTPUT_NAME = 'mpdf.pdf'; + /** * The optional content group each visibility draws in, by its resource name in OptionalContentWriter */ const VISIBILITY_GROUPS = ['printonly' => 'OC1', 'screenonly' => 'OC2', 'hidden' => 'OC3']; + /** + * The output intent profile a PDF/X-4 document embeds where ICCProfile names none: U.S. web-coated + * (SWOP) grade 3, a printer profile, as ISO 15930-7 asks of the output intent + */ + const PDFX4_OUTPUT_PROFILE = __DIR__ . '/../data/iccprofiles/SWOP2006_Coated3v2.icc'; + var $useFixedNormalLineHeight; // mPDF 6 var $useFixedTextBaseline; // mPDF 6 var $adjustFontDescLineheight; // mPDF 6 @@ -95,6 +106,24 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface var $PDFX; var $PDFXauto; + var $PDFXversion; + + /** + * @var int[] The number of colour components of each ICC profile read, by path + */ + private $iccChannels = []; + + /** + * @var bool Whether the content sets colour in the ICC-based sRGB colour space, which the page's + * resource dictionary then names - see SetColor() + */ + private $usesCalibratedRgb = false; + + /** + * @var bool Whether the content sets colour in the ICC-based grey colour space, which the page's + * resource dictionary then names - see SetColor() + */ + private $usesCalibratedGray = false; var $PDFA; var $PDFAversion; @@ -1120,6 +1149,13 @@ public function __construct(array $config = [], $container = null) $originalConfig = $config; $config = $this->initConfig($originalConfig); + if ($this->isPdfx4() && version_compare($this->pdf_version, '1.6', '<')) { + $this->pdf_version = '1.6'; + } + + // Refuses an output intent PDF/X-4 cannot print to before any content is written + $this->pdfxOutputChannels(); + $serviceFactory = new ServiceFactory($container); $services = $serviceFactory->getServices( $this, @@ -1644,6 +1680,231 @@ private function initConfig(array $config) return $config; } + /** + * @return string|null The PDF/X version PDFXversion names, '4' or '1a', or null where the document is not PDF/X + */ + public function pdfxVersion() + { + if (!$this->PDFX) { + return null; + } + + $version = strtolower((string) $this->PDFXversion); + + if (!in_array($version, ['4', '1a'], true)) { + throw new \Mpdf\MpdfException(sprintf('PDFXversion (%s) is not valid. (Use: 4 for PDF/X-4, or 1a for PDF/X-1a:2003)', $this->PDFXversion)); + } + + return $version; + } + + /** + * @return bool Whether the document is PDF/X-4, which unlike PDF/X-1a allows transparency and layers + */ + public function isPdfx4() + { + return $this->pdfxVersion() === '4'; + } + + /** + * @return bool Whether the document is PDF/X-1a, which permits no transparency, layers or RGB + */ + public function isPdfx1a() + { + return $this->pdfxVersion() === '1a'; + } + + /** + * @return string The PDF/X version the document conforms to, as warnings and its metadata name it + */ + public function pdfxVersionLabel() + { + return $this->isPdfx4() ? 'PDF/X-4' : 'PDF/X-1a:2003'; + } + + /** + * PDF/X permits no interactive form field, so under PDFXauto fields are drawn on the page as with + * useActiveForms off, and keep the values they show. Without PDFXauto they stay active and the + * document is refused when written. + * + * @return bool Whether form fields are written as interactive fields rather than drawn + */ + public function activeForms() + { + return $this->useActiveForms && !($this->PDFX && $this->PDFXauto); + } + + /** + * @return string|null The ICC profile the PDF/X output intent embeds: ICCProfile, or for PDF/X-4 the + * bundled SWOP profile where it names none. Null for PDF/X-1a naming none, whose + * output intent names the CGATS TR 001 condition alone. + */ + public function pdfxOutputProfile() + { + if (!$this->PDFX) { + return null; + } + + if ($this->ICCProfile) { + return $this->ICCProfile; + } + + return $this->isPdfx4() ? self::PDFX4_OUTPUT_PROFILE : null; + } + + /** + * The number of colour components of the PDF/X output intent: four for PDF/X-1a, which prints to a + * CMYK condition, and for PDF/X-4 as many as its profile has, four for the bundled SWOP profile it + * embeds where the document names none. + * + * ISO 15930-7 has the printing condition print to grey, RGB or CMYK, and mPDF writes device colour + * for it, so a profile of any other colour space, Lab among them, is refused. PDF/X-1a prints to CMYK + * alone. Either takes an output device profile, of the 'prtr' class: a display profile such as sRGB + * describes no printing condition, and preflight refuses it. + * + * @throws \Mpdf\MpdfException Where the PDF/X ICCProfile cannot be read, prints to another space or is + * not a printer profile + * + * @return int 1 for grey, 3 for RGB, 4 for CMYK + */ + public function pdfxOutputChannels() + { + $profile = $this->pdfxOutputProfile(); + if ($profile === null) { + return 4; + } + + if (!isset($this->iccChannels[$profile])) { + $header = is_readable($profile) ? (string) file_get_contents($profile, false, null, 0, 20) : ''; + list($channels, $permitted) = $this->isPdfx4() + ? [['GRAY' => 1, 'RGB ' => 3, 'CMYK' => 4], 'grey, RGB or CMYK'] + : [['CMYK' => 4], 'CMYK']; + $space = substr($header, 16, 4); + if (!isset($channels[$space])) { + throw new \Mpdf\MpdfException(sprintf('The %s output intent must print to %s, and ICCProfile "%s" does not.', $this->isPdfx4() ? 'PDF/X-4' : 'PDF/X-1a', $permitted, $profile)); + } + $class = substr($header, 12, 4); + if ($class !== 'prtr') { + throw new \Mpdf\MpdfException(sprintf('The %s output intent must be a printer (prtr) profile, and ICCProfile "%s" is of the %s class.', $this->pdfxVersionLabel(), $profile, trim($class))); + } + $this->iccChannels[$profile] = $channels[$space]; + } + + return $this->iccChannels[$profile]; + } + + /** + * @return bool Whether the document is PDF/X-4 printed to an RGB output intent, where RGB colour is + * written as it is + */ + public function pdfxRgbIntent() + { + return $this->pdfxOutputChannels() === 3; + } + + /** + * @return bool Whether the document is PDF/X-4 printed to a grey or RGB output intent, which may not + * use DeviceCMYK, so that CMYK is converted to RGB + */ + public function pdfxConvertsCmyk() + { + return $this->isPdfx4() && $this->pdfxOutputChannels() !== 4; + } + + /** + * PDF/X-4 printing to a CMYK or grey output condition may not use DeviceRGB, so rather than convert + * its RGB with a formula that knows nothing of the press - no profile, no rendering intent, no black + * generation, no ink limit - mPDF writes it in an ICC-based sRGB colour space and leaves the press + * one colour-managed conversion to make, of the colours in the content and in the images alike. + * + * @return bool Whether RGB is written in that colour space rather than in DeviceRGB + */ + public function writesCalibratedRgb() + { + return $this->isPdfx4() && !$this->pdfxRgbIntent(); + } + + /** + * @return bool Whether the content has set a colour in the ICC-based sRGB colour space, so that the + * page's resource dictionary must name it + */ + public function usesCalibratedRgb() + { + return $this->usesCalibratedRgb; + } + + /** + * PDF/X-4 permits DeviceGray only where its output intent is grey or CMYK. Printing to an RGB output + * intent, grey is written in an ICC-based colour space whose profile has sRGB's tone curve, so that + * each grey draws as it does in DeviceGray - data/iccprofiles/Gray_sRGB_TRC.icc. + * + * @return bool Whether grey is written in that colour space rather than in DeviceGray + */ + public function writesCalibratedGray() + { + return $this->isPdfx4() && $this->pdfxRgbIntent(); + } + + /** + * @return bool Whether the content has set a colour in the ICC-based grey colour space, so that the + * page's resource dictionary must name it + */ + public function usesCalibratedGray() + { + return $this->usesCalibratedGray; + } + + /** + * A content stream starts in black in DeviceGray. Where DeviceGray is not permitted, each page and + * each form drawn from SVG or WMF starts by setting black in the ICC-based grey colour space, so that + * nothing drawn before a colour is set falls back to DeviceGray. + * + * @return string Content setting the fill and stroke colours, or nothing where grey is DeviceGray + */ + public function initialColor() + { + if (!$this->writesCalibratedGray()) { + return ''; + } + + $black = $this->colorConverter->convert(0, $this->PDFAXwarnings); + + return $this->SetColor($black, 'Fill') . ' ' . $this->SetColor($black, 'Draw') . "\n"; + } + + /** + * PDF/X requires a document title, which SetTitle() sets. Where none is set the document does not + * conform, so mPDF says so; and where it is to fix the document it titles it after the name the + * document is written under, so that what is produced conforms rather than is merely tolerated. + * + * Asked as the document is closed, before the title is written to the Info dictionary and to the XMP + * metadata. Closed by Close() rather than Output(), the document has no name to be titled after, and + * takes mPDF's default one. + * + * @param string|null $name The name Output() was given, which may name no file + */ + private function requirePdfxTitle($name) + { + if (!$this->PDFX || !empty($this->title)) { + return; + } + + $title = pathinfo((string) $name, PATHINFO_FILENAME); + if ($title === '') { + $title = pathinfo(self::DEFAULT_OUTPUT_NAME, PATHINFO_FILENAME); + } + + $this->PDFAXwarnings[] = sprintf( + 'A document title is required in %s files, and SetTitle() set none. (Title set to the file name "%s")', + $this->pdfxVersionLabel(), + $title + ); + + if ($this->PDFXauto) { + $this->title = $title; + } + } + private function initConstructorParams(array $config) { $constructor = [ @@ -2089,13 +2350,13 @@ public function pdfaConformance() } /** - * Whether the document may use transparency: PDF/A-1 and PDF/X-1a forbid it, PDF/A-2 onwards does not + * Whether the document may use transparency: PDF/A-1 and PDF/X-1a forbid it, PDF/A-2 onwards and PDF/X-4 do not * * @return bool */ public function transparencyAllowed() { - return !$this->PDFX && $this->pdfaPart() !== '1'; + return !$this->isPdfx1a() && $this->pdfaPart() !== '1'; } /** @@ -2146,7 +2407,7 @@ public function pdfaxWarning($message) /** * Whether the document may use optional content, which layers and the visibility property draw with: PDF/A-1 and - * PDF/X-1a forbid it along with transparency, PDF/A-2 onwards allows it + * PDF/X-1a forbid it along with transparency, PDF/A-2 onwards and PDF/X-4 allow it * * @return bool */ @@ -2211,8 +2472,7 @@ function AddExtGState($parms) function SetVisibility($v) { $dropped = $this->visibilityDropped($v); - // PDF/A-2 forbids the /AS that switches print-only and screen-only content, but hidden content needs none - if ($v !== 'visible' && (!$this->optionalContentAllowed() || ($this->PDFA && $v !== 'hidden'))) { + if (!$this->visibilityAllowed($v)) { $this->PDFAXwarnings[] = "Cannot set visibility to " . $v . " when using PDFA or PDFX"; if (!$dropped) { return ''; @@ -2238,9 +2498,31 @@ function SetVisibility($v) $this->visibility = $v; } + /** + * Whether content may be drawn with this visibility. PDF/A-2 forbids the /AS that switches print-only and + * screen-only content, but hidden content needs none; PDF/X-4 forbids the /AS too, and PDF/X has no hidden + * content either + * + * @param string $v + * + * @return bool + */ + private function visibilityAllowed($v) + { + if ($v === 'visible') { + return true; + } + + if ($this->PDFX || !$this->optionalContentAllowed()) { + return false; + } + + return !$this->PDFA || $v === 'hidden'; + } + /** * Whether content with this visibility is left out: under PDFAauto and PDFXauto, what would not print is dropped, - * which is screen-only content, and hidden content where optional content is not allowed + * which is screen-only content, and hidden content where it is not allowed * * @param string $v * @@ -2248,7 +2530,7 @@ function SetVisibility($v) */ private function visibilityDropped($v) { - if ($v !== 'screenonly' && ($v !== 'hidden' || $this->optionalContentAllowed())) { + if ($v !== 'screenonly' && ($v !== 'hidden' || $this->visibilityAllowed($v))) { return false; } @@ -2309,6 +2591,14 @@ function Open() } function Close() + { + $this->closeDocument(null); + } + + /** + * @param string|null $name The name Output() was given, which an untitled PDF/X document is titled after + */ + private function closeDocument($name) { // @log Closing last page @@ -2317,6 +2607,9 @@ function Close() return; } + // Before the Info dictionary and the metadata are written + $this->requirePdfxTitle($name); + if ($this->page == 0) { $this->AddPage($this->CurOrientation); } @@ -3687,6 +3980,26 @@ function SetColor($col, $type = '') if (!$col) { return ''; } // mPDF 6 + + // Where DeviceRGB or DeviceGray is not permitted, RGB or grey is set in the ICC-based sRGB or grey + // colour space the page's resources name. CodeOnly asks for the device colour's components alone. + $calibrated = null; + if ($type !== 'CodeOnly') { + if (($col[0] == 3 || $col[0] == 5) && $this->writesCalibratedRgb()) { + $this->usesCalibratedRgb = true; + $calibrated = [Writer\BaseWriter::CALIBRATED_RGB, sprintf('%.3F %.3F %.3F', ord($col[1]) / 255, ord($col[2]) / 255, ord($col[3]) / 255)]; + } elseif ($col[0] == 1 && $this->writesCalibratedGray()) { + $this->usesCalibratedGray = true; + $calibrated = [Writer\BaseWriter::CALIBRATED_GRAY, sprintf('%.3F', ord($col[1]) / 255)]; + } + } + + if ($calibrated) { + return $type === 'Draw' + ? sprintf('/%s CS %s SC', $calibrated[0], $calibrated[1]) + : sprintf('/%s cs %s sc', $calibrated[0], $calibrated[1]); + } + if ($col[0] == 3 || $col[0] == 5) { // RGB / RGBa $out = sprintf('%.3F %.3F %.3F rg', ord($col[1]) / 255, ord($col[2]) / 255, ord($col[3]) / 255); } elseif ($col[0] == 1) { // GRAYSCALE @@ -4596,11 +4909,11 @@ function SetFont($family, $style = '', $size = 0, $write = true, $forcewrite = f if (($family == 'csymbol') || ($family == 'czapfdingbats') || ($family == 'ctimes') || ($family == 'ccourier') || ($family == 'chelvetica')) { if ($this->PDFA || $this->PDFX) { if ($family == 'csymbol' || $family == 'czapfdingbats') { - throw new \Mpdf\MpdfException("Symbol and Zapfdingbats cannot be embedded in mPDF (required for PDFA1-b or PDFX/1-a)."); + throw new \Mpdf\MpdfException("Symbol and Zapfdingbats cannot be embedded in mPDF (required for PDFA1-b or " . $this->pdfxVersionLabel() . ")."); } if ($family == 'ctimes' || $family == 'ccourier' || $family == 'chelvetica') { if (($this->PDFA && !$this->PDFAauto) || ($this->PDFX && !$this->PDFXauto)) { - $this->PDFAXwarnings[] = "Core Adobe font " . ucfirst($family) . " cannot be embedded in mPDF, which is required for PDFA1-b or PDFX/1-a. (Embedded font will be substituted.)"; + $this->PDFAXwarnings[] = "Core Adobe font " . ucfirst($family) . " cannot be embedded in mPDF, which is required for PDFA1-b or " . $this->pdfxVersionLabel() . ". (Embedded font will be substituted.)"; } if ($family == 'chelvetica') { $family = 'sans'; @@ -4753,7 +5066,7 @@ function SetFont($family, $style = '', $size = 0, $write = true, $forcewrite = f $this->setMBencoding('UTF-8'); } else { // if using core fonts if ($this->PDFA || $this->PDFX) { - throw new \Mpdf\MpdfException('Core Adobe fonts cannot be embedded in mPDF (required for PDFA1-b or PDFX/1-a) - cannot use option to use core fonts.'); + throw new \Mpdf\MpdfException('Core Adobe fonts cannot be embedded in mPDF (required for PDFA1-b or ' . $this->pdfxVersionLabel() . ') - cannot use option to use core fonts.'); } $this->setMBencoding('windows-1252'); @@ -10138,7 +10451,7 @@ function Output($name = '', $dest = '') // Finish document if necessary if ($this->state < 3) { - $this->Close(); + $this->closeDocument($name); } $this->reportBlankColorFonts(); @@ -10157,7 +10470,7 @@ function Output($name = '', $dest = '') } if (($this->PDFA || $this->PDFX) && $this->encrypted) { - throw new \Mpdf\MpdfException('PDF/A1-b or PDF/X1-a does not permit encryption of documents.'); + throw new \Mpdf\MpdfException(sprintf('%s does not permit encryption of documents.', $this->PDFA ? 'PDF/A1-b' : $this->pdfxVersionLabel())); } if (count($this->PDFAXwarnings) && (($this->PDFA && !$this->PDFAauto) || ($this->PDFX && !$this->PDFXauto))) { @@ -10165,7 +10478,7 @@ function Output($name = '', $dest = '') $standard = 'PDFA/1-b'; $option = '$mpdf->PDFAauto'; } else { - $standard = 'PDFX/1-a '; + $standard = $this->pdfxVersionLabel(); $option = '$mpdf->PDFXauto'; } @@ -10193,7 +10506,7 @@ function Output($name = '', $dest = '') $dest = strtoupper($dest); if (empty($dest)) { if (empty($name)) { - $name = 'mpdf.pdf'; + $name = self::DEFAULT_OUTPUT_NAME; $dest = Destination::INLINE; } else { $dest = Destination::FILE; @@ -10584,9 +10897,7 @@ function Annotation($text, $x = 0, $y = 0, $icon = 'Note', $author = '', $subjec } if (!$this->transparencyAllowed()) { - if (($this->PDFA && !$this->PDFAauto) || ($this->PDFX && !$this->PDFXauto)) { - $this->PDFAXwarnings[] = "Annotation markers cannot be semi-transparent in PDFA1-b or PDFX/1-a, so they may make underlying text unreadable. (Annotation markers moved to right margin)"; - } + $this->pdfaxWarning("Annotation markers cannot be semi-transparent in PDFA1-b or PDFX/1-a, so they may make underlying text unreadable. (Annotation markers moved to right margin)"); $x = ($this->w) - $this->rMargin * 0.66; } if (!$this->annotMargin) { @@ -10633,6 +10944,18 @@ function Annotation($text, $x = 0, $y = 0, $icon = 'Note', $author = '', $subjec function _enddoc() { + // PDF/X permits no JavaScript + if ($this->PDFX && $this->js !== null) { + $this->pdfaxWarning('JavaScript is not permitted in ' . $this->pdfxVersionLabel() . ' files. (JavaScript removed)'); + $this->js = null; + } + + // Nor any embedded file, which is what an associated file is written as + if ($this->PDFX && $this->associatedFiles) { + $this->pdfaxWarning('Associated files are not permitted in ' . $this->pdfxVersionLabel() . ' files. (Associated files removed)'); + $this->associatedFiles = []; + } + // @log Writing Headers & Footers $this->_puthtmlheaders(); @@ -11592,7 +11915,7 @@ function AddCJKFont($family) { if ($this->PDFA || $this->PDFX) { - throw new \Mpdf\MpdfException("Adobe CJK fonts cannot be embedded in mPDF (required for PDFA1-b and PDFX/1-a)."); + throw new \Mpdf\MpdfException("Adobe CJK fonts cannot be embedded in mPDF (required for PDFA1-b and " . $this->pdfxVersionLabel() . ")."); } if ($family == 'big5') { $this->AddBig5Font(); @@ -14258,11 +14581,11 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $e = mb_convert_case($e, MB_CASE_TITLE, "UTF-8"); } // mPDF 5.7.1 } else { - if ($this->checkSIP && (isset($this->CurrentFont['sipext']) && $this->CurrentFont['sipext']) && $this->subPos < $i && (!$this->specialcontent || !$this->useActiveForms)) { + if ($this->checkSIP && (isset($this->CurrentFont['sipext']) && $this->CurrentFont['sipext']) && $this->subPos < $i && (!$this->specialcontent || !$this->activeForms())) { $cnt += $this->SubstituteCharsSIP($a, $i, $e); } - if ($this->useSubstitutions && !$this->onlyCoreFonts && $this->CurrentFont['type'] != 'Type0' && $this->subPos < $i && (!$this->specialcontent || !$this->useActiveForms)) { + if ($this->useSubstitutions && !$this->onlyCoreFonts && $this->CurrentFont['type'] != 'Type0' && $this->subPos < $i && (!$this->specialcontent || !$this->activeForms())) { $cnt += $this->SubstituteCharsMB($a, $i, $e); } @@ -14276,7 +14599,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c /* -- OTL -- */ // Use OTL OpenType Table Layout - GSUB & GPOS - if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL'] && (!$this->specialcontent || !$this->useActiveForms)) { + if (isset($this->CurrentFont['useOTL']) && $this->CurrentFont['useOTL'] && (!$this->specialcontent || !$this->activeForms())) { if (!$this->otl) { $this->otl = new Otl($this, $this->fontCache); } @@ -14321,7 +14644,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c } // Active Forms. A static list box draws every option, so keeps each one's layout too if (isset($this->selectoption['ACTIVE']) && $this->selectoption['ACTIVE']) { - $listBox = !$this->useActiveForms && (isset($this->selectoption['MULTIPLE']) || isset($this->selectoption['SIZE'])); + $listBox = !$this->activeForms() && (isset($this->selectoption['MULTIPLE']) || isset($this->selectoption['SIZE'])); $this->selectoption['ITEMS'][] = ['exportValue' => $this->selectoption['currentVAL'], 'content' => $e, 'selected' => $this->selectoption['currentSEL'], 'OTLdata' => $listBox && !empty($this->OTLdata) ? $this->OTLdata : false]; } $this->OTLdata = []; diff --git a/src/Pdf/DocumentProfile.php b/src/Pdf/DocumentProfile.php index 076e57049..7af1852da 100644 --- a/src/Pdf/DocumentProfile.php +++ b/src/Pdf/DocumentProfile.php @@ -8,7 +8,8 @@ /** * The PDF version a document is written as, and each standard it is written to with the version applied * - * A standard's version is spelt as mPDF's configuration spells it: '3-B' for PDF/A-3b, '1a' for PDF/X-1a:2003. + * A standard's version is spelt as mPDF's configuration spells it: '3-B' for PDF/A-3b, '1a' for PDF/X-1a:2003 and + * '4' for PDF/X-4. */ class DocumentProfile { @@ -23,7 +24,7 @@ class DocumentProfile const PDFA = 'PDF/A'; /** - * PDF/X (ISO 15930), for print exchange; its version is the part, e.g. 1a for PDF/X-1a:2003 + * PDF/X (ISO 15930), for print exchange; its version is the part, e.g. 1a for PDF/X-1a:2003 or 4 for PDF/X-4 * * @see https://pdfa.org/resource/iso-15930-pdfx/ */ @@ -64,12 +65,16 @@ public static function fromMpdf(Mpdf $mpdf) } if ($mpdf->PDFX) { - $standards[self::PDFX] = '1a'; + $standards[self::PDFX] = $mpdf->pdfxVersion(); } - // The catalog's /Version raises the header's to 1.7 for PDF/A-2 onwards, which ISO 32000-1 underlies, and for AES-256 + // The catalog's /Version raises the header's to 1.7 for PDF/A-2 onwards, which ISO 32000-1 underlies, and for AES-256, + // and to 1.6 for PDF/X-4, which PDF 1.6 underlies $raised = ($mpdf->PDFA && $mpdf->pdfaPart() !== '1') || $mpdf->encrypted; $version = $raised && version_compare($mpdf->pdf_version, '1.7', '<') ? '1.7' : $mpdf->pdf_version; + if ($mpdf->isPdfx4() && version_compare($version, '1.6', '<')) { + $version = '1.6'; + } return new self($version, $standards); } diff --git a/src/ServiceFactory.php b/src/ServiceFactory.php index 41d3d425b..0357a4534 100644 --- a/src/ServiceFactory.php +++ b/src/ServiceFactory.php @@ -166,10 +166,10 @@ public function getServices( $xmpExtensions = new XmpExtensions(); $metadataWriter = new MetadataWriter($mpdf, $writer, $form, $protection, $assetFetcher, $xmpExtensions, $logger); $imageWriter = new ImageWriter($mpdf, $writer); - $pageWriter = new PageWriter($mpdf, $writer, $metadataWriter); + $pageWriter = new PageWriter($mpdf, $form, $writer, $metadataWriter); $bookmarkWriter = new BookmarkWriter($mpdf, $writer); $optionalContentWriter = new OptionalContentWriter($mpdf, $writer); - $colorWriter = new ColorWriter($mpdf, $writer); + $colorWriter = new ColorWriter($mpdf, $writer, $colorModeConverter); $backgroundWriter = new BackgroundWriter($mpdf, $writer); $javaScriptWriter = new JavaScriptWriter($mpdf, $writer); $crossReferenceWriter = new CrossReferenceWriter($mpdf, $writer, $metadataWriter); diff --git a/src/Tag/Input.php b/src/Tag/Input.php index 7b9e171a3..f9f6a1722 100644 --- a/src/Tag/Input.php +++ b/src/Tag/Input.php @@ -51,7 +51,7 @@ public function open($attr, &$ahtml, &$ihtml) if ($this->mpdf->onlyCoreFonts) { $objattr['title'] = mb_convert_encoding($objattr['title'], $this->mpdf->mb_enc, 'UTF-8'); } - if ($this->mpdf->useActiveForms && isset($attr['NAME'])) { + if ($this->mpdf->activeForms() && isset($attr['NAME'])) { $objattr['fieldname'] = $attr['NAME']; } if (isset($attr['VALUE'])) { @@ -79,7 +79,7 @@ public function open($attr, &$ahtml, &$ihtml) } $objattr['fontfamily'] = $this->mpdf->FontFamily; $objattr['fontsize'] = $this->mpdf->FontSizePt; - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($attr['ALIGN'])) { $objattr['text_align'] = $this->getAlign($attr['ALIGN']); } elseif (isset($properties['TEXT-ALIGN'])) { @@ -106,7 +106,7 @@ public function open($attr, &$ahtml, &$ihtml) switch (strtoupper($attr['TYPE'])) { case 'HIDDEN': $this->mpdf->ignorefollowingspaces = true; //Eliminate exceeding left-side spaces - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { $this->form->SetFormText(0, 0, (isset($objattr['fieldname']) ? $objattr['fieldname'] : ''), $objattr['value'], $objattr['value'], '', 0, '', true); } if ($this->mpdf->InlineProperties[$tag]) { @@ -304,7 +304,7 @@ public function open($attr, &$ahtml, &$ihtml) $objattr['image_width'] = $w; $objattr['ID'] = $info['i']; $texto = 'X'; - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($attr['ONCLICK'])) { $objattr['onClick'] = $attr['ONCLICK']; } @@ -337,7 +337,7 @@ public function open($attr, &$ahtml, &$ihtml) $height = $this->mpdf->FontSize + ($this->form->form_element_spacing['button']['outer']['v'] * 2) + ($this->form->form_element_spacing['button']['inner']['v'] * 2); - if ($this->mpdf->useActiveForms && isset($attr['ONCLICK'])) { + if ($this->mpdf->activeForms() && isset($attr['ONCLICK'])) { $objattr['onClick'] = $attr['ONCLICK']; } break; @@ -352,7 +352,7 @@ public function open($attr, &$ahtml, &$ihtml) $type = 'PASSWORD'; } - if ($properties['FONT-SIZE'] === 'auto' && $this->mpdf->useActiveForms) { + if ($properties['FONT-SIZE'] === 'auto' && $this->mpdf->activeForms()) { $objattr['use_auto_fontsize'] = true; } @@ -378,7 +378,7 @@ public function open($attr, &$ahtml, &$ihtml) if (isset($attr['MAXLENGTH']) && ctype_digit($attr['MAXLENGTH'])) { $objattr['maxlength'] = $attr['MAXLENGTH']; } - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($attr['ONCALCULATE'])) { $objattr['onCalculate'] = $attr['ONCALCULATE']; } elseif (isset($attr['ONCHANGE'])) { diff --git a/src/Tag/Select.php b/src/Tag/Select.php index 8d844ca92..c3fe6744f 100644 --- a/src/Tag/Select.php +++ b/src/Tag/Select.php @@ -53,7 +53,7 @@ public function open($attr, &$ahtml, &$ihtml) if ($size > 1) { $this->mpdf->selectoption['SIZE'] = $size; } - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($attr['NAME'])) { $this->mpdf->selectoption['NAME'] = $attr['NAME']; } @@ -77,7 +77,7 @@ public function close(&$ahtml, &$ihtml) } // A list box shows every option, so it is as wide as the widest, as an active one is - if ($this->mpdf->useActiveForms || isset($this->mpdf->selectoption['MULTIPLE']) || isset($this->mpdf->selectoption['SIZE'])) { + if ($this->mpdf->activeForms() || isset($this->mpdf->selectoption['MULTIPLE']) || isset($this->mpdf->selectoption['SIZE'])) { $w = isset($this->mpdf->selectoption['MAXWIDTH']) ? $this->mpdf->selectoption['MAXWIDTH'] : 0; } else { $w = $this->mpdf->GetStringWidth($texto, true, $OTLdata); diff --git a/src/Tag/TextArea.php b/src/Tag/TextArea.php index 7b0efba2c..de3119393 100644 --- a/src/Tag/TextArea.php +++ b/src/Tag/TextArea.php @@ -38,7 +38,7 @@ public function open($attr, &$ahtml, &$ihtml) $objattr['title'] = mb_convert_encoding($objattr['title'], $this->mpdf->mb_enc, 'UTF-8'); } } - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($attr['NAME'])) { $objattr['fieldname'] = $attr['NAME']; } @@ -73,7 +73,7 @@ public function open($attr, &$ahtml, &$ihtml) } $objattr['fontfamily'] = $this->mpdf->FontFamily; $objattr['fontsize'] = $this->mpdf->FontSizePt; - if ($this->mpdf->useActiveForms) { + if ($this->mpdf->activeForms()) { if (isset($properties['TEXT-ALIGN'])) { $objattr['text_align'] = $this->getAlign($properties['TEXT-ALIGN']); } elseif (isset($attr['ALIGN'])) { @@ -138,7 +138,7 @@ public function open($attr, &$ahtml, &$ihtml) $objattr['rows'] = $rowsize; $objattr['cols'] = $colsize; - if ($properties['FONT-SIZE'] === 'auto' && $this->mpdf->useActiveForms) { + if ($properties['FONT-SIZE'] === 'auto' && $this->mpdf->activeForms()) { $objattr['use_auto_fontsize'] = true; } diff --git a/src/Writer/BackgroundWriter.php b/src/Writer/BackgroundWriter.php index d56790baf..98d6224c6 100644 --- a/src/Writer/BackgroundWriter.php +++ b/src/Writer/BackgroundWriter.php @@ -124,12 +124,21 @@ public function writePatterns() // _putpatterns throw new \Mpdf\MpdfException('Problem: Image object not found for background pattern ' . $img['i']); } + // An SVG or WMF form has no resources of its own, so it looks up the ICC-based grey colour space + // it sets by name in its pattern's - see Mpdf::initialColor() + $form = $itype === 'svg' || $itype === 'wmf'; + $calibratedGray = $form ? $this->writer->calibratedGray() : null; + $this->writer->object(); $this->writer->write('<writer->write('/XObject <>'); + if ($calibratedGray) { + $this->writer->write('/ColorSpace <>'); + } + // ******* ADD ANY ExtGStates, Shading AND Fonts needed for the FormObject // Set in classes/svg array['fo'] = true // Required that _putshaders comes before _putpatterns in _putresources @@ -321,14 +330,26 @@ public function writeShaders() // _putshaders if (empty($grad['is_mask'])) { + $colorspace = isset($grad['colorspace']) ? $grad['colorspace'] : 'RGB'; + + // Where DeviceRGB or DeviceGray is not permitted, the stops are in the ICC-based colour space + // the content sets its own RGB or grey in. It is an object of its own, so it is written before + // the shading that names it - see Mpdf::writesCalibratedRgb() and writesCalibratedGray() + $calibrated = null; + if ($colorspace === 'RGB') { + $calibrated = $this->writer->calibratedRgb(); + } elseif ($colorspace === 'Gray') { + $calibrated = $this->writer->calibratedGray(); + } + $this->writer->object(); $this->writer->write('<<'); $this->writer->write('/ShadingType ' . $grad['type']); - if (isset($grad['colorspace'])) { - $this->writer->write('/ColorSpace /Device' . $grad['colorspace']); // Can use CMYK if all C0 and C1 above have 4 values + if ($calibrated) { + $this->writer->write('/ColorSpace ' . $calibrated . ' 0 R'); } else { - $this->writer->write('/ColorSpace /DeviceRGB'); + $this->writer->write('/ColorSpace /Device' . $colorspace); // Can use CMYK if all C0 and C1 above have 4 values } if ($grad['type'] == 2) { @@ -384,10 +405,13 @@ public function writeShaders() // _putshaders // luminosity pattern $transid = $id + $maxid; + // The mask is drawn and blended in grey + $gray = $this->writer->grayColorSpace(); + $this->writer->object(); $this->writer->write('<<'); $this->writer->write('/ShadingType ' . $grad['type']); - $this->writer->write('/ColorSpace /DeviceGray'); + $this->writer->write('/ColorSpace ' . $gray); if ($grad['type'] == 2) { $this->writer->write(sprintf('/Coords [%.3F %.3F %.3F %.3F]', $grad['coords'][0], $grad['coords'][1], $grad['coords'][2], $grad['coords'][3])); @@ -435,7 +459,7 @@ public function writeShaders() // _putshaders $this->writer->write('<< /Type /XObject /Subtype /Form /FormType 1 ' . $filter); $this->writer->write('/Length ' . $this->writer->streamLength($p)); $this->writer->write('/BBox [-' . ($this->mpdf->wPt / 2) . ' -' . ($this->mpdf->hPt / 2) . ' ' . (2 * $this->mpdf->wPt) . ' ' . (2 * $this->mpdf->hPt) . ']'); - $this->writer->write('/Group << /Type /Group /S /Transparency /CS /DeviceGray >>'); + $this->writer->write('/Group << /Type /Group /S /Transparency /CS ' . $gray . ' >>'); $this->writer->write('/Resources <<'); $this->writer->write('/ExtGState << /a0 << /ca 1 /CA 1 >> >>'); $this->writer->write('/Pattern << /p' . $transid . ' ' . $this->mpdf->gradients[$transid]['pattern'] . ' 0 R >>'); diff --git a/src/Writer/BaseWriter.php b/src/Writer/BaseWriter.php index e04343cdf..36898662d 100644 --- a/src/Writer/BaseWriter.php +++ b/src/Writer/BaseWriter.php @@ -27,6 +27,33 @@ final class BaseWriter */ private $protection; + /** + * The profile of the ICC-based sRGB colour space, and of PDF/A's output intent where it names none + */ + const SRGB_PROFILE = __DIR__ . '/../../data/iccprofiles/sRGB_IEC61966-2-1.icc'; + + /** + * The profile of the ICC-based grey colour space + */ + const GRAY_PROFILE = __DIR__ . '/../../data/iccprofiles/Gray_sRGB_TRC.icc'; + + /** + * The name the ICC-based sRGB colour space is set by. It is upper case throughout, because mPDF + * makes a stroking operator out of a filling one by upper-casing the whole of it. + */ + const CALIBRATED_RGB = 'CSRGB'; + + + /** + * The name the ICC-based grey colour space is set by, upper case for the same reason + */ + const CALIBRATED_GRAY = 'CSGRAY'; + + /** + * @var int[] The object numbers of the ICC-based colour spaces written, by profile + */ + private $calibrated = []; + public function __construct(Mpdf $mpdf, Protection $protection) { $this->mpdf = $mpdf; @@ -300,6 +327,83 @@ public function date() return $this->date; } + /** + * The colour space RGB is written in where it may not be DeviceRGB: PDF/X-4 permits DeviceRGB only + * where its output intent is RGB, so otherwise RGB is written in an ICC-based sRGB colour space. It is + * written the first time it is asked for, and so is asked for only between objects. + * + * @return int|null The object number of the ICC-based colour space, or null where RGB is DeviceRGB + */ + public function calibratedRgb() + { + return $this->mpdf->writesCalibratedRgb() ? $this->calibrated(self::SRGB_PROFILE, 3) : null; + } + + /** + * The colour space grey is written in where it may not be DeviceGray: PDF/X-4 permits DeviceGray only + * where its output intent is grey or CMYK, so under an RGB one grey is written in an ICC-based space + * whose profile is data/iccprofiles/Gray_sRGB_TRC.icc. Written the first time it is asked for, so asked + * for between objects. + * + * @return int|null The object number of the ICC-based colour space, or null where grey is DeviceGray + */ + public function calibratedGray() + { + return $this->mpdf->writesCalibratedGray() ? $this->calibrated(self::GRAY_PROFILE, 1) : null; + } + + /** + * @return string The colour space grey is written in, for a dictionary to name: /DeviceGray, or a + * reference to the ICC-based grey colour space - see calibratedGray() + */ + public function grayColorSpace() + { + $calibrated = $this->calibratedGray(); + + return $calibrated === null ? '/DeviceGray' : $calibrated . ' 0 R'; + } + + /** + * @param string $path An ICC profile + * @param int $channels The number of colour components it has + * + * @return int The object number of the ICC-based colour space on that profile, written the first time + */ + private function calibrated($path, $channels) + { + if (!isset($this->calibrated[$path])) { + $this->calibrated[$path] = $this->iccBased(file_get_contents($path), $channels); + } + + return $this->calibrated[$path]; + } + + /** + * @param string $profile An ICC profile + * @param int $channels The number of colour components it has + * + * @return int The object number of an ICC-based colour space on that profile, written with it + */ + private function iccBased($profile, $channels) + { + $filter = ''; + if ($this->mpdf->compress) { + $profile = gzcompress($profile); + $filter = '/Filter /FlateDecode '; + } + + $this->object(); + $this->write('<>'); + $this->stream($profile); + $this->write('endobj'); + + $this->object(); + $this->write('[/ICCBased ' . ($this->mpdf->n - 1) . ' 0 R]'); + $this->write('endobj'); + + return $this->mpdf->n; + } + /** * That moment as a PDF date string, for the Info dictionary and each annotation */ diff --git a/src/Writer/ColorWriter.php b/src/Writer/ColorWriter.php index c6ece981b..81566adc4 100644 --- a/src/Writer/ColorWriter.php +++ b/src/Writer/ColorWriter.php @@ -2,6 +2,7 @@ namespace Mpdf\Writer; +use Mpdf\Color\ColorModeConverter; use Mpdf\Strict; use Mpdf\Mpdf; @@ -20,22 +21,43 @@ final class ColorWriter */ private $writer; - public function __construct(Mpdf $mpdf, BaseWriter $writer) + /** + * @var \Mpdf\Color\ColorModeConverter + */ + private $colorModeConverter; + + public function __construct(Mpdf $mpdf, BaseWriter $writer, ColorModeConverter $colorModeConverter) { $this->mpdf = $mpdf; $this->writer = $writer; + $this->colorModeConverter = $colorModeConverter; } public function writeSpotColors() // _putspotcolors { + // PDF/X-4 printing to grey or RGB permits no DeviceCMYK, even as the alternate a device without + // the ink falls back to, so the alternate is RGB there + $rgb = count($this->mpdf->spotColors) && $this->mpdf->pdfxConvertsCmyk(); + if ($rgb) { + $calibratedRgb = $this->writer->calibratedRgb(); + $alternate = $calibratedRgb ? $calibratedRgb . ' 0 R' : '/DeviceRGB'; + } + foreach ($this->mpdf->spotColors as $name => $color) { $this->writer->object(); $this->writer->write('[/Separation /' . str_replace(' ', '#20', $name)); - $this->writer->write('/DeviceCMYK <<'); - $this->writer->write('/Range [0 1 0 1 0 1 0 1] /C0 [0 0 0 0] '); - $this->writer->write(sprintf('/C1 [%.3F %.3F %.3F %.3F] ', $color['c'] / 100, $color['m'] / 100, $color['y'] / 100, $color['k'] / 100)); + if ($rgb) { + $c = $this->colorModeConverter->cmyk2rgb([4, $color['c'], $color['m'], $color['y'], $color['k']]); + $this->writer->write($alternate . ' <<'); + $this->writer->write('/Range [0 1 0 1 0 1] /C0 [1 1 1] '); + $this->writer->write(sprintf('/C1 [%.3F %.3F %.3F] ', $c[1] / 255, $c[2] / 255, $c[3] / 255)); + } else { + $this->writer->write('/DeviceCMYK <<'); + $this->writer->write('/Range [0 1 0 1 0 1 0 1] /C0 [0 0 0 0] '); + $this->writer->write(sprintf('/C1 [%.3F %.3F %.3F %.3F] ', $color['c'] / 100, $color['m'] / 100, $color['y'] / 100, $color['k'] / 100)); + } $this->writer->write('/FunctionType 2 /Domain [0 1] /N 1>>]'); $this->writer->write('endobj'); diff --git a/src/Writer/FontWriter.php b/src/Writer/FontWriter.php index c3ebcea6b..be3ed0525 100644 --- a/src/Writer/FontWriter.php +++ b/src/Writer/FontWriter.php @@ -164,7 +164,7 @@ public function writeFonts() $this->mpdf->fonts[$k]['n'] = $this->mpdf->n + 1; if ($this->mpdf->PDFA || $this->mpdf->PDFX) { - throw new \Mpdf\MpdfException('Core fonts are not allowed in PDF/A1-b or PDFX/1-a files (Times, Helvetica, Courier etc.)'); + throw new \Mpdf\MpdfException('Core fonts are not allowed in PDF/A1-b or ' . $this->mpdf->pdfxVersionLabel() . ' files (Times, Helvetica, Courier etc.)'); } $this->writer->object(); @@ -211,7 +211,7 @@ public function writeFonts() $subset = $font['subsets'][$sfid]; unset($subset[0]); - $ttfontstream = $subsetter->makeSubsetSIP($font['ttffile'], $subset, $font['TTCfontID'], $this->mpdf->debugfonts, $font['useOTL'], $this->mpdf->pdfaPart() === '1'); // mPDF 5.7.1 + $ttfontstream = $subsetter->makeSubsetSIP($font['ttffile'], $subset, $font['TTCfontID'], $this->mpdf->debugfonts, $font['useOTL'], $this->oneCmapSubtable()); // mPDF 5.7.1 $ttfontsize = strlen($ttfontstream); $fontstream = gzcompress($ttfontstream); $widthstring = ''; @@ -592,6 +592,17 @@ private function type3FontWriter() return $this->type3FontWriter; } + /** + * PDF/A-1 and PDF/X preflight allow a symbolic TrueType font one cmap subtable. PDF/A-2 onwards keeps + * a second, for older readers. + * + * @return bool Whether a symbolic TrueType subset carries its (3,0) cmap subtable alone + */ + private function oneCmapSubtable() + { + return $this->mpdf->pdfaPart() === '1' || (bool) $this->mpdf->PDFX; + } + /** * Write the /W array of a TrueType font: what each character in it is drawn at. * diff --git a/src/Writer/FormWriter.php b/src/Writer/FormWriter.php index 65b9509dc..9d9ceea70 100644 --- a/src/Writer/FormWriter.php +++ b/src/Writer/FormWriter.php @@ -28,6 +28,8 @@ public function __construct(Mpdf $mpdf, BaseWriter $writer) public function writeFormObjects() // _putformobjects { + $initialColor = $this->mpdf->initialColor(); + foreach ($this->mpdf->formobjects as $file => $info) { $this->writer->object(); @@ -49,7 +51,8 @@ public function writeFormObjects() // _putformobjects $this->writer->write('/Filter /FlateDecode'); } - $data = $this->mpdf->compress ? gzcompress($info['data']) : $info['data']; + $data = $initialColor . $info['data']; + $data = $this->mpdf->compress ? gzcompress($data) : $data; $this->writer->write('/Length ' . $this->writer->streamLength($data) . '>>'); $this->writer->stream($data); diff --git a/src/Writer/ImageWriter.php b/src/Writer/ImageWriter.php index ef99839e3..5a7c88d95 100644 --- a/src/Writer/ImageWriter.php +++ b/src/Writer/ImageWriter.php @@ -34,11 +34,23 @@ public function writeImages() { $filter = $this->mpdf->compress ? '/Filter /FlateDecode ' : ''; + // Soft masks, which ISO 32000 has be DeviceGray even where other grey images may not be, by number + $masks = []; + foreach ($this->mpdf->images as $info) { + if (isset($info['masked'])) { + $masks[$info['masked']] = true; + } + } + // The object each image was written at, by its number, which is what a soft mask is named by $written = []; foreach ($this->mpdf->images as $file => $info) { + $calibrated = empty($info['icc']) && ($info['cs'] === 'DeviceRGB' || $info['cs'] === 'Indexed') ? $this->writer->calibratedRgb() : null; + $rgb = $calibrated ? $calibrated . ' 0 R' : '/DeviceRGB'; + $gray = $info['cs'] === 'DeviceGray' && empty($info['icc']) && !isset($masks[$info['i']]) ? $this->writer->grayColorSpace() : '/DeviceGray'; + $this->writer->object(); $this->mpdf->images[$file]['n'] = $this->mpdf->n; @@ -49,7 +61,8 @@ public function writeImages() $this->writer->write('/Width ' . $info['w']); $this->writer->write('/Height ' . $info['h']); - if (isset($info['interpolation']) && $info['interpolation']) { + // PDF/X does not permit interpolation + if (isset($info['interpolation']) && $info['interpolation'] && !$this->mpdf->PDFX) { $this->writer->write('/Interpolate true'); // mPDF 6 - image interpolation shall be performed by a conforming reader } @@ -64,12 +77,18 @@ public function writeImages() $icc = true; $this->writer->write('/ColorSpace [/ICCBased ' . ($this->mpdf->n + 1) . ' 0 R]'); } elseif ($info['cs'] === 'Indexed') { - if ($this->mpdf->PDFX || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3)) { + if ($this->mpdf->isPdfx1a() || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3)) { throw new \Mpdf\MpdfException('PDFA1-b and PDFX/1-a files do not permit using mixed colour space (' . $file . ').'); } - $this->writer->write('/ColorSpace [/Indexed /DeviceRGB ' . (strlen($info['pal']) / 3 - 1) . ' ' . ($this->mpdf->n + 1) . ' 0 R]'); + $this->writer->write('/ColorSpace [/Indexed ' . $rgb . ' ' . (strlen($info['pal']) / 3 - 1) . ' ' . ($this->mpdf->n + 1) . ' 0 R]'); } else { - $this->writer->write('/ColorSpace /' . $info['cs']); + if ($info['cs'] === 'DeviceRGB') { + $this->writer->write('/ColorSpace ' . $rgb); + } elseif ($info['cs'] === 'DeviceGray') { + $this->writer->write('/ColorSpace ' . $gray); + } else { + $this->writer->write('/ColorSpace /' . $info['cs']); + } if ($info['cs'] === 'DeviceCMYK') { if ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace !== 3) { throw new \Mpdf\MpdfException('PDFA1-b does not permit Images using mixed colour space (' . $file . ').'); @@ -77,7 +96,7 @@ public function writeImages() if ($info['type'] === 'jpg') { $this->writer->write('/Decode [1 0 1 0 1 0 1 0]'); } - } elseif (($this->mpdf->PDFX || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3)) && $info['cs'] === 'DeviceRGB') { + } elseif (($this->mpdf->isPdfx1a() || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3)) && $info['cs'] === 'DeviceRGB') { throw new \Mpdf\MpdfException('PDFA1-b and PDFX/1-a files do not permit using mixed colour space (' . $file . ').'); } } diff --git a/src/Writer/MetadataWriter.php b/src/Writer/MetadataWriter.php index b5ef06cad..0ebbdce65 100644 --- a/src/Writer/MetadataWriter.php +++ b/src/Writer/MetadataWriter.php @@ -3,6 +3,7 @@ namespace Mpdf\Writer; use Mpdf\AssetFetcherInterface; +use Mpdf\Color\ColorModeConverter; use Mpdf\File\FileTypeAllowList; use Mpdf\Strict; use Mpdf\Form; @@ -36,6 +37,7 @@ class MetadataWriter implements \Psr\Log\LoggerAwareInterface 'xmp' => 'http://ns.adobe.com/xap/1.0/', 'dc' => 'http://purl.org/dc/elements/1.1/', 'pdfx' => 'http://ns.adobe.com/pdfx/1.3/', + 'pdfxid' => 'http://www.npes.org/pdfx/ns/id/', 'pdfaid' => 'http://www.aiim.org/pdfa/ns/id/', 'xmpMM' => 'http://ns.adobe.com/xap/1.0/mm/', 'pdfaExtension' => 'http://www.aiim.org/pdfa/ns/extension/', @@ -128,6 +130,9 @@ public function writeMetadata() // _putmetadata if (!empty($this->mpdf->keywords)) { $m .= ' ' . htmlspecialchars($this->mpdf->keywords, ENT_QUOTES | ENT_XML1) . '' . "\n"; } + if ($this->mpdf->isPdfx4()) { + $m .= ' False' . "\n"; + } $m .= ' ' . "\n"; $m .= ' xmlns('xmp') . '>' . "\n"; @@ -143,32 +148,32 @@ public function writeMetadata() // _putmetadata $m .= ' xmlns('dc') . '>' . "\n"; $m .= ' application/pdf' . "\n"; if (!empty($this->mpdf->title)) { - $m .= ' - - ' . htmlspecialchars($this->mpdf->title, ENT_QUOTES | ENT_XML1) . ' - - ' . "\n"; + $m .= ' ' . "\n"; + $m .= "\t \n"; + $m .= "\t " . '' . htmlspecialchars($this->mpdf->title, ENT_QUOTES | ENT_XML1) . '' . "\n"; + $m .= "\t \n"; + $m .= "\t\n"; } if (!empty($this->mpdf->keywords)) { - $m .= ' - - ' . htmlspecialchars($this->mpdf->keywords, ENT_QUOTES | ENT_XML1) . ' - - ' . "\n"; + $m .= ' ' . "\n"; + $m .= "\t \n"; + $m .= "\t " . '' . htmlspecialchars($this->mpdf->keywords, ENT_QUOTES | ENT_XML1) . '' . "\n"; + $m .= "\t \n"; + $m .= "\t\n"; } if (!empty($this->mpdf->subject)) { - $m .= ' - - ' . htmlspecialchars($this->mpdf->subject, ENT_QUOTES | ENT_XML1) . ' - - ' . "\n"; + $m .= ' ' . "\n"; + $m .= "\t \n"; + $m .= "\t " . '' . htmlspecialchars($this->mpdf->subject, ENT_QUOTES | ENT_XML1) . '' . "\n"; + $m .= "\t \n"; + $m .= "\t\n"; } if (!empty($this->mpdf->author)) { - $m .= ' - - ' . htmlspecialchars($this->mpdf->author, ENT_QUOTES | ENT_XML1) . ' - - ' . "\n"; + $m .= ' ' . "\n"; + $m .= "\t \n"; + $m .= "\t " . '' . htmlspecialchars($this->mpdf->author, ENT_QUOTES | ENT_XML1) . '' . "\n"; + $m .= "\t \n"; + $m .= "\t\n"; } $m .= ' ' . "\n"; @@ -189,8 +194,9 @@ public function writeMetadata() // _putmetadata } } - // This bit is specific to PDFX-1a - if ($this->mpdf->PDFX) { + if ($this->mpdf->isPdfx4()) { + $m .= ' xmlns('pdfxid') . ' pdfxid:GTS_PDFXVersion="PDF/X-4"/>' . "\n"; + } elseif ($this->mpdf->PDFX) { $m .= ' xmlns('pdfx') . ' pdfx:Apag_PDFX_Checkup="1.3" pdfx:GTS_PDFXConformance="PDF/X-1a:2003" pdfx:GTS_PDFXVersion="PDF/X-1:2003"/>' . "\n"; } elseif ($this->mpdf->PDFA) { list($part, $conformance) = $this->mpdf->pdfaConformance(); @@ -205,6 +211,11 @@ public function writeMetadata() // _putmetadata $m .= ' xmlns('xmpMM') . '>' . "\n"; $m .= ' uuid:' . $uuid . '' . "\n"; + if ($this->mpdf->isPdfx4()) { + $m .= ' uuid:' . $uuid . '' . "\n"; + $m .= ' 1' . "\n"; + $m .= ' default' . "\n"; + } $m .= ' ' . "\n"; $m .= ' ' . "\n"; $m .= ' ' . "\n"; @@ -347,7 +358,7 @@ public function writeInfo() // _putinfo $this->writer->write('/ModDate ' . $this->writer->dateString()); if ($this->mpdf->PDFX) { $this->writer->write('/Trapped/False'); - $this->writer->write('/GTS_PDFXVersion(PDF/X-1a:2003)'); + $this->writer->write('/GTS_PDFXVersion(' . $this->mpdf->pdfxVersionLabel() . ')'); } } @@ -371,13 +382,20 @@ public function writeOutputIntent() // _putoutputintent $this->writer->write('/OutputCondition ()'); } $this->writer->write('/DestOutputProfile ' . ($this->mpdf->n + 1) . ' 0 R'); - } elseif ($this->mpdf->PDFX) { // always a CMYK profile + } elseif ($this->mpdf->PDFX) { // a CMYK condition, except where PDF/X-4 embeds another profile $this->writer->write('/S /GTS_PDFX'); if ($this->mpdf->ICCProfile) { $this->writer->write('/Info (' . $ICCProfile . ')'); $this->writer->write('/OutputConditionIdentifier (Custom)'); $this->writer->write('/OutputCondition ()'); $this->writer->write('/DestOutputProfile ' . ($this->mpdf->n + 1) . ' 0 R'); + } elseif ($this->mpdf->isPdfx4()) { + // Where the document names none, the bundled SWOP profile, characterised by CGATS TR 003 + $this->writer->write('/Info (U.S. Web Coated \\(SWOP\\) grade 3)'); + $this->writer->write('/OutputConditionIdentifier (CGATS TR 003)'); + $this->writer->write('/OutputCondition (SWOP 2006 Coated #3)'); + $this->writer->write('/RegistryName (http://www.color.org)'); + $this->writer->write('/DestOutputProfile ' . ($this->mpdf->n + 1) . ' 0 R'); } else { $this->writer->write('/Info (CGATS TR 001)'); $this->writer->write('/OutputConditionIdentifier (CGATS TR 001)'); @@ -388,20 +406,18 @@ public function writeOutputIntent() // _putoutputintent $this->writer->write('>>'); $this->writer->write('endobj'); - if ($this->mpdf->PDFX && !$this->mpdf->ICCProfile) { + // PDF/X-1a naming no ICCProfile names its condition alone; PDF/A falls back to sRGB + $profile = $this->mpdf->PDFX ? $this->mpdf->pdfxOutputProfile() : ($this->mpdf->ICCProfile ?: BaseWriter::SRGB_PROFILE); + if ($profile === null) { return; } $this->writer->object(); - if ($this->mpdf->ICCProfile) { - if (!file_exists($this->mpdf->ICCProfile)) { - throw new \Mpdf\MpdfException(sprintf('Unable to find ICC profile "%s"', $this->mpdf->ICCProfile)); - } - $s = file_get_contents($this->mpdf->ICCProfile); - } else { - $s = file_get_contents(__DIR__ . '/../../data/iccprofiles/sRGB_IEC61966-2-1.icc'); + if (!file_exists($profile)) { + throw new \Mpdf\MpdfException(sprintf('Unable to find ICC profile "%s"', $profile)); } + $s = file_get_contents($profile); if ($this->mpdf->compress) { $s = gzcompress($s); @@ -409,7 +425,9 @@ public function writeOutputIntent() // _putoutputintent $this->writer->write('<<'); - if ($this->mpdf->PDFX || ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3)) { + if ($this->mpdf->PDFX) { + $this->writer->write('/N ' . $this->mpdf->pdfxOutputChannels()); + } elseif ($this->mpdf->PDFA && $this->mpdf->restrictColorSpace === 3) { $this->writer->write('/N 4'); } else { $this->writer->write('/N 3'); @@ -520,6 +538,9 @@ public function writeCatalog() //_putcatalog // AES-256 encryption is PDF 2.0, or PDF 1.7 with Adobe's extension level 8 if ($this->mpdf->encrypted) { $this->writer->write('/Extensions <>>>'); + } elseif ($this->mpdf->isPdfx4() && version_compare($this->mpdf->pdf_version, '1.6', '<')) { + // PDF/X-4 is based on PDF 1.6, which the header may predate where PDFX was set after it was written + $this->writer->write('/Version /1.6'); } $this->writer->write('/Pages 1 0 R'); @@ -1317,6 +1338,8 @@ private function mayEmbed($file) * The fill operator for an annotation's colour, yellow if it has none * * A spot colour falls back to yellow too, as neither the /C array nor the appearance can name its colour space. + * Both take a device colour, and PDF/X-4 permits only the device colour space its output intent prints to, so + * there the colour is converted to that space: the ICC-based spaces the page content is set in cannot stand in. * * @param mixed[] $opt * @@ -1324,11 +1347,57 @@ private function mayEmbed($file) */ private function annotationFill(array $opt) { - if (empty($opt['c']) || $opt['c'][0] == 2) { - return '1.000 1.000 0.000 rg'; + $c = [3, 255, 255, 0]; + if (!empty($opt['c']) && $opt['c'][0] != 2) { + $c = [(int) $opt['c'][0], ord($opt['c'][1]), ord($opt['c'][2]), ord($opt['c'][3]), ord($opt['c'][4])]; + } + if ($c[0] == 5 || $c[0] == 6) { + $c[0] -= 2; // the annotation's opacity is its /CA, not part of its colour + } + + if ($this->mpdf->isPdfx4()) { + $c = $this->inOutputIntentSpace($c); + } + + if ($c[0] == 1) { + return sprintf('%.3F g', $c[1] / 255); + } + + if ($c[0] == 4) { + return sprintf('%.3F %.3F %.3F %.3F k', $c[1] / 100, $c[2] / 100, $c[3] / 100, $c[4] / 100); + } + + return sprintf('%.3F %.3F %.3F rg', $c[1] / 255, $c[2] / 255, $c[3] / 255); + } + + /** + * @param float[] $c A grey, RGB or CMYK colour, as ColorModeConverter takes it + * + * @return float[] The colour in the device colour space the PDF/X-4 output intent prints to + */ + private function inOutputIntentSpace(array $c) + { + $converter = new ColorModeConverter(); + $channels = $this->mpdf->pdfxOutputChannels(); + + if ($c[0] == 1 && $channels !== 1) { + // DeviceGray is the black of a CMYK intent, and is not permitted under an RGB one + return $channels === 4 ? $c : [3, $c[1], $c[1], $c[1]]; + } + + if ($c[0] == 4 && $channels !== 4) { + $c = $converter->cmyk2rgb($c); + } + + if ($c[0] == 3 && $channels === 4) { + return $converter->rgb2cmyk($c); + } + + if ($c[0] == 3 && $channels === 1) { + return $converter->rgb2gray($c); } - return $this->mpdf->SetColor($opt['c']); + return $c; } /** diff --git a/src/Writer/PageWriter.php b/src/Writer/PageWriter.php index 0758dc8f4..e6296e1e8 100644 --- a/src/Writer/PageWriter.php +++ b/src/Writer/PageWriter.php @@ -4,6 +4,7 @@ use Mpdf\Strict; use Mpdf\Mpdf; +use Mpdf\Form; final class PageWriter { @@ -15,6 +16,11 @@ final class PageWriter */ private $mpdf; + /** + * @var \Mpdf\Form + */ + private $form; + /** * @var \Mpdf\Writer\BaseWriter */ @@ -25,15 +31,20 @@ final class PageWriter */ private $metadataWriter; - public function __construct(Mpdf $mpdf, BaseWriter $writer, MetadataWriter $metadataWriter) + public function __construct(Mpdf $mpdf, Form $form, BaseWriter $writer, MetadataWriter $metadataWriter) { $this->mpdf = $mpdf; + $this->form = $form; $this->writer = $writer; $this->metadataWriter = $metadataWriter; } public function writePages() // _putpages { + if ($this->mpdf->PDFX) { + $this->removePdfxAnnotations(); + } + $nb = $this->mpdf->page; $filter = $this->mpdf->compress ? '/Filter /FlateDecode ' : ''; @@ -46,6 +57,7 @@ public function writePages() // _putpages } $annots = $this->metadataWriter->numberAnnotations(3 + 2 * $nb); + $initialColor = $this->mpdf->initialColor(); // Select unused fonts (usually default font) $unused = []; @@ -102,6 +114,8 @@ public function writePages() // _putpages $thispage = preg_replace('/(\% BTR.*?\% ETR)/is', '', $thispage, 1) . "\n" . $m[2]; } + $thispage = $initialColor . $thispage; + // Page $this->writer->object(); $this->writer->write('<writer->write('/Resources 2 0 R'); // Important to keep in RGB colorSpace when using transparency - if ($this->mpdf->transparencyAllowed()) { + if ($this->mpdf->isPdfx4()) { + // Blended in the colour space of the output intent + $spaces = [1 => '/DeviceGray', 3 => '/DeviceRGB', 4 => '/DeviceCMYK']; + $this->writer->write('/Group << /Type /Group /S /Transparency /CS ' . $spaces[$this->mpdf->pdfxOutputChannels()] . ' >> '); + } elseif ($this->mpdf->transparencyAllowed()) { if ($this->mpdf->restrictColorSpace === 3) { $this->writer->write('/Group << /Type /Group /S /Transparency /CS /DeviceCMYK >> '); } elseif ($this->mpdf->restrictColorSpace === 1) { @@ -200,4 +218,77 @@ public function writePages() // _putpages $this->writer->write('endobj'); } + /** + * PDF/X permits no interactive form field, no file attachment, and no other annotation within the + * area printed: the BleedBox, or the TrimBox where there is no bleed. Each is removed before the + * annotations are counted, which numbers their objects. + */ + private function removePdfxAnnotations() + { + $removed = count($this->form->forms) > 0 || count($this->form->form_radio_groups) > 0; + $this->form->forms = []; + $this->form->form_radio_groups = []; + + for ($n = 1; $n <= $this->mpdf->page; $n++) { + $area = $this->printedArea($n); + + if (isset($this->mpdf->PageLinks[$n])) { + foreach ($this->mpdf->PageLinks[$n] as $key => $pl) { + if (self::overlaps([$pl[0], $pl[1] - $pl[3], $pl[0] + $pl[2], $pl[1]], $area)) { + unset($this->mpdf->PageLinks[$n][$key]); + $removed = true; + } + } + } + + if (isset($this->mpdf->PageAnnots[$n])) { + $heightPt = $this->mpdf->pageDim[$n]['h'] * Mpdf::SCALE; + foreach ($this->mpdf->PageAnnots[$n] as $key => $pl) { + // The marker, placed as MetadataWriter::writeAnnotations() places it + $x = $pl['x']; + if ($this->mpdf->annotMargin != 0 || $x <= 0) { + $x = $this->mpdf->pageDim[$n]['w'] - $this->mpdf->annotMargin; + } + $left = $x * Mpdf::SCALE; + $top = $heightPt - $pl['y'] * Mpdf::SCALE; + + if (!empty($pl['opt']['file']) || self::overlaps([$left, $top - 20, $left + 20, $top], $area)) { + unset($this->mpdf->PageAnnots[$n][$key]); + $removed = true; + } + } + } + } + + if ($removed && !$this->mpdf->PDFXauto) { + $this->mpdf->PDFAXwarnings[] = 'Form fields, file attachments and annotations within the TrimBox or BleedBox are not permitted in ' . $this->mpdf->pdfxVersionLabel() . ' files. (Removed)'; + } + } + + /** + * @param int $n A page + * + * @return float[] [xMin, yMin, xMax, yMax] of its BleedBox, or its TrimBox where it has no bleed, in points + */ + private function printedArea($n) + { + $dim = $this->mpdf->pageDim[$n]; + $x = $dim['outer_width_LR'] * Mpdf::SCALE; + $y = $dim['outer_width_TB'] * Mpdf::SCALE; + $bleed = $x || $y ? $dim['bleedMargin'] * Mpdf::SCALE : 0; + + return [$x - $bleed, $y - $bleed, $dim['w'] * Mpdf::SCALE - $x + $bleed, $dim['h'] * Mpdf::SCALE - $y + $bleed]; + } + + /** + * @param float[] $a [xMin, yMin, xMax, yMax] + * @param float[] $b [xMin, yMin, xMax, yMax] + * + * @return bool Whether the two rectangles overlap + */ + private static function overlaps(array $a, array $b) + { + return $a[0] < $b[2] && $a[2] > $b[0] && $a[1] < $b[3] && $a[3] > $b[1]; + } + } diff --git a/src/Writer/ResourceWriter.php b/src/Writer/ResourceWriter.php index 9a5f1f45a..cf1776664 100644 --- a/src/Writer/ResourceWriter.php +++ b/src/Writer/ResourceWriter.php @@ -123,6 +123,10 @@ public function writeResources() // _putresources $this->backgroundWriter->writeShaders(); $this->backgroundWriter->writePatterns(); + // Written before the dictionary that names it, since it is an object of its own + $calibratedRgb = $this->mpdf->usesCalibratedRgb() ? $this->writer->calibratedRgb() : null; + $calibratedGray = $this->mpdf->usesCalibratedGray() ? $this->writer->calibratedGray() : null; + // Resource dictionary $this->mpdf->offsets[2] = $this->mpdf->buffer->getLength(); $this->writer->write('2 0 obj'); @@ -143,8 +147,14 @@ public function writeResources() // _putresources } $this->writer->write('>>'); - if (count($this->mpdf->spotColors)) { + if (count($this->mpdf->spotColors) || $calibratedRgb || $calibratedGray) { $this->writer->write('/ColorSpace <<'); + if ($calibratedRgb) { + $this->writer->write('/' . BaseWriter::CALIBRATED_RGB . ' ' . $calibratedRgb . ' 0 R'); + } + if ($calibratedGray) { + $this->writer->write('/' . BaseWriter::CALIBRATED_GRAY . ' ' . $calibratedGray . ' 0 R'); + } foreach ($this->mpdf->spotColors as $color) { $this->writer->write('/CS' . $color['i'] . ' ' . $color['n'] . ' 0 R'); } diff --git a/src/Writer/Type3FontWriter.php b/src/Writer/Type3FontWriter.php index 4b200a340..2c7700562 100644 --- a/src/Writer/Type3FontWriter.php +++ b/src/Writer/Type3FontWriter.php @@ -5,6 +5,7 @@ use Mpdf\Fonts\Color\ColorFontFile; use Mpdf\Fonts\Color\ColorFormats; use Mpdf\Fonts\Color\ColorGlyphSource; +use Mpdf\Fonts\Color\Geometry; use Mpdf\Fonts\Color\GlyphResources; use Mpdf\Fonts\FontCache; use Mpdf\Image\ImageProcessor; @@ -83,7 +84,8 @@ class Type3FontWriter implements GlyphResources * Mpdf::$images; 'states', numbers in Mpdf::$extgstates; 'shadings', keys in $shadings; * 'forms', each group by a key of its content, as ['name', 'content', 'box', 'group' => * what its /Group dictionary adds]; 'masks', each soft mask by a key, as ['name', 'form' - * => the name of the group it is drawn from, 'luminosity', 'inverted'] + * => the name of the group it is drawn from, 'luminosity', 'inverted']; 'colorSpaces', + * name => object number of each colour space its content sets by name */ private $drawn = []; @@ -233,6 +235,55 @@ public function image($data) return ['/I' . $image['i'], $image['w'], $image['h']]; } + /** + * RGB is DeviceRGB, or where the document may not use DeviceRGB, the ICC-based sRGB colour space + * BaseWriter::calibratedRgb() writes, set by name + * + * @inheritdoc + */ + public function rgb(array $rgb, $stroking = false) + { + $calibrated = $this->writer->calibratedRgb(); + if ($calibrated === null) { + return vsprintf('%.3F %.3F %.3F ', $rgb) . ($stroking ? 'RG' : 'rg'); + } + + return $this->setByName(BaseWriter::CALIBRATED_RGB, $calibrated, vsprintf('%.3F %.3F %.3F', $rgb), $stroking); + } + + /** + * Grey is DeviceGray, or where the document may not use DeviceGray, the ICC-based grey colour space + * BaseWriter::calibratedGray() writes, set by name + * + * @inheritdoc + */ + public function gray($level) + { + $calibrated = $this->writer->calibratedGray(); + if ($calibrated === null) { + return Geometry::number($level) . ' g'; + } + + return $this->setByName(BaseWriter::CALIBRATED_GRAY, $calibrated, Geometry::number($level), false); + } + + /** + * Registers an ICC-based colour space on the subset being written, for its resources to name + * + * @param string $name What the content sets it by, e.g. 'CSRGB' + * @param int $object Its object number + * @param string $components The colour in it, e.g. '1.000 0.800 0.200' + * @param bool $stroking Whether it is the colour strokes are painted in, rather than fills + * + * @return string Content setting that colour in that colour space + */ + private function setByName($name, $object, $components, $stroking) + { + $this->drawn['colorSpaces'][$name] = $object; + + return '/' . $name . ($stroking ? ' CS ' : ' cs ') . $components . ($stroking ? ' SC' : ' sc'); + } + /** * Registers a graphics state painting at an opacity, once however many glyphs paint at it * @@ -299,7 +350,7 @@ public function group($content, array $box, $isolated = false) public function softMask($content, array $box, $luminosity = false, $inverted = false) { // A mask of brightness is drawn in grey - $form = $this->form($content, $box, $luminosity ? ' /CS /DeviceGray' : ''); + $form = $this->form($content, $box, $luminosity ? ' /CS ' . $this->writer->grayColorSpace() : ''); $key = $form . ($luminosity ? '-luminosity' : '-alpha') . ($inverted ? '-inverted' : ''); if (!isset($this->drawn['masks'][$key])) { $this->drawn['masks'][$key] = ['name' => 'SM' . ++$this->named, 'form' => $form, 'luminosity' => $luminosity, 'inverted' => $inverted]; @@ -403,7 +454,7 @@ public function writeResources() } foreach ($this->resources as $object => $drawn) { - $objects = ['XObject' => [], 'ExtGState' => [], 'Shading' => []]; + $objects = ['XObject' => [], 'ExtGState' => [], 'Shading' => [], 'ColorSpace' => $drawn['colorSpaces']]; foreach ($drawn['images'] as $key) { $objects['XObject']['I' . $this->mpdf->images[$key]['i']] = $this->mpdf->images[$key]['n']; } @@ -448,7 +499,7 @@ private function shadingDictionary(array $shading) return sprintf( '<>', count($shading['coords']) === 4 ? 2 : 3, - self::colourSpace($shading['stops']), + $this->colourSpace($shading['stops']), self::values($shading['coords']), self::stopFunction($shading['stops']) ); @@ -490,7 +541,7 @@ private function writeMesh(array $shading) $this->writeStream(sprintf( '/ShadingType 4 /ColorSpace %s /BitsPerCoordinate 16 /BitsPerComponent 16 /BitsPerFlag 8 /Decode [%s 0 1] /Function %s', - self::colourSpace($shading['stops']), + $this->colourSpace($shading['stops']), self::values($decode), self::stopFunction($shading['stops']) ), $data); @@ -501,11 +552,17 @@ private function writeMesh(array $shading) /** * @param array[] $stops As GlyphResources::shading() takes them * - * @return string /DeviceGray or /DeviceRGB, as the stops' colours are + * @return string Grey or RGB, as the stops' colours are - see gray() and rgb() */ - private static function colourSpace(array $stops) + private function colourSpace(array $stops) { - return count($stops[0][1]) === 1 ? '/DeviceGray' : '/DeviceRGB'; + if (count($stops[0][1]) === 1) { + return $this->writer->grayColorSpace(); + } + + $calibrated = $this->writer->calibratedRgb(); + + return $calibrated === null ? '/DeviceRGB' : $calibrated . ' 0 R'; } /** @@ -641,7 +698,7 @@ private function writeSubset(array $font, array $subset, array $sources, array $ $widths = []; $procedures = []; $differences = ''; - $this->drawn = ['images' => [], 'states' => [], 'shadings' => [], 'forms' => [], 'masks' => []]; + $this->drawn = ['images' => [], 'states' => [], 'shadings' => [], 'forms' => [], 'masks' => [], 'colorSpaces' => []]; foreach ($subset as $code => $char) { $width = '0.000'; if ($char && isset($charToGlyph[$char])) { diff --git a/tests/Mpdf/AutoVisibilityTest.php b/tests/Mpdf/AutoVisibilityTest.php index 4fdec4adf..f5bc9fcf4 100644 --- a/tests/Mpdf/AutoVisibilityTest.php +++ b/tests/Mpdf/AutoVisibilityTest.php @@ -48,7 +48,7 @@ public function autoVisibilities() $configs = [ 'PDF/A-1b' => [self::PDFA1_AUTO, false], 'PDF/A-2b' => [['PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '2-B'], true], - 'PDF/X-1a' => [['PDFX' => true, 'PDFXauto' => true], false], + 'PDF/X-1a' => [['PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true], false], ]; $cases = []; @@ -89,7 +89,7 @@ public function refusedVisibilities() { $pdfa1 = ['PDFA' => true, 'PDFAversion' => '1-B']; $pdfa2 = ['PDFA' => true, 'PDFAversion' => '2-B']; - $pdfx = ['PDFX' => true]; + $pdfx = ['PDFX' => true, 'PDFXversion' => '1a']; return [ 'PDF/A-1b, printonly' => [$pdfa1, 'printonly'], diff --git a/tests/Mpdf/Color/ColorSpaceRestrictorTest.php b/tests/Mpdf/Color/ColorSpaceRestrictorTest.php index 007e6c77a..57f702710 100644 --- a/tests/Mpdf/Color/ColorSpaceRestrictorTest.php +++ b/tests/Mpdf/Color/ColorSpaceRestrictorTest.php @@ -41,7 +41,7 @@ public function alphaColors() 'CMYK PDF/A-2 draws rgba() as cmyka()' => [$pdfa2 + $cmyk, [5, 255, 0, 0, 0.5], [6, 0, 100, 100, 0, 0.5]], 'CMYK PDF/A-1 draws cmyka() as CMYK' => [$pdfa1 + $cmyk, [6, 0, 100, 100, 0, 0.5], [4, 0, 100, 100, 0]], 'CMYK PDF/A-2 keeps cmyka()' => [$pdfa2 + $cmyk, [6, 0, 100, 100, 0, 0.5], [6, 0, 100, 100, 0, 0.5]], - 'PDF/X draws cmyka() as CMYK' => [['PDFX' => true], [6, 0, 100, 100, 0, 0.5], [4, 0, 100, 100, 0]], + 'PDF/X draws cmyka() as CMYK' => [['PDFX' => true, 'PDFXversion' => '1a'], [6, 0, 100, 100, 0, 0.5], [4, 0, 100, 100, 0]], ]; } diff --git a/tests/Mpdf/Color/GrayIccProfile.php b/tests/Mpdf/Color/GrayIccProfile.php new file mode 100644 index 000000000..c46d50e99 --- /dev/null +++ b/tests/Mpdf/Color/GrayIccProfile.php @@ -0,0 +1,108 @@ + self::description('Gray, sRGB tone curve (mPDF)'), + 'cprt' => 'text' . pack('N', 0) . self::COPYRIGHT . "\0", + 'wtpt' => $d50, + 'kTRC' => self::curve(), + ]; + + // The header is 128 bytes, followed by the tag count and a 12-byte entry for each tag + $offset = 128 + 4 + 12 * count($tags); + $table = pack('N', count($tags)); + $data = ''; + foreach ($tags as $signature => $tag) { + $table .= $signature . pack('NN', $offset + strlen($data), strlen($tag)); + $data .= str_pad($tag, (int) ceil(strlen($tag) / 4) * 4, "\0"); + } + + $header = pack('N', 128 + strlen($table) + strlen($data)) + . pack('N', 0) // preferred CMM: none + . pack('N', 0x02100000) // version 2.1 + . 'mntr' // device class: display + . 'GRAY' // data colour space + . 'XYZ ' // profile connection space + . pack('n6', 2026, 1, 1, 0, 0, 0) // created, fixed so that building it again gives the same file + . 'acsp' + . str_repeat("\0", 24) // platform, flags, manufacturer, model and attributes + . pack('N', 0) // rendering intent: perceptual + . substr($d50, 8) // illuminant: D50, the tag's values alone + . str_repeat("\0", 48); // creator and reserved + + return $header . $table . $data; + } + + /** + * @param string $text ASCII + * + * @return string A textDescriptionType tag, with no Unicode or ScriptCode description + */ + private static function description($text) + { + return 'desc' . pack('NN', 0, strlen($text) + 1) . $text . "\0" . pack('NNnC', 0, 0, 0, 0) . str_repeat("\0", 67); + } + + /** + * @param float $x + * @param float $y + * @param float $z + * + * @return string An XYZType tag, each value an s15Fixed16Number + */ + private static function xyz($x, $y, $z) + { + return 'XYZ ' . pack('NNNN', 0, round($x * 65536), round($y * 65536), round($z * 65536)); + } + + /** + * @return string A curveType tag of sRGB's transfer function, from IEC 61966-2-1 + */ + private static function curve() + { + $curve = 'curv' . pack('NN', 0, self::CURVE_ENTRIES); + for ($i = 0; $i < self::CURVE_ENTRIES; $i++) { + $v = $i / (self::CURVE_ENTRIES - 1); + $linear = $v <= 0.04045 ? $v / 12.92 : pow(($v + 0.055) / 1.055, 2.4); + $curve .= pack('n', (int) round($linear * 65535)); + } + + return $curve; + } +} diff --git a/tests/Mpdf/Color/GrayIccProfileTest.php b/tests/Mpdf/Color/GrayIccProfileTest.php new file mode 100644 index 000000000..ac8247339 --- /dev/null +++ b/tests/Mpdf/Color/GrayIccProfileTest.php @@ -0,0 +1,131 @@ +profile = file_get_contents(GrayIccProfile::FILE); + } + + /** + * A version 2.1 display profile from grey to XYZ under D50, which says how long it is + */ + public function testTheHeaderDescribesAGreyDisplayProfile() + { + $header = unpack('Nsize/Ncmm/Nversion/a4class/a4space/a4pcs', substr($this->profile, 0, 24)); + + $this->assertSame(strlen($this->profile), $header['size']); + $this->assertSame(0x02100000, $header['version']); + $this->assertSame('mntr', $header['class']); + $this->assertSame('GRAY', $header['space']); + $this->assertSame('XYZ ', $header['pcs']); + $this->assertSame('acsp', substr($this->profile, 36, 4)); + $this->assertSame([0.9642, 1.0, 0.8249], $this->xyz(substr($this->profile, 68, 12)), 'the illuminant, D50'); + } + + /** + * The four tags a monochrome profile needs, each within the profile and on a four-byte boundary + */ + public function testTheTagTableHoldsTheTagsAMonochromeProfileNeeds() + { + $tags = $this->tags(); + + $this->assertSame(['desc', 'cprt', 'wtpt', 'kTRC'], array_keys($tags)); + foreach ($tags as $signature => $tag) { + $this->assertSame(0, $tag['offset'] % 4, $signature); + $this->assertLessThanOrEqual(strlen($this->profile), $tag['offset'] + $tag['size'], $signature); + } + + $this->assertStringStartsWith('desc', $this->tag('desc')); + $this->assertStringContainsString("Gray, sRGB tone curve (mPDF)\0", $this->tag('desc')); + $this->assertSame('text' . pack('N', 0) . GrayIccProfile::COPYRIGHT . "\0", $this->tag('cprt'), 'mPDF\'s licence'); + $this->assertSame('XYZ ', substr($this->tag('wtpt'), 0, 4)); + $this->assertSame([0.9642, 1.0, 0.8249], $this->xyz(substr($this->tag('wtpt'), 8)), 'the media white point, D50'); + } + + /** + * The tone curve is sRGB's, from black to white, so that grey g is the sRGB neutral (g, g, g) + */ + public function testTheToneCurveIsSrgbs() + { + $curve = $this->tag('kTRC'); + $this->assertSame('curv', substr($curve, 0, 4)); + + $count = unpack('N', substr($curve, 8, 4))[1]; + $this->assertSame(GrayIccProfile::CURVE_ENTRIES, $count); + $this->assertSame(12 + 2 * $count, strlen($curve)); + + $entries = array_values(unpack('n*', substr($curve, 12))); + $this->assertSame(0, $entries[0]); + $this->assertSame(65535, $entries[$count - 1]); + + // The linear segment near black, and the power segment above it + $this->assertSame((int) round(10 / 1023 / 12.92 * 65535), $entries[10]); + $this->assertSame((int) round(pow((512 / 1023 + 0.055) / 1.055, 2.4) * 65535), $entries[512]); + } + + /** + * The file is what the generator builds, so neither changes without the other - composer + * grayprofile:update rewrites it + */ + public function testTheFileIsWhatTheGeneratorBuilds() + { + $this->assertSame(GrayIccProfile::build(), $this->profile); + } + + /** + * @return array[] Each tag's offset and size, by signature, in the order of the tag table + */ + private function tags() + { + $count = unpack('N', substr($this->profile, 128, 4))[1]; + + $tags = []; + for ($i = 0; $i < $count; $i++) { + $entry = unpack('a4signature/Noffset/Nsize', substr($this->profile, 132 + 12 * $i, 12)); + $tags[$entry['signature']] = ['offset' => $entry['offset'], 'size' => $entry['size']]; + } + + return $tags; + } + + /** + * @param string $signature + * + * @return string The tag's data + */ + private function tag($signature) + { + $tag = $this->tags()[$signature]; + + return substr($this->profile, $tag['offset'], $tag['size']); + } + + /** + * @param string $data Three s15Fixed16Numbers + * + * @return float[] Them, to four places + */ + private function xyz($data) + { + return array_map(function ($value) { + return round($value / 65536, 4); + }, array_values(unpack('N3', $data))); + } +} diff --git a/tests/Mpdf/ColorFontTest.php b/tests/Mpdf/ColorFontTest.php index 44e23f1d7..f187ceaa3 100644 --- a/tests/Mpdf/ColorFontTest.php +++ b/tests/Mpdf/ColorFontTest.php @@ -313,6 +313,7 @@ public function testAColrFontIsDrawnFromItsOutlinesWhereColourIsOff(array $confi { $logger = new TestLogger(); $mpdf = $this->mpdf($config + ['default_font' => 'colr']); + $mpdf->SetTitle('Document'); // PDF/X asks for one, and would warn of its own accord $mpdf->setLogger($logger); $mpdf->WriteHTML('

' . UtfString::code2utf(0x1F600) . '

'); @@ -358,6 +359,7 @@ public function testAColourFontThatMayNotBeDrawnInColourDrawsNothingAndSaysSo(ar { $logger = new TestLogger(); $mpdf = $this->mpdf($config); + $mpdf->SetTitle('Document'); // PDF/X asks for one, and would warn of its own accord $mpdf->setLogger($logger); $mpdf->WriteHTML('

' . UtfString::code2utf(0x1F600) . '

'); @@ -384,8 +386,8 @@ public function restrictions() 'restrictColorSpace' => [['restrictColorSpace' => 1]], 'PDF/A' => [['PDFA' => true]], 'PDF/A, fixed automatically' => [['PDFA' => true, 'PDFAauto' => true]], - 'PDF/X' => [['PDFX' => true]], - 'PDF/X, fixed automatically' => [['PDFX' => true, 'PDFXauto' => true]], + 'PDF/X' => [['PDFX' => true, 'PDFXversion' => '1a']], + 'PDF/X, fixed automatically' => [['PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true]], ]; } diff --git a/tests/Mpdf/DeviceGrayMpdf.php b/tests/Mpdf/DeviceGrayMpdf.php new file mode 100644 index 000000000..90b48a9d0 --- /dev/null +++ b/tests/Mpdf/DeviceGrayMpdf.php @@ -0,0 +1,21 @@ +images), 64, 64]; } + /** + * @inheritdoc + */ + public function rgb(array $rgb, $stroking = false) + { + return vsprintf('%.3F %.3F %.3F ', $rgb) . ($stroking ? 'RG' : 'rg'); + } + + /** + * @inheritdoc + */ + public function gray($level) + { + return Geometry::number($level) . ' g'; + } + /** * @inheritdoc */ diff --git a/tests/Mpdf/FormAppearanceTest.php b/tests/Mpdf/FormAppearanceTest.php index 0e3fa7614..53195bfc3 100644 --- a/tests/Mpdf/FormAppearanceTest.php +++ b/tests/Mpdf/FormAppearanceTest.php @@ -311,7 +311,7 @@ public function testPdfxCheckboxesNeedNoCoreFont() { $pdf = $this->render( '
', - ['mode' => 'utf-8', 'PDFX' => true, 'PDFXauto' => true, 'useActiveForms' => true] + ['mode' => 'utf-8', 'PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true, 'useActiveForms' => true] ); $this->assertStringNotContainsString('/BaseFont /ZapfDingbats', $pdf); diff --git a/tests/Mpdf/Image/SvgColorAlphaTest.php b/tests/Mpdf/Image/SvgColorAlphaTest.php index e85e62570..5638c845e 100644 --- a/tests/Mpdf/Image/SvgColorAlphaTest.php +++ b/tests/Mpdf/Image/SvgColorAlphaTest.php @@ -101,7 +101,7 @@ public function noTransparencyProvider() { return [ 'PDF/A-1b' => [['mode' => 'utf-8', 'PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '1-B']], - 'PDF/X-1a' => [['mode' => 'utf-8', 'PDFX' => true, 'PDFXauto' => true]], + 'PDF/X-1a' => [['mode' => 'utf-8', 'PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true]], ]; } diff --git a/tests/Mpdf/PDFA1Test.php b/tests/Mpdf/PDFA1Test.php index f63c88422..0bb284fe4 100644 --- a/tests/Mpdf/PDFA1Test.php +++ b/tests/Mpdf/PDFA1Test.php @@ -74,7 +74,7 @@ public function gradientDocuments() $documents = [ 'PDF/A-1b' => [['PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '1-B'], false], 'PDF/A-2b' => [['PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '2-B'], true], - 'PDF/X-1a' => [['PDFX' => true, 'PDFXauto' => true], false], + 'PDF/X-1a' => [['PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true], false], ]; $cases = []; diff --git a/tests/Mpdf/PDFA2Test.php b/tests/Mpdf/PDFA2Test.php index d978d7059..297e6b0e5 100644 --- a/tests/Mpdf/PDFA2Test.php +++ b/tests/Mpdf/PDFA2Test.php @@ -35,7 +35,7 @@ public function transparencyConfigs() 'PDF/A-2b' => [['PDFA' => true, 'PDFAversion' => '2-B'], true], 'PDF/A-2u' => [['PDFA' => true, 'PDFAversion' => '2-U'], true], 'PDF/A-3b' => [['PDFA' => true, 'PDFAversion' => '3-B'], true], - 'PDF/X-1a' => [['PDFX' => true], false], + 'PDF/X-1a' => [['PDFX' => true, 'PDFXversion' => '1a'], false], ]; } @@ -204,7 +204,7 @@ public function annotationConfigs() /** * An annotation keeps its subject under PDF/A-2 and PDF/A-3, which are based on PDF 1.7, and loses it under - * PDF/A-1 and PDF/X-1a, which predate /Subj + * PDF/A-1, which predates /Subj. PDF/X has no annotation on the page to keep a subject * * @dataProvider subjectConfigs */ @@ -228,7 +228,6 @@ public function subjectConfigs() 'PDF/A-1b' => [$this->pdfaConfig('1-B'), false], 'PDF/A-2b' => [$this->pdfaConfig('2-B'), true], 'PDF/A-3b' => [$this->pdfaConfig('3-B'), true], - 'PDF/X-1a' => [['PDFX' => true, 'PDFXauto' => true], false], ]; } diff --git a/tests/Mpdf/Pdf/DocumentProfileTest.php b/tests/Mpdf/Pdf/DocumentProfileTest.php index ea711ffde..6c55f6887 100644 --- a/tests/Mpdf/Pdf/DocumentProfileTest.php +++ b/tests/Mpdf/Pdf/DocumentProfileTest.php @@ -20,7 +20,8 @@ public function configProvider() 'plain' => [[], '1.4', [], []], 'PDF/A-1b' => [['PDFA' => true, 'PDFAversion' => '1-B'], '1.4', [DocumentProfile::PDFA => '1-B'], [DocumentProfile::PDFA => 'PDF/A-1b']], 'PDF/A-3u' => [['PDFA' => true, 'PDFAversion' => '3-u'], '1.7', [DocumentProfile::PDFA => '3-U'], [DocumentProfile::PDFA => 'PDF/A-3u']], - 'PDF/X-1a' => [['PDFX' => true], '1.4', [DocumentProfile::PDFX => '1a'], [DocumentProfile::PDFX => 'PDF/X-1a:2003']], + 'PDF/X-1a' => [['PDFX' => true, 'PDFXversion' => '1a'], '1.4', [DocumentProfile::PDFX => '1a'], [DocumentProfile::PDFX => 'PDF/X-1a:2003']], + 'PDF/X-4' => [['PDFX' => true], '1.6', [DocumentProfile::PDFX => '4'], [DocumentProfile::PDFX => 'PDF/X-4']], ]; } diff --git a/tests/Mpdf/PdfHeaderVersionTest.php b/tests/Mpdf/PdfHeaderVersionTest.php index 2c83cbf0f..f7f874eb5 100644 --- a/tests/Mpdf/PdfHeaderVersionTest.php +++ b/tests/Mpdf/PdfHeaderVersionTest.php @@ -87,7 +87,7 @@ public function headerProvider() $mpdf->EndLayer(); }, '1.4'], 'PDF/A-1b keeps 1.4 over an imported PDF 1.6 page' => [['mode' => '', 'PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '1-B'], self::import('1.6'), '1.4'], - 'PDF/X-1a keeps 1.4 over an imported PDF 1.6 page' => [['mode' => '', 'PDFX' => true, 'PDFXauto' => true], self::import('1.6'), '1.4'], + 'PDF/X-1a keeps 1.4 over an imported PDF 1.6 page' => [['mode' => '', 'PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true], self::import('1.6'), '1.4'], ]; } diff --git a/tests/Mpdf/PdfX4Test.php b/tests/Mpdf/PdfX4Test.php new file mode 100644 index 000000000..327f5ac82 --- /dev/null +++ b/tests/Mpdf/PdfX4Test.php @@ -0,0 +1,1611 @@ + true, 'PDFXversion' => '1a']; + + /** + * @var string The path a CMYK profile is written to, for a CMYK output intent + */ + private $cmykProfile; + + /** + * @var string The path a grey profile is written to, for a grey output intent + */ + private $grayProfile; + + /** + * @var string The path an RGB profile is written to, for an RGB output intent + */ + private $rgbProfile; + + /** + * Writes the CMYK, grey and RGB profiles the tests that ask for those output intents name. + * + * mPDF reads only the header of the profile it is given, to count its colour components and check + * its class, before embedding it whole. So rather than embed the 2.7 MB bundled SWOP profile in each + * document, the tests write printer profiles that are a header alone, carrying no tags. + */ + public function set_up() + { + $this->cmykProfile = $this->writeProfile('cmyk', 'CMYK'); + $this->grayProfile = $this->writeProfile('gray', 'GRAY'); + $this->rgbProfile = $this->writeProfile('rgb', 'RGB '); + } + + /** + * Leaves no profile behind + */ + public function tear_down() + { + foreach (glob(sys_get_temp_dir() . '/mpdf-test-*.icc') as $profile) { + unlink($profile); + } + } + + /** + * @param string $name Names the file + * @param string $space The data colour space the profile prints to, e.g. 'CMYK' or 'Lab ' + * @param string $class The device class, 'prtr' for a printer or 'mntr' for a display + * + * @return string The path of an ICC version 2.1 profile of that class and space that is a header alone, carrying no tags + */ + private function writeProfile($name, $space, $class = 'prtr') + { + $path = sys_get_temp_dir() . '/mpdf-test-' . $name . '.icc'; + + $header = str_repeat("\0", 128); + $header = substr_replace($header, pack('N', 0x02100000), 8, 4); // ICC version 2.1 + $header = substr_replace($header, $class, 12, 4); // device class + $header = substr_replace($header, $space, 16, 4); // data colour space + $header = substr_replace($header, 'Lab ', 20, 4); // profile connection space + $header = substr_replace($header, 'acsp', 36, 4); // the file signature every profile carries + + $profile = $header . pack('N', 0); // a tag table of no tags + $profile = substr_replace($profile, pack('N', strlen($profile)), 0, 4); + + file_put_contents($path, $profile); + + return $path; + } + + /** + * @param array $config Merged over automatic fixing and no compression + * @param string $class Mpdf, or a subclass of it + * + * @return Mpdf A document titled, as PDF/X requires - the fallback where it is not is tested below + */ + private function mpdf(array $config = [], $class = Mpdf::class) + { + $mpdf = new $class($config + ['mode' => 'utf-8', 'PDFXauto' => true]); + $mpdf->SetCompression(false); + $mpdf->SetTitle('Document'); + + return $mpdf; + } + + /** + * @param array $config Merged over mpdf()'s configuration + * @param string $html + * @param callable|null $before Handed the document before the HTML is written + * + * @return string The document + */ + private function pdf(array $config, $html = '

Text

', $before = null) + { + $mpdf = $this->mpdf($config); + if ($before) { + $before($mpdf); + } + $mpdf->WriteHTML($html); + + return $mpdf->OutputBinaryData(); + } + + /** + * PDFX writes PDF/X-4 unless PDFXversion asks for PDF/X-1a, as PDFA writes PDF/A-2b unless PDFAversion + * asks for another + */ + public function testPdfxVersionNamesTheVersion() + { + $pdfx4 = $this->mpdf(['PDFX' => true]); + $this->assertSame('PDF/X-4', $pdfx4->pdfxVersionLabel()); + $this->assertTrue($pdfx4->isPdfx4()); + $this->assertFalse($pdfx4->isPdfx1a()); + $this->assertTrue($this->mpdf(['PDFX' => true, 'PDFXversion' => 4])->isPdfx4(), 'the version may be given as a number'); + + foreach (['1a', '1A'] as $version) { + $pdfx1a = $this->mpdf(['PDFX' => true, 'PDFXversion' => $version]); + $this->assertSame('PDF/X-1a:2003', $pdfx1a->pdfxVersionLabel()); + $this->assertTrue($pdfx1a->isPdfx1a()); + $this->assertFalse($pdfx1a->isPdfx4()); + } + } + + /** + * Without PDFX the document is neither version, whatever PDFXversion says + */ + public function testPdfxVersionAloneWritesNoPdfx() + { + $mpdf = $this->mpdf(['PDFXversion' => '4']); + + $this->assertFalse($mpdf->isPdfx4()); + $this->assertFalse($mpdf->isPdfx1a()); + $this->assertStringNotContainsString('GTS_PDFX', $mpdf->OutputBinaryData()); + } + + /** + * A version mPDF does not write is refused + */ + public function testAnUnknownVersionIsRefused() + { + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('PDFXversion (3) is not valid.'); + + $this->mpdf(['PDFX' => true, 'PDFXversion' => '3']); + } + + /** + * A version set after the document is made is checked as it is read, as PDFAversion is + */ + public function testAnUnknownVersionSetLaterIsRefused() + { + $mpdf = $this->mpdf(); + $mpdf->WriteHTML('

Text

'); + $mpdf->PDFX = true; + $mpdf->PDFXversion = '3'; + + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('PDFXversion (3) is not valid.'); + + $mpdf->OutputBinaryData(); + } + + /** + * PDF/X-4 is based on PDF 1.6, and PDF/X-1a stays at PDF 1.4 + */ + public function testPdfx4IsPdf16() + { + $this->assertStringStartsWith('%PDF-1.6', $this->pdf(['PDFX' => true])); + + $pdfx1a = $this->pdf(self::PDFX1A); + $this->assertStringStartsWith('%PDF-1.4', $pdfx1a); + $this->assertStringNotContainsString('/Version /', $pdfx1a); + } + + /** + * Set once the header is written, PDF/X-4 has the catalog say PDF 1.6 + */ + public function testPdfx4SetAfterTheDocumentIsMadeRaisesTheCatalogVersion() + { + $mpdf = $this->mpdf(); + $mpdf->WriteHTML('

Text

'); + $mpdf->PDFX = true; + $mpdf->PDFXauto = true; + + $this->assertStringContainsString('/Version /1.6', $mpdf->OutputBinaryData()); + } + + /** + * A page imported from a later version of PDF leaves the version at the one the PDF/X version is + * written as, and the strict mode refuses it + */ + public function testAnImportedPageKeepsThePdfxVersion() + { + $source = new Mpdf(['pdf_version' => '1.7']); + $source->WriteHTML('

Imported

'); + $data = $source->OutputBinaryData(); + + foreach ([['1a', '1.4'], ['4', '1.6']] as $case) { + list($version, $pdfVersion) = $case; + $mpdf = $this->mpdf(['PDFX' => true, 'PDFXversion' => $version]); + $mpdf->setSourceFile(StreamReader::createByString($data)); + $mpdf->AddPage(); + $mpdf->useTemplate($mpdf->importPage(1)); + $this->assertStringStartsWith('%PDF-' . $pdfVersion . "\n", $mpdf->OutputBinaryData()); + } + + $mpdf = $this->mpdf(['PDFX' => true, 'PDFXauto' => false]); + $mpdf->setSourceFile(StreamReader::createByString($data)); + + $this->expectException(MpdfException::class); + $mpdf->OutputBinaryData(); + } + + /** + * @param string|null $intent The property naming the output intent's profile, or null for the default + * + * @return array The configuration of a PDF/X-4 document printing to that intent + */ + private function x4($intent = null) + { + return $intent ? ['PDFX' => true, 'ICCProfile' => $this->$intent] : ['PDFX' => true]; + } + + /** + * PDF/X-4 asks for a printer profile as its output intent, so a document that names none prints to + * the bundled SWOP profile, identified by the CGATS TR 003 characterisation it is built on + */ + public function testPdfx4EmbedsTheSwopProfileByDefault() + { + $mpdf = $this->mpdf(['PDFX' => true]); + $this->assertSame(4, $mpdf->pdfxOutputChannels()); + $this->assertSame(Mpdf::PDFX4_OUTPUT_PROFILE, $mpdf->pdfxOutputProfile()); + + $pdf = $this->pdf(['PDFX' => true]); + $this->assertStringContainsString('/S /GTS_PDFX', $pdf); + $this->assertStringContainsString('/OutputConditionIdentifier (CGATS TR 003)', $pdf); + $this->assertStringContainsString('/RegistryName (http://www.color.org)', $pdf); + $this->assertSame(1, preg_match('/\/DestOutputProfile (\d+) 0 R/', $pdf, $match)); + $this->assertStringContainsString("\n" . $match[1] . " 0 obj\n<<\n/N 4\n/Length " . filesize(Mpdf::PDFX4_OUTPUT_PROFILE) . ">>", $pdf); + } + + /** + * A display profile describes no printing condition, so the output intent refuses one, sRGB among them + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testADisplayProfileIsRefusedAsTheOutputIntent(array $pdfx) + { + $profile = $this->writeProfile('display', 'CMYK', 'mntr'); + + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('output intent must be a printer (prtr) profile, and ICCProfile "' . $profile . '" is of the mntr class.'); + + $this->mpdf($pdfx + ['ICCProfile' => $profile]); + } + + /** + * PDF/X-1a prints to CMYK alone, so an RGB printer profile is refused as its output intent + */ + public function testPdfx1aRefusesAnRgbProfile() + { + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('The PDF/X-1a output intent must print to CMYK, and ICCProfile "' . $this->rgbProfile . '" does not.'); + + $this->mpdf(self::PDFX1A + ['ICCProfile' => $this->rgbProfile]); + } + + /** + * PDF/X-1a names a registered CMYK condition and embeds no profile, as it always has + */ + public function testPdfx1aNamesTheRegisteredConditionWithNoProfile() + { + $pdf = $this->pdf(self::PDFX1A); + + $this->assertStringContainsString('/Info (CGATS TR 001)', $pdf); + $this->assertStringContainsString('/OutputConditionIdentifier (CGATS TR 001)', $pdf); + $this->assertStringContainsString('/OutputCondition (CGATS TR 001 (SWOP))', $pdf); + $this->assertStringContainsString('/RegistryName (http://www.color.org)', $pdf); + $this->assertStringNotContainsString('/DestOutputProfile', $pdf); + } + + /** + * A document printing to an RGB profile is RGB throughout: nothing in it is written in DeviceCMYK + */ + public function testAnRgbOutputIntentIsRgbThroughout() + { + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], '

Text

'); + + $this->assertTrue($this->mpdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile])->pdfxRgbIntent()); + $this->assertStringNotContainsString('DeviceCMYK', $pdf); + $this->assertSame(0, preg_match('/[\d.]+ [\d.]+ [\d.]+ [\d.]+ k\b/', $pdf)); + } + + /** + * A CMYK output intent is had by naming a CMYK profile, which is embedded and counted + */ + public function testACmykOutputIntentIsHadByNamingAProfile() + { + $mpdf = $this->mpdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile]); + $this->assertSame(4, $mpdf->pdfxOutputChannels()); + $this->assertFalse($mpdf->pdfxRgbIntent()); + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile]); + $this->assertStringContainsString('/Info (mpdf-test-cmyk)', $pdf); + $this->assertStringContainsString('/OutputConditionIdentifier (Custom)', $pdf); + $this->assertSame(1, preg_match('/\/DestOutputProfile (\d+) 0 R/', $pdf, $match)); + $this->assertStringStartsWith("<<\n/N 4\n", $this->object($pdf, $match[1])); + } + + /** + * The printing condition of PDF/X-4 is grey, RGB or CMYK, so a profile printing to Lab or to six + * inks is refused rather than taken for CMYK + * + * @dataProvider unprintableSpaces + * + * @param string $space + */ + public function testAnOutputIntentOfAnotherColourSpaceIsRefused($space) + { + $profile = $this->writeProfile('other', $space); + + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('must print to grey, RGB or CMYK'); + + $this->pdf(['PDFX' => true, 'ICCProfile' => $profile]); + } + + /** + * @return string[][] Data colour spaces no PDF/X-4 printing condition has + */ + public function unprintableSpaces() + { + return [['Lab '], ['XYZ '], ['6CLR']]; + } + + /** + * PDF/X-1a prints to CMYK whatever profile the document names + */ + public function testPdfx1aPrintsToCmyk() + { + $this->assertSame(4, $this->mpdf(self::PDFX1A)->pdfxOutputChannels()); + $this->assertSame(4, $this->mpdf(self::PDFX1A + ['ICCProfile' => $this->cmykProfile])->pdfxOutputChannels()); + $this->assertFalse($this->mpdf(self::PDFX1A)->pdfxRgbIntent()); + } + + /** + * The metadata identify PDF/X-4 with the schema it defines, and carry what it requires of them + */ + public function testPdfx4IsIdentifiedInItsMetadata() + { + $pdf = $this->pdf(['PDFX' => true]); + + $this->assertStringContainsString('xmlns:pdfxid="http://www.npes.org/pdfx/ns/id/" pdfxid:GTS_PDFXVersion="PDF/X-4"', $pdf); + $this->assertStringContainsString('False', $pdf); + $this->assertStringContainsString('1', $pdf); + $this->assertStringContainsString('default', $pdf); + $this->assertStringContainsString('/GTS_PDFXVersion(PDF/X-4)', $pdf); + $this->assertStringNotContainsString('ns.adobe.com/pdfx/1.3', $pdf); + + $pdfx1a = $this->pdf(self::PDFX1A); + $this->assertStringContainsString('pdfx:GTS_PDFXConformance="PDF/X-1a:2003"', $pdfx1a); + $this->assertStringContainsString('/GTS_PDFXVersion(PDF/X-1a:2003)', $pdfx1a); + $this->assertStringNotContainsString('pdfxid', $pdfx1a); + } + + /** + * PDF/X permits no JavaScript: it is removed, or refused where mPDF is not to fix the document + */ + public function testJavaScriptIsRemoved() + { + $script = function (Mpdf $mpdf) { + $mpdf->SetJS('app.alert("x");'); + }; + + $this->assertStringNotContainsString('/JavaScript', $this->pdf(['PDFX' => true], '

Text

', $script)); + + $this->expectException(MpdfException::class); + $this->pdf(['PDFX' => true, 'PDFXauto' => false], '

Text

', $script); + } + + /** + * PDF/X permits no annotation within the printed area, so a link on the page is removed, or refused + * where mPDF is not to fix the document + */ + public function testALinkOnThePageIsRemoved() + { + $html = '

Link

'; + + $this->assertStringContainsString('/Subtype /Link', $this->pdf([], $html)); + $this->assertStringNotContainsString('/Subtype /Link', $this->pdf(['PDFX' => true], $html)); + + $this->expectException(MpdfException::class); + $this->pdf(['PDFX' => true, 'PDFXauto' => false], $html); + } + + /** + * PDF/X permits no embedded file, so the files SetAssociatedFiles() names are left out under PDFXauto, + * and refused without it + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testAssociatedFilesAreRemoved(array $pdfx) + { + $associate = function (Mpdf $mpdf) { + $mpdf->SetAssociatedFiles([['content' => '', 'name' => 'data.xml', 'mime' => 'text/xml', 'AFRelationship' => 'Alternative']]); + }; + + $pdf = $this->pdf($pdfx, '

Text

', $associate); + foreach (['/EmbeddedFiles', '/AF ', '/Filespec', 'data.xml'] as $embedded) { + $this->assertStringNotContainsString($embedded, $pdf); + } + + $mpdf = $this->mpdf($pdfx + ['PDFXauto' => false]); + $associate($mpdf); + $mpdf->WriteHTML('

Text

'); + try { + $mpdf->OutputBinaryData(); + $this->fail('an associated file is refused without PDFXauto'); + } catch (MpdfException $e) { + $this->assertContains(sprintf('Associated files are not permitted in %s files. (Associated files removed)', $mpdf->pdfxVersionLabel()), $mpdf->PDFAXwarnings); + } + } + + /** + * A font subset written as a symbolic TrueType font, for characters past the Basic Multilingual Plane, + * carries its (3,0) cmap subtable alone, as PDF/X preflight allows it one encoding + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testASymbolicTrueTypeFontHasOneCmapSubtable(array $pdfx) + { + $pdf = $this->pdf($pdfx, '

नमस्ते

'); + + $this->assertSame(1, preg_match('/\/Subtype \/TrueType\n(?:.*\n)*?\/FontDescriptor (\d+) 0 R/', $pdf, $font)); + $this->assertSame(1, preg_match('/\/FontFile2 (\d+) 0 R/', $this->object($pdf, $font[1]), $file)); + + // The cmap's version, its one subtable, and that subtable's platform, encoding and offset + $this->assertStringContainsString("\x00\x00\x00\x01\x00\x03\x00\x00\x00\x00\x00\x0c", gzuncompress($this->stream($pdf, $file[1]))); + } + + /** + * PDF/X-1a permits no transparency, so a greyscale PNG with an alpha channel is drawn without it, and + * stays grey, which PDF/X-1a permits, rather than going missing from the page + */ + public function testPdfx1aDrawsAGreyPngWithAlphaWithoutIt() + { + $mpdf = $this->mpdf(self::PDFX1A); + $mpdf->showImageErrors = true; + $mpdf->WriteHTML(''); + $pdf = $mpdf->OutputBinaryData(); + + $images = $this->imageObjects($pdf); + $this->assertCount(1, $images); + list($dictionary) = reset($images); + $this->assertStringContainsString('/ColorSpace /DeviceGray', $dictionary); + $this->assertStringNotContainsString('/SMask', $dictionary); + } + + /** + * A link on the sheet outside the page's BleedBox is kept + */ + public function testALinkOutsideTheBleedBoxIsKept() + { + $mpdf = $this->mpdf(['PDFX' => true, 'PDFXauto' => false]); + $mpdf->WriteHTML('

Text

'); + $mpdf->Link(1, 1, 5, 5, 'https://example.com'); + + $this->assertStringContainsString('/Subtype /Link /Rect [2.835 839.055 17.008 824.882]', $mpdf->OutputBinaryData()); + } + + /** + * An annotation kept outside the BleedBox takes its colour in the device colour space the output + * intent prints to, as its /C array can name no ICC-based one + * + * @dataProvider annotationColours + * + * @param string $intent The property naming the output intent's profile, or null for sRGB + * @param string[] $colours The /C of a red annotation and of one given no colour + */ + public function testAnAnnotationOutsideTheBleedBoxIsInTheOutputIntentsColourSpace($intent, array $colours) + { + $config = $this->x4($intent) + ['annotMargin' => -5]; + + $pdf = $this->pdf($config, '' + . '

Text and

'); + + $this->assertSame(2, preg_match_all('/\/C \[([^\]]*)\]/', $pdf, $match)); + $this->assertSame($colours, $match[1]); + } + + /** + * @return array[] Each output intent, with the /C it gives a red annotation and a yellow one + */ + public function annotationColours() + { + return [ + 'CMYK' => ['cmykProfile', ['0.000 1.000 1.000 0.000', '0.000 0.000 1.000 0.000']], + 'RGB' => ['rgbProfile', ['1.000 0.000 0.000', '1.000 1.000 0.000']], + 'grey' => ['grayProfile', ['0.210', '0.920']], + ]; + } + + /** + * @return string A form with a field of every kind, each carrying a value or a script + */ + private function form() + { + return '
' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
'; + } + + /** + * PDF/X permits no interactive form field, so under PDFXauto an active form is drawn on the page + * exactly as it is with useActiveForms off, keeping the values it shows and nothing interactive + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testActiveFormFieldsAreDrawnOnThePage(array $pdfx) + { + $mpdf = $this->mpdf($pdfx + ['useActiveForms' => true], TextRecordingMpdf::class); + $mpdf->WriteHTML($this->form()); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertTrue($mpdf->useActiveForms, 'the setting is left as it was given'); + foreach (['/AcroForm', '/Widget', '/Annots', '/JavaScript', '/SubmitForm', '/ResetForm', 'ZapfDingbats'] as $interactive) { + $this->assertStringNotContainsString($interactive, $pdf); + } + + $drawn = array_map('trim', $mpdf->drawnText); + foreach (['Typed value', '******', 'Written', 'Chosen option', 'Alpha', 'Send', 'Clear', 'Run'] as $value) { + $this->assertContains($value, $drawn); + } + $this->assertNotContains('Hidden value', $drawn); + + $static = $this->pdf($pdfx + ['useActiveForms' => false], $this->form()); + $this->assertSame($this->pages($static), $this->pages($pdf)); + } + + /** + * Without PDFXauto an active form is refused rather than drawn. The check box is refused as a + * field, not with an exception over the ZapfDingbats it would be set in. + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testActiveFormFieldsAreRefusedWithoutPdfxAuto(array $pdfx) + { + $mpdf = $this->mpdf($pdfx + ['PDFXauto' => false, 'useActiveForms' => true]); + $mpdf->WriteHTML('
'); + + try { + $mpdf->OutputBinaryData(); + $this->fail('the active form was written'); + } catch (MpdfException $e) { + $this->assertStringContainsString('PDFA/PDFX warnings generated', $e->getMessage()); + } + + $this->assertContains( + sprintf('Form fields, file attachments and annotations within the TrimBox or BleedBox are not permitted in %s files. (Removed)', $mpdf->pdfxVersionLabel()), + $mpdf->PDFAXwarnings + ); + } + + /** + * PDF/X-4 keeps opacity, which PDF/X-1a sets to full + */ + public function testPdfx4KeepsOpacity() + { + $alpha = function (Mpdf $mpdf) { + $mpdf->SetAlpha(0.4); + }; + + $this->assertStringContainsString('/ca 0.4', $this->pdf(['PDFX' => true], '

Text

', $alpha)); + $this->assertStringNotContainsString('/ca 0.4', $this->pdf(self::PDFX1A, '

Text

', $alpha)); + } + + /** + * A colour with transparency keeps it, where PDF/X-1a converts it to CMYK and loses it + */ + public function testAColourWithTransparencyKeepsItsTransparency() + { + $html = '
Text
'; + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html); + $this->assertStringContainsString('1.000 0.000 0.000 rg', $pdf); + $this->assertStringContainsString('/ca 0.5', $pdf); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertStringContainsString('/CSRGB cs 1.000 0.000 0.000 sc', $cmyk); + $this->assertStringContainsString('/ca 0.5', $cmyk); + + $this->assertStringNotContainsString('/ca 0.5', $this->pdf(self::PDFX1A, $html)); + } + + /** + * PDF/X-4 permits a watermark, drawn with transparency, which PDF/X-1a refuses + */ + public function testPdfx4PermitsAWatermark() + { + $watermark = function (Mpdf $mpdf) { + $mpdf->SetWatermarkText('DRAFT'); + $mpdf->showWatermarkText = true; + }; + + $this->assertStringContainsString('/ca 0.2', $this->pdf(['PDFX' => true], '

Text

', $watermark)); + + $this->expectException(MpdfException::class); + $this->pdf(self::PDFX1A, '

Text

', $watermark); + } + + /** + * PDF/X-4 keeps a PNG's transparency, its RGB written as it is for the RGB output intent, and in an + * ICC-based sRGB space for a CMYK one, which permits no DeviceRGB. PDF/X-1a converts it to CMYK + * without transparency. + */ + public function testPdfx4KeepsATranslucentPng() + { + $html = $this->translucentPng(); + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html); + $this->assertStringContainsString('/SMask', $pdf); + $this->assertStringContainsString('/ColorSpace /DeviceRGB', $pdf); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertStringContainsString('/SMask', $cmyk); + $this->assertStringNotContainsString('/DeviceRGB', $cmyk); + $this->assertSame(1, preg_match('/\/ColorSpace (\d+) 0 R/', $cmyk, $space)); + $this->assertSame(1, preg_match('/^\[\/ICCBased (\d+) 0 R\]/', $this->object($cmyk, $space[1]), $profile)); + $this->assertStringStartsWith('<>', $this->object($cmyk, $profile[1])); + + $pdfx1a = $this->pdf(self::PDFX1A, $html); + $this->assertStringNotContainsString('/SMask', $pdfx1a); + $this->assertStringContainsString('/ColorSpace /DeviceCMYK', $pdfx1a); + } + + /** + * A palette image's palette is in sRGB, calibrated for a CMYK output intent + */ + public function testAPaletteImageIsInSrgb() + { + $image = imagecreate(4, 4); + imagecolorallocate($image, 0, 128, 255); + ob_start(); + imagepng($image); + $html = ''; + + $this->assertStringContainsString('/ColorSpace [/Indexed /DeviceRGB ', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html)); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertSame(1, preg_match('/\/ColorSpace \[\/Indexed (\d+) 0 R 0 /', $cmyk, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($cmyk, $match[1])); + } + + /** + * An image names its soft mask by the mask's own object number, even where the ICC-based sRGB + * colour space, written the first time something asks for it, falls between the two + * + * @param string $intent The property holding the output intent's profile + * + * @dataProvider nonRgbIntents + */ + public function testAnImageNamesItsSoftMask($intent) + { + $pdf = $this->maskedPdf($intent); + + $this->assertSame(1, preg_match('/\/SMask (\d+) 0 R\n\/ColorSpace (\d+) 0 R/', $pdf, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($pdf, $match[2]), 'the image is in sRGB'); + $this->assertLessThan((int) $match[2], (int) $match[1], 'the mask is written before the colour space'); + + $mask = $this->object($pdf, $match[1]); + $this->assertStringStartsWith('<assertStringContainsString('/Subtype /Image', $mask); + $this->assertStringContainsString('/ColorSpace /DeviceGray', $mask); + } + + /** + * mPDF reads back the masked image: a page imported into another document keeps its soft mask + * + * @param string $intent The property holding the output intent's profile + * + * @dataProvider nonRgbIntents + */ + public function testAMaskedImageIsImportedByMpdf($intent) + { + $mpdf = $this->mpdf(); + $mpdf->setSourceFile(StreamReader::createByString($this->maskedPdf($intent))); + $mpdf->AddPage(); + $mpdf->useTemplate($mpdf->importPage(1)); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertSame(1, preg_match('/\/SMask (\d+) 0 R/', $pdf, $match)); + $this->assertStringContainsString('/Subtype /Image', $this->object($pdf, $match[1])); + } + + /** + * The masked image draws translucent: rasterised, its middle is lighter than the same image drawn + * opaque, since the page shows through, and is not the white page alone + * + * @param string $intent The property holding the output intent's profile + * + * @dataProvider nonRgbIntents + */ + public function testAMaskedImageDrawsTranslucent($intent) + { + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + + $translucent = $this->middleOfTheImage($this->maskedPdf($intent)); + $opaque = $this->middleOfTheImage($this->maskedPdf($intent, 0)); + + $this->assertGreaterThan($opaque['g'] + 40, $translucent['g']); + $this->assertLessThan(250, $translucent['g']); + } + + /** + * @param string $pdf A document drawing translucentPng() at the top left of its first page + * + * @return int[] The red, green and blue of the middle of the image, rasterised at 30 DPI over a white page + */ + private function middleOfTheImage($pdf) + { + $image = new \Imagick(); + $image->setResolution(30, 30); + try { + $image->readImageBlob($pdf); + } catch (\ImagickException $e) { + $this->markTestSkipped('Imagick cannot read a PDF here (' . trim($e->getMessage()) . ')'); + } + $image->setImageBackgroundColor('white'); + $image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); + $image->transformImageColorspace(\Imagick::COLORSPACE_SRGB); + + // The image is 40mm square at the page's 15mm left and 16mm top margins + return $image->getImagePixelColor(round(35 / 25.4 * 30), round(36 / 25.4 * 30))->getColor(); + } + + /** + * @param string $intent The property holding the output intent's profile + * @param int $alpha As translucentPng() takes it + * + * @return string A PDF/X-4 document for that output intent drawing translucentPng() + */ + private function maskedPdf($intent, $alpha = 63) + { + return $this->pdf($this->x4($intent), $this->translucentPng($alpha)); + } + + /** + * @return array[] The properties holding a CMYK and a grey output intent's profile, for which RGB + * is written in the ICC-based sRGB colour space + */ + public function nonRgbIntents() + { + return ['CMYK' => ['cmykProfile'], 'grey' => ['grayProfile']]; + } + + /** + * PDF/X permits no interpolation of an image + */ + public function testNoImageIsInterpolated() + { + $this->assertStringContainsString('/Interpolate true', $this->colourFontPdf([]), 'a colour glyph\'s image is interpolated elsewhere'); + $this->assertStringNotContainsString('/Interpolate', $this->colourFontPdf(['PDFX' => true])); + } + + /** + * PDF/X-4 permits layers, each configuration named, which PDF/X-1a refuses + */ + public function testPdfx4PermitsLayers() + { + $html = '
Layer

Text

'; + + $pdf = $this->pdf(['PDFX' => true, 'PDFXauto' => false], $html); + $this->assertStringContainsString('/Type /OCG', $pdf); + $this->assertStringContainsString('/Name (Default)', $pdf); + + $this->expectException(MpdfException::class); + $this->pdf(self::PDFX1A + ['PDFXauto' => false], $html); + } + + /** + * Print-only or hidden content needs optional content with usage application dictionaries, the /AS + * PDF/X-4 forbids and the optional content PDF/X-1a has none of, so it is shown in full instead + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testVisibilityIsLeftFull(array $pdfx) + { + $html = '
Print only

Hidden

'; + + $pdf = $this->pdf($pdfx, $html); + $this->assertStringNotContainsString('/OC /OC', $pdf); + $this->assertStringNotContainsString('/OCProperties', $pdf); + + $this->expectException(MpdfException::class); + $this->pdf($pdfx + ['PDFXauto' => false], $html); + } + + /** + * Each page is blended in the colour space of the output intent + */ + public function testPagesAreBlendedInTheColourSpaceOfTheOutputIntent() + { + $this->assertStringContainsString('/Group << /Type /Group /S /Transparency /CS /DeviceRGB >>', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile])); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile]); + $this->assertStringContainsString('/Group << /Type /Group /S /Transparency /CS /DeviceCMYK >>', $cmyk); + + $this->assertStringNotContainsString('/Group', $this->pdf(self::PDFX1A)); + } + + /** + * RGB is DeviceRGB for the RGB output intent, and written in the ICC-based sRGB colour space the + * page's resources name for a CMYK one, rather than converted to CMYK by a formula that knows + * nothing of the press. PDF/X-1a, which permits no ICC-based colour space, still converts it. + */ + public function testRgbIsWrittenColourManagedForACmykOutputIntent() + { + $html = '

Text

'; + + $rgb = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html); + $this->assertStringContainsString('1.000 0.000 0.000 rg', $rgb); + $this->assertStringNotContainsString('CSRGB', $rgb); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertStringContainsString('/CSRGB cs 1.000 0.000 0.000 sc', $cmyk); + $this->assertStringNotContainsString('0.000 1.000 1.000 0.000 k', $cmyk); + + $this->assertSame(1, preg_match('/\/ColorSpace <<\n\/CSRGB (\d+) 0 R/', $cmyk, $match), 'the page resources name it'); + $this->assertSame(1, preg_match('/^\[\/ICCBased (\d+) 0 R\]/', $this->object($cmyk, $match[1]), $profile)); + $this->assertStringStartsWith('<>', $this->object($cmyk, $profile[1])); + + $this->assertStringContainsString('0.000 1.000 1.000 0.000 k', $this->pdf(self::PDFX1A, $html)); + } + + /** + * A neutral colour goes to DeviceGray, which a CMYK output condition takes as its black separation, + * so that black text is printed from one plate rather than made out of four + */ + public function testBlackIsTheBlackInkAlone() + { + $html = '

Black

Grey

'; + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertStringContainsString('0.000 g', $cmyk); + $this->assertStringContainsString('0.502 g', $cmyk); + $this->assertStringNotContainsString('0.000 0.000 0.000 sc', $cmyk); + + $this->assertStringContainsString('0.000 0.000 0.000 1.000 k', $this->pdf(self::PDFX1A, $html)); + } + + /** + * A colour the document gives as CMYK is left in CMYK for a CMYK output intent + */ + public function testACmykColourIsLeftAloneForACmykOutputIntent() + { + $html = '

Text

'; + + $this->assertStringContainsString('0.100 0.200 0.300 0.400 k', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html)); + } + + /** + * Nothing is converted, so an RGB colour is no longer a conformance issue for PDF/X-4, where it + * still is for PDF/X-1a + */ + public function testAnRgbColourIsNoConformanceIssueForPdfx4() + { + $html = '

Text

'; + + $strict = ['PDFX' => true, 'PDFXauto' => false, 'ICCProfile' => $this->cmykProfile]; + $this->assertStringContainsString('/CSRGB cs', $this->pdf($strict, $html)); + + $this->expectException(MpdfException::class); + $this->pdf(self::PDFX1A + ['PDFXauto' => false], $html); + } + + /** + * A gradient's stops are in the same colour space as the colours beside them + */ + public function testAGradientIsWrittenInTheSameColourSpaceAsTheContent() + { + $html = '
Text
'; + + $this->assertStringContainsString('/ColorSpace /DeviceRGB', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html)); + + $cmyk = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + $this->assertStringNotContainsString('/ColorSpace /DeviceRGB', $cmyk); + $this->assertSame(1, preg_match('/\/ShadingType \d\n\/ColorSpace (\d+) 0 R/', $cmyk, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($cmyk, $match[1])); + } + + /** + * A gradient whose stops fall in different colour spaces is written in one they all fit, as a shading + * function gives every stop the same number of components: a grey stop beside an RGB one goes to + * RGB, and a stop beside a CMYK one to CMYK, keeping the inks the document names + * + * @dataProvider mixedGradients + * + * @param string $html + * @param string $c0 The first stop's components + * @param string $c1 The last stop's components + */ + public function testAGradientMixingColourSpacesIsWrittenInOne($html, $c0, $c1) + { + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html); + + $this->assertMatchesRegularExpression('/\/C0 \[' . preg_quote($c0, '/') . '\]\s+\/C1 \[' . preg_quote($c1, '/') . '\]/', $pdf); + } + + /** + * @return array[] Gradients mixing grey, RGB and CMYK stops, with the components each is written with + */ + public function mixedGradients() + { + $svg = function ($from, $to) { + return ''; + }; + $css = function ($from, $to) { + return '
Gradient
'; + }; + + return [ + 'CSS, grey and RGB' => [$css('#000000', '#ff0000'), '0.000 0.000 0.000', '1.000 0.000 0.000'], + 'SVG, grey and RGB' => [$svg('#000000', '#ff0000'), '0.000 0.000 0.000', '1.000 0.000 0.000'], + 'CSS, CMYK and RGB' => [$css('cmyk(0, 100, 0, 0)', '#ff0000'), '0.000 1.000 0.000 0.000', '0.000 1.000 1.000 0.000'], + 'SVG, CMYK and RGB' => [$svg('cmyk(0, 100, 0, 0)', '#ff0000'), '0.000 1.000 0.000 0.000', '0.000 1.000 1.000 0.000'], + 'CSS, grey and CMYK' => [$css('#000000', 'cmyk(100, 0, 0, 0)'), '0.000 0.000 0.000 1.000', '1.000 0.000 0.000 0.000'], + ]; + } + + /** + * CMYK is converted to RGB for the RGB output intent + */ + public function testCmykIsConvertedForAnRgbOutputIntent() + { + $html = '

Text

'; + + $this->assertStringContainsString('1.000 0.000 0.000 rg', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html)); + $this->assertStringContainsString('0.000 1.000 1.000 0.000 k', $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html), 'a CMYK intent leaves it alone'); + } + + /** + * A grey output intent permits no DeviceCMYK either, so CMYK colours and gradients are converted + * to RGB, which is then written in the ICC-based sRGB colour space + */ + public function testCmykIsConvertedForAGreyOutputIntent() + { + $html = '

Text

' + . '
Gradient
'; + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->grayProfile], $html); + + $this->assertStringNotContainsString('DeviceCMYK', $pdf); + $this->assertSame(0, preg_match('/[\d.]+ [\d.]+ [\d.]+ [\d.]+ k\b/', $pdf)); + $this->assertStringContainsString('/CSRGB cs 1.000 0.000 0.000 sc', $pdf); + } + + /** + * A spot colour falls back to RGB where the output intent is not CMYK: DeviceRGB for an RGB intent, + * the ICC-based sRGB colour space for a grey one + */ + public function testASpotColourFallsBackToTheColourSpaceOfTheOutputIntent() + { + $html = '

Text

'; + $spot = function (Mpdf $mpdf) { + $mpdf->AddSpotColor('PANTONE 300 C', 100, 44, 0, 0); + }; + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html, $spot); + $this->assertStringContainsString("/Separation /PANTONE#20300#20C\n/DeviceRGB <<\n/Range [0 1 0 1 0 1] /C0 [1 1 1] \n/C1 [0.000 0.557 1.000]", $pdf); + $this->assertStringNotContainsString('DeviceCMYK', $pdf); + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->grayProfile], $html, $spot); + $this->assertSame(1, preg_match('/\/Separation \/PANTONE#20300#20C\n(\d+) 0 R <assertSame('[/ICCBased', substr($this->object($pdf, $match[1]), 0, 10)); + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->cmykProfile], $html, $spot); + $this->assertStringContainsString("/Separation /PANTONE#20300#20C\n/DeviceCMYK <<", $pdf, 'a CMYK intent keeps the CMYK'); + } + + /** + * A bitmap colour font is drawn in colour, its images in sRGB where the output intent is CMYK + */ + public function testABitmapColourFontIsDrawnInColour() + { + $this->assertStringContainsString('/ColorSpace /DeviceRGB', $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'PDFXauto' => false])); + + $pdf = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'PDFXauto' => false, 'ICCProfile' => $this->cmykProfile]); + $this->assertStringNotContainsString('/DeviceRGB', $pdf); + $this->assertStringContainsString('/SMask', $pdf); + $this->assertSame(1, preg_match('/\/ColorSpace (\d+) 0 R/', $pdf, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($pdf, $match[1])); + } + + /** + * A COLR font is drawn in colour, each fill in sRGB set by name where the output intent is CMYK + */ + public function testAColrFontIsDrawnInColour() + { + $rgb = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'PDFXauto' => false, 'default_font' => 'colr']); + $this->assertStringContainsString('1.000 0.800 0.200 rg', $rgb, 'the face, yellow'); + $this->assertStringNotContainsString('CSRGB', $rgb); + + $pdf = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'PDFXauto' => false, 'default_font' => 'colr', 'ICCProfile' => $this->cmykProfile]); + $this->assertStringNotContainsString(' rg', $pdf); + $this->assertStringContainsString('/CSRGB cs 1.000 0.800 0.200 sc', $pdf, 'the face, yellow'); + $this->assertSame(1, preg_match('/\/ColorSpace <<\/CSRGB (\d+) 0 R >>/', $pdf, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($pdf, $match[1])); + } + + /** + * PDF/X-4 permits no encryption + */ + public function testEncryptionIsRefused() + { + $mpdf = $this->mpdf(['PDFX' => true]); + $mpdf->SetProtection(['print']); + $mpdf->WriteHTML('

Text

'); + + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('PDF/X-4 does not permit encryption of documents.'); + + $mpdf->OutputBinaryData(); + } + + /** + * PDF/X requires a document title, so an untitled document is refused where mPDF is not to fix it + * + * @param array $pdfx The configuration of the PDF/X version + * + * @dataProvider pdfxVersions + */ + public function testAnUntitledPdfxDocumentIsRefused(array $pdfx) + { + $file = sys_get_temp_dir() . '/mpdf-test-untitled.pdf'; + + $mpdf = $this->untitled($pdfx + ['PDFXauto' => false]); + $mpdf->WriteHTML('

Text

'); + + $refused = null; + try { + $mpdf->OutputFile($file); + } catch (MpdfException $e) { + $refused = $e; + } + + $this->assertNotNull($refused, 'the untitled document was written'); + $this->assertStringContainsString('PDFA/PDFX warnings generated', $refused->getMessage()); + $this->assertContains( + sprintf('A document title is required in %s files, and SetTitle() set none. (Title set to the file name "mpdf-test-untitled")', $mpdf->pdfxVersionLabel()), + $mpdf->PDFAXwarnings + ); + + $titled = $this->untitled($pdfx + ['PDFXauto' => false]); + $titled->SetTitle('Document'); + $titled->WriteHTML('

Text

'); + $titled->OutputFile($file); + + $this->assertFileExists($file, 'the same document with a title is written'); + unlink($file); + } + + /** + * @return array[] The configuration of each PDF/X version + */ + public function pdfxVersions() + { + return ['PDF/X-1a' => [self::PDFX1A], 'PDF/X-4' => [['PDFX' => true]]]; + } + + /** + * Where mPDF is to fix the document, an untitled one is titled after the file it is written to, so + * that what is produced conforms rather than is merely tolerated + */ + public function testAnUntitledPdfxDocumentIsTitledAfterItsFile() + { + $file = sys_get_temp_dir() . '/Quarterly Report.pdf'; + + $mpdf = $this->untitled(['PDFX' => true]); + $mpdf->WriteHTML('

Text

'); + $mpdf->OutputFile($file); + + $pdf = file_get_contents($file); + unlink($file); + + $this->assertStringContainsString('Quarterly Report', $pdf); + $this->assertStringContainsString('/Title ' . $this->textString('Quarterly Report'), $pdf); + } + + /** + * Written to no file, an untitled document is titled after the name mPDF sends a document under + */ + public function testAnUntitledPdfxDocumentWrittenToNoFileTakesThePdfxDefaultName() + { + $mpdf = $this->untitled(['PDFX' => true]); + $mpdf->WriteHTML('

Text

'); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertStringContainsString('mpdf', $pdf); + $this->assertStringContainsString('/Title ' . $this->textString('mpdf'), $pdf); + } + + /** + * Closed before it is output, an untitled document is titled as Close() writes its Info dictionary + * and metadata, after mPDF's default name, as Output() comes too late to title it + */ + public function testAnUntitledPdfxDocumentClosedFirstIsTitled() + { + $mpdf = $this->untitled(['PDFX' => true]); + $mpdf->WriteHTML('

Text

'); + $mpdf->Close(); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertStringContainsString('mpdf', $pdf); + $this->assertStringContainsString('/Title ' . $this->textString('mpdf'), $pdf); + } + + /** + * Refused without PDFXauto, the document warns of the title once, whichever of Close() and Output() + * asks first + */ + public function testAMissingTitleIsWarnedOfOnce() + { + foreach ([false, true] as $closeFirst) { + $mpdf = $this->untitled(['PDFX' => true, 'PDFXauto' => false]); + $mpdf->WriteHTML('

Text

'); + if ($closeFirst) { + $mpdf->Close(); + } + try { + $mpdf->OutputBinaryData(); + $this->fail('an untitled document is refused'); + } catch (MpdfException $e) { + $titles = preg_grep('/^A document title is required/', $mpdf->PDFAXwarnings); + $this->assertCount(1, $titles); + } + } + } + + /** + * A title the document sets is the title, whatever file it is written to + */ + public function testASetTitleIsKept() + { + $file = sys_get_temp_dir() . '/mpdf-test-titled.pdf'; + + $mpdf = $this->untitled(['PDFX' => true]); + $mpdf->SetTitle('Annual Accounts'); + $mpdf->WriteHTML('

Text

'); + $mpdf->OutputFile($file); + + $pdf = file_get_contents($file); + unlink($file); + + $this->assertStringContainsString('Annual Accounts', $pdf); + $this->assertStringNotContainsString('mpdf-test-titled', $pdf); + } + + /** + * A document that is not PDF/X is titled only where it says so + */ + public function testANonPdfxDocumentIsNotTitled() + { + $file = sys_get_temp_dir() . '/mpdf-test-plain.pdf'; + + $mpdf = $this->untitled([]); + $mpdf->WriteHTML('

Text

'); + $mpdf->OutputFile($file); + + $pdf = file_get_contents($file); + unlink($file); + + $this->assertStringNotContainsString('/Title', $pdf); + $this->assertStringNotContainsString('', $pdf); + } + + /** + * @param int $alpha GD's alpha, from 0 for opaque to 127 for transparent + * + * @return string An image tag drawing a PNG of red, half transparent by default, 40mm square + */ + private function translucentPng($alpha = 63) + { + $image = imagecreatetruecolor(16, 16); + imagesavealpha($image, true); + imagealphablending($image, false); + imagefilledrectangle($image, 0, 0, 15, 15, imagecolorallocatealpha($image, 220, 40, 40, $alpha)); + ob_start(); + imagepng($image); + + return ''; + } + + /** + * DeviceGray is permitted only where the output intent is grey or CMYK, so under the default sRGB + * one each neutral colour - the default text colour among them - is set in an ICC-based grey colour + * space the page's resources name, on the grey profile mPDF ships + */ + public function testGreyIsSetInAnIccBasedColourSpaceForAnRgbOutputIntent() + { + $mpdf = $this->mpdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile]); + $this->assertTrue($mpdf->writesCalibratedGray()); + $mpdf->WriteHTML('

Text

'); + $mpdf->SetDrawColor(0); + $mpdf->SetFillColor(128); + $mpdf->Rect(20, 60, 30, 10, 'DF'); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertStringContainsString('/CSGRAY cs 0.000 sc', $pdf); + $this->assertStringContainsString('/CSGRAY CS 0.000 SC', $pdf); + $this->assertStringContainsString('/CSGRAY cs 0.502 sc', $pdf); + $this->assertNoDeviceGrayButSoftMasks($pdf); + + $this->assertSame(1, preg_match('/\/ColorSpace <<\n\/CSGRAY (\d+) 0 R/', $pdf, $match), 'the page resources name it'); + $this->assertSame(1, preg_match('/^\[\/ICCBased (\d+) 0 R\]/', $this->object($pdf, $match[1]), $profile)); + $this->assertStringStartsWith('<>', $this->object($pdf, $profile[1])); + $this->assertSame(file_get_contents(self::GRAY), $this->stream($pdf, $profile[1])); + $this->assertSame(1, substr_count($pdf, 'Gray, sRGB tone curve (mPDF)'), 'written once'); + } + + /** + * Anywhere DeviceGray is permitted, grey stays DeviceGray and no grey profile is written + * + * @dataProvider deviceGrayDocuments + * + * @param array $config + * @param string|null $space The data colour space of an ICCProfile to print to, if any + */ + public function testGreyStaysDeviceGrayWhereItIsPermitted(array $config, $space = null) + { + if ($space) { + $config['ICCProfile'] = $this->writeProfile(strtolower($space), $space); + } + + $mpdf = $this->mpdf($config + ['PDFAauto' => true]); + $this->assertFalse($mpdf->writesCalibratedGray()); + $mpdf->WriteHTML('

Text

Gradient
'); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertStringContainsString('0.000 g', $pdf); + $this->assertStringNotContainsString('CSGRAY', $pdf); + $this->assertStringNotContainsString('Gray, sRGB tone curve (mPDF)', $pdf); + } + + /** + * @return array[] Documents that may use DeviceGray + */ + public function deviceGrayDocuments() + { + return [ + 'not PDF/X' => [[]], + 'PDF/X-1a' => [self::PDFX1A], + 'PDF/X-4 printing to CMYK' => [['PDFX' => true], 'CMYK'], + 'PDF/X-4 printing to grey' => [['PDFX' => true], 'GRAY'], + 'PDF/A' => [['PDFA' => true]], + ]; + } + + /** + * A greyscale JPEG or PNG keeps its data and names the ICC-based grey colour space; the soft mask of + * a PNG with alpha, which mPDF draws in RGB, stays DeviceGray, as ISO 32000 has a soft mask be + */ + public function testGreyImagesAreInTheIccBasedGreyColourSpace() + { + $html = '' + . '' + . ''; + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html); + $this->assertNoDeviceGrayButSoftMasks($pdf); + + $images = $this->imageObjects($pdf); + $this->assertCount(4, $images, 'three images and a soft mask'); + $this->assertSame(1, preg_match('/\/CSGRAY (\d+) 0 R/', $pdf, $gray)); + $spaces = []; + foreach ($images as $image) { + preg_match('/\/ColorSpace (\S+(?: 0 R)?)\n/', $image[0], $space); + $spaces[] = $space[1]; + } + $this->assertSame([$gray[1] . ' 0 R', $gray[1] . ' 0 R', '/DeviceGray', '/DeviceRGB'], $spaces, 'the JPEG, the PNG, then the soft mask and the PNG with alpha'); + + $mpdf = $this->mpdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], DeviceGrayMpdf::class); + $mpdf->WriteHTML($html); + $this->assertSame(array_column($this->imageObjects($mpdf->OutputBinaryData()), 1), array_column($images, 1), 'not encoded again'); + } + + /** + * A gradient of grey stops, and the luminosity soft mask behind an rgba gradient or a box-shadow - its + * shading and the transparency group it is blended in - are in the ICC-based grey colour space + */ + public function testGradientsAndTheirSoftMasksAreInTheIccBasedGreyColourSpace() + { + $html = '
Linear
' + . '
Radial
' + . '
Translucent
' + . '
Grey shadow
' + . '
Translucent shadow
'; + + $pdf = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], $html); + $this->assertNoDeviceGrayButSoftMasks($pdf); + + $this->assertSame(1, preg_match('/\/CSGRAY (\d+) 0 R/', $pdf, $gray)); + $grey = $gray[1] . ' 0 R'; + $this->assertStringContainsString("/ShadingType 2\n/ColorSpace " . $grey . "\n", $pdf, 'the grey linear gradient'); + $this->assertStringContainsString("/ShadingType 3\n/ColorSpace " . $grey . "\n", $pdf, 'the grey radial gradient'); + $this->assertStringContainsString("/ShadingType 6\n/ColorSpace " . $grey . "\n", $pdf, 'the grey shadow'); + $this->assertGreaterThan(1, substr_count($pdf, '/Group << /Type /Group /S /Transparency /CS ' . $grey . ' >>'), 'the groups of the soft masks'); + } + + /** + * A colour font's soft masks - of a gradient's alpha, of the shapes of a clip path - are drawn in the + * ICC-based grey colour space its Type3 font's resources name; the soft masks of its images stay + * DeviceGray + */ + public function testColourFontSoftMasksAreInTheIccBasedGreyColourSpace() + { + $emoji = ''; + foreach ([0x1F600, 0x2764, 0x1F468, 0x1F469, 0x1F467, 0x1F44D] as $codepoint) { + $emoji .= UtfString::code2utf($codepoint); + } + + $colrV1 = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'default_font' => 'colrv1'], $emoji); + $this->assertNoDeviceGrayButSoftMasks($colrV1); + $this->assertSame(1, preg_match('/\/CSGRAY (\d+) 0 R/', $colrV1, $gray)); + $this->assertStringContainsString('/Group <>', $colrV1, 'a luminosity mask'); + + $svg = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile, 'default_font' => 'svg'], $emoji); + $this->assertNoDeviceGrayButSoftMasks($svg); + $this->assertStringContainsString("/CSGRAY cs 0 sc\n", $svg, 'the clip path of several shapes'); + $this->assertSame(1, preg_match('/\/ColorSpace <<\/CSGRAY \d+ 0 R >>/', $svg), 'named by the Type3 font'); + + $cbdt = $this->colourFontPdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile]); + $this->assertNoDeviceGrayButSoftMasks($cbdt); + $this->assertStringContainsString('/SMask', $cbdt); + } + + /** + * A content stream starts in black in DeviceGray, so each page - headers, footers and watermark + * included - and each form drawn from SVG sets black in the ICC-based grey colour space before it + * draws anything. A form drawn as a background looks the colour space up in its pattern's resources. + */ + public function testEachPageAndFormStartsInTheIccBasedGreyColourSpace() + { + $svg = 'data:image/svg+xml;base64,' . base64_encode('SVG'); + + $mpdf = $this->mpdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile]); + $mpdf->SetHTMLHeader('
Header
'); + $mpdf->SetHTMLFooter('
Footer
'); + $mpdf->SetWatermarkText('Watermark'); + $mpdf->showWatermarkText = true; + $mpdf->WriteHTML('

Page one

Page two
'); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertNoDeviceGrayButSoftMasks($pdf); + + $start = "/CSGRAY cs 0.000 sc /CSGRAY CS 0.000 SC\n"; + $this->assertSame(2, preg_match_all('/\/Contents (\d+) 0 R/', $pdf, $contents)); + foreach ($contents[1] as $number) { + $this->assertStringStartsWith($start, $this->stream($pdf, $number)); + } + + $this->assertSame(1, preg_match('/(\d+) 0 obj\n<<\/Type \/XObject\n\/Subtype \/Form\n/', $pdf, $form)); + $this->assertStringStartsWith($start, $this->stream($pdf, $form[1])); + + $this->assertSame(1, preg_match('/\/XObject <<\/FO\d+ \d+ 0 R >>\n\/ColorSpace <<\/CSGRAY \d+ 0 R >>/', $pdf), 'the background pattern\'s resources'); + } + + /** + * Grey in the ICC-based colour space draws as it did in DeviceGray, pixel for pixel + */ + public function testGreyDrawsAsItDidInDeviceGray() + { + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + + $html = '

Text in the default colour

' + . '
A grey gradient
' + . '' + . '' + . '' + . '
Translucent
' + . '
Shadow
'; + + $pixels = []; + foreach ([Mpdf::class, DeviceGrayMpdf::class] as $class) { + $mpdf = $this->mpdf(['PDFX' => true], $class); + $mpdf->SetWatermarkText('Watermark'); + $mpdf->showWatermarkText = true; + $mpdf->WriteHTML($html); + $mpdf->SetFillColor(128); + $mpdf->Rect(20, 250, 30, 10, 'F'); + $pdf = $mpdf->OutputBinaryData(); + $this->assertSame($mpdf->writesCalibratedGray(), strpos($pdf, 'CSGRAY') !== false); + + $image = new \Imagick(); + $image->setResolution(120, 120); + try { + $image->readImageBlob($pdf); + } catch (\ImagickException $e) { + $this->markTestSkipped('Imagick cannot read a PDF here (' . trim($e->getMessage()) . ')'); + } + $pixels[] = $image; + } + + $difference = $pixels[0]->compareImages($pixels[1], \Imagick::METRIC_ROOTMEANSQUAREDERROR); + $this->assertSame(0.0, (float) $difference[1]); + } + + /** + * mPDF reads back what it writes: a page imported into another document keeps its grey in the + * ICC-based colour space + */ + public function testADocumentWithIccBasedGreyIsImportedByMpdf() + { + $source = $this->pdf(['PDFX' => true, 'ICCProfile' => $this->rgbProfile], '

Text

Gradient
'); + + $mpdf = $this->mpdf(); + $pages = $mpdf->setSourceFile(StreamReader::createByString($source)); + $mpdf->AddPage(); + $mpdf->useTemplate($mpdf->importPage(1)); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertSame(1, $pages); + $this->assertStringContainsString('/CSGRAY cs 0.000 sc', $pdf); + $this->assertStringContainsString('Gray, sRGB tone curve (mPDF)', $pdf); + } + + /** + * @param array $config Merged over automatic fixing and no compression + * + * @return Mpdf A document with no title of its own + */ + private function untitled(array $config) + { + $mpdf = new Mpdf($config + ['mode' => 'utf-8', 'PDFXauto' => true]); + $mpdf->SetCompression(false); + + return $mpdf; + } + + /** + * @param string $text + * + * @return string That text as the Info dictionary carries it, UTF-16BE behind a byte order mark + */ + private function textString($text) + { + return '(' . "\xFE\xFF" . mb_convert_encoding($text, 'UTF-16BE', 'UTF-8') . ')'; + } + + /** + * @param array $config Merged over mpdf()'s configuration + * @param string|null $text What to draw, the grinning face where not given + * + * @return string A document drawing the text in a test colour font, the CBDT one by default + */ + private function colourFontPdf(array $config, $text = null) + { + return $this->pdf($config + [ + 'fontRegistry' => new FontRegistry([]), + 'fontDir' => [__DIR__ . '/../data/ttf/color'], + 'fontdata' => [ + 'cbdt' => ['R' => 'TestEmoji-CBDT.ttf', 'useOTL' => 0xFF], + 'colr' => ['R' => 'TestEmoji-COLRv0.ttf', 'useOTL' => 0xFF], + 'colrv1' => ['R' => 'TestEmoji-COLRv1.ttf', 'useOTL' => 0xFF], + 'svg' => ['R' => 'TestEmoji-SVG.ttf', 'useOTL' => 0xFF], + ], + 'default_font' => 'cbdt', + ], '

' . ($text === null ? UtfString::code2utf(0x1F600) : $text) . '

'); + } + + /** + * Asserts that no content stream sets a colour with g or G, and that nothing names /DeviceGray but + * the soft masks of images + * + * @param string $pdf An uncompressed document + */ + private function assertNoDeviceGrayButSoftMasks($pdf) + { + preg_match_all('/(\d+) 0 obj\n((?:(?!endobj).)*?)stream\n(.*?)\nendstream/s', $pdf, $streams, PREG_SET_ORDER); + foreach ($streams as $stream) { + // Images, fonts and ICC profiles are binary + if (!preg_match('/\/Subtype \/Image|\/Length1|\/N \d/', $stream[2])) { + $this->assertSame(0, preg_match('/(^|\s)[\d.]+ [gG]\s/', $stream[3]), sprintf('object %d sets a colour in DeviceGray', $stream[1])); + } + } + + $softMasks = $this->softMasks($pdf); + preg_match_all('/\n(\d+) 0 obj\n((?:(?!endobj).)*)/s', $pdf, $objects, PREG_SET_ORDER); + foreach ($objects as $object) { + if (strpos($object[2], '/DeviceGray') !== false) { + $this->assertContains($object[1], $softMasks, sprintf('object %d names /DeviceGray and is not a soft mask', $object[1])); + } + } + } + + /** + * @param string $pdf + * + * @return string[] The object number of each image an image names as its /SMask + */ + private function softMasks($pdf) + { + preg_match_all('/\/SMask (\d+) 0 R/', $pdf, $masks); + + return $masks[1]; + } + + /** + * @param string $pdf An uncompressed document + * + * @return array[] Each image, as [its dictionary, its data], by object number + */ + private function imageObjects($pdf) + { + preg_match_all('/\n(\d+) 0 obj\n<<\/Type \/XObject\n\/Subtype \/Image\n(.*?)>>\nstream\n(.*?)\nendstream/s', $pdf, $images, PREG_SET_ORDER); + + $byNumber = []; + foreach ($images as $image) { + $byNumber[$image[1]] = [$image[2], $image[3]]; + } + + return $byNumber; + } + + /** + * @param string $pdf An uncompressed document + * @param int|string $number An object with a stream + * + * @return string Its stream + */ + private function stream($pdf, $number) + { + preg_match('/stream\n(.*)\nendstream/s', $this->object($pdf, $number), $match); + + return $match[1]; + } + + /** + * @param bool $alpha Whether it has an alpha channel, which mPDF writes as a soft mask + * + * @return string A 16 by 16 greyscale PNG of every grey level, made here + */ + private function greyPng($alpha) + { + $rows = ''; + for ($y = 0; $y < 16; $y++) { + $rows .= "\0"; // no filter + for ($x = 0; $x < 16; $x++) { + $rows .= chr($y * 16 + $x) . ($alpha ? chr(255 - $x * 16) : ''); + } + } + + $chunk = function ($type, $data) { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + }; + + // Colour type 0 is grey, 4 grey with alpha + return "\x89PNG\r\n\x1a\n" + . $chunk('IHDR', pack('NNCCCCC', 16, 16, 8, $alpha ? 4 : 0, 0, 0, 0)) + . $chunk('IDAT', gzcompress($rows)) + . $chunk('IEND', ''); + } +} diff --git a/tests/Mpdf/PdfaOptionalContentTest.php b/tests/Mpdf/PdfaOptionalContentTest.php index fea8318e4..0892455e7 100644 --- a/tests/Mpdf/PdfaOptionalContentTest.php +++ b/tests/Mpdf/PdfaOptionalContentTest.php @@ -37,7 +37,7 @@ public function optionalContentConfigs() 'PDF/A-2u' => [['PDFA' => true, 'PDFAversion' => '2-U'], true], 'PDF/A-3b' => [['PDFA' => true, 'PDFAversion' => '3-B'], true], 'PDF/A-3u' => [['PDFA' => true, 'PDFAversion' => '3-U'], true], - 'PDF/X-1a' => [['PDFX' => true], false], + 'PDF/X-1a' => [['PDFX' => true, 'PDFXversion' => '1a'], false], ]; } diff --git a/tests/Mpdf/StaticListBoxTest.php b/tests/Mpdf/StaticListBoxTest.php index da35b2583..5fd2a755c 100644 --- a/tests/Mpdf/StaticListBoxTest.php +++ b/tests/Mpdf/StaticListBoxTest.php @@ -69,7 +69,7 @@ public function testAListBoxWithNothingSelectedHighlightsNothing() */ public function testTheHighlightIsCmykInAPdfxDocument() { - $page = $this->page('', ['PDFX' => true, 'mode' => 'utf-8']); + $page = $this->page('', ['PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true, 'mode' => 'utf-8']); $this->assertStringNotContainsString(self::HIGHLIGHT, $page); $this->assertMatchesRegularExpression('/[\d.]+ [\d.]+ [\d.]+ [\d.]+ k\n[\d.]+ [\d.]+ [\d.]+ -[\d.]+ re f\n/', $page); diff --git a/tests/Mpdf/Writer/CrossReferenceWriterTest.php b/tests/Mpdf/Writer/CrossReferenceWriterTest.php index 03ea6030b..4bc84de6c 100644 --- a/tests/Mpdf/Writer/CrossReferenceWriterTest.php +++ b/tests/Mpdf/Writer/CrossReferenceWriterTest.php @@ -87,7 +87,7 @@ public function classicTableProvider() 'not asked for, which is the default' => [[], true, false], 'not compressed, so readable' => [['useObjectStreams' => true], false, false], 'encrypted, as each string is encrypted where it is written' => [['useObjectStreams' => true], true, true], - 'PDF/X-1a, built on PDF 1.4' => [['useObjectStreams' => true, 'mode' => '', 'PDFX' => true, 'PDFXauto' => true], true, false], + 'PDF/X-1a, built on PDF 1.4' => [['useObjectStreams' => true, 'mode' => '', 'PDFX' => true, 'PDFXversion' => '1a', 'PDFXauto' => true], true, false], 'PDF/A-1b, built on PDF 1.4' => [['useObjectStreams' => true, 'mode' => '', 'PDFA' => true, 'PDFAauto' => true, 'PDFAversion' => '1-B'], true, false], ]; } diff --git a/tests/Snapshots/PdfX4FlattenedFormSnapshotTest.php b/tests/Snapshots/PdfX4FlattenedFormSnapshotTest.php new file mode 100644 index 000000000..9ee99dc24 --- /dev/null +++ b/tests/Snapshots/PdfX4FlattenedFormSnapshotTest.php @@ -0,0 +1,121 @@ +mpdf->Output*() method + */ + public function generatePdf() + { + ob_start(); + ?> + + +

PDF/X-4, an active form drawn on the page

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Text
Password
Text area
Combo box + +
List box + +
Check boxes + Checked + Not checked +
Radio buttons + Yes + No +
Buttons + + + +
Image button
Hidden(nothing drawn)
+
+ mpdf = $this->createMpdf(['PDFX' => true, 'ICCProfile' => __DIR__ . '/../data/icc/rgb-printer-header.icc', 'PDFXauto' => true, 'useActiveForms' => true]); + // PDF/X needs a title, and would otherwise take the name of the file it is written to + $this->mpdf->SetTitle('PDF/X-4, an active form drawn on the page'); + $this->mpdf->SetBasePath(__DIR__ . '/../data'); + + $this->mpdf->WriteHTML($html); + } +} diff --git a/tests/Snapshots/PdfX4RgbAndGreySnapshotTest.php b/tests/Snapshots/PdfX4RgbAndGreySnapshotTest.php new file mode 100644 index 000000000..5f1fb875e --- /dev/null +++ b/tests/Snapshots/PdfX4RgbAndGreySnapshotTest.php @@ -0,0 +1,88 @@ +mpdf->Output*() method + */ + public function generatePdf() + { + ob_start(); + ?> + + +

PDF/X-4, grey and RGB

+ +

Grey text on white, in a grey border. Red text + and blue text sit in the same line.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fills
Gradients
Grey to colour
Translucent
Images
+ +

 

+
A grey shadow
+

 

+
A blue shadow
+ mpdf = $this->createMpdf(['PDFX' => true, 'ICCProfile' => __DIR__ . '/../data/icc/rgb-printer-header.icc', 'PDFXauto' => true]); + // PDF/X needs a title, and would otherwise take the name of the file it is written to + $this->mpdf->SetTitle('PDF/X-4, grey and RGB'); + $this->mpdf->SetBasePath(__DIR__ . '/../data'); + + $this->mpdf->WriteHTML($html); + } +} diff --git a/tests/data/icc/README.md b/tests/data/icc/README.md new file mode 100644 index 000000000..a64bec1e5 --- /dev/null +++ b/tests/data/icc/README.md @@ -0,0 +1,8 @@ +# ICC profile fixtures + +`rgb-printer-header.icc` is an ICC version 2.1 profile header of the output +device (`prtr`) class, printing to RGB, with a tag table of no tags. It was +generated for the tests, carries no colour data and no third-party licence, +and gives a PDF/X-4 document an RGB output intent without embedding a real +profile. mPDF reads only the header of an output intent profile, to count its +colour components and check its class. diff --git a/tests/data/icc/rgb-printer-header.icc b/tests/data/icc/rgb-printer-header.icc new file mode 100644 index 0000000000000000000000000000000000000000..4790c6dee2baaaf9dade7ada8cda59a495b5184c GIT binary patch literal 132 ocmZQzU}#|g0ww_lhJvD!q9AuC1)szu1&|mFBqkRZz}U2)0j_}tFaQ7m literal 0 HcmV?d00001 diff --git a/tests/data/img/grey-gradient.jpg b/tests/data/img/grey-gradient.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b690e677a137968565fec67af5d862ee5323696d GIT binary patch literal 368 zcmex=``2_j6xdp@o1cgOJMMZh|#U;cShTKr;da54bYL zCV;f+{1bfs0b(Aq=?{RW1I<{V+|b3c>i|e=B=C}BY|%3W73fSe-i+Q_+Y{S literal 0 HcmV?d00001 diff --git a/tests/data/img/grey-gradient.png b/tests/data/img/grey-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..85319860483e218c1d559d4af6fcabb5297b7a1d GIT binary patch literal 129 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%0D~f{|{V+^~a<|%lxC?V*lY*7#Udp1FiB8 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 b|22zZR)cM-Os<&Ipe;B|g0;18kl()m_btdH literal 0 HcmV?d00001 diff --git a/tests/data/snapshots/pdfx4-rgb-and-grey.pdf b/tests/data/snapshots/pdfx4-rgb-and-grey.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bb5be788d164f40c372e41b40a2f8e7ee734a672 GIT binary patch literal 103453 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 Date: Thu, 24 Sep 2026 12:33:21 +1000 Subject: [PATCH 3/4] Give an imported page's device colour a default colour space where PDF/X does not permit it mPDF writes what it imports as it was, so an imported page kept the device colour its PDF/X output intent refuses. DeviceColorScanner finds the device colour spaces a page paints in, through its content, forms, images, shadings, patterns and inline images, and its resource dictionaries then name the ICC-based space mPDF paints its own colour in as the default (ISO 32000-1, 8.6.5.6): sRGB for DeviceRGB under a CMYK or grey intent, grey for DeviceGray under an RGB one, and, under PDFXauto alone as it guesses at the press, the bundled SWOP profile for DeviceCMYK under an RGB or grey intent. DeviceRGB in PDF/X-1a cannot be remapped, and is refused without PDFXauto. An imported page also loses the transparency group it is drawn in where transparency is forbidden, as PDF/X-1a and PDF/A-1 forbid it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/FpdiTrait.php | 144 +++++++ src/Import/DeviceColorScanner.php | 425 +++++++++++++++++++ src/Writer/BaseWriter.php | 14 + tests/Mpdf/Import/DeviceColorScannerTest.php | 166 ++++++++ tests/Mpdf/PdfX4Test.php | 191 +++++++++ 5 files changed, 940 insertions(+) create mode 100644 src/Import/DeviceColorScanner.php create mode 100644 tests/Mpdf/Import/DeviceColorScannerTest.php diff --git a/src/FpdiTrait.php b/src/FpdiTrait.php index 9685d9655..3b8e29c7f 100644 --- a/src/FpdiTrait.php +++ b/src/FpdiTrait.php @@ -2,6 +2,7 @@ namespace Mpdf; +use Mpdf\Import\DeviceColorScanner; use Mpdf\Import\ObjectStreamCrossReference; use Mpdf\Import\PdfParser; use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException; @@ -9,11 +10,13 @@ use setasign\Fpdi\PdfParser\StreamReader; use setasign\Fpdi\PdfParser\Type\PdfHexString; use setasign\Fpdi\PdfParser\Type\PdfIndirectObject; +use setasign\Fpdi\PdfParser\Type\PdfDictionary; use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference; use setasign\Fpdi\PdfParser\Type\PdfNull; use setasign\Fpdi\PdfParser\Type\PdfNumeric; use setasign\Fpdi\PdfParser\Type\PdfStream; use setasign\Fpdi\PdfParser\Type\PdfString; +use setasign\Fpdi\PdfParser\Type\PdfToken; use setasign\Fpdi\PdfParser\Type\PdfType; use setasign\Fpdi\PdfParser\Type\PdfTypeException; use setasign\Fpdi\PdfReader\PageBoundaries; @@ -39,6 +42,21 @@ trait FpdiTrait */ protected $templateId = 0; + /** + * The default colour spaces each imported document's resource dictionaries name, by reader id: the + * ICC-based space a device colour space PDF/X does not permit is painted in instead + * + * @var string[][] Object references, by DefaultRGB or DefaultGray + */ + private $importDefaultSpaces = []; + + /** + * The objects of each imported document that are resource dictionaries, by reader id + * + * @var bool[][] By object number + */ + private $importResourceObjects = []; + /** * Whether a reference in an imported document names a generation of its object other than the one the document's * cross-reference now lists, which makes it a reference to the null object (ISO 32000-1, 7.3.10 and 7.5.4) @@ -263,7 +281,16 @@ public function writeImportedPagesAndResolvedObjects() { $this->currentReaderId = null; + if ($this->PDFX) { + $this->conformImportedColor(); + } + foreach ($this->importedPages as $key => $pageData) { + // An imported page is drawn in a transparency group of its own, which PDF/X-1a and PDF/A-1 forbid + if (!$this->transparencyAllowed()) { + unset($pageData['stream']->value->value['Group']); + } + $this->writer->object(); $this->importedPages[$key]['objectNumber'] = $this->n; $this->currentReaderId = $pageData['readerId']; @@ -294,6 +321,119 @@ public function writeImportedPagesAndResolvedObjects() $this->currentReaderId = null; } + /** + * PDF/X permits only the device colour spaces its output intent prints to, and mPDF writes what it + * imports as it was. So where an imported page paints in one PDF/X-4 does not permit, its resource + * dictionaries name a default colour space (ISO 32000-1, 8.6.5.6): the ICC-based space mPDF paints its + * own RGB or grey in, or for CMYK under an RGB or grey intent the bundled SWOP profile - a guess at the + * press the CMYK was made for, so made only under PDFXauto, as mPDF's other conversions are. DeviceRGB in + * PDF/X-1a, which permits no ICC-based colour, cannot be remapped, and is warned of, and refused + * without PDFXauto. + * + * Asked between objects, as the ICC-based colour spaces are written when first asked for. + */ + private function conformImportedColor() + { + $this->importDefaultSpaces = []; + $this->importResourceObjects = []; + + $scanners = []; + foreach ($this->importedPages as $pageData) { + $readerId = $pageData['readerId']; + if (!isset($scanners[$readerId])) { + $scanners[$readerId] = new DeviceColorScanner($this->getPdfReader($readerId)->getParser()); + } + $scanners[$readerId]->scan($pageData['stream']); + } + + $permitted = $this->isPdfx4() + ? [1 => ['Gray'], 3 => ['RGB'], 4 => ['Gray', 'CMYK']][$this->pdfxOutputChannels()] + : ['Gray', 'CMYK']; + $intent = [1 => 'grey', 3 => 'RGB', 4 => 'CMYK'][$this->pdfxOutputChannels()]; + $remaps = ['RGB' => 'calibratedRgb', 'Gray' => 'calibratedGray', 'CMYK' => 'calibratedCmyk']; + + foreach ($scanners as $readerId => $scanner) { + $defaults = []; + foreach (array_diff($scanner->used(), $permitted) as $family) { + $space = $this->writer->{$remaps[$family]}(); + + if ($space === null) { + $this->pdfaxWarning(sprintf( + 'An imported page paints in Device%s, which %s files printing to %s do not permit, and mPDF cannot convert what it imports. (Left as it is)', + $family, + $this->pdfxVersionLabel(), + $intent + )); + continue; + } + + if ($family === 'CMYK') { + $this->pdfaxWarning(sprintf('An imported page paints in DeviceCMYK, which PDF/X-4 files printing to %s do not permit. (Painted as the CMYK of the bundled SWOP profile)', $intent)); + } + + $defaults['Default' . $family] = $space . ' 0 R'; + } + + if ($defaults) { + $this->importDefaultSpaces[$readerId] = $defaults; + $this->importResourceObjects[$readerId] = $scanner->resourceObjects(); + } + } + } + + /** + * @param PdfType $value An object of the document being imported from, as it is about to be written + * + * @return PdfType The object, with the default colour spaces its document's resource dictionaries name + */ + private function withImportDefaultSpaces(PdfType $value) + { + if (!isset($this->importDefaultSpaces[$this->currentReaderId])) { + return $value; + } + + if ($value instanceof PdfIndirectObject && $value->value instanceof PdfDictionary + && isset($this->importResourceObjects[$this->currentReaderId][$value->objectNumber])) { + return PdfIndirectObject::create($value->objectNumber, $value->generationNumber, $this->resourcesWithDefaults($value->value)); + } + + if ($value instanceof PdfDictionary && isset($value->value['Resources']) && $value->value['Resources'] instanceof PdfDictionary) { + $entries = $value->value; + $entries['Resources'] = $this->resourcesWithDefaults($entries['Resources']); + + return PdfDictionary::create($entries); + } + + return $value; + } + + /** + * @param PdfDictionary $resources A resource dictionary of the document being imported from + * + * @return PdfDictionary A copy naming the default colour spaces, where it names none of its own + */ + private function resourcesWithDefaults(PdfDictionary $resources) + { + $spaces = []; + if (isset($resources->value['ColorSpace'])) { + $named = PdfType::resolve($resources->value['ColorSpace'], $this->getPdfReader($this->currentReaderId)->getParser()); + if ($named instanceof PdfDictionary) { + $spaces = $named->value; + } + } + + foreach ($this->importDefaultSpaces[$this->currentReaderId] as $name => $reference) { + if (!isset($spaces[$name])) { + $spaces[$name] = PdfToken::create($reference); + } + } + + $entries = $resources->value; + $entries['ColorSpace'] = PdfDictionary::create($spaces); + + return PdfDictionary::create($entries); + } + public function getImportedPages() { return $this->importedPages; @@ -312,6 +452,10 @@ protected function _put($s, $newLine = true) */ public function writePdfType(PdfType $value) { + if ($this->importDefaultSpaces && ($value instanceof PdfDictionary || $value instanceof PdfIndirectObject)) { + $value = $this->withImportDefaultSpaces($value); + } + // A reference whose generation is not the object's current one is to an object that no longer exists if ($value instanceof PdfIndirectObjectReference && $this->isStale($value)) { $value = new PdfNull(); diff --git a/src/Import/DeviceColorScanner.php b/src/Import/DeviceColorScanner.php new file mode 100644 index 000000000..de2d3a6b6 --- /dev/null +++ b/src/Import/DeviceColorScanner.php @@ -0,0 +1,425 @@ +parser = $parser; + } + + /** + * @param PdfStream $form An imported page, as the form XObject it is written as + */ + public function scan(PdfStream $form) + { + $this->form($form, 0); + } + + /** + * @return string[] The device colour spaces what was scanned paints in: 'RGB', 'Gray' or 'CMYK' + */ + public function used() + { + return array_keys($this->used); + } + + /** + * @return bool[] The numbers of the objects, in the imported document, that are resource dictionaries of + * what was scanned, which a default colour space is named in, as keys + */ + public function resourceObjects() + { + return $this->resourceObjects; + } + + /** + * A form XObject or a tiling pattern: its transparency group's colour space, and its content + * + * @param PdfStream $stream + * @param int $depth + */ + private function form(PdfStream $stream, $depth) + { + if ($depth > self::MAX_DEPTH) { + return; + } + + $dictionary = $stream->value; + + $resources = PdfDictionary::get($dictionary, 'Resources'); + if ($resources instanceof PdfIndirectObjectReference) { + $this->resourceObjects[$resources->value] = true; + } + $resources = $this->resolve($resources); + if (!$resources instanceof PdfDictionary) { + $resources = new PdfDictionary(); + } + + $group = $this->resolve(PdfDictionary::get($dictionary, 'Group')); + if ($group instanceof PdfDictionary && isset($group->value['CS'])) { + $this->colorSpace($group->value['CS'], $resources, $depth); + } + + try { + $content = $stream->getUnfilteredStream(); + } catch (\Exception $e) { + return; // a filter mPDF cannot decode: nothing in the content can be found + } + + $this->content($content, $resources, $depth); + } + + /** + * @param string $content A content stream, unfiltered + * @param PdfDictionary $resources The resources it draws from + * @param int $depth + */ + private function content($content, PdfDictionary $resources, $depth) + { + $parser = new FpdiParser(StreamReader::createByString($content)); + $operands = []; + + while (true) { + try { + $value = $parser->readValue(); + } catch (\Exception $e) { + return; // content that does not parse: what came before it has been read + } + + if ($value === false) { + return; + } + + if (!$value instanceof PdfToken) { + $operands[] = $value; + continue; + } + + $operand = end($operands); + $name = $operand instanceof PdfName ? $operand->value : null; + $operands = []; + + switch ($value->value) { + case 'rg': + case 'RG': + $this->used['RGB'] = true; + break; + + case 'g': + case 'G': + $this->used['Gray'] = true; + break; + + case 'k': + case 'K': + $this->used['CMYK'] = true; + break; + + case 'cs': + case 'CS': + if ($name !== null) { + $this->colorSpace(PdfName::create($name), $resources, $depth); + } + break; + + case 'scn': + case 'SCN': + if ($name !== null) { + $this->pattern($this->resource($resources, 'Pattern', $name), $depth); + } + break; + + case 'sh': + if ($name !== null) { + $this->shading($this->resource($resources, 'Shading', $name), $resources, $depth); + } + break; + + case 'Do': + if ($name !== null) { + $this->xObject($this->resource($resources, 'XObject', $name), $resources, $depth); + } + break; + + case 'BI': + if (!$this->inlineImage($parser, $content, $resources, $depth)) { + return; + } + break; + } + } + } + + /** + * Reads an inline image's dictionary for its colour space, and steps past its data to its EI + * + * @param FpdiParser $parser Reading the content stream, just past the BI operator + * @param string $content The content stream + * @param PdfDictionary $resources + * @param int $depth + * + * @return bool Whether the end of the image was found, so that the content can be read on + */ + private function inlineImage(FpdiParser $parser, $content, PdfDictionary $resources, $depth) + { + $entries = []; + while (true) { + try { + $value = $parser->readValue(); + } catch (\Exception $e) { + return false; + } + + if ($value === false) { + return false; + } + + if ($value instanceof PdfToken && $value->value === 'ID') { + break; + } + + $entries[] = $value; + } + + $imageMask = false; + for ($i = 0; $i + 1 < count($entries); $i += 2) { + $key = $entries[$i] instanceof PdfName ? $entries[$i]->value : ''; + if ($key === 'IM' || $key === 'ImageMask') { + $imageMask = $entries[$i + 1]->value === true; + } + if (($key === 'CS' || $key === 'ColorSpace') && !$imageMask) { + $this->colorSpace($entries[$i + 1], $resources, $depth); + } + } + + // The data runs to an EI operator standing alone between white space (8.9.7) + $reader = $parser->getStreamReader(); + $start = $reader->getOffset() + $reader->getPosition(); + if (!preg_match('/\sEI(?=\s|$)/', $content, $match, PREG_OFFSET_CAPTURE, $start)) { + return false; + } + + $reader->reset($match[0][1] + strlen($match[0][0])); + $parser->getTokenizer()->clearStack(); + + return true; + } + + /** + * @param PdfType|null $xObject + * @param PdfDictionary $resources The resources the XObject is drawn from + * @param int $depth + */ + private function xObject($xObject, PdfDictionary $resources, $depth) + { + if ($this->alreadyScanned($xObject)) { + return; + } + + $xObject = $this->resolve($xObject); + if (!$xObject instanceof PdfStream) { + return; + } + + $subtype = PdfDictionary::get($xObject->value, 'Subtype'); + + if ($subtype instanceof PdfName && $subtype->value === 'Image') { + // A mask paints in no colour space of its own, and a soft mask's DeviceGray is the one PDF/X permits + $mask = $this->resolve(PdfDictionary::get($xObject->value, 'ImageMask')); + if (!($mask !== null && $mask->value === true) && isset($xObject->value->value['ColorSpace'])) { + $this->colorSpace($xObject->value->value['ColorSpace'], $resources, $depth); + } + + return; + } + + if ($subtype instanceof PdfName && $subtype->value === 'Form') { + $this->form($xObject, $depth + 1); + } + } + + /** + * @param PdfType|null $pattern + * @param int $depth + */ + private function pattern($pattern, $depth) + { + if ($this->alreadyScanned($pattern)) { + return; + } + + $pattern = $this->resolve($pattern); + + if ($pattern instanceof PdfStream) { + $this->form($pattern, $depth + 1); // a tiling pattern, whose cell is drawn as a form is + + return; + } + + if ($pattern instanceof PdfDictionary && isset($pattern->value['Shading'])) { + $this->shading($pattern->value['Shading'], new PdfDictionary(), $depth); + } + } + + /** + * @param PdfType|null $shading + * @param PdfDictionary $resources + * @param int $depth + */ + private function shading($shading, PdfDictionary $resources, $depth) + { + $shading = $this->resolve($shading); + if ($shading instanceof PdfStream) { + $shading = $shading->value; + } + + if ($shading instanceof PdfDictionary && isset($shading->value['ColorSpace'])) { + $this->colorSpace($shading->value['ColorSpace'], $resources, $depth); + } + } + + /** + * @param PdfType $space A colour space by name or as an array, or a reference to one + * @param PdfDictionary $resources Where a name that is not a colour space family is looked up + * @param int $depth + */ + private function colorSpace(PdfType $space, PdfDictionary $resources, $depth) + { + if ($depth > self::MAX_DEPTH) { + return; + } + + $space = $this->resolve($space); + + if ($space instanceof PdfName) { + $families = ['DeviceRGB' => 'RGB', 'RGB' => 'RGB', 'DeviceGray' => 'Gray', 'G' => 'Gray', 'DeviceCMYK' => 'CMYK', 'CMYK' => 'CMYK']; + if (isset($families[$space->value])) { + $this->used[$families[$space->value]] = true; + } elseif ($space->value !== 'Pattern') { + $named = $this->resource($resources, 'ColorSpace', $space->value); + if ($named !== null && !$this->alreadyScanned($named)) { + $this->colorSpace($named, $resources, $depth + 1); + } + } + + return; + } + + if (!$space instanceof PdfArray || !isset($space->value[0])) { + return; + } + + $family = $this->resolve($space->value[0]); + $family = $family instanceof PdfName ? $family->value : ''; + + // The space an indexed or pattern space is based on, and the alternate a separation falls back to + $within = ['Indexed' => 1, 'I' => 1, 'Pattern' => 1, 'Separation' => 2, 'DeviceN' => 2]; + if (isset($within[$family], $space->value[$within[$family]])) { + $this->colorSpace($space->value[$within[$family]], $resources, $depth + 1); + } + } + + /** + * @param PdfDictionary $resources + * @param string $category e.g. 'XObject' or 'ColorSpace' + * @param string $name + * + * @return PdfType|null The resource, unresolved + */ + private function resource(PdfDictionary $resources, $category, $name) + { + $entries = $this->resolve(PdfDictionary::get($resources, $category)); + + return $entries instanceof PdfDictionary && isset($entries->value[$name]) ? $entries->value[$name] : null; + } + + /** + * Marks an object as scanned, so that one drawn many times, or drawing itself, is read once + * + * @param PdfType|null $value A form, pattern, image or colour space, by reference or direct + * + * @return bool Whether it was scanned already; a direct object never was, as it has no number to know it by + */ + private function alreadyScanned($value) + { + if (!$value instanceof PdfIndirectObjectReference) { + return false; + } + + if (isset($this->scanned[$value->value])) { + return true; + } + + $this->scanned[$value->value] = true; + + return false; + } + + /** + * @param PdfType|null $value + * + * @return PdfType|null The value, followed through any reference, or null where it cannot be + */ + private function resolve($value) + { + if ($value === null) { + return null; + } + + try { + return PdfType::resolve($value, $this->parser); + } catch (\Exception $e) { + return null; + } + } + +} diff --git a/src/Writer/BaseWriter.php b/src/Writer/BaseWriter.php index 36898662d..009f93bda 100644 --- a/src/Writer/BaseWriter.php +++ b/src/Writer/BaseWriter.php @@ -352,6 +352,20 @@ public function calibratedGray() return $this->mpdf->writesCalibratedGray() ? $this->calibrated(self::GRAY_PROFILE, 1) : null; } + /** + * The colour space DeviceCMYK an imported page paints in is taken to be where PDF/X-4 does not permit + * it: under an RGB or grey output intent, where mPDF converts its own CMYK, but cannot convert what it + * imports. DeviceCMYK says nothing of the press it was made for, so it is taken as the bundled SWOP + * profile, the one mPDF prints to by default. Written the first time it is asked for, so asked for + * between objects. + * + * @return int|null The object number of the ICC-based colour space, or null where CMYK is DeviceCMYK + */ + public function calibratedCmyk() + { + return $this->mpdf->pdfxConvertsCmyk() ? $this->calibrated(Mpdf::PDFX4_OUTPUT_PROFILE, 4) : null; + } + /** * @return string The colour space grey is written in, for a dictionary to name: /DeviceGray, or a * reference to the ICC-based grey colour space - see calibratedGray() diff --git a/tests/Mpdf/Import/DeviceColorScannerTest.php b/tests/Mpdf/Import/DeviceColorScannerTest.php new file mode 100644 index 000000000..5186c9246 --- /dev/null +++ b/tests/Mpdf/Import/DeviceColorScannerTest.php @@ -0,0 +1,166 @@ +scan($this->form($content, $resources)); + + $used = $scanner->used(); + sort($used); + + return $used; + } + + /** + * @param string $content + * @param array $resources By category, each a dictionary of entries + * @param array $entries More entries of the form's dictionary + * + * @return PdfStream A form XObject, its content unfiltered + */ + private function form($content, array $resources = [], array $entries = []) + { + $categories = []; + foreach ($resources as $category => $named) { + $categories[$category] = PdfDictionary::create($named); + } + + return PdfStream::create(PdfDictionary::create($entries + [ + 'Subtype' => PdfName::create('Form'), + 'Resources' => PdfDictionary::create($categories), + ]), $content); + } + + /** + * @param string[] $names + * + * @return PdfArray + */ + private function names(array $names) + { + return PdfArray::create(array_map(function ($name) { + return PdfName::create($name); + }, $names)); + } + + /** + * The operators that set a device colour, and a device colour space selected by name + * + * @dataProvider operators + * + * @param string $content + * @param string[] $used + */ + public function testOperatorsAndDeviceNames($content, array $used) + { + $this->assertSame($used, $this->scan($content)); + } + + /** + * @return array[] Content, and the device colour spaces it paints in + */ + public function operators() + { + return [ + 'rg and RG' => ['1 0 0 rg 0 0 1 RG', ['RGB']], + 'g and G' => ['0.5 g', ['Gray']], + 'k and K' => ['0 0 0 1 K', ['CMYK']], + 'cs by device name' => ['/DeviceRGB cs 1 0 0 sc', ['RGB']], + 'each at once' => ['1 0 0 rg 0 g 0 0 0 1 k', ['CMYK', 'Gray', 'RGB']], + 'text is not an operator' => ['BT (1 0 0 rg) Tj ET', []], + 'none' => ['0 0 10 10 re f', []], + ]; + } + + /** + * A named colour space is looked up, and an indexed space is followed to its base and a separation to + * its alternate, which a reader paints in where it has no such ink + */ + public function testNamedIndexedAndSeparationSpaces() + { + $indexed = PdfArray::create([PdfName::create('Indexed'), PdfName::create('DeviceCMYK'), PdfNumeric::create(1)]); + $separation = PdfArray::create([PdfName::create('Separation'), PdfName::create('Spot'), PdfName::create('DeviceRGB')]); + $icc = PdfArray::create([PdfName::create('ICCBased'), PdfName::create('Profile')]); + + $this->assertSame(['CMYK'], $this->scan('/CS0 cs 0 sc', ['ColorSpace' => ['CS0' => $indexed]])); + $this->assertSame(['RGB'], $this->scan('/CS0 CS 1 SC', ['ColorSpace' => ['CS0' => $separation]])); + $this->assertSame([], $this->scan('/CS0 cs 1 0 0 sc', ['ColorSpace' => ['CS0' => $icc]])); + } + + /** + * An inline image's colour space is read from its dictionary, and its data, however it reads, is stepped + * past to the operators after it; an inline mask paints in no colour space of its own + */ + public function testInlineImages() + { + $this->assertSame(['CMYK', 'RGB'], $this->scan("BI /W 1 /H 1 /BPC 8 /CS /RGB ID \x00rg\xff EI 0 0 0 1 k")); + $this->assertSame(['Gray'], $this->scan("BI /W 1 /H 1 /IM true /CS /RGB ID \x80 EI 0 g")); + $this->assertSame(['Gray'], $this->scan("BI /W 1 /H 1 /BPC 8 /CS /G ID \x80 EI")); + } + + /** + * An image XObject paints in its colour space, and a form XObject in whatever it draws, followed however + * deep it nests; an image mask paints in none + */ + public function testXObjects() + { + $image = PdfStream::create(PdfDictionary::create(['Subtype' => PdfName::create('Image'), 'ColorSpace' => PdfName::create('DeviceRGB')]), ''); + $mask = PdfStream::create(PdfDictionary::create(['Subtype' => PdfName::create('Image'), 'ImageMask' => PdfBoolean::create(true), 'ColorSpace' => PdfName::create('DeviceCMYK')]), ''); + $inner = $this->form('0.5 g'); + $outer = $this->form('/Inner Do', ['XObject' => ['Inner' => $inner]]); + + $this->assertSame(['RGB'], $this->scan('/Im0 Do', ['XObject' => ['Im0' => $image]])); + $this->assertSame([], $this->scan('/Mask Do', ['XObject' => ['Mask' => $mask]])); + $this->assertSame(['Gray'], $this->scan('/Outer Do', ['XObject' => ['Outer' => $outer]])); + } + + /** + * A shading painted with sh, and one a pattern paints with, are in their colour spaces, as is the + * transparency group a form is composited in + */ + public function testShadingsPatternsAndGroups() + { + $shading = PdfDictionary::create(['ShadingType' => PdfNumeric::create(2), 'ColorSpace' => PdfName::create('DeviceRGB')]); + $pattern = PdfDictionary::create(['PatternType' => PdfNumeric::create(2), 'Shading' => PdfDictionary::create(['ColorSpace' => PdfName::create('DeviceCMYK')])]); + + $this->assertSame(['RGB'], $this->scan('/Sh0 sh', ['Shading' => ['Sh0' => $shading]])); + $this->assertSame(['CMYK'], $this->scan('/Pattern cs /P0 scn 0 0 10 10 re f', ['Pattern' => ['P0' => $pattern]])); + + $scanner = new DeviceColorScanner(new FpdiParser(StreamReader::createByString(''))); + $scanner->scan($this->form('', [], ['Group' => PdfDictionary::create(['S' => PdfName::create('Transparency'), 'CS' => PdfName::create('DeviceRGB')])])); + $this->assertSame(['RGB'], $scanner->used()); + } + + /** + * Content that stops parsing part way keeps what was found before it + */ + public function testContentThatDoesNotParseKeepsWhatCameBefore() + { + $this->assertSame(['RGB'], $this->scan('1 0 0 rg BI /W 1 ID no end')); + } + +} diff --git a/tests/Mpdf/PdfX4Test.php b/tests/Mpdf/PdfX4Test.php index 327f5ac82..04b64b686 100644 --- a/tests/Mpdf/PdfX4Test.php +++ b/tests/Mpdf/PdfX4Test.php @@ -244,6 +244,197 @@ private function x4($intent = null) return $intent ? ['PDFX' => true, 'ICCProfile' => $this->$intent] : ['PDFX' => true]; } + /** + * @param array $config The source document's configuration + * @param string $html + * + * @return string A document mPDF wrote, to import a page of + */ + private function source(array $config, $html = null) + { + $source = $this->mpdf($config); + $source->SetTitle('Source'); + $source->WriteHTML($html === null + ? '

Red

Black

' + . '
Gradient
' + : $html); + + return $source->OutputBinaryData(); + } + + /** + * @param array $config Merged over PDF/X in the strict mode + * @param string $source A document to import the first page of + * + * @return Mpdf A document with that page placed on it, not yet written + */ + private function importing(array $config, $source) + { + $mpdf = $this->mpdf($config + ['PDFX' => true, 'PDFXauto' => false]); + $mpdf->WriteHTML('

Imported below

'); + $mpdf->setSourceFile(StreamReader::createByString($source)); + $mpdf->useTemplate($mpdf->importPage(1), 20, 40, 100); + + return $mpdf; + } + + /** + * A PDF/X-4 document mPDF wrote imports into a new PDF/X-4 document printing to the same intent, in + * the strict mode, with nothing to warn of: its RGB is already ICC-based, and its grey DeviceGray only + * where the intent permits it + * + * @dataProvider sameIntents + * + * @param string|null $intent The property naming the output intent's profile, or null for the default + */ + public function testAPdfx4DocumentImportsIntoAPdfx4Document($intent) + { + $config = $this->x4($intent); + + $mpdf = $this->importing($config, $this->source($config)); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertSame([], $mpdf->PDFAXwarnings); + $this->assertSame(1, substr_count($pdf, '/S /GTS_PDFX'), 'the imported page brings no output intent of its own'); + $this->assertStringNotContainsString('/DefaultRGB', $pdf); + $this->assertStringNotContainsString('/DefaultGray', $pdf); + } + + /** + * @return array[] The CMYK default and an RGB intent + */ + public function sameIntents() + { + return ['CMYK, the default' => [null], 'RGB' => ['rgbProfile']]; + } + + /** + * An imported page's DeviceRGB or DeviceGray, which the intent does not permit, is painted in the ICC- + * based space mPDF paints its own RGB or grey in, named as the default colour space of the imported + * page's resources + * + * @dataProvider remappedImports + * + * @param string|null $intent The property naming the output intent's profile, or null for the default + * @param string $default The default colour space named + */ + public function testAnImportedPagesDeviceColourIsGivenAnIccBasedDefault($intent, $default) + { + $config = $this->x4($intent); + + $mpdf = $this->importing($config, $this->source([])); + $pdf = $mpdf->OutputBinaryData(); + + $this->assertSame([], $mpdf->PDFAXwarnings); + $this->assertSame(1, preg_match('/\/' . $default . ' (\d+) 0 R/', $pdf, $match)); + $this->assertStringStartsWith('[/ICCBased ', $this->object($pdf, $match[1])); + } + + /** + * @return array[] Each intent a plain document's device colour is remapped for, and the default named + */ + public function remappedImports() + { + return ['DeviceRGB under CMYK' => [null, 'DefaultRGB'], 'DeviceGray under RGB' => ['rgbProfile', 'DefaultGray']]; + } + + /** + * An imported page is drawn in a transparency group of its own, except in PDF/X-1a, which permits none + * + * @dataProvider pdfxVersions + * + * @param array $pdfx The configuration of the PDF/X version + */ + public function testAnImportedPageIsGroupedOnlyWhereTransparencyIsPermitted(array $pdfx) + { + $pdf = $this->importing($pdfx + ['PDFXauto' => true], $this->source($pdfx, '

Text

'))->OutputBinaryData(); + + if (isset($pdfx['PDFXversion'])) { + $this->assertStringNotContainsString('/S /Transparency', $pdf); + } else { + $this->assertStringContainsString('/Group <x4($intent); + $source = $this->source([], '

Magenta

'); + + $pdf = $this->importing($config + ['PDFXauto' => true], $source)->OutputBinaryData(); + $this->assertSame(1, preg_match('/\/DefaultCMYK (\d+) 0 R/', $pdf, $match)); + $this->assertSame(1, preg_match('/^\[\/ICCBased (\d+) 0 R\]/', $this->object($pdf, $match[1]), $profile)); + $this->assertStringContainsString("\n" . $profile[1] . " 0 obj\n<>", $pdf); + + $mpdf = $this->importing($config, $source); + try { + $mpdf->OutputBinaryData(); + $this->fail('the document is refused'); + } catch (MpdfException $e) { + $this->assertSame([sprintf('An imported page paints in DeviceCMYK, which PDF/X-4 files printing to %s do not permit. (Painted as the CMYK of the bundled SWOP profile)', $named)], $mpdf->PDFAXwarnings); + } + } + + /** + * @return array[] The intents that permit no DeviceCMYK + */ + public function cmykImports() + { + return ['RGB' => ['rgbProfile', 'RGB'], 'grey' => ['grayProfile', 'grey']]; + } + + /** + * DeviceRGB in PDF/X-1a, which permits no ICC-based colour, cannot be remapped: the strict mode refuses + * the document, and PDFXauto writes it as it is + * + * @dataProvider unremappableImports + * + * @param array $config + * @param string $html The imported page + * @param string $warning + */ + public function testImportedDeviceColourThatCannotBeRemappedIsRefused(array $config, $html, $warning) + { + $config = array_map(function ($value) { + return $value === 'rgbProfile' ? $this->rgbProfile : $value; + }, $config); + $source = $this->source(['pdf_version' => '1.4'], $html); + + $this->importing($config + ['PDFXauto' => true], $source)->OutputBinaryData(); + + $mpdf = $this->importing($config, $source); + try { + $mpdf->OutputBinaryData(); + $this->fail('the document is refused'); + } catch (MpdfException $e) { + $this->assertSame([$warning], $mpdf->PDFAXwarnings); + } + } + + /** + * @return array[] Each device colour an intent cannot take and mPDF cannot remap, with the warning it gives + */ + public function unremappableImports() + { + return [ + 'DeviceRGB under PDF/X-1a' => [ + self::PDFX1A, + '

Red

', + 'An imported page paints in DeviceRGB, which PDF/X-1a:2003 files printing to CMYK do not permit, and mPDF cannot convert what it imports. (Left as it is)', + ], + ]; + } + /** * PDF/X-4 asks for a printer profile as its output intent, so a document that names none prints to * the bundled SWOP profile, identified by the CGATS TR 003 characterisation it is built on From e3d35bb54e046cf9b52f25543a07a9cfada17152 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 24 Sep 2026 12:33:46 +1000 Subject: [PATCH 4/4] Hold mPDF's PDF/X-4 output to a veraPDF policy in CI veraPDF has no PDF/X profile, so this is a regression test over the features it extracts, not conformance validation. The policy checks the output intent, the device colour each intent permits, the ICC-based spaces, the PDF version, the XMP and Info identification, the title, and that no JavaScript, widget, attachment, link or interpolated image is left. pdfx4_policy.php writes a conformant document for each intent and, beside each, one that breaks a single rule, and holds each to the failures it expects, so a rule that stops matching fails the run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pdfx4-policy.yml | 81 +++++++ utils/verapdf/pdfx4-policy.sch | 109 +++++++++ utils/verapdf/pdfx4_policy.php | 362 +++++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 .github/workflows/pdfx4-policy.yml create mode 100644 utils/verapdf/pdfx4-policy.sch create mode 100644 utils/verapdf/pdfx4_policy.php diff --git a/.github/workflows/pdfx4-policy.yml b/.github/workflows/pdfx4-policy.yml new file mode 100644 index 000000000..7f2c7958f --- /dev/null +++ b/.github/workflows/pdfx4-policy.yml @@ -0,0 +1,81 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions +# +# A veraPDF policy check over the features of mPDF's PDF/X-4 output. veraPDF has no PDF/X validation +# profile, so this is NOT a conformance verdict - see utils/verapdf/pdfx4_policy.php. + +name: "PDF/X-4 Policy" + +on: + pull_request: + push: + branches: + - "master" + - "development" + - "test" + - "gravitypdf" + +jobs: + + policy: + + name: "veraPDF policy" + + runs-on: ubuntu-latest + + steps: + - name: "Checkout" + uses: "actions/checkout@v7" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "8.5" + extensions: "mbstring, gd, bcmath, bz2" + tools: composer:v2 + ini-values: error_reporting=-1 + + - name: "Install Java" + uses: "actions/setup-java@v5" + with: + distribution: "temurin" + java-version: "21" + + - name: "Install veraPDF" + run: | + curl -fsSL -o verapdf-installer.zip https://software.verapdf.org/rel/verapdf-installer.zip + unzip -q verapdf-installer.zip -d "$RUNNER_TEMP" + cat > "$RUNNER_TEMP/auto-install.xml" < + + + + $RUNNER_TEMP/verapdf + + + + + + + + + + + XML + java -jar "$RUNNER_TEMP"/verapdf-greenfield-*/verapdf-izpack-installer-*.jar "$RUNNER_TEMP/auto-install.xml" + "$RUNNER_TEMP/verapdf/verapdf" --version + echo "VERAPDF_BIN=$RUNNER_TEMP/verapdf/verapdf" >> "$GITHUB_ENV" + + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + + - name: "Policy check" + run: php utils/verapdf/pdfx4_policy.php "$VERAPDF_BIN" + + - name: "Upload the documents" + if: failure() + uses: "actions/upload-artifact@v4" + with: + name: "pdfx4-policy-documents" + path: "tmp/verapdf" + if-no-files-found: "ignore" diff --git a/utils/verapdf/pdfx4-policy.sch b/utils/verapdf/pdfx4-policy.sch new file mode 100644 index 000000000..3c8391967 --- /dev/null +++ b/utils/verapdf/pdfx4-policy.sch @@ -0,0 +1,109 @@ + + + + + + + mPDF PDF/X-4 output policy + + + + + + + + + [output-intent] A PDF/X document carries exactly one GTS_PDFX output intent, and this one carries . + + [dest-output-profile] The output intent of a PDF/X-4 document names an embedded ICC profile in /DestOutputProfile, and this one names none. + + [intent-colour-space] The profile the output intent names is embedded and prints to grey, RGB or CMYK. + + + + + + + + + [no-device-rgb] Printing to a CMYK or grey output intent, a PDF/X-4 document may not use DeviceRGB, and this one does. + + [no-device-gray] Printing to an RGB output intent, a PDF/X-4 document may use DeviceGray only for the soft mask of an image, and this one uses it elsewhere. + + [no-device-cmyk] Printing to an RGB or grey output intent, a PDF/X-4 document may not use DeviceCMYK, and this one does. + + + + + + + + + [icc-components] An ICC-based colour space says it has components, and the profile it wraps prints to . + + + + + + + + [pdf-version] PDF/X-4 is written as PDF 1.6, and this document says . + + [xmp-pdfx-version] The XMP metadata identify the document as PDF/X-4 through the pdfxid schema. + + [info-pdfx-version] The Info dictionary identifies the document as PDF/X-4. + + [xmp-title] PDF/X expects a document title, and the XMP metadata carry none in dc:title. + + [info-title] PDF/X expects a document title, and the Info dictionary carries none. + + + + + + + + [no-javascript] PDF/X permits no JavaScript, and this document runs some. + + [no-widget] PDF/X permits no interactive form field, and this document has . + + [no-file-attachment] PDF/X permits no file attachment, and this document has . + + [no-link] PDF/X permits no annotation within the printed area. These documents are printed to the trimmed page, so a link anywhere on them is within it, and this one has . + + + + + + + + [no-interpolation] PDF/X permits no interpolation of an image, and this one asks for it. + + + + + diff --git a/utils/verapdf/pdfx4_policy.php b/utils/verapdf/pdfx4_policy.php new file mode 100644 index 000000000..8c9350ce7 --- /dev/null +++ b/utils/verapdf/pdfx4_policy.php @@ -0,0 +1,362 @@ + + * + * This is NOT PDF/X-4 validation. veraPDF validates PDF/A, PDF/UA and WTPDF only, and has no PDF/X + * profile; --policyfile runs a Schematron over the features it extracts from a document, which is a + * regression test and nothing more. Real conformance still needs a preflight tool. + * + * A Schematron that matches nothing passes in silence, which would be worse than no check at all. So + * this writes a conformant document for each output intent and, beside each, a document that breaks + * one rule and nothing else, and it holds every one of them to the failures it expects. A rule that + * stops matching what it is aimed at fails the run as loudly as a document that stops conforming. + * + * The default document prints to the bundled SWOP profile, a CMYK one. The documents that print to RGB + * name tests/data/icc/rgb-printer-header.icc, a printer profile header that carries no colour data. + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Mpdf\Config\ConfigVariables; +use Mpdf\Config\FontVariables; +use Mpdf\Mpdf; +use Mpdf\Utils\UtfString; + +$verapdf = isset($argv[1]) ? $argv[1] : ''; + +if (!$verapdf) { + fwrite(STDERR, "Usage: php utils/verapdf/pdfx4_policy.php \n"); + exit(2); +} + +$rgbProfile = __DIR__ . '/../../tests/data/icc/rgb-printer-header.icc'; +$rgbIntent = ['ICCProfile' => $rgbProfile]; + +$policy = __DIR__ . '/pdfx4-policy.sch'; +$directory = __DIR__ . '/../../tmp/verapdf'; + +// The features the policy reads. Anything not asked for is missing from the report, and an assertion +// over what is missing passes in silence. +$features = 'actions,annotations,colorSpace,formXobject,iccProfile,imageXobject,informationDict,lowLevelInfo,metadata,outputIntent,page,shading'; + +if (!is_dir($directory) && !mkdir($directory, 0777, true)) { + fwrite(STDERR, sprintf("Cannot write to %s\n", $directory)); + exit(2); +} + +/** + * @param array $config Merged over PDF/X-4 with automatic fixing and no compression, which every + * mutation below reads and rewrites + * + * @return Mpdf A titled document, as PDF/X asks for + */ +function document(array $config = []) +{ + $mpdf = new Mpdf($config + ['mode' => 'utf-8', 'PDFX' => true, 'PDFXauto' => true]); + $mpdf->SetCompression(false); + $mpdf->SetTitle('mPDF PDF/X-4 policy check'); + + return $mpdf; +} + +/** + * @return string The HTML of the conformant documents: text, a gradient, an RGB image and a palette + * image, which between them settle every colour space the policy looks at + */ +function content() +{ + $true = imagecreatetruecolor(8, 8); + imagefilledrectangle($true, 0, 0, 7, 7, imagecolorallocate($true, 200, 60, 30)); + ob_start(); + imagepng($true); + $rgb = base64_encode(ob_get_clean()); + + $palette = imagecreate(8, 8); + imagecolorallocate($palette, 10, 120, 240); + ob_start(); + imagepng($palette); + $indexed = base64_encode(ob_get_clean()); + + return '

Heading

' + . '

Body text, and a colour given as CMYK.

' + . '
A gradient
' + . ' '; +} + +/** + * @return string The HTML of what a document printing to RGB draws in grey: a greyscale image, one with + * alpha, whose soft mask is the one DeviceGray permitted, a gradient of grey stops, and + * the luminosity soft masks behind an rgba gradient and a shadow + */ +function greyContent() +{ + $chunk = function ($type, $data) { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + }; + + $grey = ''; + $greyAlpha = ''; + for ($y = 0; $y < 8; $y++) { + $grey .= "\0"; + $greyAlpha .= "\0"; + for ($x = 0; $x < 8; $x++) { + $grey .= chr($x * 32); + $greyAlpha .= chr($x * 32) . chr($y * 32); + } + } + + $png = function ($colourType, $rows) use ($chunk) { + return base64_encode("\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', 8, 8, 8, $colourType, 0, 0, 0)) . $chunk('IDAT', gzcompress($rows)) . $chunk('IEND', '')); + }; + + return ' ' + . '
A grey gradient
' + . '
A translucent gradient
' + . '
A shadow
'; +} + +/** + * Reads a document back, replaces one string in it with another of the same length - so that every + * object keeps its offset and the cross-reference table stays true - and writes it out again + * + * @param string $from The document to read + * @param string $to The document to write + * @param string $find + * @param string $with Of the same length as $find + */ +function mutate($from, $to, $find, $with) +{ + if (strlen($find) !== strlen($with)) { + throw new \RuntimeException(sprintf('"%s" and "%s" are not the same length', $find, $with)); + } + + $pdf = file_get_contents($from); + if (substr_count($pdf, $find) !== 1) { + throw new \RuntimeException(sprintf('"%s" is in %s %d times', $find, basename($from), substr_count($pdf, $find))); + } + + file_put_contents($to, str_replace($find, $with, $pdf)); +} + +/** + * The same, where what is replaced is matched rather than named, and what replaces it is padded out + * to the same length with spaces + * + * @param string $from The document to read + * @param string $to The document to write + * @param string $pattern A regular expression matching one place in the document + * @param string $with No longer than what it replaces + */ +function mutateMatch($from, $to, $pattern, $with) +{ + $pdf = file_get_contents($from); + if (preg_match_all($pattern, $pdf, $matches) !== 1) { + throw new \RuntimeException(sprintf('%s matches %s other than once', $pattern, basename($from))); + } + + mutate($from, $to, $matches[0][0], str_pad($with, strlen($matches[0][0]))); +} + +/** + * Runs veraPDF over a document with the policy, with validation left on because veraPDF applies no + * policy without it, and the validation verdict - against PDF/A, the only thing it could validate + * against - ignored + * + * @param string $verapdf The veraPDF binary + * @param string $features The features to extract + * @param string $policy The Schematron + * @param string $pdf The document + * + * @return string[] The name of each rule the document broke, as the policy brackets them + */ +function broke($verapdf, $features, $policy, $pdf) +{ + $command = sprintf( + '%s --off --extract %s --policyfile %s --format xml %s 2>&1', + escapeshellarg($verapdf), + escapeshellarg($features), + escapeshellarg($policy), + escapeshellarg($pdf) + ); + + $output = (string) shell_exec($command); + + // veraPDF logs to standard error as it reads a document, ahead of the report + $report = strstr($output, 'loadXML($report)) { + throw new \RuntimeException(sprintf("veraPDF wrote no report for %s:\n%s", basename($pdf), $output)); + } + + $xpath = new \DOMXPath($document); + $reports = $xpath->query('//policyReport'); + if (!$reports->length) { + throw new \RuntimeException(sprintf('veraPDF applied no policy to %s', basename($pdf))); + } + + $broke = []; + foreach ($xpath->query('//policyReport/failedChecks/check/message') as $message) { + if (preg_match('/^\[([a-z-]+)\]/', trim($message->textContent), $match)) { + $broke[] = $match[1]; + continue; + } + + throw new \RuntimeException(sprintf('A policy message names no rule: %s', trim($message->textContent))); + } + + sort($broke); + + return array_values(array_unique($broke)); +} + +// A conformant document for each output intent, and the mutations of them that each break one rule +$cases = []; + +$srgb = $directory . '/srgb.pdf'; +$mpdf = document($rgbIntent); +$mpdf->WriteHTML(content() . greyContent()); +$mpdf->OutputFile($srgb); +$cases['srgb'] = ['file' => $srgb, 'breaks' => [], 'what' => 'a document printing to an RGB output intent']; + +// The ICC-based grey colour space, made DeviceGray +$greyInSrgb = $directory . '/device-gray-in-srgb.pdf'; +mutateMatch($srgb, $greyInSrgb, '/\[\/ICCBased \d+ 0 R\]/', '/DeviceGray'); +$cases['device-gray-in-srgb'] = ['file' => $greyInSrgb, 'breaks' => ['no-device-gray'], 'what' => 'DeviceGray in a document printing to RGB']; + +// The output intent's profile, made to print to a colour space nothing knows +$unknown = $directory . '/unknown-intent-space.pdf'; +$header = substr(file_get_contents($rgbProfile), 0, 20); +$prefix = "/N 3\n/Length " . filesize($rgbProfile) . ">>\nstream\n"; +mutate($srgb, $unknown, $prefix . $header, $prefix . substr_replace($header, 'FOO ', 16, 4)); +$cases['unknown-intent-space'] = ['file' => $unknown, 'breaks' => ['intent-colour-space'], 'what' => 'its output intent printing to a colour space nothing knows']; + +// CMYK left in a document that prints to RGB: the gradient is drawn before PDFX is set, so mPDF +// does not convert its stops +$cmykInSrgb = $directory . '/device-cmyk-in-srgb.pdf'; +$mpdf = document(['PDFX' => false] + $rgbIntent); +$mpdf->WriteHTML('
A gradient
'); +$mpdf->PDFX = true; +$mpdf->PDFXauto = true; +$mpdf->OutputFile($cmykInSrgb); +$cases['device-cmyk-in-srgb'] = ['file' => $cmykInSrgb, 'breaks' => ['no-device-cmyk'], 'what' => 'DeviceCMYK in a document printing to RGB']; + +// A spot colour in a document that prints to RGB, and the same falling back to DeviceCMYK +$spot = $directory . '/spot-srgb.pdf'; +$mpdf = document($rgbIntent); +$mpdf->AddSpotColor('PANTONE 300 C', 100, 44, 0, 0); +$mpdf->WriteHTML('

A spot colour

'); +$mpdf->OutputFile($spot); +$cases['spot-srgb'] = ['file' => $spot, 'breaks' => [], 'what' => 'a spot colour in a document printing to RGB']; + +$cmykSpot = $directory . '/cmyk-spot-in-srgb.pdf'; +mutate($spot, $cmykSpot, "/DeviceRGB <<\n/Range", "/DeviceCMYK<<\n/Range"); +$cases['cmyk-spot-in-srgb'] = ['file' => $cmykSpot, 'breaks' => ['no-device-cmyk'], 'what' => 'a spot colour falling back to DeviceCMYK in a document printing to RGB']; + +// An active form, which PDFXauto draws on the page rather than writing its fields and their scripts +$forms = $directory . '/forms-srgb.pdf'; +$mpdf = document(['useActiveForms' => true] + $rgbIntent); +$mpdf->WriteHTML('
' + . '

' + . '

' + . '

' + . '

' + . '

' + . '' + . '
'); +$mpdf->OutputFile($forms); +$cases['forms-srgb'] = ['file' => $forms, 'breaks' => [], 'what' => 'an active form drawn on the page of a document printing to RGB']; + +// Everything PDF/X refuses, in a document that is not PDF/X at all +$nonconformant = $directory . '/nonconformant.pdf'; +$configVariables = new ConfigVariables(); +$fontVariables = new FontVariables(); +$defaults = $configVariables->getDefaults() + $fontVariables->getDefaults(); +$mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'useActiveForms' => true, + 'allowAnnotationFiles' => true, + 'fontDir' => array_merge($defaults['fontDir'], [__DIR__ . '/../../tests/data/ttf/color']), + 'fontdata' => $defaults['fontdata'] + ['cbdt' => ['R' => 'TestEmoji-CBDT.ttf', 'useOTL' => 0xFF]], +]); +$mpdf->SetCompression(false); +$mpdf->SetJS('app.alert("x");'); +$mpdf->WriteHTML('

' . UtfString::code2utf(0x1F600) . '

' + . '
'); +$mpdf->Annotation('A note', 60, 60, 'Note', '', '', 0, false, false, __DIR__ . '/../../tests/data/pdfs/2-Page-PDF_1_4.pdf'); +$mpdf->WriteHTML('
'); +$mpdf->OutputFile($nonconformant); +$cases['nonconformant'] = [ + 'file' => $nonconformant, + 'breaks' => [ + 'dest-output-profile', + 'info-pdfx-version', + 'info-title', + 'intent-colour-space', + 'no-file-attachment', + 'no-interpolation', + 'no-javascript', + 'no-link', + 'no-widget', + 'output-intent', + 'pdf-version', + 'xmp-pdfx-version', + 'xmp-title', + ], + 'what' => 'a document that is not PDF/X at all', +]; + +$cmyk = $directory . '/cmyk.pdf'; +$mpdf = document(); +$mpdf->WriteHTML(content()); +$mpdf->OutputFile($cmyk); +$cases['cmyk'] = ['file' => $cmyk, 'breaks' => [], 'what' => 'the default document, printing to the bundled SWOP profile']; + +// The ICC-based sRGB colour space, made DeviceRGB +$rgbInCmyk = $directory . '/device-rgb-in-cmyk.pdf'; +mutateMatch($cmyk, $rgbInCmyk, '/\[\/ICCBased \d+ 0 R\]/', '/DeviceRGB'); +$cases['device-rgb-in-cmyk'] = ['file' => $rgbInCmyk, 'breaks' => ['no-device-rgb'], 'what' => 'DeviceRGB in a document printing to CMYK']; + +// The ICC-based sRGB colour space, made to claim four components +$components = $directory . '/wrong-icc-components.pdf'; +mutate($cmyk, $components, '< $components, 'breaks' => ['icc-components'], 'what' => 'an ICC-based colour space miscounting its components']; + +echo "veraPDF policy check of mPDF's PDF/X-4 output\n"; +echo "This is a policy over extracted features, not PDF/X-4 validation.\n\n"; + +$failed = 0; +foreach ($cases as $name => $case) { + $broke = broke($verapdf, $features, $policy, $case['file']); + $expected = $case['breaks']; + sort($expected); + + $unexpected = array_diff($broke, $expected); + $missing = array_diff($expected, $broke); + + if (!$unexpected && !$missing) { + echo sprintf(" ok %-22s %s%s\n", $name, $case['what'], $expected ? ' (breaks ' . implode(', ', $expected) . ')' : ''); + continue; + } + + $failed++; + echo sprintf(" FAIL %-22s %s\n", $name, $case['what']); + if ($missing) { + echo sprintf(" rules that did not fire: %s\n", implode(', ', $missing)); + } + if ($unexpected) { + echo sprintf(" rules that fired unasked: %s\n", implode(', ', $unexpected)); + } +} + +echo "\n"; + +if ($failed) { + echo sprintf("%d of %d cases did not come out as expected.\n", $failed, count($cases)); + exit(1); +} + +echo sprintf("All %d cases came out as expected.\n", count($cases));

A link