Skip to content

add pdf features #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ reports/
venv/
# excludes
.python-version
*.bak
nohup.out
148 changes: 114 additions & 34 deletions src/iosanita/contenttypes/browser/export_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
from io import BytesIO
from io import StringIO
from iosanita.contenttypes import _
from PIL import Image
from plone import api
from plone.memoize import forever
from Products.Five.browser import BrowserView
from weasyprint import HTML
from zExceptions import BadRequest
from zExceptions import NotFound
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse

import base64
import csv
import imghdr
import importlib.resources
import logging
import re
Expand All @@ -21,11 +25,58 @@
fontools_logger.setLevel(logging.WARNING)


CONTENT_TYPES_MAPPING = {
"csv": "text/comma-separated-values",
"pdf": "application/pdf",
"html": "text/html",
}
@forever.memoize
def image_to_html(input_string):
"""
Convert image data to a base64 string formatted for HTML.

Args:
- input_string: The string containing the filename and base64 encoded image data.

Returns:
- HTML.
"""

if not input_string:
return ""

# Split the input string to extract the filename and base64 data
parts = input_string.split(";")
datab64 = parts[1].split(":")[1]

# Decode the image data from base64
image_data = base64.b64decode(datab64)

if image_data[:5] == b"<?xml":
# https://github.com/Kozea/WeasyPrint/issues/75
# anche se il ticket risulta chiuso gli svg non risultano correttamente gestiti
# return image_data
# return f'<img src="data:image/svg+xml;charset=utf-8;base64,{datab64}">'
# XXX: se non si va decode/encode il b64 non risulta corretto (!)
# return f'<img src="data:image/svg+xml;charset=utf-8;base64,{base64.b64encode(image_data).decode()}">'
# weasyprint gli svg non li gestisce comunque correttamente
return None

# Guess the image format
image_format = imghdr.what(None, image_data)

if not image_format:
# raise ValueError("Unable to determine image format")
logger.warning("site logo, unable to determine image format")
return ""

# Open the image from the decoded data
img = Image.open(BytesIO(image_data))

# Create a buffer to hold the image data
buffered = BytesIO()
img.save(buffered, format=image_format)

# Encode the image data to base64
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")

# Format the base64 string for HTML
return f'<img class="logo" src="data:{image_format};base64,{img_base64}">'


class IExportViewTraverser(IPublishTraverse):
Expand All @@ -50,6 +101,8 @@ class ExportViewDownload(BrowserView):

"""

with_footer = True

def __init__(self, context, request):
super().__init__(context, request)
self.export_type = "csv"
Expand All @@ -65,7 +118,7 @@ def publishTraverse(self, request, name):
def __call__(self):
""" """
if self.export_type not in ["csv", "pdf", "html"]:
raise BadRequest(
raise NotFound(
api.portal.translate(
_(
"invalid_export_type",
Expand All @@ -74,18 +127,21 @@ def __call__(self):
)
)
)
self.set_headers()
data = self.get_data()
if not data:
return ""
resp_data = ""
if self.export_type == "csv":
resp_data = self.get_csv(data)
# default per locales di riferimento (perr l'encoding, al momento, lasciamo
# il generico utf-8 con BOM che potrebbe funzionare per tutti,
# MS Excel incluso)
lang = api.portal.get_current_language(self.context)
if lang == "it":
sep = ";"
else:
sep = ","
return self.get_csv(data, sep=sep)
elif self.export_type == "pdf":
resp_data = self.get_pdf(data)
return self.get_pdf(data)
elif self.export_type == "html":
resp_data = self.get_html_for_pdf(data)
return resp_data
return self.get_html_for_pdf(data)

def get_filename(self):
"""
Expand All @@ -94,37 +150,46 @@ def get_filename(self):
now = datetime.now().strftime("%Y_%m_%d_%H_%M")
return f"export_{now}.{self.export_type}"

def set_headers(self):
"""
Set the headers for the response.
"""
if self.export_type in ["pdf", "csv"]:
self.request.response.setHeader(
"Content-Disposition", f"attachment;filename={self.get_filename()}"
)
self.request.response.setHeader(
"Content-Type", CONTENT_TYPES_MAPPING[self.export_type]
)

def get_csv(self, data):
def get_csv(self, data, encoding="utf-8-sig", sep=","):
"""
Generate CSV data from the provided data.
"""
# 1. Crea uno StringIO per il CSV
csv_buffer = StringIO()
# 2. Aggiungi l'header per il separatore (specifico per Excel)
# In Libreoffice viene aggiunta una riga, per ora evitiamo
# csv_buffer.write(f"sep={sep}\n")
# 3. Scrittura dei dati CSV
columns = self.get_columns(data)

csv_data = StringIO()
csv_writer = csv.writer(csv_data, quoting=csv.QUOTE_ALL)
csv_writer = csv.writer(csv_buffer, delimiter=sep, quoting=csv.QUOTE_ALL)
csv_writer.writerow([c["title"] for c in columns])

for item in data:
csv_writer.writerow(self.format_row(item))
return csv_data.getvalue().encode("utf-8")
# 4. Prepara i bytes con BOM (UTF-8-sig)
csv_data = csv_buffer.getvalue()
if encoding == "utf-8-sig":
csv_bytes = b"\xef\xbb\xbf" + csv_data.encode("utf-8") # Aggiunge BOM
else:
csv_bytes = csv_data.encode(encoding)
# 5. Crea la risposta con gli header corretti
response = self.request.response
response.setHeader(
"Content-Disposition", f"attachment;filename={self.get_filename()}"
)
response.setHeader("Content-Type", f"text/csv; charset={encoding}")
return csv_bytes

def get_pdf(self, data):
html_str = self.get_html_for_pdf(data=data)
pdf_file = BytesIO()
HTML(string=html_str).write_pdf(pdf_file)
pdf_file.seek(0)
# 5. Crea la risposta con gli header corretti
response = self.request.response
response.setHeader(
"Content-Disposition", f"attachment;filename={self.get_filename()}"
)
response.setHeader("Content-Type", "application/pdf")
return pdf_file.read()

def get_data(self):
Expand Down Expand Up @@ -176,6 +241,7 @@ def get_html_for_pdf(self, data):
context=self,
request=self.request,
)

return view(rows=data, columns=columns)

def pdf_styles(self):
Expand All @@ -184,7 +250,11 @@ def pdf_styles(self):
)

def pdf_title(self):
return None
context = self.context.context
site_title = api.portal.get_registry_record("plone.site_title")
if site_title:
return f"{site_title}: {context.Title()}"
return context.Title()

def pdf_description(self):
return None
Expand All @@ -203,3 +273,13 @@ def pdf_cell_format(self, column, value):
if re.match(r"^\d{4}-\d{2}-\d{2}T00:00:00$", value):
return {"type": "str", "value": value.split("T")[0]}
return {"type": "str", "value": str(value)}

def pdf_logo(self):
site_logo = api.portal.get_registry_record("plone.site_logo")
if site_logo:
return image_to_html(site_logo.decode())
return None

def pdf_datetime(self):
# TODO: valutare localizzazione della data
return datetime.now().strftime("%d/%m/%Y %H:%M")
79 changes: 69 additions & 10 deletions src/iosanita/contenttypes/browser/searchblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
from .export_view import IExportViewTraverser
from copy import deepcopy
from iosanita.contenttypes import _
from plone.app.querystring.interfaces import IQuerystringRegistryReader
from plone.intelligenttext.transforms import convertWebIntelligentPlainTextToHtml
from plone.memoize import view
from plone.registry.interfaces import IRegistry
from plone.restapi.interfaces import ISerializeToJson
from zExceptions import BadRequest
from zExceptions import NotFound
from zope.component import getMultiAdapter
from zope.component import getUtility
from zope.interface import implementer

import logging
Expand Down Expand Up @@ -86,11 +91,16 @@ def _query_from_facets(self):
}
)
elif facet["type"] == "daterangeFacet":
daterange = self.request.form[facet["field"]["value"]].split(",")
if not daterange[0]:
daterange[0] = "1970-01-01"
if not daterange[1]:
daterange[1] = "2500-01-01"
query.append(
{
"i": facet["field"]["value"],
"o": "plone.app.querystring.operation.date.between",
"v": self.request.form[facet["field"]["value"]].split(","),
"v": daterange,
}
)
elif facet["type"] == "selectFacet" and not facet["multiple"]:
Expand All @@ -110,7 +120,7 @@ def _query_from_facets(self):
}
)
else:
logger.warning("DEBUG: filter %s not implemnted", facet)
logger.warning("DEBUG: filter %s not implemented", facet)
query.append(
{
"i": facet["field"]["value"],
Expand All @@ -128,7 +138,6 @@ def get_data(self):
# 4. fare la ricerca
# 5. fare export in csv/pdf a seconda del formato
"""

# 2. Get columns, base filters and sorting
columns = self.block_data.get("columns", [])

Expand Down Expand Up @@ -185,13 +194,14 @@ def get_data(self):
# XXX: consideriamo però che senza usare il serializzatore un utente potrebbe
# chiedere qualsiasi atttributo degli oggetti, senza un controllo fine
# sullo schema
fullobjects = True
self.request.form["b_size"] = 9999
results = getMultiAdapter((results, self.request), ISerializeToJson)(
fullobjects=fullobjects
)
for obj in results["items"]:
yield [obj["title"]] + [obj.get(c["field"]) for c in columns]
if results:
fullobjects = True
self.request.form["b_size"] = 9999
results = getMultiAdapter((results, self.request), ISerializeToJson)(
fullobjects=fullobjects
)
for obj in results["items"]:
yield [obj["title"]] + [obj.get(c["field"]) for c in columns]

def get_columns(self, data):
# Il titolo va aggiunto di default come prima colonna ?
Expand All @@ -200,3 +210,52 @@ def get_columns(self, data):
return [{"key": "title", "title": _("Titolo")}] + [
{"key": c["field"], "title": c["title"]} for c in columns
]

@view.memoize
def _get_querystring(self):
# @querystring endpoint
context = self.context.context
registry = getUtility(IRegistry)
reader = getMultiAdapter((registry, self.request), IQuerystringRegistryReader)
reader.vocab_context = context
result = reader()
return result

# TODO: valutare eventuale titolo impostato sul blocco
# def pdf_title(self):

pdf_description_as_html = True

def pdf_description(self) -> str:
query = []
querystring_registry = self._get_querystring()
searchtext = self._query_from_searchtext()
if searchtext and searchtext[0].get("v"):
# TODO: translate
query.append(f"Ricerca per: {searchtext[0]['v']}")
for facet in self.block_data.get("facets") or []:
if "field" not in facet:
logger.warning("invalid facet %s", facet)
continue
if facet["field"]["value"] in self.request.form:
value = self.request.form[facet["field"]["value"]]
if value in ["null"]:
continue
# TODO: gestire campi particolari come: multipli, date, ...
index = querystring_registry["indexes"].get(facet["field"]["value"])
if index:
if "values" in index:
# TODO: per i valori multipli ?
# TODO: facciamo constraint o fallback come ora?
if value in index["values"] and index["values"][value].get(
"title"
):
query.append(
f'{facet["field"]["label"]}: {index["values"][value]["title"]}'
)
continue
query.append(f'{facet["field"]["label"]}: {value}')
if query:
# TODO: translate
txt = "Filtri applicati:\n- " + ",\n- ".join(query)
return convertWebIntelligentPlainTextToHtml(txt)
Loading