Created
November 2, 2025 17:43
-
-
Save AdrianVollmer/07edab2b1fba2747fbf45291ab73d81e to your computer and use it in GitHub Desktop.
Typst program to render a tandoor recipe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Inspired by https://github.com/maxdinech/typst-recipe | |
| // Call like this: | |
| // typst compile tandoor-recipe.typ --input 'data={"recipe": "recipe.json", "image": "image.jpg"}' | |
| #let primary_colour = rgb("#ce1f36") | |
| #let text_colour = rgb("#333") | |
| // Font constants | |
| #let body_font = "Libertinus Serif" | |
| #let title_font = "DejaVu Sans" | |
| #let author_font = "DejaVu Sans" | |
| #let heading_font = "DejaVu Sans" | |
| #let image-height = 15em | |
| #set page( | |
| margin: (x: 54pt, y: 52pt), | |
| numbering: "1", | |
| number-align: right, | |
| fill: rgb("ede8d0"), | |
| ) | |
| #let recipes(title, doc) = { | |
| set text(10pt, font: body_font) | |
| text(fill: primary_colour, font: title_font, size: 30pt, weight: 100, title) | |
| set align(left) | |
| show heading.where(level: 1): it => [ | |
| #pagebreak() | |
| #v(300pt) | |
| #set align(center) | |
| #text( | |
| fill: primary_colour, | |
| font: heading_font, | |
| weight: 300, | |
| size: 20pt, | |
| { it.body }, | |
| ) | |
| #text(" ") | |
| #pagebreak() | |
| ] | |
| doc | |
| } | |
| #let format_ingredient(ingredient) = { | |
| set list(tight: false) | |
| set par(spacing: 0.8em, leading: 0.3em) | |
| let amount = if ingredient.amount > 0 { str(ingredient.amount) } else { "" } | |
| let unit = if ingredient.unit != none { ingredient.unit.name } else { "" } | |
| let food = ingredient.food.name | |
| let note_text = if ingredient.note != none { footnote(ingredient.note) } else { "" } | |
| if amount != "" and unit != "" { | |
| [- #amount #unit #food#note_text] | |
| } else if amount != "" { | |
| [- #amount #food#note_text] | |
| } else { | |
| [- #food#note_text] | |
| } | |
| } | |
| #let display_ingredients_list(ingredients) = { | |
| // panic(ingredients) | |
| // grid( | |
| // columns: (2fr, 1fr), | |
| // gutter: .5em, | |
| // ..ingredients | |
| // .map(it => ( | |
| // it.food.name + { if (it.note != none) { (footnote(it.note)) } else { "" } }, | |
| // str(it.amount) + " " + it.unit.name, | |
| // )) | |
| // .flatten(), | |
| // ) | |
| for ingredient in ingredients { | |
| format_ingredient(ingredient) | |
| } | |
| } | |
| #let display_steps_list(steps) = { | |
| set enum( | |
| numbering: n => text( | |
| fill: primary_colour, | |
| font: heading_font, | |
| size: 14pt, | |
| weight: "bold", | |
| str(n), | |
| ), | |
| ) | |
| for step in steps { | |
| enum.item()[#step.instruction] | |
| } | |
| } | |
| #let display_ingredients(ingredients) = { | |
| emph(display_ingredients_list(ingredients)) | |
| } | |
| #let display_steps(steps) = { | |
| [== Preparation] | |
| set par(justify: true) | |
| set enum(spacing: 1em) | |
| display_steps_list(steps) | |
| } | |
| #let display_pairings(pairings) = { | |
| [== Pairing Suggestions] | |
| emph(pairings) | |
| } | |
| #let recipe( | |
| title: "", | |
| author: "", | |
| description: "", | |
| image_path: "", | |
| servings: 1, | |
| servings_text: "", | |
| working_time: "", | |
| waiting_time: "", | |
| ingredients: (), | |
| steps: [], | |
| remarks: [], | |
| pairings: [], | |
| ) = { | |
| show heading.where(level: 2): it => text( | |
| fill: primary_colour, | |
| font: heading_font, | |
| weight: 300, | |
| size: 11pt, | |
| grid( | |
| columns: (auto, auto), | |
| column-gutter: 5pt, | |
| [#{ upper(it.body) }], | |
| [ | |
| #v(5pt) | |
| #line(length: 100%, stroke: 0.4pt + primary_colour) | |
| ], | |
| ), | |
| ) | |
| { | |
| grid( | |
| columns: (380pt, 100pt), | |
| [ | |
| #text(fill: primary_colour, font: title_font, size: 24pt, weight: 200, upper(title)) | |
| #h(3pt) | |
| #text(fill: text_colour, font: author_font, size: 20pt, author) | |
| #v(0pt) | |
| #emph(description) | |
| ], | |
| [ | |
| #v(2pt) | |
| #set align(right) | |
| #if (working_time != "") { | |
| [_Preparation: #working_time _] | |
| } | |
| #if (waiting_time != "") { | |
| [\ _Waiting: #waiting_time _] | |
| } | |
| ], | |
| ) | |
| // Display image if it exists | |
| if image_path != "" { | |
| context { place(image(image_path, width: page.width, height: image-height), dx: -page.margin.left) } | |
| v(image-height + 2em) | |
| } | |
| grid( | |
| columns: (90pt, 380pt), | |
| column-gutter: 15pt, | |
| [ | |
| #set list(marker: [], body-indent: 0pt) | |
| #set align(right) | |
| #text(fill: primary_colour, font: heading_font, weight: 300, size: 11pt, upper([Ingredients\ ])) | |
| #[#servings servings] | |
| #servings_text | |
| #display_ingredients(ingredients) | |
| ], | |
| [ | |
| #display_steps(steps) | |
| #if remarks != [] { | |
| [== Chef's Tips] | |
| emph(remarks) | |
| } | |
| #if pairings != [] { | |
| display_pairings(pairings) | |
| } | |
| ], | |
| ) | |
| v(30pt) | |
| } | |
| } | |
| #let recipe_from_json(data) = { | |
| let recipe_data = json(data.recipe) | |
| let all_ingredients = () | |
| for step in recipe_data.steps { | |
| for ingredient in step.ingredients { | |
| all_ingredients.push((ingredient)) | |
| } | |
| } | |
| let working_time = if recipe_data.working_time > 0 { str(recipe_data.working_time) + " min" } else { "" } | |
| let waiting_time = if recipe_data.waiting_time > 0 { str(recipe_data.waiting_time) + " min" } else { "" } | |
| // Check if image.jpg exists and include it | |
| let image_path = data.at("image", default: "") | |
| recipe( | |
| title: recipe_data.name, | |
| description: recipe_data.description, | |
| servings: recipe_data.servings, | |
| servings_text: recipe_data.servings_text, | |
| working_time: working_time, | |
| waiting_time: waiting_time, | |
| ingredients: all_ingredients, | |
| steps: recipe_data.steps, | |
| image_path: image_path, | |
| ) | |
| } | |
| #recipe_from_json(json(bytes(sys.inputs.data))) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment