0.0, 'T' => 0.0, 'R' => 0.0, 'B' => 0.0]; /** @var array{L: float, T: float, R: float, B: float} Cell external margins in user units. */ protected array $cellmargin = ['L' => 0.0, 'T' => 0.0, 'R' => 0.0, 'B' => 0.0]; /** Current font family (normalized lowercase). */ protected string $fontfamily = 'helvetica'; /** Current font style letters (subset of 'B', 'I'). */ protected string $fontstyle = ''; /** @var array{U: bool, D: bool, O: bool} Current font decorations: underline, line-through, overline. */ protected array $fontdecor = ['U' => false, 'D' => false, 'O' => false]; /** Current font size in points. */ protected float $fontsizept = 12.0; /** @var array Current font metric array returned by the engine font stack. */ protected array $fontmetric = []; /** Default font subsetting mode for setFont (legacy default: true). */ protected bool $fontsubsetting = true; /** Extra font spacing (user units) applied via the engine font stack. */ protected float $fontspacing = 0.0; /** Font stretching percentage (100 = none). */ protected float $fontstretching = 100.0; /** Default monospaced font family. */ protected string $monospacedfont = 'courier'; /** Text color: engine color specification string. */ protected string $textcolorspec = 'black'; /** Draw (stroke) color: engine color specification string. */ protected string $drawcolorspec = 'black'; /** Fill color: engine color specification string. */ protected string $fillcolorspec = 'white'; /** @var array Legacy components of the current text color. */ protected array $textcolorlegacy = [0, 0, 0]; /** @var array Legacy components of the current draw color. */ protected array $drawcolorlegacy = [0, 0, 0]; /** @var array Legacy components of the current fill color. */ protected array $fillcolorlegacy = [255, 255, 255]; /** Current line width in user units. */ protected float $linewidth = 0.2; /** @var StyleDataOpt Current line style (engine style array fragment). */ protected array $linestyle = []; /** RTL direction flag. */ protected bool $rtlmode = false; /** Temporary RTL mode ('R', 'L' or false). */ protected mixed $tmprtl = false; /** Print header flag. */ protected bool $printheader = true; /** Print footer flag. */ protected bool $printfooter = true; /** @var array{logo: string, logo_width: float, title: string, string: string, text_color: array, line_color: array} Header data. */ protected array $headerdata = [ 'logo' => '', 'logo_width' => 30.0, 'title' => '', 'string' => '', 'text_color' => [0, 0, 0], 'line_color' => [0, 0, 0], ]; /** @var array{text_color: array, line_color: array} Footer text and line colors. */ protected array $footerdata = [ 'text_color' => [0, 0, 0], 'line_color' => [0, 0, 0], ]; /** Header margin (minimum distance between header and top page margin). */ protected float $headermargin = 10.0; /** Footer margin (minimum distance between footer and bottom page margin). */ protected float $footermargin = 10.0; /** @var array{0: string, 1: string, 2: float} Header font: family, style, size in points. */ protected array $headerfont = ['helvetica', '', 12.0]; /** @var array{0: string, 1: string, 2: float} Footer font: family, style, size in points. */ protected array $footerfont = ['helvetica', '', 12.0]; /** True while rendering the page header or footer. */ protected bool $inheaderfooter = false; /** * Language dependent strings (legacy $l array): built-in English * defaults, overridable via the K_TCPDF_DEFAULT_LANGUAGE constant or * setLanguageArray(). * * @var array */ protected array $langdata = [ 'a_meta_charset' => 'UTF-8', 'a_meta_dir' => 'ltr', 'a_meta_language' => 'en', 'w_page' => 'page', ]; /** Image scale ratio (used when width/height are not specified). */ protected float $imgscale = 1.0; /** JPEG quality used when re-encoding images. */ protected int $jpegquality = 90; /** Bottom-right X coordinate of the last inserted image. */ protected float $imagerbx = 0.0; /** Bottom-right Y coordinate of the last inserted image. */ protected float $imagerby = 0.0; /** @var array Registered soft-mask source files, keyed by the handle returned from Image($ismask=true). */ protected array $imagemasks = []; /** Sequence for image mask handles. */ protected int $imagemaskseq = 0; /** @var array{stroke: float, fill: bool, clip: bool} Text rendering mode: stroke width, fill, clip. */ protected array $textrendermode = ['stroke' => 0.0, 'fill' => true, 'clip' => false]; /** @var array{enabled: bool, depth_w: int|float, depth_h: int|float, color: mixed, opacity: int|float, blend_mode: string} Legacy text shadow parameters. */ protected array $textshadow = [ 'enabled' => false, 'depth_w' => 0, 'depth_h' => 0, 'color' => false, 'opacity' => 1, 'blend_mode' => 'Normal', ]; /** Starting page number (used by PageNoFormatted). */ protected int $startingpagenumber = 1; /** Total number of pages, available while rendering deferred decorations. */ protected int $decortotalpages = 0; /** Document barcode string (printed in the footer when set). */ protected string $docbarcode = ''; /** Booklet mode flag. */ protected bool $bookletmode = false; /** * True when the current page is a TOC page. * Kept protected under its legacy name: user subclasses read it. */ protected $tocpage = false; /** @var array{CA: float, ca: float, BM: string, AIS: bool} Current alpha/blend state. */ protected array $alpha = ['CA' => 1.0, 'ca' => 1.0, 'BM' => '/Normal', 'AIS' => false]; /** @var array{OP: bool, op: bool, OPM: int} Current overprint state. */ protected array $overprint = ['OP' => false, 'op' => false, 'OPM' => 0]; /** Number of currently open optional-content layers. */ protected int $openlayers = 0; /** Snapshot of the facade object used by the transaction API. */ protected ?TCPDF $transactionsnapshot = null; /** @var array Internal links created by AddLink(). */ protected array $internallinks = []; /** @var array Named destinations registered via setDestination(). */ protected array $nameddests = []; /** Identifier of the currently open XObject template ('' = none). */ protected string $xobjtid = ''; /** Height of the currently open XObject template in user units. */ protected float $xobjheight = 0.0; /** Page group number for the next added page (0 = default group). */ protected int $nextpagegroup = 0; /** True when page groups are in use. */ protected bool $pagegroupsused = false; /** Columns requested via setEqualColumns() for subsequently added pages. */ protected int $pagecolumns = 0; /** Column width requested via setEqualColumns() (0 = divide the content width evenly). */ protected float $pagecolumnwidth = 0.0; /** @var array Page regions for subsequently added pages. */ protected array $pageregions = []; /** * Legacy no-write page regions (setPageRegions()): rectangular/trapezoidal * exclusion zones that flowing text and HTML must avoid. Stored verbatim in * the legacy form and converted to engine banded writable regions at flow * time (see applyNoWriteRegionsForFlow()). * * @var array */ protected array $nowriteareas = []; /** @var array Default form field properties. */ protected array $formdefaultprop = []; /** @var array HTML link color (legacy components). */ protected array $htmllinkcolor = [0, 0, 255]; /** HTML link font style letters. */ protected string $htmllinkstyle = 'U'; /** True while rendering HTML that must ignore the current cell padding (legacy writeHTML without $cell). */ protected bool $htmlnopadding = false; /** @var array{create: int, modify: int} Document timestamps (facade state; the engine stamps output itself). */ protected array $doctimestamps = ['create' => 0, 'modify' => 0]; /** @var array Per-page decoration flags, frozen when each page starts. */ protected array $pagedecor = []; /** @var array{zoom: int|string, layout: string, mode: string} Viewer display mode storage. */ protected array $displaymode = ['zoom' => 'fullwidth', 'layout' => 'SinglePage', 'mode' => 'UseNone']; public function __construct( $_orientation = 'P', $_unit = 'mm', $_format = 'A4', $_unicode = true, $_encoding = 'UTF-8', $_diskcache = false, $_pdfa = false, ) { // $_diskcache is deprecated and intentionally ignored. // $_encoding: the engine always works in UTF-8. $this->unicode = (bool) $_unicode; $this->charencoding = (string) $_encoding; $this->pdfamode = $this->normalizePdfaMode($_pdfa); $this->deforientation = $this->normalizeOrientation($_orientation); $this->curorientation = $this->deforientation; $this->defformat = is_array($_format) ? $_format : (string) $_format; $this->curformat = $this->defformat; $this->engineInit((string) $_unit); // Legacy defaults: 1cm page margins, padding L/R = margin/10. $margin = 28.35 / $this->kratio; $this->setMargins($margin, $margin); $this->orig_lmargin = $this->lmargin; $this->orig_rmargin = $this->rmargin; $this->setCellPaddings($margin / 10, 0, $margin / 10, 0); $this->setCellMargins(0, 0, 0, 0); $this->linewidth = 0.57 / $this->kratio; $this->setAutoPageBreak(true, 2 * $margin); $this->setImageScale(PDF_IMAGE_SCALE_RATIO); $this->headermargin = (float) PDF_MARGIN_HEADER; $this->footermargin = (float) PDF_MARGIN_FOOTER; // Language-dependent strings: built-in English defaults, merged // with the optional configuration override. $langoverride = defined('K_TCPDF_DEFAULT_LANGUAGE') ? constant('K_TCPDF_DEFAULT_LANGUAGE') : null; $this->setLanguageArray( is_array($langoverride) ? array_merge($this->langdata, $langoverride) : $this->langdata, ); $fontname = defined('PDF_FONT_NAME_MAIN') ? PDF_FONT_NAME_MAIN : 'helvetica'; $this->setFont($fontname, '', 12.0); $this->setHeaderFont([$fontname, '', 12.0]); $this->setFooterFont([$fontname, '', 12.0]); $this->docstate = 1; } public function __destruct() { $this->eng = null; } /** * Legacy protected properties historically read by TCPDF subclasses * (e.g. $this->AutoPageBreak, $this->lMargin), mapped to facade state. */ public function __get($name) { return match ($name) { 'AutoPageBreak' => $this->autopagebreak, 'lMargin', 'original_lMargin' => $this->lmargin, 'rMargin', 'original_rMargin' => $this->rmargin, 'tMargin' => $this->tmargin, 'bMargin' => $this->bmargin, 'x' => $this->posx, 'y' => $this->posy, 'w' => $this->getPageWidth(), 'h' => $this->getPageHeight(), 'k' => $this->kratio, 'page' => $this->getPage(), 'lasth' => $this->lasth, 'FontFamily' => $this->fontfamily, 'FontStyle' => $this->fontstyle, 'FontSizePt' => $this->fontsizept, 'FontSize' => $this->getFontSize(), 'l' => $this->langdata, 'header_margin' => $this->headermargin, 'footer_margin' => $this->footermargin, 'print_header' => $this->printheader, 'print_footer' => $this->printfooter, 'rtl' => $this->rtlmode, 'img_rb_x' => $this->imagerbx, 'img_rb_y' => $this->imagerby, 'imgscale' => $this->imgscale, default => null, }; } /** @see __get() */ public function __isset($name) { return $this->__get($name) !== null; } // =================================================================== // Internal engine and state helpers (not part of the public API). // =================================================================== /** * Create (or re-create) the tc-lib-pdf engine for the given unit. */ protected function engineInit(string $unit): void { $unit = strtolower(trim($unit)) === '' ? 'mm' : strtolower(trim($unit)); $this->docunit = $unit; $this->eng = $this->engineNew(); $this->kratio = $this->eng->toPoints(1.0); } /** * Build a new engine instance wired to re-emit the facade ambient text * state at the start of every page content stream it opens. */ private function engineNew(?\Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt = null): TCPDF_ENGINE { $eng = new TCPDF_ENGINE( $this->docunit, $this->unicode, false, true, $this->pdfamode, $encrypt, $this->fileOptions(), ); $eng->pagecontexthook = $this->ambientPageContent(...); return $eng; } /** * Raw PDF operators for the ambient text state that must open every page * content stream: the engine re-emits the current font (carrying its * spacing/stretching) when it adds a page, so the facade only needs to * carry the legacy text color across page breaks. */ protected function ambientPageContent(): string { return $this->engine()->color->getPdfFillColor($this->textcolorspec); } /** * Local paths the engine may read files from. The legacy API loaded * images and fonts from application-relative locations, so in addition * to the engine defaults this allows the configured TCPDF paths, the * current working directory and the running script directory. * * Applications may extend this allowlist with the K_ALLOWED_PATHS * configuration constant (array of path prefixes): its entries are * merged on top of the built-in defaults, never replacing them. * * @return array */ protected function fileAllowedPaths(): array { $candidates = [ sys_get_temp_dir(), K_PATH_MAIN, dirname(__FILE__) . '/vendor/tecnickcom/', getcwd(), ]; if (defined('K_PATH_FONTS')) { $candidates[] = K_PATH_FONTS; } if (defined('K_PATH_IMAGES')) { $candidates[] = K_PATH_IMAGES; } // Additional trusted read locations from the configuration; merged on // top of the built-in defaults so bundled assets keep resolving. if (defined('K_ALLOWED_PATHS') && is_array(K_ALLOWED_PATHS)) { foreach (K_ALLOWED_PATHS as $extrapath) { $candidates[] = $extrapath; } } $candidates[] = dirname($_SERVER['SCRIPT_FILENAME']); $paths = []; foreach ($candidates as $candidate) { if (!is_string($candidate) || $candidate === '') { continue; } $real = realpath($candidate); if ($real !== false) { $paths[] = $real; } } return array_values(array_unique($paths)); } /** * Assemble the file-access options handed to the engine's shared file * helper (tc-lib-file). These drive the upstream security sandbox that * governs which local paths and remote hosts external resources (images, * fonts, SVG, ...) may be loaded from. * * The mapping is configuration-driven: * - allowedPaths <- built-in defaults + K_ALLOWED_PATHS (local reads) * - allowedHosts <- K_ALLOWED_HOSTS (remote HTTP/HTTPS reads; empty * disables remote loading entirely — the safe default) * - maxRemoteSize <- K_MAX_REMOTE_SIZE (byte cap on remote downloads) * - curlopts <- K_CURLOPTS (per-request cURL overrides) * * Only explicitly configured keys are forwarded; anything omitted keeps * the upstream library's secure defaults. * * @return array{ * allowedPaths: array, * allowedHosts?: array, * maxRemoteSize?: int, * curlopts?: array, * } */ protected function fileOptions(): array { $options = ['allowedPaths' => $this->fileAllowedPaths()]; // Remote URL loading is disabled by default in the upstream library: // an empty (or unset) host allowlist keeps it disabled. Populate // K_ALLOWED_HOSTS with trusted host names to opt in to remote reads. if (defined('K_ALLOWED_HOSTS') && is_array(K_ALLOWED_HOSTS) && K_ALLOWED_HOSTS !== []) { $options['allowedHosts'] = array_values(array_map( static fn(mixed $host): string => (string) $host, K_ALLOWED_HOSTS, )); } if (defined('K_MAX_REMOTE_SIZE') && (int) K_MAX_REMOTE_SIZE > 0) { $options['maxRemoteSize'] = (int) K_MAX_REMOTE_SIZE; } if (defined('K_CURLOPTS') && is_array(K_CURLOPTS) && K_CURLOPTS !== []) { $options['curlopts'] = K_CURLOPTS; } // The K_* security constants resolve to their concrete defaults during // static analysis, so the analyzer narrows array_map(K_ALLOWED_HOSTS) // to a less-specific nested type than the documented contract; the // declared return type is authoritative for the runtime-configurable // values. // @mago-expect analysis:less-specific-nested-return-statement return $options; } /** * Return the engine instance. */ protected function engine(): \Com\Tecnick\Pdf\Tcpdf { if (!$this->eng instanceof \Com\Tecnick\Pdf\Tcpdf) { throw new \RuntimeException('TCPDF engine is not initialized'); } return $this->eng; } /** * Append raw PDF content to the current page (no-op when no page exists). */ protected function emitToPage(string $content): void { if ($content === '' || $this->docstate < 2) { return; } if ($this->xobjtid !== '') { $this->engine()->addXObjectContent($this->xobjtid, $content); return; } $this->engine()->page->addContent($content); } /** * Normalize a legacy orientation value to 'P' or 'L'. */ protected function normalizeOrientation(mixed $orientation): string { $val = strtoupper(substr((string) $orientation, 0, 1)); return $val === 'L' ? 'L' : 'P'; } /** * Map the legacy $pdfa constructor flag onto an engine conformance mode. */ protected function normalizePdfaMode(mixed $pdfa): string { $level = (int) $pdfa; if ($level <= 0) { return ''; } return match ($level) { 2 => 'pdfa2b', 3 => 'pdfa3b', default => 'pdfa1b', }; } /** * Build the engine page data array for a new page. */ protected function buildPageData(mixed $orientation, mixed $format): array { $orientation = (string) $orientation === '' ? $this->curorientation : $this->normalizeOrientation($orientation); if (!is_array($format)) { $format = (string) $format === '' ? $this->curformat : (string) $format; } $this->curorientation = $orientation; $this->curformat = $format; $data = [ 'orientation' => $orientation, 'margin' => [ 'PL' => $this->lmargin, 'PR' => $this->rmargin, 'PT' => 0.0, 'HB' => 0.0, 'CT' => $this->tmargin, 'CB' => $this->bmargin, 'FT' => 0.0, 'PB' => 0.0, ], 'autobreak' => $this->autopagebreak, ]; if ($this->pagegroupsused) { $data['group'] = $this->nextpagegroup; } if ($this->pageregions !== []) { // Columns restart at the top margin on every page after the one // where they were defined (legacy selectColumn() behavior). $data['region'] = array_map(fn(array $region): array => [ 'RX' => $region['RX'], 'RY' => $this->tmargin, 'RW' => $region['RW'], 'RH' => $this->getPageHeight() - $this->tmargin - $this->bmargin, ], $this->pageregions); } elseif ($this->pagecolumns > 1) { $data['region'] = $this->equalColumnRegions($this->pagecolumns, $this->pagecolumnwidth, $this->tmargin); } if (is_array($format)) { $width = (float) ($format[0] ?? 0); $height = (float) ($format[1] ?? 0); if ($width > 0 && $height > 0) { $data['width'] = $width; $data['height'] = $height; } elseif (isset($format['MediaBox']) && is_array($format['MediaBox'])) { // Legacy extended format array: page boxes are given in user // units; the engine expects them in points. $boxes = []; foreach (['MediaBox', 'CropBox', 'BleedBox', 'TrimBox', 'ArtBox'] as $boxname) { if (!(isset($format[$boxname]) && is_array($format[$boxname]))) { continue; } $boxes = $this->engine()->page->setBox( $boxes, $boxname, (float) ($format[$boxname]['llx'] ?? 0) * $this->kratio, (float) ($format[$boxname]['lly'] ?? 0) * $this->kratio, (float) ($format[$boxname]['urx'] ?? 0) * $this->kratio, (float) ($format[$boxname]['ury'] ?? 0) * $this->kratio, ); } $data['box'] = $boxes; $mediabox = $boxes['MediaBox'] ?? ['llx' => 0.0, 'lly' => 0.0, 'urx' => 0.0, 'ury' => 0.0]; $data['width'] = abs($mediabox['urx'] - $mediabox['llx']) / $this->kratio; $data['height'] = abs($mediabox['ury'] - $mediabox['lly']) / $this->kratio; } elseif (isset($format['format'])) { $data['format'] = strtoupper((string) $format['format']); } else { $data['format'] = is_string($this->defformat) ? strtoupper($this->defformat) : 'A4'; } if (isset($format['Rotate']) && is_numeric($format['Rotate'])) { $data['rotation'] = (int) $format['Rotate']; } if (isset($format['PZ']) && is_numeric($format['PZ'])) { $data['zoom'] = (float) $format['PZ']; } $transition = []; if (isset($format['Dur']) && is_numeric($format['Dur'])) { $transition['Dur'] = (float) $format['Dur']; } if (isset($format['trans']) && is_array($format['trans'])) { foreach (['D', 'S', 'Dm', 'M', 'Di', 'SS', 'B'] as $key) { if (!isset($format['trans'][$key])) { continue; } $transition[$key] = $format['trans'][$key]; } } if ($transition !== []) { $data['transition'] = $transition; } } else { $data['format'] = strtoupper((string) $format); } return $data; } /** * Convert a legacy color definition (component list or array) to an * engine color specification string. * * Legacy conventions: 1 component = grayscale 0-255; 3 components = * RGB 0-255; 4 components = CMYK 0-100; 5th component = spot color name. */ protected function colorSpecFromLegacy(mixed $color): string { if (is_string($color) && $color !== '') { return $color; } if (!is_array($color) || $color === []) { return 'black'; } $values = array_values($color); $num = count($values); if ($num >= 4 && is_numeric($values[0]) && is_numeric($values[3])) { return ( 'cmyk(' . (float) $values[0] . '%,' . (float) $values[1] . '%,' . (float) $values[2] . '%,' . (float) $values[3] . '%)' ); } if ($num >= 3) { return 'rgb(' . (int) $values[0] . ',' . (int) ($values[1] ?? 0) . ',' . (int) ($values[2] ?? 0) . ')'; } $gray = (int) $values[0]; return 'rgb(' . $gray . ',' . $gray . ',' . $gray . ')'; } /** * Convert legacy color components (setColor-style) to a spec string. */ protected function colorSpecFromComponents(mixed $col1, mixed $col2, mixed $col3, mixed $col4, string $name): string { if ($name !== '') { return $name; } if ((float) $col4 >= 0) { return $this->colorSpecFromLegacy([(float) $col1, (float) $col2, (float) $col3, (float) $col4]); } if ((float) $col2 >= 0 && (float) $col3 >= 0) { return $this->colorSpecFromLegacy([(int) $col1, (int) $col2, (int) $col3]); } return $this->colorSpecFromLegacy([(int) $col1]); } /** * Sanitize a legacy color component array (numbers, optional spot name). * * @param array $default Fallback components. * * @return array */ protected function legacyColorComponents(mixed $color, array $default): array { if (!is_array($color)) { return $default; } /** @var array $out */ $out = []; foreach (array_values($color) as $val) { $out[] = is_string($val) ? $val : (float) $val; } return $out === [] ? $default : $out; } /** * Current line style as an engine style array (lineWidth, lineColor, ...). * * @return StyleDataOpt */ protected function currentLineStyle(): array { $style = $this->linestyle; $style['lineWidth'] = $this->linewidth; // The draw color is the single source of truth for the stroke color: // setLineStyle() keeps it in sync, and setDrawColor() updates it on its // own, so an explicit setDrawColor() after a setLineStyle() still wins. $style['lineColor'] = $this->drawcolorspec; return $style; } /** * Build the engine per-side styles array from a legacy border argument. * * @param mixed $border 0/false = none, 1/true = full frame, * string with letters L,T,R,B = specific sides, * array of side => style = per-side styles. * * @return array{T?: StyleDataOpt, R?: StyleDataOpt, B?: StyleDataOpt, L?: StyleDataOpt, all?: StyleDataOpt} */ protected function stylesFromLegacyBorder(mixed $border, bool $fill): array { $styles = []; $line = $this->currentLineStyle(); if (is_array($border)) { foreach ($border as $key => $sty) { $side = is_string($key) ? strtoupper($key) : 'all'; $sidestyle = $line; if (is_array($sty)) { $sidestyle = $this->styleFromLegacyLineStyle($sty); } if ($side === 'all' || $side === 'LTRB' || $side === 'TRBL') { $styles['all'] = $sidestyle; continue; } foreach (str_split($side) as $letter) { if (!in_array($letter, ['L', 'T', 'R', 'B'], true)) { continue; } $styles[$letter] = $sidestyle; } } } elseif ((is_int($border) || is_bool($border)) && (int) $border === 1) { $styles['all'] = $line; } elseif (is_string($border) && $border !== '' && $border !== '0') { foreach (str_split(strtoupper($border)) as $letter) { if (!in_array($letter, ['L', 'T', 'R', 'B'], true)) { continue; } $styles[$letter] = $line; } } if ($fill) { if (!isset($styles['all'])) { $styles['all'] = ['lineWidth' => 0.0]; } $styles['all']['fillColor'] = $this->fillcolorspec; } if ($styles === []) { // Explicit zero line width: otherwise the engine derives a // minimum cell padding from the ambient line style, shifting // the text relative to the legacy layout. $styles['all'] = ['lineWidth' => 0.0]; } return $styles; } /** * Mirror legacy adjustCellPadding(): a cell border reserves a minimum cell * padding so the stroke does not overlap the text, and the optional * position mode decides how much of the stroke falls inside the cell box: * - ext : the whole stroke is painted outside -> 0 padding, the * border rectangle grows outward; * - int : the whole stroke is painted inside -> a full line-width * padding, the border rectangle shrinks inward; * - normal : the stroke straddles the edge -> half line-width. * The derived padding only ever increases the current cell padding (it * never shrinks it) and grows the auto cell height exactly like legacy. * * @return array{pos: float, padding: array{T: float, R: float, B: float, L: float}} */ protected function legacyBorderCellMetrics(mixed $border): array { $padding = [ 'T' => $this->cellpadding['T'], 'R' => $this->cellpadding['R'], 'B' => $this->cellpadding['B'], 'L' => $this->cellpadding['L'], ]; // Normalize the legacy border argument into a per-side map and pull the // optional position mode, like legacy adjustCellPadding(). $mode = 'normal'; $sides = []; if (is_array($border)) { $map = $border; if (isset($map['mode'])) { $mode = strtolower((string) $map['mode']); unset($map['mode']); } foreach ($map as $key => $style) { $side = is_string($key) ? strtoupper($key) : 'LTRB'; if (in_array($side, ['ALL', 'LTRB', 'TRBL'], true)) { $side = 'LTRB'; } $sides[$side] = $style; } } elseif ((is_int($border) || is_bool($border)) && (int) $border === 1) { $sides['LTRB'] = true; } elseif (is_string($border) && $border !== '' && $border !== '0') { $sides[strtoupper($border)] = true; } $pos = match ($mode) { 'ext' => \Com\Tecnick\Pdf\Tcpdf::BORDERPOS_EXTERNAL, 'int' => \Com\Tecnick\Pdf\Tcpdf::BORDERPOS_INTERNAL, default => \Com\Tecnick\Pdf\Tcpdf::BORDERPOS_DEFAULT, }; if ($sides === []) { return ['pos' => $pos, 'padding' => $padding]; } foreach ($sides as $side => $style) { $linewidth = $this->linewidth; if (is_array($style) && isset($style['width']) && is_numeric($style['width'])) { $linewidth = (float) $style['width']; } $adj = match ($mode) { 'ext' => 0.0, 'int' => $linewidth, default => $linewidth / 2.0, }; // Only sides present in this border entry reserve the padding // (literal keys keep the inferred array shape intact for the // static analyzer). if (str_contains($side, 'T')) { $padding['T'] = max($padding['T'], $adj); } if (str_contains($side, 'R')) { $padding['R'] = max($padding['R'], $adj); } if (str_contains($side, 'B')) { $padding['B'] = max($padding['B'], $adj); } if (str_contains($side, 'L')) { $padding['L'] = max($padding['L'], $adj); } } return ['pos' => $pos, 'padding' => $padding]; } /** * Convert a legacy line style array (width, cap, join, dash, phase, color) * to an engine style array. * * @param array $style Legacy style array. * * @return StyleDataOpt */ protected function styleFromLegacyLineStyle(array $style): array { $out = $this->currentLineStyle(); if (isset($style['width']) && is_numeric($style['width'])) { $out['lineWidth'] = (float) $style['width']; } if (isset($style['cap']) && is_string($style['cap'])) { $out['lineCap'] = $style['cap']; } if (isset($style['join']) && is_string($style['join'])) { $out['lineJoin'] = $style['join']; } if (isset($style['dash'])) { $dash = $style['dash']; $dasharray = []; if (is_string($dash) && $dash !== '' && $dash !== '0') { foreach (explode(',', $dash) as $val) { $dasharray[] = (int) round((float) $val); } } elseif (is_numeric($dash) && (float) $dash > 0) { $dasharray = [(int) round((float) $dash)]; } $out['dashArray'] = $dasharray; $out['dashPhase'] = (float) ($style['phase'] ?? 0); } if (isset($style['color']) && is_array($style['color'])) { $out['lineColor'] = $this->colorSpecFromLegacy($style['color']); } return $out; } /** * Map a legacy shape style letter combination to an engine paint mode. */ protected function shapeModeFromLegacy(mixed $style, bool $hasfill, bool $close = false): string { $val = strtoupper((string) $style); if ($close) { // Closing variant for polygon-like shapes: the engine appends the // closing segment only for the lowercase "close" paint operators, // mirroring the legacy Polygon() which always closes the path. return match ($val) { 'F' => 'f', 'DF', 'FD' => 'b', 'C' => 's', 'CNZ' => 'CNZ', 'CEO' => 'CEO', '' => $hasfill ? 'b' : 's', default => 's', }; } return match ($val) { 'F' => 'f', 'DF', 'FD' => 'B', 'C' => 's', 'CNZ' => 'CNZ', 'CEO' => 'CEO', '' => $hasfill ? 'B' : 'S', default => 'S', }; } /** * Build the engine style for a legacy (line_style, fill_color) pair. * * The legacy drawing methods PERSIST these parameters: a style array * updates the current line style and a fill color array updates the * current fill color (subsequent shapes inherit them). * * @return StyleDataOpt */ protected function shapeStyleFromLegacy(mixed $linestyle, mixed $fillcolor): array { if (is_array($linestyle) && $linestyle !== []) { $this->setLineStyle($linestyle, true); } if (is_array($fillcolor) && $fillcolor !== []) { $this->fillcolorspec = $this->colorSpecFromLegacy($fillcolor); } $style = $this->currentLineStyle(); $style['fillColor'] = $this->fillcolorspec; return $style; } /** * Convert a legacy per-segment/per-side style list to engine styles. * Integer 0 entries mean "do not draw this segment". * * @return array */ protected function segmentStylesFromLegacy(mixed $linestyle, mixed $fillcolor): array { $base = $this->shapeStyleFromLegacy([], $fillcolor); $styles = ['all' => $base]; if (!is_array($linestyle)) { return $styles; } if (isset($linestyle['all']) && is_array($linestyle['all'])) { // Persist the global style like the legacy setLineStyle($style['all']) // so subsequent shapes inherit it as their current line style. $this->setLineStyle($linestyle['all'], true); $allstyle = $this->currentLineStyle(); $allstyle['fillColor'] = $base['fillColor'] ?? $this->fillcolorspec; $styles['all'] = $allstyle; return $styles; } $islist = $linestyle !== [] && array_keys($linestyle) === range(0, count($linestyle) - 1); if ($islist) { foreach ($linestyle as $idx => $segstyle) { if (is_array($segstyle)) { $styles[(int) $idx] = $this->styleFromLegacyLineStyle($segstyle); } else { // 0 = segment not drawn $styles[(int) $idx] = ['lineWidth' => 0.0]; } } return $styles; } if ($linestyle !== []) { $single = $this->styleFromLegacyLineStyle($linestyle); $single['fillColor'] = $base['fillColor'] ?? $this->fillcolorspec; $styles['all'] = $single; } return $styles; } /** * Resolve a legacy optional coordinate ('' or null means current position). */ protected function coordOrCursor(mixed $value, bool $vertical): float { if ($value === null || (string) $value === '') { return $vertical ? $this->posy : $this->posx; } return (float) $value; } /** * Complete partial per-side styles with the current graph style defaults * (the engine HTML renderer requires fully-populated style arrays). * * @param array{T?: StyleDataOpt, R?: StyleDataOpt, B?: StyleDataOpt, L?: StyleDataOpt, all?: StyleDataOpt} $styles * * @return array, dashPhase: float, fillColor: string, lineCap: string, lineColor: string, lineJoin: string, lineWidth: float, miterLimit: float}> */ protected function completeSideStyles(array $styles): array { $base = $this->engine()->graph->getCurrentStyleArray(); $out = []; foreach ($styles as $side => $style) { $out[$side] = [ 'dashArray' => array_map( static fn(mixed $val): int => (int) $val, $style['dashArray'] ?? $base['dashArray'] ?? [], ), 'dashPhase' => $style['dashPhase'] ?? $base['dashPhase'] ?? 0.0, 'fillColor' => $style['fillColor'] ?? '', 'lineCap' => $style['lineCap'] ?? $base['lineCap'] ?? 'butt', 'lineColor' => $style['lineColor'] ?? $base['lineColor'] ?? 'black', 'lineJoin' => $style['lineJoin'] ?? $base['lineJoin'] ?? 'miter', 'lineWidth' => $style['lineWidth'] ?? 0.0, 'miterLimit' => $style['miterLimit'] ?? $base['miterLimit'] ?? 10.0, ]; } return $out; } /** * Trigger an automatic page break when the given height does not fit. */ protected function breakIfNeeded(float $height): void { if ( $this->inheaderfooter || $this->xobjtid !== '' || !$this->autopagebreak || $this->docstate !== 2 || !$this->AcceptPageBreak() ) { return; } $limit = $this->getPageHeight() - $this->bmargin; if (($this->posy + $height - $limit) > 0.0001) { $posx = $this->posx; $this->AddPage($this->curorientation); $this->posx = $posx; } } /** * Move to the top of the next page for an automatic break, reusing an * already-created page when one exists. * * Legacy startPage() reuses the next page (`if numpages > page: page++`) * instead of always appending, so a cell the caller placed back on an * earlier page (e.g. the MultiRow pattern of example_020, which writes * the two columns at the same Y on the start page) still breaks onto the * single shared continuation page rather than spawning a fresh one. The * current abscissa is preserved across the break, like breakIfNeeded(). */ protected function advanceToNextPage(): void { $posx = $this->posx; if ($this->getPage() < $this->getNumPages()) { $this->setPage($this->getPage() + 1); } else { $this->AddPage($this->curorientation); } $this->posy = $this->tmargin; $this->posx = $posx; } /** * Map a legacy text vertical alignment letter to the engine convention. */ protected function valignToEngine(mixed $valign): string { $val = strtoupper((string) $valign); return match ($val) { 'T', 'B', 'A', 'L', 'D' => $val, default => 'C', }; } /** * Legacy $calign cell vertical alignment: the amount (user units) by which * the cell box top is shifted UP relative to the current text line. The * font-relative modes (A/L/D) depend on $valign, mirroring getCellCode(). */ protected function cellCalignShift(string $calign, string $valign, float $height): float { $metric = $this->currentFontMetric(); $ascent = (float) ($metric['ascent'] ?? 0.0) / $this->kratio; $descent = abs((float) ($metric['descent'] ?? 0.0)) / $this->kratio; $padt = $this->cellpadding['T']; $padb = $this->cellpadding['B']; $valign = strtoupper($valign); switch (strtoupper($calign)) { case 'A': // font top (ascent line at the text line) if ($valign === 'T') { return $padt; } if ($valign === 'B') { return $height - $padb - $ascent - $descent; } return ($height - $ascent - $descent) / 2; case 'L': // font baseline if ($valign === 'T') { return $padt + $ascent; } if ($valign === 'B') { return $height - $padb - $descent; } return ($height + $ascent - $descent) / 2; case 'D': // font bottom (descent line) if ($valign === 'T') { return $padt + $ascent + $descent; } if ($valign === 'B') { return $height - $padb; } return ($height + $ascent + $descent) / 2; case 'B': // cell bottom return $height; case 'C': case 'M': // cell center return $height / 2; default: // 'T' cell top return 0.0; } } /** * Map a legacy horizontal alignment letter to the engine convention. */ protected function halignToEngine(mixed $align): string { $val = strtoupper((string) $align); if (in_array($val, ['L', 'C', 'R', 'J'], true)) { return $val; } return $this->isRTLTextDir() ? 'R' : 'L'; } /** * Bookmark outline entries collected by the engine. * * @return array */ protected function engineOutlines(): array { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Base::class, 'outlines'); $outlines = $prop->getValue($this->engine()); if (!is_array($outlines)) { return []; } /** @var array $outlines */ return $outlines; } /** * Replace the engine bookmark outline entries (used to remap page * targets after the TOC pages are relocated). * * @param array $outlines */ protected function setEngineOutlines(array $outlines): void { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Base::class, 'outlines'); $prop->setValue($this->engine(), $outlines); } /** * Emulate the legacy text shadow for engine-rendered HTML content. * * The engine HTML renderer has no shadow support, so the text objects * (BT..ET blocks) of the freshly rendered chunks are duplicated into an * offset, shadow-colored, alpha-blended layer painted underneath. */ protected function applyHtmlTextShadow(int $pid, int $start): void { $shadow = $this->engineShadow(); if ($shadow === null) { return; } $eng = $this->engine(); $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Page::class, 'page'); $pages = $prop->getValue($eng->page); if (!is_array($pages) || !isset($pages[$pid]['content']) || !is_array($pages[$pid]['content'])) { return; } $content = $pages[$pid]['content']; $chunks = []; foreach (array_slice($content, $start) as $chunk) { $chunks[] = is_string($chunk) ? $chunk : ''; } $rendered = implode('', $chunks); $blocks = []; if (preg_match_all('/BT .*? ET/s', $rendered, $blocks) === 0) { return; } $text = implode("\n", $blocks[0] ?? []); // Neutralize color and rendering-mode operators inside the copy so // the whole layer paints in the shadow color. $text = (string) preg_replace( ['#/CS[0-9]+ (cs|CS)#', '#[0-9.]+( [0-9.]+)* (scn|SCN|rg|RG|g|G|k|K)(?![a-zA-Z])#', '#[0-9]+ Tr#'], ['', '', '0 Tr'], $text, ); $layer = $eng->graph->getStartTransform(); $layer .= sprintf( '1 0 0 1 %F %F cm' . "\n", $shadow['xoffset'] * $this->kratio, -$shadow['yoffset'] * $this->kratio, ); $layer .= $eng->graph->getAlpha($shadow['opacity'], $shadow['mode'], $shadow['opacity']); $layer .= $eng->color->getPdfFillColor($shadow['color']); $layer .= $text . "\n"; $layer .= $eng->graph->getStopTransform(); // Paint the shadow layer beneath the rendered chunks. array_splice($content, $start, 0, [$layer]); $pages[$pid]['content'] = $content; $prop->setValue($eng->page, $pages); } /** * Lowest bottom edge (user units from the page top) of the images * placed in the content chunks appended after the given index. * Image placements carry their position in the CTM: * "w 0 0 h x y cm /IMGn Do" with y = bottom edge in PDF coordinates. */ protected function pageContentImageBottom(int $pid, int $start, float $fallback): float { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Page::class, 'page'); $pages = $prop->getValue($this->engine()->page); if (!is_array($pages) || !isset($pages[$pid]['content']) || !is_array($pages[$pid]['content'])) { return $fallback; } $chunks = []; foreach (array_slice($pages[$pid]['content'], $start) as $chunk) { $chunks[] = is_string($chunk) ? $chunk : ''; } $matches = []; if ( preg_match_all( '#[0-9.]+ 0 0 [0-9.]+ [0-9.]+ ([0-9.]+) cm /IMG[a-z]*[0-9]+ Do#', implode('', $chunks), $matches, ) === 0 ) { return $fallback; } $pageheight = (float) ($pages[$pid]['height'] ?? $this->getPageHeight()); $bottom = $fallback; foreach ($matches[1] ?? [] as $pdfy) { $bottom = max($bottom, $pageheight - ((float) $pdfy / $this->kratio)); } return $bottom; } /** * Number of raw content chunks of the given engine page. */ protected function pageContentCount(int $pid): int { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Page::class, 'page'); $pages = $prop->getValue($this->engine()->page); return is_array($pages) && isset($pages[$pid]['content']) && is_array($pages[$pid]['content']) ? count($pages[$pid]['content']) : 0; } /** * Move the content chunks appended after the given index to the front * of the page stream, so they paint *behind* the page body. Used for * deferred header rendering: the legacy engine painted headers at * page-start time, i.e. under the body content. */ protected function movePageContentToFront(int $pid, int $start): void { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Page::class, 'page'); $pages = $prop->getValue($this->engine()->page); if (!is_array($pages) || !isset($pages[$pid]['content']) || !is_array($pages[$pid]['content'])) { return; } $content = $pages[$pid]['content']; if ($start >= count($content)) { return; } $moved = array_splice($content, $start); $pages[$pid]['content'] = array_merge($moved, $content); $prop->setValue($this->engine()->page, $pages); } /** * Number of entries currently on the engine cell bounding-box stack. */ protected function cellBBoxCount(): int { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Base::class, 'cellbbox'); $stack = $prop->getValue($this->engine()); return is_array($stack) ? count($stack) : 0; } /** * Lowest bottom edge (user units) among the cell bounding boxes pushed * on the engine stack after the given index. */ protected function cellBBoxBottomSince(int $start, float $fallback): float { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Base::class, 'cellbbox'); $stack = $prop->getValue($this->engine()); if (!is_array($stack)) { return $fallback; } $bottom = $fallback; foreach (array_slice($stack, $start) as $bbox) { if (!(is_array($bbox) && isset($bbox['y'], $bbox['h']))) { continue; } $bottom = max($bottom, (float) $bbox['y'] + (float) $bbox['h']); } return $bottom; } /** * PDF code for a cell background fill and the requested border sides. * * @param array{T?: StyleDataOpt, R?: StyleDataOpt, B?: StyleDataOpt, L?: StyleDataOpt, all?: StyleDataOpt} $styles */ protected function cellBoxOutput( float $posx, float $posy, float $width, float $height, array $styles, bool $fill, ): string { $eng = $this->engine(); $out = ''; if ($fill) { $out .= $eng->graph->getBasicRect($posx, $posy, $width, $height, 'f', [ 'lineWidth' => 0.0, 'fillColor' => $this->fillcolorspec, ]); } $sides = [ 'T' => [$posx, $posy, $posx + $width, $posy], 'R' => [$posx + $width, $posy, $posx + $width, $posy + $height], 'B' => [$posx + $width, $posy + $height, $posx, $posy + $height], 'L' => [$posx, $posy + $height, $posx, $posy], ]; foreach ($sides as $letter => $line) { $sty = $styles[$letter] ?? $styles['all'] ?? null; if (is_array($sty) && isset($sty['lineWidth']) && $sty['lineWidth'] > 0) { $out .= $eng->graph->getLine($line[0], $line[1], $line[2], $line[3], $sty); } } return $out; } /** * Draw the per-page border/fill segments of an auto-height HTML cell * that flowed across pages (the engine skips the cell box in that case). * Legacy MultiCell splits the frame at each break in open-cell mode: * the segment before a page break has no bottom edge and runs to the * break trigger, the segment after it has no top edge and restarts from * the top margin. Each segment is inserted behind its page content. * * @param array{T?: StyleDataOpt, R?: StyleDataOpt, B?: StyleDataOpt, L?: StyleDataOpt, all?: StyleDataOpt} $styles * @param ?int $endpage Last page the cell content actually reached. * Defaults to the current page; callers pass an * explicit value when the engine advanced the page * pointer past the cell's real end (see MultiCell). */ protected function drawHtmlCellSegments( float $posx, float $width, float $starty, float $bottom, int $startpage, int $contentstart, array $styles, bool $fill, ?int $endpage = null, ): void { $eng = $this->engine(); $endpage ??= $this->getPage(); $breaky = $this->getPageHeight() - $this->bmargin; for ($page = $startpage; $page <= $endpage; $page++) { $top = $page === $startpage ? $starty : $this->tmargin; $segbottom = max($top, $page === $endpage ? $bottom : $breaky); $segstyles = $styles; if ($page > $startpage) { $segstyles['T'] = ['lineWidth' => 0.0]; } if ($page < $endpage) { $segstyles['B'] = ['lineWidth' => 0.0]; } $box = $this->cellBoxOutput($posx, $top, $width, $segbottom - $top, $segstyles, $fill); if ($box === '') { continue; } $this->insertPageContent( $page - 1, $page === $startpage ? $contentstart : 0, $eng->graph->getStartTransform() . $box . $eng->graph->getStopTransform(), ); } } /** * Insert a PDF content chunk at the given position of an engine page * content stream, so it paints behind the chunks that follow it. */ protected function insertPageContent(int $pid, int $start, string $content): void { $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Page::class, 'page'); $pages = $prop->getValue($this->engine()->page); if (!is_array($pages) || !isset($pages[$pid]['content']) || !is_array($pages[$pid]['content'])) { return; } $chunks = $pages[$pid]['content']; array_splice($chunks, $start, 0, [$content]); $pages[$pid]['content'] = $chunks; $prop->setValue($this->engine()->page, $pages); } /** * PDF code for a stretched cell text run (legacy Cell stretch modes). * * Modes 1/2 scale the glyphs horizontally (via a CTM transform around * the text anchor); modes 3/4 adjust the character spacing (via a Tc * operator). The engine renders the text at its NATURAL width (the font * stack is neutralised for this run), so its line splitting never * triggers, and the ambient spacing/stretching plus the fit transform are * emitted here as self-contained text-state operators. * * @param array{xoffset: float, yoffset: float, opacity: float, mode: string, color: string}|null $shadow */ protected function stretchedCellText( int $mode, string $txt, float $width, float $height, float $textw, string $valign, ?array $shadow, ): string { $eng = $this->engine(); $padl = $this->cellpadding['L']; $padr = $this->cellpadding['R']; $avail = $width - $padl - $padr; // Render the text at its natural width: neutralise the ambient // spacing/stretching on the font stack so the engine emits no Tz/Tc // of its own and never splits the line. The box is sized from the // larger of the natural and the rendered widths. $eng->font->cloneFont($eng->pon, null, null, $this->fontsizept, 0.0, 1.0); $rawwidth = $eng->font->getOrdArrDims(array_values($eng->uniconv->strToOrdArr($txt)))['totwidth'] / $this->kratio; $naturalbox = max($rawwidth, $textw) + $padl + $padr; $boxx = $this->rtlmode ? $this->posx + $width - $naturalbox : $this->posx; $text = $eng->getTextCell( $txt, $boxx, $this->posy, $naturalbox, $height, 0, 0, $valign, $this->rtlmode ? 'R' : 'L', null, ['all' => ['lineWidth' => 0.0]], $this->textrendermode['stroke'], 0, 0, 0, true, $this->textrendermode['fill'], $this->textrendermode['stroke'] > 0, $this->fontdecor['U'], $this->fontdecor['D'], $this->fontdecor['O'], $this->textrendermode['clip'], false, $this->forcedTextDir(), $shadow, ); $eng->font->popLastFont(); // Ambient text state (base spacing Tc + base stretch Tz) and the // reset back to the neutral text state after the run. $basestate = sprintf('%F Tc %F Tz ', $this->fontspacing * $this->kratio, $this->fontstretching); $reset = '0.000000 Tc 100.000000 Tz '; if ($mode <= 2) { // Glyph scaling anchored at the text start edge: the ambient // spacing/stretching apply inside the saved graphics state and // the CTM brings the rendered width up to the available width. $anchor = $this->rtlmode ? $this->posx + $width - $padr : $this->posx + $padl; return ( $basestate . $eng->graph->getStartTransform() . $eng->graph->getHorizScaling($avail / $textw, $anchor, $this->posy) . $text . $eng->graph->getStopTransform() . $reset ); } // Character spacing: solve (raw + S * chars) * stretching = avail // for the per-glyph spacing S (Tc applies to every glyph and is // scaled by the ambient horizontal scaling). $chars = (int) $this->GetNumChars($txt); if ($chars < 2) { return $basestate . $text . $reset; } $spacing = ((($avail * 100) / $this->fontstretching) - $rawwidth) / $chars; $spacingstate = sprintf('%F Tc %F Tz ', $spacing * $this->kratio, $this->fontstretching); return $spacingstate . $text . $reset; } /** * Re-apply the current font to the engine font stack so it carries the * up-to-date spacing/stretching. * * The engine reads spacing (points) and stretching (ratio) from the * current font and emits the matching Tc/Tz operators (with their resets) * per text run, so changing them only needs to refresh the stacked font; * the font selection (Tf) is unchanged and need not be re-emitted. */ protected function refreshFontState(): void { $eng = $this->engine(); if (!$eng->font->hasCurrentFont()) { // No font selected yet: the next setFont() applies the value. return; } $this->fontmetric = $eng->font->cloneFont( $eng->pon, null, null, $this->fontsizept, $this->fontspacing * $this->kratio, $this->fontstretching / 100.0, ); if ($this->xobjtid !== '') { $eng->addXObjectFontID($this->xobjtid, $this->fontmetric['key']); } } /** * Raw PDF code that renders the current font's natural (unstretched, * unspaced) measurement of the given text width in user units. * * Used by the legacy Cell stretch modes, whose fit math is expressed * against the natural glyph widths while the engine font stack already * carries the ambient spacing/stretching. */ protected function naturalOrdWidth(string $txt): float { $eng = $this->engine(); $eng->font->cloneFont($eng->pon, null, null, $this->fontsizept, 0.0, 1.0); $width = $eng->font->getOrdArrDims(array_values($eng->uniconv->strToOrdArr($txt)))['totwidth'] / $this->kratio; $eng->font->popLastFont(); return $width; } /** * Current engine text shadow array (or null when disabled). * * @return array{xoffset: float, yoffset: float, opacity: float, mode: string, color: string}|null */ protected function engineShadow(): ?array { if (!$this->textshadow['enabled']) { return null; } return [ 'xoffset' => (float) $this->textshadow['depth_w'], 'yoffset' => (float) $this->textshadow['depth_h'], 'opacity' => (float) $this->textshadow['opacity'], 'mode' => $this->textshadow['blend_mode'], 'color' => $this->colorSpecFromLegacy($this->textshadow['color']), ]; } /** * Forced text direction for the engine ('' = inherit document direction). */ protected function forcedTextDir(): string { if ($this->tmprtl === 'R' || $this->tmprtl === 'L') { return $this->tmprtl; } return ''; } /** * Canonicalize a local file path before handing it to the engine. * * The engine file helper rejects paths containing '..' components for * security; the legacy API accepted them, so resolve local paths first. */ protected function resolveLocalFile(string $file): string { if ($file === '' || $file[0] === '@' || $file[0] === '*' || str_contains($file, '://')) { return $file; } $real = realpath($file); return $real === false ? $file : $real; } /** * Register an external link annotation for the given rectangle. */ protected function attachLink(mixed $link, float $posx, float $posy, float $width, float $height): void { if ($this->docstate !== 2) { return; } if (is_int($link) || is_string($link) && preg_match('/^@L([0-9]+)$/', $link) === 1) { // Internal link identifier created by AddLink(). $lid = is_int($link) ? $link : (int) substr($link, 2); $dest = $this->internallinks[$lid] ?? null; if ($dest === null) { return; } $eng = $this->engine(); $target = $eng->addInternalLink((int) $dest['page'], $dest['y']); $oid = $eng->setLink($posx, $posy, $width, $height, $target); $this->attachAnnotRef($oid); return; } if (!is_string($link) || $link === '') { return; } $oid = $this->engine()->setLink($posx, $posy, $width, $height, $link); $this->attachAnnotRef($oid); } /** * Render the deferred page decorations (headers and footers) on every * page. Called once when the document is closed: at that time the total * page count is known, so the default footer can print real numbers. */ protected function decorateAllPages(): void { $any = $this->printheader || $this->printfooter; foreach ($this->pagedecor as $decor) { $any = $any || $decor['header'] || $decor['footer']; } if (!$any) { return; } $eng = $this->engine(); $pages = $eng->page->getPages(); $total = count($pages); $this->decortotalpages = $total; $this->inheaderfooter = true; $saved = [ 'x' => $this->posx, 'y' => $this->posy, 'lasth' => $this->lasth, 'family' => $this->fontfamily, 'style' => $this->fontstyle, 'size' => $this->fontsizept, 'padding' => $this->cellpadding, 'lmargin' => $this->lmargin, 'rmargin' => $this->rmargin, ]; $savedspacing = $this->fontspacing; $savedstretching = $this->fontstretching; $savedshadow = $this->textshadow; $savedcellheightratio = $this->cellheightratio; foreach (array_keys($pages) as $pid) { $decor = $this->pagedecor[(int) $pid] ?? ['header' => true, 'footer' => true]; if (!$decor['header'] && !$decor['footer']) { continue; } $eng->setCurrentPage((int) $pid); $this->lmargin = $this->orig_lmargin; $this->rmargin = $this->orig_rmargin; $this->setCellPadding(0); // Header/footer rendering starts from the default text state // (no spacing, no stretching, no text shadow, default line // height). Legacy resets the full graphic state to the document // defaults before drawing the header, so a user setCellHeightRatio() // for the body must not stretch the header/footer line pitch. $this->fontspacing = 0.0; $this->fontstretching = 100.0; $this->textshadow['enabled'] = false; $this->cellheightratio = K_CELL_HEIGHT_RATIO; if ($decor['header']) { // Headers paint *behind* the body (legacy drew them at // page-start): render normally, then move the chunks to // the front of the page stream. $start = $this->pageContentCount((int) $pid); $this->emitToPage($eng->graph->getStartTransform()); $this->setXY($this->orig_lmargin, $this->headermargin); $this->setFont($this->headerfont[0], $this->headerfont[1], $this->headerfont[2]); $this->Header(); $this->emitToPage($eng->graph->getStopTransform()); $this->movePageContentToFront((int) $pid, $start); } if ($decor['footer']) { $this->emitToPage($eng->graph->getStartTransform()); $footery = $this->getPageHeight((int) $pid + 1) - $this->footermargin; $this->setXY($this->orig_lmargin, $footery); $this->setFont($this->footerfont[0], $this->footerfont[1], $this->footerfont[2]); $this->Footer(); $this->emitToPage($eng->graph->getStopTransform()); } } $this->posx = $saved['x']; $this->posy = $saved['y']; $this->lasth = $saved['lasth']; $this->lmargin = $saved['lmargin']; $this->rmargin = $saved['rmargin']; $this->cellpadding = $saved['padding']; $this->fontspacing = $savedspacing; $this->fontstretching = $savedstretching; $this->textshadow = $savedshadow; $this->cellheightratio = $savedcellheightratio; $this->setFont($saved['family'], $saved['style'], $saved['size']); $this->inheaderfooter = false; $this->decortotalpages = 0; } // =================================================================== // Document settings. // =================================================================== public function setPageUnit($_unit) { if ($this->docstate >= 2) { $this->Error('setPageUnit() must be called before adding pages'); return; } $family = $this->fontfamily; $style = $this->fontstyle; $size = $this->fontsizept; $this->engineInit((string) $_unit); $this->setFont($family, $style, $size); } public function setPageOrientation($_orientation, $_autopagebreak = null, $_bottommargin = null) { $this->curorientation = $this->normalizeOrientation($_orientation); if ($_autopagebreak !== null) { $this->autopagebreak = (bool) $_autopagebreak; } if ($_bottommargin !== null) { $this->bmargin = (float) $_bottommargin; } } public function setSpacesRE($_re = '/[^\S\xa0]/') { $this->engine()->setSpaceRegexp((string) $_re); } public function setRTL($_enable, $_resetx = true) { $this->rtlmode = (bool) $_enable; $this->engine()->setRTL($this->rtlmode); if ((bool) $_resetx) { $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } } /** * @return bool Current RTL document direction. */ public function getRTL() { return $this->rtlmode; } public function setTempRTL($_mode) { $mode = is_string($_mode) ? strtoupper($_mode) : false; if (in_array($mode, ['R', 'RTL'], true)) { $this->tmprtl = 'R'; } elseif (in_array($mode, ['L', 'LTR'], true)) { $this->tmprtl = 'L'; } else { $this->tmprtl = false; } } /** * @return bool True when the current text direction is right-to-left. */ public function isRTLTextDir() { return $this->rtlmode || $this->tmprtl === 'R'; } public function setLastH($_h) { $this->lasth = (float) $_h; } /** * Return the cell height for the given font size. * * @param int|float $_fontsize Font size in user units. * @param bool $_padding When true include the top and bottom cell padding. * * @return float Cell height in user units. */ public function getCellHeight($_fontsize, $_padding = true) { $height = (float) $_fontsize * $this->cellheightratio; if ($_padding) { $height += $this->cellpadding['T'] + $this->cellpadding['B']; } return round($height, 6); } public function resetLastH() { $this->lasth = $this->getCellHeight($this->getFontSize()); } /** * @return float Height of the last printed cell in user units. */ public function getLastH() { return $this->lasth; } public function setImageScale($_scale) { $this->imgscale = (float) $_scale; } /** * @return float Image scale ratio. */ public function getImageScale() { return $this->imgscale; } public function getPageDimensions($_pagenum = null) { $eng = $this->engine(); $pid = $_pagenum === null ? -1 : (int) $_pagenum - 1; $page = $eng->page->getPage($pid); return [ 'w' => $page['pwidth'], 'h' => $page['pheight'], 'wk' => $page['width'], 'hk' => $page['height'], 'tm' => $page['margin']['CT'], 'bm' => $page['margin']['CB'], 'lm' => $page['margin']['PL'], 'rm' => $page['margin']['PR'], 'pb' => $page['autobreak'], 'or' => $page['orientation'], 'olm' => $this->orig_lmargin, 'orm' => $this->orig_rmargin, ]; } /** * Return the page width in user units. * * @param int|null $_pagenum Page number (1-based) or null for the current page. * * @return float Page width in user units. */ public function getPageWidth($_pagenum = null) { if ($this->docstate < 2) { $size = $this->engine()->page->getPageFormatSize( is_string($this->curformat) ? strtoupper($this->curformat) : 'A4', $this->curorientation, ); return $size[0] / $this->kratio; } $pid = $_pagenum === null ? -1 : (int) $_pagenum - 1; $page = $this->engine()->page->getPage($pid); return $page['width']; } /** * Return the page height in user units. * * @param int|null $_pagenum Page number (1-based) or null for the current page. * * @return float Page height in user units. */ public function getPageHeight($_pagenum = null) { if ($this->docstate < 2) { $size = $this->engine()->page->getPageFormatSize( is_string($this->curformat) ? strtoupper($this->curformat) : 'A4', $this->curorientation, ); return $size[1] / $this->kratio; } $pid = $_pagenum === null ? -1 : (int) $_pagenum - 1; $page = $this->engine()->page->getPage($pid); return $page['height']; } /** * Return the automatic page-break bottom margin. * * @param int|null $_pagenum Page number (1-based) or null for the current page. * * @return float Break margin in user units. */ public function getBreakMargin($_pagenum = null) { if ($this->docstate < 2) { return $this->bmargin; } $pid = $_pagenum === null ? -1 : (int) $_pagenum - 1; $page = $this->engine()->page->getPage($pid); return $page['margin']['CB']; } /** * @return float Document scale factor (number of points in user unit). */ public function getScaleFactor() { return $this->kratio; } public function setMargins($_left, $_top, $_right = null, $_keepmargins = false) { $this->lmargin = (float) $_left; $this->tmargin = (float) $_top; $this->rmargin = $_right === null ? (float) $_left : (float) $_right; if ((bool) $_keepmargins) { $this->orig_lmargin = $this->lmargin; $this->orig_rmargin = $this->rmargin; } if ($this->docstate < 2) { $this->orig_lmargin = $this->lmargin; $this->orig_rmargin = $this->rmargin; $this->posx = $this->lmargin; $this->posy = $this->tmargin; } } public function setLeftMargin($_margin) { $this->lmargin = (float) $_margin; if ($this->docstate === 2 && $this->posx < $this->lmargin) { $this->posx = $this->lmargin; } } public function setTopMargin($_margin) { $this->tmargin = (float) $_margin; if ($this->docstate === 2 && $this->posy < $this->tmargin) { $this->posy = $this->tmargin; } } public function setRightMargin($_margin) { $this->rmargin = (float) $_margin; } public function setCellPadding($_pad) { $this->setCellPaddings((float) $_pad, (float) $_pad, (float) $_pad, (float) $_pad); } public function setCellPaddings($_left = null, $_top = null, $_right = null, $_bottom = null) { if ($_left !== null && (float) $_left >= 0) { $this->cellpadding['L'] = (float) $_left; } if ($_top !== null && (float) $_top >= 0) { $this->cellpadding['T'] = (float) $_top; } if ($_right !== null && (float) $_right >= 0) { $this->cellpadding['R'] = (float) $_right; } if ($_bottom !== null && (float) $_bottom >= 0) { $this->cellpadding['B'] = (float) $_bottom; } $this->engine()->setDefaultCellPadding( $this->cellpadding['T'], $this->cellpadding['R'], $this->cellpadding['B'], $this->cellpadding['L'], ); } /** * @return array{L: float, T: float, R: float, B: float} Cell padding in user units. */ public function getCellPaddings() { return $this->cellpadding; } public function setCellMargins($_left = null, $_top = null, $_right = null, $_bottom = null) { if ($_left !== null && (float) $_left >= 0) { $this->cellmargin['L'] = (float) $_left; } if ($_top !== null && (float) $_top >= 0) { $this->cellmargin['T'] = (float) $_top; } if ($_right !== null && (float) $_right >= 0) { $this->cellmargin['R'] = (float) $_right; } if ($_bottom !== null && (float) $_bottom >= 0) { $this->cellmargin['B'] = (float) $_bottom; } $this->engine()->setDefaultCellMargin( $this->cellmargin['T'], $this->cellmargin['R'], $this->cellmargin['B'], $this->cellmargin['L'], ); } /** * @return array{L: float, T: float, R: float, B: float} Cell margins in user units. */ public function getCellMargins() { return $this->cellmargin; } public function setAutoPageBreak($_auto, $_margin = 0) { $this->autopagebreak = (bool) $_auto; $this->bmargin = (float) $_margin; if ($this->docstate === 2) { $this->engine()->page->enableAutoPageBreak($this->autopagebreak); } } /** * @return bool Automatic page break state. */ public function getAutoPageBreak() { return $this->autopagebreak; } public function setDisplayMode($_zoom, $_layout = 'SinglePage', $_mode = 'UseNone') { $zoom = is_numeric($_zoom) ? (int) $_zoom : (string) $_zoom; $this->displaymode = ['zoom' => $zoom, 'layout' => (string) $_layout, 'mode' => (string) $_mode]; $this->engine()->setDisplayMode($zoom, (string) $_layout, (string) $_mode); } public function setCompression($_compress = true) { // Stream compression is configured at engine construction time and // is always enabled there; disabling it is not supported. } public function setSRGBmode($_mode = false) { $this->engine()->setSRGB((bool) $_mode); } public function setDocInfoUnicode($_unicode = true) { // The engine always encodes document information in Unicode. } public function setTitle($_title) { $this->engine()->setTitle((string) $_title); } public function setSubject($_subject) { $this->engine()->setSubject((string) $_subject); } public function setAuthor($_author) { $this->engine()->setAuthor((string) $_author); } public function setKeywords($_keywords) { $this->engine()->setKeywords((string) $_keywords); } public function setCreator($_creator) { $this->engine()->setCreator((string) $_creator); } public function setAllowLocalFiles($_allowLocalFiles) { // The engine restricts file access via constructor-bound options; // the legacy toggle has no effect on the modern engine. } public function Error($_msg) { $this->pdfraw = ''; if (defined('K_TCPDF_THROW_EXCEPTION_ERROR') && !constant('K_TCPDF_THROW_EXCEPTION_ERROR')) { die('TCPDF ERROR: ' . (string) $_msg); } throw new \Exception('TCPDF ERROR: ' . (string) $_msg); } public function Open() { $this->docstate = max(1, $this->docstate); } public function Close() { if ($this->docstate === 3) { return; } if ($this->docstate < 2) { $this->AddPage(); } $this->decorateAllPages(); $this->applyBookmarkDisplayMode(); $this->pdfraw = $this->engine()->getOutPDFString(); $this->docstate = 3; } /** * Match legacy TCPDF: when bookmark outlines are present the document * opens with the outline (bookmark) panel visible (/PageMode /UseOutlines). * * The engine only promotes the page mode to UseOutlines when it is the * empty string, but the default mode is UseNone, so that promotion never * fires on its own. Apply it here from the facade, preserving the tracked * zoom and layout, unless the user explicitly selected a non-default * display mode. */ protected function applyBookmarkDisplayMode(): void { if ($this->engineOutlines() === []) { return; } if (!in_array($this->displaymode['mode'], ['', 'UseNone'], true)) { return; } $this->engine()->setDisplayMode($this->displaymode['zoom'], $this->displaymode['layout'], 'UseOutlines'); } // =================================================================== // Page handling. // =================================================================== public function setPage($_pnum, $_resetmargins = false) { $pnum = (int) $_pnum; if ($pnum < 1 || $pnum > $this->getNumPages()) { $this->Error('Wrong page number on setPage() function: ' . $pnum); return; } $this->engine()->setCurrentPage($pnum - 1); if ((bool) $_resetmargins) { $this->lmargin = $this->orig_lmargin; $this->rmargin = $this->orig_rmargin; $this->posy = $this->tmargin; } } public function lastPage($_resetmargins = false) { $this->setPage($this->getNumPages(), $_resetmargins); } /** * @return int Current page number (1-based, 0 when no page exists). */ public function getPage() { if ($this->docstate < 2) { return 0; } return (int) $this->engine()->page->getPageId() + 1; } /** * @return int Total number of pages. */ public function getNumPages() { if ($this->docstate < 2) { return 0; } return count($this->engine()->page->getPages()); } public function addTOCPage($_orientation = '', $_format = '', $_keepmargins = false) { $this->AddPage($_orientation, $_format, $_keepmargins, true); } public function endTOCPage() { $this->endPage(true); } public function AddPage($_orientation = '', $_format = '', $_keepmargins = false, $_tocpage = false) { if ($this->docstate === 3) { $this->Error('Unable to add pages on a closed document'); return; } if ((bool) $_keepmargins) { $this->orig_lmargin = $this->lmargin; $this->orig_rmargin = $this->rmargin; } $this->endPage(); $this->startPage($_orientation, $_format, $_tocpage); } public function endPage($_tocpage = false) { // Headers and footers are rendered for all pages when the document // is closed, so no other per-page action is required here. if ((bool) $_tocpage || $this->tocpage === true) { $this->tocpage = false; } } public function startPage($_orientation = '', $_format = '', $_tocpage = false) { if ((bool) $_tocpage) { $this->tocpage = true; } $eng = $this->engine(); $eng->addPage($this->buildPageData($_orientation, $_format)); $this->docstate = 2; $this->pagedecor[$this->getPage() - 1] = [ 'header' => $this->printheader, 'footer' => $this->printfooter, ]; $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; $this->posy = $this->tmargin; // The new page content stream already starts with the ambient text // state: the engine re-emits the current font and the page context // hook re-emits the facade state (see ambientPageContent()). } public function setPageMark() { if ($this->docstate === 2) { $this->engine()->page->addContentMark(); } } public function setHeaderData($_ln = '', $_lw = 0, $_ht = '', $_hs = '', $_tc = [0, 0, 0], $_lc = [0, 0, 0]) { $this->headerdata = [ 'logo' => (string) $_ln, 'logo_width' => (float) $_lw, 'title' => (string) $_ht, 'string' => (string) $_hs, 'text_color' => $this->legacyColorComponents($_tc, [0, 0, 0]), 'line_color' => $this->legacyColorComponents($_lc, [0, 0, 0]), ]; } public function setFooterData($_tc = [0, 0, 0], $_lc = [0, 0, 0]) { $this->footerdata = [ 'text_color' => $this->legacyColorComponents($_tc, [0, 0, 0]), 'line_color' => $this->legacyColorComponents($_lc, [0, 0, 0]), ]; } /** * @return array{logo: string, logo_width: float, title: string, string: string, text_color: array, line_color: array} Header data. */ public function getHeaderData() { return $this->headerdata; } public function setHeaderMargin($_hm = 10) { $this->headermargin = (float) $_hm; } /** * @return float Header margin in user units. */ public function getHeaderMargin() { return $this->headermargin; } public function setFooterMargin($_fm = 10) { $this->footermargin = (float) $_fm; } /** * @return float Footer margin in user units. */ public function getFooterMargin() { return $this->footermargin; } public function setPrintHeader($_val = true) { $this->printheader = (bool) $_val; } public function setPrintFooter($_val = true) { $this->printfooter = (bool) $_val; if ($this->docstate === 2) { $pid = $this->getPage() - 1; if (isset($this->pagedecor[$pid])) { $this->pagedecor[$pid]['footer'] = $this->printfooter; } } } /** * @return float Right-bottom X coordinate of the last inserted image. */ public function getImageRBX() { return $this->imagerbx; } /** * @return float Right-bottom Y coordinate of the last inserted image. */ public function getImageRBY() { return $this->imagerby; } public function resetHeaderTemplate() { // The facade renders headers directly (no cached XObject template). } public function setHeaderTemplateAutoreset($_val = true) { // The facade renders headers directly (no cached XObject template). } public function Header() { $headerfont = $this->getHeaderFont(); $headerdata = $this->getHeaderData(); $this->posy = $this->headermargin; $this->posx = $this->orig_lmargin; $imgy = $this->posy; $logo = $headerdata['logo']; if ($logo !== '' && $logo !== K_BLANK_IMAGE && defined('K_PATH_IMAGES')) { $logofile = K_PATH_IMAGES . $logo; if (is_file($logofile)) { $this->Image($logofile, '', '', $headerdata['logo_width']); $imgy = $this->getImageRBY(); } } $cell_height = $this->getCellHeight($headerfont[2] / $this->kratio); $header_x = $this->orig_lmargin + ($headerdata['logo_width'] * 1.1); $cw = $this->getPageWidth() - $this->orig_lmargin - $this->orig_rmargin - ($headerdata['logo_width'] * 1.1); $this->setTextColorArray($headerdata['text_color']); // header title $this->setFont($headerfont[0], 'B', $headerfont[2] + 1); $this->setX($header_x); $this->Cell($cw, $cell_height, $headerdata['title'], 0, 1, '', 0, '', 0); // header string $this->setFont($headerfont[0], $headerfont[1], $headerfont[2]); $this->setX($header_x); $this->MultiCell( $cw, $cell_height, $headerdata['string'], 0, '', 0, 1, '', '', true, 0, false, true, 0, 'T', false, ); // header line $this->setLineStyle([ 'width' => 0.85 / $this->kratio, 'cap' => 'butt', 'join' => 'miter', 'dash' => 0, 'color' => $headerdata['line_color'], ]); $this->setY((2.835 / $this->kratio) + max($imgy, $this->posy)); $this->setX($this->orig_lmargin); $this->Cell($this->getPageWidth() - $this->orig_lmargin - $this->orig_rmargin, 0, '', 'T', 0, 'C'); } public function Footer() { $cur_y = $this->posy; $this->setTextColorArray($this->footerdata['text_color']); $line_width = 0.85 / $this->kratio; $this->setLineStyle([ 'width' => $line_width, 'cap' => 'butt', 'join' => 'miter', 'dash' => 0, 'color' => $this->footerdata['line_color'], ]); $w_page = isset($this->langdata['w_page']) ? (string) $this->langdata['w_page'] . ' ' : ''; if ($this->pagegroupsused) { $pagenumtxt = $w_page . $this->getPageNumGroupAlias() . ' / ' . $this->getPageGroupAlias(); } else { $pagenumtxt = $w_page . $this->getAliasNumPage() . ' / ' . $this->getAliasNbPages(); } $this->setY($cur_y); $this->setX($this->orig_lmargin); $this->Cell(0, 0, $pagenumtxt, 'T', 0, 'R'); } /** * @return int Current page number. */ public function PageNo() { return $this->getPage(); } // =================================================================== // Colors. // =================================================================== public function getAllSpotColors() { return $this->engine()->color->getSpotColors(); } public function AddSpotColor($_name, $_c, $_m, $_y, $_k) { $cmyk = new \Com\Tecnick\Color\Model\Cmyk([ 'cyan' => (float) $_c / 100, 'magenta' => (float) $_m / 100, 'yellow' => (float) $_y / 100, 'key' => (float) $_k / 100, 'alpha' => 1, ]); $this->engine()->color->addSpotColor((string) $_name, $cmyk); } public function setSpotColor($_type, $_name, $_tint = 100) { $eng = $this->engine(); $name = (string) $_name; $tint = (float) $_tint / 100; $type = strtolower((string) $_type); $cmd = ''; switch ($type) { case 'draw': $this->drawcolorspec = $name; $cmd = $eng->color->getPdfColor($name, true, $tint); break; case 'fill': $this->fillcolorspec = $name; $cmd = $eng->color->getPdfColor($name, false, $tint); break; case 'text': $this->textcolorspec = $name; return ''; } $this->emitToPage($cmd); return $cmd; } public function setDrawSpotColor($_name, $_tint = 100) { return $this->setSpotColor('draw', $_name, $_tint); } public function setFillSpotColor($_name, $_tint = 100) { return $this->setSpotColor('fill', $_name, $_tint); } public function setTextSpotColor($_name, $_tint = 100) { return $this->setSpotColor('text', $_name, $_tint); } public function setColorArray($_type, $_color, $_ret = false) { $color = is_array($_color) ? $_color : [0, 0, 0]; $values = array_values($color); return $this->setColor( (string) $_type, (float) ($values[0] ?? 0), (float) ($values[1] ?? -1), (float) ($values[2] ?? -1), (float) ($values[3] ?? -1), (bool) $_ret, isset($values[4]) && is_string($values[4]) ? $values[4] : '', ); } public function setDrawColorArray($_color, $_ret = false) { return $this->setColorArray('draw', $_color, $_ret); } public function setFillColorArray($_color, $_ret = false) { return $this->setColorArray('fill', $_color, $_ret); } public function setTextColorArray($_color, $_ret = false) { return $this->setColorArray('text', $_color, $_ret); } public function setColor($_type, $_col1 = 0, $_col2 = -1, $_col3 = -1, $_col4 = -1, $_ret = false, $_name = '') { $spec = $this->colorSpecFromComponents($_col1, $_col2, $_col3, $_col4, (string) $_name); $legacy = [(float) $_col1, (float) $_col2, (float) $_col3, (float) $_col4]; $eng = $this->engine(); $type = strtolower((string) $_type); $cmd = ''; switch ($type) { case 'draw': $this->drawcolorspec = $spec; $this->drawcolorlegacy = $legacy; $cmd = $eng->color->getPdfColor($spec, true); break; case 'fill': $this->fillcolorspec = $spec; $this->fillcolorlegacy = $legacy; $cmd = $eng->color->getPdfFillColor($spec); break; case 'text': $this->textcolorspec = $spec; $this->textcolorlegacy = $legacy; // Text color is emitted before each text operation. return ''; } if ((bool) $_ret) { return $cmd; } $this->emitToPage($cmd); return $cmd; } public function setDrawColor($_col1 = 0, $_col2 = -1, $_col3 = -1, $_col4 = -1, $_ret = false, $_name = '') { return $this->setColor('draw', $_col1, $_col2, $_col3, $_col4, $_ret, $_name); } public function setFillColor($_col1 = 0, $_col2 = -1, $_col3 = -1, $_col4 = -1, $_ret = false, $_name = '') { return $this->setColor('fill', $_col1, $_col2, $_col3, $_col4, $_ret, $_name); } public function setTextColor($_col1 = 0, $_col2 = -1, $_col3 = -1, $_col4 = -1, $_ret = false, $_name = '') { return $this->setColor('text', $_col1, $_col2, $_col3, $_col4, $_ret, $_name); } // =================================================================== // Fonts and text metrics. // =================================================================== /** * Return the length of a string in user units. * * @param string $_s Input string. * @param string $_fontname Optional family to measure with (default: current font). * @param string $_fontstyle Optional font style. * @param int|float $_fontsize Optional font size in points. * @param bool $_getarray When true return an array of per-character widths. * * @return float|array Total width, or per-character widths. */ public function GetStringWidth($_s, $_fontname = '', $_fontstyle = '', $_fontsize = 0, $_getarray = false) { return $this->GetArrStringWidth( array_values($this->engine()->uniconv->strToOrdArr($_s)), $_fontname, $_fontstyle, $_fontsize, $_getarray, ); } /** * Return the width of a character array in user units. * * @param array $_sa Characters (codepoints or strings). * @param string $_fontname Optional family to measure with (default: current font). * @param string $_fontstyle Optional font style. * @param int|float $_fontsize Optional font size in points. * @param bool $_getarray When true return an array of per-character widths. * * @return float|array Total width, or per-character widths. */ public function GetArrStringWidth($_sa, $_fontname = '', $_fontstyle = '', $_fontsize = 0, $_getarray = false) { $eng = $this->engine(); $ordarr = []; foreach ($_sa as $item) { $ordarr[] = is_int($item) ? $item : $eng->uniconv->ord($item); } // The engine font stack already carries the ambient spacing/stretching // (set via setFont/setFontStretching/setFontSpacing): its glyph widths // are scaled by the stretching ratio and getOrdArrDims() folds in the // inter-character spacing. A temporary measuring font inherits the same // ambient values so the result stays consistent with rendering. $usetemp = $_fontname !== ''; if ($usetemp) { $size = (float) $_fontsize > 0 ? (float) $_fontsize : $this->fontsizept; $eng->font->insert( $eng->pon, $_fontname, $_fontstyle, $size, $this->fontspacing * $this->kratio, $this->fontstretching / 100.0, ); } if ($_getarray) { $charspacing = $this->fontspacing * ($this->fontstretching / 100.0); $widths = []; foreach ($ordarr as $ord) { $widths[] = ($eng->font->getCharWidth((int) $ord) / $this->kratio) + $charspacing; } $result = $widths; } else { $dim = $eng->font->getOrdArrDims($ordarr); $result = $dim['totwidth'] / $this->kratio; } if ($usetemp) { $eng->font->popLastFont(); } return $result; } public function GetCharWidth($_char, $_notlast = true) { $char = is_int($_char) ? $_char : (string) $_char; return $this->getRawCharWidth($char); } /** * @param int|string $_char Character codepoint or string. * * @return float Character width in user units. */ public function getRawCharWidth($_char) { $ord = is_int($_char) ? $_char : $this->engine()->uniconv->ord($_char); return $this->engine()->font->getCharWidth($ord) / $this->kratio; } public function GetNumChars($_s) { return count($this->engine()->uniconv->strToOrdArr((string) $_s)); } public function AddFont($_family, $_style = '', $_fontfile = '', $_subset = 'default') { $eng = $this->engine(); $subset = $_subset === 'default' ? $this->fontsubsetting : (bool) $_subset; $metric = $eng->font->insert( $eng->pon, strtolower((string) $_family), (string) $_style, null, null, null, (string) $_fontfile, $subset, ); $eng->font->popLastFont(); return [ 'fontkey' => $metric['key'], 'family' => strtolower((string) $_family), 'style' => $metric['style'], ]; } public function setFont($_family, $_style = '', $_size = null, $_fontfile = '', $_subset = 'default', $_out = true) { $eng = $this->engine(); $family = strtolower(trim((string) $_family)); if ($family === '') { $family = $this->fontfamily; } $style = strtoupper((string) $_style); $match = []; if (preg_match('/^(courier|helvetica|times)(bi|b|i)$/', $family, $match) === 1) { // Legacy core font names may carry the style suffix in the // family ('courierB'); the engine expects family + style. $family = $match[1] ?? $family; $style .= strtoupper($match[2] ?? ''); } $this->fontdecor = [ 'U' => str_contains($style, 'U'), 'D' => str_contains($style, 'D'), 'O' => str_contains($style, 'O'), ]; $fontstyle = str_replace(['U', 'D', 'O'], '', $style); $size = $_size === null || (float) $_size <= 0 ? $this->fontsizept : (float) $_size; $subset = $_subset === 'default' ? $this->fontsubsetting : (bool) $_subset; // Carry the current font spacing/stretching into the engine font // stack: the engine lays text out with the stretching as a ratio and // emits the matching Tz/Tc text-state operators (paired with their // resets) per text run, so every text path (Cell/Text/Write/HTML) // renders consistently. The legacy API stores stretching as a // percentage and spacing in user units; the engine wants a ratio and // points respectively. $this->fontmetric = $eng->font->insert( $eng->pon, $family, $fontstyle, $size, $this->fontspacing * $this->kratio, $this->fontstretching / 100.0, (string) $_fontfile, $subset, ); $this->fontfamily = $family; $this->fontstyle = $fontstyle; $this->fontsizept = $size; if ($this->xobjtid !== '') { // Fonts used inside an XObject template must be listed in the // template resource dictionary. $eng->addXObjectFontID($this->xobjtid, $this->fontmetric['key']); } if ((bool) $_out) { $this->emitToPage($this->fontmetric['out']); } } public function setFontSize($_size, $_out = true) { $this->setFont($this->fontfamily, $this->getFontStyle(), (float) $_size, '', 'default', $_out); } public function getFontBBox() { $metric = $this->currentFontMetric(); $fbbox = is_array($metric['fbbox'] ?? null) ? $metric['fbbox'] : [0.0, 0.0, 0.0, 0.0]; return array_map(fn(mixed $val): float => (float) $val / $this->kratio, array_values($fbbox)); } public function getAbsFontMeasure($_s) { return (((float) $_s * $this->fontsizept) / 1000.0) / $this->kratio; } public function getCharBBox($_char) { $ord = is_int($_char) ? $_char : $this->engine()->uniconv->ord((string) $_char); $bbox = $this->engine()->font->getCharBBox($ord); return array_map(fn(mixed $val): float => $val / $this->kratio, array_values($bbox)); } public function getFontDescent($_font, $_style = '', $_size = 0) { $eng = $this->engine(); $eng->font->insert($eng->pon, strtolower((string) $_font), (string) $_style, (float) $_size); $metric = $eng->font->getCurrentFont(); $eng->font->popLastFont(); return abs($metric['descent']) / $this->kratio; } public function getFontAscent($_font, $_style = '', $_size = 0) { $eng = $this->engine(); $eng->font->insert($eng->pon, strtolower((string) $_font), (string) $_style, (float) $_size); $metric = $eng->font->getCurrentFont(); $eng->font->popLastFont(); return $metric['ascent'] / $this->kratio; } public function isCharDefined($_char, $_font = '', $_style = '') { $eng = $this->engine(); $ord = is_int($_char) ? $_char : $eng->uniconv->ord((string) $_char); $usetemp = (string) $_font !== ''; if ($usetemp) { $eng->font->insert($eng->pon, strtolower((string) $_font), (string) $_style, null); } $defined = $eng->font->isCharDefined($ord); if ($usetemp) { $eng->font->popLastFont(); } return $defined; } public function replaceMissingChars($_text, $_font = '', $_style = '', $_subs = []) { $eng = $this->engine(); $usetemp = (string) $_font !== ''; if ($usetemp) { $eng->font->insert($eng->pon, strtolower((string) $_font), (string) $_style, null); } $ordarr = $eng->uniconv->strToOrdArr((string) $_text); $subs = []; foreach (is_array($_subs) ? $_subs : [] as $key => $values) { $subs[(int) $key] = array_map(static fn(mixed $val): int => (int) $val, (array) $values); } $ordarr = $eng->font->replaceMissingChars(array_values($ordarr), $subs); if ($usetemp) { $eng->font->popLastFont(); } return implode('', $eng->uniconv->ordArrToChrArr($ordarr)); } public function setDefaultMonospacedFont($_font) { $this->monospacedfont = strtolower((string) $_font); } // =================================================================== // Links and annotations. // =================================================================== public function AddLink() { $lid = count($this->internallinks) + 1; $this->internallinks[$lid] = [ 'page' => max(0, $this->getPage() - 1), 'y' => $this->posy, ]; return $lid; } public function setLink($_link, $_y = 0, $_page = -1) { $lid = (int) $_link; if (!isset($this->internallinks[$lid])) { return; } $posy = (float) $_y === -1.0 ? $this->posy : (float) $_y; $page = (int) $_page; $this->internallinks[$lid] = [ 'page' => $page < 1 ? max(0, $this->getPage() - 1) : $page - 1, 'y' => $posy, ]; } public function Link($_x, $_y, $_w, $_h, $_link, $_spaces = 0) { $this->attachLink($_link, (float) $_x, (float) $_y, (float) $_w, (float) $_h); } public function Annotation($_x, $_y, $_w, $_h, $_text, $_opt = ['Subtype' => 'Text'], $_spaces = 0) { if ($this->docstate !== 2) { return; } $opt = []; foreach (is_array($_opt) ? $_opt : [] as $key => $val) { $key = strtolower((string) $key); if ($key === 'fs' && is_string($val)) { $val = $this->resolveLocalFile($val); if (!is_file($val)) { // The legacy implementation silently skipped attachments // whose source file cannot be read. return; } } $opt[$key] = $val; } $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; /** @var TAnnotOpts $opt */ $oid = $this->engine()->setAnnotation($posx, $posy, (float) $_w, (float) $_h, (string) $_text, $opt); $this->attachAnnotRef($oid); } public function EmbedFile($_opt) { $opt = is_array($_opt) ? $_opt : ['file' => (string) $_opt]; $file = $this->resolveLocalFile((string) ($opt['file'] ?? '')); if ($file === '') { return; } $this->engine()->addEmbeddedFile( $file, (string) ($opt['mime'] ?? 'application/octet-stream'), (string) ($opt['afrel'] ?? 'Source'), (string) ($opt['desc'] ?? ''), ); } public function EmbedFileFromString($_filename, $_content) { $this->engine()->addContentAsEmbeddedFile((string) $_content, (string) $_filename); } // =================================================================== // Text rendering. // =================================================================== public function Text( $_x, $_y, $_txt, $_fstroke = 0, $_fclip = false, $_ffill = true, $_border = 0, $_ln = 0, $_align = '', $_fill = false, $_link = '', $_stretch = 0, $_ignore_min_height = false, $_calign = 'T', $_valign = 'M', $_rtloff = false, ) { $this->setXY((float) $_x, (float) $_y, (bool) $_rtloff); $this->Cell( 0, 0, $_txt, $_border, $_ln, $_align, $_fill, $_link, $_stretch, $_ignore_min_height, $_calign, $_valign, ); } /** * @return bool True when an automatic page break is allowed. */ public function AcceptPageBreak() { return $this->autopagebreak; } public function Cell( $_w, $_h = 0, $_txt = '', $_border = 0, $_ln = 0, $_align = '', $_fill = false, $_link = '', $_stretch = 0, $_ignore_min_height = false, $_calign = 'T', $_valign = 'M', ) { if ($this->docstate !== 2) { $this->Error('Unable to print a cell: no page has been started'); return; } $eng = $this->engine(); $txt = (string) $_txt; // A border reserves a minimum cell padding (and an external/internal // border position) like legacy adjustCellPadding(); the implied top and // bottom padding grow the auto cell height so the stroke is contained. $bordermetrics = $this->legacyBorderCellMetrics($_border); $borderpos = $bordermetrics['pos']; $borderpadding = $bordermetrics['padding']; $height = (float) $_h; if (!(bool) $_ignore_min_height) { $minheight = $this->getCellHeight($this->getFontSize()) + $borderpadding['T'] - $this->cellpadding['T'] + ($borderpadding['B'] - $this->cellpadding['B']); $height = max($height, $minheight); } $this->breakIfNeeded($height); $width = (float) $_w; if ($width <= 0) { $width = $this->rtlmode ? max(0.0, $this->posx - $this->lmargin) : $this->getPageWidth() - $this->rmargin - $this->posx; } // In RTL the cursor X marks the cell's right edge; the engine draws // every cell from its left edge, so shift left by the cell width. // The ln cursor advance below keeps using $this->posx. $cellx = $this->rtlmode ? $this->posx - $width : $this->posx; // Legacy $calign shifts the whole cell box vertically relative to the // text line; the engine still positions the text inside the box per // $valign. Apply the shift to the draw Y only (the ln cursor advance // below uses the original Y, like getCellCode()). $cellposy = $this->posy; $this->posy -= $this->cellCalignShift((string) $_calign, (string) $_valign, $height); $styles = $this->stylesFromLegacyBorder($_border, (bool) $_fill); $out = $eng->color->getPdfFillColor($this->textcolorspec); // For an external/internal border the engine offsets the stroke by // (line width * borderpos) and derives the matching text padding; the // default position keeps the byte-identical null-cell path. $engcell = null; if ($borderpos !== \Com\Tecnick\Pdf\Tcpdf::BORDERPOS_DEFAULT) { $engcell = \Com\Tecnick\Pdf\Tcpdf::ZEROCELL; $engcell['borderpos'] = $borderpos; } // Legacy stretch modes: 1 scale / 3 space when the text exceeds the // cell width; 2 force scaling / 4 force spacing to fill it exactly. $stretchmode = (int) $_stretch; $stretchtextw = 0.0; if ($stretchmode > 0 && $txt !== '') { // True rendered width per the PDF imaging model: character // spacing (Tc) applies to every glyph and is scaled by the // horizontal scaling (Tz) together with the glyph widths. The fit // math is expressed against the natural glyph widths, so measure // with the ambient spacing/stretching neutralised. $stretchraw = $this->naturalOrdWidth($txt); $stretchchars = (int) $this->GetNumChars($txt); $stretchtextw = (($stretchraw + ($this->fontspacing * $stretchchars)) * $this->fontstretching) / 100; $stretchavail = $width - $this->cellpadding['L'] - $this->cellpadding['R']; if ($stretchtextw <= 0 || $stretchmode !== 2 && $stretchmode !== 4 && $stretchtextw <= $stretchavail) { $stretchmode = 0; } } if ($stretchmode > 0 && $txt !== '') { // Box and text drawn separately: the border must keep the // requested geometry while the text run is scaled or spaced. $out .= $this->cellBoxOutput($cellx, $this->posy, $width, $height, $styles, (bool) $_fill); $out .= $this->stretchedCellText( $stretchmode, $txt, $width, $height, $stretchtextw, $this->valignToEngine($_valign === 'M' ? 'C' : $_valign), $this->engineShadow(), ); } elseif ($txt !== '') { $out .= $eng->getTextCell( $txt, $cellx, $this->posy, $width, $height, 0, 0, $this->valignToEngine($_valign === 'M' ? 'C' : $_valign), $this->halignToEngine($_align), $engcell, $styles, $this->textrendermode['stroke'], 0, 0, 0, true, $this->textrendermode['fill'], $this->textrendermode['stroke'] > 0, $this->fontdecor['U'], $this->fontdecor['D'], $this->fontdecor['O'], $this->textrendermode['clip'], true, $this->forcedTextDir(), $this->engineShadow(), ); } elseif ($styles !== []) { // Empty text: getTextCell() would return nothing, but the legacy // Cell still draws the background fill and the requested border // sides (and only those: a 'T' border is a single line, not a // rectangle). if ((bool) $_fill) { $out .= $eng->graph->getBasicRect($cellx, $this->posy, $width, $height, 'f', [ 'lineWidth' => 0.0, 'fillColor' => $this->fillcolorspec, ]); } $sides = [ 'T' => [$cellx, $this->posy, $cellx + $width, $this->posy], 'R' => [$cellx + $width, $this->posy, $cellx + $width, $this->posy + $height], 'B' => [$cellx + $width, $this->posy + $height, $cellx, $this->posy + $height], 'L' => [$cellx, $this->posy + $height, $cellx, $this->posy], ]; foreach ($sides as $letter => $line) { $sty = $styles[$letter] ?? $styles['all'] ?? null; if (is_array($sty) && isset($sty['lineWidth']) && $sty['lineWidth'] > 0) { $out .= $eng->graph->getLine($line[0], $line[1], $line[2], $line[3], $sty); } } } $this->emitToPage($out); $this->attachLink($_link, $cellx, $this->posy, $width, $height); $this->posy = $cellposy; $this->lasth = $height; $ln = (int) $_ln; if ($ln === 1) { $this->posy += $height; $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } elseif ($ln === 2) { $this->posy += $height; } elseif ($this->rtlmode) { $this->posx -= $width; } else { $this->posx += $width; } } public function MultiCell( $_w, $_h, $_txt, $_border = 0, $_align = 'J', $_fill = false, $_ln = 1, $_x = null, $_y = null, $_reseth = true, $_stretch = 0, $_ishtml = false, $_autopadding = true, $_maxh = 0, $_valign = 'T', $_fitcell = false, ) { if ($this->docstate !== 2) { $this->Error('Unable to print a cell: no page has been started'); return 0; } if ($_x !== null && (float) $_x !== 0.0) { $this->setX((float) $_x); } if ($_y !== null && (float) $_y !== 0.0) { $this->setY((float) $_y, false); } if ((bool) $_ishtml) { $this->writeHTMLCell( $_w, $_h, $this->posx, $this->posy, $_txt, $_border, $_ln, $_fill, $_reseth, $_align, $_autopadding, ); return 1; } $eng = $this->engine(); $width = (float) $_w; if ($width <= 0) { $width = $this->getPageWidth() - $this->rmargin - $this->posx; } $txt = $this->preserveBlankLines((string) $_txt); $styles = $this->stylesFromLegacyBorder($_border, (bool) $_fill); $startx = $this->posx; $starty = $this->posy; $startpid = $this->getPage(); // Legacy MultiCell delegates to Write() with the current lasth as // the row height, after resetting it on $reseth and zeroing the // vertical cell padding (the padding wraps the whole cell, not the // single rows), so the row pitch excludes the cell padding. if ((bool) $_reseth || $this->lasth <= 0) { $this->lasth = $this->getCellHeight($this->getFontSize(), false); } $pitch = max($this->lasth, $this->getCellHeight($this->getFontSize(), false)); $row = $this->legacyRowPitch($pitch, true); // The legacy height parameter is a minimum: the cell grows to fit // its text. Measure the natural height first (no output emitted). // With fitcell the height is a fixed limit instead: the engine // shrinks the font to make the text fit ('F' fit mode). $height = (float) $_h; if ($txt !== '' && $height > 0 && !(bool) $_fitcell) { $eng->getTextCell( $txt, $this->posx, $this->posy, $width, 0, 0, $row['linespace'], $this->valignToEngine($_valign), $this->halignToEngine($_align), $row['cell'], $styles, 0, 0, 0, 0, false, true, false, false, false, false, false, false, ); // The engine cell ends at the text bottom: add the trailing // remainder of the last row so the grown height covers whole // legacy rows. $natural = $eng->getLastCellBBox()['h'] + ($row['linespace'] / 2.0); $height = max($height, $natural); if ((float) $_maxh > 0) { $height = min($height, (float) $_maxh); } } $this->emitToPage($eng->color->getPdfFillColor($this->textcolorspec)); $pagecapacity = $this->getPageHeight() - $this->tmargin - $this->bmargin; $facadebox = false; $contentstart = 0; $bboxstart = 0; // With no-write page regions the text must hug the obstacle band by // band, which only the region-advancing flowing path does; the // single-placement getTextCell call below would wrap every line at the // starting band's width instead of following the shape. $hasnowrite = $this->currentPageNoWriteAreas() !== []; if ($txt !== '' && $height > 0 && !$hasnowrite && ($height <= $pagecapacity || !$this->autopagebreak)) { // The whole cell fits on one page: render it via the // non-flowing engine call (the flowing variant mis-splits // cells near the page bottom and re-applies the explicit // height to every continuation block). The facade handles // the page break itself. $this->breakIfNeeded($height); $this->emitToPage($eng->getTextCell( $txt, $this->posx, $this->posy, $width, $height, 0, $row['linespace'], $this->valignToEngine($_valign), $this->halignToEngine($_align), $row['cell'], $styles, $this->textrendermode['stroke'], 0, 0, 0, // Justify the last line too (legacy getCellCode behavior). false, $this->textrendermode['fill'], $this->textrendermode['stroke'] > 0, $this->fontdecor['U'], $this->fontdecor['D'], $this->fontdecor['O'], $this->textrendermode['clip'], true, $this->forcedTextDir(), $this->engineShadow(), (bool) $_fitcell ? 'F' : '', )); } elseif ($txt !== '') { // Legacy writes the cell text line by line and breaks to the next // page before the first line when it would not fit at the current // Y; the whole cell then starts on the new page and the rest // flows. The engine on its own keeps drawing past the bottom // margin (it does not break a block that starts below the region // bottom), so trigger the first-line break here. $firstrow = $pitch + $this->cellpadding['T']; if ( $this->autopagebreak && !$this->inheaderfooter && $this->xobjtid === '' && $this->docstate === 2 && $this->AcceptPageBreak() && ($this->posy + $firstrow - ($this->getPageHeight() - $this->bmargin)) > 0.0001 ) { $this->advanceToNextPage(); $startx = $this->posx; $starty = $this->posy; $startpid = $this->getPage(); } // Legacy no-write page regions: build the engine banded writable // regions for the current page so the text hugs the obstacles band // by band (each band uses its own writable width) before continuing // full width on the next page. if ($this->nowriteareas !== [] && $this->applyNoWriteRegionsForFlow($pitch, $this->posy)) { $width = 0.0; $region = $eng->page->getRegion(); $this->posx = $region['RX']; $startx = $this->posx; // Snap the cursor down to the band top when it lands inside a // one-line obstacle band: a fractional offset into such a band // (the cursor rarely lands on a boundary) would leave less than // one line of room and the engine would render nothing there. // The shift is capped at one band so the cursor is NOT pulled up // when it sits in a *tall merged* region: the engine collapses // the full-width slices above/below an obstacle into a single // tall region, and there the flow must start at the cursor, not // at the region top (otherwise the MultiCell jumps to the page // start and overlaps the preceding content). if ($this->posy > $region['RY'] && ($this->posy - $region['RY']) < $pitch) { $this->posy = $region['RY']; $starty = $this->posy; } } // Taller than a page (or auto-height): let the engine flow and // split the text across pages, but draw the border/fill box in // the facade (drawcell=false below). The engine sizes the box of // every split block from the *full* remaining text height, so on // each page but the last the frame overflows past the bottom // margin to the page edge. The facade instead clamps each page's // frame segment to its region, matching legacy open-cell // MultiCell (see drawHtmlCellSegments). $facadebox = true; $contentstart = $this->pageContentCount($startpid - 1); $bboxstart = $this->cellBBoxCount(); $eng->addTextCellXY( $txt, -1, $this->posx, $this->posy, $width, 0, 0, $row['linespace'], $this->valignToEngine($_valign), $this->halignToEngine($_align), $row['cell'], $styles, $this->textrendermode['stroke'], 0, 0, 0, // Justify the last line too (legacy getCellCode behavior). false, $this->textrendermode['fill'], $this->textrendermode['stroke'] > 0, $this->fontdecor['U'], $this->fontdecor['D'], $this->fontdecor['O'], $this->textrendermode['clip'], // The facade draws the box per page (see $facadebox below). false, $this->forcedTextDir(), $this->engineShadow(), (bool) $_fitcell ? 'F' : '', ); } $bbox = $eng->getLastCellBBox(); $cellh = $txt === '' ? $height : $bbox['h']; $cellbottom = $txt === '' ? $this->posy + $this->cellmargin['T'] + $cellh : $bbox['y'] + $cellh; if ($facadebox) { // Real end page from the number of rendered blocks (one cell // bounding box per block), not getPage(): the engine advances // the page pointer past a cell that ends exactly at a region // boundary, which would otherwise paint a phantom full-page // frame on a page the cell never reached. $blocks = max(1, $this->cellBBoxCount() - $bboxstart); $endpage = min($startpid + $blocks - 1, $this->getPage()); // Draw the border/fill box the engine omitted, as per-page frame // segments clamped to each page region (a no-op when neither // border nor fill is requested). $this->drawHtmlCellSegments( $startx, $width, $starty, $cellbottom, $startpid, $contentstart, $styles, (bool) $_fill, $endpage, ); } // Estimate the number of rendered text lines from the cell height. $numlines = $pitch > 0 ? max( 1, (int) round( ($bbox['h'] - $this->cellpadding['T'] - $this->cellpadding['B'] + ($row['linespace'] / 2.0)) / $pitch, ), ) : 1; $this->lasth = $pitch; $ln = (int) $_ln; if ($ln === 0) { // Legacy semantics: the cursor returns to the page and vertical // position where the cell started, with X advanced past it // (cell margins included). if ($this->getPage() > $startpid) { $this->setPage($startpid); } $this->posy = $starty; $this->posx = $startx + $width + $this->cellmargin['L'] + $this->cellmargin['R']; } else { $this->posy = $cellbottom + $this->cellmargin['B']; if ($ln === 2) { // Below the cell, with X past its right edge. $this->posx = $startx + $width + $this->cellmargin['L'] + $this->cellmargin['R']; } else { $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } } return $numlines; } /** * Estimate the number of lines needed to print the given text. * * @return int Estimated number of lines. */ public function getNumLines( $_txt, $_w = 0, $_reseth = false, $_autopadding = true, $_cellpadding = null, $_border = 0, ) { $width = (float) $_w; if ($width <= 0) { $width = $this->getPageWidth() - $this->rmargin - $this->posx; } $textwidth = $width - $this->cellpadding['L'] - $this->cellpadding['R']; if ($textwidth <= 0) { return 1; } $lines = 0; foreach (explode("\n", (string) $_txt) as $block) { $blockwidth = (float) $this->GetStringWidth($block); $lines += max(1, (int) ceil($blockwidth / $textwidth)); } return max(1, $lines); } public function getStringHeight( $_w, $_txt, $_reseth = false, $_autopadding = true, $_cellpadding = null, $_border = 0, ) { $lines = (int) $this->getNumLines($_txt, $_w, $_reseth, $_autopadding, $_cellpadding, $_border); $height = $lines * $this->getCellHeight($this->getFontSize(), false); if ((bool) $_autopadding) { $height += $this->cellpadding['T'] + $this->cellpadding['B']; } return $height; } /** * Fill the empty lines of a multi-line text with a single space. * * The engine drops empty lines from the rendered output: a line with no * characters produces no text operator and no bounding-box advance, so * consecutive line breaks collapse into one. The legacy API preserves * each empty line as a full line feed, so give every empty line a single * (invisible) space character to force the engine to render and advance * past it. Line counting is unaffected: the engine already counts empty * lines when measuring the cell, only their rendering position is lost. */ protected function preserveBlankLines(string $txt): string { if (!str_contains($txt, "\n")) { return $txt; } if (str_starts_with($txt, "\n")) { $txt = ' ' . $txt; } return preg_replace('/\n(?=\n)/', "\n ", $txt) ?? $txt; } /** * Map the legacy text row pitch onto the engine text-cell inputs. * * Legacy rendering emits every text line as a cell at least * getCellHeight() tall with the text vertically centered in it, while * the engine stacks the lines at the bare font metric height below the * cell top. The extra leading between the rows becomes the engine * inter-line space and half of it (the centering of the first line * inside its row) is added to the cell top padding. * * @param float $pitch Row pitch in user units (the caller resolves * the legacy minimum cell height). * @param bool $withpadding When true the cell carries the legacy cell * padding and margins (MultiCell); when false * the rows span the whole cell like legacy * Write(). * * @return array{ * linespace: float, * cell: array{ * margin: array{T: float, R: float, B: float, L: float}, * padding: array{T: float, R: float, B: float, L: float}, * borderpos: float, * }, * } Engine inter-line space in user units and cell definition (in * internal points). */ protected function legacyRowPitch(float $pitch, bool $withpadding = false): array { $metric = $this->currentFontMetric(); $lineh = (float) ($metric['height'] ?? ($this->fontsizept * $this->cellheightratio)) / $this->kratio; $linespace = $pitch - $lineh; $cell = \Com\Tecnick\Pdf\Tcpdf::ZEROCELL; $cell['padding']['T'] = $this->kratio * ($linespace / 2.0); if ($withpadding) { $cell['padding']['T'] += $this->kratio * $this->cellpadding['T']; $cell['padding']['R'] = $this->kratio * $this->cellpadding['R']; $cell['padding']['B'] = $this->kratio * $this->cellpadding['B']; $cell['padding']['L'] = $this->kratio * $this->cellpadding['L']; $cell['margin']['T'] = $this->kratio * $this->cellmargin['T']; $cell['margin']['R'] = $this->kratio * $this->cellmargin['R']; $cell['margin']['B'] = $this->kratio * $this->cellmargin['B']; $cell['margin']['L'] = $this->kratio * $this->cellmargin['L']; } return [ 'linespace' => $linespace, 'cell' => $cell, ]; } public function Write( $_h, $_txt, $_link = '', $_fill = false, $_align = '', $_ln = false, $_stretch = 0, $_firstline = false, $_firstblock = false, $_maxh = 0, $_wadj = 0, $_margin = null, ) { if ($this->docstate !== 2) { $this->Error('Unable to write text: no page has been started'); return 0; } $eng = $this->engine(); $txt = $this->preserveBlankLines((string) $_txt); if ($txt === '') { return 0; } // In column mode the text flows inside the current column region // (and through the following columns/pages inside the engine call). $incolumns = $this->inColumnMode(); $region = $eng->page->getRegion(); $originx = $incolumns ? $region['RX'] : $this->lmargin; $width = $incolumns ? $region['RW'] : $this->getPageWidth() - $this->rmargin - $this->lmargin; // Legacy wraps the lines inside the horizontal cell padding and // starts them at the padded edge. $originx += $this->cellpadding['L']; $width = max(0.0, $width - $this->cellpadding['L'] - $this->cellpadding['R']); $offset = max(0.0, $this->posx - $originx); $styles = ['all' => ['lineWidth' => 0.0]]; if ((bool) $_fill) { $styles['all']['fillColor'] = $this->fillcolorspec; } $pitch = max((float) $_h, $this->getCellHeight($this->getFontSize())); $row = $this->legacyRowPitch($pitch); $this->emitToPage($eng->color->getPdfFillColor($this->textcolorspec)); $eng->addTextCellXY( $txt, -1, $originx, $this->posy, $width, 0, $offset, $row['linespace'], 'T', $this->halignToEngine($_align), $row['cell'], $styles, $this->textrendermode['stroke'], 0, 0, 0, // Justify the last line too (legacy getCellCode behavior; lines // ending with an explicit line break are still excluded). false, $this->textrendermode['fill'], $this->textrendermode['stroke'] > 0, $this->fontdecor['U'], $this->fontdecor['D'], $this->fontdecor['O'], $this->textrendermode['clip'], (bool) $_fill, $this->forcedTextDir(), $this->engineShadow(), ); $textbbox = $eng->getLastBBox(); $cellbbox = $eng->getLastCellBBox(); // The cell bbox covers the last rendered block: the top padding plus // the text lines without the trailing remainder of the last row, so // the block spans rows * pitch - linespace / 2. $numlines = $pitch > 0 ? max(1, (int) round(($cellbbox['h'] + ($row['linespace'] / 2.0)) / $pitch)) : 1; $this->lasth = $pitch; if ((bool) $_ln || str_ends_with($txt, "\n")) { // Explicit line feed, or the text itself ends with a newline: // the cursor moves to the start of the next line (in column // mode: of the column the flow ended in). $this->posy = $cellbbox['y'] + ($numlines * $pitch); if ($incolumns) { $region = $eng->page->getRegion(); $this->posx = $this->rtlmode ? $region['RX'] + $region['RW'] : $region['RX']; } else { $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } } else { // Cursor stays on the last written line: the top of the last // row (single line: the cell top). $this->posy = $cellbbox['y'] + (($numlines - 1) * $pitch); $this->posx = (float) ($textbbox['x'] ?? 0) + (float) ($textbbox['w'] ?? 0); } if (is_string($_link) && $_link !== '') { $this->attachLink($_link, $cellbbox['x'], $cellbbox['y'], $cellbbox['w'], $cellbbox['h']); } return $numlines; } /** * Return the current font metric array (inserting the default when missing). */ protected function currentFontMetric(): array { if ($this->fontmetric === []) { $this->setFont($this->fontfamily, $this->fontstyle, $this->fontsizept, '', 'default', false); } return $this->fontmetric; } // =================================================================== // Images. // =================================================================== public function Image( $_file, $_x = null, $_y = null, $_w = 0, $_h = 0, $_type = '', $_link = '', $_align = '', $_resize = false, $_dpi = 300, $_palign = '', $_ismask = false, $_imgmask = false, $_border = 0, $_fitbox = false, $_hidden = false, $_fitonpage = false, $_alt = false, $_altimgs = [], ) { if ($this->docstate !== 2) { $this->Error('Unable to add an image: no page has been started'); return; } $eng = $this->engine(); $file = $this->resolveLocalFile((string) $_file); // Legacy image masks: Image($ismask=true) registers a soft-mask source // and returns a handle without drawing anything. A later Image() given // that handle as $imgmask gets the mask merged into its alpha channel // and is rendered through the engine's embedded-alpha path. if ((bool) $_ismask) { $this->imagemasks[++$this->imagemaskseq] = $file; return $this->imagemaskseq; } $maskhandle = is_numeric($_imgmask) ? (int) $_imgmask : 0; $maskedpng = $maskhandle > 0 && isset($this->imagemasks[$maskhandle]) ? $this->mergeImageAlphaMask($file, $this->imagemasks[$maskhandle]) : null; $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; $width = (float) $_w; $height = (float) $_h; if ($width <= 0 || $height <= 0) { [$pixw, $pixh] = $this->imagePixelSize($file); if ($pixw > 0 && $pixh > 0) { if ($width <= 0 && $height <= 0) { $width = $pixw / ($this->imgscale * $this->kratio); $height = $pixh / ($this->imgscale * $this->kratio); } elseif ($width <= 0) { $width = ($height * $pixw) / $pixh; } else { $height = ($width * $pixh) / $pixw; } } } if ($width <= 0 || $height <= 0) { $this->Error('Unable to determine the size of the image: ' . $file); return; } if ((bool) $_fitbox && (float) $_w > 0 && (float) $_h > 0) { // Scale the image to fit the given box, preserving the original // pixel proportions; the fitbox letters select the alignment // inside the box. [$pixw, $pixh] = $this->imagePixelSize($file); if ($pixw > 0 && $pixh > 0) { $fit = is_string($_fitbox) ? strtoupper($_fitbox) : ''; $boxw = (float) $_w; $boxh = (float) $_h; $ratio = min($boxw / $pixw, $boxh / $pixh); $neww = $pixw * $ratio; $newh = $pixh * $ratio; $posx += match (true) { str_contains($fit, 'C') => ($boxw - $neww) / 2, str_contains($fit, 'R') => $boxw - $neww, default => 0.0, }; $posy += match (true) { str_contains($fit, 'M') => ($boxh - $newh) / 2, str_contains($fit, 'B') => $boxh - $newh, default => 0.0, }; $width = $neww; $height = $newh; } } // Horizontal alignment relative to the page content area. $palign = strtoupper((string) $_palign); if ($palign === 'C') { $posx = ($this->getPageWidth() - $width) / 2; } elseif ($palign === 'R') { $posx = $this->getPageWidth() - $this->rmargin - $width; } elseif ($palign === 'L') { $posx = $this->lmargin; } $this->breakIfNeeded($posy === $this->posy ? $height : 0.0); if ($posy === $this->posy && $this->posy !== ($posy = max($posy, $this->posy))) { $posy = $this->posy; } if ((bool) $_hidden) { return; } $iid = $eng->image->add($maskedpng === null ? $file : '@' . $maskedpng, null, null, false, $this->jpegquality); if ($this->xobjtid !== '') { $eng->addXObjectImageID($this->xobjtid, $iid); $flipheight = $this->xobjheight; } else { $page = $eng->page->getPage(); $flipheight = $page['height']; } $this->emitToPage($eng->image->getSetImage($iid, $posx, $posy, $width, $height, $flipheight)); if ($_border !== 0 && $_border !== false && $_border !== '') { $styles = $this->stylesFromLegacyBorder($_border, false); if ($styles !== []) { $this->emitToPage($eng->graph->getRect($posx, $posy, $width, $height, 'S', $styles)); } } $this->attachLink($_link, $posx, $posy, $width, $height); $this->imagerbx = $posx + $width; $this->imagerby = $posy + $height; switch (strtoupper((string) $_align)) { case 'T': $this->posy = $posy; $this->posx = $this->imagerbx; break; case 'M': $this->posy = $posy + round($height / 2); $this->posx = $this->imagerbx; break; case 'B': $this->posy = $this->imagerby; $this->posx = $this->imagerbx; break; case 'N': $this->setY($this->imagerby); break; default: break; } } /** * Apply a legacy 8-bit soft mask to an image by writing it into the image's * alpha channel, then return the merged PNG content. The engine's import * path then splits that alpha back out into a /SMask (the only soft-mask * mechanism tc-lib-pdf-image exposes), reproducing legacy's separate-mask * Image() call. Returns null when the images cannot be read (caller falls * back to the unmasked file). * * Mask polarity follows the PDF soft-mask convention used by legacy: a * white mask pixel is fully opaque, a black one fully transparent. */ protected function mergeImageAlphaMask(string $basefile, string $maskfile): ?string { if (!function_exists('imagecreatefromstring')) { return null; } if (!is_readable($basefile) || !is_readable($maskfile)) { return null; } $basedata = file_get_contents($basefile); $maskdata = file_get_contents($maskfile); if ($basedata === false || $maskdata === false) { return null; } // Validate the bytes before decoding so imagecreatefromstring() does // not raise a warning on malformed data. if (getimagesizefromstring($basedata) === false || getimagesizefromstring($maskdata) === false) { return null; } $base = imagecreatefromstring($basedata); $mask = imagecreatefromstring($maskdata); if ($base === false || $mask === false) { return null; } imagepalettetotruecolor($base); imagepalettetotruecolor($mask); $width = imagesx($base); $height = imagesy($base); // Scale the mask to the base image size (legacy ignores the mask's own // width/height and stretches it onto the target image). if (imagesx($mask) !== $width || imagesy($mask) !== $height) { $scaled = imagecreatetruecolor($width, $height); imagecopyresampled($scaled, $mask, 0, 0, 0, 0, $width, $height, imagesx($mask), imagesy($mask)); $mask = $scaled; } $out = imagecreatetruecolor($width, $height); imagealphablending($out, false); imagesavealpha($out, true); for ($y = 0; $y < $height; ++$y) { for ($x = 0; $x < $width; ++$x) { $rgb = imagecolorat($base, $x, $y) & 0xFF_FF_FF; // Gray value of the mask (R=G=B); white => opaque, black => transparent. $gray = imagecolorat($mask, $x, $y) & 0xFF; // GD alpha is inverted (0 = opaque, 127 = transparent). $alpha = 127 - (int) round(($gray / 255) * 127); imagesetpixel($out, $x, $y, ($alpha << 24) | $rgb); } } ob_start(); imagepng($out); $png = (string) ob_get_clean(); return $png === '' ? null : $png; } /** * Return the pixel size of an image file or '@'-prefixed data stream. * * @return array{0: int, 1: int} Width and height in pixels (0 when unknown). */ protected function imagePixelSize(string $file): array { if ($file !== '' && $file[0] === '@') { $info = getimagesizefromstring(substr($file, 1)); } else { $info = is_file($file) ? getimagesize($file) : false; } return is_array($info) ? [(int) $info[0], (int) $info[1]] : [0, 0]; } // =================================================================== // Cursor positioning. // =================================================================== public function Ln($_h = null, $_cell = false) { $height = $_h === null || (string) $_h === '' ? $this->lasth : (float) $_h; $cellmargin = (bool) $_cell ? $this->cellmargin['L'] : 0.0; $this->posy += $height; $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin - $cellmargin : $this->lmargin + $cellmargin; } /** * @return float Relative X coordinate of the cursor in user units. */ public function GetX() { return $this->rtlmode ? $this->getPageWidth() - $this->posx : $this->posx; } /** * @return float Absolute X coordinate of the cursor in user units. */ public function GetAbsX() { return $this->posx; } /** * @return float Y coordinate of the cursor in user units. */ public function GetY() { return $this->posy; } public function setX($_x, $_rtloff = false) { $posx = (float) $_x; if (!(bool) $_rtloff && $this->rtlmode) { $this->posx = $posx >= 0 ? $this->getPageWidth() - $posx : -$posx; return; } $this->posx = $posx >= 0 ? $posx : $this->getPageWidth() + $posx; } public function setY($_y, $_resetx = true, $_rtloff = false) { $posy = (float) $_y; $this->posy = $posy >= 0 ? $posy : $this->getPageHeight() + $posy; if ((bool) $_resetx) { $this->posx = !(bool) $_rtloff && $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } } public function setXY($_x, $_y, $_rtloff = false) { $this->setY($_y, false, $_rtloff); $this->setX($_x, $_rtloff); } public function setAbsX($_x) { $this->posx = (float) $_x; } public function setAbsY($_y) { $this->posy = (float) $_y; } public function setAbsXY($_x, $_y) { $this->posx = (float) $_x; $this->posy = (float) $_y; } // =================================================================== // Output. // =================================================================== public function Output($_name = 'doc.pdf', $_dest = 'I') { $this->Close(); $name = (string) $_name === '' ? 'doc.pdf' : (string) $_name; $dest = strtoupper((string) $_dest); if ($dest === '') { $dest = 'I'; } $eng = $this->engine(); switch ($dest) { case 'I': $eng->setPDFFilename(basename($name)); $eng->renderPDF($this->pdfraw); return ''; case 'D': $eng->setPDFFilename(basename($name)); if (PHP_SAPI === 'cli') { $eng->renderPDF($this->pdfraw); return ''; } $eng->downloadPDF($this->pdfraw); return ''; case 'F': case 'FI': case 'FD': $eng->savePDF($name, $this->pdfraw); if ($dest !== 'F') { $eng->setPDFFilename(basename($name)); $eng->renderPDF($this->pdfraw); } return ''; case 'E': $eng->setPDFFilename(basename($name)); return $eng->getMIMEAttachmentPDF($this->pdfraw); case 'S': return $this->pdfraw; default: $this->Error('Incorrect output destination: ' . $dest); return ''; } } public function getPDFData() { $this->Close(); return $this->pdfraw; } public function setExtraXMP($_xmp) { $this->engine()->setCustomXMP('tcpdf_extra', (string) $_xmp); } public function setExtraXMPRDF($_xmp) { $this->engine()->setCustomXMP('tcpdf_extra_rdf', (string) $_xmp); } public function setExtraXMPPdfaextension($_xmp) { $this->engine()->setCustomXMP('tcpdf_extra_pdfaextension', (string) $_xmp); } public function setDocCreationTimestamp($_time) { $this->doctimestamps['create'] = is_numeric($_time) ? (int) $_time : (int) strtotime((string) $_time); } public function setDocModificationTimestamp($_time) { $this->doctimestamps['modify'] = is_numeric($_time) ? (int) $_time : (int) strtotime((string) $_time); } public function getDocCreationTimestamp() { return $this->doctimestamps['create']; } public function getDocModificationTimestamp() { return $this->doctimestamps['modify']; } public function setHeaderFont($_font) { $font = is_array($_font) ? array_values($_font) : []; $this->headerfont = [ (string) ($font[0] ?? $this->fontfamily), (string) ($font[1] ?? ''), (float) ($font[2] ?? $this->fontsizept), ]; } /** * @return array{0: string, 1: string, 2: float} Header font: family, style, size in points. */ public function getHeaderFont() { return $this->headerfont; } public function setFooterFont($_font) { $font = is_array($_font) ? array_values($_font) : []; $this->footerfont = [ (string) ($font[0] ?? $this->fontfamily), (string) ($font[1] ?? ''), (float) ($font[2] ?? $this->fontsizept), ]; } /** * @return array{0: string, 1: string, 2: float} Footer font: family, style, size in points. */ public function getFooterFont() { return $this->footerfont; } public function setLanguageArray($_language) { $this->langdata = is_array($_language) ? $_language : []; $lang = []; foreach ($this->langdata as $key => $val) { if (!(is_string($key) && is_string($val))) { continue; } $lang[$key] = $val; } if ($lang !== []) { $this->engine()->setLanguageArray($lang); } // Legacy semantics: a_meta_dir == 'rtl' switches the whole document // to right-to-left (the engine and all cursor positioning follow). $dir = $this->langdata['a_meta_dir'] ?? ''; $this->rtlmode = is_string($dir) && strtolower($dir) === 'rtl'; $this->engine()->setRTL($this->rtlmode); $this->tmprtl = false; } public function addHtmlLink( $_url, $_name, $_fill = false, $_firstline = false, $_color = null, $_style = -1, $_firstblock = false, ) { $prevcolor = $this->textcolorspec; $prevstyle = $this->getFontStyle(); if (is_array($_color) && $_color !== []) { $this->textcolorspec = $this->colorSpecFromLegacy($_color); } else { $this->textcolorspec = $this->colorSpecFromLegacy($this->htmllinkcolor); } $style = is_string($_style) && $_style !== '' && $_style !== '-1' ? $_style : $this->htmllinkstyle; $this->setFont($this->fontfamily, $style, $this->fontsizept); $ret = $this->Write( $this->lasth, (string) $_name, (string) $_url, $_fill, '', false, 0, $_firstline, $_firstblock, 0, ); $this->textcolorspec = $prevcolor; $this->setFont($this->fontfamily, $prevstyle, $this->fontsizept); return $ret; } public function pixelsToUnits($_px) { return (float) $_px / ($this->imgscale * $this->kratio); } public function unhtmlentities($_text_to_convert) { return html_entity_decode((string) $_text_to_convert, ENT_QUOTES, 'UTF-8'); } // =================================================================== // Security (Stage 4). // =================================================================== public function setProtection( $_permissions = [ 'print', 'modify', 'copy', 'annot-forms', 'fill-forms', 'extract', 'assemble', 'print-high', ], $_user_pass = '', $_owner_pass = null, $_mode = 0, $_pubkeys = null, ) { if ($this->docstate >= 2) { $this->Error('setProtection() must be called before adding pages'); return; } $permissions = array_map( static fn(mixed $val): string => (string) $val, is_array($_permissions) ? array_values($_permissions) : [], ); $ownerpass = $_owner_pass === null ? md5(uniqid('tcpdf', true)) : (string) $_owner_pass; // The engine deprecates the legacy RC4 modes (0 and 1); the legacy // API accepted them silently, so that specific notice is muted here. set_error_handler(static fn(int $_errno, string $errstr): bool => str_contains( $errstr, 'RC4 encryption', ), E_USER_DEPRECATED | E_DEPRECATED); /** @var array{0: array{c: string, p: array}}|null $pubkeys */ $pubkeys = is_array($_pubkeys) && $_pubkeys !== [] ? array_values($_pubkeys) : null; try { $encrypt = new \Com\Tecnick\Pdf\Encrypt\Encrypt( true, md5(uniqid('tcpdf_fid', true)), (int) $_mode, $permissions, (string) $_user_pass, $ownerpass, $pubkeys, ); } finally { restore_error_handler(); } // Encryption is constructor-bound in the engine: re-initialize it // and replay the construction-time settings. $family = $this->fontfamily; $style = $this->fontstyle; $size = $this->fontsizept; $this->eng = $this->engineNew($encrypt); $this->kratio = $this->eng->toPoints(1.0); $this->setFont($family, $style, $size); } // =================================================================== // Transformations. // =================================================================== public function StartTransform() { $this->emitToPage($this->engine()->graph->getStartTransform()); } public function StopTransform() { $this->emitToPage($this->engine()->graph->getStopTransform()); } public function ScaleX($_s_x, $_x = '', $_y = '') { $this->Scale($_s_x, 100, $_x, $_y); } public function ScaleY($_s_y, $_x = '', $_y = '') { $this->Scale(100, $_s_y, $_x, $_y); } public function ScaleXY($_s, $_x = '', $_y = '') { $this->Scale($_s, $_s, $_x, $_y); } public function Scale($_s_x, $_s_y, $_x = null, $_y = null) { $scalex = (float) $_s_x; $scaley = (float) $_s_y; if ($scalex === 0.0 || $scaley === 0.0) { $this->Error('Please do not use values equal to zero for scaling'); return; } $this->emitToPage($this->engine()->graph->getScaling( $scalex / 100, $scaley / 100, $this->coordOrCursor($_x, false), $this->coordOrCursor($_y, true), )); } public function MirrorH($_x = null) { $this->emitToPage($this->engine()->graph->getHorizMirroring($this->coordOrCursor($_x, false))); } public function MirrorV($_y = null) { $this->emitToPage($this->engine()->graph->getVertMirroring($this->coordOrCursor($_y, true))); } public function MirrorP($_x = null, $_y = null) { $this->emitToPage($this->engine()->graph->getPointMirroring( $this->coordOrCursor($_x, false), $this->coordOrCursor($_y, true), )); } public function MirrorL($_angle = 0, $_x = null, $_y = null) { $this->emitToPage($this->engine()->graph->getReflection( (float) $_angle, $this->coordOrCursor($_x, false), $this->coordOrCursor($_y, true), )); } public function TranslateX($_t_x) { $this->Translate($_t_x, 0); } public function TranslateY($_t_y) { $this->Translate(0, $_t_y); } public function Translate($_t_x, $_t_y) { $this->emitToPage($this->engine()->graph->getTranslation((float) $_t_x, (float) $_t_y)); } public function Rotate($_angle, $_x = null, $_y = null) { $this->emitToPage($this->engine()->graph->getRotation( (float) $_angle, $this->coordOrCursor($_x, false), $this->coordOrCursor($_y, true), )); } public function SkewX($_angle_x, $_x = null, $_y = null) { $this->Skew($_angle_x, 0, $_x, $_y); } public function SkewY($_angle_y, $_x = null, $_y = null) { $this->Skew(0, $_angle_y, $_x, $_y); } public function Skew($_angle_x, $_angle_y, $_x = null, $_y = null) { $anglex = (float) $_angle_x; $angley = (float) $_angle_y; if ($anglex <= -90 || $anglex >= 90 || $angley <= -90 || $angley >= 90) { $this->Error('Please use values between -90 and +90 degrees for Skewing.'); return; } $this->emitToPage($this->engine()->graph->getSkewing( $anglex, $angley, $this->coordOrCursor($_x, false), $this->coordOrCursor($_y, true), )); } // =================================================================== // Graphics. // =================================================================== public function setLineWidth($_width) { $this->linewidth = (float) $_width; $this->emitToPage(sprintf('%F w' . "\n", $this->linewidth * $this->kratio)); } /** * @return float Current line width in user units. */ public function GetLineWidth() { return $this->linewidth; } public function setLineStyle($_style, $_ret = false) { $style = is_array($_style) ? $_style : []; $engstyle = $this->styleFromLegacyLineStyle($style); if (isset($style['width']) && is_numeric($style['width'])) { $this->linewidth = (float) $style['width']; } if (isset($engstyle['lineColor'])) { $this->drawcolorspec = $engstyle['lineColor']; } $this->linestyle = $engstyle; $cmd = $this->engine()->graph->getStyleCmd($engstyle); if (!(bool) $_ret) { $this->emitToPage($cmd); } return $cmd; } public function Line($_x1, $_y1, $_x2, $_y2, $_style = []) { if ($this->docstate !== 2) { return; } if (is_array($_style) && $_style !== []) { $this->setLineStyle($_style, true); } $this->emitToPage($this->engine()->graph->getLine( (float) $_x1, (float) $_y1, (float) $_x2, (float) $_y2, $this->currentLineStyle(), )); } public function Rect($_x, $_y, $_w, $_h, $_style = '', $_border_style = [], $_fill_color = []) { if ($this->docstate !== 2) { return; } $eng = $this->engine(); $mode = $this->shapeModeFromLegacy($_style, false); if (is_array($_fill_color) && $_fill_color !== []) { $this->fillcolorspec = $this->colorSpecFromLegacy($_fill_color); } $border = is_array($_border_style) ? $_border_style : []; $sidekeys = array_intersect(array_keys($border), ['L', 'T', 'R', 'B']); if ($sidekeys !== []) { // Per-side border styles: fill first (stroke removed), then stroke // each requested side as an independent line, exactly like the // legacy library. Routing each side through Line()/setLineStyle() // makes the line state persist between sides, so a side that omits // a style key (e.g. 'color') inherits it from the previous side. if (str_contains($mode, 'f') || str_contains($mode, 'B') || str_contains($mode, 'b')) { $this->emitToPage($eng->graph->getBasicRect((float) $_x, (float) $_y, (float) $_w, (float) $_h, 'f', [ 'lineWidth' => 0.0, 'fillColor' => $this->fillcolorspec, ])); } $posx = (float) $_x; $posy = (float) $_y; $width = (float) $_w; $height = (float) $_h; if (isset($border['L']) && $border['L']) { $this->Line($posx, $posy, $posx, $posy + $height, is_array($border['L']) ? $border['L'] : []); } if (isset($border['T']) && $border['T']) { $this->Line($posx, $posy, $posx + $width, $posy, is_array($border['T']) ? $border['T'] : []); } if (isset($border['R']) && $border['R']) { $this->Line( $posx + $width, $posy, $posx + $width, $posy + $height, is_array($border['R']) ? $border['R'] : [], ); } if (isset($border['B']) && $border['B']) { $this->Line( $posx, $posy + $height, $posx + $width, $posy + $height, is_array($border['B']) ? $border['B'] : [], ); } return; } if (isset($border['all']) && is_array($border['all'])) { $this->setLineStyle($border['all'], true); } $style = $this->currentLineStyle(); $style['fillColor'] = $this->fillcolorspec; $this->emitToPage($eng->graph->getBasicRect((float) $_x, (float) $_y, (float) $_w, (float) $_h, $mode, $style)); } public function Curve( $_x0, $_y0, $_x1, $_y1, $_x2, $_y2, $_x3, $_y3, $_style = '', $_line_style = [], $_fill_color = [], ) { $style = $this->shapeStyleFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getCurve( (float) $_x0, (float) $_y0, (float) $_x1, (float) $_y1, (float) $_x2, (float) $_y2, (float) $_x3, (float) $_y3, $this->shapeModeFromLegacy($_style, false), $style, )); } public function Polycurve($_x0, $_y0, $_segments, $_style = '', $_line_style = [], $_fill_color = []) { $segments = []; foreach (is_array($_segments) ? $_segments : [] as $segment) { if (!is_array($segment)) { continue; } $segments[] = array_map(static fn(mixed $val): float => (float) $val, array_values($segment)); } $style = $this->shapeStyleFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getPolycurve( (float) $_x0, (float) $_y0, $segments, $this->shapeModeFromLegacy($_style, false), $style, )); } public function Ellipse( $_x0, $_y0, $_rx, $_ry = 0, $_angle = 0, $_astart = 0, $_afinish = 360, $_style = '', $_line_style = [], $_fill_color = [], $_nc = 2, ) { $vrad = (float) $_ry; if ($vrad <= 0) { $vrad = (float) $_rx; } $style = $this->shapeStyleFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getEllipse( (float) $_x0, (float) $_y0, (float) $_rx, $vrad, (float) $_angle, (float) $_astart, (float) $_afinish, $this->shapeModeFromLegacy($_style, false), $style, max(2, (int) $_nc), )); } public function Circle( $_x0, $_y0, $_r, $_angstr = 0, $_angend = 360, $_style = '', $_line_style = [], $_fill_color = [], $_nc = 2, ) { $style = $this->shapeStyleFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getCircle( (float) $_x0, (float) $_y0, (float) $_r, (float) $_angstr, (float) $_angend, $this->shapeModeFromLegacy($_style, false), $style, max(2, (int) $_nc), )); } public function PolyLine($_p, $_style = '', $_line_style = [], $_fill_color = []) { /** @var array $points */ $points = []; foreach (is_array($_p) ? array_values($_p) : [] as $val) { $points[] = (float) $val; } $style = $this->shapeStyleFromLegacy($_line_style, $_fill_color); /** @var array $points */ $this->emitToPage($this->engine()->graph->getBasicPolygon( $points, $this->shapeModeFromLegacy($_style, false), $style, )); } public function Polygon($_p, $_style = '', $_line_style = [], $_fill_color = [], $_closed = true) { /** @var array $points */ $points = []; foreach (is_array($_p) ? array_values($_p) : [] as $val) { $points[] = (float) $val; } $styles = $this->segmentStylesFromLegacy($_line_style, $_fill_color); /** @var array $points */ $this->emitToPage($this->engine()->graph->getPolygon( $points, $this->shapeModeFromLegacy($_style, false, (bool) $_closed), $styles, )); } public function RegularPolygon( $_x0, $_y0, $_r, $_ns, $_angle = 0, $_draw_circle = false, $_style = '', $_line_style = [], $_fill_color = [], $_circle_style = '', $_circle_outLine_style = [], $_circle_fill_color = [], ) { // Legacy draws the inscribed circle before the polygon, so its // style parameters persist first. $cirmode = (bool) $_draw_circle ? $this->shapeModeFromLegacy($_circle_style, false) : ''; $cirstyle = (bool) $_draw_circle ? $this->shapeStyleFromLegacy($_circle_outLine_style, $_circle_fill_color) : []; $styles = $this->segmentStylesFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getRegularPolygon( (float) $_x0, (float) $_y0, (float) $_r, (int) $_ns, (float) $_angle, $this->shapeModeFromLegacy($_style, false, true), $styles, $cirmode, $cirstyle, )); } public function StarPolygon( $_x0, $_y0, $_r, $_nv, $_ng, $_angle = 0, $_draw_circle = false, $_style = '', $_line_style = [], $_fill_color = [], $_circle_style = '', $_circle_outLine_style = [], $_circle_fill_color = [], ) { $cirmode = (bool) $_draw_circle ? $this->shapeModeFromLegacy($_circle_style, false) : ''; $cirstyle = (bool) $_draw_circle ? $this->shapeStyleFromLegacy($_circle_outLine_style, $_circle_fill_color) : []; $styles = $this->segmentStylesFromLegacy($_line_style, $_fill_color); $this->emitToPage($this->engine()->graph->getStarPolygon( (float) $_x0, (float) $_y0, (float) $_r, (int) $_nv, (int) $_ng, (float) $_angle, $this->shapeModeFromLegacy($_style, false, true), $styles, $cirmode, $cirstyle, )); } public function RoundedRect( $_x, $_y, $_w, $_h, $_r, $_round_corner = '1111', $_style = '', $_border_style = [], $_fill_color = [], ) { $this->RoundedRectXY($_x, $_y, $_w, $_h, $_r, $_r, $_round_corner, $_style, $_border_style, $_fill_color); } public function RoundedRectXY( $_x, $_y, $_w, $_h, $_rx, $_ry, $_round_corner = '1111', $_style = '', $_border_style = [], $_fill_color = [], ) { $style = $this->shapeStyleFromLegacy($_border_style, $_fill_color); $this->emitToPage($this->engine()->graph->getRoundedRect( (float) $_x, (float) $_y, (float) $_w, (float) $_h, (float) $_rx, (float) $_ry, (string) $_round_corner, $this->shapeModeFromLegacy($_style, false), $style, )); } public function Arrow($_x0, $_y0, $_x1, $_y1, $_head_style = 0, $_arm_size = 5, $_arm_angle = 15) { $this->emitToPage($this->engine()->graph->getArrow( (float) $_x0, (float) $_y0, (float) $_x1, (float) $_y1, (int) $_head_style, (float) $_arm_size, (int) $_arm_angle, $this->currentLineStyle(), )); } // =================================================================== // Destinations and bookmarks (Stage 4). // =================================================================== public function setDestination($_name, $_y = -1, $_page = '', $_x = -1) { if ($this->docstate !== 2) { return false; } $name = (string) $_name; $posx = (float) $_x === -1.0 ? $this->posx : (float) $_x; $posy = (float) $_y === -1.0 ? $this->posy : (float) $_y; $page = (string) $_page === '' ? $this->getPage() : (int) $_page; $this->engine()->setNamedDestination($name, $page - 1, $posx, $posy); $this->nameddests[$name] = ['x' => $posx, 'y' => $posy, 'p' => $page]; return $name; } public function getDestination() { return $this->nameddests; } public function setBookmark( $_txt, $_level = 0, $_y = -1, $_page = '', $_style = '', $_color = [0, 0, 0], $_x = -1, $_link = '', ) { $this->Bookmark($_txt, $_level, $_y, $_page, $_style, $_color, $_x, $_link); } public function Bookmark( $_txt, $_level = 0, $_y = -1, $_page = '', $_style = '', $_color = [0, 0, 0], $_x = -1, $_link = '', ) { if ($this->docstate !== 2) { return; } $posx = (float) $_x === -1.0 ? $this->posx : (float) $_x; $posy = (float) $_y === -1.0 ? $this->posy : (float) $_y; $page = (string) $_page === '' ? $this->getPage() : (int) $_page; $color = is_array($_color) && $_color !== [] ? $this->colorSpecFromLegacy($_color) : ''; $this->engine()->setBookmark( (string) $_txt, is_string($_link) ? $_link : '', max(0, (int) $_level), $page - 1, $posx, $posy, (string) $_style, $color, ); } // =================================================================== // JavaScript and forms (Stage 4). // =================================================================== public function IncludeJS($_script) { $this->engine()->appendRawJavaScript((string) $_script); } public function addJavascriptObject($_script, $_onload = false) { return $this->engine()->addRawJavaScriptObj((string) $_script, (bool) $_onload); } public function setFormDefaultProp($_prop = []) { $this->formdefaultprop = []; foreach (is_array($_prop) ? $_prop : [] as $key => $val) { $this->formdefaultprop[(string) $key] = $val; } $this->engine()->setDefJSAnnotProp($this->formdefaultprop); } public function getFormDefaultProp() { return $this->formdefaultprop; } /** * Normalize legacy list/combo values: either a list of strings or a * list of [export, display] pairs (the engine requires a homogeneous list). * * @return array|array */ protected function formFieldValues(mixed $values): array { $input = is_array($values) ? array_values($values) : []; $first = $input[0] ?? null; if (is_array($first)) { $pairs = []; foreach ($input as $val) { $pair = is_array($val) ? array_values($val) : [$val, $val]; $export = (string) ($pair[0] ?? ''); $display = array_key_exists(1, $pair) ? (string) $pair[1] : $export; $pairs[] = [$export, $display]; } return $pairs; } $strings = []; foreach ($input as $val) { $strings[] = is_scalar($val) ? (string) $val : ''; } return $strings; } /** * Normalize legacy form-field (prop, opt) pairs for the engine. * * @return array{0: TAnnotOpts, 1: array} [opt, jsp] */ protected function formFieldOptions(mixed $prop, mixed $opt): array { /** @var array $jsp */ $jsp = []; foreach (array_merge($this->formdefaultprop, is_array($prop) ? $prop : []) as $key => $val) { if (is_array($val)) { // Legacy color properties are component arrays. $jsp[(string) $key] = $this->colorSpecFromLegacy($val); } elseif (is_bool($val)) { $jsp[(string) $key] = $val ? 'true' : 'false'; } elseif (is_scalar($val)) { $jsp[(string) $key] = (string) $val; } } /** @var array $annot */ $annot = []; foreach (is_array($opt) ? $opt : [] as $key => $val) { $annot[strtolower((string) $key)] = $val; } $subtype = $annot['subtype'] ?? ''; $annot['subtype'] = is_string($subtype) && $subtype !== '' ? $subtype : 'Widget'; /** @var TAnnotOpts $annot */ return [$annot, $jsp]; } /** * Attach a freshly created annotation/form-field widget to the current * page so the engine emits it in the page's /Annots array. * * The engine's addFF*()/setAnnotation()/setLink() methods register the * annotation object and return its id, but leave page attachment to the * caller (see Com\Tecnick\Pdf\JavaScript::setAnnotation). An id of 0 means * the object was not created (e.g. captured by an XObject template or * suppressed in PDF/X mode) and must not be referenced. */ protected function attachAnnotRef(int $oid): void { if ($oid > 0) { $this->engine()->page->addAnnotRef($oid); } } /** * Advance the horizontal cursor after placing a form field, matching the * legacy behaviour where each field method shifts the current X position * by the field width (RTL-aware). The shift is applied to the live cursor * regardless of any explicit x/y override, exactly as legacy TCPDF does. */ protected function advanceFormFieldX(float $width): void { if ($this->rtlmode) { $this->posx -= $width; } else { $this->posx += $width; } } public function TextField($_name, $_w, $_h, $_prop = [], $_opt = [], $_x = null, $_y = null, $_js = false) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if ((bool) $_js) { $this->engine()->addJSText((string) $_name, $posx, $posy, (float) $_w, (float) $_h, $jsp); return; } $oid = $this->engine()->addFFText((string) $_name, $posx, $posy, (float) $_w, (float) $_h, $opt, $jsp); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } public function RadioButton( $_name, $_w, $_prop = [], $_opt = [], $_onvalue = 'On', $_checked = false, $_x = null, $_y = null, $_js = false, ) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if ((bool) $_js) { $this->engine()->addJSRadioButton((string) $_name, $posx, $posy, (float) $_w, $jsp); return; } $oid = $this->engine()->addFFRadioButton( (string) $_name, $posx, $posy, (float) $_w, (string) $_onvalue, (bool) $_checked, $opt, $jsp, ); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } public function ListBox($_name, $_w, $_h, $_values, $_prop = [], $_opt = [], $_x = null, $_y = null, $_js = false) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $values = $this->formFieldValues($_values); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if ((bool) $_js) { $this->engine()->addJSListBox((string) $_name, $posx, $posy, (float) $_w, (float) $_h, $values, $jsp); return; } $oid = $this->engine()->addFFListBox( (string) $_name, $posx, $posy, (float) $_w, (float) $_h, $values, $opt, $jsp, ); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } public function ComboBox($_name, $_w, $_h, $_values, $_prop = [], $_opt = [], $_x = null, $_y = null, $_js = false) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $values = $this->formFieldValues($_values); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if ((bool) $_js) { $this->engine()->addJSComboBox((string) $_name, $posx, $posy, (float) $_w, (float) $_h, $values, $jsp); return; } $oid = $this->engine()->addFFComboBox( (string) $_name, $posx, $posy, (float) $_w, (float) $_h, $values, $opt, $jsp, ); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } public function CheckBox( $_name, $_w, $_checked = false, $_prop = [], $_opt = [], $_onvalue = 'Yes', $_x = null, $_y = null, $_js = false, ) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if ((bool) $_js) { $this->engine()->addJSCheckBox((string) $_name, $posx, $posy, (float) $_w, $jsp); return; } $oid = $this->engine()->addFFCheckBox( (string) $_name, $posx, $posy, (float) $_w, (string) $_onvalue, (bool) $_checked, $opt, $jsp, ); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } public function Button( $_name, $_w, $_h, $_caption, $_action, $_prop = [], $_opt = [], $_x = null, $_y = null, $_js = false, ) { if ($this->docstate !== 2) { return; } [$opt, $jsp] = $this->formFieldOptions($_prop, $_opt); $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; if (is_array($_action)) { /** @var array $action */ $action = []; foreach ($_action as $key => $val) { $action[(string) $key] = $val; } } else { $action = (string) $_action; } if ((bool) $_js) { $this->engine()->addJSButton( (string) $_name, $posx, $posy, (float) $_w, (float) $_h, (string) $_caption, is_string($action) ? $action : '', $jsp, ); return; } $ffopt = $opt; $oid = $this->engine()->addFFButton( (string) $_name, $posx, $posy, (float) $_w, (float) $_h, (string) $_caption, $action, $ffopt, $jsp, ); $this->attachAnnotRef($oid); $this->advanceFormFieldX((float) $_w); } // =================================================================== // Signatures and rights (Stage 4). // =================================================================== public function setUserRights( $_enable = true, $_document = '/FullSave', $_annots = '/Create/Delete/Modify/Copy/Import/Export', $_form = '/Add/Delete/FillIn/Import/Export/SubmitStandalone/SpawnTemplate', $_signature = '/Modify', $_ef = '/Create/Delete/Modify/Import', $_formex = '', ) { $this->engine()->setUserRights([ 'enabled' => (bool) $_enable, 'document' => (string) $_document, 'annots' => (string) $_annots, 'form' => (string) $_form, 'signature' => (string) $_signature, 'ef' => (string) $_ef, 'formex' => (string) $_formex, ]); } public function setSignature( $_signing_cert = '', $_private_key = '', #[\SensitiveParameter] $_private_key_password = '', $_extracerts = '', $_cert_type = 2, $_info = [], $_approval = '', ) { $info = is_array($_info) ? $_info : []; $extracerts = (string) $_extracerts; /** @var array{appearance: array{ap: array>, as: string, empty: array, name: string, page: int, rect: string, xobj: string}, approval: string, cert_type: int, extracerts: ?string, info: array{ContactInfo: string, Location: string, Name: string, Reason: string}, password: string, privkey: string, signcert: string} $data */ $data = [ 'appearance' => [ 'ap' => [], 'as' => '', 'empty' => [], 'name' => '', 'page' => 0, 'rect' => '', 'xobj' => '', ], 'approval' => (string) $_approval, 'cert_type' => (int) $_cert_type, 'extracerts' => $extracerts === '' ? null : $extracerts, 'info' => [ 'ContactInfo' => (string) ($info['ContactInfo'] ?? ''), 'Location' => (string) ($info['Location'] ?? ''), 'Name' => (string) ($info['Name'] ?? ''), 'Reason' => (string) ($info['Reason'] ?? ''), ], 'password' => (string) $_private_key_password, 'privkey' => (string) ($_private_key === '' ? $_signing_cert : $_private_key), 'signcert' => (string) $_signing_cert, ]; $this->engine()->setSignature($data); } public function setSignatureAppearance($_x = 0, $_y = 0, $_w = 0, $_h = 0, $_page = -1, $_name = '') { $this->engine()->setSignatureAppearance( (float) $_x, (float) $_y, (float) $_w, (float) $_h, (int) $_page > 0 ? (int) $_page - 1 : -1, (string) $_name, ); } public function addEmptySignatureAppearance($_x = 0, $_y = 0, $_w = 0, $_h = 0, $_page = -1, $_name = '') { $this->engine()->addEmptySignatureAppearance( (float) $_x, (float) $_y, (float) $_w, (float) $_h, (int) $_page > 0 ? (int) $_page - 1 : -1, (string) $_name, ); } public function setTimeStamp( $_tsa_host = '', $_tsa_username = '', #[\SensitiveParameter] $_tsa_password = '', $_tsa_cert = '', ) { $this->engine()->setSignTimeStamp([ 'enabled' => (string) $_tsa_host !== '', 'host' => (string) $_tsa_host, 'username' => (string) $_tsa_username, 'password' => (string) $_tsa_password, 'cert' => (string) $_tsa_cert, 'hash_algorithm' => 'sha256', 'policy_oid' => '', 'nonce_enabled' => true, 'timeout' => 30, 'verify_peer' => true, ]); } // =================================================================== // Page groups and numbering. // =================================================================== public function startPageGroup($_page = null) { // The group takes effect from the next added page. $this->pagegroupsused = true; $this->nextpagegroup++; } public function setStartingPageNumber($_num = 1) { $this->startingpagenumber = max(1, (int) $_num); } /** * @return string Right-shift compensation prefix (unused by the facade). */ public function getAliasRightShift() { return ''; } /** * @return string Total number of pages, or the legacy alias placeholder. */ public function getAliasNbPages() { if ($this->decortotalpages > 0) { return (string) $this->decortotalpages; } return '{nb}'; } /** * @return string Current page number, or the legacy alias placeholder. */ public function getAliasNumPage() { if ($this->decortotalpages > 0) { return (string) $this->PageNo(); } return '{pnb}'; } /** * @return string Total pages in the current group, or the legacy alias placeholder. */ public function getPageGroupAlias() { if ($this->decortotalpages > 0) { $eng = $this->engine(); $group = (int) $eng->page->getPage()['group']; $total = 0; foreach ($eng->page->getPages() as $page) { if ((int) $page['group'] !== $group) { continue; } ++$total; } return (string) $total; } return '{gnb}'; } /** * @return string Page number in the current group, or the legacy alias placeholder. */ public function getPageNumGroupAlias() { if ($this->decortotalpages > 0) { return (string) $this->getGroupPageNo(); } return '{gpnb}'; } /** * @return int Current page number within its page group. */ public function getGroupPageNo() { // The engine computes the group-relative page 'num' lazily at output // time, so while the footer is being rendered it is still unset and // the previous fallback leaked the absolute page index (e.g. "6 / 4" // on the 4th page of the second group). Count this page's position // within its own group directly instead: groups are contiguous // (startPageGroup only ever advances the group id), so the position is // the number of same-group pages up to and including the current one. $eng = $this->engine(); /** @var array $current */ $current = $eng->page->getPage(); $group = (int) ($current['group'] ?? 0); $currentpid = (int) $eng->page->getPageId(); $num = 0; foreach ($eng->page->getPages() as $idx => $page) { if ((int) $idx > $currentpid || (int) $page['group'] !== $group) { continue; } ++$num; } return $num > 0 ? $num : $currentpid + 1; } public function getGroupPageNoFormatted() { return number_format((float) $this->getGroupPageNo(), 0, '.', ','); } public function PageNoFormatted() { return number_format((float) ($this->PageNo() + $this->startingpagenumber - 1), 0, '.', ','); } // =================================================================== // Layers, visibility, transparency (Stage 4). // =================================================================== public function startLayer($_name = '', $_print = true, $_view = true, $_lock = true) { $this->openlayers++; $this->emitToPage($this->engine()->newLayer((string) $_name, [], (bool) $_print, (bool) $_view, (bool) $_lock)); } public function endLayer() { if ($this->openlayers > 0) { $this->openlayers--; $this->emitToPage($this->engine()->closeLayer()); } } public function setVisibility($_v) { $this->endLayer(); switch (strtolower((string) $_v)) { case 'print': $this->startLayer('Print', true, false, true); break; case 'view': case 'screen': $this->startLayer('View', false, true, true); break; case 'all': break; default: $this->Error('Incorrect visibility: ' . (string) $_v); } } public function setOverprint($_stroking = true, $_nonstroking = null, $_mode = 0) { $this->overprint = [ 'OP' => (bool) $_stroking, 'op' => $_nonstroking === null ? (bool) $_stroking : (bool) $_nonstroking, 'OPM' => (int) $_mode, ]; $this->emitToPage($this->engine()->graph->getOverprint( (bool) $_stroking, $_nonstroking === null ? null : (bool) $_nonstroking, (int) $_mode, )); } public function getOverprint() { return $this->overprint; } public function setAlpha($_stroking = 1, $_bm = 'Normal', $_nonstroking = null, $_ais = false) { $stroking = (float) $_stroking; $nonstroking = $_nonstroking === null ? $stroking : (float) $_nonstroking; $this->alpha = [ 'CA' => $stroking, 'ca' => $nonstroking, 'BM' => '/' . (string) $_bm, 'AIS' => (bool) $_ais, ]; $cmd = $this->engine()->graph->getAlpha($stroking, (string) $_bm, $nonstroking, (bool) $_ais); $this->registerTemplateExtGState($cmd); $this->emitToPage($cmd); } /** * Register the ExtGState referenced by the given command with the open * XObject template resource dictionary. */ protected function registerTemplateExtGState(string $cmd): void { $match = []; if ($this->xobjtid !== '' && preg_match('|/GS([0-9]+) gs|', $cmd, $match) === 1) { $this->engine()->addXObjectExtGStateID($this->xobjtid, (int) ($match[1] ?? 0)); } } public function getAlpha() { return $this->alpha; } public function setJPEGQuality($_quality) { $quality = (int) $_quality; $this->jpegquality = $quality < 1 || $quality > 100 ? 75 : $quality; } public function setDefaultTableColumns($_cols = 4) { // Table layout is computed by the engine HTML renderer. } public function setCellHeightRatio($_h) { $this->cellheightratio = (float) $_h; } /** * @return float Cell height ratio. */ public function getCellHeightRatio() { return $this->cellheightratio; } public function setPDFVersion($_version = '1.7') { $this->engine()->setPDFVersion((string) $_version); } public function setViewerPreferences($_preferences) { /** @var array{CenterWindow?: bool, Direction?: string, DisplayDocTitle?: bool, Duplex?: string, FitWindow?: bool, HideMenubar?: bool, HideToolbar?: bool, HideWindowUI?: bool, NonFullScreenPageMode?: string, NumCopies?: int, PickTrayByPDFSize?: bool, PrintArea?: string, PrintClip?: string, PrintPageRange?: array, PrintScaling?: string, ViewArea?: string, ViewClip?: string} $prefs */ $prefs = is_array($_preferences) ? $_preferences : []; $this->engine()->setViewerPreferences($prefs); } // =================================================================== // Prepress marks and gradients (Stage 4). // =================================================================== public function colorRegistrationBar( $_x, $_y, $_w, $_h, $_transition = true, $_vertical = false, $_colors = 'A,R,G,B,C,M,Y,K', ) { $white = 'rgb(255,255,255)'; $barmap = [ 'A' => ['g(0%)', 'g(100%)'], 'W' => ['g(100%)', 'g(0%)'], 'R' => ['rgb(100%,0%,0%)', $white], 'G' => ['rgb(0%,100%,0%)', $white], 'B' => ['rgb(0%,0%,100%)', $white], 'C' => ['cmyk(100%,0%,0%,0%)', 'cmyk(0%,0%,0%,0%)'], 'M' => ['cmyk(0%,100%,0%,0%)', 'cmyk(0%,0%,0%,0%)'], 'Y' => ['cmyk(0%,0%,100%,0%)', 'cmyk(0%,0%,0%,0%)'], 'K' => ['cmyk(0%,0%,0%,100%)', 'cmyk(0%,0%,0%,0%)'], ]; $bars = []; foreach (explode(',', strtoupper((string) $_colors)) as $letter) { $letter = trim($letter); if (isset($barmap[$letter])) { $pair = $barmap[$letter]; if (!(bool) $_transition) { $pair[1] = $pair[0]; } $bars[] = $pair; } } if ($bars === []) { return; } $this->emitToPage($this->engine()->graph->getColorRegistrationBar( (float) $_x, (float) $_y, (float) $_w, (float) $_h, (bool) $_vertical, $bars, )); } public function cropMark($_x, $_y, $_w, $_h, $_type = 'T,R,B,L', $_color = [100, 100, 100, 100, 'All']) { $style = $this->currentLineStyle(); $style['lineColor'] = $this->colorSpecFromLegacy($_color); $this->emitToPage($this->engine()->graph->getCropMark( (float) $_x, (float) $_y, (float) $_w, (float) $_h, str_replace(',', '', strtoupper((string) $_type)), $style, )); } public function registrationMark( $_x, $_y, $_r, $_double = false, $_cola = [100, 100, 100, 100, 'All'], $_colb = [0, 0, 0, 0, 'None'], ) { $this->emitToPage($this->engine()->graph->getRegistrationMark( (float) $_x, (float) $_y, (float) $_r, (bool) $_double, $this->colorSpecFromLegacy($_cola), )); } public function registrationMarkCMYK($_x, $_y, $_r) { $this->emitToPage($this->engine()->graph->getCmykRegistrationMark((float) $_x, (float) $_y, (float) $_r)); } public function LinearGradient($_x, $_y, $_w, $_h, $_col1 = [], $_col2 = [], $_coords = [0, 0, 1, 0]) { $coords = array_map( static fn(mixed $val): float => (float) $val, array_values(is_array($_coords) ? $_coords : [0, 0, 1, 0]), ); $this->emitToPage($this->engine()->graph->getLinearGradient( (float) $_x, (float) $_y, (float) $_w, (float) $_h, $this->colorSpecFromLegacy($_col1), $this->colorSpecFromLegacy($_col2), $coords, )); } public function RadialGradient($_x, $_y, $_w, $_h, $_col1 = [], $_col2 = [], $_coords = [0.5, 0.5, 0.5, 0.5, 1]) { $coords = array_map( static fn(mixed $val): float => (float) $val, array_values(is_array($_coords) ? $_coords : [0.5, 0.5, 0.5, 0.5, 1]), ); $this->emitToPage($this->engine()->graph->getRadialGradient( (float) $_x, (float) $_y, (float) $_w, (float) $_h, $this->colorSpecFromLegacy($_col1), $this->colorSpecFromLegacy($_col2), $coords, )); } public function CoonsPatchMesh( $_x, $_y, $_w, $_h, $_col1 = [], $_col2 = [], $_col3 = [], $_col4 = [], $_coords = [ 0.00, 0.0, 0.33, 0.00, 0.67, 0.00, 1.00, 0.00, 1.00, 0.33, 1.00, 0.67, 1.00, 1.00, 0.67, 1.00, 0.33, 1.00, 0.00, 1.00, 0.00, 0.67, 0.00, 0.33, ], $_coords_min = 0, $_coords_max = 1, $_antialias = false, ) { // Multi-patch form: $coords is an array of patches, each carrying an // 'f' edge flag (legacy detects this with isset($coords[0]['f'])). if (is_array($_coords) && isset($_coords[0]) && is_array($_coords[0]) && isset($_coords[0]['f'])) { $this->emitToPage($this->engine()->graph->getCoonsPatchMesh( (float) $_x, (float) $_y, (float) $_w, (float) $_h, $this->coonsPatchArray($_coords), (float) $_coords_min, (float) $_coords_max, (bool) $_antialias, )); return; } $coords = array_map( static fn(mixed $val): float => (float) $val, array_values(is_array($_coords) ? $_coords : []), ); // The engine corner names use PDF y-up coordinates, which matches // the legacy corner order directly (verified against the reference // renderer output). $this->emitToPage($this->engine()->graph->getCoonsPatchMeshWithCoords( (float) $_x, (float) $_y, (float) $_w, (float) $_h, $this->colorSpecFromLegacy($_col1), $this->colorSpecFromLegacy($_col2), $this->colorSpecFromLegacy($_col3), $this->colorSpecFromLegacy($_col4), $coords, (float) $_coords_min, (float) $_coords_max, (bool) $_antialias, )); } /** * Convert a legacy Coons patch-mesh array (per-patch 'f', 'points', and * 'colors' with 0-255 r/g/b keys) to the engine shape (normalized * red/green/blue floats). * * @param array $patches * * @return list, colors: list}> */ protected function coonsPatchArray(array $patches): array { $out = []; foreach ($patches as $patch) { if (!is_array($patch)) { continue; } $points = isset($patch['points']) && is_array($patch['points']) ? array_map(static fn(mixed $v): float => (float) $v, array_values($patch['points'])) : []; $colors = []; if (isset($patch['colors']) && is_array($patch['colors'])) { foreach ($patch['colors'] as $color) { if (!is_array($color)) { continue; } $colors[] = [ 'red' => (isset($color['r']) ? (float) $color['r'] : 0.0) / 255, 'green' => (isset($color['g']) ? (float) $color['g'] : 0.0) / 255, 'blue' => (isset($color['b']) ? (float) $color['b'] : 0.0) / 255, ]; } } $out[] = [ 'f' => isset($patch['f']) ? (int) $patch['f'] : 0, 'points' => $points, 'colors' => $colors, ]; } return $out; } public function Gradient($_type, $_coords, $_stops, $_background = [], $_antialias = false) { $coords = array_map( static fn(mixed $val): float => (float) $val, array_values(is_array($_coords) ? $_coords : []), ); $stops = []; foreach (is_array($_stops) ? $_stops : [] as $stop) { if (!is_array($stop)) { continue; } $stops[] = [ 'color' => $this->colorSpecFromLegacy($stop['color'] ?? [0, 0, 0]), 'offset' => (float) ($stop['offset'] ?? 0), 'exponent' => (float) ($stop['exponent'] ?? 1), 'opacity' => (float) ($stop['opacity'] ?? 1), ]; } $background = is_array($_background) && $_background !== [] ? $this->colorSpecFromLegacy($_background) : ''; $this->emitToPage($this->engine()->graph->getGradient( (int) $_type, $coords, $stops, $background, (bool) $_antialias, )); } public function PieSector($_xc, $_yc, $_r, $_a, $_b, $_style = 'FD', $_cw = true, $_o = 90) { $angs = (float) $_a; $angf = (float) $_b; if ((bool) $_cw) { $tmp = $angf; $angf = (float) $_o - $angs; $angs = (float) $_o - $tmp; } else { $angs += (float) $_o; $angf += (float) $_o; } $this->emitToPage($this->engine()->graph->getPieSector( (float) $_xc, (float) $_yc, (float) $_r, $angs, $angf, $this->shapeModeFromLegacy($_style, true), $this->shapeStyleFromLegacy([], []), )); } public function PieSectorXY($_xc, $_yc, $_rx, $_ry, $_a, $_b, $_style = 'FD', $_cw = false, $_o = 0, $_nc = 2) { // The engine pie sector is circular: elliptical sectors are // approximated by scaling a circular sector of radius rx. $rx = (float) $_rx; $ry = (float) $_ry; if ($rx <= 0) { return; } $eng = $this->engine(); $out = $eng->graph->getStartTransform(); if ($ry > 0 && $ry !== $rx) { $out .= $eng->graph->getScaling(1.0, $ry / $rx, (float) $_xc, (float) $_yc); } $angs = (float) $_a; $angf = (float) $_b; if ((bool) $_cw) { $tmp = $angf; $angf = (float) $_o - $angs; $angs = (float) $_o - $tmp; } else { $angs += (float) $_o; $angf += (float) $_o; } $out .= $eng->graph->getPieSector( (float) $_xc, (float) $_yc, $rx, $angs, $angf, $this->shapeModeFromLegacy($_style, true), $this->shapeStyleFromLegacy([], []), ); $out .= $eng->graph->getStopTransform(); $this->emitToPage($out); } /** * [BREAKING CHANGE] EPS/AI vector import is not supported. * * The modern engine has no PostScript interpreter: convert EPS/AI * artwork to SVG (e.g. `inkscape file.eps --export-filename=file.svg` * or `mutool convert`) and use ImageSVG() instead. As a convenience, * SVG files passed to this method are dispatched to ImageSVG() and * raster files to Image(); EPS/AI input is silently skipped. */ public function ImageEps( $_file, $_x = null, $_y = null, $_w = 0, $_h = 0, $_link = '', $_useBoundingBox = true, $_align = '', $_palign = '', $_border = 0, $_fitonpage = false, $_fixoutvals = false, ) { $file = strtolower((string) $_file); if (str_ends_with($file, '.svg')) { $this->ImageSVG($_file, $_x, $_y, $_w, $_h, $_link, $_align, $_palign, $_border, $_fitonpage); return; } if (preg_match('/\.(png|jpe?g|gif|webp)$/', $file) === 1) { $this->Image( $_file, $_x, $_y, $_w, $_h, '', $_link, $_align, false, 300, $_palign, false, false, $_border, false, false, $_fitonpage, ); } } // =================================================================== // Barcodes (Stage 4). // =================================================================== public function setBarcode($_bc = '') { $this->docbarcode = (string) $_bc; } /** * @return string Document barcode string. */ public function getBarcode() { return $this->docbarcode; } public function write1DBarcode( $_code, $_type, $_x = null, $_y = null, $_w = null, $_h = null, $_xres = null, $_style = [], $_align = '', ) { $xres = $_xres === null || (float) $_xres <= 0 ? 0.4 : (float) $_xres; $this->emitBarcode( (string) $_code, (string) $_type, $_x, $_y, $_w, $_h, $xres, $_style, (string) $_align, false, ); } public function write2DBarcode( $_code, $_type, $_x = null, $_y = null, $_w = null, $_h = null, $_style = [], $_align = '', $_distort = false, ) { $this->emitBarcode( (string) $_code, (string) $_type, $_x, $_y, $_w, $_h, 0.25, $_style, (string) $_align, true, (bool) $_distort, ); } /** * Render a barcode at the given position via the engine. * * @param float $xres Width (and height for 2D codes) of a single module * in user units, used when no explicit size is given. */ protected function emitBarcode( string $code, string $type, mixed $posx, mixed $posy, mixed $width, mixed $height, float $xres, mixed $style, string $align, bool $is2d, bool $distort = false, ): void { if ($this->docstate !== 2 || $code === '') { return; } $eng = $this->engine(); $style = is_array($style) ? $style : []; $type = strtoupper(trim($type)); if ($type === 'RAW' || $type === 'RAW2') { // Legacy raw modes: RAW = comma-separated rows, RAW2 = [row][row]. if ($type === 'RAW2') { $code = str_replace(['] [', '][', '[', ']'], [',', ',', '', ''], $code); } $type = $is2d ? 'SRAW' : 'LRAW'; } $posx = $posx === null || (string) $posx === '' ? $this->posx : (float) $posx; $posy = $posy === null || (string) $posy === '' ? $this->posy : (float) $posy; // Natural module grid, used to derive missing dimensions. try { $info = $eng->barcode->getBarcodeObj($type, $code)->getArray(); } catch (\Com\Tecnick\Barcode\Exception) { // Unsupported legacy barcode type: skipped. return; } $maxw = max(1, (int) $info['ncols']); $maxh = max(1, (int) $info['nrows']); $fgspec = isset($style['fgcolor']) && $style['fgcolor'] !== false ? $this->colorSpecFromLegacy($style['fgcolor']) : 'black'; if ($is2d) { $this->drawBarcode2D( $type, $code, $posx, $posy, $width, $height, $style, $align, $maxw, $maxh, $fgspec, $distort, ); return; } $this->drawBarcode1D($type, $code, $posx, $posy, $width, $height, $xres, $style, $align, $maxw, $fgspec); } /** * Render a 1D barcode with the legacy geometry: bars, the optional * human-readable label and the vertical padding all fit *inside* the * requested height (barh = height - text_height - 2*vpadding) instead of * the label being appended below it. Bar drawing is delegated to the * engine; the box, label and cursor advance mirror write1DBarcode(). * * @param array $style */ protected function drawBarcode1D( string $type, string $code, float $posx, float $posy, mixed $width, mixed $height, float $xres, array $style, string $align, int $maxw, string $fgspec, ): void { $eng = $this->engine(); $wparam = $width === null || (string) $width === '' ? null : (float) $width; $givenwidth = $wparam !== null && $wparam > 0; // Resolve the position/stretch/fitwidth defaults exactly like legacy. $position = isset($style['position']) && is_string($style['position']) ? strtoupper($style['position']) : ''; if ($position === 'S') { $position = ''; $style['stretch'] = true; } $fitwidth = isset($style['fitwidth']) ? (bool) $style['fitwidth'] : !isset($style['stretch']); if ($fitwidth) { $style['stretch'] = false; } if (!isset($style['stretch'])) { $style['stretch'] = $givenwidth; } $stretch = (bool) $style['stretch']; // The human-readable label is rendered with its own font: legacy only // switches font when both the text flag and a font name are present. $hastext = isset($style['text']) && (bool) $style['text']; $fontsizept = 0.0; $savefamily = $this->fontfamily; $savestyle = $this->fontstyle; $savesize = $this->fontsizept; $labelfont = ''; if ($hastext && isset($style['font']) && is_string($style['font']) && $style['font'] !== '') { $labelfont = strtolower($style['font']); $fontsizept = isset($style['fontsize']) && is_numeric($style['fontsize']) ? (float) $style['fontsize'] : 0.0; $this->setFont($labelfont, '', $fontsizept, '', 'default', false); } $stretchtext = isset($style['stretchtext']) ? (int) $style['stretchtext'] : 4; // Box width. if ($givenwidth) { $boxw = $wparam; } else { $boxw = $this->rtlmode ? max(0.0, $posx - $this->lmargin) : $this->getPageWidth() - $this->rmargin - $posx; } // Horizontal/vertical padding ('auto' = 10*(w/(maxw+20))). if (!isset($style['padding'])) { $padding = 0.0; } elseif ($style['padding'] === 'auto') { $padding = 10 * ($boxw / ($maxw + 20)); } else { $padding = (float) $style['padding']; } if (!isset($style['hpadding'])) { $hpadding = $padding; } elseif ($style['hpadding'] === 'auto') { $hpadding = 10 * ($boxw / ($maxw + 20)); } else { $hpadding = (float) $style['hpadding']; } if (!isset($style['vpadding'])) { $vpadding = $padding; } elseif ($style['vpadding'] === 'auto') { $vpadding = $hpadding / 2; } else { $vpadding = (float) $style['vpadding']; } // Single-bar width. $maxxres = ($boxw - (2 * $hpadding)) / $maxw; if ($stretch) { $xres = $maxxres; } else { if ($xres > $maxxres) { $xres = $maxxres; } if ( isset($style['padding']) && $style['padding'] === 'auto' || isset($style['hpadding']) && $style['hpadding'] === 'auto' ) { $hpadding = 10 * $xres; if (isset($style['vpadding']) && $style['vpadding'] === 'auto') { $vpadding = $hpadding / 2; } } } $barw = $maxw * $xres; if ($fitwidth) { $wold = $boxw; $boxw = $barw + (2 * $hpadding); $cellfit = isset($style['cellfitalign']) && is_string($style['cellfitalign']) ? $style['cellfitalign'] : ''; if ($cellfit === 'L' && $this->rtlmode) { $posx -= $wold - $boxw; } elseif ($cellfit === 'R' && !$this->rtlmode) { $posx += $wold - $boxw; } elseif ($cellfit === 'C') { $posx += $this->rtlmode ? -(($wold - $boxw) / 2) : ($wold - $boxw) / 2; } } // The label height and the bars share the requested box height. $textheight = $this->getCellHeight($fontsizept / $this->kratio); if ($height === null || (string) $height === '' || (float) $height <= 0) { $boxh = ($barw / 3) + (2 * $vpadding) + $textheight; } else { $boxh = (float) $height; } $barh = $boxh - $textheight - (2 * $vpadding); if ($barh <= 0) { if ($textheight > $boxh) { $fontsizept = ($boxh * $this->kratio) / (4 * $this->cellheightratio); $this->setFont( $labelfont !== '' ? $labelfont : $this->fontfamily, '', $fontsizept, '', 'default', false, ); $textheight = $this->getCellHeight($fontsizept / $this->kratio); } if ($vpadding > 0) { $vpadding = ($boxh - $textheight) / 4; } $barh = $boxh - $textheight - (2 * $vpadding); } // Move the whole barcode to the next page when it would not fit // (legacy does this through fitBlock()). $drawy = $this->fitBarcodeBlock($posx, $posy, $boxh); // Box position then in-box bar alignment. $xposrect = $this->barcodeBoxX($position, $posx, $boxw); $alignin = isset($style['align']) && is_string($style['align']) ? strtoupper($style['align']) : 'C'; if ($alignin === 'L') { $barx = $xposrect + $hpadding; } elseif ($alignin === 'R') { $barx = $xposrect + ($boxw - $barw) - $hpadding; } else { $barx = $xposrect + (($boxw - $barw) / 2); } $out = $this->barcodeBox($xposrect, $drawy, $boxw, $boxh, $style); try { $out .= $eng->getBarcode( $type, $code, $barx, $drawy + $vpadding, (int) round($barw), (int) round($barh), [0, 0, 0, 0], ['lineColor' => $fgspec, 'fillColor' => $fgspec, 'lineWidth' => 0.0], ); } catch (\Com\Tecnick\Barcode\Exception) { $this->setFont($savefamily, $savestyle, $savesize, '', 'default', false); return; } $this->emitToPage($out); if ($hastext) { $label = isset($style['label']) && is_string($style['label']) && $style['label'] !== '' ? $style['label'] : $code; if ((float) $this->GetStringWidth($label) > $barw) { // Force horizontal scaling so an over-long label still fits. $stretchtext = 2; } $savepadding = $this->cellpadding; $this->posx = $barx; $this->posy = $drawy + $vpadding + $barh; $this->setCellPadding(0); $this->Cell($barw, 0, $label, 0, 0, 'C', false, '', $stretchtext, false, 'T', 'T'); $this->cellpadding = $savepadding; } $this->setFont($savefamily, $savestyle, $savesize, '', 'default', false); $this->advanceAfterBarcode($align, $position, $posx, $drawy, $boxw, $boxh); } /** * Render a 2D barcode with the legacy geometry: the barcode cells are * inset inside the requested box by the (auto) padding quiet-zone and the * aspect ratio is preserved unless $distort is set. Mirrors write2DBarcode(). * * @param array $style */ protected function drawBarcode2D( string $type, string $code, float $posx, float $posy, mixed $width, mixed $height, array $style, string $align, int $cols, int $rows, string $fgspec, bool $distort, ): void { $eng = $this->engine(); // Padding in barcode modules ('auto' = 4 modules). if (!isset($style['padding'])) { $padding = 0.0; } elseif ($style['padding'] === 'auto') { $padding = 4.0; } else { $padding = (float) $style['padding']; } if (!isset($style['hpadding'])) { $hpadding = $padding; } elseif ($style['hpadding'] === 'auto') { $hpadding = 4.0; } else { $hpadding = (float) $style['hpadding']; } if (!isset($style['vpadding'])) { $vpadding = $padding; } elseif ($style['vpadding'] === 'auto') { $vpadding = 4.0; } else { $vpadding = (float) $style['vpadding']; } $hpad = 2 * $hpadding; $vpad = 2 * $vpadding; $mw = isset($style['module_width']) && (float) $style['module_width'] > 0 ? (float) $style['module_width'] : 1.0; $mh = isset($style['module_height']) && (float) $style['module_height'] > 0 ? (float) $style['module_height'] : 1.0; $position = isset($style['position']) && is_string($style['position']) ? strtoupper($style['position']) : ''; $maxw = $this->rtlmode ? max(0.0, $posx - $this->lmargin) : $this->getPageWidth() - $this->rmargin - $posx; $maxh = $this->getPageHeight() - $this->tmargin - $this->bmargin; $ratioHW = (($rows * $mh) + $hpad) / (($cols * $mw) + $vpad); $ratioWH = (($cols * $mw) + $vpad) / (($rows * $mh) + $hpad); if (!$distort) { if (($maxw * $ratioHW) > $maxh) { $maxw = $maxh * $ratioWH; } if (($maxh * $ratioWH) > $maxw) { $maxh = $maxw * $ratioHW; } } $boxw = $width === null || (string) $width === '' ? 0.0 : (float) $width; $boxh = $height === null || (string) $height === '' ? 0.0 : (float) $height; if ($boxw > $maxw) { $boxw = $maxw; } if ($boxh > $maxh) { $boxh = $maxh; } if ($boxw <= 0 && $boxh <= 0) { $boxw = ($cols + $hpad) * ($mw / $this->kratio); $boxh = ($rows + $vpad) * ($mh / $this->kratio); } elseif ($boxw <= 0) { $boxw = $boxh * $ratioWH; } elseif ($boxh <= 0) { $boxh = $boxw * $ratioHW; } // Barcode size excluding padding and single-cell dimensions. $bw = ($boxw * $cols) / ($cols + $hpad); $bh = ($boxh * $rows) / ($rows + $vpad); $cw = $bw / $cols; $ch = $bh / $rows; if (!$distort) { if (($cw / $ch) > ($mw / $mh)) { $cw = ($ch * $mw) / $mh; $bw = $cw * $cols; $hpadding = ($boxw - $bw) / (2 * $cw); } else { $ch = ($cw * $mh) / $mw; $bh = $ch * $rows; $vpadding = ($boxh - $bh) / (2 * $ch); } } $drawy = $this->fitBarcodeBlock($posx, $posy, $boxh); $xpos = $this->barcodeBoxX($position, $posx, $boxw); $xstart = $xpos + ($hpadding * $cw); $ystart = $drawy + ($vpadding * $ch); $out = $this->barcodeBox($xpos, $drawy, $boxw, $boxh, $style); try { $out .= $eng->getBarcode( $type, $code, $xstart, $ystart, (int) round($bw), (int) round($bh), [0, 0, 0, 0], ['lineColor' => $fgspec, 'fillColor' => $fgspec, 'lineWidth' => 0.0], ); } catch (\Com\Tecnick\Barcode\Exception) { return; } $this->emitToPage($out); $this->advanceAfterBarcode($align, $position, $posx, $drawy, $boxw, $boxh); } /** * Move to the next page when a barcode of the given height would not fit * below the current Y, preserving the abscissa. Returns the Y to draw at. */ protected function fitBarcodeBlock(float $posx, float $posy, float $boxh): float { if ( $this->inheaderfooter || $this->xobjtid !== '' || !$this->autopagebreak || $this->docstate !== 2 || !$this->AcceptPageBreak() ) { return $posy; } if (($posy + $boxh - ($this->getPageHeight() - $this->bmargin)) > 0.0001) { $this->AddPage($this->curorientation); $this->posx = $posx; return $this->posy; } return $posy; } /** * Left edge of a barcode box for the legacy position keyword (L/C/R or * the current abscissa for the empty/default case). */ protected function barcodeBoxX(string $position, float $posx, float $boxw): float { if ($position === 'L') { return $this->lmargin; } if ($position === 'C') { return ($this->getPageWidth() + $this->lmargin - $this->rmargin - $boxw) / 2; } if ($position === 'R') { return $this->getPageWidth() - $this->rmargin - $boxw; } return $this->rtlmode ? $posx - $boxw : $posx; } /** * Background fill and/or border around a barcode box. * * @param array $style */ protected function barcodeBox(float $x, float $y, float $w, float $h, array $style): string { $border = isset($style['border']) && (bool) $style['border']; $out = ''; if (isset($style['bgcolor']) && is_array($style['bgcolor'])) { $out .= $this->engine()->graph->getBasicRect($x, $y, $w, $h, 'f', [ 'lineWidth' => 0.0, 'fillColor' => $this->colorSpecFromLegacy($style['bgcolor']), ]); } if ($border) { $out .= $this->engine()->graph->getBasicRect($x, $y, $w, $h, 'S', $this->currentLineStyle()); } return $out; } /** * Advance the cursor next to a drawn barcode for the legacy $align value. */ protected function advanceAfterBarcode( string $align, string $position, float $posx, float $drawy, float $boxw, float $boxh, ): void { $xpos = $this->barcodeBoxX($position, $posx, $boxw); $imgrbx = $this->rtlmode ? $xpos : $xpos + $boxw; $imgrby = $drawy + $boxh; switch (strtoupper($align)) { case 'T': $this->posy = $drawy; $this->posx = $imgrbx; break; case 'M': $this->posy = $drawy + round($boxh / 2); $this->posx = $imgrbx; break; case 'B': $this->posy = $imgrby; $this->posx = $imgrbx; break; case 'N': $this->setY($imgrby); break; default: break; } } // =================================================================== // State accessors. // =================================================================== public function getMargins() { return [ 'left' => $this->lmargin, 'top' => $this->tmargin, 'right' => $this->rmargin, 'bottom' => $this->bmargin, 'header' => $this->headermargin, 'footer' => $this->footermargin, 'cell' => $this->cellpadding, 'padding_left' => $this->cellpadding['L'], 'padding_top' => $this->cellpadding['T'], 'padding_right' => $this->cellpadding['R'], 'padding_bottom' => $this->cellpadding['B'], ]; } public function getOriginalMargins() { return [ 'left' => $this->orig_lmargin, 'right' => $this->orig_rmargin, ]; } /** * @return float Current font size in user units. */ public function getFontSize() { return $this->fontsizept / $this->kratio; } /** * @return float Current font size in points. */ public function getFontSizePt() { return $this->fontsizept; } /** * @return string Current font family. */ public function getFontFamily() { return $this->fontfamily; } /** * @return string Current font style letters including decorations. */ public function getFontStyle() { return $this->fontstyle . implode('', array_keys(array_filter($this->fontdecor))); } // =================================================================== // HTML rendering. // =================================================================== public function fixHTMLCode($_html, $_default_css = '', $_tagvs = null, $_tidy_options = null) { return $this->engine()->tidyHTML((string) $_html, (string) $_default_css); } public function getCSSPadding($_csspadding, $_width = 0) { return $this->cssBoxValues((string) $_csspadding, (float) $_width); } public function getCSSMargin($_cssmargin, $_width = 0) { return $this->cssBoxValues((string) $_cssmargin, (float) $_width); } public function getCSSBorderMargin($_cssbspace, $_width = 0) { return $this->cssBoxValues((string) $_cssbspace, (float) $_width); } /** * Parse a CSS shorthand box value (top right bottom left) into user units. * * @return array{T: float, R: float, B: float, L: float} */ protected function cssBoxValues(string $css, float $width): array { $parts = preg_split('/[\s]+/', trim($css)); /** @var list $values */ $values = []; foreach (is_array($parts) ? $parts : [] as $part) { if ($part === '') { continue; } $values[] = (float) $this->getHTMLUnitToUnits($part, $width, 'px', false); } $top = $values[0] ?? 0.0; $right = $values[1] ?? $top; $bottom = $values[2] ?? $top; $left = $values[3] ?? $right; return ['T' => $top, 'R' => $right, 'B' => $bottom, 'L' => $left]; } public function getHTMLFontUnits($_val, $_refsize = 12, $_parent_size = 12, $_defaultunit = 'pt') { $refsize = (float) $_refsize; $parent = (float) $_parent_size; $val = is_string($_val) ? trim(strtolower($_val)) : $_val; $named = [ 'xx-small' => $refsize - 4, 'x-small' => $refsize - 3, 'small' => $refsize - 2, 'medium' => $refsize, 'large' => $refsize + 2, 'x-large' => $refsize + 4, 'xx-large' => $refsize + 6, 'smaller' => $parent - 3, 'larger' => $parent + 3, ]; if (is_string($val) && isset($named[$val])) { return $named[$val]; } return (float) $this->getHTMLUnitToUnits($_val, $parent, (string) $_defaultunit, true); } public function serializeTCPDFtag($_method, $_params = []) { $data = ['m' => (string) $_method, 'p' => is_array($_params) ? $_params : []]; $encoded = json_encode($data); return 'tcpdf://' . ($encoded === false ? '' : urlencode($encoded)); } public function writeHTMLCell( $_w, $_h, $_x, $_y, $_html = '', $_border = 0, $_ln = 0, $_fill = false, $_reseth = true, $_align = '', $_autopadding = true, ) { if ($this->docstate !== 2) { $this->Error('Unable to write HTML: no page has been started'); return; } if ( defined('K_TCPDF_CALLS_IN_HTML') && constant('K_TCPDF_CALLS_IN_HTML') && preg_match('/writeHtmlWithTcpdfTags( (string) $_html, $_w, $_h, $_x, $_y, $_border, $_ln, $_fill, $_reseth, $_align, $_autopadding, ); return; } $eng = $this->engine(); $posx = $_x === null || (string) $_x === '' || (float) $_x === 0.0 ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' || (float) $_y === 0.0 ? $this->posy : (float) $_y; // Legacy no-write page regions: build the engine banded writable // regions for the current page so the HTML hugs the obstacles band by // band (then continues full width on the next page). This turns the // page multi-region, so the inColumnMode() width branch below applies. if ($this->nowriteareas !== []) { $this->applyNoWriteRegionsForFlow($this->noWriteBandFromHtml((string) $_html), $posy); } $width = (float) $_w; if ($width <= 0) { if ($this->inColumnMode()) { // The HTML fragment flows inside the current column region // (and through the following columns/pages inside the call). $region = $eng->page->getRegion(); $width = $this->rtlmode ? max(0.0, $posx - $region['RX']) : max(0.0, $region['RX'] + $region['RW'] - $posx); } elseif ($this->rtlmode) { $width = max(0.0, $posx - $this->lmargin); } else { $width = $this->getPageWidth() - $this->rmargin - $posx; } } // In RTL the cursor X marks the cell's right edge; the engine places // every cell from its left edge, so shift left by the cell width. $cellx = $this->rtlmode ? $posx - $width : $posx; $sidestyles = $this->stylesFromLegacyBorder($_border, (bool) $_fill); $styles = $this->completeSideStyles($sidestyles); $html = $this->normalizeHtmlMarkup((string) $_html); $alignmap = ['L' => 'left', 'C' => 'center', 'R' => 'right', 'J' => 'justify']; $align = strtoupper((string) $_align); // The legacy renderer spaces HTML lines by fontsize * cell height // ratio (the engine default is the font metric line height). $wrapstyle = 'line-height:' . $this->cellheightratio . ';'; if (isset($alignmap[$align])) { // The legacy align parameter sets the default block alignment. $wrapstyle .= 'text-align:' . $alignmap[$align] . ';'; } if ($this->textcolorspec !== 'black') { // The engine resolves HTML text color from the CSS cascade // (root default black): the legacy current text color must be // injected as the root color of the fragment. $wrapstyle .= 'color:' . $this->textcolorspec . ';'; } // The div is left unclosed on purpose: the parser auto-closes // it at the end of the fragment, and an explicit closing tag // would add a trailing block advance to the cell frame. $html = '
' . $html; $this->emitToPage($eng->color->getPdfFillColor($this->textcolorspec)); $startpid = $this->getPage(); $starty = $posy; $bboxstart = $this->cellBBoxCount(); $contentstart = $this->pageContentCount($startpid - 1); $cell = $this->htmlnopadding ? \Com\Tecnick\Pdf\Tcpdf::ZEROCELL : null; $eng->addHTMLCell($html, $cellx, $posy, $width, (float) $_h, $cell, $styles); if ($this->textshadow['enabled']) { // First page: only the chunks appended by this call; pages // created by the flow shadow their full content. $this->applyHtmlTextShadow($startpid - 1, $contentstart); for ($pid = $startpid; $pid < $this->getPage(); $pid++) { $this->applyHtmlTextShadow($pid, 0); } } // The bottom of the rendered content is the lowest cell box pushed // during this call (the last box may belong to a higher fragment, // e.g. a middle table column). When the content flowed to a new // page, only the boxes there matter, but the simple maximum works // because Y restarts from the top region edge on the new page. $bottom = $this->getPage() > $startpid ? $eng->getLastCellBBox()['y'] + $eng->getLastCellBBox()['h'] : $this->cellBBoxBottomSince($bboxstart, $posy); if ($this->getPage() === $startpid) { // Image-only fragments (e.g. images in table cells) push no // cell bounding boxes: account for their placement directly. $bottom = $this->pageContentImageBottom($startpid - 1, $contentstart, $bottom); } if ((float) $_h <= 0 && $this->getPage() > $startpid) { // The engine skips the cell box when auto-height content flows // to more pages: replicate the legacy per-page frame segments // (a no-op when neither border nor fill is requested). $this->drawHtmlCellSegments( $cellx, $width, $starty, $bottom, $startpid, $contentstart, $sidestyles, (bool) $_fill, ); } if (preg_match('/<\/(table|thead)>\s*$/i', $html) === 1) { // The legacy renderer leaves one line of space after a closing // table (the engine tracks only the table cells themselves). $bottom += $this->getCellHeight($this->getFontSize(), false); } $ln = (int) $_ln; if ($ln === 1) { $this->posy = $bottom; if ($this->inColumnMode()) { $region = $eng->page->getRegion(); $this->posx = $this->rtlmode ? $region['RX'] + $region['RW'] : $region['RX']; } else { $this->posx = $this->rtlmode ? $this->getPageWidth() - $this->rmargin : $this->lmargin; } } elseif ($ln === 2) { $this->posy = $bottom; } elseif ($ln === 0) { // Legacy semantics: return to the page and vertical position // where the cell started, with X advanced past the cell. if ($this->getPage() > $startpid) { $this->setPage($startpid); } $this->posy = $starty; $this->posx = $this->rtlmode ? $cellx : $posx + $width; } } /** * Make a relative local markup resource path absolute. */ protected function absolutizeMarkupPath(string $source): string { if ( $source === '' || str_contains($source, '://') || str_starts_with($source, '@') || str_starts_with($source, '/') ) { return $source; } $real = realpath($source); return $real === false ? $source : $real; } /** * Return a markup resource path that survives CSS lowercasing. * * CSS values are lowercased by the engine parser, so paths used in CSS * context are copied to a lowercase temporary file when necessary. */ protected function cssSafeMarkupPath(string $source): string { $source = $this->absolutizeMarkupPath($source); if ($source === strtolower($source) || !is_file($source)) { return $source; } $ext = strtolower(pathinfo($source, PATHINFO_EXTENSION)); $copy = rtrim(sys_get_temp_dir(), '/') . '/tcpdf_css_' . md5($source) . ($ext !== '' ? '.' . $ext : ''); if (!is_file($copy)) { copy($source, $copy); } return $copy; } /** * Flatten styled inline elements that contain nested inline children. * * The engine HTML renderer only paints inline backgrounds for fragments * whose element sits directly below the block element; nested children * (e.g. xy) lose the * background. The parent is therefore split into a sequence of sibling * elements of the same tag, each carrying the merged style and the * original attributes (links keep working, one annotation per piece). */ protected function inheritHtmlBackgrounds(string $html): string { $result = preg_replace_callback( '/<(a|span|font)(\s[^>]*?)?\s+style="([^"]*background-color[^"]*)"([^>]*)>(.*?)<\/\1>/is', static function (array $match): string { $tag = $match[1] ?? ''; $attrs = trim(($match[2] ?? '') . ($match[4] ?? '')); $parentstyle = rtrim($match[3] ?? '', '; '); $inner = $match[5] ?? ''; if (stripos($inner, '<') === false) { // No nested elements: the engine handles this directly. return $match[0] ?? ''; } // Tokenize into text runs and one-level inline children. $tokens = preg_split( '/(<(?:span|font|b|i|u|em|strong)\b[^>]*>.*?<\/(?:span|font|b|i|u|em|strong)>)/is', $inner, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, ); if (!is_array($tokens)) { return $match[0] ?? ''; } $out = ''; foreach ($tokens as $token) { $childmatch = []; if ( preg_match('/^<(span|font|b|i|u|em|strong)\b([^>]*)>(.*)<\/\1>$/is', $token, $childmatch) === 1 ) { $childinner = $childmatch[3] ?? ''; if (stripos($childinner, '<') !== false) { // Deeper nesting: bail out, keep the original markup. return $match[0] ?? ''; } $childattrs = $childmatch[2] ?? ''; $stylematch = []; $childstyle = preg_match('/style="([^"]*)"/i', $childattrs, $stylematch) === 1 ? $stylematch[1] ?? '' : ''; $merged = $parentstyle . ';' . $childstyle; $out .= '<' . $tag . ($attrs !== '' ? ' ' . $attrs : '') . ' style="' . $merged . '">' . $childinner . ''; continue; } if (str_contains($token, '<')) { // Unhandled markup (images, breaks, ...): bail out. return $match[0] ?? ''; } $out .= '<' . $tag . ($attrs !== '' ? ' ' . $attrs : '') . ' style="' . $parentstyle . '">' . $token . ''; } return $out; }, $html, ); return $result === null ? $html : $result; } /** * Adapt legacy-tolerant HTML markup for the stricter engine renderer. * * Applied transformations: * - styled inline parents with nested children are flattened so the * engine paints their backgrounds (see inheritHtmlBackgrounds()); * - vertical align attributes are dropped from images inside tables * (the engine would overlap them with the row borders); * - '@'-prefixed base64 image data is rewritten to a data URI; * - relative local image paths are made absolute (src attributes, * CSS url() values and the legacy img| list-bullet syntax); * - malformed hexadecimal color tokens, silently coerced by the * legacy parser, are repaired to valid values. */ protected function normalizeHtmlMarkup(string $html): string { $html = $this->inheritHtmlBackgrounds($html); // Make relative local image sources absolute: the engine file // helper only accepts paths under its allowed roots. Covers both // src attributes and CSS url(...) values (e.g. list-style-image). // The engine positions middle/bottom aligned images relative to the // first line box of the table cell, which makes them overlap the // row borders; the vertical-align attribute is dropped inside // tables so the rows contain their images (legacy parity for the // dominant image-only cell case). $html = (string) preg_replace_callback( '##is', static fn(array $match): string => (string) preg_replace( '/(]*?)\s+align="(top|middle|bottom|absmiddle|baseline)"/i', '$1', $match[0] ?? '', ), $html, ); // Legacy HTML images may carry base64 data with an '@' prefix; the // engine expects raw bytes after '@' but decodes data URIs. $html = (string) preg_replace('/src="@([A-Za-z0-9+\/=]+)"/', 'src="data:image/any;base64,$1"', $html); $html = (string) preg_replace_callback( '/(src=["\'])([^"\']+)(["\'])/', fn(array $match): string => ( ($match[1] ?? '') . $this->absolutizeMarkupPath($match[2] ?? '') . ($match[3] ?? '') ), $html, ); $html = (string) preg_replace_callback( '/url\(["\']?([^"\')]+)["\']?\)/', fn(array $match): string => "url('" . $this->cssSafeMarkupPath($match[1] ?? '') . "')", $html, ); // Legacy custom list bullet syntax: list-style-type:img|ext|w|h|path $html = (string) preg_replace_callback( '/(img\|[^|;"\'<>]*\|[^|;"\'<>]*\|[^|;"\'<>]*\|)([^;"\'<>]+)/', fn(array $match): string => ($match[1] ?? '') . $this->cssSafeMarkupPath($match[2] ?? ''), $html, ); $result = preg_replace_callback( '/#([0-9a-zA-Z]+)\b/', static function (array $match): string { $token = $match[0] ?? ''; $hex = strtolower($match[1] ?? ''); $len = strlen($hex); if (preg_match('/^[0-9a-f]+$/', $hex) === 1 && in_array($len, [3, 4, 6, 8], true)) { return $token; } // Only repair tokens that could be a mistyped hex color: every // character must be a hex digit or a digit-look-alike letter // (l/o/i/s/g/z). This leaves CSS id selectors (#second), link // fragments (#chapter2) and placeholders (#TOC_...) untouched // instead of mangling them into bogus colors. if (preg_match('/^[0-9a-fgilosz]+$/', $hex) !== 1) { return $token; } // Map common look-alike characters, drop the rest. $hex = strtr($hex, ['l' => '1', 'o' => '0', 'i' => '1', 's' => '5', 'g' => '6', 'z' => '2']); $hex = (string) preg_replace('/[^0-9a-f]/', '', $hex); $hex = substr(str_pad($hex, 6, '0'), 0, 6); return '#' . $hex; }, $html, ); return $result === null ? $html : $result; } /** * Render HTML containing embedded method calls * (K_TCPDF_CALLS_IN_HTML feature): the markup is split at the tags, * each chunk is rendered normally and each tag invokes the serialized * facade method when listed in K_ALLOWED_TCPDF_TAGS. */ protected function writeHtmlWithTcpdfTags( string $html, mixed $w, mixed $h, mixed $x, mixed $y, mixed $border, mixed $_ln, mixed $fill, mixed $reseth, mixed $align, mixed $autopadding, ): void { $parts = preg_split('/]*?)\/?>/i', $html, -1, PREG_SPLIT_DELIM_CAPTURE); if (!is_array($parts)) { return; } $allowed = defined('K_ALLOWED_TCPDF_TAGS') ? K_ALLOWED_TCPDF_TAGS : ''; foreach ($parts as $idx => $part) { if (($idx % 2) === 0) { // HTML chunk. if (trim($part) !== '') { $this->writeHTMLCell($w, $h, $x, $y, $part, $border, 1, $fill, $reseth, $align, $autopadding); // Subsequent chunks continue from the cursor. $x = ''; $y = ''; } continue; } // Tag attributes: extract the serialized payload. $match = []; if (preg_match('/data="tcpdf:\/\/([^"]*)"/i', $part, $match) !== 1) { continue; } $payload = json_decode(urldecode($match[1] ?? ''), true); if (!is_array($payload) || !isset($payload['m']) || !is_string($payload['m'])) { continue; } $method = $payload['m']; $params = isset($payload['p']) && is_array($payload['p']) ? array_values($payload['p']) : []; if (stripos($allowed, '|' . $method . '|') === false || !method_exists($this, $method)) { // Method not in the K_ALLOWED_TCPDF_TAGS whitelist. continue; } /** @var callable $callable */ $callable = [$this, $method]; call_user_func_array($callable, $params); $x = ''; $y = ''; } } public function writeHTML($_html, $_ln = true, $_fill = false, $_reseth = false, $_cell = false, $_align = '') { // Legacy renders the fragment without the current cell padding // unless $cell is requested (writeHTMLCell always applies it). $this->htmlnopadding = !(bool) $_cell; try { $this->writeHTMLCell(0, 0, '', '', $_html, 0, (bool) $_ln ? 1 : 0, $_fill, $_reseth, $_align, true); } finally { $this->htmlnopadding = false; } } public function setLIsymbol($_symbol = '!') { $this->engine()->setULLIDot((string) $_symbol); } public function setBooklet($_booklet = true, $_inner = -1, $_outer = -1) { $this->bookletmode = (bool) $_booklet; if ((float) $_inner >= 0) { $this->lmargin = (float) $_inner; } if ((float) $_outer >= 0) { $this->rmargin = (float) $_outer; } } public function setHtmlVSpace($_tagvs) { /** @var array> $tagvs */ $tagvs = []; foreach (is_array($_tagvs) ? $_tagvs : [] as $tag => $spaces) { if (!is_string($tag) || !is_array($spaces)) { continue; } foreach (array_values($spaces) as $idx => $space) { if (!is_array($space)) { continue; } $entry = []; if (isset($space['h']) && is_numeric($space['h'])) { $entry['h'] = (float) $space['h']; } if (isset($space['n']) && is_numeric($space['n'])) { $entry['n'] = (int) $space['n']; } $tagvs[$tag][$idx] = $entry; } } $this->engine()->setHtmlVSpace($tagvs); } public function setListIndentWidth($_width) { // List indentation is computed by the engine HTML renderer. } public function setOpenCell($_isopen) { // Block continuation borders are handled by the engine HTML renderer. } public function setHtmlLinksStyle($_color = [0, 0, 255], $_fontstyle = 'U') { $this->htmllinkcolor = $this->legacyColorComponents($_color, [0, 0, 255]); $this->htmllinkstyle = (string) $_fontstyle; } public function getHTMLUnitToUnits($_htmlval, $_refsize = 1, $_defaultunit = 'px', $_points = false) { $supportedunits = ['%', 'em', 'ex', 'px', 'in', 'cm', 'mm', 'pc', 'pt']; $refsize = (float) $_refsize; $unit = strtolower((string) $_defaultunit); if (!in_array($unit, $supportedunits, true)) { $unit = 'px'; } $value = 0.0; $htmlval = $_htmlval; if (is_numeric($htmlval)) { $value = (float) $htmlval; } elseif (is_string($htmlval)) { $mat = []; if (preg_match('/^([0-9\.\-\+]+)[\s]*(%|em|ex|px|in|mm|cm|pc|pt)?$/', trim($htmlval), $mat) === 1) { $value = (float) ($mat[1] ?? 0); if (isset($mat[2]) && in_array($mat[2], $supportedunits, true)) { $unit = $mat[2]; } } } $kfactor = (bool) $_points ? 1.0 : $this->kratio; return match ($unit) { '%' => ($value * $refsize) / 100.0, 'em' => $value * $refsize, 'ex' => ($value * $refsize) / 2.0, 'in' => ($value * 72.0) / $kfactor, 'cm' => (($value * 72.0) / 2.54) / $kfactor, 'mm' => (($value * 72.0) / 25.4) / $kfactor, 'pc' => ($value * 12.0) / $kfactor, 'pt' => $value / $kfactor, default => $value / ($this->imgscale * $kfactor), }; } // =================================================================== // Page reordering (Stage 4). // =================================================================== public function movePage($_frompage, $_topage) { $from = (int) $_frompage; $target = (int) $_topage; if ($from <= $target || $from < 1 || $from > $this->getNumPages()) { return false; } $this->engine()->page->move($from - 1, $target - 1); return true; } public function deletePage($_page) { $page = (int) $_page; if ($page < 1 || $page > $this->getNumPages()) { return false; } $this->engine()->page->delete($page - 1); return true; } public function copyPage($_page = 0) { $eng = $this->engine(); $page = (int) $_page; if ($page === 0) { $page = $this->getPage(); } if ($page < 1 || $page > $this->getNumPages()) { return false; } $source = $eng->page->getPage($page - 1); $eng->addPage([ 'orientation' => $source['orientation'], 'format' => $source['format'], 'margin' => $source['margin'], 'autobreak' => $source['autobreak'], 'group' => (int) $source['group'], 'content' => implode('', $source['content']), ]); return true; } public function addTOC( $_page = null, $_numbersfont = '', $_filler = '.', $_toc_name = 'TOC', $_style = '', $_color = [0, 0, 0], ) { if ($this->docstate !== 2) { return; } $eng = $this->engine(); $outlines = $this->engineOutlines(); if ($outlines === []) { return; } $insertion = $_page === null || (string) $_page === '' ? 0 : (int) $_page; $tocstart = $this->getPage(); $filler = (string) $_filler !== '' ? (string) $_filler : '.'; $numbersfont = (string) $_numbersfont !== '' ? strtolower((string) $_numbersfont) : $this->monospacedfont; $basefamily = $this->fontfamily; $basestyle = $this->getFontStyle(); $basesize = $this->fontsizept; // Estimated number of TOC pages, used to adjust the printed page // numbers for the later relocation of the TOC itself. $rowheight = $this->getCellHeight($basesize / $this->kratio); $usable = $this->getPageHeight() - $this->tmargin - $this->bmargin; $ntoc = max(1, (int) ceil(((count($outlines) * $rowheight) + ($this->posy - $this->tmargin)) / $usable)); $contentwidth = $this->getPageWidth() - $this->lmargin - $this->rmargin; $this->setTextColorArray($_color); foreach ($outlines as $outline) { $level = max(0, (int) $outline['l']); $title = $outline['t']; $target = (int) $outline['p']; // Rows inherit the bookmark's own style and color when set. if (isset($outline['s']) && $outline['s'] !== '') { $rowstyle = $outline['s']; } else { $rowstyle = $level === 0 ? (string) $_style : ''; } if (isset($outline['c']) && $outline['c'] !== '') { $this->textcolorspec = $outline['c']; } else { $this->setTextColorArray($_color); } $display = $target + 1; $targetadj = $target; if ($insertion > 0 && $display >= $insertion) { $display += $ntoc; $targetadj += $ntoc; } $indent = 4.0 * $level; $size = max(1.0, $basesize - $level); $this->setFont($basefamily, $rowstyle, $size); $rowy = $this->posy; $rowh = $this->getCellHeight($size / $this->kratio); $this->breakIfNeeded($rowh); $rowy = $this->posy; // Title with dot filler up to the page-number box. $numwidth = (4 * $size) / $this->kratio; $textwidth = $contentwidth - $indent - $numwidth; $titlewidth = (float) $this->GetStringWidth($title . ' '); $fillerwidth = max(0.1, (float) $this->GetStringWidth($filler)); $padding = $this->cellpadding['L'] + $this->cellpadding['R']; $nfill = max(0, (int) floor(($textwidth - $padding - $titlewidth - 1) / $fillerwidth)); $this->setX($this->lmargin + $indent); $this->Cell($textwidth, $rowh, $title . ' ' . str_repeat($filler, $nfill), 0, 0, 'L', false, '', 1); // Page number, right aligned with its own font. $this->setFont($numbersfont, '', $size); $this->Cell($numwidth, $rowh, (string) $display, 0, 1, 'R'); // Link the whole row to the bookmark destination (the target // page index already accounts for the TOC relocation). $oid = $eng->setLink( $this->lmargin, $rowy, $contentwidth, $rowh, $eng->addInternalLink($targetadj, $outline['y']), ); $this->attachAnnotRef($oid); } $this->setFont($basefamily, $basestyle, $basesize); if ($insertion > 0) { // Relocate the TOC pages and remap the bookmark page targets. $tocend = $this->getPage(); $ntocreal = $tocend - $tocstart + 1; for ($idx = 0; $idx < $ntocreal; $idx++) { $eng->page->move($tocstart - 1 + $idx, $insertion - 1 + $idx); } foreach ($outlines as $key => $outline) { if ((int) $outline['p'] < ($insertion - 1)) { continue; } $outlines[$key]['p'] = (int) $outline['p'] + $ntocreal; } $this->setEngineOutlines($outlines); $eng->page->setCurrentPage($insertion - 1 + $ntocreal - 1); } } public function addHTMLTOC( $_page = null, $_toc_name = 'TOC', $_templates = [], $_correct_align = true, $_style = '', $_color = [0, 0, 0], ) { // Approximated with the engine bookmark-based TOC renderer // (HTML templates are not applied). $this->addTOC($_page, '', '.', $_toc_name, $_style, $_color); } public function startTransaction() { // Deep snapshot of the facade including the engine object graph. // The engine page context hook is a closure (not serializable): // detach it around the snapshot and rebind it on the copy. $this->transactionsnapshot = null; $hook = null; if ($this->eng instanceof TCPDF_ENGINE) { $hook = $this->eng->pagecontexthook; $this->eng->pagecontexthook = null; } try { $snapshot = unserialize(serialize($this), ['allowed_classes' => true]); } finally { if ($this->eng instanceof TCPDF_ENGINE) { $this->eng->pagecontexthook = $hook; } } if ($snapshot instanceof TCPDF) { if ($snapshot->eng instanceof TCPDF_ENGINE) { $snapshot->eng->pagecontexthook = $snapshot->ambientPageContent(...); } $this->transactionsnapshot = $snapshot; } } public function commitTransaction() { $this->transactionsnapshot = null; } public function rollbackTransaction($_self = false) { if (!$this->transactionsnapshot instanceof TCPDF) { return $this; } $snapshot = $this->transactionsnapshot; if ((bool) $_self) { foreach (get_object_vars($snapshot) as $prop => $value) { $prop = (string) $prop; if ($prop !== 'transactionsnapshot') { // The snapshot restore copies every facade property back // by name; the dynamic selector is inherent to the shim. // @mago-expect analysis:string-member-selector $this->{$prop} = $value; } } // The restored engine hook was bound to the snapshot facade: // rebind it to this (live) instance. if ($this->eng instanceof TCPDF_ENGINE) { $this->eng->pagecontexthook = $this->ambientPageContent(...); } return $this; } return $snapshot; } // =================================================================== // Columns (Stage 4). // =================================================================== /** * Legacy equal-column geometry: the requested width is capped to an even * share of the content width and the remainder becomes the inter-column * gutters. * * @return array */ protected function equalColumnRegions(int $numcols, float $width, float $ypos): array { $usable = $this->getPageWidth() - $this->orig_lmargin - $this->orig_rmargin; $maxwidth = $usable / $numcols; if ($width <= 0 || $width > $maxwidth) { $width = $maxwidth; } $space = ($usable - ($numcols * $width)) / ($numcols - 1); $regions = []; for ($idx = 0; $idx < $numcols; ++$idx) { $colpos = $idx * ($width + $space); $regions[] = [ 'RX' => $this->rtlmode ? $this->getPageWidth() - $this->orig_rmargin - $colpos - $width : $this->orig_lmargin + $colpos, 'RY' => $ypos, 'RW' => $width, 'RH' => $this->getPageHeight() - $this->bmargin - $ypos, ]; } return $regions; } /** * Replace the region (column) layout of the current engine page. * * The engine fixes the region list when a page is created and exposes no * mutator: the normalized region data (same normalization as the engine * page settings) is written directly into the page store. * * @param array $regions */ protected function setEnginePageRegions(array $regions): void { if ($regions === []) { return; } $eng = $this->engine(); $page = $eng->page->getPage(); $pagewidth = $page['width']; $pageheight = $page['height']; $marginright = $page['margin']['PR']; $marginbottom = $page['margin']['CB']; $contentwidth = $pagewidth - $page['margin']['PL'] - $marginright; $contentheight = $pageheight - $page['margin']['CT'] - $marginbottom; $normalized = []; foreach ($regions as $region) { $normalized[] = [ 'RW' => min(max(0.0, $region['RW']), $contentwidth), 'RX' => min(max(0.0, $region['RX']), $pagewidth - $marginright - $region['RW']), 'RL' => $region['RX'] + $region['RW'], 'RR' => $pagewidth - $region['RX'] - $region['RW'], 'RH' => min(max(0.0, $region['RH']), $contentheight), 'RY' => min(max(0.0, $region['RY']), $pageheight - $marginbottom - $region['RH']), 'RT' => $region['RY'] + $region['RH'], 'RB' => $pageheight - $region['RY'] - $region['RH'], 'x' => $region['RX'], 'y' => $region['RY'], ]; } $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Settings::class, 'page'); $pages = $prop->getValue($eng->page); if (!is_array($pages)) { return; } /** @var array> $pages */ $pid = (int) $page['pid']; $pages[$pid]['region'] = $normalized; $pages[$pid]['columns'] = count($normalized); $pages[$pid]['currentRegion'] = 0; $prop->setValue($eng->page, $pages); } /** * True when the current page is split into multiple column regions. */ protected function inColumnMode(): bool { return $this->docstate === 2 && count($this->engine()->page->getPage()['region']) > 1; } public function setEqualColumns($_numcols = 0, $_width = 0, $_y = null) { $numcols = max(0, (int) $_numcols); if ($numcols < 2) { $this->resetColumns(); return; } $this->pagecolumns = $numcols; $this->pagecolumnwidth = (float) $_width; $this->pageregions = []; if ($this->docstate !== 2) { return; } // Legacy: columns start at the given Y (default: the current Y) on // the current page and at the top margin on the following pages. $ypos = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; $this->setEnginePageRegions($this->equalColumnRegions($numcols, (float) $_width, $ypos)); $this->selectColumn(0); } public function resetColumns() { // Legacy: restore the original margins and disable column mode. $this->lmargin = $this->orig_lmargin; $this->rmargin = $this->orig_rmargin; $this->pagecolumns = 0; $this->pagecolumnwidth = 0.0; $this->pageregions = []; if ($this->docstate !== 2) { return; } $this->setEnginePageRegions([ [ 'RX' => $this->lmargin, 'RY' => $this->tmargin, 'RW' => $this->getPageWidth() - $this->lmargin - $this->rmargin, 'RH' => $this->getPageHeight() - $this->tmargin - $this->bmargin, ], ]); } public function setColumnsArray($_columns) { $columns = []; foreach (is_array($_columns) ? $_columns : [] as $column) { if (!is_array($column)) { continue; } $columns[] = $column; } if (count($columns) < 2) { $this->resetColumns(); return; } // Legacy columns are given as width + trailing space (+ start y); // the X positions accumulate from the original margin. $this->pagecolumns = count($columns); $this->pagecolumnwidth = 0.0; $this->pageregions = []; $colpos = 0.0; foreach ($columns as $column) { $width = (float) ($column['w'] ?? 0); $ypos = isset($column['y']) && is_numeric($column['y']) ? (float) $column['y'] : $this->posy; if (isset($column['x']) && is_numeric($column['x'])) { $posx = (float) $column['x']; } elseif ($this->rtlmode) { $posx = $this->getPageWidth() - $this->orig_rmargin - $colpos - $width; } else { $posx = $this->orig_lmargin + $colpos; } $this->pageregions[] = [ 'RX' => $posx, 'RY' => $ypos, 'RW' => $width, 'RH' => $this->getPageHeight() - $this->bmargin - $ypos, ]; $colpos += $width + (float) ($column['s'] ?? 0); } if ($this->docstate === 2) { $this->setEnginePageRegions($this->pageregions); $this->selectColumn(0); } } public function selectColumn($_col = null) { if ($this->docstate !== 2) { return; } $eng = $this->engine(); $page = $eng->page->getPage(); $regions = $page['region']; $current = (int) $page['currentRegion']; // Legacy: no argument re-selects the current column; out-of-range // wraps back to the first one. $col = $_col === null || (string) $_col === '' ? $current : (int) $_col; if ($col < 0 || $col >= count($regions)) { $col = 0; } $eng->page->selectRegion($col); $region = $eng->page->getRegion(); if ($col !== $current) { // Legacy: switching columns moves the cursor to the column top. $this->posy = $region['RY']; } $this->posx = $this->rtlmode ? $region['RX'] + $region['RW'] : $region['RX']; } public function getColumn() { if ($this->docstate !== 2) { return 0; } return (int) $this->engine()->page->getPage()['currentRegion']; } public function getNumberOfColumns() { if ($this->docstate !== 2) { return $this->pagecolumns; } return count($this->engine()->page->getPage()['region']); } // =================================================================== // Text state. // =================================================================== public function setTextRenderingMode($_stroke = 0, $_fill = true, $_clip = false) { $this->textrendermode = [ 'stroke' => (float) $_stroke / $this->kratio, 'fill' => (bool) $_fill, 'clip' => (bool) $_clip, ]; } public function setTextShadow($_params = [ 'enabled' => false, 'depth_w' => 0, 'depth_h' => 0, 'color' => false, 'opacity' => 1, 'blend_mode' => 'Normal', ]) { $params = is_array($_params) ? $_params : []; $this->textshadow = [ 'enabled' => (bool) ($params['enabled'] ?? false), 'depth_w' => (float) ($params['depth_w'] ?? 0), 'depth_h' => (float) ($params['depth_h'] ?? 0), 'color' => $params['color'] ?? false, 'opacity' => (float) ($params['opacity'] ?? 1), 'blend_mode' => (string) ($params['blend_mode'] ?? 'Normal'), ]; } /** * @return array{enabled: bool, depth_w: int|float, depth_h: int|float, color: mixed, opacity: int|float, blend_mode: string} Text shadow parameters. */ public function getTextShadow() { return $this->textshadow; } public function hyphenateText( $_text, $_patterns, $_dictionary = [], $_leftmin = 1, $_rightmin = 2, $_charmin = 1, $_charmax = 8, ) { // The engine hyphenates at render time from TeX patterns; configure // them and return the text unchanged. $eng = $this->engine(); if (is_string($_patterns) && $_patterns !== '') { if (strtoupper($_patterns) === 'LATIN') { $_patterns = 'hyph-la.tex'; } $file = $this->resolveLocalFile($_patterns); if (is_file($file)) { $eng->setTexHyphenPatterns($eng->loadTexHyphenPatterns($file)); } } elseif (is_array($_patterns) && $_patterns !== []) { $patterns = []; foreach ($_patterns as $key => $val) { if (!(is_string($key) && is_string($val))) { continue; } $patterns[$key] = $val; } $eng->setTexHyphenPatterns($patterns); } return (string) $_text; } public function setRasterizeVectorImages($_mode) { // The engine renders vector images natively. } public function setFontSubsetting($_enable = true) { $this->fontsubsetting = (bool) $_enable; } /** * @return bool Default font subsetting mode. */ public function getFontSubsetting() { return $this->fontsubsetting; } public function stringLeftTrim($_str, $_replace = '') { return $this->engine()->strTrimLeft((string) $_str, (string) $_replace); } public function stringRightTrim($_str, $_replace = '') { return $this->engine()->strTrimRight((string) $_str, (string) $_replace); } public function stringTrim($_str, $_replace = '') { return $this->engine()->strTrim((string) $_str, (string) $_replace); } public function isUnicodeFont() { return $this->engine()->font->isCurrentUnicodeFont(); } public function getFontFamilyName($_fontfamily) { return $this->engine()->font->getFontFamilyName((string) $_fontfamily); } // =================================================================== // Templates (Stage 4). // =================================================================== public function startTemplate($_w = 0, $_h = 0, $_group = false) { if ($this->docstate !== 2) { $this->Error('Unable to start a template: no page has been started'); return false; } $width = (float) $_w > 0 ? (float) $_w : $this->getPageWidth(); $height = (float) $_h > 0 ? (float) $_h : $this->getPageHeight(); $this->xobjtid = $this->engine()->newXObjectTemplate($width, $height); $this->xobjheight = $height; return $this->xobjtid; } public function endTemplate() { if ($this->xobjtid === '') { return false; } $tid = $this->xobjtid; $this->xobjtid = ''; $this->engine()->exitXObjectTemplate(); return $tid; } public function printTemplate( $_id, $_x = null, $_y = null, $_w = 0, $_h = 0, $_align = '', $_palign = '', $_fitonpage = false, ) { if ($this->docstate !== 2) { return; } $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; $this->emitToPage($this->engine()->getXObjectTemplate( (string) $_id, $posx, $posy, (float) $_w, (float) $_h, 'T', $this->halignToEngine($_align), )); } public function setFontStretching($_perc = 100) { $this->fontstretching = (float) $_perc; $this->refreshFontState(); } /** * @return float Font stretching percentage. */ public function getFontStretching() { return $this->fontstretching; } public function setFontSpacing($_spacing = 0) { $this->fontspacing = (float) $_spacing; $this->refreshFontState(); } /** * @return float Extra font spacing in user units. */ public function getFontSpacing() { return $this->fontspacing; } // =================================================================== // Page regions (Stage 4). // =================================================================== public function getPageRegions() { return $this->nowriteareas; } public function setPageRegions($_regions = []) { // Legacy: empties the current regions, then re-adds each one. The // areas are kept verbatim and converted to engine banded writable // regions when text is flowed (applyNoWriteRegionsForFlow()). $this->nowriteareas = []; foreach (is_array($_regions) ? $_regions : [] as $region) { $this->addPageRegion($region); } } public function addPageRegion($_region) { if (!is_array($_region)) { return; } // Legacy no-write region: a vertical (possibly slanted) segment plus // the page side it blocks. Validation mirrors the legacy library // (positive X, top above bottom, side L or R); an empty/zero page // means the current page. $page = isset($_region['page']) && (int) $_region['page'] > 0 ? (int) $_region['page'] : $this->getPage(); $xt = (float) ($_region['xt'] ?? 0); $yt = (float) ($_region['yt'] ?? 0); $xb = (float) ($_region['xb'] ?? 0); $yb = (float) ($_region['yb'] ?? 0); $side = (string) ($_region['side'] ?? ''); if ($xt > 0 && $xb > 0 && $yt >= 0 && $yt < $yb && ($side === 'L' || $side === 'R')) { $this->nowriteareas[] = [ 'page' => $page, 'xt' => $xt, 'yt' => $yt, 'xb' => $xb, 'yb' => $yb, 'side' => $side, ]; } } public function removePageRegion($_key) { $key = (int) $_key; if (isset($this->nowriteareas[$key])) { unset($this->nowriteareas[$key]); $this->nowriteareas = array_values($this->nowriteareas); } } /** * The legacy no-write areas registered for the current page, in the engine * setNoWriteRegions() input format (xt/yt/xb/yb/side). * * No-write regions only apply to flowing body content; the header/footer * decoration (drawn in a late pass with the page context restored, when a * page's regions may already be set) must keep its own layout, so report * none while rendering it — matching legacy, whose header sits above the * content area the regions live in. * * @return array */ protected function currentPageNoWriteAreas(): array { if ($this->nowriteareas === [] || $this->docstate !== 2 || $this->inheaderfooter) { return []; } $pagenum = $this->getPage(); $areas = []; foreach ($this->nowriteareas as $area) { if ($area['page'] !== $pagenum) { continue; } $areas[] = [ 'xt' => $area['xt'], 'yt' => $area['yt'], 'xb' => $area['xb'], 'yb' => $area['yb'], 'side' => $area['side'], ]; } return $areas; } /** * (Re)build the engine banded writable regions for the current page from * its legacy no-write areas, using $bandheight as the height of each * horizontal slice, then select the region that contains the current Y so * the flow starts at the cursor and hugs the obstacles band by band. The * text/HTML engine then advances region -> region -> fresh full-width page * on its own. * * @return bool True when no-write regions were applied (multi-region flow). */ protected function applyNoWriteRegionsForFlow(float $bandheight, float $cursory): bool { $areas = $this->currentPageNoWriteAreas(); if ($areas === []) { return false; } $this->engine()->page->setNoWriteRegions($areas, max($bandheight, 0.1)); $this->selectRegionAtY($cursory); return true; } /** * Select the (top-to-bottom ordered) writable region of the current page * whose vertical span contains $y, so a flow that starts at the cursor * begins in the right band. Falls back to the first region when $y sits * above them all. */ protected function selectRegionAtY(float $y): void { $eng = $this->engine(); /** @var array $regions */ $regions = $eng->page->getPage()['region']; $idx = 0; foreach ($regions as $i => $region) { if ($region['RY'] <= ($y + 0.01)) { $idx = (int) $i; continue; } break; } $eng->page->selectRegion($idx); } /** * Band height (one writable slice) for a no-write region flow of HTML: the * cell height of the body (outermost) font size declared in the fragment, * falling back to the current font. A slice should be about one body line * tall so the bands hug the obstacle without wasting vertical space (which * would push too much text onto the continuation page); larger inline runs * sit in the tall merged region at the top rather than at a band boundary. */ protected function noWriteBandFromHtml(string $html): float { $size = $this->getFontSize(); $match = []; if (preg_match('/font-size\s*:\s*([0-9.]+)\s*(px|pt|em|ex|mm|cm|in|pc|%)?/i', $html, $match) === 1) { $unit = isset($match[2]) && $match[2] !== '' ? $match[2] : 'pt'; $size = (float) $this->getHTMLUnitToUnits($match[1] ?? '', $this->getFontSize(), $unit); } return $this->getCellHeight($size, false); } public function ImageSVG( $_file, $_x = null, $_y = null, $_w = 0, $_h = 0, $_link = '', $_align = '', $_palign = '', $_border = 0, $_fitonpage = false, ) { if ($this->docstate !== 2) { $this->Error('Unable to add an SVG image: no page has been started'); return; } $eng = $this->engine(); $file = (string) $_file; if (!str_starts_with($file, '@')) { $file = $this->resolveLocalFile($file); } $posx = $_x === null || (string) $_x === '' ? $this->posx : (float) $_x; $posy = $_y === null || (string) $_y === '' ? $this->posy : (float) $_y; $page = $eng->page->getPage(); $soid = $eng->addSVG($file, $posx, $posy, (float) $_w, (float) $_h, $page['height']); $this->emitToPage($eng->getSetSVG($soid)); if (is_string($_link) && $_link !== '' && (float) $_w > 0 && (float) $_h > 0) { $this->attachLink($_link, $posx, $posy, (float) $_w, (float) $_h); } } } /** * @class TCPDF_ENGINE * tc-lib-pdf engine specialization used by the TCPDF facade. * * Every page content stream starts from the PDF default graphics state, and * the engine re-emits only the current font when it opens a page context * (explicit addPage() and the automatic page breaks performed inside its * flowing methods such as addTextCell()). The legacy TCPDF API instead * carries ambient text state (text color, font spacing/stretching) across * page breaks, so this subclass lets the facade re-emit that state at the * start of every page the engine opens. * * @package com.tecnick.tcpdf */ // The single-file layout (tcpdf.php as the only historical include) and the // legacy ALL-CAPS underscore class naming (TCPDF_STATIC, TCPDF_FONTS, ...) // are part of the TCPDF compatibility contract and cannot change. // @mago-expect lint:single-class-per-file // @mago-expect lint:class-name class TCPDF_ENGINE extends \Com\Tecnick\Pdf\Tcpdf { /** * Facade callback returning the raw PDF operators for the ambient text * state to prepend to every new page content stream. * * @var (\Closure(): string)|null */ public ?\Closure $pagecontexthook = null; public function setPageContext(int $pid = -1): void { $this->anchorPageRegionsToContentTop($pid); // The engine declares the protected implementation through a public // @method tag, which the analyzer resolves before the real method. // @mago-expect analysis:possibly-non-existent-method parent::setPageContext($pid); if ($this->pagecontexthook instanceof \Closure) { $content = (string) ($this->pagecontexthook)(); if ($content !== '') { $this->page->addContent($content, $pid); } } } /** * Anchor the page regions (columns) at the content top margin. * * Pages opened by the engine's internal flows clone the previous page * data, including regions that may start mid-page (legacy columns set * on the page where they were defined): on every following page the * columns restart at the top margin (legacy selectColumn() behavior). * The region list of an existing page has no mutator, so the adjusted * data is written directly into the page store. */ protected function anchorPageRegionsToContentTop(int $pid): void { $pagedata = $this->page->getPage($pid); $regions = $pagedata['region']; $margintop = $pagedata['margin']['CT']; $pageheight = $pagedata['height']; $contentheight = $pageheight - $margintop - $pagedata['margin']['CB']; $changed = false; foreach ($regions as $idx => $region) { if (abs($region['RY'] - $margintop) < 0.0001 && abs($region['RH'] - $contentheight) < 0.0001) { continue; } $region['RY'] = $margintop; $region['RH'] = $contentheight; $region['RT'] = $margintop + $contentheight; $region['RB'] = $pageheight - $margintop - $contentheight; $region['y'] = $margintop; $regions[$idx] = $region; $changed = true; } if (!$changed) { return; } $prop = new \ReflectionProperty(\Com\Tecnick\Pdf\Page\Settings::class, 'page'); $pages = $prop->getValue($this->page); if (!is_array($pages)) { return; } /** @var array> $pages */ $pages[(int) $pagedata['pid']]['region'] = $regions; $prop->setValue($this->page, $pages); } }