text-compositor 0.3.0__tar.gz → 0.3.6__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.
Files changed (66) hide show
  1. {text_compositor-0.3.0 → text_compositor-0.3.6}/PKG-INFO +23 -5
  2. text_compositor-0.3.0/text_compositor.egg-info/PKG-INFO → text_compositor-0.3.6/README.md +18 -18
  3. {text_compositor-0.3.0 → text_compositor-0.3.6}/pyproject.toml +5 -5
  4. text_compositor-0.3.6/tests/test_api.py +330 -0
  5. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_asset_resolution.py +8 -7
  6. text_compositor-0.3.6/tests/test_check_pins.py +70 -0
  7. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_common_template.py +9 -7
  8. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_config.py +16 -15
  9. text_compositor-0.3.6/tests/test_csv_table.py +149 -0
  10. text_compositor-0.3.6/tests/test_diagnostics.py +134 -0
  11. text_compositor-0.3.6/tests/test_diagram_cache_key.py +45 -0
  12. text_compositor-0.3.6/tests/test_distribution.py +68 -0
  13. text_compositor-0.3.6/tests/test_doc_badges.py +38 -0
  14. text_compositor-0.3.6/tests/test_download.py +231 -0
  15. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_front_matter.py +9 -8
  16. text_compositor-0.3.6/tests/test_html_output.py +689 -0
  17. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_incremental.py +19 -14
  18. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_layout_blocks.py +3 -3
  19. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_line_mapping.py +23 -21
  20. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_markdown_basics.py +9 -9
  21. text_compositor-0.3.6/tests/test_mermaid_resident.py +151 -0
  22. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_paper_template.py +12 -10
  23. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_revision_history.py +9 -9
  24. text_compositor-0.3.6/tests/test_script_references.py +37 -0
  25. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_sections.py +15 -11
  26. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_table_cell_style.py +2 -2
  27. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_variables.py +11 -10
  28. text_compositor-0.3.6/tests/test_viewer_version.py +21 -0
  29. {text_compositor-0.3.0 → text_compositor-0.3.6}/tests/test_watch.py +12 -10
  30. text_compositor-0.3.6/tests/test_worker.py +491 -0
  31. text_compositor-0.3.6/text_compositor/__init__.py +28 -0
  32. text_compositor-0.3.6/text_compositor/api.py +349 -0
  33. text_compositor-0.3.6/text_compositor/build.py +215 -0
  34. text_compositor-0.3.6/text_compositor/changes.py +93 -0
  35. text_compositor-0.3.6/text_compositor/chapters.py +270 -0
  36. text_compositor-0.3.6/text_compositor/compiler.py +187 -0
  37. text_compositor-0.3.6/text_compositor/config.py +205 -0
  38. text_compositor-0.3.6/text_compositor/deps.py +459 -0
  39. text_compositor-0.3.6/text_compositor/diagnostics.py +105 -0
  40. text_compositor-0.3.6/text_compositor/document.py +229 -0
  41. text_compositor-0.3.6/text_compositor/env_check.py +204 -0
  42. text_compositor-0.3.6/text_compositor/host_renderers.py +26 -0
  43. text_compositor-0.3.6/text_compositor/html_output.py +690 -0
  44. text_compositor-0.3.6/text_compositor/log.py +37 -0
  45. text_compositor-0.3.6/text_compositor/mermaid.py +150 -0
  46. text_compositor-0.3.6/text_compositor/project.py +155 -0
  47. text_compositor-0.3.6/text_compositor/renderer.py +1515 -0
  48. text_compositor-0.3.6/text_compositor/typst_literal.py +15 -0
  49. text_compositor-0.3.6/text_compositor/worker.py +197 -0
  50. text_compositor-0.3.0/README.md → text_compositor-0.3.6/text_compositor.egg-info/PKG-INFO +36 -0
  51. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor.egg-info/SOURCES.txt +28 -0
  52. text_compositor-0.3.6/text_compositor.egg-info/requires.txt +8 -0
  53. text_compositor-0.3.0/tests/test_csv_table.py +0 -78
  54. text_compositor-0.3.0/tests/test_diagram_cache_key.py +0 -43
  55. text_compositor-0.3.0/text_compositor/__init__.py +0 -0
  56. text_compositor-0.3.0/text_compositor/build.py +0 -3286
  57. text_compositor-0.3.0/text_compositor.egg-info/requires.txt +0 -8
  58. {text_compositor-0.3.0 → text_compositor-0.3.6}/LICENSE +0 -0
  59. {text_compositor-0.3.0 → text_compositor-0.3.6}/setup.cfg +0 -0
  60. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor/templates/_common.typ +0 -0
  61. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor/templates/paper.typ +0 -0
  62. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor/templates/slide.typ +0 -0
  63. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor/templates/template.typ +0 -0
  64. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor.egg-info/dependency_links.txt +0 -0
  65. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor.egg-info/entry_points.txt +0 -0
  66. {text_compositor-0.3.0 → text_compositor-0.3.6}/text_compositor.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: text-compositor
3
- Version: 0.3.0
3
+ Version: 0.3.6
4
4
  Summary: A tool for composing multiple text files into a PDF document.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/tokudiro/text-compositor
@@ -8,12 +8,12 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: typst==0.15.0
11
- Requires-Dist: markdown-it-py==3.0.0
11
+ Requires-Dist: markdown-it-py==4.2.0
12
12
  Requires-Dist: mdit-py-plugins==0.6.1
13
- Requires-Dist: PyYAML==6.0.2
14
- Requires-Dist: platformdirs==4.3.6
13
+ Requires-Dist: PyYAML==6.0.3
14
+ Requires-Dist: platformdirs==4.11.9
15
15
  Provides-Extra: mermaid
16
- Requires-Dist: playwright==1.62.0; extra == "mermaid"
16
+ Requires-Dist: playwright==1.63.0; extra == "mermaid"
17
17
  Dynamic: license-file
18
18
 
19
19
  # text-compositor
@@ -75,6 +75,10 @@ pip install -r requirements.txt
75
75
 
76
76
  No Typst compiler binary is bundled; it's obtained from the `typst` package (PyPI wheel) installed via `pip install` above. No additional downloads or installation steps are needed.
77
77
 
78
+ ### Keeping up to date
79
+
80
+ Security and bug fixes ship as new releases. There is no automatic update, so use the latest release: `pipx upgrade text-compositor` (or `pip install -U text-compositor`). For the [Obunzu Viewer](viewer/README.md), download the latest ZIP from the [Releases page](https://github.com/tokudiro/text-compositor/releases/latest). The Viewer bundles its own Python, so a new ZIP also updates that Python and the other bundled parts.
81
+
78
82
  ### Using Mermaid diagrams (optional)
79
83
 
80
84
  This is only needed if your source documents use ` ```mermaid ` fences (or specify an `.mmd` file directly in `chapters`). With Option A, install the `mermaid` extra:
@@ -147,6 +151,20 @@ Other flags control runtime behavior only (not document content, which stays ent
147
151
 
148
152
  `--clean` deletes the output PDF and the intermediate files under `.text-compositor/` (`temp_build.typ`, `_template.typ`, `_common.typ`) without building. `--clean-cache` additionally deletes the diagram cache (`.text-compositor/cache/`). Input files and the config are never deleted.
149
153
 
154
+ ### Python API
155
+
156
+ `text_compositor.Session` and `build_markdown()` turn a single Markdown file into a PDF without a `config.yaml`, and return warnings and errors as structured diagnostics (Markdown file and line included) instead of printing them. A resident worker (`python -m text_compositor.worker`) exposes the same over JSON lines on stdin/stdout, for GUI viewers. See the usage guide chapter "Using from Python" and section 14 of [doc/spec.md](doc/spec.md).
157
+
158
+ ```python
159
+ from text_compositor import Session
160
+
161
+ with Session() as session: # reuses the Mermaid browser and the Typst compiler across builds
162
+ result = session.build("doc.md", "out/doc.pdf")
163
+ print(result.ok, result.pdf_path, [d.message for d in result.diagnostics])
164
+ ```
165
+
166
+ `session.render_html("doc.md")` (experimental, [#161](https://github.com/tokudiro/text-compositor/issues/161)) writes a self-contained HTML file instead. Diagrams (Mermaid, PlantUML, D2, `svg`) become images next to it. `typst-exec` and raw HTML are not supported yet and are shown as code with a warning. Graphviz is drawn only in the Obunzu Viewer; elsewhere it is shown as code with a warning.
167
+
150
168
  See [sample/text-compositor.config.yaml](sample/text-compositor.config.yaml) for how to write the config file, and the [usage guide](doc/usage/) for details on `document:`/`plugins:`, front matter, Marp directives, and more. The Markdown files listed in `chapters` are concatenated in order to produce the PDF.
151
169
 
152
170
  ## Trying the sample
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: text-compositor
3
- Version: 0.3.0
4
- Summary: A tool for composing multiple text files into a PDF document.
5
- License-Expression: MIT
6
- Project-URL: Repository, https://github.com/tokudiro/text-compositor
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: typst==0.15.0
11
- Requires-Dist: markdown-it-py==3.0.0
12
- Requires-Dist: mdit-py-plugins==0.6.1
13
- Requires-Dist: PyYAML==6.0.2
14
- Requires-Dist: platformdirs==4.3.6
15
- Provides-Extra: mermaid
16
- Requires-Dist: playwright==1.62.0; extra == "mermaid"
17
- Dynamic: license-file
18
-
19
1
  # text-compositor
20
2
 
21
3
  *[日本語版 (Japanese version)](README-ja.md)*
@@ -75,6 +57,10 @@ pip install -r requirements.txt
75
57
 
76
58
  No Typst compiler binary is bundled; it's obtained from the `typst` package (PyPI wheel) installed via `pip install` above. No additional downloads or installation steps are needed.
77
59
 
60
+ ### Keeping up to date
61
+
62
+ Security and bug fixes ship as new releases. There is no automatic update, so use the latest release: `pipx upgrade text-compositor` (or `pip install -U text-compositor`). For the [Obunzu Viewer](viewer/README.md), download the latest ZIP from the [Releases page](https://github.com/tokudiro/text-compositor/releases/latest). The Viewer bundles its own Python, so a new ZIP also updates that Python and the other bundled parts.
63
+
78
64
  ### Using Mermaid diagrams (optional)
79
65
 
80
66
  This is only needed if your source documents use ` ```mermaid ` fences (or specify an `.mmd` file directly in `chapters`). With Option A, install the `mermaid` extra:
@@ -147,6 +133,20 @@ Other flags control runtime behavior only (not document content, which stays ent
147
133
 
148
134
  `--clean` deletes the output PDF and the intermediate files under `.text-compositor/` (`temp_build.typ`, `_template.typ`, `_common.typ`) without building. `--clean-cache` additionally deletes the diagram cache (`.text-compositor/cache/`). Input files and the config are never deleted.
149
135
 
136
+ ### Python API
137
+
138
+ `text_compositor.Session` and `build_markdown()` turn a single Markdown file into a PDF without a `config.yaml`, and return warnings and errors as structured diagnostics (Markdown file and line included) instead of printing them. A resident worker (`python -m text_compositor.worker`) exposes the same over JSON lines on stdin/stdout, for GUI viewers. See the usage guide chapter "Using from Python" and section 14 of [doc/spec.md](doc/spec.md).
139
+
140
+ ```python
141
+ from text_compositor import Session
142
+
143
+ with Session() as session: # reuses the Mermaid browser and the Typst compiler across builds
144
+ result = session.build("doc.md", "out/doc.pdf")
145
+ print(result.ok, result.pdf_path, [d.message for d in result.diagnostics])
146
+ ```
147
+
148
+ `session.render_html("doc.md")` (experimental, [#161](https://github.com/tokudiro/text-compositor/issues/161)) writes a self-contained HTML file instead. Diagrams (Mermaid, PlantUML, D2, `svg`) become images next to it. `typst-exec` and raw HTML are not supported yet and are shown as code with a warning. Graphviz is drawn only in the Obunzu Viewer; elsewhere it is shown as code with a warning.
149
+
150
150
  See [sample/text-compositor.config.yaml](sample/text-compositor.config.yaml) for how to write the config file, and the [usage guide](doc/usage/) for details on `document:`/`plugins:`, front matter, Marp directives, and more. The Markdown files listed in `chapters` are concatenated in order to produce the PDF.
151
151
 
152
152
  ## Trying the sample
@@ -4,23 +4,23 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "text-compositor"
7
- version = "0.3.0"
7
+ version = "0.3.6"
8
8
  description = "A tool for composing multiple text files into a PDF document."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
12
12
  dependencies = [
13
13
  "typst==0.15.0",
14
- "markdown-it-py==3.0.0",
14
+ "markdown-it-py==4.2.0",
15
15
  "mdit-py-plugins==0.6.1",
16
- "PyYAML==6.0.2",
17
- "platformdirs==4.3.6",
16
+ "PyYAML==6.0.3",
17
+ "platformdirs==4.11.9",
18
18
  ]
19
19
 
20
20
  # plugins.mermaid: true(既定)を使う場合のみ必要(11章)。
21
21
  # `pip install text-compositor[mermaid]` でまとめて導入できる。
22
22
  [project.optional-dependencies]
23
- mermaid = ["playwright==1.62.0"]
23
+ mermaid = ["playwright==1.63.0"]
24
24
 
25
25
  [project.urls]
26
26
  Repository = "https://github.com/tokudiro/text-compositor"
@@ -0,0 +1,330 @@
1
+ """Python API(api.py、#167)のテスト。実際にTypstでコンパイルするため、フォント(初回のみ取得)と、
2
+ Typstのパッケージ(テンプレートが使う@preview/…。初回のみ取得)を使う。"""
3
+ import os
4
+ import struct
5
+ import sys
6
+ import zlib
7
+
8
+ import pytest
9
+
10
+ import text_compositor.build as build
11
+ from text_compositor.deps import ensure_fonts
12
+ import text_compositor.project as project_mod
13
+ import time
14
+ from text_compositor import diagnostics
15
+ from text_compositor.api import BuildResult, Session, build_markdown
16
+
17
+
18
+ @pytest.fixture(scope="module")
19
+ def font_dir():
20
+ return ensure_fonts()
21
+
22
+
23
+ @pytest.fixture
24
+ def session(font_dir):
25
+ with Session(font_dir=font_dir) as s:
26
+ yield s
27
+
28
+
29
+ def write(path, text):
30
+ os.makedirs(os.path.dirname(path), exist_ok=True)
31
+ with open(path, "w", encoding="utf-8") as f:
32
+ f.write(text)
33
+
34
+
35
+ def png_bytes(rgb):
36
+ """8x8の単色PNG(標準ライブラリだけで作る)。"""
37
+ def chunk(kind, data):
38
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
39
+ raw = b"".join(b"\x00" + bytes(rgb) * 8 for _ in range(8))
40
+ return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 8, 8, 8, 2, 0, 0, 0))
41
+ + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
42
+
43
+
44
+ def read_bytes(path):
45
+ with open(path, "rb") as f:
46
+ return f.read()
47
+
48
+
49
+ PLAIN = {"mermaid": False, "plantuml": False, "d2": False}
50
+
51
+
52
+ class TestBuild:
53
+ def test_builds_a_pdf_to_the_requested_path(self, session, tmp_path):
54
+ md = tmp_path / "doc.md"
55
+ write(str(md), "# 見出し\n\n本文。\n\n- a\n- b\n")
56
+ out = tmp_path / "out" / "doc.pdf"
57
+ result = session.build(str(md), str(out), plugins=PLAIN)
58
+ assert isinstance(result, BuildResult)
59
+ assert result.ok and not result.errors
60
+ assert result.pdf_path == str(out)
61
+ assert read_bytes(str(out)).startswith(b"%PDF")
62
+
63
+ def test_does_not_create_an_outputs_directory_next_to_the_markdown(self, session, tmp_path):
64
+ md = tmp_path / "doc.md"
65
+ write(str(md), "# T\n\nbody\n")
66
+ session.build(str(md), str(tmp_path / "x" / "o.pdf"), plugins=PLAIN)
67
+ assert not (tmp_path / "outputs").exists()
68
+
69
+ def test_default_output_is_under_the_work_directory(self, session, tmp_path):
70
+ md = tmp_path / "doc.md"
71
+ write(str(md), "# T\n\nbody\n")
72
+ result = session.build(str(md), plugins=PLAIN)
73
+ assert result.ok
74
+ assert result.pdf_path == str(tmp_path / ".text-compositor" / "preview.pdf")
75
+ assert os.path.exists(result.pdf_path)
76
+
77
+ def test_leaves_no_intermediate_files_and_no_temporary_pdf(self, session, tmp_path):
78
+ md = tmp_path / "doc.md"
79
+ write(str(md), "# T\n\nbody\n")
80
+ out = tmp_path / "out" / "doc.pdf"
81
+ session.build(str(md), str(out), plugins=PLAIN)
82
+ assert sorted(os.listdir(str(tmp_path / ".text-compositor"))) == []
83
+ assert sorted(os.listdir(str(out.parent))) == ["doc.pdf"]
84
+
85
+ def test_keep_temp_keeps_the_generated_typst(self, session, tmp_path):
86
+ md = tmp_path / "doc.md"
87
+ write(str(md), "# T\n\nbody\n")
88
+ session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN, keep_temp=True)
89
+ assert (tmp_path / ".text-compositor" / "temp_build.typ").exists()
90
+
91
+ def test_writes_nothing_to_stdout(self, session, tmp_path, capsys):
92
+ md = tmp_path / "doc.md"
93
+ write(str(md), "# T\n\nbefore <span>x</span> after\n")
94
+ session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
95
+ write(str(md), "# T\n\n```dot {width=bogus}\ndigraph { a -> b }\n```\n")
96
+ session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
97
+ assert capsys.readouterr().out == ""
98
+
99
+ def test_timings_are_reported(self, session, tmp_path):
100
+ md = tmp_path / "doc.md"
101
+ write(str(md), "# T\n\nbody\n")
102
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
103
+ assert set(result.timings_ms) >= {"render", "compile", "total"}
104
+ assert result.timings_ms["total"] >= result.timings_ms["compile"] > 0
105
+
106
+ def test_first_markdown_heading_is_kept_as_is(self, session, tmp_path):
107
+ """プレビューでは、先頭のH1を落とさない(CLIの既定のcover: noneは、先頭のタイトルを落とす)。"""
108
+ md = tmp_path / "doc.md"
109
+ write(str(md), "# 先頭の見出し\n\n本文。\n")
110
+ session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN, keep_temp=True)
111
+ typst = (tmp_path / ".text-compositor" / "temp_build.typ").read_text(encoding="utf-8")
112
+ assert "= 先頭の見出し" in typst
113
+ assert "cover: false" in typst
114
+
115
+
116
+ class TestOptions:
117
+ @pytest.mark.parametrize("template", ["template", "slide", "paper"])
118
+ def test_bundled_templates(self, session, tmp_path, template):
119
+ md = tmp_path / "doc.md"
120
+ write(str(md), "# T\n\n## 節\n\n本文。\n")
121
+ result = session.build(str(md), str(tmp_path / "o.pdf"), template=template, plugins=PLAIN)
122
+ assert result.ok, result.diagnostics
123
+
124
+ def test_document_and_variables_overrides(self, session, tmp_path):
125
+ md = tmp_path / "doc.md"
126
+ write(str(md), "# T\n\nバージョンは{{VERSION}}。\n")
127
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN,
128
+ document={"title": "仕様書", "toc": True}, variables={"VERSION": "1.2"},
129
+ keep_temp=True)
130
+ assert result.ok
131
+ typst = (tmp_path / ".text-compositor" / "temp_build.typ").read_text(encoding="utf-8")
132
+ assert 'title: "仕様書"' in typst and "toc: true" in typst and "バージョンは1.2。" in typst
133
+
134
+ def test_config_override_applies_last(self, session, tmp_path):
135
+ md = tmp_path / "doc.md"
136
+ write(str(md), "# T\n\nbody\n")
137
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN, document={"title": "A"},
138
+ config={"document": {"title": "B"}}, keep_temp=True)
139
+ assert result.ok
140
+ assert 'title: "B"' in (tmp_path / ".text-compositor" / "temp_build.typ").read_text(encoding="utf-8")
141
+
142
+ def test_custom_typ_template_by_path(self, session, tmp_path):
143
+ """独自テンプレートは、Markdownの隣からの相対パスで指定できる。"""
144
+ with open(os.path.join(build.__file__ and os.path.dirname(build.__file__), "templates", "template.typ"),
145
+ encoding="utf-8") as f:
146
+ write(str(tmp_path / "my.typ"), f.read())
147
+ md = tmp_path / "doc.md"
148
+ write(str(md), "# T\n\nbody\n")
149
+ result = session.build(str(md), str(tmp_path / "o.pdf"), template="my.typ", plugins=PLAIN)
150
+ assert result.ok, result.diagnostics
151
+
152
+
153
+ class TestFailures:
154
+ def test_missing_markdown_is_reported_not_raised(self, session, tmp_path):
155
+ result = session.build(str(tmp_path / "none.md"), str(tmp_path / "o.pdf"))
156
+ assert not result.ok and result.pdf_path is None
157
+ assert result.errors and "not found" in result.errors[0].message
158
+
159
+ def test_unknown_template_is_reported(self, session, tmp_path):
160
+ md = tmp_path / "doc.md"
161
+ write(str(md), "# T\n")
162
+ result = session.build(str(md), str(tmp_path / "o.pdf"), template="no_such_template", plugins=PLAIN)
163
+ assert not result.ok
164
+ assert any("Template not found" in d.message for d in result.errors)
165
+
166
+ def test_compile_error_points_at_the_markdown_line(self, session, tmp_path):
167
+ md = tmp_path / "doc.md"
168
+ write(str(md), "# 見出し\n\n```dot {width=bogus}\ndigraph { a -> b }\n```\n")
169
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
170
+ assert not result.ok and result.pdf_path is None
171
+ error = result.errors[0]
172
+ assert "unknown variable: bogus" in error.message
173
+ assert error.file == str(md) and error.line == 3
174
+ assert error.detail and "temp_build.typ" in error.detail
175
+
176
+ def test_a_failed_build_keeps_the_previous_pdf(self, session, tmp_path):
177
+ md = tmp_path / "doc.md"
178
+ out = tmp_path / "o.pdf"
179
+ write(str(md), "# T\n\nok\n")
180
+ assert session.build(str(md), str(out), plugins=PLAIN).ok
181
+ before = read_bytes(str(out))
182
+ write(str(md), "# T\n\n```dot {width=bogus}\ndigraph { a -> b }\n```\n")
183
+ assert not session.build(str(md), str(out), plugins=PLAIN).ok
184
+ assert read_bytes(str(out)) == before
185
+
186
+ def test_the_session_recovers_after_a_failure(self, session, tmp_path):
187
+ md = tmp_path / "doc.md"
188
+ write(str(md), "# T\n\n```dot {width=bogus}\ndigraph { a -> b }\n```\n")
189
+ assert not session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN).ok
190
+ write(str(md), "# T\n\nfine\n")
191
+ assert session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN).ok
192
+
193
+ def test_security_violation_is_a_structured_error(self, session, tmp_path):
194
+ md = tmp_path / "doc.md"
195
+ write(str(md), "# T\n\n```typst-exec\n#panic(\"x\")\n```\n")
196
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
197
+ assert not result.ok
198
+ assert any("typst-exec" in d.message and d.file == str(md) for d in result.errors)
199
+
200
+ def test_unexpected_exceptions_do_not_escape(self, session, tmp_path, monkeypatch):
201
+ md = tmp_path / "doc.md"
202
+ write(str(md), "# T\n")
203
+
204
+ def boom(*a, **k):
205
+ raise RuntimeError("kaboom")
206
+ monkeypatch.setattr(project_mod, "_build_project", boom)
207
+ result = session.build(str(md), str(tmp_path / "o.pdf"))
208
+ assert not result.ok
209
+ assert "kaboom" in result.errors[0].message and "Traceback" in result.errors[0].detail
210
+
211
+ def test_closed_session_reports_an_error(self, font_dir, tmp_path):
212
+ s = Session(font_dir=font_dir)
213
+ s.close()
214
+ md = tmp_path / "doc.md"
215
+ write(str(md), "# T\n")
216
+ assert not s.build(str(md), str(tmp_path / "o.pdf")).ok
217
+
218
+ def test_output_open_elsewhere_is_reported(self, session, tmp_path, monkeypatch):
219
+ md = tmp_path / "doc.md"
220
+ write(str(md), "# T\n\nbody\n")
221
+
222
+ def denied(src, dst):
223
+ raise PermissionError("in use")
224
+ monkeypatch.setattr(os, "replace", denied)
225
+ monkeypatch.setattr(time, "sleep", lambda s: None)
226
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
227
+ assert not result.ok
228
+ assert any("Cannot write the PDF" in d.message for d in result.errors)
229
+ assert [n for n in os.listdir(str(tmp_path)) if n.startswith(".tmp-")] == []
230
+
231
+
232
+ class TestWarnings:
233
+ def test_html_warning_carries_file_and_line(self, session, tmp_path):
234
+ md = tmp_path / "doc.md"
235
+ write(str(md), "# T\n\ntext\n\nbefore <span>x</span> after\n")
236
+ result = session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
237
+ assert result.ok
238
+ assert result.warnings
239
+ assert all(d.file == str(md) and d.line == 5 for d in result.warnings if "HTML" in d.message)
240
+
241
+ def test_warnings_do_not_leak_between_builds(self, session, tmp_path):
242
+ md = tmp_path / "doc.md"
243
+ write(str(md), "# T\n\nbefore <span>x</span>\n")
244
+ assert session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN).warnings
245
+ write(str(md), "# T\n\nclean\n")
246
+ assert not session.build(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN).warnings
247
+
248
+
249
+ class TestResidentReuse:
250
+ def test_compiler_is_reused_and_changes_are_reflected(self, session, tmp_path):
251
+ md = tmp_path / "doc.md"
252
+ out = tmp_path / "o.pdf"
253
+ write(str(md), "# T\n\nfirst\n")
254
+ assert session.build(str(md), str(out), plugins=PLAIN).ok
255
+ first = read_bytes(str(out))
256
+ write(str(md), "# T\n\nsecond, and a bit longer text\n")
257
+ assert session.build(str(md), str(out), plugins=PLAIN).ok
258
+ assert read_bytes(str(out)) != first
259
+ assert len(session._compilers) == 1
260
+
261
+ def test_a_replaced_image_is_picked_up(self, session, tmp_path):
262
+ """同じパスの画像を差し替えても、使い回したコンパイラが古い画像を返さない。"""
263
+ md = tmp_path / "doc.md"
264
+ img = tmp_path / "img.png"
265
+ out = tmp_path / "o.pdf"
266
+ write(str(md), "# T\n\n![pic](img.png)\n")
267
+ img.write_bytes(png_bytes((255, 0, 0)))
268
+ assert session.build(str(md), str(out), plugins=PLAIN).ok
269
+ red = read_bytes(str(out))
270
+ img.write_bytes(png_bytes((0, 0, 255)))
271
+ assert session.build(str(md), str(out), plugins=PLAIN).ok
272
+ assert read_bytes(str(out)) != red
273
+
274
+ def test_different_documents_in_one_session(self, session, tmp_path):
275
+ a, b = tmp_path / "a" / "a.md", tmp_path / "b" / "b.md"
276
+ write(str(a), "# A\n\nalpha\n")
277
+ write(str(b), "# B\n\nbeta\n")
278
+ assert session.build(str(a), str(tmp_path / "a.pdf"), plugins=PLAIN).ok
279
+ assert session.build(str(b), str(tmp_path / "b.pdf"), plugins=PLAIN).ok
280
+ assert session.build(str(a), str(tmp_path / "a.pdf"), plugins=PLAIN).ok
281
+ assert read_bytes(str(tmp_path / "a.pdf")) != read_bytes(str(tmp_path / "b.pdf"))
282
+
283
+ def test_close_releases_everything_and_is_idempotent(self, font_dir):
284
+ closed = []
285
+
286
+ class FakeBrowser:
287
+ page = None
288
+
289
+ def close(self):
290
+ closed.append(1)
291
+ s = Session(font_dir=font_dir)
292
+ s._mermaid = FakeBrowser()
293
+ s._compilers["k"] = object()
294
+ s.close()
295
+ s.close()
296
+ assert closed == [1, 1] and s._compilers == {}
297
+
298
+
299
+ class TestModuleFunction:
300
+ def test_build_markdown_is_a_one_shot(self, font_dir, tmp_path):
301
+ md = tmp_path / "doc.md"
302
+ write(str(md), "# T\n\nbody\n")
303
+ result = build_markdown(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
304
+ assert result.ok and os.path.exists(result.pdf_path)
305
+
306
+ def test_public_names_are_importable_from_the_package(self):
307
+ import text_compositor
308
+ assert text_compositor.Session is Session
309
+ assert text_compositor.build_markdown is build_markdown
310
+ assert text_compositor.BuildResult is BuildResult
311
+ assert text_compositor.Diagnostic is diagnostics.Diagnostic
312
+ with pytest.raises(AttributeError):
313
+ text_compositor.nope
314
+
315
+ def test_plain_import_stays_light(self):
316
+ import subprocess
317
+ code = "import sys, text_compositor; sys.exit(1 if 'text_compositor.build' in sys.modules else 0)"
318
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
319
+ env = dict(os.environ, PYTHONPATH=repo_root + os.pathsep + os.environ.get("PYTHONPATH", ""))
320
+ assert subprocess.run([sys.executable, "-c", code], env=env, cwd=repo_root).returncode == 0
321
+
322
+ def test_result_to_dict_is_json_serializable(self, font_dir, tmp_path):
323
+ import json
324
+ md = tmp_path / "doc.md"
325
+ write(str(md), "# T\n\nbefore <span>x</span>\n")
326
+ result = build_markdown(str(md), str(tmp_path / "o.pdf"), plugins=PLAIN)
327
+ data = json.loads(json.dumps(result.to_dict()))
328
+ assert data["ok"] is True and data["pdf"] == str(tmp_path / "o.pdf")
329
+ assert data["diagnostics"][0]["severity"] == "warning"
330
+ assert set(data["timings_ms"]) >= {"render", "compile", "total"}
@@ -6,7 +6,8 @@ import os
6
6
 
7
7
  import pytest
8
8
 
9
- import text_compositor.build as build
9
+ from text_compositor.config import _resolve_project_image_path
10
+ from text_compositor.renderer import TypstRenderer
10
11
 
11
12
 
12
13
  class TestResolveAsset:
@@ -14,7 +15,7 @@ class TestResolveAsset:
14
15
  temp_build.typの置き場所に依存しないtypst_root起点のルート絶対パスへ変換する。"""
15
16
 
16
17
  def _renderer(self, tmp_path):
17
- renderer = build.TypstRenderer(line_mapping="off", base_dir=str(tmp_path), typst_root=str(tmp_path))
18
+ renderer = TypstRenderer(line_mapping="off", base_dir=str(tmp_path), typst_root=str(tmp_path))
18
19
  renderer.current_dir = str(tmp_path)
19
20
  renderer.current_file = str(tmp_path / "doc.md")
20
21
  return renderer
@@ -44,7 +45,7 @@ class TestResolveAsset:
44
45
  project_dir = tmp_path / "project"
45
46
  project_dir.mkdir()
46
47
  (project_dir / "a.png").write_bytes(b"\x89PNG")
47
- renderer = build.TypstRenderer(line_mapping="off", base_dir=str(project_dir), typst_root=str(tmp_path))
48
+ renderer = TypstRenderer(line_mapping="off", base_dir=str(project_dir), typst_root=str(tmp_path))
48
49
  renderer.current_dir = str(project_dir)
49
50
  renderer.current_file = str(project_dir / "doc.md")
50
51
  assert renderer._resolve_asset("a.png") == "/project/a.png"
@@ -54,20 +55,20 @@ class TestResolveProjectImagePath:
54
55
  """document.background/logo、chapters[].background/logo(project_dir基準)の解決。"""
55
56
 
56
57
  def test_none_path_returns_none(self, tmp_path):
57
- assert build._resolve_project_image_path(None, str(tmp_path), str(tmp_path), "Logo") is None
58
+ assert _resolve_project_image_path(None, str(tmp_path), str(tmp_path), "Logo") is None
58
59
 
59
60
  def test_existing_file_resolves_to_root_absolute_path(self, tmp_path):
60
61
  (tmp_path / "logo.png").write_bytes(b"\x89PNG")
61
- result = build._resolve_project_image_path("logo.png", str(tmp_path), str(tmp_path), "Logo")
62
+ result = _resolve_project_image_path("logo.png", str(tmp_path), str(tmp_path), "Logo")
62
63
  assert result == "/logo.png"
63
64
 
64
65
  def test_missing_file_exits(self, tmp_path):
65
66
  with pytest.raises(SystemExit):
66
- build._resolve_project_image_path("missing.png", str(tmp_path), str(tmp_path), "Logo")
67
+ _resolve_project_image_path("missing.png", str(tmp_path), str(tmp_path), "Logo")
67
68
 
68
69
  def test_resolves_relative_to_typst_root_not_base_dir(self, tmp_path):
69
70
  project_dir = tmp_path / "project"
70
71
  project_dir.mkdir()
71
72
  (project_dir / "logo.png").write_bytes(b"\x89PNG")
72
- result = build._resolve_project_image_path("logo.png", str(project_dir), str(tmp_path), "Logo")
73
+ result = _resolve_project_image_path("logo.png", str(project_dir), str(tmp_path), "Logo")
73
74
  assert result == "/project/logo.png"
@@ -0,0 +1,70 @@
1
+ """週次の固定値確認(.github/scripts/check-pins.py。#240)のテスト。ネットワークには接続しない。"""
2
+ import importlib.util
3
+ import os
4
+
5
+ import pytest
6
+
7
+ SCRIPT = os.path.join(os.path.dirname(__file__), "..", ".github", "scripts", "check-pins.py")
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def check_pins():
12
+ spec = importlib.util.spec_from_file_location("check_pins", SCRIPT)
13
+ module = importlib.util.module_from_spec(spec)
14
+ spec.loader.exec_module(module)
15
+ return module
16
+
17
+
18
+ def test_read_pins_finds_every_pin_in_the_sources(check_pins):
19
+ """deps.py・build-dist.js・_common.typの書き方を変えたときに、確認が空振りにならないための番人。"""
20
+ pins = check_pins.read_pins()
21
+ missing = [name for name, version in pins.items() if version is None]
22
+ assert missing == []
23
+ for name in ("Mermaid", "Viz.js", "PlantUML", "D2", "Temurin JRE 21", "Noto Sans JP", "組込版Python"):
24
+ assert name in pins
25
+ assert any(name.startswith("Typstパッケージ ") for name in pins)
26
+
27
+
28
+ def test_read_pins_matches_the_source_constants(check_pins):
29
+ from text_compositor import deps
30
+ pins = check_pins.read_pins()
31
+ assert pins["Viz.js"] == deps.VIZ_JS_VERSION
32
+ assert f"v{pins['D2']}" == deps.D2_RELEASE
33
+ assert f"jdk-{pins['Temurin JRE 21']}" == deps.TEMURIN_JRE_RELEASE
34
+
35
+
36
+ @pytest.mark.parametrize("latest, current, expected", [
37
+ ("1.2026.8", "1.2026.6", True),
38
+ ("21.0.12.1+1", "21.0.12+8", True), # 桁が多い版は、新しい(ビルド番号の大小より先に比べる)
39
+ ("21.0.12+8", "21.0.12.1+1", False),
40
+ ("3.14.7", "3.14.7", False),
41
+ ("3.14.10", "3.14.9", True), # 文字列ではなく、数として比べる
42
+ ("v0.9.1", "0.9.0", True),
43
+ ("2.004", "2.004", False),
44
+ ])
45
+ def test_is_newer(check_pins, latest, current, expected):
46
+ assert check_pins.is_newer(latest, current) is expected
47
+
48
+
49
+ def test_is_major_update(check_pins):
50
+ assert check_pins.is_major_update("12.0.0", "11.16.1")
51
+ assert not check_pins.is_major_update("11.17.0", "11.16.1")
52
+
53
+
54
+ def test_build_report_counts_updates_and_failures(check_pins):
55
+ pins = {"Mermaid": "11.16.1", "D2": "0.9.0", "PlantUML": "1.2026.6"}
56
+ latest = {
57
+ "Mermaid": ("12.0.0", ""),
58
+ "D2": ("0.9.0", ""),
59
+ "PlantUML": TimeoutError("timed out"),
60
+ }
61
+ report, updates, failures = check_pins.build_report(pins, latest)
62
+ assert (updates, failures) == (1, 1)
63
+ assert "**12.0.0**" in report and "メジャー更新" in report
64
+ assert "確認できず(TimeoutError: timed out)" in report
65
+
66
+
67
+ def test_build_report_without_differences(check_pins):
68
+ report, updates, failures = check_pins.build_report({"D2": "0.9.0"}, {"D2": ("0.9.0", "")})
69
+ assert (updates, failures) == (0, 0)
70
+ assert "最新" in report
@@ -5,6 +5,9 @@ import re
5
5
  import pytest
6
6
 
7
7
  import text_compositor.build as build
8
+ from text_compositor.changes import _is_up_to_date
9
+ from text_compositor.config import _load_project_config
10
+ from text_compositor.document import _prepare_template
8
11
 
9
12
  TOOL_DIR = os.path.dirname(os.path.abspath(build.__file__))
10
13
  TEMPLATES = os.path.join(TOOL_DIR, "templates")
@@ -59,7 +62,7 @@ class TestPrepareTemplate:
59
62
  def test_common_is_copied_next_to_template(self, env):
60
63
  project_dir, work_dir = env
61
64
  config = {"template": {"path": "template"}}
62
- copy_path, _ = build._prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
65
+ copy_path, _ = _prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
63
66
  assert os.path.dirname(copy_path) == str(work_dir)
64
67
  assert (work_dir / "_common.typ").read_text(encoding="utf-8") == read("_common.typ")
65
68
 
@@ -68,7 +71,7 @@ class TestPrepareTemplate:
68
71
  project_dir, work_dir = env
69
72
  write(str(project_dir / "adapter.typ"), '#import "_common.typ": fit-image\n')
70
73
  config = {"template": {"path": "adapter.typ"}}
71
- build._prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
74
+ _prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
72
75
  assert (work_dir / "_common.typ").exists()
73
76
 
74
77
 
@@ -84,12 +87,11 @@ class TestCleanRemovesCommon:
84
87
 
85
88
 
86
89
  class TestIfChangedDependsOnCommon:
87
- def test_newer_common_makes_output_stale(self, tmp_path, monkeypatch):
90
+ def test_newer_common_makes_output_stale(self, tmp_path):
88
91
  """同梱の補助関数が更新(pip upgrade等)されたら、入力が同じでも再生成する。"""
89
92
  tool = tmp_path / "tool"
90
93
  for name in ("templates/template.typ", "templates/_common.typ", "build.py"):
91
94
  write(str(tool / name))
92
- monkeypatch.setattr(build, "__file__", str(tool / "build.py"), raising=False)
93
95
  cfg = str(tmp_path / "proj" / "c.yaml")
94
96
  write(cfg, "chapters: [a.md]\n")
95
97
  pdf = str(tmp_path / "proj" / "outputs" / "System_Specification.pdf")
@@ -102,8 +104,8 @@ class TestIfChangedDependsOnCommon:
102
104
  mtime(p, 1000)
103
105
  mtime(str(tool / "templates" / "_common.typ"), 1000)
104
106
  mtime(pdf, 2000)
105
- project_dir, config, _ = build._load_project_config(cfg)
106
- assert build._is_up_to_date(str(tool), cfg, project_dir, config)[0] is True
107
+ project_dir, config, _ = _load_project_config(cfg)
108
+ assert _is_up_to_date(str(tool), cfg, project_dir, config)[0] is True
107
109
 
108
110
  mtime(str(tool / "templates" / "_common.typ"), 3000)
109
- assert build._is_up_to_date(str(tool), cfg, project_dir, config)[0] is False
111
+ assert _is_up_to_date(str(tool), cfg, project_dir, config)[0] is False