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 +0 -0
- remarkable/helpers.py +72 -0
- remarkable/hl_fix.py +166 -0
- remarkable/hl_palette.py +142 -0
- remarkable/hl_processing.py +287 -0
- remarkable/remarkable_model.py +188 -0
- remarkable/ui.py +347 -0
- remarkable/ui.tcss +74 -0
- remarkable_tools-0.2.1.dist-info/METADATA +246 -0
- remarkable_tools-0.2.1.dist-info/RECORD +13 -0
- remarkable_tools-0.2.1.dist-info/WHEEL +4 -0
- remarkable_tools-0.2.1.dist-info/entry_points.txt +4 -0
- remarkable_tools-0.2.1.dist-info/licenses/LICENSE.md +7 -0
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()
|
remarkable/hl_palette.py
ADDED
|
@@ -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
|