tinyleaf 0.1.0__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.
tinyleaf/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tinyleaf/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ """CLI entry point for tinyleaf."""
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import webbrowser
8
+
9
+ from tinyleaf import registry
10
+ from tinyleaf.server import run_server
11
+
12
+ DEFAULT_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "tinyleaf")
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser(
17
+ prog="tinyleaf",
18
+ description="Lightweight web-based LaTeX editor",
19
+ )
20
+ parser.add_argument(
21
+ "project_path",
22
+ nargs="?",
23
+ help="Single project directory to open",
24
+ )
25
+ parser.add_argument(
26
+ "--projects-dir",
27
+ metavar="DIR",
28
+ help="Legacy: migrate subdirs into registry, then use registry mode",
29
+ )
30
+ parser.add_argument(
31
+ "--config-dir",
32
+ metavar="DIR",
33
+ default=DEFAULT_CONFIG_DIR,
34
+ help=f"Config directory for project registry (default: {DEFAULT_CONFIG_DIR})",
35
+ )
36
+ parser.add_argument(
37
+ "--docker",
38
+ action=argparse.BooleanOptionalAction,
39
+ default=True,
40
+ help="Use Docker for compilation (default: enabled)",
41
+ )
42
+ parser.add_argument(
43
+ "--image",
44
+ default="oaklight/texlive:alpine-science-cn",
45
+ help="Docker image to use (default: oaklight/texlive:alpine-science-cn)",
46
+ )
47
+ parser.add_argument(
48
+ "--port",
49
+ type=int,
50
+ default=8080,
51
+ help="Server port (default: 8080)",
52
+ )
53
+ parser.add_argument(
54
+ "--host",
55
+ default="127.0.0.1",
56
+ help="Server host (default: 127.0.0.1)",
57
+ )
58
+ parser.add_argument(
59
+ "--no-browser",
60
+ action="store_true",
61
+ help="Don't auto-open browser on start",
62
+ )
63
+
64
+ args = parser.parse_args()
65
+
66
+ # Validate
67
+ if args.project_path and args.projects_dir:
68
+ parser.error("Cannot specify both project_path and --projects-dir")
69
+
70
+ # Determine mode
71
+ if args.project_path:
72
+ mode = "single"
73
+ project_path = os.path.abspath(args.project_path)
74
+ if not os.path.isdir(project_path):
75
+ print(f"Error: '{project_path}' is not a directory", file=sys.stderr)
76
+ sys.exit(1)
77
+ config_dir = None
78
+ else:
79
+ mode = "multi"
80
+ config_dir = os.path.abspath(args.config_dir)
81
+ registry.ensure_config_dir(config_dir)
82
+ project_path = None
83
+
84
+ # Backward compat: migrate --projects-dir subdirs into registry
85
+ if args.projects_dir:
86
+ projects_dir = os.path.abspath(args.projects_dir)
87
+ if os.path.isdir(projects_dir):
88
+ count = _migrate_projects_dir(config_dir, projects_dir)
89
+ if count:
90
+ print(f" Migrated {count} project(s) from {projects_dir}")
91
+
92
+ # Auto-download vendor JS modules on first start
93
+ from tinyleaf import vendor
94
+
95
+ vendor_dir = os.path.join(config_dir, "vendor")
96
+ if not vendor.is_vendor_ready(vendor_dir):
97
+ proxy = vendor.load_proxy(config_dir)
98
+ print(" Downloading JS modules...")
99
+ if proxy:
100
+ print(f" Using proxy: {proxy}")
101
+ try:
102
+ vendor.download_vendor(vendor_dir, proxy=proxy)
103
+ print(" JS modules ready")
104
+ except Exception as e:
105
+ print(f" Warning: failed to download JS modules: {e}", file=sys.stderr)
106
+ print(" Editor will try CDN as fallback", file=sys.stderr)
107
+
108
+ # Check compilation backend
109
+ use_docker = args.docker
110
+ if not use_docker and not shutil.which("latexmk"):
111
+ print(
112
+ "Warning: 'latexmk' not found in PATH. Add --docker to use Docker for compilation.",
113
+ file=sys.stderr,
114
+ )
115
+ print("Continuing anyway — compilation will fail without latexmk.", file=sys.stderr)
116
+
117
+ if use_docker and not shutil.which("docker"):
118
+ print("Error: --docker specified but 'docker' not found in PATH", file=sys.stderr)
119
+ sys.exit(1)
120
+
121
+ config = {
122
+ "mode": mode,
123
+ "project_path": project_path,
124
+ "config_dir": config_dir,
125
+ "use_docker": use_docker,
126
+ "docker_image": args.image,
127
+ "host": args.host,
128
+ "port": args.port,
129
+ }
130
+
131
+ url = f"http://{args.host}:{args.port}"
132
+ print(f"Starting tinyleaf ({mode} mode)")
133
+ if use_docker:
134
+ print(f" Compiler: Docker ({args.image})")
135
+ else:
136
+ print(" Compiler: local latexmk")
137
+ if mode == "single":
138
+ print(f" Project: {project_path}")
139
+ else:
140
+ print(f" Config: {config_dir}")
141
+ print(f" URL: {url}")
142
+
143
+ if not args.no_browser:
144
+ webbrowser.open(url)
145
+
146
+ run_server(config)
147
+
148
+
149
+ def _migrate_projects_dir(config_dir, projects_dir):
150
+ """Migrate subdirectories of a projects dir into the registry."""
151
+ count = 0
152
+ for entry in sorted(os.listdir(projects_dir)):
153
+ full = os.path.join(projects_dir, entry)
154
+ if not os.path.isdir(full) or entry.startswith("."):
155
+ continue
156
+ try:
157
+ registry.register_project(config_dir, entry, full)
158
+ count += 1
159
+ except ValueError:
160
+ pass # name collision, skip
161
+ return count
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
tinyleaf/compiler.py ADDED
@@ -0,0 +1,451 @@
1
+ """Compilation backend for tinyleaf.
2
+
3
+ Supports local latexmk and Docker-based compilation.
4
+ """
5
+
6
+ import os
7
+ import subprocess
8
+ import threading
9
+ import uuid
10
+
11
+
12
+ class CompileJob:
13
+ """Tracks a single compilation run."""
14
+
15
+ def __init__(
16
+ self,
17
+ compile_id,
18
+ project_dir,
19
+ main_file,
20
+ engine,
21
+ use_docker,
22
+ docker_image,
23
+ registry_mirror=None,
24
+ ):
25
+ self.compile_id = compile_id
26
+ self.project_dir = project_dir
27
+ self.main_file = main_file
28
+ self.engine = engine
29
+ self.use_docker = use_docker
30
+ self.docker_image = docker_image
31
+ self.registry_mirror = registry_mirror
32
+ self.log_lines = []
33
+ self.status = "running" # running | success | error | cancelled
34
+ self.pdf_path = None
35
+ self.proc = None # subprocess.Popen reference for cancellation
36
+ self._cancelled = False
37
+ self._lock = threading.Lock()
38
+ self._done_event = threading.Event()
39
+
40
+ def append_log(self, line, level="info"):
41
+ with self._lock:
42
+ self.log_lines.append({"line": line, "level": level})
43
+
44
+ def get_logs_from(self, index):
45
+ with self._lock:
46
+ return list(self.log_lines[index:])
47
+
48
+ def finish(self, status, pdf_path=None):
49
+ self.status = status
50
+ self.pdf_path = pdf_path
51
+ self._done_event.set()
52
+
53
+ def wait(self, timeout=None):
54
+ self._done_event.wait(timeout)
55
+
56
+ def cancel(self):
57
+ """Cancel this compilation by killing the subprocess."""
58
+ self._cancelled = True
59
+ if self.proc and self.proc.poll() is None:
60
+ self.proc.kill()
61
+ self.append_log("Cancelled by user.", level="warning")
62
+ self.finish("cancelled")
63
+
64
+ @property
65
+ def is_cancelled(self):
66
+ return self._cancelled
67
+
68
+ @property
69
+ def is_done(self):
70
+ return self._done_event.is_set()
71
+
72
+
73
+ # Global compile job registry
74
+ _jobs: dict[str, CompileJob] = {}
75
+ _jobs_lock = threading.Lock()
76
+
77
+
78
+ def get_job(compile_id):
79
+ with _jobs_lock:
80
+ return _jobs.get(compile_id)
81
+
82
+
83
+ def cancel_compile(compile_id):
84
+ """Cancel a running compilation job.
85
+
86
+ Returns:
87
+ True if the job was found and cancelled, False otherwise.
88
+ """
89
+ job = get_job(compile_id)
90
+ if not job or job.is_done:
91
+ return False
92
+ job.cancel()
93
+ return True
94
+
95
+
96
+ def start_compile(
97
+ project_dir,
98
+ main_file="main.tex",
99
+ engine="pdflatex",
100
+ use_docker=False,
101
+ docker_image="oaklight/texlive:alpine-science-cn",
102
+ registry_mirror=None,
103
+ ):
104
+ """Start a compilation and return the compile_id.
105
+
106
+ Args:
107
+ project_dir: Absolute path to the project directory.
108
+ main_file: Main .tex file relative to project_dir.
109
+ engine: Compilation engine (pdflatex, lualatex, xelatex).
110
+ use_docker: Whether to use Docker for compilation.
111
+ docker_image: Docker image to use.
112
+ registry_mirror: Optional registry mirror (e.g. "docker.1ms.run").
113
+
114
+ Returns:
115
+ compile_id string.
116
+ """
117
+ compile_id = uuid.uuid4().hex[:12]
118
+ job = CompileJob(
119
+ compile_id,
120
+ project_dir,
121
+ main_file,
122
+ engine,
123
+ use_docker,
124
+ docker_image,
125
+ registry_mirror=registry_mirror,
126
+ )
127
+
128
+ with _jobs_lock:
129
+ _jobs[compile_id] = job
130
+
131
+ thread = threading.Thread(target=_run_compile, args=(job,), daemon=True)
132
+ thread.start()
133
+ return compile_id
134
+
135
+
136
+ def _build_latexmk_args(engine, main_file):
137
+ """Build latexmk command arguments."""
138
+ engine_flag = {
139
+ "pdflatex": "-pdf",
140
+ "lualatex": "-lualatex",
141
+ "xelatex": "-xelatex",
142
+ }.get(engine, "-pdf")
143
+
144
+ return [
145
+ "latexmk",
146
+ engine_flag,
147
+ "-cd",
148
+ "-synctex=1",
149
+ "-interaction=nonstopmode",
150
+ "-file-line-error",
151
+ main_file,
152
+ ]
153
+
154
+
155
+ def _build_multipass_cmds(engine, main_file):
156
+ """Build multi-pass compile commands for direct engine use.
157
+
158
+ Flow: engine → bibtex → engine → engine (3 passes with bibliography).
159
+ """
160
+ base = os.path.splitext(main_file)[0]
161
+ common_flags = ["-synctex=1", "-interaction=nonstopmode", "-file-line-error"]
162
+ engine_cmd = [engine] + common_flags + [main_file]
163
+ bibtex_cmd = ["bibtex", base]
164
+ return [engine_cmd, bibtex_cmd, engine_cmd, engine_cmd]
165
+
166
+
167
+ def _run_compile(job: CompileJob):
168
+ """Run the compilation in a background thread."""
169
+ try:
170
+ if job.engine == "latexmk":
171
+ # latexmk auto-detects engine from .latexmkrc or defaults to pdflatex
172
+ cmds = [_build_latexmk_args("pdflatex", job.main_file)]
173
+ elif job.engine in ("pdflatex", "lualatex", "xelatex"):
174
+ if _has_latexmk(job):
175
+ cmds = [_build_latexmk_args(job.engine, job.main_file)]
176
+ else:
177
+ cmds = _build_multipass_cmds(job.engine, job.main_file)
178
+ else:
179
+ cmds = [_build_latexmk_args("pdflatex", job.main_file)]
180
+
181
+ if job.use_docker:
182
+ # Auto-pull image if not available locally
183
+ if not _docker_image_exists(job.docker_image):
184
+ if job.is_cancelled:
185
+ return
186
+ if not _docker_pull(job, job.docker_image, job.registry_mirror):
187
+ return
188
+
189
+ if job.is_cancelled:
190
+ return
191
+
192
+ docker_prefix = [
193
+ "docker",
194
+ "run",
195
+ "--rm",
196
+ "-v",
197
+ f"{job.project_dir}:/workspace",
198
+ "-w",
199
+ "/workspace",
200
+ job.docker_image,
201
+ ]
202
+ cmds = [docker_prefix + c for c in cmds]
203
+
204
+ for cmd_idx, cmd in enumerate(cmds):
205
+ if job.is_cancelled:
206
+ return
207
+
208
+ job.append_log(f"$ {' '.join(cmd)}", level="info")
209
+
210
+ proc = subprocess.Popen(
211
+ cmd,
212
+ stdout=subprocess.PIPE,
213
+ stderr=subprocess.STDOUT,
214
+ cwd=job.project_dir if not job.use_docker else None,
215
+ text=True,
216
+ bufsize=1,
217
+ )
218
+ job.proc = proc
219
+
220
+ for line in proc.stdout:
221
+ if job.is_cancelled:
222
+ break
223
+ line = line.rstrip("\n")
224
+ level = _classify_log_line(line)
225
+ job.append_log(line, level=level)
226
+
227
+ proc.wait()
228
+
229
+ if job.is_cancelled:
230
+ return
231
+
232
+ # bibtex may fail if no .bib — that's okay, continue
233
+ is_bibtex = cmd[-1].endswith((".aux",)) or "bibtex" in cmd
234
+ if proc.returncode != 0 and not is_bibtex:
235
+ break
236
+
237
+ # Find output PDF
238
+ base = os.path.splitext(job.main_file)[0]
239
+ pdf_name = base + ".pdf"
240
+ pdf_full = os.path.join(job.project_dir, pdf_name)
241
+
242
+ last_rc = proc.returncode
243
+ if last_rc == 0 and os.path.exists(pdf_full):
244
+ job.finish("success", pdf_path=pdf_name)
245
+ else:
246
+ job.append_log(
247
+ f"Compilation failed (exit code {last_rc})",
248
+ level="error",
249
+ )
250
+ if os.path.exists(pdf_full):
251
+ job.finish("error", pdf_path=pdf_name)
252
+ else:
253
+ job.finish("error")
254
+
255
+ except Exception as e:
256
+ if not job.is_cancelled:
257
+ job.append_log(f"Internal error: {e}", level="error")
258
+ job.finish("error")
259
+
260
+
261
+ def _classify_log_line(line):
262
+ """Classify a log line as info/warning/error."""
263
+ lower = line.lower()
264
+ if "error" in lower or "!" in line[:5]:
265
+ return "error"
266
+ if "warning" in lower or "overfull" in lower or "underfull" in lower:
267
+ return "warning"
268
+ return "info"
269
+
270
+
271
+ def _has_latexmk(job):
272
+ """Check if latexmk is available (in Docker or locally)."""
273
+ try:
274
+ if job.use_docker:
275
+ r = subprocess.run(
276
+ ["docker", "run", "--rm", job.docker_image, "which", "latexmk"],
277
+ capture_output=True,
278
+ timeout=15,
279
+ )
280
+ else:
281
+ r = subprocess.run(["which", "latexmk"], capture_output=True, timeout=5)
282
+ return r.returncode == 0
283
+ except Exception:
284
+ return False
285
+
286
+
287
+ def _docker_image_exists(image):
288
+ """Check if a Docker image exists locally."""
289
+ try:
290
+ result = subprocess.run(
291
+ ["docker", "image", "inspect", image],
292
+ capture_output=True,
293
+ timeout=10,
294
+ )
295
+ return result.returncode == 0
296
+ except Exception:
297
+ return False
298
+
299
+
300
+ def _docker_pull(job, image, registry_mirror=None):
301
+ """Pull a Docker image, streaming output to the compile job log.
302
+
303
+ If registry_mirror is set, pulls from the mirror and retags to the
304
+ original name.
305
+
306
+ Returns:
307
+ True if pull succeeded, False otherwise.
308
+ """
309
+ if registry_mirror:
310
+ # e.g. "oaklight/texlive:tag" -> "docker.1ms.run/oaklight/texlive:tag"
311
+ pull_image = f"{registry_mirror}/{image}"
312
+ job.append_log(
313
+ f"Image '{image}' not found locally, pulling from mirror {registry_mirror}..."
314
+ )
315
+ else:
316
+ pull_image = image
317
+ job.append_log(f"Image '{image}' not found locally, pulling...")
318
+
319
+ try:
320
+ proc = subprocess.Popen(
321
+ ["docker", "pull", pull_image],
322
+ stdout=subprocess.PIPE,
323
+ stderr=subprocess.STDOUT,
324
+ text=True,
325
+ bufsize=1,
326
+ )
327
+ job.proc = proc
328
+
329
+ for line in proc.stdout:
330
+ if job.is_cancelled:
331
+ proc.kill()
332
+ proc.wait()
333
+ return False
334
+ job.append_log(line.rstrip("\n"))
335
+ proc.wait()
336
+
337
+ if job.is_cancelled:
338
+ return False
339
+
340
+ if proc.returncode != 0:
341
+ job.append_log(f"Failed to pull image '{pull_image}'", level="error")
342
+ job.finish("error")
343
+ return False
344
+
345
+ # Retag if pulled from mirror
346
+ if registry_mirror and pull_image != image:
347
+ subprocess.run(["docker", "tag", pull_image, image], timeout=10)
348
+ subprocess.run(["docker", "rmi", pull_image], capture_output=True, timeout=10)
349
+ job.append_log(f"Retagged '{pull_image}' -> '{image}'")
350
+
351
+ job.append_log("Image ready.")
352
+ return True
353
+
354
+ except Exception as e:
355
+ job.append_log(f"Pull error: {e}", level="error")
356
+ job.finish("error")
357
+ return False
358
+
359
+
360
+ def docker_pull_image(image, registry_mirror=None):
361
+ """Pull a Docker image (standalone, not tied to a compile job).
362
+
363
+ Args:
364
+ image: Full image name (e.g. "oaklight/texlive:alpine-science-cn").
365
+ registry_mirror: Optional registry mirror host.
366
+
367
+ Returns:
368
+ Tuple of (success: bool, message: str).
369
+ """
370
+ if registry_mirror:
371
+ pull_image = f"{registry_mirror}/{image}"
372
+ else:
373
+ pull_image = image
374
+
375
+ try:
376
+ proc = subprocess.Popen(
377
+ ["docker", "pull", pull_image],
378
+ stdout=subprocess.PIPE,
379
+ stderr=subprocess.STDOUT,
380
+ text=True,
381
+ )
382
+ with _pull_procs_lock:
383
+ _pull_procs[image] = proc
384
+
385
+ output_lines = []
386
+ for line in proc.stdout:
387
+ output_lines.append(line)
388
+ proc.wait()
389
+
390
+ with _pull_procs_lock:
391
+ _pull_procs.pop(image, None)
392
+
393
+ if proc.returncode != 0:
394
+ stderr = "".join(output_lines).strip()
395
+ if proc.returncode == -9 or proc.returncode == -15:
396
+ return False, "Cancelled"
397
+ return False, stderr or "Pull failed"
398
+
399
+ if registry_mirror and pull_image != image:
400
+ subprocess.run(["docker", "tag", pull_image, image], timeout=10)
401
+ subprocess.run(["docker", "rmi", pull_image], capture_output=True, timeout=10)
402
+
403
+ return True, "OK"
404
+ except subprocess.TimeoutExpired:
405
+ return False, "Pull timed out"
406
+ except Exception as e:
407
+ with _pull_procs_lock:
408
+ _pull_procs.pop(image, None)
409
+ return False, str(e)
410
+
411
+
412
+ # Track standalone pull processes for cancellation
413
+ _pull_procs: dict[str, subprocess.Popen] = {}
414
+ _pull_procs_lock = threading.Lock()
415
+
416
+
417
+ def cancel_docker_pull(image):
418
+ """Cancel a running standalone docker pull.
419
+
420
+ Returns:
421
+ True if a pull was found and killed, False otherwise.
422
+ """
423
+ with _pull_procs_lock:
424
+ proc = _pull_procs.pop(image, None)
425
+ if proc and proc.poll() is None:
426
+ proc.kill()
427
+ return True
428
+ return False
429
+
430
+
431
+ def docker_remove_image(image):
432
+ """Remove a local Docker image.
433
+
434
+ Args:
435
+ image: Full image name to remove.
436
+
437
+ Returns:
438
+ Tuple of (success: bool, message: str).
439
+ """
440
+ try:
441
+ result = subprocess.run(
442
+ ["docker", "rmi", image],
443
+ capture_output=True,
444
+ text=True,
445
+ timeout=30,
446
+ )
447
+ if result.returncode == 0:
448
+ return True, "OK"
449
+ return False, result.stderr.strip() or "Remove failed"
450
+ except Exception as e:
451
+ return False, str(e)