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.
- checksums.yaml +4 -4
- data/Makefile +26 -4
- data/docs/_documentation/en/40-distribution.md +33 -10
- data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
- data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
- data/docs/agents/consolidation-0.7.2.md +97 -0
- data/docs/agents/d0-runtime-identity.md +8 -0
- data/docs/agents/deployment-integration-0.7.1.md +4 -0
- data/docs/agents/manual-authoring-components.md +23 -0
- data/docs/agents/mcp-contract.md +109 -4
- data/docs/agents/owner-closeout-93.md +185 -0
- data/docs/agents/practice-editions-98.md +816 -0
- data/docs/agents/practice-pdf-installable-98.md +250 -0
- data/docs/agents/practice-runtime-archives-98.md +147 -0
- data/docs/agents/private-practice-verifier-98.md +296 -0
- data/docs/agents/qgis-0.6.1-adoption-109.md +110 -0
- data/docs/agents/source-ci-98.md +148 -0
- data/scripts/check_practice_verifier_runtime.py +69 -0
- data/scripts/export_practice_runtime_archives.py +138 -0
- data/scripts/manual/filters/practice-finish.lua +36 -0
- data/scripts/manual/filters/practice-layout.lua +84 -0
- data/scripts/manual/practice_pdf.py +31 -0
- data/scripts/manual/render_practice.py +125 -0
- data/scripts/manual/templates/practice.tex +95 -0
- data/scripts/prepare_practice_candidate.py +159 -0
- data/scripts/prepare_practice_editions_candidate.py +119 -0
- data/scripts/test_wheel_install.py +20 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
- data/scripts/validate_distribution.py +18 -1
- data/src/unaltraweb_mcp/component-contract.json +28 -17
- data/src/unaltraweb_mcp/editorial_sources.py +62 -4
- 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}
|