unaltraweb 0.7.1 → 0.7.3

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +26 -4
  3. data/docs/_documentation/en/40-distribution.md +33 -10
  4. data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
  5. data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
  6. data/docs/agents/consolidation-0.7.2.md +97 -0
  7. data/docs/agents/d0-runtime-identity.md +8 -0
  8. data/docs/agents/deployment-integration-0.7.1.md +4 -0
  9. data/docs/agents/manual-authoring-components.md +23 -0
  10. data/docs/agents/mcp-contract.md +109 -4
  11. data/docs/agents/owner-closeout-93.md +185 -0
  12. data/docs/agents/practice-editions-98.md +816 -0
  13. data/docs/agents/practice-pdf-installable-98.md +250 -0
  14. data/docs/agents/practice-runtime-archives-98.md +147 -0
  15. data/docs/agents/private-practice-verifier-98.md +296 -0
  16. data/docs/agents/qgis-0.6.1-adoption-109.md +110 -0
  17. data/docs/agents/source-ci-98.md +148 -0
  18. data/scripts/check_practice_verifier_runtime.py +69 -0
  19. data/scripts/export_practice_runtime_archives.py +138 -0
  20. data/scripts/manual/filters/practice-finish.lua +36 -0
  21. data/scripts/manual/filters/practice-layout.lua +84 -0
  22. data/scripts/manual/practice_pdf.py +31 -0
  23. data/scripts/manual/render_practice.py +125 -0
  24. data/scripts/manual/templates/practice.tex +95 -0
  25. data/scripts/prepare_practice_candidate.py +159 -0
  26. data/scripts/prepare_practice_editions_candidate.py +119 -0
  27. data/scripts/test_wheel_install.py +20 -0
  28. data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
  29. data/scripts/validate_distribution.py +18 -1
  30. data/src/unaltraweb_mcp/component-contract.json +28 -17
  31. data/src/unaltraweb_mcp/editorial_sources.py +62 -4
  32. metadata +20 -2
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Export two prepared images for the hub's existing Docker-save loader.
3
+
4
+ No build, pull, load, retag, installation or resource manager is implemented here.
5
+ The existing bounded exporter is reused; PDF provenance comes from its own BOM
6
+ record rather than the controller's source commit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import shutil
17
+ import sys
18
+ import tarfile
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(ROOT))
22
+ sys.path.insert(0, str(ROOT / "src"))
23
+ from scripts.prepare_practice_candidate import export_image, image, sha256
24
+ from unaltraweb_mcp.distribution import distribution_contract
25
+ from unaltraweb_mcp.runtime_lifecycle import IMAGE_ID, immutable_reference
26
+
27
+
28
+ def verify_archive(path, expected_id, expected_source):
29
+ """Inspect only Docker-save metadata/config; never extract or load layers."""
30
+ with tarfile.open(path, "r:gz") as archive:
31
+ records = archive.getmembers()
32
+ by_name = {entry.name: entry for entry in records}
33
+ if len(records) != len(by_name) or len(records) > 10000:
34
+ raise ValueError("Docker-save archive inventory is ambiguous or excessive")
35
+ def small_json(name):
36
+ entry = by_name.get(name)
37
+ if entry is None or not entry.isfile() or entry.size > 2*1024*1024:
38
+ raise ValueError("Docker-save metadata is missing or invalid")
39
+ with archive.extractfile(entry) as stream:
40
+ raw = stream.read()
41
+ return json.loads(raw), raw
42
+ manifest, _ = small_json("manifest.json")
43
+ if not isinstance(manifest, list) or len(manifest) != 1:
44
+ raise ValueError("Expected one immutable image per Docker-save archive")
45
+ item = manifest[0]
46
+ if item.get("RepoTags"):
47
+ raise ValueError("Export by configuration ID only; archive must not reassign tag aliases")
48
+ config, raw = small_json(item.get("Config"))
49
+ if "sha256:" + hashlib.sha256(raw).hexdigest() != expected_id:
50
+ raise ValueError("Docker-save config bytes differ from the expected image ID")
51
+ if config.get("config", {}).get("Labels", {}).get("org.opencontainers.image.revision") != expected_source:
52
+ raise ValueError("Docker-save image producer differs from its own expected source")
53
+ if (config.get("os"), config.get("architecture")) != ("linux", "amd64"):
54
+ raise ValueError("Archive must retain the selected Linux/amd64 platform")
55
+ for name in item.get("Layers", []):
56
+ if name not in by_name or not by_name[name].isfile():
57
+ raise ValueError("Docker-save layer is missing")
58
+ return {"config_id": expected_id, "source_revision": expected_source, "repo_tags": []}
59
+
60
+
61
+ def write_new(path, value):
62
+ with path.open("x") as stream:
63
+ stream.write(json.dumps(value, indent=2, sort_keys=True) + "\n")
64
+ stream.flush()
65
+ os.fsync(stream.fileno())
66
+
67
+
68
+ def prepare(output, controller_reference, controller_id, controller_source, pdf_id, *, controller_archive=None, controller_archive_sha256=None):
69
+ output = Path(output).absolute()
70
+ if (output.resolve() != output or not any(output.is_relative_to(root) for root in (ROOT / "tmp", Path("/tmp/opencode")))
71
+ or not output.parent.is_dir()):
72
+ raise ValueError("Select a new non-symlink export directory under factory tmp/ or /tmp/opencode/")
73
+ if os.path.lexists(output):
74
+ raise FileExistsError("Runtime archive delivery already exists")
75
+ if not immutable_reference(controller_reference) or not IMAGE_ID.fullmatch(controller_id) or not IMAGE_ID.fullmatch(pdf_id):
76
+ raise ValueError("Select immutable image references and full expected configuration IDs")
77
+ if not re.fullmatch(r"(?:[0-9a-f]{40}|sha256:[0-9a-f]{64})", controller_source):
78
+ raise ValueError("Controller source must be its exact Git or sealed snapshot identity")
79
+ if bool(controller_archive) != bool(controller_archive_sha256):
80
+ raise ValueError("Reusing a controller archive requires its exact SHA-256")
81
+ contract = distribution_contract()
82
+ pdf_reference = contract["consumer_integration"]["manual_pdf_image"]
83
+ producers = [row["producer_sha"] for row in contract["deployment_contract"]["manual_pdf_workers"] if row["reference"] == pdf_reference]
84
+ if len(producers) != 1:
85
+ raise ValueError("Selected PDF needs one provider-owned producer record")
86
+ selected = {"controller": image(controller_reference, controller_id), "manual_pdf": image(pdf_reference, pdf_id)}
87
+ if selected["controller"]["source_revision"] != controller_source or selected["manual_pdf"]["source_revision"] != producers[0]:
88
+ raise ValueError("Prepared controller/PDF does not match its own producer")
89
+ if controller_archive:
90
+ controller_archive = Path(controller_archive)
91
+ if controller_archive.is_symlink() or sha256(controller_archive) != controller_archive_sha256:
92
+ raise ValueError("Retained controller archive hash differs")
93
+ verify_archive(controller_archive, controller_id, controller_source)
94
+ if shutil.disk_usage(output.parent).free < sum(item["size"] for item in selected.values()) + 256*1024*1024:
95
+ raise RuntimeError("Insufficient space for the bounded archive export")
96
+ output.mkdir(mode=0o755)
97
+ exported = {}
98
+ for role, observed in selected.items():
99
+ destination = output / ("controller.docker.tar.gz" if role == "controller" else "manual-pdf.docker.tar.gz")
100
+ if role == "controller" and controller_archive:
101
+ with controller_archive.open("rb") as source, destination.open("xb") as target:
102
+ shutil.copyfileobj(source, target, 1024*1024)
103
+ target.flush()
104
+ os.fsync(target.fileno())
105
+ if sha256(destination) != controller_archive_sha256:
106
+ raise ValueError("Copied controller archive differs")
107
+ record = {"path": destination.name, "sha256": controller_archive_sha256, "bytes": destination.stat().st_size,
108
+ "format": "docker-save-gzip", "reused_exact_bytes": True}
109
+ else:
110
+ record = export_image(observed["image_id"], destination)
111
+ archive_identity = verify_archive(destination, observed["image_id"], observed["source_revision"])
112
+ exported[role] = {"archive": record, "image": observed, "archive_identity": archive_identity,
113
+ "offline_selection": {"reference": observed["image_id"], "expected_image_id": observed["image_id"]}}
114
+ manifest = {"schema_version": 1, "kind": "unaltraweb-practice-runtime-archives", "published": False, "images": exported,
115
+ "loading": "Existing hub H2 Docker-save loader; no Web loader or alias mutation",
116
+ "digest_note": "Registry digests are pre-export observations, not a claim that docker load restores RepoDigests."}
117
+ write_new(output / "runtime-archives.json", manifest)
118
+ files = [{"path": path.name, "sha256": sha256(path), "bytes": path.stat().st_size} for path in sorted(output.iterdir())]
119
+ write_new(output / "ready.json", {"schema_version": 1, "kind": "unaltraweb-runtime-archives-ready", "ready": True,
120
+ "manifest": "runtime-archives.json", "files": files, "published": False})
121
+ for row in files:
122
+ if sha256(output / row["path"]) != row["sha256"]:
123
+ raise ValueError("Runtime archive delivery changed before handoff")
124
+ return {"ok": True, "ready": str(output / "ready.json"), "sha256": sha256(output / "ready.json"), "images": exported}
125
+
126
+
127
+ if __name__ == "__main__":
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("--output", type=Path, required=True)
130
+ parser.add_argument("--controller-image", required=True)
131
+ parser.add_argument("--controller-id", required=True)
132
+ parser.add_argument("--controller-source", required=True)
133
+ parser.add_argument("--pdf-id", required=True)
134
+ parser.add_argument("--controller-archive", type=Path)
135
+ parser.add_argument("--controller-archive-sha256")
136
+ args = parser.parse_args()
137
+ print(json.dumps(prepare(args.output, args.controller_image, args.controller_id, args.controller_source, args.pdf_id,
138
+ controller_archive=args.controller_archive, controller_archive_sha256=args.controller_archive_sha256), indent=2))
@@ -0,0 +1,36 @@
1
+ -- Consume the core caption filter's output, preserving its short captions/credits.
2
+ local continuation = "continued"
3
+ local function metadata(meta)
4
+ continuation = pandoc.utils.stringify(meta["continuation-label"] or "continued")
5
+ end
6
+
7
+ local function figures(blocks)
8
+ local result, figure = pandoc.List(), nil
9
+ for _, block in ipairs(blocks) do
10
+ if block.t == "RawBlock" and block.text == "\\begin{figure}[H]\n\\centering" then
11
+ assert(not figure, "Nested practice figure")
12
+ figure = pandoc.List()
13
+ elseif figure and block.t == "RawBlock" and block.text == "\\end{figure}" then
14
+ assert(#figure == 2, "Practice figures require one caption and one image")
15
+ local caption = pandoc.write(pandoc.Pandoc({figure[1]}), "latex"):gsub("%s+$", "")
16
+ local image = pandoc.write(pandoc.Pandoc({figure[2]}), "latex"):gsub("%s+$", "")
17
+ result:insert(pandoc.RawBlock("latex", "\\PracticeFigure{" .. caption .. "}{" .. image .. "}"))
18
+ figure = nil
19
+ elseif figure then figure:insert(block)
20
+ else result:insert(block) end
21
+ end
22
+ assert(not figure, "Unclosed practice figure")
23
+ return result
24
+ end
25
+
26
+ local function table_caption(element)
27
+ assert(#element.caption.long > 0, "Practice tables require a Table: caption")
28
+ local rendered = pandoc.write(pandoc.Pandoc({element}), "latex")
29
+ local caption = pandoc.write(pandoc.Pandoc(element.caption.long), "latex"):gsub("%s+$", "")
30
+ rendered = rendered:gsub("\\endfirsthead", function()
31
+ return "\\endfirsthead\n\\caption[]{" .. caption .. " (" .. continuation .. ")}\\tabularnewline"
32
+ end, 1)
33
+ return pandoc.RawBlock("latex", rendered)
34
+ end
35
+
36
+ return {{Meta = metadata}, {Blocks = figures, Table = table_caption}}
@@ -0,0 +1,84 @@
1
+ -- Private practice PDFs use their own counters, pagination and image policy.
2
+ local assets = {}
3
+
4
+ local function metadata(meta)
5
+ for original, target in pairs(meta["practice-assets"] or {}) do
6
+ assets[original] = pandoc.utils.stringify(target)
7
+ end
8
+ end
9
+
10
+ local function image(element)
11
+ local target = assets[element.src]
12
+ assert(target, "Undeclared practice image: " .. element.src)
13
+ element.src = target
14
+ return element
15
+ end
16
+
17
+ local function figure(element)
18
+ assert(#element.content == 1 and element.content[1].content
19
+ and #element.content[1].content == 1 and element.content[1].content[1].t == "Image",
20
+ "Practice figures require one standalone image")
21
+ local picture = element.content[1].content[1]
22
+ picture.attributes.width = nil
23
+ picture.attributes.height = nil
24
+ if picture.title ~= "" then
25
+ element.caption.long = pandoc.read(picture.title, "markdown").blocks
26
+ end
27
+ assert(#element.caption.long > 0, "Practice figures require a caption")
28
+ return element
29
+ end
30
+
31
+ local function line_count(text)
32
+ local _, count = text:gsub("\n", "")
33
+ return count + 1
34
+ end
35
+
36
+ local function blocks(values)
37
+ local result = pandoc.List()
38
+ for index, element in ipairs(values) do
39
+ local needed = nil
40
+ if element.t == "Header" then
41
+ needed = 6
42
+ local following = values[index + 1]
43
+ if following and following.t == "Table" then needed = 9 end
44
+ if following and following.t == "CodeBlock" and line_count(following.text) <= 12 then
45
+ needed = line_count(following.text) + 7
46
+ end
47
+ elseif element.t == "CodeBlock" and line_count(element.text) <= 12 then
48
+ needed = line_count(element.text) + 4
49
+ end
50
+ if needed then
51
+ result:insert(pandoc.RawBlock("latex", "\\Needspace{" .. needed .. "\\baselineskip}"))
52
+ end
53
+ result:insert(element)
54
+ end
55
+ return result
56
+ end
57
+
58
+ local function table_widths(element)
59
+ if #element.colspecs < 3 then return nil end
60
+ for _, column in ipairs(element.colspecs) do
61
+ if column[2] and column[2] ~= 0 then return nil end
62
+ end
63
+ local weights, counts, longest = {}, {}, {}
64
+ for index in ipairs(element.colspecs) do weights[index], counts[index], longest[index] = 0, 0, 0 end
65
+ for _, body in ipairs(element.bodies) do
66
+ for _, row in ipairs(body.body) do
67
+ for index, cell in ipairs(row.cells) do
68
+ local text = pandoc.utils.stringify(cell.contents)
69
+ weights[index] = weights[index] + utf8.len(text)
70
+ counts[index] = counts[index] + 1
71
+ for word in text:gmatch("%S+") do longest[index] = math.max(longest[index], utf8.len(word)) end
72
+ end
73
+ end
74
+ end
75
+ local total = 0
76
+ for index in ipairs(weights) do
77
+ weights[index] = math.max(12, longest[index] * 0.9, math.min(90, weights[index] / math.max(1, counts[index]) * 0.65))
78
+ total = total + weights[index]
79
+ end
80
+ for index, weight in ipairs(weights) do element.colspecs[index] = {element.colspecs[index][1], weight / total} end
81
+ return element
82
+ end
83
+
84
+ return {{Meta = metadata}, {Image = image}, {Figure = figure, Table = table_widths}, {Blocks = blocks}}
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ """Consumer-bound entry point used by the factory Make contract."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ FACTORY = Path(__file__).resolve().parents[2]
10
+ sys.path.insert(0, str(FACTORY / "src"))
11
+ from unaltraweb_mcp.distribution import consumer_integration
12
+ from unaltraweb_mcp.practice_pdf import build
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--project", required=True)
18
+ args = parser.parse_args()
19
+ try:
20
+ result = build(Path(args.project), FACTORY, os.environ.get("PRACTICE_SOURCE", ""),
21
+ os.environ.get("PRACTICE_VERSION", ""), run=os.environ.get("PRACTICE_RUN", ""),
22
+ image=os.environ.get("MANUAL_PDF_IMAGE") or consumer_integration()["manual_pdf_image"],
23
+ dry_run=os.environ.get("PRACTICE_DRY_RUN", "0") == "1")
24
+ except (OSError, ValueError, RuntimeError) as exc:
25
+ result = {"ok": False, "publishes": False, "error": str(exc)}
26
+ print(json.dumps(result, ensure_ascii=False, indent=2))
27
+ return 0 if result["ok"] else 1
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Run only inside the bounded PDF toolchain with /source readonly and /out writable."""
3
+ import json
4
+ from pathlib import Path
5
+ import re
6
+ import subprocess
7
+ from urllib.parse import unquote, urlsplit
8
+
9
+ SOURCE = Path("/source")
10
+ OUTPUT = Path("/out")
11
+ MARKDOWN = "markdown+fenced_divs+pipe_tables+link_attributes-raw_tex-raw_html"
12
+
13
+
14
+ def run(command, log):
15
+ subprocess.run(command, cwd=OUTPUT, check=True, stdout=log, stderr=subprocess.STDOUT)
16
+
17
+
18
+ def effective_source(request, log):
19
+ """Apply the controller's authorised links to the actual Pandoc AST.
20
+
21
+ This is a lookup/fail-closed rendering pass, not another filesystem resolver.
22
+ The exact effective AST/Markdown are retained with the render artifacts.
23
+ """
24
+ assert request.get("portable_links") == "portable-links.json"
25
+ raw = (SOURCE / "portable-links.json").read_bytes()
26
+ if len(raw) > 1024*1024:
27
+ raise ValueError("Portable link context exceeds its bound")
28
+ links = json.loads(raw)
29
+ assert links["kind"] == "unaltraweb-practice-reading-links" and links["schema_version"] == 1
30
+ mapping = {entry["href"]: entry["target"] for entry in links["links"]}
31
+ run(["pandoc", str(SOURCE / "source.md"), "--from="+MARKDOWN, "--to=json", "--output=source.parsed.json"], log)
32
+ parsed = OUTPUT / "source.parsed.json"
33
+ if parsed.stat().st_size > 16*1024*1024:
34
+ raise ValueError("Practice AST exceeds its bound")
35
+ document = json.loads(parsed.read_bytes())
36
+ pending, count, applied = [(document, 0)], 0, []
37
+ while pending:
38
+ value, depth = pending.pop()
39
+ count += 1
40
+ if count > 200000 or depth > 128:
41
+ raise ValueError("Practice AST exceeds its structural bound")
42
+ if isinstance(value, dict):
43
+ if value.get("t") == "Link":
44
+ target = value["c"][-1]
45
+ href = target[0]
46
+ uri = urlsplit(href)
47
+ if href in mapping:
48
+ target[0] = mapping[href]
49
+ applied.append({"source_href": href, "target_href": target[0]})
50
+ elif not href.startswith("#") and uri.scheme.lower() not in {"https", "http", "mailto"}:
51
+ raise ValueError("Pandoc found a local/unsupported reading link without an authorised portable mapping: " + href)
52
+ pending.extend((child, depth+1) for child in value.values())
53
+ elif isinstance(value, list):
54
+ pending.extend((child, depth+1) for child in value)
55
+ effective = OUTPUT / "source.effective.json"
56
+ effective.write_text(json.dumps(document, ensure_ascii=False, sort_keys=True)+"\n")
57
+ run(["pandoc", str(effective), "--from=json", "--to=markdown", "--wrap=none", "--output=source.effective.md"], log)
58
+ return links, applied
59
+
60
+
61
+ def check_pdf_links(pdf, context):
62
+ allowed = {row["destination"] for row in context["targets"].values()}
63
+ observed = []
64
+ for page_number, page in enumerate(pdf.pages, 1):
65
+ for reference in page.get("/Annots", []):
66
+ annotation = reference.get_object()
67
+ if annotation.get("/Subtype") != "/Link" or annotation.get("/A") is None:
68
+ continue
69
+ action = annotation["/A"].get_object()
70
+ kind = str(action.get("/S"))
71
+ if kind == "/GoTo":
72
+ continue
73
+ if kind not in {"/URI", "/GoToR", "/Launch"}:
74
+ raise ValueError("Unsupported action in a reading link")
75
+ value = action.get("/URI") if kind == "/URI" else action.get("/F")
76
+ if hasattr(value, "get_object"):
77
+ value = value.get_object()
78
+ if isinstance(value, dict):
79
+ value = value.get("/UF") or value.get("/F")
80
+ if not isinstance(value, str):
81
+ raise ValueError("Reading link has no bounded file/URI target")
82
+ uri = urlsplit(value)
83
+ if uri.scheme.lower() in {"https", "http", "mailto"} or value.startswith("#"):
84
+ continue
85
+ if uri.scheme or uri.netloc or uri.query or unquote(uri.path, errors="strict") not in allowed:
86
+ raise ValueError("Final PDF contains a local target outside its portable recipient selection: " + value)
87
+ observed.append({"page": page_number, "kind": kind, "target": value})
88
+ return observed
89
+
90
+
91
+ def main():
92
+ request = json.loads((SOURCE / "request.json").read_text())
93
+ basename = request["basename"]
94
+ assert basename in {"LLEGIU-ME", "LEEME", "README"}
95
+ filters = request["filters"]
96
+ assert filters == ["practice-layout.lua", "code-blocks.lua", "figure-captions.lua", "practice-finish.lua"]
97
+ with (OUTPUT / "render.log").open("x") as log:
98
+ context, applied = effective_source(request, log)
99
+ command = ["pandoc", str(OUTPUT / "source.effective.json"), "--standalone", "--from=json",
100
+ "--number-sections", "--top-level-division=section", "--metadata-file=/source/metadata.yml",
101
+ "--template=/source/practice.tex", "--resource-path=/source", "-t", "latex", "-o", "practice.tex"]
102
+ for name in filters:
103
+ command.append("--lua-filter=/source/" + name)
104
+ run(command, log)
105
+ for _ in range(3):
106
+ run(["xelatex", "-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error", "practice.tex"], log)
107
+ run(["qpdf", "--deterministic-id", "--object-streams=generate", "--recompress-flate",
108
+ "--compression-level=9", "practice.pdf", basename + ".pdf"], log)
109
+ run(["pdftoppm", "-f", "1", "-singlefile", "-png", "-r", "110", basename + ".pdf", "preview"], log)
110
+ latex_log = (OUTPUT / "practice.log").read_text(errors="replace")
111
+ diagnostics = [line for line in latex_log.splitlines() if re.search(r"Overfull|Underfull|Warning:|Missing character", line)]
112
+ from pypdf import PdfReader
113
+ pdf = PdfReader(OUTPUT / (basename + ".pdf"))
114
+ if not pdf.pages or any(float(page.mediabox.width) < float(page.mediabox.height) for page in pdf.pages):
115
+ raise ValueError("Practice PDF must contain landscape pages")
116
+ local_links = check_pdf_links(pdf, context)
117
+ report = {"pages": len(pdf.pages), "diagnostics": diagnostics,
118
+ "portable_link_context": context["context"], "applied_links": applied, "local_pdf_links": local_links,
119
+ "review_required": True, "publishes": False}
120
+ (OUTPUT / "render-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
121
+ print(json.dumps(report, ensure_ascii=False))
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,95 @@
1
+ % Article wrapper appended to the factory-owned manual typographic preamble.
2
+ \usepackage{fancyhdr}
3
+ \usepackage{lastpage}
4
+ \pagestyle{fancy}
5
+ \fancyhf{}
6
+ \fancyhead[L]{\small\sffamily\color{ManualMuted}$course-short-title$}
7
+ \fancyhead[R]{\small\sffamily\color{ManualMuted}$short-title$}
8
+ \fancyfoot[L]{\footnotesize\sffamily\color{ManualMuted}v$practice-version$ · $lang$}
9
+ \fancyfoot[C]{\footnotesize\sffamily\color{ManualMuted}$if(draft)$$draft-label$$endif$}
10
+ \fancyfoot[R]{\footnotesize\sffamily\color{ManualMuted}\thepage/\pageref*{LastPage}}
11
+ \renewcommand{\headrulewidth}{0.2pt}
12
+ \renewcommand{\footrulewidth}{0pt}
13
+ \fancypagestyle{practicefirst}{\fancyhead{}\renewcommand{\headrulewidth}{0pt}}
14
+ \titleformat{\section}{\normalfont\sffamily\Large\bfseries\color{ManualSecondary}}{\thesection}{0.55em}{}
15
+ \titleformat{\subsection}{\normalfont\large\bfseries\color{ManualSecondary}}{\thesubsection}{0.6em}{}
16
+ \titlespacing*{\section}{0pt}{1.1em}{0.45em}
17
+ \titlespacing*{\subsection}{0pt}{0.9em}{0.35em}
18
+ \setcounter{secnumdepth}{2}
19
+ \setlength{\emergencystretch}{3em}
20
+ \setlist[enumerate]{before=\raggedright}
21
+ \setlength{\LTleft}{\fill}
22
+ \setlength{\LTright}{\fill}
23
+ \setlength{\LTcapwidth}{\textwidth}
24
+ \captionsetup[figure]{format=plain,justification=centering,singlelinecheck=false,margin=0pt,aboveskip=0pt,belowskip=0pt}
25
+ \captionsetup[table]{format=plain,justification=centering,singlelinecheck=false,margin=0pt,skip=14pt}
26
+
27
+ % Measure the caption once, then fit the image to the remaining text rectangle.
28
+ % The complete unit moves to the next page instead of shrinking into a page tail.
29
+ \newsavebox{\PracticeCaptionBox}
30
+ \newsavebox{\PracticeImageBox}
31
+ \newsavebox{\PracticeFigureBox}
32
+ \newlength{\PracticeImageHeight}
33
+ \newlength{\PracticeFigureHeight}
34
+ \newlength{\PracticeCaptionGap}
35
+ \setlength{\PracticeCaptionGap}{14pt}
36
+ \newcommand{\PracticeFigure}[2]{%
37
+ \par\begingroup
38
+ \setlength{\parskip}{0pt}%
39
+ \setbox\PracticeCaptionBox=\vbox{\hsize=\linewidth\captionsetup{type=figure}#1\par}%
40
+ \setlength{\PracticeImageHeight}{\dimexpr\textheight-\ht\PracticeCaptionBox-\dp\PracticeCaptionBox-\PracticeCaptionGap-2pt\relax}%
41
+ \ifdim\PracticeImageHeight<\baselineskip
42
+ \PackageError{unaltraweb-practice}{Figure caption exceeds the usable page height}{Shorten the caption or split the figure.}%
43
+ \fi
44
+ \setkeys{Gin}{width=\linewidth,height=\PracticeImageHeight,keepaspectratio}%
45
+ \sbox{\PracticeImageBox}{#2}%
46
+ \setbox\PracticeFigureBox=\vbox{%
47
+ \hsize=\linewidth\offinterlineskip
48
+ \hbox to\linewidth{\hfil\usebox{\PracticeCaptionBox}\hfil}%
49
+ \vskip\PracticeCaptionGap
50
+ \hbox to\linewidth{\hfil\usebox{\PracticeImageBox}\hfil}%
51
+ }%
52
+ \setlength{\PracticeFigureHeight}{\dimexpr\ht\PracticeFigureBox+\dp\PracticeFigureBox+2pt\relax}%
53
+ \Needspace{\PracticeFigureHeight}%
54
+ \nointerlineskip\usebox{\PracticeFigureBox}\par
55
+ \endgroup\addvspace{1em}%
56
+ }
57
+
58
+ \begin{document}
59
+ \thispagestyle{practicefirst}
60
+ {\setlength{\parskip}{0pt}%
61
+ \noindent
62
+ \begin{minipage}[c]{0.65\linewidth}
63
+ $if(series-logo)$
64
+ \begin{minipage}[c]{17mm}
65
+ \setlength{\fboxsep}{2mm}%
66
+ \colorbox{ManualBand}{\includegraphics[width=13mm,height=10.4mm,keepaspectratio]{$series-logo$}}%
67
+ \end{minipage}%
68
+ \hspace{3mm}%
69
+ $endif$
70
+ \begin{minipage}[c]{0.76\linewidth}
71
+ {\sffamily\bfseries\color{ManualPrimary}$series$}\par
72
+ {\small\sffamily\color{ManualMuted}$audience-label$}\par
73
+ \end{minipage}
74
+ \end{minipage}%
75
+ \hfill
76
+ \begin{minipage}[c]{0.32\linewidth}
77
+ $if(cover-logo)$\raggedleft\includegraphics[width=58mm,height=26mm,keepaspectratio]{$cover-logo$}$endif$
78
+ \end{minipage}\par
79
+ \vspace{4mm}
80
+ {\small\sffamily\bfseries\color{ManualSecondary}$subject$$if(subject-codes)$ ($subject-codes$)$endif$}\par
81
+ \vspace{1mm}
82
+ {\sffamily\fontsize{9.2}{11.6}\selectfont\color{ManualMuted}%
83
+ $for(teaching-guides)$
84
+ $teaching-guides.degree$\par
85
+ $endfor$
86
+ $if(institution-line)$$institution-line$\par$endif$
87
+ $if(author)$$author$ · $endif$$practice-version-label$ $practice-version$\par
88
+ }
89
+ \vspace{2mm}{\color{ManualSecondary!40}\hrule}
90
+ }
91
+ \vspace{4mm}
92
+ {\LARGE\sffamily\bfseries\color{ManualSecondary}$title$}\par
93
+ \vspace{1mm}
94
+ $body$
95
+ \end{document}