remarkable-tools 0.2.1__py3-none-any.whl

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.
remarkable/__init__.py ADDED
File without changes
remarkable/helpers.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata as importlib_metadata
4
+ import logging
5
+ import os
6
+ import tomllib
7
+ import typing
8
+ from pathlib import Path
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def deep_merge(
14
+ parent_source: dict[str, typing.Any], source: dict[str, typing.Any]
15
+ ) -> dict[str, typing.Any]:
16
+ result = parent_source.copy()
17
+ for k, v in source.items():
18
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
19
+ result[k] = deep_merge(result[k], v)
20
+ else:
21
+ result[k] = v
22
+ return result
23
+
24
+
25
+ def get_config_paths(platform: str | None = None) -> list[Path]:
26
+ home = Path.home()
27
+ if (platform or os.name) == "nt":
28
+ config_paths: list[Path] = []
29
+ for env_var in ("PROGRAMDATA", "APPDATA", "LOCALAPPDATA"):
30
+ config_dir = os.getenv(env_var)
31
+ if config_dir:
32
+ config_paths.append(Path(config_dir) / "remarkable" / "config")
33
+ config_paths.append(home / ".remarkableconfig")
34
+ return config_paths
35
+
36
+ config_paths = [Path("/") / "usr" / "local" / "etc" / "remarkableconfig"]
37
+ xdg_config_home = os.getenv("XDG_CONFIG_HOME")
38
+ if xdg_config_home:
39
+ config_paths.append(Path(xdg_config_home) / "remarkable" / "config")
40
+ config_paths.append(home / ".config" / "remarkable" / "config")
41
+ config_paths.append(home / ".remarkableconfig")
42
+ return config_paths
43
+
44
+
45
+ def parse_config(default: dict[str, typing.Any]) -> dict[str, typing.Any]:
46
+ config_paths = get_config_paths()
47
+ config = default
48
+ for path in config_paths:
49
+ if not path.is_file():
50
+ continue
51
+ log.info("Loading configs from: %s", path)
52
+ with path.open("rb") as f:
53
+ data = tomllib.load(f)
54
+ log.info("Loaded config from %s: %s", path, data)
55
+ config = deep_merge(config, data)
56
+ return config
57
+
58
+
59
+ def get_runtime_version(dist_name: str = "remarkable") -> str:
60
+ """Return installed package version, or fall back to pyproject.toml."""
61
+ try:
62
+ return importlib_metadata.version(dist_name)
63
+ except importlib_metadata.PackageNotFoundError:
64
+ pass
65
+
66
+ pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
67
+ try:
68
+ with pyproject_path.open("rb") as f:
69
+ pyproject_data = tomllib.load(f)
70
+ return str(pyproject_data["project"]["version"])
71
+ except OSError, tomllib.TOMLDecodeError, KeyError, TypeError:
72
+ return "unknown"
remarkable/hl_fix.py ADDED
@@ -0,0 +1,166 @@
1
+ """Lower reMarkable highlight opacity and optionally calibrate highlight colors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from remarkable import helpers
9
+ from remarkable.hl_palette import (
10
+ HL_RGB_COLORS,
11
+ detect_highlight_palette,
12
+ format_palette_block,
13
+ )
14
+ from remarkable.hl_processing import (
15
+ ALPHA_THRESHOLD,
16
+ MIN_STROKE_WIDTH_FOR_TRANSPARENCY,
17
+ TARGET_ALPHA,
18
+ set_hl_transparency,
19
+ )
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ description=(
25
+ "Reduce reMarkable-style highlight fill and stroke opacity from "
26
+ "1.0 to 0.4 in a PDF file."
27
+ )
28
+ )
29
+ parser.add_argument(
30
+ "input_pdf",
31
+ nargs="?",
32
+ default=Path("input.pdf"),
33
+ type=Path,
34
+ help="Path to the input PDF (default: %(default)s)",
35
+ )
36
+ parser.add_argument(
37
+ "output_pdf",
38
+ nargs="?",
39
+ default=Path("output.pdf"),
40
+ type=Path,
41
+ help="Path to the output PDF (default: %(default)s)",
42
+ )
43
+ parser.add_argument(
44
+ "--version",
45
+ action="version",
46
+ version=f"%(prog)s {helpers.get_runtime_version()}",
47
+ )
48
+ parser.add_argument(
49
+ "--all-colors",
50
+ action="store_true",
51
+ help=(
52
+ "Apply opacity reduction to all fully opaque filled shapes, not only "
53
+ "known reMarkable highlight colors"
54
+ ),
55
+ )
56
+ parser.add_argument(
57
+ "--min-stroke-width",
58
+ type=float,
59
+ default=MIN_STROKE_WIDTH_FOR_TRANSPARENCY,
60
+ help=(
61
+ "Minimum stroke width required before stroke opacity is reduced "
62
+ "(default: %(default)s)."
63
+ ),
64
+ )
65
+ parser.add_argument(
66
+ "--calibrate-palette",
67
+ action="store_true",
68
+ help=(
69
+ "Detect highlight colors from a single page and print a paste-ready "
70
+ "HIGHLIGHT_RGB_COLORS block, then exit."
71
+ ),
72
+ )
73
+ parser.add_argument(
74
+ "--calibration-page",
75
+ type=int,
76
+ default=1,
77
+ help="1-based page number for --calibrate-palette (default: %(default)s).",
78
+ )
79
+ return parser
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # High-Level Commands
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def run_calibration(input_pdf: Path, calibration_page: int) -> int:
88
+ detected_palette = detect_highlight_palette(
89
+ input_pdf,
90
+ page_number=calibration_page,
91
+ )
92
+ if not detected_palette:
93
+ print(
94
+ f"No known highlight colors were detected on calibration page {calibration_page}."
95
+ )
96
+ return 0
97
+
98
+ print(
99
+ f"Detected {len(detected_palette)} highlight colors on page {calibration_page}:"
100
+ )
101
+ print(format_palette_block(detected_palette))
102
+ return 0
103
+
104
+
105
+ def run_transparency_pass(
106
+ input_pdf: Path,
107
+ output_pdf: Path,
108
+ *,
109
+ all_colors: bool,
110
+ min_stroke_width: float,
111
+ target_alpha: float,
112
+ alpha_threshold: float,
113
+ ) -> int:
114
+ with input_pdf.open("rb") as f:
115
+ output_stream, changed, total = set_hl_transparency(
116
+ f,
117
+ all_colors=all_colors,
118
+ hl_palette=HL_RGB_COLORS,
119
+ min_stroke_width=min_stroke_width,
120
+ target_alpha=target_alpha,
121
+ alpha_threshold=alpha_threshold,
122
+ )
123
+ with output_pdf.open("wb") as f:
124
+ f.write(output_stream)
125
+ print(
126
+ f"Processed {total} drawing instructions. Updated {changed} highlight paints in "
127
+ f"{output_pdf}."
128
+ )
129
+ return 0
130
+
131
+
132
+ def main() -> None:
133
+ config = helpers.parse_config(
134
+ default={
135
+ "hl_fix": {
136
+ "alpha": TARGET_ALPHA,
137
+ "min_stroke_width": MIN_STROKE_WIDTH_FOR_TRANSPARENCY,
138
+ "alpha_threshold": ALPHA_THRESHOLD,
139
+ }
140
+ }
141
+ )
142
+
143
+ parser = build_parser()
144
+ parser.set_defaults(**config.get("hl_fix", {}))
145
+ args = parser.parse_args()
146
+
147
+ if not args.input_pdf.exists():
148
+ parser.error(f"Input file not found: {args.input_pdf}")
149
+
150
+ if args.calibrate_palette:
151
+ raise SystemExit(run_calibration(args.input_pdf, args.calibration_page))
152
+
153
+ raise SystemExit(
154
+ run_transparency_pass(
155
+ args.input_pdf,
156
+ args.output_pdf,
157
+ all_colors=args.all_colors,
158
+ min_stroke_width=args.min_stroke_width,
159
+ target_alpha=config["hl_fix"]["alpha"],
160
+ alpha_threshold=config["hl_fix"]["alpha_threshold"],
161
+ )
162
+ )
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
@@ -0,0 +1,142 @@
1
+ """Utilities for highlight color matching and one-shot palette calibration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Iterator, Sequence, cast
7
+
8
+ import pikepdf
9
+
10
+ # Tuned highlight colors detected from input.pdf
11
+ RGB = tuple[float, float, float]
12
+ Palette = Sequence[RGB]
13
+ HL_RGB_COLORS: Palette = (
14
+ (1.0, 0.929412, 0.458824), # yellow
15
+ (1.0, 0.764706, 0.54902), # orange
16
+ (0.67451, 1.0, 0.521569), # green
17
+ (0.94902, 0.619608, 1.0), # pink
18
+ (0.745098, 0.917647, 0.996078), # light blue
19
+ (0.780392, 0.780392, 0.776471), # gray
20
+ )
21
+ COLOR_TOLERANCE = 0.1
22
+
23
+
24
+ def color_from_instruction(op: str, operands: Sequence[Any]) -> RGB | None:
25
+ """Return RGB color for grayscale/rgb paint operators, else None."""
26
+ if op in {"rg", "RG"}:
27
+ if len(operands) < 3:
28
+ return None
29
+ return (float(operands[0]), float(operands[1]), float(operands[2]))
30
+ if op in {"g", "G"}:
31
+ if len(operands) < 1:
32
+ return None
33
+ gray = float(operands[0])
34
+ return (gray, gray, gray)
35
+ return None
36
+
37
+
38
+ def iter_content_streams(contents: Any) -> Iterator[pikepdf.Stream]:
39
+ """Normalize page contents to a list of content streams."""
40
+ if isinstance(contents, pikepdf.Stream):
41
+ yield contents
42
+ return
43
+ yield from (
44
+ stream
45
+ for stream in cast(Sequence[Any], contents)
46
+ if isinstance(stream, pikepdf.Stream)
47
+ )
48
+
49
+
50
+ def palette_index_for_color(color: RGB | None, palette: Palette) -> int | None:
51
+ if color is None:
52
+ return None
53
+
54
+ best_cost = 100.0 # arbitrary large number
55
+ index = None
56
+ for i, expected in enumerate(palette):
57
+ if all(abs(c - e) <= COLOR_TOLERANCE for c, e in zip(color, expected)):
58
+ cost = sum(abs(c - e) for c, e in zip(color, expected))
59
+ if cost < best_cost:
60
+ best_cost = cost
61
+ index = i
62
+ return index
63
+
64
+
65
+ def _collect_palette_indices_from_stream(stream: pikepdf.Stream) -> set[int]:
66
+ instructions = pikepdf.parse_content_stream(stream)
67
+ indices: set[int] = set()
68
+
69
+ for instruction in instructions:
70
+ if not hasattr(instruction, "operator"):
71
+ continue
72
+
73
+ op = str(instruction.operator)
74
+ operands = list(instruction.operands)
75
+ color = color_from_instruction(op, operands)
76
+
77
+ idx = palette_index_for_color(color, HL_RGB_COLORS)
78
+ if idx is not None:
79
+ indices.add(idx)
80
+
81
+ return indices
82
+
83
+
84
+ def _collect_palette_indices_from_resources(resources: pikepdf.Dictionary) -> set[int]:
85
+ indices: set[int] = set()
86
+ xobjects_obj = resources.get("/XObject")
87
+ if not isinstance(xobjects_obj, pikepdf.Dictionary):
88
+ return indices
89
+
90
+ for _, xobj in xobjects_obj.items():
91
+ if not isinstance(xobj, pikepdf.Stream):
92
+ continue
93
+ if xobj.get("/Subtype") != pikepdf.Name("/Form"):
94
+ continue
95
+
96
+ indices |= _collect_palette_indices_from_stream(xobj)
97
+
98
+ form_resources_obj = xobj.get("/Resources")
99
+ if isinstance(form_resources_obj, pikepdf.Dictionary):
100
+ indices |= _collect_palette_indices_from_resources(form_resources_obj)
101
+
102
+ return indices
103
+
104
+
105
+ def detect_highlight_palette(
106
+ input_path: Path,
107
+ *,
108
+ page_number: int = 3,
109
+ ) -> tuple[RGB, ...]:
110
+ """Detect which tuned highlight colors are present on the given page."""
111
+ if page_number < 1:
112
+ msg = "page_number must be >= 1"
113
+ raise ValueError(msg)
114
+
115
+ with pikepdf.open(input_path) as pdf:
116
+ if page_number > len(pdf.pages):
117
+ msg = (
118
+ f"page_number {page_number} exceeds number of pages in PDF ({len(pdf.pages)})"
119
+ )
120
+ raise ValueError(msg)
121
+
122
+ page = pdf.pages[page_number - 1]
123
+ indices: set[int] = set()
124
+
125
+ resources_obj = page.obj.get("/Resources")
126
+ resources = resources_obj if isinstance(resources_obj, pikepdf.Dictionary) else None
127
+ if resources is not None:
128
+ indices |= _collect_palette_indices_from_resources(resources)
129
+
130
+ for stream in iter_content_streams(page.Contents):
131
+ indices |= _collect_palette_indices_from_stream(stream)
132
+
133
+ return tuple(HL_RGB_COLORS[i] for i in sorted(indices))
134
+
135
+
136
+ def format_palette_block(palette: Palette) -> str:
137
+ """Format a palette as a paste-ready HIGHLIGHT_RGB_COLORS block."""
138
+ lines = ["HIGHLIGHT_RGB_COLORS = ("]
139
+ for r, g, b in palette:
140
+ lines.append(f" ({r:.6f}, {g:.6f}, {b:.6f}),")
141
+ lines.append(")")
142
+ return "\n".join(lines)
@@ -0,0 +1,287 @@
1
+ """PDF content-stream processing for lowering highlight fill/stroke opacity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from dataclasses import dataclass, replace
7
+ from typing import Any, BinaryIO
8
+
9
+ import pikepdf
10
+
11
+ from remarkable.hl_palette import (
12
+ HL_RGB_COLORS,
13
+ RGB,
14
+ Palette,
15
+ color_from_instruction,
16
+ iter_content_streams,
17
+ palette_index_for_color,
18
+ )
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class State:
23
+ fill_alpha: float
24
+ stroke_alpha: float
25
+ line_width: float
26
+ fill_rgb: RGB | None
27
+ stroke_rgb: RGB | None
28
+
29
+
30
+ TARGET_ALPHA = 0.4
31
+ ALPHA_THRESHOLD = 0.999
32
+ MIN_STROKE_WIDTH_FOR_TRANSPARENCY = 0.1
33
+ GS_NAME_FILL = pikepdf.Name("/GS_HIGHLIGHT_FILL_20")
34
+ GS_NAME_STROKE = pikepdf.Name("/GS_HIGHLIGHT_STROKE_20")
35
+ GS_NAME_BOTH = pikepdf.Name("/GS_HIGHLIGHT_20")
36
+
37
+ FILL_OPERATORS = {"f", "f*", "B", "B*", "b", "b*"}
38
+ STROKE_OPERATORS = {"S", "s", "B", "B*", "b", "b*"}
39
+
40
+
41
+ def _ensure_extgstate(resources: pikepdf.Dictionary, target_alpha: float) -> None:
42
+ if "/ExtGState" not in resources:
43
+ resources["/ExtGState"] = pikepdf.Dictionary()
44
+
45
+ extgstate = resources["/ExtGState"]
46
+ extgstate[GS_NAME_FILL] = pikepdf.Dictionary(
47
+ {
48
+ "/Type": pikepdf.Name("/ExtGState"),
49
+ "/ca": target_alpha,
50
+ }
51
+ )
52
+ extgstate[GS_NAME_STROKE] = pikepdf.Dictionary(
53
+ {
54
+ "/Type": pikepdf.Name("/ExtGState"),
55
+ "/CA": target_alpha,
56
+ }
57
+ )
58
+ extgstate[GS_NAME_BOTH] = pikepdf.Dictionary(
59
+ {
60
+ "/Type": pikepdf.Name("/ExtGState"),
61
+ "/ca": target_alpha,
62
+ "/CA": target_alpha,
63
+ }
64
+ )
65
+
66
+
67
+ def _extgstate_alpha_map(
68
+ resources: pikepdf.Dictionary,
69
+ ) -> dict[str, tuple[float | None, float | None]]:
70
+ mapping: dict[str, tuple[float | None, float | None]] = {}
71
+ extgstate = resources.get("/ExtGState")
72
+ if extgstate is None:
73
+ return mapping
74
+
75
+ for name, state in extgstate.items():
76
+ fill_alpha = float(state["/ca"]) if "/ca" in state else None
77
+ stroke_alpha = float(state["/CA"]) if "/CA" in state else None
78
+ mapping[str(name)] = (fill_alpha, stroke_alpha)
79
+
80
+ return mapping
81
+
82
+
83
+ def _process_stream(
84
+ stream: pikepdf.Stream,
85
+ resources: pikepdf.Dictionary,
86
+ *,
87
+ all_colors: bool,
88
+ hl_palette: Palette,
89
+ min_stroke_width: float,
90
+ target_alpha: float,
91
+ alpha_threshold: float,
92
+ ) -> int:
93
+ instructions = pikepdf.parse_content_stream(stream)
94
+ if not instructions:
95
+ return 0
96
+
97
+ alpha_by_gs = _extgstate_alpha_map(resources)
98
+
99
+ state = State(
100
+ fill_alpha=1.0,
101
+ stroke_alpha=1.0,
102
+ line_width=1.0,
103
+ fill_rgb=None,
104
+ stroke_rgb=None,
105
+ )
106
+ state_stack: list[State] = []
107
+ out: list[Any] = []
108
+ changed = 0
109
+
110
+ for instruction in instructions:
111
+ if not hasattr(instruction, "operator"):
112
+ out.append(instruction)
113
+ continue
114
+
115
+ op = str(instruction.operator)
116
+ operands = list(instruction.operands)
117
+
118
+ if op == "q":
119
+ # q/Q saves and restores graphics state. Snapshot values to prevent
120
+ # alpha and color state from leaking across sibling blocks.
121
+ state_stack.append(replace(state))
122
+ elif op == "Q" and state_stack:
123
+ state = state_stack.pop()
124
+ elif op in ("rg", "g"):
125
+ state.fill_rgb = color_from_instruction(op, operands)
126
+ elif op in ("RG", "G"):
127
+ state.stroke_rgb = color_from_instruction(op, operands)
128
+ elif op == "k":
129
+ state.fill_rgb = None
130
+ elif op == "K":
131
+ state.stroke_rgb = None
132
+ elif op == "gs" and operands:
133
+ fill_alpha, stroke_alpha = alpha_by_gs.get(str(operands[0]), (None, None))
134
+ if fill_alpha is not None:
135
+ state.fill_alpha = fill_alpha
136
+ if stroke_alpha is not None:
137
+ state.stroke_alpha = stroke_alpha
138
+ elif op == "w" and operands:
139
+ try:
140
+ state.line_width = float(operands[0])
141
+ except TypeError, ValueError:
142
+ pass
143
+
144
+ should_update_fill = all_colors or (
145
+ palette_index_for_color(state.fill_rgb, hl_palette) is not None
146
+ )
147
+ should_update_stroke = all_colors or (
148
+ palette_index_for_color(state.stroke_rgb, hl_palette) is not None
149
+ )
150
+ apply_fill_opacity = (
151
+ op in FILL_OPERATORS
152
+ and should_update_fill
153
+ and state.fill_alpha >= alpha_threshold
154
+ )
155
+ apply_stroke_opacity = (
156
+ op in STROKE_OPERATORS
157
+ and should_update_stroke
158
+ and state.stroke_alpha >= alpha_threshold
159
+ and state.line_width >= min_stroke_width
160
+ )
161
+ if apply_fill_opacity or apply_stroke_opacity:
162
+ if apply_fill_opacity and apply_stroke_opacity:
163
+ gs_name = GS_NAME_BOTH
164
+ state.fill_alpha = target_alpha
165
+ state.stroke_alpha = target_alpha
166
+ elif apply_fill_opacity:
167
+ gs_name = GS_NAME_FILL
168
+ state.fill_alpha = target_alpha
169
+ else:
170
+ gs_name = GS_NAME_STROKE
171
+ state.stroke_alpha = target_alpha
172
+
173
+ out.append(pikepdf.ContentStreamInstruction([gs_name], pikepdf.Operator("gs")))
174
+ changed += 1
175
+
176
+ out.append(instruction)
177
+
178
+ if changed:
179
+ stream.write(pikepdf.unparse_content_stream(out))
180
+
181
+ return changed
182
+
183
+
184
+ def _get_or_create_resources(page: pikepdf.Page) -> pikepdf.Dictionary:
185
+ page_dict = page.obj
186
+ resources_obj = page_dict.get("/Resources")
187
+ if isinstance(resources_obj, pikepdf.Dictionary):
188
+ return resources_obj
189
+
190
+ resources = pikepdf.Dictionary()
191
+ page_dict["/Resources"] = resources
192
+ return resources
193
+
194
+
195
+ def _process_resources(
196
+ resources: pikepdf.Dictionary,
197
+ *,
198
+ all_colors: bool,
199
+ hl_palette: Palette,
200
+ min_stroke_width: float,
201
+ target_alpha: float,
202
+ alpha_threshold: float,
203
+ ) -> int:
204
+ _ensure_extgstate(resources, target_alpha)
205
+ changed = 0
206
+
207
+ xobjects_obj = resources.get("/XObject")
208
+ if not isinstance(xobjects_obj, pikepdf.Dictionary):
209
+ return changed
210
+
211
+ for _, xobj in xobjects_obj.items():
212
+ if not isinstance(xobj, pikepdf.Stream):
213
+ continue
214
+
215
+ if xobj.get("/Subtype") != pikepdf.Name("/Form"):
216
+ continue
217
+
218
+ form_resources_obj = xobj.get("/Resources")
219
+ form_resources = (
220
+ form_resources_obj if isinstance(form_resources_obj, pikepdf.Dictionary) else None
221
+ )
222
+ active_resources = form_resources if form_resources is not None else resources
223
+ _ensure_extgstate(active_resources, target_alpha)
224
+ changed += _process_stream(
225
+ xobj,
226
+ active_resources,
227
+ all_colors=all_colors,
228
+ hl_palette=hl_palette,
229
+ min_stroke_width=min_stroke_width,
230
+ target_alpha=target_alpha,
231
+ alpha_threshold=alpha_threshold,
232
+ )
233
+
234
+ if form_resources is not None:
235
+ changed += _process_resources(
236
+ form_resources,
237
+ all_colors=all_colors,
238
+ hl_palette=hl_palette,
239
+ min_stroke_width=min_stroke_width,
240
+ target_alpha=target_alpha,
241
+ alpha_threshold=alpha_threshold,
242
+ )
243
+
244
+ return changed
245
+
246
+
247
+ def set_hl_transparency(
248
+ input_stream: BinaryIO,
249
+ *,
250
+ all_colors: bool,
251
+ hl_palette: Palette = HL_RGB_COLORS,
252
+ min_stroke_width: float,
253
+ target_alpha: float,
254
+ alpha_threshold: float,
255
+ ) -> tuple[bytes, int, int]:
256
+ """Apply transparency and return (updated_paints, instructions_seen)."""
257
+ changed = 0
258
+ instructions_seen = 0
259
+
260
+ with pikepdf.open(input_stream) as pdf:
261
+ for page in pdf.pages:
262
+ resources = _get_or_create_resources(page)
263
+ _ensure_extgstate(resources, target_alpha)
264
+ changed += _process_resources(
265
+ resources,
266
+ all_colors=all_colors,
267
+ hl_palette=hl_palette,
268
+ min_stroke_width=min_stroke_width,
269
+ target_alpha=target_alpha,
270
+ alpha_threshold=alpha_threshold,
271
+ )
272
+
273
+ for stream in iter_content_streams(page.Contents):
274
+ instructions_seen += len(pikepdf.parse_content_stream(stream))
275
+ changed += _process_stream(
276
+ stream,
277
+ resources,
278
+ all_colors=all_colors,
279
+ hl_palette=hl_palette,
280
+ min_stroke_width=min_stroke_width,
281
+ target_alpha=target_alpha,
282
+ alpha_threshold=alpha_threshold,
283
+ )
284
+ buffer = io.BytesIO()
285
+ pdf.save(buffer)
286
+
287
+ return buffer.getvalue(), changed, instructions_seen