diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d8bbd..a251666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 libpulse0 + libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ + libpulse0 fonts-dejavu-core - name: Install Python dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index d7d7a62..d77ffec 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ expanded production workflow: - Full-resolution JPG/PNG extraction from the current display view. - Excel-friendly UTF-8 CSV export for comments, text and drawing notes, including frame, SMPTE timecode, normalized pin position and resolved state. +- Searchable PDF review reports with a shot cover, annotated full-resolution + frames, wrapped comments, matching pin numbers and per-frame timecode. - High-quality MP4 conversion from movies or image sequences, preserving movie FPS/audio and allowing user-defined image-sequence FPS. - PyInstaller-based Windows portable packaging and Ubuntu AppImage packaging. diff --git a/tests/conftest.py b/tests/conftest.py index 63d38b9..791c68e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,36 @@ import pytest +# Qt no longer ships fonts, and the offscreen platform plugin finds none on its +# own: QFontDatabase.families() comes back EMPTY. Every drawText() then silently +# draws nothing -- no glyphs in a rendered widget, no text operators in a +# generated PDF -- so any test asserting on drawn text fails for reasons that +# have nothing to do with the code under test. Register a real font so text +# rendering is exercised for real. +_FONT_CANDIDATES = ( + r"C:\Windows\Fonts\arial.ttf", + r"C:\Windows\Fonts\segoeui.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/System/Library/Fonts/Helvetica.ttc", +) + + +def _ensure_font(): + """Give Qt at least one usable font family. Returns True when it has one.""" + from PySide6.QtGui import QFontDatabase + + if QFontDatabase.families(): + return True + + for candidate in _FONT_CANDIDATES: + if os.path.exists(candidate): + QFontDatabase.addApplicationFont(candidate) + if QFontDatabase.families(): + return True + + return bool(QFontDatabase.families()) + @pytest.fixture(scope="session") def qapp(): @@ -38,5 +68,14 @@ def qapp(): from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) + _ensure_font() yield app # Do not call app.quit(): a session-wide instance is reused across tests. + + +@pytest.fixture(scope="session") +def qfont(qapp): + """Skip a text-rendering test when the environment has no font at all.""" + if not _ensure_font(): + pytest.skip("no font available to Qt; text cannot be rendered") + return True diff --git a/tests/test_pdf_report.py b/tests/test_pdf_report.py new file mode 100644 index 0000000..bd192fc --- /dev/null +++ b/tests/test_pdf_report.py @@ -0,0 +1,248 @@ +"""Tests for the PDF review report. + +The report is verified by reading the written PDF back with pypdf, so the +assertions are about what a recipient actually gets -- not about what the +builder believed it drew. +""" + +import re + +import pytest + +from PySide6.QtGui import QColor, QImage + +import constants + +from widgets import pdfreport + + +def _image(width=320, height=180, color=(200, 40, 40)): + image = QImage(width, height, QImage.Format.Format_RGB888) + image.fill(QColor(*color)) + return image + + +def _page(frame, comments=None, strokes=0, timecode="00:00:01:00"): + return { + "frame": frame, + "timecode": timecode, + "image": _image(), + "comments": comments or [], + "stroke_count": strokes, + } + + +def _comment(text, done=False, pinned=False): + comment = {"id": text, "text": text, "done": done, "timestamp": "now"} + if pinned: + comment["x"] = 0.5 + comment["y"] = 0.5 + return comment + + +def _read(path): + """Return (reader, text) with whitespace normalized. + + Qt positions each word with its own text-showing operator, so pypdf renders + the inter-word gaps as tabs rather than spaces. That is a property of how the + glyphs are laid out, not of the words themselves, so the runs are collapsed + before asserting on them. + """ + from pypdf import PdfReader + + reader = PdfReader(str(path)) + raw = "\n".join(page.extract_text() or "" for page in reader.pages) + return reader, re.sub(r"\s+", " ", raw) + + +# --------------------------------------------------------------------------- # +# Structure +# --------------------------------------------------------------------------- # +def test_report_has_a_cover_plus_one_page_per_frame(tmp_path, qapp): + path = tmp_path / "review.pdf" + + written = pdfreport.build_report( + path, + [_page(10), _page(25), _page(41)], + meta={"shot": "KP_010_020_comp_v003"}, + ) + + assert written == 4 # cover + 3 frames + + reader, _text = _read(path) + assert len(reader.pages) == 4 + + +def test_a_report_with_no_pages_is_still_a_valid_pdf(tmp_path, qapp): + path = tmp_path / "empty.pdf" + + assert pdfreport.build_report(path, [], meta={"shot": "empty"}) == 1 + + reader, _text = _read(path) + assert len(reader.pages) == 1 + + +def test_the_pdf_carries_a_title(tmp_path, qapp): + path = tmp_path / "titled.pdf" + pdfreport.build_report(path, [_page(1)], meta={"shot": "KP_010_020"}) + + reader, _text = _read(path) + + assert "KP_010_020" in (reader.metadata.title or "") + assert constants.VL_TOOL_NAME in (reader.metadata.title or "") + + +# --------------------------------------------------------------------------- # +# Content that a recipient actually reads +# --------------------------------------------------------------------------- # +def test_comments_survive_into_the_pdf_text(tmp_path, qapp, qfont): + path = tmp_path / "notes.pdf" + + pdfreport.build_report( + path, + [ + _page(12, comments=[_comment("warm the key light")]), + _page(30, comments=[_comment("soften the matte edge")]), + ], + meta={"shot": "KP_010_020"}, + ) + + _reader, text = _read(path) + + # Text is drawn with embedded fonts rather than baked into the image, so it + # stays selectable and searchable in the delivered file. + assert "warm the key light" in text + assert "soften the matte edge" in text + + +def test_long_comments_wrap_without_losing_the_tail(tmp_path, qapp, qfont): + path = tmp_path / "long-note.pdf" + long_note = ( + "Reduce the bright edge around the foreground character and preserve " + "the fine hair detail through the final delivery render." + ) + + pdfreport.build_report( + path, + [_page(12, comments=[_comment(long_note)])], + meta={"shot": "KP_010_020"}, + ) + + _reader, text = _read(path) + assert "fine hair detail through the final delivery render" in text + + +def test_the_cover_identifies_the_shot_and_counts_the_notes(tmp_path, qapp, qfont): + path = tmp_path / "cover.pdf" + + pdfreport.build_report( + path, + [ + _page(12, comments=[_comment("one"), _comment("two")]), + _page(30, comments=[_comment("three")]), + ], + meta={"shot": "KP_010_020_comp_v003", "source": "/show/kp/plate.exr", + "fps": "24"}, + ) + + _reader, text = _read(path) + + assert "KP_010_020_comp_v003" in text + assert "/show/kp/plate.exr" in text + assert "Annotated frames" in text + assert "Comments" in text + + +def test_frames_are_labelled_with_number_and_timecode(tmp_path, qapp, qfont): + path = tmp_path / "frames.pdf" + + pdfreport.build_report( + path, + [_page(42, timecode="00:00:01:18", comments=[_comment("check this")])], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + assert "0042" in text # zero-padded to the project's frame padding + assert "00:00:01:18" in text + + +def test_resolved_comments_are_marked(tmp_path, qapp, qfont): + path = tmp_path / "resolved.pdf" + + pdfreport.build_report( + path, + [_page(5, comments=[_comment("fixed already", done=True)])], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + assert "fixed already" in text + assert "resolved" in text + + +def test_pinned_comments_are_numbered_to_match_their_markers(tmp_path, qapp, qfont): + path = tmp_path / "pins.pdf" + + pdfreport.build_report( + path, + [ + _page( + 7, + comments=[ + _comment("frame level note"), + _comment("first pin", pinned=True), + _comment("second pin", pinned=True), + ], + ) + ], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + # The numbers must line up with the pins drawn on the frame above, or a note + # cannot be traced back to the spot it refers to. + assert "1." in text + assert "2." in text + assert "first pin" in text + assert "second pin" in text + + +def test_a_frame_with_only_drawings_still_gets_a_page(tmp_path, qapp, qfont): + path = tmp_path / "drawings.pdf" + + written = pdfreport.build_report( + path, [_page(9, strokes=3)], meta={"shot": "shot"} + ) + + assert written == 2 + + _reader, text = _read(path) + assert "3 drawn note(s)" in text # summarized on the cover + + +def test_a_missing_image_does_not_break_the_page(tmp_path, qapp, qfont): + path = tmp_path / "noimage.pdf" + + page = _page(3, comments=[_comment("the frame failed to decode")]) + page["image"] = None + + written = pdfreport.build_report(path, [page], meta={"shot": "shot"}) + + assert written == 2 + + _reader, text = _read(path) + # The note still has to reach the recipient even if the frame did not. + assert "the frame failed to decode" in text + + +def test_an_unwritable_path_raises_rather_than_failing_silently(tmp_path, qapp): + with pytest.raises(OSError): + pdfreport.build_report( + tmp_path / "no such directory" / "out.pdf", + [_page(1)], + meta={"shot": "shot"}, + ) diff --git a/widgets/__init__.py b/widgets/__init__.py index e0c8e48..f7ddef7 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -26,6 +26,8 @@ import os import json +import datetime +import numpy import utils import logger @@ -40,6 +42,8 @@ from playback import stats as statsmath from utils import notescsv +from widgets import pdfreport + from PySide6 import QtGui from PySide6 import QtCore from PySide6 import QtWidgets @@ -445,6 +449,11 @@ def setup_review_chrome(self): self.actionExportNotes.setIcon(NamePixmapIcon("recaps")) self.actionExportNotes.triggered.connect(self.export_notes) file_menu.addAction(self.actionExportNotes) + self.actionExportPdf = QtGui.QAction("Export Review PDF...", self) + self.actionExportPdf.setIcon(NamePixmapIcon("export")) + self.actionExportPdf.setShortcut(QtGui.QKeySequence("Ctrl+Alt+P")) + self.actionExportPdf.triggered.connect(self.export_pdf_report) + file_menu.addAction(self.actionExportPdf) self.actionExportNotesCsv = QtGui.QAction("Export Notes as CSV...", self) self.actionExportNotesCsv.setIcon(NamePixmapIcon("export")) self.actionExportNotesCsv.triggered.connect(self.export_notes_csv) @@ -2669,6 +2678,163 @@ def export_notes_csv(self): 5000, ) + def export_pdf_report(self): + """Render every annotated frame and its notes into a review PDF.""" + annotations = self.viewframe.viewer.annotations + frames = annotations.annotated_frames() + + if not frames: + QtWidgets.QMessageBox.information( + self, + "Export Review PDF", + "There are no notes to export yet.", + ) + return + if not self.current_source_filepath: + return + + shot_name = os.path.splitext( + os.path.basename(self.current_source_filepath) + )[0] + + filepath, _selected = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export review PDF", + os.path.join( + self.browsepath or os.path.dirname(self.current_source_filepath), + f"{shot_name}_review.pdf", + ), + "PDF files (*.pdf)", + ) + if not filepath: + return + + if self.player.player is not None: + self.player.player.pause() + if self.compare_player.player is not None: + self.compare_player.player.pause() + self.viewframe.timelineToolbarLayout.playPauseButton.switch(False) + + source = self.media_cache.resolve(self.current_source_filepath) + reader = None + pages = list() + failed = list() + + progress = QtWidgets.QProgressDialog( + "Rendering review pages...", "Cancel", 0, len(frames), self + ) + progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + + try: + extension = os.path.splitext(source)[1].lower() + reader = ( + SequenceReader(source, review_proxy=False) + if extension in PlaylistWidget.IMAGE_EXTENSIONS + else MovieReader(source) + ) + fps = max(0.001, reader.get_fps()) + ocio_processor = self.player.ocio_processor + active_aov = getattr(self.player.player, "current_aov", "rgb") + + for index, frame_number in enumerate(frames, start=1): + if progress.wasCanceled(): + break + progress.setLabelText(f"Rendering frame {frame_number}...") + progress.setValue(index - 1) + QtWidgets.QApplication.processEvents() + + rendered = None + try: + if reader.media_type == "sequence": + image = reader.get_frame( + frame_number, + aov=active_aov, + ocio_processor=ocio_processor, + ) + else: + seconds = (frame_number - constants.VL_START_FRAME) / fps + video_frame = reader.seek_time(seconds) + image = ( + video_frame.to_ndarray(format="rgb24") + if video_frame is not None + else None + ) + if image is not None and ocio_processor is not None: + image = image.astype(numpy.float32) / 255.0 + image = ocio_processor.process_image(image) + image = ( + numpy.clip(image, 0.0, 1.0) * 255.0 + ).astype(numpy.uint8) + + rendered = self.viewframe.viewer.render_annotated_frame( + image, frame_number + ) + except Exception: + LOGGER.exception( + "Unable to render PDF frame %s", frame_number + ) + + if rendered is None: + failed.append(frame_number) + + # Keep the review note even when its picture cannot be decoded; + # silently dropping the entire page loses the handoff content. + pages.append( + { + "frame": frame_number, + "timecode": timecode.frame_to_timecode( + max(0, frame_number - constants.VL_START_FRAME), + self._current_fps(), + ), + "image": rendered, + "comments": annotations.get_comments(frame_number), + "stroke_count": len(annotations.strokes.get(frame_number, [])), + } + ) + + progress.setValue(len(frames)) + + if not pages: + QtWidgets.QMessageBox.warning( + self, + "Export Review PDF", + "None of the annotated frames could be rendered.", + ) + return + + written = pdfreport.build_report( + filepath, + pages, + meta={ + "shot": shot_name, + "source": self.current_source_filepath, + "date": datetime.datetime.now().strftime( + constants.DATE_TIME_FORMAT + ), + "fps": f"{self._current_fps():g}" if self._current_fps() else "", + }, + ) + except (OSError, RuntimeError, ValueError): + LOGGER.exception("Unable to write review PDF") + QtWidgets.QMessageBox.warning( + self, + "Export Review PDF", + f"Could not export the review PDF to:\n{filepath}", + ) + return + finally: + progress.close() + if reader is not None: + reader.close() + + message = f"Exported {written} page review PDF to {filepath}" + if failed: + # Say which frames are missing rather than shipping a quietly + # incomplete report. + message = f"{message} ({len(failed)} frame(s) could not be rendered)" + self.statusBar().showMessage(message, 6000) + def export_notes(self): """Export every annotated frame as a PNG with notes burned in.""" frames = self.viewframe.viewer.annotations.annotated_frames() diff --git a/widgets/pdfreport.py b/widgets/pdfreport.py new file mode 100644 index 0000000..c9e36f1 --- /dev/null +++ b/widgets/pdfreport.py @@ -0,0 +1,360 @@ +"""Render a review report to PDF. + +The artifact a supervisor actually hands over: the annotated frames, the notes +that go with them, and enough identification that the recipient knows which shot +and which version they are looking at. + +Written with Qt's own QPdfWriter, so there is no new dependency -- Qt is already +required by the app, and it embeds its own fonts, so the text stays selectable +and searchable in the delivered file rather than being baked into the image. + +The builder takes plain page dicts, not a player or a reader, so a report can be +laid out and verified without decoding anything. +""" + +from __future__ import absolute_import + +from PySide6 import QtCore +from PySide6 import QtGui + +import constants + +# Rendered at print resolution: a note pinned to a 4K plate has to stay legible +# when someone prints the page and marks it up by hand. +RESOLUTION_DPI = 150 + +# Page furniture, in device pixels at RESOLUTION_DPI. +MARGIN = 60 +HEADER_HEIGHT = 70 +FOOTER_HEIGHT = 40 + +ACCENT = QtGui.QColor(224, 174, 74) +INK = QtGui.QColor(24, 24, 24) +MUTED = QtGui.QColor(110, 110, 110) +RULE = QtGui.QColor(205, 205, 205) + + +def _font(size, bold=False): + font = QtGui.QFont("Helvetica") + font.setPointSizeF(size) + font.setBold(bold) + return font + + +def _page_rect(writer): + """Return the printable area in device pixels.""" + return writer.pageLayout().paintRectPixels(writer.resolution()) + + +def _draw_header(painter, rect, left_text, right_text): + """Draw the running header and return the y below it.""" + painter.setFont(_font(13, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), rect.top(), rect.width() * 0.7, HEADER_HEIGHT), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignLeft, + left_text, + ) + + painter.setFont(_font(10)) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF( + rect.left() + rect.width() * 0.7, + rect.top(), + rect.width() * 0.3, + HEADER_HEIGHT, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignRight, + right_text, + ) + + baseline = rect.top() + HEADER_HEIGHT + painter.setPen(QtGui.QPen(ACCENT, 2)) + painter.drawLine(rect.left(), baseline, rect.right(), baseline) + + return baseline + 24 + + +def _draw_footer(painter, rect, text): + painter.setFont(_font(8)) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF( + rect.left(), + rect.bottom() - FOOTER_HEIGHT, + rect.width(), + FOOTER_HEIGHT, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignRight, + text, + ) + + +def _draw_cover(painter, rect, meta, pages): + """Draw the summary page: what this is, and what is in it.""" + y = _draw_header(painter, rect, constants.VL_TOOL_NAME + " Review Notes", meta.get("date", "")) + + painter.setFont(_font(24, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 50), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + meta.get("shot", "Untitled"), + ) + y += 66 + + rows = [ + ("Source", meta.get("source", "")), + ("Frame rate", meta.get("fps", "")), + ("Annotated frames", str(len(pages))), + ("Comments", str(sum(len(page.get("comments") or []) for page in pages))), + ] + + painter.setFont(_font(10)) + for label, value in rows: + if not value: + continue + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 150, 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + label, + ) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 160, y, rect.width() - 160, 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + str(value), + ) + y += 26 + + y += 20 + painter.setPen(QtGui.QPen(RULE, 1)) + painter.drawLine(rect.left(), y, rect.right(), y) + y += 20 + + # Contents: every annotated frame, so the reader can find a note without + # paging through the whole report. + painter.setFont(_font(11, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "Contents", + ) + y += 30 + + painter.setFont(_font(9)) + for index, page in enumerate(pages, start=2): # page 1 is this cover + if y > rect.bottom() - FOOTER_HEIGHT - 20: + break + summary = _first_comment(page) or "{0} drawn note(s)".format( + page.get("stroke_count", 0) + ) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 220, 22), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "p{0} Frame {1} {2}".format( + index, + str(page["frame"]).zfill(constants.VL_FRAME_PADDING), + page.get("timecode", ""), + ), + ) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 240, y, rect.width() - 240, 22), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + summary, + ) + y += 22 + + +def _first_comment(page): + comments = page.get("comments") or [] + return comments[0].get("text", "") if comments else "" + + +def _comment_text(comment): + text = str(comment.get("text", "")) + if comment.get("done"): + text = "{0} [resolved]".format(text) + return text + + +def _comment_height(font, text, width): + """Return enough vertical space to preserve a wrapped review comment.""" + flags = ( + int(QtCore.Qt.AlignmentFlag.AlignLeft) + | int(QtCore.Qt.AlignmentFlag.AlignTop) + | int(QtCore.Qt.TextFlag.TextWordWrap) + ) + bounds = QtGui.QFontMetricsF(font).boundingRect( + QtCore.QRectF(0, 0, max(1.0, width), 10000), flags, text + ) + return max(26.0, bounds.height() + 8.0) + + +def _draw_frame_page(painter, rect, meta, page, number, total): + """Draw one annotated frame with its notes underneath.""" + y = _draw_header( + painter, + rect, + "{0} Frame {1}".format( + meta.get("shot", ""), + str(page["frame"]).zfill(constants.VL_FRAME_PADDING), + ), + page.get("timecode", ""), + ) + + comments = page.get("comments") or [] + + # The notes get the room they need; the frame takes what is left. Measure + # wrapped text instead of assuming every note fits on one 24-pixel line. + note_font = _font(9) + comment_width = rect.width() - 32 + comment_heights = [ + _comment_height(note_font, _comment_text(comment), comment_width) + for comment in comments + ] + available_height = rect.bottom() - FOOTER_HEIGHT - 20 - y + desired_note_height = 30 + sum(comment_heights) if comments else 0 + note_height = min( + max(0.0, available_height - 60.0), + desired_note_height, + ) + image_area = QtCore.QRectF( + rect.left(), + y, + rect.width(), + rect.bottom() - FOOTER_HEIGHT - 20 - note_height - y, + ) + + image = page.get("image") + if image is not None and not image.isNull() and image_area.height() > 40: + scaled = image.scaled( + int(image_area.width()), + int(image_area.height()), + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation, + ) + target = QtCore.QRectF( + image_area.left() + (image_area.width() - scaled.width()) / 2.0, + image_area.top(), + scaled.width(), + scaled.height(), + ) + painter.drawImage(target, scaled) + painter.setPen(QtGui.QPen(RULE, 1)) + painter.drawRect(target) + + if comments: + y = rect.bottom() - FOOTER_HEIGHT - 20 - note_height + painter.setFont(_font(10, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 24), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "Notes", + ) + y += 26 + + painter.setFont(note_font) + pin = 0 + text_flags = ( + int(QtCore.Qt.AlignmentFlag.AlignLeft) + | int(QtCore.Qt.AlignmentFlag.AlignTop) + | int(QtCore.Qt.TextFlag.TextWordWrap) + ) + for comment, row_height in zip(comments, comment_heights): + if y + row_height > rect.bottom() - FOOTER_HEIGHT: + break + + pinned = "x" in comment and "y" in comment + if pinned: + pin += 1 + + # The marker number matches the pin drawn on the frame above, so a + # note can be traced back to the exact spot it refers to. + marker = "{0}.".format(pin) if pinned else "-" + painter.setPen(ACCENT if pinned else MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 30, row_height), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop, + marker, + ) + + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 32, y, comment_width, row_height), + text_flags, + _comment_text(comment), + ) + y += row_height + + _draw_footer(painter, rect, "Page {0} of {1}".format(number, total)) + + +def build_report(filepath, pages, meta=None): + """Write a review PDF. + + Args: + filepath (str): + Destination path. + + pages (list[dict]): + One entry per annotated frame: + ``{"frame": int, "timecode": str, "image": QImage, + "comments": [comment dicts], "stroke_count": int}`` + + meta (dict): + ``{"shot": str, "source": str, "date": str, "fps": str}`` + + Returns: + int: The number of pages written (cover included). + """ + + meta = meta or dict() + pages = list(pages or []) + + writer = QtGui.QPdfWriter(str(filepath)) + writer.setPageSize(QtGui.QPageSize(QtGui.QPageSize.PageSizeId.A4)) + writer.setPageOrientation(QtGui.QPageLayout.Orientation.Landscape) + writer.setResolution(RESOLUTION_DPI) + writer.setTitle( + "{0} review notes - {1}".format( + constants.VL_TOOL_NAME, meta.get("shot", "Untitled") + ) + ) + writer.setPageMargins( + QtCore.QMarginsF(10, 10, 10, 10), QtGui.QPageLayout.Unit.Millimeter + ) + + painter = QtGui.QPainter() + if not painter.begin(writer): + raise OSError("Could not open the PDF for writing: {0}".format(filepath)) + + total = len(pages) + 1 + + try: + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform, True) + + rect = QtCore.QRectF(_page_rect(writer)) + + _draw_cover(painter, rect, meta, pages) + _draw_footer(painter, rect, "Page 1 of {0}".format(total)) + + for index, page in enumerate(pages, start=2): + writer.newPage() + _draw_frame_page(painter, rect, meta, page, index, total) + finally: + painter.end() + + return total + + +if __name__ == "__main__": + pass