Files

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