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/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 000000000..3cf641591 Binary files /dev/null and b/data/iccprofiles/Gray_sRGB_TRC.icc differ 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/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/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..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) @@ -102,13 +120,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; } } @@ -253,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']; @@ -284,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; @@ -302,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/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/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/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..009f93bda 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,97 @@ 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; + } + + /** + * 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() + */ + 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/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/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/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'], ]; 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/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/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..04b64b686 --- /dev/null +++ b/tests/Mpdf/PdfX4Test.php @@ -0,0 +1,1802 @@ + 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]; + } + + /** + * @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 + */ + 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 000000000..4790c6dee Binary files /dev/null and b/tests/data/icc/rgb-printer-header.icc differ diff --git a/tests/data/img/grey-gradient.jpg b/tests/data/img/grey-gradient.jpg new file mode 100644 index 000000000..b690e677a Binary files /dev/null and b/tests/data/img/grey-gradient.jpg differ diff --git a/tests/data/img/grey-gradient.png b/tests/data/img/grey-gradient.png new file mode 100644 index 000000000..853198604 Binary files /dev/null and b/tests/data/img/grey-gradient.png differ diff --git a/tests/data/snapshots/pdfx4-flattened-form.pdf b/tests/data/snapshots/pdfx4-flattened-form.pdf new file mode 100644 index 000000000..afc0a00e0 Binary files /dev/null and b/tests/data/snapshots/pdfx4-flattened-form.pdf differ 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 000000000..bb5be788d Binary files /dev/null and b/tests/data/snapshots/pdfx4-rgb-and-grey.pdf differ diff --git a/utils/grayprofile_update.php b/utils/grayprofile_update.php new file mode 100644 index 000000000..725b440fb --- /dev/null +++ b/utils/grayprofile_update.php @@ -0,0 +1,18 @@ + + + + + + + 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) . '

' + . '

A link

'); +$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));