Skip to content
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
97 changes: 97 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Pympress — Copilot Instructions

Pympress is a dual-screen PDF presentation tool (like PowerPoint Presenter View) built with **GTK 3** (PyGObject) and **Poppler**. Licensed GPLv2+.

## Quick Reference

| Action | Command |
|---|---|
| Install (dev) | `pip install -e .` |
| Install deps only | `pip install -r requirements.txt` |
| Lint | `flake8 . --count --show-source --statistics --select=E,F,W,C` |
| Lint docstrings | `flake8 . --select=D` |
| Build docs | `sphinx-build -b html docs build/sphinx/html` |
| Extract i18n | `pybabel extract -F pympress/share/locale/babel_mapping.cfg -o pympress/share/locale/pympress.pot .` |
| Compile translations | `pybabel compile -d pympress/share/locale/ -D pympress` |
| Run | `python -m pympress [file.pdf]` |

There is **no automated test suite** — linting (flake8) is the primary CI check.

## Architecture

Component-based, event-driven design around a central `UI` object. Not strict MVC, but clear separation:

- **Model**: `Document` / `Page` in [pympress/document.py](../pympress/document.py) — pure PDF data, no GTK imports except rendering
- **View**: Glade XML files in `pympress/share/xml/` define widget trees, loaded by `Builder`
- **Controller**: `UI` (in [pympress/ui.py](../pympress/ui.py)) owns all sub-components and handles events

### Key modules

| Module | Role |
|---|---|
| `ui.py` | Main `UI(Builder)` class — creates Content + Presenter windows, wires everything |
| `document.py` | `Document`, `Page`, `PdfPage` — GUI-independent PDF model via Poppler |
| `builder.py` | `Builder(Gtk.Builder)` — loads Glade XML, auto-translates strings, introspectively binds widgets to object attributes by matching IDs to `None`-valued class attributes |
| `config.py` | `Config(ConfigParser)` — `defaults.conf` → user config → CLI overrides. Layouts stored as JSON within INI |
| `extras.py` | Auxiliary features: `Media`, `TimingReport`, `Annotations`, `Zoom`, `Cursor`, `FileWatcher` |
| `scribble.py` | Freehand drawing/annotation overlay on slides |
| `pointer.py` | Software laser pointer |
| `talk_time.py` | Elapsed/remaining time with color-coded warnings |
| `editable_label.py` | Click-to-edit labels (page number, estimated talk time) |
| `surfacecache.py` | Thread-safe LRU cache of rendered `cairo.ImageSurface` pages |
| `util.py` | Platform detection (`IS_WINDOWS`/`IS_MAC_OS`/`IS_POSIX`), resource paths, icon/CSS loading |
| `__main__.py` | Entry point — CLI parsing (`getopt`), locale/gettext setup, `Gtk.main()` |

### Media overlays (plugin system)

`pympress/media_overlays/` provides a backend abstraction for video/animation playback:

- **Base**: `VideoOverlay(Builder)` in `base.py` — abstract interface (`do_play`, `do_stop`, etc.), overlay positioning, progress bar
- **GIF**: `gif_backend.py` — `GdkPixbuf.PixbufAnimation` frame-by-frame rendering
- **GStreamer**: `gst_backend.py` — `GstPlayer.Player` for full video
- **VLC**: `vlc_backend.py` — `vlc.MediaPlayer` for full video
- Backends are lazily registered by mime type in `Media._setup_backends()` (`extras.py`). Priority: GIF → GStreamer → VLC (last becomes default catch-all)
- Each media element creates **two** overlay instances (content window muted + presenter window with audio)

### Wiring pattern

`UI.__init__()` creates sub-components → calls `Builder.load_ui(name)` for each Glade file → `connect_signals(self)` resolves signal handlers via dot-path notation (e.g. `doc.goto_page` resolves `self.doc.goto_page`).

## Code Conventions

- **Style**: PEP 8, 120-char line limit, 4-space indent — see `[style]` and `[flake8]` in [setup.cfg](../setup.cfg)
- **Docstrings**: Google style with types in backticks (e.g. `` `str` ``), no type annotations in signatures
- **Naming**: `snake_case` functions/variables, `PascalCase` classes, `_`/`__` prefix for private
- **Imports**: standard lib → `gi.repository` → `pympress` modules. `from __future__ import print_function, unicode_literals` in every file (Python 2/3 compat)
- **gettext**: `_()` builtin installed by `gettext.install()`. All user-facing strings wrapped in `_()`
- **Widget binding**: Declare `attribute = None` on the class, give the Glade widget the same `id` — `Builder` auto-assigns it
- **Flake8 config**: Some rules are deliberately ignored (alignment flexibility) — see `[flake8]` in setup.cfg before "fixing" style warnings

## Internationalization

- Translations managed via [POEditor](https://poeditor.com/join/project/nKfRxeN8pS) — do not edit `.po` files by hand for existing languages
- Extraction: `babel` + `babelgladeextractor` from both Python and Glade sources
- `.mo` compiled at install time. Supported locales: cs, de, es, fr, pl
- `Builder.__translate_widget_strings()` applies `_()` to all string properties of loaded widgets

## Glade UI files (`pympress/share/xml/`)

| File | Purpose |
|---|---|
| `presenter.glade` | Presenter window (current/next slide, notes, annotations, timer, menus) |
| `content.glade` | Audience window (fullscreen slide) |
| `media_overlay.glade` | Video overlay widget with progress bar and controls |
| `shortcuts.glade` | Keyboard shortcuts help window |
| `time_report_dialog.glade` | Per-section timing breakdown dialog |

## Dependencies (non-pip, system-level)

PyGObject, PyCairo, GTK 3, Cairo, Poppler (with GObject introspection bindings). Optionally VLC. See [README.md](../README.md#dependencies) for platform-specific install commands.

## Common Pitfalls

- GTK operations must happen on the main thread; `GLib.idle_add()` for cross-thread UI updates
- `Builder` widget auto-binding only works if the class attribute is set to `None` before `load_ui()`
- Config values may come as strings from INI — use `getboolean()`, `getint()`, etc.
- Platform-specific code paths (especially Windows) — check `util.IS_WINDOWS` and test accordingly
- No type hints in the codebase — don't add them unless specifically asked
6 changes: 3 additions & 3 deletions .github/workflows/deploy_doc+l10n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
sudo apt-get install -qy jq
pip install -e .[babel]
- name: Extract
run: python setup.py extract_messages
run: pybabel extract -F pympress/share/locale/babel_mapping.cfg -o pympress/share/locale/pympress.pot --no-location --no-wrap --sort-output --omit-header .
- name: Upload
env:
poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }}
Expand All @@ -41,13 +41,13 @@ jobs:
run: |
sudo apt-get update -q
sudo apt-get install -qy jq gobject-introspection libgirepository-1.0-1 gir1.2-gtk-3.0 gir1.2-glib-2.0 gir1.2-gstreamer-1.0 gir1.2-poppler-0.18 python3-pip python3-setuptools python3-wheel libgirepository1.0-dev
pip install pygobject pycairo .[build_sphinx]
pip install pygobject pycairo .[docs]
- name: Build
env:
poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }}
run: |
./scripts/poedit.sh contributors
python setup.py build_sphinx
sphinx-build -b html docs build/sphinx/html
tar czf pympress-docs.tar.gz -C build/sphinx/html/ .
- name: Upload
uses: actions/upload-artifact@v1
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ tpym
pympress/share/pixmaps/icons
core.*
empty.*

.venv/
.vscode/
6 changes: 3 additions & 3 deletions pympress/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,12 @@ def __init__(config):
super(Config, config).__init__()

# populate values first from the default config file, then from the proper one
config.read(util.get_default_config())
config.read(util.get_default_config(), encoding='utf-8')
config.load_window_layouts()

all_commands = dict(config.items('shortcuts')).keys()

config.read(config.path_to_config(True))
config.read(config.path_to_config(True), encoding='utf-8')
config.upgrade()
config.load_window_layouts()

Expand Down Expand Up @@ -197,7 +197,7 @@ def save_config(self):
for layout_name in self.layout:
self.set('layout', layout_name, json.dumps(self.layout[layout_name], indent=4))

with open(self.path_to_config(), 'w') as configfile:
with open(self.path_to_config(), 'w', encoding='utf-8') as configfile:
self.write(configfile)


Expand Down
6 changes: 3 additions & 3 deletions pympress/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ def __init__(self, builder, pop_doc, path, page=0, old_doc=None):
self.scribbles = {}
self.highlight_mode = builder.highlight_mode
try:
f = open(self.path + '.pymp', "r")
f = open(self.path + '.pymp', "r", encoding='utf-8')
in_dict = json.load(f)
self.page_map = {int(key): val for key, val in in_dict.get('page_map', {}).items() if val < self.doc.get_n_pages()}
mapped_pages = self.page_map.values()
Expand Down Expand Up @@ -789,7 +789,7 @@ def default(self, obj):
path = self.path
if path[:7] == 'file://':
path = path[7:]
f = open(path + '.pymp', "w")
f = open(path + '.pymp', "w", encoding='utf-8')
json.dump(out_dict, f, cls=RGBAEncoder)

def export_pdf(self, filename=None):
Expand All @@ -807,7 +807,7 @@ def export_xopp(self, filename=None, f=None):
if filename is None:
filename = self.path + '.xopp'
if f is None:
f = open(filename, "w")
f = open(filename, "w", encoding='utf-8')
print("""<?xml version="1.0" standalone="no"?>
<xournal creator="pympress" fileversion="4">
<title>Xournal++ document - see https://github.com/xournalpp/xournalpp</title>
Expand Down
104 changes: 104 additions & 0 deletions pympress/scribble.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import io
import copy

from pympress import shape_recognition

import gi
import cairo
gi.require_version('Gtk', '3.0')
Expand Down Expand Up @@ -823,6 +825,10 @@ def toggle_scribble(self, widget, e_type, point, button, always=False, state=0):
if self.drawing_mode in ["box", "ellipse"]:
self.scribble_list[-1][4] = [x[:] for x in self.scribble_list[-1][3]]

# Shape recognition: convert freehand to geometric shape when Shift is held
if self.drawing_mode == "draw" and state & Gdk.ModifierType.SHIFT_MASK:
self._try_recognize_shape(state, widget)

self.scribble_drawing = False
if self.have_pen and self.pen_pointer is not None:
self.pen_pointer[0] = []
Expand All @@ -831,6 +837,100 @@ def toggle_scribble(self, widget, e_type, point, button, always=False, state=0):
return False


def _try_recognize_shape(self, state, widget):
""" Attempt to convert the last freehand stroke to a geometric shape.

Called on BUTTON_RELEASE when Shift is held in draw mode.
Converts the scribble in-place and adds an undo entry so the user
can revert to the original freehand stroke.

Args:
state (`int`): modifier key state from the GTK event
widget (:class:`~Gtk.Widget`): the widget for aspect ratio computation
"""
if not self.scribble_list:
logger.debug('shape_recognition: no scribbles')
return

scribble = self.scribble_list[-1]
if scribble[0] != "segment" or len(scribble[3]) < shape_recognition.MIN_POINTS_LINE:
logger.debug('shape_recognition: skip — type=%s, npoints=%d',
scribble[0], len(scribble[3]) if scribble[3] else 0)
return

logger.debug('shape_recognition: analyzing %d points', len(scribble[3]))
result = shape_recognition.recognize_shape(scribble[3])
if result is None:
logger.debug('shape_recognition: no shape detected — dumping points for diagnosis')
if logger.isEnabledFor(logging.DEBUG):
for i, p in enumerate(scribble[3]):
logger.debug(' pt[%d] = (%.6f, %.6f)', i, p[0], p[1])
return

logger.debug('shape_recognition: detected %s with params %s', result[0], result[1])

shape_type, params = result
old_state = copy.deepcopy(scribble[:])

make_arrow = bool(state & Gdk.ModifierType.CONTROL_MASK)

if shape_type == 'line':
start, end = params
scribble[0] = "segment"
scribble[3] = [start, end]
scribble[4] = [list(map(min, zip(start, end))), list(map(max, zip(start, end)))]
# Truncate any extra elements from the freehand stroke
del scribble[5:]

if make_arrow:
aspect = widget.get_allocated_width() / widget.get_allocated_height() if widget else 1
line_vec = (start[0] - end[0], (start[1] - end[1]) / aspect)
angle = math.atan2(line_vec[1], line_vec[0])
scribble[3].append([
end[0] + 0.04 * math.cos(angle + math.pi / 6),
end[1] + 0.04 * math.sin(angle + math.pi / 6) * aspect])
scribble[3].append([
end[0] + 0.04 * math.cos(angle - math.pi / 6),
end[1] + 0.04 * math.sin(angle - math.pi / 6) * aspect])
scribble[3].append([end[0], end[1]])
scribble.append([])

elif shape_type == 'circle':
center, radius = params
corner1 = [center[0] - radius, center[1] - radius]
corner2 = [center[0] + radius, center[1] + radius]
scribble[0] = "ellipse"
scribble[3] = [corner1, corner2]
scribble[4] = [corner1[:], corner2[:]]
del scribble[5:]
scribble.append((0, 0, 0, 0))

elif shape_type == 'ellipse':
center, half_w, half_h = params
corner1 = [center[0] - half_w, center[1] - half_h]
corner2 = [center[0] + half_w, center[1] + half_h]
scribble[0] = "ellipse"
scribble[3] = [corner1, corner2]
scribble[4] = [corner1[:], corner2[:]]
del scribble[5:]
scribble.append((0, 0, 0, 0))

elif shape_type == 'rectangle':
corner1, corner2 = params
scribble[0] = "box"
scribble[3] = [corner1, corner2]
scribble[4] = [corner1[:], corner2[:]]
del scribble[5:]
scribble.append((0, 0, 0, 0))

else:
return

new_state = copy.deepcopy(scribble[:])
self.add_undo(('r', scribble, old_state, new_state))
self.redraw_current_slide()


def draw_scribble(self, widget, cairo_context, draw_selected, pw):
""" Perform the drawings by user.

Expand Down Expand Up @@ -1364,6 +1464,8 @@ def undo(self, *args):
s[5] = oc
elif op[0] == 'm':
adjust_scribbles(op[1], -op[2], -op[3])
elif op[0] == 'r':
op[1][:] = op[2]

self.redraw_current_slide()
return True
Expand Down Expand Up @@ -1393,6 +1495,8 @@ def redo(self, *args):
s[5] = nc
elif op[0] == 'm':
adjust_scribbles(op[1], op[2], op[3])
elif op[0] == 'r':
op[1][:] = op[3]
self.undo_stack_pos = self.undo_stack_pos + 1
if self.undo_stack_pos == len(self.undo_stack):
self.buttons["redo"].set_sensitive(False)
Expand Down
Loading