Files
openswarm/.venv/lib/python3.12/site-packages/PIL/PdfParser.py
T
3008f083ca [aidan] feat: scheduled tasks (#105)
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add

* [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69)

* [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice)

* [eric] ops: gitignore dev-team local state files

* [eric] ops: backlog item for download-tracking visibility

* [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks

* [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step

* [aidan] fix: schedule time in chat

* [aidan] ux/workflows: add workflow step removal (#91)

* [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs

* [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93)

* [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94)

* [aidan] bug: fix schedule button

* [aidan] fix/agent-errors: surface provider rate limits

* [aidan] ux/cards: click-to-rename for chat and workflow titles

Single-click a card's title to enter edit mode inline. Commit on Enter/blur,
cancel on Escape. Rename persists via PATCH for workflows and sessions.

* [aidan] feat/workflows: seed build prompt for zero-step workflows

When a new workflow has no steps, seed the agent with a prompt asking
the user to describe what the workflow should do, rather than starting blank.

* [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows

Clicking the "+" on an unscheduled workflow row opens a popover with two options:
- Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled
- Change schedule: opens the scheduling editor to pick a different time

* [aidan] ux/workflows: wire add-to-schedule popover and simplify New button

- Made the "+" icon on unscheduled workflow rows clickable, opening a popover
  to keep or change the schedule
- Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New")

* [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked

Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler.
Exempted the toolbar pills via data-toolbar-pills so their click handlers fire.

* [aidan] ux/workflows: open New workflow in agent build chat instead of empty card

When creating a new workflow from the hub, open it in edit_agent view (with the
agent builder chat) instead of a preview card. The workflow is created on the
backend first so the embedded session has a real ID.

* [aidan] feat/workflow-edit: add draft testing save flow

* [aidan] ux/chat: remove continue chat button

* [aidan] ux/workflows: polish workflow card interactions

* [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts

* aidan ui: schedule naming changes

* [aidan] ui: tool calling desc/naming

* [aidan] ui: calendar sidebar naming

* [aidan] ui: fix stop viewing closing chat

* [aidan] feat/workflows: auto-name workflows and polish the build flow (#95)

* [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps

Generate a title + description from a workflow's steps (one aux call,
reused for step labels) whenever it is still auto_named, so a workflow
built in the Edit Agent names itself on commit instead of staying
"New workflow". A manual rename sets auto_named=False and is never
overwritten. Stream the aux call (non-streaming drops content on some
9router lanes) and fall back to a step-derived title when the model is
unavailable.

* [aidan] feat/workflows: hide unsaved new workflows until first save

A brand-new "+ New" workflow is created with unsaved=true and kept out
of the hub's scheduled/unscheduled lists while the user is still building
it in the Edit Agent. The first commit (Save) clears the flag and the
workflow appears. Every other create path stays visible immediately.

* [aidan] ux/workflows: remove redundant save workflow button

The Edit Agent already has Discard/Save controls in its strip, so the
header "Save Workflow" button was a duplicate save path. Remove it and
its pulse/edit-session-id wiring; the model/time subtitle stays.

* [aidan] ux/workflows: animate title on auto-rename

Wrap the workflow card title in the same Typewriter the chat card uses,
so when the auto-generated name replaces the placeholder after Save it
retypes letter-by-letter. Gated on a real (non-placeholder) title so it
never animates on mount or for already-named workflows.

* [aidan] ux/workflows: animate sidebar title on auto-rename

Wrap the calendar hub's sidebar row title in the same Typewriter the
workflow card uses, so a title that auto-renames retypes letter-by-letter
in the sidebar too. Extract the placeholder/isRealTitle guard into the
shared workflowVisuals so the card and sidebar stay in sync.

* [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history

* [aidan] ui: grey out chat pill when not selected

* [aidan] ui: fix running agent display

* [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96)

* [aidan] ux/schedule: toast when calendar view already open on expand

* [aidan] ui: fix popover descs

* [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs

* [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones

* [aidan] feat/workflows: require at least one step to save a workflow

* [aidan] ux/edit-agent: hide Discard for an unsaved new workflow

* [aidan] ux/edit-agent: move fix-prefix card below the step list

* [aidan] feat/workflows: toast when an unattended scheduled run starts

* [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects

* [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory

* [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown

* [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97)

* [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history

* [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous

* [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces

* [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts

* [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop

* [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat

* [aidan] feat/schedule-calendar: mark current time with a now line in week view

* [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_

* [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98)

* [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow

Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task
when they've completed something worth repeating (daily report, weekly check,
recurring data pull). Frontend detects the tool call and glows the "Convert to
workflow" button 3 times to draw the eye. When user clicks it, the suggested
cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into
the scheduling agent's first prompt, so the agent can act on the suggestion
rather than asking the user again.

Tool is never auto-called — agents decide when a task is genuinely repeatable
(not debugging, creative work, one-off lookup). Tool description emphasizes
sparse, high-confidence use only (once per session max).

Files changed:
- backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool
- frontend/src/shared/mcpToolMeta.ts: add label for new tool
- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in
  session messages, show+glow "Convert to workflow" button, pass cadence to draft
- frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to
  Workflow interface
- frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent
  prompt with suggested cadence hint

* [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill

* [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card

* [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99)

* [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key

* [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle

* [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing

* [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view

* [aidan] ux/schedule-calendar: clickable +N more opens day's full run list

* [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history

* [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires

* [aidan] ux/calendar-card: match border color and radius to chat and workflow cards

* [aidan] fix/minimap: render missed-runs card on the minimap

* [aidan] ux/run-sparkline: simplify tooltip to plain run tally

* [aidan] ux/run-history: collapse expanded run view to one clickable line

* [aidan] ux/calendar-card: match corner radius to browser cards

* [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)

* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel

* [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

* [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom

* [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed

* [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome

* [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast

* [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown

* [aidan] chore/backend: remove stale explanatory comments

* [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch

* [aidan] feat/workflows: generate title, description, step labels at convert time

* [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view

* [aidan] feat/schedule-list: window long list via measured-height virtualizer

* [aidan] ux/workflows-hub: remove time-saved badge from calendar header

* [aidan] fix/types: add missing semantic-type labels and drop stray fade arg

* [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals

* [aidan] feat/schedule: inherit source-session tool surface for scheduled runs

* [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance

* [aidan] feat/calendar: open the run card when clicking a scheduled occurrence

* [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action

* [aidan] feat: new scheduled task design ported

* [aidan] ui: sidebar reorder, repeat controls on schedule card

* [aidan] ui: sidebar, scheduling time

* [aidan] feat/schedule: pin monthly last-day-of-month

* [aidan] feat/steps: per-step enable toggle

* [aidan] feat/workflows: per-workflow color swatch

* [aidan] feat/trash: soft-delete workflows with restore and purge

* [aidan] feat/run-monitor: live run monitor card on the canvas

* [aidan] feat/run-context: attach a run as removable chat context

* [aidan] feat/compose: new-workflow landing page and auto-commit build flow

* [aidan] ui/workflows: dark mode and design-system cohesion

* [aidan] ui/calendar: overflow popover, condensed week view, scroll fix

* [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts

* [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs

* [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input

* [aidan] ux/workflows: default window size and toolbar icon

* [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at

* [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl

* [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect

* [aidan] chore/agent-card: remove unreachable convert-to-workflow action

* [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar

* [aidan] feat/schedule: warn when scheduling a workflow that has no steps

* [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections

* [aidan] ux/compose: diversify new-workflow starter prompts across personas

* [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app

* [aidan] fix/schedule: harden run recovery and storage writes against crashes

* [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts

* [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly

* [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes

* [aidan] ui/workflows: close buttons turn red on hover, matching the chat card

* [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps

* [aidan] fix: remove package-lock json

* [aidan] fix/workflows-compose: keep compose view until edit agent replies

* [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation

* [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci)

---------

Co-authored-by: Eric <ciregenz@berkeley.edu>
Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
2026-06-24 18:58:43 -07:00

1082 lines
37 KiB
Python

from __future__ import annotations
import calendar
import codecs
import collections
import mmap
import os
import re
import time
import zlib
from typing import Any, NamedTuple
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO
_DictBase = collections.UserDict[str | bytes, Any]
else:
_DictBase = collections.UserDict
# see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set
# on page 656
def encode_text(s: str) -> bytes:
return codecs.BOM_UTF16_BE + s.encode("utf_16_be")
PDFDocEncoding = {
0x16: "\u0017",
0x18: "\u02d8",
0x19: "\u02c7",
0x1A: "\u02c6",
0x1B: "\u02d9",
0x1C: "\u02dd",
0x1D: "\u02db",
0x1E: "\u02da",
0x1F: "\u02dc",
0x80: "\u2022",
0x81: "\u2020",
0x82: "\u2021",
0x83: "\u2026",
0x84: "\u2014",
0x85: "\u2013",
0x86: "\u0192",
0x87: "\u2044",
0x88: "\u2039",
0x89: "\u203a",
0x8A: "\u2212",
0x8B: "\u2030",
0x8C: "\u201e",
0x8D: "\u201c",
0x8E: "\u201d",
0x8F: "\u2018",
0x90: "\u2019",
0x91: "\u201a",
0x92: "\u2122",
0x93: "\ufb01",
0x94: "\ufb02",
0x95: "\u0141",
0x96: "\u0152",
0x97: "\u0160",
0x98: "\u0178",
0x99: "\u017d",
0x9A: "\u0131",
0x9B: "\u0142",
0x9C: "\u0153",
0x9D: "\u0161",
0x9E: "\u017e",
0xA0: "\u20ac",
}
def decode_text(b: bytes) -> str:
if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE:
return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be")
else:
return "".join(PDFDocEncoding.get(byte, chr(byte)) for byte in b)
class PdfFormatError(RuntimeError):
"""An error that probably indicates a syntactic or semantic error in the
PDF file structure"""
pass
def check_format_condition(condition: bool, error_message: str) -> None:
if not condition:
raise PdfFormatError(error_message)
class IndirectReferenceTuple(NamedTuple):
object_id: int
generation: int
class IndirectReference(IndirectReferenceTuple):
def __str__(self) -> str:
return f"{self.object_id} {self.generation} R"
def __bytes__(self) -> bytes:
return self.__str__().encode("us-ascii")
def __eq__(self, other: object) -> bool:
if self.__class__ is not other.__class__:
return False
assert isinstance(other, IndirectReference)
return other.object_id == self.object_id and other.generation == self.generation
def __ne__(self, other: object) -> bool:
return not (self == other)
def __hash__(self) -> int:
return hash((self.object_id, self.generation))
class IndirectObjectDef(IndirectReference):
def __str__(self) -> str:
return f"{self.object_id} {self.generation} obj"
class XrefTable:
def __init__(self) -> None:
self.existing_entries: dict[int, tuple[int, int]] = (
{}
) # object ID => (offset, generation)
self.new_entries: dict[int, tuple[int, int]] = (
{}
) # object ID => (offset, generation)
self.deleted_entries = {0: 65536} # object ID => generation
self.reading_finished = False
def __setitem__(self, key: int, value: tuple[int, int]) -> None:
if self.reading_finished:
self.new_entries[key] = value
else:
self.existing_entries[key] = value
if key in self.deleted_entries:
del self.deleted_entries[key]
def __getitem__(self, key: int) -> tuple[int, int]:
try:
return self.new_entries[key]
except KeyError:
return self.existing_entries[key]
def __delitem__(self, key: int) -> None:
if key in self.new_entries:
generation = self.new_entries[key][1] + 1
del self.new_entries[key]
self.deleted_entries[key] = generation
elif key in self.existing_entries:
generation = self.existing_entries[key][1] + 1
self.deleted_entries[key] = generation
elif key in self.deleted_entries:
generation = self.deleted_entries[key]
else:
msg = f"object ID {key} cannot be deleted because it doesn't exist"
raise IndexError(msg)
def __contains__(self, key: int) -> bool:
return key in self.existing_entries or key in self.new_entries
def __len__(self) -> int:
return len(
set(self.existing_entries.keys())
| set(self.new_entries.keys())
| set(self.deleted_entries.keys())
)
def keys(self) -> set[int]:
return (
set(self.existing_entries.keys()) - set(self.deleted_entries.keys())
) | set(self.new_entries.keys())
def write(self, f: IO[bytes]) -> int:
keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys()))
deleted_keys = sorted(set(self.deleted_entries.keys()))
startxref = f.tell()
f.write(b"xref\n")
while keys:
# find a contiguous sequence of object IDs
prev: int | None = None
for index, key in enumerate(keys):
if prev is None or prev + 1 == key:
prev = key
else:
contiguous_keys = keys[:index]
keys = keys[index:]
break
else:
contiguous_keys = keys
keys = []
f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys)))
for object_id in contiguous_keys:
if object_id in self.new_entries:
f.write(b"%010d %05d n \n" % self.new_entries[object_id])
else:
this_deleted_object_id = deleted_keys.pop(0)
check_format_condition(
object_id == this_deleted_object_id,
f"expected the next deleted object ID to be {object_id}, "
f"instead found {this_deleted_object_id}",
)
try:
next_in_linked_list = deleted_keys[0]
except IndexError:
next_in_linked_list = 0
f.write(
b"%010d %05d f \n"
% (next_in_linked_list, self.deleted_entries[object_id])
)
return startxref
class PdfName:
name: bytes
def __init__(self, name: PdfName | bytes | str) -> None:
if isinstance(name, PdfName):
self.name = name.name
elif isinstance(name, bytes):
self.name = name
else:
self.name = name.encode("us-ascii")
def name_as_str(self) -> str:
return self.name.decode("us-ascii")
def __eq__(self, other: object) -> bool:
return (
isinstance(other, PdfName) and other.name == self.name
) or other == self.name
def __hash__(self) -> int:
return hash(self.name)
def __repr__(self) -> str:
return f"{self.__class__.__name__}({repr(self.name)})"
@classmethod
def from_pdf_stream(cls, data: bytes) -> PdfName:
return cls(PdfParser.interpret_name(data))
allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"}
def __bytes__(self) -> bytes:
result = bytearray(b"/")
for b in self.name:
if b in self.allowed_chars:
result.append(b)
else:
result.extend(b"#%02X" % b)
return bytes(result)
class PdfArray(list[Any]):
def __bytes__(self) -> bytes:
return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]"
class PdfDict(_DictBase):
def __setattr__(self, key: str, value: Any) -> None:
if key == "data":
collections.UserDict.__setattr__(self, key, value)
else:
self[key.encode("us-ascii")] = value
def __getattr__(self, key: str) -> str | time.struct_time:
try:
value = self[key.encode("us-ascii")]
except KeyError as e:
raise AttributeError(key) from e
if isinstance(value, bytes):
value = decode_text(value)
if key.endswith("Date"):
if value.startswith("D:"):
value = value[2:]
relationship = "Z"
if len(value) > 17:
relationship = value[14]
offset = int(value[15:17]) * 60
if len(value) > 20:
offset += int(value[18:20])
format = "%Y%m%d%H%M%S"[: len(value) - 2]
value = time.strptime(value[: len(format) + 2], format)
if relationship in ["+", "-"]:
offset *= 60
if relationship == "+":
offset *= -1
value = time.gmtime(calendar.timegm(value) + offset)
return value
def __bytes__(self) -> bytes:
out = bytearray(b"<<")
for key, value in self.items():
if value is None:
continue
value = pdf_repr(value)
out.extend(b"\n")
out.extend(bytes(PdfName(key)))
out.extend(b" ")
out.extend(value)
out.extend(b"\n>>")
return bytes(out)
class PdfBinary:
def __init__(self, data: list[int] | bytes) -> None:
self.data = data
def __bytes__(self) -> bytes:
return b"<%s>" % b"".join(b"%02X" % b for b in self.data)
class PdfStream:
def __init__(self, dictionary: PdfDict, buf: bytes) -> None:
self.dictionary = dictionary
self.buf = buf
def decode(self) -> bytes:
try:
filter = self.dictionary[b"Filter"]
except KeyError:
return self.buf
if filter == b"FlateDecode":
try:
expected_length = self.dictionary[b"DL"]
except KeyError:
expected_length = self.dictionary[b"Length"]
return zlib.decompress(self.buf, bufsize=int(expected_length))
else:
msg = f"stream filter {repr(filter)} unknown/unsupported"
raise NotImplementedError(msg)
def pdf_repr(x: Any) -> bytes:
if x is True:
return b"true"
elif x is False:
return b"false"
elif x is None:
return b"null"
elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)):
return bytes(x)
elif isinstance(x, (int, float)):
return str(x).encode("us-ascii")
elif isinstance(x, time.struct_time):
return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")"
elif isinstance(x, dict):
return bytes(PdfDict(x))
elif isinstance(x, list):
return bytes(PdfArray(x))
elif isinstance(x, str):
return pdf_repr(encode_text(x))
elif isinstance(x, bytes):
# XXX escape more chars? handle binary garbage
x = x.replace(b"\\", b"\\\\")
x = x.replace(b"(", b"\\(")
x = x.replace(b")", b"\\)")
return b"(" + x + b")"
else:
return bytes(x)
class PdfParser:
"""Based on
https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf
Supports PDF up to 1.4
"""
def __init__(
self,
filename: str | None = None,
f: IO[bytes] | None = None,
buf: bytes | bytearray | None = None,
start_offset: int = 0,
mode: str = "rb",
) -> None:
if buf and f:
msg = "specify buf or f or filename, but not both buf and f"
raise RuntimeError(msg)
self.filename = filename
self.buf: bytes | bytearray | mmap.mmap | None = buf
self.f = f
self.start_offset = start_offset
self.should_close_buf = False
self.should_close_file = False
if filename is not None and f is None:
self.f = f = open(filename, mode)
self.should_close_file = True
if f is not None:
self.buf = self.get_buf_from_file(f)
self.should_close_buf = True
if not filename and hasattr(f, "name"):
self.filename = f.name
self.cached_objects: dict[IndirectReference, Any] = {}
self.root_ref: IndirectReference | None
self.info_ref: IndirectReference | None
self.pages_ref: IndirectReference | None
self.last_xref_section_offset: int | None
if self.buf:
self.read_pdf_info()
else:
self.file_size_total = self.file_size_this = 0
self.root = PdfDict()
self.root_ref = None
self.info = PdfDict()
self.info_ref = None
self.page_tree_root = PdfDict()
self.pages: list[IndirectReference] = []
self.orig_pages: list[IndirectReference] = []
self.pages_ref = None
self.last_xref_section_offset = None
self.trailer_dict: dict[bytes, Any] = {}
self.xref_table = XrefTable()
self.xref_table.reading_finished = True
if f:
self.seek_end()
def __enter__(self) -> PdfParser:
return self
def __exit__(self, *args: object) -> None:
self.close()
def start_writing(self) -> None:
self.close_buf()
self.seek_end()
def close_buf(self) -> None:
if isinstance(self.buf, mmap.mmap):
self.buf.close()
self.buf = None
def close(self) -> None:
if self.should_close_buf:
self.close_buf()
if self.f is not None and self.should_close_file:
self.f.close()
self.f = None
def seek_end(self) -> None:
assert self.f is not None
self.f.seek(0, os.SEEK_END)
def write_header(self) -> None:
assert self.f is not None
self.f.write(b"%PDF-1.4\n")
def write_comment(self, s: str) -> None:
assert self.f is not None
self.f.write(f"% {s}\n".encode())
def write_catalog(self) -> IndirectReference:
assert self.f is not None
self.del_root()
self.root_ref = self.next_object_id(self.f.tell())
self.pages_ref = self.next_object_id(0)
self.rewrite_pages()
self.write_obj(self.root_ref, Type=PdfName(b"Catalog"), Pages=self.pages_ref)
self.write_obj(
self.pages_ref,
Type=PdfName(b"Pages"),
Count=len(self.pages),
Kids=self.pages,
)
return self.root_ref
def rewrite_pages(self) -> None:
pages_tree_nodes_to_delete = []
for i, page_ref in enumerate(self.orig_pages):
page_info = self.cached_objects[page_ref]
del self.xref_table[page_ref.object_id]
pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")])
if page_ref not in self.pages:
# the page has been deleted
continue
# make dict keys into strings for passing to write_page
stringified_page_info = {}
for key, value in page_info.items():
# key should be a PdfName
stringified_page_info[key.name_as_str()] = value
stringified_page_info["Parent"] = self.pages_ref
new_page_ref = self.write_page(None, **stringified_page_info)
for j, cur_page_ref in enumerate(self.pages):
if cur_page_ref == page_ref:
# replace the page reference with the new one
self.pages[j] = new_page_ref
# delete redundant Pages tree nodes from xref table
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
pages_tree_node = self.cached_objects[pages_tree_node_ref]
if pages_tree_node_ref.object_id in self.xref_table:
del self.xref_table[pages_tree_node_ref.object_id]
pages_tree_node_ref = pages_tree_node.get(b"Parent", None)
self.orig_pages = []
def write_xref_and_trailer(
self, new_root_ref: IndirectReference | None = None
) -> None:
assert self.f is not None
if new_root_ref:
self.del_root()
self.root_ref = new_root_ref
if self.info:
self.info_ref = self.write_obj(None, self.info)
start_xref = self.xref_table.write(self.f)
num_entries = len(self.xref_table)
trailer_dict: dict[str | bytes, Any] = {
b"Root": self.root_ref,
b"Size": num_entries,
}
if self.last_xref_section_offset is not None:
trailer_dict[b"Prev"] = self.last_xref_section_offset
if self.info:
trailer_dict[b"Info"] = self.info_ref
self.last_xref_section_offset = start_xref
self.f.write(
b"trailer\n"
+ bytes(PdfDict(trailer_dict))
+ b"\nstartxref\n%d\n%%%%EOF" % start_xref
)
def write_page(
self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any
) -> IndirectReference:
obj_ref = self.pages[ref] if isinstance(ref, int) else ref
if "Type" not in dict_obj:
dict_obj["Type"] = PdfName(b"Page")
if "Parent" not in dict_obj:
dict_obj["Parent"] = self.pages_ref
return self.write_obj(obj_ref, *objs, **dict_obj)
def write_obj(
self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any
) -> IndirectReference:
assert self.f is not None
f = self.f
if ref is None:
ref = self.next_object_id(f.tell())
else:
self.xref_table[ref.object_id] = (f.tell(), ref.generation)
f.write(bytes(IndirectObjectDef(*ref)))
stream = dict_obj.pop("stream", None)
if stream is not None:
dict_obj["Length"] = len(stream)
if dict_obj:
f.write(pdf_repr(dict_obj))
for obj in objs:
f.write(pdf_repr(obj))
if stream is not None:
f.write(b"stream\n")
f.write(stream)
f.write(b"\nendstream\n")
f.write(b"endobj\n")
return ref
def del_root(self) -> None:
if self.root_ref is None:
return
del self.xref_table[self.root_ref.object_id]
del self.xref_table[self.root[b"Pages"].object_id]
@staticmethod
def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap:
if hasattr(f, "getbuffer"):
return f.getbuffer()
elif hasattr(f, "getvalue"):
return f.getvalue()
else:
try:
return mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
except ValueError: # cannot mmap an empty file
return b""
def read_pdf_info(self) -> None:
assert self.buf is not None
self.file_size_total = len(self.buf)
self.file_size_this = self.file_size_total - self.start_offset
self.read_trailer()
check_format_condition(
self.trailer_dict.get(b"Root") is not None, "Root is missing"
)
self.root_ref = self.trailer_dict[b"Root"]
assert self.root_ref is not None
self.info_ref = self.trailer_dict.get(b"Info", None)
self.root = PdfDict(self.read_indirect(self.root_ref))
if self.info_ref is None:
self.info = PdfDict()
else:
self.info = PdfDict(self.read_indirect(self.info_ref))
check_format_condition(b"Type" in self.root, "/Type missing in Root")
check_format_condition(
self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog"
)
check_format_condition(
self.root.get(b"Pages") is not None, "/Pages missing in Root"
)
check_format_condition(
isinstance(self.root[b"Pages"], IndirectReference),
"/Pages in Root is not an indirect reference",
)
self.pages_ref = self.root[b"Pages"]
assert self.pages_ref is not None
self.page_tree_root = self.read_indirect(self.pages_ref)
self.pages = self.linearize_page_tree(self.page_tree_root)
# save the original list of page references
# in case the user modifies, adds or deletes some pages
# and we need to rewrite the pages and their list
self.orig_pages = self.pages[:]
def next_object_id(self, offset: int | None = None) -> IndirectReference:
try:
# TODO: support reuse of deleted objects
reference = IndirectReference(max(self.xref_table.keys()) + 1, 0)
except ValueError:
reference = IndirectReference(1, 0)
if offset is not None:
self.xref_table[reference.object_id] = (offset, 0)
return reference
delimiter = rb"[][()<>{}/%]"
delimiter_or_ws = rb"[][()<>{}/%\000\011\012\014\015\040]"
whitespace = rb"[\000\011\012\014\015\040]"
whitespace_or_hex = rb"[\000\011\012\014\015\0400-9a-fA-F]"
whitespace_optional = whitespace + b"*"
whitespace_mandatory = whitespace + b"+"
# No "\012" aka "\n" or "\015" aka "\r":
whitespace_optional_no_nl = rb"[\000\011\014\040]*"
newline_only = rb"[\r\n]+"
newline = whitespace_optional_no_nl + newline_only + whitespace_optional_no_nl
re_trailer_end = re.compile(
whitespace_mandatory
+ rb"trailer"
+ whitespace_optional
+ rb"<<(.*>>)"
+ newline
+ rb"startxref"
+ newline
+ rb"([0-9]+)"
+ newline
+ rb"%%EOF"
+ whitespace_optional
+ rb"$",
re.DOTALL,
)
re_trailer_prev = re.compile(
whitespace_optional
+ rb"trailer"
+ whitespace_optional
+ rb"<<(.*?>>)"
+ newline
+ rb"startxref"
+ newline
+ rb"([0-9]+)"
+ newline
+ rb"%%EOF"
+ whitespace_optional,
re.DOTALL,
)
def read_trailer(self) -> None:
assert self.buf is not None
search_start_offset = len(self.buf) - 16384
if search_start_offset < self.start_offset:
search_start_offset = self.start_offset
m = self.re_trailer_end.search(self.buf, search_start_offset)
check_format_condition(m is not None, "trailer end not found")
# make sure we found the LAST trailer
last_match = m
while m:
last_match = m
m = self.re_trailer_end.search(self.buf, m.start() + 16)
if not m:
m = last_match
assert m is not None
trailer_data = m.group(1)
self.last_xref_section_offset = int(m.group(2))
self.trailer_dict = self.interpret_trailer(trailer_data)
self.xref_table = XrefTable()
self.read_xref_table(xref_section_offset=self.last_xref_section_offset)
if b"Prev" in self.trailer_dict:
self.read_prev_trailer(self.trailer_dict[b"Prev"])
def read_prev_trailer(
self, xref_section_offset: int, processed_offsets: list[int] = []
) -> None:
assert self.buf is not None
trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset)
m = self.re_trailer_prev.search(
self.buf[trailer_offset : trailer_offset + 16384]
)
check_format_condition(m is not None, "previous trailer not found")
assert m is not None
trailer_data = m.group(1)
check_format_condition(
int(m.group(2)) == xref_section_offset,
"xref section offset in previous trailer doesn't match what was expected",
)
trailer_dict = self.interpret_trailer(trailer_data)
if b"Prev" in trailer_dict:
processed_offsets.append(xref_section_offset)
check_format_condition(
trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found"
)
self.read_prev_trailer(trailer_dict[b"Prev"], processed_offsets)
re_whitespace_optional = re.compile(whitespace_optional)
re_name = re.compile(
whitespace_optional
+ rb"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?="
+ delimiter_or_ws
+ rb")"
)
re_dict_start = re.compile(whitespace_optional + rb"<<")
re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional)
@classmethod
def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]:
trailer = {}
offset = 0
while True:
m = cls.re_name.match(trailer_data, offset)
if not m:
m = cls.re_dict_end.match(trailer_data, offset)
check_format_condition(
m is not None and m.end() == len(trailer_data),
"name not found in trailer, remaining data: "
+ repr(trailer_data[offset:]),
)
break
key = cls.interpret_name(m.group(1))
assert isinstance(key, bytes)
value, value_offset = cls.get_value(trailer_data, m.end())
trailer[key] = value
if value_offset is None:
break
offset = value_offset
check_format_condition(
b"Size" in trailer and isinstance(trailer[b"Size"], int),
"/Size not in trailer or not an integer",
)
check_format_condition(
b"Root" in trailer and isinstance(trailer[b"Root"], IndirectReference),
"/Root not in trailer or not an indirect reference",
)
return trailer
re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?")
@classmethod
def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes:
name = b""
for m in cls.re_hashes_in_name.finditer(raw):
if m.group(3):
name += m.group(1) + bytearray.fromhex(m.group(3).decode("us-ascii"))
else:
name += m.group(1)
if as_text:
return name.decode("utf-8")
else:
return bytes(name)
re_null = re.compile(whitespace_optional + rb"null(?=" + delimiter_or_ws + rb")")
re_true = re.compile(whitespace_optional + rb"true(?=" + delimiter_or_ws + rb")")
re_false = re.compile(whitespace_optional + rb"false(?=" + delimiter_or_ws + rb")")
re_int = re.compile(
whitespace_optional + rb"([-+]?[0-9]+)(?=" + delimiter_or_ws + rb")"
)
re_real = re.compile(
whitespace_optional
+ rb"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?="
+ delimiter_or_ws
+ rb")"
)
re_array_start = re.compile(whitespace_optional + rb"\[")
re_array_end = re.compile(whitespace_optional + rb"]")
re_string_hex = re.compile(
whitespace_optional + rb"<(" + whitespace_or_hex + rb"*)>"
)
re_string_lit = re.compile(whitespace_optional + rb"\(")
re_indirect_reference = re.compile(
whitespace_optional
+ rb"([-+]?[0-9]+)"
+ whitespace_mandatory
+ rb"([-+]?[0-9]+)"
+ whitespace_mandatory
+ rb"R(?="
+ delimiter_or_ws
+ rb")"
)
re_indirect_def_start = re.compile(
whitespace_optional
+ rb"([-+]?[0-9]+)"
+ whitespace_mandatory
+ rb"([-+]?[0-9]+)"
+ whitespace_mandatory
+ rb"obj(?="
+ delimiter_or_ws
+ rb")"
)
re_indirect_def_end = re.compile(
whitespace_optional + rb"endobj(?=" + delimiter_or_ws + rb")"
)
re_comment = re.compile(
rb"(" + whitespace_optional + rb"%[^\r\n]*" + newline + rb")*"
)
re_stream_start = re.compile(whitespace_optional + rb"stream\r?\n")
re_stream_end = re.compile(
whitespace_optional + rb"endstream(?=" + delimiter_or_ws + rb")"
)
@classmethod
def get_value(
cls,
data: bytes | bytearray | mmap.mmap,
offset: int,
expect_indirect: IndirectReference | None = None,
max_nesting: int = -1,
) -> tuple[Any, int | None]:
if max_nesting == 0:
return None, None
m = cls.re_comment.match(data, offset)
if m:
offset = m.end()
m = cls.re_indirect_def_start.match(data, offset)
if m:
check_format_condition(
int(m.group(1)) > 0,
"indirect object definition: object ID must be greater than 0",
)
check_format_condition(
int(m.group(2)) >= 0,
"indirect object definition: generation must be non-negative",
)
check_format_condition(
expect_indirect is None
or expect_indirect
== IndirectReference(int(m.group(1)), int(m.group(2))),
"indirect object definition different than expected",
)
object, object_offset = cls.get_value(
data, m.end(), max_nesting=max_nesting - 1
)
if object_offset is None:
return object, None
m = cls.re_indirect_def_end.match(data, object_offset)
check_format_condition(
m is not None, "indirect object definition end not found"
)
assert m is not None
return object, m.end()
check_format_condition(
not expect_indirect, "indirect object definition not found"
)
m = cls.re_indirect_reference.match(data, offset)
if m:
check_format_condition(
int(m.group(1)) > 0,
"indirect object reference: object ID must be greater than 0",
)
check_format_condition(
int(m.group(2)) >= 0,
"indirect object reference: generation must be non-negative",
)
return IndirectReference(int(m.group(1)), int(m.group(2))), m.end()
m = cls.re_dict_start.match(data, offset)
if m:
offset = m.end()
result: dict[Any, Any] = {}
m = cls.re_dict_end.match(data, offset)
current_offset: int | None = offset
while not m:
assert current_offset is not None
key, current_offset = cls.get_value(
data, current_offset, max_nesting=max_nesting - 1
)
if current_offset is None:
return result, None
value, current_offset = cls.get_value(
data, current_offset, max_nesting=max_nesting - 1
)
result[key] = value
if current_offset is None:
return result, None
m = cls.re_dict_end.match(data, current_offset)
current_offset = m.end()
m = cls.re_stream_start.match(data, current_offset)
if m:
stream_len = result.get(b"Length")
if stream_len is None or not isinstance(stream_len, int):
msg = f"bad or missing Length in stream dict ({stream_len})"
raise PdfFormatError(msg)
stream_data = data[m.end() : m.end() + stream_len]
m = cls.re_stream_end.match(data, m.end() + stream_len)
check_format_condition(m is not None, "stream end not found")
assert m is not None
current_offset = m.end()
return PdfStream(PdfDict(result), stream_data), current_offset
return PdfDict(result), current_offset
m = cls.re_array_start.match(data, offset)
if m:
offset = m.end()
results = []
m = cls.re_array_end.match(data, offset)
current_offset = offset
while not m:
assert current_offset is not None
value, current_offset = cls.get_value(
data, current_offset, max_nesting=max_nesting - 1
)
results.append(value)
if current_offset is None:
return results, None
m = cls.re_array_end.match(data, current_offset)
return results, m.end()
m = cls.re_null.match(data, offset)
if m:
return None, m.end()
m = cls.re_true.match(data, offset)
if m:
return True, m.end()
m = cls.re_false.match(data, offset)
if m:
return False, m.end()
m = cls.re_name.match(data, offset)
if m:
return PdfName(cls.interpret_name(m.group(1))), m.end()
m = cls.re_int.match(data, offset)
if m:
return int(m.group(1)), m.end()
m = cls.re_real.match(data, offset)
if m:
# XXX Decimal instead of float???
return float(m.group(1)), m.end()
m = cls.re_string_hex.match(data, offset)
if m:
# filter out whitespace
hex_string = bytearray(
b for b in m.group(1) if b in b"0123456789abcdefABCDEF"
)
if len(hex_string) % 2 == 1:
# append a 0 if the length is not even - yes, at the end
hex_string.append(ord(b"0"))
return bytearray.fromhex(hex_string.decode("us-ascii")), m.end()
m = cls.re_string_lit.match(data, offset)
if m:
return cls.get_literal_string(data, m.end())
# return None, offset # fallback (only for debugging)
msg = f"unrecognized object: {repr(data[offset : offset + 32])}"
raise PdfFormatError(msg)
re_lit_str_token = re.compile(
rb"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))"
)
escaped_chars = {
b"n": b"\n",
b"r": b"\r",
b"t": b"\t",
b"b": b"\b",
b"f": b"\f",
b"(": b"(",
b")": b")",
b"\\": b"\\",
ord(b"n"): b"\n",
ord(b"r"): b"\r",
ord(b"t"): b"\t",
ord(b"b"): b"\b",
ord(b"f"): b"\f",
ord(b"("): b"(",
ord(b")"): b")",
ord(b"\\"): b"\\",
}
@classmethod
def get_literal_string(
cls, data: bytes | bytearray | mmap.mmap, offset: int
) -> tuple[bytes, int]:
nesting_depth = 0
result = bytearray()
for m in cls.re_lit_str_token.finditer(data, offset):
result.extend(data[offset : m.start()])
if m.group(1):
result.extend(cls.escaped_chars[m.group(1)[1]])
elif m.group(2):
result.append(int(m.group(2)[1:], 8))
elif m.group(3):
pass
elif m.group(5):
result.extend(b"\n")
elif m.group(6):
result.extend(b"(")
nesting_depth += 1
elif m.group(7):
if nesting_depth == 0:
return bytes(result), m.end()
result.extend(b")")
nesting_depth -= 1
offset = m.end()
msg = "unfinished literal string"
raise PdfFormatError(msg)
re_xref_section_start = re.compile(whitespace_optional + rb"xref" + newline)
re_xref_subsection_start = re.compile(
whitespace_optional
+ rb"([0-9]+)"
+ whitespace_mandatory
+ rb"([0-9]+)"
+ whitespace_optional
+ newline_only
)
re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)")
def read_xref_table(self, xref_section_offset: int) -> int:
assert self.buf is not None
subsection_found = False
m = self.re_xref_section_start.match(
self.buf, xref_section_offset + self.start_offset
)
check_format_condition(m is not None, "xref section start not found")
assert m is not None
offset = m.end()
while True:
m = self.re_xref_subsection_start.match(self.buf, offset)
if not m:
check_format_condition(
subsection_found, "xref subsection start not found"
)
break
subsection_found = True
offset = m.end()
first_object = int(m.group(1))
num_objects = int(m.group(2))
for i in range(first_object, first_object + num_objects):
m = self.re_xref_entry.match(self.buf, offset)
check_format_condition(m is not None, "xref entry not found")
assert m is not None
offset = m.end()
is_free = m.group(3) == b"f"
if not is_free:
generation = int(m.group(2))
new_entry = (int(m.group(1)), generation)
if i not in self.xref_table:
self.xref_table[i] = new_entry
return offset
def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any:
offset, generation = self.xref_table[ref[0]]
check_format_condition(
generation == ref[1],
f"expected to find generation {ref[1]} for object ID {ref[0]} in xref "
f"table, instead found generation {generation} at offset {offset}",
)
assert self.buf is not None
value = self.get_value(
self.buf,
offset + self.start_offset,
expect_indirect=IndirectReference(*ref),
max_nesting=max_nesting,
)[0]
self.cached_objects[ref] = value
return value
def linearize_page_tree(
self, node: PdfDict | None = None
) -> list[IndirectReference]:
page_node = node if node is not None else self.page_tree_root
check_format_condition(
page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages"
)
pages = []
for kid in page_node[b"Kids"]:
kid_object = self.read_indirect(kid)
if kid_object[b"Type"] == b"Page":
pages.append(kid)
else:
pages.extend(self.linearize_page_tree(node=kid_object))
return pages