1<?php
2/**
3 * This file is part of escpos-php: PHP receipt printer library for use with
4 * ESC/POS-compatible thermal and impact printers.
5 *
6 * Copyright (c) 2014-18 Michael Billington < michael.billington@gmail.com >,
7 * incorporating modifications by others. See CONTRIBUTORS.md for a full list.
8 *
9 * This software is distributed under the terms of the MIT license. See LICENSE.md
10 * for details.
11 */
12
13namespace Mike42\Escpos\PrintConnectors;
14
15use Exception;
16use BadMethodCallException;
17
18/**
19 * Connector for sending print jobs to
20 * - local ports on windows (COM1, LPT1, etc)
21 * - shared (SMB) printers from any platform (smb://server/foo)
22 * For USB printers or other ports, the trick is to share the printer with a
23 * generic text driver, then connect to the shared printer locally.
24 */
25class WindowsPrintConnector implements PrintConnector
26{
27    /**
28     * @var array $buffer
29     *  Accumulated lines of output for later use.
30     */
31    private $buffer;
32
33    /**
34     * @var string $hostname
35     *  The hostname of the target machine, or null if this is a local connection.
36     */
37    private $hostname;
38
39    /**
40     * @var boolean $isLocal
41     *  True if a port is being used directly (must be Windows), false if network shares will be used.
42     */
43    private $isLocal;
44
45    /**
46     * @var int $platform
47     *  Platform we're running on, for selecting different commands. See PLATFORM_* constants.
48     */
49    private $platform;
50
51    /**
52     * @var string $printerName
53     *  The name of the target printer (eg "Foo Printer") or port ("COM1", "LPT1").
54     */
55    private $printerName;
56
57    /**
58     * @var string $userName
59     *  Login name for network printer, or null if not using authentication.
60     */
61    private $userName;
62
63    /**
64     * @var string $userPassword
65     *  Password for network printer, or null if no password is required.
66     */
67    private $userPassword;
68
69    /**
70     * @var string $workgroup
71     *  Workgroup that the printer is located on
72     */
73    private $workgroup;
74
75    /**
76     * Represents Linux
77     */
78    const PLATFORM_LINUX = 0;
79
80    /**
81     * Represents Mac
82     */
83    const PLATFORM_MAC = 1;
84
85    /**
86     * Represents Windows
87     */
88    const PLATFORM_WIN = 2;
89
90    /**
91     * Valid local ports.
92     */
93    const REGEX_LOCAL = "/^(LPT\d|COM\d)$/";
94
95    /**
96     * Valid printer name.
97     */
98    const REGEX_PRINTERNAME = "/^[\d\w-]+(\s[\d\w-]+)*$/";
99
100    /**
101     * Valid smb:// URI containing hostname & printer with optional user & optional password only.
102     */
103    const REGEX_SMB = "/^smb:\/\/([\s\d\w-]+(:[\s\d\w+-]+)?@)?([\d\w-]+\.)*[\d\w-]+\/([\d\w-]+\/)?[\d\w-]+(\s[\d\w-]+)*$/";
104
105    /**
106     * @param string $dest
107     * @throws BadMethodCallException
108     */
109    public function __construct($dest)
110    {
111        $this -> platform = $this -> getCurrentPlatform();
112        $this -> isLocal = false;
113        $this -> buffer = null;
114        $this -> userName = null;
115        $this -> userPassword = null;
116        $this -> workgroup = null;
117        if (preg_match(self::REGEX_LOCAL, $dest) == 1) {
118            // Straight to LPT1, COM1 or other local port. Allowed only if we are actually on windows.
119            if ($this -> platform !== self::PLATFORM_WIN) {
120                throw new BadMethodCallException("WindowsPrintConnector can only be " .
121                    "used to print to a local printer ('".$dest."') on a Windows computer.");
122            }
123            $this -> isLocal = true;
124            $this -> hostname = null;
125            $this -> printerName = $dest;
126        } elseif (preg_match(self::REGEX_SMB, $dest) == 1) {
127            // Connect to samba share, eg smb://host/printer
128            $part = parse_url($dest);
129            $this -> hostname = $part['host'];
130            /* Printer name and optional workgroup */
131            $path = ltrim($part['path'], '/');
132            if (strpos($path, "/") !== false) {
133                $pathPart = explode("/", $path);
134                $this -> workgroup = $pathPart[0];
135                $this -> printerName = $pathPart[1];
136            } else {
137                $this -> printerName = $path;
138            }
139            /* Username and password if set */
140            if (isset($part['user'])) {
141                $this -> userName = $part['user'];
142                if (isset($part['pass'])) {
143                    $this -> userPassword = $part['pass'];
144                }
145            }
146        } elseif (preg_match(self::REGEX_PRINTERNAME, $dest) == 1) {
147            // Just got a printer name. Assume it's on the current computer.
148            $hostname = gethostname();
149            if (!$hostname) {
150                $hostname = "localhost";
151            }
152            $this -> hostname = $hostname;
153            $this -> printerName = $dest;
154        } else {
155            throw new BadMethodCallException("Printer '" . $dest . "' is not a valid " .
156                "printer name. Use local port (LPT1, COM1, etc) or smb://computer/printer notation.");
157        }
158        $this -> buffer = [];
159    }
160
161    public function __destruct()
162    {
163        if ($this -> buffer !== null) {
164            trigger_error("Print connector was not finalized. Did you forget to close the printer?", E_USER_NOTICE);
165        }
166    }
167
168    public function finalize()
169    {
170        $data = implode($this -> buffer);
171        $this -> buffer = null;
172        if ($this -> platform == self::PLATFORM_WIN) {
173            $this -> finalizeWin($data);
174        } elseif ($this -> platform == self::PLATFORM_LINUX) {
175            $this -> finalizeLinux($data);
176        } else {
177            $this -> finalizeMac($data);
178        }
179    }
180
181    /**
182     * Send job to printer -- platform-specific Linux code.
183     *
184     * @param string $data Print data
185     * @throws Exception
186     */
187    protected function finalizeLinux($data)
188    {
189        /* Non-Windows samba printing */
190        $device = "//" . $this -> hostname . "/" . $this -> printerName;
191        if ($this -> userName !== null) {
192            $user = ($this -> workgroup != null ? ($this -> workgroup . "\\") : "") . $this -> userName;
193            if ($this -> userPassword == null) {
194                // No password
195                $command = sprintf(
196                    "smbclient %s -U %s -c %s -N -m SMB2",
197                    escapeshellarg($device),
198                    escapeshellarg($user),
199                    escapeshellarg("print -")
200                );
201                $redactedCommand = $command;
202            } else {
203                // With password
204                $command = sprintf(
205                    "smbclient %s %s -U %s -c %s -m SMB2",
206                    escapeshellarg($device),
207                    escapeshellarg($this -> userPassword),
208                    escapeshellarg($user),
209                    escapeshellarg("print -")
210                );
211                $redactedCommand = sprintf(
212                    "smbclient %s %s -U %s -c %s -m SMB2",
213                    escapeshellarg($device),
214                    escapeshellarg("*****"),
215                    escapeshellarg($user),
216                    escapeshellarg("print -")
217                );
218            }
219        } else {
220            // No authentication information at all
221            $command = sprintf(
222                "smbclient %s -c %s -N -m SMB2",
223                escapeshellarg($device),
224                escapeshellarg("print -")
225            );
226            $redactedCommand = $command;
227        }
228        $retval = $this -> runCommand($command, $outputStr, $errorStr, $data);
229        if ($retval != 0) {
230            throw new Exception("Failed to print. Command \"$redactedCommand\" " .
231                "failed with exit code $retval: " . trim($errorStr) . trim($outputStr));
232        }
233    }
234
235    /**
236     * Send job to printer -- platform-specific Mac code.
237     *
238     * @param string $data Print data
239     * @throws Exception
240     */
241    protected function finalizeMac($data)
242    {
243        throw new Exception("Mac printing not implemented.");
244    }
245
246    /**
247     * Send data to printer -- platform-specific Windows code.
248     *
249     * @param string $data
250     */
251    protected function finalizeWin($data)
252    {
253        /* Windows-friendly printing of all sorts */
254        if (!$this -> isLocal) {
255            /* Networked printing */
256            $device = "\\\\" . $this -> hostname . "\\" . $this -> printerName;
257            if ($this -> userName !== null) {
258                /* Log in */
259                $user = "/user:" . ($this -> workgroup != null ? ($this -> workgroup . "\\") : "") . $this -> userName;
260                if ($this -> userPassword == null) {
261                    $command = sprintf(
262                        "net use %s %s",
263                        escapeshellarg($device),
264                        escapeshellarg($user)
265                    );
266                    $redactedCommand = $command;
267                } else {
268                    $command = sprintf(
269                        "net use %s %s %s",
270                        escapeshellarg($device),
271                        escapeshellarg($user),
272                        escapeshellarg($this -> userPassword)
273                    );
274                    $redactedCommand = sprintf(
275                        "net use %s %s %s",
276                        escapeshellarg($device),
277                        escapeshellarg($user),
278                        escapeshellarg("*****")
279                    );
280                }
281                $retval = $this -> runCommand($command, $outputStr, $errorStr);
282                if ($retval != 0) {
283                    throw new Exception("Failed to print. Command \"$redactedCommand\" " .
284                        "failed with exit code $retval: " . trim($errorStr));
285                }
286            }
287            /* Final print-out */
288            $filename = tempnam(sys_get_temp_dir(), "escpos");
289            file_put_contents($filename, $data);
290            if (!$this -> runCopy($filename, $device)) {
291                throw new Exception("Failed to copy file to printer");
292            }
293            unlink($filename);
294        } else {
295            /* Drop data straight on the printer */
296            if (!$this -> runWrite($data, $this -> printerName)) {
297                throw new Exception("Failed to write file to printer at " . $this -> printerName);
298            }
299        }
300    }
301
302    /**
303     * @return string Current platform. Separated out for testing purposes.
304     */
305    protected function getCurrentPlatform()
306    {
307        if (PHP_OS == "WINNT") {
308            return self::PLATFORM_WIN;
309        }
310        if (PHP_OS == "Darwin") {
311            return self::PLATFORM_MAC;
312        }
313        return self::PLATFORM_LINUX;
314    }
315
316    /* (non-PHPdoc)
317     * @see PrintConnector::read()
318     */
319    public function read($len)
320    {
321        /* Two-way communication is not supported */
322        return false;
323    }
324
325    /**
326     * Run a command, pass it data, and retrieve its return value, standard output, and standard error.
327     *
328     * @param string $command the command to run.
329     * @param string $outputStr variable to fill with standard output.
330     * @param string $errorStr variable to fill with standard error.
331     * @param string $inputStr text to pass to the command's standard input (optional).
332     * @return number
333     */
334    protected function runCommand($command, &$outputStr, &$errorStr, $inputStr = null)
335    {
336        $descriptors = [
337                0 => ["pipe", "r"],
338                1 => ["pipe", "w"],
339                2 => ["pipe", "w"],
340        ];
341        $process = proc_open($command, $descriptors, $fd);
342        if (is_resource($process)) {
343            /* Write to input */
344            if ($inputStr !== null) {
345                fwrite($fd[0], $inputStr);
346            }
347            fclose($fd[0]);
348            /* Read stdout */
349            $outputStr = stream_get_contents($fd[1]);
350            fclose($fd[1]);
351            /* Read stderr */
352            $errorStr = stream_get_contents($fd[2]);
353            fclose($fd[2]);
354            /* Finish up */
355            $retval = proc_close($process);
356            return $retval;
357        } else {
358            /* Method calling this should notice a non-zero exit and print an error */
359            return -1;
360        }
361    }
362
363    /**
364     * Copy a file. Separated out so that nothing is actually printed during test runs.
365     *
366     * @param string $from Source file
367     * @param string $to Destination file
368     * @return boolean True if copy was successful, false otherwise
369     */
370    protected function runCopy($from, $to)
371    {
372        return copy($from, $to);
373    }
374
375    /**
376     * Write data to a file. Separated out so that nothing is actually printed during test runs.
377     *
378     * @param string $data Data to print
379     * @param string $filename Destination file
380         * @return boolean True if write was successful, false otherwise
381     */
382    protected function runWrite($data, $filename)
383    {
384        return file_put_contents($filename, $data) !== false;
385    }
386
387    public function write($data)
388    {
389        $this -> buffer[] = $data;
390    }
391}
392