今年在员工服务台上线了云打印功能,不少小伙伴私下联系好奇是如何实现的,连公司打印机供应商都惊动了,于是去翻了下笔记,单开一篇文档简单说明下实现过程吧,

设计思路

CUPS(Common UNIX Printing System,通用 UNIX 打印系统)是一个开源的打印系统,通过实现标准的打印协议(如 IPP)以及提供强大的管理工具,为用户和管理员简化打印任务的配置和管理。本服务主要是依赖CUPS打印服务实现,大致设计思路如下:

  1. 用户前端浏览器上传文件,发送文件及打印参数至后端接口

  2. 后端接收文件和打印参数,创建打印任务队列调用打印工具类lp

  3. 通过lp命令发送文件至cups服务器实现打印

  • 业务流程图和前端页面:

打印服务端实现

  • 安装cups和过滤器依赖

# 我这里使用的是1.6.3版本,openprinting最新版本是2.4,这个版本耦合性太强,安装filters时编译复杂,太多依赖需要手动安装,这里功能不需要太复杂,故而选择旧版本
yum install -y cups foomatic-filters
  • 编辑配置/etc/cups/cupsd.conf

Listen 0.0.0.0:631
​
# 增加Allow all
# Restrict access to the server...
<Location />
  Allow all
  Order allow,deny
</Location>
​
# Restrict access to the admin pages...
<Location /admin>
  Allow all
  AuthType Default
  Require user @SYSTEM
  Order allow,deny
</Location>
​
# Restrict access to configuration files...
<Location /admin/conf>
  Allow all
  AuthType Default
  Require user @SYSTEM
  Order allow,deny
</Location>
  • 重启服务

systemctl restart cups
  • 下载打印机ppd驱动描述文件

    [理光打印机]  https://www.openprinting.org/download/PPD/Ricoh/PS/ 

    [其他型号]  https://www.openprinting.org/download/PPD/ 

  • 回到CUPS管理后台,添加打印机

打印命令配置测试

使用lp打印命令测试驱动配置,打印选项根据机型和驱动版本有所不同,具体参数可使用lpoptions -d -l printername查看

  • 理光

# 页面自适应
-o fit-to-page 
​
# 打印份数
-o copies=1
​
# 颜色模式:Gray黑白,CMYK彩色
-o ColorModel=Gray/CMKY
​
# 双面模式:None关闭,DuplexNoTumble长边翻转,DuplexTumble短边翻转
-o Duplex=None/DuplexNoTumble/DuplexTumble
​
# 横向打印,仅文本模式
-o landscape
​
# 双面模式:one-sided单面,two-sided-long-edge双面长边翻转
-o sides=one-sided/two-sided-long-edge
  • 震旦(仅差异部分)

#颜色模式:Grayscale黑白,Color彩色
-o SelectColor=Grayscale/Color

后端接口实现

class PrintJobSerializer(serializers.Serializer):
    """
    打印接口序列化器
    *cups_server:cups服务器和端口号,例127.0.0.1:631
    *printer_name:cups中打印机的名称
    *file:打印文件
    copies:打印份数,默认为1
    duplex:双面参数,默认为双面长边翻转(one-sided单面;two-sided-long-edge双面长边翻转)
    landscape:打印方向,默认为纵向。(True横向;False纵向)
    page_range:页码范围,默认打印全部
    color_mode:色彩模式,默认黑白(Gray黑白;CMYK彩色)
    fit_to_page:页面缩放,默认为填充(True填充;False原始比例)
    """
    cups_server = serializers.CharField(required=True)
    printer_name = serializers.CharField(required=True)
    file = serializers.ListField(child=serializers.FileField(), required=True)
    copies = serializers.IntegerField(default=1)
    duplex = serializers.CharField(default="two-sided-long-edge")
    landscape = serializers.BooleanField(default=False)
    page_range = serializers.CharField(default=None)
    color_mode = serializers.CharField(default="Gray")
    fit_to_page = serializers.BooleanField(default=True)
​
​
class PrintJobView(APIView):
    authentication_classes = []
​
    def post(self, request, *args, **kwargs):
        serializer = PrintJobSerializer(data=request.data)
        if serializer.is_valid():
            files = serializer.validated_data['file']
            files_path = []
​
            for file in files:
                random_filename = str(uuid.uuid4()) + '_' + os.path.basename(file.name)
                file_path = os.path.join('media', random_filename)
                files_path.append(file_path)
                with open(file_path, 'wb') as destination:
                    for chunk in file.chunks():
                        destination.write(chunk)
​
            try:
                print_job_command = [
                    "lp",
                    "-h", str(serializer.validated_data['cups_server']),
                    "-d", serializer.validated_data['printer_name'],
                    "-n", str(serializer.validated_data['copies']),
                    "-o", f"sides={serializer.validated_data['duplex']}",
                    "-o", f"ColorModel={serializer.validated_data['color_mode']}",
                ]
​
                if serializer.validated_data['landscape']:
                    print_job_command.extend(["-o", "landscape"])
​
                if serializer.validated_data['fit_to_page']:
                    print_job_command.extend(["-o", "fit-to-page"])
​
                if serializer.validated_data.get('page_range'):
                    print_job_command.extend(["-o", f"page-ranges={serializer.validated_data['page_range']}"])
​
                for command_file_path in files_path:
                    print_job_command.append(command_file_path)
​
                result = subprocess.run(print_job_command, capture_output=True, text=True, shell=True)
                # 生产环境需要去掉shell=True,否则无法打印
                # result = subprocess.run(print_job_command, capture_output=True, text=True)
                if result.returncode != 0:
                    raise CalledProcessError(result.returncode, result.args, output=result.stdout, stderr=result.stderr)
                match = re.search(r'request id is (\S+)', result.stdout)
                if match:
                    print_id = match.group(1)
                    for file in files_path:
                        os.remove(file)
                    return Response({"code": return_code.SUCCESS, "print_id": print_id, })
                return Response({"code": return_code.SUCCESS, "print_id": None, })
            except CalledProcessError as e:
                for file in files_path:
                    os.remove(file)
                return Response({"code": return_code.ERROR, "error": f"{e.stderr}"})
        else:
            return Response({"code": return_code.ERROR, "error": serializer.errors})
​

参考文档