822 lines
25 KiB
Python
822 lines
25 KiB
Python
"""The classroom's presentation library.
|
|
|
|
What a teacher sees is in classroom-presentations. This is everything
|
|
underneath it — where the files live, what is known about each one, and
|
|
the two things that have to be right whoever is asking: when a download
|
|
has really finished, and when a presentation has really been presented.
|
|
|
|
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
|
|
|
|
A teacher downloads a session into ordinary ~/Downloads. Once the download
|
|
is complete, it is moved into this managed library. These locked-down
|
|
machines have no general file manager, so leaving another copy in Downloads
|
|
only wastes space and makes Remove misleading. The moved original is kept
|
|
beside the generated PDF and page images, which means conversion failure is
|
|
recoverable and cleanup has one contained directory to remove.
|
|
|
|
WHY EVERY FORMAT BECOMES A PDF.
|
|
|
|
Measured in the lab, on Debian 13, before any of this was designed —
|
|
docs/presentations.md has the whole table. The short of it:
|
|
|
|
soffice --show starts a real fullscreen slideshow, and leaves
|
|
the Impress editor behind when it ends. It also
|
|
wants a first-run dialog dismissed and shows a
|
|
splash. A teacher who presses Escape must land
|
|
in the library, never in an application that can
|
|
open any file on the machine.
|
|
|
|
okular --presentation opens THREE windows: the
|
|
presentation, the ordinary Okular document
|
|
window behind it, and an information dialog.
|
|
|
|
So LibreOffice converts to PDF once, at import, with no window on the
|
|
screen at all, and poppler renders that PDF to page images. The classroom
|
|
presents the images. Nothing here renders a slide: LibreOffice and
|
|
poppler do all of it, which is the same engine Impress would have used
|
|
live. This is not a slide renderer — it is a picture viewer over pictures
|
|
somebody else drew.
|
|
|
|
That also makes a PDF export a first-class citizen rather than a special
|
|
case: a PDF is already the thing everything else is converted into, so a
|
|
session exported from Google Slides as PDF skips a conversion and keeps
|
|
the appearance Google gave it.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
import unicodedata
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
# What a teacher may put in the library.
|
|
#
|
|
# Three, and deliberately no more. Every one of them is something the
|
|
# pastor's material is actually exported as, and each has a conversion
|
|
# into PDF that Debian packages.
|
|
SUPPORTED = {
|
|
".pdf": "pdf",
|
|
".pptx": "pptx",
|
|
".ppt": "ppt",
|
|
}
|
|
|
|
# A download that is still arriving.
|
|
#
|
|
# Chrome writes "Session 7.pptx.crdownload" and renames it to
|
|
# "Session 7.pptx" when the transfer completes, so the presence of the
|
|
# sibling is the authoritative answer to "has this finished". The others
|
|
# are what a different browser or a copy in progress leaves.
|
|
PARTIAL_SUFFIXES = (".crdownload", ".part", ".partial", ".download", ".tmp")
|
|
|
|
# How long a file has to stop changing before it counts as finished.
|
|
#
|
|
# Belt and braces with the .crdownload check above. A file copied onto
|
|
# the machine from a USB stick has no .crdownload beside it and is still
|
|
# half written, and a teacher watching a presentation appear mid-copy
|
|
# would get a library entry that cannot be opened.
|
|
SETTLE_SECONDS = 6
|
|
|
|
# Retention, in days. Neither is a teacher-facing setting in 0.2.6.
|
|
NEVER_PRESENTED_DAYS = 180
|
|
AFTER_PRESENTING_DAYS = 30
|
|
|
|
# How wide a rendered slide is, in pixels.
|
|
#
|
|
# The classroom televisions are 1080p and 4K. 1920 is the 1080p set
|
|
# exactly and a 4K set scaled up by two, which on static Bible study
|
|
# slides is indistinguishable at classroom distance and is a quarter of
|
|
# the pixels to render and store.
|
|
SLIDE_WIDTH = 1920
|
|
|
|
# The library card. Small on purpose: a wall of these has to stay quick.
|
|
THUMBNAIL_WIDTH = 480
|
|
|
|
|
|
def data_home():
|
|
return Path(os.environ.get("XDG_DATA_HOME")
|
|
or Path.home() / ".local" / "share")
|
|
|
|
|
|
def runtime_home():
|
|
return Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
|
|
|
|
|
|
def library_root():
|
|
return data_home() / "ekklesia-classroom" / "presentations"
|
|
|
|
|
|
def downloads_root():
|
|
return Path(os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads")
|
|
|
|
|
|
# Wonder Ink's own downloads, which are not presentations and must never
|
|
# be imported, moved or removed by any of this. classroom-wonderink puts
|
|
# a lesson's worksheets here; see the DOWNLOADS comment in that file for
|
|
# why it is a directory of its own.
|
|
WONDERINK_DOWNLOADS = "Wonder Ink"
|
|
|
|
|
|
def presenting_lock():
|
|
"""The file that says a presentation is on the screen right now.
|
|
|
|
In the runtime directory, so it cannot outlive a reboot: a lock left
|
|
behind by a crash would otherwise make a presentation permanently
|
|
un-removable.
|
|
"""
|
|
return runtime_home() / "ekklesia-classroom-presenting"
|
|
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def stamp(moment=None):
|
|
return (moment or now()).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def parse_stamp(text):
|
|
if not text:
|
|
return None
|
|
|
|
try:
|
|
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
tzinfo=timezone.utc)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def write_json(path, data):
|
|
"""Write a JSON file so that a crash cannot leave half of one.
|
|
|
|
Everything the library knows is written this way. A presentation whose
|
|
metadata is truncated is a card a teacher cannot open and cannot
|
|
remove, which is worse than one that was never imported.
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + ".new")
|
|
|
|
with open(temporary, "w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def read_json(path, default=None):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
except (OSError, ValueError):
|
|
return default
|
|
|
|
|
|
# ------------------------------------------------------------- titles
|
|
|
|
# Things a download picks up on its way to the machine, none of which
|
|
# mean anything to a teacher.
|
|
_NOISE = re.compile(
|
|
r"""(
|
|
\s*\(\d+\)$ # Chrome's " (1)" for a second copy
|
|
| ^\d{8,}[-_\s]+ # a leading timestamp
|
|
| [-_\s]*\b[0-9a-f]{8,}\b # a hash or a UUID fragment
|
|
| \b(final|v\d+|copy|export|download)\b
|
|
)""",
|
|
re.IGNORECASE | re.VERBOSE,
|
|
)
|
|
|
|
|
|
def readable_title(filename, fallback_text=""):
|
|
"""A title a teacher would recognise, from a filename they did not choose.
|
|
|
|
Google Slides exports as "Session 7 - Romans.pptx" and a browser may
|
|
make that "Session_7_-_Romans%20(1).pptx". Neither a UUID nor a
|
|
40-character hash is a title, and a teacher scanning a wall of cards
|
|
for tonight's session should not have to read one.
|
|
"""
|
|
stem = Path(filename).stem
|
|
|
|
# Percent-escapes a download can carry, decoded only when they are
|
|
# obviously escapes rather than a literal per cent in a name.
|
|
if "%20" in stem or "%28" in stem:
|
|
for escape, character in (("%20", " "), ("%28", "("), ("%29", ")"),
|
|
("%26", "&"), ("%27", "'")):
|
|
stem = stem.replace(escape, character)
|
|
|
|
stem = unicodedata.normalize("NFC", stem)
|
|
stem = stem.replace("_", " ").replace("+", " ")
|
|
# A dash between words is a dash; a dash padding one is a space.
|
|
stem = re.sub(r"\s*-\s*", " - ", stem)
|
|
|
|
cleaned = _NOISE.sub(" ", stem)
|
|
cleaned = re.sub(r"\s+", " ", cleaned).strip(" -–—")
|
|
|
|
if len(cleaned) >= 3 and not re.fullmatch(r"[\W\d_]+", cleaned):
|
|
# Long enough to be a name and not purely punctuation and digits.
|
|
return cleaned[:80]
|
|
|
|
# The filename told us nothing. The first readable line of the
|
|
# document itself is a better answer than "a4f39c2e".
|
|
for line in (fallback_text or "").splitlines():
|
|
line = re.sub(r"\s+", " ", line).strip()
|
|
|
|
if len(line) >= 3:
|
|
return line[:80]
|
|
|
|
return "Presentation"
|
|
|
|
|
|
def unique_title(title, taken):
|
|
"""A title that is not already on another card.
|
|
|
|
Two files of the same name are two presentations, never one
|
|
overwriting the other, so the second has to be tellable from the
|
|
first on a wall of cards.
|
|
"""
|
|
if title not in taken:
|
|
return title
|
|
|
|
for number in range(2, 100):
|
|
candidate = f"{title} ({number})"
|
|
|
|
if candidate not in taken:
|
|
return candidate
|
|
|
|
return f"{title} ({uuid.uuid4().hex[:6]})"
|
|
|
|
|
|
# -------------------------------------------------------- the library
|
|
|
|
class Presentation:
|
|
"""One entry in the library, and the directory holding it."""
|
|
|
|
def __init__(self, directory, data):
|
|
self.directory = Path(directory)
|
|
self.data = data
|
|
|
|
# -- identity -------------------------------------------------
|
|
|
|
@property
|
|
def id(self):
|
|
return self.data.get("id") or self.directory.name
|
|
|
|
@property
|
|
def title(self):
|
|
return self.data.get("title") or "Presentation"
|
|
|
|
@property
|
|
def pages(self):
|
|
try:
|
|
return max(0, int(self.data.get("pages") or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
@property
|
|
def state(self):
|
|
return self.data.get("state") or "importing"
|
|
|
|
@property
|
|
def ready(self):
|
|
return self.state == "ready" and self.slides.exists()
|
|
|
|
@property
|
|
def failed(self):
|
|
return self.state == "failed"
|
|
|
|
@property
|
|
def error(self):
|
|
return self.data.get("error") or ""
|
|
|
|
# -- files ----------------------------------------------------
|
|
|
|
@property
|
|
def slides(self):
|
|
"""The PDF everything is presented and rendered from."""
|
|
return self.directory / "slides.pdf"
|
|
|
|
@property
|
|
def pages_directory(self):
|
|
return self.directory / "pages"
|
|
|
|
def page_image(self, number):
|
|
return self.pages_directory / f"page-{number:03d}.png"
|
|
|
|
@property
|
|
def thumbnail(self):
|
|
return self.directory / "thumbnail.png"
|
|
|
|
@property
|
|
def managed_copy(self):
|
|
name = self.data.get("managed_copy")
|
|
|
|
return self.directory / name if name else None
|
|
|
|
# -- what a teacher chose -------------------------------------
|
|
|
|
@property
|
|
def thumbnail_page(self):
|
|
"""Which slide is the card's picture.
|
|
|
|
Independent of where a presentation starts, and independent of
|
|
whether it has ever been presented. Changing it is not a
|
|
presentation and does not touch a retention timer — the three are
|
|
kept apart on purpose, because the first slide of a Bible study
|
|
session is a generic opening shared by every session and the one
|
|
that names the session is usually the second.
|
|
"""
|
|
try:
|
|
chosen = int(self.data.get("thumbnail_page") or 1)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
return min(max(chosen, 1), self.pages or 1)
|
|
|
|
# -- retention ------------------------------------------------
|
|
|
|
@property
|
|
def imported_at(self):
|
|
return parse_stamp(self.data.get("imported_at"))
|
|
|
|
@property
|
|
def last_presented_at(self):
|
|
return parse_stamp(self.data.get("last_presented_at"))
|
|
|
|
@property
|
|
def times_presented(self):
|
|
try:
|
|
return max(0, int(self.data.get("times_presented") or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
def expires_at(self):
|
|
"""When the cleanup may remove this, or None if it cannot say.
|
|
|
|
Two clocks, and only ever one of them running. A presentation
|
|
that has never been shown is kept for six months from the day it
|
|
arrived. One that has been shown is kept for a month from the
|
|
last time it was actually shown, and every showing starts that
|
|
month again.
|
|
"""
|
|
presented = self.last_presented_at
|
|
|
|
if presented is not None:
|
|
return presented + timedelta(days=AFTER_PRESENTING_DAYS)
|
|
|
|
imported = self.imported_at
|
|
|
|
if imported is None:
|
|
return None
|
|
|
|
return imported + timedelta(days=NEVER_PRESENTED_DAYS)
|
|
|
|
def expired(self, moment=None):
|
|
due = self.expires_at()
|
|
|
|
return due is not None and (moment or now()) >= due
|
|
|
|
# -- writing --------------------------------------------------
|
|
|
|
@property
|
|
def metadata_path(self):
|
|
return self.directory / "presentation.json"
|
|
|
|
def save(self):
|
|
write_json(self.metadata_path, self.data)
|
|
|
|
def set_thumbnail_page(self, number):
|
|
"""Choose the slide the library card shows.
|
|
|
|
Deliberately writes nothing else. This is not a presentation, it
|
|
is not an import, and it must not move a retention timer.
|
|
"""
|
|
number = min(max(int(number), 1), self.pages or 1)
|
|
self.data["thumbnail_page"] = number
|
|
self.save()
|
|
|
|
source = self.page_image(number)
|
|
|
|
if source.exists():
|
|
render_thumbnail(source, self.thumbnail)
|
|
|
|
return number
|
|
|
|
def mark_presented(self, moment=None):
|
|
"""Record that this was really shown to a room.
|
|
|
|
Called only after the presenter has a slide on the screen. Quick
|
|
Look, opening the library, generating previews and choosing a
|
|
thumbnail all deliberately do not call it, and neither does a
|
|
viewer that failed to start — a presentation that did not happen
|
|
must not buy another thirty days.
|
|
"""
|
|
self.data["last_presented_at"] = stamp(moment)
|
|
self.data["times_presented"] = self.times_presented + 1
|
|
self.save()
|
|
|
|
|
|
def load(directory):
|
|
directory = Path(directory)
|
|
data = read_json(directory / "presentation.json")
|
|
|
|
if not isinstance(data, dict):
|
|
return None
|
|
|
|
return Presentation(directory, data)
|
|
|
|
|
|
def library(root=None):
|
|
"""Every presentation, newest import first.
|
|
|
|
A directory that is half written — an import interrupted by a reboot,
|
|
say — has no readable metadata and is simply not in the list. The
|
|
importer clears those up; the library never shows a card it cannot
|
|
open.
|
|
"""
|
|
root = Path(root) if root is not None else library_root()
|
|
|
|
if not root.is_dir():
|
|
return []
|
|
|
|
found = []
|
|
|
|
for entry in sorted(root.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
|
|
presentation = load(entry)
|
|
|
|
if presentation is not None:
|
|
found.append(presentation)
|
|
|
|
found.sort(key=lambda p: (p.data.get("imported_at") or ""), reverse=True)
|
|
|
|
return found
|
|
|
|
|
|
def remove(presentation):
|
|
"""Delete exactly one managed entry, unless it is on screen now."""
|
|
try:
|
|
showing = presenting_lock().read_text().strip()
|
|
except OSError:
|
|
showing = ""
|
|
|
|
if presentation.id == showing:
|
|
return False
|
|
|
|
shutil.rmtree(presentation.directory, ignore_errors=True)
|
|
|
|
return not presentation.directory.exists()
|
|
|
|
|
|
def titles_in_use(root=None, exclude=None):
|
|
"""Every title already on a card, optionally ignoring one presentation.
|
|
|
|
Excluded BY ID rather than by title, which is not a detail. The import
|
|
being named is already in the library — it is written before the
|
|
conversion starts so an interruption is recoverable — so its own
|
|
provisional title has to come out of the set. Taking it out by title
|
|
removed the other presentation's identical title along with it, a set
|
|
holding one copy of a repeated string, and two files whose names
|
|
differ only in their extension both came out called "Session 7 -
|
|
Romans". Found in the lab, importing a PDF and a PPTX of the same
|
|
session, which is exactly what tomorrow's test does.
|
|
"""
|
|
return {
|
|
presentation.title
|
|
for presentation in library(root)
|
|
if exclude is None or presentation.id != exclude
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------ imports
|
|
|
|
def is_partial(path):
|
|
"""Whether this file is still arriving."""
|
|
path = Path(path)
|
|
|
|
if path.name.startswith("."):
|
|
return True
|
|
|
|
if path.suffix.lower() in PARTIAL_SUFFIXES:
|
|
return True
|
|
|
|
# Chrome's in-progress file sits beside the finished name.
|
|
for suffix in PARTIAL_SUFFIXES:
|
|
if path.with_name(path.name + suffix).exists():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def settled(path, seconds=SETTLE_SECONDS):
|
|
"""Whether the file has stopped being written to."""
|
|
try:
|
|
stat = Path(path).stat()
|
|
except OSError:
|
|
return False
|
|
|
|
return (time.time() - stat.st_mtime) >= seconds and stat.st_size > 0
|
|
|
|
|
|
def importable(path):
|
|
"""Whether this is a finished file the library should take."""
|
|
path = Path(path)
|
|
|
|
if path.suffix.lower() not in SUPPORTED:
|
|
return False
|
|
|
|
if not path.is_file():
|
|
return False
|
|
|
|
return not is_partial(path) and settled(path)
|
|
|
|
|
|
def within_wonderink(path, downloads=None):
|
|
"""Whether this file belongs to Wonder Ink's own download workflow.
|
|
|
|
Wonder Ink puts a lesson's worksheets in a directory of its own
|
|
inside Downloads. They are worksheets, not presentations: they are
|
|
not imported, and nothing in the library ever removes one.
|
|
"""
|
|
downloads = Path(downloads) if downloads is not None else downloads_root()
|
|
|
|
try:
|
|
relative = Path(path).resolve().relative_to(downloads.resolve())
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
return WONDERINK_DOWNLOADS in relative.parts
|
|
|
|
|
|
def candidates(downloads=None):
|
|
"""Finished presentation files sitting in the teacher's Downloads.
|
|
|
|
The top level only. Nothing recurses into Wonder Ink's directory,
|
|
and nothing looks anywhere else on the machine: a library that
|
|
imported from the whole home directory would be a file manager.
|
|
"""
|
|
downloads = Path(downloads) if downloads is not None else downloads_root()
|
|
|
|
if not downloads.is_dir():
|
|
return []
|
|
|
|
found = []
|
|
|
|
try:
|
|
entries = sorted(downloads.iterdir())
|
|
except OSError:
|
|
return []
|
|
|
|
for entry in entries:
|
|
if entry.is_dir():
|
|
continue
|
|
|
|
if within_wonderink(entry, downloads):
|
|
continue
|
|
|
|
if importable(entry):
|
|
found.append(entry)
|
|
|
|
return found
|
|
|
|
|
|
def fingerprint(path):
|
|
"""What makes this exact download this exact download.
|
|
|
|
Path, size and modification time. Enough that re-downloading the same
|
|
session tomorrow is a new presentation, and that a reboot does not
|
|
import everything in Downloads all over again.
|
|
"""
|
|
try:
|
|
stat = Path(path).stat()
|
|
except OSError:
|
|
return None
|
|
|
|
return f"{Path(path).resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
|
|
|
|
|
|
def ledger_path(root=None):
|
|
root = Path(root) if root is not None else library_root()
|
|
|
|
return root / "imported.json"
|
|
|
|
|
|
def ledger(root=None):
|
|
data = read_json(ledger_path(root), default={})
|
|
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def remember(mark, value, root=None):
|
|
"""Record that this download has been dealt with.
|
|
|
|
The normal successful import moves the source out of Downloads, so it
|
|
cannot be found by a later scan. The ledger is still the transaction
|
|
record that makes an interrupted same- or cross-filesystem move safe:
|
|
startup can tell which half-built directory belongs to which source.
|
|
"""
|
|
data = ledger(root)
|
|
data[mark] = value
|
|
write_json(ledger_path(root), data)
|
|
|
|
|
|
# ----------------------------------------------------- rendering
|
|
|
|
# Everything below shells out. Nothing here draws a slide: LibreOffice
|
|
# converts a deck to PDF exactly as Impress would have rendered it live,
|
|
# and poppler turns PDF pages into pictures. The classroom's own code
|
|
# only ever displays a picture one of them produced.
|
|
|
|
SOFFICE = "soffice"
|
|
PDFTOPPM = "pdftoppm"
|
|
PDFINFO = "pdfinfo"
|
|
PDFTOTEXT = "pdftotext"
|
|
|
|
# A large deck on a slow machine. Generous, because the alternative to
|
|
# waiting is a half-converted file presented to a room.
|
|
CONVERT_TIMEOUT = 300
|
|
RENDER_TIMEOUT = 600
|
|
|
|
|
|
def _run(command, timeout, **kwargs):
|
|
import subprocess
|
|
|
|
try:
|
|
return subprocess.run(
|
|
command, capture_output=True, text=True, timeout=timeout,
|
|
check=False, **kwargs)
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
class Failed:
|
|
returncode = 127
|
|
stdout = ""
|
|
stderr = str(error)
|
|
|
|
return Failed()
|
|
|
|
|
|
def convert_to_pdf(source, destination):
|
|
"""Turn a PPT or PPTX into the PDF everything else works from.
|
|
|
|
Headless, in a profile of its own, with no window on any screen. A
|
|
teacher never sees LibreOffice: not its splash, not its first-run
|
|
dialog, and above all not its editor. It runs once, at import, and
|
|
then never again for that presentation.
|
|
|
|
A separate -env:UserInstallation is not optional. LibreOffice takes
|
|
an exclusive lock on its user profile, so a conversion running while
|
|
anything else on the machine uses LibreOffice would fail, and the
|
|
failure would arrive as a presentation that never finished importing.
|
|
"""
|
|
source = Path(source)
|
|
destination = Path(destination)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
workspace = destination.parent / "convert"
|
|
profile = destination.parent / "loprofile"
|
|
|
|
if workspace.exists():
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
|
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
profile.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = _run(
|
|
[
|
|
SOFFICE,
|
|
f"-env:UserInstallation=file://{profile}",
|
|
"--headless", "--norestore", "--nologo", "--nolockcheck",
|
|
"--nodefault", "--nofirststartwizard",
|
|
"--convert-to", "pdf:impress_pdf_Export",
|
|
"--outdir", str(workspace),
|
|
str(source),
|
|
],
|
|
CONVERT_TIMEOUT,
|
|
)
|
|
|
|
produced = sorted(workspace.glob("*.pdf"))
|
|
|
|
if not produced:
|
|
shutil.rmtree(profile, ignore_errors=True)
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
|
|
return False, (result.stderr or result.stdout or
|
|
"the file could not be opened").strip()
|
|
|
|
os.replace(produced[0], destination)
|
|
shutil.rmtree(profile, ignore_errors=True)
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
|
|
return True, ""
|
|
|
|
|
|
def page_count(pdf):
|
|
"""How many slides, according to poppler."""
|
|
result = _run([PDFINFO, str(pdf)], 60)
|
|
|
|
for line in (result.stdout or "").splitlines():
|
|
if line.startswith("Pages:"):
|
|
try:
|
|
return int(line.split(":", 1)[1].strip())
|
|
except ValueError:
|
|
return 0
|
|
|
|
return 0
|
|
|
|
|
|
def first_text(pdf, pages=2):
|
|
"""A little of the document's own words, for a title of last resort."""
|
|
result = _run([PDFTOTEXT, "-f", "1", "-l", str(pages), str(pdf), "-"], 60)
|
|
|
|
return result.stdout or ""
|
|
|
|
|
|
def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
|
|
"""Every slide as a picture, at the size a television shows it.
|
|
|
|
One pdftoppm run for the whole document rather than one per page:
|
|
measured in the lab at 0.03s for ten pages, against a process launch
|
|
each time. `progress` is called with the number rendered so far, so a
|
|
library window can show a large import filling in rather than freeze.
|
|
"""
|
|
pdf = Path(pdf)
|
|
directory = Path(directory)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
import subprocess
|
|
|
|
command = [
|
|
PDFTOPPM, "-png",
|
|
"-scale-to-x", str(width), "-scale-to-y", "-1",
|
|
str(pdf), str(directory / "page"),
|
|
]
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True)
|
|
except OSError as error:
|
|
return 0, str(error)
|
|
|
|
deadline = time.monotonic() + RENDER_TIMEOUT
|
|
|
|
while process.poll() is None:
|
|
if time.monotonic() > deadline:
|
|
process.kill()
|
|
return 0, "rendering took too long"
|
|
|
|
if progress is not None:
|
|
progress(len(list(directory.glob("page-*.png"))))
|
|
|
|
time.sleep(0.4)
|
|
|
|
if process.returncode != 0:
|
|
return 0, (process.stderr.read() if process.stderr else
|
|
"the slides could not be rendered")
|
|
|
|
# pdftoppm numbers by the page count's width: page-1 for a nine-page
|
|
# document, page-01 for a ninety-page one. The library asks for
|
|
# page-001, so they are renamed into one shape and nothing that reads
|
|
# them has to know how many there were.
|
|
rendered = sorted(directory.glob("page-*.png"))
|
|
|
|
for number, path in enumerate(rendered, start=1):
|
|
wanted = directory / f"page-{number:03d}.png"
|
|
|
|
if path != wanted:
|
|
os.replace(path, wanted)
|
|
|
|
return len(rendered), ""
|
|
|
|
|
|
def render_thumbnail(source, destination, width=THUMBNAIL_WIDTH):
|
|
"""The small picture on a library card, from an already rendered page."""
|
|
source = Path(source)
|
|
destination = Path(destination)
|
|
|
|
if not source.exists():
|
|
return False
|
|
|
|
try:
|
|
import gi
|
|
|
|
gi.require_version("GdkPixbuf", "2.0")
|
|
from gi.repository import GdkPixbuf
|
|
|
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
|
str(source), width, -1, True)
|
|
temporary = destination.with_name(destination.name + ".new")
|
|
pixbuf.savev(str(temporary), "png", [], [])
|
|
os.replace(temporary, destination)
|
|
|
|
return True
|
|
except Exception: # noqa: BLE001 - a card without a picture is fine
|
|
# A thumbnail is a convenience. A library that refused to show a
|
|
# presentation because it could not shrink a picture would be
|
|
# worse than one showing a card with no picture on it.
|
|
return False
|