Files
tt-support-tools/doc_utils.py
T

513 lines
19 KiB
Python

import logging
import os
import re
import subprocess
from typing import Optional
import chevron
import matplotlib as mpl
from config import Config, DatasheetConfig
class DocsHelper:
@staticmethod
def pretty_address(address: int, subtile_address: Optional[int] = None) -> str:
"""
Format the address and subtile address (if applicable) into an nice string for the typst template
"""
content = str(address).rjust(4, "-")
if subtile_address is not None:
content += f"/{subtile_address}"
return content
@staticmethod
def pretty_clock(clock: int) -> str:
"""
Format the clock with engineering notation
"""
if clock == 0:
return "No Clock"
else:
formatter = mpl.ticker.EngFormatter(sep=" ")
# [clock, SI suffix]
hz_as_eng = formatter(clock).split(" ")
if len(hz_as_eng) == 2:
return f"{hz_as_eng[0]} {hz_as_eng[1]}Hz"
elif len(hz_as_eng) == 1:
return f"{hz_as_eng[0]} Hz"
else:
raise RuntimeError(
"unexpected amount of entries when formatting clock for datasheet"
)
@staticmethod
def format_authors(authors: str) -> str:
"""
Format the authors properly
Split the authors with a set of delimiters, then repackage as a typst array for the template
"""
split_authors = re.split(r"[,|;|+]| and | & ", authors)
formatted_authors = []
for author in split_authors:
stripped = author.strip()
if stripped == "":
continue
# typst needs a trailing comma if len(array) == 1, so that it doesn't interpret it as an expression
# https://typst.app/docs/reference/foundations/array/
formatted_authors.append(f'"{stripped}",')
return "".join(formatted_authors)
@staticmethod
def _escape_characters_in_content(text: str) -> str:
"""
Helper function to escape certain characters in content blocks
"""
return (
text.replace("[", "\\[")
.replace("]", "\\]")
.replace("/", "\\/")
.replace("#", "\\#")
)
@staticmethod
def _escape_characters_in_string(text: str) -> str:
"""
Helper function to escape characters in strings
- Escapes double quotation mark
- Removes newlines, carrige returns
"""
return text.replace('"', '\\"').replace("\n", "").replace("\r", "")
@staticmethod
def format_digital_pins(pins: dict) -> str:
"""
Iterate through and create the digital pin table as a string
"""
pin_table = ""
for pin in pins:
ui_text = DocsHelper._escape_characters_in_content(pin["ui"])
uo_text = DocsHelper._escape_characters_in_content(pin["uo"])
uio_text = DocsHelper._escape_characters_in_content(pin["uio"])
pin_table += (
f"[`{pin['pin_index']}`], [{ui_text}], [{uo_text}], [{uio_text}],\n"
)
return pin_table
@staticmethod
def format_analog_pins(pins: dict):
"""
Iterate through and create the analog pin table as a string
"""
pin_table = ""
for pin in pins:
desc_text = DocsHelper._escape_characters_in_content(pin["desc"])
pin_table += (
f"[`{pin['ua_index']}`], [`{pin['analog_index']}`], [{desc_text}],\n"
)
return pin_table
@staticmethod
def get_docs_as_typst(path: str) -> str:
"""
Run pandoc to convert a given file to typst
"""
# enable/disable certain extensions
# see all with `pandoc --list-extensions=markdown`
# documentation at https://pandoc.org/MANUAL.html
# in general, these extensions should offer some additional formatting options whilst still
# remaining similar to github flavoured markdown
pandoc_extensions = [
"-auto_identifiers",
"+autolink_bare_uris",
"-citations",
"-definition_lists",
"+emoji",
"-example_lists",
"-fancy_lists",
"+lists_without_preceding_blankline",
"-fenced_divs",
]
pandoc_command = [
"pandoc",
path,
"--shift-heading-level-by=-1",
"-f",
f"markdown{''.join(pandoc_extensions)}",
"-t",
"typst-citations",
"--wrap=preserve",
]
logging.info(pandoc_command)
result = subprocess.run(pandoc_command, capture_output=True)
if result.stderr != b"":
logging.warning(result.stderr.decode())
return result.stdout.decode()
@staticmethod
def get_project_type(language: str, is_wokwi: bool, is_analog: bool) -> str:
if is_wokwi:
return "Wokwi"
elif is_analog:
return "Analog"
else:
return "HDL"
@staticmethod
def format_project_info(yaml_info: dict, index_info: dict) -> dict:
"""
Merge two sources of project information into one dictionary
Accepts `info.yaml` from the project root, and project information from the tapeout index.
Using these two sources, the function will pick and merge the required information for the datasheet.
"""
info = {
"title": yaml_info["project"]["title"],
"author": yaml_info["project"]["author"],
"description": yaml_info["project"]["description"],
"address": index_info["address"],
"clock_hz": index_info["clock_hz"],
"git_url": index_info["repo"],
"language": yaml_info["project"]["language"],
"macro": index_info["macro"],
"is_analog": len(index_info["analog_pins"]) > 0,
"is_wokwi": True if "wokwi_id" in yaml_info["project"] else False,
"type": "project",
}
if info["is_wokwi"]:
info["wokwi_id"] = yaml_info["project"]["wokwi_id"]
try:
info["pins"] = [
{
"pin_index": str(i),
"ui": index_info["pinout"][f"ui[{i}]"],
"uo": index_info["pinout"][f"uo[{i}]"],
"uio": index_info["pinout"][f"uio[{i}]"],
}
for i in range(8)
]
except KeyError as e:
logging.info(
f"project is missing a pin entry ({e}), falling back to info.yaml (is this a subtile?)"
)
info["pins"] = [
{
"pin_index": str(i),
"ui": yaml_info["pinout"][f"ui[{i}]"],
"uo": yaml_info["pinout"][f"uo[{i}]"],
"uio": yaml_info["pinout"][f"uio[{i}]"],
}
for i in range(8)
]
if info["is_analog"]:
info["analog_pins"] = [
{
"ua_index": str(i),
"analog_index": index_info["analog_pins"][i],
"desc": index_info["pinout"][f"ua[{i}]"],
}
for i in range(len(index_info["analog_pins"]))
]
if "type" in index_info:
if index_info["type"] == "subtile":
info["type"] = "subtile"
info["subtile_addr"] = index_info["subtile_addr"]
info["subtile_group"] = index_info["subtile_group"]
elif index_info["type"] == "group":
info["type"] = "group"
return info
@staticmethod
def normalise_project_info(project) -> dict:
"""
Similar to `DocHelper.format_project_info`, but this accepts a `Project` object and attempts to
normalise it to the tapeout index format that is expected by the `Docs.build_datasheet()` function.
To help visualise it, the output of this function would be fed to the `index_info` parameter of
the `DocsHelper.format_project_info()`.
"""
info = {
"title": project.info.title,
"author": project.info.author,
"description": project.info.description,
"address": project.mux_address,
"clock_hz": project.info.clock_hz,
"repo": project.git_url,
"language": project.info.language,
"macro": project.info.top_module,
"is_analog": project.is_analog_design(),
"is_wokwi": project.is_wokwi(),
"type": "project",
}
if project.is_wokwi():
info["wokwi_id"] = project.info.wokwi_id
info["pinout"] = {}
for i in range(8):
info["pinout"].update({f"ui[{i}]": project.info.pinout.ui[i]})
info["pinout"].update({f"uo[{i}]": project.info.pinout.uo[i]})
info["pinout"].update({f"uio[{i}]": project.info.pinout.uio[i]})
if project.is_analog_design():
info["analog_pins"] = project.analog_pins
for i, desc in enumerate(project.info.pinout.ua):
info["pinout"].update({f"ua[{i}]": desc})
else:
info["analog_pins"] = []
return info
@staticmethod
def write_doc(path: str, template: str, content: dict) -> None:
try:
with open(path, "w") as f:
f.write(chevron.render(template, content))
except FileNotFoundError:
logging.warning(
f"unable to write to {path}... the project exists in tapeout index, but not in local directory? skipping"
)
@staticmethod
def populate_template_tags(
info: dict, danger_info: dict, docs: str, template_version="1.0.0"
) -> dict:
"""
Populate the required template fields given information about the project and its danger level
"""
content = {
"template_version": template_version,
"project_title": DocsHelper._escape_characters_in_string(info["title"]),
"project_author": f"({DocsHelper.format_authors(info['author'])})",
"project_repo_link": info["git_url"],
"project_description": DocsHelper._escape_characters_in_string(
info["description"]
),
"project_address": DocsHelper.pretty_address(info["address"]),
"project_clock": DocsHelper.pretty_clock(info["clock_hz"]),
"project_type": DocsHelper.get_project_type(
info["language"], info["is_wokwi"], info["is_analog"]
),
"project_doc_body": docs,
"digital_pins": DocsHelper.format_digital_pins(info["pins"]),
}
if info["macro"] in danger_info:
content["is_dangerous"] = True
content["project_danger_level"] = danger_info[info["macro"]]["level"]
content["project_danger_reason"] = danger_info[info["macro"]]["reason"]
if info["type"] == "subtile":
content["project_address"] = DocsHelper.pretty_address(
info["address"], info["subtile_addr"]
)
if info["is_wokwi"]:
content["is_wokwi"] = True
content["project_wokwi_id"] = info["wokwi_id"]
if info["is_analog"]:
content["is_analog"] = True
content["analog_pins"] = DocsHelper.format_analog_pins(info["analog_pins"])
return content
@staticmethod
def configure_datasheet(
shuttle_config: Config, datasheet_template: str, template_version: str = "1.0.0"
) -> None:
"""
Prepare the datasheet.typ file with info from `config.yaml`
The following keys can be used to configure the datasheet:
- `pinout`: set what pinout table is shown in multiplexer chapter (see DatasheetConfig.pinout in config.py)
- `theme_override_colour`: set a custom colour for the datasheet (rgb object)
- `show_chip_viewer`*: toggle the chip viewer QR code (boolean)
- `link_disable_colour`: disable link colouring (boolean)
- `link_override_colour`: set a custom link colour (rgb object)
- `qrcode_follows_theme`: colour template-generated QR codes with the main theme colour (boolean)
- `include`*: a list of additional `.typ` files to include - they are copied into the body of `datasheet.typ`
The keys correspond to the arguments available in the template, except those marked with an asterisk.
Example usage (in `config.yaml`):
```yaml
datasheet_config:
pinout: caravel
theme_override_colour: tt.colours.THEME_TT06_PINK
link_override_colour: rgb("#a5415c")
include:
- doc/chip_map.typ
- doc/funding.typ
```
There are other keys which are *not* used to configure the datasheet, but instead control the content of it:
- `disabled`: a list of projects which are excluded from the datasheet, mainly due to compilation issues
- `artwork`: a list of key pairs that specify which artwork is added in the projects section (order sensitive)
The `artwork` key pairs consist of an `ID` and a `rotate` value that specify which image to use and its rotation:
```yaml
datasheet_config:
disabled:
- tt_um_project_1
- tt_um_project_2
artwork:
- {id: photo1, rotate: 90deg}
- {id: photo2, rotate: -90deg}
```
"""
logging.info("configuring datasheet.typ")
content = {
"template_version": template_version,
"shuttle_name": shuttle_config["name"],
"shuttle_id": shuttle_config["id"],
"shuttle_id_upper": shuttle_config["id"].upper(),
"if_chip_viewer": True,
# themeing
"qrcode_follows_theme": "false",
"link_disable_colour": "false",
}
# get repo link
git_repo_link_cmd = ["git", "config", "--get", "remote.origin.url"]
logging.info(git_repo_link_cmd)
result = subprocess.run(git_repo_link_cmd, capture_output=True)
git_repo_link = result.stdout.decode().strip()
content["repo_link"] = git_repo_link
if "datasheet_config" in shuttle_config:
datasheet_config = shuttle_config["datasheet_config"]
if datasheet_config is None:
logging.warning(
"datasheet config specified in config.yaml but has no entries"
)
else:
content["if_pinout"] = True
if "pinout" in datasheet_config:
content["pinout"] = shuttle_config["datasheet_config"]["pinout"]
logging.info(
f"using pinout config verbatim ('{content['pinout']}')"
)
else:
# determine appropriate pinout table from pdk first, otherwise fall back to shuttle id
if "pdk" in shuttle_config:
match shuttle_config["pdk"]:
case "sky130A":
content["pinout"] = "openframe_sky130"
case "ihp-sg13g2":
content["pinout"] = "customframe_ihp_sg13g2"
case "gf180mcuD":
content["pinout"] = "customframe_gf180mcud"
case _:
logging.warning(
f"unable to determine appropriate pinout from PDK, falling back to 'openframe_sky130'"
)
content["pinout"] = "openframe_sky130"
logging.info(f"PDK defined, using pinout '{content['pinout']}'")
else:
if shuttle_config["id"].startswith("ttsky"):
content["pinout"] = "openframe_sky130"
elif shuttle_config["id"].startswith("ttihp"):
content["pinout"] = "customframe_ihp_sg13g2"
elif shuttle_config["id"].startswith("ttgf"):
content["pinout"] = "customframe_gf180mcud"
else:
logging.warning(
f"unable to determine appropriate pinout, falling back to 'openframe_sky130'"
)
logging.warning(
f"either define 'pinout' or 'pdk' in shuttle config"
)
content["pinout"] = "openframe_sky130"
logging.info(
f"PDK not defined, using '{content['pinout']}' pinout"
)
if "theme_override_colour" in datasheet_config:
content["if_theme_override_colour"] = True
content["theme_override_colour"] = datasheet_config[
"theme_override_colour"
]
if "show_chip_viewer" in datasheet_config:
content["if_chip_viewer"] = datasheet_config["show_chip_viewer"]
if "link_disable_colour" in datasheet_config:
# convert to string and lowercase because python "False" != typst "false"
content["link_disable_colour"] = str(
datasheet_config["link_disable_colour"]
).lower()
if "link_override_colour" in datasheet_config:
content["if_link_override_colour"] = True
content["link_override_colour"] = datasheet_config[
"link_override_colour"
]
if "qrcode_follows_theme" in datasheet_config:
content["qrcode_follows_theme"] = str(
datasheet_config["qrcode_follows_theme"]
).lower()
if "include" in datasheet_config:
doc_body = []
for path in datasheet_config["include"]:
logging.info(f"including {os.path.abspath(path)}")
with open(os.path.abspath(path)) as f:
doc_body.append(f.read())
content["datasheet_body"] = "\n".join(doc_body)
with open("datasheet.typ", "w") as f:
logging.info("writing datasheet.typ")
f.write(chevron.render(datasheet_template, content))
@staticmethod
def compile(path="datasheet.typ") -> None:
typst_cmd = [
"typst",
"compile",
path,
"datasheet.pdf",
"--root",
".",
"--font-path",
"./tt/docs/typst/resources/fonts",
]
logging.info(typst_cmd)
result = subprocess.call(typst_cmd)
if result != 0:
logging.error("running typst returned non-zero exit code")
exit(1)
@staticmethod
def project_is_disabled(config: DatasheetConfig, macro: str) -> bool:
if config is not None:
if "disabled" in config and config["disabled"] is not None:
return macro in config["disabled"]
return False