text-compositor 0.2.0__tar.gz → 0.3.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.
- {text_compositor-0.2.0/text_compositor.egg-info → text_compositor-0.3.0}/PKG-INFO +8 -2
- {text_compositor-0.2.0 → text_compositor-0.3.0}/README.md +7 -1
- {text_compositor-0.2.0 → text_compositor-0.3.0}/pyproject.toml +1 -1
- text_compositor-0.3.0/tests/test_common_template.py +109 -0
- text_compositor-0.3.0/tests/test_diagram_cache_key.py +43 -0
- text_compositor-0.3.0/tests/test_incremental.py +192 -0
- text_compositor-0.3.0/tests/test_paper_template.py +84 -0
- text_compositor-0.3.0/tests/test_revision_history.py +83 -0
- text_compositor-0.3.0/tests/test_sections.py +184 -0
- text_compositor-0.3.0/tests/test_table_cell_style.py +94 -0
- text_compositor-0.3.0/tests/test_variables.py +114 -0
- text_compositor-0.3.0/tests/test_watch.py +201 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor/build.py +638 -32
- text_compositor-0.2.0/text_compositor/templates/template.typ → text_compositor-0.3.0/text_compositor/templates/_common.typ +17 -98
- text_compositor-0.3.0/text_compositor/templates/paper.typ +99 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor/templates/slide.typ +7 -52
- text_compositor-0.3.0/text_compositor/templates/template.typ +151 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0/text_compositor.egg-info}/PKG-INFO +8 -2
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor.egg-info/SOURCES.txt +11 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/LICENSE +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/setup.cfg +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_asset_resolution.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_config.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_csv_table.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_front_matter.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_layout_blocks.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_line_mapping.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/tests/test_markdown_basics.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor/__init__.py +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor.egg-info/dependency_links.txt +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor.egg-info/entry_points.txt +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/text_compositor.egg-info/requires.txt +0 -0
- {text_compositor-0.2.0 → text_compositor-0.3.0}/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
|
+
Version: 0.3.0
|
|
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
|
|
@@ -141,6 +141,12 @@ Run `text-compositor --check-env` (or `python build.py --check-env`) to check yo
|
|
|
141
141
|
|
|
142
142
|
Other flags control runtime behavior only (not document content, which stays entirely in `config.yaml`): `-q`/`--quiet` suppresses `[Info]`-level logging, `-v`/`--verbose` adds extra detail (which chapter is being processed, cache reuse), and `--keep-temp` keeps the intermediate `temp_build.typ` around after a successful build instead of deleting it (useful for debugging; it's always kept after a failed build).
|
|
143
143
|
|
|
144
|
+
`--watch` keeps running after the first build and rebuilds whenever it detects a save in the config, the input files, or a custom `.typ` template. A failed build does not stop it; fix the file and save again. Press Ctrl+C to stop. Changes to files outside the project directory (e.g. images referenced via `../`) are not detected.
|
|
145
|
+
|
|
146
|
+
`--if-changed` skips the build, like `make`, when the output PDF is newer than the config, the input files, the template, and text-compositor itself (judged by modification time only). Without it, the PDF is always regenerated. Changes to Typst's version, to files outside the project directory, or to environment variables used by `variables` are not detected. On CI, `actions/checkout` resets every file's modification time, so restore the output directory from a cache if you want the skip to take effect.
|
|
147
|
+
|
|
148
|
+
`--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
|
+
|
|
144
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.
|
|
145
151
|
|
|
146
152
|
## Trying the sample
|
|
@@ -150,7 +156,7 @@ cd sample/
|
|
|
150
156
|
python ../build.py
|
|
151
157
|
```
|
|
152
158
|
|
|
153
|
-
This generates `sample/
|
|
159
|
+
This generates `sample/SampleDocument.pdf`. Two more samples show other templates: `sample/paper/` (two-column paper layout, `template.path: paper`) and `sample/universe-ilm/` (a [Typst Universe](https://typst.app/universe) template used through an adapter; needs network access on the first build).
|
|
154
160
|
|
|
155
161
|
## Implementation status
|
|
156
162
|
|
|
@@ -123,6 +123,12 @@ Run `text-compositor --check-env` (or `python build.py --check-env`) to check yo
|
|
|
123
123
|
|
|
124
124
|
Other flags control runtime behavior only (not document content, which stays entirely in `config.yaml`): `-q`/`--quiet` suppresses `[Info]`-level logging, `-v`/`--verbose` adds extra detail (which chapter is being processed, cache reuse), and `--keep-temp` keeps the intermediate `temp_build.typ` around after a successful build instead of deleting it (useful for debugging; it's always kept after a failed build).
|
|
125
125
|
|
|
126
|
+
`--watch` keeps running after the first build and rebuilds whenever it detects a save in the config, the input files, or a custom `.typ` template. A failed build does not stop it; fix the file and save again. Press Ctrl+C to stop. Changes to files outside the project directory (e.g. images referenced via `../`) are not detected.
|
|
127
|
+
|
|
128
|
+
`--if-changed` skips the build, like `make`, when the output PDF is newer than the config, the input files, the template, and text-compositor itself (judged by modification time only). Without it, the PDF is always regenerated. Changes to Typst's version, to files outside the project directory, or to environment variables used by `variables` are not detected. On CI, `actions/checkout` resets every file's modification time, so restore the output directory from a cache if you want the skip to take effect.
|
|
129
|
+
|
|
130
|
+
`--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.
|
|
131
|
+
|
|
126
132
|
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.
|
|
127
133
|
|
|
128
134
|
## Trying the sample
|
|
@@ -132,7 +138,7 @@ cd sample/
|
|
|
132
138
|
python ../build.py
|
|
133
139
|
```
|
|
134
140
|
|
|
135
|
-
This generates `sample/
|
|
141
|
+
This generates `sample/SampleDocument.pdf`. Two more samples show other templates: `sample/paper/` (two-column paper layout, `template.path: paper`) and `sample/universe-ilm/` (a [Typst Universe](https://typst.app/universe) template used through an adapter; needs network access on the first build).
|
|
136
142
|
|
|
137
143
|
## Implementation status
|
|
138
144
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""共通の補助関数`_common.typ`(#63)のリグレッションテスト。"""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import text_compositor.build as build
|
|
8
|
+
|
|
9
|
+
TOOL_DIR = os.path.dirname(os.path.abspath(build.__file__))
|
|
10
|
+
TEMPLATES = os.path.join(TOOL_DIR, "templates")
|
|
11
|
+
HELPERS = ["fit-image", "render-graph", "render-header", "render-footer", "render-background", "callout"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read(name):
|
|
15
|
+
with open(os.path.join(TEMPLATES, name), encoding="utf-8") as f:
|
|
16
|
+
return f.read()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write(path, text="x"):
|
|
20
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
21
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
22
|
+
f.write(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestBundledTemplates:
|
|
26
|
+
def test_common_defines_all_helpers(self):
|
|
27
|
+
common = read("_common.typ")
|
|
28
|
+
for name in HELPERS:
|
|
29
|
+
assert re.search(rf"^#let {re.escape(name)}\(", common, re.MULTILINE), name
|
|
30
|
+
|
|
31
|
+
def test_template_reexports_all_helpers_from_common(self):
|
|
32
|
+
"""build.pyの生成コードはtemplate.typからimportするため、全補助関数が見える必要がある。"""
|
|
33
|
+
tpl = read("template.typ")
|
|
34
|
+
m = re.search(r'^#import "_common\.typ": (.+)$', tpl, re.MULTILINE)
|
|
35
|
+
assert m
|
|
36
|
+
assert set(HELPERS) <= {n.strip() for n in m.group(1).split(",")}
|
|
37
|
+
|
|
38
|
+
def test_helpers_are_not_defined_twice_in_template(self):
|
|
39
|
+
for name in ("fit-image", "render-graph", "callout", "render-background"):
|
|
40
|
+
for tpl_name in ("template.typ", "slide.typ"):
|
|
41
|
+
assert not re.search(rf"^#let {re.escape(name)}\(", read(tpl_name), re.MULTILINE), (tpl_name, name)
|
|
42
|
+
|
|
43
|
+
def test_slide_keeps_its_own_header_and_footer(self):
|
|
44
|
+
slide = read("slide.typ")
|
|
45
|
+
assert re.search(r"^#let render-header\(", slide, re.MULTILINE)
|
|
46
|
+
assert re.search(r"^#let render-footer\(", slide, re.MULTILINE)
|
|
47
|
+
m = re.search(r'^#import "_common\.typ": (.+)$', slide, re.MULTILINE)
|
|
48
|
+
assert "render-header" not in m.group(1) and "render-footer" not in m.group(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestPrepareTemplate:
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def env(self, tmp_path):
|
|
54
|
+
project_dir = tmp_path / "proj"
|
|
55
|
+
work_dir = project_dir / ".text-compositor"
|
|
56
|
+
work_dir.mkdir(parents=True)
|
|
57
|
+
return project_dir, work_dir
|
|
58
|
+
|
|
59
|
+
def test_common_is_copied_next_to_template(self, env):
|
|
60
|
+
project_dir, work_dir = env
|
|
61
|
+
config = {"template": {"path": "template"}}
|
|
62
|
+
copy_path, _ = build._prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
|
|
63
|
+
assert os.path.dirname(copy_path) == str(work_dir)
|
|
64
|
+
assert (work_dir / "_common.typ").read_text(encoding="utf-8") == read("_common.typ")
|
|
65
|
+
|
|
66
|
+
def test_common_is_copied_for_custom_typ_template(self, env):
|
|
67
|
+
"""アダプタ(独自の.typ)も、隣の_common.typを相対パスで読み込める。"""
|
|
68
|
+
project_dir, work_dir = env
|
|
69
|
+
write(str(project_dir / "adapter.typ"), '#import "_common.typ": fit-image\n')
|
|
70
|
+
config = {"template": {"path": "adapter.typ"}}
|
|
71
|
+
build._prepare_template(config, TOOL_DIR, str(project_dir), str(work_dir), str(project_dir))
|
|
72
|
+
assert (work_dir / "_common.typ").exists()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestCleanRemovesCommon:
|
|
76
|
+
def test_clean_removes_common_copy(self, tmp_path):
|
|
77
|
+
cfg = str(tmp_path / "c.yaml")
|
|
78
|
+
write(cfg, "chapters: [a.md]\n")
|
|
79
|
+
work = tmp_path / ".text-compositor"
|
|
80
|
+
write(str(work / "_common.typ"))
|
|
81
|
+
write(str(work / "_template.typ"))
|
|
82
|
+
build._clean_one(cfg, include_cache=False)
|
|
83
|
+
assert not work.exists()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestIfChangedDependsOnCommon:
|
|
87
|
+
def test_newer_common_makes_output_stale(self, tmp_path, monkeypatch):
|
|
88
|
+
"""同梱の補助関数が更新(pip upgrade等)されたら、入力が同じでも再生成する。"""
|
|
89
|
+
tool = tmp_path / "tool"
|
|
90
|
+
for name in ("templates/template.typ", "templates/_common.typ", "build.py"):
|
|
91
|
+
write(str(tool / name))
|
|
92
|
+
monkeypatch.setattr(build, "__file__", str(tool / "build.py"), raising=False)
|
|
93
|
+
cfg = str(tmp_path / "proj" / "c.yaml")
|
|
94
|
+
write(cfg, "chapters: [a.md]\n")
|
|
95
|
+
pdf = str(tmp_path / "proj" / "outputs" / "System_Specification.pdf")
|
|
96
|
+
write(pdf)
|
|
97
|
+
|
|
98
|
+
def mtime(path, sec):
|
|
99
|
+
os.utime(path, ns=(int(sec * 1e9), int(sec * 1e9)))
|
|
100
|
+
|
|
101
|
+
for p in (str(tool / "templates" / "template.typ"), str(tool / "build.py"), cfg):
|
|
102
|
+
mtime(p, 1000)
|
|
103
|
+
mtime(str(tool / "templates" / "_common.typ"), 1000)
|
|
104
|
+
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
|
+
|
|
108
|
+
mtime(str(tool / "templates" / "_common.typ"), 3000)
|
|
109
|
+
assert build._is_up_to_date(str(tool), cfg, project_dir, config)[0] is False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""図のキャッシュキーを複合ハッシュにする設計(#26)のリグレッションテスト。"""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import text_compositor.build as build
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestDiagramCacheKey:
|
|
8
|
+
def test_same_inputs_give_same_key(self):
|
|
9
|
+
assert build._diagram_cache_key("d2", "v0.9.0", "a -> b") == build._diagram_cache_key("d2", "v0.9.0", "a -> b")
|
|
10
|
+
|
|
11
|
+
def test_tool_version_changes_key(self):
|
|
12
|
+
assert build._diagram_cache_key("d2", "v0.9.0", "a -> b") != build._diagram_cache_key("d2", "v0.9.1", "a -> b")
|
|
13
|
+
|
|
14
|
+
def test_code_changes_key(self):
|
|
15
|
+
assert build._diagram_cache_key("d2", "v0.9.0", "a -> b") != build._diagram_cache_key("d2", "v0.9.0", "a -> c")
|
|
16
|
+
|
|
17
|
+
def test_kind_changes_key(self):
|
|
18
|
+
assert build._diagram_cache_key("d2", "v1", "x") != build._diagram_cache_key("plantuml", "v1", "x")
|
|
19
|
+
|
|
20
|
+
def test_element_boundary_does_not_collide(self):
|
|
21
|
+
# 連結すると同じ文字列になる組み合わせでも、要素の境界が違えば別キーになる
|
|
22
|
+
assert build._diagram_cache_key("d2", "v1", "2x") != build._diagram_cache_key("d2", "v12", "x")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestDiagramCachePath:
|
|
26
|
+
def test_path_contains_kind_and_key(self, tmp_path):
|
|
27
|
+
renderer = build.TypstRenderer(base_dir=str(tmp_path), line_mapping="off")
|
|
28
|
+
path, digest = renderer._diagram_cache_path("plantuml", "ver", "@startuml\n@enduml\n")
|
|
29
|
+
assert os.path.dirname(path) == str(tmp_path / ".text-compositor" / "cache")
|
|
30
|
+
assert os.path.basename(path) == f"plantuml_{digest}.svg"
|
|
31
|
+
assert os.path.isdir(os.path.dirname(path))
|
|
32
|
+
|
|
33
|
+
def test_d2_version_uses_system_d2_output(self, tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.setattr(build, "find_system_d2", lambda: "/usr/bin/d2")
|
|
35
|
+
monkeypatch.setattr(build, "_system_d2_version", lambda _bin: "v9.9.9")
|
|
36
|
+
renderer = build.TypstRenderer(base_dir=str(tmp_path), line_mapping="off")
|
|
37
|
+
assert renderer._d2_version() == "v9.9.9"
|
|
38
|
+
|
|
39
|
+
def test_d2_version_falls_back_to_pinned_release_without_downloading(self, tmp_path, monkeypatch):
|
|
40
|
+
monkeypatch.setattr(build, "find_system_d2", lambda: None)
|
|
41
|
+
monkeypatch.setattr(build, "ensure_d2_binary", lambda: (_ for _ in ()).throw(AssertionError("must not download")))
|
|
42
|
+
renderer = build.TypstRenderer(base_dir=str(tmp_path), line_mapping="off")
|
|
43
|
+
assert renderer._d2_version() == build.D2_RELEASE
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""--if-changed(更新日時による生成スキップ)と--clean/--clean-cacheのリグレッションテスト(#151)。"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import text_compositor.build as build
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write(path, text="x"):
|
|
11
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
12
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
13
|
+
f.write(text)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_mtime(path, seconds):
|
|
17
|
+
ns = int(seconds * 1e9)
|
|
18
|
+
os.utime(path, ns=(ns, ns))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def project(tmp_path):
|
|
23
|
+
"""全入力の更新日時を1000秒、出力PDFを2000秒に固定した、ビルド済みのプロジェクト。"""
|
|
24
|
+
cfg = str(tmp_path / "text-compositor.config.yaml")
|
|
25
|
+
write(cfg, "chapters:\n - a.md\n")
|
|
26
|
+
write(str(tmp_path / "inputs" / "a.md"), "# A\n")
|
|
27
|
+
pdf = str(tmp_path / "outputs" / "System_Specification.pdf")
|
|
28
|
+
write(pdf)
|
|
29
|
+
for p in (cfg, str(tmp_path / "inputs" / "a.md")):
|
|
30
|
+
set_mtime(p, 1000)
|
|
31
|
+
set_mtime(pdf, 2000)
|
|
32
|
+
return tmp_path, cfg, pdf
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def tool_dir(tmp_path_factory, monkeypatch):
|
|
37
|
+
"""同梱テンプレートとbuild.py自身の更新日時を、実行時点に左右されない古い値に固定した仮のtool_dir。"""
|
|
38
|
+
d = tmp_path_factory.mktemp("tool")
|
|
39
|
+
tpl = str(d / "templates" / "template.typ")
|
|
40
|
+
write(tpl)
|
|
41
|
+
set_mtime(tpl, 500)
|
|
42
|
+
fake_build = str(d / "build.py")
|
|
43
|
+
write(fake_build)
|
|
44
|
+
set_mtime(fake_build, 500)
|
|
45
|
+
monkeypatch.setattr(build, "__file__", fake_build, raising=False)
|
|
46
|
+
return str(d)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check(tmp_path, cfg, tool_dir):
|
|
50
|
+
project_dir, config, _ = build._load_project_config(cfg)
|
|
51
|
+
return build._is_up_to_date(tool_dir, cfg, project_dir, config)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestIsUpToDate:
|
|
55
|
+
def test_up_to_date_when_output_is_newest(self, project, tool_dir):
|
|
56
|
+
tmp_path, cfg, pdf = project
|
|
57
|
+
up_to_date, out = check(tmp_path, cfg, tool_dir)
|
|
58
|
+
assert up_to_date
|
|
59
|
+
assert out == os.path.normpath(pdf)
|
|
60
|
+
|
|
61
|
+
def test_stale_when_output_missing(self, project, tool_dir):
|
|
62
|
+
tmp_path, cfg, pdf = project
|
|
63
|
+
os.remove(pdf)
|
|
64
|
+
assert check(tmp_path, cfg, tool_dir)[0] is False
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("rel", ["text-compositor.config.yaml", os.path.join("inputs", "a.md")])
|
|
67
|
+
def test_stale_when_input_or_config_is_newer(self, project, tool_dir, rel):
|
|
68
|
+
tmp_path, cfg, pdf = project
|
|
69
|
+
set_mtime(str(tmp_path / rel), 3000)
|
|
70
|
+
assert check(tmp_path, cfg, tool_dir)[0] is False
|
|
71
|
+
|
|
72
|
+
def test_stale_when_new_input_file_is_added(self, project, tool_dir):
|
|
73
|
+
tmp_path, cfg, pdf = project
|
|
74
|
+
b = str(tmp_path / "inputs" / "b.csv")
|
|
75
|
+
write(b)
|
|
76
|
+
set_mtime(b, 3000)
|
|
77
|
+
assert check(tmp_path, cfg, tool_dir)[0] is False
|
|
78
|
+
|
|
79
|
+
def test_stale_when_custom_typ_template_is_newer(self, tmp_path, tool_dir):
|
|
80
|
+
cfg = str(tmp_path / "c.yaml")
|
|
81
|
+
write(cfg, "template:\n path: my/tpl.typ\nchapters: [a.md]\n")
|
|
82
|
+
tpl = str(tmp_path / "my" / "tpl.typ")
|
|
83
|
+
write(tpl)
|
|
84
|
+
pdf = str(tmp_path / "outputs" / "System_Specification.pdf")
|
|
85
|
+
write(pdf)
|
|
86
|
+
set_mtime(cfg, 1000)
|
|
87
|
+
set_mtime(tpl, 3000)
|
|
88
|
+
set_mtime(pdf, 2000)
|
|
89
|
+
assert check(tmp_path, cfg, tool_dir)[0] is False
|
|
90
|
+
|
|
91
|
+
def test_stale_when_tool_is_newer(self, project, tool_dir):
|
|
92
|
+
tmp_path, cfg, pdf = project
|
|
93
|
+
set_mtime(build.__file__, 3000)
|
|
94
|
+
assert check(tmp_path, cfg, tool_dir)[0] is False
|
|
95
|
+
|
|
96
|
+
def test_intermediate_files_do_not_make_it_stale(self, project, tool_dir):
|
|
97
|
+
tmp_path, cfg, pdf = project
|
|
98
|
+
tmp_file = str(tmp_path / ".text-compositor" / "temp_build.typ")
|
|
99
|
+
write(tmp_file)
|
|
100
|
+
set_mtime(tmp_file, 3000)
|
|
101
|
+
assert check(tmp_path, cfg, tool_dir)[0] is True
|
|
102
|
+
|
|
103
|
+
def test_other_pdf_in_project_dir_is_ignored(self, project, tool_dir):
|
|
104
|
+
"""--config-listで同じproject_dirを共有する別configのPDFで、互いに常に再生成にならないこと。"""
|
|
105
|
+
tmp_path, cfg, pdf = project
|
|
106
|
+
other = str(tmp_path / "other_outputs" / "other.pdf")
|
|
107
|
+
write(other)
|
|
108
|
+
set_mtime(other, 9000)
|
|
109
|
+
assert check(tmp_path, cfg, tool_dir)[0] is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestBuildOneSkips:
|
|
113
|
+
def test_skip_logs_and_does_not_touch_work_dir(self, project, tool_dir, capsys, monkeypatch):
|
|
114
|
+
tmp_path, cfg, pdf = project
|
|
115
|
+
monkeypatch.setattr(build, "_is_up_to_date", lambda *a: (True, pdf))
|
|
116
|
+
# スキップ時はrepo_root/font_dirを使わない。触ればNoneで即座に失敗する。
|
|
117
|
+
build._build_one(tool_dir, None, None, cfg, if_changed=True)
|
|
118
|
+
assert "Skipped (up to date)" in capsys.readouterr().out
|
|
119
|
+
assert not (tmp_path / ".text-compositor").exists()
|
|
120
|
+
|
|
121
|
+
def test_without_flag_never_checks_freshness(self, project, tool_dir, monkeypatch):
|
|
122
|
+
tmp_path, cfg, pdf = project
|
|
123
|
+
|
|
124
|
+
def boom(*a):
|
|
125
|
+
raise AssertionError("checked")
|
|
126
|
+
monkeypatch.setattr(build, "_is_up_to_date", boom)
|
|
127
|
+
# 既定は従来どおり常に再生成する。判定関数は呼ばれず、後続の処理(ここでは_resolve_variables以降)へ進む。
|
|
128
|
+
monkeypatch.setattr(build, "_resolve_variables", lambda c: (_ for _ in ()).throw(RuntimeError("proceeded")))
|
|
129
|
+
with pytest.raises(RuntimeError, match="proceeded"):
|
|
130
|
+
build._build_one(tool_dir, None, None, cfg)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestClean:
|
|
134
|
+
@pytest.fixture
|
|
135
|
+
def built(self, project):
|
|
136
|
+
tmp_path, cfg, pdf = project
|
|
137
|
+
work = tmp_path / ".text-compositor"
|
|
138
|
+
write(str(work / "temp_build.typ"))
|
|
139
|
+
write(str(work / "_template.typ"))
|
|
140
|
+
write(str(work / "cache" / "svg_abc.svg"))
|
|
141
|
+
return tmp_path, cfg, pdf, work
|
|
142
|
+
|
|
143
|
+
def test_clean_removes_pdf_and_intermediates_but_keeps_cache(self, built):
|
|
144
|
+
tmp_path, cfg, pdf, work = built
|
|
145
|
+
build._clean_one(cfg, include_cache=False)
|
|
146
|
+
assert not os.path.exists(pdf)
|
|
147
|
+
assert not (work / "temp_build.typ").exists()
|
|
148
|
+
assert not (work / "_template.typ").exists()
|
|
149
|
+
assert (work / "cache" / "svg_abc.svg").exists()
|
|
150
|
+
|
|
151
|
+
def test_clean_cache_also_removes_cache_and_empty_work_dir(self, built):
|
|
152
|
+
tmp_path, cfg, pdf, work = built
|
|
153
|
+
build._clean_one(cfg, include_cache=True)
|
|
154
|
+
assert not os.path.exists(pdf)
|
|
155
|
+
assert not work.exists()
|
|
156
|
+
|
|
157
|
+
def test_inputs_and_config_are_never_removed(self, built):
|
|
158
|
+
tmp_path, cfg, pdf, work = built
|
|
159
|
+
build._clean_one(cfg, include_cache=True)
|
|
160
|
+
assert os.path.exists(cfg)
|
|
161
|
+
assert (tmp_path / "inputs" / "a.md").exists()
|
|
162
|
+
|
|
163
|
+
def test_clean_is_noop_when_nothing_to_remove(self, project, capsys):
|
|
164
|
+
tmp_path, cfg, pdf = project
|
|
165
|
+
os.remove(pdf)
|
|
166
|
+
build._clean_one(cfg, include_cache=True)
|
|
167
|
+
assert "Cleaned 0 item(s)" in capsys.readouterr().out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestArgs:
|
|
171
|
+
def parse(self, monkeypatch, *argv):
|
|
172
|
+
monkeypatch.setattr(sys, "argv", ["text-compositor", *argv])
|
|
173
|
+
return build.parse_args()
|
|
174
|
+
|
|
175
|
+
def test_clean_cache_implies_clean(self, monkeypatch):
|
|
176
|
+
args = self.parse(monkeypatch, "--clean-cache")
|
|
177
|
+
assert args.clean and args.clean_cache
|
|
178
|
+
|
|
179
|
+
def test_defaults_keep_existing_behavior(self, monkeypatch):
|
|
180
|
+
args = self.parse(monkeypatch)
|
|
181
|
+
assert not args.if_changed and not args.clean
|
|
182
|
+
|
|
183
|
+
@pytest.mark.parametrize("extra", [["--watch"], ["--if-changed"], ["--check-env"]])
|
|
184
|
+
def test_clean_conflicts(self, monkeypatch, extra):
|
|
185
|
+
with pytest.raises(SystemExit) as e:
|
|
186
|
+
self.parse(monkeypatch, "--clean", *extra)
|
|
187
|
+
assert e.value.code == 2
|
|
188
|
+
|
|
189
|
+
def test_if_changed_conflicts_with_watch(self, monkeypatch):
|
|
190
|
+
with pytest.raises(SystemExit) as e:
|
|
191
|
+
self.parse(monkeypatch, "--if-changed", "--watch")
|
|
192
|
+
assert e.value.code == 2
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""論文形式テンプレート`paper`と`document.abstract`(#64)のリグレッションテスト。"""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import text_compositor.build as build
|
|
8
|
+
|
|
9
|
+
TOOL_DIR = os.path.dirname(os.path.abspath(build.__file__))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_template(name):
|
|
13
|
+
with open(os.path.join(TOOL_DIR, "templates", name), encoding="utf-8") as f:
|
|
14
|
+
return f.read()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestResolveAbstract:
|
|
18
|
+
def test_unset_returns_none(self):
|
|
19
|
+
assert build._resolve_abstract({}) is None
|
|
20
|
+
|
|
21
|
+
def test_blank_returns_none(self):
|
|
22
|
+
assert build._resolve_abstract({"abstract": " \n "}) is None
|
|
23
|
+
|
|
24
|
+
def test_string_is_stripped(self):
|
|
25
|
+
assert build._resolve_abstract({"abstract": "\n本文。\n"}) == "本文。"
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("value", [["a", "b"], {"k": "v"}, 3, True])
|
|
28
|
+
def test_non_string_is_an_error(self, value, capsys):
|
|
29
|
+
with pytest.raises(SystemExit) as e:
|
|
30
|
+
build._resolve_abstract({"abstract": value})
|
|
31
|
+
assert e.value.code == 1
|
|
32
|
+
assert "document.abstract must be a string" in capsys.readouterr().out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestAbstractTypstArg:
|
|
36
|
+
def test_none_passes_no_argument(self):
|
|
37
|
+
assert build._abstract_typst_arg(None) == ""
|
|
38
|
+
|
|
39
|
+
def test_multiline_is_one_line_literal_with_escaped_newline(self):
|
|
40
|
+
arg = build._abstract_typst_arg("一行目\n二行目")
|
|
41
|
+
assert arg == ' abstract: "一行目\\n二行目",\n'
|
|
42
|
+
|
|
43
|
+
def test_markup_characters_are_data_not_markup(self):
|
|
44
|
+
arg = build._abstract_typst_arg('#import "x" *bold*')
|
|
45
|
+
assert '\\"x\\"' in arg and arg.startswith(' abstract: "')
|
|
46
|
+
|
|
47
|
+
def test_crlf_is_normalized(self):
|
|
48
|
+
assert "\\r" not in build._abstract_typst_arg("a\r\nb") and "\r" not in build._abstract_typst_arg("a\r\nb")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestPreamble:
|
|
52
|
+
def preamble(self, doc):
|
|
53
|
+
return build._build_document_preamble({"document": doc}, "/t.typ", True, ".", ".")[0]
|
|
54
|
+
|
|
55
|
+
def test_abstract_is_passed_only_when_set(self):
|
|
56
|
+
assert "abstract:" not in self.preamble({"title": "T"})
|
|
57
|
+
assert 'abstract: "概要",' in self.preamble({"title": "T", "abstract": "概要"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestPaperTemplate:
|
|
61
|
+
def test_bundled_name_resolves(self):
|
|
62
|
+
path = build.resolve_template_path("paper", TOOL_DIR, ".")
|
|
63
|
+
assert os.path.exists(path)
|
|
64
|
+
|
|
65
|
+
def test_conf_accepts_every_required_argument(self):
|
|
66
|
+
"""build.pyが常に渡す引数を受け取れないと、そのテンプレートは常にビルドエラーになる。"""
|
|
67
|
+
conf = read_template("paper.typ").split("#let conf(")[1].split(") = {")[0]
|
|
68
|
+
for arg in ["title", "subtitle", "author", "date", "paper_size", "landscape", "graphviz",
|
|
69
|
+
"header", "footer", "paginate", "background", "logo"]:
|
|
70
|
+
assert re.search(rf"^\s*{arg}:", conf, re.MULTILINE), arg
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("name", ["paper.typ", "template.typ", "slide.typ"])
|
|
73
|
+
def test_every_bundled_template_accepts_abstract(self, name):
|
|
74
|
+
conf = read_template(name).split("#let conf(")[1].split(") = {")[0]
|
|
75
|
+
assert re.search(r"^\s*abstract:", conf, re.MULTILINE)
|
|
76
|
+
|
|
77
|
+
def test_paper_exports_all_helpers_via_common(self):
|
|
78
|
+
m = re.search(r'^#import "_common\.typ": (.+)$', read_template("paper.typ"), re.MULTILINE)
|
|
79
|
+
names = {n.strip() for n in m.group(1).split(",")}
|
|
80
|
+
assert {"fit-image", "render-graph", "callout", "render-header", "render-footer",
|
|
81
|
+
"render-background"} <= names
|
|
82
|
+
|
|
83
|
+
def test_paper_is_two_columns(self):
|
|
84
|
+
assert "columns: 2" in read_template("paper.typ")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""改版履歴ページ(document.revision_history、#56)のリグレッションテスト。"""
|
|
2
|
+
import datetime
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import text_compositor.build as build
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve(entries):
|
|
10
|
+
return build._resolve_revision_history({"revision_history": entries})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestResolve:
|
|
14
|
+
def test_absent_or_empty_disables_the_page(self):
|
|
15
|
+
assert build._resolve_revision_history({}) is None
|
|
16
|
+
assert resolve([]) is None
|
|
17
|
+
|
|
18
|
+
def test_fills_missing_keys_with_empty_strings(self):
|
|
19
|
+
assert resolve([{"version": "1.0", "description": "初版"}]) == [
|
|
20
|
+
{"version": "1.0", "date": "", "description": "初版", "author": ""}]
|
|
21
|
+
|
|
22
|
+
def test_values_are_stringified_including_yaml_dates(self):
|
|
23
|
+
out = resolve([{"version": 2, "date": datetime.date(2026, 8, 1), "description": None}])
|
|
24
|
+
assert out == [{"version": "2", "date": "2026-08-01", "description": "", "author": ""}]
|
|
25
|
+
|
|
26
|
+
def test_order_is_preserved(self):
|
|
27
|
+
out = resolve([{"version": "1.0"}, {"version": "1.1"}])
|
|
28
|
+
assert [e["version"] for e in out] == ["1.0", "1.1"]
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("bad", [
|
|
31
|
+
"1.0",
|
|
32
|
+
{"version": "1.0"},
|
|
33
|
+
["1.0"],
|
|
34
|
+
[{"version": "1", "discription": "typo"}],
|
|
35
|
+
[{}],
|
|
36
|
+
[{"version": " ", "description": ""}],
|
|
37
|
+
])
|
|
38
|
+
def test_invalid_definitions_are_fatal(self, bad, capsys):
|
|
39
|
+
with pytest.raises(SystemExit) as e:
|
|
40
|
+
resolve(bad)
|
|
41
|
+
assert e.value.code == 1
|
|
42
|
+
assert "revision_history" in capsys.readouterr().out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestTypstArg:
|
|
46
|
+
def test_none_passes_no_argument(self):
|
|
47
|
+
# 引数を持たない既存の独自テンプレートとの互換のため、引数行自体を出さない
|
|
48
|
+
assert build._revision_history_typst_arg(None) == ""
|
|
49
|
+
|
|
50
|
+
def test_single_entry_is_still_an_array(self):
|
|
51
|
+
arg = build._revision_history_typst_arg(resolve([{"version": "1.0"}]))
|
|
52
|
+
assert arg == ' revision_history: ((version: "1.0", date: "", description: "", author: ""),),\n'
|
|
53
|
+
|
|
54
|
+
def test_multiple_entries(self):
|
|
55
|
+
arg = build._revision_history_typst_arg(resolve([{"version": "1.0"}, {"version": "1.1"}]))
|
|
56
|
+
assert arg.count("(version:") == 2
|
|
57
|
+
|
|
58
|
+
def test_quotes_backslashes_and_markup_are_escaped_as_string_data(self):
|
|
59
|
+
arg = build._revision_history_typst_arg(resolve([{"description": 'say "hi" \\ #x *y*'}]))
|
|
60
|
+
assert 'description: "say \\"hi\\" \\\\ #x *y*"' in arg
|
|
61
|
+
|
|
62
|
+
def test_newlines_become_escape_sequences_not_raw_line_breaks(self):
|
|
63
|
+
arg = build._revision_history_typst_arg(resolve([{"description": "a\r\nb\nc"}]))
|
|
64
|
+
assert 'description: "a\\nb\\nc"' in arg
|
|
65
|
+
assert arg.count("\n") == 1 # 引数行末の改行のみ
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestPreamble:
|
|
69
|
+
def build_preamble(self, doc):
|
|
70
|
+
preamble, *_ = build._build_document_preamble({"document": doc}, "/t.typ", True, "/proj", "/proj")
|
|
71
|
+
return preamble
|
|
72
|
+
|
|
73
|
+
def test_argument_is_passed_to_conf_when_configured(self):
|
|
74
|
+
pre = self.build_preamble({"revision_history": [{"version": "1.0", "description": "初版"}]})
|
|
75
|
+
assert 'revision_history: ((version: "1.0", date: "", description: "初版", author: ""),),' in pre
|
|
76
|
+
|
|
77
|
+
def test_no_argument_when_not_configured(self):
|
|
78
|
+
assert "revision_history" not in self.build_preamble({})
|
|
79
|
+
assert "revision_history" not in self.build_preamble({"revision_history": []})
|
|
80
|
+
|
|
81
|
+
def test_argument_is_placed_among_the_optional_arguments(self):
|
|
82
|
+
pre = self.build_preamble({"toc": True, "revision_history": [{"version": "1"}]})
|
|
83
|
+
assert pre.index("toc: true") < pre.index("revision_history:") < pre.index("graphviz:")
|