quicktools-atom 0.2.0__tar.gz → 0.4.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.
- quicktools_atom-0.4.0/PKG-INFO +59 -0
- quicktools_atom-0.4.0/pyproject.toml +34 -0
- quicktools_atom-0.4.0/src/quicktools/__init__.py +88 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools/calculus.py +51 -1
- quicktools_atom-0.4.0/src/quicktools/combinatorics.py +88 -0
- quicktools_atom-0.4.0/src/quicktools/doctools.py +89 -0
- quicktools_atom-0.4.0/src/quicktools/filetools.py +103 -0
- quicktools_atom-0.4.0/src/quicktools/imagetools.py +128 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools/linalg.py +90 -1
- quicktools_atom-0.4.0/src/quicktools/mathtools.py +128 -0
- quicktools_atom-0.4.0/src/quicktools/numbertheory.py +149 -0
- quicktools_atom-0.4.0/src/quicktools/pdftools.py +161 -0
- quicktools_atom-0.4.0/src/quicktools/spreadsheettools.py +72 -0
- quicktools_atom-0.4.0/src/quicktools/strtools.py +236 -0
- quicktools_atom-0.4.0/src/quicktools_atom.egg-info/PKG-INFO +59 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools_atom.egg-info/SOURCES.txt +6 -0
- quicktools_atom-0.4.0/src/quicktools_atom.egg-info/requires.txt +8 -0
- quicktools_atom-0.2.0/PKG-INFO +0 -47
- quicktools_atom-0.2.0/pyproject.toml +0 -17
- quicktools_atom-0.2.0/src/quicktools/__init__.py +0 -3
- quicktools_atom-0.2.0/src/quicktools/combinatorics.py +0 -36
- quicktools_atom-0.2.0/src/quicktools/mathtools.py +0 -50
- quicktools_atom-0.2.0/src/quicktools/numbertheory.py +0 -61
- quicktools_atom-0.2.0/src/quicktools/strtools.py +0 -106
- quicktools_atom-0.2.0/src/quicktools_atom.egg-info/PKG-INFO +0 -47
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/README.md +0 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/setup.cfg +0 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools/cli.py +0 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools_atom.egg-info/dependency_links.txt +0 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools_atom.egg-info/entry_points.txt +0 -0
- {quicktools_atom-0.2.0 → quicktools_atom-0.4.0}/src/quicktools_atom.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quicktools-atom
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Math and string utilities with a bundled CLI, by AtomDev Studios
|
|
5
|
+
Author-email: Samuel Peprah <windscribe.samuel@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Samuel-Peprah-cmd/quicktools
|
|
7
|
+
Project-URL: Repository, https://github.com/Samuel-Peprah-cmd/quicktools
|
|
8
|
+
Project-URL: Documentation, https://samuel-peprah-cmd.github.io/quicktools/
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: Pillow
|
|
12
|
+
Requires-Dist: python-docx
|
|
13
|
+
Requires-Dist: pypdf
|
|
14
|
+
Requires-Dist: openpyxl
|
|
15
|
+
Requires-Dist: reportlab
|
|
16
|
+
Requires-Dist: PyYAML
|
|
17
|
+
Requires-Dist: pdf2docx
|
|
18
|
+
Requires-Dist: docx2pdf
|
|
19
|
+
|
|
20
|
+
\# quicktools
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
A small toolkit with math and string utilities, plus a bundled CLI.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
\## Install
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
  pip install quicktools-atom
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
\## Usage
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
  from quicktools import mathtools, strtools
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
  mathtools.is\_prime(97)
|
|
45
|
+
|
|
46
|
+
  strtools.slugify("Hello World")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
\## CLI
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
  quicktools is-prime 97
|
|
55
|
+
|
|
56
|
+
  quicktools stats 1 2 3 4 5
|
|
57
|
+
|
|
58
|
+
  quicktools slugify "Hello World"
|
|
59
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quicktools-atom"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Math and string utilities with a bundled CLI, by AtomDev Studios"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Samuel Peprah", email = "windscribe.samuel@gmail.com" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"Pillow",
|
|
16
|
+
"python-docx",
|
|
17
|
+
"pypdf",
|
|
18
|
+
"openpyxl",
|
|
19
|
+
"reportlab",
|
|
20
|
+
"PyYAML",
|
|
21
|
+
"pdf2docx",
|
|
22
|
+
"docx2pdf",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/Samuel-Peprah-cmd/quicktools"
|
|
27
|
+
Repository = "https://github.com/Samuel-Peprah-cmd/quicktools"
|
|
28
|
+
Documentation = "https://samuel-peprah-cmd.github.io/quicktools/"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
quicktools = "quicktools.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="assets/AtomDev_Studios.png" alt="AtomDev Studios" width="320" style="border-radius: 8px;">
|
|
4
|
+
</p>
|
|
5
|
+
|
|
6
|
+
# quicktools
|
|
7
|
+
|
|
8
|
+
A math and string-manipulation toolkit covering linear algebra, calculus,
|
|
9
|
+
number theory, combinatorics, and text processing — built by **Samuel Peprah**
|
|
10
|
+
under **AtomDev Studios**.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## About Samuel Peprah
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="assets/sam.png" alt="Samuel Peprah" width="160" style="border-radius: 50%; object-fit: cover;">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
Samuel Peprah is a software developer, web application developer, and
|
|
21
|
+
Occupational Therapy student at the University of Ghana. Alongside his
|
|
22
|
+
healthcare education, he is also studying Computer Science at the University
|
|
23
|
+
of the People, combining knowledge from both fields to develop practical
|
|
24
|
+
technology solutions.
|
|
25
|
+
|
|
26
|
+
His interests span full-stack web development, software engineering,
|
|
27
|
+
automation, artificial intelligence, cybersecurity, and health technology.
|
|
28
|
+
He enjoys building scalable applications that solve real-world problems and
|
|
29
|
+
continuously explores new technologies to improve his skills.
|
|
30
|
+
|
|
31
|
+
Samuel has developed educational platforms, business management systems,
|
|
32
|
+
attendance systems, healthcare applications, and AI-powered solutions. He is
|
|
33
|
+
passionate about creating software that is modern, efficient, secure, and
|
|
34
|
+
user-friendly.
|
|
35
|
+
|
|
36
|
+
## About AtomDev Studios
|
|
37
|
+
|
|
38
|
+
AtomDev Studios is the personal software development brand founded by Samuel
|
|
39
|
+
Peprah. The studio specializes in designing and developing modern websites,
|
|
40
|
+
web applications, business management systems, educational platforms, and
|
|
41
|
+
custom software solutions for individuals, startups, schools, and
|
|
42
|
+
organizations.
|
|
43
|
+
|
|
44
|
+
The mission of AtomDev Studios is to transform ideas into reliable, scalable,
|
|
45
|
+
and innovative digital products that solve real-world problems while
|
|
46
|
+
delivering exceptional user experiences.
|
|
47
|
+
|
|
48
|
+
### Areas of Expertise
|
|
49
|
+
|
|
50
|
+
- Full-Stack Web Development
|
|
51
|
+
- Custom Web Applications
|
|
52
|
+
- REST API Development
|
|
53
|
+
- Database Design & Management
|
|
54
|
+
- Educational Technology Platforms
|
|
55
|
+
- Healthcare Technology Solutions
|
|
56
|
+
- AI Integration & Automation
|
|
57
|
+
- UI/UX Design
|
|
58
|
+
- Cloud Deployment & Hosting
|
|
59
|
+
- Website Maintenance & Support
|
|
60
|
+
|
|
61
|
+
### Technologies
|
|
62
|
+
|
|
63
|
+
Python • Flask • JavaScript • HTML • CSS • Tailwind CSS • Bootstrap •
|
|
64
|
+
PostgreSQL • SQLite • Git • GitHub • Cloudflare • Docker
|
|
65
|
+
|
|
66
|
+
### Connect
|
|
67
|
+
|
|
68
|
+
- LinkedIn: [samuel-peprah-a63598347](https://www.linkedin.com/in/samuel-peprah-a63598347)
|
|
69
|
+
- X (Twitter): [@legend_consult](https://x.com/legend_consult)
|
|
70
|
+
- TikTok: [@legendinlearning](https://www.tiktok.com/@legendinlearning)
|
|
71
|
+
- Instagram: [@ksapeprah](https://www.instagram.com/ksapeprah)
|
|
72
|
+
- GitHub: [Samuel-Peprah-cmd](https://github.com/Samuel-Peprah-cmd)
|
|
73
|
+
|
|
74
|
+
> "Building innovative software solutions that bridge technology with real-world impact."
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Modules
|
|
79
|
+
|
|
80
|
+
- `mathtools` — primes, GCD/LCM, mean, median, standard deviation
|
|
81
|
+
- `strtools` — palindromes, slugify, edit distance, anagrams, ciphers
|
|
82
|
+
- `linalg` — matrix operations, determinants, inverses, linear systems
|
|
83
|
+
- `calculus` — derivatives, integrals, Taylor series, Newton's method
|
|
84
|
+
- `numbertheory` — extended GCD, modular inverse, sieve, prime factorization
|
|
85
|
+
- `combinatorics` — permutations, combinations, Catalan numbers, Fibonacci
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
__version__ = "0.2.0"
|
|
@@ -62,4 +62,54 @@ def newtons_method(f: Callable[[float], float], x0: float, tol: float = 1e-10, m
|
|
|
62
62
|
if dfx == 0:
|
|
63
63
|
raise ZeroDivisionError("Derivative is zero; Newton's method failed")
|
|
64
64
|
x = x - fx / dfx
|
|
65
|
-
return x
|
|
65
|
+
return x
|
|
66
|
+
|
|
67
|
+
def bisection_method(f, a: float, b: float, tol: float = 1e-10, max_iter: int = 200) -> float:
|
|
68
|
+
"""Find a root of f within [a, b] using the bisection method. Requires f(a) and f(b) to have opposite signs."""
|
|
69
|
+
if f(a) * f(b) > 0:
|
|
70
|
+
raise ValueError("f(a) and f(b) must have opposite signs")
|
|
71
|
+
for _ in range(max_iter):
|
|
72
|
+
mid = (a + b) / 2
|
|
73
|
+
if abs(f(mid)) < tol or (b - a) / 2 < tol:
|
|
74
|
+
return mid
|
|
75
|
+
if f(a) * f(mid) < 0:
|
|
76
|
+
b = mid
|
|
77
|
+
else:
|
|
78
|
+
a = mid
|
|
79
|
+
return (a + b) / 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def secant_method(f, x0: float, x1: float, tol: float = 1e-10, max_iter: int = 200) -> float:
|
|
83
|
+
"""Find a root of f near x0 and x1 using the secant method (no derivative required)."""
|
|
84
|
+
for _ in range(max_iter):
|
|
85
|
+
fx0, fx1 = f(x0), f(x1)
|
|
86
|
+
if fx1 - fx0 == 0:
|
|
87
|
+
raise ZeroDivisionError("Secant method failed: division by zero")
|
|
88
|
+
x2 = x1 - fx1 * (x1 - x0) / (fx1 - fx0)
|
|
89
|
+
if abs(x2 - x1) < tol:
|
|
90
|
+
return x2
|
|
91
|
+
x0, x1 = x1, x2
|
|
92
|
+
return x1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def partial_derivative(f, point: list[float], index: int, h: float = 1e-6) -> float:
|
|
96
|
+
"""Approximate the partial derivative of a multivariable function f at `point`,
|
|
97
|
+
with respect to the variable at position `index`."""
|
|
98
|
+
point_plus = list(point)
|
|
99
|
+
point_minus = list(point)
|
|
100
|
+
point_plus[index] += h
|
|
101
|
+
point_minus[index] -= h
|
|
102
|
+
return (f(*point_plus) - f(*point_minus)) / (2 * h)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def arc_length(f, a: float, b: float, n: int = 1000) -> float:
|
|
106
|
+
"""Approximate the arc length of y = f(x) from x=a to x=b."""
|
|
107
|
+
def integrand(x: float) -> float:
|
|
108
|
+
return math.sqrt(1 + derivative(f, x) ** 2)
|
|
109
|
+
return integral_simpson(integrand, a, b, n)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def limit_approximation(f, x: float, h: float = 1e-6) -> float:
|
|
113
|
+
"""Approximate the limit of f(t) as t approaches x, by averaging values just before and after x
|
|
114
|
+
(useful for functions undefined exactly at x, e.g. removable discontinuities)."""
|
|
115
|
+
return (f(x - h) + f(x + h)) / 2
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Combinatorics: permutations, combinations, and counting sequences."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def permutations_count(n: int, r: int) -> int:
|
|
6
|
+
"""Number of ways to arrange r items out of n, order matters: nPr."""
|
|
7
|
+
if r > n or r < 0:
|
|
8
|
+
return 0
|
|
9
|
+
return math.factorial(n) // math.factorial(n - r)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def combinations_count(n: int, r: int) -> int:
|
|
13
|
+
"""Number of ways to choose r items out of n, order doesn't matter: nCr."""
|
|
14
|
+
if r > n or r < 0:
|
|
15
|
+
return 0
|
|
16
|
+
return math.factorial(n) // (math.factorial(r) * math.factorial(n - r))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def catalan_number(n: int) -> int:
|
|
20
|
+
"""The nth Catalan number — counts things like valid bracket sequences and binary tree shapes."""
|
|
21
|
+
return combinations_count(2 * n, n) // (n + 1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fibonacci(n: int) -> int:
|
|
25
|
+
"""The nth Fibonacci number (0-indexed: fibonacci(0) == 0)."""
|
|
26
|
+
if n < 0:
|
|
27
|
+
raise ValueError("n must be non-negative")
|
|
28
|
+
a, b = 0, 1
|
|
29
|
+
for _ in range(n):
|
|
30
|
+
a, b = b, a + b
|
|
31
|
+
return a
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def binomial_expansion_coeffs(n: int) -> list[int]:
|
|
35
|
+
"""Coefficients of (x + y)^n, i.e. row n of Pascal's triangle."""
|
|
36
|
+
return [combinations_count(n, k) for k in range(n + 1)]
|
|
37
|
+
|
|
38
|
+
def multinomial_coefficient(n: int, ks: list[int]) -> int:
|
|
39
|
+
"""Number of ways to divide n items into groups of sizes ks (which must sum to n)."""
|
|
40
|
+
if sum(ks) != n:
|
|
41
|
+
raise ValueError("The group sizes must sum to n")
|
|
42
|
+
result = math.factorial(n)
|
|
43
|
+
for k in ks:
|
|
44
|
+
result //= math.factorial(k)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stirling_second_kind(n: int, k: int) -> int:
|
|
49
|
+
"""Number of ways to partition a set of n objects into k non-empty subsets."""
|
|
50
|
+
if k == 0:
|
|
51
|
+
return 1 if n == 0 else 0
|
|
52
|
+
if k > n or n < 0:
|
|
53
|
+
return 0
|
|
54
|
+
if k == n:
|
|
55
|
+
return 1
|
|
56
|
+
total = 0
|
|
57
|
+
for j in range(k + 1):
|
|
58
|
+
sign = (-1) ** (k - j)
|
|
59
|
+
total += sign * combinations_count(k, j) * (j ** n)
|
|
60
|
+
return total // math.factorial(k)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def derangements_count(n: int) -> int:
|
|
64
|
+
"""Number of permutations of n items where no item ends up in its original position."""
|
|
65
|
+
if n == 0:
|
|
66
|
+
return 1
|
|
67
|
+
if n == 1:
|
|
68
|
+
return 0
|
|
69
|
+
a, b = 1, 0
|
|
70
|
+
for i in range(2, n + 1):
|
|
71
|
+
a, b = b, (i - 1) * (a + b)
|
|
72
|
+
return b
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def integer_partitions_count(n: int) -> int:
|
|
76
|
+
"""Number of ways to write n as a sum of positive integers, order not mattering."""
|
|
77
|
+
if n < 0:
|
|
78
|
+
return 0
|
|
79
|
+
partitions = [1] + [0] * n
|
|
80
|
+
for k in range(1, n + 1):
|
|
81
|
+
for i in range(k, n + 1):
|
|
82
|
+
partitions[i] += partitions[i - k]
|
|
83
|
+
return partitions[n]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def combinations_with_replacement_count(n: int, r: int) -> int:
|
|
87
|
+
"""Number of ways to choose r items from n types, with repetition allowed and order not mattering."""
|
|
88
|
+
return combinations_count(n + r - 1, r)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Word document utilities: reading, creating, editing .docx files, and converting to/from PDF."""
|
|
2
|
+
from docx import Document
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_docx_text(path: str) -> str:
|
|
6
|
+
"""Extract and return all paragraph text from a .docx file."""
|
|
7
|
+
doc = Document(path)
|
|
8
|
+
return "\n".join(p.text for p in doc.paragraphs)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_docx_from_text(text: str, output_path: str) -> None:
|
|
12
|
+
"""Create a new .docx file, adding each line of text as a separate paragraph."""
|
|
13
|
+
doc = Document()
|
|
14
|
+
for line in text.split("\n"):
|
|
15
|
+
doc.add_paragraph(line)
|
|
16
|
+
doc.save(output_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_docx_word_count(path: str) -> int:
|
|
20
|
+
"""Count the total number of words across all paragraphs in a .docx file."""
|
|
21
|
+
doc = Document(path)
|
|
22
|
+
return sum(len(p.text.split()) for p in doc.paragraphs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_docx_paragraph_count(path: str) -> int:
|
|
26
|
+
"""Count the number of paragraphs in a .docx file."""
|
|
27
|
+
return len(Document(path).paragraphs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def replace_text_in_docx(path: str, old: str, new: str, output_path: str) -> None:
|
|
31
|
+
"""Replace all occurrences of `old` with `new` across every paragraph, saving to output_path."""
|
|
32
|
+
doc = Document(path)
|
|
33
|
+
for p in doc.paragraphs:
|
|
34
|
+
if old in p.text:
|
|
35
|
+
for run in p.runs:
|
|
36
|
+
if old in run.text:
|
|
37
|
+
run.text = run.text.replace(old, new)
|
|
38
|
+
doc.save(output_path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_docx_tables(path: str) -> list[list[list[str]]]:
|
|
42
|
+
"""Extract every table in a .docx file as a list of tables, each a list of rows, each a list of cell strings."""
|
|
43
|
+
doc = Document(path)
|
|
44
|
+
all_tables = []
|
|
45
|
+
for table in doc.tables:
|
|
46
|
+
rows = []
|
|
47
|
+
for row in table.rows:
|
|
48
|
+
rows.append([cell.text for cell in row.cells])
|
|
49
|
+
all_tables.append(rows)
|
|
50
|
+
return all_tables
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def merge_docx_files(paths: list[str], output_path: str) -> None:
|
|
54
|
+
"""Merge multiple .docx files into one, concatenating their paragraphs in order."""
|
|
55
|
+
combined = Document()
|
|
56
|
+
for i, path in enumerate(paths):
|
|
57
|
+
doc = Document(path)
|
|
58
|
+
for p in doc.paragraphs:
|
|
59
|
+
combined.add_paragraph(p.text)
|
|
60
|
+
if i < len(paths) - 1:
|
|
61
|
+
combined.add_page_break()
|
|
62
|
+
combined.save(output_path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def docx_to_pdf(path: str, output_path: str) -> None:
|
|
66
|
+
"""Convert a .docx file to PDF. Requires Microsoft Word to be installed (Windows/macOS) and
|
|
67
|
+
the optional 'docx2pdf' package (pip install docx2pdf)."""
|
|
68
|
+
try:
|
|
69
|
+
from docx2pdf import convert
|
|
70
|
+
except ImportError:
|
|
71
|
+
raise ImportError(
|
|
72
|
+
"docx_to_pdf() requires docx2pdf. Install it with: pip install docx2pdf\n"
|
|
73
|
+
"Note: it also requires Microsoft Word to be installed on this machine."
|
|
74
|
+
)
|
|
75
|
+
convert(path, output_path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pdf_to_docx(path: str, output_path: str) -> None:
|
|
79
|
+
"""Convert a PDF file to .docx, preserving text layout where possible.
|
|
80
|
+
Requires the optional 'pdf2docx' package (pip install pdf2docx)."""
|
|
81
|
+
try:
|
|
82
|
+
from pdf2docx import Converter
|
|
83
|
+
except ImportError:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"pdf_to_docx() requires pdf2docx. Install it with: pip install pdf2docx"
|
|
86
|
+
)
|
|
87
|
+
cv = Converter(path)
|
|
88
|
+
cv.convert(output_path)
|
|
89
|
+
cv.close()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""File utilities: reading, writing, hashing, zipping, and inspecting files of any type."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import zipfile
|
|
6
|
+
import mimetypes
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_file_info(path: str) -> dict:
|
|
11
|
+
"""Return basic metadata about a file: size, extension, mime type, last modified time."""
|
|
12
|
+
stat = os.stat(path)
|
|
13
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
14
|
+
return {
|
|
15
|
+
"size_bytes": stat.st_size,
|
|
16
|
+
"extension": os.path.splitext(path)[1],
|
|
17
|
+
"mime_type": mime_type or "unknown",
|
|
18
|
+
"modified_time": stat.st_mtime,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_text_file(path: str, encoding: str = "utf-8") -> str:
|
|
23
|
+
"""Read and return the full contents of a text file."""
|
|
24
|
+
with open(path, "r", encoding=encoding) as f:
|
|
25
|
+
return f.read()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_text_file(path: str, content: str, encoding: str = "utf-8") -> None:
|
|
29
|
+
"""Write text content to a file, overwriting it if it exists."""
|
|
30
|
+
with open(path, "w", encoding=encoding) as f:
|
|
31
|
+
f.write(content)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_json_file(path: str) -> object:
|
|
35
|
+
"""Read and parse a JSON file, returning the resulting Python object."""
|
|
36
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_json_file(path: str, data: object, indent: int = 2) -> None:
|
|
41
|
+
"""Write a Python object to a file as formatted JSON."""
|
|
42
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
43
|
+
json.dump(data, f, indent=indent)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_yaml_file(path: str) -> object:
|
|
47
|
+
"""Read and parse a YAML file, returning the resulting Python object."""
|
|
48
|
+
import yaml
|
|
49
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
50
|
+
return yaml.safe_load(f)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_yaml_file(path: str, data: object) -> None:
|
|
54
|
+
"""Write a Python object to a file as YAML."""
|
|
55
|
+
import yaml
|
|
56
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
57
|
+
yaml.safe_dump(data, f)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compute_file_hash(path: str, algorithm: str = "sha256") -> str:
|
|
61
|
+
"""Compute the hex digest hash of a file's contents (works for any file type)."""
|
|
62
|
+
h = hashlib.new(algorithm)
|
|
63
|
+
with open(path, "rb") as f:
|
|
64
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
65
|
+
h.update(chunk)
|
|
66
|
+
return h.hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def zip_files(file_paths: list[str], output_zip_path: str) -> None:
|
|
70
|
+
"""Compress a list of files into a single .zip archive."""
|
|
71
|
+
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
72
|
+
for path in file_paths:
|
|
73
|
+
zf.write(path, arcname=os.path.basename(path))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def unzip_file(zip_path: str, output_dir: str) -> list[str]:
|
|
77
|
+
"""Extract a .zip archive into output_dir, returning the list of extracted file names."""
|
|
78
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
79
|
+
zf.extractall(output_dir)
|
|
80
|
+
return zf.namelist()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def list_files_in_directory(directory: str, extension: str | None = None) -> list[str]:
|
|
84
|
+
"""List all files in a directory, optionally filtered by extension (e.g. '.txt')."""
|
|
85
|
+
files = os.listdir(directory)
|
|
86
|
+
if extension:
|
|
87
|
+
files = [f for f in files if f.endswith(extension)]
|
|
88
|
+
return files
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_file_extension(path: str) -> str:
|
|
92
|
+
"""Return the file extension of a path, including the leading dot (e.g. '.pdf')."""
|
|
93
|
+
return os.path.splitext(path)[1]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def copy_file(src: str, dst: str) -> None:
|
|
97
|
+
"""Copy a file from src to dst."""
|
|
98
|
+
shutil.copy2(src, dst)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def delete_file(path: str) -> None:
|
|
102
|
+
"""Delete a file. Raises FileNotFoundError if it doesn't exist."""
|
|
103
|
+
os.remove(path)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Image utilities: format conversion, resizing, editing, and image-to-PDF, powered by Pillow.
|
|
2
|
+
|
|
3
|
+
Supports conversion between PNG, JPEG/JPG, BMP, GIF, WEBP, TIFF, and more —
|
|
4
|
+
the output format is inferred automatically from the output file's extension.
|
|
5
|
+
"""
|
|
6
|
+
from PIL import Image, ImageOps, ImageDraw, ImageFont
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def convert_image(input_path: str, output_path: str) -> None:
|
|
10
|
+
"""Convert an image from one format to another (e.g. PNG -> JPEG, JPEG -> WEBP).
|
|
11
|
+
The target format is inferred from output_path's file extension."""
|
|
12
|
+
img = Image.open(input_path)
|
|
13
|
+
# JPEG doesn't support transparency (RGBA); flatten onto white if needed.
|
|
14
|
+
if output_path.lower().endswith((".jpg", ".jpeg")) and img.mode in ("RGBA", "P"):
|
|
15
|
+
img = img.convert("RGB")
|
|
16
|
+
img.save(output_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resize_image(input_path: str, output_path: str, width: int, height: int) -> None:
|
|
20
|
+
"""Resize an image to the given width and height."""
|
|
21
|
+
img = Image.open(input_path)
|
|
22
|
+
resized = img.resize((width, height))
|
|
23
|
+
resized.save(output_path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rotate_image(input_path: str, output_path: str, degrees: float) -> None:
|
|
27
|
+
"""Rotate an image by the given number of degrees (counter-clockwise), expanding the canvas as needed."""
|
|
28
|
+
img = Image.open(input_path)
|
|
29
|
+
rotated = img.rotate(degrees, expand=True)
|
|
30
|
+
rotated.save(output_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def flip_image(input_path: str, output_path: str, direction: str = "horizontal") -> None:
|
|
34
|
+
"""Flip an image. direction is 'horizontal' or 'vertical'."""
|
|
35
|
+
img = Image.open(input_path)
|
|
36
|
+
if direction == "horizontal":
|
|
37
|
+
flipped = ImageOps.mirror(img)
|
|
38
|
+
elif direction == "vertical":
|
|
39
|
+
flipped = ImageOps.flip(img)
|
|
40
|
+
else:
|
|
41
|
+
raise ValueError("direction must be 'horizontal' or 'vertical'")
|
|
42
|
+
flipped.save(output_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def grayscale_image(input_path: str, output_path: str) -> None:
|
|
46
|
+
"""Convert an image to grayscale."""
|
|
47
|
+
img = Image.open(input_path).convert("L")
|
|
48
|
+
img.save(output_path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def crop_image(input_path: str, output_path: str, left: int, top: int, right: int, bottom: int) -> None:
|
|
52
|
+
"""Crop an image to the box defined by (left, top, right, bottom) pixel coordinates."""
|
|
53
|
+
img = Image.open(input_path)
|
|
54
|
+
cropped = img.crop((left, top, right, bottom))
|
|
55
|
+
cropped.save(output_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_image_info(path: str) -> dict:
|
|
59
|
+
"""Return basic information about an image: format, dimensions, and color mode."""
|
|
60
|
+
img = Image.open(path)
|
|
61
|
+
return {"format": img.format, "size": img.size, "mode": img.mode}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_thumbnail(input_path: str, output_path: str, max_size: int = 128) -> None:
|
|
65
|
+
"""Create a thumbnail no larger than max_size x max_size, preserving aspect ratio."""
|
|
66
|
+
img = Image.open(input_path)
|
|
67
|
+
img.thumbnail((max_size, max_size))
|
|
68
|
+
img.save(output_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compress_image(input_path: str, output_path: str, quality: int = 70) -> None:
|
|
72
|
+
"""Save a (typically JPEG) image at a reduced quality level to shrink file size."""
|
|
73
|
+
img = Image.open(input_path)
|
|
74
|
+
if img.mode in ("RGBA", "P"):
|
|
75
|
+
img = img.convert("RGB")
|
|
76
|
+
img.save(output_path, quality=quality, optimize=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def merge_images_horizontally(paths: list[str], output_path: str) -> None:
|
|
80
|
+
"""Combine multiple images side by side into a single image."""
|
|
81
|
+
images = [Image.open(p) for p in paths]
|
|
82
|
+
total_width = sum(img.width for img in images)
|
|
83
|
+
max_height = max(img.height for img in images)
|
|
84
|
+
combined = Image.new("RGB", (total_width, max_height), "white")
|
|
85
|
+
x_offset = 0
|
|
86
|
+
for img in images:
|
|
87
|
+
combined.paste(img, (x_offset, 0))
|
|
88
|
+
x_offset += img.width
|
|
89
|
+
combined.save(output_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def merge_images_vertically(paths: list[str], output_path: str) -> None:
|
|
93
|
+
"""Stack multiple images on top of each other into a single image."""
|
|
94
|
+
images = [Image.open(p) for p in paths]
|
|
95
|
+
max_width = max(img.width for img in images)
|
|
96
|
+
total_height = sum(img.height for img in images)
|
|
97
|
+
combined = Image.new("RGB", (max_width, total_height), "white")
|
|
98
|
+
y_offset = 0
|
|
99
|
+
for img in images:
|
|
100
|
+
combined.paste(img, (0, y_offset))
|
|
101
|
+
y_offset += img.height
|
|
102
|
+
combined.save(output_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_text_watermark(input_path: str, output_path: str, text: str) -> None:
|
|
106
|
+
"""Overlay a text watermark in the bottom-right corner of an image."""
|
|
107
|
+
img = Image.open(input_path).convert("RGBA")
|
|
108
|
+
overlay = Image.new("RGBA", img.size, (255, 255, 255, 0))
|
|
109
|
+
draw = ImageDraw.Draw(overlay)
|
|
110
|
+
try:
|
|
111
|
+
font = ImageFont.load_default()
|
|
112
|
+
except Exception:
|
|
113
|
+
font = None
|
|
114
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
115
|
+
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
116
|
+
position = (img.width - text_w - 10, img.height - text_h - 10)
|
|
117
|
+
draw.text(position, text, fill=(255, 255, 255, 180), font=font)
|
|
118
|
+
watermarked = Image.alpha_composite(img, overlay).convert("RGB")
|
|
119
|
+
watermarked.save(output_path)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def images_to_pdf(image_paths: list[str], output_pdf_path: str) -> None:
|
|
123
|
+
"""Combine one or more images into a single multi-page PDF file."""
|
|
124
|
+
images = [Image.open(p).convert("RGB") for p in image_paths]
|
|
125
|
+
if not images:
|
|
126
|
+
raise ValueError("At least one image path is required")
|
|
127
|
+
first, rest = images[0], images[1:]
|
|
128
|
+
first.save(output_pdf_path, save_all=True, append_images=rest)
|