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.
- fcp_pdf-0.1.0/.github/workflows/release.yml +23 -0
- fcp_pdf-0.1.0/.gitignore +10 -0
- fcp_pdf-0.1.0/CLAUDE.md +35 -0
- fcp_pdf-0.1.0/PKG-INFO +8 -0
- fcp_pdf-0.1.0/pyproject.toml +37 -0
- fcp_pdf-0.1.0/src/fcp_pdf/__init__.py +0 -0
- fcp_pdf-0.1.0/src/fcp_pdf/__main__.py +3 -0
- fcp_pdf-0.1.0/src/fcp_pdf/adapter.py +153 -0
- fcp_pdf-0.1.0/src/fcp_pdf/lib/__init__.py +0 -0
- fcp_pdf-0.1.0/src/fcp_pdf/lib/themes.py +213 -0
- fcp_pdf-0.1.0/src/fcp_pdf/main.py +36 -0
- fcp_pdf-0.1.0/src/fcp_pdf/model/__init__.py +0 -0
- fcp_pdf-0.1.0/src/fcp_pdf/model/snapshot.py +52 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/__init__.py +0 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_annotate.py +237 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_bookmark.py +91 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_compose.py +797 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_image.py +189 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_link.py +144 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_merge.py +121 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_meta.py +92 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_pages.py +192 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_redact.py +90 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_text.py +162 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/ops_watermark.py +79 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/queries.py +302 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/reference_card.py +153 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/resolvers.py +118 -0
- fcp_pdf-0.1.0/src/fcp_pdf/server/verb_registry.py +122 -0
- fcp_pdf-0.1.0/tests/__init__.py +0 -0
- fcp_pdf-0.1.0/tests/test_adapter.py +486 -0
- 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
|
fcp_pdf-0.1.0/.gitignore
ADDED
fcp_pdf-0.1.0/CLAUDE.md
ADDED
|
@@ -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,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,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
|