cacography 0.14.0b1__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.
- cacography-0.14.0b1.dist-info/METADATA +178 -0
- cacography-0.14.0b1.dist-info/RECORD +30 -0
- cacography-0.14.0b1.dist-info/WHEEL +5 -0
- cacography-0.14.0b1.dist-info/entry_points.txt +3 -0
- cacography-0.14.0b1.dist-info/licenses/LICENSE +40 -0
- cacography-0.14.0b1.dist-info/licenses/latex/LICENSE +15 -0
- cacography-0.14.0b1.dist-info/top_level.txt +1 -0
- cacography_render/__init__.py +1 -0
- cacography_render/__main__.py +155 -0
- cacography_render/aspects.py +128 -0
- cacography_render/camera.py +547 -0
- cacography_render/cli.py +293 -0
- cacography_render/drawing.py +247 -0
- cacography_render/fabric.py +579 -0
- cacography_render/handling.py +502 -0
- cacography_render/imaging.py +123 -0
- cacography_render/ink.py +199 -0
- cacography_render/marks.py +501 -0
- cacography_render/model.py +233 -0
- cacography_render/page.py +1533 -0
- cacography_render/paper.py +381 -0
- cacography_render/pen.py +188 -0
- cacography_render/pens.py +962 -0
- cacography_render/props.py +839 -0
- cacography_render/render3d.py +485 -0
- cacography_render/surfaces.py +463 -0
- cacography_render/symbols.py +111 -0
- cacography_render/tex/LICENSE +15 -0
- cacography_render/tex/cacography.lua +620 -0
- cacography_render/tex/cacography.sty +182 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cacography
|
|
3
|
+
Version: 0.14.0b1
|
|
4
|
+
Summary: Typeset in LaTeX, get back a phone photo of handwritten notes.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/aynader/cacography
|
|
7
|
+
Keywords: latex,lualatex,handwriting,notes,rendering
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
11
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
License-File: latex/LICENSE
|
|
16
|
+
Requires-Dist: numpy>=1.26
|
|
17
|
+
Requires-Dist: pillow>=10
|
|
18
|
+
Requires-Dist: torch>=2.2
|
|
19
|
+
Requires-Dist: mitsuba>=3.5
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# cacography
|
|
23
|
+
|
|
24
|
+
*cacography* (n.): bad handwriting, on purpose.
|
|
25
|
+
|
|
26
|
+
Write your notes in LaTeX and get back a phone photo of them handwritten on a slightly abused sheet of paper, with crossouts, coffee rings and all. The PDF underneath stays searchable, so nobody has to know. ;)
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
Every word is written by a handwriting model trained on real pen recordings. Each photo is traced in 3D (paper, desk, lamp, pen). LaTeX still does the layout, the maths and the figures.
|
|
31
|
+
|
|
32
|
+
## From this to this
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
You need LuaLaTeX ([TeX Live](https://tug.org/texlive) or [MiKTeX](https://miktex.org)). Then, from this folder:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
.\install.ps1 # Windows
|
|
42
|
+
sh install.sh # macOS, Linux
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The installer brings its own Python (via [uv](https://docs.astral.sh/uv)), installs the renderer, puts the LaTeX package where TeX finds it, fetches the handwriting models and runs `cacography doctor` to check everything. Allow for a few GB of download, mostly PyTorch. Already have PyTorch? Activate that environment first (`conda activate base`) and the installer uses it instead. An NVIDIA GPU is optional. Without one, pages are photographed with formulas instead of traced in 3D: no props, simpler light.
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cacography notes.tex
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or press Compile in your editor. Compile with LuaLaTeX and shell escape on, twice, as you would for references. The first run draws the pages and the second puts them in.
|
|
54
|
+
|
|
55
|
+
- **TeXstudio:** put these two lines at the top of the file. TeXstudio asks once whether to allow them.
|
|
56
|
+
```latex
|
|
57
|
+
% !TeX program = lualatex
|
|
58
|
+
% !TeX TXS-program:compile = txs:///lualatex/[--shell-escape]
|
|
59
|
+
```
|
|
60
|
+
- **VS Code (LaTeX Workshop), latexmk:** copy [`.latexmkrc`](https://github.com/aynader/cacography/blob/main/.latexmkrc) next to your file. It reruns by itself.
|
|
61
|
+
- **TeXworks, Texmaker, others:** add `-shell-escape` to the LuaLaTeX command.
|
|
62
|
+
- **Overleaf:** no. The pages are drawn by a program on your computer.
|
|
63
|
+
|
|
64
|
+
Start from [`examples/notes.tex`](https://github.com/aynader/cacography/blob/main/examples/notes.tex). Only pages that changed are drawn again.
|
|
65
|
+
|
|
66
|
+
## Options
|
|
67
|
+
|
|
68
|
+
```latex
|
|
69
|
+
\usepackage[paper=graph, ink=pencil, light=window, crumple=0.6, seed=42]{cacography}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Anything you don't set is picked at random from the `seed`. Each part has its own seed, so pinning one part never reshuffles another. `\cacographypage{crumple=1, stains=coffee}` changes just the sheet it lands on. In each row below, only the named option changes.
|
|
73
|
+
|
|
74
|
+
### The writing
|
|
75
|
+
|
|
76
|
+
**`seed`**: a different writer (and room) for each number.
|
|
77
|
+

|
|
78
|
+
|
|
79
|
+
**`sloppiness`**: 0 to 1, from neat, even lines to words that change size, tilt and wander off the line.
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
**`ink`**: `blue`, `black`, `red` (ballpoint), `pencil`.
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+
**`paper`**: `notebook`, `wide`, `graph`, `legal` (yellow pad), `blank`.
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
**`underline`**: `headings` underlines section titles by hand.
|
|
89
|
+

|
|
90
|
+
|
|
91
|
+
### The photo
|
|
92
|
+
|
|
93
|
+
**`camera`**: `phone`, `scan`, `none` (just the paper).
|
|
94
|
+

|
|
95
|
+
|
|
96
|
+
**`background`**: 30-odd desks and fabrics (`oak`, `walnut`, `marble`, `concrete`, `denim`, `plaid`, `cork`, …), a group (`wood`, `fabric`, `stone`), or `file:my-desk.jpg`.
|
|
97
|
+

|
|
98
|
+
|
|
99
|
+
**`light`**: `lamp`, `overhead`, `window`, `soft`, `raking`, `flash`, `dim`.
|
|
100
|
+

|
|
101
|
+
|
|
102
|
+
**`angle`**: `flat`, `slight`, `steep`, or degrees.
|
|
103
|
+

|
|
104
|
+
|
|
105
|
+
**`framing`**: `tight`, `loose`, `cropped`.
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+
**`shadow`**: `none`, or `phone` (your phone and hand, blocking the light).
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
**`props`**: any of `tape`, `clip`, `binder`, `staple`, `pen`, `sheet`. Pens come in a dozen kinds, and pencil ink brings a pencil.
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
### The sheet
|
|
115
|
+
|
|
116
|
+
**`crumple`**: `none`, or 0 to 1 (balled up and smoothed out again).
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
**`folds`**: `half`, `thirds`, `quarters`. **`creases`**, **`scores`** (thin pressed lines): how many.
|
|
120
|
+

|
|
121
|
+

|
|
122
|
+
|
|
123
|
+
**`edges`**: `clean`, `worn`, `torn`, `spiral`.
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
**`stains`**: `coffee`, `water`, `smudge` (ink dragged by a hand), or several: `stains={coffee, smudge}`.
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
**`grooves`**: 0 to 1, how hard the pen pressed into the paper, with faint grooves from writing on the sheet above. Subtle on purpose; it shows in raking light, up close.
|
|
130
|
+
|
|
131
|
+
**`curl`**: 0 to 1, edges and corners rolling up.
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+
**`age`**: 0 to 1, yellowed and faded.
|
|
135
|
+

|
|
136
|
+
|
|
137
|
+
**`backside`**: `writing` shows the back of the sheet through.
|
|
138
|
+

|
|
139
|
+
|
|
140
|
+
**`wear`**: 0 to 1, how likely everything in this section is when left random.
|
|
141
|
+

|
|
142
|
+
|
|
143
|
+
## Commands
|
|
144
|
+
|
|
145
|
+
Corrections are written the way people make them. Each picture below shows the LaTeX beside what it wrote.
|
|
146
|
+
|
|
147
|
+
**`\crossout[line|double|scribble]{text}`**
|
|
148
|
+

|
|
149
|
+
|
|
150
|
+
**`\erased{old}{new}`**: rubbed out and written over; best with `ink=pencil`.
|
|
151
|
+

|
|
152
|
+
|
|
153
|
+
**`\cacographynote{text}`**: a margin note; with `*`, an arrow to where it belongs; with `[right]`, in the right margin.
|
|
154
|
+

|
|
155
|
+
|
|
156
|
+
**`\boxed`, `\underline`, `\fbox`**: drawn by hand.
|
|
157
|
+

|
|
158
|
+
|
|
159
|
+
**TikZ and pgfplots**: every line, curve, label and hatch drawn again by hand.
|
|
160
|
+

|
|
161
|
+
|
|
162
|
+
**`\reroll[n]{word}`**: one badly written word written again, leaving the rest of the page alone.
|
|
163
|
+

|
|
164
|
+
|
|
165
|
+
## How it works
|
|
166
|
+
|
|
167
|
+
1. **LuaLaTeX** lays the page out at handwriting proportions (letters 10% wider, word gaps 2.3× wider), so lines break where a hand would. It records where every glyph, rule and drawing goes.
|
|
168
|
+
2. **The writer**, an LSTM with a mixture-density output, writes each word. A reader trained alongside it reads the word back, and anything that doesn't read as its text is written again. Maths symbols come from real handwritten samples.
|
|
169
|
+
3. **The ink** is laid down like a ballpoint's: heavier on downstrokes, pooling where the pen lands, skipping now and then. It goes onto paper with grain, ruling, holes, folds and stains.
|
|
170
|
+
4. **The camera** traces the sheet in 3D with [Mitsuba 3](https://www.mitsuba-renderer.org): bent paper, a real lamp, props, the phone's own processing and JPEG. The photo goes back into the PDF over the invisible typeset text.
|
|
171
|
+
|
|
172
|
+
The model, datasets and training recipe are in [`training/`](https://github.com/aynader/cacography/blob/main/training/README.md). Trained on [BRUSH](https://github.com/brownvc/decoupled-style-descriptors#brush-dataset), [IAM-OnDB](https://fki.tic.heia-fr.ch/databases/iam-on-line-handwriting-database) and [MathWriting](https://github.com/google-research/google-research/tree/master/mathwriting). In a blind test, its handwriting was judged real as often as real handwriting was.
|
|
173
|
+
|
|
174
|
+
The full reference is the [manual](https://github.com/aynader/cacography/blob/main/docs/cacography-manual.pdf). English and German are supported.
|
|
175
|
+
|
|
176
|
+
## Licence
|
|
177
|
+
|
|
178
|
+
The LaTeX package is under the [LPPL 1.3c](https://github.com/aynader/cacography/blob/main/latex/LICENSE) and the rest of the code under [MIT](https://github.com/aynader/cacography/blob/main/LICENSE). The trained handwriting models are a separate download for **non-commercial research only**, because the datasets they learned from allow only that.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
cacography-0.14.0b1.dist-info/licenses/LICENSE,sha256=Ms0tgx07vBxXHHrH_TMymWcoiEyHZvVXJ3jDFz4rIu4,2088
|
|
2
|
+
cacography-0.14.0b1.dist-info/licenses/latex/LICENSE,sha256=KWJTHKS2EAzlbaS3KuTXvwrzAIqdOff5wZkqKepyxMw,616
|
|
3
|
+
cacography_render/__init__.py,sha256=KK8_jQh9P-792eCgOJ7TpvH796pwxn4mHHYLCDSNZM0,71
|
|
4
|
+
cacography_render/__main__.py,sha256=kyM1m58R-QegtHCpqPmf6mdVQ9uZsQpaD4ovbLWOF4s,7931
|
|
5
|
+
cacography_render/aspects.py,sha256=dMnvuLEgmcOgAdgaXqv7Cpkmlct9HofLXEfyYOktwQQ,6364
|
|
6
|
+
cacography_render/camera.py,sha256=i_4KChRXtlJ69h64TWSW7PHID2AHH0JBlSzGkLhd6rU,29123
|
|
7
|
+
cacography_render/cli.py,sha256=okr0U1qJKd_mhqoQa3Ue-s_52KGN39cQ6-4j2VzMTWk,12011
|
|
8
|
+
cacography_render/drawing.py,sha256=nkeGJTt3p-ZnyhC0mpP9KuJS3egn86ikJuNNpjEha7Y,9813
|
|
9
|
+
cacography_render/fabric.py,sha256=bbOu9fnJe3v_qNkJSFleWLBtmFlvma9l5k-FOk6v9Ik,30826
|
|
10
|
+
cacography_render/handling.py,sha256=CZK5-TB7gpJhIQTxY2bNdhjUJHUNj2xOM3VFvXxTo4g,25597
|
|
11
|
+
cacography_render/imaging.py,sha256=4tPLAm3pUnnZtxURNRY8Ak8B8RF2bvuX1Cpf1CgvTpA,4598
|
|
12
|
+
cacography_render/ink.py,sha256=aR_VBeLx4yQi8hjOhlnN6yxQ4Msea0OgN2BQow5Fef8,9943
|
|
13
|
+
cacography_render/marks.py,sha256=PHYTl_CfMx8wgdgLhN0Th7VMkIthR1XXlJlXBKB4uHA,21666
|
|
14
|
+
cacography_render/model.py,sha256=miuvWCdruw9l8YYjL7arEmzcuY6Bpif52UATIP7RZ0o,10165
|
|
15
|
+
cacography_render/page.py,sha256=ln8Z08ndmVbMeUJnOZXEDOibgMOCI9jUyaFdghvrEDE,81871
|
|
16
|
+
cacography_render/paper.py,sha256=9-gBZMCkWuE8ny0MTq5sSZ1A5a59laOdamglEJoxVtM,21217
|
|
17
|
+
cacography_render/pen.py,sha256=5BJvLc7whZ8mVV0v2BdBVSFYL9GHpkrUlZPgzyTWOk0,9531
|
|
18
|
+
cacography_render/pens.py,sha256=9K8890vB6FOigOZ7s7vKatp7xGHY5bWa9umXXz9D02A,54163
|
|
19
|
+
cacography_render/props.py,sha256=o-92eS1CBY2HTTyhfnc47whygNtJ-vcAOZhTFR7BMIA,45196
|
|
20
|
+
cacography_render/render3d.py,sha256=hWpcqVPSnv0errHlKI_slnlp6ClmZpbxQvXOCS-7Wu8,29670
|
|
21
|
+
cacography_render/surfaces.py,sha256=n-ZcWm9Jew2oZ1j-_yFFSCW0Zfm3rbAOAqYexTacV8M,24525
|
|
22
|
+
cacography_render/symbols.py,sha256=zT7DGmGRCeoNkgwp6_dcig9I1CV_N5T-O6zPpHLXn6Q,6136
|
|
23
|
+
cacography_render/tex/LICENSE,sha256=KWJTHKS2EAzlbaS3KuTXvwrzAIqdOff5wZkqKepyxMw,616
|
|
24
|
+
cacography_render/tex/cacography.lua,sha256=stlWWtU-mv6KSAOBBThV35s65exs3VIT6UULAzuFlyA,21988
|
|
25
|
+
cacography_render/tex/cacography.sty,sha256=CqPo8LOptVYFYIwFiIaY6uR0s1eVXZjlBDj0uVPPsNM,8999
|
|
26
|
+
cacography-0.14.0b1.dist-info/METADATA,sha256=E01Rf3-_8GFe1zBveNg9X0siIvL2eiJEhow53k4BlpM,10415
|
|
27
|
+
cacography-0.14.0b1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
28
|
+
cacography-0.14.0b1.dist-info/entry_points.txt,sha256=ulttSD-ngdgWf4ti6Q2_-0Ya_cZ3XLwQMVPCVuKaU1Y,110
|
|
29
|
+
cacography-0.14.0b1.dist-info/top_level.txt,sha256=wrxbH32fpwKhXKZVS8vbQjUL8ULxecQ8R1XshjIkX6g,18
|
|
30
|
+
cacography-0.14.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
cacography is three things under three licences.
|
|
2
|
+
|
|
3
|
+
1. The LaTeX package (latex/cacography.sty, latex/cacography.lua) is under the LaTeX
|
|
4
|
+
Project Public License, version 1.3c or later (latex/LICENSE).
|
|
5
|
+
|
|
6
|
+
2. Everything else in this repository (the renderer in python/, the training code in
|
|
7
|
+
training/, the tools and examples) is under the MIT License below.
|
|
8
|
+
|
|
9
|
+
3. The trained handwriting models and the symbol bank are NOT in this repository and NOT
|
|
10
|
+
under either licence. They are a separate download (cacography-models.zip) and may be
|
|
11
|
+
used for non-commercial research only, because they are trained on datasets that allow
|
|
12
|
+
only that: BRUSH (Brown University), IAM-OnDB (University of Bern) and MathWriting
|
|
13
|
+
(Google, CC BY-NC-SA 4.0). See MODELS-LICENSE in that download.
|
|
14
|
+
|
|
15
|
+
Textures and room lights fetched at run time come from Poly Haven and ambientCG under CC0.
|
|
16
|
+
They are cached on your computer and not redistributed.
|
|
17
|
+
|
|
18
|
+
-------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
MIT License
|
|
21
|
+
|
|
22
|
+
Copyright (c) 2026 the cacography authors
|
|
23
|
+
|
|
24
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
25
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
26
|
+
in the Software without restriction, including without limitation the rights
|
|
27
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
29
|
+
furnished to do so, subject to the following conditions:
|
|
30
|
+
|
|
31
|
+
The above copyright notice and this permission notice shall be included in all
|
|
32
|
+
copies or substantial portions of the Software.
|
|
33
|
+
|
|
34
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
35
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
36
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
37
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
38
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
39
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
40
|
+
SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cacography.sty and cacography.lua
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the cacography authors
|
|
4
|
+
|
|
5
|
+
This work may be distributed and/or modified under the conditions of the LaTeX Project
|
|
6
|
+
Public License, either version 1.3c of this license or (at your option) any later
|
|
7
|
+
version. The latest version of this license is in
|
|
8
|
+
|
|
9
|
+
https://www.latex-project.org/lppl.txt
|
|
10
|
+
|
|
11
|
+
and version 1.3c or later is part of all distributions of LaTeX version 2008 or later.
|
|
12
|
+
|
|
13
|
+
This work has the LPPL maintenance status `maintained'.
|
|
14
|
+
The Current Maintainer of this work is the cacography authors.
|
|
15
|
+
This work consists of the files cacography.sty and cacography.lua.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cacography_render
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cacography renderer: handwriting for pages laid out by LuaLaTeX."""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""cacography render <jobname>: write by hand every page LuaLaTeX recorded.
|
|
2
|
+
|
|
3
|
+
Reads <jobname>.cacography/, draws each page whose layout changed since it was last
|
|
4
|
+
drawn, and stamps it so the next LuaLaTeX run puts the drawing in the PDF.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from .aspects import Aspects, parse_options
|
|
20
|
+
from . import render3d, surfaces
|
|
21
|
+
from .camera import CAMERAS, photograph, studio
|
|
22
|
+
from .page import PageWriter
|
|
23
|
+
from .paper import PAPERS, make_sheet
|
|
24
|
+
from .pen import Pen, subseed
|
|
25
|
+
from .symbols import SymbolBank
|
|
26
|
+
|
|
27
|
+
def _models():
|
|
28
|
+
"""Where the handwriting models are: CACOGRAPHY_MODELS, else the source tree's models/
|
|
29
|
+
folder, else models/ in the cache folder (where the separate download unpacks to)."""
|
|
30
|
+
if os.environ.get("CACOGRAPHY_MODELS"):
|
|
31
|
+
return Path(os.environ["CACOGRAPHY_MODELS"])
|
|
32
|
+
here = Path(__file__).resolve().parents[2] / "models"
|
|
33
|
+
return here if (here / "hand").exists() else surfaces.cache_dir() / "models"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MODELS = _models()
|
|
37
|
+
RENDER_VERSION = "0.13.2" # bump when a change to the drawing should redraw existing pages
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_math_font():
|
|
41
|
+
try:
|
|
42
|
+
out = subprocess.run(["kpsewhich", "latinmodern-math.otf"], capture_output=True, text=True, timeout=60)
|
|
43
|
+
if out.stdout.strip():
|
|
44
|
+
return out.stdout.strip()
|
|
45
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
46
|
+
pass
|
|
47
|
+
sys.exit("cacography: cannot find latinmodern-math.otf (is a TeX distribution installed?)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main():
|
|
51
|
+
ap = argparse.ArgumentParser(prog="cacography render", description=__doc__)
|
|
52
|
+
ap.add_argument("jobname")
|
|
53
|
+
ap.add_argument("--dpi", type=int, default=200)
|
|
54
|
+
# The same seed gives the same page only on the CPU: GPU arithmetic differs slightly.
|
|
55
|
+
ap.add_argument("--device", default="cpu", help="cpu (reproducible) or cuda (faster, not reproducible)")
|
|
56
|
+
ap.add_argument("--models", type=Path, default=MODELS)
|
|
57
|
+
ap.add_argument("--all", action="store_true", help="redraw pages even when unchanged")
|
|
58
|
+
ap.add_argument("--flat", action="store_true", help="photograph with formulas, not by tracing light in 3D")
|
|
59
|
+
args = ap.parse_args()
|
|
60
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
folder = Path(f"{args.jobname}.cacography")
|
|
63
|
+
doc_path = folder / "document.json"
|
|
64
|
+
if not doc_path.exists():
|
|
65
|
+
sys.exit(f"cacography: {doc_path} not found. Run LuaLaTeX on {args.jobname}.tex first.")
|
|
66
|
+
doc = json.loads(doc_path.read_text(encoding="utf-8"))
|
|
67
|
+
if doc["seed"] == "random":
|
|
68
|
+
seed = secrets.randbelow(2**31)
|
|
69
|
+
print(f"seed=random: using seed {seed} (put seed={seed} in the options to get this page again)")
|
|
70
|
+
else:
|
|
71
|
+
seed = int(doc["seed"]) if doc["seed"].isdigit() else int.from_bytes(hashlib.sha256(doc["seed"].encode()).digest()[:4], "little")
|
|
72
|
+
sloppiness = float(doc.get("sloppiness", 0.5))
|
|
73
|
+
wear = float(doc.get("wear", 0.3))
|
|
74
|
+
paper, camera = doc["paper"], doc.get("camera", "phone")
|
|
75
|
+
if paper not in PAPERS:
|
|
76
|
+
sys.exit(f"cacography: unknown paper={paper} (choose from {', '.join(PAPERS)})")
|
|
77
|
+
if camera not in CAMERAS:
|
|
78
|
+
sys.exit(f"cacography: unknown camera={camera} (choose from {', '.join(CAMERAS)})")
|
|
79
|
+
|
|
80
|
+
t0 = time.time()
|
|
81
|
+
if not (args.models / "hand").exists():
|
|
82
|
+
sys.exit(f"cacography: no handwriting models in {args.models}. They are a separate download (non-commercial "
|
|
83
|
+
"licence); unpack them there, or point CACOGRAPHY_MODELS at them.")
|
|
84
|
+
pen = Pen(args.models, seed, sloppiness, folder / "words.npz", device=args.device)
|
|
85
|
+
print(f"writer: blend of {pen.writers} (seed {seed}), device {args.device}")
|
|
86
|
+
bank = SymbolBank(args.models / "symbols", seed, pen.slant) if (args.models / "symbols" / "bank.npz").exists() else None
|
|
87
|
+
writer = PageWriter(pen, doc, args.dpi, find_math_font(), bank)
|
|
88
|
+
# One room for the whole document: the same desk, lamp and phone on every page.
|
|
89
|
+
try:
|
|
90
|
+
aspects = Aspects(doc, seed)
|
|
91
|
+
background = aspects.value("background")
|
|
92
|
+
if not surfaces.valid(background):
|
|
93
|
+
raise ValueError(f"background={background} (choose from {', '.join(surfaces.names())})")
|
|
94
|
+
if background.startswith("file:"):
|
|
95
|
+
# Your own photo of a surface, found from the document's folder.
|
|
96
|
+
path = Path(background[5:]).expanduser()
|
|
97
|
+
path = path if path.is_absolute() else (folder.parent / path)
|
|
98
|
+
if not path.is_file():
|
|
99
|
+
raise ValueError(f"background={background}: no such file {path}")
|
|
100
|
+
aspects.values["background"] = f"file:{path.resolve()}|{path.stat().st_size}|{path.stat().st_mtime_ns}"
|
|
101
|
+
except ValueError as e:
|
|
102
|
+
sys.exit(f"cacography: unknown {e}")
|
|
103
|
+
room = studio(aspects)
|
|
104
|
+
traced = camera == "phone" and not args.flat and render3d.available()
|
|
105
|
+
if camera == "phone" and not args.flat and not traced:
|
|
106
|
+
print("note: photographing with formulas; tracing light in 3D needs the mitsuba package and an NVIDIA GPU")
|
|
107
|
+
settings = f"{pen.tag}|{paper}|{doc['ink']}|{camera}|{wear}|{args.dpi}|{aspects.values}|{aspects.rerolls}|{traced}|{RENDER_VERSION}"
|
|
108
|
+
if writer.mess != 1.0:
|
|
109
|
+
# Sloppiness away from 0.5 now loosens the layout too: those pages draw again.
|
|
110
|
+
settings += f"|mess{writer.mess:.3f}"
|
|
111
|
+
drawn = 0
|
|
112
|
+
for n in range(1, int(doc["pages"]) + 1):
|
|
113
|
+
body = (folder / f"page-{n}.json").read_bytes()
|
|
114
|
+
# LuaLaTeX swaps a drawing in only when its stamp matches the page's layout.
|
|
115
|
+
stamp = hashlib.md5(body).hexdigest()
|
|
116
|
+
made_with = hashlib.md5(f"{stamp}|{settings}".encode()).hexdigest()
|
|
117
|
+
record = folder / f"page-{n}.drawn"
|
|
118
|
+
scene = json.loads(body)
|
|
119
|
+
if args.all or not record.exists() or record.read_text().strip() != made_with:
|
|
120
|
+
# A page's own settings (\cacographypage) on top of the document's.
|
|
121
|
+
options = parse_options(scene.get("options", ""))
|
|
122
|
+
try:
|
|
123
|
+
page_aspects = aspects.for_page(options)
|
|
124
|
+
page_wear = float(options.get("wear", wear))
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
sys.exit(f"cacography: page {n}: {e}")
|
|
127
|
+
ink, rules_from = writer.render(scene)
|
|
128
|
+
sheet = make_sheet(paper, scene["width"], scene["height"], args.dpi, rules_from,
|
|
129
|
+
writer.spacing * writer.px, page_wear, page_aspects, n)
|
|
130
|
+
sheet.erasures, sheet.rubbed = writer.erasures, writer.rubbed
|
|
131
|
+
# One pen for the whole document: the same ink on every page.
|
|
132
|
+
sheet.lay_ink(ink, doc["ink"], np.random.default_rng(subseed(seed, "ink")))
|
|
133
|
+
if traced:
|
|
134
|
+
photo, quality = render3d.photograph(sheet, room, page_aspects, n)
|
|
135
|
+
else:
|
|
136
|
+
photo, quality = photograph(sheet, room, camera, page_aspects, n)
|
|
137
|
+
photo.save(folder / f"page-{n}.jpg", quality=quality, subsampling=0 if camera != "phone" else 2)
|
|
138
|
+
(folder / f"page-{n}.stamp").write_text(stamp)
|
|
139
|
+
record.write_text(made_with)
|
|
140
|
+
drawn += 1
|
|
141
|
+
else:
|
|
142
|
+
# Still count this page's words, so later pages keep their seeds.
|
|
143
|
+
writer.skip(scene)
|
|
144
|
+
pen.save()
|
|
145
|
+
s = pen.stats
|
|
146
|
+
print(f"drew {drawn} of {doc['pages']} pages in {time.time() - t0:.0f}s: {s['words']} words, {s['cached']} from cache,"
|
|
147
|
+
f" {s['tries']} attempts, {s['failed']} never passed the checks")
|
|
148
|
+
if writer.missing:
|
|
149
|
+
print("not handwritten yet (drawn from the font):", " ".join(f"{c}x{n}" for c, n in sorted(writer.missing.items())))
|
|
150
|
+
if not os.environ.get("CACOGRAPHY_BUILDING"):
|
|
151
|
+
print(f"now run LuaLaTeX on {args.jobname}.tex again")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""What can be set about the scene, one aspect at a time, and the randomness behind each.
|
|
2
|
+
|
|
3
|
+
Every aspect draws its random numbers from its own seed, made from the document seed, the
|
|
4
|
+
aspect's name, the page (for aspects that differ from page to page) and how often the
|
|
5
|
+
user rerolled it. So changing or rerolling one aspect leaves all the others as they were:
|
|
6
|
+
a new camera angle keeps the same folds, the same desk and the same handwriting.
|
|
7
|
+
|
|
8
|
+
In LaTeX: \\usepackage[angle=steep, folds=half, reroll={light, stains=2}]{cacography}, and
|
|
9
|
+
for one page, \\cacographypage{crumple=0.8, stains=coffee}.
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .pen import subseed
|
|
16
|
+
|
|
17
|
+
# name: (choices, or None for any value checked elsewhere; differs page to page?)
|
|
18
|
+
ASPECTS = {
|
|
19
|
+
"background": (None, False), # checked against surfaces.valid
|
|
20
|
+
"light": (("random", "lamp", "overhead", "window", "soft", "raking", "flash", "dim"), False),
|
|
21
|
+
"angle": (("random", "flat", "slight", "steep"), True), # or a tilt in degrees
|
|
22
|
+
"framing": (("random", "tight", "loose", "cropped"), True), # how much desk round the sheet, or part cut off
|
|
23
|
+
"shadow": (("random", "none", "phone"), True),
|
|
24
|
+
"crumple": (("random", "none"), True), # or how crumpled, 0 to 1
|
|
25
|
+
"folds": (("random", "none", "half", "thirds", "quarters"), True),
|
|
26
|
+
"creases": (("random", "none"), True), # or a number of creases
|
|
27
|
+
"scores": (("random", "none"), True), # or a number of pressed lines
|
|
28
|
+
"edges": (("random", "clean", "worn", "torn", "spiral"), True),
|
|
29
|
+
"stains": (("random", "none", "coffee", "water", "smudge"), True), # or several: coffee+smudge
|
|
30
|
+
"grooves": (("random", "none"), True), # or how hard the pen pressed in, 0 to 1
|
|
31
|
+
"backside": (("random", "none", "writing"), True), # writing on the back, showing through
|
|
32
|
+
"curl": (("random", "none"), True), # or how much the edges roll up, 0 to 1
|
|
33
|
+
"age": (("random", "none"), True), # or how old the sheet is, 0 to 1: yellowed, spotted, ink faded
|
|
34
|
+
"props": (("random", "none", "tape", "clip", "binder", "staple", "pen", "sheet"), True), # or several: tape+pen
|
|
35
|
+
"paper": ((), True), # the sheet's own texture, curl and grime: reroll only
|
|
36
|
+
"camera": ((), False), # the phone's character, and its noise on each page: reroll only
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
NUMBERS = ("angle", "creases", "crumple", "scores", "grooves", "curl", "age")
|
|
41
|
+
# What one page may set for itself with \cacographypage: how that sheet looks and is shot.
|
|
42
|
+
PER_PAGE = ("angle", "framing", "shadow", "crumple", "folds", "creases", "scores", "edges", "stains", "grooves", "backside", "curl", "age", "props")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _number(text):
|
|
46
|
+
return re.fullmatch(r"\s*[-+]?(\d+(\.\d*)?|\.\d+)\s*", text) is not None # 1, 0.8, .8, 1.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _valid(name, value, choices):
|
|
50
|
+
if name in ("stains", "props"):
|
|
51
|
+
parts = [p for p in re.split(r"[,+\s]+", value) if p]
|
|
52
|
+
return bool(parts) and all(p in choices for p in parts) and (len(parts) == 1 or not {"random", "none"} & set(parts))
|
|
53
|
+
return value in choices or (name in NUMBERS and _number(value))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_options(text):
|
|
57
|
+
"""'crumple=0.8, stains={coffee, water}' as a dict, keeping braced commas together."""
|
|
58
|
+
out, depth, key, buf = {}, 0, None, ""
|
|
59
|
+
for ch in str(text) + ",":
|
|
60
|
+
if ch == "{":
|
|
61
|
+
depth += 1
|
|
62
|
+
elif ch == "}":
|
|
63
|
+
depth -= 1
|
|
64
|
+
elif ch == "=" and depth == 0 and key is None:
|
|
65
|
+
key, buf = buf.strip(), ""
|
|
66
|
+
elif ch == "," and depth == 0:
|
|
67
|
+
if key is not None:
|
|
68
|
+
out[key] = buf.strip()
|
|
69
|
+
elif buf.strip():
|
|
70
|
+
out[buf.strip()] = ""
|
|
71
|
+
key, buf = None, ""
|
|
72
|
+
else:
|
|
73
|
+
buf += ch
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Aspects:
|
|
78
|
+
def __init__(self, doc, seed):
|
|
79
|
+
self.seed = seed
|
|
80
|
+
self.values = {}
|
|
81
|
+
for name, (choices, _) in ASPECTS.items():
|
|
82
|
+
if choices == ():
|
|
83
|
+
continue
|
|
84
|
+
value = str(doc.get(name, "random")).strip() or "random"
|
|
85
|
+
if choices and not _valid(name, value, choices):
|
|
86
|
+
raise ValueError(f"{name}={value} (choose from {', '.join(choices)}" + (", or a number)" if name in NUMBERS else ")"))
|
|
87
|
+
self.values[name] = value
|
|
88
|
+
self.rerolls = {}
|
|
89
|
+
for part in str(doc.get("reroll", "")).split(","):
|
|
90
|
+
if not part.strip():
|
|
91
|
+
continue
|
|
92
|
+
name, _, times = part.partition("=")
|
|
93
|
+
name = name.strip()
|
|
94
|
+
if name not in ASPECTS:
|
|
95
|
+
raise ValueError(f"reroll={name}: no such aspect (choose from {', '.join(ASPECTS)})")
|
|
96
|
+
self.rerolls[name] = int(times) if times.strip() else 1
|
|
97
|
+
|
|
98
|
+
def for_page(self, options):
|
|
99
|
+
"""These aspects with one page's own settings (from \\cacographypage) on top.
|
|
100
|
+
|
|
101
|
+
A page may set or reroll only what belongs to its own sheet and shot (PER_PAGE);
|
|
102
|
+
the room, desk and light stay the document's.
|
|
103
|
+
"""
|
|
104
|
+
unknown = set(options) - set(PER_PAGE) - {"reroll", "wear"}
|
|
105
|
+
if unknown:
|
|
106
|
+
raise ValueError(f"{', '.join(sorted(unknown))} on one page (a page may set wear, {', '.join(PER_PAGE)}, or reroll them)")
|
|
107
|
+
options = {k: v for k, v in options.items() if k != "wear"}
|
|
108
|
+
if not options:
|
|
109
|
+
return self
|
|
110
|
+
page = Aspects({**self.values, **{k: v for k, v in options.items() if k != "reroll"}}, self.seed)
|
|
111
|
+
page.rerolls = dict(self.rerolls)
|
|
112
|
+
for name, times in parse_options(options.get("reroll", "")).items():
|
|
113
|
+
if name not in PER_PAGE:
|
|
114
|
+
raise ValueError(f"reroll={name} on one page (a page may reroll {', '.join(PER_PAGE)})")
|
|
115
|
+
page.rerolls[name] = page.rerolls.get(name, 0) + (int(times) if times else 1)
|
|
116
|
+
return page
|
|
117
|
+
|
|
118
|
+
def value(self, name):
|
|
119
|
+
return self.values.get(name, "random")
|
|
120
|
+
|
|
121
|
+
def rng(self, name, page=None, part=None):
|
|
122
|
+
"""The random numbers for one aspect (on one page, if it differs between pages).
|
|
123
|
+
|
|
124
|
+
`part` gives a separate stream within the aspect, for a part of it drawn elsewhere.
|
|
125
|
+
"""
|
|
126
|
+
per_page = ASPECTS[name][1]
|
|
127
|
+
keys = (self.seed, "aspect", name, page if per_page else None, self.rerolls.get(name, 0)) + ((part,) if part else ())
|
|
128
|
+
return np.random.default_rng(subseed(*keys))
|