2019-10-13 15:13:39 +00:00
|
|
|
# ##########################################################
|
2019-04-19 14:12:10 +00:00
|
|
|
# FlatCAM: 2D Post-processing for Manufacturing #
|
|
|
|
# File Author: Marius Adrian Stanciu (c) #
|
2019-04-24 22:13:37 +00:00
|
|
|
# Date: 4/23/2019 #
|
2019-04-19 14:12:10 +00:00
|
|
|
# MIT Licence #
|
2019-10-13 15:13:39 +00:00
|
|
|
# ##########################################################
|
2019-04-19 14:12:10 +00:00
|
|
|
|
2019-10-15 23:28:18 +00:00
|
|
|
from PyQt5 import QtWidgets, QtCore
|
|
|
|
|
2020-05-18 13:11:02 +00:00
|
|
|
from AppTool import AppTool
|
2020-05-18 13:02:41 +00:00
|
|
|
from Common import GracefulException as grace
|
|
|
|
from AppParsers.ParsePDF import PdfParser
|
2020-05-18 02:46:57 +00:00
|
|
|
from shapely.geometry import Point, MultiPolygon
|
2019-10-15 23:28:18 +00:00
|
|
|
from shapely.ops import unary_union
|
2019-04-21 01:43:49 +00:00
|
|
|
|
2020-05-18 02:46:57 +00:00
|
|
|
from copy import deepcopy
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
import zlib
|
|
|
|
import re
|
2019-04-25 13:26:31 +00:00
|
|
|
import time
|
2019-10-15 23:28:18 +00:00
|
|
|
import logging
|
|
|
|
import traceback
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
import gettext
|
2020-05-18 13:02:41 +00:00
|
|
|
import AppTranslation as fcTranslate
|
2019-04-21 01:43:49 +00:00
|
|
|
import builtins
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
fcTranslate.apply_language('strings')
|
|
|
|
if '_' not in builtins.__dict__:
|
|
|
|
_ = gettext.gettext
|
|
|
|
|
2019-10-15 23:28:18 +00:00
|
|
|
log = logging.getLogger('base')
|
|
|
|
|
2019-04-19 14:12:10 +00:00
|
|
|
|
2020-05-18 13:11:02 +00:00
|
|
|
class ToolPDF(AppTool):
|
2019-04-21 01:43:49 +00:00
|
|
|
"""
|
2019-04-19 14:12:10 +00:00
|
|
|
Parse a PDF file.
|
|
|
|
Reference here: https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/pdf_reference_archives/PDFReference.pdf
|
|
|
|
Return a list of geometries
|
2019-04-21 01:43:49 +00:00
|
|
|
"""
|
2019-04-19 14:12:10 +00:00
|
|
|
toolName = _("PDF Import Tool")
|
|
|
|
|
|
|
|
def __init__(self, app):
|
2020-05-18 13:11:02 +00:00
|
|
|
AppTool.__init__(self, app)
|
2019-04-19 14:12:10 +00:00
|
|
|
self.app = app
|
2019-12-05 13:18:54 +00:00
|
|
|
self.decimals = self.app.decimals
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
self.stream_re = re.compile(b'.*?FlateDecode.*?stream(.*?)endstream', re.S)
|
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
self.pdf_decompressed = {}
|
|
|
|
|
|
|
|
# key = file name and extension
|
|
|
|
# value is a dict to store the parsed content of the PDF
|
|
|
|
self.pdf_parsed = {}
|
|
|
|
|
|
|
|
# QTimer for periodic check
|
2019-04-24 19:26:13 +00:00
|
|
|
self.check_thread = QtCore.QTimer()
|
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
# Every time a parser is started we add a promise; every time a parser finished we remove a promise
|
|
|
|
# when empty we start the layer rendering
|
|
|
|
self.parsing_promises = []
|
2019-04-19 14:12:10 +00:00
|
|
|
|
2020-05-18 02:46:57 +00:00
|
|
|
self.parser = PdfParser(app=self.app)
|
2019-04-21 01:43:49 +00:00
|
|
|
|
2019-04-19 14:12:10 +00:00
|
|
|
def run(self, toggle=True):
|
2020-04-29 01:46:52 +00:00
|
|
|
self.app.defaults.report_usage("ToolPDF()")
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
self.set_tool_ui()
|
|
|
|
self.on_open_pdf_click()
|
|
|
|
|
|
|
|
def install(self, icon=None, separator=None, **kwargs):
|
2020-05-18 13:11:02 +00:00
|
|
|
AppTool.install(self, icon, separator, shortcut='Ctrl+Q', **kwargs)
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
def set_tool_ui(self):
|
|
|
|
pass
|
|
|
|
|
|
|
|
def on_open_pdf_click(self):
|
|
|
|
"""
|
|
|
|
File menu callback for opening an PDF file.
|
|
|
|
|
|
|
|
:return: None
|
|
|
|
"""
|
|
|
|
|
2020-04-29 01:46:52 +00:00
|
|
|
self.app.defaults.report_usage("ToolPDF.on_open_pdf_click()")
|
2019-04-19 14:12:10 +00:00
|
|
|
self.app.log.debug("ToolPDF.on_open_pdf_click()")
|
|
|
|
|
|
|
|
_filter_ = "Adobe PDF Files (*.pdf);;" \
|
|
|
|
"All Files (*.*)"
|
|
|
|
|
|
|
|
try:
|
|
|
|
filenames, _f = QtWidgets.QFileDialog.getOpenFileNames(caption=_("Open PDF"),
|
2019-04-21 01:43:49 +00:00
|
|
|
directory=self.app.get_last_folder(),
|
|
|
|
filter=_filter_)
|
2019-04-19 14:12:10 +00:00
|
|
|
except TypeError:
|
|
|
|
filenames, _f = QtWidgets.QFileDialog.getOpenFileNames(caption=_("Open PDF"), filter=_filter_)
|
|
|
|
|
|
|
|
if len(filenames) == 0:
|
2019-09-06 18:02:08 +00:00
|
|
|
self.app.inform.emit('[WARNING_NOTCL] %s.' % _("Open PDF cancelled"))
|
2019-04-19 14:12:10 +00:00
|
|
|
else:
|
2019-04-24 11:11:15 +00:00
|
|
|
# start the parsing timer with a period of 1 second
|
|
|
|
self.periodic_check(1000)
|
|
|
|
|
2019-04-19 14:12:10 +00:00
|
|
|
for filename in filenames:
|
|
|
|
if filename != '':
|
2019-04-24 11:11:15 +00:00
|
|
|
self.app.worker_task.emit({'fcn': self.open_pdf,
|
|
|
|
'params': [filename]})
|
2019-04-19 14:12:10 +00:00
|
|
|
|
|
|
|
def open_pdf(self, filename):
|
2019-04-24 11:11:15 +00:00
|
|
|
short_name = filename.split('/')[-1].split('\\')[-1]
|
|
|
|
self.parsing_promises.append(short_name)
|
2020-05-18 02:46:57 +00:00
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
self.pdf_parsed[short_name] = {}
|
|
|
|
self.pdf_parsed[short_name]['pdf'] = {}
|
|
|
|
self.pdf_parsed[short_name]['filename'] = filename
|
|
|
|
|
|
|
|
self.pdf_decompressed[short_name] = ''
|
2019-04-22 17:33:03 +00:00
|
|
|
|
2019-09-07 20:16:20 +00:00
|
|
|
if self.app.abort_flag:
|
|
|
|
# graceful abort requested by the user
|
2020-04-27 07:03:22 +00:00
|
|
|
raise grace
|
2019-09-07 20:16:20 +00:00
|
|
|
|
2019-04-22 14:35:36 +00:00
|
|
|
with self.app.proc_container.new(_("Parsing PDF file ...")):
|
2019-04-19 14:12:10 +00:00
|
|
|
with open(filename, "rb") as f:
|
|
|
|
pdf = f.read()
|
|
|
|
|
2019-04-21 01:43:49 +00:00
|
|
|
stream_nr = 0
|
2019-04-19 14:12:10 +00:00
|
|
|
for s in re.findall(self.stream_re, pdf):
|
2019-09-07 20:16:20 +00:00
|
|
|
if self.app.abort_flag:
|
|
|
|
# graceful abort requested by the user
|
2020-04-27 07:03:22 +00:00
|
|
|
raise grace
|
2019-09-07 20:16:20 +00:00
|
|
|
|
2019-04-21 01:43:49 +00:00
|
|
|
stream_nr += 1
|
2019-04-22 00:28:05 +00:00
|
|
|
log.debug(" PDF STREAM: %d\n" % stream_nr)
|
2019-04-19 14:12:10 +00:00
|
|
|
s = s.strip(b'\r\n')
|
|
|
|
try:
|
2019-04-24 11:11:15 +00:00
|
|
|
self.pdf_decompressed[short_name] += (zlib.decompress(s).decode('UTF-8') + '\r\n')
|
2019-04-21 01:43:49 +00:00
|
|
|
except Exception as e:
|
2020-05-18 02:46:57 +00:00
|
|
|
self.app.inform.emit('[ERROR_NOTCL] %s: %s\n%s' % (_("Failed to open"), str(filename), str(e)))
|
2019-04-22 14:35:36 +00:00
|
|
|
log.debug("ToolPDF.open_pdf().obj_init() --> %s" % str(e))
|
2020-05-18 02:46:57 +00:00
|
|
|
return
|
2019-04-22 14:35:36 +00:00
|
|
|
|
2020-05-18 02:46:57 +00:00
|
|
|
self.pdf_parsed[short_name]['pdf'] = self.parser.parse_pdf(pdf_content=self.pdf_decompressed[short_name])
|
2019-04-24 19:26:13 +00:00
|
|
|
# we used it, now we delete it
|
|
|
|
self.pdf_decompressed[short_name] = ''
|
2019-04-22 14:35:36 +00:00
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
# removal from list is done in a multithreaded way therefore not always the removal can be done
|
2019-04-25 13:26:31 +00:00
|
|
|
# try to remove until it's done
|
2019-04-24 11:11:15 +00:00
|
|
|
try:
|
2019-04-25 13:26:31 +00:00
|
|
|
while True:
|
|
|
|
self.parsing_promises.remove(short_name)
|
|
|
|
time.sleep(0.1)
|
2019-07-16 13:22:20 +00:00
|
|
|
except Exception as e:
|
|
|
|
log.debug("ToolPDF.open_pdf() --> %s" % str(e))
|
2019-09-13 15:27:00 +00:00
|
|
|
self.app.inform.emit('[success] %s: %s' % (_("Opened"), str(filename)))
|
2019-04-24 11:11:15 +00:00
|
|
|
|
2019-04-24 19:26:13 +00:00
|
|
|
def layer_rendering_as_excellon(self, filename, ap_dict, layer_nr):
|
|
|
|
outname = filename.split('/')[-1].split('\\')[-1] + "_%s" % str(layer_nr)
|
|
|
|
|
|
|
|
# store the points here until reconstitution:
|
|
|
|
# keys are diameters and values are list of (x,y) coords
|
|
|
|
points = {}
|
|
|
|
|
|
|
|
def obj_init(exc_obj, app_obj):
|
2019-05-18 21:44:52 +00:00
|
|
|
clear_geo = [geo_el['clear'] for geo_el in ap_dict['0']['geometry']]
|
2019-04-24 11:11:15 +00:00
|
|
|
|
2019-05-18 21:44:52 +00:00
|
|
|
for geo in clear_geo:
|
2019-04-24 19:26:13 +00:00
|
|
|
xmin, ymin, xmax, ymax = geo.bounds
|
|
|
|
center = (((xmax - xmin) / 2) + xmin, ((ymax - ymin) / 2) + ymin)
|
|
|
|
|
|
|
|
# for drill bits, even in INCH, it's enough 3 decimals
|
|
|
|
correction_factor = 0.974
|
|
|
|
dia = (xmax - xmin) * correction_factor
|
|
|
|
dia = round(dia, 3)
|
|
|
|
if dia in points:
|
|
|
|
points[dia].append(center)
|
2019-04-22 23:02:20 +00:00
|
|
|
else:
|
2019-04-24 19:26:13 +00:00
|
|
|
points[dia] = [center]
|
|
|
|
|
|
|
|
sorted_dia = sorted(points.keys())
|
|
|
|
|
|
|
|
name_tool = 0
|
|
|
|
for dia in sorted_dia:
|
|
|
|
name_tool += 1
|
|
|
|
|
|
|
|
# create tools dictionary
|
2019-09-06 18:02:08 +00:00
|
|
|
spec = {"C": dia, 'solid_geometry': []}
|
2019-04-24 19:26:13 +00:00
|
|
|
exc_obj.tools[str(name_tool)] = spec
|
|
|
|
|
|
|
|
# create drill list of dictionaries
|
|
|
|
for dia_points in points:
|
|
|
|
if dia == dia_points:
|
|
|
|
for pt in points[dia_points]:
|
|
|
|
exc_obj.drills.append({'point': Point(pt), 'tool': str(name_tool)})
|
|
|
|
break
|
|
|
|
|
|
|
|
ret = exc_obj.create_geometry()
|
|
|
|
if ret == 'fail':
|
|
|
|
log.debug("Could not create geometry for Excellon object.")
|
|
|
|
return "fail"
|
|
|
|
for tool in exc_obj.tools:
|
|
|
|
if exc_obj.tools[tool]['solid_geometry']:
|
|
|
|
return
|
2020-05-18 02:46:57 +00:00
|
|
|
app_obj.inform.emit('[ERROR_NOTCL] %s: %s' % (_("No geometry found in file"), outname))
|
2019-04-24 19:26:13 +00:00
|
|
|
return "fail"
|
|
|
|
|
|
|
|
with self.app.proc_container.new(_("Rendering PDF layer #%d ...") % int(layer_nr)):
|
|
|
|
|
2020-05-18 14:31:02 +00:00
|
|
|
ret_val = self.app.app_obj.new_object("excellon", outname, obj_init, autoselected=False)
|
2019-07-16 13:22:20 +00:00
|
|
|
if ret_val == 'fail':
|
2020-05-18 02:46:57 +00:00
|
|
|
self.app.inform.emit('[ERROR_NOTCL] %s' % _('Open PDF file failed.'))
|
2019-04-24 19:26:13 +00:00
|
|
|
return
|
|
|
|
# Register recent file
|
|
|
|
self.app.file_opened.emit("excellon", filename)
|
2020-05-18 13:39:26 +00:00
|
|
|
# GUI feedback
|
2020-05-18 02:46:57 +00:00
|
|
|
self.app.inform.emit('[success] %s: %s' % (_("Rendered"), outname))
|
2019-04-24 19:26:13 +00:00
|
|
|
|
|
|
|
def layer_rendering_as_gerber(self, filename, ap_dict, layer_nr):
|
|
|
|
outname = filename.split('/')[-1].split('\\')[-1] + "_%s" % str(layer_nr)
|
|
|
|
|
2020-05-18 02:46:57 +00:00
|
|
|
def obj_init(grb_obj, app_obj):
|
2019-04-24 19:26:13 +00:00
|
|
|
|
|
|
|
grb_obj.apertures = ap_dict
|
|
|
|
|
|
|
|
poly_buff = []
|
2019-05-18 21:44:52 +00:00
|
|
|
follow_buf = []
|
2019-04-24 19:26:13 +00:00
|
|
|
for ap in grb_obj.apertures:
|
|
|
|
for k in grb_obj.apertures[ap]:
|
2019-05-18 21:44:52 +00:00
|
|
|
if k == 'geometry':
|
|
|
|
for geo_el in ap_dict[ap][k]:
|
|
|
|
if 'solid' in geo_el:
|
|
|
|
poly_buff.append(geo_el['solid'])
|
|
|
|
if 'follow' in geo_el:
|
|
|
|
follow_buf.append(geo_el['follow'])
|
2019-04-24 19:26:13 +00:00
|
|
|
poly_buff = unary_union(poly_buff)
|
2019-05-18 21:44:52 +00:00
|
|
|
|
|
|
|
if '0' in grb_obj.apertures:
|
|
|
|
global_clear_geo = []
|
|
|
|
if 'geometry' in grb_obj.apertures['0']:
|
|
|
|
for geo_el in ap_dict['0']['geometry']:
|
|
|
|
if 'clear' in geo_el:
|
|
|
|
global_clear_geo.append(geo_el['clear'])
|
|
|
|
|
|
|
|
if global_clear_geo:
|
2019-07-16 13:22:20 +00:00
|
|
|
solid = []
|
2019-05-18 21:44:52 +00:00
|
|
|
for apid in grb_obj.apertures:
|
|
|
|
if 'geometry' in grb_obj.apertures[apid]:
|
|
|
|
for elem in grb_obj.apertures[apid]['geometry']:
|
|
|
|
if 'solid' in elem:
|
|
|
|
solid_geo = deepcopy(elem['solid'])
|
|
|
|
for clear_geo in global_clear_geo:
|
|
|
|
# Make sure that the clear_geo is within the solid_geo otherwise we loose
|
|
|
|
# the solid_geometry. We want for clear_geometry just to cut into solid_geometry
|
|
|
|
# not to delete it
|
|
|
|
if clear_geo.within(solid_geo):
|
|
|
|
solid_geo = solid_geo.difference(clear_geo)
|
|
|
|
if solid_geo.is_empty:
|
|
|
|
solid_geo = elem['solid']
|
|
|
|
try:
|
|
|
|
for poly in solid_geo:
|
|
|
|
solid.append(poly)
|
|
|
|
except TypeError:
|
|
|
|
solid.append(solid_geo)
|
|
|
|
poly_buff = deepcopy(MultiPolygon(solid))
|
|
|
|
|
|
|
|
follow_buf = unary_union(follow_buf)
|
|
|
|
|
2019-04-24 19:26:13 +00:00
|
|
|
try:
|
|
|
|
poly_buff = poly_buff.buffer(0.0000001)
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
try:
|
|
|
|
poly_buff = poly_buff.buffer(-0.0000001)
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
grb_obj.solid_geometry = deepcopy(poly_buff)
|
2019-05-18 21:44:52 +00:00
|
|
|
grb_obj.follow_geometry = deepcopy(follow_buf)
|
2019-04-24 19:26:13 +00:00
|
|
|
|
|
|
|
with self.app.proc_container.new(_("Rendering PDF layer #%d ...") % int(layer_nr)):
|
|
|
|
|
2020-05-18 14:31:02 +00:00
|
|
|
ret = self.app.app_obj.new_object('gerber', outname, obj_init, autoselected=False)
|
2019-04-24 19:26:13 +00:00
|
|
|
if ret == 'fail':
|
2020-05-18 02:46:57 +00:00
|
|
|
self.app.inform.emit('[ERROR_NOTCL] %s' % _('Open PDF file failed.'))
|
2019-04-24 19:26:13 +00:00
|
|
|
return
|
|
|
|
# Register recent file
|
|
|
|
self.app.file_opened.emit('gerber', filename)
|
2020-05-18 13:39:26 +00:00
|
|
|
# GUI feedback
|
2019-09-06 18:02:08 +00:00
|
|
|
self.app.inform.emit('[success] %s: %s' % (_("Rendered"), outname))
|
2019-04-21 01:43:49 +00:00
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
def periodic_check(self, check_period):
|
|
|
|
"""
|
|
|
|
This function starts an QTimer and it will periodically check if parsing was done
|
|
|
|
|
|
|
|
:param check_period: time at which to check periodically if all plots finished to be plotted
|
|
|
|
:return:
|
|
|
|
"""
|
|
|
|
|
|
|
|
# self.plot_thread = threading.Thread(target=lambda: self.check_plot_finished(check_period))
|
|
|
|
# self.plot_thread.start()
|
|
|
|
log.debug("ToolPDF --> Periodic Check started.")
|
2019-04-24 19:26:13 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
self.check_thread.stop()
|
2019-07-19 18:46:11 +00:00
|
|
|
except TypeError:
|
2019-04-24 19:26:13 +00:00
|
|
|
pass
|
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
self.check_thread.setInterval(check_period)
|
2019-04-24 19:26:13 +00:00
|
|
|
try:
|
|
|
|
self.check_thread.timeout.disconnect(self.periodic_check_handler)
|
2019-07-31 21:37:11 +00:00
|
|
|
except (TypeError, AttributeError):
|
2019-04-24 19:26:13 +00:00
|
|
|
pass
|
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
self.check_thread.timeout.connect(self.periodic_check_handler)
|
2019-04-24 19:26:13 +00:00
|
|
|
self.check_thread.start(QtCore.QThread.HighPriority)
|
2019-04-24 11:11:15 +00:00
|
|
|
|
|
|
|
def periodic_check_handler(self):
|
|
|
|
"""
|
2019-04-24 19:26:13 +00:00
|
|
|
If the parsing worker finished then start multithreaded rendering
|
2019-04-24 11:11:15 +00:00
|
|
|
:return:
|
|
|
|
"""
|
2019-04-24 19:26:13 +00:00
|
|
|
# log.debug("checking parsing --> %s" % str(self.parsing_promises))
|
|
|
|
|
2019-04-24 11:11:15 +00:00
|
|
|
try:
|
|
|
|
if not self.parsing_promises:
|
|
|
|
self.check_thread.stop()
|
2020-05-18 02:46:57 +00:00
|
|
|
log.debug("PDF --> start rendering")
|
2019-04-24 11:11:15 +00:00
|
|
|
# parsing finished start the layer rendering
|
|
|
|
if self.pdf_parsed:
|
2019-04-24 19:26:13 +00:00
|
|
|
obj_to_delete = []
|
|
|
|
for object_name in self.pdf_parsed:
|
2019-09-07 20:16:20 +00:00
|
|
|
if self.app.abort_flag:
|
|
|
|
# graceful abort requested by the user
|
2020-04-27 07:03:22 +00:00
|
|
|
raise grace
|
2019-09-07 20:16:20 +00:00
|
|
|
|
2019-04-24 19:26:13 +00:00
|
|
|
filename = deepcopy(self.pdf_parsed[object_name]['filename'])
|
|
|
|
pdf_content = deepcopy(self.pdf_parsed[object_name]['pdf'])
|
|
|
|
obj_to_delete.append(object_name)
|
|
|
|
for k in pdf_content:
|
2019-09-07 20:16:20 +00:00
|
|
|
if self.app.abort_flag:
|
|
|
|
# graceful abort requested by the user
|
2020-04-27 07:03:22 +00:00
|
|
|
raise grace
|
2019-09-07 20:16:20 +00:00
|
|
|
|
2019-04-24 19:26:13 +00:00
|
|
|
ap_dict = pdf_content[k]
|
2020-05-18 02:46:57 +00:00
|
|
|
print(k, ap_dict)
|
2019-04-24 19:26:13 +00:00
|
|
|
if ap_dict:
|
|
|
|
layer_nr = k
|
|
|
|
if k == 0:
|
|
|
|
self.app.worker_task.emit({'fcn': self.layer_rendering_as_excellon,
|
|
|
|
'params': [filename, ap_dict, layer_nr]})
|
|
|
|
else:
|
|
|
|
self.app.worker_task.emit({'fcn': self.layer_rendering_as_gerber,
|
|
|
|
'params': [filename, ap_dict, layer_nr]})
|
|
|
|
# delete the object already processed so it will not be processed again for other objects
|
2020-05-18 13:02:41 +00:00
|
|
|
# that were opened at the same time; like in drag & drop on AppGUI
|
2019-04-24 19:26:13 +00:00
|
|
|
for obj_name in obj_to_delete:
|
|
|
|
if obj_name in self.pdf_parsed:
|
|
|
|
self.pdf_parsed.pop(obj_name)
|
2019-04-24 11:11:15 +00:00
|
|
|
|
|
|
|
log.debug("ToolPDF --> Periodic check finished.")
|
|
|
|
except Exception:
|
|
|
|
traceback.print_exc()
|