Skip to content
8 changes: 8 additions & 0 deletions backend/api/formula_data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from .physics_ii import FORMULAS as PHYSICS_II, CLASS_NAME as PHYSICS_II_NAME
from .statistics_i import FORMULAS as STATISTICS_I, CLASS_NAME as STATISTICS_I_NAME
from .statistics_ii import FORMULAS as STATISTICS_II, CLASS_NAME as STATISTICS_II_NAME
from .linear_algebra_i import FORMULAS as LINEAR_ALGEBRA_I, CLASS_NAME as LINEAR_ALGEBRA_I_NAME
from .linear_algebra_ii import FORMULAS as LINEAR_ALGEBRA_II, CLASS_NAME as LINEAR_ALGEBRA_II_NAME

AVAILABLE_CLASSES = [
PRE_ALGEBRA_NAME,
Expand All @@ -33,6 +35,8 @@
PHYSICS_II_NAME,
STATISTICS_I_NAME,
STATISTICS_II_NAME,
LINEAR_ALGEBRA_I_NAME,
LINEAR_ALGEBRA_II_NAME,
]

# Classes that have categories (normal structure)
Expand All @@ -50,6 +54,8 @@
PHYSICS_II_NAME,
STATISTICS_I_NAME,
STATISTICS_II_NAME,
LINEAR_ALGEBRA_I_NAME,
LINEAR_ALGEBRA_II_NAME,
]

# Special classes with no categories (single toggle)
Expand All @@ -71,6 +77,8 @@
PHYSICS_II_NAME: PHYSICS_II,
STATISTICS_I_NAME: STATISTICS_I,
STATISTICS_II_NAME: STATISTICS_II,
LINEAR_ALGEBRA_I_NAME: LINEAR_ALGEBRA_I,
LINEAR_ALGEBRA_II_NAME: LINEAR_ALGEBRA_II,
}


Expand Down
20 changes: 20 additions & 0 deletions backend/api/formula_data/linear_algebra_i.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
""" LINEAR ALGEBRA I formulas """
CLASS_NAME = "LINEAR ALGEBRA I"

FORMULAS = {
"Vector Basics": [
{"name": "Vector Addition", "latex": r"\mathbf{u} + \mathbf{v} = \langle u_1+v_1, u_2+v_2 \rangle"},
{"name": "Scalar Multiplication", "latex": r"k\mathbf{u} = \langle ku_1, ku_2 \rangle"},
{"name": "Magnitude (L2 Norm)", "latex": r"\|\mathbf{v}\| = \sqrt{v_1^2 + v_2^2 + \dots + v_n^2}"},
{"name": "Dot Product", "latex": r"\mathbf{u} \cdot \mathbf{v} = u_1v_1 + u_2v_2 + \dots + u_nv_n"},
],
"Matrix Operations": [
{"name": "Matrix Addition", "latex": r"A + B = [a_{ij} + b_{ij}]"},
{"name": "Matrix-Vector Multiplication", "latex": r"A\mathbf{x} = \mathbf{b}"},
{"name": "Transpose", "latex": r"(A^T)_{ij} = A_{ji}"},
],
"2x2 Systems": [
{"name": "2x2 Determinant", "latex": r"\det(A) = ad - bc"},
{"name": "2x2 Inverse", "latex": r"A^{-1} = \frac{1}{ad-bc} \begin{bmatrix} d & -b \\ -c & a \end{bmatrix}"},
]
}
19 changes: 19 additions & 0 deletions backend/api/formula_data/linear_algebra_ii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
""" LINEAR ALGEBRA II formulas """
CLASS_NAME = "LINEAR ALGEBRA II"

FORMULAS = {
"Matrix Properties": [
{"name": "Invertibility Condition", "latex": r"A \text{ is invertible iff } \det(A) \neq 0"},
{"name": "Matrix Multiplication Property", "latex": r"(AB)^{-1} = B^{-1}A^{-1}"},
{"name": "Orthogonality", "latex": r"Q^T Q = I"},
],
"Eigenvalues & Eigenvectors": [
{"name": "Characteristic Equation", "latex": r"\det(A - \lambda I) = 0"},
{"name": "Eigenvector Definition", "latex": r"A\mathbf{v} = \lambda\mathbf{v}"},
],
"Decompositions & Spaces": [
{"name": "Diagonalization", "latex": r"A = PDP^{-1}"},
{"name": "Rank-Nullity Theorem", "latex": r"\text{rank}(A) + \text{nullity}(A) = n"},
{"name": "Projection Formula", "latex": r"\text{proj}_{\mathbf{u}}(\mathbf{v}) = \frac{\mathbf{u} \cdot \mathbf{v}}{\|\mathbf{u}\|^2}\mathbf{u}"},
]
}
38 changes: 24 additions & 14 deletions backend/api/latex_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"large": ("1.2pt", "1.2pt"),
}


FONT_SIZE_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$")
SPACING_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$")
BODY_FONT_COMMAND_PATTERN = re.compile(
Expand All @@ -47,7 +46,7 @@
LEGACY_ANSWER_LABEL_PATTERN = re.compile(r"\\textbf\{Answer:\}\s*")
APP_LAYOUT_COMMENT_LINE_PATTERN = re.compile(r"(?m)^% @cheatsheet-layout .*\n?")
APP_LAYOUT_COMMENT_BLOCK_PATTERN = re.compile(
r"(?m)(?:^% @cheatsheet-layout .*\n){4}^%\n?"
r"(?m)(?:^% @cheatsheet-layout .*\n)+^%\n?"
)


Expand Down Expand Up @@ -130,31 +129,42 @@ def append_text_heading(lines, text):
lines.append(r"\noindent " + text + r"\par")


def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
return [
f"% @cheatsheet-layout columns: {columns} | change layout options up top to update columns",
f"% @cheatsheet-layout font_size: {font_size} | change layout options up top to update text size",
f"% @cheatsheet-layout spacing: {spacing} | change layout options up top to update spacing",
f"% @cheatsheet-layout margins: {margins} | change layout options up top to update margins",
f"% @cheatsheet-layout orientation: {orientation} | change layout options up top to update orientation",
"%",
]


def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""
Build a dynamic LaTeX header based on user-selected options.
"""
size_command = get_body_font_command(font_size)
spacing_values = get_spacing_values(spacing, font_size)
doc_class, doc_class_size = get_document_class(font_size)

# 1. Force the PDF driver to rotate by passing landscape and letterpaper to the document class
doc_options = f"{doc_class_size},fleqn,letterpaper"
if orientation == "landscape":
doc_options += ",landscape"

# 2. Also pass them to the geometry package
geometry_options = f"letterpaper,margin={margins}"
if orientation == "landscape":
geometry_options += ",landscape"
Comment on lines +151 to +159

header_lines = [
f"\\documentclass[{doc_class_size},fleqn]{{{doc_class}}}",
f"\\usepackage[margin={margins}]{{geometry}}",
f"\\documentclass[{doc_options}]{{{doc_class}}}",
f"\\usepackage[{geometry_options}]{{geometry}}",
"\\usepackage{amsmath, amssymb}",
"\\usepackage{enumitem}",
"\\usepackage{multicol}",
"\\usepackage{adjustbox}", # For auto-scaling equations to fit column width
"\\usepackage{adjustbox}",
"",
"\\setlength{\\mathindent}{0pt}",
"\\setlist[itemize]{noitemsep, topsep=0pt, leftmargin=*}",
Expand Down Expand Up @@ -188,12 +198,12 @@ def build_dynamic_footer(columns=2):
return "\n".join(footer_lines)


def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""Rebuild document wrappers so current layout controls apply to existing LaTeX content."""
if not content:
return content

header = build_dynamic_header(columns, font_size, margins, spacing)
header = build_dynamic_header(columns, font_size, margins, spacing, orientation)
footer = build_dynamic_footer(columns)

if r"\begin{document}" not in content or r"\end{document}" not in content:
Expand All @@ -219,26 +229,26 @@ def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in"
body = re.sub(r"(?m)^\\vspace\{[^}]+\}\s*$", rf"\\vspace{{{formula_gap}}}", body)
body = body.strip("\n")

layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing))
layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing, orientation))
body = layout_comment_block + ("\n" + body if body else "")

return header + body + ("\n" if body else "") + footer


def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""
Given a list of selected formulas (each with class_name, category, name, latex),
build a complete LaTeX document.
"""
header = build_dynamic_header(columns, font_size, margins, spacing)
header = build_dynamic_header(columns, font_size, margins, spacing, orientation)
footer = build_dynamic_footer(columns)
formula_gap = get_spacing_values(spacing, font_size)["formula_gap"]

if not selected_formulas:
return header + footer

body_lines = []
body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing))
body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing, orientation))
current_class = None
current_category = None
in_flushleft = False
Expand Down Expand Up @@ -334,4 +344,4 @@ def compile_latex_to_pdf(content):

# Read and return the PDF bytes before the temporary directory is removed
with open(pdf_file_path, "rb") as pdf_file:
return pdf_file.read()
return pdf_file.read()
18 changes: 18 additions & 0 deletions backend/api/migrations/0008_cheatsheet_orientation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-05-06 02:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0007_cheatsheet_content_source'),
]

operations = [
migrations.AddField(
model_name='cheatsheet',
name='orientation',
field=models.CharField(default='portrait', max_length=20),
),
]
14 changes: 12 additions & 2 deletions backend/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class CheatSheet(models.Model):
margins = models.CharField(max_length=20, default="0.15in")
font_size = models.CharField(max_length=10, default="9pt")
spacing = models.CharField(max_length=10, default="small")
orientation = models.CharField(max_length=20, default="portrait")
# Stores selected formulas with user-defined order: [{"class": "...", "category": "...", "name": "..."}]
selected_formulas = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
Expand Down Expand Up @@ -100,12 +101,21 @@ def build_full_latex(self):
# Build document header
document_class, document_class_size = get_document_class(self.font_size)
spacing_values = get_spacing_values(self.spacing, self.font_size)

doc_options = f"{document_class_size},fleqn,letterpaper"
if self.orientation == "landscape":
doc_options += ",landscape"

geometry_options = f"letterpaper,margin={self.margins}"
if self.orientation == "landscape":
geometry_options += ",landscape"

header = [
f"\\documentclass[{document_class_size}]{{{document_class}}}",
f"\\documentclass[{doc_options}]{{{document_class}}}",
"\\usepackage[utf8]{inputenc}",
"\\usepackage{amsmath, amssymb}",
"\\usepackage{adjustbox}",
f"\\usepackage[a4paper, margin={self.margins}]{{geometry}}",
f"\\usepackage[{geometry_options}]{{geometry}}",
f"\\setlength{{\\baselineskip}}{{{spacing_values['baseline_skip']}}}",
f"\\setlength{{\\parskip}}{{{spacing_values['paragraph_skip']}}}",
]
Expand Down
1 change: 1 addition & 0 deletions backend/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class Meta:
"columns",
"font_size",
"spacing",
"orientation",
"selected_formulas",
"problems",
"full_latex",
Expand Down
27 changes: 19 additions & 8 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def test_build_full_latex_wraps_content(self):
columns=1,
font_size="10pt",
user=self.user,
orientation="portrait",
)
full = sheet.build_full_latex()
assert "\\begin{document}" in full
Expand Down Expand Up @@ -192,7 +193,7 @@ def test_build_full_latex_8pt_uses_extarticle(self):

full = sheet.build_full_latex()

assert "\\documentclass[8pt]{extarticle}" in full
assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in full

def test_build_full_latex_custom_font_size_uses_supported_wrapper(self):
sheet = CheatSheet.objects.create(
Expand All @@ -204,7 +205,7 @@ def test_build_full_latex_custom_font_size_uses_supported_wrapper(self):

full = sheet.build_full_latex()

assert "\\documentclass[10pt]{article}" in full
assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in full
assert "\\fontsize{10.5pt}{11.3pt}\\selectfont" in full

def test_build_full_latex_includes_saved_spacing(self):
Expand Down Expand Up @@ -237,9 +238,9 @@ def test_normalize_latex_layout_rewraps_existing_document_with_current_settings(
"\\end{document}"
)

normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny")
normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny", orientation="portrait")

assert "\\documentclass[8pt,fleqn]{extarticle}" in normalized
assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in normalized
assert "margin=0.5in" in normalized
assert "\\begin{multicols}{4}" in normalized
assert "\\begin{multicols}{2}" not in normalized
Expand Down Expand Up @@ -383,12 +384,18 @@ def test_build_dynamic_header_keeps_headers_close_to_body_size(self):

def test_build_dynamic_header_accepts_custom_font_and_spacing(self):
header = build_dynamic_header(columns=5, font_size="10.5pt", margins="0.25in", spacing="0.6pt")
assert "\\documentclass[10pt,fleqn]{article}" in header
assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in header
assert "\\fontsize{10.5pt}{11.3pt}\\selectfont" in header
assert "\\setlength{\\baselineskip}{11.1pt}" in header
assert "\\setlength{\\parskip}{0.6pt}" in header
assert "\\begin{multicols}{5}" in header

def test_build_dynamic_header_landscape_includes_landscape_options(self):
header = build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="landscape")
assert "landscape" in header
assert "\\documentclass[9pt,fleqn,letterpaper,landscape]{extarticle}" in header
assert "letterpaper,margin=0.15in,landscape" in header

def test_build_latex_for_formulas_includes_editable_layout_comments(self):
tex = build_latex_for_formulas(
[{"class_name": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula", "latex": "m=\\frac{y_2-y_1}{x_2-x_1}"}],
Expand All @@ -402,6 +409,7 @@ def test_build_latex_for_formulas_includes_editable_layout_comments(self):
assert "% @cheatsheet-layout font_size: 10.5pt | change layout options up top to update text size" in tex
assert "% @cheatsheet-layout spacing: 0.6pt | change layout options up top to update spacing" in tex
assert "% @cheatsheet-layout margins: 0.5in | change layout options up top to update margins" in tex
assert "% @cheatsheet-layout orientation: portrait | change layout options up top to update orientation" in tex


# ── API Tests ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1246,7 +1254,7 @@ def test_generate_sheet_8pt_uses_extarticle(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[8pt,fleqn]{extarticle}" in tex
assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in tex
assert "\\documentclass[8pt,fleqn]{article}" not in tex

def test_generate_sheet_9pt_uses_extarticle(self, auth_client):
Expand All @@ -1261,7 +1269,7 @@ def test_generate_sheet_9pt_uses_extarticle(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[9pt,fleqn]{extarticle}" in tex
assert "\\documentclass[9pt,fleqn,letterpaper]{extarticle}" in tex
assert "\\documentclass[9pt,fleqn]{article}" not in tex

def test_generate_sheet_10pt_uses_article(self, auth_client):
Expand All @@ -1276,7 +1284,7 @@ def test_generate_sheet_10pt_uses_article(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[10pt,fleqn]{article}" in tex
assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in tex
assert "extarticle" not in tex

def test_generate_sheet_latex_injection_blocked(self, auth_client):
Expand Down Expand Up @@ -1375,6 +1383,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
"font_size": "8pt",
"spacing": "tiny",
"margins": "0.25in",
"orientation": "portrait",
},
format="json",
)
Expand All @@ -1387,6 +1396,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
"font_size": "8pt",
"spacing": "tiny",
"margins": "0.25in",
"orientation": "portrait",
}
assert "\\begin{multicols}{2}" in tex
assert "\\fontsize{8pt}{8.8pt}\\selectfont" in tex
Expand All @@ -1395,6 +1405,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
assert "% @cheatsheet-layout font_size: 8pt | change layout options up top to update text size" in tex
assert "% @cheatsheet-layout spacing: tiny | change layout options up top to update spacing" in tex
assert "% @cheatsheet-layout margins: 0.25in | change layout options up top to update margins" in tex
assert "orientation: portrait" in tex


# ── Auth Endpoint Tests ──────────────────────────────────────────────
Expand Down
Loading
Loading