fcp-pdf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. fcp_pdf-0.1.0/.github/workflows/release.yml +23 -0
  2. fcp_pdf-0.1.0/.gitignore +10 -0
  3. fcp_pdf-0.1.0/CLAUDE.md +35 -0
  4. fcp_pdf-0.1.0/PKG-INFO +8 -0
  5. fcp_pdf-0.1.0/pyproject.toml +37 -0
  6. fcp_pdf-0.1.0/src/fcp_pdf/__init__.py +0 -0
  7. fcp_pdf-0.1.0/src/fcp_pdf/__main__.py +3 -0
  8. fcp_pdf-0.1.0/src/fcp_pdf/adapter.py +153 -0
  9. fcp_pdf-0.1.0/src/fcp_pdf/lib/__init__.py +0 -0
  10. fcp_pdf-0.1.0/src/fcp_pdf/lib/themes.py +213 -0
  11. fcp_pdf-0.1.0/src/fcp_pdf/main.py +36 -0
  12. fcp_pdf-0.1.0/src/fcp_pdf/model/__init__.py +0 -0
  13. fcp_pdf-0.1.0/src/fcp_pdf/model/snapshot.py +52 -0
  14. fcp_pdf-0.1.0/src/fcp_pdf/server/__init__.py +0 -0
  15. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_annotate.py +237 -0
  16. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_bookmark.py +91 -0
  17. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_compose.py +797 -0
  18. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_image.py +189 -0
  19. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_link.py +144 -0
  20. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_merge.py +121 -0
  21. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_meta.py +92 -0
  22. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_pages.py +192 -0
  23. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_redact.py +90 -0
  24. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_text.py +162 -0
  25. fcp_pdf-0.1.0/src/fcp_pdf/server/ops_watermark.py +79 -0
  26. fcp_pdf-0.1.0/src/fcp_pdf/server/queries.py +302 -0
  27. fcp_pdf-0.1.0/src/fcp_pdf/server/reference_card.py +153 -0
  28. fcp_pdf-0.1.0/src/fcp_pdf/server/resolvers.py +118 -0
  29. fcp_pdf-0.1.0/src/fcp_pdf/server/verb_registry.py +122 -0
  30. fcp_pdf-0.1.0/tests/__init__.py +0 -0
  31. fcp_pdf-0.1.0/tests/test_adapter.py +486 -0
  32. fcp_pdf-0.1.0/tests/test_compose.py +686 -0
@@ -0,0 +1,23 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: '3.12'
20
+ - run: uv sync
21
+ - run: uv run pytest
22
+ - run: uv build
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
10
+ .python-version
@@ -0,0 +1,35 @@
1
+ # fcp-pdf
2
+
3
+ PDF driver for the File Context Protocol (FCP). Provides semantic PDF operations as an MCP server.
4
+
5
+ ## Architecture
6
+
7
+ - `src/fcp_pdf/main.py` — MCP server entry point via `create_fcp_server()`
8
+ - `src/fcp_pdf/adapter.py` — `PdfAdapter` implementing `FcpDomainAdapter` protocol
9
+ - `src/fcp_pdf/model/snapshot.py` — `PdfModel` wrapper around `fitz.Document` with byte-snapshot undo/redo
10
+ - `src/fcp_pdf/server/ops_*.py` — verb handlers (pages, text, annotate, bookmark, merge, meta, watermark, image, redact, link)
11
+ - `src/fcp_pdf/server/queries.py` — read-only query handlers (plan, status, describe, find, toc, fonts, annots)
12
+ - `src/fcp_pdf/server/resolvers.py` — page resolution, range parsing, color parsing
13
+ - `src/fcp_pdf/server/verb_registry.py` — verb specs for all operations
14
+ - `src/fcp_pdf/server/reference_card.py` — help content
15
+
16
+ ## Key dependencies
17
+
18
+ - **PyMuPDF** (`pymupdf`/`fitz`) — PDF engine
19
+ - **fcp-core** — shared FCP server framework
20
+ - **fastmcp** — MCP server transport
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ uv sync --dev # install deps
26
+ uv run pytest # run tests
27
+ uv run fcp-pdf # start MCP server
28
+ ```
29
+
30
+ ## MCP tools exposed
31
+
32
+ - `pdf(ops)` — execute operations (page, text, insert-text, annotate, bookmark, merge, split, meta, watermark, image, redact, link)
33
+ - `pdf_query(q)` — query document state (plan, status, describe, find, toc, fonts, annots)
34
+ - `pdf_session(action)` — session lifecycle (new, open, save, checkpoint, undo, redo)
35
+ - `pdf_help()` — reference card
fcp_pdf-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: fcp-pdf
3
+ Version: 0.1.0
4
+ Summary: PDF File Context Protocol — semantic PDF operations for LLMs
5
+ Requires-Python: <3.14,>=3.11
6
+ Requires-Dist: fastmcp>=3.0
7
+ Requires-Dist: fcp-core>=0.1.18
8
+ Requires-Dist: pymupdf>=1.25
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "fcp-pdf"
3
+ version = "0.1.0"
4
+ description = "PDF File Context Protocol — semantic PDF operations for LLMs"
5
+ requires-python = ">=3.11,<3.14"
6
+ dependencies = [
7
+ "fcp-core>=0.1.18",
8
+ "fastmcp>=3.0",
9
+ "pymupdf>=1.25",
10
+ ]
11
+
12
+ # [tool.uv.sources]
13
+ # For local development, use `make link-py` instead of uncommenting:
14
+ # fcp-core = { path = "../fcp-core/python", editable = true }
15
+
16
+ [project.scripts]
17
+ fcp-pdf = "fcp_pdf.main:main"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "ruff>=0.4",
23
+ "pyright>=1.1",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/fcp_pdf"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ src = ["src"]
File without changes
@@ -0,0 +1,3 @@
1
+ from fcp_pdf.main import main
2
+
3
+ main()
@@ -0,0 +1,153 @@
1
+ """PdfAdapter — FcpDomainAdapter implementation for PyMuPDF documents.
2
+
3
+ Bridges fcp-core to PyMuPDF via PdfModel. Handles batch atomicity
4
+ and snapshot-based undo/redo.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fitz
10
+
11
+ from fcp_core import EventLog, OpResult, ParsedOp
12
+
13
+ from fcp_pdf.model.snapshot import PdfModel, SnapshotEvent
14
+ from fcp_pdf.server.queries import dispatch_query
15
+ from fcp_pdf.server.resolvers import PdfOpContext
16
+
17
+ # Import all handler dicts
18
+ from fcp_pdf.server.ops_pages import HANDLERS as PAGE_HANDLERS
19
+ from fcp_pdf.server.ops_text import HANDLERS as TEXT_HANDLERS
20
+ from fcp_pdf.server.ops_annotate import HANDLERS as ANNOTATE_HANDLERS
21
+ from fcp_pdf.server.ops_bookmark import HANDLERS as BOOKMARK_HANDLERS
22
+ from fcp_pdf.server.ops_merge import HANDLERS as MERGE_HANDLERS
23
+ from fcp_pdf.server.ops_meta import HANDLERS as META_HANDLERS
24
+ from fcp_pdf.server.ops_watermark import HANDLERS as WATERMARK_HANDLERS
25
+ from fcp_pdf.server.ops_image import HANDLERS as IMAGE_HANDLERS
26
+ from fcp_pdf.server.ops_redact import HANDLERS as REDACT_HANDLERS
27
+ from fcp_pdf.server.ops_link import HANDLERS as LINK_HANDLERS
28
+ from fcp_pdf.server.ops_compose import HANDLERS as COMPOSE_HANDLERS
29
+
30
+
31
+ class PdfAdapter:
32
+ """FcpDomainAdapter[PdfModel, SnapshotEvent] for PDF operations."""
33
+
34
+ def __init__(self) -> None:
35
+ # Merge all verb handlers
36
+ self._handlers: dict[str, callable] = {}
37
+ for h in (
38
+ PAGE_HANDLERS, TEXT_HANDLERS, ANNOTATE_HANDLERS,
39
+ BOOKMARK_HANDLERS, MERGE_HANDLERS, META_HANDLERS,
40
+ WATERMARK_HANDLERS, IMAGE_HANDLERS, REDACT_HANDLERS,
41
+ LINK_HANDLERS, COMPOSE_HANDLERS,
42
+ ):
43
+ self._handlers.update(h)
44
+
45
+ # -- FcpDomainAdapter protocol --
46
+
47
+ def create_empty(self, title: str, params: dict[str, str]) -> PdfModel:
48
+ """Create a new empty PDF document."""
49
+ doc = fitz.open()
50
+ model = PdfModel(title=title, doc=doc)
51
+ return model
52
+
53
+ def serialize(self, model: PdfModel, path: str) -> None:
54
+ """Save PDF to file."""
55
+ model.doc.save(path, garbage=3, deflate=True)
56
+ model.file_path = path
57
+
58
+ def deserialize(self, path: str) -> PdfModel:
59
+ """Load PDF from file."""
60
+ doc = fitz.open(path)
61
+ title = path.rsplit("/", 1)[-1]
62
+ model = PdfModel(title=title, doc=doc)
63
+ model.file_path = path
64
+ return model
65
+
66
+ def rebuild_indices(self, model: PdfModel) -> None:
67
+ """Rebuild index after undo/redo. Clamp active page."""
68
+ if len(model.doc) == 0:
69
+ model.active_page = 0
70
+ elif model.active_page >= len(model.doc):
71
+ model.active_page = len(model.doc) - 1
72
+
73
+ def get_digest(self, model: PdfModel) -> str:
74
+ """Return a compact state fingerprint."""
75
+ doc = model.doc
76
+ page_count = len(doc)
77
+ active = model.active_page + 1
78
+
79
+ # Quick stats
80
+ total_images = 0
81
+ total_annots = 0
82
+ for i in range(page_count):
83
+ page = doc[i]
84
+ total_images += len(page.get_images())
85
+ for _ in page.annots():
86
+ total_annots += 1
87
+
88
+ parts = [f"Active: page {active}", f"Pages: {page_count}"]
89
+ if total_images:
90
+ parts.append(f"Images: {total_images}")
91
+ if total_annots:
92
+ parts.append(f"Annotations: {total_annots}")
93
+
94
+ return ", ".join(parts)
95
+
96
+ def dispatch_op(
97
+ self, op: ParsedOp, model: PdfModel, log: EventLog
98
+ ) -> OpResult:
99
+ """Execute a parsed operation on the model."""
100
+ handler = self._handlers.get(op.verb)
101
+ if handler is None:
102
+ from fcp_core import suggest
103
+ s = suggest(op.verb, list(self._handlers.keys()))
104
+ msg = f"Unknown verb: {op.verb!r}"
105
+ if s:
106
+ msg += f"\n try: {s}"
107
+ return OpResult(success=False, message=msg)
108
+
109
+ # Take pre-op snapshot
110
+ before = model.snapshot()
111
+
112
+ # Build context
113
+ ctx = PdfOpContext(doc=model.doc, model=model)
114
+
115
+ # Dispatch
116
+ try:
117
+ result = handler(op, ctx)
118
+ except NotImplementedError as exc:
119
+ return OpResult(success=False, message=str(exc))
120
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
121
+ return OpResult(success=False, message=f"Error: {exc}")
122
+
123
+ if not result.success:
124
+ return result
125
+
126
+ # Log snapshot for undo
127
+ after = model.snapshot()
128
+ log.append(SnapshotEvent(before=before, after=after, summary=op.raw))
129
+
130
+ return result
131
+
132
+ def take_snapshot(self, model: PdfModel) -> bytes:
133
+ """Return byte snapshot for batch rollback."""
134
+ return model.snapshot()
135
+
136
+ def restore_snapshot(self, model: PdfModel, snapshot: bytes) -> None:
137
+ """Restore model from snapshot and rebuild indices."""
138
+ model.restore(snapshot)
139
+ self.rebuild_indices(model)
140
+
141
+ def dispatch_query(self, query: str, model: PdfModel) -> str:
142
+ """Execute a query against the model."""
143
+ return dispatch_query(query, model)
144
+
145
+ def reverse_event(self, event: SnapshotEvent, model: PdfModel) -> None:
146
+ """Undo — restore from before-snapshot."""
147
+ model.restore(event.before)
148
+ self.rebuild_indices(model)
149
+
150
+ def replay_event(self, event: SnapshotEvent, model: PdfModel) -> None:
151
+ """Redo — restore from after-snapshot."""
152
+ model.restore(event.after)
153
+ self.rebuild_indices(model)
File without changes
@@ -0,0 +1,213 @@
1
+ """Named color themes for professional documents.
2
+
3
+ Each theme defines a coordinated palette that the compose engine
4
+ uses for headings, table headers, accents, callouts, and body text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class Theme:
14
+ """A coordinated color palette for document styling."""
15
+ name: str
16
+ # Core
17
+ heading: str # H1/H2 color
18
+ subheading: str # H3/H4 color
19
+ body: str # body text
20
+ muted: str # captions, footnotes
21
+ # Accents
22
+ accent: str # primary accent (links, highlights)
23
+ accent_bg: str # accent background (light tint)
24
+ # Table
25
+ table_header_bg: str
26
+ table_header_fg: str
27
+ table_border: str
28
+ table_stripe: str # alternating row background
29
+ # Callouts
30
+ info_bg: str
31
+ info_border: str
32
+ info_fg: str
33
+ warning_bg: str
34
+ warning_border: str
35
+ warning_fg: str
36
+ success_bg: str
37
+ success_border: str
38
+ success_fg: str
39
+ error_bg: str
40
+ error_border: str
41
+ error_fg: str
42
+ # Structural
43
+ rule: str # horizontal rule color
44
+ blockquote_border: str
45
+ code_bg: str
46
+
47
+
48
+ # -- Built-in themes --
49
+
50
+ CORPORATE = Theme(
51
+ name="corporate",
52
+ heading="#1a1a2e",
53
+ subheading="#16213e",
54
+ body="#2d2d2d",
55
+ muted="#6c757d",
56
+ accent="#0066cc",
57
+ accent_bg="#e8f0fe",
58
+ table_header_bg="#1a1a2e",
59
+ table_header_fg="#ffffff",
60
+ table_border="#d0d0d0",
61
+ table_stripe="#f8f9fa",
62
+ info_bg="#e8f4fd",
63
+ info_border="#0288d1",
64
+ info_fg="#01579b",
65
+ warning_bg="#fff8e1",
66
+ warning_border="#f9a825",
67
+ warning_fg="#e65100",
68
+ success_bg="#e8f5e9",
69
+ success_border="#43a047",
70
+ success_fg="#1b5e20",
71
+ error_bg="#fce4ec",
72
+ error_border="#e53935",
73
+ error_fg="#b71c1c",
74
+ rule="#d0d0d0",
75
+ blockquote_border="#0066cc",
76
+ code_bg="#f5f5f5",
77
+ )
78
+
79
+ MODERN = Theme(
80
+ name="modern",
81
+ heading="#0f172a",
82
+ subheading="#334155",
83
+ body="#374151",
84
+ muted="#9ca3af",
85
+ accent="#6366f1",
86
+ accent_bg="#eef2ff",
87
+ table_header_bg="#6366f1",
88
+ table_header_fg="#ffffff",
89
+ table_border="#e5e7eb",
90
+ table_stripe="#f9fafb",
91
+ info_bg="#eff6ff",
92
+ info_border="#3b82f6",
93
+ info_fg="#1e40af",
94
+ warning_bg="#fffbeb",
95
+ warning_border="#f59e0b",
96
+ warning_fg="#92400e",
97
+ success_bg="#f0fdf4",
98
+ success_border="#22c55e",
99
+ success_fg="#166534",
100
+ error_bg="#fef2f2",
101
+ error_border="#ef4444",
102
+ error_fg="#991b1b",
103
+ rule="#e5e7eb",
104
+ blockquote_border="#6366f1",
105
+ code_bg="#f1f5f9",
106
+ )
107
+
108
+ MINIMAL = Theme(
109
+ name="minimal",
110
+ heading="#111111",
111
+ subheading="#333333",
112
+ body="#222222",
113
+ muted="#888888",
114
+ accent="#111111",
115
+ accent_bg="#f5f5f5",
116
+ table_header_bg="#f0f0f0",
117
+ table_header_fg="#111111",
118
+ table_border="#e0e0e0",
119
+ table_stripe="#fafafa",
120
+ info_bg="#f5f5f5",
121
+ info_border="#999999",
122
+ info_fg="#333333",
123
+ warning_bg="#f5f5f5",
124
+ warning_border="#999999",
125
+ warning_fg="#333333",
126
+ success_bg="#f5f5f5",
127
+ success_border="#999999",
128
+ success_fg="#333333",
129
+ error_bg="#f5f5f5",
130
+ error_border="#999999",
131
+ error_fg="#333333",
132
+ rule="#cccccc",
133
+ blockquote_border="#cccccc",
134
+ code_bg="#f5f5f5",
135
+ )
136
+
137
+ EXECUTIVE = Theme(
138
+ name="executive",
139
+ heading="#1b2838",
140
+ subheading="#2c3e50",
141
+ body="#2c3e50",
142
+ muted="#7f8c8d",
143
+ accent="#c0392b",
144
+ accent_bg="#fdedec",
145
+ table_header_bg="#2c3e50",
146
+ table_header_fg="#ecf0f1",
147
+ table_border="#bdc3c7",
148
+ table_stripe="#f8f9f9",
149
+ info_bg="#eaf2f8",
150
+ info_border="#2980b9",
151
+ info_fg="#1a5276",
152
+ warning_bg="#fef9e7",
153
+ warning_border="#f39c12",
154
+ warning_fg="#7d6608",
155
+ success_bg="#eafaf1",
156
+ success_border="#27ae60",
157
+ success_fg="#1e8449",
158
+ error_bg="#fdedec",
159
+ error_border="#c0392b",
160
+ error_fg="#922b21",
161
+ rule="#bdc3c7",
162
+ blockquote_border="#c0392b",
163
+ code_bg="#f4f6f7",
164
+ )
165
+
166
+ OCEAN = Theme(
167
+ name="ocean",
168
+ heading="#023e8a",
169
+ subheading="#0077b6",
170
+ body="#264653",
171
+ muted="#8ecae6",
172
+ accent="#0096c7",
173
+ accent_bg="#caf0f8",
174
+ table_header_bg="#023e8a",
175
+ table_header_fg="#ffffff",
176
+ table_border="#90e0ef",
177
+ table_stripe="#caf0f8",
178
+ info_bg="#caf0f8",
179
+ info_border="#0077b6",
180
+ info_fg="#023e8a",
181
+ warning_bg="#fff3cd",
182
+ warning_border="#e9c46a",
183
+ warning_fg="#795548",
184
+ success_bg="#d1fae5",
185
+ success_border="#2a9d8f",
186
+ success_fg="#264653",
187
+ error_bg="#fee2e2",
188
+ error_border="#e76f51",
189
+ error_fg="#9b2226",
190
+ rule="#90e0ef",
191
+ blockquote_border="#0077b6",
192
+ code_bg="#edf6f9",
193
+ )
194
+
195
+ THEMES: dict[str, Theme] = {
196
+ "corporate": CORPORATE,
197
+ "modern": MODERN,
198
+ "minimal": MINIMAL,
199
+ "executive": EXECUTIVE,
200
+ "ocean": OCEAN,
201
+ }
202
+
203
+ DEFAULT_THEME = CORPORATE
204
+
205
+
206
+ def get_theme(name: str) -> Theme | None:
207
+ """Look up a theme by name (case-insensitive)."""
208
+ return THEMES.get(name.lower())
209
+
210
+
211
+ def list_themes() -> list[str]:
212
+ """Return available theme names."""
213
+ return sorted(THEMES.keys())
@@ -0,0 +1,36 @@
1
+ """fcp-pdf — PDF File Context Protocol MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fcp_core.server import create_fcp_server
6
+
7
+ from fcp_pdf.adapter import PdfAdapter
8
+ from fcp_pdf.server.reference_card import EXTRA_SECTIONS
9
+ from fcp_pdf.server.verb_registry import VERBS
10
+
11
+ adapter = PdfAdapter()
12
+
13
+ mcp = create_fcp_server(
14
+ domain="pdf",
15
+ adapter=adapter,
16
+ verbs=VERBS,
17
+ extra_sections=EXTRA_SECTIONS,
18
+ extensions=["pdf"],
19
+ name="fcp-pdf",
20
+ instructions=(
21
+ "FCP PDF server for reading, manipulating, and creating PDF files. "
22
+ "Use pdf_session to open an existing PDF or create a new one, "
23
+ "pdf to execute operations (page, text, annotate, bookmark, merge, split, meta, watermark, image, redact), "
24
+ "pdf_query to inspect document structure and search content, "
25
+ "and pdf_help for the full verb reference. "
26
+ "Start every interaction with pdf_session."
27
+ ),
28
+ )
29
+
30
+
31
+ def main() -> None:
32
+ mcp.run()
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
File without changes
@@ -0,0 +1,52 @@
1
+ """Byte-snapshot undo/redo for PyMuPDF documents.
2
+
3
+ SnapshotEvent captures before/after states as bytes.
4
+ fitz documents serialize/deserialize via doc.tobytes()/fitz.open(stream=...).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ import fitz
12
+
13
+
14
+ @dataclass
15
+ class SnapshotEvent:
16
+ """Event type for byte-snapshot undo/redo."""
17
+
18
+ type: str = "snapshot"
19
+ before: bytes = field(default=b"", repr=False)
20
+ after: bytes = field(default=b"", repr=False)
21
+ summary: str = ""
22
+
23
+
24
+ class PdfModel:
25
+ """Thin wrapper around fitz.Document for in-place undo/redo.
26
+
27
+ The session dispatcher holds a reference to this object.
28
+ reverse_event/replay_event replace self.doc in place so
29
+ the session reference stays valid.
30
+ """
31
+
32
+ def __init__(self, title: str = "Untitled", doc: fitz.Document | None = None):
33
+ self.title = title
34
+ self.doc: fitz.Document = doc or fitz.open()
35
+ self.file_path: str | None = None
36
+ self.active_page: int = 0
37
+
38
+ def snapshot(self) -> bytes:
39
+ """Take a byte snapshot of the current document state."""
40
+ if len(self.doc) == 0:
41
+ # PyMuPDF can't serialize zero-page docs — return sentinel
42
+ return b""
43
+ return self.doc.tobytes(deflate=True, garbage=3)
44
+
45
+ def restore(self, data: bytes) -> None:
46
+ """Replace the document from snapshot bytes (in-place for undo/redo)."""
47
+ self.doc.close()
48
+ if data == b"":
49
+ # Restore to empty doc
50
+ self.doc = fitz.open()
51
+ else:
52
+ self.doc = fitz.open(stream=data, filetype="pdf")
File without changes